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; civilianHits: 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 civilianHits: number; readonly despawnedUnhitTargets: number; }; export function createAimTrainerGameState(): AimTrainerGameState { return { targets: [], pendingSpawnAts: [], startedAt: 0, headshots: 0, torsoHits: 0, civilianHits: 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, civilianHits: 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, game.civilianHits, ), accuracy: calculateAimTrainerAccuracy(hits, game.shotsFired, game.despawnedUnhitTargets), shotsFired: game.shotsFired, headshots: game.headshots, torsoHits: game.torsoHits, civilianHits: game.civilianHits, 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); } const STANDARD_TARGET_PALETTE = { torsoStops: ["#9b956d", "#706f52", "#4d5542"] as const, lowerShade: "rgba(29, 38, 30, 0.42)", straps: "rgba(217, 207, 151, 0.32)", torsoOutline: "rgba(226, 217, 166, 0.6)", headFill: "#afa678", headShade: "rgba(48, 55, 40, 0.48)", headOutline: "rgba(226, 217, 166, 0.72)", crosshair: "rgba(235, 226, 175, 0.32)", boundsBox: "rgba(211, 202, 149, 0.12)", }; const CIVILIAN_TARGET_PALETTE = { torsoStops: ["#8fb4dd", "#5b87b8", "#3a6191"] as const, lowerShade: "rgba(16, 32, 52, 0.42)", straps: "rgba(235, 245, 255, 0.35)", torsoOutline: "rgba(190, 215, 240, 0.6)", headFill: "#b7d0ec", headShade: "rgba(30, 58, 92, 0.48)", headOutline: "rgba(190, 215, 240, 0.72)", crosshair: "rgba(215, 235, 255, 0.35)", boundsBox: "rgba(167, 197, 232, 0.14)", }; function drawTarget( ctx: CanvasRenderingContext2D, activeTarget: AimTrainerActiveTarget, frame: AimTrainerSceneFrame, ): void { const { target, spawnedAt } = activeTarget; const palette = target.kind === "civilian" ? CIVILIAN_TARGET_PALETTE : STANDARD_TARGET_PALETTE; 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, palette.torsoStops[0]); torso.addColorStop(0.5, palette.torsoStops[1]); torso.addColorStop(1, palette.torsoStops[2]); ctx.fillStyle = torso; ctx.fillRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH); ctx.fillStyle = palette.lowerShade; ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46); ctx.fillStyle = palette.straps; 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 = palette.torsoOutline; ctx.lineWidth = 1.5; ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH); ctx.fillStyle = palette.headFill; ctx.beginPath(); ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = palette.headShade; ctx.beginPath(); ctx.arc(0, -target.headRadius - 2, target.headRadius * 0.72, Math.PI, Math.PI * 2); ctx.fill(); ctx.strokeStyle = palette.headOutline; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = palette.crosshair; 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 = palette.boundsBox; 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); } }