Files
ki-fwi/src/routes/[[locale=locale]]/je/rekonet/+page.svelte
T
OKIandClaude Opus 5 dadcbdbcbb 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>
2026-07-27 11:53:22 -04:00

277 lines
7.1 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>