diff --git a/docs/adr/0002-phaser-hors-budget-initial.md b/docs/adr/0002-phaser-hors-budget-initial.md index a26919a..47f8cfd 100644 --- a/docs/adr/0002-phaser-hors-budget-initial.md +++ b/docs/adr/0002-phaser-hors-budget-initial.md @@ -48,12 +48,29 @@ au budget JS, borné par quatre conditions : ### Mesures — à compléter en session 4 -| Mesure | Cible | Relevé | +| Mesure | Cible | Relevé (2026-07-27) | |---|---|---| -| Shell JS initial (gzip) | ≤ 170 Ko | _à mesurer_ | -| Bundle Phaser seul (gzip) | information | _à mesurer_ | -| fps scène Ranmasé sur Mali-G52 | ≥ 30 stables | _à mesurer_ | -| Poids total de l'accueil | ≤ 2 Mo | _à mesurer_ | +| Shell JS initial, Phaser exclu (gzip) | ≤ 170 Ko | **78 Ko** ✅ | +| Bundle Phaser seul (gzip) | information | **348 Ko** — sous le seuil de réexamen de 450 Ko | +| Phaser dans le chemin critique de l'accueil | absent | **absent**, vérifié sur le HTML prérendu ✅ | +| Poids total du build | ≤ 2 Mo | **1,4 Mo** ✅ | +| fps scène Ranmasé sur Mali-G52 | ≥ 30 stables | **non mesuré** — demande un appareil réel | + +Le total JS du site atteint 426 Ko gzip, mais 348 Ko ne sont téléchargés qu'à l'entrée dans +`/je/ranmase/`. Un visiteur de l'encyclopédie ne les voit jamais. + +### Correction apportée à cet ADR par la mise en œuvre + +Cet ADR prescrivait un « DPR plafonné à 1,5 » via l'option `resolution`. **Cette option +n'existe pas dans Phaser 4** : `pixelRatio` y est une information matérielle en lecture +seule, et `resolution` avait déjà été neutralisée en Phaser 3. + +L'objectif est atteint autrement, et mieux : le tampon de rendu est fixé à **480 × 640**, +étiré en CSS par le mode `FIT`. Le GPU dessine donc toujours 480 × 640 pixels, quel que +soit le DPR de l'appareil — c'est-à-dire un plafond dur, là où `resolution` n'aurait donné +qu'un plafond relatif. + +`powerPreference: 'low-power'` existe bien, sous la clé `render`. ## Condition de réexamen diff --git a/docs/sessions/04-ranmase-patouche.md b/docs/sessions/04-ranmase-patouche.md new file mode 100644 index 0000000..5107793 --- /dev/null +++ b/docs/sessions/04-ranmase-patouche.md @@ -0,0 +1,52 @@ +# Session 4 — Ranmasé (Phaser 4) et Pa touché + +**Date :** 2026-07-27 + +## Porte de sortie + +| Critère | Cible | Mesuré | +|---|---|---| +| Phaser dans le bundle initial | absent | **absent** du HTML prérendu de l'accueil ✅ | +| Shell JS initial, Phaser exclu | ≤ 170 Ko gzip | **78 Ko** ✅ | +| Bundle Phaser mesuré et consigné | fait | **348 Ko gzip**, inscrit dans l'ADR 0002 ✅ | +| Miroir DOM de la scène | navigable | liste textuelle de ce qui tombe, canvas `aria-hidden` ✅ | +| Écran de chargement designé | présent | flag chip + « Chargement du jaden… » ✅ | +| Module Pa touché | aucun chrono, aucun score, aucune épreuve | ✅ | +| `npm run check` | 0/0 | **266 fichiers, 0/0** ✅ | +| fps sur Mali-G52 | ≥ 30 stables | **non mesuré** — demande un appareil réel | + +## Pa touché — une consultation, pas un mode de jeu + +Cinq plantes, triées du plus grave au moins grave, chacune dépliable. Aucun chrono, aucun +score, aucun classement, rien à débloquer. Le brief est catégorique et il a raison : +transformer ces fiches en quiz d'adresse ferait d'une consigne de survie un jeu de +rapidité. + +Le module affiche lui-même qu'il **n'a pas encore été relu** par une personne compétente, +et dit qu'une telle relecture est la contribution la plus utile que le projet puisse +recevoir. C'est dans le produit, pas seulement dans un fichier de développeur. + +## Ranmasé — l'îlot Phaser + +Import dynamique depuis la route, `game.destroy(true)` au démontage, communication par +**événements** — la scène ne lit ni n'écrit aucun état Svelte, et n'émet que lorsqu'une +valeur change, jamais soixante fois par seconde. + +Trois choix de game design qui viennent de la pédagogie, pas du gameplay : + +- **Laisser passer une plante dangereuse ne coûte rien.** C'est le bon geste : le jeu ne + peut pas récompenser le fait d'attraper ce qu'on doit éviter. +- **Ramasser le mauvais fruit coûte 2 points, pas une vie.** Il n'y a pas d'erreur à + cueillir un fruit comestible ; il y a juste une consigne à mieux lire. +- **Une plante dangereuse sur cinq.** Présente, jamais dominante. + +Aucun asset externe : les objets sont des primitives graphiques dans la palette de la +charte. Embarquer des textures avant que le pipeline n'ait produit quoi que ce soit de +validé aurait été mettre la charrue avant les bœufs. + +### Une prescription de l'ADR 0002 qui était fausse + +L'ADR demandait un « DPR plafonné à 1,5 » via l'option `resolution`. **Elle n'existe pas +dans Phaser 4.** Corrigé et documenté : le tampon de rendu est fixé à 480 × 640 et étiré +en CSS par le mode `FIT`, ce qui donne un plafond dur là où `resolution` n'aurait donné +qu'un plafond relatif. L'ADR a été mis à jour plutôt que contourné. diff --git a/package-lock.json b/package-lock.json index e913b84..50b2940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@sveltejs/vite-plugin-svelte": "^7.2.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "phaser": "^4.2.1", "svelte": "^5.56.8", "svelte-check": "^4.7.4", "typescript": "^5.9.3", @@ -3469,6 +3470,13 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4989,6 +4997,16 @@ "node": "20 || >=22" } }, + "node_modules/phaser": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.1.tgz", + "integrity": "sha512-WUNwCPJpdjvZiuT6SgCfYVW8Qw/3j0jJ4ws7P2QkhFLFu74sbGuyHJcbFueGkY/AYO4Pi47bNQXn1OCJeLX//w==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index 1e51ee4..f18bb51 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@sveltejs/vite-plugin-svelte": "^7.2.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", + "phaser": "^4.2.1", "svelte": "^5.56.8", "svelte-check": "^4.7.4", "typescript": "^5.9.3", diff --git a/src/lib/jeu/ranmase/scene.ts b/src/lib/jeu/ranmase/scene.ts new file mode 100644 index 0000000..62d95da --- /dev/null +++ b/src/lib/jeu/ranmase/scene.ts @@ -0,0 +1,206 @@ +/** + * Scène « Ranmasé » — le seul îlot Phaser du projet (ADR 0003). + * + * Frontière stricte : cette scène ne lit ni n'écrit aucun état Svelte. Elle publie des + * événements, et la coquille Svelte s'y abonne. Le playbook §3 l'interdit explicitement — + * un état réactif touché à chaque frame déclenche un rendu Svelte 60 fois par seconde. + * + * Le module entier est chargé par import dynamique depuis la route, jamais au layout + * racine : Phaser ne doit pas entrer dans le chemin critique de l'encyclopédie. + * + * Aucun asset externe : les fruits sont dessinés en primitives graphiques. C'est cohérent + * avec la filière B (le décor peut être stylisé) et cela évite d'embarquer des textures + * avant que le pipeline d'assets n'ait produit quoi que ce soit de validé. + */ + +import type Phaser from 'phaser'; + +export interface EvenementsRanmase { + score: (valeur: number) => void; + vies: (valeur: number) => void; + consigne: (nom: string) => void; + /** Contenu du miroir DOM : ce qui tombe, en texte. */ + miroir: (objets: { id: string; nom: string; dangereux: boolean; x: number; y: number }[]) => void; + fini: (score: number) => void; +} + +export interface OptionsRanmase { + conteneur: HTMLElement; + fruits: { id: string; nom: string; couleur: number }[]; + dangereuses: { id: string; nom: string; couleur: number }[]; + sur: (evenement: K, gestionnaire: EvenementsRanmase[K]) => void; + emettre: ( + evenement: K, + ...args: Parameters + ) => void; +} + +const LARGEUR = 480; +const HAUTEUR = 640; +const VIES_DEPART = 3; + +interface Tombant { + id: string; + nom: string; + dangereux: boolean; + forme: Phaser.GameObjects.Arc; +} + +/** + * Construit et démarre le jeu. Renvoie une fonction de destruction : l'appelant DOIT + * l'invoquer au démontage, sinon le moteur survit à sa route et continue de consommer + * du rAF et de la mémoire. + */ +export async function demarrer(options: OptionsRanmase): Promise<() => void> { + const PhaserModule = (await import('phaser')).default; + + let score = 0; + let vies = VIES_DEPART; + let cible = options.fruits[Math.floor(Math.random() * options.fruits.length)]; + const tombants: Tombant[] = []; + let panier: Phaser.GameObjects.Rectangle; + let scene: Phaser.Scene; + let prochain = 0; + let termine = false; + + class Ranmase extends PhaserModule.Scene { + constructor() { + super('ranmase'); + } + + create() { + scene = this; + + this.cameras.main.setBackgroundColor('#0D0D0D'); + + panier = this.add.rectangle(LARGEUR / 2, HAUTEUR - 40, 96, 18, 0xfdb813); + panier.setStrokeStyle(2, 0xfff8e7); + + // Pointeur ET clavier : le mode doit rester jouable sans écran tactile. + this.input.on('pointermove', (p: Phaser.Input.Pointer) => { + panier.x = PhaserModule.Math.Clamp(p.x, 48, LARGEUR - 48); + }); + + const clavier = this.input.keyboard; + if (clavier) { + const gauche = clavier.addKey('LEFT'); + const droite = clavier.addKey('RIGHT'); + this.events.on('update', () => { + if (gauche.isDown) panier.x = Math.max(48, panier.x - 8); + if (droite.isDown) panier.x = Math.min(LARGEUR - 48, panier.x + 8); + }); + } + + options.emettre('consigne', cible.nom); + options.emettre('score', score); + options.emettre('vies', vies); + } + + update(temps: number, delta: number) { + if (termine) return; + this.events.emit('update'); + + if (temps > prochain) { + prochain = temps + 850; + this.lacher(); + } + + for (let i = tombants.length - 1; i >= 0; i--) { + const objet = tombants[i]; + objet.forme.y += (delta / 1000) * 190; + + const attrape = + objet.forme.y > panier.y - 22 && + objet.forme.y < panier.y + 22 && + Math.abs(objet.forme.x - panier.x) < 56; + + if (attrape) { + this.resoudre(objet, i); + } else if (objet.forme.y > HAUTEUR + 24) { + // Laisser passer une plante dangereuse est le bon geste : aucune sanction. + if (!objet.dangereux && objet.id === cible.id) this.perdreUneVie(); + objet.forme.destroy(); + tombants.splice(i, 1); + } + } + + options.emettre( + 'miroir', + tombants.map((o) => ({ + id: o.id, + nom: o.nom, + dangereux: o.dangereux, + x: Math.round(o.forme.x), + y: Math.round(o.forme.y) + })) + ); + } + + lacher() { + // Une plante dangereuse sur cinq : présente, jamais dominante. + const dangereux = Math.random() < 0.2 && options.dangereuses.length > 0; + const source = dangereux + ? options.dangereuses[Math.floor(Math.random() * options.dangereuses.length)] + : options.fruits[Math.floor(Math.random() * options.fruits.length)]; + + const x = 40 + Math.random() * (LARGEUR - 80); + const forme = this.add.circle(x, -20, dangereux ? 16 : 18, source.couleur); + forme.setStrokeStyle(dangereux ? 3 : 1, dangereux ? 0xff1654 : 0xfff8e7); + tombants.push({ id: source.id, nom: source.nom, dangereux, forme }); + } + + resoudre(objet: Tombant, index: number) { + if (objet.dangereux) { + this.perdreUneVie(); + } else if (objet.id === cible.id) { + score += 10; + options.emettre('score', score); + cible = options.fruits[Math.floor(Math.random() * options.fruits.length)]; + options.emettre('consigne', cible.nom); + } else { + // Ramasser le mauvais fruit ne punit pas : il n'y a pas d'erreur à cueillir. + score = Math.max(0, score - 2); + options.emettre('score', score); + } + objet.forme.destroy(); + tombants.splice(index, 1); + } + + perdreUneVie() { + vies -= 1; + options.emettre('vies', vies); + this.cameras.main.flash(120, 255, 22, 84); + if (vies <= 0) { + termine = true; + options.emettre('fini', score); + } + } + } + + const jeu = new PhaserModule.Game({ + type: PhaserModule.AUTO, // WebGL avec repli canvas + parent: options.conteneur, + width: LARGEUR, + height: HAUTEUR, + backgroundColor: '#0D0D0D', + // Phaser 4 n'expose plus `resolution` : `pixelRatio` y est une info matérielle + // en lecture seule. Le plafonnement se fait donc par la taille du tampon de rendu, + // fixée à 480×640 et étirée en CSS par le mode FIT. Le GPU dessine toujours + // 480×640 pixels, quel que soit le DPR de l'appareil — c'est l'objectif de l'ADR 0002, + // atteint autrement que prévu. + render: { powerPreference: 'low-power' }, + scale: { + mode: PhaserModule.Scale.FIT, + autoCenter: PhaserModule.Scale.CENTER_BOTH + }, + scene: [Ranmase], + audio: { noAudio: true } + }); + + return () => { + termine = true; + // Destruction symétrique : sans elle, le moteur survit à la route. + jeu.destroy(true); + void scene; + }; +} diff --git a/src/routes/[[locale=locale]]/je/+page.svelte b/src/routes/[[locale=locale]]/je/+page.svelte index 95b60b6..ce88ed8 100644 --- a/src/routes/[[locale=locale]]/je/+page.svelte +++ b/src/routes/[[locale=locale]]/je/+page.svelte @@ -19,8 +19,8 @@ { id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: true }, { id: 'kache', cle: 'jeu.kache', desc: 'jeu.kache.desc', route: 'kache', besoinPhotos: false, livre: true }, { id: 'sezon', cle: 'jeu.sezon', desc: 'jeu.sezon.desc', route: 'sezon', besoinPhotos: false, livre: true }, - { id: 'ranmase', cle: 'jeu.ranmase', desc: 'jeu.ranmase.desc', route: 'ranmase', besoinPhotos: false, livre: false }, - { id: 'patouche', cle: 'jeu.patouche', desc: 'jeu.patouche.desc', route: 'patouche', besoinPhotos: false, livre: false } + { id: 'ranmase', cle: 'jeu.ranmase', desc: 'jeu.ranmase.desc', route: 'ranmase', besoinPhotos: false, livre: true }, + { id: 'patouche', cle: 'jeu.patouche', desc: 'jeu.patouche.desc', route: 'patouche', besoinPhotos: false, livre: true } ] as const); diff --git a/src/routes/[[locale=locale]]/je/patouche/+page.svelte b/src/routes/[[locale=locale]]/je/patouche/+page.svelte new file mode 100644 index 0000000..7017f1f --- /dev/null +++ b/src/routes/[[locale=locale]]/je/patouche/+page.svelte @@ -0,0 +1,246 @@ + + + + +
+

{t(locale, 'jeu.quitter')}

+ +

{t(locale, 'jeu.patouche')}

+ +

{t(locale, 'patouche.avertissement')}

+ +
    + {#each plantes as p (p.id)} + {@const estOuverte = ouverte === p.id} +
  • +

    + +

    + + {#if p.danger} +

    {p.danger.resume}

    + {/if} + + +
  • + {/each} +
+ +

{t(locale, 'patouche.urgence')}

+ + +

+ Ces fiches n’ont pas encore été relues par une personne compétente. Si vous en êtes une — + enseignant de SVT, professionnel de santé, agriculteur — + votre relecture est la contribution la plus utile + que ce projet puisse recevoir. +

+
+ + diff --git a/src/routes/[[locale=locale]]/je/ranmase/+page.svelte b/src/routes/[[locale=locale]]/je/ranmase/+page.svelte new file mode 100644 index 0000000..0ca0159 --- /dev/null +++ b/src/routes/[[locale=locale]]/je/ranmase/+page.svelte @@ -0,0 +1,243 @@ + + + + +
+

{t(locale, 'jeu.quitter')}

+ +
+

{t(locale, 'jeu.ranmase')}

+

+ {#if consigne}Ranmasé : {consigne}{/if} +

+

+ {t(locale, 'jeu.score')} : {score} + + {#each Array(3) as _, i (i)} + = vies} aria-hidden="true"> + {/each} + +

+
+ + {#if chargement} + +
+ +

Chargement du jaden…

+
+ {/if} + + {#if erreur} +

Le mode arcade n’a pas pu démarrer : {erreur}

+

{t(locale, 'jeu.kache')}

+ {/if} + + + + +
+

Ce qui tombe

+

+ Version lisible de la scène. Déplacez le panier avec les flèches gauche et droite. +

+ {#if objets.length === 0} +

Rien ne tombe pour l’instant.

+ {:else} +
    + {#each objets as o (o.id + o.y)} +
  • + {o.nom} + + {o.dangereux ? '— à ne pas ramasser' : ''} · colonne {Math.round(o.x / 48)}, hauteur {Math.round( + (o.y / 640) * 100 + )} % + +
  • + {/each} +
+ {/if} +
+ + {#if fini} +
+

{t(locale, 'jeu.termine')}

+

+ + {score} +

+

{t(locale, 'jeu.quitter')}

+
+ {/if} +
+ +