Compare commits
7 commits
cfa29a64ea
...
72715c42a4
| Author | SHA1 | Date | |
|---|---|---|---|
| 72715c42a4 | |||
| dd2c868c89 | |||
| 15e5fa7d01 | |||
| b905fe2b5e | |||
| 6c7a9fb5e2 | |||
| dc9d2f4605 | |||
| 7d6a4bd671 |
11 changed files with 526 additions and 62 deletions
|
|
@ -13,6 +13,7 @@ import {
|
|||
MAX_SHOTS_FIRED,
|
||||
MAX_HITS,
|
||||
MAX_DESPAWNED_TARGETS,
|
||||
MAX_CIVILIAN_HITS,
|
||||
} from "@/lib/aim-trainer";
|
||||
|
||||
interface ActionResult<T = undefined> {
|
||||
|
|
@ -40,6 +41,7 @@ export interface AimTrainerResultInput {
|
|||
difficulty: string;
|
||||
headshots: number;
|
||||
torsoHits: number;
|
||||
civilianHits: number;
|
||||
shotsFired: number;
|
||||
despawnedUnhitTargets: number;
|
||||
}
|
||||
|
|
@ -71,12 +73,14 @@ export async function submitAimTrainerResult(
|
|||
return { success: false, error: "Invalid difficulty." };
|
||||
}
|
||||
|
||||
const { headshots, torsoHits, shotsFired, despawnedUnhitTargets } = input;
|
||||
const { headshots, torsoHits, civilianHits, shotsFired, despawnedUnhitTargets } = input;
|
||||
if (
|
||||
!Number.isInteger(headshots) ||
|
||||
headshots < 0 ||
|
||||
!Number.isInteger(torsoHits) ||
|
||||
torsoHits < 0 ||
|
||||
!Number.isInteger(civilianHits) ||
|
||||
civilianHits < 0 ||
|
||||
!Number.isInteger(shotsFired) ||
|
||||
shotsFired < 0 ||
|
||||
!Number.isInteger(despawnedUnhitTargets) ||
|
||||
|
|
@ -91,9 +95,12 @@ export async function submitAimTrainerResult(
|
|||
if (hits > MAX_HITS) {
|
||||
return { success: false, error: "Too many hits." };
|
||||
}
|
||||
if (hits > shotsFired) {
|
||||
if (headshots + torsoHits + civilianHits > shotsFired) {
|
||||
return { success: false, error: "Hits cannot exceed shots fired." };
|
||||
}
|
||||
if (civilianHits > MAX_CIVILIAN_HITS) {
|
||||
return { success: false, error: "Too many civilian hits." };
|
||||
}
|
||||
if (despawnedUnhitTargets > MAX_DESPAWNED_TARGETS) {
|
||||
return { success: false, error: "Too many despawned targets." };
|
||||
}
|
||||
|
|
@ -129,7 +136,13 @@ export async function submitAimTrainerResult(
|
|||
const current = progression.minigames?.aimTrainer ?? {};
|
||||
|
||||
const accuracy = calculateAimTrainerAccuracy(hits, shotsFired, despawnedUnhitTargets);
|
||||
const score = calculateAimTrainerScore(headshots, torsoHits, shotsFired, despawnedUnhitTargets);
|
||||
const score = calculateAimTrainerScore(
|
||||
headshots,
|
||||
torsoHits,
|
||||
shotsFired,
|
||||
despawnedUnhitTargets,
|
||||
civilianHits,
|
||||
);
|
||||
|
||||
const difficultyBestScoreKey = `${input.difficulty}BestScore` as
|
||||
| "recruitBestScore"
|
||||
|
|
@ -204,6 +217,7 @@ export async function submitAimTrainerResult(
|
|||
difficulty: input.difficulty,
|
||||
score,
|
||||
accuracy,
|
||||
civilianHits,
|
||||
xpEarned: xp,
|
||||
bestScore,
|
||||
difficultyBestScore,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ interface ApiProfile {
|
|||
|
||||
interface LeaderboardRow {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
name: string;
|
||||
gamesPlayed: number;
|
||||
wins: number;
|
||||
|
|
@ -113,6 +114,7 @@ export function EodLeaderboard({
|
|||
user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`;
|
||||
return {
|
||||
id: doc.id,
|
||||
userId: user?.id ?? null,
|
||||
name,
|
||||
gamesPlayed: eod.gamesPlayed ?? 0,
|
||||
wins: eod.wins ?? 0,
|
||||
|
|
@ -236,7 +238,7 @@ export function EodLeaderboard({
|
|||
unranked
|
||||
</span>
|
||||
)}
|
||||
{activeTeams.some((team) => team.id === row.id) && (
|
||||
{row.userId !== null && activeTeams.some((team) => team.id === row.userId) && (
|
||||
<span
|
||||
className="size-2 rounded-full bg-emerald-400"
|
||||
title="Clearing now"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ interface ApiProfile {
|
|||
|
||||
interface LeaderboardRow {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
name: string;
|
||||
gamesPlayed: number;
|
||||
totalPasses: number;
|
||||
|
|
@ -82,6 +83,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
|
|||
user?.displayName || doc.profileTitle || user?.username || `Pilot #${doc.id}`;
|
||||
return {
|
||||
id: doc.id,
|
||||
userId: user?.id ?? null,
|
||||
name,
|
||||
gamesPlayed: flappy.gamesPlayed ?? 0,
|
||||
totalPasses: flappy.totalPasses ?? 0,
|
||||
|
|
@ -170,7 +172,7 @@ export function FlappyLeaderboard({ refreshKey = 0, currentUserId, activePilots
|
|||
unranked
|
||||
</span>
|
||||
)}
|
||||
{activePilots.some((pilot) => pilot.id === row.id) && (
|
||||
{row.userId !== null && activePilots.some((pilot) => pilot.id === row.userId) && (
|
||||
<span className="size-2 rounded-full bg-emerald-400" title="Playing now" aria-label="Playing now" />
|
||||
)}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -148,13 +148,17 @@ export function AimTrainerArena({
|
|||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-foreground">Acquire</span>
|
||||
<span>
|
||||
Up to {AIM_TRAINER_DIFFICULTIES[difficulty].maxConcurrentTargets} silhouettes are
|
||||
active at once. Moving targets rebound inside the range.
|
||||
Up to {AIM_TRAINER_DIFFICULTIES[difficulty].maxConcurrentTargets} hostile
|
||||
silhouettes are active at once, plus an occasional blue civilian that never counts
|
||||
toward the limit. Targets rebound off each other and the range walls.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-foreground">Prioritize</span>
|
||||
<span>Headshots score 250 points. Torso hits score 100 points.</span>
|
||||
<span>
|
||||
Headshots score 250 points. Torso hits score 100 points. Blue civilians cost 100
|
||||
points — never shoot them.
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-foreground">Qualify</span>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export type AimTrainerGameState = {
|
|||
startedAt: number;
|
||||
headshots: number;
|
||||
torsoHits: number;
|
||||
civilianHits: number;
|
||||
shotsFired: number;
|
||||
despawnedUnhitTargets: number;
|
||||
feedback: AimTrainerPointFeedback[];
|
||||
|
|
@ -51,6 +52,7 @@ export type AimTrainerLiveStats = {
|
|||
readonly shotsFired: number;
|
||||
readonly headshots: number;
|
||||
readonly torsoHits: number;
|
||||
readonly civilianHits: number;
|
||||
readonly despawnedUnhitTargets: number;
|
||||
};
|
||||
|
||||
|
|
@ -61,6 +63,7 @@ export function createAimTrainerGameState(): AimTrainerGameState {
|
|||
startedAt: 0,
|
||||
headshots: 0,
|
||||
torsoHits: 0,
|
||||
civilianHits: 0,
|
||||
shotsFired: 0,
|
||||
despawnedUnhitTargets: 0,
|
||||
feedback: [],
|
||||
|
|
@ -77,6 +80,7 @@ export function emptyAimTrainerLiveStats(): AimTrainerLiveStats {
|
|||
shotsFired: 0,
|
||||
headshots: 0,
|
||||
torsoHits: 0,
|
||||
civilianHits: 0,
|
||||
despawnedUnhitTargets: 0,
|
||||
};
|
||||
}
|
||||
|
|
@ -89,11 +93,13 @@ export function getAimTrainerLiveStats(game: AimTrainerGameState): AimTrainerLiv
|
|||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -141,12 +147,37 @@ function drawRange(ctx: CanvasRenderingContext2D): void {
|
|||
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,
|
||||
|
|
@ -173,35 +204,35 @@ function drawTarget(
|
|||
ctx.shadowColor = "transparent";
|
||||
|
||||
const torso = ctx.createLinearGradient(-target.torsoW / 2, 0, target.torsoW / 2, target.torsoH);
|
||||
torso.addColorStop(0, "#9b956d");
|
||||
torso.addColorStop(0.5, "#706f52");
|
||||
torso.addColorStop(1, "#4d5542");
|
||||
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 = "rgba(29, 38, 30, 0.42)";
|
||||
ctx.fillStyle = palette.lowerShade;
|
||||
ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46);
|
||||
ctx.fillStyle = "rgba(217, 207, 151, 0.32)";
|
||||
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 = "rgba(226, 217, 166, 0.6)";
|
||||
ctx.strokeStyle = palette.torsoOutline;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
|
||||
|
||||
ctx.fillStyle = "#afa678";
|
||||
ctx.fillStyle = palette.headFill;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(48, 55, 40, 0.48)";
|
||||
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 = "rgba(226, 217, 166, 0.72)";
|
||||
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 = "rgba(235, 226, 175, 0.32)";
|
||||
ctx.strokeStyle = palette.crosshair;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -target.headRadius - target.headRadius - 9);
|
||||
|
|
@ -213,7 +244,7 @@ function drawTarget(
|
|||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.strokeStyle = "rgba(211, 202, 149, 0.12)";
|
||||
ctx.strokeStyle = palette.boundsBox;
|
||||
ctx.setLineDash([4, 5]);
|
||||
ctx.strokeRect(torsoX, target.y, target.torsoW * pulse, target.torsoH);
|
||||
ctx.setLineDash([]);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Acquire every silhouette before it clears the range.
|
||||
Acquire every silhouette before it clears the range — and never shoot the blue
|
||||
civilians.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
|
|
@ -593,7 +650,7 @@ export function AimTrainerGame({
|
|||
{phase === "over" && lastRun && (
|
||||
<div className="absolute inset-0 flex flex-col justify-center gap-3 overflow-y-auto bg-background/90 p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
||||
<Trophy aria-hidden="true" className="size-5 text-amber-400" />
|
||||
<span className="text-lg font-semibold tracking-wide">Course complete</span>
|
||||
</div>
|
||||
{lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>}
|
||||
|
|
@ -608,7 +665,8 @@ export function AimTrainerGame({
|
|||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>{lastRun.shotsFired} shots fired</span>
|
||||
<span>{lastRun.despawnedUnhitTargets} targets expired</span>
|
||||
<span className="font-semibold text-primary">+{lastRun.xpEarned} XP</span>
|
||||
<span>{lastRun.civilianHits} civilian hits</span>
|
||||
<span className="font-semibold text-emerald-400">+{lastRun.xpEarned} XP</span>
|
||||
</div>
|
||||
{lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
|
||||
{submitting && (
|
||||
|
|
@ -669,7 +727,8 @@ export function AimTrainerGame({
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
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.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
|
||||
type LeaderboardRow = {
|
||||
readonly id: number;
|
||||
readonly userId: number | null;
|
||||
readonly name: string;
|
||||
readonly gamesPlayed: number;
|
||||
readonly bestScore: number;
|
||||
|
|
@ -24,9 +25,9 @@ type LeaderboardRow = {
|
|||
};
|
||||
|
||||
function rankClass(index: number): string {
|
||||
if (index === 0) return "font-bold text-primary";
|
||||
if (index === 1) return "font-semibold text-foreground";
|
||||
if (index === 2) return "font-semibold text-muted-foreground";
|
||||
if (index === 0) return "font-bold text-amber-400";
|
||||
if (index === 1) return "font-semibold text-slate-300";
|
||||
if (index === 2) return "font-semibold text-amber-700";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +78,7 @@ export function AimTrainerLeaderboard({
|
|||
const aimTrainer = profile.aimTrainer;
|
||||
return {
|
||||
id: profile.id,
|
||||
userId,
|
||||
name,
|
||||
gamesPlayed: aimTrainer?.gamesPlayed ?? 0,
|
||||
bestScore: aimTrainer?.[tier.scoreField] ?? 0,
|
||||
|
|
@ -111,7 +113,7 @@ export function AimTrainerLeaderboard({
|
|||
<Card className="w-full max-w-3xl overflow-hidden">
|
||||
<CardHeader className="gap-1 border-b border-border px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy aria-hidden="true" className="size-4 text-primary" />
|
||||
<Trophy aria-hidden="true" className="size-4 text-amber-400" />
|
||||
<CardTitle className="text-sm">Qualification leaderboard</CardTitle>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span>
|
||||
</div>
|
||||
|
|
@ -121,7 +123,7 @@ export function AimTrainerLeaderboard({
|
|||
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="font-medium text-primary">{activeTrainees.length}</span>{" "}
|
||||
<span className="font-medium text-emerald-400">{activeTrainees.length}</span>{" "}
|
||||
{activeTrainees.length === 1 ? "trainee is" : "trainees are"} on the line
|
||||
{activeTrainees.length > 0 && (
|
||||
<span className="ml-1 text-foreground">
|
||||
|
|
@ -190,9 +192,9 @@ export function AimTrainerLeaderboard({
|
|||
unranked
|
||||
</span>
|
||||
)}
|
||||
{activeTrainees.some((trainee) => trainee.id === row.id) && (
|
||||
{row.userId !== null && activeTrainees.some((trainee) => trainee.id === row.userId) && (
|
||||
<span
|
||||
className="size-2 rounded-full bg-primary"
|
||||
className="size-2 rounded-full bg-emerald-400"
|
||||
title="Playing now"
|
||||
aria-label="Playing now"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ interface ApiProfile {
|
|||
|
||||
interface LeaderboardRow {
|
||||
id: number;
|
||||
userId: number | null;
|
||||
name: string;
|
||||
totalMessages: number;
|
||||
bestScore: number;
|
||||
|
|
@ -134,6 +135,7 @@ export function RadioLeaderboard({
|
|||
const user = typeof doc.user === "object" && doc.user !== null ? doc.user : null;
|
||||
return {
|
||||
id: doc.id,
|
||||
userId: user?.id ?? null,
|
||||
name: user?.displayName || doc.profileTitle || user?.username || `Operator #${doc.id}`,
|
||||
totalMessages:
|
||||
radioTraffic[mode === "sprint" ? "sprintTotalMessages" : "watchTotalMessages"] ?? 0,
|
||||
|
|
@ -299,7 +301,7 @@ export function RadioLeaderboard({
|
|||
unranked
|
||||
</span>
|
||||
)}
|
||||
{activeOperators.some((operator) => operator.id === row.id) && (
|
||||
{row.userId !== null && activeOperators.some((operator) => operator.id === row.userId) && (
|
||||
<span
|
||||
className="size-2 rounded-full bg-emerald-400"
|
||||
title="Transmitting now"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue