diff --git a/src/app/(frontend)/land-nav/actions.ts b/src/app/(frontend)/land-nav/actions.ts new file mode 100644 index 0000000..1eb98d8 --- /dev/null +++ b/src/app/(frontend)/land-nav/actions.ts @@ -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 { + 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> { + 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 }; + } +} diff --git a/src/app/(frontend)/land-nav/page.tsx b/src/app/(frontend)/land-nav/page.tsx new file mode 100644 index 0000000..bb80986 --- /dev/null +++ b/src/app/(frontend)/land-nav/page.tsx @@ -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 ( +
+
+
+ +

Land Navigation

+
+

+ Click grid squares, shoot azimuths and identify terrain features. Faster courses score + higher. +

+
+ + +
+ ); +} diff --git a/src/components/frontend/land-nav/LandNavArena.tsx b/src/components/frontend/land-nav/LandNavArena.tsx new file mode 100644 index 0000000..5f5f5d7 --- /dev/null +++ b/src/components/frontend/land-nav/LandNavArena.tsx @@ -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("ready"); + const [difficulty, setDifficulty] = useState("waypoint"); + const [course, setCourse] = useState(null); + const [legIndex, setLegIndex] = useState(0); + const [legsCorrect, setLegsCorrect] = useState(0); + const [seconds, setSeconds] = useState(0); + const [lastResult, setLastResult] = useState(null); + const [feedback, setFeedback] = useState(null); + const [result, setResult] = useState(null); + const [stats, setStats] = useState(initialStats); + const [refreshKey, setRefreshKey] = useState(0); + const submittingRef = useRef(false); + const phaseRef = useRef("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 ( +
+
+
+ Games {stats.gamesPlayed} + Wins {stats.wins} + Losses {stats.losses} + Best {stats.bestScore} +
+ {phase === "ready" && ( + + )} + {phase === "over" && ( + + )} +
+ + {phase === "ready" && ( + + + + + Choose your course + + + + {DIFFICULTY_ORDER.map((key) => { + const cfg = LAND_NAV_DIFFICULTIES[key]; + const selected = difficulty === key; + return ( + + ); + })} + + + )} + + {phase === "playing" && course && leg && config && ( + + + + + Leg {legIndex + 1} of {config.legs} · {config.label} + + + {Math.floor((config.maxSeconds - seconds) / 60)}: + {String((config.maxSeconds - seconds) % 60).padStart(2, "0")} left + + + + +

{leg.prompt}

+ + {leg.kind === "identify" && leg.options && ( +
+ {leg.options.map((option) => ( + + ))} +
+ )} + {feedback && ( +

+ {feedback} +

+ )} +

+ Correct legs: {legsCorrect} · pass at {config.passLegs} +

+
+
+ )} + + {phase === "over" && result && ( + + + + {result.won ? "Course completed" : "Course failed"} + + + +

+ Legs correct: {result.legsCorrect}/{result.legsTotal} +

+

+ Time: {Math.floor(result.seconds / 60)}:{String(result.seconds % 60).padStart(2, "0")} +

+

Score: {result.score}

+

0 ? "font-medium text-emerald-400" : "text-muted-foreground" + } + > + {result.xpEarned > 0 ? `+${result.xpEarned} XP earned` : "No XP earned"} +

+ {feedback &&

{feedback}

} +
+
+ )} + + {expired && ( +

Your session expired. Result not recorded.

+ )} + + +
+ ); +} diff --git a/src/components/frontend/land-nav/LandNavLeaderboard.tsx b/src/components/frontend/land-nav/LandNavLeaderboard.tsx new file mode 100644 index 0000000..ba18d49 --- /dev/null +++ b/src/components/frontend/land-nav/LandNavLeaderboard.tsx @@ -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; + }; + }; +} + +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 = { + waypoint: "bestWaypointSeconds", + route: "bestRouteSeconds", + expedition: "bestExpeditionSeconds", +}; + +const DIFFICULTY_LABELS: Record = { + 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(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 ( +
+
+ +

Land Nav Leaderboard

+
+ + Top 10 · fastest time +
+
+
+ + {activeTeams.length} navigators on the course + + {activeTeams.length > 0 && ( + + ({activeTeams.map((team) => team.name).join(", ")}) + + )} +
+ + {failed ? ( +

Could not load the leaderboard.

+ ) : rows === null ? ( +
+
+
+
+
+ ) : rows.length === 0 ? ( +

+ No recorded courses yet. Be the first to navigate one! +

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((row, index) => ( + + + + + + + + + ))} + +
#OperatorBest TimeGamesWinsXP
+ + {row.isOutsideTopTen ? "-" : index + 1} + + + + {row.name} + {row.isCurrentUser && ( + (you) + )} + {row.isOutsideTopTen && ( + + unranked + + )} + {row.userId !== null && + activeTeams.some((team) => team.id === row.userId) && ( + + )} + + + {formatSeconds(row.bestSeconds)} + + {row.gamesPlayed} + + {row.wins} + + {row.xpEarned} +
+
+ )} +
+ ); +} diff --git a/src/components/frontend/land-nav/LandNavMap.tsx b/src/components/frontend/land-nav/LandNavMap.tsx new file mode 100644 index 0000000..28f724a --- /dev/null +++ b/src/components/frontend/land-nav/LandNavMap.tsx @@ -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(null); + const wrapRef = useRef(null); + const [hover, setHover] = useState(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 ( +
+ { + 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); + }} + /> +

+ Symbols: {featureTypeLabel("hill")} triangle, {featureTypeLabel("lake")} oval,{" "} + {featureTypeLabel("ruins")} blocks, {featureTypeLabel("tower")} mast,{" "} + {featureTypeLabel("bridge")} bar, {featureTypeLabel("copse")} clusters +

+
+ ); +}