Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
251 lines
6.6 KiB
TypeScript
251 lines
6.6 KiB
TypeScript
/**
|
|
* 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<AimTrainerDifficulty, AimTrainerTierConfig>
|
|
> = {
|
|
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;
|
|
}
|