"use client"; // allow: SIZE_OK — the course state machine keeps its rAF loop and submission lifecycle together. import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; import { useRouter } from "next/navigation"; import { Crosshair, RotateCcw, Target, Timer, Trophy } from "lucide-react"; import { AIM_TRAINER_DIFFICULTIES, calculateAimTrainerAccuracy, calculateAimTrainerScore, COURSE_DURATION_MS, HEADSHOT_POINTS, createAimTrainerTarget, resolveAimTrainerTargetHit, stepAimTrainerTarget, TORSO_POINTS, type AimTrainerDifficulty, } from "@/lib/aim-trainer"; import { type AimTrainerResultInput, type AimTrainerStats, submitAimTrainerResult, } from "@/app/(frontend)/qualification/actions"; import { LoginLink } from "@/components/frontend/auth/LoginLink"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick"; import { useSessionExpired } from "@/hooks/useSessionExpired"; import { cn } from "@/lib/utils"; import { AIM_TRAINER_CANVAS_HEIGHT as CANVAS_H, AIM_TRAINER_CANVAS_WIDTH as CANVAS_W, AIM_TRAINER_FEEDBACK_DURATION_MS as FEEDBACK_DURATION_MS, AIM_TRAINER_FLASH_DURATION_MS as FLASH_DURATION_MS, createAimTrainerGameState, drawAimTrainerScene, emptyAimTrainerLiveStats, getAimTrainerLiveStats, type AimTrainerActiveTarget, type AimTrainerGameState, type AimTrainerLiveStats, } from "./AimTrainerCanvas"; import { playCountdownTick, playFire, playHeadshot, playMiss, playRoundComplete, playTorsoHit, playXp, } from "./sounds"; const FINAL_COUNTDOWN_WINDOW_MS = 10_000; const CRITICAL_COUNTDOWN_WINDOW_MS = 3_000; const MAX_FRAME_DT_SEC = 0.05; const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000; const TIER_META = { recruit: { label: "Recruit", description: "Stationary silhouettes with a forgiving acquisition window.", }, veteran: { label: "Veteran", description: "A mixed range with faster movement and tighter hit zones.", }, elite: { label: "Elite", description: "High-speed targets. Every sight picture matters.", }, } as const satisfies Record< AimTrainerDifficulty, { readonly label: string; readonly description: string } >; const TIER_SCORE_KEYS = { recruit: "recruitBestScore", veteran: "veteranBestScore", elite: "eliteBestScore", } as const satisfies Record< AimTrainerDifficulty, "recruitBestScore" | "veteranBestScore" | "eliteBestScore" >; export type AimTrainerPhase = "ready" | "playing" | "over"; type LastRun = { readonly hitScore: number; readonly accuracyBonus: number; readonly totalScore: number; readonly accuracy: number; readonly headshots: number; readonly torsoHits: number; readonly shotsFired: number; readonly despawnedUnhitTargets: number; readonly xpEarned: number; readonly bestScore: number; readonly isNewRecord: boolean; readonly error?: string; }; function assertNever(value: never): never { throw new Error(`Unexpected hit zone: ${String(value)}`); } function tierBestScore(stats: AimTrainerStats, difficulty: AimTrainerDifficulty): number { return stats[TIER_SCORE_KEYS[difficulty]]; } function formatAccuracy(value: number): string { return `${Math.round(value * 100)}%`; } function HudMetric({ label, value, urgent = false, }: { label: string; value: string | number; urgent?: boolean; }) { return (
{label} {value}
); } function SummaryMetric({ label, value }: { label: string; value: string | number }) { return (
{label}
{value}
); } export function AimTrainerGame({ initialStats: serverStats, difficulty, onGameRecordedAction, onPhaseChangeAction, }: { readonly initialStats: AimTrainerStats; readonly difficulty: AimTrainerDifficulty; readonly onGameRecordedAction?: (stats: AimTrainerStats) => void; readonly onPhaseChangeAction?: (phase: AimTrainerPhase) => void; }) { const router = useRouter(); const { expired, expiredRef } = useSessionExpired(); const canvasRef = useRef(null); const wrapperRef = useRef(null); const phaseRef = useRef("ready"); const submittingRef = useRef(false); const statsRef = useRef(serverStats); const gameRef = useRef(createAimTrainerGameState()); const [phase, setPhase] = useState("ready"); const [timerSeconds, setTimerSeconds] = useState(COURSE_DURATION_SECONDS); const [live, setLive] = useState(emptyAimTrainerLiveStats); const [stats, setStats] = useState(serverStats); const [lastRun, setLastRun] = useState(null); const [submitting, setSubmitting] = useState(false); const tier = TIER_META[difficulty]; const config = AIM_TRAINER_DIFFICULTIES[difficulty]; const setPhaseSafe = useCallback( (nextPhase: AimTrainerPhase) => { phaseRef.current = nextPhase; setPhase(nextPhase); onPhaseChangeAction?.(nextPhase); }, [onPhaseChangeAction], ); const handleRoundComplete = useCallback( async (round: AimTrainerResultInput) => { if (submittingRef.current) return; const hits = round.headshots + round.torsoHits; const accuracy = calculateAimTrainerAccuracy( hits, round.shotsFired, round.despawnedUnhitTargets, ); const totalScore = calculateAimTrainerScore( round.headshots, round.torsoHits, round.shotsFired, round.despawnedUnhitTargets, ); const hitScore = round.headshots * HEADSHOT_POINTS + round.torsoHits * TORSO_POINTS; const previousTierBest = tierBestScore(statsRef.current, difficulty); const preview: LastRun = { hitScore, accuracyBonus: Math.max(0, totalScore - hitScore), totalScore, accuracy, headshots: round.headshots, torsoHits: round.torsoHits, shotsFired: round.shotsFired, despawnedUnhitTargets: round.despawnedUnhitTargets, xpEarned: 0, bestScore: previousTierBest, isNewRecord: false, }; setLastRun(preview); if (expiredRef.current) { setLastRun({ ...preview, error: "Your session expired. Result not recorded." }); return; } submittingRef.current = true; setSubmitting(true); try { const result = await submitAimTrainerResult(round); if (result.success && result.data) { statsRef.current = result.data; setStats(result.data); onGameRecordedAction?.(result.data); if (result.data.xpEarned > 0) { playXp(); window.dispatchEvent( new CustomEvent(PROFILE_UPDATE_EVENT, { detail: { reason: "xp", xpGained: result.data.xpEarned }, }), ); window.setTimeout(() => router.refresh(), 1700); } setLastRun({ ...preview, accuracy: result.data.accuracy, accuracyBonus: Math.max(0, result.data.score - hitScore), totalScore: result.data.score, xpEarned: result.data.xpEarned, bestScore: tierBestScore(result.data, difficulty), isNewRecord: result.data.score > previousTierBest, }); } else { setLastRun({ ...preview, error: result.error ?? "Could not record qualification." }); } } catch (error) { const message = error instanceof Error ? error.message : "Could not record qualification."; setLastRun({ ...preview, error: message }); } finally { submittingRef.current = false; setSubmitting(false); } }, [difficulty, expiredRef, onGameRecordedAction, router], ); const handleRoundCompleteRef = useRef(handleRoundComplete); useEffect(() => { handleRoundCompleteRef.current = handleRoundComplete; }, [handleRoundComplete]); const resetToReady = useCallback(() => { if (expiredRef.current || submittingRef.current) return; gameRef.current = createAimTrainerGameState(); setLive(emptyAimTrainerLiveStats()); setTimerSeconds(COURSE_DURATION_SECONDS); setLastRun(null); setPhaseSafe("ready"); }, [expiredRef, setPhaseSafe]); const startGame = useCallback(() => { if (expiredRef.current || submittingRef.current) return; const now = performance.now(); const game = createAimTrainerGameState(); game.pendingSpawnAts = Array.from( { length: config.maxConcurrentTargets }, (_, index) => now + index * config.replacementDelayMs, ); game.startedAt = now; gameRef.current = game; setLive(emptyAimTrainerLiveStats()); setTimerSeconds(COURSE_DURATION_SECONDS); setLastRun(null); setPhaseSafe("playing"); wrapperRef.current?.focus({ preventScroll: true }); }, [config.maxConcurrentTargets, config.replacementDelayMs, expiredRef, setPhaseSafe]); const handlePrimaryAction = useCallback(() => { const currentPhase = phaseRef.current; switch (currentPhase) { case "ready": startGame(); return; case "playing": return; case "over": resetToReady(); return; default: assertNever(currentPhase); } }, [resetToReady, startGame]); const handleRestartAction = useCallback(() => { if (phaseRef.current !== "playing") return; startGame(); }, [startGame]); const handleShot = useCallback( (event: PointerEvent) => { if (event.button !== 0 || phaseRef.current !== "playing") return; if (expiredRef.current || submittingRef.current) return; event.preventDefault(); wrapperRef.current?.focus({ preventScroll: true }); const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * CANVAS_W; const y = ((event.clientY - rect.top) / rect.height) * CANVAS_H; const game = gameRef.current; const now = performance.now(); game.shotsFired += 1; playFire(); const hit = resolveAimTrainerTargetHit( game.targets.map((activeTarget) => activeTarget.target), x, y, ); if (!hit) { playMiss(); game.feedback.push({ x, y, text: "MISS", color: "#b8b49c", createdAt: now }); setLive(getAimTrainerLiveStats(game)); return; } const [activeTarget] = game.targets.splice(hit.index, 1); if (!activeTarget) return; const target = activeTarget.target; game.pendingSpawnAts.push(now + config.replacementDelayMs); switch (hit.zone) { case "head": game.headshots += 1; game.feedback.push({ x: target.x, y: target.y - target.headRadius, text: `+${HEADSHOT_POINTS}`, color: "#f0e6a8", createdAt: now, }); game.flashColor = "#e5d98a"; game.flashUntil = now + FLASH_DURATION_MS; playHeadshot(); break; case "torso": game.torsoHits += 1; game.feedback.push({ x: target.x, y: target.y + target.torsoH * 0.45, text: `+${TORSO_POINTS}`, color: "#c8c59d", createdAt: now, }); game.flashColor = "#aaa878"; game.flashUntil = now + FLASH_DURATION_MS; playTorsoHit(); break; default: assertNever(hit.zone); } setLive(getAimTrainerLiveStats(game)); }, [config.replacementDelayMs, expiredRef], ); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const dpr = window.devicePixelRatio || 1; canvas.width = CANVAS_W * dpr; canvas.height = CANVAS_H * dpr; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); let raf = 0; let lastTime = performance.now(); const finish = () => { const game = gameRef.current; if (phaseRef.current !== "playing") return; game.targets = []; game.pendingSpawnAts = []; setTimerSeconds(0); setLive(getAimTrainerLiveStats(game)); setPhaseSafe("over"); playRoundComplete(); void handleRoundCompleteRef.current({ difficulty, headshots: game.headshots, torsoHits: game.torsoHits, shotsFired: game.shotsFired, despawnedUnhitTargets: game.despawnedUnhitTargets, }); }; const step = (now: number) => { raf = requestAnimationFrame(step); const dtSec = Math.min(Math.max((now - lastTime) / 1000, 0), MAX_FRAME_DT_SEC); lastTime = now; const game = gameRef.current; if (phaseRef.current === "playing") { const elapsed = now - game.startedAt; const remaining = Math.max(0, COURSE_DURATION_MS - elapsed); if (remaining === 0) { finish(); } else { const displayedSecond = remaining <= FINAL_COUNTDOWN_WINDOW_MS ? Math.ceil(remaining / 1000) : COURSE_DURATION_SECONDS; if (displayedSecond !== game.lastDisplayedSecond) { game.lastDisplayedSecond = displayedSecond; setTimerSeconds(displayedSecond); if (remaining <= CRITICAL_COUNTDOWN_WINDOW_MS) playCountdownTick(true); else if (remaining <= FINAL_COUNTDOWN_WINDOW_MS) playCountdownTick(); } const survivingTargets: AimTrainerActiveTarget[] = []; let expiredTargets = 0; for (const activeTarget of game.targets) { if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) { expiredTargets += 1; game.pendingSpawnAts.push(now + config.replacementDelayMs); continue; } survivingTargets.push({ target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H), spawnedAt: activeTarget.spawnedAt, }); } game.targets = survivingTargets; if (expiredTargets > 0) { game.despawnedUnhitTargets += expiredTargets; setLive(getAimTrainerLiveStats(game)); } while (game.targets.length < config.maxConcurrentTargets) { const dueIndex = game.pendingSpawnAts.findIndex((spawnAt) => spawnAt <= now); if (dueIndex === -1) break; game.pendingSpawnAts.splice(dueIndex, 1); game.targets.push({ target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty), spawnedAt: now, }); } } } game.feedback = game.feedback.filter((item) => now - item.createdAt < FEEDBACK_DURATION_MS); drawAimTrainerScene(ctx, game, { now, targetLifetimeMs: config.targetLifetimeMs, }); }; raf = requestAnimationFrame(step); const onKeyDown = (event: KeyboardEvent) => { if (event.code === "KeyR") { if (event.repeat || event.ctrlKey || event.metaKey || event.altKey) return; event.preventDefault(); event.stopPropagation(); handleRestartAction(); return; } if (event.code !== "Enter" && event.code !== "Space") return; if (event.repeat) return; event.preventDefault(); event.stopPropagation(); handlePrimaryAction(); }; const onKeyUp = (event: KeyboardEvent) => { if (event.code !== "Space") return; event.preventDefault(); event.stopPropagation(); }; window.addEventListener("keydown", onKeyDown, { capture: true }); window.addEventListener("keyup", onKeyUp, { capture: true }); return () => { cancelAnimationFrame(raf); window.removeEventListener("keydown", onKeyDown, { capture: true }); window.removeEventListener("keyup", onKeyUp, { capture: true }); }; }, [ config.replacementDelayMs, config.maxConcurrentTargets, config.targetLifetimeMs, difficulty, handlePrimaryAction, handleRestartAction, setPhaseSafe, ]); const timerIsCritical = phase === "playing" && timerSeconds <= 3; const timerLabel = phase === "ready" ? `${COURSE_DURATION_SECONDS}s` : `${timerSeconds}s`; return (
Live-fire qualification {tier.description}
{timerLabel}
{phase === "ready" && (
{tier.label} lane
Ready on the line Acquire every silhouette before it clears the range.
Click the target or press Enter / Space to begin. Course duration:{" "} {COURSE_DURATION_SECONDS}s.
)} {phase === "over" && lastRun && (
{lastRun.isNewRecord && New record!}
{lastRun.shotsFired} shots fired {lastRun.despawnedUnhitTargets} targets expired +{lastRun.xpEarned} XP
{lastRun.error && {lastRun.error}} {submitting && ( Recording result… )} Press Enter or Space to return to the line.
)} {expired && (
Session ending soon Your result will not be recorded after the session expires. Log in again to continue.
)}
Games {stats.gamesPlayed}
Best score {stats.bestScore}
{tier.label} best {tierBestScore(stats, difficulty)}
XP from the course {stats.aimTrainerXp}
Headshots score {HEADSHOT_POINTS}. Torso hits score {TORSO_POINTS}.
); }