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.
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.
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.
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.
À 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; }
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.