1
0
Fork 0
polaris-task-force/src/components/frontend/minigames/qualification/AimTrainerCanvas.ts
2026-09-09 00:02:59 -04:00

290 lines
9 KiB
TypeScript

import {
calculateAimTrainerAccuracy,
calculateAimTrainerScore,
COURSE_DURATION_MS,
type AimTrainerTarget,
} from "@/lib/minigames/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);
}
}