Harden gameplay timing and map loading

This commit is contained in:
2026-10-05 15:15:35 +05:00
parent 8b31891776
commit dcc3779262
36 changed files with 1050 additions and 205 deletions

View File

@@ -9,10 +9,11 @@ import {
MapPin,
} from "lucide-react";
import { Brand } from "./Brand";
import { Panorama, preloadFaces } from "./Panorama";
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";
export type RoundResult = {
score: number;
distance: number | null;
@@ -44,15 +45,19 @@ export function Game({ id }: { id: string }) {
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[result, setResult] = useState<RoundResult | null>(null),
[remaining, setRemaining] = useState(0),
[progress, setProgress] = useState(0);
const offset = useRef(0),
[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);
offset.current = s.serverNow - Date.now();
if (version !== loadVersion.current) return;
clock.current = roundClock(s.serverNow, sentAt, performance.now());
if (s.round >= 5) {
router.replace("/results/" + id);
return;
@@ -60,31 +65,28 @@ export function Game({ id }: { id: string }) {
setSession(s);
setRemaining(
s.rounds[s.round].deadline
? Math.max(
0,
Math.ceil(
(Date.parse(s.rounds[s.round].deadline!) - s.serverNow) / 1000,
),
? clock.current.remaining(
s.rounds[s.round].deadline!,
performance.now(),
)
: difficulties[s.difficulty].seconds,
);
s.rounds.forEach((r) =>
preloadFaces(r.faces)
.then(() => setProgress((p) => Math.min(100, p + 20)))
.catch(() => {}),
);
} catch (e) {
setError((e as Error).message);
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
)
@@ -92,28 +94,31 @@ export function Game({ id }: { id: string }) {
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) => {
offset.current = d.serverNow - Date.now();
clock.current = roundClock(d.serverNow, sentAt, performance.now());
setSession((s) =>
s
s && s.round === round
? {
...s,
rounds: s.rounds.map((r) =>
r.number === s.round ? { ...r, deadline: d.deadline } : r,
),
}
: null,
: s,
);
})
.catch((e) => setError(e.message))
.finally(() => (activating.current = false));
}, [loaded, session, id, result]);
}, [loaded, session, id, result, error]);
const guess = useCallback(
async (timeout = false) => {
if (!session || busy || result || (!pin && !timeout)) return;
if (!session || guessing.current || result || (!pin && !timeout)) return;
guessing.current = true;
setBusy(true);
setError("");
try {
@@ -122,26 +127,36 @@ export function Game({ id }: { id: string }) {
...(!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, busy, result, pin, id],
[session, result, pin, id],
);
useEffect(() => {
if (!session || result) return;
if (!session || result || error) return;
const deadline = session.rounds[session.round].deadline;
if (!deadline) return;
const tick = () => {
const left = Math.max(
0,
Math.ceil((Date.parse(deadline) - Date.now() - offset.current) / 1000),
);
const left = clock.current?.remaining(deadline, performance.now()) ?? 0;
setRemaining(left);
if (left === 0 && !timingOut.current) {
if (left === 0 && !timingOut.current && !guessing.current) {
timingOut.current = true;
guess(true);
}
@@ -149,7 +164,7 @@ export function Game({ id }: { id: string }) {
tick();
const timer = setInterval(tick, 250);
return () => clearInterval(timer);
}, [session, result, guess]);
}, [session, result, guess, error]);
async function next() {
if (!session) return;
setSession(null);
@@ -157,7 +172,7 @@ export function Game({ id }: { id: string }) {
setPin(null);
setLoaded({ panorama: false, map: false });
timingOut.current = false;
setProgress(0);
setError("");
await load();
}
const ready = useCallback(
@@ -215,7 +230,7 @@ export function Game({ id }: { id: string }) {
key={"map-" + session.round}
bounds={session.bounds}
image={session.map}
pin={result?.guess || pin}
pin={result ? result.guess : pin}
actual={result?.actual}
onPin={setPin}
onReady={mapReady}
@@ -252,7 +267,7 @@ export function Game({ id }: { id: string }) {
<div className="game-loading">
<div className="loading-cube" />
<h2>Preparing your expedition.</h2>
<p>Loading compressed panoramas · {progress}%</p>
<p>Loading panorama and surface map…</p>
</div>
)}
{error && (
@@ -267,7 +282,7 @@ export function Game({ id }: { id: string }) {
</button>
</div>
)}
{result && (
{result && !error && (
<div className="round-result">
<span className="eyebrow">
ROUND {(session?.round || 0) + 1} COMPLETE