/** * 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; /** Chance that a standard-target spawn event also puts a civilian on the range. */ readonly civilianChance: 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, civilianChance: 0.2, speed: 80, maxConcurrentTargets: 1, targetLifetimeMs: 2000, replacementDelayMs: 400, }, veteran: { torsoW: 62, torsoH: 100, headRadius: 18, movingRatio: 0.55, civilianChance: 0.3, speed: 140, maxConcurrentTargets: 2, targetLifetimeMs: 1500, replacementDelayMs: 300, }, elite: { torsoW: 50, torsoH: 82, headRadius: 15, movingRatio: 0.8, civilianChance: 0.35, 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 CIVILIAN_PENALTY = 100; /** At most this many civilians are on the range at once. */ export const MAX_CONCURRENT_CIVILIANS = 1; export const MAX_SHOTS_FIRED = 500; export const MAX_HITS = 180; export const MAX_DESPAWNED_TARGETS = 60; export const MAX_CIVILIAN_HITS = 30; 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, civilianHits = 0, ): number { const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets); return Math.max( 0, headshots * HEADSHOT_POINTS + torsoHits * TORSO_POINTS - civilianHits * CIVILIAN_PENALTY + 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 type AimTrainerTargetKind = "standard" | "civilian"; export interface AimTrainerTarget { readonly kind: AimTrainerTargetKind; 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"; } export interface AimTrainerTargetBoundsSource { readonly x: number; readonly y: number; readonly torsoW: number; readonly torsoH: number; readonly headRadius: number; } export interface AimTrainerTargetBounds { readonly left: number; readonly right: number; readonly top: number; readonly bottom: number; } /** * Axis-aligned bounding box covering a target's whole body: the torso * rectangle plus the circular head above it. The head circle is centered at * `(x, y - headRadius)`, so the vertical extent is `[y - 2*headRadius, * y + torsoH]`. */ export function aimTrainerTargetBounds(target: AimTrainerTargetBoundsSource): AimTrainerTargetBounds { const halfW = target.torsoW / 2; return { left: target.x - halfW, right: target.x + halfW, top: target.y - 2 * target.headRadius, bottom: target.y + target.torsoH, }; } /** Whether two targets' bounding boxes overlap. */ export function boundsOverlap(a: AimTrainerTargetBounds, b: AimTrainerTargetBounds): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } export interface CreateAimTrainerTargetOptions { readonly civilian?: boolean; readonly existingTargets?: readonly AimTrainerTarget[]; } /** Spawn position re-rolls before giving up and accepting the last roll. */ const SPAWN_POSITION_ATTEMPTS = 24; /** * 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]`. * * When `existingTargets` is provided, spawn positions overlapping an existing * target's bounding box are re-rolled up to `SPAWN_POSITION_ATTEMPTS` times so * targets never appear on top of each other. */ export function createAimTrainerTarget( rng: () => number, canvasW: number, canvasH: number, difficulty: AimTrainerDifficulty, options: CreateAimTrainerTargetOptions = {}, ): AimTrainerTarget { const config = AIM_TRAINER_DIFFICULTIES[difficulty]; const kind: AimTrainerTargetKind = options.civilian ? "civilian" : "standard"; const minX = config.torsoW / 2; const maxX = canvasW - config.torsoW / 2; const minY = 2 * config.headRadius; const maxY = canvasH - config.torsoH; let x = minX + rng() * (maxX - minX); let y = minY + rng() * (maxY - minY); if (options.existingTargets && options.existingTargets.length > 0) { for (let attempt = 1; attempt < SPAWN_POSITION_ATTEMPTS; attempt += 1) { const candidateBounds = aimTrainerTargetBounds({ x, y, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }); const overlapsExisting = options.existingTargets.some((existing) => boundsOverlap(candidateBounds, aimTrainerTargetBounds(existing))); if (!overlapsExisting) break; x = minX + rng() * (maxX - minX); y = minY + rng() * (maxY - minY); } } const moving = rng() < config.movingRatio; if (!moving) { return { kind, 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 { kind, 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 }; } /** Extra separation applied when resolving target collisions, in px. */ export const COLLISION_SEPARATION_EPSILON_PX = 0.25; function clampTargetX(x: number, torsoW: number, canvasW: number): number { const minX = torsoW / 2; const maxX = canvasW - torsoW / 2; if (x < minX) return minX; if (x > maxX) return maxX; return x; } function clampTargetY(y: number, headRadius: number, torsoH: number, canvasH: number): number { const minY = 2 * headRadius; const maxY = canvasH - torsoH; if (y < minY) return minY; if (y > maxY) return maxY; return y; } /** * Resolve overlaps between all pairs of targets: each overlapping pair is * separated along the axis of minimum penetration (both bodies shift by half * the overlap plus a small epsilon), velocities are reflected only when they * point into the other target, and every body is clamped back inside the * canvas. Pure: returns new targets, never mutates the input. */ export function resolveAimTrainerTargetCollisions( targets: readonly AimTrainerTarget[], canvasW: number, canvasH: number, ): AimTrainerTarget[] { const resolved = targets.map((target) => ({ ...target })); for (let i = 0; i < resolved.length; i += 1) { for (let j = i + 1; j < resolved.length; j += 1) { const a = resolved[i]; const b = resolved[j]; if (!a || !b) continue; const boundsA = aimTrainerTargetBounds(a); const boundsB = aimTrainerTargetBounds(b); if (!boundsOverlap(boundsA, boundsB)) continue; const overlapX = Math.min(boundsA.right, boundsB.right) - Math.max(boundsA.left, boundsB.left); const overlapY = Math.min(boundsA.bottom, boundsB.bottom) - Math.max(boundsA.top, boundsB.top); if (overlapX < overlapY) { const shift = overlapX / 2 + COLLISION_SEPARATION_EPSILON_PX; if (a.x < b.x) { a.x -= shift; b.x += shift; if (a.vx > 0) a.vx = -a.vx; if (b.vx < 0) b.vx = -b.vx; } else { a.x += shift; b.x -= shift; if (a.vx < 0) a.vx = -a.vx; if (b.vx > 0) b.vx = -b.vx; } } else { const shift = overlapY / 2 + COLLISION_SEPARATION_EPSILON_PX; if (a.y < b.y) { a.y -= shift; b.y += shift; if (a.vy > 0) a.vy = -a.vy; if (b.vy < 0) b.vy = -b.vy; } else { a.y += shift; b.y -= shift; if (a.vy < 0) a.vy = -a.vy; if (b.vy > 0) b.vy = -b.vy; } } a.x = clampTargetX(a.x, a.torsoW, canvasW); a.y = clampTargetY(a.y, a.headRadius, a.torsoH, canvasH); b.x = clampTargetX(b.x, b.torsoW, canvasW); b.y = clampTargetY(b.y, b.headRadius, b.torsoH, canvasH); } } return resolved; } /** Step every target by `dt` seconds, then resolve collisions between them. */ export function stepAimTrainerTargets( targets: readonly AimTrainerTarget[], dt: number, canvasW: number, canvasH: number, ): AimTrainerTarget[] { const stepped = targets.map((target) => stepAimTrainerTarget(target, dt, canvasW, canvasH)); return resolveAimTrainerTargetCollisions(stepped, canvasW, canvasH); } /** * 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; }