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:
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user