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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user