560 lines
13 KiB
Svelte
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>
|