277 lines
7.1 KiB
Svelte
277 lines
7.1 KiB
Svelte
<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>
|