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:
@@ -48,12 +48,29 @@ au budget JS, borné par quatre conditions :
|
|||||||
|
|
||||||
### Mesures — à compléter en session 4
|
### Mesures — à compléter en session 4
|
||||||
|
|
||||||
| Mesure | Cible | Relevé |
|
| Mesure | Cible | Relevé (2026-07-27) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Shell JS initial (gzip) | ≤ 170 Ko | _à mesurer_ |
|
| Shell JS initial, Phaser exclu (gzip) | ≤ 170 Ko | **78 Ko** ✅ |
|
||||||
| Bundle Phaser seul (gzip) | information | _à mesurer_ |
|
| Bundle Phaser seul (gzip) | information | **348 Ko** — sous le seuil de réexamen de 450 Ko |
|
||||||
| fps scène Ranmasé sur Mali-G52 | ≥ 30 stables | _à mesurer_ |
|
| Phaser dans le chemin critique de l'accueil | absent | **absent**, vérifié sur le HTML prérendu ✅ |
|
||||||
| Poids total de l'accueil | ≤ 2 Mo | _à mesurer_ |
|
| 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
|
## Condition de réexamen
|
||||||
|
|
||||||
|
|||||||
@@ -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é.
|
||||||
Generated
+18
@@ -16,6 +16,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
|
"phaser": "^4.2.1",
|
||||||
"svelte": "^5.56.8",
|
"svelte": "^5.56.8",
|
||||||
"svelte-check": "^4.7.4",
|
"svelte-check": "^4.7.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
@@ -3469,6 +3470,13 @@
|
|||||||
"url": "https://github.com/bgub/eta?sponsor=1"
|
"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": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -4989,6 +4997,16 @@
|
|||||||
"node": "20 || >=22"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"ajv-formats": "^3.0.1",
|
"ajv-formats": "^3.0.1",
|
||||||
|
"phaser": "^4.2.1",
|
||||||
"svelte": "^5.56.8",
|
"svelte": "^5.56.8",
|
||||||
"svelte-check": "^4.7.4",
|
"svelte-check": "^4.7.4",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@
|
|||||||
{ id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: true },
|
{ 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: '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: '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: '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: false }
|
{ id: 'patouche', cle: 'jeu.patouche', desc: 'jeu.patouche.desc', route: 'patouche', besoinPhotos: false, livre: true }
|
||||||
] as const);
|
] as const);
|
||||||
</script>
|
</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 n’ont 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 n’a 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 l’instant.</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>
|
||||||
Reference in New Issue
Block a user