# Créer un jeu

Beaucoup de gens veulent écrire des jeux en utilisant three.js. Cet article vous donnera, je l'espère, quelques idées sur la façon de commencer.

Au moment où j'écris cet article, il s'agit probablement de l'article le plus long de ce site. Il est possible que le code ici soit massivement sur-conçu, mais à mesure que j'écrivais chaque nouvelle fonctionnalité, je rencontrais un problème qui nécessitait une solution à laquelle je suis habitué depuis d'autres jeux que j'ai écrits. En d'autres termes, chaque nouvelle solution semblait importante, je vais donc essayer de montrer pourquoi. Bien sûr, plus votre jeu est petit, moins vous pourriez avoir besoin de certaines des solutions présentées ici, mais il s'agit d'un jeu assez petit et pourtant, avec les complexités des personnages 3D, beaucoup de choses demandent plus d'organisation qu'elles ne le feraient avec des personnages 2D.

Par exemple, si vous créez PacMan en 2D, lorsque PacMan tourne dans un coin, cela se produit instantanément à 90 degrés. Il n'y a pas d'étape intermédiaire. Mais dans un jeu 3D, nous avons souvent besoin que le personnage pivote sur plusieurs images. Ce simple changement peut ajouter beaucoup de complexité et nécessiter des solutions différentes.

La majorité du code ici ne sera pas vraiment three.js et c'est important à noter, three.js n'est pas un moteur de jeu. Three.js est une bibliothèque 3D. Elle fournit un graphe de scène et des fonctionnalités pour afficher les objets 3D ajoutés à ce graphe de scène, mais elle ne fournit pas toutes les autres choses nécessaires pour créer un jeu. Pas de collisions, pas de physique, pas de systèmes d'entrée, pas de recherche de chemin, etc., etc... Donc, nous devrons fournir ces choses nous-mêmes.

J'ai fini par écrire pas mal de code pour créer cette simple chose inachevée ressemblant à un jeu, et encore une fois, il est certainement possible que j'aie sur-conçu et qu'il existe des solutions plus simples, mais j'ai l'impression de ne pas avoir écrit assez de code et j'espère pouvoir expliquer ce qui, à mon avis, manque.

Beaucoup des idées ici sont fortement influencées par Unity. Si vous n'êtes pas familier avec Unity, cela n'a probablement pas d'importance. Je n'en parle que parce que des dizaines de milliers de jeux ont été publiés en utilisant ces idées.

Commençons par les parties three.js. Nous devons charger des modèles pour notre jeu.

Sur opengameart.org j'ai trouvé ce modèle de chevalier animé par quaternius

quaternius a également créé ces animaux animés.

Ceux-ci semblent être de bons modèles pour commencer, donc la première chose à faire est de les charger.

Nous avons abordé le chargement de fichiers glTF auparavant. La différence cette fois est que nous devons charger plusieurs modèles et nous ne pouvons pas démarrer le jeu tant que tous les modèles ne sont pas chargés.

Heureusement, three.js fournit le `LoadingManager` juste à cette fin. Nous créons un `LoadingManager` et le passons aux autres chargeurs. Le `LoadingManager` fournit à la fois les propriétés `onProgress` et `onLoad` auxquelles nous pouvons attacher des callbacks. Le callback `onLoad` sera appelé lorsque tous les fichiers auront été chargés. Le callback `onProgress` est appelé après l'arrivée de chaque fichier individuel pour nous donner une chance de montrer la progression du chargement.

En partant du code de chargement d'un fichier glTF, j'ai supprimé tout le code lié au cadrage de la scène et ajouté ce code pour charger tous les modèles.

    const manager = new THREE.LoadingManager();
    manager.onLoad = init;
    const models = {
      pig:    { url: 'resources/models/animals/Pig.gltf' },
      cow:    { url: 'resources/models/animals/Cow.gltf' },
      llama:  { url: 'resources/models/animals/Llama.gltf' },
      pug:    { url: 'resources/models/animals/Pug.gltf' },
      sheep:  { url: 'resources/models/animals/Sheep.gltf' },
      zebra:  { url: 'resources/models/animals/Zebra.gltf' },
      horse:  { url: 'resources/models/animals/Horse.gltf' },
      knight: { url: 'resources/models/knight/KnightCharacter.gltf' },
    };
    {
      const gltfLoader = new GLTFLoader(manager);
      for (const model of Object.values(models)) {
        gltfLoader.load(model.url, (gltf) => {
          model.gltf = gltf;
        });
      }
    }

    function init() {
      // TBD
    }

Ce code chargera tous les modèles ci-dessus et le `LoadingManager` appellera `init` une fois terminé. Nous utiliserons l'objet `models` plus tard pour accéder aux modèles chargés, de sorte que le callback du `GLTFLoader` pour chaque modèle individuel attache les données chargées aux informations de ce modèle.

Tous les modèles avec toutes leurs animations font actuellement environ 6,6 Mo. C'est un téléchargement assez important. En supposant que votre serveur prenne en charge la compression (ce qui est le cas du serveur sur lequel ce site fonctionne), il peut les compresser à environ 1,4 Mo. C'est nettement mieux que 6,6 Mo, mais ce n'est toujours pas une petite quantité de données. Il serait probablement bon d'ajouter une barre de progression pour que l'utilisateur ait une idée du temps qu'il lui reste à attendre.

Alors, ajoutons un callback `onProgress`. Il sera appelé avec 3 arguments : l'`url` du dernier objet chargé, puis le nombre d'éléments chargés jusqu'à présent, ainsi que le nombre total d'éléments.

Mettons en place du code HTML pour une barre de chargement

    <body>
      <canvas id="c"></canvas>
    +  <div id="loading">
    +    <div>
    +      <div>...chargement...</div>
    +      <div class="progress"><div id="progressbar"></div></div>
    +    </div>
    +  </div>
    </body>

Nous allons rechercher la div `#progressbar` et nous pourrons définir la largeur de 0 % à 100 % pour montrer notre progression. Tout ce que nous avons à faire est de définir cela dans notre callback.

    const manager = new THREE.LoadingManager();
    manager.onLoad = init;

    +const progressbarElem = document.querySelector('#progressbar');
    +manager.onProgress = (url, itemsLoaded, itemsTotal) => {
    +  progressbarElem.style.width = `${itemsLoaded / itemsTotal * 100 | 0}%`;
    +};

Nous avons déjà configuré `init` pour être appelé lorsque tous les modèles sont chargés, nous pouvons donc désactiver la barre de progression en masquant l'élément `#loading`.

    function init() {
    +  // masquer la barre de chargement
    +  const loadingElem = document.querySelector('#loading');
    +  loadingElem.style.display = 'none';
    }

Voici un tas de CSS pour styler la barre. Le CSS rend la `#loading` `<div>` de la taille totale de la page et centre ses enfants. Le CSS crée une zone `.progress` pour contenir la barre de progression. Le CSS donne également à la barre de progression une animation CSS de rayures diagonales.

    #loading {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
      align-items: center;
      justify-content: center;
      text-align: center;
      font-size: xx-large;
      font-family: sans-serif;
    }
    #loading>div>div {
      padding: 2px;
    }
    .progress {
      width: 50vw;
      border: 1px solid black;
    }
    #progressbar {
      width: 0;
      transition: width ease-out .5s;
      height: 1em;
      background-color: #888;
      background-image: linear-gradient(
        -45deg,
        rgba(255, 255, 255, .5) 25%,
        transparent 25%,
        transparent 50%,
        rgba(255, 255, 255, .5) 50%,
        rgba(255, 255, 255, .5) 75%,
        transparent 75%,
        transparent
      );
      background-size: 50px 50px;
      animation: progressanim 2s linear infinite;
    }

    @keyframes progressanim {
      0% {
        background-position: 50px 50px;
      }
      100% {
        background-position: 0 0;
      }
    }

Maintenant que nous avons une barre de progression, occupons-nous des modèles. Ces modèles ont des animations et nous voulons pouvoir y accéder. Les animations sont stockées dans un tableau par défaut, mais nous aimerions pouvoir y accéder facilement par leur nom. Configurons donc une propriété `animations` pour chaque modèle afin de faire cela. Notez bien sûr que cela signifie que les animations doivent avoir des noms uniques.

    +function prepModelsAndAnimations() {
    +  Object.values(models).forEach(model => {
    +    const animsByName = {};
    +    model.gltf.animations.forEach((clip) => {
    +      animsByName[clip.name] = clip;
    +    });
    +    model.animations = animsByName;
    +  });
    +}

    function init() {
      // masquer la barre de chargement
      const loadingElem = document.querySelector('#loading');
      loadingElem.style.display = 'none';

    +  prepModelsAndAnimations();
    }

Affichons les modèles animés.

Contrairement à l'exemple précédent de chargement d'un fichier glTF, cette fois-ci, nous voulons probablement pouvoir afficher plus d'une instance de chaque modèle. Pour ce faire, au lieu d'ajouter directement la scène glTF chargée, comme nous l'avons fait dans l'article sur le chargement d'un glTF, nous voulons plutôt cloner la scène et, en particulier, nous voulons la cloner pour les personnages animés avec skinning. Heureusement, il existe une fonction utilitaire, `SkeletonUtils.clone`, que nous pouvons utiliser pour cela. Donc, nous devons d'abord inclure les utilitaires.

    import * as THREE from 'three';
    import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
    +import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';

Ensuite, nous pouvons cloner les modèles que nous venons de charger

    function init() {
      // masquer la barre de chargement
      const loadingElem = document.querySelector('#loading');
      loadingElem.style.display = 'none';

      prepModelsAndAnimations();

    +  Object.values(models).forEach((model, ndx) => {
    +    const clonedScene = SkeletonUtils.clone(model.gltf.scene);
    +    const root = new THREE.Object3D();
    +    root.add(clonedScene);
    +    scene.add(root);
    +    root.position.x = (ndx - 3) * 3;
    +  });
    }

Ci-dessus, pour chaque modèle, nous clonons la `gltf.scene` que nous avons chargée et nous en faisons l'enfant d'un nouveau `Object3D`. Nous devons l'attacher à un autre objet, car lorsque nous jouons des animations, l'animation appliquera des positions animées aux nœuds de la scène chargée, ce qui signifie que nous n'aurons pas le contrôle sur ces positions.

Pour jouer les animations, chaque modèle que nous clonons a besoin d'un `AnimationMixer`. Un `AnimationMixer` contient 1 ou plusieurs `AnimationAction`s. Une `AnimationAction` référence un `AnimationClip`. Les `AnimationAction`s ont toutes sortes de paramètres pour jouer, puis enchaîner avec une autre action ou faire un crossfade entre les actions. Prenons simplement le premier `AnimationClip` et créons une action pour celui-ci. La valeur par défaut est qu'une action joue son clip en boucle indéfiniment.

    +const mixers = [];

    function init() {
      // masquer la barre de chargement
      const loadingElem = document.querySelector('#loading');
      loadingElem.style.display = 'none';

      prepModelsAndAnimations();

      Object.values(models).forEach((model, ndx) => {
        const clonedScene = SkeletonUtils.clone(model.gltf.scene);
        const root = new THREE.Object3D();
        root.add(clonedScene);
        scene.add(root);
        root.position.x = (ndx - 3) * 3;

    +    const mixer = new THREE.AnimationMixer(clonedScene);
    +    const firstClip = Object.values(model.animations)[0];
    +    const action = mixer.clipAction(firstClip);
    +    action.play();
    +    mixers.push(mixer);
      });
    }

Nous avons appelé `play` pour démarrer l'action et stocké tous les `AnimationMixers` dans un tableau appelé `mixers`. Enfin, nous devons mettre à jour chaque `AnimationMixer` dans notre boucle de rendu en calculant le temps écoulé depuis la dernière image et en le passant à `AnimationMixer.update`.

    +let then = 0;
    function render(now) {
    +  now *= 0.001;  // convertir en secondes
    +  const deltaTime = now - then;
    +  then = now;

      if (resizeRendererToDisplaySize(renderer)) {
        const canvas = renderer.domElement;
        camera.aspect = canvas.clientWidth / canvas.clientHeight;
        camera.updateProjectionMatrix();
      }

    +  for (const mixer of mixers) {
    +    mixer.update(deltaTime);
    +  }

      renderer.render(scene, camera);

      requestAnimationFrame(render);
    }

Et avec cela, chaque modèle devrait être chargé et jouer sa première animation.

cliquez ici pour ouvrir dans une nouvelle fenêtre

Faisons en sorte que nous puissions vérifier toutes les animations. Nous ajouterons tous les clips en tant qu'actions, puis nous n'en activerons qu'un seul à la fois.

    -const mixers = [];
    +const mixerInfos = [];

    function init() {
      // masquer la barre de chargement
      const loadingElem = document.querySelector('#loading');
      loadingElem.style.display = 'none';

      prepModelsAndAnimations();

      Object.values(models).forEach((model, ndx) => {
        const clonedScene = SkeletonUtils.clone(model.gltf.scene);
        const root = new THREE.Object3D();
        root.add(clonedScene);
        scene.add(root);
        root.position.x = (ndx - 3) * 3;

        const mixer = new THREE.AnimationMixer(clonedScene);
    -    const firstClip = Object.values(model.animations)[0];
    -    const action = mixer.clipAction(firstClip);
    -    action.play();
    -    mixers.push(mixer);
    +    const actions = Object.values(model.animations).map((clip) => {
    +      return mixer.clipAction(clip);
    +    });
    +    const mixerInfo = {
    +      mixer,
    +      actions,
    +      actionNdx: -1,
    +    };
    +    mixerInfos.push(mixerInfo);
    +    playNextAction(mixerInfo);
      });
    }

    +function playNextAction(mixerInfo) {
    +  const {actions, actionNdx} = mixerInfo;
    +  const nextActionNdx = (actionNdx + 1) % actions.length;
    +  mixerInfo.actionNdx = nextActionNdx;
    +  actions.forEach((action, ndx) => {
    +    const enabled = ndx === nextActionNdx;
    +    action.enabled = enabled;
    +    if (enabled) {
    +      action.play();
    +    }
    +  });
    +}

Le code ci-dessus crée un tableau de `AnimationAction`s, une pour chaque `AnimationClip`. Il crée un tableau d'objets, `mixerInfos`, avec des références au `AnimationMixer` et à toutes les `AnimationAction`s pour chaque modèle. Il appelle ensuite `playNextAction` qui définit la propriété `enabled` à l'exception d'une seule action pour ce mixeur.

Nous devons mettre à jour la boucle de rendu pour le nouveau tableau

    -for (const mixer of mixers) {
    +for (const {mixer} of mixerInfos) {
      mixer.update(deltaTime);
    }

Faisons en sorte qu'en appuyant sur une touche de 1 à 8, l'animation suivante soit jouée pour chaque modèle

    window.addEventListener('keydown', (e) => {
      const mixerInfo = mixerInfos[e.keyCode - 49];
      if (!mixerInfo) {
        return;
      }
      playNextAction(mixerInfo);
    });

Maintenant, vous devriez pouvoir cliquer sur l'exemple, puis appuyer sur les touches 1 à 8 pour faire défiler chaque modèle à travers ses animations disponibles.

cliquez ici pour ouvrir dans une nouvelle fenêtre

On peut donc dire que c'est la somme totale de la partie three.js de cet article. Nous avons abordé le chargement de plusieurs fichiers, le clonage de modèles skinnés, et la lecture d'animations sur ceux-ci. Dans un vrai jeu, vous auriez beaucoup plus de manipulations à faire sur les objets `AnimationAction`.

Commençons à créer une infrastructure de jeu

Un modèle courant pour créer un jeu moderne est d'utiliser un Entity Component System. Dans un Entity Component System, un objet dans un jeu est appelé une entité qui se compose d'un ensemble de composants. Vous construisez des entités en décidant quels composants leur attacher. Alors, créons un Entity Component System.

Nous appellerons nos entités `GameObject`. C'est effectivement juste une collection de composants et un `Object3D` de three.js.

    function removeArrayElement(array, element) {
      const ndx = array.indexOf(element);
      if (ndx >= 0) {
        array.splice(ndx, 1);
      }
    }

    class GameObject {
      constructor(parent, name) {
        this.name = name;
        this.components = [];
        this.transform = new THREE.Object3D();
        parent.add(this.transform);
      }
      addComponent(ComponentType, ...args) {
        const component = new ComponentType(this, ...args);
        this.components.push(component);
        return component;
      }
      removeComponent(component) {
        removeArrayElement(this.components, component);
      }
      getComponent(ComponentType) {
        return this.components.find(c => c instanceof ComponentType);
      }
      update() {
        for (const component of this.components) {
          component.update();
        }
      }
    }

L'appel de `GameObject.update` appelle la fonction `update` sur tous les composants.

J'ai inclus un nom uniquement pour faciliter le débogage, de sorte que si j'examine un `GameObject` dans le débogueur, je puisse voir un nom pour l'aider à l'identifier.

Quelques choses qui pourraient sembler un peu étranges :

`GameObject.addComponent` est utilisé pour créer des composants. Que ce soit une bonne ou une mauvaise idée, je ne suis pas sûr. Ma pensée était qu'il n'a aucun sens pour un composant d'exister en dehors d'un gameobject, alors j'ai pensé qu'il pourrait être bon que la création d'un composant ajoute automatiquement ce composant au gameobject et passe le gameobject au constructeur du composant. En d'autres termes, pour ajouter un composant, vous faites ceci

    const gameObject = new GameObject(scene, 'foo');
    gameObject.addComponent(TypeOfComponent);

Si je ne le faisais pas de cette façon, vous feriez plutôt quelque chose comme ceci

    const gameObject = new GameObject(scene, 'foo');
    const component = new TypeOfComponent(gameObject);
    gameObject.addComponent(component);

Est-ce mieux que la première méthode soit plus courte et plus automatisée, ou est-ce pire parce que cela sort de l'ordinaire ? Je ne sais pas.

`GameObject.getComponent` recherche les composants par type. Cela implique que vous ne pouvez pas avoir 2 composants du même type sur un seul objet de jeu, ou du moins si vous en avez, vous ne pouvez rechercher que le premier sans ajouter une autre API.

Il est courant qu'un composant en recherche un autre, et lors de cette recherche, ils doivent correspondre par type, sinon vous pourriez obtenir le mauvais. Nous pourrions à la place donner un nom à chaque composant et vous pourriez les rechercher par leur nom. Ce serait plus flexible car vous pourriez avoir plus d'un composant du même type, mais ce serait aussi plus fastidieux. Encore une fois, je ne suis pas sûr de ce qui est le mieux.

Passons aux composants eux-mêmes. Voici leur classe de base.

    // Base pour tous les composants
    class Component {
      constructor(gameObject) {
        this.gameObject = gameObject;
      }
      update() {
      }
    }

Les composants ont-ils besoin d'une classe de base ? JavaScript n'est pas comme la plupart des langages strictement typés, donc en pratique, nous pourrions ne pas avoir de classe de base et laisser chaque composant faire ce qu'il veut dans son constructeur, sachant que le premier argument est toujours le gameobject du composant. S'il ne se soucie pas du gameobject, il ne le stockerait pas. J'ai un peu l'impression que cette base commune est bonne cependant. Cela signifie que si vous avez une référence à un composant, vous savez que vous pouvez toujours trouver son gameobject parent, et à partir de son parent, vous pouvez facilement rechercher d'autres composants ainsi que regarder sa transformation.

Pour gérer les gameobjects, nous avons probablement besoin d'une sorte de gestionnaire de gameobjects. Vous pourriez penser que nous pourrions simplement garder un tableau de gameobjects, mais dans un vrai jeu, les composants d'un gameobject pourraient ajouter et supprimer d'autres gameobjects pendant l'exécution. Par exemple, un gameobject d'arme pourrait ajouter un gameobject de balle chaque fois que l'arme tire. Un gameobject de monstre pourrait se supprimer s'il a été tué. Nous aurions alors un problème : nous pourrions avoir du code comme ceci

    for (const gameObject of globalArrayOfGameObjects) {
      gameObject.update();
    }

La boucle ci-dessus échouerait ou ferait des choses inattendues si des gameobjects étaient ajoutés ou supprimés de `globalArrayOfGameObjects` au milieu de la boucle dans la fonction `update` d'un composant.

Pour essayer de prévenir ce problème, nous avons besoin de quelque chose d'un peu plus sûr. Voici une tentative.

    class SafeArray {
      constructor() {
        this.array = [];
        this.addQueue = [];
        this.removeQueue = new Set();
      }
      get isEmpty() {
        return this.addQueue.length + this.array.length > 0;
      }
      add(element) {
        this.addQueue.push(element);
      }
      remove(element) {
        this.removeQueue.add(element);
      }
      forEach(fn) {
        this._addQueued();
        this._removeQueued();
        for (const element of this.array) {
          if (this.removeQueue.has(element)) {
            continue;
          }
          fn(element);
        }
        this._removeQueued();
      }
      _addQueued() {
        if (this.addQueue.length) {
          this.array.splice(this.array.length, 0, ...this.addQueue);
          this.addQueue = [];
        }
      }
      _removeQueued() {
        if (this.removeQueue.size) {
          this.array = this.array.filter(element => !this.removeQueue.has(element));
          this.removeQueue.clear();
        }
      }
    }

La classe ci-dessus vous permet d'ajouter ou de supprimer des éléments du `SafeArray` sans altérer le tableau lui-même pendant qu'il est parcouru. Au lieu de cela, les nouveaux éléments sont ajoutés à `addQueue` et les éléments supprimés à `removeQueue`, puis ajoutés ou supprimés en dehors de la boucle.

En utilisant cela, voici notre classe pour gérer les gameobjects.

    class GameObjectManager {
      constructor() {
        this.gameObjects = new SafeArray();
      }
      createGameObject(parent, name) {
        const gameObject = new GameObject(parent, name);
        this.gameObjects.add(gameObject);
        return gameObject;
      }
      removeGameObject(gameObject) {
        this.gameObjects.remove(gameObject);
      }
      update() {
        this.gameObjects.forEach(gameObject => gameObject.update());
      }
    }

Avec tout cela, créons maintenant notre premier composant. Ce composant gérera simplement un objet three.js skinné comme ceux que nous venons de créer. Pour rester simple, il n'aura qu'une seule méthode, `setAnimation`, qui prend le nom de l'animation à jouer et la lance.

    class SkinInstance extends Component {
      constructor(gameObject, model) {
        super(gameObject);
        this.model = model;
        this.animRoot = SkeletonUtils.clone(this.model.gltf.scene);
        this.mixer = new THREE.AnimationMixer(this.animRoot);
        gameObject.transform.add(this.animRoot);
        this.actions = {};
      }
      setAnimation(animName) {
        const clip = this.model.animations[animName];
        // turn off all current actions
        for (const action of Object.values(this.actions)) {
          action.enabled = false;
        }
        // get or create existing action for clip
        const action = this.mixer.clipAction(clip);
        action.enabled = true;
        action.reset();
        action.play();
        this.actions[animName] = action;
      }
      update() {
        this.mixer.update(globals.deltaTime);
      }
    }

Vous pouvez voir qu'il s'agit essentiellement du code que nous avions auparavant qui clone la scène que nous avons chargée, puis configure un `AnimationMixer`. `setAnimation` ajoute une `AnimationAction` pour un `AnimationClip` particulier s'il n'existe pas déjà, et désactive toutes les actions existantes.

Le code référence `globals.deltaTime`. Créons un objet globals

    const globals = {
      time: 0,
      deltaTime: 0,
    };

Et mettons-le à jour dans la boucle de rendu

    let then = 0;
    function render(now) {
      // convertir en secondes
      globals.time = now * 0.001;
      // s'assurer que le temps delta n'est pas trop grand.
      globals.deltaTime = Math.min(globals.time - then, 1 / 20);
      then = globals.time;

La vérification ci-dessus pour s'assurer que `deltaTime` ne dépasse pas 1/20ème de seconde est due au fait que, sinon, nous obtiendrions une valeur énorme pour `deltaTime` si nous masquions l'onglet. Nous pourrions le masquer pendant des secondes ou des minutes, et ensuite, lorsque notre onglet serait ramené au premier plan, `deltaTime` serait énorme et pourrait téléporter des personnages à travers notre monde de jeu si nous avions du code comme

    position += velocity * deltaTime;

En limitant le maximum `deltaTime`, ce problème est évité.

Créons maintenant un composant pour le joueur.

    +const kForward = new THREE.Vector3(0, 0, 1);
    const globals = {
      time: 0,
      deltaTime: 0,
    +  moveSpeed: 16,
    };

    class Player extends Component {
      constructor(gameObject) {
        super(gameObject);
        const model = models.knight;
        this.skinInstance = gameObject.addComponent(SkinInstance, model);
        this.skinInstance.setAnimation('Run');
    +    this.turnSpeed = globals.moveSpeed / 4;
      }
    +  update() {
    +    const {deltaTime, moveSpeed} = globals;
    +    const {transform} = this.gameObject;
    +    const delta = (inputManager.keys.left.down  ?  1 : 0) +
    +                  (inputManager.keys.right.down ? -1 : 0);
    +    transform.rotation.y += this.turnSpeed * delta * deltaTime;
    +    transform.translateOnAxis(kForward, moveSpeed * deltaTime);
    +  }
    }

Le code ci-dessus utilise `Object3D.transformOnAxis` pour faire avancer le joueur. `Object3D.transformOnAxis` fonctionne dans l'espace local, il ne fonctionne donc que si l'objet en question est à la racine de la scène, pas s'il est un enfant de quelque chose d'autre 1

Nous avons également ajouté une vitesse de déplacement globale (`moveSpeed`) et basé une vitesse de rotation (`turnSpeed`) sur la vitesse de déplacement. La vitesse de rotation est basée sur la vitesse de déplacement pour essayer de s'assurer qu'un personnage peut tourner assez brusquement pour atteindre sa cible. Si `turnSpeed` est trop faible, un personnage tournera en rond autour de sa cible sans jamais l'atteindre. Je n'ai pas pris la peine de faire les calculs pour déterminer la vitesse de rotation requise pour une vitesse de déplacement donnée. J'ai juste deviné.

Le code jusqu'à présent fonctionnerait, mais si le joueur sort de l'écran, il n'y a aucun moyen de savoir où il se trouve. Faisons en sorte que s'il est hors écran pendant plus d'un certain temps, il soit téléporté à l'origine. Nous pouvons le faire en utilisant la classe `Frustum` de three.js pour vérifier si un point est à l'intérieur du frustum de vue de la caméra.

Nous devons construire un frustum à partir de la caméra. Nous pourrions le faire dans le composant Player, mais d'autres objets pourraient vouloir l'utiliser également, alors ajoutons un autre gameobject avec un composant pour gérer un frustum.

    class CameraInfo extends Component {
      constructor(gameObject) {
        super(gameObject);
        this.projScreenMatrix = new THREE.Matrix4();
        this.frustum = new THREE.Frustum();
      }
      update() {
        const {camera} = globals;
        this.projScreenMatrix.multiplyMatrices(
            camera.projectionMatrix,
            camera.matrixWorldInverse);
        this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
      }
    }

Configurons ensuite un autre gameobject au moment de l'initialisation.

    function init() {
      // masquer la barre de chargement
      const loadingElem = document.querySelector('#loading');
      loadingElem.style.display = 'none';

      prepModelsAndAnimations();

    +  {
    +    const gameObject = gameObjectManager.createGameObject(camera, 'camera');
    +    globals.cameraInfo = gameObject.addComponent(CameraInfo);
    +  }

      {
        const gameObject = gameObjectManager.createGameObject(scene, 'player');
        gameObject.addComponent(Player);
      }
    }

et maintenant nous pouvons l'utiliser dans le composant `Player`.

    class Player extends Component {
      constructor(gameObject) {
        super(gameObject);
        const model = models.knight;
        this.skinInstance = gameObject.addComponent(SkinInstance, model);
        this.skinInstance.setAnimation('Run');
        this.turnSpeed = globals.moveSpeed / 4;
    +    this.offscreenTimer = 0;
    +    this.maxTimeOffScreen = 3;
      }
      update() {
    -    const {deltaTime, moveSpeed} = globals;
    +    const {deltaTime, moveSpeed, cameraInfo} = globals;
        const {transform} = this.gameObject;
        const delta = (inputManager.keys.left.down  ?  1 : 0) +
                      (inputManager.keys.right.down ? -1 : 0);
        transform.rotation.y += this.turnSpeed * delta * deltaTime;
        transform.translateOnAxis(kForward, moveSpeed * deltaTime);

    +    const {frustum} = cameraInfo;
    +    if (frustum.containsPoint(transform.position)) {
    +      this.offscreenTimer = 0;
    +    } else {
    +      this.offscreenTimer += deltaTime;
    +      if (this.offscreenTimer >= this.maxTimeOffScreen) {
    +        transform.position.set(0, 0, 0);
    +      }
    +    }
      }
    }

Une dernière chose avant d'essayer, ajoutons le support des écrans tactiles pour mobile. Tout d'abord, ajoutons un peu de code HTML pour le toucher

    <body>
      <canvas id="c"></canvas>
    +  <div id="ui">
    +    <div id="left"><img src="../resources/images/left.svg"></div>
    +    <div style="flex: 0 0 40px;"></div>
    +    <div id="right"><img src="../resources/images/right.svg"></div>
    +  </div>
      <div id="loading">
        <div>
          <div>...chargement...</div>
          <div class="progress"><div id="progressbar"></div></div>
        </div>
      </div>
    +  <div id="labels"></div>
    </body>

et un peu de CSS pour le styler

    #ui {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
      justify-items: center;
      align-content: stretch;
    }
    #ui>div {
      display: flex;
      align-items: flex-end;
      flex: 1 1 auto;
    }
    .bright {
      filter: brightness(2);
    }
    #left {
      justify-content: flex-end;
    }
    #right {
      justify-content: flex-start;
    }
    #ui img {
      padding: 10px;
      width: 80px;
      height: 80px;
      display: block;
    }
    #labels {
      position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
      left: 0;             /* fait que notre position est en haut à gauche du conteneur */
      top: 0;
      color: white;
      width: 100%;
      height: 100%;
      overflow: hidden;
      pointer-events: none;
    }
    #labels>div {
      position: absolute;  /* nous permet de les positionner à l'intérieur du conteneur */
      left: 0;             /* fait que leur position par défaut est en haut à gauche du conteneur */
      top: 0;
      font-size: large;
      font-family: monospace;
      user-select: none;   /* n'autorise pas la sélection du texte */
      text-shadow:         /* crée un contour noir */
        -1px -1px 0 #000,
         0   -1px 0 #000,
         1px -1px 0 #000,
         1px  0   0 #000,
         1px  1px 0 #000,
         0    1px 0 #000,
        -1px  1px 0 #000,
        -1px  0   0 #000;
    }

L'idée ici est d'avoir une div, `#ui`, qui couvre toute la page. À l'intérieur, il y aura 2 divs, `#left` et `#right`, chacune occupant près de la moitié de la largeur de la page et toute la hauteur de l'écran. Entre les deux, il y a un séparateur de 40px. Si l'utilisateur glisse son doigt sur le côté gauche ou droit, nous devons mettre à jour `keys.left` et `keys.right` dans l'`InputManager`. Cela rend tout l'écran sensible au toucher, ce qui semble mieux que de simples petites flèches.

    class InputManager {
      constructor() {
        this.keys = {};
        const keyMap = new Map();

        const setKey = (keyName, pressed) => {
          const keyState = this.keys[keyName];
          keyState.justPressed = pressed && !keyState.down;
          keyState.down = pressed;
        };

        const addKey = (keyCode, name) => {
          this.keys[name] = { down: false, justPressed: false };
          keyMap.set(keyCode, name);
        };

        const setKeyFromKeyCode = (keyCode, pressed) => {
          const keyName = keyMap.get(keyCode);
          if (!keyName) {
            return;
          }
          setKey(keyName, pressed);
        };

        addKey(37, 'left');
        addKey(39, 'right');
        addKey(38, 'up');
        addKey(40, 'down');
        addKey(90, 'a');
        addKey(88, 'b');

        window.addEventListener('keydown', (e) => {
          setKeyFromKeyCode(e.keyCode, true);
        });
        window.addEventListener('keyup', (e) => {
          setKeyFromKeyCode(e.keyCode, false);
        });

    +    const sides = [
    +      { elem: document.querySelector('#left'),  key: 'left'  },
    +      { elem: document.querySelector('#right'), key: 'right' },
    +    ];
    +
    +    const clearKeys = () => {
    +      for (const {key} of sides) {
    +          setKey(key, false);
    +      }
    +    };
    +
    +    const handleMouseMove = (e) => {
    +      e.preventDefault();
    +      // ceci est nécessaire car nous appelons preventDefault();
    +      // nous avons également donné au canvas un tabindex afin qu'il puisse
    +      // obtenir le focus
    +      canvas.focus();
    +      window.addEventListener('pointermove', handleMouseMove);
    +      window.addEventListener('pointerup', handleMouseUp);
    +
    +      for (const {elem, key} of sides) {
    +        let pressed = false;
    +        const rect = elem.getBoundingClientRect();
    +        const x = e.clientX;
    +        const y = e.clientY;
    +        const inRect = x >= rect.left && x < rect.right &&
    +                       y >= rect.top && y < rect.bottom;
    +        if (inRect) {
    +          pressed = true;
    +        }
    +        setKey(key, pressed);
    +      }
    +    };
    +
    +    function handleMouseUp() {
    +      clearKeys();
    +      window.removeEventListener('pointermove', handleMouseMove, {passive: false});
    +      window.removeEventListener('pointerup', handleMouseUp);
    +    }
    +
    +    const uiElem = document.querySelector('#ui');
    +    uiElem.addEventListener('pointerdown', handleMouseMove, {passive: false});
    +
    +    uiElem.addEventListener('touchstart', (e) => {
    +      // empêcher le défilement
    +      e.preventDefault();
    +    }, {passive: false});
      }
      update() {
        for (const keyState of Object.values(this.keys)) {
          if (keyState.justPressed) {
            keyState.justPressed = false;
          }
        }
      }
    }

Et maintenant, nous devrions pouvoir contrôler le personnage avec les touches curseur gauche et droite ou avec nos doigts sur un écran tactile.

cliquez ici pour ouvrir dans une nouvelle fenêtre

Idéalement, nous ferions quelque chose d'autre si le joueur sortait de l'écran, comme déplacer la caméra ou peut-être considérer que hors écran = mort, mais cet article va déjà être trop long, donc pour l'instant, se téléporter au milieu était la chose la plus simple.

Ajoutons quelques animaux. Nous pouvons commencer de manière similaire au `Player` en créant un composant `Animal`.

    class Animal extends Component {
      constructor(gameObject, model) {
        super(gameObject);
        const skinInstance = gameObject.addComponent(SkinInstance, model);
        skinInstance.mixer.timeScale = globals.moveSpeed / 4;
        skinInstance.setAnimation('Idle');
      }
    }

Le code ci-dessus définit le `AnimationMixer.timeScale` pour régler la vitesse de lecture des animations par rapport à la vitesse de déplacement. De cette façon, si nous ajustons la vitesse de déplacement, l'animation accélérera ou ralentira également.

Pour commencer, nous pourrions configurer un animal de chaque type

    function init() {
      // masquer la barre de chargement
      const loadingElem = document.querySelector('#loading');
      loadingElem.style.display = 'none';

      prepModelsAndAnimations();
      {
        const gameObject = gameObjectManager.createGameObject(camera, 'camera');
        globals.cameraInfo = gameObject.addComponent(CameraInfo);
      }

      {
        const gameObject = gameObjectManager.createGameObject(scene, 'player');
        globals.player = gameObject.addComponent(Player);
        globals.congaLine = [gameObject];
      }

    +  const animalModelNames = [
    +    'pig',
    +    'cow',
    +    'llama',
    +    'pug',
    +    'sheep',
    +    'zebra',
    +    'horse',
    +  ];
    +  animalModelNames.forEach((name, ndx) => {
    +    const gameObject = gameObjectManager.createGameObject(scene, name);
    +    gameObject.addComponent(Animal, models[name]);
    +    gameObject.transform.position.x = (ndx + 1) * 5;
    +  });
    }

Et cela nous donnerait des animaux debout à l'écran, mais nous voulons qu'ils fassent quelque chose.

Faisons en sorte qu'ils suivent le joueur en file indienne, mais seulement si le joueur s'approche suffisamment. Pour cela, nous avons besoin de plusieurs états.

- Inactif :

  L'animal attend que le joueur s'approche

- Attendre la fin de la ligne :

  L'animal a été "tagué" par le joueur, mais doit maintenant attendre que l'animal au bout de la ligne arrive pour pouvoir rejoindre la fin de la ligne.

- Aller au dernier :

  L'animal doit marcher jusqu'à l'endroit où se trouvait l'animal qu'il suit, tout en enregistrant un historique de la position actuelle de l'animal qu'il suit.

- Suivre

  L'animal doit continuer à enregistrer un historique de la position de l'animal qu'il suit tout en se déplaçant vers l'endroit où se trouvait cet animal auparavant.

Il existe de nombreuses façons de gérer différents états comme ceux-ci. Une méthode courante consiste à utiliser une machine à états finis (Finite State Machine) et à construire une classe pour nous aider à gérer l'état.

Alors, faisons cela.

    class FiniteStateMachine {
      constructor(states, initialState) {
        this.states = states;
        this.transition(initialState);
      }
      get state() {
        return this.currentState;
      }
      transition(state) {
        const oldState = this.states[this.currentState];
        if (oldState && oldState.exit) {
          oldState.exit.call(this);
        }
        this.currentState = state;
        const newState = this.states[state];
        if (newState.enter) {
          newState.enter.call(this);
        }
      }
      update() {
        const state = this.states[this.currentState];
        if (state.update) {
          state.update.call(this);
        }
      }
    }

Voici une classe simple. Nous lui passons un objet contenant un ensemble d'états. Chaque état a 3 fonctions optionnelles : `enter`, `update` et `exit`. Pour changer d'état, nous appelons `FiniteStateMachine.transition` et lui passons le nom du nouvel état. Si l'état actuel a une fonction `exit`, elle est appelée. Ensuite, si le nouvel état a une fonction `enter`, elle est appelée. Enfin, à chaque image, `FiniteStateMachine.update` appelle la fonction `update` de l'état actuel.

Utilisons-le pour gérer les états des animaux.

    // Retourne vrai si obj1 et obj2 sont proches
    function isClose(obj1, obj1Radius, obj2, obj2Radius) {
      const minDist = obj1Radius + obj2Radius;
      const dist = obj1.position.distanceTo(obj2.position);
      return dist < minDist;
    }

    // maintient v entre -min et +min
    function minMagnitude(v, min) {
      return Math.abs(v) > min
          ? min * Math.sign(v)
          : v;
    }

    const aimTowardAndGetDistance = function() {
      const delta = new THREE.Vector3();

      return function aimTowardAndGetDistance(source, targetPos, maxTurn) {
        delta.subVectors(targetPos, source.position);
        // calculer la direction dans laquelle nous voulons faire face
        const targetRot = Math.atan2(delta.x, delta.z) + Math.PI * 1.5;
        // tourner dans la direction la plus courte
        const deltaRot = (targetRot - source.rotation.y + Math.PI * 1.5) % (Math.PI * 2) - Math.PI;
        // s'assurer que nous ne tournons pas plus vite que maxTurn
        const deltaRotation = minMagnitude(deltaRot, maxTurn);
        // maintenir la rotation entre 0 et Math.PI * 2
        source.rotation.y = THREE.MathUtils.euclideanModulo(
            source.rotation.y + deltaRotation, Math.PI * 2);
        // retourner la distance à la cible
        return delta.length();
      };
    }();

    class Animal extends Component {
      constructor(gameObject, model) {
        super(gameObject);
    +    const hitRadius = model.size / 2;
        const skinInstance = gameObject.addComponent(SkinInstance, model);
        skinInstance.mixer.timeScale = globals.moveSpeed / 4;
    +    const transform = gameObject.transform;
    +    const playerTransform = globals.player.gameObject.transform;
    +    const maxTurnSpeed = Math.PI * (globals.moveSpeed / 4);
    +    const targetHistory = [];
    +    let targetNdx = 0;
    +
    +    function addHistory() {
    +      const targetGO = globals.congaLine[targetNdx];
    +      const newTargetPos = new THREE.Vector3();
    +      newTargetPos.copy(targetGO.transform.position);
    +      targetHistory.push(newTargetPos);
    +    }
    +
    +    this.fsm = new FiniteStateMachine({
    +      idle: {
    +        enter: () => {
    +          skinInstance.setAnimation('Idle');
    +        },
    +        update: () => {
    +          // vérifier si le joueur est proche
    +          if (isClose(transform, hitRadius, playerTransform, globals.playerRadius)) {
    +            this.fsm.transition('waitForEnd');
    +          }
    +        },
    +      },
    +      waitForEnd: {
    +        enter: () => {
    +          skinInstance.setAnimation('Jump');
    +        },
    +        update: () => {
    +          // obtenir le gameObject à la fin de la file indienne
    +          const lastGO = globals.congaLine[globals.congaLine.length - 1];
    +          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
    +          const targetPos = lastGO.transform.position;
    +          aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
    +          // vérifier si le dernier élément de la file indienne est proche
    +          if (isClose(transform, hitRadius, lastGO.transform, globals.playerRadius)) {
    +            this.fsm.transition('goToLast');
    +          }
    +        },
    +      },
    +      goToLast: {
    +        enter: () => {
    +          // se souvenir de qui nous suivons
    +          targetNdx = globals.congaLine.length - 1;
    +          // nous ajouter à la file indienne
    +          globals.congaLine.push(gameObject);
    +          skinInstance.setAnimation('Walk');
    +        },
    +        update: () => {
    +          addHistory();
    +          // marcher jusqu'au point le plus ancien de l'historique
    +          const targetPos = targetHistory[0];
    +          const maxVelocity = globals.moveSpeed * globals.deltaTime;
    +          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
    +          const distance = aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
    +          const velocity = distance;
    +          transform.translateOnAxis(kForward, Math.min(velocity, maxVelocity));
    +          if (distance <= maxVelocity) {
    +            this.fsm.transition('follow');
    +          }
    +        },
    +      },
    +      follow: {
    +        update: () => {
    +          addHistory();
    +          // supprimer l'historique le plus ancien et nous placer simplement là.
    +          const targetPos = targetHistory.shift();
    +          transform.position.copy(targetPos);
    +          const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
    +          aimTowardAndGetDistance(transform, targetHistory[0], deltaTurnSpeed);
    +        },
    +      },
    +    }, 'idle');
    +  }
    +  update() {
    +    this.fsm.update();
    +  }
    }

C'était un gros morceau de code, mais il fait ce qui a été décrit ci-dessus. J'espère que si vous parcourez chaque état, ce sera clair.

Quelques choses que nous devons ajouter. Nous devons faire en sorte que le joueur s'ajoute aux variables globales afin que les animaux puissent le trouver, et nous devons commencer la file indienne avec le `GameObject` du joueur.

    function init() {

      ...

      {
        const gameObject = gameObjectManager.createGameObject(scene, 'player');
    +    globals.player = gameObject.addComponent(Player);
    +    globals.congaLine = [gameObject];
      }

    }

Nous devons également calculer une taille pour chaque modèle

    function prepModelsAndAnimations() {
    +  const box = new THREE.Box3();
    +  const size = new THREE.Vector3();
      Object.values(models).forEach(model => {
    +    box.setFromObject(model.gltf.scene);
    +    box.getSize(size);
    +    model.size = size.length();
        const animsByName = {};
        model.gltf.animations.forEach((clip) => {
          animsByName[clip.name] = clip;
          // Devrait vraiment être corrigé dans le fichier .blend
          if (clip.name === 'Walk') {
            clip.duration /= 2;
          }
        });
        model.animations = animsByName;
      });
    }

Et nous avons besoin que le joueur enregistre sa taille

    class Player extends Component {
      constructor(gameObject) {
        super(gameObject);
        const model = models.knight;
    +    globals.playerRadius = model.size / 2;

En y pensant maintenant, il aurait probablement été plus judicieux que les animaux ciblent simplement la tête de la file indienne au lieu du joueur spécifiquement. Peut-être que je reviendrai dessus et modifierai cela plus tard.

Lorsque j'ai commencé cela, j'ai utilisé un seul rayon pour tous les animaux, mais bien sûr, ce n'était pas bon, car le carlin est beaucoup plus petit que le cheval. J'ai donc ajouté les différentes tailles, mais je voulais pouvoir visualiser les choses. Pour ce faire, j'ai créé un composant `StatusDisplayHelper`.

J'utilise un `PolarGridHelper` pour dessiner un cercle autour de chaque personnage, et il utilise des éléments html pour permettre à chaque personnage d'afficher un certain statut en utilisant les techniques couvertes dans l'article sur l'alignement des éléments html en 3D.

Nous devons d'abord ajouter du code HTML pour héberger ces éléments

    <body>
      <canvas id="c"></canvas>
      <div id="ui">
        <div id="left"><img src="../resources/images/left.svg"></div>
        <div style="flex: 0 0 40px;"></div>
        <div id="right"><img src="../resources/images/right.svg"></div>
      </div>
      <div id="loading">
        <div>
          <div>...chargement...</div>
          <div class="progress"><div id="progressbar"></div></div>
        </div>
      </div>
    +  <div id="labels"></div>
    </body>

Et ajouter du CSS pour eux

    #ui {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
      justify-items: center;
      align-content: stretch;
    }
    #ui>div {
      display: flex;
      align-items: flex-end;
      flex: 1 1 auto;
    }
    .bright {
      filter: brightness(2);
    }
    #left {
      justify-content: flex-end;
    }
    #right {
      justify-content: flex-start;
    }
    #ui img {
      padding: 10px;
      width: 80px;
      height: 80px;
      display: block;
    }
    #labels {
      position: absolute;  /* nous permet de nous positionner à l'intérieur du conteneur */
      left: 0;             /* fait que notre position est en haut à gauche du conteneur */
      top: 0;
      color: white;
      width: 100%;
      height: 100%;
      overflow: hidden;
      pointer-events: none;
    }
    #labels>div {
      position: absolute;  /* nous permet de les positionner à l'intérieur du conteneur */
      left: 0;             /* fait que leur position par défaut est en haut à gauche du conteneur */
      top: 0;
      font-size: large;
      font-family: monospace;
      user-select: none;   /* n'autorise pas la sélection du texte */
      text-shadow:         /* crée un contour noir */
        -1px -1px 0 #000,
         0   -1px 0 #000,
         1px -1px 0 #000,
         1px  0   0 #000,
         1px  1px 0 #000,
         0    1px 0 #000,
        -1px  1px 0 #000,
        -1px  0   0 #000;
    }

Voici ensuite le composant

    const labelContainerElem = document.querySelector('#labels');

    class StateDisplayHelper extends Component {
      constructor(gameObject, size) {
        super(gameObject);
        this.elem = document.createElement('div');
        labelContainerElem.appendChild(this.elem);
        this.pos = new THREE.Vector3();

        this.helper = new THREE.PolarGridHelper(size / 2, 1, 1, 16);
        gameObject.transform.add(this.helper);
      }
      setState(s) {
        this.elem.textContent = s;
      }
      setColor(cssColor) {
        this.elem.style.color = cssColor;
        this.helper.material.color.set(cssColor);
      }
      update() {
        const {pos} = this;
        const {transform} = this.gameObject;
        const {canvas} = globals;
        pos.copy(transform.position);

        // obtenir les coordonnées d'écran normalisées de cette position
        // x et y seront dans la plage -1 à +1, avec x = -1 étant
        // à gauche et y = -1 étant en bas
        pos.project(globals.camera);

        // convertir la position normalisée en coordonnées CSS
        const x = (pos.x *  .5 + .5) * canvas.clientWidth;
        const y = (pos.y * -.5 + .5) * canvas.clientHeight;

        // déplacer l'élément à cette position
        this.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
      }
    }

Et nous pouvons ensuite les ajouter aux animaux comme ceci

    class Animal extends Component {
      constructor(gameObject, model) {
        super(gameObject);
    +    this.helper = gameObject.addComponent(StateDisplayHelper, model.size);

         ...

      }
      update() {
        this.fsm.update();
    +    const dir = THREE.MathUtils.radToDeg(this.gameObject.transform.rotation.y);
    +    this.helper.setState(`${this.fsm.state}:${dir.toFixed(0)}`);
      }
    }

Pendant que nous y sommes, faisons en sorte que nous puissions les activer/désactiver en utilisant lil-gui, comme nous l'avons fait ailleurs.

    import * as THREE from 'three';
    import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
    import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
    import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
    +import {GUI} from 'three/addons/libs/lil-gui.module.min.js';

    +const gui = new GUI();
    +gui.add(globals, 'debug').onChange(showHideDebugInfo);
    +showHideDebugInfo();

    const labelContainerElem = document.querySelector('#labels');
    +function showHideDebugInfo() {
    +  labelContainerElem.style.display = globals.debug ? '' : 'none';
    +}
    +showHideDebugInfo();

    class StateDisplayHelper extends Component {

      ...

      update() {
    +    this.helper.visible = globals.debug;
    +    if (!globals.debug) {
    +      return;
    +    }

        ...
      }
    }

Et avec cela, nous obtenons une sorte de début de jeu.

cliquez ici pour ouvrir dans une nouvelle fenêtre

À l'origine, j'avais l'intention de créer un jeu de serpent où, à mesure que vous ajoutez des animaux à votre ligne, cela devient plus difficile car vous devez éviter de les heurter. J'aurais également placé des obstacles dans la scène et peut-être une clôture ou une sorte de barrière autour du périmètre.

Malheureusement, les animaux sont longs et minces. Vu d'en haut, voici le zèbre.

Le code jusqu'à présent utilise des collisions circulaires, ce qui signifie que si nous avions des obstacles comme une clôture, cela serait considéré comme une collision

Ce n'est pas bon. Même d'animal à animal, nous aurions le même problème.

J'ai pensé à écrire un système de collision rectangle à rectangle en 2D, mais j'ai rapidement réalisé que cela pourrait vraiment représenter beaucoup de code. Vérifier si 2 boîtes orientées arbitrairement se chevauchent n'est pas trop de code, et pour notre jeu avec seulement quelques objets, cela pourrait fonctionner, mais en y regardant de plus près, après quelques objets, vous commencez rapidement à avoir besoin d'optimiser la vérification des collisions. Tout d'abord, vous pourriez parcourir tous les objets qui peuvent potentiellement entrer en collision les uns avec les autres et vérifier leurs sphères englobantes, leurs cercles englobants ou leurs boîtes englobantes alignées sur les axes. Une fois que vous savez quels objets pourraient entrer en collision, vous devez faire plus de travail pour vérifier s'ils entrent réellement en collision. Souvent, même la vérification des sphères englobantes est trop de travail, et vous avez besoin d'une sorte de meilleure structure spatiale pour les objets afin de pouvoir vérifier plus rapidement uniquement les objets potentiellement proches les uns des autres.

Ensuite, une fois que vous avez écrit le code pour vérifier si 2 objets entrent en collision, vous voulez généralement créer un système de collision plutôt que de demander manuellement "est-ce que je collisionne avec ces objets". Un système de collision émet des événements ou appelle des callbacks en relation avec les collisions. L'avantage est qu'il peut vérifier toutes les collisions en une seule fois, de sorte qu'aucun objet n'est vérifié plus d'une fois, alors que si vous appelez manuellement une fonction "est-ce que je collisionne", les objets sont souvent vérifiés plus d'une fois, ce qui fait perdre du temps.

Créer ce système de collision ne représenterait probablement pas plus de 100 à 300 lignes de code pour vérifier uniquement les rectangles orientés arbitrairement, mais c'est toujours beaucoup plus de code, il a donc semblé préférable de l'omettre.

Une autre solution aurait été d'essayer de trouver d'autres personnages qui sont majoritairement circulaires vus du dessus. D'autres personnages humanoïdes par exemple, au lieu d'animaux, auquel cas la vérification circulaire pourrait fonctionner d'animal à animal. Cela ne fonctionnerait pas d'animal à clôture ; eh bien, nous devrions ajouter une vérification de cercle à rectangle. J'ai pensé à faire de la clôture une clôture de buissons ou de poteaux, quelque chose de circulaire, mais alors il me faudrait probablement 120 à 200 d'entre eux pour entourer la zone de jeu, ce qui entraînerait les problèmes d'optimisation mentionnés ci-dessus.

Ce sont des raisons pour lesquelles de nombreux jeux utilisent une solution existante. Souvent, ces solutions font partie d'une bibliothèque de physique. La bibliothèque de physique a besoin de savoir si les objets entrent en collision les uns avec les autres, donc en plus de fournir la physique, elles peuvent également être utilisées pour détecter les collisions.

Si vous cherchez une solution, certains exemples three.js utilisent ammo.js, cela pourrait donc être une option.

Une autre solution aurait pu être de placer les obstacles sur une grille et d'essayer de faire en sorte que chaque animal et le joueur n'aient qu'à regarder la grille. Bien que cela serait performant, j'ai estimé qu'il valait mieux laisser cela comme un exercice pour le lecteur 😜

Une chose de plus, de nombreux systèmes de jeu ont ce qu'on appelle des coroutines. Les coroutines sont des routines qui peuvent se mettre en pause pendant l'exécution et reprendre plus tard.

Faisons en sorte que le personnage principal émette des notes de musique comme s'il dirigeait la ligne en chantant. Il existe de nombreuses façons de mettre cela en œuvre, mais pour l'instant, faisons-le en utilisant des coroutines.

Tout d'abord, voici une classe pour gérer les coroutines

    function* waitSeconds(duration) {
      while (duration > 0) {
        duration -= globals.deltaTime;
        yield;
      }
    }

    class CoroutineRunner {
      constructor() {
        this.generatorStacks = [];
        this.addQueue = [];
        this.removeQueue = new Set();
      }
      isBusy() {
        return this.addQueue.length + this.generatorStacks.length > 0;
      }
      add(generator, delay = 0) {
        const genStack = [generator];
        if (delay) {
          genStack.push(waitSeconds(delay));
        }
        this.addQueue.push(genStack);
      }
      remove(generator) {
        this.removeQueue.add(generator);
      }
      update() {
        this._addQueued();
        this._removeQueued();
        for (const genStack of this.generatorStacks) {
          const main = genStack[0];
          // Gérer si une coroutine en supprime une autre
          if (this.removeQueue.has(main)) {
            continue;
          }
          while (genStack.length) {
            const topGen = genStack[genStack.length - 1];
            const {value, done} = topGen.next();
            if (done) {
              if (genStack.length === 1) {
                this.removeQueue.add(topGen);
                break;
              }
              genStack.pop();
            } else if (value) {
              genStack.push(value);
            } else {
              break;
            }
          }
        }
        this._removeQueued();
      }
      _addQueued() {
        if (this.addQueue.length) {
          this.generatorStacks.splice(this.generatorStacks.length, 0, ...this.addQueue);
          this.addQueue = [];
        }
      }
      _removeQueued() {
        if (this.removeQueue.size) {
          this.generatorStacks = this.generatorStacks.filter(genStack => !this.removeQueue.has(genStack[0]));
          this.removeQueue.clear();
        }
      }
    }

Il fait des choses similaires à `SafeArray` pour s'assurer qu'il est sûr d'ajouter ou de supprimer des coroutines pendant que d'autres coroutines s'exécutent. Il gère également les coroutines imbriquées.

Pour créer une coroutine, vous créez une fonction génératrice JavaScript. Une fonction génératrice est précédée du mot-clé `function*` (l'astérisque est important !)

Les fonctions génératrices peuvent `yield` (céder). Par exemple

    function* countOTo9() {
      for (let i = 0; i < 10; ++i) {
        console.log(i);
        yield;
      }
    }

Si nous ajoutions cette fonction au `CoroutineRunner` ci-dessus, elle imprimerait chaque nombre, de 0 à 9, une fois par image, ou plutôt une fois par appel de `runner.update`.

    const runner = new CoroutineRunner();
    runner.add(count0To9);
    while(runner.isBusy()) {
      runner.update();
    }

Les coroutines sont supprimées automatiquement lorsqu'elles sont terminées.

Pour supprimer une coroutine prématurément, avant qu'elle n'atteigne la fin, vous devez conserver une référence à son générateur comme ceci

    const gen = count0To9();
    runner.add(gen);

    // plus tard

    runner.remove(gen);

En tout cas, dans le joueur, utilisons une coroutine pour émettre une note toutes les demi-secondes à 1 seconde.

    class Player extends Component {
      constructor(gameObject) {

        ...

    +    this.runner = new CoroutineRunner();
    +
    +    function* emitNotes() {
    +      for (;;) {
    +        yield waitSeconds(rand(0.5, 1));
    +        const noteGO = gameObjectManager.createGameObject(scene, 'note');
    +        noteGO.transform.position.copy(gameObject.transform.position);
    +        noteGO.transform.position.y += 5;
    +        noteGO.addComponent(Note);
    +      }
    +    }
    +
    +    this.runner.add(emitNotes());
      }
      update() {
    +    this.runner.update();

      ...

      }
    }

    function rand(min, max) {
      if (max === undefined) {
        max = min;
        min = 0;
      }
      return Math.random() * (max - min) + min;
    }

Vous pouvez voir que nous créons un `CoroutineRunner` et ajoutons une coroutine `emitNotes`. Cette fonction s'exécutera indéfiniment, attendant 0,5 à 1 seconde, puis créant un objet de jeu avec un composant `Note`.

Pour le composant `Note`, créons d'abord une texture avec une note dessus et, au lieu de charger une image de note, créons-en une à l'aide d'un canvas, comme nous l'avons vu dans l'article sur les textures de canvas.

    function makeTextTexture(str) {
      const ctx = document.createElement('canvas').getContext('2d');
      ctx.canvas.width = 64;
      ctx.canvas.height = 64;
      ctx.font = '60px sans-serif';
      ctx.textAlign = 'center';
      ctx.textBaseline = 'middle';
      ctx.fillStyle = '#FFF';
      ctx.fillText(str, ctx.canvas.width / 2, ctx.canvas.height / 2);
      return new THREE.CanvasTexture(ctx.canvas);
    }
    const noteTexture = makeTextTexture('♪');

La texture que nous créons ci-dessus est blanche, ce qui signifie que lorsque nous l'utilisons, nous pouvons définir la couleur du matériau et obtenir une note de n'importe quelle couleur.

Maintenant que nous avons une texture de note, voici le composant `Note`. Il utilise `SpriteMaterial` et un `Sprite`, comme nous l'avons vu dans l'article sur les billboards

    class Note extends Component {
      constructor(gameObject) {
        super(gameObject);
        const {transform} = gameObject;
        const noteMaterial = new THREE.SpriteMaterial({
          color: new THREE.Color().setHSL(rand(1), 1, 0.5),
          map: noteTexture,
          side: THREE.DoubleSide,
          transparent: true,
        });
        const note = new THREE.Sprite(noteMaterial);
        note.scale.setScalar(3);
        transform.add(note);
        this.runner = new CoroutineRunner();
        const direction = new THREE.Vector3(rand(-0.2, 0.2), 1, rand(-0.2, 0.2));

        function* moveAndRemove() {
          for (let i = 0; i < 60; ++i) {
            transform.translateOnAxis(direction, globals.deltaTime * 10);
            noteMaterial.opacity = 1 - (i / 60);
            yield;
          }
          transform.parent.remove(transform);
          gameObjectManager.removeGameObject(gameObject);
        }

        this.runner.add(moveAndRemove());
      }
      update() {
        this.runner.update();
      }
    }

Tout ce qu'il fait est de configurer un `Sprite`, puis de choisir une vitesse aléatoire et de déplacer la transformation à cette vitesse pendant 60 images, tout en estompant la note en définissant l'`opacity` du matériau. Après la boucle, il supprime la transformation de la scène et la note elle-même des gameobjects actifs.

Une dernière chose, ajoutons quelques animaux supplémentaires.

    function init() {

       ...

      const animalModelNames = [
        'pig',
        'cow',
        'llama',
        'pug',
        'sheep',
        'zebra',
        'horse',
      ];
    +  const base = new THREE.Object3D();
    +  const offset = new THREE.Object3D();
    +  base.add(offset);
    +
    +  // positionner les animaux en spirale.
    +  const numAnimals = 28;
    +  const arc = 10;
    +  const b = 10 / (2 * Math.PI);
    +  let r = 10;
    +  let phi = r / b;
    +  for (let i = 0; i < numAnimals; ++i) {
    +    const name = animalModelNames[rand(animalModelNames.length) | 0];
        const gameObject = gameObjectManager.createGameObject(scene, name);
        gameObject.addComponent(Animal, models[name]);
    +    base.rotation.y = phi;
    +    offset.position.x = r;
    +    offset.updateWorldMatrix(true, false);
    +    offset.getWorldPosition(gameObject.transform.position);
    +    phi += arc / r;
    +    r = b * phi;
      }

cliquez ici pour ouvrir dans une nouvelle fenêtre

Vous pourriez vous demander, pourquoi ne pas utiliser `setTimeout` ? Le problème avec `setTimeout` est qu'il n'est pas lié à l'horloge du jeu. Par exemple, ci-dessus, nous avons défini le temps maximum autorisé à s'écouler entre les images à 1/20ème de seconde. Notre système de coroutine respectera cette limite, mais `setTimeout` ne le ferait pas.

Bien sûr, nous aurions pu créer un simple minuteur nous-mêmes

    class Player ... {
      update() {
        this.noteTimer -= globals.deltaTime;
        if (this.noteTimer <= 0) {
          // réinitialiser le minuteur
          this.noteTimer = rand(0.5, 1);
          // créer un gameobject avec un composant de note
        }
      }

Et pour ce cas particulier, cela aurait peut-être été mieux, mais à mesure que vous ajoutez de plus en plus de choses, vous aurez de plus en plus de variables ajoutées à vos classes, alors qu'avec les coroutines, vous pouvez souvent simplement lancer et oublier.

Étant donné les états simples de nos animaux, nous aurions également pu les implémenter avec une coroutine sous la forme de

    // pseudo-code !
    function* animalCoroutine() {
       setAnimation('Idle');
       while(playerIsTooFar()) {
         yield;
       }
       const target = endOfLine;
       setAnimation('Jump');
       while(targetIsTooFar()) {
         aimAt(target);
         yield;
       }
       setAnimation('Walk')
       while(notAtOldestPositionOfTarget()) {
         addHistory();
         aimAt(target);
         yield;
       }
       for(;;) {
         addHistory();
         const pos = history.unshift();
         transform.position.copy(pos);
         aimAt(history[0]);
         yield;
       }
    }

Cela aurait fonctionné, mais bien sûr, dès que nos états n'auraient pas été si linéaires, nous aurions dû passer à une `FiniteStateMachine`.

Il ne m'était pas non plus clair si les coroutines devaient s'exécuter indépendamment de leurs composants. Nous aurions pu créer un `CoroutineRunner` global et y placer toutes les coroutines. Cela rendrait leur nettoyage plus difficile. En l'état actuel, si le gameobject est supprimé, tous ses composants sont supprimés et donc les CoroutineRunner créés ne sont plus appelés, et tout sera collecté par le ramasse-miettes. Si nous avions un CoroutineRunner global, il incomberait alors à chaque composant de supprimer toute coroutine qu'il aurait ajoutée, ou bien un autre mécanisme d'enregistrement des coroutines auprès d'un composant ou d'un gameobject particulier serait nécessaire afin que la suppression de l'un supprime les autres.

Il y a beaucoup d'autres problèmes qu'un moteur de jeu normal gérerait. En l'état actuel, il n'y a pas d'ordre dans la façon dont les gameobjects ou leurs composants sont exécutés. Ils sont simplement exécutés dans l'ordre d'ajout. De nombreux systèmes de jeu ajoutent une priorité pour que l'ordre puisse être défini ou modifié.

Un autre problème que nous avons rencontré est que le composant `Note` supprime la transformation de son gameobject de la scène. Cela semble être quelque chose qui devrait se produire dans `GameObject` puisque c'est `GameObject` qui a ajouté la transformation en premier lieu. Peut-être que `GameObject` devrait avoir une méthode `dispose` qui est appelée par `GameObjectManager.removeGameObject` ?

Encore un autre problème est la façon dont nous appelons manuellement `gameObjectManager.update` et `inputManager.update`. Peut-être qu'il devrait y avoir un `SystemManager` auquel ces services globaux pourraient s'ajouter, et chaque service aurait sa fonction `update` appelée. De cette façon, si nous ajoutions un nouveau service comme `CollisionManager`, nous pourrions simplement l'ajouter au gestionnaire de système sans avoir à modifier la boucle de rendu.

Je vous laisse le soin de régler ce genre de problèmes. J'espère que cet article vous a donné quelques idées pour votre propre moteur de jeu.

Peut-être devrais-je promouvoir un game jam. Si vous cliquez sur les boutons jsfiddle ou codepen au-dessus du dernier exemple, ils s'ouvriront sur ces sites prêts à être modifiés. Ajoutez des fonctionnalités, changez le jeu pour qu'un carlin mène un groupe de chevaliers. Utilisez l'animation de roulade du chevalier comme boule de bowling et faites un jeu de bowling avec des animaux. Faites une course de relais avec des animaux. Si vous créez un jeu sympa, postez un lien dans les commentaires ci-dessous.

[1] : techniquement, cela fonctionnerait toujours si aucun des parents n'a de translation, de rotation ou d'échelle §.