Le JavaScript dans l'Engine

L'API de script de l'Engine : le cycle de vie, les pièges de coordonnées, les trois façons de créer un objet, et comment en déplacer un.

En un mot#

Chaque entité peut porter un script JavaScript. Il s'exécute dans le navigateur, dans votre onglet, contre la scène vivante, et c'est l'API de script canonique : le dialecte BASIC, et les vues Python, C# et Rust de l'éditeur, compilent tous vers la même surface.

La liste exhaustive est la référence de l'API : 538 membres en 50 espaces de noms, une page chacun, avec une recherche. Cette page-ci est ce qu'il faut savoir avant de la lire.

Le cycle de vie#

function start() {}                     // une fois, au démarrage de l'entité
function update(dt) {}                  // à chaque image, dt en secondes
function fixedUpdate(dt) {}             // pas fixe, pour la physique
function onCollisionEnter(other) {}     // première image de contact
function onCollisionStay(other) {}      // à chaque image de contact
function onCollisionExit(other) {}      // le contact vient de finir
function onTriggerEnter(other) {}       // entrée dans un volume déclencheur
function onTriggerExit(other) {}
function onMessage(msg, data, senderId) {}   // messagerie entre entités
function onAnimationEvent(name, data) {}
function onDestroy() {}
function main() {}                      // entrée du jeu console, async permis

deltaTime, time, entityId et entity sont des variables globales ; getComponent(type) lit un composant sur l'entité qui porte le script.

Collisions et déclencheurs se produisent en 2D comme en 3D, et chaque côté reçoit sa propre normale de contact : other.contact.normal pointe de l'autre entité vers celle-ci. C'est ce qui permet à un script de distinguer « j'ai atterri dessus » de « il m'est tombé dessus », un saut sur la tête se lit other.contact.normal.y > 0.5.

Les deux pièges de coordonnées#

Les deux coûtent de vraies heures de débogage, et aucun n'échoue bruyamment.

Souris : page contre canevas#

input.mousePosition est en coordonnées page. scene.pick() veut des coordonnées canevas. Passer l'une pour l'autre ne rate pas la cible, cela touche un point décalé de la position de la vue dans la fenêtre, si bien qu'un héros commandé au clic marche quelque part de plausible et de faux.

Appelez scene.pick() sans argument (il vise le curseur), ou utilisez input.mouseViewport quand il vous faut les nombres.

Les commandes virtuelles sont en pixels canevas#

input.addVirtualJoystick(id, x, y, size) et input.addVirtualButton(...) placent leur centre dans le même espace que mouseViewport. Un joystick rend un X/Y normalisé après sa zone morte radiale ; un bouton expose séparément tenu, pressé cette image et relâché cette image. Leur pointeur est capturé et consommé, donc un appui sur la commande n'est pas aussi rapporté comme une action brute sur le canevas.

Le joystick par défaut est unifié. input.getJoystickX/Y() choisit un vecteur complet parmi le stick à l'écran, une manette physique ou le clavier (WASD/flèches), selon la source la plus active. Une manette branchée mais immobile ne désactive donc plus le clavier. Son axe Y suit l'espace écran 2D : le haut vaut -1.

Trois façons de créer un objet, et elles ne sont pas interchangeables#

Ce que ça créeDurée de vie
scene.createEntity(def)une entité d'auteur : elle apparaît dans le Scene Graph et l'Inspecteurle projet, elle est enregistrée
game.spawn(type, pos)une primitive nue : ni vie, ni IA, ni comportementla session de jeu
game.spawnFrom(template, pos, opts)une copie d'entité entière : composants, corps physique, tags, calque, échelle et la pile de comportements compiléela session de jeu

game.spawn convient aux débris et aux prototypes, et ne sert à rien pour un ennemi, un générateur nourri par lui produit des boules inertes. Le modèle que copie spawnFrom est une entité ordinaire que vous composez dans l'éditeur puis éteignez avec l'œil de la hiérarchie : pas de format à part, pas de second éditeur, donc ce que vous voyez est ce qui apparaîtra.

// Une fois, au démarrage : un modèle absent doit le dire, pas rater chaque vague en silence
if (!game.hasTemplate('Skeleton')) console.warn('no Skeleton template');

const mob = game.spawnFrom('Skeleton', { x, y, z }, { tag: 'Enemy' });

template accepte un identifiant, un nom ou un tag. Les copies faites pendant le jeu sont limitées à la session : stop() les retire, et la scène d'édition n'est jamais polluée.

Éteindre une entité veut dire invisible ET muette : nœud désactivé, script arrêté, corps physique qui ne répond plus. La cacher seulement laisserait un ennemi invisible continuer à vous frapper. game.setEnabled(id, on) le pilote depuis un script, l'icône en œil depuis la hiérarchie.

Déplacer quelque chose : pousser un corps, pas téléporter#

Écrire transform.position à chaque image traverse les murs, les autres monstres et le sol. Pilotez la vitesse : mais seulement s'il y a quelque chose à piloter :

if (physics.hasBody) {
  const v = physics.getVelocity();
  physics.setVelocity({ x: vx, y: v.y, z: vz });   // pousse un vrai corps
} else {
  transform.position.x += vx * dt;                 // légitime pour un volant
}

physics.setVelocity sans corps remplit un champ que personne n'intègre, et l'entité s'arrête net. C'est pour cela que physics.hasBody existe, et pourquoi tous les comportements mobiles du catalogue posent la question au lieu de supposer la réponse. S'arrêter, c'est annuler la vitesse : ne rien faire laisse le corps en roue libre.

Écrire une position déplace bien un corps physique, désormais. Havok pilote la transformation, jamais l'inverse : affecter transform.position sur un corps dynamique était écrasé au pas suivant, donc toute téléportation échouait en silence sur une entité physique. Le proxy de position resynchronise maintenant le corps, comme le proxy de rotation l'a toujours fait.

Le pathfinding est un instantané#

pathfinding.createGrid() lance une grille de rayons une fois, centrée là où il a été appelé. Sortir de cette boîte rend null, et la plupart des appelants retombent alors sur une ligne droite, à travers les murs, sans un mot.

La grille se recentre par défaut sur le trajet demandé. setAutoRecenter(false) permet de s'en passer sur un monde fixe, où la reconstruction coûte une passe de rayons pour rien.

console.log journalise. Print dessine.#

La console, c'est console.log / warn / error, comme partout. Le Print du BASIC dessine à l'écran et s'appelle à chaque image, ce sont deux commandes délibérément séparées, et les confondre est ce qui remplit une console avec une boucle d'image.

Où s'exécute un script#

Dans l'éditeur et dans une compilation web, les scripts tournent dans le navigateur. Dans une compilation native, bureau ou mobile, ils tournent inchangés sur un moteur QuickJS embarqué, la même source, pas un portage.

Les scripts sont conservés dans la base du navigateur pendant l'édition, donc un tampon non enregistré survit à un rechargement ; ils sont enregistrés dans le projet avec le reste.

Limites et pannes courantes#

  • scene.pick() avec un argument veut des coordonnées canevas. Voir plus haut ; c'est le bug de mauvais repère le plus fréquent.
  • game.spawn fabrique une primitive, pas un personnage. Utilisez spawnFrom.
  • Un corps est créé pour les entités nées en cours de partie. La physique n'était construite qu'au lancement, si bien que tout ce qui apparaissait après traversait le monde en silence pendant que ses voisins posés à la main se comportaient normalement.
  • Les huit étapes sont le plafond du coéquipier IA, pas le vôtre. Une boucle de script n'a pas cette limite ; un while emballé gèle l'image comme partout ailleurs.
  • Les quatre autres vues de langage sont transpilées depuis celle-ci. Modifier une vue Python ou Rust puis revenir repasse par le JavaScript conservé, voir la page BASIC pour la raison.