Phaser 3.60 est-il le moteur de jeu ultime pour les développeurs web? – Partie 1 / 3

Phaser est un moteur de jeu pour les développeurs JavaScript / TypeScript

Depuis que j’ai commencé le développement web, il m’a fallu apprendre beaucoup de concepts et de technologies. Ma technique, pas si secrète, pour garder la motivation dans cet apprentissage continu? Appliquer ce que j’apprends pour créer des (prototypes de) jeux vidéo. Phaser est le moteur de jeu qui m’a permis d’exercer cette technique.

Pourquoi Phaser est-il intéressant?

Phaser est un framework JavaScript pour la création de jeux vidéos, principalement en 2D. Il intègre beaucoup de fonctionnalités à ce type de jeu: affichage pixel art, animation de « sprites », moteur physique, gestion des « tilemaps », etc.

Créé en 2013 par Richard Davey, alias PhotonStorm, ce framework a rapidement acquis une popularité chez les développeurs de jeux sur navigateur, et sa communauté est resté l’une des plus importantes/actives dans le milieu des moteurs de jeux JavaScript. Beaucoup de tutoriels et d’outils ont été produits pour Phaser, toutes versions confondues.

Principalement dédié à créer des jeux exécutables dans le navigateur, les jeux crées avec Phaser peuvent également tirer parti des technologies comme Capacitor ou Electron pour produire des applications mobile ou de bureau, comme cela a été le cas pour le récent hit Vampire Survivors!

Enfin, les jeux exportés au format web se prêtent facilement au type de jeux en ligne qui a été popularisé par Agar.io.

Le 12 Avril 2023 sort la version 3.60, soit la version « définitive » du projet Phaser 3. En d’autres termes, cette version contient toutes les fonctionnalités prévues pour le projet, des correctifs et mises à jour mineures sont encore prévues, mais l’API du framework ne va plus changer.

Pour vous présenter plus en détail ce moteur de jeu, je vous propose de décortiquer trois de mes créations.

Mon premier jeu: Roomblin’

  • code source: github.com/raaaahman/roomblin
  • Publié: Décembre 2016
  • Expérience: En cours de formation
  • Game Jam: Ludum Dare 37
  • Version: 2.6.2 (Community Edition)

J’ai programmé ce jeu à l’occasion de la game jam Ludum Dare 37. Le but était de créer un jeu à partir de zéro, en 48 heures seulement (ou 72, je ne me rappelle plus de l’option que j’avais choisie), sur le thème « One Room ».

Je suis à l’époque en cours de formation pour le métier de développeur web. J’ai donc choisi un concept très simple: le joueur doit « repeindre » une salle entière, en déplaçant un cube qui doit passer au moins une fois par chaque case de cette salle.

A l’époque, Phaser est disponible en deux versions:

  • la version 3, en chantier, dont les tutoriels sont assez rares et peuvent devenir obsolètes à cause des modifications de l’API du framework
  • la version 2, rebaptisée « Community Edition », signifiant que le créateur ne la changera plus et dont la maintenance est l’évolution est déléguée à sa communauté
Phaser
Phaser
Phaser 2
Phaser 2
Phaser 3
Phaser 3
Phaser CE (Community Edition)
Phaser CE (Community Edition)
Avril
2013
Avril…
Février
2014
Février…
Février
2018
Février…
Avril
2023
Avril…
Text is not SVG – cannot display

Je choisis cette version 2, pour sa stabilité et la possibilité de trouver des tutoriels. Il n’y a aujourd’hui plus aucune raison d’utiliser cette version, la version 3.60 étant la dernière mise à jour majeure (Phaser ne respecte pas le versionnage sémantique), elle est la version qu’il est préférable d’apprendre à l’heure ou j’écris ces lignes.

Ne prenez donc pas les exemple de code qui vont suivre pour un tutoriel. Mais même si l’API a changé au fil des versions, les fonctionnalités que je vais vous présenter se retrouvent aussi bien dans la version 3.

Structure de projet

Au moment de la game jam, mes connaissances en Javascript se résume aux bases de la version ECMAScript 5, et la bibliothèque jQuery. Heureusement, Phaser peut être ajouté dans une page web de manière très simple. Il m’a suffit d’inclure une version minifiée de Phaser dans une balise <script> à l’intérieur de la balise <head>. Ensuite, je peux ajouter les fichiers contenant mon code personnel, qui utilisent la variable globale Phaser, introduite par le framework:

<!doctype html>

<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>LD37</title>
    <link href="https://fonts.googleapis.com/css?family=Passion+One|Share+Tech+Mono" rel="stylesheet">

    <script type="text/javascript" src="js/phaser.min.js"></script>

    <script type="text/javascript" src="js/boot.js"></script>
    <script type="text/javascript" src="js/preload.js"></script>
    <script type="text/javascript" src="js/menu.js"></script>
    <script type="text/javascript" src="js/endscreen.js"></script>
    <script type="text/javascript" src="js/game.js"></script>
    <script type="text/javascript" src="js/main.js"></script>

    <style type="text/css">
        body {
            margin: 0;
        }
    </style>
</head>

L’ordre d’import des scripts est ici important, puisque le code d’un fichier peut dépendre d’une variable dans un autre fichier! Allons au dernier fichier pour comprendre pourquoi:

var game = new Phaser.Game(160, 160, Phaser.AUTO, '');

game.state.add('Boot', Boot); //configuration for the game
game.state.add('Preload', Preload); //Loading our assets
game.state.add('Menu', Menu); //The menu
game.state.add('EndScreen', EndScreen); //Screen between games
game.state.add('Game', Game); // Our actual game

game.state.start('Boot');

La première ligne crée le jeu, aux dimensions 160 x 160 pixels), et ajoute alors automatiquement une balise <canvas> dans la page HTML. Le mode de rendu précisé Phaser.AUTO est une valide spéciale qui délègue à Phaser de choisir entre un rendu WebGL, plus performant, si le navigateur en est capable, et un rendu utilisant la Canvas API du navigateur en solution de secours. C’est aujourd’hui un peu superflu car tous les navigateurs supportent très bien la bibliothèque WebGL…

Les autres lignes ajoutent les states (« états », qui seront renommés « scènes » dans les versions ultérieures). Ce sont ces états qui constituent la structure d’un jeu créé avec Phaser.

start
start
preload
preload
create
create
update
update
Text is not SVG – cannot display

Chaque état est un objet JavaScript disposant des plusieurs méthodes aux noms imposées par Phaser, principalement: preload, create et update.

Phaser va invoquer ces méthodes dans l’ordre: preload, pour charger les ressources nécessaires, create, pour initialiser les variables et les propriétés de cet état, puis il va appeler la méthode update en boucle jusqu’à ce qu’une ligne de code lui demande de changer de state.

Par exemple, l’état Boot (« démarrage ») que j’ai créé pour ce jeu:

var text1, text2;

var Boot = {

    preload: function() {
        //call to the fonts, for loading
        text1 = game.add.text(-64, -64, '0', {font: 'normal 10px "Passion One"'});
        text2 = game.add.text(-64, -64, '0', {font: 'normal 10px "Share Tech Mono"'})
    },

    create: function() {
        game.stage.backgroundColor = '#fff';

        //Our game space takes the whole screen
        game.scale.scaleMode = Phaser.ScaleManager.SHOW_ALL;

        //Rounding pixels
        game.renderer.renderSession.roundPixels = true;

        //Center the game
        game.scale.pageAlignHorizontally = true;
        game.scale.pageAlignVertically = true;

        //Initiate the physics system
        game.physics.startSystem(Phaser.Physics.ARCADE);

        //Launching the preloading state
        game.state.start('Menu');
    }

}

Cet état très simple configure le jeu lui même: couleur du fond, mise à l’échelle, alignement. Il définit également deux valeurs pour les polices d’écritures qui seront utilisées par la suite, en les stockant dans des variables globales.

Ici pas de méthode update, puisque nous changeons d’état à la fin de la méthode create qui nous amène directement à l’état suivant: le chargement des « assets » (ressources graphiques, sonores, données, etc.).

start
start
preload
preload
create
create
Boot
start
start
preload
preload
create
create
Preload
start
start
preload
preload
create
create
update
update
GameText is not SVG – cannot display

C’est cette alternance d’états qui permet au jeu d’alterner écran de chargement, écran titre, écran de fin de niveau, etc.

Gestion des ressources

L’état suivant est responsable du chargement de tous les « assets » (ressources utilisées dans le jeu). C’est un jeu très simple, et lesdites ressources sont peu nombreuses, il est donc facile de toutes les charger en amont et de laisser Phaser les gérer:

//A global variable to store the current level number
var currentLevel = currentLevel | 1;

var Preload = {
  preload: function() {
    //can add a loading screen

    //assets for the game
    game.load.tilemap('level', 'assets/levels/level' + currentLevel + '.json', null, Phaser.Tilemap.TILED_JSON);
    game.load.image('tileset', 'assets/images/tileset.png');
    game.load.image('player0', 'assets/images/blue_cube.png');

    //audio
    game.load.audio('move', 'assets/sounds/move.wav');
    game.load.audio('win', 'assets/sounds/win.wav');
    game.load.audio('reset', 'assets/sounds/reset.wav')
  },

  create: function() {
    //Bind the sounds to variables, so they can be controlled
    soundMove = game.add.audio('move');
    soundWin = game.add.audio('win');
    soundReset = game.add.audio('reset');

    //Then launch the game
    game.state.start('Game');
  },
}

Ce code que j’avais écrit alors que j’étais un débutant contient un problème qui me saute aux yeux aujourd’hui. Comme vous pouvez le constater, la première ressource, un fichier JSON contenant les données qui permettent de définir le niveau actuel, est identifié en utilisant un nombre provenant d’une variable. C’est ce code qui permet de changer de niveau.

Or, l’état Preload (à ne pas confondre avec la méthode preload(), appelable dans chacun des états), charge également tout le reste des ressources: le « jeu de tuiles » (images qui permettent de composer le niveau), les images et les sons. Ces ressources n’auraient besoin de n’être chargées qu’une seule fois pour tout le jeu, et non pas une fois par niveau!

Il aurait donc fallu charger ce fichier JSON dans la fonction preload de l’état suivant, Game, qui est le « jeu » en lui-même.

Gestionnaire de cartes

Si nous inspectons la fonction create de l’état suivant (Game), nous y trouvons le code qui utilise les données contenues dans ce fichier JSON:

    //Create the map
    map = game.add.tilemap('level');

Le nom 'level' qui avait été assignée à cette ressource dans le code précédent est ici utilisé pour retrouver la ressource en question. Elle permet de transformer ces données en un objet JavaScript avec lequel nous pouvons interagir.

Les fichiers JSON qui contiennent les données des différents niveaux du jeu ont été créés avec l’outil Tiled. C’est un outil qui n’est pas lié à un moteur de jeu particulier, et Phaser contient du code permettant de charger les fichiers qu’il génère.

Vue de l'éditeur Tiled
Vue de l’éditeur de niveau Tiled

La suite du code permet d’assigner les tuiles qui seront représentées à l’écran lorsque ce niveau sera affiché. Ces tuiles sont réparties en calques, et il est possible de définir quels calques seront utilisés pour gérer les collisions dans le moteur physique.

    map.addTilesetImage('tileset', 'tileset');
    //Creating the layers
    groundLayer = map.createLayer('ground');
    wallLayer = map.createLayer('walls');

    //Setting the collision with the tiles in the wall layer
    map.setCollisionBetween(1, 20, true, 'walls');

Moteur Physique

Phaser intègre un moteur physique très basique nommé ArcadePhysics. Comme son nom l’indique, sa simplicité le rend très adapté aux jeux rétros que l’on aurait pu trouver sur une borne d’arcade (les classiques comme Pacman, Tetris, Space Invaders, etc.).

    //Start the physics
    game.physics.startSystem(Phaser.Physics.ARCADE);

Phaser 2 donne le choix entre plusieurs implémentations de moteurs physique, comme ceux des bibliothèques P2.js ou BOX2D. On commence donc par préciser lequel de ces moteurs l’on veut utiliser.

    //Spawning the players
    players = [];
    for (var i = 0; i < map.objects['startingPos'].length; i++) {
      var newPlayer = game.add.sprite(map.objects['startingPos'][i].x, map.objects['startingPos'][i].y, 'player' + i);
      players.push(newPlayer);
      game.physics.arcade.enable(players[i]);
    }

Le code que j’ai écrit à l’époque semble prévoir la possibilité qu’il y aie plusieurs joueurs, ce qui ne sera jamais le cas au final. Dans tous les cas, ce qui fonctionne pour plusieurs joueurs, fonctionne pour un seul joueur également. Mais le code est plus difficile à lire. Dans l’ordre, il faut:

  • ajouter un « sprite » pour le joueur, c’est à dire un objet graphique qui le représente, à partir d’une image chargée précédemment
  • passer cet objet au moteur physique pour qu’il lui génère une « boîte de collision » selon les dimensions de cet objet
  • préciser au moteur quel sont les objets, ou groupes d’objets, qui doivent entrer en collision
    //Collision rules
    game.physics.arcade.collide(players[0], wallLayer);

Ici, c’est le sprite du joueur qui entre en collision avec le calque que l’on a utilisé pour définir les murs du niveau, empêchant donc le joueur de traverser les murs.

Contrôles du joueur

Phaser simplifie aussi la gestion des contrôles: clavier, souris, écran tactile, en créant sa propre abstraction qui permet de gérer cela simplement. Par exemple, dans ce jeu, le joueur utilise les touches fléchées pour se déplacer:

    //setting keyboard controls
    cursors = game.input.keyboard.createCursorKeys();
    controls = game.input.keyboard.addKeys({
      "r": Phaser.KeyCode.R,
      "space": Phaser.KeyCode.SPACEBAR
    });

La première ligne indique à Phaser de surveiller les touches fléchées, la deuxième ajoute également les touches « R » et « Espace ». Pas besoin d’écrire les écouteurs d’évènements, Phaser les ajoute pour nous.

Ensuite, dans la fonction update, qui est appelée en boucle jusqu’à ce que l’on change d’état, nous allons vérifier si le joueur a appuyé sur l’un de ces boutons.

update: function() {
    //Player movement
    //If player is not moving, set a new velocity
    if (players[0].body.velocity.x == 0 && players[0].body.velocity.y == 0) {

      //Keyboard inputs
      if (cursors.up.isDown && map.getTile(tileUnder.x, tileUnder.y - 1, 'walls') == null) {

        this.countMove();
        players[0].body.velocity.y = -movingSpeed;

      } else if(cursors.down.isDown && map.getTile(tileUnder.x, tileUnder.y + 1, 'walls') == null) {

        this.countMove();
        players[0].body.velocity.y = movingSpeed;

      } else if(cursors.left.isDown && map.getTile(tileUnder.x - 1, tileUnder.y, 'walls') == null) {

        this.countMove();
        players[0].body.velocity.x = -movingSpeed;

      } else if(cursors.right.isDown && map.getTile(tileUnder.x + 1, tileUnder.y, 'walls') == null) {

        this.countMove();
        players[0].body.velocity.x = movingSpeed;

      }

    //If moving, replace the tiles with the player's color
  }
}

Le code vérifie ici si le joueur est en mouvement, car c’est une des règles du jeu: le joueur se déplace en ligne droite jusqu’à toucher un obstacle. Cette première condition empêche donc le joueur de changer de direction en cours de mouvement.

La suite de if ... else if qui suit vérifie laquelle des touches fléchées a été enfoncée. Egalement, on vérifie que la prochaine tuile dans la direction désirée ne soit pas un mur, en vérifiant sur le calque approprié de la carte.

Si toutes ces conditions sont réunies, on peut modifier la vélocité horizontale ou verticale du joueur dans la direction choisie. Le moteur physique remettra de lui-même cette vélocité à 0 lors d’une collision avec un mur. On incrémente également le compteur de mouvement, qui va servir de score dans ce jeu.

Logique du jeu

Phaser nous a bien simplifié la vie pour toutes les tâches précédentes, qui sont très communes dans bon nombre de jeux vidéos. Maintenant c’est à nous d’implémenter les règles particulières du jeu que l’on veut créer.

On commence par vérifier, au début de la méthode update , si les conditions de victoire du niveau sont remplies: il faut que le joueur soit passé au moins une fois par chacune des tuiles du niveau qui ne sont pas des murs. Dans ce code, on compare simplement le nombre de tuiles parcourues au nombre de tuiles à parcourir.

update: function() {
    //Check if the game is won
    if (playerScore >= levelGoal) {
      //stop moving
      players[0].body.stopMovement(true);

      //display a sound
      soundWin.play();

      //Go the screen between levels
      game.state.start('EndScreen');
    }

    // ...
}

Si elles sont remplies, on passe à l’écran de fin de niveau, qui est un autre état avec ses propres méthodes preload, create et update: voir le code source.

Si les conditions de victoire ne sont pas remplies, alors on exécute le code de vérification du mouvement et des contrôles du joueur vu au chapitre précédent.

Lorsque le joueur est en mouvement, on incrémente le nombre de tuiles parcourues en fonction de sa position. On change également l’apparence de la tuile pour indiquer au joueur que celle-ci a déjà été parcourue.

    //Check the tile the player is over
    if (map.getTile(tileUnder.x, tileUnder.y).index == 18) {
      map.replace(18, 16, tileUnder.x, tileUnder.y, 1, 1, 'ground');
      playerScore++;
    }

On implémente également une méthode dans l’objet Game pour compter le nombre de mouvement effectués, jouer un son lorsque le joueur bouge, et afficher le nombre de mouvement effectués.

  countMove: function() {
    //Add a sound effect
    soundMove.play();
    //Count one move
    playerMoves++;
    moveText.text = 'Moves: ' + playerMoves;
  }

On peut aussi ajouter, dans la méthode update, la possibilité de recommencer le niveau lors de l’appui sur la touche « R ».

    //Check for reset
    if (controls.r.isDown) {
      //alert with a sound
      soundReset.play();

      this.create();
    }

Publier le jeu

Comme tout ce code est réalisé en Javascript natif, il est très facile à publier sur internet. La procédure pour le téléverser sur itch.io est très simple: il suffit de compresser tous les fichiers, scripts, ressources, incluant le fichier index.html qui doit lancer le jeu. Puis le site fait le reste, et on peut dès lors partager son jeu avec les autres participants de la game jam.

Conclusion

La simplicité de Phaser et les nombreuses fonctions que le framework intègre m’a donc permis de réaliser, sur une période de 72 heures seulement, mon premier jeu vidéo, alors que je ne connaissais que les bases de JavaScript.

Si cette version 2.6.2 est aujourd’hui dépassée, les fonctionnalités que j’ai présentées dans cet article existent toujours dans la version 3.60, et probablement dans les versions 4.x à venir. Pensez donc à consulter la documentation officielle.

Dans un prochain article, je vous montrerai comment quelques mois d’apprentissage supplémentaires m’ont permis d’intégrer des outils comme Webpack et Babel afin de créer des jeux en suivant des méthodes de travail plus « professionnelles » (pour un développeur web).

En attendant, si vous souhaitez créer votre propre jeu avec Phaser, le 13 Avril (soit ce soir, si vous lisez cet article le jour où je le publie) démarre la Gamedev.js Jam 2023, cela vous donnera une bonne occasion de tester cette version 3.60!