diff --git a/src/lib/aim-trainer.ts b/src/lib/aim-trainer.ts index 1282000..9e9fab6 100644 --- a/src/lib/aim-trainer.ts +++ b/src/lib/aim-trainer.ts @@ -13,6 +13,8 @@ export interface AimTrainerTierConfig { 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; @@ -27,6 +29,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly< torsoH: 118, headRadius: 21, movingRatio: 0.25, + civilianChance: 0.2, speed: 80, maxConcurrentTargets: 1, targetLifetimeMs: 2000, @@ -37,6 +40,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly< torsoH: 100, headRadius: 18, movingRatio: 0.55, + civilianChance: 0.3, speed: 140, maxConcurrentTargets: 2, targetLifetimeMs: 1500, @@ -47,6 +51,7 @@ export const AIM_TRAINER_DIFFICULTIES: Readonly< torsoH: 82, headRadius: 15, movingRatio: 0.8, + civilianChance: 0.35, speed: 220, maxConcurrentTargets: 2, targetLifetimeMs: 1100, @@ -65,9 +70,14 @@ 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"]; @@ -93,12 +103,15 @@ export function calculateAimTrainerScore( torsoHits: number, shotsFired: number, despawnedUnhitTargets: number, + civilianHits = 0, ): number { const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets); - return ( + return Math.max( + 0, headshots * HEADSHOT_POINTS + - torsoHits * TORSO_POINTS + - Math.round(accuracy * ACCURACY_BONUS_MAX) + torsoHits * TORSO_POINTS - + civilianHits * CIVILIAN_PENALTY + + Math.round(accuracy * ACCURACY_BONUS_MAX), ); } @@ -110,7 +123,10 @@ export function calculateAimTrainerXp(score: number, isNewTierBest: boolean): nu 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; @@ -126,36 +142,96 @@ export interface AimTrainerTargetHit { 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; - const x = minX + rng() * (maxX - minX); - const y = minY + rng() * (maxY - minY); + 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 { x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }; + 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 { x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }; + return { kind, x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius }; } /** @@ -200,6 +276,101 @@ export function stepAimTrainerTarget( 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. diff --git a/tests/int/aim-trainer.int.spec.ts b/tests/int/aim-trainer.int.spec.ts index 9a3cfef..8c3cc9a 100644 --- a/tests/int/aim-trainer.int.spec.ts +++ b/tests/int/aim-trainer.int.spec.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { ACCURACY_BONUS_MAX, AIM_TRAINER_DIFFICULTIES, + CIVILIAN_PENALTY, COMPLETION_XP, COURSE_DURATION_MS, HEADSHOT_POINTS, + MAX_CONCURRENT_CIVILIANS, + MAX_CIVILIAN_HITS, MAX_DESPAWNED_TARGETS, MAX_HITS, MAX_SHOTS_FIRED, @@ -14,14 +17,18 @@ import { TORSO_POINTS, XP_CAP, type AimTrainerTarget, + aimTrainerTargetBounds, + boundsOverlap, calculateAimTrainerAccuracy, calculateAimTrainerScore, calculateAimTrainerXp, createAimTrainerTarget, isAimTrainerDifficulty, resolveAimTrainerHitZone, + resolveAimTrainerTargetCollisions, resolveAimTrainerTargetHit, stepAimTrainerTarget, + stepAimTrainerTargets, } from "@/lib/aim-trainer"; /** Deterministic LCG-style RNG so target creation is reproducible across runs. */ @@ -38,6 +45,7 @@ describe("Aim Trainer constants", () => { expect(COURSE_DURATION_MS).toBe(30_000); expect(TORSO_POINTS).toBe(100); expect(HEADSHOT_POINTS).toBe(250); + expect(CIVILIAN_PENALTY).toBe(100); expect(ACCURACY_BONUS_MAX).toBe(500); expect(POINTS_PER_XP).toBe(500); expect(SCORE_XP_RATE).toBe(0.125); @@ -50,6 +58,8 @@ describe("Aim Trainer constants", () => { expect(MAX_SHOTS_FIRED).toBe(500); expect(MAX_HITS).toBe(180); expect(MAX_DESPAWNED_TARGETS).toBe(60); + expect(MAX_CONCURRENT_CIVILIANS).toBe(1); + expect(MAX_CIVILIAN_HITS).toBe(30); }); }); @@ -65,6 +75,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => { targetLifetimeMs: 2000, replacementDelayMs: 400, maxConcurrentTargets: 1, + civilianChance: 0.2, }, veteran: { torsoW: 62, @@ -75,6 +86,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => { targetLifetimeMs: 1500, replacementDelayMs: 300, maxConcurrentTargets: 2, + civilianChance: 0.3, }, elite: { torsoW: 50, @@ -85,6 +97,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => { targetLifetimeMs: 1100, replacementDelayMs: 200, maxConcurrentTargets: 2, + civilianChance: 0.35, }, }); }); @@ -99,6 +112,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => { expect(recruit.targetLifetimeMs).toBe(2000); expect(recruit.replacementDelayMs).toBe(400); expect(recruit.maxConcurrentTargets).toBe(1); + expect(recruit.civilianChance).toBe(0.2); const veteran = AIM_TRAINER_DIFFICULTIES.veteran; expect(veteran.torsoW).toBe(62); @@ -109,6 +123,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => { expect(veteran.targetLifetimeMs).toBe(1500); expect(veteran.replacementDelayMs).toBe(300); expect(veteran.maxConcurrentTargets).toBe(2); + expect(veteran.civilianChance).toBe(0.3); const elite = AIM_TRAINER_DIFFICULTIES.elite; expect(elite.torsoW).toBe(50); @@ -119,6 +134,7 @@ describe("AIM_TRAINER_DIFFICULTIES", () => { expect(elite.targetLifetimeMs).toBe(1100); expect(elite.replacementDelayMs).toBe(200); expect(elite.maxConcurrentTargets).toBe(2); + expect(elite.civilianChance).toBe(0.35); }); }); @@ -166,6 +182,23 @@ describe("calculateAimTrainerScore", () => { // 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750. expect(calculateAimTrainerScore(1, 0, 1, 0)).toBe(750); }); + + it("subtracts the civilian penalty from the hit score", () => { + // 1 headshot + 1 torso = 350, minus 2 civilian hits = -200. + // Accuracy counts only real hits: 2/2 shots -> round(1*500) = 500. Total 650. + expect(calculateAimTrainerScore(1, 1, 2, 0, 2)).toBe(650); + }); + + it("clamps the total to zero when civilian penalties outweigh the hits", () => { + // No real hits, 3 civilian hits -> -300 + round(0*500) clamped to 0. + expect(calculateAimTrainerScore(0, 0, 3, 0, 3)).toBe(0); + }); + + it("excludes civilian hits from the accuracy denominator numerator", () => { + // 1 torso + 1 civilian, 2 shots: accuracy = 1/2 (civilian not a hit). + // 100 - 100 + round(0.5*500) = 250. + expect(calculateAimTrainerScore(0, 1, 2, 0, 1)).toBe(250); + }); }); describe("calculateAimTrainerXp", () => { @@ -206,6 +239,36 @@ describe("createAimTrainerTarget", () => { expect(typeof target.y).toBe("number"); expect(typeof target.vx).toBe("number"); expect(typeof target.vy).toBe("number"); + expect(target.kind).toBe("standard"); + }); + + it("marks civilian targets when requested", () => { + const target = createAimTrainerTarget(seededRng(7), canvasW, canvasH, "veteran", { + civilian: true, + }); + expect(target.kind).toBe("civilian"); + }); + + it("avoids spawning on top of existing targets", () => { + const blocker: AimTrainerTarget = { + x: 400, + y: 300, + vx: 0, + vy: 0, + moving: false, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + for (let seed = 0; seed < 50; seed++) { + const target = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "veteran", { + existingTargets: [blocker], + }); + expect( + boundsOverlap(aimTrainerTargetBounds(target), aimTrainerTargetBounds(blocker)), + ).toBe(false); + } }); it("is deterministic for the same seed across repeated calls", () => { @@ -254,7 +317,7 @@ describe("createAimTrainerTarget", () => { }); describe("resolveAimTrainerHitZone", () => { - const target = { + const target: AimTrainerTarget = { x: 400, y: 300, vx: 0, @@ -263,6 +326,7 @@ describe("resolveAimTrainerHitZone", () => { torsoW: 62, torsoH: 100, headRadius: 18, + kind: "standard", }; it("resolves a head hit inside the head circle", () => { @@ -296,6 +360,7 @@ describe("resolveAimTrainerTargetHit", () => { torsoW: 62, torsoH: 100, headRadius: 18, + kind: "standard", }; const frontTarget: AimTrainerTarget = { ...rearTarget, @@ -330,7 +395,7 @@ describe("stepAimTrainerTarget", () => { const canvasH = 600; it("moves a moving target by velocity times dt", () => { - const target = { + const target: AimTrainerTarget = { x: 400, y: 300, vx: 100, @@ -339,6 +404,7 @@ describe("stepAimTrainerTarget", () => { torsoW: 62, torsoH: 100, headRadius: 18, + kind: "standard", }; const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH); expect(stepped.x).toBeCloseTo(450); @@ -346,7 +412,7 @@ describe("stepAimTrainerTarget", () => { }); it("leaves a stationary target in place", () => { - const target = { + const target: AimTrainerTarget = { x: 400, y: 300, vx: 100, @@ -355,6 +421,7 @@ describe("stepAimTrainerTarget", () => { torsoW: 62, torsoH: 100, headRadius: 18, + kind: "standard", }; const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH); expect(stepped.x).toBe(400); @@ -362,7 +429,7 @@ describe("stepAimTrainerTarget", () => { }); it("bounces off the horizontal bounds and keeps the target inside", () => { - const target = { + const target: AimTrainerTarget = { x: 400, y: 300, vx: 1000, @@ -371,6 +438,7 @@ describe("stepAimTrainerTarget", () => { torsoW: 62, torsoH: 100, headRadius: 18, + kind: "standard", }; let current = target; for (let i = 0; i < 1000; i++) { @@ -387,7 +455,7 @@ describe("stepAimTrainerTarget", () => { }); it("bounces off the vertical bounds and keeps the target inside", () => { - const target = { + const target: AimTrainerTarget = { x: 400, y: 300, vx: 0, @@ -396,6 +464,7 @@ describe("stepAimTrainerTarget", () => { torsoW: 62, torsoH: 100, headRadius: 18, + kind: "standard", }; let current = target; for (let i = 0; i < 1000; i++) { @@ -407,3 +476,98 @@ describe("stepAimTrainerTarget", () => { } }); }); + +describe("resolveAimTrainerTargetCollisions", () => { + const canvasW = 800; + const canvasH = 600; + + it("bounces approaching targets apart without ever overlapping", () => { + let a: AimTrainerTarget = { + x: 300, + y: 300, + vx: 200, + vy: 0, + moving: true, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + let b: AimTrainerTarget = { + x: 400, + y: 300, + vx: -200, + vy: 0, + moving: true, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + for (let i = 0; i < 200; i++) { + const stepped = stepAimTrainerTargets([a, b], 0.05, canvasW, canvasH); + if (!stepped[0] || !stepped[1]) throw new Error("expected two stepped targets"); + a = stepped[0]; + b = stepped[1]; + expect(boundsOverlap(aimTrainerTargetBounds(a), aimTrainerTargetBounds(b))).toBe(false); + } + }); + + it("reflects velocity components that point into the other target", () => { + const a: AimTrainerTarget = { + x: 380, + y: 300, + vx: 100, + vy: 0, + moving: true, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + const b: AimTrainerTarget = { + x: 420, + y: 300, + vx: -100, + vy: 0, + moving: true, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + const resolved = resolveAimTrainerTargetCollisions([a, b], canvasW, canvasH); + expect(resolved[0]?.vx).toBe(-100); + expect(resolved[1]?.vx).toBe(100); + }); + + it("separates stationary targets without giving them velocity", () => { + const a: AimTrainerTarget = { + x: 400, + y: 300, + vx: 0, + vy: 0, + moving: false, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + const b: AimTrainerTarget = { + x: 420, + y: 300, + vx: 0, + vy: 0, + moving: false, + torsoW: 62, + torsoH: 100, + headRadius: 18, + kind: "standard", + }; + const resolved = resolveAimTrainerTargetCollisions([a, b], canvasW, canvasH); + expect(resolved[0]?.vx).toBe(0); + expect(resolved[0]?.vy).toBe(0); + expect(resolved[1]?.vx).toBe(0); + expect(resolved[1]?.vy).toBe(0); + }); +});