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:
@@ -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