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;
|
||||
};
|
||||
}
|
||||
@@ -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 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