The first commit, the core and all files is adding to repo
This commit is contained in:
300
components/Game.tsx
Normal file
300
components/Game.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
"use client";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
MousePointer2,
|
||||
Move,
|
||||
MapPin,
|
||||
} from "lucide-react";
|
||||
import { Brand } from "./Shell";
|
||||
import { Panorama, preloadFaces } from "./Panorama";
|
||||
import { GuessMap, type Pin } from "./GuessMap";
|
||||
import { api } from "./StartGame";
|
||||
import { difficulties, type Difficulty, type Bounds } from "@/shared/protocol";
|
||||
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 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),
|
||||
[progress, setProgress] = useState(0);
|
||||
const offset = useRef(0),
|
||||
activating = useRef(false),
|
||||
timingOut = useRef(false);
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const s = await api<SessionView>("sessions/" + id);
|
||||
offset.current = s.serverNow - Date.now();
|
||||
if (s.round >= 5) {
|
||||
router.replace("/results/" + id);
|
||||
return;
|
||||
}
|
||||
setSession(s);
|
||||
setRemaining(
|
||||
s.rounds[s.round].deadline
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil(
|
||||
(Date.parse(s.rounds[s.round].deadline!) - s.serverNow) / 1000,
|
||||
),
|
||||
)
|
||||
: 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);
|
||||
}
|
||||
}, [id, router]);
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => {
|
||||
if (
|
||||
!session ||
|
||||
!loaded.panorama ||
|
||||
!loaded.map ||
|
||||
result ||
|
||||
activating.current
|
||||
)
|
||||
return;
|
||||
const r = session.rounds[session.round];
|
||||
if (r.deadline) return;
|
||||
activating.current = true;
|
||||
api<{ deadline: string; serverNow: number }>(`sessions/${id}/activate`, {
|
||||
round: session.round,
|
||||
})
|
||||
.then((d) => {
|
||||
offset.current = d.serverNow - Date.now();
|
||||
setSession((s) =>
|
||||
s
|
||||
? {
|
||||
...s,
|
||||
rounds: s.rounds.map((r) =>
|
||||
r.number === s.round ? { ...r, deadline: d.deadline } : r,
|
||||
),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
})
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => (activating.current = false));
|
||||
}, [loaded, session, id, result]);
|
||||
const guess = useCallback(
|
||||
async (timeout = false) => {
|
||||
if (!session || busy || result || (!pin && !timeout)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await api<RoundResult>(`sessions/${id}/guess`, {
|
||||
round: session.round,
|
||||
...(!timeout && pin ? pin : {}),
|
||||
});
|
||||
setResult(r);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
timingOut.current = false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[session, busy, result, pin, id],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!session || result) 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),
|
||||
);
|
||||
setRemaining(left);
|
||||
if (left === 0 && !timingOut.current) {
|
||||
timingOut.current = true;
|
||||
guess(true);
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const timer = setInterval(tick, 250);
|
||||
return () => clearInterval(timer);
|
||||
}, [session, result, guess]);
|
||||
async function next() {
|
||||
if (!session) return;
|
||||
setSession(null);
|
||||
setResult(null);
|
||||
setPin(null);
|
||||
setLoaded({ panorama: false, map: false });
|
||||
timingOut.current = false;
|
||||
setProgress(0);
|
||||
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 />
|
||||
<div className="game-info">
|
||||
<div>
|
||||
<small>ROUND</small>
|
||||
<strong>{session ? session.round + 1 : "—"} / 5</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>TIME LEFT</small>
|
||||
<strong className={remaining <= 10 ? "urgent" : ""}>
|
||||
{Math.floor(remaining / 60)}:
|
||||
{String(remaining % 60).padStart(2, "0")}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small>POINTS</small>
|
||||
<strong>{total.toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{session && (
|
||||
<div className={"game-map " + (expanded ? "expanded" : "")}>
|
||||
<div className="map-topline">
|
||||
<span>↑ N · PLACE YOUR GUESS</span>
|
||||
<button
|
||||
aria-label={expanded ? "Collapse map" : "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?.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)}`
|
||||
: "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 ? "Checking your guess…" : "Submit guess"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="look-help">
|
||||
<span>
|
||||
<Move size={12} /> DRAG TO LOOK AROUND
|
||||
</span>
|
||||
<span>
|
||||
<MousePointer2 size={12} />{" "}
|
||||
{session?.difficulty === "hard" ? "NO ZOOM" : "SCROLL TO ZOOM"}
|
||||
</span>
|
||||
</div>
|
||||
{(!session || !loaded.panorama || !loaded.map) && !error && (
|
||||
<div className="game-loading">
|
||||
<div className="loading-cube" />
|
||||
<h2>Preparing your expedition.</h2>
|
||||
<p>Loading compressed panoramas · {progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="round-result" role="alert">
|
||||
<h2>Connection interrupted.</h2>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
className="button primary"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="round-result">
|
||||
<span className="eyebrow">
|
||||
ROUND {(session?.round || 0) + 1} COMPLETE
|
||||
</span>
|
||||
<h2>
|
||||
{result.timedOut
|
||||
? "Time slipped away."
|
||||
: result.score === 5000
|
||||
? "Right on the block!"
|
||||
: result.score > 3000
|
||||
? "You know your way around."
|
||||
: "A little off the beaten block."}
|
||||
</h2>
|
||||
<div className="score">
|
||||
{result.score.toLocaleString()}
|
||||
<small> / 5,000</small>
|
||||
</div>
|
||||
<p>
|
||||
{result.timedOut
|
||||
? "The clock ran out. This round scores zero."
|
||||
: `${Math.round(result.distance || 0).toLocaleString()} blocks from the actual location.`}
|
||||
</p>
|
||||
<button className="button primary full" onClick={next}>
|
||||
{session?.round === 4 ? "See expedition results" : "Next round"} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user