"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 && (
Course complete
{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}.
);
}