diff --git a/src/components/frontend/qualification/AimTrainerGame.tsx b/src/components/frontend/qualification/AimTrainerGame.tsx index 9b52b00..621d40f 100644 --- a/src/components/frontend/qualification/AimTrainerGame.tsx +++ b/src/components/frontend/qualification/AimTrainerGame.tsx @@ -8,13 +8,15 @@ import { Crosshair, RotateCcw, Target, Timer, Trophy } from "lucide-react"; import { AIM_TRAINER_DIFFICULTIES, + CIVILIAN_PENALTY, + MAX_CONCURRENT_CIVILIANS, calculateAimTrainerAccuracy, calculateAimTrainerScore, COURSE_DURATION_MS, HEADSHOT_POINTS, createAimTrainerTarget, resolveAimTrainerTargetHit, - stepAimTrainerTarget, + stepAimTrainerTargets, TORSO_POINTS, type AimTrainerDifficulty, } from "@/lib/aim-trainer"; @@ -44,6 +46,7 @@ import { type AimTrainerLiveStats, } from "./AimTrainerCanvas"; import { + playCivilianHit, playCountdownTick, playFire, playHeadshot, @@ -94,6 +97,7 @@ type LastRun = { readonly accuracy: number; readonly headshots: number; readonly torsoHits: number; + readonly civilianHits: number; readonly shotsFired: number; readonly despawnedUnhitTargets: number; readonly xpEarned: number; @@ -205,8 +209,12 @@ export function AimTrainerGame({ round.torsoHits, round.shotsFired, round.despawnedUnhitTargets, + round.civilianHits, ); - const hitScore = round.headshots * HEADSHOT_POINTS + round.torsoHits * TORSO_POINTS; + const hitScore = + round.headshots * HEADSHOT_POINTS + + round.torsoHits * TORSO_POINTS - + round.civilianHits * CIVILIAN_PENALTY; const previousTierBest = tierBestScore(statsRef.current, difficulty); const preview: LastRun = { hitScore, @@ -215,6 +223,7 @@ export function AimTrainerGame({ accuracy, headshots: round.headshots, torsoHits: round.torsoHits, + civilianHits: round.civilianHits, shotsFired: round.shotsFired, despawnedUnhitTargets: round.despawnedUnhitTargets, xpEarned: 0, @@ -352,6 +361,23 @@ export function AimTrainerGame({ const [activeTarget] = game.targets.splice(hit.index, 1); if (!activeTarget) return; const target = activeTarget.target; + + if (target.kind === "civilian") { + game.civilianHits += 1; + game.feedback.push({ + x: target.x, + y: target.y + target.torsoH * 0.45, + text: `-${CIVILIAN_PENALTY}`, + color: "#efb08a", + createdAt: now, + }); + game.flashColor = "#7fa8d9"; + game.flashUntil = now + FLASH_DURATION_MS; + playCivilianHit(); + setLive(getAimTrainerLiveStats(game)); + return; + } + game.pendingSpawnAts.push(now + config.replacementDelayMs); switch (hit.zone) { case "head": @@ -410,13 +436,14 @@ export function AimTrainerGame({ setLive(getAimTrainerLiveStats(game)); setPhaseSafe("over"); playRoundComplete(); - void handleRoundCompleteRef.current({ - difficulty, - headshots: game.headshots, - torsoHits: game.torsoHits, - shotsFired: game.shotsFired, - despawnedUnhitTargets: game.despawnedUnhitTargets, - }); + void handleRoundCompleteRef.current({ + difficulty, + headshots: game.headshots, + torsoHits: game.torsoHits, + civilianHits: game.civilianHits, + shotsFired: game.shotsFired, + despawnedUnhitTargets: game.despawnedUnhitTargets, + }); }; const step = (now: number) => { @@ -443,32 +470,60 @@ export function AimTrainerGame({ } const survivingTargets: AimTrainerActiveTarget[] = []; - let expiredTargets = 0; + let expiredStandardTargets = 0; for (const activeTarget of game.targets) { if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) { - expiredTargets += 1; - game.pendingSpawnAts.push(now + config.replacementDelayMs); + if (activeTarget.target.kind === "standard") { + expiredStandardTargets += 1; + game.pendingSpawnAts.push(now + config.replacementDelayMs); + } continue; } survivingTargets.push({ - target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H), + target: activeTarget.target, spawnedAt: activeTarget.spawnedAt, }); } - game.targets = survivingTargets; - if (expiredTargets > 0) { - game.despawnedUnhitTargets += expiredTargets; + const steppedTargets = stepAimTrainerTargets( + survivingTargets.map((activeTarget) => activeTarget.target), + dtSec, + CANVAS_W, + CANVAS_H, + ); + game.targets = survivingTargets.map((activeTarget, index) => ({ + target: steppedTargets[index] ?? activeTarget.target, + spawnedAt: activeTarget.spawnedAt, + })); + if (expiredStandardTargets > 0) { + game.despawnedUnhitTargets += expiredStandardTargets; setLive(getAimTrainerLiveStats(game)); } - while (game.targets.length < config.maxConcurrentTargets) { + let standardCount = game.targets.filter( + (activeTarget) => activeTarget.target.kind === "standard", + ).length; + let civilianCount = game.targets.length - standardCount; + while (standardCount < 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), + target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty, { + existingTargets: game.targets.map((activeTarget) => activeTarget.target), + }), spawnedAt: now, }); + standardCount += 1; + if (civilianCount < MAX_CONCURRENT_CIVILIANS && Math.random() < config.civilianChance) { + game.targets.push({ + target: createAimTrainerTarget(Math.random, CANVAS_W, CANVAS_H, difficulty, { + civilian: true, + existingTargets: game.targets.map((activeTarget) => activeTarget.target), + }), + spawnedAt: now, + }); + civilianCount += 1; + } } } } @@ -509,8 +564,9 @@ export function AimTrainerGame({ window.removeEventListener("keyup", onKeyUp, { capture: true }); }; }, [ - config.replacementDelayMs, + config.civilianChance, config.maxConcurrentTargets, + config.replacementDelayMs, config.targetLifetimeMs, difficulty, handlePrimaryAction, @@ -571,7 +627,8 @@ export function AimTrainerGame({ Ready on the line - Acquire every silhouette before it clears the range. + Acquire every silhouette before it clears the range — and never shoot the blue + civilians. - + Course complete {lastRun.isNewRecord && New record!} @@ -608,7 +665,8 @@ export function AimTrainerGame({ {lastRun.shotsFired} shots fired {lastRun.despawnedUnhitTargets} targets expired - +{lastRun.xpEarned} XP + {lastRun.civilianHits} civilian hits + +{lastRun.xpEarned} XP {lastRun.error && {lastRun.error}} {submitting && ( @@ -669,7 +727,8 @@ export function AimTrainerGame({ - Headshots score {HEADSHOT_POINTS}. Torso hits score {TORSO_POINTS}. + Headshots score {HEADSHOT_POINTS} · torso hits score {TORSO_POINTS} · civilians cost{" "} + {CIVILIAN_PENALTY}. Press R to restart the lane. diff --git a/src/components/frontend/qualification/sounds.ts b/src/components/frontend/qualification/sounds.ts index f38676d..a486ee1 100644 --- a/src/components/frontend/qualification/sounds.ts +++ b/src/components/frontend/qualification/sounds.ts @@ -109,6 +109,19 @@ export function playTorsoHit(): void { }); } +export function playCivilianHit(): void { + const ctx = getCtx(); + if (!ctx) return; + playTone(ctx, { + frequency: 300, + endFrequency: 140, + duration: 0.22, + type: "sawtooth", + volume: 0.16, + }); + playNoise(ctx, { duration: 0.12, volume: 0.12, lowpass: 520 }); +} + export function playMiss(): void { const ctx = getCtx(); if (!ctx) return;