Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
369 lines
11 KiB
TypeScript
369 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 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<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(),
|
|
)
|
|
: 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<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={session.difficultySettings.zoom}
|
|
pan={session.difficultySettings.pan}
|
|
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 && session.difficultySettings.coordinates !== false && (
|
|
<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(
|
|
session.difficultySettings.compass === false
|
|
? "PLACE YOUR GUESS"
|
|
: "↑ 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}
|
|
zoom={session.difficultySettings.mapZoom}
|
|
compass={session.difficultySettings.compass}
|
|
/>
|
|
<div className="map-actions">
|
|
<small>
|
|
{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")}
|
|
</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(
|
|
session?.difficultySettings.pan === false
|
|
? "FIXED VIEW"
|
|
: "DRAG TO LOOK AROUND",
|
|
)}
|
|
</span>
|
|
<span>
|
|
<MousePointer2 size={12} />{" "}
|
|
{session && !session.difficultySettings.zoom
|
|
? 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>
|
|
);
|
|
}
|