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
+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;
}
/**
* 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 {
color: var(--muted);
}