Tutorial : Gestion des scenes d’un jeu, des transitions et de la mise à l’echelle avec Pixi.js
La plupart des tutorials sur le développement de jeux en HTML5 expliquent surtout comment développer la mécanique du jeu, dessiner des choses à l’écran …. or une grande partie du travail quand on développe un jeu réside dans la gestion des scènes, des menus, des transitions …etc
dans ce tutorial, je vais vous expliquer comment gérer les scènes d’un jeu, mettre une scène en pause et la reprendre, ainsi que la mise à l’échelle de l’écran pour que votre jeu soit compatible avec différentes résolution ; très important si vous ciblez des plateformes mobiles
//Le but de ce tutorial est d’obtenir ce résultat //
Introduction
Je vais utiliser l’excellent Pixi.js : un moteur de rendu HTML5 très puissant , il permet entre autres de détécter automatiquement le support WebGL et l’utilise pour augmenter les performances du rendu, à défaut il utilisera un rendu Canvas 2D classique.
Comme langage de programmation je vais utiliser TypeScript , et Visual Studio 2012 for Web comme IDE.
Pour exploiter pleinement la puissance de VisualStudio et TypeScript avec Pixi je vous recommande d’utiliser le fichier de définitions suivant : pixi.d.ts
Voici comment j’ai organisé mon code
à noter que ceci représente la structure finale
Le dossier engine contient les classes communes
Le dossier game contient les classes spécifiques
le dossier Lib contient les librairies tierses : ici on a placé Pixi.js avec son fichier de définition pixi.d.js
Les scenes
La classe Scene
nous allons commencer par créer une classe Scene.
un objet Scene est en fait un objet PIXI.Stage qu’on va étendre pour rajouter la possibilité de mettre en pause, de le reprendre et de le mettre à jour
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
///<reference path="../lib/pixi.d.ts" /> // Module module tuto.Ezelia { export class Scene extends PIXI.Stage { private paused: bool = false; private updateCB = function () { }; constructor(background:number) { super(background); } public onUpdate(updateCB: () => void ) { this.updateCB = updateCB; } public update() { this.updateCB(); } public pause() { this.paused = true; } public resume() { this.paused = false; } public isPaused() { return this.paused; } } } |
Notez comment nous avons étendu l’objet PIXI.Stage en utilisant une syntax TypeScript même si la librairie Pixi n’a pas été développée à la base en TypeScript
la méthode onUpdate() va permettre d’enregistrer un callback qui nous permettera de mettre à jour l’état des objets du jeu. (nous allons voir plus tard que nous pouvons utiliser une autre méthode plus propre qui consiste à étendre la méthode Update dans une classe fille).
La classe ScenesManager
Pour gérer les scènes nous allons créer la classe ScenesManager
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
///<reference path="../lib/pixi.d.ts" /> ///<reference path="Scene.class.ts" /> // Module module tuto.Ezelia { export class ScenesManager { private static scenes: any = {}; // should be hashmap but a JS object is fine too :) public static currentScene: Scene; public static renderer: PIXI.IRenderer; public static create(width: number, height: number) { if (ScenesManager.renderer) return this; ScenesManager.renderer = PIXI.autoDetectRenderer(width, height); document.body.appendChild(ScenesManager.renderer.view); requestAnimFrame(ScenesManager.loop); return this; } private static loop() { requestAnimFrame(function () { ScenesManager.loop() }); if (!currentScene || currentScene.isPaused()) return; currentScene.update(); ScenesManager.renderer.render(currentScene); } } //# next code block } |
Un jeu n’est sensé disposer d’un seul ScenesManager, c’est lui qui va gérer l’ensemble des scènes et des transition, c’est pour celà que nous déclarons toutes ses méthodes comme statiques.
La méthode create() va initialiser un objet Renderer de Pixi et démarrer une boucle infinie immédiatement (gameloop).
La méthode loop() va vérifier qu’une scène courante existe et qu’elle n’est pas en pause,si c’est le cas, elle va mettre à jour son état via update() puis la dessiner via render().
Ensuite nous ajoutons deux méthodes au ScenesManager qui nous permettent de créer une scène et de passer d’une scène à une autre.
ce code remplace la ligne « //# next code block » dans le code précédent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public static createScene(id: string): Scene { if (ScenesManager.scenes[id]) return undefined; var scene = new Scene(); ScenesManager.scenes[id] = scene; return scene; } public static goToScene(id: string): bool { if (ScenesManager.scenes[id]) { if (ScenesManager.currentScene) ScenesManager.currentScene.pause(); ScenesManager.currentScene = ScenesManager.scenes[id]; ScenesManager.currentScene.resume(); return true; } return false; } |
Les noms sont explicites je pense et ne nécessite pas plus de précisions que ça 😉
notez que la méthode goToScene() va mettre en pause la scène courant avant de basculer vers une autre scène, ainsi nous préservons la cohérence de l’état de la précédente scène.
Testons notre code 🙂
pour cela on va créer un fichier HTML avec des références vers Pixi.js, ScenesManager.class.js and Scene.class.js
1 2 3 |
<script src="lib/pixi.dev.js"></script> <script src="engine/ScenesManager.class.js"></script> <script src="engine/Scene.class.js"></script> |
puis nous ajoutons ce script </body>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<script> //get reference of ScenesManager; var scenesManager = tuto.Ezelia.ScenesManager; //create scenesManager.create(300, 400); //create a the game scene var game = scenesManager.createScene('game'); //add a bunny :) var bunny = PIXI.Sprite.fromImage("img/bunny.png"); // center the sprites anchor point bunny.anchor.x = 0.5; bunny.anchor.y = 0.5; // move the sprite t the center of the screen bunny.position.x = 50; bunny.position.y = 50; game.addChild(bunny); //switch to 'game' Scene scenesManager.goToScene('game'); //register update event game.onUpdate(function () { bunny.rotation += 0.1; }); </script> |
et voici le résultat:
Basculer d’une Scène à l’autre
vous avez noté que nous avons ajouté la logique de notre jeu (le petit lapin en rotation) dans le callback d’une instance Scène, dans la vrai vie, quand on a des centaines (milliers?) de lignes de code cette approche risque de rendre le code illisible (surtout qu’il faudra par la suite ajouter aussi du code pour gérer les menus et les interactions).
pour faire les choses plus proprement nous allons créer des classes filles de la classe Scene qui contiendront le code spécifique à chaque scène. nous allons aussi modifier notre ScenesManager pour lui permettre d’instancier des Scènes de différents types.
Dans ScenesManager la méthode createScene devient :
1 2 3 4 5 6 7 |
public static createScene(id: string, TScene: new () => Scene = Scene): Scene { if (ScenesManager.scenes[id]) return undefined; var scene = new TScene(); ScenesManager.scenes[id] = scene; return scene; } |
ici nous avons remplacer la création d’un objet Scene « new Scene() » par une une instanciation générique « new TScene() »
TScene est déclaré ainsi : « TScene: new () => Scene = Scene », cette syntaxe un peu bizarre indique simplement à TypeScript d’accépter comme argument des Types héritant la classe Scene.
on peut obtenir le même résultat avec cette syntaxe plus simple :
public static createScene(id: string, TScene:any): Scene {…}
mais avec cette syntaxe nous perdons la vérification des types d’objets qu’effectue TypeScript à la compilation..
Maintenant nous allons créer une classe GameScene qui hérite de Scene et implémente le code du jeu.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
///<reference path="engine/Scene.class.ts" /> ///<reference path="lib/pixi.d.ts" /> module tuto.Ezelia { // Class export class GameScene extends Scene { private bunny: PIXI.Sprite; constructor() { super(); //add a bunny :) this.bunny = PIXI.Sprite.fromImage("img/bunny.png"); // center the sprites anchor point this.bunny.anchor.x = 0.5; this.bunny.anchor.y = 0.5; // move the sprite t the center of the screen this.bunny.position.x = 50; this.bunny.position.y = 50; this.addChild(this.bunny); } public update() { super.update(); this.bunny.rotation += 0.1; } } } |
et notre script dans le fichier HTML de test devient
1 2 3 4 5 6 7 8 9 10 11 12 13 |
<script> //get reference of ScenesManager; var scenesManager = tuto.Ezelia.ScenesManager; //create scenesManager.create(300, 400); //create a the game scene var game = scenesManager.createScene('game', tuto.Ezelia.GameScene); var blank = scenesManager.createScene('blank'); scenesManager.goToScene('game'); </script> |
comme vous voyez, nous obtenons le même résultat visuel que précédemment sauf que cette fois ci, toute la logique du jeu est cachée dans une classe spécifique
Intro, Menu et transitions
Nous allons maintenant créer deux nouvelles Scenes pour afficher un logo avec un effet de fadeIn (vous pouvez utiliser cette scène pour précharger les ressources par exemple) puis un bouton cliquable permettant de lancer le jeu.
Le jeu va aussi contenir un bouton cliquable permettant de revenir au menu.
Scene d’intro
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
///<reference path="../engine/ScenesManager.class.ts" /> ///<reference path="../engine/Scene.class.ts" /> ///<reference path="../lib/pixi.d.ts" /> module tuto.Ezelia { // Class export class IntroScene extends Scene { private logo: PIXI.Sprite; constructor() { super(); this.setBackgroundColor(0xffffff); this.logo = PIXI.Sprite.fromImage("img/logo.png"); this.addChild(this.logo); this.logo.scale.x = ScenesManager.defaultWidth/250; this.logo.scale.y = this.logo.scale.x; this.logo.anchor.x = 0.5; this.logo.anchor.y = 0.5; this.logo.alpha = 0; // move the sprite to the center of the screen this.logo.position.x = ScenesManager.defaultWidth / 2; this.logo.position.y = ScenesManager.defaultHeight /2; } public update() { super.update(); if (this.logo.alpha < 1) this.logo.alpha += 0.01; else ScenesManager.goToScene('menu'); } } } |
Le menu
La plupart du code que j’utilise ci-dessous n’est qu’une reprise de cet exemple de code pour pixi , j’ai juste ajouté un contrôle permettant de ne pas prendre en compte les événements (clique …etc) quand le menu n’est pas la scène courante (donc invisible)
en effet, au moment ou j’écrivais ce tutorial, il semble que Pixi traite lees événements rattachés à des objets invisibles, j’ai ajouté ce petit controle if (_this.isPaused()) return; au début de chaque fonction traitant un événement.
il y a un correctif en cours sur le repository github de pixi une fois implémenté il ne sera plus nécessaire d’ajouter ce test .
rien d’extraordinaire 🙂 assurez vous juste que vous aves bien fait appel à setInteractive(true) pour le bouton et la scène qui le contient sinon les événements ne seront pas traités.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
///<reference path="../engine/ScenesManager.class.ts" /> ///<reference path="../engine/Scene.class.ts" /> ///<reference path="../lib/pixi.d.ts" /> module tuto.Ezelia { // Class export class MenuScene extends Scene { private button: PIXI.Sprite; private textureButton: PIXI.Texture; private textureButtonDown: PIXI.Texture; private textureButtonOver: PIXI.Texture; constructor() { super(); this.setBackgroundColor(0xffffff); this.textureButton = PIXI.Texture.fromImage("img/button.png"); this.textureButtonDown = PIXI.Texture.fromImage("img/buttonDown.png"); this.textureButtonOver = PIXI.Texture.fromImage("img/buttonOver.png"); this.button = new PIXI.Sprite(this.textureButton); // Scaling and positionning this.button.scale.x = ScenesManager.defaultWidth / 400; this.button.scale.y = this.button.scale.x; this.button.anchor.x = 0.5; this.button.anchor.y = 0.5; // move the sprite to the center of the screen this.button.position.x = ScenesManager.defaultWidth / 2; this.button.position.y = ScenesManager.defaultHeight / 2; // make the button interactive.. this.button.setInteractive(true); this._registerEvents(); this.addChild(this.button); this.setInteractive(true); } private _registerEvents() { var _this = this; // set the mousedown and touchstart callback.. this.button.mousedown = this.button.touchstart = function (data) { if (_this.isPaused()) return; this.isdown = true; this.setTexture(_this.textureButtonDown); this.alpha = 1; } // set the mouseup and touchend callback.. this.button.mouseup = this.button.touchend = function (data) { if (_this.isPaused()) return; this.isdown = false; if (this.isOver) { this.setTexture(_this.textureButtonOver); } else { this.setTexture(_this.textureButton); } } // set the mouseover callback.. this.button.mouseover = function (data) { if (_this.isPaused()) return; this.isOver = true; if (this.isdown) return; this.setTexture(_this.textureButtonOver) } // set the mouseout callback.. this.button.mouseout = function (data) { if (_this.isPaused()) return; this.isOver = false; if (this.isdown) return this.setTexture(_this.textureButton) } this.button.click = this.button.tap = function (data) { if (_this.isPaused()) return; ScenesManager.goToScene('game'); } } } } |
Accès au menu depuis le jeu
nous allons modifier GameScene pour ajouter un bouton qui permet d’aller au menu.
on ajoute ces ligne à la fin du constructeur de GameScene
1 2 3 4 5 6 7 8 9 10 11 12 13 |
"] var _this = this; var button = new PIXI.Sprite(PIXI.Texture.fromImage("img/button.png")); button.position.x = ScenesManager.defaultWidth - 200; button.scale.x = 0.5; button.scale.y = 0.5; button.click = button.tap = function (data) { if (_this.isPaused()) return; ScenesManager.goToScene('menu'); } button.setInteractive(true); this.addChild(button); this.setInteractive(true); |
et pour finir nous créons les nouvelles scènes dans notre script de test
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<script> //get reference of ScenesManager; var scenesManager = tuto.Ezelia.ScenesManager; //note the scale parameter is set to true scenesManager.create(320, 480); //create a the game scene var game = scenesManager.createScene('game', tuto.Ezelia.GameScene); var intro = scenesManager.createScene('intro', tuto.Ezelia.IntroScene); var menu = scenesManager.createScene('menu', tuto.Ezelia.MenuScene); scenesManager.goToScene('intro'); </script> |
Cliquez ici pour voir le résultat
Mise à l’échelle de l’affichage
maintenant que nous avons un système de transition opérationnel ainsi que des éléments interactifs pour aller d’une scène à l’autre ; nous allons voir comment redimensionner l’affichage de notre jeu afin qu’il exploite toujours au maximum la taille de l’écran disponible tout en préservant le bon rapport hauteur/largeur.
nous allons besoins de stocker la largeur et la hauteur par défaut ainsi que la largeur et hauteur actuelle de l’affichage.
nous calculons ensuite le rapport de mise à l’échelle afin de l’appliquer à tous les objets du jeu.
nous aurons besoin de ces variables
1 2 3 4 5 |
public static ratio:number = 1; public static defaultWidth: number; public static defaultHeight: number; public static width: number; public static height: number; |
… ainsi qu’une méthode qui calcul le ratio et redimensionne l’affichage de Pixi en fonction
1 2 3 4 5 6 |
private static _rescale() { ScenesManager.ratio = Math.min(window.innerWidth / ScenesManager.defaultWidth, window.innerHeight / ScenesManager.defaultHeight) ScenesManager.width = defaultWidth * ScenesManager.ratio; ScenesManager.height = defaultHeight * ScenesManager.ratio; ScenesManager.renderer.resize(ScenesManager.width, ScenesManager.height); } |
nous modifions également la méthode ScenesManager.create() pour pouvoir demander ou non la mise à l’échelle automatique (via le paramètre scale).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public static create(width: number, height: number, scale:bool=false) { if (ScenesManager.renderer) return this; this.defaultWidth = ScenesManager.width = width; this.defaultHeight = ScenesManager.height = height; ScenesManager.renderer = PIXI.autoDetectRenderer(ScenesManager.width, ScenesManager.height); document.body.appendChild(ScenesManager.renderer.view); if (scale) { ScenesManager._rescale(); window.addEventListener('resize', ScenesManager._rescale, false); } requestAnimFrame(ScenesManager.loop); return this; } |
Quand j’écrivais ce tutorial, je pensais que la mise à l’échelle d’un objet Pixi Stage (parent de nos classes Scene) était suffisante pour mettre à l’échelle tous ses objets enfants : il aurait donc simplement fallu de multiplier scenes.scale.x/y par le ratio afin que tout soit à la bonne taille …. J’avais tort 🙂
en fait, cette solution ne semble pas fonctionner, et je ne saurais dire si c’est un comportement normal de Pixi ou un bug … peu importe, j’ai mis en place un contournement.
Voyons comment nous allons gérer la mise à l’échelle.
Nous allons tout d’abord ajouter une methode qui applique un ratio à un objet Pixi DisplayObject ainsi qu’a tous ses enfants (DisplayObject est le parent de tous les objets PIXI)
1 2 3 4 5 6 7 8 9 10 11 12 |
private static _applyRatio(displayObj: PIXI.DisplayObject, ratio: number) { if (ratio == 1) return; var object: any = displayObj; object.position.x = object.position.x * ratio; object.position.y = object.position.y * ratio; object.scale.x = object.scale.x * ratio; object.scale.y = object.scale.y * ratio; for (var i = 0; i < object.children.length; i++) { ScenesManager._applyRatio(object.children[i], ratio); } } |
ensuite dans la méthode ScenesManager.loop() nous appliquons le ratio à tous les objets de la scène courante avant de la dessiner, nous dessinons la scène puis juste après nous récupérons le ratio par défaut. Ainsi tout reste transparent pour le développeur quand il s’agira de manipuler des positions ou des dimensions des objets du jeu.
1 2 3 4 5 6 7 8 9 10 11 12 |
private static loop() { requestAnimFrame(function () { ScenesManager.loop() }); if (!currentScene || currentScene.isPaused()) return; currentScene.update(); ScenesManager._applyRatio(currentScene, ScenesManager.ratio); //scale to screen size ScenesManager.renderer.render(currentScene); ScenesManager._applyRatio(currentScene, 1/ScenesManager.ratio); //restore original scale } |
tout est prêt maintenant, nous avons juste initialiser scenesManager avec le paramètre « scale » à true, pour lui demander d’appliquer la mise à l’échelle automatiquement.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<script> //get reference of ScenesManager; var scenesManager = tuto.Ezelia.ScenesManager; //note the scale parameter is set to true scenesManager.create(320, 480, true); //create a the game scene var game = scenesManager.createScene('game', tuto.Ezelia.GameScene); var intro = scenesManager.createScene('intro', tuto.Ezelia.IntroScene); var menu = scenesManager.createScene('menu', tuto.Ezelia.MenuScene); scenesManager.goToScene('intro'); </script> |
Cliquez ici pour voir le résultat ,
redimensionnez la fenêtre et voyez comment l’affichage s’adapte 🙂
Télécharger le code source complet
J’espère que ce tutorial vous sera utile dans le développement de vos jeux mobiles en HTML5 🙂 …
Vos remarques est suggestions sont les bienvenues.