2026-07-16 23:21:31 -04:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { page } from '$app/state';
|
2026-07-21 16:32:47 -04:00
|
|
|
import '$lib/styles/jeu.css';
|
2026-07-16 23:21:31 -04:00
|
|
|
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';
|
2026-07-21 16:32:47 -04:00
|
|
|
import Seo from '$lib/components/Seo.svelte';
|
2026-07-16 23:21:31 -04:00
|
|
|
import TimerRing from '$lib/components/TimerRing.svelte';
|
|
|
|
|
import { t } from '$lib/i18n/store.svelte';
|
2026-07-21 16:32:47 -04:00
|
|
|
import { compte } from '$lib/motion/countup';
|
|
|
|
|
import { reveal } from '$lib/motion/reveal';
|
|
|
|
|
import { syncopatedDelay } from '$lib/motion/tokens';
|
|
|
|
|
import { fonduMesure, monteeMesure } from '$lib/motion/transitions';
|
|
|
|
|
import { partager } from '$lib/partage';
|
2026-07-16 23:21:31 -04:00
|
|
|
import { defi } from '$lib/stores/defi.svelte';
|
2026-08-16 22:21:29 -04:00
|
|
|
import { ajouterPartie } from '$lib/stores/history.svelte';
|
2026-07-16 23:21:31 -04:00
|
|
|
import type { Coordonnees, GuessResult, RoundPlace } from '$lib/types';
|
|
|
|
|
|
|
|
|
|
let { data } = $props();
|
|
|
|
|
const seed = $derived(data.seed);
|
|
|
|
|
const rounds = $derived(data.rounds);
|
|
|
|
|
|
|
|
|
|
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 erreur = $state(false);
|
|
|
|
|
let enCours = $state(false); // requête guess en vol
|
|
|
|
|
|
|
|
|
|
let timerRef: ReturnType<typeof TimerRing> | undefined = $state();
|
|
|
|
|
let bilan = $state(false); // écran final du défi
|
|
|
|
|
let lienCopie = $state(false); // retour visuel du bouton « Copier le lien »
|
|
|
|
|
|
|
|
|
|
const mancheCourante = $derived(defi.manches.length + 1);
|
|
|
|
|
const defiTermine = $derived(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 }
|
|
|
|
|
];
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Les 5 lieux sont préchargés par le load : la manche courante suit l'état du store.
|
|
|
|
|
function chargerRound(): void {
|
|
|
|
|
guess = null;
|
|
|
|
|
result = null;
|
|
|
|
|
indicesMontres = [];
|
|
|
|
|
place = rounds[defi.manches.length] ?? null;
|
|
|
|
|
erreur = !place;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
defi.ajouter({ place, guess: skip ? null : guess, result });
|
2026-08-16 22:21:29 -04:00
|
|
|
if (defi.termine) {
|
|
|
|
|
ajouterPartie({
|
|
|
|
|
mode: 'defi',
|
|
|
|
|
region: 'TOUTES',
|
|
|
|
|
scoreTotal: defi.total,
|
|
|
|
|
roundsCount: defi.manches.length,
|
|
|
|
|
rounds: defi.manches.map((m) => ({
|
|
|
|
|
nom: m.result.nom,
|
|
|
|
|
commune: m.result.commune,
|
|
|
|
|
region: m.result.region,
|
|
|
|
|
distanceKm: m.result.distanceKm,
|
|
|
|
|
score: m.result.score
|
|
|
|
|
}))
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-07-16 23:21:31 -04:00
|
|
|
} 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 {
|
|
|
|
|
chargerRound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function montrerBilan(): void {
|
|
|
|
|
bilan = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function rejouerDefi(): void {
|
|
|
|
|
defi.reinitialiser();
|
|
|
|
|
bilan = false;
|
|
|
|
|
lienCopie = false;
|
|
|
|
|
chargerRound();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Copie l'URL du défi dans le presse-papiers (repli : champ temporaire + sélection). */
|
|
|
|
|
async function copierLien(): Promise<void> {
|
|
|
|
|
const url = `${window.location.origin}/defi/${seed}`;
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
} catch {
|
|
|
|
|
// Contexte non sécurisé (HTTP) ou navigateur sans Clipboard API.
|
|
|
|
|
const champ = document.createElement('textarea');
|
|
|
|
|
champ.value = url;
|
|
|
|
|
document.body.appendChild(champ);
|
|
|
|
|
champ.select();
|
|
|
|
|
document.execCommand('copy');
|
|
|
|
|
champ.remove();
|
|
|
|
|
}
|
|
|
|
|
lienCopie = true;
|
|
|
|
|
setTimeout(() => (lienCopie = false), 2500);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// (Ré)initialise le défi au montage et à chaque changement de seed :
|
|
|
|
|
// en navigation client d'un /defi/<seed> vers un autre, le composant est conservé.
|
|
|
|
|
let seedJouee = '';
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (seed === seedJouee) return;
|
|
|
|
|
seedJouee = seed;
|
|
|
|
|
defi.reinitialiser();
|
|
|
|
|
bilan = false;
|
|
|
|
|
lienCopie = false;
|
|
|
|
|
chargerRound();
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-07-21 16:32:47 -04:00
|
|
|
<Seo
|
|
|
|
|
title="{t().modes.defi} — seed {seed}"
|
|
|
|
|
description={t().modes.defiDesc}
|
|
|
|
|
path="/defi/{seed}"
|
|
|
|
|
/>
|
2026-07-16 23:21:31 -04:00
|
|
|
|
|
|
|
|
<div class="jeu">
|
|
|
|
|
{#if bilan}
|
2026-07-21 16:32:47 -04:00
|
|
|
<div class="bilan" in:monteeMesure>
|
2026-07-16 23:21:31 -04:00
|
|
|
<h1>{t().defi.bilan}</h1>
|
|
|
|
|
<p class="seed-discret">{t().defi.titreSeed} <strong>{seed}</strong></p>
|
2026-07-21 16:32:47 -04:00
|
|
|
<div class="filet-drapeau" aria-hidden="true">
|
|
|
|
|
<span class="seg seg-vert"></span><span class="seg seg-corail"></span><span class="seg seg-bleu"></span>
|
|
|
|
|
</div>
|
2026-07-16 23:21:31 -04:00
|
|
|
<p class="total">
|
2026-07-21 16:32:47 -04:00
|
|
|
<span class="valeur" aria-hidden="true" use:compte={defi.total}>{defi.total}</span>
|
|
|
|
|
<span class="sr-only">{defi.total}</span>
|
2026-07-16 23:21:31 -04:00
|
|
|
<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)}
|
2026-07-21 16:32:47 -04:00
|
|
|
<li data-reveal style:--d="{syncopatedDelay(i)}ms" use:reveal>
|
2026-07-16 23:21:31 -04:00
|
|
|
<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={() => void copierLien()}>
|
|
|
|
|
{lienCopie ? t().defi.lienCopie : t().defi.copierLien}
|
|
|
|
|
</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 erreur}
|
2026-07-21 16:32:47 -04:00
|
|
|
<div class="etat" in:fonduMesure>
|
2026-07-16 23:21:31 -04:00
|
|
|
<p>{t().jeu.erreur}</p>
|
|
|
|
|
<button type="button" class="btn btn-secondaire" onclick={chargerRound}>
|
|
|
|
|
{t().jeu.reessayer}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
{:else if place}
|
2026-07-21 16:32:47 -04:00
|
|
|
<div class="split" class:resultat={!!result} in:monteeMesure>
|
2026-07-16 23:21:31 -04:00
|
|
|
<section class="panneau-photo">
|
|
|
|
|
{#if result}
|
|
|
|
|
<div class="resultat-scroll">
|
|
|
|
|
<ResultPanel
|
|
|
|
|
{result}
|
2026-07-18 20:15:03 -04:00
|
|
|
creditPhoto={place.photo?.credit}
|
2026-07-16 23:21:31 -04:00
|
|
|
labelBouton={defiTermine ? t().resultat.voirBilan : t().resultat.suivant}
|
|
|
|
|
onsuivant={defiTermine ? montrerBilan : suivant}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-07-18 20:15:03 -04:00
|
|
|
{:else if place.photo}
|
2026-07-16 23:21:31 -04:00
|
|
|
<PhotoPanel photo={place.photo} />
|
|
|
|
|
{/if}
|
2026-07-21 16:32:47 -04:00
|
|
|
{#if !result && 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}
|
2026-07-16 23:21:31 -04:00
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="panneau-carte">
|
|
|
|
|
<GameMap
|
|
|
|
|
bind:guess
|
|
|
|
|
real={result ? result.coordonnees : null}
|
2026-07-16 23:45:17 -04:00
|
|
|
distanceKm={result ? result.distanceKm : null}
|
2026-07-16 23:21:31 -04:00
|
|
|
interactive={!result}
|
|
|
|
|
onvalidate={() => void soumettre(false)}
|
|
|
|
|
/>
|
2026-07-21 16:32:47 -04:00
|
|
|
<div class="statut">
|
|
|
|
|
{#if !defiTermine}
|
|
|
|
|
<span class="badge-manche">{t().jeu.round} {Math.min(mancheCourante, 5)} {t().jeu.sur} 5</span>
|
|
|
|
|
{/if}
|
|
|
|
|
<span class="badge-seed">{t().defi.titreSeed} {seed}</span>
|
|
|
|
|
{#if timerActif && !result}
|
|
|
|
|
<span class="badge-timer">
|
|
|
|
|
<TimerRing bind:this={timerRef} actif={!result} />
|
|
|
|
|
</span>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{#if !result && !guess}
|
|
|
|
|
<p class="aide-carte">{t().jeu.poserMarqueur}</p>
|
2026-07-16 23:21:31 -04:00
|
|
|
{/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}
|
|
|
|
|
{:else}
|
2026-07-21 16:32:47 -04:00
|
|
|
<div class="etat" in:fonduMesure><p>{t().jeu.charger}</p></div>
|
2026-07-16 23:21:31 -04:00
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
|