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

@@ -8,9 +8,9 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
const shown = maps.filter(
(m) =>
(source === "all" || m.source === source) &&
(m.name + " " + m.description + " " + m.server?.name)
(m.name + " " + m.description + " " + (m.server?.name ?? ""))
.toLowerCase()
.includes(query.toLowerCase()),
.includes(query.trim().toLowerCase()),
);
return (
<>
@@ -33,6 +33,7 @@ export function CatalogBrowser({ maps }: { maps: CardMap[] }) {
<button
key={id}
className={source === id ? "active" : ""}
aria-pressed={source === id}
onClick={() => setSource(id)}
>
{label}

View File

@@ -29,6 +29,7 @@ export function Dashboard() {
]);
setMaps(m.maps);
setTokens(t.tokens);
setError("");
} catch (e) {
setError((e as Error).message);
}
@@ -65,7 +66,11 @@ export function Dashboard() {
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
await client.auth.signOut();
const { error: signOutError } = await client.auth.signOut();
if (signOutError) {
setError("Could not sign out. Please try again.");
return;
}
router.refresh();
}
return (

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

View File

@@ -26,12 +26,21 @@ export function GuessMap({
const root = useRef<HTMLDivElement>(null),
markers = useRef<Leaflet.LayerGroup | null>(null),
lib = useRef<typeof Leaflet | null>(null),
[generation, setGeneration] = useState(0);
[generation, setGeneration] = useState(0),
[error, setError] = useState(""),
[attempt, setAttempt] = useState(0);
const callbacks = useRef({ onPin, onReady, onError, disabled });
callbacks.current = { onPin, onReady, onError, disabled };
useEffect(() => {
let disposed = false;
let cleanup = () => {};
setError("");
const fail = (message: string) => {
if (disposed) return;
setError(message);
callbacks.current.onError?.(message);
};
let loadTimer: ReturnType<typeof setTimeout> | undefined;
import("leaflet")
.then((L) => {
if (disposed || !root.current) return;
@@ -48,13 +57,31 @@ export function GuessMap({
maxBounds: b.pad(0.15),
attributionControl: false,
});
// Install cleanup immediately, including when later initialization fails.
let observer: ResizeObserver | undefined;
cleanup = () => {
observer?.disconnect();
markers.current = null;
lib.current = null;
m.remove();
};
const overlay = L.imageOverlay(image, b);
overlay.on("load", () => callbacks.current.onReady?.());
overlay.on("error", () =>
callbacks.current.onError?.(
"The surface map could not load. Reconnect to retry.",
),
loadTimer = setTimeout(
() =>
fail("The surface map took too long to load. Reconnect to retry."),
30000,
);
overlay.on("load", () => {
clearTimeout(loadTimer);
if (!disposed) {
setError("");
callbacks.current.onReady?.();
}
});
overlay.on("error", () => {
clearTimeout(loadTimer);
fail("The surface map could not load. Reconnect to retry.");
});
overlay.addTo(m);
m.setMaxZoom(Math.max(3, m.getBoundsZoom(b) + 3));
m.fitBounds(b, { padding: [0, 0], animate: false });
@@ -74,12 +101,33 @@ export function GuessMap({
)
callbacks.current.onPin?.({ x, z });
});
const keydown = (event: KeyboardEvent) => {
if (
event.key !== "Enter" ||
event.target !== m.getContainer() ||
callbacks.current.disabled
)
return;
event.preventDefault();
const center = m.getCenter();
callbacks.current.onPin?.({
x: Math.max(bounds.minX, Math.min(bounds.maxX, center.lng)),
z: Math.max(bounds.minZ, Math.min(bounds.maxZ, -center.lat)),
});
};
m.getContainer().addEventListener("keydown", keydown);
const removeMap = cleanup;
cleanup = () => {
m.getContainer().removeEventListener("keydown", keydown);
removeMap();
};
let fittedZoom = fitZoom;
const observer = new ResizeObserver(() => {
observer = new ResizeObserver(() => {
if (!root.current?.clientWidth || !root.current.clientHeight) return;
// Expanding the map should reveal the whole survey at its new size.
// Preserve a player's chosen zoom and center while inspecting terrain.
const wasFitted = Math.abs(m.getZoom() - fittedZoom) < 0.01;
m.invalidateSize();
m.invalidateSize({ pan: false });
m.setMinZoom(-5);
fittedZoom = m.getBoundsZoom(b);
m.setMaxZoom(Math.max(3, fittedZoom + 3));
@@ -88,20 +136,14 @@ export function GuessMap({
m.setMinZoom(fittedZoom);
});
observer.observe(root.current);
cleanup = () => {
observer.disconnect();
markers.current = null;
m.remove();
};
})
.catch(() =>
callbacks.current.onError?.("The map viewer could not initialize."),
);
.catch(() => fail("The map viewer could not initialize."));
return () => {
disposed = true;
clearTimeout(loadTimer);
cleanup();
};
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image]);
}, [bounds.minX, bounds.maxX, bounds.minZ, bounds.maxZ, image, attempt]);
useEffect(() => {
const L = lib.current,
group = markers.current;
@@ -133,8 +175,23 @@ export function GuessMap({
ref={root}
style={{ height: "100%", width: "100%" }}
role="application"
aria-label="Minecraft map. North is up. Click to place a guess."
aria-label={
disabled
? "Minecraft map. North is up."
: "Minecraft map. North is up. Click to place a guess, or use arrow keys to pan and Enter to pin the center."
}
/>
{error && (
<div className="map-load-error" role="alert">
<p>{error}</p>
<button
className="button"
onClick={() => setAttempt((value) => value + 1)}
>
Retry map
</button>
</div>
)}
</div>
);
}

View File

@@ -1,29 +1,8 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef } from "react";
import { Plus, Minus, RotateCcw } from "lucide-react";
import * as THREE from "three";
const textureCache = new Map<string, Promise<THREE.Texture>>();
export function preloadFaces(urls: string[]) {
return Promise.all(
urls.map((url) => {
if (!textureCache.has(url))
textureCache.set(
url,
new THREE.TextureLoader()
.loadAsync(url)
.then((t) => {
t.colorSpace = THREE.SRGBColorSpace;
return t;
})
.catch((e) => {
textureCache.delete(url);
throw e;
}),
);
return textureCache.get(url)!;
}),
);
}
import { loadPanoramaTextures } from "@/lib/panorama-textures";
// Explicit projection matches the six positive-FOV camera images emitted by
// both exporters; it avoids CubeTexture's renderer-dependent orientation.
const fragment = `uniform sampler2D px;uniform sampler2D nx;uniform sampler2D py;uniform sampler2D ny;uniform sampler2D pz;uniform sampler2D nz;varying vec3 vDirection;
@@ -43,8 +22,7 @@ export function Panorama({
onError: (message: string) => void;
}) {
const mount = useRef<HTMLDivElement>(null),
view = useRef({ yaw: 0, pitch: 0, fov: 80 }),
[fov, setFov] = useState(80);
view = useRef({ yaw: 0, pitch: 0, fov: 80 });
const callbacks = useRef({ onReady, onError });
callbacks.current = { onReady, onError };
useEffect(() => {
@@ -53,7 +31,8 @@ export function Panorama({
frame = 0,
observer: ResizeObserver | undefined;
let material: THREE.ShaderMaterial | undefined,
geometry: THREE.SphereGeometry | undefined;
geometry: THREE.SphereGeometry | undefined,
ownedTextures: THREE.Texture[] = [];
const element = mount.current!;
view.current = { yaw: 0, pitch: 0, fov: 80 };
const down = (e: PointerEvent) => {
@@ -81,6 +60,13 @@ export function Panorama({
Math.min(100, view.current.fov + e.deltaY * 0.04),
);
};
const contextLost = (event: Event) => {
event.preventDefault();
cancelAnimationFrame(frame);
callbacks.current.onError(
"The panorama graphics context was lost. Reconnect to restore the view.",
);
};
let drag: { x: number; y: number } | null = null;
if (urls.length !== 6) {
callbacks.current.onError(
@@ -88,13 +74,18 @@ export function Panorama({
);
return;
}
preloadFaces(urls)
loadPanoramaTextures(urls)
.then((textures) => {
if (disposed) return;
if (disposed) {
textures.forEach((texture) => texture.dispose());
return;
}
ownedTextures = textures;
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
element.appendChild(renderer.domElement);
renderer.domElement.addEventListener("webglcontextlost", contextLost);
const scene = new THREE.Scene(),
camera = new THREE.PerspectiveCamera(80, 1, 0.1, 10);
geometry = new THREE.SphereGeometry(5, 32, 16);
@@ -112,9 +103,11 @@ export function Panorama({
});
scene.add(new THREE.Mesh(geometry, material));
const resize = () => {
camera.aspect = element.clientWidth / element.clientHeight;
const width = Math.max(1, element.clientWidth),
height = Math.max(1, element.clientHeight);
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer!.setSize(element.clientWidth, element.clientHeight);
renderer!.setSize(width, height);
};
observer = new ResizeObserver(resize);
observer.observe(element);
@@ -157,13 +150,12 @@ export function Panorama({
element.removeEventListener("wheel", wheel);
geometry?.dispose();
material?.dispose();
ownedTextures.forEach((texture) => texture.dispose());
renderer?.domElement.removeEventListener("webglcontextlost", contextLost);
renderer?.dispose();
renderer?.domElement.remove();
};
}, [urls, zoom]);
useEffect(() => {
view.current.fov = fov;
}, [fov]);
return (
<>
<div
@@ -177,13 +169,17 @@ export function Panorama({
<>
<button
aria-label="Zoom in"
onClick={() => setFov(Math.max(35, view.current.fov - 10))}
onClick={() => {
view.current.fov = Math.max(35, view.current.fov - 10);
}}
>
<Plus size={17} />
</button>
<button
aria-label="Zoom out"
onClick={() => setFov(Math.min(100, view.current.fov + 10))}
onClick={() => {
view.current.fov = Math.min(100, view.current.fov + 10);
}}
>
<Minus size={17} />
</button>
@@ -193,7 +189,6 @@ export function Panorama({
aria-label="Reset view"
onClick={() => {
view.current = { yaw: 0, pitch: 0, fov: 80 };
setFov(80);
}}
>
<RotateCcw size={17} />

View File

@@ -11,12 +11,22 @@ export function Results({ id }: { id: string }) {
[error, setError] = useState("");
const router = useRouter();
useEffect(() => {
let disposed = false;
setSession(null);
setSelected(0);
setError("");
api<SessionView>("sessions/" + id)
.then((s) => {
if (disposed) return;
if (s.round < 5) router.replace("/play/" + id);
else setSession(s);
})
.catch((e) => setError(e.message));
.catch((e) => {
if (!disposed) setError(e.message);
});
return () => {
disposed = true;
};
}, [id, router]);
if (error) return <p role="alert">{error}</p>;
if (!session) return <p className="muted">Unpacking your expedition…</p>;
@@ -52,6 +62,7 @@ export function Results({ id }: { id: string }) {
<button
key={i}
className={i === selected ? "active" : ""}
aria-pressed={i === selected}
onClick={() => setSelected(i)}
>
Round {i + 1}
@@ -85,7 +96,7 @@ export function Results({ id }: { id: string }) {
Another adventure →
</Link>
<Link className="button" href="/dashboard">
My expeditions
My maps
</Link>
</div>
</div>

View File

@@ -3,21 +3,8 @@ import { useState } from "react";
import { useRouter } from "next/navigation";
import { ArrowRight, Timer, ZoomIn } from "lucide-react";
import { difficulties, type Difficulty } from "@/shared/protocol";
export async function api<T = Record<string, unknown>>(
path: string,
body?: unknown,
method = body ? "POST" : "GET",
): Promise<T> {
const res = await fetch("/api/v1/" + path, {
signal: AbortSignal.timeout(30000),
method,
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Request failed");
return data;
}
import { api } from "@/lib/client-api";
export { api } from "@/lib/client-api";
export function StartGame({ datasetId }: { datasetId: string }) {
const [difficulty, setDifficulty] = useState<Difficulty>("normal"),
[loading, setLoading] = useState(false),