Files
MineGeo/components/Game.tsx

350 lines
11 KiB
TypeScript

"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 { difficulties, type Difficulty, type 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: Difficulty;
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<SessionView | null>(null),
[pin, setPin] = useState<Pin | null>(null),
[expanded, setExpanded] = useState(false),
[loaded, setLoaded] = useState({ panorama: false, map: false }),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[result, setResult] = useState<RoundResult | null>(null),
[remaining, setRemaining] = useState(0);
const clock = useRef<ReturnType<typeof roundClock> | 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<SessionView>("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(),
)
: difficulties[s.difficulty].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<RoundResult>(`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 (
<main className="game-page">
{session && current && (
<Panorama
key={session.round}
urls={current.faces}
zoom={difficulties[session.difficulty].zoom}
onReady={ready}
onError={onError}
/>
)}
<div className="game-header">
<Brand />
{session && (
<ReportContent
key={session.round}
sessionId={id}
round={session.round}
/>
)}
<div className="game-info">
<div>
<small>{t("ROUND")}</small>
<strong>{session ? session.round + 1 : "—"} / 5</strong>
</div>
<div>
<small>{t("TIME LEFT")}</small>
<strong className={remaining <= 10 ? "urgent" : ""}>
{Math.floor(remaining / 60)}:
{String(remaining % 60).padStart(2, "0")}
</strong>
</div>
<div>
<small>{t("POINTS")}</small>
<strong>{formatNumber(total)}</strong>
</div>
</div>
</div>
{session && (
<div className={"game-map " + (expanded ? "expanded" : "")}>
<button
className="mobile-map-toggle"
aria-expanded={expanded}
aria-controls="guess-map-panel"
onClick={() => setExpanded(true)}
>
<MapIcon size={20} aria-hidden="true" />
<span>{pin ? t("Edit your guess") : t("Open guess map")}</span>
{pin && (
<small>
X {Math.round(pin.x)} · Z {Math.round(pin.z)}
</small>
)}
</button>
<div className="game-map-content" id="guess-map-panel">
<div className="map-topline">
<span>{t("↑ N · PLACE YOUR GUESS")}</span>
<button
aria-label={expanded ? t("Collapse map") : t("Expand map")}
onClick={() => setExpanded((v) => !v)}
>
{expanded ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
</div>
<GuessMap
key={"map-" + session.round}
bounds={session.bounds}
image={session.map}
pin={result ? result.guess : pin}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
onError={onError}
disabled={!!result || busy}
/>
<div className="map-actions">
<small>
{pin
? `X ${Math.round(pin.x)} · Z ${Math.round(pin.z)}`
: t("Click the map to drop a pin")}
</small>
<button
className="button primary full"
disabled={!pin || busy || !!result || !current?.deadline}
onClick={() => guess()}
>
<MapPin size={15} />
{busy ? t("Checking your guess…") : t("Submit guess")}
</button>
</div>
</div>
</div>
)}
<div className="look-help">
<span>
<Move size={12} /> {t("DRAG TO LOOK AROUND")}
</span>
<span>
<MousePointer2 size={12} />{" "}
{session?.difficulty === "hard" ? t("NO ZOOM") : t("SCROLL TO ZOOM")}
</span>
</div>
{(!session || !loaded.panorama || !loaded.map) && !error && (
<div className="game-loading">
<div className="loading-cube" />
<h2>{t("Preparing your expedition.")}</h2>
<p>{t("Loading panorama and surface map…")}</p>
</div>
)}
{error && (
<div className="round-result" role="alert">
<h2>{t("Connection interrupted.")}</h2>
<p>{t(error)}</p>
<button
className="button primary"
onClick={() => window.location.reload()}
>
{t("Reconnect")}
</button>
</div>
)}
{result && !error && (
<div className="round-result">
<span className="eyebrow">
{t("ROUND")} {(session?.round || 0) + 1} {t("COMPLETE")}
</span>
<h2>
{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.")}
</h2>
<div className="score">
{formatNumber(result.score)}
<small> / 5,000</small>
</div>
<p>
{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)),
})}
</p>
<button className="button primary full" onClick={next}>
{session?.round === 4
? t("See expedition results")
: t("Next round")}{" "}
→
</button>
</div>
)}
</main>
);
}