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
+22 -5
View File
@@ -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
+52
View File
@@ -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é.
+18
View File
@@ -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",
+1
View File
@@ -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",
+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>