Présentation
Réalisé au fil de l’eau : sans relecture …
L’objectif de ce projet de réaliser le portage en typescript et sous Phaser3 du projet mis à disposition sur le site MDN 2D platform game with Phaser qui a été réalisé par Belén Albeza et dont le code source est ici ou ici
Les assets sont directement récupérés depuis le tutoriel initial dont voici les références :
The graphic and audio assets of the game in this guide have been released in the public domain under a CC0 license. These assets are:
* The images have been created by Kenney, and are part of his Platformer Art: Pixel Redux set (they have been scaled up, and some of them have minor edits).
* The background music track, Happy Adventure, has been created by Rick Hoppmann.
* The sound effects have been randomly generated with the Bfxr synth.
[Original Web Site](https://mozdevs.github.io/html5-games-workshop/en/guides/platformer/start-here/)
Les sources complètes sont ici.
Les composants
Le projet va utiliser :
Comme ils expliquent beaucoup mieux que moi ce qu’ils sont et ceux qu’ils font, je vous laisse suivre les liens.
Quelques informations complémentaires
Depuis quelques jours et pour changer des applications habituelles, je regarde le développement de jeux. De manière, très très basique … Au départ, j’ai trouvé un guide pour un simple casse brique en JS puis dans la même lignée, l’utilisation de Phaser.
En continuant à chercher, j’ai trouvé deux trois tutos assez complets. Avant de me lancer dans un jeu de 0, j’imagine de porter simplement le jeu de plateforme MDN mais en Phaser 3 et avec Typescript.
Voici la liste des tutos :
- 2D breakout game using pure JavaScript,
- 2D breakout game using Phaser,
- 2D maze game with device orientation,
- 2D platform game with Phaser,
- Infinite Runner in Phaser 3 with TypeScript,
- Phaser.js: A Step-by-Step Tutorial On Making A Phaser 3 Game.
Mise en place
Premiers pas : mise en place du projet
Pour les premiers pas, il faut mettre en place le projet.
Pour commencer:
- un petit
npm init
, - Typescript, eslint … :
npm install typescript eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser --save-dev
, - Comme bundler Parcel pour faire simple :
npm install parcel parcel-plugin-clean-easy parcel-plugin-static-files-copy --save-dev
.
Au sein d’un nouveau répertoire src/
, création d’un fichier index.html
et d’un fichier main.ts
.
Le fichier index.html est simple pour le moment et devrait le rester :
<html>
<head>
<title>MDN Portage Phase 3 & Typescript</title>
</head>
<body>
<script src="main.ts" type="module"></script>
</body>
</html>
Le fichier main.ts
va contenir la définition du jeu, il va se compléxifier mais pour le moment, un console log va suffire:
console.log("COUCOU !!");
Il faut également compléter le fichier package.json
avec quelques commandes :
[...]
"scripts": {
"start": "parcel src/index.html -p 8000",
"build": "parcel build src/index.html --out-dir dist",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint ./src --ext .js,.jsx,.ts,.tsx"
},
[...]
Un premier test avec npm start
et ouverture de la page http://localhost:8080
qui n’affiche rien et c’est bien normal ! Juste un petit coucou dans le console.
Encore deux trois petites choses :
- Ajout d’un fichier
.gitignore
(issue de gitignore.io), - Initialisation d’un repo
git init
et d’un premier commit, - Ajout d’un paramétrage dans le package.json pour gérer les futurs fichiers static : (cela permet de forcer leur copie)
[...] "staticFiles": { "staticPath": "src/assets", "watcherGlob": "**", "staticOutPath": "assets" } [...]
- Ajout d’un fichier ~/.parcelrc pour activer le plugin de copie :
{ "extends": ["@parcel/config-default"], "reporters": ["...", "parcel-reporter-static-files-copy"] }
Mise en place de Phaser 3
Place à l’installation de Phaser : npm install phaser
. La version utilisée au moment de l’écriture est 3.55.2. Une fois la commande terminée, il faut mettre à jour le fichier main.ts
pour initialiser la configuration du jeu. Le code contient des commentaires si besoin.
// Il faut pas le faire mais c'est pratique
console.log('-- Lancement du jeu');
// Import de la librairie
import Phaser from 'phaser'
// Import de la première scene.
// Les scènes peuvent être vues comme des espaces de jeu : un niveau, un plateau, une zone spécifique (comme le tableau de score).
import HelloWorldScene from './scenes/HelloWorldScene'
// La configuration
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO, // Phaser peut fonctionner soit en WebGL soit avec Canvas. En mettant auto, c'est lui qui choisi en fonction du navigateur,
width: 800, // La taille du jeu
height: 640,
physics: {
default: 'arcade', // Phaser intègre un moteur physique par défaut.
arcade: {
gravity: { y: 200 }, // Ce moteur permet de gérer la gravité
debug: true
}
},
scene: [HelloWorldScene]
}
// Export
export default new Phaser.Game(config)
A ce stade, la compilation ne fonctionne pas car la scène par défaut n’existe pas. Il faut ajouter un fichier src/scenes/hello-wolrd.scene
. Pour cette première scène qui doit juste permettre le chargement d’un logo, une classe simple :
import Phaser from 'phaser'
/**
* La classe est une version simplifiée d'une classe présente dans un des tutos présents ci-dessus
* */
export class HelloWorldScene extends Phaser.Scene {
// Appeler par défaut au démarrage
constructor() {
// Passe une clé qui permettra de référencer la scène
super('hello-world')
}
/***
* La méthode est appelée après le constructeur et doit permettre le chargement
* des assets ou autres composants nécessaires au jeu
*/
preload() {
// Permet de spécifier une URL de base pour le chargement des assets
this.load.setBaseURL('http://labs.phaser.io')
// Chargement d'un logo avec comme clé logo ...
this.load.image('logo', 'assets/sprites/phaser3-logo.png')
}
/**
* Après le preload, la méthode create est appelée.
* Elle utilise (par exemple), les assets pour créer des éléments comme des images
*/
create() {
// Ici, uniquement une image qu'on place dans le jeu
const logo = this.add.image(400, 100, 'logo')
}
}
Bon pour le moment, le résultat n’est pas terrible mais au moins il y a quelques choses à afficher :
Premières actions réelles
Mise en place des assets
Pour aller chercher les assets nécessaires au jeu, direction la page initiale : ici. Il faut télécharger le zip puis récupérer les répertoires audio, data & images présents et les copier dans le répertoire ~/src/assets.
Si tout va bien, lors du prochain build, les différents assets seront présents dans le répertoire de sortie ~/dist/assets.
Création d’une classe de chargement
Dans le différents articles lu, les projets contiennent une scene spécialement dédiée au chargement des assets. Cette scène est appelée dès le démarrage puis charge la première scène de jeu.
Il faut créer une nouvelle classe : ~/src/scenes/loading.scene.ts.
import Phaser from 'phaser'
import { AssetsList, ScenesList } from '../consts'
// Comme la première scene, la classe doit hériter de Phase.Scene
export default class LoadingScene extends Phaser.Scene {
constructor() {
// L'idée est d'éviter les constantes en dure.
// Utilisation d'une classe conts qui expose différents enums utilisés au fil du jeu.
super(ScenesList.LoadingScene);
}
/***
* La méthode est appelée après le constructeur et doit permettre le chargement
* des assets ou autres composants nécessaires au jeu
*/
preload() {
// A partir de maintenant les assets sont chargés depuis le répertoire local
this.load.setBaseURL('./assets/')
// Chargement du fond d'écran. Utilisation d'une constante.
this.load.image(AssetsList.IMG_BackGround, 'images/background.png');
}
/**
* Après le preload, la méthode create est appelée.
* Elle utilise (par exemple), les assets pour créer des éléments comme des images
*/
create() {
// Comme il s'agit uniquement de la page de chargement,
// Ouverture du premier tableau : La scene 1
this.scene.start(ScenesList.Level1Scene);
}
}
La création de cette scène intègre également la création de deux enums listant des constantes. Le code est centralisé dans ~/src/consts.ts :
export enum ScenesList {
LoadingScene = 'loading-scene',
Level1Scene = 'level-1',
Level2Scena = 'level-2',
HelloWorldScene = 'hello-world'
}
export enum AssetsList {
IMG_BackGround = 'img-background'
}
Premier niveau
La scène de chargement finie en demandant le démarrage du premier niveau. Il faut donc que cette scène existe. C’est reparti, création d’une scène ~/src/scenes/level-1.scene.ts :
import Phaser from 'phaser'
import { AssetsList, ScenesList } from '../consts'
export class LevelOneScene extends Phaser.Scene {
constructor() {
super(ScenesList.Level1Scene);
}
/***
* A ce stade, les éléments ont déjà été chargés par la première scène.
* Donc ici, normalement, rien à faire
*/
preload() {
}
/**
*/
create() {
// Création du background.
// Pas besoin de le charger car déjà fait dans la scène de chargement
const background = this.add.image(0, 0, AssetsList.IMG_BackGround)
}
}
Mise à jour du main.ts
Les scénes sont crées mais ne sont pas actives. Pour cela, il faut modifier le fichier main.ts :
[...]
// Remplacement de HelloWorld par les nouvelles scènes
// scene: [HelloWorldScene],
scene: [LoadingScene, LevelOneScene]
[...]
et après rechargement :
Un souci. L’image n’est pas vraiment bien placée. Et c’est normal. Par défaut, Phaser place les objets en fonction de leur centre. Ici, on veut la placer en fonction de son coin haut / gauche. Pour corriger, cela il faut modifier l’orgine :
[...]
const background = this.add.image(0, 0, AssetsList.IMG_BackGround)
// Il faut changer l'orgine du
background.setOrigin(0, 0);
[...]
mais il y a toujours comme un souci :
.
EN fait, l’image ne fait pas la taille du jeu … changement de la taille du jeu :) dans main.ts :
// La configuration
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 960, // Changement !
height: 600, // CHangement
physics: {
default: 'arcade',
arcade: {
gravity: { y: 200 },
debug: true
}
},
// Remplacement de HelloWorld par les nouvelles
// scene: [HelloWorldScene],
scene: [LoadingScene, LevelOneScene]
}
et là c’est mieux :
.
Plateformes
Mise en place
Comme il s’agit d’un jeu de plateformes, il faudrait placer des … plateformes. Parmi les assets fournis, il y un fichier sympa : ~/src/assets/data/level01.json qui contient en fait les données sur le niveau : plateformes, pièces, …
Le principe va donc être de lire le fichier pour créer les éléments nécessaires.
Chargement du fichier Level
Le fichier étant dépendant du niveau et pas du jeu. Donc pour charger le fichier, il est possible de le faire dans la méthode preload de la scène pour le moment.
[...]
preload() {
// Chargement du niveau
this.load.json(this.levelName, `./assets/data/${this.levelName}.json`);
}
[...]
create() {
// [...] : Après la gestion du background
// Creation du niveau
this._createLevel(this.cache.json.get(this.levelName));
}
/**
* Fonction spécifique pour créer les niveaux
*/
private _createLevel(data: any) {
console.log(data);
}
La bonne nouvelle c’est que la console contient bien toutes les données. Par contre pour aller au bout de la logique, il faudrait créer des modeles pour avoir les entités. C’est parti :)
- Création du répertoire ~/src/models,
- Création de BaseModel pour centraliser les coordonées,
- Création de PlateformModel, DecorationModel, CoinModel, HeroModel, SpiderModel, DoorModel et KeyModel,
- Puis enfin LevelModel.
La fonction _createLevel devient :
private _createLevel(data: LevelModel) {
console.log(data);
}
Création des plateaux
Les plateaux utilisent des images qu’il faut ajouter au chargement dans la scène associée :
// [...]
preload() {
// [...]
// Chargement des différents images en lien avec les plateformes
// /!\ Il faut que les codes images correspondent bien au code présent
// dans le fichier de niveau
this.load.image(AssetsList.IMG_ground, 'images/ground.png');
this.load.image(AssetsList.IMG_Platform8x1, 'images/grass_8x1.png');
this.load.image(AssetsList.IMG_Platform6x1, 'images/grass_6x1.png');
this.load.image(AssetsList.IMG_Platform4x1, 'images/grass_4x1.png');
this.load.image(AssetsList.IMG_Platform2x1, 'images/grass_2x1.png');
this.load.image(AssetsList.IMG_Platform1x1, 'images/grass_1x1.png');
// Vu que les codes sont dans les images, l'utilisation d'enum n'était peut-être
// pas nécessaire.
}
// [...]
Puis venir compléter la fonction _createLevel présent dans level1 :
// [...]
/**
* Fonction spécifique pour créer les niveaux
*/
private _createLevel(data: LevelModel) {
// Gestion des plateformes
data.platforms.forEach(this._createPlatform, this);
}
/**
* Juste pour les plateaux
* */
private _createPlatform(platformModel: PlatformModel) {
// Création des plateaux
const sprite = this.add.sprite(platformModel.x, platformModel.y, platformModel.image);
// Pour que le placement soit cohérent
sprite.setOrigin(0, 0);
}
// [...]
Après un rechargement, les plateaux semblent en place :
Le Heros
Introduction
Pour jouer, il faut un personnage et c’est l’objectif de cette partie.
Création d’une entité
Afin d’éviter d’avoir du code un peu partout, la gestion du Hero va être centralisée dans une entité dédiée. Il faut commencer par créer ~/src/entities/hero.ts dont voici une première version :
import { Physics } from 'phaser';
import { AssetsList } from '../consts';
import { HeroModel } from '../models/hero.model';
// La classe est une extension d'un sprite pour en avoir
// toutes les méthodes et services.
export class Hero extends Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, heroModel: HeroModel) {
console.log(heroModel);
// Il faut commencer par appeler le constructeur parent
super(scene, heroModel.x, heroModel.y, AssetsList.IMG_Hero);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Limite le hero à la zone de jeu
(this.body as Physics.Arcade.Body).setCollideWorldBounds(true);
}
}
Ce qui donne :
.
Deux points :
- Le personne est entouré de bord rouge : cela est du au fait que le mode debug est activé pour la gravité dans le configuration,
- Le personne ne semble pas être bloqué par le sol : normal, rien n’est fait pour cela,
- Par contre, il est bien bloqué au limite du jeu : cf. la dernière ligne du constructeur.
Modification de la gestion des plateformes.
Les platesformes sont bien crées mais elles n’ont pas de corps (body, physic) donc pas de gestion de collisions, etc… Pour corriger cela, il est possible de changer la création du sprite de :
// const sprite = this.add.sprite(platformModel.x, platformModel.y, platformModel.image);
const sprite = this.physics.add.sprite(platformModel.x, platformModel.y, platformModel.image);
Par contre, cette modification va avoir une première conséquence : vous allez voir les plateaux tombés. En effet, comme une gravité est définie, elle agi sur les objets présents. Il faut donc supprimer cet effet : sprite.body.setAllowGravity(false);
.
Si vous relancez le jeu, vous allez voir le sol disparaître sous le joueur. En effet, en “tombant” le joueur va pousser la plateforme et la faire tomber. Autre action à effectuer, indiquer que les plateformes ne sont pas faîtes pour bouger : sprite.body.setImmovable(true);
A ce stade, les plateformes ne bougent plus mais notre héro tombe toujours. Il faut gérer la détection de la collision.
Collision
La bonne nouvelle : Phaser le fait très bien. Il faut juste lui dire ce qui est attendu.
Les deux premières actions sont :
- Création dans la scène d’une variable contenant les différents plateformes :
protected _plateforms: Phaser.GameObjects.Sprite[] = [];
, - Dans la fonction de création des plateformes, enregistrement dans le tableau :
private _createPlatform(platformModel: PlatformModel) { // Les platesformes devant être physics, elles sont ajoutées à cette dimension const sprite = this.physics.add.sprite(platformModel.x, platformModel.y, platformModel.image); // [...] // Ajout au tableau this._plateforms.push(sprite); }
Ensuite, il faut indiquer à Phaser de gérer la collision entre le hero et les plateformes. A ce niveau, une seule ligne à ajouter dans la méthode create du niveau :
/**
*/
create() {
// [...] Après la création des niveaux
// Définition des collisions
this.physics.add.collider(this._hero, this._plateforms);
}
Rien de plus. L’appel à cette méthode va simplement indiquer à Phaser que quand ils se rencontrent, ils doivent se bloquer. Notre petit heros est mieux placé :
Retour sur la création des plateformes
Juste un petit rappel sur l’état de la fonction de création des plateformes :
private _createPlatform(platformModel: PlatformModel) {
// Les platesformes devant être physics, elles sont ajoutées à cette dimension
const sprite = this.physics.add.sprite(platformModel.x, platformModel.y, platformModel.image);
// Par contre, pour éviter qu'elle tombe, il faut leur dire que la gravité n'a pas d'impact
sprite.body.setAllowGravity(false);
// Et pour éviter que si quelqu'un marche dessus, la plateforme glisse
sprite.body.setImmovable(true);
// Pour que le placement soit cohérent
sprite.setOrigin(0, 0);
// Ajout au tableau
this._plateforms.push(sprite);
}
Faut pas rester là … faut bouger maintenant
Le héros est là mais pour le moment, il ne peut rien faire. Ce serait bien qu’il puisse un peu bouger histoire de ne pas être totalement inutile. Retour dans la classe du héros pour gérer cela.
Phaser étant bien sympathique, il permet de détecter l’appel au clavier soit pour des touches spécifiques soit pour des certaines disont très utilisées comme haut, bas, gauche et droite. Pour accéder à cela, il suffit :
- d’ajouter une proprité à la classe Héro :
private cursors: Phaser.Types.Input.Keyboard.CursorKeys;
, - de l’initialiser dans le constructeur :
this.cursors = this.scene.input.keyboard.createCursorKeys();
.
Maintenant, il faut l’utiliser au bon endroit. Dans la gestion du jeu, nous avons vu que Phaser “proposait” l’utilisation de plusieurs méthodes dont il se charge de l’appel : preload et create. Il en existe d’autres comme update. La méthode update est appelée à chaque boucle du jeu et permet d’influer sur le rendu en fonction de l’action du joueur.
Il existe également une autre méthode preUpdate qui peut être implémentée sur un sprite. C’est cette solution qui va être utilisée.
Déclaration de la méthode preUpdate
Voici une première version de cette méthode :
preUpdate() {
// Par défaut, il ne doit pas bouger
this.body.velocity.x = 0;
// Gestion de doite et gauche
if (this.cursors.left.isDown) {
// Le joueur veut aller à gauche
this.body.velocity.x = -1 * Hero.SPEED;
}
else if (this.cursors.right.isDown) {
// Le joeur veur aller à droite
this.body.velocity.x = 1 * Hero.SPEED;
}
// Gestion du saut
if (this.cursors.up.isDown) {
// Est-ce qu'il n'est pas déjà en cours de saut ?
if (this.body.touching.down) {
// RAPPEL : 0,0 c'est en haute. Donc quand il saute,
// il descend :)
this.body.velocity.y = -1 * Hero.JUMP;
}
}
}
Les fonctions et propriétés sont assez explicites. Les deux seules choses :
- Il faut bien se souveniir que le point 0,0 est en haut à gauche (pour le saut …),
- Qu’on veut éviter que l’utilisateur puisse sauter deux fois. Donc avant de valider, une vérification est faîte qu’il est bien en contact avec quelque chose.
A noter que la descente n’est pas gérée : Phaser s’en occupe via la pomme de Newton. Par contre, à ce stade, le hero peut atteindre la plateau le plus haut d’un seul saut ce qui simplifie un peu trop sa tâche. Il faut renforcer le pouvoir d’attraction en modifiant un paramètre dans la configuration du jeu :
// [...]
arcade: {
gravity: { y: 1200 }, // Passage de 200 à 1200
debug: true
}
//[...]
C’est déjà plus raisonnable.
Un jeu de plateformes sans pièces
Ce n’est pas un jeu de plateformes…
Intro
Le but de cette partie va être de disposer des pièces dans le jeu et de permettre au héro de les récolter. Sur le principe, cela va être très proche de la mise en place des plateformes mais avec la mise en place d’animations pour que les pièces bougent.
Chargement
Comme toujours, il faut commencer par aller charger l’asset dans la scéne dédiée :
// [...]
preload() {
// [...]
// Gestion des pièces
// Il faut indiquer la taille d'une image dans l'image pour qu'il puisse faire le découpage nécessaire
// Ne pas oublier d'ajouter un élément à l'enum contenant les assets.
this.load.spritesheet(AssetsList.SPRITESHEET_Coins, 'images/coin_animated.png', { frameWidth: 22, frameHeight: 22 });
// [...]
}
// [...]
Pour les pièces, il faut utiliser autre chose qu’une image : spritesheet. Pour comprendre pourquoi, ouvrez le fichier et vous verrez que l’image contient en fait plusieurs “bouts” qui vont nous donner l’animation de la pièce.
Création d’une entité
Afin d’éviter de surcharger la scène Level, les pièces sont initialisées dans une entité dédiée (ce qu’il aurait fallu faire pour plateforme …).
Voici la première version qui permet à minima d’afficher les pièces :
import { Physics } from 'phaser';
import { AssetsList } from '../consts';
import { CoinModel } from '../models/coin.model';
// La classe est une extension d'un sprite pour en avoir
// toutes les méthodes est service
export class Coin extends Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, coinModel: CoinModel) {
// Il faut commencer par appeler le constructeur parent
// --> Il faut bien passer la bonne texture
super(scene, coinModel.x, coinModel.y, AssetsList.SPRITESHEET_Coins);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Gestion du corps (comme plateforme)
// Sinon les pièces tombent ou peuvent bouger
const body = this.body as Phaser.Physics.Arcade.Body;
body.setAllowGravity(false);
body.setImmovable(true);
}
}
Puis, il ne faut pas oublier de modifier la scène pour créer les pièces :
/**
* Fonction spécifique pour créer les niveaux
*/
private _createLevel(data: LevelModel) {
// Gestion des plateformes
data.platforms.forEach(this._createPlatform, this);
// Gestion du heros
this._hero = new Hero(this, data.hero);
// Gestion des pièces
data.coins.forEach((coinModel: CoinModel) => {
this._coins.push(
new Coin(this, coinModel)
);
}, this);
}
Normalement vous devez voir cela :
Un peu d’animations
Afin d’utiliser l’animation et rendre cela jolie, il faut retourner dans le constructeur de notre pièce pour ajouter l’animation :
constructor(){
// [...]
// A la fin.
// Création de l'animation de rotation des pièces
this.scene.anims.create({
key: Coin.COINANIM,
frameRate: 6, // Vitesse de la rotation
repeat: -1, // Tourne toujours
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Coins, { frames: [0, 1, 2, 1] })
});
// Une fois crée, on la lance
this.anims.play(Coin.COINANIM, true);
}
Les pièces tournent :).
La collecte
Elles sont là, c’est sympa mais pour le moment, le personnage les traverses sans trop de souci. Comme pour les plateformes, il faut gérer une collision mais ici avec un peu plus d’impact.
Dans la fonction create, ajouter une gestion de collisions :
create() {
// [...]
// -- Hero avec Pièce
this.physics.add.overlap(this._hero, this._coins, (hero, coin) => coin.destroy())
}
Première différence : utilisation d’overlap plutôt que collider. Cela implique que la joueur peut passer devant la pièce. Deuxième différence, ajout d’une fonction pour permettre de décider ce qu’il faut faire. Dans notre cas, destruction pure et simple de la pièce (pour le moment).
Les ennemis
Après les pièces … un peu de bestioles …
On recommence un peu pareil
Entre les pièces et les ennemis, au moins pour le départ, pas trop de différence sur les actions :
- Dans la scène de chargement, chargement (:)) de l’image :
this.load.spritesheet(AssetsList.SPRITESHEET_Spider, 'images/spider.png', { frameWidth: 42, frameHeight: 32 });
, - Création d’une classe Spider :
import { Physics } from 'phaser'; import { AssetsList } from '../consts'; import { SpiderModel } from '../models/spider.model';
// La classe est une extension d’un sprite pour en avoir
// toutes les méthodes est service
export class Spider extends Physics.Arcade.Sprite {
static readonly MOVEANIM = 'move';
static readonly DIEANIM = 'die';
static readonly SPEED = 100;
constructor(scene: Phaser.Scene, spiderModel: SpiderModel) {
// Il faut commencer par appeler le constructeur parent
// --> Il faut bien passer la bonne texture
super(scene, spiderModel.x, spiderModel.y, AssetsList.SPRITESHEET_Spider);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Quelques ajustements
const body = this.body as Phaser.Physics.Arcade.Body;
body.setCollideWorldBounds(true); // au cas où pour qu'ils sortent du jeu,
body.velocity.x = Spider.SPEED; // ils bougent tout le temps et tout seul
// Création des animations :
// -- La première quand il bouge
this.scene.anims.create({
key: Spider.MOVEANIM,
frameRate: 8, // Vitesse de la rotation
repeat: -1, // Tourne toujours
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Spider, { frames: [0, 1, 2] })
});
// -- La deuxèime quand il meurt
this.scene.anims.create({
key: Spider.DIEANIM,
frameRate: 8, // Vitesse de la rotation
repeat: 0, // Tourne toujours
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Spider, { frames: [0, 4, 0, 4, 0, 4, 3, 3, 3, 3, 3, 3] })
});
// Une fois crée, on la lance
this.anims.play(Spider.MOVEANIM, true);
}
}
* Ajout des araignés dans la méthode qui gère la création du niveau :
```typescript
//[...]
private _createLevel(data: LevelModel) {
// [...]
// Gestion des araignées
data.spiders.forEach((spiderModel: SpiderModel) => {
this._spider.push(
new Spider(this, spiderModel)
);
}, this);
}
A ce stade, vous devez avoir des araignés qui apparaissent mais qui tombent des plateformes pour aller se coller dans le coin en bas à droite.
.
Même si elles ne peuvent pas sortir du jeu, rien ne les bloquent autrement donc elles bougent …
Bloqué !
Une première chose simple à faire; indiquer dans la gestion des collisions que les araignés sont bloquées par les platesformes :
create() {
// [...]
// -- Araignes avec plateforme
this.physics.add.collider(this._spider, this._plateforms);
}
Mais pas assez
La solution proposée dans le tutoriel initial est de mettre en place des murs invisibles qui vont bloqués nos petites bêtes et leur demander de repartir en arrière.
Les premières étapes sont identiques et connues depuis quelques temps maintenant :
- Chargement de l’image :
this.load.image(AssetsList.IMG_Walls, 'images/invisible_wall.png');
, - Création d’une classe enemyWalls :
import { Physics } from 'phaser'; import { AssetsList } from '../consts';
// Pour bien gérer la position, il sera nécessaire d’indiquer
// si le mur est sur le côté gauche ou droit de la plateforme
export enum EnemyWallSide {
left = ‘left’,
right = ‘right’
}
// La classe est une extension d’un sprite pour en avoir
// toutes les méthodes est service
export class EnemyWall extends Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, x: number, y: number, side: EnemyWallSide) {
// Il faut commencer par appeler le constructeur parent
// --> Il faut bien passer la bonne texture
super(scene, x, y, AssetsList.IMG_Walls);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Gestion du corps (comme plateforme)
// Sinon les pièces tombent ou peuvent bouger
const body = this.body as Phaser.Physics.Arcade.Body;
body.setAllowGravity(false);
body.setImmovable(true);
}
}
* Création des murs au même moment que la création des plateformes :
```typescript
// [...]
private _createPlatform(platformModel: PlatformModel) {
// ---- Création de la plateforme
// [...]
// --- Création des murs invisibles de chaque côté de la plateforme
this._enemyWalls.push(
new EnemyWall(this, sprite.x, sprite.y, EnemyWallSide.left),
new EnemyWall(this, sprite.x + sprite.width, sprite.y, EnemyWallSide.right)
);
}
Ce qui bloque déjà un peu mieux :
.
Par contre, les murs ne sont très bien placés. Cela est du au fait qu’ils sont placés en fontion de leur centre. Il faut effectuer une modification de l’origine au moment de leur création :
constructor([...]) {
// [...]
// Correction du point d'origine
// C'est toujours en bas y = 1
// par contre si mur de gauche, le point d'origine est à droite (1,1)
// si mur de droite c'est à gauche (0, 1)
this.setOrigin(
side === EnemyWallSide.left ? 1 : 0,
1
);
}
Les murs sont déjà mieux placés :
.
Par contre, elles restent toujours bêtement dans le coin sans revenir sur leur pas.
Aller - retour
Pour changer cela, il est possible d’utiliser la méthode preUpdate de la classe Spider pour changer de sens en fonction des contacts :
preUpdate(time, delta) {
// Nécessaire pour que l'animation fonctionne encore
super.preUpdate(time, delta);
// Récupération du body avec le bon type
const body = this.getBody();
// Mise à jour en fonction des contacts
if (body.touching.right || body.blocked.right) {
body.velocity.x = -1 * Spider.SPEED;
}
else if (body.touching.left || body.blocked.left) {
body.velocity.x = Spider.SPEED;
}
}
Deux points qui m’ont fait perdre du temps :
- preUpdate : il faut appeler la version parente. Sinon certaines actions sont perdues comme les animations,
- velocity.x passe à 0: c’est assez logique quand l’araigné est bloqué sa vitesse passe à 0. Il est donc obligatoire de la ré-initialiser.
Les araignés se déplacent et restent sur les plateaux. Il reste simplement à rendre invisible les murs ce qui est faisable directement dans la classe associée :
// [...]
constructor() {
// [...]
// Pas besoin de les voir
this.setVisible(false);
}
Bon pour le moment, ils sont toujours visibles à cause du mode debug de la gravité.
Let’s fight !
Tout se joue au niveau des collisions qu’il va falloir détecter et gérer.
Pour commencer, ajout de méthodes sur le héros :
- Une méthode pour savoir s’il est entrain de tomber,
- une méthode pour qu’il puisse faire un petit rebond.
// [...]
export class Hero extends Physics.Arcade.Sprite {
// [...]
// Une vitesse de rebond
static readonly BOUNCE_SPEED = 200;
// [...]
/**
* Vrai si la vélocité y est > 0
* @returns bool
*/
public isFalling(): boolean {
return this.body.velocity.y > 0;
}
/**
* Un petit effet rebond
*/
public bounce() {
this.body.velocity.y = -Hero.BOUNCE_SPEED;
}
}
Idem au niveau de l’araigné. Une méthode qui va venir gérer son décès :
// [...]
export class Spider extends Physics.Arcade.Sprite {
// [...]
/**
* Une araignée meurt
*/
public die() {
// On commence par rendre son corps inactif
// Pour que le hero ne meurt pas à cause du cadavre
this.body.enable = false;
// Arrêt de l'animation en cours
this.anims.stop();
// Ecoute pour savoir quand on peut supprimer
// Quand l'animation est terminée
this.once('animationcomplete', () => this.destroy(), this);
// Joue l'animation de mort
this.anims.play(Spider.DIEANIM);
}
}
A noter qu’il faut également mettre à jour la méthode preUpdate. En effet, en désactivant le body il n’est plus accessible donc risque d’erreur dans l’exécution :
// [...]
export class Spider extends Physics.Arcade.Sprite {
// [...]
/**
* Gestion de la mise à jour entre deux refresh
* @param time
* @param delta
*/
preUpdate(time, delta) {
// Nécessaire pour que l'animation fonctionne encore
super.preUpdate(time, delta);
// Récupération du body avec le bon type
const body = this.getBody();
if (this.body) {
if (body.touching.right || body.blocked.right) {
body.velocity.x = -1 * Spider.SPEED;
}
else if (body.touching.left || body.blocked.left) {
body.velocity.x = Spider.SPEED;
}
}
}
et finalement la gestion de la collision :
// [...]
export class LevelOneScene extends Phaser.Scene {
// [...]
create() {
// [...]
// -- Hero avec araignés
this.physics.add.overlap(this._hero, this._spider, this._handleHeroAndSpider, null, this);
}
/**
* Gestion d'un contact entre notre hero et une araigné
* Tout va dépendre qui touche qui et comment
* @param hero Le hero
* @param spider L'araigné
*/
private _handleHeroAndSpider(heroGO: GameObjects.GameObject, spiderGO: GameObjects.GameObject) {
// Cast
const hero = heroGO as Hero;
const spider = spiderGO as Spider;
// Est-ce que le heros est en train de tomber ?
if (hero.isFalling()) {
// Oui alors, on considère qu'il peut tuer l'araigné
// Un petit rebond pour la classe
hero.bounce();
// L'araigné meurt
spider.die();
} else {
// Oups ... Pour le moment, on relance le jeu
this.scene.restart();
}
} // _handleHeroAndSpider
}
Petit point étape
Ca avance : le joueur bouge, les araignés aussi, il peut collecter des pièces, combat possible … Ca commence à ressemble à quelque chose.
Gestion d’un score
Introduction
L’idée ici est d’ajouter une zone en haut de l’écran pour compter le nombre de pièces collectées par notre héro.
Ici, c’est un peu navigation a vu car il semble que pas mal de choses aient changés entre la version 2 & 3 au niveau des retroFonts et aucune documentation précise existe.
De plus, la solution proposée n’est pas celle directement utilisée dans le tutoriel d’origine mais une combinaison de plusieurs solutions trouvées dans différents exemples.
Comme toujours, le chargement
Dans la scène de chargement, il faut ajouter les éléments nécessaires. Il faut également mettre à jour les différents enums :
preload() {
// [...]
// La pièce pour le score
this.load.image(AssetsList.IMG_Coin, 'images/coin_icon.png');
// Le tableau des scores
this.load.image(AssetsList.IMG_FontNumber, 'images/numbers.png');
// [...]
}
Ensuite un container
Pour afficher le score qui doit présenter une pièce et le score, il faut associer différents éléments : une image de pièce + le texte. L’idée est de créer un composant intégrant les deux que l’on peut placer ensemble.
Pour cela, il est possible d’utiliser un container. L’avantage est qu’en positionnant le container, les éléments contenus sont positionnés ensemble.
Création d’une classe ScoreContainer contenu dans ~/src/ui/score.container.ts :
export class ScoreContainer extends Phaser.GameObjects.Container {
}
C’est ici que le nombre de pièces collectées est gérée donc ajout d’une variable :
export class ScoreContainer extends Phaser.GameObjects.Container {
/**
* La valeur qui doit être affichée
*/
protected _value = 0;
public get value() {
return this._value;
}
/**
* Doit pouvoir être manipulée de l'extérieur
* */
public set value(value: number) {
this._value = value;
}
}
Maintenant, il faut commencer l’ajout des différents composants au sein du constructeur :
export class ScoreContainer extends Phaser.GameObjects.Container {
// Les chiffres sous forme de chaîne
// Nécessaire pour la fonction RetroFont pour trouver les
// bons caractères dans l'image
static readonly NUMBERS_STR = '0123456789X ';
// C'est cette propriété qui va être mise à jour
// quand le score va changer. Il faut donc pouvoir
// la référencée.
protected dynamic: Phaser.GameObjects.BitmapText;
// [...]
constructor(scene: Phaser.Scene, x: number, y: number) {
// L'appel au parent
super(scene, x, y);
// Création de la pièce
// --> Les coordonnées sont relatives au container.
let coinIcon = scene.add.image(0, 0, AssetsList.IMG_Coin);
coinIcon.setOrigin(0, 0);
// Mise en cache de l'image que Phaser va découper via
// RetroFont.
// Avec les différents paramètres, il sera capable de traduire
// une chaine de texte en chaîne d'image
scene.cache.bitmapFont.add(
AssetsList.IMG_FontNumber,
Phaser.GameObjects.RetroFont.Parse(scene, {
image: AssetsList.IMG_FontNumber,
width: 20, height: 26,
chars: ScoreContainer.NUMBERS_STR,
charsPerRow: 6,
"spacing.x": 0,
"spacing.y": 0,
lineSpacing: 1,
"offset.x": 0,
"offset.y": 0
})
);
// Mais au final, c'est cette image qui sera mise à jour.
// Via la référence au cache, il est capable de recalculer
// l'image à chaque changement de valeur.
this.dynamic = scene.add.bitmapText(
coinIcon.x + coinIcon.width + 10,
coinIcon.height / 2,
AssetsList.IMG_FontNumber);
this.dynamic.setOrigin(0, 0.5);
// A noter que l'utilisation des mêmes codes n'est sans doute pas une bonne idée ...
// Pour que les éléments soient effectivement considérés
// dans le container, il faut les ajouter.
this.add(coinIcon);
this.add(this.dynamic);
}
}
Une dernière chose : gestion de la mise à jour du texte :
export class ScoreContainer extends Phaser.GameObjects.Container {
// [...]
preUpdate() {
this.dynamic.text = `X${this.value}`;
}
}
C’est bien directement l’image bitmapText qui est mise à jour. Ensuite, un recalcul sera fait pour générer l’image qui sera basée sur les éléments présents dans l’image de base.
Une scène UI
Le container pourrait être directement affiché dans la scène du niveau. Mais cela ne semble pas être une bonne pratique et il est plutôt recommandé de mettre cela dans une scène à part.
Création d’une scène dédiée UIScene dans ~/src/scenes/UIScene.ts. Cette scène est pour le moment très simple :
import { Scene } from 'phaser';
import { AssetsList, EventList, ScenesList } from '../consts';
import { ScoreContainer } from '../ui/score.container';
export class UIScene extends Scene {
private scoreContainer!: ScoreContainer;
/**
*
*/
constructor() {
super(ScenesList.UIScene);
}
/**
* Creation ...
*/
create() {
// Création du conteneur qui affiche le score
this.scoreContainer = new ScoreContainer(this, 10, 10);
this.add.existing(this.scoreContainer);
}
}
Pour que la scène soit chargée, deux choses à faire :
- La référencer dans la configuration du jeu : (ne pas oublier l’import)
// [...] scene: [LoadingScene, LevelOneScene, UIScene] // [...]
- Demander son lancement au même moment que le niveau :
export class LoadingScene extends Phaser.Scene { // [...] create() { // Comme il s'agit uniquement de la page de chargement, // Ouverture du premier tableau : La scene 1 this.scene.start(ScenesList.Level1Scene); // Puis lancement de l'UI this.scene.run(ScenesList.UIScene); } }
A ce stade, une pièce avec un score à 0 doit apparaître :
.
Mise à jour du score
La mise à jour du score doit se faire quand une pièce est collectée. Pour ce faire, un évènement est émis et doit être capté.
Une liste d’évènements est ajoutée au constantes :
export enum EventList {
GET_COIN = 'GET_COIN',
KILL_SPIDER = 'KILL_SPIDER',
HERO_JUMP = 'HERO_JUMP'
}
Anticipation de certaines évolutions ?
Puis, un évènement est lancé quand une pièce est collecté :
export class LevelOneScene extends Phaser.Scene {
// [...]
/**
*/
create() {
// [...]
// -- Hero avec Pièce
this.physics.add.overlap(this._hero, this._coins, (hero, coin) => {
// Destruction de la pièce
coin.destroy()
// Events indiquant que la pièce a été captée
this.game.events.emit(EventList.GET_COIN);
});
// [...]
}
// [...]
}
Enfin, l’évènement est capturé pour mettre à jour le score :
export class UIScene extends Scene {
// [...]
create() {
// [...]
this.game.events.on(EventList.GET_COIN, this.manageGetCoin, this);
}
/**
* Gère quand notre héros capture une pièce
*/
private manageGetCoin() {
this.scoreContainer.value += 1;
}
}
Et normalement quand notre héros collecte des pièces :
Un peu de son
Pour marquer les actions
Afin de faire un retour à l’utilisateur, il peut-être sympa de lui jouer quelques notes alors qu’il effectue des actions comme sauter …
Chargement des sons
Classique :
export class LoadingScene extends Phaser.Scene {
// [...]
preload() {
// [...]
// Les différents sons :
this.load.audio(AssetsList.SND_Coin, 'audio/coin.wav');
this.load.audio(AssetsList.SND_Jump, 'audio/jump.wav');
this.load.audio(AssetsList.SND_Stomp, 'audio/stomp.wav');
}
}
Il faut juste ne pas oublier d’aller compléter l’enum contenant les assets.
Ecoute des évènements
C’est l’UIScene qui va être responsable de faire le retour à l’utilisateur :
export class UIScene extends Scene {
// [...]
create() {
// [...]
// Ecoute des évènements
// -- Collecte de pièces
this.game.events.on(EventList.GET_COIN, this.manageGetCoin, this);
// -- Saut
this.game.events.on(EventList.HERO_JUMP, () => this.sound.play(AssetsList.SND_Jump));
// -- Kill a spider
this.game.events.on(EventList.KILL_SPIDER, () => this.sound.play(AssetsList.SND_Stomp));
}
/**
* Gère quand notre héros capture une pièce
*/
private manageGetCoin() {
// Mise à jour du score
this.scoreContainer.value += 1;
this.sound.play(AssetsList.SND_Coin);
}
}
Deux méthodes sont modifiées :
- Directement dans le create pour ajouter de nouveaux écouteur,
- Dans manageGetCoin pour ajouter la ligne qui joue le son :
this.sound.play(AssetsList.SND_Coin);
.
Cela ne change pas grand chose mais cela donne du retour à l’utilisateur.
Ajout d’évènements au bon moment
Il en manque deux car celui des pièces est déjà présents :
- Au niveau du héros, quand il saute :
this.scene.game.events.emit(EventList.HERO_JUMP);
, - Au niveau du level quand une araignée est tuée :
this.scene.game.events.emit(EventList.KILL_SPIDER);
.
Dans les faits, il est déconseillé d’utiliser directemet l’instance game mais créer son propre gestionnaire et surtout éviter de mettre une dépendance comme celle mise en place. Mais ici, c’est pas Zelda :).
Amélioration du heros
Introduction
Les pièces et les araignées sont animées mais pas notre héros ? Ce n’est pas normal.
Changement d’image
La première chose à faire est de changer l’image du héros pour passer à une liste d’images :
// [...]
export class LoadingScene extends Phaser.Scene {
preload() {
// [...]
// this.load.image(AssetsList.IMG_Hero, 'images/hero_stopped.png');
this.load.spritesheet(AssetsList.SPRITESHEET_Hero, 'images/hero.png', { frameWidth: 36, frameHeight: 42 });
// [...]
}
}
Evidemment, il faut penser à ajouter une entrée dans l’enum.
Changement du héros
Le héros doit évoluer pour :
- Utiliser la bonne image,
- Créer des animations,
- Calculer l’animation à utiliser,
- Etre dans le bon sens.
Image & Gestion des animations
Quelques premières modifications :
// Ajout d'un enum qui contient le nom des animations
enum HeroAnim {
Stop = 'stop',
Run = 'run',
Jump = 'jump',
Fall = 'fall'
}
// La classe est une extension d'un sprite pour en avoir
// toutes les méthodes est service
export class Hero extends Physics.Arcade.Sprite {
// [...]
constructor(scene: Phaser.Scene, heroModel: HeroModel) {
// [...]
// Création des différents animations
// -- Stop
this.scene.anims.create({
key: HeroAnim.Stop,
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Hero, { frames: [0] })
});
// -- Run
this.scene.anims.create({
key: HeroAnim.Run,
frameRate: 8,
repeat: -1,
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Hero, { frames: [1, 2] })
});
// -- Jump
this.scene.anims.create({
key: HeroAnim.Jump,
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Hero, { frames: [3] })
});
// -- Fall
this.scene.anims.create({
key: HeroAnim.Fall,
frames: this.anims.generateFrameNumbers(AssetsList.SPRITESHEET_Hero, { frames: [4] })
});
}
}
Il a maintenant de quoi montrer ce qu’il fait. Il reste à utiliser la bonne au bon moment …
La bonne animation au bon moment
Pour faciliter, une méthode est mise en place :
export class Hero extends Physics.Arcade.Sprite {
// [...]
/**
* Calcul le nom de l'animation en fonction de l'état du hero
*/
private getAnimationName(): string {
// Par défaut, stop
let name = HeroAnim.Stop;
if (this.isJumping()) {
// Jumping
name = HeroAnim.Jump;
} else if (this.isFalling() && !this.body.touching.down) {
// Falling
name = HeroAnim.Fall;
} else if (this.body.velocity.x !== 0 && this.body.touching.down) {
// Running
name = HeroAnim.Run;
}
return name;
}
}
Gestion du sens
En fonction du mouvement, il faut mettre à jour le sens du heros. Idem, une méthode est mise en place :
protected checkFlip(): void {
if (this.body.velocity.x < 0) {
this.scaleX = -1;
} else {
this.scaleX = 1;
}
}
Mise à jour de preUpdate
Il faut maintenant utiliser les méthodes ci-dessus dans la méthode preUpdate :
export class Hero extends Physics.Arcade.Sprite {
// [...]
preUpdate() {
// [...]
// A la fin ...
// En fonction, changement d'animation
this.anims.play(this.getAnimationName(), true);
// Il faut mettre le personne du bon côté
this.checkFlip();
}
}
Victoire
Introduction
Le joueur peut perdre mais il ne peut pas gagner. Une porte et une clé sont ajoutées pour remédier à ce point.
Chargement
Etape quasiment de routines maintenant : ajout des assets images et son pour les nouveaux éleménts :
export class LoadingScene extends Phaser.Scene {
// [...]
preload() {
// [...]
this.load.spritesheet(AssetsList.SPRITESHEET_Door, 'images/door.png', { frameWidth: 42, frameHeight: 66 });
this.load.image(AssetsList.IMG_Key, 'images/key.png');
this.load.spritesheet(AssetsList.SPRITESHEET_KeyIcon, 'image/key_icon.png', { frameWidth: 34, frameHeight: 30 });
// [...]
this.load.audio(AssetsList.SND_Door, 'audio/door.wav');
this.load.audio(AssetsList.SND_Key, 'audio/key.wav')
}
// [...]
}
Création d’entité
Une première pour la porte :
import { Physics } from 'phaser';
import { AssetsList } from '../consts';
import { DoorModel } from '../models/door.model';
// La classe est une extension d'un sprite pour en avoir
// toutes les méthodes est service
export class Door extends Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, doorModel: DoorModel) {
// Il faut commencer par appeler le constructeur parent
// --> Il faut bien passer la bonne texture
super(scene, doorModel.x, doorModel.y, AssetsList.IMG_Door);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Gestion du corps (comme plateforme)
// Sinon les pièces tombent ou peuvent bouger
const body = this.body as Phaser.Physics.Arcade.Body;
body.setAllowGravity(false);
body.setImmovable(true);
// Mise à jour de l'origin pour placer la porte en fonction
// du milieu en "bas"
this.setOrigin(0.5, 1);
}
}
et une deuxième pour la clé :
import { Physics } from 'phaser';
import { AssetsList } from '../consts';
import { KeyModel } from '../models/key.model';
// La classe est une extension d'un sprite pour en avoir
// toutes les méthodes est service
export class Key extends Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, keyModel: KeyModel) {
// Il faut commencer par appeler le constructeur parent
// --> Il faut bien passer la bonne texture
super(scene, keyModel.x, keyModel.y, AssetsList.IMG_Key);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Gestion du corps (comme plateforme)
// Sinon les pièces tombent ou peuvent bouger
const body = this.body as Phaser.Physics.Arcade.Body;
body.setAllowGravity(false);
body.setImmovable(true);
this.setOrigin(0.5, 0.5);
}
}
Ajout dans la scéne
Dans Phaser, l’ordre d’ajout des éléments est important : il détermine qui est affiché devant qui quand un objet passe devant un autre. Donc comme il faut que la porte et la clé soit derrière, il faut les ajouter en premier.
// [...]
export class LevelOneScene extends Phaser.Scene {
// [...]
private _createLevel(data: LevelModel) {
// Gestion des plateformes
data.platforms.forEach(this._createPlatform, this);
// Gestion de la porte et de la clé
this._door = new Door(this, data.door);
this._key = new Key(this, data.key);
// Gestion du heros
this._hero = new Hero(this, data.hero);
// [...]
}
// [...]
}
Et normalement :
.
Règles
Pour gagner, le hero doit collecter la clé puis passer la porte.
Ajout d’une information sur le hero permettant d’indiquer qu’il a bien collecté la clé :
export class Hero extends Physics.Arcade.Sprite {
// [...]
// Vrai si le joueur à collecter la clé
private _hasKey: boolean = false;
public get HasKey(): boolean { return this._hasKey; }
public set HasKey(value: boolean) { this._hasKey = value; }
// [...]
}
Détection de la collision entre le joueur et la clé en cas de de rencontre :
export class LevelOneScene extends Phaser.Scene {
// [...]
create() {
// [...]
// -- Hero avec la clé
this.physics.add.overlap(this._hero, this._key, (hero, key) => {
// Mise à jour du hero
this._hero.HasKey = true;
// Suppression de la clé
key.destroy();
// Event
this.game.events.emit(EventList.GET_KEY);
})
// [...]
}
// [...]
}
Puis la même chose avec la porte. Ici, un fonction supplémentaire est passée qui permet de valider qu’effectivement, la collision peut être gérée :
export class LevelOneScene extends Phaser.Scene {
// [...]
create() {
// [...]
// -- Hero avec la clé
// -- Hero avec la porte
this.physics.add.overlap(this._hero, this._door,
// Gestion de la collision
() => {
// Event
this.game.events.emit(EventList.OPEN_DOOR);
// Relance du jeu
this.scene.restart();
},
// Est-ce qu'il faut gérer la collision
() => {
// Oui, si le hero a la clé et qu'il touche le sol
// On prend pas la porte par le dessus !
return this._hero.HasKey && this._hero.body.touching.down;
}
)
// [...]
}
// [...]
}
Correction d’une anomalie
Le mode debug permet de constater une anomalie dans le jeu : quand le joueur part vers la gauche, sa zone de corps est détachée de son image. Cela génère des comportements étranges comme être touché de loin ou passer entre les murs.
Le souci vient de la méthode checkFlip. En changeant de sens, il faut également déplacer la partie physique :
protected checkFlip(): void {
if (this.body.velocity.x < 0) {
this.scaleX = -1;
this.body.setOffset(this.width, 0);
} else {
this.scaleX = 1;
this.body.setOffset(0, 0);
}
}
Il faut jouer sur l’offset pour le recaler sur l’image.
Affichage de la clé
Quand le joueur collecte la clé, il serait intéressant de le mettre en avant. Une icône a été chargée en même temps que l’image de la clé et de la porte.
L’idée étant de l’afficher à côté du score, il faut modifier le container du score.
export class ScoreContainer extends Phaser.GameObjects.Container {
// [...]
// L'image
private _key!: Phaser.GameObjects.Image;
// Vrai si le joueur à la clé
private _hasKey = false;
public get HasKey(): boolean { return this._hasKey; }
public set HasKey(value: boolean) { this._hasKey = value; }
// [...]
constructor(scene: Phaser.Scene, x: number, y: number) {
super(scene, x, y);
// Création de la clé
this._key = scene.add.image(0, 19, AssetsList.SPRITESHEET_KeyIcon);
this._key.setOrigin(0, 0.5);
// Création de la pièce
// Il faut modifier sa position par rapport à la clé
let coinIcon = scene.add.image(this._key.width + 7, 0, AssetsList.IMG_Coin);
coinIcon.setOrigin(0, 0);
// [...]
// Ajout dans le container
this.add(this._key);
this.add(coinIcon);
this.add(this.dynamic);
}
Et la clé apparaît :
.
Une dernière chose : quand le joueur collecte la clé, il faut mettre à jour cette icone.
Au niveau d’UI, un écouteur est présente pour émettre un son au moment de la collecte de la clé. Il peut servir pour mettre à jour le container score :
export class UIScene extends Scene {
// [...]
create() {
// [...]
// -- Récupération de la clé
this.game.events.on(EventList.GET_KEY, () => {
this.sound.play(AssetsList.SND_Key);
this.scoreContainer.HasKey = true;
});
// -- Gestion de la porte
this.game.events.on(EventList.OPEN_DOOR, () => this.sound.play(AssetsList.SND_Door));
}
}
Puis, mise à jour du container :
preUpdate() {
this.dynamic.text = `X${this.value}`;
this._key.setFrame(this.HasKey ? 1 : 0);
}
Et après la collecte de la clé, l’icône change :
.
Une animation sur la clé
La clé étant un élément important, il faut le metttre en avant. Un moyen est d’ajouter un tween (interpolation) sur la clé :
export class Key extends Physics.Arcade.Sprite {
constructor(scene: Phaser.Scene, keyModel: KeyModel) {
// [...]
scene.tweens.add({
targets: this,
y: { from: this.y - 3, to: this.y + 6 },
yoyo: true,
ease: 'Linear',
duration: 1000,
repeat: -1,
});
}
Changer de niveau
Introduction
Bien que deux fichiers de niveau soient présents, un seul est utilisé à présent et cela implique que le joueur reste sur le même niveau à chaque fois.
Déplacement du chargement
Actuellement, le chargement du fichier JSON est effectué dans la méthode preLoad de la classe LevelOneScene. Ce chargement est déplacé dans la scène dédiée à cela et complété par le chargement de l’autre niveau :
export class LoadingScene extends Phaser.Scene {
// [...]
preload() {
// [...]
// Chargement des niveaux
this.load.json('level:00', `data/level00.json`);
this.load.json('level:01', `data/level01.json`);
// [...]
}
Niveau par défaut et gestion d’un paramètre
Une proprité est ajoutée à la classe LevelOneScene pour savoir à quel niveau est le joueur :
private currentLevel = 0;
La méthode create accepte un paramètre qui correspond aux données qui peuvent être passée quand une scène est lancée. Afin d’avoir une vérification, un model est ajouté :
export class StartModel {
public level: number = 0;
public lost: boolean = false;
}
et le paramètre est ajouté à la fonction create de la classe LevelOneScene et est utilisé pour déterminer le niveau en cours. Ce même niveau est utilisé pour indiquer ce qu’il faut utiliser pour créer la scène :
// [...]
create(startModel: StartModel) {
// Récupération des informations
this.currentLevel = (startModel.level || 0) % 2;
// [...]
this._createLevel(this.cache.json.get(`level:0${this.currentLevel}`));
}
Passé des paramètres à la relance
Le jeu est relancé à deux endroits: quand le joueur gagne et quand le joueur perd.
Dans le premier cas c’est à dire quand il passe la porte, l’appel est modifié pour indiquer un changement de niveau :
this.scene.restart({ level: this.currentLevel + 1, lost: false });
Dans le second cas c’est à dire quand il rencontre une araigné, l’appel est modifié pour indiquer que le niveau reste mais qu’il a perdu :
this.scene.restart({ level: this.currentLevel, lost: true });
A ce stade, l’utilisateur peut passer les niveaux les uns après les autres.
Pièces et clé
Un souci demeure : quand il change de niveau, l’icone clé reste comme s’il l’avait. De même quand, il perd le nombre de pièce reste.
NOTE : dans le tuto d’origine, il est fait mention d’appeler game.state.start. Mais cette fonction ne semble plus présente…
En fait, ce qu’il manque c’est un moyen de dire à la scène UI de se mettre à jour. La communication entre la scène de jeu et celle d’UI passant par des évènements, c’est cette solution qui va être utilisée.
La liste des évènements est modifiée pour avoir un évènement supplémentaire :
export enum EventList {
GET_COIN = 'GET_COIN',
KILL_SPIDER = 'KILL_SPIDER',
HERO_JUMP = 'HERO_JUMP',
OPEN_DOOR = 'OPEN_DOOR',
GET_KEY = 'GET_KEY',
GAME_END = 'GAME_END'
}
Dans la classe LevelOneScene, une méthode centralise la fin du jeu et l’émission de l’évènement :
private _restartScene(startModel: StartModel) {
/**
* Relance la scène
* @param startModel Infos
*/
private _restartScene(startModel: StartModel) {
// Event
this.game.events.emit(EventList.GAME_END, startModel);
// Relance
this.scene.restart(startModel);
}
}
Il serait mieux de faire un model dédié pour passer des informations à l’évènement mais dans le cadre de cette exercice c’est suffisant.
Les deux appels à this.scene.restart sont remplacés par l’appel à la nouvelle méthode en passant les paramètres identiques.
// [...]
// this.scene.restart({ level: this.currentLevel + 1, lost: false });
this._restartScene({ level: this.currentLevel + 1, lost: false });
// [...]
// this.scene.restart({ level: this.currentLevel, lost: true });
this._restartScene({ level: this.currentLevel, lost: true });
// [...]
Pour le moment, cela ne change rien au scénario du jeu. Pour finaliser, il faut gérer l’évènement fin du jeu dans UIScene :
export class UIScene extends Scene {
// [...]
this.game.events.on(EventList.GAME_END, (data: StartModel) => {
// Il n'a plus la clé
this.scoreContainer.HasKey = false;
// S'il a perdu, il n'a plus de pièce
if (data.lost) {
this.scoreContainer.value = 0;
}
});
// [...]
}
Et là … c’est bien.
Il faut ne pas oublier d’enlever le mode debug pour ne plus avoir les carrés rouges.
Un peu de nettoyage
Introduction
Le jeu est fonctionnel et c’est déjà une bonne chose. Par contre, une phase de nettoyage du code semble nécessaire …
Constantes
La liste des constantes a évolué au fil de l’eau et certaines ne sont plus nécessaires et certaines méritent d’être renommées.
Par exemple, la liste des scènes :
export enum ScenesList {
LoadingScene = 'loading-scene',
LevelScene = 'level-scene',
UIScene = 'ui-scene'
}
Suite à cette mise à jour, il faut aller modifier les scènes :
- Suppression de la scène HelloWorld,
- Mise à jour du label dans la classe LevelOneScene,
- Mise à jour du label dans la classe LoadingScene,
- Mise à jour de la configuration dans main.ts.
Renommage de la scène level
La classe porte un nom qui n’a pas de sens : LevelOneScene.
Actions :
- Renommage de LevelOneScene en LevelScene,
- Renommage du fichier également,
- Mise à jour de la configuration dans main.ts.
Gestion des plateformes
Pour pratiquement tous les composants, il existe une classe dédiée sauf pour les plateformes. C’est à corriger…
import { Physics } from 'phaser';
import { PlatformModel } from '../models/plateform.model';
export class Platform extends Physics.Arcade.Sprite {
/**
*/
constructor(scene: Phaser.Scene, plateformModel: PlatformModel) {
// Appel du parent
super(scene, plateformModel.x, plateformModel.y, plateformModel.image);
// Ajout à la scéne
scene.add.existing(this);
// Mais également faisant partie de la "physic"
scene.physics.add.existing(this);
// Mise à jour du Body
const body = this.body as Physics.Arcade.Body;
// Par contre, pour éviter qu'elle tombe, il faut leur dire que la gravité n'a pas d'impact
body.setAllowGravity(false);
// Et pour éviter que si quelqu'un marche dessus, la plateforme glisse
body.setImmovable(true);
// Pour que le placement soit cohérent
this.setOrigin(0, 0);
}
}
Puis il faut mettre à jour la méthode de création des plateformes :
export class LevelScene extends Phaser.Scene {
// [...]
private _createPlatform(platformModel: PlatformModel) {
// ---- Création de la plateforme
const platform = new Platform(this, platformModel);
// Ajout au tableau
this._plateforms.push(platform);
// --- Création des murs invisibles
// de chaque côté de la plateforme
this._enemyWalls.push(
new EnemyWall(this, platform.x, platform.y, EnemyWallSide.left),
new EnemyWall(this, platform.x + platform.width, platform.y, EnemyWallSide.right)
);
}
// [...]
}
Pleins d’autres petites choses
Ajout de commentaires, quelques modifications de variables …
Mais le jeu est là. Il faudrait plus de plateaux mais bon ce sera pour une autre fois.
Le jeu !
Il est là : Phaser 3 - Typescript et ses sources sont ici.