Files
JWE/app/src/routes/jeu/[mode]/+page.svelte
T

560 lines
13 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import GameMap from '$lib/components/GameMap.svelte';
import CarteRegions, { type MarqueurCarte } from '$lib/components/CarteRegions.svelte';
import PhotoPanel from '$lib/components/PhotoPanel.svelte';
import ResultPanel from '$lib/components/ResultPanel.svelte';
import TimerRing from '$lib/components/TimerRing.svelte';
import { t } from '$lib/i18n/store.svelte';
import { defi } from '$lib/stores/defi.svelte';
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
let { data } = $props();
const mode = $derived(data.mode);
const regionParam = $derived(page.url.searchParams.get('region'));
const timerActif = $derived(page.url.searchParams.get('timer') === '1');
let place = $state<RoundPlace | null>(null);
let guess = $state<Coordonnees | null>(null);
let result = $state<GuessResult | null>(null);
let indicesMontres = $state<string[]>([]);
let chargement = $state(true);
let erreur = $state(false);
let enCours = $state(false); // requête guess en vol
let timerRef: ReturnType<typeof TimerRing> | undefined = $state();
let joues = $state<string[]>([]); // ids déjà joués (évite les répétitions)
let bilan = $state(false); // écran final du défi
const estDefi = $derived(mode === 'defi');
const mancheCourante = $derived(defi.manches.length + 1);
const defiTermine = $derived(estDefi && defi.termine);
// Marqueurs du récap SVG : position réelle (vert) + guess du joueur (corail),
// numérotés par manche ; une manche passée (guess null) n'a que le point réel.
const marqueursRecap = $derived(
defi.manches.flatMap((m, i): MarqueurCarte[] => {
const reel: MarqueurCarte = {
lat: m.result.coordonnees.lat,
lon: m.result.coordonnees.lon,
type: 'reel',
label: m.result.nom,
paire: i + 1
};
if (!m.guess) return [reel];
return [
reel,
{ lat: m.guess.lat, lon: m.guess.lon, type: 'guess', label: m.result.nom, paire: i + 1 }
];
})
);
async function chargerRound(): Promise<void> {
chargement = true;
erreur = false;
place = null;
guess = null;
result = null;
indicesMontres = [];
try {
const params = new URLSearchParams();
if (regionParam) params.set('region', regionParam);
if (mode === 'monument') params.set('categorie', 'MONUMENT');
if (mode === 'lieu') params.set('categorie', 'LIEU');
if (joues.length > 0) params.set('exclude', joues.join(','));
const res = await fetch(`/api/round?${params}`);
if (!res.ok) throw new Error(String(res.status));
place = (await res.json()) as RoundPlace;
} catch {
erreur = true;
} finally {
chargement = false;
}
}
async function soumettre(skip: boolean): Promise<void> {
if (!place || result || enCours) return;
if (!skip && !guess) return;
enCours = true;
try {
const body: Record<string, unknown> = { id: place.id };
if (skip) {
body.skip = true;
} else {
body.lat = guess!.lat;
body.lon = guess!.lon;
body.hintsUsed = indicesMontres.length;
if (timerActif && timerRef) body.timeMs = timerRef.tempsMs();
}
const res = await fetch('/api/guess', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(String(res.status));
result = (await res.json()) as GuessResult;
joues = [...joues, place.id];
if (estDefi) {
defi.ajouter({ place, guess: skip ? null : guess, result });
}
} catch {
erreur = true;
} finally {
enCours = false;
}
}
function montrerIndice(): void {
if (!place) return;
const prochain = place.indices.find((i) => !indicesMontres.includes(i.texte));
if (prochain) indicesMontres = [...indicesMontres, prochain.texte];
}
function suivant(): void {
void chargerRound();
}
function montrerBilan(): void {
bilan = true;
}
function rejouerDefi(): void {
defi.reinitialiser();
joues = [];
bilan = false;
void chargerRound();
}
/** Génère une image canvas téléchargeable : score total + régions jouées. */
function partager(): void {
const canvas = document.createElement('canvas');
canvas.width = 1080;
canvas.height = 1080;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Fond sable + bandeau vert profond
ctx.fillStyle = '#f5efe2';
ctx.fillRect(0, 0, 1080, 1080);
ctx.fillStyle = '#0b3d2e';
ctx.fillRect(0, 0, 1080, 240);
ctx.fillStyle = '#fffdf8';
ctx.textAlign = 'center';
ctx.font = '800 96px system-ui, sans-serif';
ctx.fillText('JWE', 540, 130);
ctx.font = '600 44px system-ui, sans-serif';
ctx.fillText(t().modes.defi, 540, 200);
// Score total
ctx.fillStyle = '#0b3d2e';
ctx.font = '800 150px system-ui, sans-serif';
ctx.fillText(`${defi.total}`, 540, 420);
ctx.fillStyle = '#5a6b62';
ctx.font = '500 40px system-ui, sans-serif';
ctx.fillText(t().defi.scoreTotal, 540, 480);
// Manches : lieu, région, score
let y = 580;
for (const [i, m] of defi.manches.entries()) {
ctx.fillStyle = i % 2 === 0 ? '#e3efe7' : '#f5efe2';
ctx.fillRect(90, y - 44, 900, 74);
ctx.fillStyle = '#22302a';
ctx.textAlign = 'left';
ctx.font = '600 34px system-ui, sans-serif';
ctx.fillText(`${i + 1}. ${m.result.nom}`, 120, y);
ctx.fillStyle = '#17624a';
ctx.font = '500 30px system-ui, sans-serif';
ctx.fillText(t().regions[m.result.region], 120, y + 34);
ctx.textAlign = 'right';
ctx.fillStyle = '#0b3d2e';
ctx.font = '800 40px system-ui, sans-serif';
ctx.fillText(`${m.result.score}`, 960, y + 8);
y += 92;
}
ctx.fillStyle = '#5a6b62';
ctx.textAlign = 'center';
ctx.font = '500 30px system-ui, sans-serif';
ctx.fillText('labola.o-k-i.net', 540, 1010);
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = 'jwe-defi.png';
a.click();
}
onMount(() => {
// Nouveau défi : on repart de zéro quand on arrive sur la première manche.
if (estDefi && defi.termine) defi.reinitialiser();
void chargerRound();
});
</script>
<svelte:head>
<title>{t().app.titre}{t().modes[mode]}</title>
</svelte:head>
<div class="jeu">
{#if bilan}
<div class="bilan">
<h1>{t().defi.bilan}</h1>
<p class="total">
<span class="valeur">{defi.total}</span>
<span class="unite">{t().defi.scoreTotal}</span>
</p>
<div class="bilan-carte">
<CarteRegions selectionnable={false} marqueurs={marqueursRecap} />
</div>
<ol class="recap">
{#each defi.manches as m, i (m.place.id)}
<li>
<span class="r-nom">{i + 1}. {m.result.nom}</span>
<span class="r-region">{t().regions[m.result.region]}</span>
<span class="r-score">{m.result.score} {t().resultat.points}</span>
</li>
{/each}
</ol>
<div class="bilan-actions">
<button type="button" class="btn btn-primaire" onclick={partager}>
{t().defi.partager}
</button>
<button type="button" class="btn btn-secondaire" onclick={rejouerDefi}>
{t().resultat.rejouer}
</button>
<a href="/" class="btn btn-tertiaire">{t().resultat.retourAccueil}</a>
</div>
<p class="legende">
<span class="puce joueur" aria-hidden="true"></span> {t().defi.taPosition}
<span class="puce reelle" aria-hidden="true"></span> {t().defi.positionReelle}
</p>
</div>
{:else if chargement}
<div class="etat"><p>{t().jeu.charger}</p></div>
{:else if erreur}
<div class="etat">
<p>{t().jeu.erreur}</p>
<button type="button" class="btn btn-secondaire" onclick={() => void chargerRound()}>
{t().jeu.reessayer}
</button>
</div>
{:else if place}
<div class="split" class:resultat={!!result}>
<section class="panneau-photo">
{#if result}
<div class="resultat-scroll">
<ResultPanel
{result}
creditPhoto={place.photo.credit}
labelBouton={estDefi && defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
onsuivant={estDefi && defiTermine ? montrerBilan : suivant}
/>
</div>
{:else}
<PhotoPanel photo={place.photo} />
{/if}
</section>
<section class="panneau-carte">
<GameMap
bind:guess
real={result ? result.coordonnees : null}
interactive={!result}
onvalidate={() => void soumettre(false)}
/>
{#if estDefi && !defiTermine}
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
{/if}
{#if timerActif && !result}
<div class="badge-timer">
<TimerRing bind:this={timerRef} actif={!result} />
</div>
{/if}
</section>
</div>
{#if !result}
<div class="barre-actions">
<div class="actions-secondaires">
<button
type="button"
class="btn btn-tertiaire"
onclick={montrerIndice}
disabled={indicesMontres.length >= place.indices.length}
>
{t().jeu.indice}
<span class="penalite">{t().jeu.indicePenalite}</span>
</button>
<button type="button" class="btn btn-secondaire" onclick={() => void soumettre(true)}>
{t().jeu.passer}
</button>
</div>
<button
type="button"
class="btn btn-primaire valider"
disabled={!guess || enCours}
onclick={() => void soumettre(false)}
>
{t().jeu.valider}
</button>
</div>
{#if indicesMontres.length > 0}
<aside class="indices" aria-live="polite">
{#each indicesMontres as texte, i (texte)}
<p class="indice"><strong>{t().jeu.indice} {i + 1}</strong>{texte}</p>
{/each}
</aside>
{/if}
{#if !guess}
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
{/if}
{/if}
{/if}
</div>
<style>
.jeu {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.etat {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
color: var(--oki-encre-doux);
}
.split {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.6rem;
min-height: 0;
}
/* Mobile : photo 60 % haut, carte 40 % bas */
.panneau-photo {
flex: 0 0 58%;
min-height: 0;
}
.split.resultat .panneau-photo {
flex: 1 1 55%;
overflow-y: auto;
}
.panneau-carte {
position: relative;
flex: 1;
min-height: 220px;
}
.resultat-scroll {
height: 100%;
overflow-y: auto;
background: var(--oki-blanc);
border-radius: var(--oki-radius);
box-shadow: var(--oki-ombre);
padding: 1rem;
}
.badge-manche {
position: absolute;
top: 0.6rem;
right: 0.6rem;
background: rgba(255, 253, 248, 0.94);
color: var(--oki-vert);
border-radius: 999px;
padding: 0.25rem 0.8rem;
font-size: 0.82rem;
font-weight: 700;
box-shadow: var(--oki-ombre);
}
.badge-timer {
position: absolute;
top: 0.6rem;
left: 3.4rem; /* à droite des boutons zoom */
}
/* Barre d'actions : fixe en zone du pouce (tiers inférieur) */
.barre-actions {
position: sticky;
bottom: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.7rem 0.8rem calc(0.7rem + env(safe-area-inset-bottom));
background: var(--oki-blanc);
border-top: 1px solid var(--oki-sable-fonce);
}
.actions-secondaires {
display: flex;
gap: 0.5rem;
}
.actions-secondaires .btn {
padding: 0.7rem 1rem;
font-size: 0.92rem;
}
.penalite {
font-size: 0.75rem;
font-weight: 500;
opacity: 0.8;
}
.valider {
flex: 0 0 auto;
padding: 0.9rem 1.4rem;
}
.indices {
padding: 0 1rem 0.5rem;
}
.indice {
margin: 0.25rem 0;
background: var(--oki-bleu-doux);
border-left: 4px solid var(--oki-bleu);
border-radius: var(--oki-radius-petit);
padding: 0.5rem 0.8rem;
font-size: 0.95rem;
}
.aide-carte {
margin: 0;
padding: 0 1rem 0.6rem;
text-align: center;
font-size: 0.88rem;
color: var(--oki-encre-doux);
}
/* Desktop : 50/50 */
@media (min-width: 768px) {
.split {
flex-direction: row;
}
.panneau-photo,
.split.resultat .panneau-photo {
flex: 1 1 50%;
}
.panneau-carte {
flex: 1 1 50%;
}
.barre-actions {
justify-content: center;
gap: 1.5rem;
}
}
/* — Écran bilan du défi — */
.bilan {
flex: 1;
width: 100%;
max-width: 860px;
margin: 0 auto;
padding: 1.2rem 1rem 2rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.bilan h1 {
margin: 0;
text-align: center;
color: var(--oki-vert);
}
.total {
margin: 0;
text-align: center;
}
.total .valeur {
font-size: 2.8rem;
font-weight: 800;
color: var(--oki-vert);
}
.total .unite {
margin-left: 0.4rem;
color: var(--oki-encre-doux);
}
.bilan-carte {
width: 100%;
max-width: 520px;
margin: 0 auto;
}
.recap {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.recap li {
display: flex;
align-items: baseline;
gap: 0.6rem;
background: var(--oki-blanc);
border-radius: var(--oki-radius-petit);
box-shadow: var(--oki-ombre);
padding: 0.55rem 0.9rem;
}
.r-nom {
font-weight: 700;
color: var(--oki-vert);
}
.r-region {
flex: 1;
color: var(--oki-encre-doux);
font-size: 0.9rem;
}
.r-score {
font-weight: 800;
}
.bilan-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.7rem;
}
.legende {
margin: 0;
text-align: center;
font-size: 0.85rem;
color: var(--oki-encre-doux);
}
.puce.joueur {
color: var(--oki-corail);
}
.puce.reelle {
color: var(--oki-vert);
}
</style>