Bienvenue sur le blog de l'agence Soluka !

Retrouvez des articles dédiés aux nouveautés dans le développement web, rédigés par des passionnés, ainsi que les dernières actualités de l'agence.

Recherche

Articles populaires

Jeux Phaser

29/09/14

Phaser 2 : créer un Timberman en HTML5 Canvas

par Kevin le 29 septembre 2014
Image Phaser 2 : créer un Timberman en HTML5 Canvas

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 »

Image timberman

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).

Groupe de troncs

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.

Chiffres pour 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"

Titre level

  • L'image qui contient les numéros du niveau

Chiffres pour les niveaux

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 :

  • Greg

    Bonjour

    Merci pour votre retour j’ai regardé cela en profondeur cette nuit cela venait du drivers opengl de mon pc.

    Par contre je suis confronté a un autre petit soucis le son ne semble pas fonctionner sous ios.

    Avez-vous une explication a ce petit soucis.

    Bonne journée.

    • Ok je vois =D

      Mais le conseil que je t’ai donné est quand même intéressant à suivre car tu pourras voir que l’animation en devient beaucoup plus fluide =)

      Je n’ai par contre pas de solution pour le son.. En effet, cela est directement lié aux fonctionnalités du Framework Phaser. Peut-être que cela sera corrigé dans les prochaines versions !

      Bonne journée

  • Salut Greg,

    Merci pour le compliment =)

    Je pense savoir d’où viennent les problèmes de performances.. En effet, au début du tutoriel je conseille d’utiliser les variables ci-dessous pour définir la taille du jeu (en se basant sur la taille d’écran d’un nexus sans la barre de navigation):

    const width = 1080;
    const height = 1775;

    Le même conseil a été donné dans le premier tuto, celui sur Flappy Bird (http://www.soluka.fr/blog/jeux/phaser-creer-flappy-bird-en-html5-canvas) et cela ne posait pas de problème.
    La différence est que dans notre exemple Timberman, nous utilisons la version 2 de Phaser, qui comporte beaucoup plus de fonctionnalités et qui est donc plus gourmande que la version 1.

    Du coup, avec la version 2 de Phaser, ces dimensions (width et height) sont beaucoup trop importantes et impliquent sûrement plus de traitements de la part du Framework.

    Voici donc mes conseils pour réduire ces problèmes de performances :
    – Réduit de moitié les valeurs « width » et « height »
    – Réduit de moitié la taille de toutes tes images
    – Modifie les fichiers data/man.json et data/numbers.json en conséquence de la réduction de tes images

    Ex :
    **** Avant :
    {« frames »: [
    {
    « name »: « breath01 »,
    « frame »: {« x »:1059, »y »:5, »w »:517, »h »:403}
    }….

    **** Après :
    {« frames »: [
    {
    « name »: « breath01 »,
    « frame »: {« x »:529.5, »y »:2.5, »w »:258.5, »h »:201.5}
    }….

    Si tu as le temps, tu peux tester et me dire si ça t’as aidé ! =)

  • greg

    Bonjour,

    Super tuto. Par contre j’ai rencontré un petit soucis. Le jeu consomme pas mal de CPU (1 coeur à 100%). As tu une astuce pour éviter cela?

    Merci pour ce tuto vraiment interressant.

    • Salut Greg,

      Merci pour le compliment =)

      Je pense savoir d’où viennent les problèmes de performances.. En effet, au début du tutoriel je conseille d’utiliser les variables ci-dessous pour définir la taille du jeu (en se basant sur la taille d’écran d’un nexus sans la barre de navigation):

      const width = 1080;
      const height = 1775;

      Le même conseil a été donné dans le premier tuto, celui sur Flappy Bird (http://www.soluka.fr/blog/jeux… et cela ne posait pas de problème.
      La différence est que dans notre exemple Timberman, nous utilisons la version 2 de Phaser, qui comporte beaucoup plus de fonctionnalités et qui est donc plus gourmande que la version 1.

      Du coup, avec la version 2 de Phaser, ces dimensions (width et height) sont beaucoup trop importantes et impliquent sûrement plus de traitements de la part du Framework.

      Voici donc mes conseils pour réduire ces problèmes de performances :
      – Réduit de moitié les valeurs « width » et « height »
      – Réduit de moitié la taille de toutes tes images
      – Modifie les fichiers data/man.json et data/numbers.json en conséquence de la réduction de tes images

      Ex :
      **** Avant :
      {« frames »: [
      {
      « name »: « breath01 »,
      « frame »: {« x »:1059, »y »:5, »w »:517, »h »:403}
      }….

      **** Après :
      {« frames »: [
      {
      « name »: « breath01 »,
      « frame »: {« x »:529.5, »y »:2.5, »w »:258.5, »h »:201.5}
      }….

      Si tu as le temps, tu peux tester et me dire si ça t’as aidé ! =)

  • Alain

    Merci pour ce tutoriel vraiment bien fait !

  • mornaloce

    Merci beaucoup pour ce tuto sympathique ! J’ai un petit soucis au niveau du resizing et de la taille de base du canvas en revanche.

    • Bonjour mornaloce !
      Merci ça fait plaisir =)

      Quel est votre problème exactement avec le resize du canvas ?

      • mornaloce

        Le resize fonctionne quand je rétréci la fenêtre mais quand je l’agrandi rien ne se passe. De plus la taille du canvas ne se met pas à la taille de la fenêtre au premier chargement de la page (je suis obligé de scroller pour avoir le canvas en entier).

      • Puis-je avoir accès à votre code ? Si vos sources ne se trouvent pas en ligne, envoyez-les moi si possible à l’adresse suivante : kevin.brivois@soluka.fr

      • Je pense savoir d’où ça vient. Avez-vous télécharger la dernière version de phaser (2.2) ? Si c’est le cas, le resize ne fonctionne effectivement plus pareil. J’essaye de résoudre le problème et reviens vers vous pour vous donner la solution à votre problème =)

      • Désolé de revenir vers vous aussi tard, mais c’est bon, j’ai enfin trouvé la solution ! =)

        Il suffit de remplacer tout ça :

        game.scale.setShowAll();
        window.addEventListener(‘resize’, function () {
        game.scale.refresh();
        });
        game.scale.refresh();

        par cette simple ligne :

        game.scale.parentIsWindow = true;

        Essayez et dites-moi si ça marche pour vous =)

  • yassine

    Merci pour le tuorial.

  • Makota Taras

    very good!) thx!

  • tangzero

    Great tutorial.
    Can I use these assets in my own project? Are CC?

    • Hi tangzero ! We extracted each image and each sound without asking creators, so these assets aren’t CC, sorry =(