feat(session-2): moteur pédagogique en Svelte pur — Kaché, Sézon, Rekonèt, Lakou

Trois modes, l'album et la progression locale. Aucun Phaser.

Kaché est pleinement jouable : indices dévoilés un à un (feuille →
écorce → port → coupe → fruit), score dégressif de 100 à 20. Il ne
dépend d'aucune photographie, ce qui le rend jouable aujourd'hui alors
qu'aucune image n'est validée. Les distracteurs sont tirés par proximité
botanique — un corossol face à une pomme-cannelle enseigne la vraie
confusion, deux Annona qu'on confond réellement.

Sézon est jouable et affiche en permanence que sa saisonnalité n'est
sourcée nulle part (a-arbitrer §3). Signalé, pas caché.

Rekonèt est complet mais bloqué : 0 photo validée sur 8 espèces. Il
affiche un état designé qui dit pourquoi, et pose la règle n°1 là où un
enfant la lira — il ne se rabattra jamais sur une image générée.

Répétition espacée Leitner à 5 boîtes dans localStorage, tirage pondéré
8 contre 1 vers les boîtes basses. Vérifié en jouant : bonne réponse →
boîte 2 et fiche débloquée, erreur → retour boîte 1. Export/import JSON,
aucun compte, aucune donnée qui quitte l'appareil.

use:compte pour le moment de révélation : rAF → DOM direct, span animé
aria-hidden, valeur finale en .sr-only dans une région aria-live.

Mesures
- 65,6 Ko de JS gzip (budget 170) · 68 pages prérendues
- npm run check : 259 fichiers, 0 erreur, 0 warning
- elementFromPoint à 375 px : 0 recouvrement, 0 cible sous 44 px
- ordre de tabulation naturel, aucun tabindex positif

Défaut trouvé en testant : le fil d'Ariane faisait 16 px de haut sur
mobile. Corrigé — un lien seul sur sa ligne est une commande, pas un
lien de texte.

La porte « le jeu est-il amusant sans Phaser » reste ouverte : elle
demande de jouer, et elle appartient au porteur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
OKI
2026-07-27 11:53:22 -04:00
co-authored by Claude Opus 5
parent ccad7177b3
commit dadcbdbcbb
10 changed files with 1413 additions and 21 deletions
+98
View File
@@ -0,0 +1,98 @@
# Session 2 — Moteur pédagogique, en Svelte pur
**Date :** 2026-07-27 · **Livrable :** trois modes de jeu, l'album, la progression locale.
## La porte de sortie, honnêtement
| Critère | Cible | Mesuré |
|---|---|---|
| **Le jeu est amusant sans Phaser** | jugement humain | **à trancher par le porteur** — voir plus bas |
| Navigation clavier | complète | contrôles `<button>` natifs, ordre DOM naturel, **aucun `tabindex` positif**, règle `:focus-visible` présente ✅ |
| Cibles tactiles | ≥ 44 px en 375 px | **0 élément sous 44 px** après correction ✅ |
| Zéro superposition | `elementFromPoint` | **0 recouvrement** sur 11 contrôles ✅ |
| Scroll horizontal mobile | aucun | aucun ✅ |
| Progression conservée au rechargement | oui | vérifié dans `localStorage` ✅ |
| JS initial gzip | ≤ 170 Ko | **65,6 Ko** ✅ |
| `npm run check` | 0/0 | **259 fichiers, 0/0** ✅ |
| Erreurs console en partie | 0 | 0 ✅ |
**Le « le jeu est-il amusant » n'est pas une mesure que je peux prendre.** Elle demande de
jouer, et elle appartient au porteur. C'est la porte la plus importante du plan et elle
reste ouverte.
## Ce qui est jouable
### Kaché — pleinement jouable
Cinq questions, indices dévoilés un à un dans l'ordre feuille → écorce → port → coupe →
fruit, score dégressif de 100 à 20 points. Quatre propositions.
**Ce mode ne dépend d'aucune photographie** : il repose sur les indices textuels du dataset.
C'est ce qui le rend jouable aujourd'hui, alors qu'aucune image n'est validée.
Les distracteurs sont choisis par proximité — même famille botanique d'abord, puis mêmes
îles. Un corossol opposé à trois plantes sans rapport n'apprend rien ; opposé à une
pomme-cannelle, il enseigne la vraie confusion, celle qui existe sur le terrain entre deux
*Annona*.
### Sézon — jouable, avec un avertissement affiché
« Quel fruit trouve-t-on en octobre ? » Six questions, quatre propositions.
Le mode **affiche en permanence** que sa saisonnalité n'est vérifiée contre aucune source
et n'est pas ventilée par île (`docs/a-arbitrer.md` §3). Le plan interdit de le publier en
l'état ; il est livré jouable et signalé, pas caché.
### Rekonèt — livré, et bloqué
Le code est complet et fonctionnera dès la première photo validée. Aujourd'hui il affiche
un **état designé** plutôt qu'une page d'erreur : combien d'espèces disposent d'une
photographie validée (0 sur 8), pourquoi il en faut au moins quatre, et surtout —
> « Ce mode fonctionne uniquement sur des photographies réelles, validées par un humain.
> Il ne se rabattra jamais sur une image générée : ce serait apprendre à reconnaître ce
> qu'un modèle invente. »
C'est la règle n°1 du projet, écrite là où un enfant et son enseignant la liront.
### Lakou — l'album
Fiches débloquées, boîtes de Leitner visualisées, export et import de la progression en
JSON, effacement. Les fiches non débloquées apparaissent en cases voilées : on voit ce
qu'il reste à découvrir sans voir la réponse.
## Répétition espacée
Leitner à 5 boîtes, persisté dans `localStorage`. Bonne réponse → la carte monte d'une
boîte ; erreur → retour en boîte 1. Le tirage pondère les boîtes basses 8 contre 1.
Vérifié en jouant : `fwiyapen` répondu juste passe en boîte 2 et se débloque, `pom-kannel`
répondu faux reste en boîte 1 et ne se débloque pas.
Aucun compte, aucun serveur, aucune donnée qui quitte l'appareil. L'export JSON est le seul
moyen de déplacer une progression — et il est utile à un enseignant.
## Le moment de révélation
`use:compte` anime le score final : rAF avec écriture DOM directe, **jamais d'état réactif
par frame**. Le span animé est `aria-hidden`, la valeur finale vit en `.sr-only` dans une
région `aria-live="polite"` — un lecteur d'écran annonce le résultat une fois, pas les
quarante valeurs intermédiaires. En `prefers-reduced-motion`, la valeur finale est posée
immédiatement.
Il manque une pièce au moment de révélation : **le nom kréyòl prononcé**. Aucun
enregistrement n'est embarqué, le jeu se replie en silence et le dit.
## Un défaut trouvé en testant, pas en relisant
Le fil d'Ariane « Retour à l'encyclopédie » mesurait **16 px de haut** sur mobile — moins
de la moitié de la cible tactile minimale. Trouvé par `elementFromPoint` à 375 px, pas à la
relecture du code. Corrigé par une règle partagée : un lien seul sur sa ligne est une
commande, pas un lien de texte.
## Reste ouvert
- **Jouer et juger.** C'est la porte de la session.
- Les prononciations : 10 à enregistrer, 3 déjà disponibles sur Lingua Libre.
- Rekonèt attend des photographies validées.
- Sézon attend une source pour sa saisonnalité.
+130
View File
@@ -0,0 +1,130 @@
/**
* Progression du joueur — répétition espacée façon Leitner, persistée en local.
*
* Aucun compte, aucun serveur, aucune donnée qui sort de l'appareil. La progression
* vit dans `localStorage` et s'exporte en JSON pour qu'un enseignant puisse la lire
* ou la déplacer d'un appareil à l'autre.
*
* Le système de Leitner à 5 boîtes est ce qui fait *apprendre* plutôt que *deviner* :
* une bonne réponse fait monter la carte d'une boîte, une erreur la renvoie en boîte 1.
* Les boîtes basses reviennent bien plus souvent.
*/
import { browser } from '$app/environment';
const CLE = 'ki-fwi-progression';
const VERSION = 1;
/** Poids de tirage par boîte : la boîte 1 revient 8 fois plus souvent que la boîte 5. */
const POIDS_BOITE = [8, 5, 3, 2, 1];
export const NB_BOITES = 5;
export interface EtatEntree {
/** Boîte de Leitner, de 1 (à revoir souvent) à 5 (acquis). */
boite: number;
vues: number;
reussites: number;
/** ISO court. Sert à ne pas reproposer immédiatement ce qu'on vient de voir. */
derniere: string | null;
/** La fiche est-elle débloquée dans l'album ? */
debloque: boolean;
}
export interface Progression {
version: number;
entrees: Record<string, EtatEntree>;
/** Total cumulé, pour le plaisir du compteur. Jamais comparé à autrui. */
pointsTotal: number;
}
const vide = (): Progression => ({ version: VERSION, entrees: {}, pointsTotal: 0 });
function charger(): Progression {
if (!browser) return vide();
try {
const brut = localStorage.getItem(CLE);
if (!brut) return vide();
const p = JSON.parse(brut) as Progression;
// Une progression d'une version inconnue est ignorée plutôt que mal interprétée.
if (p.version !== VERSION || typeof p.entrees !== 'object') return vide();
return { ...vide(), ...p };
} catch {
return vide();
}
}
/**
* Objet réactif exporté. `$state` dans un module `.svelte.ts` : la progression est
* partagée par toutes les vues du jeu sans passer par des props en cascade.
*/
export const progression = $state<Progression>(charger());
function sauver() {
if (!browser) return;
try {
localStorage.setItem(CLE, JSON.stringify(progression));
} catch {
/* Quota plein ou stockage refusé : le jeu continue, la progression ne survit pas. */
}
}
export function etatDe(id: string): EtatEntree {
return progression.entrees[id] ?? { boite: 1, vues: 0, reussites: 0, derniere: null, debloque: false };
}
/** Enregistre une réponse et déplace la carte dans les boîtes de Leitner. */
export function repondre(id: string, correct: boolean, points = 0): EtatEntree {
const avant = etatDe(id);
const apres: EtatEntree = {
boite: correct ? Math.min(avant.boite + 1, NB_BOITES) : 1,
vues: avant.vues + 1,
reussites: avant.reussites + (correct ? 1 : 0),
derniere: new Date().toISOString().slice(0, 10),
// Une fiche se débloque à la première bonne réponse — et ne se reverrouille jamais.
debloque: avant.debloque || correct
};
progression.entrees[id] = apres;
progression.pointsTotal += Math.max(0, points);
sauver();
return apres;
}
/** Ordre de révision : les boîtes basses d'abord, avec une part d'aléatoire. */
export function ordreRevision(ids: string[]): string[] {
return [...ids].sort((a, b) => {
const pa = POIDS_BOITE[etatDe(a).boite - 1] ?? 1;
const pb = POIDS_BOITE[etatDe(b).boite - 1] ?? 1;
return pb * Math.random() - pa * Math.random();
});
}
export function debloquees(): string[] {
return Object.entries(progression.entrees)
.filter(([, e]) => e.debloque)
.map(([id]) => id);
}
export function reinitialiser() {
progression.entrees = {};
progression.pointsTotal = 0;
sauver();
}
/** Export JSON — utile à un enseignant, et seule façon de déplacer sa progression. */
export function exporter(): string {
return JSON.stringify(progression, null, 2);
}
export function importer(json: string): boolean {
try {
const p = JSON.parse(json) as Progression;
if (p.version !== VERSION || typeof p.entrees !== 'object') return false;
progression.entrees = p.entrees;
progression.pointsTotal = Number(p.pointsTotal) || 0;
sauver();
return true;
} catch {
return false;
}
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Tirage des questions et des mauvaises réponses.
*
* Le choix des distracteurs fait la difficulté d'un QCM : proposer un corossol contre
* trois plantes sans rapport n'apprend rien. On privilégie donc la même famille
* botanique, puis les mêmes îles — c'est exactement là que la confusion existe sur le
* terrain (kowosòl et pòm-kannèl sont deux Annona, et on les confond vraiment).
*/
import type { Entree } from '$lib/data/types';
export function melanger<T>(liste: T[]): T[] {
const copie = [...liste];
for (let i = copie.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copie[i], copie[j]] = [copie[j], copie[i]];
}
return copie;
}
/**
* Choisit `nb` distracteurs pour une cible, du plus proche au plus lointain :
* même famille → mêmes îles → n'importe lequel.
*/
export function distracteurs(cible: Entree, bassin: Entree[], nb = 3): Entree[] {
const autres = bassin.filter((e) => e.id !== cible.id);
const memeFamille = melanger(autres.filter((e) => e.taxon.famille === cible.taxon.famille));
const memeIle = melanger(
autres.filter((e) => e.taxon.famille !== cible.taxon.famille && e.iles.some((i) => cible.iles.includes(i)))
);
const reste = melanger(
autres.filter((e) => e.taxon.famille !== cible.taxon.famille && !e.iles.some((i) => cible.iles.includes(i)))
);
return [...memeFamille, ...memeIle, ...reste].slice(0, nb);
}
/** Les quatre propositions d'une question, ordre mélangé. */
export function propositions(cible: Entree, bassin: Entree[], nb = 3): Entree[] {
return melanger([cible, ...distracteurs(cible, bassin, nb)]);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* use:compte — le compteur du « moment de révélation » (playbook §5b.12).
*
* Deux règles, toutes deux apprises sur le jeu JWE :
* 1. rAF → écriture DOM directe. Jamais d'état réactif par frame : Svelte
* reprogrammerait un rendu complet 60 fois par seconde pour un nombre.
* 2. Le span animé est `aria-hidden` et la valeur finale vit dans une région
* `aria-live` séparée. Un lecteur d'écran ne doit pas énoncer les 43 valeurs
* intermédiaires — il annonce le résultat, une fois.
*
* En `prefers-reduced-motion`, la valeur finale est posée immédiatement.
*/
interface Options {
cible: number;
depart?: number;
duree?: number;
}
const reduit = () =>
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
/** Sortie douce : rapide au début, se pose à la fin. Cadence `--ease-ka` de la charte. */
const sortie = (t: number) => 1 - Math.pow(1 - t, 3);
export function compte(node: HTMLElement, options: Options | number) {
let frame = 0;
const lire = (o: Options | number): Required<Options> =>
typeof o === 'number'
? { cible: o, depart: 0, duree: 900 }
: { cible: o.cible, depart: o.depart ?? 0, duree: o.duree ?? 900 };
function jouer(o: Options | number) {
cancelAnimationFrame(frame);
const { cible, depart, duree } = lire(o);
if (reduit() || duree <= 0) {
node.textContent = String(cible);
return;
}
const t0 = performance.now();
const parcours = cible - depart;
const tick = (maintenant: number) => {
const avancement = Math.min(1, (maintenant - t0) / duree);
node.textContent = String(Math.round(depart + parcours * sortie(avancement)));
if (avancement < 1) frame = requestAnimationFrame(tick);
};
node.textContent = String(depart);
frame = requestAnimationFrame(tick);
}
jouer(options);
return {
update: (o: Options | number) => jouer(o),
destroy: () => cancelAnimationFrame(frame)
};
}
+14
View File
@@ -216,6 +216,20 @@ a.card:focus-visible {
cursor: not-allowed; cursor: not-allowed;
} }
/**
* Liens de navigation isolés — fil d'Ariane, « quitter », retours.
*
* Un lien seul sur sa ligne est une commande, pas un lien de texte : il doit offrir
* une cible tactile d'au moins 44 px. Mesuré à 16 px sur mobile avant cette règle.
* L'épaisseur visuelle reste celle du texte, seule la zone de contact grandit.
*/
.fil a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding-right: 0.5rem;
}
.muted { .muted {
color: var(--muted); color: var(--muted);
} }
+14 -21
View File
@@ -16,9 +16,9 @@
* un lien mort — et le prerender strict fait échouer le build, ce qui est voulu. * un lien mort — et le prerender strict fait échouer le build, ce qui est voulu.
*/ */
const modes = $derived([ const modes = $derived([
{ id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: false }, { 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: false }, { 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: false }, { 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: false },
{ 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: false }
] as const); ] as const);
@@ -32,26 +32,23 @@
<ul class="grid modes"> <ul class="grid modes">
{#each modes as mode (mode.id)} {#each modes as mode (mode.id)}
{@const bloque = mode.besoinPhotos && jouables < 4} {@const enAttente = mode.besoinPhotos && jouables < 4}
<li> <li>
{#if mode.livre && !bloque} {#if mode.livre}
<a class="card" class:card--sur={mode.id === 'patouche'} href={chemin(locale, 'je', mode.route)}> <a class="card" href={chemin(locale, 'je', mode.route)}>
<h2 lang="gcf">{t(locale, mode.cle)}</h2> <h2 lang="gcf">{t(locale, mode.cle)}</h2>
<p>{t(locale, mode.desc)}</p> <p>{t(locale, mode.desc)}</p>
{#if enAttente}
<!-- Le mode reste accessible : sa page explique le manque plutôt que
de le masquer derrière un lien mort (ADR 0006). -->
<p class="tag tag--danger">En attente de photographies</p>
{/if}
</a> </a>
{:else} {:else}
<div class="card card--bloque"> <div class="card card--bloque">
<h2 lang="gcf">{t(locale, mode.cle)}</h2> <h2 lang="gcf">{t(locale, mode.cle)}</h2>
<p>{t(locale, mode.desc)}</p> <p>{t(locale, mode.desc)}</p>
{#if bloque} <p class="tag">Bientôt</p>
<p class="tag tag--danger">{t(locale, 'jeu.indisponible.titre')}</p>
<p class="muted petit">{t(locale, 'jeu.indisponible.texte')}</p>
<p>
<a href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
</p>
{:else}
<p class="tag">Bientôt</p>
{/if}
</div> </div>
{/if} {/if}
</li> </li>
@@ -61,6 +58,8 @@
<p class="etat muted"> <p class="etat muted">
{jouables} espèce{jouables > 1 ? 's' : ''} sur {FRUITS.length} dispose dune photographie validée. {jouables} espèce{jouables > 1 ? 's' : ''} sur {FRUITS.length} dispose dune photographie validée.
</p> </p>
<p><a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a></p>
</div> </div>
<style> <style>
@@ -80,13 +79,7 @@
opacity: 0.85; opacity: 0.85;
} }
.card--sur {
border-left-color: var(--rouge-oki);
}
.petit {
font-size: 0.85rem;
}
.etat { .etat {
font-size: 0.85rem; font-size: 0.85rem;
@@ -0,0 +1,291 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { FRUITS } from '$lib/data';
import { propositions } from '$lib/jeu/tirage';
import { ordreRevision, repondre } from '$lib/jeu/progression.svelte';
import { compte } from '$lib/motion/countup';
import type { Entree } from '$lib/data/types';
const { data } = $props();
const locale = $derived(data.locale);
/** Ordre pédagogique : du plus discret au plus évident. Le fruit vient en dernier. */
const ORDRE = ['feuille', 'ecorce', 'port', 'coupe', 'fruit'] as const;
const QUESTIONS_PAR_PARTIE = 5;
const POINTS_DEPART = 100;
const MALUS_INDICE = 20;
interface Question {
cible: Entree;
choix: Entree[];
indices: { cle: (typeof ORDRE)[number]; texte: string }[];
}
function fabriquer(): Question[] {
const bassin = FRUITS.filter((e) => ORDRE.some((k) => e.indices[k]));
return ordreRevision(bassin.map((e) => e.id))
.slice(0, QUESTIONS_PAR_PARTIE)
.map((id) => {
const cible = bassin.find((e) => e.id === id) as Entree;
return {
cible,
choix: propositions(cible, bassin),
indices: ORDRE.filter((k) => cible.indices[k]).map((k) => ({
cle: k,
texte: cible.indices[k] as string
}))
};
});
}
let questions = $state<Question[]>(fabriquer());
let index = $state(0);
let devoiles = $state(1);
let reponse = $state<Entree | null>(null);
let scoreTotal = $state(0);
let bonnes = $state(0);
const q = $derived(questions[index]);
const fini = $derived(index >= questions.length);
const pointsPossibles = $derived(Math.max(20, POINTS_DEPART - (devoiles - 1) * MALUS_INDICE));
const correct = $derived(reponse?.id === q?.cible.id);
function devoilerPlus() {
if (q && devoiles < q.indices.length) devoiles += 1;
}
function choisir(e: Entree) {
if (reponse || !q) return;
reponse = e;
const juste = e.id === q.cible.id;
const gagnes = juste ? pointsPossibles : 0;
if (juste) bonnes += 1;
scoreTotal += gagnes;
repondre(q.cible.id, juste, gagnes);
}
function suivant() {
reponse = null;
devoiles = 1;
index += 1;
}
function rejouer() {
questions = fabriquer();
index = 0;
devoiles = 1;
reponse = null;
scoreTotal = 0;
bonnes = 0;
}
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.kache')} />
<div class="container section jeu">
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
{#if fini}
<section class="bilan" aria-labelledby="bilan-titre">
<h1 id="bilan-titre">{t(locale, 'jeu.termine')}</h1>
<p class="gros">
<span use:compte={scoreTotal} aria-hidden="true">0</span>
<span class="sr-only">{scoreTotal}</span>
</p>
<p>{t(locale, 'jeu.resultat', { bons: bonnes, total: questions.length })}</p>
<p class="actions">
<button class="btn" onclick={rejouer}>{t(locale, 'jeu.rejouer')}</button>
<a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a>
</p>
</section>
{:else if q}
<header class="entete">
<h1 lang="gcf">{t(locale, 'jeu.kache')}</h1>
<p class="muted">
{index + 1} / {questions.length}{t(locale, 'jeu.score')} : {scoreTotal}
</p>
</header>
<section class="indices" aria-labelledby="indices-titre">
<h2 id="indices-titre" class="sr-only">{t(locale, 'fiche.indices')}</h2>
<ol>
{#each q.indices.slice(0, devoiles) as indice (indice.cle)}
<li>
<span class="etiquette">{t(locale, `fiche.indice.${indice.cle}`)}</span>
<span>{indice.texte}</span>
</li>
{/each}
</ol>
{#if !reponse && devoiles < q.indices.length}
<p>
<button class="btn btn--sobre" onclick={devoilerPlus}>
{t(locale, 'jeu.indice-suivant')}
</button>
<span class="muted petit">
{t(locale, 'jeu.indices-restants', { n: q.indices.length - devoiles })}{pointsPossibles} pts
</span>
</p>
{/if}
</section>
<section aria-labelledby="question-titre">
<h2 id="question-titre">{t(locale, 'jeu.question')}</h2>
<ul class="choix">
{#each q.choix as choix (choix.id)}
<li>
<button
class="btn reponse"
class:juste={reponse && choix.id === q.cible.id}
class:faux={reponse === choix && choix.id !== q.cible.id}
disabled={!!reponse}
onclick={() => choisir(choix)}
>
<span lang="gcf">{choix.noms.gcf}</span>
</button>
</li>
{/each}
</ul>
</section>
<!-- Le résultat est annoncé une fois, pas frame par frame. -->
<div class="revelation" aria-live="polite">
{#if reponse}
<p class="verdict" class:ok={correct}>
{correct ? t(locale, 'jeu.bonne-reponse') : t(locale, 'jeu.mauvaise-reponse')}
</p>
<p>
{t(locale, 'jeu.reponse-etait', { nom: q.cible.noms.gcf })}
{#if q.cible.noms.fr}<span class="muted">{q.cible.noms.fr}</span>{/if}
</p>
<p class="muted petit">{t(locale, 'fiche.audio-manquant')}</p>
<p class="actions">
<button class="btn" onclick={suivant}>{t(locale, 'jeu.suivant')}</button>
<a href={chemin(locale, 'zerbaj', q.cible.id)}>{t(locale, 'zerbaj.voir')}</a>
</p>
{/if}
</div>
{/if}
</div>
<style>
.jeu {
max-width: 44rem;
}
.fil {
font-size: 0.85rem;
}
.entete h1 {
margin-bottom: 0.25rem;
}
.indices ol {
margin: 0 0 var(--space-2);
padding: 0;
list-style: none;
display: grid;
gap: var(--space-1);
}
.indices li {
padding: var(--space-2);
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-sm);
}
.etiquette {
display: block;
font-family: var(--font-display);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--or-oki);
margin-bottom: 0.2rem;
}
.choix {
display: grid;
gap: var(--space-1);
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.reponse {
width: 100%;
font-size: 1.05rem;
}
.reponse.juste {
border-color: var(--vert-oki);
color: var(--vert-oki);
}
.reponse.juste::after {
background: var(--vert-oki);
}
.reponse.faux {
border-color: var(--rouge-oki);
color: var(--rouge-oki);
}
.reponse.faux::after {
background: var(--rouge-oki);
}
.reponse[disabled] {
opacity: 1;
}
.revelation {
margin-top: var(--space-3);
min-height: 6rem;
}
.verdict {
font-family: var(--font-display);
font-size: 1.2rem;
text-transform: uppercase;
color: var(--rouge-oki);
margin-bottom: 0.25rem;
}
.verdict.ok {
color: var(--vert-oki);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.btn--sobre {
color: var(--blanc-creme);
border-color: var(--line);
}
.bilan {
text-align: center;
}
.gros {
font-family: var(--font-display);
font-size: clamp(3rem, 12vw, 6rem);
color: var(--or-oki);
margin: var(--space-2) 0;
line-height: 1;
}
.petit {
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,202 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { ENTREES } from '$lib/data';
import { NB_BOITES, etatDe, exporter, importer, progression, reinitialiser } from '$lib/jeu/progression.svelte';
const { data } = $props();
const locale = $derived(data.locale);
const debloquees = $derived(ENTREES.filter((e) => etatDe(e.id).debloque));
const restantes = $derived(ENTREES.filter((e) => !etatDe(e.id).debloque));
let messageImport = $state('');
/** Export local : un fichier que le joueur garde. Rien n'est envoyé nulle part. */
function telecharger() {
const blob = new Blob([exporter()], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'ki-fwi-progression.json';
a.click();
URL.revokeObjectURL(url);
}
async function choisirFichier(evenement: Event) {
const input = evenement.currentTarget as HTMLInputElement;
const fichier = input.files?.[0];
if (!fichier) return;
const ok = importer(await fichier.text());
messageImport = ok ? 'Progression importée.' : 'Ce fichier nest pas une progression valide.';
input.value = '';
}
function effacer() {
if (confirm('Effacer toute la progression ? Cette action est définitive.')) {
reinitialiser();
messageImport = 'Progression effacée.';
}
}
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.album')} />
<div class="container section">
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
<h1 lang="gcf">{t(locale, 'jeu.album')}</h1>
<p class="muted">{t(locale, 'jeu.album.desc')}</p>
<p class="total">
{debloquees.length} / {ENTREES.length}{progression.pointsTotal} points
</p>
{#if debloquees.length === 0}
<p>{t(locale, 'jeu.album.vide')}</p>
{:else}
<ul class="grid liste">
{#each debloquees as e (e.id)}
{@const etat = etatDe(e.id)}
<li>
<a class="card" href={chemin(locale, 'zerbaj', e.id)}>
<h2 lang="gcf">{e.noms.gcf}</h2>
<p class="muted latin">{e.taxon.scientifique}</p>
<p class="boites" aria-label={`Boîte ${etat.boite} sur ${NB_BOITES}`}>
{#each Array(NB_BOITES) as _, i (i)}
<span class="boite" class:pleine={i < etat.boite} aria-hidden="true"></span>
{/each}
</p>
<p class="muted petit">{etat.reussites} / {etat.vues}</p>
</a>
</li>
{/each}
</ul>
{/if}
{#if restantes.length}
<h2>Encore à découvrir ({restantes.length})</h2>
<ul class="voile">
{#each restantes as e (e.id)}
<li aria-label="Fiche non débloquée">···</li>
{/each}
</ul>
{/if}
<section class="donnees" aria-labelledby="donnees-titre">
<h2 id="donnees-titre">Ma progression</h2>
<p class="muted petit">
Elle reste sur cet appareil. Aucun compte, aucun envoi : le fichier exporté est le seul
moyen de la déplacer.
</p>
<p class="actions">
<button class="btn" onclick={telecharger}>{t(locale, 'jeu.progression.export')}</button>
<label class="btn">
{t(locale, 'jeu.progression.import')}
<input type="file" accept="application/json" onchange={choisirFichier} />
</label>
<button class="btn btn--danger" onclick={effacer}>{t(locale, 'jeu.progression.effacer')}</button>
</p>
<p aria-live="polite" class="muted petit">{messageImport}</p>
</section>
</div>
<style>
.fil {
font-size: 0.85rem;
}
.total {
font-family: var(--font-display);
font-size: 1.2rem;
color: var(--or-oki);
}
.liste {
list-style: none;
margin: var(--space-3) 0;
padding: 0;
}
.liste h2 {
font-size: 1.2rem;
margin-bottom: 0.25rem;
}
.latin {
font-style: italic;
font-size: 0.85rem;
margin-bottom: 0.4rem;
}
.boites {
display: flex;
gap: 3px;
margin: 0 0 0.35rem;
}
.boite {
width: 22px;
height: 6px;
background: var(--line);
border-radius: 1px;
}
.boite.pleine {
background: var(--or-oki);
}
.voile {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
list-style: none;
margin: 0 0 var(--space-4);
padding: 0;
}
.voile li {
min-width: 3.5rem;
padding: 0.8rem 0.4rem;
text-align: center;
color: var(--muted);
background: var(--card-bg);
border: 1px dashed var(--line);
border-radius: var(--radius-sm);
}
.donnees {
margin-top: var(--space-4);
padding-top: var(--space-3);
border-top: 1px solid var(--line);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.btn--danger {
color: var(--rouge-oki);
border-color: var(--rouge-oki);
}
.btn--danger::after {
background: var(--rouge-oki);
}
label.btn {
position: relative;
overflow: hidden;
}
label.btn input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.petit {
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,276 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { FRUITS, entreesJouables, photosValidees } from '$lib/data';
import { melanger, propositions } from '$lib/jeu/tirage';
import { ordreRevision, repondre } from '$lib/jeu/progression.svelte';
import { compte } from '$lib/motion/countup';
import type { Entree, Media } from '$lib/data/types';
const { data } = $props();
const locale = $derived(data.locale);
const QUESTIONS = 8;
const POINTS = 25;
interface Question {
cible: Entree;
photo: Media;
choix: Entree[];
}
/**
* Le bassin ne contient que les espèces disposant d'une photographie de filière A,
* validée par un humain nommé et portant l'usage « épreuve ». C'est la même règle
* que `tools/data/validate.mjs`, qui fait échouer le build si elle est violée.
*
* Aujourd'hui ce bassin est vide, et c'est le comportement correct : personne n'a
* encore validé de photo. Le mode ne triche pas avec une image générée.
*/
const jouables = entreesJouables();
function fabriquer(): Question[] {
return ordreRevision(jouables.map((e) => e.id))
.slice(0, QUESTIONS)
.map((id) => {
const cible = jouables.find((e) => e.id === id) as Entree;
const photos = photosValidees(cible).filter((m) => m.usages.includes('epreuve'));
return {
cible,
photo: melanger(photos)[0],
choix: propositions(cible, jouables)
};
});
}
let questions = $state<Question[]>(jouables.length >= 4 ? fabriquer() : []);
let index = $state(0);
let reponse = $state<Entree | null>(null);
let score = $state(0);
let bonnes = $state(0);
const q = $derived(questions[index]);
const fini = $derived(questions.length > 0 && index >= questions.length);
const correct = $derived(reponse?.id === q?.cible.id);
function choisir(e: Entree) {
if (reponse || !q) return;
reponse = e;
const juste = e.id === q.cible.id;
if (juste) {
bonnes += 1;
score += POINTS;
}
repondre(q.cible.id, juste, juste ? POINTS : 0);
}
function suivant() {
reponse = null;
index += 1;
}
function rejouer() {
questions = fabriquer();
index = 0;
reponse = null;
score = 0;
bonnes = 0;
}
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.rekonet')} />
<div class="container section jeu">
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
{#if questions.length === 0}
<!-- État designé, pas page d'erreur : le manque est une information (ADR 0006). -->
<section class="bloque" aria-labelledby="bloque-titre">
<span class="flag-chip" aria-hidden="true"></span>
<h1 id="bloque-titre">{t(locale, 'jeu.indisponible.titre')}</h1>
<p>{t(locale, 'jeu.indisponible.texte')}</p>
<p class="chiffre">
<strong>{jouables.length}</strong> espèce{jouables.length > 1 ? 's' : ''} sur {FRUITS.length}
dispose dune photographie validée. Il en faut au moins quatre pour poser une question
honnête.
</p>
<p class="muted">
Ce mode fonctionne uniquement sur des photographies réelles, validées par un humain. Il ne
se rabattra jamais sur une image générée : ce serait apprendre à reconnaître ce quun
modèle invente.
</p>
<p class="actions">
<a class="btn" href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
<a class="btn btn--sobre" href={chemin(locale, 'je', 'kache')}>{t(locale, 'jeu.kache')}</a>
</p>
</section>
{:else if fini}
<section class="bilan" aria-labelledby="bilan-titre">
<h1 id="bilan-titre">{t(locale, 'jeu.termine')}</h1>
<p class="gros">
<span use:compte={score} aria-hidden="true">0</span>
<span class="sr-only">{score}</span>
</p>
<p>{t(locale, 'jeu.resultat', { bons: bonnes, total: questions.length })}</p>
<p class="actions">
<button class="btn" onclick={rejouer}>{t(locale, 'jeu.rejouer')}</button>
<a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a>
</p>
</section>
{:else if q}
<header class="entete">
<h1 lang="gcf">{t(locale, 'jeu.rekonet')}</h1>
<p class="muted">{index + 1} / {questions.length}{t(locale, 'jeu.score')} : {score}</p>
</header>
<figure class="photo">
<img src={q.photo.fichier_local ?? ''} alt={t(locale, 'jeu.question')} />
<figcaption class="muted petit">
{q.photo.auteur}{q.photo.licence}
</figcaption>
</figure>
<section aria-labelledby="question-titre">
<h2 id="question-titre">{t(locale, 'jeu.question')}</h2>
<ul class="choix">
{#each q.choix as choix (choix.id)}
<li>
<button
class="btn reponse"
class:juste={reponse && choix.id === q.cible.id}
class:faux={reponse === choix && choix.id !== q.cible.id}
disabled={!!reponse}
onclick={() => choisir(choix)}
>
<span lang="gcf">{choix.noms.gcf}</span>
</button>
</li>
{/each}
</ul>
</section>
<div class="revelation" aria-live="polite">
{#if reponse}
<p class="verdict" class:ok={correct}>
{correct ? t(locale, 'jeu.bonne-reponse') : t(locale, 'jeu.mauvaise-reponse')}
</p>
<p>{t(locale, 'jeu.reponse-etait', { nom: q.cible.noms.gcf })}</p>
<p class="actions">
<button class="btn" onclick={suivant}>{t(locale, 'jeu.suivant')}</button>
<a href={chemin(locale, 'zerbaj', q.cible.id)}>{t(locale, 'zerbaj.voir')}</a>
</p>
{/if}
</div>
{/if}
</div>
<style>
.jeu {
max-width: 44rem;
}
.fil {
font-size: 0.85rem;
}
.bloque {
padding: var(--space-3);
background: var(--card-bg);
border: var(--border-card);
border-left: 4px solid var(--or-oki);
border-radius: var(--radius-sm);
}
.chiffre strong {
font-family: var(--font-display);
font-size: 1.3rem;
color: var(--or-oki);
}
.photo {
margin: 0 0 var(--space-3);
}
.photo img {
width: 100%;
border-radius: var(--radius-sm);
border: var(--border-card);
}
.choix {
display: grid;
gap: var(--space-1);
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.reponse {
width: 100%;
font-size: 1.05rem;
}
.reponse.juste {
border-color: var(--vert-oki);
color: var(--vert-oki);
}
.reponse.juste::after {
background: var(--vert-oki);
}
.reponse.faux {
border-color: var(--rouge-oki);
color: var(--rouge-oki);
}
.reponse.faux::after {
background: var(--rouge-oki);
}
.reponse[disabled] {
opacity: 1;
}
.revelation {
margin-top: var(--space-3);
min-height: 6rem;
}
.verdict {
font-family: var(--font-display);
font-size: 1.2rem;
text-transform: uppercase;
color: var(--rouge-oki);
margin-bottom: 0.25rem;
}
.verdict.ok {
color: var(--vert-oki);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.btn--sobre {
color: var(--blanc-creme);
border-color: var(--line);
}
.bilan {
text-align: center;
}
.gros {
font-family: var(--font-display);
font-size: clamp(3rem, 12vw, 6rem);
color: var(--or-oki);
margin: var(--space-2) 0;
line-height: 1;
}
.petit {
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,284 @@
<script lang="ts">
import { page } from '$app/state';
import Seo from '$lib/components/Seo.svelte';
import { chemin, t } from '$lib/i18n';
import { FRUITS, MOIS } from '$lib/data';
import { melanger } from '$lib/jeu/tirage';
import { repondre } from '$lib/jeu/progression.svelte';
import { compte } from '$lib/motion/countup';
import type { Entree } from '$lib/data/types';
const { data } = $props();
const locale = $derived(data.locale);
const QUESTIONS = 6;
const POINTS = 20;
interface Question {
mois: number;
cible: Entree;
choix: Entree[];
}
const avecSaison = FRUITS.filter((e) => (e.saison_mois?.length ?? 0) > 0);
/**
* On tire un mois, puis un fruit qu'on y trouve, et trois fruits qu'on n'y trouve pas.
* Une question n'est posée que si les quatre existent : sinon elle enseignerait
* une opposition qui n'en est pas une.
*/
function fabriquer(): Question[] {
const sorties: Question[] = [];
for (const mois of melanger([...Array(12).keys()].map((i) => i + 1))) {
const dedans = melanger(avecSaison.filter((e) => e.saison_mois?.includes(mois)));
const dehors = melanger(avecSaison.filter((e) => !e.saison_mois?.includes(mois)));
if (!dedans.length || dehors.length < 3) continue;
sorties.push({ mois, cible: dedans[0], choix: melanger([dedans[0], ...dehors.slice(0, 3)]) });
if (sorties.length === QUESTIONS) break;
}
return sorties;
}
let questions = $state<Question[]>(fabriquer());
let index = $state(0);
let reponse = $state<Entree | null>(null);
let score = $state(0);
let bonnes = $state(0);
const q = $derived(questions[index]);
const fini = $derived(index >= questions.length);
const correct = $derived(reponse?.id === q?.cible.id);
function choisir(e: Entree) {
if (reponse || !q) return;
reponse = e;
const juste = e.id === q.cible.id;
if (juste) {
bonnes += 1;
score += POINTS;
}
repondre(q.cible.id, juste, juste ? POINTS : 0);
}
function suivant() {
reponse = null;
index += 1;
}
function rejouer() {
questions = fabriquer();
index = 0;
reponse = null;
score = 0;
bonnes = 0;
}
</script>
<Seo {locale} pathname={page.url.pathname} titre={t(locale, 'jeu.sezon')} />
<div class="container section jeu">
<p class="fil"><a href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a></p>
<!--
La saisonnalité du jeu de données n'est vérifiée contre aucune source et n'est pas
ventilée par île (docs/a-arbitrer.md §3). Le dire ici est la condition pour proposer
le mode : enseigner une saison fausse est pire que ne pas l'enseigner.
-->
<p class="avertissement">
{t(locale, 'fiche.saison-non-sourcee')}
<a href={chemin(locale, 'kontribiye')}>{t(locale, 'manques.aider')}</a>
</p>
{#if !questions.length}
<p>{t(locale, 'jeu.indisponible.texte')}</p>
{:else if fini}
<section class="bilan" aria-labelledby="bilan-titre">
<h1 id="bilan-titre">{t(locale, 'jeu.termine')}</h1>
<p class="gros">
<span use:compte={score} aria-hidden="true">0</span>
<span class="sr-only">{score}</span>
</p>
<p>{t(locale, 'jeu.resultat', { bons: bonnes, total: questions.length })}</p>
<p class="actions">
<button class="btn" onclick={rejouer}>{t(locale, 'jeu.rejouer')}</button>
<a class="btn" href={chemin(locale, 'je')}>{t(locale, 'jeu.quitter')}</a>
</p>
</section>
{:else if q}
<header class="entete">
<h1 lang="gcf">{t(locale, 'jeu.sezon')}</h1>
<p class="muted">{index + 1} / {questions.length}{t(locale, 'jeu.score')} : {score}</p>
</header>
<section aria-labelledby="mois-titre">
<h2 id="mois-titre" class="question">
Quel fruit trouve-t-on en <strong>{MOIS[q.mois - 1]}</strong> ?
</h2>
<ol class="calendrier" aria-hidden="true">
{#each MOIS as nom, i (nom)}
<li class:actif={i + 1 === q.mois}>{nom.slice(0, 3)}</li>
{/each}
</ol>
<ul class="choix">
{#each q.choix as choix (choix.id)}
<li>
<button
class="btn reponse"
class:juste={reponse && choix.id === q.cible.id}
class:faux={reponse === choix && choix.id !== q.cible.id}
disabled={!!reponse}
onclick={() => choisir(choix)}
>
<span lang="gcf">{choix.noms.gcf}</span>
</button>
</li>
{/each}
</ul>
</section>
<div class="revelation" aria-live="polite">
{#if reponse}
<p class="verdict" class:ok={correct}>
{correct ? t(locale, 'jeu.bonne-reponse') : t(locale, 'jeu.mauvaise-reponse')}
</p>
<p>{t(locale, 'jeu.reponse-etait', { nom: q.cible.noms.gcf })}</p>
<p class="muted petit">
{q.cible.noms.gcf} : {q.cible.saison_mois?.map((m) => MOIS[m - 1]).join(', ')}
</p>
<p class="actions">
<button class="btn" onclick={suivant}>{t(locale, 'jeu.suivant')}</button>
<a href={chemin(locale, 'zerbaj', q.cible.id)}>{t(locale, 'zerbaj.voir')}</a>
</p>
{/if}
</div>
{/if}
</div>
<style>
.jeu {
max-width: 44rem;
}
.fil {
font-size: 0.85rem;
}
.avertissement {
padding: var(--space-1) var(--space-2);
font-size: 0.85rem;
background: var(--card-bg);
border-left: 4px solid var(--or-oki);
color: var(--muted);
}
.question {
font-family: var(--font-body);
font-size: 1.2rem;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.question strong {
font-family: var(--font-display);
text-transform: uppercase;
color: var(--or-oki);
}
.calendrier {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
list-style: none;
margin: 0 0 var(--space-3);
padding: 0;
}
.calendrier li {
padding: 0.3rem 0.45rem;
font-size: 0.7rem;
text-transform: uppercase;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--muted);
}
.calendrier li.actif {
background: var(--or-oki);
border-color: var(--or-oki);
color: var(--noir-oki);
font-weight: 700;
}
.choix {
display: grid;
gap: var(--space-1);
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
list-style: none;
margin: 0;
padding: 0;
}
.reponse {
width: 100%;
font-size: 1.05rem;
}
.reponse.juste {
border-color: var(--vert-oki);
color: var(--vert-oki);
}
.reponse.juste::after {
background: var(--vert-oki);
}
.reponse.faux {
border-color: var(--rouge-oki);
color: var(--rouge-oki);
}
.reponse.faux::after {
background: var(--rouge-oki);
}
.reponse[disabled] {
opacity: 1;
}
.revelation {
margin-top: var(--space-3);
min-height: 6rem;
}
.verdict {
font-family: var(--font-display);
font-size: 1.2rem;
text-transform: uppercase;
color: var(--rouge-oki);
margin-bottom: 0.25rem;
}
.verdict.ok {
color: var(--vert-oki);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-2);
}
.bilan {
text-align: center;
}
.gros {
font-family: var(--font-display);
font-size: clamp(3rem, 12vw, 6rem);
color: var(--or-oki);
margin: var(--space-2) 0;
line-height: 1;
}
.petit {
font-size: 0.85rem;
}
</style>