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