Dans cet article, nous allons tester ensemble la dernière version de Phaser (2.1.1) à travers un tutoriel. Pour rappel, Phaser est un framework en HTML5 Canvas qui vous permettra de créer des jeux plus facilement. Je vous avais déjà rédigé un article sur Phaser 1.6 (article sur Flappy Bird), article que je vous conseille de consulter pour avoir une description plus précise de ce framework.
Les changements entre Phaser 1.0 et Phaser 2.0 étant assez importants, j’ai décidé de vous refaire un tutoriel dans lequel nous verrons ensemble comment créer une réplique du jeu Timberman, développé par Digital Melody. Ce jeu consiste, grâce à un petit personnage, à couper le maximum de morceaux d’un arbre infiniment grand pour obtenir le meilleur score dans un temps imparti. C’est donc un jeu d’adresse et d’endurance.
Avant de commencer, voici les liens vers le dépôt Github et la démonstration du jeu afin que vous sachiez à quoi vous attendre :
Il faut savoir que la démonstration proposée ci-dessus est une version plus aboutie du tutoriel qui va suivre.
Plus tard et dans la continuité de ce tutoriel, je vous rédigerai un article qui vous apprendra comment transformer votre jeu en application mobile grâce à CocoonJS.
Comme nous n’allons pas couvrir toutes les fonctionnalités de Phaser dans ce tutoriel, je vous conseille d’aller voir la documentation et les exemples qui se trouvent sur le site de Phaser.
Remarques importantes :
- L’exemple sur lequel va s’appuyer ce tutoriel utilise la version 2.1.1 de Phaser. Je ne garantie donc pas son bon fonctionnement avec des versions plus récentes de ce framework.
- Dans cet article, je vais vous présenter plusieurs fonctions propres à Phaser. Beaucoup d’entre elles comportent des arguments et donc des fonctionnalités que je n’aborderai pas dans le contexte de ce tutoriel. Si vous souhaitez avoir plus de détails sur ces dernières, je vous conseille donc de vous référer à la documentation Phaser
Fichier phaser.js
Tout d’abord, il faut télécharger la dernière version de Phaser. Vous la trouverez sur le dépôt Github de Photonstorm, créateur de Phaser. Il vous suffira juste de l’inclure dans votre fichier index.html.
Organisation du projet
L’architecture de notre jeu est très simple et reste la même que celle décrite dans le tutoriel sur Flappy Bird :
- index.html
- style.css
- phaser.min.js : ficher javaScript du framework Phaser
- main.js : fichier qui contiendra le code de votre jeu
- img/ : dossier qui contiendra les images
- sons/ : dossier qui contiendra les sons
- data/ : dossier qui contiendra les fichiers JSON. Ces fichiers permettront de paramétrer les animations
Le contenu des fichiers index.html et style.css
Avant de s’attaquer au coeur même du jeu, il faut d’abord créer les fichiers index.html et style.css :
<!DOCTYPE HTML>
<html>
<head>
<title>Timberman avec Phaser 2</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<script type="text/javascript" src="phaser.min.js"></script>
<script type="text/javascript" src="main.js"/></script>
<link rel="stylesheet" href="style.css"/>
</head>
<body>
<!-- div qui contiendra le canvas de notre jeu -->
<div id="timberman"></div>
</body>
</html>
index.html
Le fichier CSS nous permettra de centrer le jeu :
body {
padding: 0; margin: 0;
}
#timberman {
margin: auto;
display: table;
}
style.css
Initialisation de Phaser
Les fichiers index.html et style.css étant maintenant créés, nous pouvons commencer à développer notre jeu. La première étape consiste à instancier Phaser dans le fichier main.js.
Création du jeu
// Variables qui nous permettront de savoir quand le jeu démarre ou quand il y a un GAME OVER
var GAME_START = false;
var GAME_OVER = false;
// Taille du jeu (mode portrait d'un nexus 5 sans la barre de navigation)
const width = 1080;
const height = 1775;
// Phaser
var game = new Phaser.Game(width, height, Phaser.AUTO, 'timberman');
// On rend le background transparent
game.transparent = true;
main.js - instanciation de Phaser
Pour instancier un objet Phaser, il faut utiliser la classe Game :
new Game(width, height, renderer, parent, state, transparent, antialias, physicsConfig)
Dans notre cas, nous n’utiliserons que les 4 premiers arguments :
- width : la largeur du jeu (largeur du Canvas)
- height : la hauteur du jeu (hauteur du Canvas)
- renderer : le rendu graphique (WebGL ou Canvas)
- parent : id de la balise HTML dans laquelle on souhaite créer le canvas (#timberman dans notre exemple)
Cet objet sera stocké dans la variable game
. Cette variable sera le point central de notre projet car ce sera elle qui contiendra tous les éléments du jeu (les sprites, les sons…).
Gestion des différents états
Notre jeu sera structuré en 2 parties. D’un côté, le chargement des ressources (sons, images…) et de l’autre, la mécanique de jeu (placement des images, animations…).
Pour cela, Phaser nous met à disposition une gestion des états. On retrouve l’état load
pour le chargement des ressources et l’état main
pour la mécanique et la mise en place du jeu. Ces 2 états sont donc prédéfinis par Phaser :
// On déclare un objet quelconque qui contiendra les états "load" et "main"
var gameState = {};
gameState.load = function() { };
gameState.main = function() { };
// Va contenir le code qui chargera les ressources
gameState.load.prototype = {
};
// Va contenir le coeur du jeu
gameState.main.prototype = {
};
// On ajoute les 2 fonctions "gameState.load" et "gameState.main" à notre objet Phaser
game.state.add('load', gameState.load);
game.state.add('main', gameState.main);
// Il ne reste plus qu'à lancer l'état "load"
game.state.start('load');
main.js - Déclaration des états
Grâce à la méthode game.state.add
, nous avons ajouté les 2 états load et main à notre objet Phaser game
. Le contenu de ces états sera défini dans l’objet gameState
déclaré juste avant.
Maintenant que les états sont créés, il ne manque plus qu’à écrire leur contenu !
// Va contenir le code qui chargera les ressources
gameState.load.prototype = {
preload: function() {
// Chargement des ressources
},
create: function() {
game.state.start('main');
}
};
// va contenir le coeur du jeu
gameState.main.prototype = {
create: function() {
// Initialisation et intégration des ressources dans le Canvas
...
// On fait en sorte que le jeu se redimensionne selon la taille de l'écran (Pour les PC)
game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;
game.scale.setShowAll();
window.addEventListener('resize', function () {
game.scale.refresh();
});
game.scale.refresh();
},
update: function() {
// Animations
}
};
main.js - Contenu des états
Les états load et main sont tous 2 composés de 2 méthodes propres au framework Phaser.
Pour l’état load :
preload
: méthode lancée automatiquement après l’appel de l’état load. Elle va permettre de charger les ressources du jeu
create
: méthode appelée juste après preload
. Permet de lancer l’état main
Pour l’état main :
create
: méthode lancée automatiquement après l’appel de l’état main. Mise en place des ressources chargées précédemment
update
: C’est la boucle principale du jeu. Dans notre cas, elle va détecter les touches pressées par l’utilisateur et gérer la barre de temps
Affichage du background et du personnage
Nous pouvons enfin commencer les choses sérieuses. La toute première étape est d’afficher le fond de notre jeu et le personnage. Pour ce faire, il faut charger les images correspondantes grâce au preload
de l’état load et placer l’image dans le Canvas grâce à la méthode create
de l’état main.
...
gameState.load.prototype = {
preload: function() {
// Chargement de l'image du background
game.load.image('background', 'img/background.png');
// Chargement du personnage - PNG et JSON
game.load.atlas('man', 'img/man.png', 'data/man.json');
},
create: function() {
game.state.start('main');
}
};
gameState.main.prototype = {
create: function() {
...
// Création de l'arrière-plan dans le Canvas
this.background = game.add.sprite(0, 0, 'background');
this.background.width = game.width;
this.background.height = game.height;
// ---- BÛCHERON
// Création du bûcheron
this.man = game.add.sprite(0, 1070, 'man');
// On ajoute l'animation de la respiration (fait appel au JSON)
this.man.animations.add('breath', [0,1]);
// On ajoute l'animation de la coupe (fait appel au JSON)
this.man.animations.add('cut', [1,2,3,4]);
// On fait démarrer l'animation, avec 3 images par seconde et répétée en boucle
this.man.animations.play('breath', 3, true);
// Position du bûcheron
this.manPosition = 'left';
},
...
};
main.js - affichage du background et du personnage
Le background
Pour mettre en place le background, c’est très simple. Premièrement, il suffit d’utiliser la méthode game.load.image(key, url)
pour précharger l’image. Ensuite, il faut créer le sprite de ce background grâce à game.add.sprite(x, y, key)
. Dans notre cas, nous stockons ce sprite dans l’attribut this.background
. Pour finir, nous lui donnons la largeur et la hauteur du jeu afin qu’il s’étende sur toute la surface du Canvas.
Le personnage
Pour le personnage, c’est un peu plus compliqué. En effet, il est en fait construit à partir de plusieurs images (frames) que nous avons rassemblé dans une seule (voir article sur Stitches). Le personnage possède plusieurs frames car il doit être animé : animation de la respiration et animation quand il coupe le bois.
C’est pour cela que nous n’allons pas utiliser game.load.image()
mais plutôt la fonction game.load.atlas(key, url de l'image, url du fichier de données)
. Cette fonction aura pour rôle d’associer un fichier de données (JSON dans notre cas) à une image afin de la découper en plusieurs frames :
{"frames": [
{
"name": "breath01",
"frame": {"x":1059,"y":5,"w":517,"h":403}
},
{
"name": "breath02",
"frame": {"x":1586,"y":5,"w":517,"h":403}
},
{
"name": "cut01",
"frame": {"x":5,"y":5,"w":517,"h":403}
},
{
"name": "cut02",
"frame": {"x":532,"y":5,"w":517,"h":403}
},
{
"name": "cut03",
"frame": {"x":5,"y":5,"w":517,"h":403}
}
]}
man.json - les différentes frames du personnage
Pour chaque frame, on précise :
- Le name : vous permet de nommer vos frames
- La frame : va contenir les coordonnées et la taille de la frame à récupérer dans l’image « img/background.png »
Maintenant que les animations sont prêtes à être utilisées, il faut les ajouter au sprite du personnage grâce à la fonction this.man.animations.add
auquel nous allons passer 2 arguments :
- Le nom de l’animation à ajouter
- Les numéros de frames (fichier JSON) qui doivent faire partie de l’animation
Par exemple, pour l’animation « breath », ce sont les frames 0 et 1 (« breath01 » et « breath02 « ) qui sont concernées.
Pour finir, il suffit de lancer l’animation voulue grâce au nom de cette dernière et à la fonction this.man.animations.play(nom de l'animation, nombre de frames par seconde, animation répétée ou non)
.
Création de l’arbre
Pour créer l’arbre qui sera coupé par le personnage, 5 images différentes sont nécessaires : la souche d’arbre, 2 troncs différents, un tronc avec une branche sur la gauche et un tronc avec une branche sur la droite.
gameState.load.prototype = {
preload: function() {
...
// Arbre
game.load.image('trunk1', 'img/trunk1.png');
game.load.image('trunk2', 'img/trunk2.png');
game.load.image('branchLeft', 'img/branch1.png');
game.load.image('branchRight', 'img/branch2.png');
game.load.image('stump', 'img/stump.png');
},
...
};
gameState.main.prototype = {
create: function() {
...
// ---- ARBRE
// souche
this.stump = game.add.sprite(0, 0, 'stump');
this.stump.x = 352;
this.stump.y = 1394;
// construction de l'arbre
this.HEIGHT_TRUNK = 243;
this.constructTree();
this.canCut = true;
// ---- BÛCHERON
...
},
...
};
main.js - chargement de l'arbre
Les 3 règles à respecter pour construire l’arbre
La première chose à faire, après avoir chargé les images, est de placer la souche de l’arbre. Pour construire le reste de l’arbre, c’est un peu plus complexe. En effet, il faut suivre 3 règles :
- Il ne faut pas qu’une branche apparaisse sur le personnage directement après avoir lancé le jeu.
Solution : il faut placer 2 troncs sans branches dès le début
- Il ne peut pas y avoir 2 branches à la suite.
Solution : il faut placer un ou plusieurs (aléatoirement) troncs entre 2 branches
- Les branches doivent être plus présentes que les troncs.
Solution : il faut forcer le hasard en donnant plus de chance qu’une branche soit placée
Toutes ces règles vont être gérées dans la fonction constructTree()
et addTrunk()
gameState.main.prototype = {
create: function() {
...
},
update: function() {
},
constructTree: function() {
// On construit le groupe this.tree qui va contenir tous les morceaux de l'arbre (troncs simple et branches)
this.tree = game.add.group();
// Les 2 premiers troncs sont des troncs simples
this.tree.create(37, 1151, 'trunk1');
this.tree.create(37, 1151 - this.HEIGHT_TRUNK, 'trunk2');
// On construit le reste de l'arbre
for(var i = 0; i < 4; i++) {
this.addTrunk();
}
},
addTrunk: function() {
var trunks = ['trunk1', 'trunk2'];
var branchs = ['branchLeft', 'branchRight'];
// Si le dernier tronc du groupe this.tree n'est pas une branche
if(branchs.indexOf(this.tree.getAt(this.tree.length - 1).key) == -1) {
// 1 chance sur 4 de placer un tronc sans branche
if(Math.random() * 4 <= 1)
this.tree.create(37, this.stump.y - this.HEIGHT_TRUNK * (this.tree.length + 1), trunks[Math.floor(Math.random() * 2)]);
// 3 chances sur 4 de placer une branche
else
this.tree.create(37, this.stump.y - this.HEIGHT_TRUNK * (this.tree.length + 1), branchs[Math.floor(Math.random() * 2)]);
}
// Si le tronc précédent est une branche, on place un tronc simple
else
this.tree.create(37, this.stump.y - this.HEIGHT_TRUNK * (this.tree.length + 1), trunks[Math.floor(Math.random() * 2)]);
}
};
main.js - construction de l'arbre
La fonction constructTree()
Premièrement, on crée un groupe dans la variable this.tree
grâce à la fonction game.add.group()
. Ce groupe va contenir les 6 morceaux de l’arbre visibles à l’écran (troncs ou branches).
Pour respecter la 1ère règle, les 2 premiers éléments que nous allons placer sur l’arbre (et donc ajouter au groupe this.tree
) sont les troncs sans branches « trunk1 » et « trunk2 » grâce à la méthode this.tree.create(x, y, image à ajouter au groupe)
.
Il faut maintenant construire le reste de l’arbre et donc les 4 derniers troncs. C’est le rôle de la méthode addTrunk()
, qui, à chaque appel, ajoutera un tronc ou une branche selon la situation. C’est pour cette raison qu’elle est appelée 4 fois.
La fonction addTrunk()
Le but de cette fonction et d’ajouter un tronc ou une branche sur l’arbre. Dans un premier temps, elle va vérifier si le dernier morceau de l’arbre est un tronc ou une branche :
- Si le dernier morceau de l’arbre se révèle être un tronc, nous aurons 3 chances sur 4 de poser une branche et donc 1 chance sur 4 de poser un tronc simple
- Si le dernier morceau de l’arbre est une branche, nous placerons automatiquement un tronc simple
Vous devriez maintenant obtenir un bel arbre bien construit !
Animation du personnage avec l’arbre
Pour donner à notre personnage l’impression de couper du bois, il faut utiliser l’animation « cut », animation que nous avons déjà créée plus haut. Mais il faut attendre une action particulière de la part de le joueur : l’utilisation du clic (avec la souris) ou des flèches directionnelles du clavier.
gameState.main.prototype = {
create: function() {
...
// Au clic, on appelle la fonction "listener()"
game.input.onDown.add(this.listener, this);
},
update: function() {
// Si la partie n'est pas terminée
if(!GAME_OVER) {
// Détection des touches left et right du clavier
if (game.input.keyboard.justPressed(Phaser.Keyboard.LEFT))
this.listener('left');
else if (game.input.keyboard.justPressed(Phaser.Keyboard.RIGHT))
this.listener('right');
}
},
listener: function(action) {
if(this.canCut) {
// La première action de l'utilisateur déclenche le début de partie
if(!GAME_START)
GAME_START = true;
// On vérifie si l'action du joueur est un clic
var isClick = action instanceof Phaser.Pointer;
// Si la touche directionnelle gauche est pressée ou s'il y a un clic dans la moitié gauche du jeu
if(action == 'left' || (isClick && game.input.activePointer.x <= game.width / 2)) {
// On remet le personnage à gauche de l'arbre et dans le sens de départ
this.man.anchor.setTo(0, 0);
this.man.scale.x = 1;
this.man.x = 0;
this.manPosition = 'left';
// Si la touche directionnelle droite est pressée ou s'il y a un clic dans la moitié droite du jeu
} else {
// On inverse le sens du personnage pour le mettre à droite de l'arbre
this.man.anchor.setTo(1, 0);
this.man.scale.x = -1;
this.man.x = game.width - Math.abs(this.man.width);
this.manPosition = 'right';
}
// On stop l'animation de respiration
this.man.animations.stop('breath', true);
// Pour démarrer l'animation de la coupe, une seule fois et avec 3 images par seconde
var animationCut = this.man.animations.play('cut', 15);
// Une fois que l'animation de la coupe est finie, on reprend l'animation de la respiration
animationCut.onComplete.add(function() {
this.man.animations.play('breath', 3, true);
}, this);
}
},
...
};
main.js - animation du personnage sur action du joueur
Détection du clic ou des touches directionnelles
Grâce à la fonction game.input.onDown.add(fonction à déclencher au clic du joueur, contexte de la fonction)
, il est possible d’écouter l’action « clic » du joueur.
C’est un peu différent pour les touches directionnelles gauche et droite. En effet, il est nécessaire de passer par la méthode update()
pour savoir quand le joueur va presser une de ces 2 touches grâce à la fonction game.input.keyboard.justPressed(touche à vérifier)
qui doit renvoyer true ou false. Je vous rappelle que update()
est une fonction propre à Phaser qui est appelée en boucle pendant le jeu.
Animation du personnage
Une fois qu’une action du joueur a été observée, la fonction listener()
est appelée. C’est dans cette dernière que l’animation « cut » du personnage va être gérée. Deux cas sont à prendre en compte :
- L’utilisateur a cliqué sur le jeu
- La souris se trouve dans la moitié gauche du jeu, on place le personnage à gauche de l’arbre
- La souris se trouve dans la moitié droite du jeu, on place le personnage à droite de l’arbre
- L’utilisateur a pressé une touche directionnelle
- Si la c’est la touche gauche, on place le personnage à gauche de l’arbre
- Si la c’est la touche gauche, on place le personnage à droite de l’arbre
Dans les 2 cas, l’animation « cut » est lancée.
Découpage de l’arbre
Découpage morceau par morceau
Comme dit au début de ce tutoriel, le but du jeu Timberman est d’obtenir le meilleur score en découpant un maximum de morceaux de l’arbre. Il faut donc, en même temps que l’animation « cut » du personnage, déclencher une fonction qui permettra de découper l’arbre morceau par morceau.
gameState.main.prototype = {
create: function() {
// Physique du jeu
game.physics.startSystem(Phaser.Physics.ARCADE);
...
},
...
listener: function(action) {
if(this.canCut) {
...
this.cutTrunk();
}
},
cutTrunk: function() {
// On ajoute un tronc ou une branche
this.addTrunk();
// On crée une copie du morceau de l'arbre qui doit être coupé
var trunkCut = game.add.sprite(37, 1151, this.tree.getAt(0).key);
// Et on supprime le morceau appartenant à l'arbre
this.tree.remove(this.tree.getAt(0));
// On active le système de physique sur ce sprite
game.physics.enable(trunkCut, Phaser.Physics.ARCADE);
// On déplace le centre de gravité du sprite en son milieu, ce qui nous permettra de lui faire faire une rotation sur lui même
trunkCut.anchor.setTo(0.5, 0.5);
trunkCut.x += trunkCut.width / 2;
trunkCut.y += trunkCut.height / 2;
var angle = 0;
// Si le personnage se trouve à gauche, on envoie le morceau de bois vers la droite
if(this.manPosition == 'left') {
trunkCut.body.velocity.x = 1300;
angle = -400;
// Sinon, on l'envoie vers la gauche
} else {
trunkCut.body.velocity.x = -1300;
angle = 400;
}
// Permet de créer un effet de gravité
// Dans un premier temps, le morceau de bois est propulsé en l'air
trunkCut.body.velocity.y = -800;
// Et dans un second temps, il retombe
trunkCut.body.gravity.y = 2000;
// On ajoute une animation de rotation sur le morceau de bois coupé
game.add.tween(trunkCut).to({angle: trunkCut.angle + angle}, 1000, Phaser.Easing.Linear.None,true);
// On empêche une nouvelle coupe
this.canCut = false;
var self = this;
// Pour chaque morceau (troncs et branches) encore présent sur l'arbre, on lui ajoute une animation de chute.
// Donne l'impression que tout l'arbre tombe pour boucher le trou laissé par le morceau coupé.
this.tree.forEach(function(trunk) {
var tween = game.add.tween(trunk).to({y: trunk.y + self.HEIGHT_TRUNK}, 100, Phaser.Easing.Linear.None,true);
tween.onComplete.add(function() {
// Une fois que l'arbre à fini son animation, on redonne la possibilité de couper au personnage
self.canCut = true;
}, self);
});
},
...
};
main.js - Découpage de l'arbre
Comme vous pouvez le voir, le découpage de l’arbre se fait en plusieurs étapes :
- S’il y a possibilité de couper, on appelle la fonction
cutTrunk()
quand le joueur clique ou appui sur une touche directionnelle
- Une fois dans
cutTrunk()
, on ajoute directement un nouveau morceau à l’arbre pour anticiper sur celui qui va être coupé
- On crée une copie du tronc qui va être coupé. En fait, cette copie va nous permettre de donner l’impression que le bout de bois coupé est éjecté de l’arbre
- On ajoute de la
velocity
(vitesse),de la gravity
(gravité) au body
de notre copie afin de la faire voler hors de l’arbre. On lui ajoute aussi une animation sur sa propriété angle
afin de lui faire subir une rotation. Cette animation est gérée grâce à la fonction game.add.tween(sprite à animer).to(propriétés à animer, durée, fonction ease, autostart)
- Avant que le haut de l’arbre ne retombe pour combler le trou fait par le morceau coupé, nous empêchons une nouvelle coupe du joueur grâce à
this.canCut = false
. On ajoute ensuite une animation sur chaque morceaux encore présents sur l’arbre pour les faire tomber sur la souche
- Une fois cette animation terminée, on redonne la possibilité au joueur de couper un autre morceau
Le score
Comptabilisation des points
Il est très facile de mettre à jour le score du joueur. En effet, il suffit juste de l’incrémenter à chaque fois que le personnage coupe un morceau de l’arbre :
gameState.main.prototype = {
create: function() {
...
// ---- SCORE
this.currentScore = 0;
},
...
cutTrunk: function() {
// On incrémente le score
this.increaseScore();
...
},
...
increaseScore: function() {
this.currentScore++;
}
};
main.js - Augmentation du score
Affichage du score
Il va maintenant falloir gérer l’image qui va nous permettre d’afficher le score.
Comme pour les animations du personnage, l’accès aux différents chiffres de cette image va être géré dans un fichier JSON :
{"frames": [
{
"name": "number00",
"frame": {"x":5,"y":5,"w":66,"h":91}
},
{
"name": "number01",
"frame": {"x":81,"y":5,"w":50,"h":91}
},
{
"name": "number02",
"frame": {"x":141,"y":5,"w":66,"h":91}
},
{
"name": "number03",
"frame": {"x":217,"y":5,"w":66,"h":91}
},
{
"name": "number04",
"frame": {"x":293,"y":5,"w":66,"h":91}
},
{
"name": "number05",
"frame": {"x":369,"y":5,"w":66,"h":91}
},
{
"name": "number06",
"frame": {"x":445,"y":5,"w":66,"h":91}
},
{
"name": "number07",
"frame": {"x":521,"y":5,"w":66,"h":91}
},
{
"name": "number08",
"frame": {"x":597,"y":5,"w":66,"h":91}
},
{
"name": "number09",
"frame": {"x":673,"y":5,"w":66,"h":91}
}
]}
numbers.json - Gestion des chiffres
Encore une fois, l’affichage de ces différents chiffres se fera de la même façon que celui des différentes animations du personnage :
gameState.load.prototype = {
preload: function() {
...
// Chiffres pour le score
game.load.atlas('numbers', 'img/numbers.png', 'data/numbers.json');
},
create: function() {
game.state.start('main');
}
};
gameState.main.prototype = {
create: function() {
...
// ---- SCORE
this.currentScore = 0;
// On crée le sprite du score
var spriteScoreNumber = game.add.sprite(game.width / 2, 440, 'numbers');
// On affiche le score à 0 en ajoutant le JSON "number" aux animations de "spriteScoreNumber"
spriteScoreNumber.animations.add('number');
spriteScoreNumber.animations.frame = this.currentScore;
// On centre le score
spriteScoreNumber.x -= spriteScoreNumber.width / 2;
// "this.spritesScoreNumbers" va contenir les sprites des chiffres qui composent le score
this.spritesScoreNumbers = new Array();
this.spritesScoreNumbers.push(spriteScoreNumber);
},
...
cutTrunk: function() {
// On incrémente le score
this.increaseScore();
...
},
...
increaseScore: function() {
this.currentScore++;
// On "kill" chaque sprite (chaque chiffre) qui compose le score
for(var j = 0; j < this.spritesScoreNumbers.length; j++)
this.spritesScoreNumbers[j].kill();
this.spritesScoreNumbers = new Array();
// On recrée les sprites qui vont composer le score
this.spritesScoreNumbers = this.createSpritesNumbers(this.currentScore, 'numbers', 440, 1);
},
createSpritesNumbers: function(number /* Nombre à créer en sprite */, imgRef /* Image à utiliser pour créer le score */, posY, alpha) {
// on découpe le nombre en chiffres individuels
var digits = number.toString().split('');
var widthNumbers = 0;
var arraySpritesNumbers = new Array();
// on met en forme le nombre avec les sprites
for(var i = 0; i < digits.length; i++) {
var spaceBetweenNumbers = 0;
if(i > 0)
spaceBetweenNumbers = 5;
var spriteNumber = game.add.sprite(widthNumbers + spaceBetweenNumbers, posY, imgRef);
spriteNumber.alpha = alpha;
// On ajoute le JSON des nombres dans l'animation de "spriteNumber"
spriteNumber.animations.add('number');
// On sélection la frame n° "digits[i]" dans le JSON
spriteNumber.animations.frame = +digits[i];
arraySpritesNumbers.push(spriteNumber);
// On calcule la width totale du sprite du score
widthNumbers += spriteNumber.width + spaceBetweenNumbers;
}
// On ajoute les sprites du score dans le groupe "numbersGroup" afin de centrer le tout
var numbersGroup = game.add.group();
for(var i = 0; i < arraySpritesNumbers.length; i++)
numbersGroup.add(arraySpritesNumbers[i]);
// On centre horizontalement
numbersGroup.x = game.width / 2 - numbersGroup.width / 2;
return arraySpritesNumbers;
}
};
main.js - Affichage du score
Afin de créer plus facilement un nombre avec des sprites, nous avons créé la fonction createSpritesNumbers(Nombre à créer en sprite, Image à utiliser pour créer le sprite, Position y, transparence)
. L’astuce ici est de découper le nombre passé en paramètre en chiffres individuels. Ces chiffres seront alors traités séparément afin d’en faire des sprites. Au final, ces sprites seront assemblés pour afficher un nombre à l’écran.
La gestion des niveaux et du temps
Les différents niveaux
Au début de la partie, le niveau du jeu est à 1. Ce dernier augmente au fur et à mesure que le joueur accumule des points, tous les 20 points pour être précis.
gameState.load.prototype = {
preload: function() {
...
// Niveaux
game.load.atlas('levelNumbers', 'img/levelNumbers.png', 'data/numbers.json');
game.load.image('level', 'img/level.png');
},
create: function() {
game.state.start('main');
}
};
gameState.main.prototype = {
create: function() {
...
// ---- NIVEAU
// Niveau de départ
this.currentLevel = 1;
var levelPosY = 290;
// Sprite "Level"
this.intituleLevel = game.add.sprite(0, levelPosY, 'level');
this.intituleLevel.alpha = 0;
// Sprite "Numéro du level"
var spriteLevelNumber = game.add.sprite(0, levelPosY, 'levelNumbers');
spriteLevelNumber.alpha = 0;
// On change l'animation du sprite pour chosir le sprite du niveau actuel (ici, niveau 1)
spriteLevelNumber.animations.add('number');
spriteLevelNumber.animations.frame = this.currentLevel;
this.spritesLevelNumbers = new Array();
this.spritesLevelNumbers.push(spriteLevelNumber);
},
...
increaseScore: function() {
this.currentScore++;
// Tous les 20 points, on augmente le niveau
if(this.currentScore % 20 == 0)
this.increaseLevel();
...
},
increaseLevel: function() {
// On incrémente le niveau actuel
this.currentLevel++;
// On "kill" chaque sprite (chaque chiffre) du numéro du précédent niveau
for(var j = 0; j < this.spritesLevelNumbers.length; j++)
this.spritesLevelNumbers[j].kill();
this.spritesLevelNumbers = new Array();
// On crée les sprites (sprites des chiffres) du niveau actuel
this.spritesLevelNumbers = this.createSpritesNumbers(this.currentLevel, 'levelNumbers', this.intituleLevel.y, 0);
// On positionne le numéro du niveau (composé de différents sprites) derrière le sprite "level"
this.intituleLevel.x = 0;
for(var i = 0; i < this.spritesLevelNumbers.length; i++) {
if(i == 0)
this.spritesLevelNumbers[i].x = this.intituleLevel.width + 20;
else
this.spritesLevelNumbers[i].x = this.intituleLevel.width + 20 + this.spritesLevelNumbers[i - 1].width;
}
// On ajoute le tout à un groupe afin de tout centrer
var levelGroup = game.add.group();
levelGroup.add(this.intituleLevel);
for(var i = 0; i < this.spritesLevelNumbers.length; i++)
levelGroup.add(this.spritesLevelNumbers[i]);
levelGroup.x = game.width / 2 - levelGroup.width / 2;
// On fait apparaître le sprite "level" et le numéro du niveau en même temps
for(var i = 0; i < this.spritesLevelNumbers.length; i++) {
game.add.tween(this.spritesLevelNumbers[i]).to({alpha: 1}, 300, Phaser.Easing.Linear.None,true);
}
game.add.tween(this.intituleLevel).to({alpha: 1}, 300, Phaser.Easing.Linear.None,true);
// On fait disparaître le tout au bout de 1.5 secondes
var self = this;
setTimeout(function() {
for(var i = 0; i < self.spritesLevelNumbers.length; i++) {
game.add.tween(self.spritesLevelNumbers[i]).to({alpha: 0}, 300, Phaser.Easing.Linear.None,true);
}
game.add.tween(self.intituleLevel).to({alpha: 0}, 300, Phaser.Easing.Linear.None,true);
}, 1500);
}
};
main.js - Gestion des niveaux
A chaque fois que le joueur atteint un score multiple de 20, le numéro du niveau s’incrémente et s’affiche à l’écran. Pour atteindre ce résultat, nous utilisons 2 images :
- L’image qui contient le mot « Level »
- L’image qui contient les numéros du niveau
La gestion du temps
Dans Timberman, le joueur doit couper le maximum de troncs dans un temps imparti. Il se trouvera sous la forme d’une barre de temps qui diminuera continuellement durant la partie. Voici les 2 situations qui peuvent affecter ce timer :
- Il augmente légèrement à chaque fois que le joueur coupe un morceau de l’arbre
- Plus le niveau est élevé, plus il diminue rapidement
gameState.load.prototype = {
preload: function() {
...
// Temps
game.load.image('timeContainer', 'img/time-container.png');
game.load.image('timeBar', 'img/time-bar.png');
},
create: function() {
game.state.start('main');
}
};
gameState.main.prototype = {
create: function() {
...
// ---- BARRE DE TEMPS
// Container
this.timeContainer = game.add.sprite(0, 100, 'timeContainer');
// On le centre
this.timeContainer.x = game.width / 2 - this.timeContainer.width / 2;
// Barre
this.timeBar = game.add.sprite(0, 130, 'timeBar');
// On la centre
this.timeBar.x = game.width / 2 - this.timeBar.width / 2;
this.timeBarWidth = this.timeBar.width / 2;
this.timeBarWidthComplete = this.timeBar.width;
// On crop la barre pour la diminuer de moitié
var cropRect = new Phaser.Rectangle(0, 0, this.timeBarWidth, this.timeBar.height);
this.timeBar.crop(cropRect);
this.timeBar.updateCrop();
},
update: function() {
...
// Si la partie a débuté (première action du joueur)
if(GAME_START) {
// Mise à jour de la barre de temps
if(this.timeBarWidth > 0) {
// On diminue la barre de temps en fonction du niveau
this.timeBarWidth -= (0.6 + 0.1 * this.currentLevel);
var cropRect = new Phaser.Rectangle(0, 0, this.timeBarWidth, this.timeBar.height);
this.timeBar.crop(cropRect);
this.timeBar.updateCrop();
}
}
},
...
increaseScore: function() {
...
// On ajoute un peu de temps supplémentaire
if(this.timeBarWidth + 12 * ratio < this.timeBarWidthComplete)
this.timeBarWidth += 12 * ratio;
else
this.timeBarWidth = this.timeBarWidthComplete;
},
...
};
main.js - Gestion du temps
Le gestion du GAME OVER
Jusqu’à maintenant, nous avons parlé de toutes les mécaniques de jeu sauf des événements qui peuvent déclencher la fin d’une partie. Le Game Over se produit dans 2 cas :
- Le personnage heurte une branche
- Il l’a heurtée en changeant de côté de l’arbre
- Il l’a heurtée car elle se trouvait sur le tronc juste au-dessus de celui qu’il venait de couper
- Il ne reste plus de temps (la barre de temps est descendue à 0)
La fin d’une partie aboutie au même résultat : la disparition du personnage et l’apparition d’une pierre tombale.
gameState.load.prototype = {
preload: function() {
...
// tombe rip
game.load.image('rip', 'img/rip.png');
},
create: function() {
game.state.start('main');
}
};
gameState.main.prototype = {
...
update: function() {
// Si le partie a débuté (première action du joueur)
if(GAME_START) {
// S'il reste du temps, mise à jour de la barre de temps
if(this.timeBarWidth > 0) {
// On diminue la barre de temps en fonction du niveau
this.timeBarWidth -= (0.6 + 0.1 * this.currentLevel);
var cropRect = new Phaser.Rectangle(0, 0, this.timeBarWidth, this.timeBar.height);
this.timeBar.crop(cropRect);
this.timeBar.updateCrop();
// Sinon, le personnage meurt
} else {
this.death();
}
}
...
},
listener: function(action) {
if(this.canCut) {
...
// Nom du tronc à couper
var nameTrunkToCut = this.tree.getAt(0).key;
// Nom du tronc qui se trouve juste au-dessus du tronc "nameTrunkToCut"
var nameTrunkJustAfter = this.tree.getAt(1).key;
// Si le personnage heurte une branche alors qu'il vient de changer de côté
if(nameTrunkToCut == 'branchLeft' && this.manPosition == 'left' || nameTrunkToCut == 'branchRight' && this.manPosition == 'right') {
// Game Over
this.death();
// Si tout va bien, le personnage coupe le tronc
} else {
this.man.animations.stop('breath', true);
// On fait démarrer l'animation, avec 3 images par seconde
var animationCut = this.man.animations.play('cut', 15);
animationCut.onComplete.add(function() {
this.man.animations.play('breath', 3, true);
}, this);
this.cutTrunk();
// Une fois le tronc coupé, on vérifie si le tronc qui retombe n'est pas une branche qui pourrait heurter le personnage
if(nameTrunkJustAfter == 'branchLeft' && this.manPosition == 'left' || nameTrunkJustAfter == 'branchRight' && this.manPosition == 'right') {
// Game Over
this.death();
}
}
}
},
...
death: function() {
// On empêche toute action du joueur
GAME_START = false;
GAME_OVER = true;
this.canCut = false;
game.input.onDown.removeAll();
var self = this;
// On fait disparaître le personnage
var ripTween = game.add.tween(this.man).to({alpha: 0}, 300, Phaser.Easing.Linear.None,true);
// Une fois la disparition complète
ripTween.onComplete.add(function() {
// On fait apparaître la tombe à la place du personnage
self.rip = game.add.sprite(0, 0, 'rip');
self.rip.alpha = 0;
game.add.tween(self.rip).to({alpha: 1}, 300, Phaser.Easing.Linear.None,true);
self.rip.x = (this.manPosition == 'left') ? (this.man.x + 50) : (this.man.x + 200);
self.rip.y = this.man.y + this.man.height - self.rip.height;
// Après 1 seconde, on fait appel à la fonction "gameFinish()"
setTimeout(function() {self.gameFinish()}, 1000);
}, this);
},
gameFinish: function() {
// On redémarre la partie
GAME_START = false;
GAME_OVER = false;
game.state.start('main');
}
};
main.js - Mort du personnage
La dernière étape : l’ajout des sons
Nous arrivons enfin presque au bout de ce tutoriel ! La dernière étape consiste à gérer les sons de notre jeu. Nous allons y intégrer 3 sons différents : le son que fait le personnage lorsque qu’il donne un coup de hache, le son lorsqu’il meurt et la musique de fond.
gameState.load.prototype = {
preload: function() {
...
/**** SONS *****/
// Coup de hache
game.load.audio('soundCut', ['sons/cut.ogg']);
// Musique de fond
game.load.audio('soundTheme', ['sons/theme.ogg']);
// Mort du personnage
game.load.audio('soundDeath', ['sons/death.ogg']);
},
create: function() {
game.state.start('main');
}
};
gameState.main.prototype = {
create: function() {
...
// ---- SONS
this.soundCut = game.add.audio('soundCut', 1);
this.soundTheme = game.add.audio('soundTheme', 0.5, true);
this.soundDeath = game.add.audio('soundDeath', 1);
},
...
listener: function(action) {
if(this.canCut) {
// La première action de l'utilisateur déclenche le début de partie
if(!GAME_START) {
GAME_START = true;
// On active la musique de fond
this.soundTheme.play();
}
...
}
},
cutTrunk: function() {
// On active le son de hache contre le bois
this.soundCut.play();
...
},
...
death: function() {
// On joue le son de la mort du personnage
this.soundDeath.play();
// Et on stop la musique de fond
this.soundTheme.stop();
...
},
...
};
main.js - Gestion des sons
Ici, nous utilisons 4 nouvelles méthodes propres à Phaser :
- La méthode
game.load.audio(key, [fichier(s) audio(s)])
. Permet de charger un fichier audio
- La méthode
game.add.audio(key du son chargé précédemment, volume, repeat)
. Permet d’ajouter un son à notre jeu
- La méthode
Phaser.Sound.play()
. Permet de lancer un son
- La méthode
Phaser.Sound.stop()
. Permet de stopper un son
Voilà maintenant ce tutoriel terminé ! J’espère qu’il vous aura appris où tout simplement aidé à utiliser Phaser. N’hésitez pas à nous laisser des commentaires si vous avez des remarques ou des questions à nous poser ! =)
Ci-dessous, la démo et les sources finales de ce tutoriel :