Créer un nouveau jeu
Construire pas à pas un jeu Maketools sur le moteur commun, en partant du jeu d'exemple.
Chaque jeu Maketools se greffe sur le moteur commun (@dg/engine). Le moteur exécute le jeu, lui
fournit tout ce dont il a besoin par un hôte de jeu (game host) et gère ce qui est commun à
tous les jeux : play sessions, télémétrie, chrono, HUD, sons, thème du fork, zones éditables,
pause, écran de fin et accessibilité.
La référence est le jeu d'exemple de engine/examples/sample-game, un quiz vrai/faux.
Reprenez sa structure : il est volontairement petit, testé et tenu à jour. Un nouveau jeu part
des briques du moteur décrites ici : si vous écrivez une minuterie, un banc de test ou une
gestion des touches, cherchez d'abord la brique existante ; si un besoin sert à deux jeux, il
devient une brique du moteur.
1. Organisation du package
games/<id>/src/
├── core/ TypeScript pur, importé aussi par le backend
│ ├── config.ts schéma de config (defineGameConfigSchema) et config par défaut, dans la langue source
│ ├── translations/ une ContentTranslation par autre langue (fr.ts…)
│ ├── scoring.ts scoring rejouable (ScoringModule)
│ ├── editor-model.ts modèle d'édition (entités, collections, écrans, apparence)
│ └── definition.ts GameCoreDefinition : manifest, schéma, langues, zones, fieldLabels, editor, scoring, checkContent
└── ui/ Angular + Phaser
├── <id>-game.ts composant racine (injecte l'hôte)
├── <id>.definition.ts GameDefinition = core + loadUi() + loadEndSummary()
└── scenes/ scènes Phaser, toujours chargées en lazy
games/<id>/scripts/content-review.ts rapport de relecture du contenu (bun run content:review)
core n'importe jamais Angular, Phaser ni Encore : le backend s'en sert pour valider les configs
des forks et recalculer les scores. ESLint fait respecter ces frontières.
2. Config et définition
Déclarez le schéma de config avec defineGameConfigSchema({ major, rules, content }). Tout texte
affiché au joueur vit dans la config, pour qu'une organisation puisse le modifier dans son fork.
Donnez à chaque item scoré un id stable et un skillId.
Ce qui se traduit
Une config est écrite dans une seule langue : ses textes sont de simples chaînes. Le jeu décide, champ par champ, de ce qui se traduit :
contentText(maxLength)pour tout texte lu par le joueur (affirmation, libellé, explication). Le studio l'édite dans la langue source du fork et le met dans les fichiers de traduction (mot-cléx-dg-textdans le JSON Schema) ;z.string()pour ce qui ne se traduit jamais : identifiants, valeurs de données comparées par le scoring, codes.
const ItemSchema = z.object({
id: z.string(), // identifiant : jamais traduit
skillId: SkillIdSchema,
statement: contentText(300), // affiché au joueur : traduit
answer: z.boolean(),
});
Dans le doute, demandez-vous : « un joueur anglophone verrait-il ce texte en français ? ». Si
oui, c'est un contentText.
Langues du jeu
La définition donne la langue de defaultConfig dans sourceLocale, et une traduction par autre
langue dans translations. Une traduction est une ContentTranslation ({ locale, entries }),
rangée dans src/core/translations/<langue>.ts. Chaque entrée { id, source, target } a la même
forme que dans le fichier de traduction d'un fork : id est le
pointeur JSON du texte, source son texte dans defaultConfig, target la traduction.
// src/core/translations/fr.ts
export const frenchTranslation: ContentTranslation = {
locale: 'fr',
entries: [
{ id: '/content/intro', source: 'Spot the hallucinations.', target: 'Repérez les hallucinations.' },
// …une entrée par texte de defaultConfig
],
};
// src/core/definition.ts
sourceLocale: 'en',
translations: [frenchTranslation],
Le catalogue présente le jeu dans sa langue source et dans chaque traduction
(gameLocales(definition)), et la démo joue defaultConfigIn(definition, locale). Quand une
organisation forke le jeu dans l'une de ces langues, les autres deviennent des traductions de son
fork (forkStartingPoint). configTexts(definition, config) liste les textes traduisibles d'une
config, dans l'ordre du document.
checkGameDefinition échoue si une traduction livrée :
- oublie un texte de
defaultConfig: une traduction livrée est complète, la démo n'affiche jamais un texte dans la langue source ; - a une entrée obsolète, dont la
sourcene correspond plus au texte àid: après une modification du contenu par défaut, mettez à jour les traductions dans le même commit ; - donne une config traduite qui ne passe pas
configSchemaoucheckContent(par exemple, un passage qui doit figurer dans son texte). - est écrite dans une langue qui n'est pas une langue de contenu, ou répète la langue source.
Langues de contenu (CONTENT_LOCALES, type ContentLocale) : anglais, français, espagnol,
portugais et allemand. Ce ne sont pas les langues du site (Locale : anglais et français, pour
l'interface, les e-mails et les rapports). Un jeu peut être écrit dans n'importe quelle langue de
contenu et livrer des traductions dans les autres ; une organisation ajoute celles qui manquent à
son fork par un fichier de traduction. Les textes du moteur que voit le joueur (HUD, pause, écran
de fin, SKILL_LABELS) existent dans toutes les langues de contenu (PlayerText). Les UiText
d'un jeu (titre et résumé du manifeste, textes d'interface écrits dans le code) exigent en et
fr et acceptent es, pt et de, avec repli sur l'anglais : un jeu livré en allemand écrit
aussi de pour ce que voit le joueur (le titre du manifeste s'affiche sur l'écran de fin). Les
libellés de l'éditeur (fieldLabels) restent en anglais et en français.
Zones éditables et libellés de l'éditeur
Le manifest porte les visuels livrés par le jeu : thumbnail, la couverture sans texte
(cartes des forks, dont le titre diffère du jeu), poster, facultatif, la vignette du catalogue
avec le titre du jeu dessiné au centre (16:10, 800 px), et ogImage, facultative, l'image
1200 × 630 que la fiche du jeu partage sur les réseaux sociaux et les moteurs de recherche. Sans
poster, le catalogue écrit le titre par-dessus la couverture ; sans ogImage, la fiche partage
son affiche. Générez ogImage depuis l'affiche par bun run og:images <id-du-jeu> (publié ou non : un jeu
absent de l'instantané du catalogue part de games/<id>/assets/poster.webp, sinon de
cover.webp) : elle écrit games/<id>/assets/og.webp, de façon déterministe, puis déclarez-la
(ogImage: builtinAsset('og.webp')). Relancez-la à chaque changement de l'affiche.
Il donne aussi releasedOn, la date de sortie du jeu (AAAA-MM-JJ, la même pour toutes ses
majors).
Un nouveau jeu arrive non publié : enregistré dans le registre, il n'a ni fiche, ni démo, ni
place au classement, et aucune org ne peut le forker. Ce qui est exposé se décide dans la base du
service catalog, jamais dans le code : l'équipe Maketools le teste en l'accordant à son org
interne (préversion), puis le publie depuis la curation du catalogue (/curation). La date de sa
première publication fonde le badge « Nouveau » (30 jours) ; il n'a aucune place réservée parmi
les six jeux mis en avant : il y entre par l'usage, ou forcé en tête par un curateur. Une
publication exceptionnelle par migration de la base catalog
(bunx drizzle-kit generate --config catalog/drizzle.config.ts --custom --name publish_<id>,
depuis backend/, sur le modèle de 0002_publish_best_cdo.sql : une ligne game_listings
publiée, listed_at au jour de la publication, position en fin de liste) s'ajoute aussi à
INITIAL_LISTINGS (graine vérifiée par un test), puis bun run api:gen réécrit l'instantané du
catalogue (frontend/src/app/core/catalog/catalog-snapshot.gen.json, jeux publiés de la graine),
que la CI compare au code. Au déploiement, l'instantané est repris de l'API de l'environnement :
un jeu publié depuis la curation y entre au déploiement suivant, et les pages le montrent dès leur
chargement dans le navigateur. Un jeu sur mesure (availability: 'private') ne se publie
jamais : il s'accorde org par org.
Rien d'autre à écrire pour le site : depuis cet instantané, le jeu reçoit sa fiche, prérendue
en anglais et en français (/games/<id>, /fr/games/<id> : affiche, résumé, compétences, démo,
données structurées), une page par compétence qu'il entraîne et qui n'en avait pas encore
(/skills/<slug>), son entrée dans le sitemap et dans llms.txt (bun run docs:build). Deux
choses seulement sont exigées, vérifiées par bun run docs:check : le guide du jeu
docs/{en,fr}/games/<id>.md, qui renvoie vers sa fiche (un lien vers /games/<id>), et la description
de toute nouvelle compétence dans l'espace i18n skills
(frontend/public/i18n/{en,fr}/skills.json). Écrivez le guide avec le jeu, avant sa
publication : tant que le jeu manque à l'instantané, son guide est gardé hors du site (ni page,
ni navigation, ni sitemap), peut déjà lier sa fiche et ses pages de compétence, et aucune page
publiée ne peut le lier ; il paraît avec la publication du jeu. Reliez-le alors depuis les pages
learn des notions qu'il entraîne.
Déclarez les zones éditables (editableZones) : des pointeurs JSON dans la config, avec * pour
les tableaux, par exemple /content/items/*/statement.
Nommez tous les autres champs de la config dans fieldLabels : le panneau latéral du studio
prend tous ses libellés dans la définition du jeu, tout comme le context de chaque entrée d'un
fichier de traduction (« Manches › Manche 1 › Titre »). Les libellés, comme le titre et le résumé
du manifest, sont des textes d'interface écrits dans le code (UiText), donnés dans chaque langue
de la plateforme (anglais et français) : ce n'est pas du contenu à traduire. Les clés sont des
motifs de pointeur JSON, avec * pour un index de tableau ou une propriété quelconque :
fieldLabels: {
'/rules/pointsPerItem': { en: 'Points per item', fr: 'Points par item' },
'/content/items': { en: 'Items', fr: 'Items' }, // section
'/content/items/*': { en: 'Item', fr: 'Item' }, // un élément : « Item 3 »
'/content/items/*/skillId': { en: 'Skill', fr: 'Compétence' },
'/content/items/*/kind': { en: 'Kind', fr: 'Type', options: { quiz: { en: 'Quiz', fr: 'Quiz' } } },
}
- Une zone éditable nomme déjà les champs qu'elle couvre : son libellé l'emporte.
- Les champs communs (
BaseRules, thème, marque) sont nommés par les contrats (BASE_FIELD_LABELS) : ne les répétez pas. - Chaque valeur d'une énumération a son libellé dans
options, sauf les compétences (taxonomie ferméeSkillId). - Un champ gardé jusqu'à la prochaine major mais que le jeu n'utilise plus reçoit
deprecated: true: le studio le masque. Retirez-le pour de bon avec une migration de contenu.
checkGameDefinition échoue si un champ, une section ou un élément de liste du schéma de config
n'a pas de libellé, si une option n'en a pas, ou si une clé de fieldLabels ne correspond à aucun
champ.
Ce que le schéma ne sait pas dire (ids uniques, une bonne réponse par item…) va dans le hook
optionnel checkContent(config): string[] de la définition. checkGameDefinition l'appelle sur
la config par défaut et sur chaque traduction livrée, et le studio à chaque draft et à chaque release : un problème renvoyé
bloque l'enregistrement (ERR_CONFIG_INVALID).
Testez la définition avec expect(checkGameDefinition(definition)).toEqual([]).
Modèle d'édition
Le panneau généré depuis le schéma suit l'arbre de la config : c'est une vue de développeur.
Pour qu'un responsable formation retrouve la 12e question de la manche 3 sans six dépliages, le
jeu décrit son contenu par objets dans editor (EditorModel des contrats, fichier
core/editor-model.ts, plans/08-editor.md). Le studio en tire un écran de contenu unique (liste
cherchable, filtres, tableau, édition en lot), séparé de la coque des écrans.
entities: les objets du jeu, nommés et expliqués en une phrase du point de vue du joueur (name,plural,description, enUiText). Pour chaque entité :title(le texte qui nomme un élément dans une liste, jamais un id),overline,idField(id stable, masqué),facetsetcolumns(énumérations, booléens, tableaux d'énumération, textes de 60 caractères au plus, ouderived:<id>; une colonne peut aussi être un nombre, édité dans la cellule),derived(valeurs calculées par le core, comme la bonne réponse : même règle que le scoring),sectionsde la fiche,referencesvers une autre partie de la config (le personnage d'une demande : un texte ; les e-mails qu'une réponse déclenche : un tableau de textes),create(config, id)(élément neuf valide). Les champs d'une entité sont des pointeurs relatifs à l'élément ('/text',''= l'élément lui-même).collections: où vivent les éléments, en motifs ('/content/requests/*', ou'/content/rounds/*/items/*'pour les items de toutes les manches). Un référentiel à clés fixes ('/content/redFlags/*') portefixedReason: on en écrit les textes, jamais la liste. Une collection imbriquée reprend son sous-arbre à la collection parente (la fiche d'une manche ne montre pas ses items).views: ['graph']ajoute la vue Graphe : les éléments de la collection reliés par lesreferences(de n'importe quelle entité) qui les visent.screens: la coque, dans l'ordre du parcours du joueur, chaque écran avec ses champs (titres, boutons, consignes).appearance: les visuels du contenu (décor, personnages), à côté du thème et de la marque. Les règles, le thème et la marque ont leur propre mode, sans déclaration.
Règle de rangement : toute feuille de /content a exactement une place, un élément de
collection (tout son sous-arbre), un champ d'écran ou un motif d'apparence (sous-arbre compris).
checkGameDefinition nomme chaque champ oublié ou rangé deux fois, et vérifie aussi les ids
(kebab-case, uniques), les motifs (ils désignent un champ), les champs des entités et leurs
types, les valeurs calculées sur chaque élément de la config par défaut, create() (l'élément
passe le schéma et reçoit son id) et les références de la config par défaut (chaque valeur
désigne un élément existant). Un champ deprecated peut rester sans place.
Extrait d'AI Not AI :
export const aiNotAiEditor: EditorModel<AiNotAiConfig> = {
entities: {
request: {
name: t('Request', 'Demande'),
plural: t('Requests', 'Demandes'),
description: t('What a colleague asks at your desk…', "Ce qu'un collègue demande à ton bureau…"),
title: '/text',
overline: '/department',
idField: '/id',
facets: ['derived:expected', '/redFlags', '/tricky', '/department'],
derived: [{ id: 'expected', label: t('Right answer', 'Bonne réponse'),
compute: (item) => expectedFor((item as AiRequest).redFlags).choice,
options: { ai: { label: t('AI', 'IA'), tone: 'success' }, 'not-ai': { label: t('NOT AI', 'PAS IA'), tone: 'danger' } } }],
create: (config, id) => ({ id, department: '', text: '', character: config.content.characters[0]?.id ?? '', redFlags: [], tricky: false }),
},
// case, profile, word…
},
collections: [
{ id: 'requests', entity: 'request', items: ['/content/requests/*'] },
{ id: 'red-flags', entity: 'case', items: ['/content/redFlags/*', '/content/aiFit'], fixedReason: t('…', '…') },
],
screens: [{ id: 'intro', name: t('Welcome', 'Accueil'), description: t('…', '…'), fields: ['/content/title', '/content/ui/start'] }],
appearance: ['/content/characters', '/content/images', '/content/scenery'],
};
Le studio lit une collection avec collectionItems(config, collection) (pointeurs concrets et
clés, dans l'ordre), un champ d'élément avec itemPointer(élément, champ) et le graphe d'une
collection avec referenceGraph(config, editor, collection) (nœuds, arêtes, références
cassées). Sans editor, il
n'offre que le panneau généré.
3. Scoring rejouable
Le serveur ignore le score envoyé par le client : il rejoue les événements avec la config de la release, dans sa langue source. Le scoring ne doit donc pas dépendre de la langue : les réponses portent des identifiants (item, option, brique), jamais un texte affiché. Le jeu calcule son résultat local avec la config traduite qu'il a reçue : avec des identifiants seulement, c'est le même que celui du serveur.
Écrivez la règle de notation d'un item une seule fois, et utilisez-la dans l'UI comme dans le scoring :
export function gradeItem(config: MyConfig, item: MyItem, answer: Json): ItemGrade { … }
function scoreFromEvents(config: MyConfig, events: readonly PlayEvent[]): GameResult {
const items = new Map(config.content.items.map((item) => [item.id, item]));
const grades = gradeAnswers(events, (id) => items.get(id), (item, a) => gradeItem(config, item, a));
return buildResult(grades, maxScore(config), events);
}
Les briques de @dg/engine/core :
| Brique | Usage |
|---|---|
gradeAnswers, buildResult |
première réponse de chaque item, agrégat des compétences et de la durée |
withoutClientClaims(events) |
les événements tels que le serveur les rejoue (sans session.completed ni round.completed) |
hintsBeforeAnswer(events), applyHintPenalty(points, used, { cost, available }) |
indices lus avant la réponse, et leur coût |
AnswerKey, scriptedEvaluatorFor(answerKey), evaluateWithFallback |
clé de réponses dérivée du contenu, partagée par l'evaluator et le scoring |
splitHighlight(text, passage) |
surligner un passage dans un texte (<mark>) |
CyclingDeck(cartes, rng) |
pioche mélangée sans répétition, remélangée une fois épuisée (jeu d'arcade sans fin) |
PROMPT_SLOTS, slotCapacity, promptBrickSchema(n), slotQuality, worstLevel |
consigne composée de briques (rôle, tâche, contraintes, exemples, format) : schéma d'une brique et niveau d'un emplacement (Prompt Quest, Double Agent) |
SKILL_LABELS |
libellés FR/EN des compétences |
Pour des réponses écrites dans la config (missions, combinaisons), dérivez une clé de réponses
du contenu (AnswerKey<MyConfig>, une liste de ScriptedItem) : scriptedEvaluatorFor(key)
devient createEvaluator de la définition, et le scoring note avec gradeScripted sur la même
clé. Dans le jeu, evaluateWithFallback(host.evaluator, task, item) interroge l'evaluator de
l'hôte (scripté au MVP, LLM plus tard) et se rabat sur la clé s'il échoue.
4. Le composant racine
Le composant racine reçoit l'hôte par injectGameHost<MyConfig>(). Il ne doit jamais appeler
l'API, lire le routeur ni lire localStorage. L'hôte fournit :
| Membre | Usage |
|---|---|
mode |
play, preview (test d'un draft) ou edit (éditeur, sans chrono) |
config() |
config en direct, déjà dans la langue de la partie : lisez ses textes directement |
locale() |
langue de la partie, une ContentLocale (config, textes du moteur) ; les nombres suivent la langue du site |
t(text) |
un UiText écrit dans le code (titre du jeu, SKILL_LABELS), jamais du contenu |
seed |
createRng(host.seed), jamais Math.random() |
start(), pause(), resume(), complete(result), abandon(reason) |
cycle de vie de la partie |
telemetry.emit(event), events |
événements de jeu, et leur journal pour calculer le résultat |
clock.remainingMs(), clock.expired() |
chrono (null sans chrono) |
hud.setScore(), hud.setCombo(), hud.setProgress(), hud.setLives() |
état du HUD commun (vies : jeux d'arcade, null par défaut) ; setProgress({ item, label }) : label = libellé écrit par le jeu dans la langue de la partie (« Mission 2 sur 6 »), à la place de « Manche … · Item … » |
hud.setShowUntimed(false) |
masque la pastille « Mode : sans chrono » d'un jeu qui ne se joue jamais contre la montre (un chrono en cours reste affiché) |
feedback(kind, message, points) |
son et annonce d'une réponse (correct, wrong) ou d'une info (info) |
audio.play(id), audio.deny() |
sons du manifest ; une action refusée appelle toujours deny() |
assets.url(ref) |
URL d'une AssetRef |
reducedMotion() |
couper les animations non essentielles |
Déroulé type : start(), puis round.started, item.answered à chaque réponse (avec la réponse
brute dans answer), round.completed, et enfin
host.complete(scoring.scoreFromEvents(host.config(), withoutClientClaims(host.events))). Le
résultat affiché, durée comprise, est alors exactement celui que calcule le serveur. Quand
clock.expired() passe à vrai, terminez la partie avec les réponses déjà données.
Les briques de @dg/engine/angular, à appeler dans un champ du composant :
| Brique | Usage |
|---|---|
injectGameTimeout() |
minuterie ponctuelle (enchaînement, tapis) qui se fige pendant la pause |
injectGameCountdown(onExpire) |
temps limité d'une étape (mission, question) : start(s), remainingMs(), clear() |
readGameKey(event) |
touches 1 à 9, Entrée, flèches (direction) et lettres A à Z (letter, mot à taper), hors champs de saisie ; Entrée sur un bouton reste au clic natif |
injectAutofocus() |
après un changement d'écran, focus sur le dernier [data-autofocus] |
<dg-brand-logo /> |
logo de la marque du fork (branding.logo, nommé par branding.orgName), rien sans logo ; taille par --dg-brand-logo-max-inline et -max-block |
La page passe la config déjà rendue dans la langue de la partie (traductions appliquées par le
serveur ou le studio) : un template lit host.config().content.intro, jamais un { en, fr }.
Pour lire un texte par son pointeur (zones éditables, Phaser), utilisez
textAt(host.config(), pointer) de @dg/engine/core.
Posez [dgEditable]="'/content/intro'" sur chaque texte lié à la config : en mode edit,
l'élément est encadré et un clic sélectionne la zone. Sur un texte (ou un <span> dans un
bouton), le clic sélectionne sans déclencher l'action ; posé sur le bouton lui-même, le clic
sélectionne le libellé et garde l'action, pour que l'éditeur puisse parcourir le jeu.
5. HUD, feedback, pause et fin de partie
Le jeu ne dessine ni score, ni chrono, ni menu pause : le runner les affiche avec les composants
HUD de @dg/ui (ui-hud-score, ui-hud-progress, ui-hud-timer, bouton son, bouton pause),
à partir de host.hud, host.clock et host.status(). Échap met en pause ; le menu pause
propose Reprendre, Recommencer, Sons et Quitter.
Chaque host.feedback('correct' | 'wrong', message, points) est annoncé une seule fois par la
carte ui-answer-feedback du runner. Si le jeu affiche déjà sa propre correction, déclarez
feedbackCard: false dans sa définition : la carte reste lue par les lecteurs d'écran mais n'est
plus visible. Ne posez pas d'aria-live sur ce que le jeu affiche du même feedback.
À la fin de la partie, le runner affiche l'écran de fin commun à tous les jeux
(ui-end-screen) : titre, résumé du jeu, score, bilan par compétence (libellés de
SKILL_LABELS, niveau acquis à 80 %, en progrès à 50 %), Rejouer et Quitter. Sur les pages de la
plateforme, le score affiché est celui recalculé par le serveur, avec la mention « Score vérifié
par le serveur » (et le rang dans l'organisation) : c'est le seul score à l'écran.
Le jeu n'a pas d'écran de fin à lui. Une fois host.complete(...) appelé, il n'affiche plus
rien de nouveau (sa scène peut rester derrière l'écran de fin). Il peut seulement fournir un
résumé propre, affiché sous le titre de l'écran de fin : un composant chargé en lazy par
loadEndSummary, qui reçoit l'input result (le résultat affiché, celui du serveur dès qu'il
est connu) et le GameHost. La brique dg-end-summary de @dg/engine/angular en donne la
mise en page : un titre et des chiffres propres au jeu, avec leur pointeur pour l'édition sur
place. Ne répétez ni le score ni les compétences.
// my-game.definition.ts
loadEndSummary: () => import('./my-summary').then((m) => m.MySummary),
// my-summary.ts
@Component({
selector: 'dg-my-summary',
imports: [EndSummary],
template: `<dg-end-summary [heading]="ui().endTitle" headingPointer="/content/ui/endTitle"
[stats]="stats()" />`,
})
export class MySummary {
protected readonly host = injectGameHost<MyConfig>();
readonly result = input.required<GameResult>();
protected readonly ui = computed(() => this.host.config().content.ui);
protected readonly stats = computed<EndSummaryStat[]>(() => {
const { correct, total } = answerTally(this.result());
return [{ label: this.ui().correctCount, value: `${correct} / ${total}`,
pointer: '/content/ui/correctCount' }];
});
}
L'écran de fin s'affiche aussi dans l'éditeur (sans Quitter), pour y éditer le résumé sur place.
Testez le résumé avec renderGame(MySummary, { config, inputs: { result } }).
6. La scène Phaser
Phaser dessine la scène, Angular les textes, le HUD et les contrôles. Le canvas est masqué aux lecteurs d'écran : tout texte utile doit aussi être dans le DOM.
<dg-phaser-stage [scenes]="loadScenes" [context]="store" />
protected readonly loadScenes = () => import('./scenes/my-scene').then((m) => [m.MyScene]);
Les scènes étendent BaseScene (@dg/engine/phaser/runtime), qui donne this.host,
this.context (le store partagé avec Angular), this.watch(source, react) pour suivre un signal,
this.motion(ms) pour les durées d'animation et themeColor() pour lire le thème. EditableText
et EditableImage lient un texte ou une image du canvas à la config et les rendent éditables.
N'importez @dg/engine/phaser/runtime que depuis des fichiers de scènes chargés en lazy :
Phaser ne tourne pas sous jsdom. Le canvas est hors flux dans dg-phaser-stage : il ne force
jamais la taille de son parent et suit son cadre à chaque changement de mise en page, même en
pause. Donnez donc au cadre une taille propre (par exemple aspect-ratio: 16 / 9).
Par défaut, la scène tient entière dans son cadre (fit="contain"). Un décor plein cadre passe
fit="cover" : la scène couvre le cadre, bords rognés, quel que soit son format (téléphone en
portrait compris). ParallaxBackdrop (@dg/engine/phaser/runtime) monte un décor en plusieurs
plans : chaque plan suit le pointeur à son rythme, le décor dérive lentement, une nouvelle image
remplace l'ancienne en fondu (décor qui change au fil des manches) et chaque plan est une zone
éditable sur son pointeur. Modèle : la scène du bureau d'AI Not AI
(games/ai-not-ai/src/ui/scenes/office-scene.ts).
En plein écran, la page donne au runner toute la hauteur de l'écran (input fill de
dg-game-runner) : sa scène devient un conteneur de taille et expose --dg-stage-block-size.
Un jeu qui veut remplir l'écran lit cette variable pour la hauteur de sa scène, avec sa hauteur
de page en repli : block-size: var(--dg-stage-block-size, clamp(34rem, 80vh, 46rem)). Un jeu
qui ne la lit pas garde sa hauteur. Pensez alors aux formats larges : un premier plan dimensionné
sur la largeur seule (image à 108 % de la largeur) peut manger la scène en 16:9 ; bornez sa
hauteur (block-size: min(36cqi, 52%) + object-fit: cover, comme le bureau d'AI Not AI).
7. Thème
Le jeu n'utilise que les variables CSS --dg-game-*. Son univers est défini dans
games/<id>/theme.css sous [data-dg-game='<id>'] ; les surcharges du fork (couleurs de marque,
police, rayon) sont appliquées par le runner sur le seul conteneur du jeu. Sur une surface
changée, --dg-game-text-muted (texte adouci tant que le contraste AA tient) et
--dg-game-border (20 % du texte sur la surface) sont recalculés.
8. Tests
Testez l'UI avec le banc de test du moteur, comme dans sample-game.spec.ts :
vi.useFakeTimers();
const config = defaultConfigIn(myGameCore, 'fr'); // la config telle que la page la passe, en français
const game = renderGame(MyGame, { config, mode: 'play', locale: 'fr', providers: [provideNoPhaser()] });
game.click('[data-action="start"]');
game.press('1');
await game.settle(); // promesses (evaluator) et temps simulé
game.wait(1000);
// game.host.sink.events, game.host.bus.played(), game.host.result, game.host.editorBridge.selected
Couvrez les trois modes, les actions refusées (deny), la pause, la fin du chrono, et vérifiez
que host.result est égal à scoring.scoreFromEvents(config, withoutClientClaims(host.sink.events)).
Les scènes sont couvertes par les tests de bout en bout.
9. Relecture du contenu
bun run content:review dans le package du jeu écrit content-review.md : chaque texte du
contenu dans la langue source, puis dans chaque traduction (un texte sans traduction à jour est
marqué ⚠ à traduire), avec son pointeur et les champs utiles (id, compétence, réponse
attendue). Un expert métier relit ainsi tout le contenu en quelques minutes ; --check échoue si
le rapport n'est plus à jour. Le script tient en une ligne :
runContentReview(myGameCore, new URL('..', import.meta.url)).
10. Lancer le jeu
La plateforme exécute un jeu avec le runner, la config déjà rendue dans locale :
<dg-game-runner
[definition]="definition" [config]="config" [mode]="'play'" [locale]="'fr'"
[seed]="session.seed" [telemetry]="sink"
(completed)="onCompleted($event)" (abandoned)="onAbandoned($event)"
(restarted)="onRestarted()" />
En play et preview, sink vaut createHttpTelemetrySink(playSessionId, playToken) : les événements
partent par lots toutes les 5 secondes et en fin de partie, avec reprise après échec. Quand le
joueur rejoue, le runner crée une nouvelle session locale et émet restarted : la page ouvre
alors une nouvelle play session et passe sa graine et son sink.
11. Sons
Le runner joue les sons communs du moteur (ENGINE_SOUNDS : juste, faux, refus, compte à rebours,
début et fin de partie, musique d'ambiance). Pour des sons propres au jeu, générez-les avec
tooling/sound dans games/<id>/assets/audio/ et déclarez audioManifest: '<id>/audio' dans la
définition. Jouez-les avec host.audio.play(id) ou host.audio.startMusic(id) : le bus applique
le gain de mix du manifest, les règles du fork (rules.audio), le mute (bouton son du HUD) et
l'anti-répétition. Le pack d'un jeu se déclare dans tooling/sound (game_pack('<id>', '<préfixe>', CATALOG), ids préfixés, voir son README) ; un son accordé (qui ne doit pas varier de hauteur)
porte tonal: true dans le manifest.
Un jeu peut aussi remplacer un son du moteur par le sien : son son déclare
replaces='<id du son du moteur>' dans son SoundSpec (un id de ENGINE_SOUNDS, même
catégorie), et le manifest porte replaces. Le jeu continue d'appeler host.feedback() et
host.complete() : quand le moteur joue answer-correct, answer-wrong ou session-end, le bus
joue le son du jeu à la place, quel que soit l'ordre de chargement des manifests. Exemple : AI Not
AI, dont ainotai-good, ainotai-bad et ainotai-over sont les sons de son jeu d'origine.
Modifier cette page sur GitHub (s'ouvre dans un nouvel onglet)