Une petite introduction à PixiJS
Tags: pixijs, gamedev, french, javascript
Published: 2023-03-06 14:41:58

J'ai récemment entrepris de programmer mon premier jeu en Javascript. À la recherche d'outils performants et faciles à utiliser, mon choix s'est porté sur la bibliothèque PixiJS. 

Mais avant d'aller plus avant sur sa présentation, j'aimerais faire une rapide présentation de mon projet!

## Présentation de mon jeu

![capture d'écran de mon jeu dans lequel on peut voir le personnage joueur et les ennemis](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/y3yxji5dl091fzzwgcwr.png)

C'est un jeu de tir vu du dessus dans lequel on peut contrôler le personnage au centre. Le but est de tirer des pieux sur un maximum de vampires tout en évitant d'entrer en contact avec eux! Le repo est trouvable à [cette adresse](https://github.com/Tass2T/pixi-game) !!

## Courte introduction de PixiJS

**Du coup, c'est quoi Pixi JS?** Pour faire court, c'est une bibliothèque Javascript 2D pour la création de graphismes et d'animations interactives sur le web. **Elle utilise la technologie WebGL, qui est une API basée sur OpenGL permettant d'afficher des graphismes 3D et 2D sur navigateur en se servant de la puissance des processeurs graphiques**. L'utilisation de la carte graphique de l'ordinateur permet non seulement de réaliser de très nombreux calculs en parallèle, mais en plus de soulager la charge du processeur.

Pour information, WebGl est supporté nativement [par la très grande majorité des navigateurs modernes](https://caniuse.com/?search=webgl). Néanmoins, les équipes derrière PixiJs ont prévu un fallback en canvas si ce n'est pas le cas. 

Les performances ne sont heureusement pas le seul avantage de Pixi car la librairie est aussi rapide à apprendre grâce à une documentation bien fournie et très claire ainsi que grâce à sa communauté très active.

Pour finir cette courte présentation il faudra bien prendre en compte que Pixi Js **est une bibliothèque de rendu et pas un moteur de jeu**. Il n'inclut pas de système de physique (collision ou gravité) par exemple. Ce dernier devra être incorporé par le développeur.

## La structure

**La structure de PixiJS est modulaire**, chaque module pouvant être utilisé indépendamment des autres. Les modules principaux sont:

- **Renderer**: le module qui se charge d'afficher la scène dans le canvas cible. C'est le module qui utilise WebGL par défaut.
- **Container**: cet élément va servir à contenir différents éléments d'affichages. Je reviendrai plus en détails sur ce module un peu plus loin!
- **Loader**: permet le chargement asynchrone des ressources du jeu. Ce module a néanmoins été "remplacé" par le module Assets, plus adapté pour le chargement asynchrone de texture.
- **Ticker**: va jouer un ou plusieurs callbacks à chaque tick. Bien sûr c'est à l'utilisateur de préciser quelle fonction Pixi doit jouer à chaque tick.\
- **Application**: Cette classe très pratique va nous fournir un ticker, un renderer et un loader dès son instanciation. 
- **Interaction**: Comme son nom l'indique, il s'agit de la gestion des interactions utilisateurs (clavier, souris etc...).

Voyons maintenant comment ces différents modules sont utilisés afin de mettre en place un petit jeu sur navigateur 😀

## Installation

L'installation de Pixi Js dans un projet peut-être réalisée de deux manières: à travers l'ajout d'un CDN dans le fichier HTML d'entrée de l'application

```<script src="https://pixijs.download/release/pixi.js"</script>```

ou dans le cas d'un module javascript, avec la commande 
`npm install pixi.js`

Une fois la bibliothèque installéee et une structure HTML classique mise en place comme ci dessous: 

```
<!doctype html>
<html>
  <head>
  </head>
  <body>
    <script type="module" src="./main.js"></script>
  </body>
</html>
```
nous pouvons alors commencer à instancier PixiJs. Dans le fichier main.js, qui me servira de porte d'entrée pour mon projet, j'inscris le code suivant ==>

```
    import * as PIXI from 'pixi.js';

    this.app = new PIXI.Application({
      width: 640,
      height: 360,
    });
    document.body.appendChild(this.app.view);
```
Et j'ai une application PixiJS! Bien sûr elle ne fait pas grand chose pour l'instant mais l'app est dotée désormais d'un moteur de rendu, d'un loader pour charger nos textures et d'un ticker! 

Ici j'ai simplement instancié la classe Application en ajoutant en paramètre la largeur et la hauteur de l'élément canvas du renderer! Puis j'utilise du javascript basique pour ajouter celui-ci dans le body de mon DOM!

Je peux maintenant mettre en place une fonction qui servira à mettre à jour les différents éléments de mon application

```
/* je préfère personnellement écrire mes fonctions sous forme de fonctions fléchés afin de conserver le contexte de la classe même si celle-ci est appelé de l'extérieur  */
update = () => {
   this.updateCurrentState()
   this.refreshScore()
}

```
 et l'ajouter à mon ticker de la sorte:

```
let elapsed = 0.0;
// au passage je règle le framerate de l'app!
this.app.ticker.maxFPS = 60;
this.app.ticker.add((delta) => {
   elapsed += delta
   this.update()
})
```
Ainsi, la méthode "update" sera jouée à chaque tick! 
A noter que le ticker peut être stoppé avec 

```
this.app.ticker.stop();
```
et mis à jour manuellement avec 

```
this.app.ticker.update();
```
Très pratique quand comme moi on a choisi de passer par un ticker d'une tierce librairie (gsap dans mon cas)!

## Les containers et l'arborescence des objets graphiques

Les "containers" représentent un concept important dans PixiJS. **Ce sont des objets graphiques vides dont le but sera de contenir d'autres éléments d'affichage** comme les sprites, les graphismes vectoriels, des textes mais également d'autres containers, créant ainsi une arborescence d'éléments ayant les uns avec les autres des relations enfants-parents. 

**Cette structure a deux objectifs**: d'abord elle permet à PixiJS de savoir dans quel ordre les éléments doivent être rendus à l'écran (les parents apparaîtront toujours avant les enfants, donc derrière eux pour l'utilisateur) et ensuite elle permet de grouper des éléments afin de les traiter comme une entité unique. 
Ainsi toute transformation appliquée sur un container sera également appliquée sur ses enfants. 

On pourrait de ce fait imaginer les containers comme des div pour une application web classique en html. Ils permettent de séparer le programme et le visuel en "boite" qui vont rassembler des éléments dont la thématique et/ou logique est la même.

Il est alors aisé d'imaginer leur utilités dans un programme comme un jeu vidéo où l'on a besoin de mettre en place des états, des scènes, entre lesquels on va pouvoir switcher en fonction des besoins. Prenons comme exemple le code de mon jeu ci-dessous: 

```
this.states = {
      menu: new Menu(this.app, this.changeState),
      game: new Game(this.app, this.changeState),
      gameOver: new GameOver(this.app, this.changeState),
    };
```

Mon jeu étant relativement simpliste, je n'ai besoin que de trois états qui correspondent au menu principal du jeu, le jeu en lui-même et l'écran de fin de partie. Chacun correspond à une classe qui recevra en paramètre l'adresse mémoire de l'instance app en cours ainsi que la fonction qui leur permettra de changer l'état du jeu. 

L'instance app est envoyée car celle-ci contient une propriété "stage" qui est générée à l'instanciation de l'application et qui sera le 'conteneur-racine' pour ainsi dire. 
Prenons maintenant le code du constructeur de ma classe Game qui sera responsable de l'affichage du jeu en lui-même (joueur, ennemis et mise à jour à chaque tick des mécaniques) ==>

```
constructor(app, stateFunc) {
    this.app = app;
    this.container = new Container();
    this.app.stage.addChild(this.container);
    this.container.visible = false;
//...
}
```
La classe Game sera responsable d'afficher et de gérer la logique du jeu. La première action du constructeur sera de générer son propre container. On utilise la méthode addChild afin de représenter ce dernier comme l'enfant du conteneur racine "stage". Cette étape est importante dans la mesure ou un élément ne sera pas affiché s'il n'est pas affilié à un parent visible.
Comme l'application commence sur le menu principal et non sur le jeu en lui-même, ce container et ses enfants ne sont pas censés apparaître de suite. Par conséquent la ligne `this.container.visible = false` va faire passer le container **et tous ses enfants (sprite joueur, sprites ennemis et autres objets)** en invisible! 
Comme la classe Container hérite de la classe DisplayObject, elle obtient de nombreuses méthodes visibles [sur cette page de la doc](https://pixijs.download/dev/docs/PIXI.DisplayObject.html) et qui lui permet de nombreuses transformations (scale, pivot, rotation etc...).

## Afficher des sprites et des animations

Un sprite est un élément texturé qui va pouvoir être affiché à l'écran. PixiJS nous fournit une classe Sprite qui peut être ajoutée de la sorte: 
`import { Sprite } from "pixi.js";`
ou qui sera déjà importé si vous aviez ajoutée ceci dans les importations: 

```
import * as PIXI from "pixi.js"
```

Cette classe fournit la méthode "from" qui permet de charger une image existante et d'en faire un sprite.
_exemple_: 
`const sprite = Sprite.from("chemin/vers/mon/image");`

Si le chemin est correct, le sprite est créé et prêt à l'utilisation! Il suffit alors de l'ajouter au container en utilisant la méthode addChild de la classe Container et il sera visible à l'écran ✨

```
this.container.addChild(sprite)
```

Enfin, si le container n'a pas été rendu invisible comme pour celui de la classe Game un peu plus haut 😁! Et comme Sprite hérite de la classe Container, il hérite également de DisplayObject, lui permettant de nombreuses transformations et animations. 

**Mais que faire si on a beaucoup de sprite à charger?**

Mais la méthode consistant à créer un sprite à la volée (comme réalisé plus haut) montre ses limites dans la mesure où le chargement d'une image en sprite est une action asynchrone et que de nombreux assets devront probablement être chargés. Il faut pouvoir retenir le processus du programme tant que tous ne sont pas prêts. C'est là où la classe Assets intervient.

Prenons comme exemple ma classe SpriteManager:

```
import { Assets } from "pixi.js";

export default class SpriteManager {
  constructor() {
    // l'instance de Assets est conservé dans this.loader
    this.loader = Assets;
    this.isLoading = true;
    this.addTextures();
  }

  addTextures = () => {
    this.loader.add("player", "/src/assets/player/playerSpriteSheetData.json");
    this.loader.add(
      "background",
      "/src/assets/background/backgroundSpriteSheetData.json"
    );
    this.loader.add("bullet", "/src/assets/bullets/stake.png");
    this.loader.add("villain", "/src/assets/villain/villainSpriteSheet.json");
  };

  loadTextures = async (callback) => {
    const texturePromise = await this.loader.load([
      "player",
      "background",
      "bullet",
      "villain",
    ]);

    this.playerTexture = texturePromise.player;
    this.backgroundTexture = texturePromise.background;
    this.bulletTexture = texturePromise.bullet;
    this.villainTexture = texturePromise.villain;
    this.isLoading = false;
    callback();
  };
}
```
Observons cette ligne-ci: 
`this.loader.add("player", "/src/assets/player/playerSpriteSheetData.json");`

La méthode "add" permet d'ajouter des images ou des atlas de  textures dans le loader. Cependant aucun chargement n'est effectué a ce stade. Le fichier et le 'surnom' qu'on lui donne sont juste gardés dans un registre en mémoire pour le moment.

Ma méthode loadTextures va permettre de lancer le chargement de toutes les textures que je vais lister en paramètre via la méthode asynchrone "load". Les images/atlas à charger sont désignées uniquement par le nom qu'on leur a donné lors de l'appel de la fonction "add". De cette manière là, il est possible de ne charger que les éléments pertinents pour le niveau du jeu en cours. Le callback passé en paramètre sera lancé quand toutes les textures seront prêtes!

**Quid des animations?**

A la manière d'un dessin animé, une animation de sprite est une succession d'images donnant l'illusion du mouvement. Idéalement les différentes images faisant partie d'une animation doivent être contenues au sein d'un même fichier appelé un spritesheet.
Ci-dessous, un exemple de spritesheet fait maison!

![spritesheet des vampires](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bitu8n7t9n4firy33asg.png)

Comme vous pouvez le voir, l'image ci-dessus contient toutes les étapes des animations de mouvement. **Il convient maintenant de fournir à Pixi les informations nécessaires pour lui permettre de découper le fichier en images individuelles que l'on pourra accéder facilement**.
Nous avons pu constater lorsque nous avions examiné mon SpriteManager que celui-ci chargeait des fichiers json. Ce sont des atlas, c'est-à-dire des fichiers qui vont contenir ces fameuses informations. Un atlas sera découpé en trois partie:

- **frames**: on va lister chaque frame une à une en lui donnant un nom et en notant ses coordonnées sur le fichier. En l'occurrence chaque image fait 500px sur 500px, je peux donc facilement calculer les coordonnées de chaque frame.

```
{
  "frames": {
    "villain_down1": {
      "frame": { "x": 0, "y": 0, "w": 500, "h": 500 },
      "sourceSize": { "w": 500, "h": 500 },
      "spriteSourceSize": { "x": 0, "y": 0, "w": 500, "h": 500 }
    },
    "villain_down2": {
      "frame": { "x": 0, "y": 500, "w": 500, "h": 500 },
      "sourceSize": { "w": 500, "h": 500 },
      "spriteSourceSize": { "x": 0, "y": 0, "w": 500, "h": 500 }
    },
    "villain_down3": {
      "frame": { "x": 0, "y": 1000, "w": 500, "h": 500 },
      "sourceSize": { "w": 500, "h": 500 },
      "spriteSourceSize": { "x": 0, "y": 0, "w": 500, "h": 500 }
    },
    "villain_down4": {
      "frame": { "x": 0, "y": 1500, "w": 500, "h": 500 },
      "sourceSize": { "w": 500, "h": 500 },
      "spriteSourceSize": { "x": 0, "y": 0, "w": 500, "h": 500 }
    },...
}
```

Ci-dessus je récupère les quatre images représentant le vampire orienté vers le bas.
La deuxième partie est la méta, contenant les informations relatives à l'image (comme où elle se trouve par exemple 😜)!

```
"meta": {
    "image": "./villainSpriteSheet.png",
    "format": "RGBA8888",
    "size": { "w": 2000, "h": 2000 },
    "scale": 4.5
  },
```
On va aussi noter la taille de l'image pour référence ainsi que la mise à l'échelle du personnage si besoin (via scale).
Enfin, on va avoir une troisième partie relative aux animations dans laquelle on va grouper les frames listées dans la première partie au sein de leur animation respective.

```
"animations": {
    "down": [
      "villain_down1",
      "villain_down2",
      "villain_down3",
      "villain_down4"
    ],
    "up": ["villain_top1", "villain_top2", "villain_top3", "villain_top4"],
    "left": [
      "villain_left1",
      "villain_left2",
      "villain_left3",
      "villain_left4"
    ],
...
  }
```

Le chargement de cet atlas va permettre d'obtenir une instance de SpriteSheet. Cet objet contiendra toutes les informations ainsi qu'une instance Texture de toutes les images en fonction du découpage en frame!
Une instance de Spritesheet peut alors être utilisée pour instancier un AnimatedSprite en définissant bien quelle animation sera jouée:

```
this.animatedSprite = new AnimatedSprite(spriteSheetInstance.animations[nom_de_l'animation à jouer]);
```
Une instance de AnimatedSprite fonctionne globalement de la même manière qu'un Sprite mais avec quelques fonctionnalités en plus 😃 (après tout ils héritent tous deux de Container et de DisplayObject à travers lui). Ainsi

```
this.animatedSprite.play()
```
va modifier la frame en cours en suivant l'ordre des images constituant l'animation en cours. Il est bien sûr possible de personnaliser l'animation en changeant la vitesse d'animation

```
this.animatedSprite.animationSpeed =0.3;
```
ou encore d'empêcher une animation de boucler 

```
this.animatedSprite.loop = false;
```

Dans le cas où une animation qui ne boucle pas a besoin d'être rejouée, la méthode 

```
this.animatedSprite.gotoAndPlay(numéro_de_frame)
```
permet de jouer une animation à partir d'une frame spécifique. Par exemple 0 si je souhaite qu'elle redémarre du début. 

Bref, Pixi a suffisamment d'outils pour rendre l'animation très simple 💪

## Conclusion

 En conclusion, PixiJS est une bibliothèque JavaScript de rendu 2D extrêmement puissante, qui offre de nombreuses fonctionnalités pour la création de jeux, d'applications interactives et de contenus multimédia. 
C'est une excellente option pour les développeurs qui cherchent à créer des jeux 2D avec des performances élevées, une compatibilité multiplateforme et une grande facilité d'utilisation.
Elle montre aussi à quel point l'utilisation de WebGL, généralement associé à la 3D, peut apporter aux applications 2D en termes de performance et de rendu. 
Cette dépendance à WebGL pour fonctionner pleinement peut quand même se révéler un inconvénient en fonction de la plateforme visée (même si cet inconvénient tend à diminuer à mesure que le temps passe 😁).