add land navigation page, arena, map canvas and leaderboard UI
This commit is contained in:
parent
62df673249
commit
0e65260ea0
5 changed files with 1173 additions and 0 deletions
218
src/app/(frontend)/land-nav/actions.ts
Normal file
218
src/app/(frontend)/land-nav/actions.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
import {
|
||||||
|
BEST_TIME_FIELDS,
|
||||||
|
LAND_NAV_DIFFICULTIES,
|
||||||
|
MAX_LEGS,
|
||||||
|
MAX_SECONDS,
|
||||||
|
calculateScore,
|
||||||
|
calculateXp,
|
||||||
|
isLandNavDifficulty,
|
||||||
|
type LandNavDifficulty,
|
||||||
|
} from "@/lib/land-nav";
|
||||||
|
|
||||||
|
export type { LandNavDifficulty };
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LandNavStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
bestScore: number;
|
||||||
|
landNavXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestWaypointSeconds: number;
|
||||||
|
bestRouteSeconds: number;
|
||||||
|
bestExpeditionSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BestTimeField = (typeof BEST_TIME_FIELDS)[LandNavDifficulty];
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitLandNavResult(input: {
|
||||||
|
difficulty: LandNavDifficulty;
|
||||||
|
legsCorrect: number;
|
||||||
|
seconds: number;
|
||||||
|
won: boolean;
|
||||||
|
}): Promise<ActionResult<LandNavStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (typeof input.difficulty !== "string" || !isLandNavDifficulty(input.difficulty)) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const difficulty = input.difficulty;
|
||||||
|
const difficultyConfig = LAND_NAV_DIFFICULTIES[difficulty];
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.legsCorrect) ||
|
||||||
|
input.legsCorrect < 0 ||
|
||||||
|
input.legsCorrect > Math.min(difficultyConfig.legs, MAX_LEGS)
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!Number.isInteger(input.seconds) ||
|
||||||
|
input.seconds <= 0 ||
|
||||||
|
input.seconds > difficultyConfig.maxSeconds
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (typeof input.won !== "boolean") {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (input.won && input.legsCorrect < difficultyConfig.passLegs) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!input.won &&
|
||||||
|
input.legsCorrect >= difficultyConfig.passLegs &&
|
||||||
|
input.seconds >= difficultyConfig.maxSeconds
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, error: "No profile found for this user." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = (profile.progression ?? {}) as {
|
||||||
|
xp?: unknown;
|
||||||
|
minigames?: {
|
||||||
|
landNav?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
wins?: number;
|
||||||
|
losses?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
bestWaypointSeconds?: number;
|
||||||
|
bestRouteSeconds?: number;
|
||||||
|
bestExpeditionSeconds?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.landNav ?? {};
|
||||||
|
const score = calculateScore(difficulty, input.legsCorrect, input.seconds, input.won);
|
||||||
|
const runXp = calculateXp(score, input.won);
|
||||||
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
|
const wins = (current.wins ?? 0) + (input.won ? 1 : 0);
|
||||||
|
const losses = (current.losses ?? 0) + (input.won ? 0 : 1);
|
||||||
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
|
const totalXp = (current.xpEarned ?? 0) + runXp;
|
||||||
|
const bestTimeField = BEST_TIME_FIELDS[difficulty];
|
||||||
|
const previousBestTime = current[bestTimeField as keyof typeof current] ?? 0;
|
||||||
|
const isBestTime = input.won && (previousBestTime === 0 || input.seconds < previousBestTime);
|
||||||
|
const bestTime = isBestTime ? input.seconds : previousBestTime;
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
landNav: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
xpEarned: totalXp,
|
||||||
|
bestWaypointSeconds: current.bestWaypointSeconds ?? 0,
|
||||||
|
bestRouteSeconds: current.bestRouteSeconds ?? 0,
|
||||||
|
bestExpeditionSeconds: current.bestExpeditionSeconds ?? 0,
|
||||||
|
[bestTimeField]: bestTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: runXp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (input.won) {
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MinigameLandNav,
|
||||||
|
message: `Completed a Land Navigation course (${difficultyConfig.label}) with score ${score} (+${runXp} XP)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
difficulty,
|
||||||
|
legsCorrect: input.legsCorrect,
|
||||||
|
legsTotal: difficultyConfig.legs,
|
||||||
|
seconds: input.seconds,
|
||||||
|
score,
|
||||||
|
xpEarned: runXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
bestScore,
|
||||||
|
landNavXp: totalXp,
|
||||||
|
xpEarned: runXp,
|
||||||
|
bestWaypointSeconds:
|
||||||
|
bestTimeField === "bestWaypointSeconds" ? bestTime : (current.bestWaypointSeconds ?? 0),
|
||||||
|
bestRouteSeconds:
|
||||||
|
bestTimeField === "bestRouteSeconds" ? bestTime : (current.bestRouteSeconds ?? 0),
|
||||||
|
bestExpeditionSeconds:
|
||||||
|
bestTimeField === "bestExpeditionSeconds"
|
||||||
|
? bestTime
|
||||||
|
: (current.bestExpeditionSeconds ?? 0),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/(frontend)/land-nav/page.tsx
Normal file
67
src/app/(frontend)/land-nav/page.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Compass } from "lucide-react";
|
||||||
|
import { LandNavArena } from "@/components/frontend/land-nav/LandNavArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Land Navigation (Polaris Task Force)",
|
||||||
|
description:
|
||||||
|
"Navigate grid squares, azimuths and terrain features across three course difficulties to earn XP for the task force.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function LandNavPage() {
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/login?returnTo=${encodeURIComponent("/land-nav")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
select: {
|
||||||
|
progression: {
|
||||||
|
minigames: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const landNav = profileRes.docs[0]?.progression?.minigames?.landNav ?? {};
|
||||||
|
const stats = {
|
||||||
|
gamesPlayed: landNav.gamesPlayed ?? 0,
|
||||||
|
wins: landNav.wins ?? 0,
|
||||||
|
losses: landNav.losses ?? 0,
|
||||||
|
bestScore: landNav.bestScore ?? 0,
|
||||||
|
landNavXp: landNav.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
bestWaypointSeconds: landNav.bestWaypointSeconds ?? 0,
|
||||||
|
bestRouteSeconds: landNav.bestRouteSeconds ?? 0,
|
||||||
|
bestExpeditionSeconds: landNav.bestExpeditionSeconds ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Compass className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Land Navigation</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Click grid squares, shoot azimuths and identify terrain features. Faster courses score
|
||||||
|
higher.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LandNavArena initialStats={stats} currentUserId={user.id as number} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
378
src/components/frontend/land-nav/LandNavArena.tsx
Normal file
378
src/components/frontend/land-nav/LandNavArena.tsx
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Compass, RotateCcw } from "lucide-react";
|
||||||
|
import type { LandNavStats } from "@/app/(frontend)/land-nav/actions";
|
||||||
|
import { submitLandNavResult } from "@/app/(frontend)/land-nav/actions";
|
||||||
|
import {
|
||||||
|
LAND_NAV_DIFFICULTIES,
|
||||||
|
calculateScore,
|
||||||
|
createCourse,
|
||||||
|
gridRef,
|
||||||
|
isLegCorrect,
|
||||||
|
isOptionCorrect,
|
||||||
|
type Cell,
|
||||||
|
type LandNavCourse,
|
||||||
|
type LandNavDifficulty,
|
||||||
|
type LandNavLeg,
|
||||||
|
} from "@/lib/land-nav";
|
||||||
|
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||||
|
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||||
|
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||||
|
import { playXp } from "@/components/frontend/qualification/sounds";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LandNavLeaderboard } from "./LandNavLeaderboard";
|
||||||
|
import { LandNavMap, type MapClickResult } from "./LandNavMap";
|
||||||
|
|
||||||
|
type Phase = "ready" | "playing" | "over";
|
||||||
|
|
||||||
|
interface LandNavArenaProps {
|
||||||
|
initialStats: LandNavStats;
|
||||||
|
currentUserId: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RunResult {
|
||||||
|
legsCorrect: number;
|
||||||
|
legsTotal: number;
|
||||||
|
seconds: number;
|
||||||
|
won: boolean;
|
||||||
|
score: number;
|
||||||
|
xpEarned: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_ORDER: LandNavDifficulty[] = ["waypoint", "route", "expedition"];
|
||||||
|
|
||||||
|
export function LandNavArena({ initialStats, currentUserId }: LandNavArenaProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { expired, expiredRef } = useSessionExpired();
|
||||||
|
const [phase, setPhase] = useState<Phase>("ready");
|
||||||
|
const [difficulty, setDifficulty] = useState<LandNavDifficulty>("waypoint");
|
||||||
|
const [course, setCourse] = useState<LandNavCourse | null>(null);
|
||||||
|
const [legIndex, setLegIndex] = useState(0);
|
||||||
|
const [legsCorrect, setLegsCorrect] = useState(0);
|
||||||
|
const [seconds, setSeconds] = useState(0);
|
||||||
|
const [lastResult, setLastResult] = useState<MapClickResult | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<RunResult | null>(null);
|
||||||
|
const [stats, setStats] = useState<LandNavStats>(initialStats);
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0);
|
||||||
|
const submittingRef = useRef(false);
|
||||||
|
const phaseRef = useRef<Phase>("ready");
|
||||||
|
const legsCorrectRef = useRef(0);
|
||||||
|
|
||||||
|
const playing = phase === "playing";
|
||||||
|
const activeTeams = useRealtimePresence("land-nav", playing, true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
phaseRef.current = phase;
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setSeconds((s) => s + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [playing]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || !course) return;
|
||||||
|
if (seconds >= LAND_NAV_DIFFICULTIES[course.difficulty].maxSeconds) {
|
||||||
|
finishRun(legsCorrectRef.current, true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [seconds, playing, course]);
|
||||||
|
|
||||||
|
const startRun = useCallback(
|
||||||
|
(selected: LandNavDifficulty) => {
|
||||||
|
if (expiredRef.current) return;
|
||||||
|
setDifficulty(selected);
|
||||||
|
setCourse(createCourse(selected));
|
||||||
|
setLegIndex(0);
|
||||||
|
setLegsCorrect(0);
|
||||||
|
legsCorrectRef.current = 0;
|
||||||
|
setSeconds(0);
|
||||||
|
setLastResult(null);
|
||||||
|
setFeedback(null);
|
||||||
|
setResult(null);
|
||||||
|
setPhase("playing");
|
||||||
|
},
|
||||||
|
[expiredRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const finishRun = useCallback(
|
||||||
|
(finalLegsCorrect: number, timedOut: boolean) => {
|
||||||
|
if (!course || submittingRef.current || phaseRef.current !== "playing") return;
|
||||||
|
submittingRef.current = true;
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[course.difficulty];
|
||||||
|
const won = !timedOut && finalLegsCorrect >= config.passLegs;
|
||||||
|
const runSeconds = Math.max(1, seconds);
|
||||||
|
const score = calculateScore(course.difficulty, finalLegsCorrect, runSeconds, won);
|
||||||
|
setPhase("over");
|
||||||
|
phaseRef.current = "over";
|
||||||
|
|
||||||
|
if (expiredRef.current) {
|
||||||
|
setResult({
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
legsTotal: config.legs,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
score,
|
||||||
|
xpEarned: 0,
|
||||||
|
});
|
||||||
|
setFeedback("Your session expired. Result not recorded.");
|
||||||
|
submittingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitLandNavResult({
|
||||||
|
difficulty: course.difficulty,
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
if (res.success && res.data) {
|
||||||
|
setStats(res.data);
|
||||||
|
setResult({
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
legsTotal: config.legs,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
score,
|
||||||
|
xpEarned: res.data.xpEarned,
|
||||||
|
});
|
||||||
|
if (res.data.xpEarned > 0) {
|
||||||
|
playXp();
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||||
|
detail: { reason: "xp", xpGained: res.data.xpEarned },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setTimeout(() => router.refresh(), 1700);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setResult({
|
||||||
|
legsCorrect: finalLegsCorrect,
|
||||||
|
legsTotal: config.legs,
|
||||||
|
seconds: runSeconds,
|
||||||
|
won,
|
||||||
|
score,
|
||||||
|
xpEarned: 0,
|
||||||
|
});
|
||||||
|
setFeedback(res.error ?? "Result not recorded.");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
submittingRef.current = false;
|
||||||
|
setRefreshKey((key) => key + 1);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[course, seconds, expiredRef, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const answerLeg = useCallback(
|
||||||
|
(correct: boolean, clickResult: MapClickResult | null, optionName?: string) => {
|
||||||
|
if (!course || phaseRef.current !== "playing") return;
|
||||||
|
const config = LAND_NAV_DIFFICULTIES[course.difficulty];
|
||||||
|
const leg = course.legs[legIndex] as LandNavLeg;
|
||||||
|
setLastResult(clickResult);
|
||||||
|
const nextCorrect = legsCorrect + (correct ? 1 : 0);
|
||||||
|
legsCorrectRef.current = nextCorrect;
|
||||||
|
setLegsCorrect(nextCorrect);
|
||||||
|
setFeedback(
|
||||||
|
correct
|
||||||
|
? `Correct, ${leg.kind === "identify" ? optionName : gridRef(course, leg.answerCell)}.`
|
||||||
|
: `Wrong. The answer was ${
|
||||||
|
leg.kind === "identify"
|
||||||
|
? (course.features.find((f) => f.id === leg.answerFeatureId)?.name ?? "unknown")
|
||||||
|
: gridRef(course, leg.answerCell)
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
if (legIndex + 1 >= config.legs) {
|
||||||
|
setTimeout(() => finishRun(nextCorrect, false), 350);
|
||||||
|
} else {
|
||||||
|
setTimeout(() => setLegIndex((i) => i + 1), 350);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[course, legIndex, legsCorrect, finishRun],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCellClick = useCallback(
|
||||||
|
(cell: Cell) => {
|
||||||
|
if (!course || phaseRef.current !== "playing") return;
|
||||||
|
const leg = course.legs[legIndex] as LandNavLeg;
|
||||||
|
if (leg.kind === "identify") return;
|
||||||
|
const correct = isLegCorrect(leg, cell);
|
||||||
|
answerLeg(correct, { cell, correct });
|
||||||
|
},
|
||||||
|
[course, legIndex, answerLeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptionClick = useCallback(
|
||||||
|
(option: string) => {
|
||||||
|
if (!course || phaseRef.current !== "playing") return;
|
||||||
|
const leg = course.legs[legIndex] as LandNavLeg;
|
||||||
|
if (leg.kind !== "identify") return;
|
||||||
|
const correct = isOptionCorrect(leg, course, option);
|
||||||
|
answerLeg(correct, null, option);
|
||||||
|
},
|
||||||
|
[course, legIndex, answerLeg],
|
||||||
|
);
|
||||||
|
|
||||||
|
const leg: LandNavLeg | null = course ? (course.legs[legIndex] ?? null) : null;
|
||||||
|
const config = course ? LAND_NAV_DIFFICULTIES[course.difficulty] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<div className="flex w-full max-w-[520px] flex-wrap items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">Games {stats.gamesPlayed}</Badge>
|
||||||
|
<Badge variant="outline">Wins {stats.wins}</Badge>
|
||||||
|
<Badge variant="outline">Losses {stats.losses}</Badge>
|
||||||
|
<Badge variant="outline">Best {stats.bestScore}</Badge>
|
||||||
|
</div>
|
||||||
|
{phase === "ready" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => startRun(difficulty)}>
|
||||||
|
<Compass className="size-4" />
|
||||||
|
Start Course
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{phase === "over" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => startRun(difficulty)}>
|
||||||
|
<RotateCcw className="size-4" />
|
||||||
|
Run Again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{phase === "ready" && (
|
||||||
|
<Card className="w-full max-w-[520px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Compass className="size-4 text-muted-foreground" />
|
||||||
|
Choose your course
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{DIFFICULTY_ORDER.map((key) => {
|
||||||
|
const cfg = LAND_NAV_DIFFICULTIES[key];
|
||||||
|
const selected = difficulty === key;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDifficulty(key)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border px-4 py-3 text-left transition-colors",
|
||||||
|
selected ? "border-primary bg-accent/40" : "border-border hover:bg-accent/20",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium">{cfg.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{cfg.legs} legs · par {Math.floor(cfg.parSeconds / 60)}:
|
||||||
|
{String(cfg.parSeconds % 60).padStart(2, "0")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{cfg.description}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "playing" && course && leg && config && (
|
||||||
|
<Card className="w-full max-w-[520px]">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="flex items-center justify-between text-sm font-medium">
|
||||||
|
<span>
|
||||||
|
Leg {legIndex + 1} of {config.legs} · {config.label}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{Math.floor((config.maxSeconds - seconds) / 60)}:
|
||||||
|
{String((config.maxSeconds - seconds) % 60).padStart(2, "0")} left
|
||||||
|
</span>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm font-medium">{leg.prompt}</p>
|
||||||
|
<LandNavMap
|
||||||
|
course={course}
|
||||||
|
leg={leg.kind === "identify" ? null : leg}
|
||||||
|
lastResult={lastResult}
|
||||||
|
locked={leg.kind === "identify"}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
/>
|
||||||
|
{leg.kind === "identify" && leg.options && (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{leg.options.map((option) => (
|
||||||
|
<Button key={option} variant="outline" onClick={() => handleOptionClick(option)}>
|
||||||
|
{option}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{feedback && (
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
lastResult?.correct === false ? "text-red-400" : "text-emerald-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{feedback}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Correct legs: {legsCorrect} · pass at {config.passLegs}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{phase === "over" && result && (
|
||||||
|
<Card className="w-full max-w-[520px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">
|
||||||
|
{result.won ? "Course completed" : "Course failed"}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-1 text-sm">
|
||||||
|
<p>
|
||||||
|
Legs correct: {result.legsCorrect}/{result.legsTotal}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Time: {Math.floor(result.seconds / 60)}:{String(result.seconds % 60).padStart(2, "0")}
|
||||||
|
</p>
|
||||||
|
<p>Score: {result.score}</p>
|
||||||
|
<p
|
||||||
|
className={
|
||||||
|
result.xpEarned > 0 ? "font-medium text-emerald-400" : "text-muted-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result.xpEarned > 0 ? `+${result.xpEarned} XP earned` : "No XP earned"}
|
||||||
|
</p>
|
||||||
|
{feedback && <p className="text-xs text-muted-foreground">{feedback}</p>}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired && (
|
||||||
|
<p className="text-sm text-amber-400">Your session expired. Result not recorded.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<LandNavLeaderboard
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
activeTeams={activeTeams}
|
||||||
|
difficulty={difficulty}
|
||||||
|
onDifficultyChange={setDifficulty}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
263
src/components/frontend/land-nav/LandNavLeaderboard.tsx
Normal file
263
src/components/frontend/land-nav/LandNavLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
import type { LandNavDifficulty } from "@/app/(frontend)/land-nav/actions";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ApiProfile {
|
||||||
|
id: number;
|
||||||
|
profileTitle?: string | null;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
displayName?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
} | null;
|
||||||
|
progression?: {
|
||||||
|
minigames?: {
|
||||||
|
landNav?: Record<string, number | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardRow {
|
||||||
|
id: number;
|
||||||
|
userId: number | null;
|
||||||
|
name: string;
|
||||||
|
gamesPlayed: number;
|
||||||
|
wins: number;
|
||||||
|
bestScore: number;
|
||||||
|
xpEarned: number;
|
||||||
|
bestSeconds: number;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
isOutsideTopTen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LandNavLeaderboardProps {
|
||||||
|
refreshKey?: number;
|
||||||
|
currentUserId?: number | string;
|
||||||
|
activeTeams?: PresenceParticipant[];
|
||||||
|
difficulty: LandNavDifficulty;
|
||||||
|
onDifficultyChange: (difficulty: LandNavDifficulty) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIFFICULTY_TIME_FIELDS: Record<LandNavDifficulty, string> = {
|
||||||
|
waypoint: "bestWaypointSeconds",
|
||||||
|
route: "bestRouteSeconds",
|
||||||
|
expedition: "bestExpeditionSeconds",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DIFFICULTY_LABELS: Record<LandNavDifficulty, string> = {
|
||||||
|
waypoint: "Waypoint",
|
||||||
|
route: "Route",
|
||||||
|
expedition: "Expedition",
|
||||||
|
};
|
||||||
|
|
||||||
|
function rankClass(index: number) {
|
||||||
|
if (index === 0) return "font-bold text-amber-400";
|
||||||
|
if (index === 1) return "font-semibold text-slate-300";
|
||||||
|
if (index === 2) return "font-semibold text-amber-700";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins}:${String(secs).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LandNavLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
currentUserId,
|
||||||
|
activeTeams = [],
|
||||||
|
difficulty,
|
||||||
|
onDifficultyChange,
|
||||||
|
}: LandNavLeaderboardProps) {
|
||||||
|
const [rows, setRows] = useState<LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(false);
|
||||||
|
|
||||||
|
const timeField = `progression.minigames.landNav.${DIFFICULTY_TIME_FIELDS[difficulty]}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: timeField,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
params.set(`where[${timeField}][greater_than]`, "0");
|
||||||
|
|
||||||
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
return res.json() as Promise<{ docs?: ApiProfile[] }>;
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mapped: LeaderboardRow[] = (data.docs ?? []).map((doc) => {
|
||||||
|
const game = doc.progression?.minigames?.landNav ?? {};
|
||||||
|
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||||
|
const name =
|
||||||
|
user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`;
|
||||||
|
return {
|
||||||
|
id: doc.id,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
name,
|
||||||
|
gamesPlayed: game.gamesPlayed ?? 0,
|
||||||
|
wins: game.wins ?? 0,
|
||||||
|
bestScore: game.bestScore ?? 0,
|
||||||
|
xpEarned: game.xpEarned ?? 0,
|
||||||
|
bestSeconds: game[DIFFICULTY_TIME_FIELDS[difficulty]] ?? 0,
|
||||||
|
isCurrentUser: user ? String(user.id) === String(currentUserId) : false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestSeconds > 0)
|
||||||
|
.sort((a, b) => a.bestSeconds - b.bestSeconds || b.bestScore - a.bestScore);
|
||||||
|
const visible = scored.slice(0, 10);
|
||||||
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setFailed(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [difficulty, refreshKey, currentUserId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-[520px] rounded-xl border border-border bg-card">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||||
|
<Trophy className="size-4 text-amber-400" />
|
||||||
|
<h2 className="text-sm font-semibold">Land Nav Leaderboard</h2>
|
||||||
|
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Select
|
||||||
|
value={difficulty}
|
||||||
|
onValueChange={(value) => onDifficultyChange(value as LandNavDifficulty)}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label="Leaderboard course" className="h-8 w-[140px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="waypoint">Waypoint</SelectItem>
|
||||||
|
<SelectItem value="route">Route</SelectItem>
|
||||||
|
<SelectItem value="expedition">Expedition</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span>Top 10 · fastest time</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-emerald-400">
|
||||||
|
{activeTeams.length} navigators on the course
|
||||||
|
</span>
|
||||||
|
{activeTeams.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
({activeTeams.map((team) => team.name).join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="space-y-2 px-4 py-4">
|
||||||
|
<div className="h-4 w-full animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-3/4 animate-pulse rounded bg-border" />
|
||||||
|
<div className="h-4 w-1/2 animate-pulse rounded bg-border" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No recorded courses yet. Be the first to navigate one!
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Best Time</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Games</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Wins</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">XP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
row.isOutsideTopTen
|
||||||
|
? "Operator status (not currently ranked in the top 10)"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={rankClass(index)}>
|
||||||
|
{row.isOutsideTopTen ? "-" : index + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="max-w-[180px] truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-[10px] font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{row.userId !== null &&
|
||||||
|
activeTeams.some((team) => team.id === row.userId) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-emerald-400"
|
||||||
|
title="Navigating now"
|
||||||
|
aria-label="Navigating now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-semibold tabular-nums">
|
||||||
|
{formatSeconds(row.bestSeconds)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.wins}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right tabular-nums text-muted-foreground">
|
||||||
|
{row.xpEarned}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
src/components/frontend/land-nav/LandNavMap.tsx
Normal file
247
src/components/frontend/land-nav/LandNavMap.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { type Cell, type LandNavCourse, type LandNavLeg, featureTypeLabel } from "@/lib/land-nav";
|
||||||
|
|
||||||
|
export interface MapClickResult {
|
||||||
|
cell: Cell;
|
||||||
|
correct: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LandNavMapProps {
|
||||||
|
course: LandNavCourse;
|
||||||
|
leg: LandNavLeg | null;
|
||||||
|
lastResult: MapClickResult | null;
|
||||||
|
locked: boolean;
|
||||||
|
onCellClick: (cell: Cell) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
background: "#101a12",
|
||||||
|
grid: "#2c4531",
|
||||||
|
gridMajor: "#3d5c43",
|
||||||
|
text: "#8fb39a",
|
||||||
|
hill: "#a9805a",
|
||||||
|
water: "#4f83b8",
|
||||||
|
ruins: "#9aa0a6",
|
||||||
|
tower: "#c9c9c9",
|
||||||
|
bridge: "#b58a4e",
|
||||||
|
copse: "#4e8c4a",
|
||||||
|
start: "#f4d35e",
|
||||||
|
correct: "#4ade80",
|
||||||
|
wrong: "#f87171",
|
||||||
|
};
|
||||||
|
|
||||||
|
function featureColor(type: MapFeatureType): string {
|
||||||
|
switch (type) {
|
||||||
|
case "hill":
|
||||||
|
return COLORS.hill;
|
||||||
|
case "lake":
|
||||||
|
return COLORS.water;
|
||||||
|
case "ruins":
|
||||||
|
return COLORS.ruins;
|
||||||
|
case "tower":
|
||||||
|
return COLORS.tower;
|
||||||
|
case "bridge":
|
||||||
|
return COLORS.bridge;
|
||||||
|
case "copse":
|
||||||
|
return COLORS.copse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type MapFeatureType = LandNavCourse["features"][number]["type"];
|
||||||
|
|
||||||
|
export function LandNavMap({ course, leg, lastResult, locked, onCellClick }: LandNavMapProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [hover, setHover] = useState<Cell | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
const width = wrap.clientWidth;
|
||||||
|
const cell = width / course.cols;
|
||||||
|
const height = cell * course.rows;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = width * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
canvas.style.width = `${width}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.fillStyle = COLORS.background;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
ctx.font = "10px ui-monospace, monospace";
|
||||||
|
ctx.fillStyle = COLORS.text;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
for (let c = 0; c < course.cols; c += 1) {
|
||||||
|
const label = String(course.easting0 + c).padStart(3, "0");
|
||||||
|
ctx.fillText(label, (c + 0.5) * cell, 11);
|
||||||
|
}
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
for (let r = 0; r < course.rows; r += 1) {
|
||||||
|
const label = String(course.northing0 + r).padStart(3, "0");
|
||||||
|
ctx.fillText(label, 3, (r + 0.5) * cell + 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.strokeStyle = COLORS.grid;
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let c = 0; c <= course.cols; c += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(c * cell + 0.5, 0);
|
||||||
|
ctx.lineTo(c * cell + 0.5, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let r = 0; r <= course.rows; r += 1) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, r * cell + 0.5);
|
||||||
|
ctx.lineTo(width, r * cell + 0.5);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of course.features) {
|
||||||
|
const cx = (feature.col + 0.5) * cell;
|
||||||
|
const cy = (feature.row + 0.5) * cell;
|
||||||
|
const color = featureColor(feature.type);
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
switch (feature.type) {
|
||||||
|
case "hill":
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(cx, cy - cell * 0.22);
|
||||||
|
ctx.lineTo(cx - cell * 0.26, cy + cell * 0.16);
|
||||||
|
ctx.lineTo(cx + cell * 0.26, cy + cell * 0.16);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
case "lake":
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.ellipse(cx, cy, cell * 0.26, cell * 0.17, 0, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
case "ruins":
|
||||||
|
ctx.fillRect(cx - cell * 0.2, cy - cell * 0.12, cell * 0.16, cell * 0.16);
|
||||||
|
ctx.fillRect(cx - cell * 0.02, cy - cell * 0.02, cell * 0.16, cell * 0.16);
|
||||||
|
ctx.fillRect(cx + cell * 0.08, cy - cell * 0.22, cell * 0.12, cell * 0.12);
|
||||||
|
break;
|
||||||
|
case "tower":
|
||||||
|
ctx.fillRect(cx - cell * 0.05, cy - cell * 0.26, cell * 0.1, cell * 0.42);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy - cell * 0.3, cell * 0.07, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
break;
|
||||||
|
case "bridge":
|
||||||
|
ctx.fillRect(cx - cell * 0.28, cy - cell * 0.06, cell * 0.56, cell * 0.12);
|
||||||
|
break;
|
||||||
|
case "copse":
|
||||||
|
for (const [dx, dy] of [
|
||||||
|
[-0.14, -0.08],
|
||||||
|
[0.12, -0.12],
|
||||||
|
[0, 0.1],
|
||||||
|
]) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx + dx * cell, cy + dy * cell, cell * 0.11, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
ctx.fillStyle = "rgba(0, 0, 0, 0.55)";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.font = "9px ui-sans-serif, system-ui";
|
||||||
|
const label = feature.name;
|
||||||
|
ctx.fillText(label, cx, cy + cell * 0.38, cell * 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leg && (leg.kind === "azimuth" || leg.kind === "declination") && leg.startCell) {
|
||||||
|
const sx = (leg.startCell.col + 0.5) * cell;
|
||||||
|
const sy = (leg.startCell.row + 0.5) * cell;
|
||||||
|
ctx.fillStyle = COLORS.start;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(sx, sy, cell * 0.18, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
if (leg.kind === "azimuth") {
|
||||||
|
ctx.strokeStyle = COLORS.start;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.setLineDash([4, 4]);
|
||||||
|
const radians = ((leg.azimuth ?? 0) * Math.PI) / 180;
|
||||||
|
const ray = cell * 1.6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(sx, sy);
|
||||||
|
ctx.lineTo(sx + Math.sin(radians) * ray, sy - Math.cos(radians) * ray);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hover && !locked) {
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(hover.col * cell + 1, hover.row * cell + 1, cell - 2, cell - 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastResult) {
|
||||||
|
ctx.fillStyle = lastResult.correct ? COLORS.correct : COLORS.wrong;
|
||||||
|
ctx.globalAlpha = 0.35;
|
||||||
|
ctx.fillRect(lastResult.cell.col * cell, lastResult.cell.row * cell, cell, cell);
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
ctx.strokeStyle = lastResult.correct ? COLORS.correct : COLORS.wrong;
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeRect(
|
||||||
|
lastResult.cell.col * cell + 1,
|
||||||
|
lastResult.cell.row * cell + 1,
|
||||||
|
cell - 2,
|
||||||
|
cell - 2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
const observer = new ResizeObserver(draw);
|
||||||
|
observer.observe(wrap);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [course, leg, lastResult, hover, locked]);
|
||||||
|
|
||||||
|
const cellFromEvent = (clientX: number, clientY: number): Cell | null => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return null;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const colSize = rect.width / course.cols;
|
||||||
|
const rowSize = rect.height / course.rows;
|
||||||
|
const col = Math.floor((clientX - rect.left) / colSize);
|
||||||
|
const row = Math.floor((clientY - rect.top) / rowSize);
|
||||||
|
if (col < 0 || col >= course.cols || row < 0 || row >= course.rows) return null;
|
||||||
|
return { col, row };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapRef} className="w-full">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
role="grid"
|
||||||
|
aria-label="Land navigation map"
|
||||||
|
className={`block w-full rounded-lg border border-border ${locked ? "cursor-default" : "cursor-crosshair"}`}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
if (locked) return;
|
||||||
|
setHover(cellFromEvent(e.clientX, e.clientY));
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (locked) return;
|
||||||
|
const cell = cellFromEvent(e.clientX, e.clientY);
|
||||||
|
if (cell) onCellClick(cell);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-center text-[10px] text-muted-foreground">
|
||||||
|
Symbols: {featureTypeLabel("hill")} triangle, {featureTypeLabel("lake")} oval,{" "}
|
||||||
|
{featureTypeLabel("ruins")} blocks, {featureTypeLabel("tower")} mast,{" "}
|
||||||
|
{featureTypeLabel("bridge")} bar, {featureTypeLabel("copse")} clusters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue