Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
290 lines
8.9 KiB
TypeScript
290 lines
8.9 KiB
TypeScript
import {
|
|
calculateAimTrainerAccuracy,
|
|
calculateAimTrainerScore,
|
|
COURSE_DURATION_MS,
|
|
type AimTrainerTarget,
|
|
} from "@/lib/aim-trainer";
|
|
|
|
export const AIM_TRAINER_CANVAS_WIDTH = 720;
|
|
export const AIM_TRAINER_CANVAS_HEIGHT = 480;
|
|
export const AIM_TRAINER_FEEDBACK_DURATION_MS = 700;
|
|
export const AIM_TRAINER_FLASH_DURATION_MS = 150;
|
|
export const AIM_TRAINER_TARGET_FADE_DURATION_MS = 250;
|
|
|
|
const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000;
|
|
|
|
export type AimTrainerPointFeedback = {
|
|
readonly x: number;
|
|
readonly y: number;
|
|
readonly text: string;
|
|
readonly color: string;
|
|
readonly createdAt: number;
|
|
};
|
|
|
|
export type AimTrainerActiveTarget = {
|
|
readonly target: AimTrainerTarget;
|
|
readonly spawnedAt: number;
|
|
};
|
|
|
|
type AimTrainerSceneFrame = {
|
|
readonly now: number;
|
|
readonly targetLifetimeMs: number;
|
|
};
|
|
|
|
export type AimTrainerGameState = {
|
|
targets: AimTrainerActiveTarget[];
|
|
pendingSpawnAts: number[];
|
|
startedAt: number;
|
|
headshots: number;
|
|
torsoHits: number;
|
|
civilianHits: number;
|
|
shotsFired: number;
|
|
despawnedUnhitTargets: number;
|
|
feedback: AimTrainerPointFeedback[];
|
|
flashColor: string;
|
|
flashUntil: number;
|
|
lastDisplayedSecond: number;
|
|
};
|
|
|
|
export type AimTrainerLiveStats = {
|
|
readonly score: number;
|
|
readonly accuracy: number;
|
|
readonly shotsFired: number;
|
|
readonly headshots: number;
|
|
readonly torsoHits: number;
|
|
readonly civilianHits: number;
|
|
readonly despawnedUnhitTargets: number;
|
|
};
|
|
|
|
export function createAimTrainerGameState(): AimTrainerGameState {
|
|
return {
|
|
targets: [],
|
|
pendingSpawnAts: [],
|
|
startedAt: 0,
|
|
headshots: 0,
|
|
torsoHits: 0,
|
|
civilianHits: 0,
|
|
shotsFired: 0,
|
|
despawnedUnhitTargets: 0,
|
|
feedback: [],
|
|
flashColor: "#d9d19b",
|
|
flashUntil: 0,
|
|
lastDisplayedSecond: COURSE_DURATION_SECONDS,
|
|
};
|
|
}
|
|
|
|
export function emptyAimTrainerLiveStats(): AimTrainerLiveStats {
|
|
return {
|
|
score: 0,
|
|
accuracy: 0,
|
|
shotsFired: 0,
|
|
headshots: 0,
|
|
torsoHits: 0,
|
|
civilianHits: 0,
|
|
despawnedUnhitTargets: 0,
|
|
};
|
|
}
|
|
|
|
export function getAimTrainerLiveStats(game: AimTrainerGameState): AimTrainerLiveStats {
|
|
const hits = game.headshots + game.torsoHits;
|
|
return {
|
|
score: calculateAimTrainerScore(
|
|
game.headshots,
|
|
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,
|
|
};
|
|
}
|
|
|
|
function drawRange(ctx: CanvasRenderingContext2D): void {
|
|
const range = ctx.createLinearGradient(0, 0, 0, AIM_TRAINER_CANVAS_HEIGHT);
|
|
range.addColorStop(0, "#1a2422");
|
|
range.addColorStop(0.55, "#26332c");
|
|
range.addColorStop(1, "#111916");
|
|
ctx.fillStyle = range;
|
|
ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT);
|
|
|
|
ctx.strokeStyle = "rgba(174, 167, 117, 0.1)";
|
|
ctx.lineWidth = 1;
|
|
for (let x = 0; x <= AIM_TRAINER_CANVAS_WIDTH; x += 40) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, AIM_TRAINER_CANVAS_HEIGHT);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= AIM_TRAINER_CANVAS_HEIGHT; y += 40) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(AIM_TRAINER_CANVAS_WIDTH, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const horizon = AIM_TRAINER_CANVAS_HEIGHT * 0.68;
|
|
ctx.fillStyle = "rgba(8, 13, 11, 0.38)";
|
|
ctx.fillRect(0, horizon, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT - horizon);
|
|
ctx.strokeStyle = "rgba(202, 193, 139, 0.2)";
|
|
ctx.setLineDash([12, 12]);
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, horizon);
|
|
ctx.lineTo(AIM_TRAINER_CANVAS_WIDTH, horizon);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
ctx.fillStyle = "rgba(201, 192, 142, 0.18)";
|
|
ctx.font = "11px ui-monospace, monospace";
|
|
ctx.textAlign = "left";
|
|
ctx.textBaseline = "top";
|
|
ctx.fillText("RANGE 04 // LIVE FIRE", 18, 16);
|
|
ctx.textAlign = "right";
|
|
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,
|
|
frame.targetLifetimeMs / 5,
|
|
);
|
|
const fadeStart = frame.targetLifetimeMs - fadeDuration;
|
|
const fadeProgress =
|
|
fadeDuration > 0 ? Math.min(1, Math.max(0, (age - fadeStart) / fadeDuration)) : 0;
|
|
const pulse = 1 + Math.sin(frame.now / 150) * 0.025;
|
|
const torsoX = target.x - (target.torsoW * pulse) / 2;
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = 1 - fadeProgress;
|
|
ctx.save();
|
|
ctx.translate(target.x, target.y);
|
|
ctx.shadowColor = "rgba(0, 0, 0, 0.45)";
|
|
ctx.shadowBlur = 18;
|
|
ctx.shadowOffsetY = 8;
|
|
ctx.fillStyle = "#0d1411";
|
|
ctx.fillRect(-target.torsoW / 2 - 3, 3, target.torsoW + 6, target.torsoH + 4);
|
|
ctx.beginPath();
|
|
ctx.arc(0, -target.headRadius, target.headRadius + 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.shadowColor = "transparent";
|
|
|
|
const torso = ctx.createLinearGradient(-target.torsoW / 2, 0, target.torsoW / 2, target.torsoH);
|
|
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 = palette.lowerShade;
|
|
ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46);
|
|
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 = palette.torsoOutline;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
|
|
|
|
ctx.fillStyle = palette.headFill;
|
|
ctx.beginPath();
|
|
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
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 = palette.headOutline;
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = palette.crosshair;
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -target.headRadius - target.headRadius - 9);
|
|
ctx.lineTo(0, -target.headRadius - target.headRadius - 1);
|
|
ctx.moveTo(-target.headRadius - 9, -target.headRadius);
|
|
ctx.lineTo(-target.headRadius - 1, -target.headRadius);
|
|
ctx.moveTo(target.headRadius + 1, -target.headRadius);
|
|
ctx.lineTo(target.headRadius + 9, -target.headRadius);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
|
|
ctx.strokeStyle = palette.boundsBox;
|
|
ctx.setLineDash([4, 5]);
|
|
ctx.strokeRect(torsoX, target.y, target.torsoW * pulse, target.torsoH);
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawFeedback(
|
|
ctx: CanvasRenderingContext2D,
|
|
feedback: readonly AimTrainerPointFeedback[],
|
|
now: number,
|
|
): void {
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.font = "bold 18px ui-monospace, monospace";
|
|
for (const item of feedback) {
|
|
const age = now - item.createdAt;
|
|
const progress = Math.min(1, age / AIM_TRAINER_FEEDBACK_DURATION_MS);
|
|
ctx.globalAlpha = 1 - progress;
|
|
ctx.fillStyle = item.color;
|
|
ctx.fillText(item.text, item.x, item.y - progress * 28);
|
|
}
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
|
|
export function drawAimTrainerScene(
|
|
ctx: CanvasRenderingContext2D,
|
|
game: AimTrainerGameState,
|
|
frame: AimTrainerSceneFrame,
|
|
): void {
|
|
drawRange(ctx);
|
|
for (const activeTarget of game.targets) {
|
|
drawTarget(ctx, activeTarget, frame);
|
|
}
|
|
drawFeedback(ctx, game.feedback, frame.now);
|
|
if (game.flashUntil > frame.now) {
|
|
ctx.fillStyle = `${game.flashColor}${Math.round(
|
|
((game.flashUntil - frame.now) / AIM_TRAINER_FLASH_DURATION_MS) * 28,
|
|
)
|
|
.toString(16)
|
|
.padStart(2, "0")}`;
|
|
ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT);
|
|
}
|
|
}
|