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;
};
}
+2 -2
View File
@@ -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);
</script>
@@ -0,0 +1,246 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { DANGEREUSES, NOM_ILE } from '$lib/data';
const { data } = $props();
const locale = $derived(data.locale);
/**
* MODULE DE SÉCURITÉ — brief §6.
*
* Aucun chrono, aucun score, aucune épreuve, aucun classement. On n'apprend pas le
* danger sous pression, et transformer ces cinq plantes en quiz d'adresse
* transformerait une consigne de survie en jeu de rapidité.
*
* Ce n'est donc pas un mode de jeu : c'est une consultation. L'ordre est fixe, du
* plus grave au moins grave, et rien ne se débloque.
*/
const GRAVITE = { critique: 0, eleve: 1, vigilance: 2 } as const;
const plantes = $derived(
[...DANGEREUSES].sort(
(a, b) => (GRAVITE[a.danger?.niveau ?? 'vigilance'] ?? 3) - (GRAVITE[b.danger?.niveau ?? 'vigilance'] ?? 3)
)
);
/** Ce qu'un enfant doit pouvoir reconnaître sur le terrain, dans l'ordre où il le verra. */
const INDICES = ['port', 'feuille', 'fruit', 'fleur', 'ecorce', 'graine', 'latex', 'usage_haie'] as const;
let ouverte = $state<string | null>(null);
const basculer = (id: string) => (ouverte = ouverte === id ? null : id);
</script>
<Seo
{locale}
pathname={page.url.pathname}
titre={t(locale, 'jeu.patouche')}
description="Les plantes des Antilles à ne jamais toucher : mancenillier, akée non mûr, coque de pomme-cajou, médicinier, laurier-jaune."
/>
<div class="container section">
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
<h1 lang="gcf">{t(locale, 'jeu.patouche')}</h1>
<p class="cadre">{t(locale, 'patouche.avertissement')}</p>
<ul class="plantes">
{#each plantes as p (p.id)}
{@const estOuverte = ouverte === p.id}
<li class="plante niveau-{p.danger?.niveau ?? 'vigilance'}">
<h2>
<button
class="titre"
aria-expanded={estOuverte}
aria-controls={`detail-${p.id}`}
onclick={() => basculer(p.id)}
>
<span class="nom" lang="gcf">{p.noms.gcf}</span>
<span class="fr muted">{p.noms.fr}</span>
{#if p.danger}
<span class="tag tag--danger">{t(locale, `fiche.danger.${p.danger.niveau}`)}</span>
{/if}
</button>
</h2>
{#if p.danger}
<p class="resume">{p.danger.resume}</p>
{/if}
<div id={`detail-${p.id}`} class="detail" hidden={!estOuverte}>
<p class="latin">{p.taxon.scientifique} — {p.taxon.famille}</p>
<p class="muted petit">{p.iles.map((i) => NOM_ILE[i]).join(' · ')}</p>
<h3>Comment la reconnaître</h3>
<dl>
{#each INDICES.filter((k) => p.indices[k]) as cle (cle)}
<dt>{t(locale, `fiche.indice.${cle}`)}</dt>
<dd>{p.indices[cle]}</dd>
{/each}
</dl>
{#if p.danger?.signalement}
<h3>{t(locale, 'fiche.danger.signalement')}</h3>
<p>{p.danger.signalement}</p>
{/if}
{#if p.danger?.conduite}
<h3>{t(locale, 'patouche.conduite')}</h3>
<p class="conduite">{p.danger.conduite}</p>
{/if}
<p><a href={chemin(locale, 'zerbaj', p.id)}>{t(locale, 'zerbaj.voir')}</a></p>
</div>
</li>
{/each}
</ul>
<p class="urgence">{t(locale, 'patouche.urgence')}</p>
<!--
Ce module doit être relu par un humain compétent avant livraison (brief §10).
Le dire dans le produit, et pas seulement dans un fichier de développeur.
-->
<p class="relecture muted">
Ces fiches nont pas encore été relues par une personne compétente. Si vous en êtes une —
enseignant de SVT, professionnel de santé, agriculteur —
<a href={chemin(locale, 'kontribiye')}>votre relecture est la contribution la plus utile</a>
que ce projet puisse recevoir.
</p>
</div>
<style>
.fil {
font-size: 0.85rem;
}
.cadre {
padding: var(--space-2);
background: var(--card-bg);
border-left: 4px solid var(--rouge-oki);
border-radius: var(--radius-sm);
max-width: 60ch;
}
.plantes {
list-style: none;
margin: var(--space-3) 0;
padding: 0;
display: grid;
gap: var(--space-2);
}
.plante {
padding: var(--space-3);
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--rouge-oki);
border-radius: var(--radius-sm);
}
.plante.niveau-eleve {
border-left-color: var(--orange-flamme);
}
.plante.niveau-vigilance {
border-left-color: var(--jaune-soleil);
}
h2 {
margin: 0 0 var(--space-1);
}
.titre {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.6rem;
width: 100%;
min-height: 44px;
padding: 0;
background: none;
border: 0;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.nom {
font-family: var(--font-display);
font-size: 1.3rem;
text-transform: uppercase;
}
.fr {
font-family: var(--font-body);
font-size: 0.95rem;
text-transform: none;
}
.resume {
font-weight: 600;
max-width: 62ch;
margin-bottom: 0.5rem;
}
.detail {
padding-top: var(--space-2);
border-top: 1px solid var(--line);
}
.detail h3 {
font-size: 0.85rem;
margin-bottom: 0.3rem;
}
.latin {
font-style: italic;
margin-bottom: 0.2rem;
}
dl {
display: grid;
grid-template-columns: minmax(7rem, auto) 1fr;
gap: 0.3rem var(--space-2);
margin: 0 0 var(--space-2);
}
dt {
font-family: var(--font-display);
font-size: 0.75rem;
text-transform: uppercase;
color: var(--or-oki);
}
dd {
margin: 0;
}
.conduite {
padding: var(--space-1) var(--space-2);
background: color-mix(in srgb, var(--rouge-oki) 8%, transparent);
border: 1px solid var(--rouge-oki);
border-radius: var(--radius-sm);
}
.urgence {
margin-top: var(--space-3);
font-family: var(--font-display);
text-transform: uppercase;
color: var(--rouge-oki);
}
.relecture {
margin-top: var(--space-4);
padding-top: var(--space-2);
border-top: 1px solid var(--line);
font-size: 0.85rem;
max-width: 60ch;
}
.petit {
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,243 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { DANGEREUSES, FRUITS } from '$lib/data';
import { compte } from '$lib/motion/countup';
const { data } = $props();
const locale = $derived(data.locale);
let conteneur = $state<HTMLDivElement | null>(null);
let chargement = $state(true);
let erreur = $state('');
// État du HUD. Écrit par des événements, jamais par frame : la scène émet quand
// quelque chose change, pas soixante fois par seconde (ADR 0003).
let score = $state(0);
let vies = $state(3);
let consigne = $state('');
let fini = $state(false);
/** Miroir DOM de la scène : le canvas est décoratif, l'état lisible vit ici. */
let objets = $state<{ id: string; nom: string; dangereux: boolean; x: number; y: number }[]>([]);
/** Palette de la charte : l'or porte l'action, le rouge signale. */
const COULEURS = [0xfdb813, 0x00d66c, 0xffd700, 0xff6b35, 0x00ced1];
onMount(() => {
let detruire: (() => void) | undefined;
let annule = false;
(async () => {
try {
// Import dynamique : Phaser n'entre jamais dans le bundle initial (ADR 0002).
const { demarrer } = await import('$lib/jeu/ranmase/scene');
if (annule || !conteneur) return;
detruire = await demarrer({
conteneur,
fruits: FRUITS.map((e, i) => ({
id: e.id,
nom: e.noms.gcf,
couleur: COULEURS[i % COULEURS.length]
})),
dangereuses: DANGEREUSES.map((e) => ({ id: e.id, nom: e.noms.gcf, couleur: 0x6b2d5c })),
sur: () => {},
emettre: (evenement, ...args) => {
if (evenement === 'score') score = args[0] as number;
else if (evenement === 'vies') vies = args[0] as number;
else if (evenement === 'consigne') consigne = args[0] as string;
else if (evenement === 'miroir') objets = args[0] as typeof objets;
else if (evenement === 'fini') fini = true;
}
});
chargement = false;
} catch (e) {
erreur = e instanceof Error ? e.message : String(e);
chargement = false;
}
})();
return () => {
annule = true;
detruire?.();
};
});
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.ranmase')} />
<div class="container section jeu">
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
<header class="hud">
<h1 lang="gcf">{t(locale, 'jeu.ranmase')}</h1>
<p class="consigne">
{#if consigne}Ranmasé : <strong lang="gcf">{consigne}</strong>{/if}
</p>
<p class="etat">
<span>{t(locale, 'jeu.score')} : {score}</span>
<span aria-label={`${vies} vies restantes`}>
{#each Array(3) as _, i (i)}
<span class="vie" class:perdue={i >= vies} aria-hidden="true"></span>
{/each}
</span>
</p>
</header>
{#if chargement}
<!-- Écran de chargement designé, pas une page blanche (playbook P5). -->
<div class="chargement">
<span class="flag-chip" aria-hidden="true"></span>
<p>Chargement du jaden…</p>
</div>
{/if}
{#if erreur}
<p class="erreur">Le mode arcade na pas pu démarrer : {erreur}</p>
<p><a class="btn" href={chemin(locale, 'je', 'kache')}>{t(locale, 'jeu.kache')}</a></p>
{/if}
<!-- Le canvas est décoratif : tout son contenu est doublé dans le miroir ci-dessous. -->
<div class="scene" bind:this={conteneur} aria-hidden="true"></div>
<section class="miroir" aria-labelledby="miroir-titre">
<h2 id="miroir-titre">Ce qui tombe</h2>
<p class="muted petit">
Version lisible de la scène. Déplacez le panier avec les flèches gauche et droite.
</p>
{#if objets.length === 0}
<p class="muted">Rien ne tombe pour linstant.</p>
{:else}
<ul>
{#each objets as o (o.id + o.y)}
<li class:danger={o.dangereux}>
<span lang="gcf">{o.nom}</span>
<span class="muted petit">
{o.dangereux ? '— à ne pas ramasser' : ''} · colonne {Math.round(o.x / 48)}, hauteur {Math.round(
(o.y / 640) * 100
)} %
</span>
</li>
{/each}
</ul>
{/if}
</section>
{#if fini}
<div class="bilan" aria-live="polite">
<h2>{t(locale, 'jeu.termine')}</h2>
<p class="gros">
<span use:compte={score} aria-hidden="true">0</span>
<span class="sr-only">{score}</span>
</p>
<p><a class="btn" href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
</div>
{/if}
</div>
<style>
.jeu {
max-width: 44rem;
}
.fil {
font-size: 0.85rem;
}
.hud h1 {
margin-bottom: 0.2rem;
}
.consigne {
font-size: 1.1rem;
margin-bottom: 0.3rem;
}
.consigne strong {
font-family: var(--font-display);
text-transform: uppercase;
color: var(--or-oki);
}
.etat {
display: flex;
gap: var(--space-3);
align-items: center;
font-family: var(--font-display);
text-transform: uppercase;
font-size: 0.85rem;
}
.vie {
display: inline-block;
width: 14px;
height: 14px;
margin-right: 4px;
background: var(--rouge-oki);
}
.vie.perdue {
background: var(--line);
}
.scene {
margin: var(--space-2) 0;
min-height: 200px;
}
.chargement {
display: grid;
justify-items: center;
gap: var(--space-1);
padding: var(--space-4);
color: var(--muted);
}
.erreur {
padding: var(--space-2);
border: 1px solid var(--rouge-oki);
border-radius: var(--radius-sm);
}
.miroir {
margin-top: var(--space-3);
padding-top: var(--space-2);
border-top: 1px solid var(--line);
}
.miroir h2 {
font-size: 1rem;
}
.miroir ul {
list-style: none;
margin: 0;
padding: 0;
}
.miroir li {
padding: 0.25rem 0;
border-bottom: 1px solid var(--line);
font-size: 0.9rem;
}
.miroir li.danger {
color: var(--rouge-oki);
}
.bilan {
text-align: center;
margin-top: var(--space-3);
}
.gros {
font-family: var(--font-display);
font-size: clamp(2.5rem, 10vw, 5rem);
color: var(--or-oki);
line-height: 1;
}
.petit {
font-size: 0.85rem;
}
</style>