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,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 d’une 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 qu’un
|
||||
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>
|
||||
Reference in New Issue
Block a user