Créer un nouveau jeu

Construire pas à pas un jeu Maketools sur le moteur commun, en partant du jeu d'exemple.

DéveloppeursLecture 23 min

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-text dans 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 source ne 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 configSchema ou checkContent (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ée SkillId).
  • 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, en UiText). Pour chaque entité : title (le texte qui nomme un élément dans une liste, jamais un id), overline, idField (id stable, masqué), facets et columns (énumérations, booléens, tableaux d'énumération, textes de 60 caractères au plus, ou derived:<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), sections de la fiche, references vers 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/*') porte fixedReason : 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 les references (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)