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);
|
||||
}
|
||||
|
||||
@@ -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 d’une 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 n’est 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 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user