Harden gameplay timing and map loading
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user