feat(session-4): module Pa touché et scène arcade Ranmasé en Phaser 4

Pa touché est une consultation, pas un mode de jeu : cinq plantes triées
par gravité, dépliables, sans chrono, sans score, sans épreuve. Le module
affiche lui-même qu'il n'a pas encore été relu par une personne compétente
et dit que cette relecture est la contribution la plus utile au projet.

Ranmasé : import dynamique, game.destroy(true) au démontage, communication
par événements — la scène ne touche aucun état Svelte et n'émet que sur
changement, jamais par frame. Miroir DOM sémantique, canvas aria-hidden,
jouable au clavier. Écran de chargement designé.

Game design guidé par la pédagogie : laisser passer une plante dangereuse
ne coûte rien (c'est le bon geste), ramasser le mauvais fruit coûte 2
points et pas une vie (il n'y a pas d'erreur à cueillir), une plante
dangereuse sur cinq.

Budgets mesurés et consignés dans l'ADR 0002
- shell hors Phaser : 78 Ko gzip (budget 170)
- Phaser seul : 348 Ko gzip, sous le seuil de réexamen de 450
- absent du chemin critique de l'accueil, vérifié sur le HTML prérendu
- build total 1,4 Mo

Une prescription de l'ADR 0002 était fausse : l'option `resolution`
n'existe pas dans Phaser 4 (pixelRatio y est en lecture seule). Le
plafonnement passe par un tampon de rendu fixe 480×640 étiré en CSS —
plafond dur plutôt que relatif. L'ADR est corrigé, pas contourné.

Reste non mesuré : les 30 fps sur Mali-G52, qui demandent un appareil réel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
OKI
2026-07-27 12:07:48 -04:00
co-authored by Claude Opus 5
parent 0683992384
commit 3c660c776b
8 changed files with 790 additions and 7 deletions
+206
View File
@@ -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: <K extends keyof EvenementsRanmase>(evenement: K, gestionnaire: EvenementsRanmase[K]) => void;
emettre: <K extends keyof EvenementsRanmase>(
evenement: K,
...args: Parameters<EvenementsRanmase[K]>
) => 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;
};
}