quest'n'click 4.5.0 · r1

Construite pour quest'n'click 4.5.0, révision r1. Si elle est publiée, et laquelle est la courante, est dit par publication.json.

Comment fonctionne le moteur

English · Type : explication · Public : auteur, contributeur · Contexte : dépôt du moteur, projet créé · Statut : prête · S'applique depuis : 4.3.0

Les pièces du moteur et pourquoi elles sont découpées ainsi : le jeu comme donnée, l'amorçage d'un jeu par une page, les deux dispositions d'écran, le modèle de scène et ses peintres, la trame de scène et le journal sémantique, la marche, le graphe des assets, le directeur de musique, et le cache qui permet de jouer hors ligne. L'ordre dans lequel une action s'exécute est dans Modèle d'exécution.

Le moteur

Un moteur de point & click façon SCUMM, pour navigateur, pensé pour le téléphone en paysage. Il ne connaît aucun jeu : un jeu est un dossier de contenu que le moteur lit.

Voir Organisation d'un projet et La boucle de travail.

Un jeu est une donnée

Un jeu est un GameDef (src/engine/core/types.ts, découpé par sujet dans src/engine/core/types/) : lieux, verbes, objets, personnages, règles ({ verb, a, b, if, do }), scripts, écouteurs, dialogues, une carte, une fin. Il ne contient aucune fonction : on peut donc le valider (src/engine/tools/validate.ts, npm run validate), le sauvegarder, le comparer et le résoudre (npm run solve). Le code dont un jeu a besoin (une commande personnalisée, un mini-jeu) est du code de confiance déclaré à côté du contenu et appelé par son nom (core/custom.ts, src/engine/minigames/). compileGame (src/engine/core/define.ts) fige le contenu et donne un id stable à chaque règle, choix, sujet, écouteur et étape de script : sauvegardes, sessions et solveur parlent avec ces ids. Voir Code personnalisé et Formats de fichiers.

Amorçage

src/engine/boot.ts bootGame({ game, layouts, manifest, minigames, commands, locales, version, dev, sw }) est ce qu'une page fait pour lancer un jeu : la langue, les polices, le stockage de sauvegarde vérifié (les premières erreurs sont gardées jusqu'à ce que l'App puisse les montrer), l'App, window.__game, les outils de dev, le titre, le service worker. src/main.ts l'appelle ; un jeu qui embarque le moteur aussi (Notes de mise à jour v3 : un jeu qui embarque le moteur). pickLanguage, waitFonts et openStore sont exportés pour une page qui a besoin d'un autre ordre. La signature complète est dans l'API publique.

Deux dispositions

Le choix se fait tout seul (App.layout, dom/shell.ts) : la disposition ordinateur demande un pointeur fin (pointer: fine) et une zone de contenu d'au moins 720 × 450 une fois les zones sûres retirées. Il se refait à chaque redimensionnement de la fenêtre.

La scène : un modèle et un peintre

dom/room.ts (RoomView) est le modèle de scène d'un lieu : qui est où, quelle image de quelle pose, la profondeur, la marche, les bouches et les clignements, les fondus, la caméra, et le test de toucher (la plus petite cible visible sous le doigt). Il donne chaque entité à un peintre sous forme de sprite fini (SpriteSpec dans dom/renderer.ts : image, position des pieds, taille, profondeur, miroir, rotation, opacité, halo, ombre) et ne lui demande jamais rien en retour : deux peintres ne peuvent pas diverger sur ce qu'un toucher atteint.

Le peintre DOM (dom/render-dom.ts) est la référence (D10) : une <img> par sprite, le z-index pour la profondeur, une translation CSS pour la caméra. RoomView.still() arrête toute animation sur une image fixe (premières images, bouches fermées, caméra au repos) : npm run e2e:visual compare chaque lieu de la démo, figé, avec sa référence dans tests/visual/demo/ (bloquant en CI ; --update réécrit les références après un changement voulu). Le peintre DOM dessine les calques, la parallaxe et les occulteurs en polygone (le fond découpé à la profondeur de l'occulteur).

Le peintre Canvas 2D (dom/render-canvas.ts) : un <canvas> de la taille de la fenêtre, tenu en place pendant que le lieu dessous suit la caméra, repeint à l'image suivante après un changement.

Le modèle évalue chaque condition de la scène et la redonne quand l'une change ; la transition d'un lieu fond ou balaie la surface du peintre ; le mouvement réduit (le réglage ou celui du système) coupe la parallaxe, les particules et les transitions. Un lieu choisit Canvas avec renderer: 'canvas' (ou le jeu, pour tous les lieux) ; le paramètre d'URL ?renderer=canvas|dom impose un peintre à tous les lieux. L'interface, les dialogues et chaque cible accessible restent dans le DOM, au-dessus du canvas.

Mesure · canvas-parity Les lieux de la démo peints par Canvas à 0,31 % au plus des références DOM ; le jeu entier joué jusqu'à la fin avec lui ; 60 images par seconde avec le CPU ralenti 4× (npm run e2e:perf), le tout dans la ligne CI chromium / canvas.

La trame de scène, le renderer et le journal

ADR 0011 et décision D21 (4.1.11) : le rendu n'est pas une source d'état. Trois contrats le portent.

La trame de scène. scene/frame.ts sceneFrame(state, layouts, anim) est une fonction pure qui rend une SceneFrame immuable en unités logiques : la caméra ; les calques (le fond, les calques de scène, les occulteurs, les accessoires) ; les personnages (où ils se tiennent, de quel côté ils regardent, s'ils parlent ou marchent, leur sprite) ; les cibles avec leurs polygones de clic précalculés (le polygone du layout, sinon la boîte de ce qui est dessiné, rotation comprise ; la plus petite l'emporte) ; les effets (lumières, particules, transition) ; et un hash de l'ensemble. RoomView résout chaque sprite, fait la trame et la peint quand un lieu est construit ; entre deux constructions, une entité qui change peint son propre sprite (sa part de la trame). Le test de toucher (hitTest) et les boutons accessibles lisent la trame : la couche DOM sémantique (boutons, focus, clavier, lecteur d'écran) suit ce qui peint le lieu, quel qu'il soit. tests/dom/scene-frame.test.ts garde le DOM de chaque lieu de la démo et du chapitre de référence, dans trois états, identique à ce qu'il était avant la trame.

Le renderer. Renderer (mount, render(frame), onIntent, unmount) est ce qui dessine une trame. Les peintres DOM et Canvas sont enveloppés en un seul (dom/frame-renderer.ts) : une trame inchangée est sautée grâce à son hash, un nouveau lieu est peint en entier, une trame changée par ses différences. scene/null-renderer.ts ne dessine rien, pour les tests et l'exécution sans écran. La boucle de rendu, la conversion écran ↔ scène et les animations de présentation (cycles de marche, bouches, particules) sont du côté du renderer ; le moteur ne connaît pas les images. Un peintre n'importe jamais le moteur (tests/boundaries.test.ts).

Les intentions. Une Intent est la seule chose qui remonte : act (un verbe sur une cible, un objet dessus), walk (un point du sol), pick (un choix), skip, open (la carte, le sac, le menu). La couche d'entrée (dom/input.ts) les fait à partir des touchers, des touches et des clics et les donne au presenter (dom/presenter.ts intent()), qui applique celles du moteur (scene/intent.ts) et répond aux siennes. App compose le moteur, le presenter et le renderer de la vue du lieu. Les mêmes clics sur le peintre DOM et sur le peintre Canvas enregistrent la même session et le même journal, à des densités de pixels 1, 2 et 3, sur un écran de téléphone et de bureau, avec et sans mouvement réduit (tests/dom/intent-equivalence.test.ts).

Garantie · same-clicks-same-session · référencée · exécutée sur aff6caab7514 · portée vérifiée par son auteur (IA), pas relue Les mêmes clics sur le peintre DOM et sur le peintre Canvas enregistrent la même session et le même journal.

Le journal sémantique. Engine.journal (core/journal.ts) numérote ce qui s'est passé, en identifiants :

ÉvénementQuand
sessionStartedUne nouvelle partie, un chargement, un checkpoint
roomEnteredOn entre dans un lieu (avec from quand on vient d'un autre lieu)
itemAcquired, itemLostUn objet entre dans le sac ou en sort (un objet passé d'un joueur à l'autre est les deux, chacun avec son player)
flagChangedLa valeur d'un drapeau change (seulement alors ; null pour un drapeau retiré)
playerSwitchedLe joueur prend un autre personnage
objectiveCompletedUn objectif devient vrai pour la première fois dans la session (4.1.12, ADR 0014)
endingReachedend, ou la fin scellée
loadMadeUne sauvegarde est chargée (journalisé avant son sessionStarted)
saveMadeL'autosauvegarde qui suit quelque chose de sémantique

Les handlers de commandes, l'entrée dans un lieu et le cycle de vie du moteur l'émettent ; rien dans le DOM, et un genre inconnu est refusé (ce n'est pas le bus de l'interface). since(seq) donne les événements après un numéro de séquence, subscribe les entend au fil de l'eau. La fenêtre garde les 10 000 derniers : une session plus longue est exportée avec journalTruncated, et npm run replay dit alors qu'il n'a comparé aucun journal. Rejouer une session donne le même journal (tests/journal.test.ts : la démo, le chapitre de référence, 200 jeux générés) ; un fichier de session le porte, npm run replay l'affiche et échoue quand celui du replay diffère, et le panneau de dev liste les derniers événements.

core/objectives.ts vérifie le done de chaque objectif à chaque sauvegarde et juste avant une fin, et émet objectiveCompleted la première fois qu'il est vrai, plus jamais dans la session, après le drapeau qui l'a accompli et avant le saveMade de l'autosauvegarde. Un emplacement sur saveMade et loadMade attend une sauvegarde qui fasse partie de la session. Voir Chapitres et objectifs.

Marche et mouvements

dom/walk.ts fait marcher un personnage sur les zones de marche de son lieu (walkZones : chacune un polygone avec ses trous, sa propre échelle de profondeur et son zoom de caméra) et les liens entre elles (walkLinks : marche, escalier, échelle, saut, téléportation, avec une durée, une animation et une orientation) : le moins de liens d'abord, puis le plus court chemin dans chaque zone.

Comment les dessiner : Géométrie ; quand une chose est visible mais inatteignable : Visible mais inatteignable.

Assets : un seul graphe

src/engine/core/asset-graph.ts dit quels fichiers chaque partie du jeu demande, d'après le contenu :

Le renderer précharge la part d'un lieu pour qui s'y trouve vraiment ; le préchargement de fond lit les portées du lieu courant et des voisins ; le plan hors ligne complet est la portée offline ; la provenance la couvre ; npm run weight la budgète. Un fichier que l'un connaît ne peut échapper aux autres. La portée d'un lieu sur-approxime une visite (toutes les variantes, chaque personnage qui pourrait s'y trouver), jamais l'inverse : npm run e2e:weight échoue sur une requête hors prédiction.

Polices. Les polices par défaut passent par le bundler, avec des noms hachés : le précache du service worker les prend dans le cache HTTP au lieu de les télécharger une seconde fois. La police d'interface, Galmuri11 (depuis 4.3.0), est une police pixel proportionnelle proche des polices de dialogue LucasArts, livrée en sous-ensemble latin (125 Ko, tools/subset-font.py) avec un size-adjust qui donne à ses capitales la hauteur de celles de DotGothic16. DotGothic16, la police par défaut jusqu'à 4.2.5, reste derrière toute famille d'interface (fontStack dans dom/fonts.ts) : son propre sous-ensemble latin (118 Ko) répond aux quelques symboles absents de Galmuri11, et la police complète (2 Mo, surtout du japonais) est une face dont le unicode-range ne couvre que le reste : un navigateur ne la télécharge, et ne la garde, que pour un caractère hors des sous-ensembles. Voir Habillage et polices.

Musique : un directeur et ses stems

dom/director.ts joue une partition (audio.scores, depuis 3.5) sur Web Audio depuis des tampons décodés : chaque stem part au même instant de l'horloge audio et boucle sur la même fenêtre, ils restent donc calés à l'échantillon ; un changement d'état du jeu déplace les gains des stems à la mesure (ou au temps) suivante, en fondu, programmé à l'avance sur le fil audio. core/score.ts décide quoi et quand, sans carte son : le mix par état, la grille à travers la boucle, les rampes.

Stems ou mix unique. dom/audio.ts confie un morceau à partition au directeur là où il convient : Web Audio, pas de Save-Data, plus de 2 Go de mémoire et 2 cœurs ; sur un navigateur qui ne donne pas sa mémoire (Safari), seulement si les partitions décodées font 128 Mo au plus (pcmBytes). Ailleurs, il joue le mix unique avec Howler. Le paramètre d'URL ?music=mix|stems force l'un ou l'autre. Les voix et les morceaux ponctuels baissent la partition comme le mix.

Ce que garantit le directeur.

Mesure · music-e2e npm run e2e:music rend trente minutes hors ligne avec 0 échantillon de dérive, joue 100 changements de mix sans clic (une bascule sèche est détectée), et mesure la gigue en temps réel dans Chromium et WebKit à 0,02 ms, le tout en CI. Depuis 3.6, il vérifie aussi les transitions et la phase sauvegardée à l'échantillon.

Comment écrire une partition : Musique en stems.

Cache et fluidité

Le service worker (vite-plugin-pwa / Workbox, configuré dans vite.config.ts) garde l'application de base en cache dès la première visite. Les images, les sons, les vidéos et les polices complètes sont gardés au premier usage (cache d'abord), puis servis sans réseau ; la musique est servie par morceaux (requêtes partielles) depuis le cache. Le fichier de la fin scellée (data/) est toujours redemandé au réseau d'abord. Une nouvelle version ne s'active jamais toute seule : le jeu le propose au joueur, après une sauvegarde durable. Ce que cela implique pour un déploiement (un sous-chemin, une mise à jour qui ne s'affiche pas) est dans Sous-chemin, cache et service worker.

Le préchargement. Dès l'écran titre puis après chaque changement de lieu, le moteur réchauffe en tâche de fond le lieu courant, les lieux directement accessibles et leur audio, par lots dimensionnés par GameDef.assetBudgets. Puis, une fois par page, le reste du jeu (GameDef.offline, full par défaut) : chaque image, bruitage, voix, musique et vidéo, lot par lot pendant les temps morts, en pause quand la page est cachée. Rien n'est téléchargé en « économie de données » ou en 2G ; musiques et vidéos attendent mieux que la 3G. offline: 'nearby' ne garde que le réchauffement par lieu.

Contourner l'ancien cache. Chaque adresse d'image ou de son porte ?v=<empreinte> : l'empreinte change dès qu'un fichier de public/assets change, ce qui contourne l'ancien cache.

Hors ligne, et le dire. Après la première visite, le jeu entier se joue hors ligne, et le jeu dit si c'est vrai : le préchargement rend un statut (App.offlineStatus, offlineReady) :

StatutSens
completeChaque fichier du plan est dans le cache
partialAvec sa raison : network (et les fichiers en échec), save-data, slow, quota (le navigateur annonce moins de 64 Mo libres avant de commencer)
skippedRien n'a été tenté : worker quand le service worker n'a jamais pris le contrôle de la page à temps (télécharger n'aurait rien mis en cache)
offoffline: 'nearby'

Le menu pause l'affiche (ui.offlineStatus : « 312/400 », « jeu entier en cache », « 312/400 ⚠ toucher pour réessayer ») ; un toucher relance un préchargement partiel, et un fichier déjà dans le Cache API n'est jamais redemandé : le préchargement reprend d'un rechargement à l'autre sans état propre. npm run e2e:pwa exige complete, puis vérifie hors ligne chaque fichier du plan dans le cache et rend un lieu jamais visité (Chromium ; WebKit ne sait pas naviguer hors ligne sous automatisation et répond « skipped », code 3, accepté seulement par le --allow-skip de la CI).

Limite · nearby-needs-network Avec offline: 'nearby', un lieu jamais visité ni préchargé peut encore demander le réseau.

Pourquoi ces choix

Compromis

Décisions et ADR


Modifier la source de cette page · Signaler une erreur