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
+14 -21
View File
@@ -16,9 +16,9 @@
* un lien mort — et le prerender strict fait échouer le build, ce qui est voulu.
*/
const modes = $derived([
{ id: 'rekonet', cle: 'jeu.rekonet', desc: 'jeu.rekonet.desc', route: 'rekonet', besoinPhotos: true, livre: false },
{ id: 'kache', cle: 'jeu.kache', desc: 'jeu.kache.desc', route: 'kache', besoinPhotos: false, livre: false },
{ id: 'sezon', cle: 'jeu.sezon', desc: 'jeu.sezon.desc', route: 'sezon', besoinPhotos: false, 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: true },
{ 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: 'patouche', cle: 'jeu.patouche', desc: 'jeu.patouche.desc', route: 'patouche', besoinPhotos: false, livre: false }
] as const);
@@ -32,26 +32,23 @@
<ul class="grid modes">
{#each modes as mode (mode.id)}
{@const bloque = mode.besoinPhotos && jouables < 4}
{@const enAttente = mode.besoinPhotos && jouables < 4}
<li>
{#if mode.livre && !bloque}
<a class="card" class:card--sur={mode.id === 'patouche'} href={chemin(locale, 'je', mode.route)}>
{#if mode.livre}
<a class="card" href={chemin(locale, 'je', mode.route)}>
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
<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>
{:else}
<div class="card card--bloque">
<h2 lang="gcf">{t(locale, mode.cle)}</h2>
<p>{t(locale, mode.desc)}</p>
{#if bloque}
<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}
<p class="tag">Bientôt</p>
</div>
{/if}
</li>
@@ -61,6 +58,8 @@
<p class="etat muted">
{jouables} espèce{jouables > 1 ? 's' : ''} sur {FRUITS.length} dispose dune photographie validée.
</p>
<p><a class="btn" href={chemin(locale, 'je', 'lakou')}>{t(locale, 'jeu.album')}</a></p>
</div>
<style>
@@ -80,13 +79,7 @@
opacity: 0.85;
}
.card--sur {
border-left-color: var(--rouge-oki);
}
.petit {
font-size: 0.85rem;
}
.etat {
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>