/** * Aim Trainer (Qualification Course) — deterministic, pure game-rules module. * * Framework-free and client-safe: no React, Next.js, or Payload imports, no * side effects. All randomness is injected via an `rng` callback so every * function is reproducible and unit-testable. */ export type AimTrainerDifficulty = "recruit" | "veteran" | "elite"; export interface AimTrainerTierConfig { readonly torsoW: number; readonly torsoH: number; readonly headRadius: number; readonly movingRatio: number; readonly speed: number; readonly maxConcurrentTargets: number; readonly targetLifetimeMs: number; readonly replacementDelayMs: number; } export const AIM_TRAINER_DIFFICULTIES: Readonly< Record > = { recruit: { torsoW: 76, torsoH: 118, headRadius: 21, movingRatio: 0.25, speed: 80, maxConcurrentTargets: 1, targetLifetimeMs: 2000, replacementDelayMs: 400, }, veteran: { torsoW: 62, torsoH: 100, headRadius: 18, movingRatio: 0.55, speed: 140, maxConcurrentTargets: 2, targetLifetimeMs: 1500, replacementDelayMs: 300, }, elite: { torsoW: 50, torsoH: 82, headRadius: 15, movingRatio: 0.8, speed: 220, maxConcurrentTargets: 2, targetLifetimeMs: 1100, replacementDelayMs: 200, }, }; export const COURSE_DURATION_MS = 30_000; export const TORSO_POINTS = 100; export const HEADSHOT_POINTS = 250; export const ACCURACY_BONUS_MAX = 500; export const POINTS_PER_XP = 500; /** XP awarded per full POINTS_PER_XP score block — one XP per 4,000 score. */ export const SCORE_XP_RATE = 0.125; export const COMPLETION_XP = 1; export const NEW_TIER_RECORD_XP = 2; export const XP_CAP = 50; export const MAX_SHOTS_FIRED = 500; export const MAX_HITS = 180; export const MAX_DESPAWNED_TARGETS = 60; const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"]; export function isAimTrainerDifficulty(value: unknown): value is AimTrainerDifficulty { return ( typeof value === "string" && (DIFFICULTY_VALUES as readonly string[]).includes(value) ); } export function calculateAimTrainerAccuracy( hits: number, shotsFired: number, despawnedUnhitTargets: number, ): number { const denominator = shotsFired + despawnedUnhitTargets; if (denominator === 0) return 0; return Math.min(1, Math.max(0, hits / denominator)); } export function calculateAimTrainerScore( headshots: number, torsoHits: number, shotsFired: number, despawnedUnhitTargets: number, ): number { const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets); return ( headshots * HEADSHOT_POINTS + torsoHits * TORSO_POINTS + Math.round(accuracy * ACCURACY_BONUS_MAX) ); } export function calculateAimTrainerXp(score: number, isNewTierBest: boolean): number { const xp = COMPLETION_XP + Math.floor(score / POINTS_PER_XP) * SCORE_XP_RATE + (isNewTierBest ? NEW_TIER_RECORD_XP : 0); return Math.min(XP_CAP, xp); } export interface AimTrainerTarget { readonly x: number; readonly y: number; readonly vx: number; readonly vy: number; readonly moving: boolean; readonly torsoW: number; readonly torsoH: number; readonly headRadius: number; } export interface AimTrainerTargetHit { readonly index: number; readonly zone: "head" | "torso"; } /** * Create a target whose whole body (torso rectangle plus the circular head * sitting above it) stays inside the canvas. The head circle is centered at * `(x, y - headRadius)` and the torso spans `[y, y + torsoH]`, so the target's * vertical extent is `[y - 2*headRadius, y + torsoH]`. */ export function createAimTrainerTarget( rng: () => number, canvasW: number, canvasH: number, difficulty: AimTrainerDifficulty, ): AimTrainerTarget { const config = AIM_TRAINER_DIFFICULTIES[difficulty]; const minX = config.torsoW / 2; const maxX = canvasW - config.torsoW / 2; const minY = 2 * config.headRadius; const maxY = canvasH - config.torsoH; const x = minX + rng() * (maxX - minX); const y = minY + rng() * (maxY - minY); const moving = rng() < config.movingRatio; if (!moving) { return { x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }; } const angle = rng() * 2 * Math.PI; const vx = Math.cos(angle) * config.speed; const vy = Math.sin(angle) * config.speed; return { x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }; } /** * Advance a target by `dt` seconds, bouncing it off the playable bounds so the * whole body stays inside the canvas. Pure: returns a new target, never mutates * the input. */ export function stepAimTrainerTarget( target: AimTrainerTarget, dt: number, canvasW: number, canvasH: number, ): AimTrainerTarget { if (!target.moving) return target; let x = target.x + target.vx * dt; let y = target.y + target.vy * dt; let vx = target.vx; let vy = target.vy; const minX = target.torsoW / 2; const maxX = canvasW - target.torsoW / 2; const minY = 2 * target.headRadius; const maxY = canvasH - target.torsoH; if (x < minX) { x = minX; vx = -vx; } else if (x > maxX) { x = maxX; vx = -vx; } if (y < minY) { y = minY; vy = -vy; } else if (y > maxY) { y = maxY; vy = -vy; } return { ...target, x, y, vx, vy }; } /** * Resolve a click at `(x, y)` against a target. The circular head zone is * checked first, then the torso rectangle. Returns `null` for a miss. */ export function resolveAimTrainerHitZone( target: AimTrainerTarget, x: number, y: number, ): "head" | "torso" | null { const headCx = target.x; const headCy = target.y - target.headRadius; const dx = x - headCx; const dy = y - headCy; if (dx * dx + dy * dy <= target.headRadius * target.headRadius) { return "head"; } const halfW = target.torsoW / 2; if ( x >= target.x - halfW && x <= target.x + halfW && y >= target.y && y <= target.y + target.torsoH ) { return "torso"; } return null; } /** * Resolve a click against the topmost matching target. Later targets are drawn * above earlier targets, so overlapping targets are checked from last to first. */ export function resolveAimTrainerTargetHit( targets: readonly AimTrainerTarget[], x: number, y: number, ): AimTrainerTargetHit | null { for (let targetIndex = targets.length - 1; targetIndex >= 0; targetIndex -= 1) { const target = targets[targetIndex]; if (!target) continue; const zone = resolveAimTrainerHitZone(target, x, y); if (zone) return { index: targetIndex, zone }; } return null; }