Retour sur la création d’un jeu avec phaser.io

Nov 30, 2021 • Mathieu Jolivet

Pourquoi créer un jeu ?

Chez Lucca, nous créons des applications centrées sur les problématiques liées au SIRH. Complètement éloigné du monde du jeu vidéo, donc. Vous pourriez donc vous demander « pourquoi faire un article sur le thème de la création de jeu alors qu’ils n’en font pas du tout ? »

Il faut un peu de contexte pour cela. Il y a quelques semaines a eu lieu le Devfest à Nantes. Un ensemble de conférences en rapport avec le monde de l’IT, où des entreprises peuvent aussi avoir un stand de façon à se faire connaitre. Nous y participions cette année encore et devions donc travailler sur une animation à proposer pour les deux jours.

Devfest 2021

Le thème cette année était le street-art. L’idée nous est venue de faire quelque chose centré autour du dessin ou de la peinture. Plusieurs idées (assez différentes les unes des autres) ont émergées, pour partir finalement sur un jeu vidéo.

Le principe de ce jeu était de faire un tracé à l’intérieur d’une forme définie, le plus long possible, le plus rapidement possible, tout en ramassant des objets sur la route.

Pour créer facilement cette application, nous avons décidé d’utiliser le framework phaser.io.

Luccartiste

C’est quoi Phaser.io ?

Phaser.io

Phaser.io est un framework javascript dédié à la création de jeu, facile à prendre en main.

Il met à disposition plusieurs outils permettant de gérer facilement des assets, la physique, les collisions, etc.

Ce framework existe depuis 2013, et en est à sa troisième version, avec une communauté assez active. Nous avons pu voir que les retours sur son utilisation étaient assez positifs, et beaucoup de ressources sont disponibles (attention cependant il y a eu plusieurs breaking changes importants entre chaque grande version ; ça a de l’importance quand on fait des recherches de documentation).

C’est un framework qui permet très rapidement de créer un POC sur lequel on peut par la suite itérer. Ce point nous a permis de lancer assez facilement les premières phases du projet.

Une démo vaut mieux qu’un long discours

Plutôt que d’écrire un énorme pavé rébarbatif, je vais vous retranscrire ici les premières phases du POC qui nous a permis d’établir les bases du futur jeu.

Etant donné qu’il s’agit pour le moment d’un POC, on va rester sur la forme la plus simple d’architecture, c’est-à-dire qu’on va avoir :

  • un fichier HTML pour accéder au jeu ;
  • un fichier .js avec notre code ;
  • le fichier phaser.min.js contenant le code de phaser.

À noter qu’il y a plusieurs autres façons possibles de faire (installer phaser via npm, englober le projet dans un projet angular et utiliser typescript, découper notre code en plusieurs fichiers avec un système de scenes…), mais nous ne nous y attarderons pas ici pour le moment.

Objectifs du POC

Le poc va avoir plusieurs objectifs :

  • pouvoir dessiner un tracé ;
  • afficher une image ;
  • pouvoir faire interagir le tracé en fonction de l’image, suivant si on est dedans ou en dehors de l’image ;
  • avoir des indications de temps et longueur parcourue (sera utilisé ultérieurement pour le score) ;
  • avoir des items à ramasser en bonus.

Structure vide

Voici tout d’abord à quoi ressemble un projet vide :

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Phaser.io demo</title>
  <script src="./phaser.min.js"></script>
</head>
<body>
  <script src="index.js"></script>
  <div id="game-container"></div>
</body>
</html>

index.js

function preload(){}

function init() {}

function create() {}

function update() {}

const config = {
  width: window.innerWidth,
  height: window.innerHeight,
  type: Phaser.AUTO,
  parent: 'game-container',
  physics: {
    default: 'matter',
    matter: {
      debug: false,
      gravity: {
        x: 0,
        y: 0
      }
    }
  },
  scene: {
    preload,
    init,
    create,
    update
  }
};

var game = new Phaser.Game(config);

On peut noter ici plusieurs choses :

  • le fichier html ne sera plus modifié.
    • il fait le lien vers les deux fichiers .js nécessaires
    • il contient une div qui englobera le jeu
  • dans le fichier .js on retrouve :
    • une déclaration de configuration avec :
      • la taille du jeu
      • le type de gestion graphique (ici AUTO)
      • le nom de la div où le jeu apparaitra
      • la définition du moteur graphique (le moteur par défaut étant arcade; Nous avons ici choisi matter, j’expliquerai plus loin pourquoi)
      • le câblage des différentes méthodes qui seront utilisées
    • la création du jeu
    • la mise en place des méthodes principales
      • preload : permet de charger des choses en mémoire
      • init : utilisé pour initialiser des variables avant la création du jeu
      • create : appelé au moment de la création du jeu
      • update : appelé à chaque cycle (frame) du jeu

On peut noter ici l’apparition du terme "scene" qui correspond au contexte courant du jeu. Ici on n’en a qu’une, mais on peut très bien en avoir plusieurs, déclenchées séparément ou en même temps.

Quand on lance le projet, on obtient un écran noir :

écran noir

Créer un tracé

Pour créer le tracé, il va falloir mettre à jour le fichier « index.js » :

let isDrawing;
let graphics;
let path;

function preload(){}

function init() {
  isDrawing = false;
}

function create() {
  graphics = this.add.graphics();
  graphics.lineStyle(4, 0x00aa00);
}

function update() {
  let x = this.input.activePointer.position.x;
  let y = this.input.activePointer.position.y;
  
  if (!this.input.activePointer.isDown && isDrawing) {
    isDrawing = false;
  } else if (this.input.activePointer.isDown) {
    if (!isDrawing) {
      path = new Phaser.Curves.Path(x, y);
      isDrawing = true;
    } else {
      path.lineTo(x, y);
    }
    path.draw(graphics);
  }
}

Le point important dans la fonction create :

  • on crée un objet graphics qui permet de définir un style graphique à appliquer sur une forme (carré, rond ou forme libre). Ici on définit une largeur de trait de 4 et une couleur verte (0x00aa00 correspond à du RGB sous forme hexadécimale).

Les points importants dans la fonction update :

  • this.input.activePointer permet de récupérer beaucoup d’informations liées à la souris (ou au doigt de l’utilisateur dans le cas d’une appli mobile). Par exemple :
    • la position du pointeur,
    • l’information pour savoir si on clique ou pas.
  • On crée une forme libre (Path) et on dessine des lignes vers l’endroit où la souris se trouve.
  • Pour afficher le tracé qu’on a défini précédemment, on le dessine en utilisant le graphics défini plus haut.

Et voilà le résultat :

tracé

Ajouter une image

La deuxième étape consiste en l’affichage d’une image qui servira de zone de délimitation :

Test image

C’est là que l’utilisation du moteur physique matter est importante. En effet, en restant sur le moteur arcade, on n’aurait pas pu définir de contour d’image précis (le contour de l’image aurait été un rectangle correspondant au pourtour du fichier png). Ici nous avons possibilité de définir des coordonnées précises et de les attacher à l’image.

{
    "generator_info": "Shape definitions generated with PhysicsEditor. Visit https://www.codeandweb.com/physicseditor",
    "test": {
        "type": "fromPhysicsEditor",
        "label": "test",
        "isStatic": true,
        "density": 0.1,
        "restitution": 0,
        "friction": 0.1,
        "frictionAir": 0.01,
        "frictionStatic": 0.5,
        "collisionFilter": {
            "group": 0,
            "category": 1,
            "mask": 255
        },
        "fixtures": [
            {
                "label": "test",
                "isSensor": true,
                "vertices": [
                    [ { "x":357, "y":54 }, { "x":357, "y":56 }, { "x":522, "y":343 }, { "x":359, "y":54 } ],
                    [ { "x":345, "y":72 }, { "x":350, "y":74 }, { "x":355, "y":56 }, { "x":345, "y":66 } ],
                    [ { "x":9, "y":477 }, { "x":13, "y":479 }, { "x":672, "y":479 }, { "x":673, "y":469 }, { "x":567, "y":416 }, { "x":107, "y":416 }, { "x":15, "y":458 }, { "x":8, "y":472 } ],
                    [ { "x":350, "y":74 }, { "x":364, "y":95 }, { "x":355, "y":56 } ],
                    [ { "x":675, "y":477 }, { "x":673, "y":469 }, { "x":672, "y":479 } ],
                    [ { "x":245, "y":28 }, { "x":238, "y":30 }, { "x":228, "y":47 }, { "x":146, "y":208 }, { "x":107, "y":416 }, { "x":263, "y":46 }, { "x":263, "y":44 } ],
                    [ { "x":436, "y":98 }, { "x":434, "y":98 }, { "x":439, "y":106 } ],
                    [ { "x":388, "y":27 }, { "x":383, "y":28 }, { "x":359, "y":52 }, { "x":427, "y":88 }, { "x":390, "y":28 } ],
                    [ { "x":427, "y":88 }, { "x":359, "y":52 }, { "x":439, "y":106 }, { "x":434, "y":98 } ],
                    [ { "x":536, "y":368 }, { "x":538, "y":368 }, { "x":522, "y":343 } ],
                    [ { "x":278, "y":61 }, { "x":263, "y":46 }, { "x":107, "y":416 }, { "x":280, "y":65 } ],
                    [ { "x":364, "y":95 }, { "x":522, "y":343 }, { "x":357, "y":56 }, { "x":355, "y":56 } ],
                    [ { "x":439, "y":106 }, { "x":359, "y":52 }, { "x":481, "y":172 } ],
                    [ { "x":481, "y":172 }, { "x":359, "y":52 }, { "x":359, "y":54 }, { "x":522, "y":343 }, { "x":538, "y":368 }, { "x":673, "y":469 } ],
                    [ { "x":146, "y":208 }, { "x":142, "y":213 }, { "x":15, "y":458 }, { "x":107, "y":416 } ],
                    [ { "x":567, "y":416 }, { "x":673, "y":469 }, { "x":538, "y":368 } ]
                ]
            }
        ]
    }
}

Pour générer ce fichier, j’ai utilisé un outil nommé PhysicsEditor. Il permet de sélectionner facilement le contour d’une forme, puis de choisir un format d’export et de créer le fichier désiré.

Physics Editor

Pour pouvoir utiliser cette image, voici les modifications à apporter :

function preload(){
  this.load.image('test_image', 'assets/test.png');
  this.load.json('test_shape', 'assets/test.json');
}

Ici on charge l’image en cache, en lui donnant un nom pour pouvoir la réutiliser plus tard (test_image), ainsi que les coordonnées issues du json.

function create() {
  graphics = this.add.graphics();
  graphics.lineStyle(4, 0x00aa00);

  this.matter.world.disableGravity();
  let shapes = this.cache.json.get('test_shape');

  test = this.matter.add.image(400, 360, 'test_image', null, { shape: shapes.test  });
  test.depth = -1;
}

Ici les deux premières lignes sont inchangées.

Les lignes d’après permettent de :

  • désactiver la gravité (pour éviter que l’image tombe au chargement de la scène) ;
  • récupérer les coordonnées de la forme depuis le cache ;
  • ajouter l’image en lui donnant une taille, en se basant sur le nom dans le cache, et lui attachant ses coordonnées ;
  • définir l’emplacement de profondeur de l’image (de façon à ce que le tracé apparaisse au-dessus).

On a maintenant une image et on peut dessiner par-dessus :

tracé et image

Gestion des collisions

Phaser.io a une bonne gestion des collisions entre les images et les sprites.

Par contre, mauvaise nouvelle, ce n’est pas possible entre un Path et une image.

À ce stade, on se dit que c’est dommage, on va devoir utiliser un autre framework… Sauf que non, il existe une fonction pour ça.

Plutôt que de savoir si notre Path entre en collision avec la forme ou pas, on peut regarder si, au moment de dessiner, la coordonnée en cours de dessin est à l’intérieur de la forme ou pas.

Pour visualiser les changements d’état, on va également rajouter du texte (qui encore une fois se fait très simplement).

Note : pour aller à l’essentiel, je remplace le code préexistant non modifié par // [...] //, de façon à ne mettre l’accent que sur les modifications apportées.

let statusText;
// [...] //
function create(){
  // [...] //
  statusText = this.add.text(32, 32, 'pas commencé');
}
function update() {
  if (!this.input.activePointer.isDown && isDrawing) {
    // [...] //
    statusText.setText('pas de dessin en cours');
  } else if (this.input.activePointer.isDown) {
    // [...] //
    if (!isDrawing) {
      // [...] //
      statusText.setText('nouveau');
    } else {
      // [...] //
      if (this.matter.containsPoint(test, x, y)) {
        statusText.setText('dans la forme');
      } else {
        statusText.setText('en dehors de la forme');
      }
    }
    path.draw(graphics);
  }
}

On peut donc voir qu’ajouter et modifier du texte se fait très rapidement, de la même manière que le fait de savoir si on a un point dans la forme, ou pas.

interractions

Un peu d’embellissement

Pour rendre l’ensemble un peu plus agréable, on va :

  • rajouter un fond d’écran ;
  • modifier le style du texte ;
  • ajouter un effet de fade in avec la caméra quand on charge la scène.

Pour rajouter le fond, il suffit de charger une image, comme on a pu le voir plus haut :

function preload(){
  this.load.image('background', 'assets/background.jpg');
}

function create(){
  this.add
      .image(0, 0, 'background')
      .setOrigin(0, 0)
      .setDisplaySize(
              this.sys.game.config.width,
              this.sys.game.config.height
      );
}

Pour modifier le style du texte, on peut passer un objet de style en paramètre du texte créé précédemment :

  const infoStyle = {
    font: '20px Calibri',
    fill: '#ffffff',
    stroke: '#000000',
    strokeThickness: 3
  };

  statusText = this.add.text(32, 32, 'pas commencé', infoStyle);

Pour un effet de fade in, il y a une fonction déjà existante avec un objet Camera directement lié à la scene :

function create(){
  this.cameras.main.fadeIn(1000, 0, 0, 0);
}

embellissement

Prise en compte du temps et de la longueur du tracé

Nous allons maintenant voir s’il est facile ou pas de récupérer certaines informations qui seront importantes plus tard pour le calcul du score.

Tout d’abord la longueur du tracé.

Bonne nouvelle, il y a déjà une fonction permettant de récupérer sa longueur :

const pathLength = path.getLength() ?? 0;

Pour la récupération du temps, il y a un objet timer directement lié à la scène. Il suffit de lui définir un nouvel événement, avec une durée, et de décider quand on le démarre ou quand on l’arrête :

function create(){
  timerEvent = this.time.addEvent({ delay: 4000 });
  timerEvent.paused = true;
  // [...] //
}
function update(){
  const timeUpdate = timerEvent.getRemainingSeconds();
    // [...] //
  if (!isDrawing) {
    // [...] //
    timerEvent.paused = false;
  }
  // [...] //
}

informations

Ramasser des objets

Nous allons maintenant permettre de ramasser une potion : Potion

Pas besoin de lui attacher des coordonnées de contour cette fois-ci, l’image sera suffisamment petite pour qu’un rectangle global soit suffisant dans notre contexte.

J’ai créé une petite fonction permettant de créer facilement un item :

function addItem(x, y) {
  const image = this.matter.add.image(x, y, 'item');
  image.depth = 3;

  const itemStyle = {
    font: '40px Calibri',
    fill: '#1f1f2f',
    stroke: '#ffff00',
    strokeThickness: 5
  };
  const itemText = this.add.text(x, y, '+5000!', itemStyle);
  itemText.setShadow(0, 0, 'rgba(0, 0, 0, 0.5)', 0);
  itemText.depth = 3;
  itemText.setVisible(false);

  itemsTexts.push(itemText);
  itemsList.push(image);
}

Ici, j’ajoute une image (précédemment mise en cache dans la fonction preload) aux coordonnées passées en paramètre. Je définis ensuite un texte qui apparaitra quand on ramassera la potion, mais par défaut je le cache. Je mets ensuite cette image et ce texte dans des tableaux.

Ajouter des potions est ensuite très simple :

function create() {
  // [...] //
  itemsList = [];
  itemsTexts = [];

  addItem.call(this, 290, 150);
  addItem.call(this, 480, 150);
  addItem.call(this, 400, 480);
  // [...] //
}

Pour ramasser une potion, pendant le dessin, j’ai rajouté ce code :

function update() {
  // [...] //

    } else if (this.input.activePointer.isDown) {
    itemsList.forEach((item, index) => {
      if (item.visible && isDrawing && this.matter.containsPoint(item, x, y)) {
        itemsTexts[index].setVisible(true);
        this.tweens.add({
          targets: itemsTexts[index],
          alpha: 0,
          scale: 10,
          duration: 1500,
          ease: 'Power2'
        });
        item.setVisible(false);
      }
    });
  }
  // [...] //
}

Ici on vérifie si le point en cours de dessin est dans une potion encore visible. Si c’est le cas, on rend l’image invisible (on la ramasse), puis on rend le texte correspondant visible, et on applique un tween.

Un tween est quelque chose permettant de modifier progressivement une propriété d’un objet.

Ici on change la transparence (alpha) du texte cible, sur une durée de 1500 millisecondes, avec une vitesse exponentielle.

Ça veut dire qu’on va faire apparaitre le texte « +5000 » ajouté précédemment, et dans la foulée, on va le faire disparaitre progressivement. Ça nous permet d’avoir un texte de feedback lors du ramassage de la potion.

Voilà ce que ça donne :

potions

L’avantage du tween est qu’on peut très bien modifier plusieurs propriétés en même temps.

Par exemple si je décide de modifier également l’échelle du texte :

          this.tweens.add({
            targets: itemsTexts[index],
            alpha: 0,
            scale: 10,
            duration: 1500,
            ease: 'Power2'
          });

Voilà ce que ça donne :

scale

Les seules limites sont votre imagination, et votre capacité à ne pas (trop) saigner des yeux (pensez quand même un peu aux utilisateurs finaux).

Et ensuite ?

Les premières étapes du POC sont terminées, après il reste encore pas mal de choses à faire :

  • réutiliser du code pour pouvoir avoir plusieurs niveaux (dans l’application finale, créer un nouveau niveau revenait juste à rajouter une image, ses coordonnées et une ligne dans un json) ;
  • compléter l’interaction entre l’image et le tracé ;
  • avoir un vrai score ;
  • Communication avec un backend pour stocker les scores et avoir un leaderboard ;
  • etc.

Mais on a déjà pu très rapidement avoir quelque chose de fonctionnel permettant de valider la faisabilité du projet.

Il y a d’autres pistes à explorer avec Phaser.io ?

Oui.

On peut déjà essayer de faire quelque chose de plus classique avec des sprites et une gestion de collisions qui est très poussée, jouer avec la physique, créer des niveaux avec un éditeur comme Tiled, ou même faire de la 3D avec Enable3D (même si Unity ou un autre moteur similaire sera probablement plus adapté).

En tout cas Phaser est un framework vraiment simple d’utilisation, nous mettant beaucoup d’outils à disposition.

C’est vraiment top pour du prototypage, et on peut très facilement transformer l’essai en partant ensuite sur des architectures un peu plus complexes (avec plusieurs scenes, des classes dédiées à certains objets spécifiques, etc.)

À l’utilisation, nous avons eu très peu de mauvaises surprises (notre principal ennemi lors de l’utilisation de l’appli finale ayant été le réseau très fluctuant, ce qui rendait la communication avec le backend parfois un peu longue). Je ne saurai donc que conseiller de vous y essayer si l’envie de créer des jeux vous vient.

On se prend vite au jeu (c’est le cas de le dire), et on peut très rapidement obtenir un résultat utilisable.

Quelques ressources

Voici quelques liens et ressources intéressantes si cet article a piqué votre intérêt et que vous souhaitez vous lancer dans la création de jeu avec phaser :

About the author

Mathieu Jolivet

Lead Developer