"use client"; import { useI18n } from "@/components/I18nProvider"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Maximize2, Minimize2, MousePointer2, Move, MapPin, Map as MapIcon, } from "lucide-react"; import { Brand } from "./Brand"; import { Panorama } from "./Panorama"; import { GuessMap, type Pin } from "./GuessMap"; import { api } from "./StartGame"; import type { DifficultyPreset, Bounds } from "@/shared/protocol"; import { roundClock } from "@/shared/round-clock"; import { ReportContent } from "./ReportContent"; export type RoundResult = { score: number; distance: number | null; timedOut: boolean; actual: Pin; guess: Pin | null; }; export type SessionView = { id: string; name: string; difficulty: string; difficultySettings: DifficultyPreset; round: number; bounds: Bounds; map: string; serverNow: number; rounds: { number: number; faces: string[]; deadline: string | null; result: RoundResult | null; }[]; }; export function Game({ id }: { id: string }) { const { t, formatNumber } = useI18n(); const router = useRouter(), [session, setSession] = useState(null), [pin, setPin] = useState(null), [expanded, setExpanded] = useState(false), [loaded, setLoaded] = useState({ panorama: false, map: false }), [error, setError] = useState(""), [busy, setBusy] = useState(false), [result, setResult] = useState(null), [remaining, setRemaining] = useState(0); const clock = useRef | null>(null), loadVersion = useRef(0), guessing = useRef(false), activating = useRef(false), timingOut = useRef(false); const load = useCallback(async () => { const version = ++loadVersion.current; const sentAt = performance.now(); try { const s = await api("sessions/" + id); if (version !== loadVersion.current) return; clock.current = roundClock(s.serverNow, sentAt, performance.now()); if (s.round >= 5) { router.replace("/results/" + id); return; } setSession(s); setRemaining( s.rounds[s.round].deadline ? clock.current.remaining( s.rounds[s.round].deadline!, performance.now(), ) : s.difficultySettings.seconds, ); } catch (e) { if (version === loadVersion.current) setError((e as Error).message); } }, [id, router]); useEffect(() => { load(); return () => { loadVersion.current++; }; }, [load]); useEffect(() => { if ( !session || !loaded.panorama || !loaded.map || error || result || activating.current ) return; const r = session.rounds[session.round]; if (r.deadline) return; activating.current = true; const round = session.round; const sentAt = performance.now(); api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, { round: session.round, }) .then((d) => { clock.current = roundClock(d.serverNow, sentAt, performance.now()); setSession((s) => s && s.round === round ? { ...s, rounds: s.rounds.map((r) => r.number === s.round ? { ...r, deadline: d.deadline } : r, ), } : s, ); }) .catch((e) => setError(e.message)) .finally(() => (activating.current = false)); }, [loaded, session, id, result, error]); const guess = useCallback( async (timeout = false) => { if (!session || guessing.current || result || (!pin && !timeout)) return; guessing.current = true; setBusy(true); setError(""); try { const r = await api(`sessions/${id}/guess`, { round: session.round, ...(!timeout && pin ? pin : {}), }); setResult(r); setSession((s) => s ? { ...s, rounds: s.rounds.map((round) => round.number === session.round ? { ...round, result: r } : round, ), } : s, ); } catch (e) { setError((e as Error).message); timingOut.current = false; } finally { guessing.current = false; setBusy(false); } }, [session, result, pin, id], ); useEffect(() => { if (!session || result || error) return; const deadline = session.rounds[session.round].deadline; if (!deadline) return; const tick = () => { const left = clock.current?.remaining(deadline, performance.now()) ?? 0; setRemaining(left); if (left === 0 && !timingOut.current && !guessing.current) { timingOut.current = true; guess(true); } }; tick(); const timer = setInterval(tick, 250); return () => clearInterval(timer); }, [session, result, guess, error]); async function next() { if (!session) return; setSession(null); setResult(null); setPin(null); setExpanded(false); setLoaded({ panorama: false, map: false }); timingOut.current = false; setError(""); await load(); } const ready = useCallback( () => setLoaded((s) => ({ ...s, panorama: true })), [], ), mapReady = useCallback(() => setLoaded((s) => ({ ...s, map: true })), []); const onError = useCallback((e: string) => setError(e), []); const current = session?.rounds[session.round]; const total = session?.rounds.reduce((n, r) => n + (r.result?.score || 0), 0) || 0; return (
{session && current && ( )}
{session && ( )}
{t("ROUND")} {session ? session.round + 1 : "—"} / 5
{session?.difficultySettings.timerVisible !== false && (
{t("TIME LEFT")} {Math.floor(remaining / 60)}: {String(remaining % 60).padStart(2, "0")}
)}
{t("POINTS")} {formatNumber(total)}
{session && (
{t( session.difficultySettings.compass === false ? "PLACE YOUR GUESS" : "↑ N · PLACE YOUR GUESS", )}
{pin ? session.difficultySettings.coordinates !== false ? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}` : t("Guess selected") : t("Click the map to drop a pin")}
)}
{" "} {t( session?.difficultySettings.pan === false ? "FIXED VIEW" : "DRAG TO LOOK AROUND", )} {" "} {session && !session.difficultySettings.zoom ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
{(!session || !loaded.panorama || !loaded.map) && !error && (

{t("Preparing your expedition.")}

{t("Loading panorama and surface map…")}

)} {error && (

{t("Connection interrupted.")}

{t(error)}

)} {result && !error && (
{t("ROUND")} {(session?.round || 0) + 1} {t("COMPLETE")}

{result.timedOut ? t("Time slipped away.") : result.score === 5000 ? t("Right on the block!") : result.score > 3000 ? t("You know your way around.") : t("A little off the beaten block.")}

{formatNumber(result.score)} / 5,000

{result.timedOut ? t("The clock ran out. This round scores zero.") : t("{count} blocks from the actual location.", { count: formatNumber(Math.round(result.distance || 0)), })}

)}
); }