diff --git a/src/components/frontend/qualification/AimTrainerArena.tsx b/src/components/frontend/qualification/AimTrainerArena.tsx new file mode 100644 index 0000000..0c367c6 --- /dev/null +++ b/src/components/frontend/qualification/AimTrainerArena.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { + AIM_TRAINER_DIFFICULTIES, + isAimTrainerDifficulty, + type AimTrainerDifficulty, +} from "@/lib/aim-trainer"; +import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions"; +import { useRealtimePresence } from "@/hooks/useRealtimePresence"; +import { useSidebar } from "@/components/ui/sidebar"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { cn } from "@/lib/utils"; +import { AimTrainerGame, type AimTrainerPhase } from "./AimTrainerGame"; +import { AimTrainerLeaderboard } from "./AimTrainerLeaderboard"; + +const TIER_OPTIONS = [ + { value: "recruit", label: "Recruit", detail: "Steady silhouettes" }, + { value: "veteran", label: "Veteran", detail: "Moving range" }, + { value: "elite", label: "Elite", detail: "High-speed range" }, +] as const satisfies ReadonlyArray<{ + readonly value: AimTrainerDifficulty; + readonly label: string; + readonly detail: string; +}>; + +const TIER_STAT_KEYS = { + recruit: { bestScore: "recruitBestScore", bestAccuracy: "recruitBestAccuracy" }, + veteran: { bestScore: "veteranBestScore", bestAccuracy: "veteranBestAccuracy" }, + elite: { bestScore: "eliteBestScore", bestAccuracy: "eliteBestAccuracy" }, +} as const satisfies Record< + AimTrainerDifficulty, + { + readonly bestScore: "recruitBestScore" | "veteranBestScore" | "eliteBestScore"; + readonly bestAccuracy: "recruitBestAccuracy" | "veteranBestAccuracy" | "eliteBestAccuracy"; + } +>; + +export function AimTrainerArena({ + initialStats, + currentUserId, +}: { + readonly initialStats: AimTrainerStats; + readonly currentUserId: number | string; +}) { + const [stats, setStats] = useState(initialStats); + const [refreshKey, setRefreshKey] = useState(0); + const [difficulty, setDifficulty] = useState("recruit"); + const [playing, setPlaying] = useState(false); + const { isMobile, setOpenMobile } = useSidebar(); + const activeTrainees = useRealtimePresence("qualification", playing, true); + + const handlePhaseChange = useCallback((nextPhase: AimTrainerPhase) => { + setPlaying(nextPhase === "playing"); + }, []); + + const handleGameRecorded = useCallback((nextStats: AimTrainerStats) => { + setStats(nextStats); + setRefreshKey((key) => key + 1); + }, []); + + useEffect(() => { + if (playing && isMobile) setOpenMobile(false); + }, [isMobile, playing, setOpenMobile]); + + return ( +
+ + + Choose a qualification lane + + Each course lasts 30 seconds. Accuracy keeps your score climbing when the range gets + busy. + + + +
+ { + if (isAimTrainerDifficulty(value)) setDifficulty(value); + }} + aria-label="Qualification difficulty" + className="grid gap-2 md:grid-cols-3" + > + {TIER_OPTIONS.map((option) => { + const statKeys = TIER_STAT_KEYS[option.value]; + const optionStats = { + bestScore: stats[statKeys.bestScore], + bestAccuracy: stats[statKeys.bestAccuracy], + }; + return ( + + ); + })} + +
+
+
+ + + + + + Qualification protocol + + +
+ Acquire + + Up to {AIM_TRAINER_DIFFICULTIES[difficulty].maxConcurrentTargets} silhouettes are + active at once. Moving targets rebound inside the range. + +
+
+ Prioritize + Headshots score 250 points. Torso hits score 100 points. +
+
+ Qualify + + The course adds an accuracy bonus and records the best score for each lane. Current + target window: {AIM_TRAINER_DIFFICULTIES[difficulty].targetLifetimeMs / 1000}s. + +
+
+
+ + +
+ ); +} diff --git a/src/components/frontend/qualification/AimTrainerCanvas.ts b/src/components/frontend/qualification/AimTrainerCanvas.ts new file mode 100644 index 0000000..a406f2e --- /dev/null +++ b/src/components/frontend/qualification/AimTrainerCanvas.ts @@ -0,0 +1,259 @@ +import { + calculateAimTrainerAccuracy, + calculateAimTrainerScore, + COURSE_DURATION_MS, + type AimTrainerTarget, +} from "@/lib/aim-trainer"; + +export const AIM_TRAINER_CANVAS_WIDTH = 720; +export const AIM_TRAINER_CANVAS_HEIGHT = 480; +export const AIM_TRAINER_FEEDBACK_DURATION_MS = 700; +export const AIM_TRAINER_FLASH_DURATION_MS = 150; +export const AIM_TRAINER_TARGET_FADE_DURATION_MS = 250; + +const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000; + +export type AimTrainerPointFeedback = { + readonly x: number; + readonly y: number; + readonly text: string; + readonly color: string; + readonly createdAt: number; +}; + +export type AimTrainerActiveTarget = { + readonly target: AimTrainerTarget; + readonly spawnedAt: number; +}; + +type AimTrainerSceneFrame = { + readonly now: number; + readonly targetLifetimeMs: number; +}; + +export type AimTrainerGameState = { + targets: AimTrainerActiveTarget[]; + pendingSpawnAts: number[]; + startedAt: number; + headshots: number; + torsoHits: number; + shotsFired: number; + despawnedUnhitTargets: number; + feedback: AimTrainerPointFeedback[]; + flashColor: string; + flashUntil: number; + lastDisplayedSecond: number; +}; + +export type AimTrainerLiveStats = { + readonly score: number; + readonly accuracy: number; + readonly shotsFired: number; + readonly headshots: number; + readonly torsoHits: number; + readonly despawnedUnhitTargets: number; +}; + +export function createAimTrainerGameState(): AimTrainerGameState { + return { + targets: [], + pendingSpawnAts: [], + startedAt: 0, + headshots: 0, + torsoHits: 0, + shotsFired: 0, + despawnedUnhitTargets: 0, + feedback: [], + flashColor: "#d9d19b", + flashUntil: 0, + lastDisplayedSecond: COURSE_DURATION_SECONDS, + }; +} + +export function emptyAimTrainerLiveStats(): AimTrainerLiveStats { + return { + score: 0, + accuracy: 0, + shotsFired: 0, + headshots: 0, + torsoHits: 0, + despawnedUnhitTargets: 0, + }; +} + +export function getAimTrainerLiveStats(game: AimTrainerGameState): AimTrainerLiveStats { + const hits = game.headshots + game.torsoHits; + return { + score: calculateAimTrainerScore( + game.headshots, + game.torsoHits, + game.shotsFired, + game.despawnedUnhitTargets, + ), + accuracy: calculateAimTrainerAccuracy(hits, game.shotsFired, game.despawnedUnhitTargets), + shotsFired: game.shotsFired, + headshots: game.headshots, + torsoHits: game.torsoHits, + despawnedUnhitTargets: game.despawnedUnhitTargets, + }; +} + +function drawRange(ctx: CanvasRenderingContext2D): void { + const range = ctx.createLinearGradient(0, 0, 0, AIM_TRAINER_CANVAS_HEIGHT); + range.addColorStop(0, "#1a2422"); + range.addColorStop(0.55, "#26332c"); + range.addColorStop(1, "#111916"); + ctx.fillStyle = range; + ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT); + + ctx.strokeStyle = "rgba(174, 167, 117, 0.1)"; + ctx.lineWidth = 1; + for (let x = 0; x <= AIM_TRAINER_CANVAS_WIDTH; x += 40) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, AIM_TRAINER_CANVAS_HEIGHT); + ctx.stroke(); + } + for (let y = 0; y <= AIM_TRAINER_CANVAS_HEIGHT; y += 40) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(AIM_TRAINER_CANVAS_WIDTH, y); + ctx.stroke(); + } + + const horizon = AIM_TRAINER_CANVAS_HEIGHT * 0.68; + ctx.fillStyle = "rgba(8, 13, 11, 0.38)"; + ctx.fillRect(0, horizon, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT - horizon); + ctx.strokeStyle = "rgba(202, 193, 139, 0.2)"; + ctx.setLineDash([12, 12]); + ctx.beginPath(); + ctx.moveTo(0, horizon); + ctx.lineTo(AIM_TRAINER_CANVAS_WIDTH, horizon); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = "rgba(201, 192, 142, 0.18)"; + ctx.font = "11px ui-monospace, monospace"; + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + ctx.fillText("RANGE 04 // LIVE FIRE", 18, 16); + ctx.textAlign = "right"; + ctx.fillText("QUALIFICATION COURSE", AIM_TRAINER_CANVAS_WIDTH - 18, 16); +} + +function drawTarget( + ctx: CanvasRenderingContext2D, + activeTarget: AimTrainerActiveTarget, + frame: AimTrainerSceneFrame, +): void { + const { target, spawnedAt } = activeTarget; + const age = Math.max(0, frame.now - spawnedAt); + const fadeDuration = Math.min( + AIM_TRAINER_TARGET_FADE_DURATION_MS, + frame.targetLifetimeMs / 5, + ); + const fadeStart = frame.targetLifetimeMs - fadeDuration; + const fadeProgress = + fadeDuration > 0 ? Math.min(1, Math.max(0, (age - fadeStart) / fadeDuration)) : 0; + const pulse = 1 + Math.sin(frame.now / 150) * 0.025; + const torsoX = target.x - (target.torsoW * pulse) / 2; + + ctx.save(); + ctx.globalAlpha = 1 - fadeProgress; + ctx.save(); + ctx.translate(target.x, target.y); + ctx.shadowColor = "rgba(0, 0, 0, 0.45)"; + ctx.shadowBlur = 18; + ctx.shadowOffsetY = 8; + ctx.fillStyle = "#0d1411"; + ctx.fillRect(-target.torsoW / 2 - 3, 3, target.torsoW + 6, target.torsoH + 4); + ctx.beginPath(); + ctx.arc(0, -target.headRadius, target.headRadius + 3, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowColor = "transparent"; + + const torso = ctx.createLinearGradient(-target.torsoW / 2, 0, target.torsoW / 2, target.torsoH); + torso.addColorStop(0, "#9b956d"); + torso.addColorStop(0.5, "#706f52"); + torso.addColorStop(1, "#4d5542"); + ctx.fillStyle = torso; + ctx.fillRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH); + ctx.fillStyle = "rgba(29, 38, 30, 0.42)"; + ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46); + ctx.fillStyle = "rgba(217, 207, 151, 0.32)"; + ctx.fillRect(-target.torsoW * 0.28, target.torsoH * 0.16, target.torsoW * 0.56, 3); + ctx.fillRect(-target.torsoW * 0.28, target.torsoH * 0.42, target.torsoW * 0.56, 3); + ctx.strokeStyle = "rgba(226, 217, 166, 0.6)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH); + + ctx.fillStyle = "#afa678"; + ctx.beginPath(); + ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(48, 55, 40, 0.48)"; + ctx.beginPath(); + ctx.arc(0, -target.headRadius - 2, target.headRadius * 0.72, Math.PI, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(226, 217, 166, 0.72)"; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2); + ctx.stroke(); + + ctx.strokeStyle = "rgba(235, 226, 175, 0.32)"; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, -target.headRadius - target.headRadius - 9); + ctx.lineTo(0, -target.headRadius - target.headRadius - 1); + ctx.moveTo(-target.headRadius - 9, -target.headRadius); + ctx.lineTo(-target.headRadius - 1, -target.headRadius); + ctx.moveTo(target.headRadius + 1, -target.headRadius); + ctx.lineTo(target.headRadius + 9, -target.headRadius); + ctx.stroke(); + ctx.restore(); + + ctx.strokeStyle = "rgba(211, 202, 149, 0.12)"; + ctx.setLineDash([4, 5]); + ctx.strokeRect(torsoX, target.y, target.torsoW * pulse, target.torsoH); + ctx.setLineDash([]); + ctx.restore(); +} + +function drawFeedback( + ctx: CanvasRenderingContext2D, + feedback: readonly AimTrainerPointFeedback[], + now: number, +): void { + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = "bold 18px ui-monospace, monospace"; + for (const item of feedback) { + const age = now - item.createdAt; + const progress = Math.min(1, age / AIM_TRAINER_FEEDBACK_DURATION_MS); + ctx.globalAlpha = 1 - progress; + ctx.fillStyle = item.color; + ctx.fillText(item.text, item.x, item.y - progress * 28); + } + ctx.globalAlpha = 1; +} + +export function drawAimTrainerScene( + ctx: CanvasRenderingContext2D, + game: AimTrainerGameState, + frame: AimTrainerSceneFrame, +): void { + drawRange(ctx); + for (const activeTarget of game.targets) { + drawTarget(ctx, activeTarget, frame); + } + drawFeedback(ctx, game.feedback, frame.now); + if (game.flashUntil > frame.now) { + ctx.fillStyle = `${game.flashColor}${Math.round( + ((game.flashUntil - frame.now) / AIM_TRAINER_FLASH_DURATION_MS) * 28, + ) + .toString(16) + .padStart(2, "0")}`; + ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT); + } +} diff --git a/src/components/frontend/qualification/AimTrainerGame.tsx b/src/components/frontend/qualification/AimTrainerGame.tsx new file mode 100644 index 0000000..6d6fe01 --- /dev/null +++ b/src/components/frontend/qualification/AimTrainerGame.tsx @@ -0,0 +1,664 @@ +"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 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 !== "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, + 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}. +
+
+
+ ); +} diff --git a/src/components/frontend/qualification/sounds.ts b/src/components/frontend/qualification/sounds.ts new file mode 100644 index 0000000..f38676d --- /dev/null +++ b/src/components/frontend/qualification/sounds.ts @@ -0,0 +1,145 @@ +declare global { + interface Window { + webkitAudioContext?: typeof AudioContext; + } +} + +let audioCtx: AudioContext | null = null; + +function getCtx(): AudioContext | null { + if (typeof window === "undefined") return null; + const AudioCtor = window.AudioContext ?? window.webkitAudioContext; + if (!AudioCtor) return null; + if (!audioCtx) audioCtx = new AudioCtor(); + if (audioCtx.state === "suspended") void audioCtx.resume(); + return audioCtx; +} + +type ToneOptions = { + readonly frequency: number; + readonly endFrequency?: number; + readonly duration: number; + readonly type?: OscillatorType; + readonly volume?: number; + readonly delay?: number; +}; + +function playTone(ctx: AudioContext, options: ToneOptions): void { + const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = options; + const oscillator = ctx.createOscillator(); + const gain = ctx.createGain(); + const startAt = ctx.currentTime + delay; + oscillator.type = type; + oscillator.frequency.setValueAtTime(Math.max(frequency, 1), startAt); + if (endFrequency !== undefined && endFrequency !== frequency) { + oscillator.frequency.exponentialRampToValueAtTime( + Math.max(endFrequency, 1), + startAt + duration, + ); + } + gain.gain.setValueAtTime(0.0001, startAt); + gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.012); + gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration); + oscillator.connect(gain); + gain.connect(ctx.destination); + oscillator.start(startAt); + oscillator.stop(startAt + duration + 0.05); +} + +function playNoise( + ctx: AudioContext, + options: { readonly duration: number; readonly volume?: number; readonly lowpass?: number }, +): void { + const { duration, volume = 0.2, lowpass = 800 } = options; + const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration)); + const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); + const data = buffer.getChannelData(0); + for (let index = 0; index < bufferSize; index += 1) { + data[index] = Math.random() * 2 - 1; + } + const source = ctx.createBufferSource(); + source.buffer = buffer; + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(lowpass, ctx.currentTime); + const gain = ctx.createGain(); + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(volume, ctx.currentTime + 0.005); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration); + source.connect(filter); + filter.connect(gain); + gain.connect(ctx.destination); + source.start(); +} + +export function playFire(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { + frequency: 180, + endFrequency: 90, + duration: 0.055, + type: "square", + volume: 0.12, + }); +} + +export function playHeadshot(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { + frequency: 720, + endFrequency: 1180, + duration: 0.12, + type: "triangle", + volume: 0.16, + }); + playTone(ctx, { frequency: 1040, duration: 0.16, type: "sine", volume: 0.12, delay: 0.06 }); +} + +export function playTorsoHit(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { + frequency: 360, + endFrequency: 220, + duration: 0.1, + type: "triangle", + volume: 0.15, + }); +} + +export function playMiss(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { + frequency: 120, + endFrequency: 55, + duration: 0.18, + type: "sawtooth", + volume: 0.14, + }); + playNoise(ctx, { duration: 0.11, volume: 0.11, lowpass: 480 }); +} + +export function playCountdownTick(critical = false): void { + const ctx = getCtx(); + if (!ctx) return; + const frequency = critical ? 880 : 520; + playTone(ctx, { frequency, duration: critical ? 0.09 : 0.06, type: "square", volume: 0.1 }); +} + +export function playRoundComplete(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 420, duration: 0.12, type: "triangle", volume: 0.14 }); + playTone(ctx, { frequency: 630, duration: 0.12, type: "triangle", volume: 0.14, delay: 0.1 }); + playTone(ctx, { frequency: 940, duration: 0.24, type: "sine", volume: 0.16, delay: 0.2 }); +} + +export function playXp(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 }); + playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 }); +}