feat(qualification): add aim trainer game interface
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
e3e4760f49
commit
320fe4b36e
4 changed files with 1245 additions and 0 deletions
177
src/components/frontend/qualification/AimTrainerArena.tsx
Normal file
177
src/components/frontend/qualification/AimTrainerArena.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
AIM_TRAINER_DIFFICULTIES,
|
||||
isAimTrainerDifficulty,
|
||||
type AimTrainerDifficulty,
|
||||
} from "@/lib/aim-trainer";
|
||||
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions";
|
||||
import { useRealtimePresence } from "@/hooks/useRealtimePresence";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AimTrainerGame, type AimTrainerPhase } from "./AimTrainerGame";
|
||||
import { AimTrainerLeaderboard } from "./AimTrainerLeaderboard";
|
||||
|
||||
const TIER_OPTIONS = [
|
||||
{ value: "recruit", label: "Recruit", detail: "Steady silhouettes" },
|
||||
{ value: "veteran", label: "Veteran", detail: "Moving range" },
|
||||
{ value: "elite", label: "Elite", detail: "High-speed range" },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
readonly value: AimTrainerDifficulty;
|
||||
readonly label: string;
|
||||
readonly detail: string;
|
||||
}>;
|
||||
|
||||
const TIER_STAT_KEYS = {
|
||||
recruit: { bestScore: "recruitBestScore", bestAccuracy: "recruitBestAccuracy" },
|
||||
veteran: { bestScore: "veteranBestScore", bestAccuracy: "veteranBestAccuracy" },
|
||||
elite: { bestScore: "eliteBestScore", bestAccuracy: "eliteBestAccuracy" },
|
||||
} as const satisfies Record<
|
||||
AimTrainerDifficulty,
|
||||
{
|
||||
readonly bestScore: "recruitBestScore" | "veteranBestScore" | "eliteBestScore";
|
||||
readonly bestAccuracy: "recruitBestAccuracy" | "veteranBestAccuracy" | "eliteBestAccuracy";
|
||||
}
|
||||
>;
|
||||
|
||||
export function AimTrainerArena({
|
||||
initialStats,
|
||||
currentUserId,
|
||||
}: {
|
||||
readonly initialStats: AimTrainerStats;
|
||||
readonly currentUserId: number | string;
|
||||
}) {
|
||||
const [stats, setStats] = useState<AimTrainerStats>(initialStats);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [difficulty, setDifficulty] = useState<AimTrainerDifficulty>("recruit");
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const { isMobile, setOpenMobile } = useSidebar();
|
||||
const activeTrainees = useRealtimePresence("qualification", playing, true);
|
||||
|
||||
const handlePhaseChange = useCallback((nextPhase: AimTrainerPhase) => {
|
||||
setPlaying(nextPhase === "playing");
|
||||
}, []);
|
||||
|
||||
const handleGameRecorded = useCallback((nextStats: AimTrainerStats) => {
|
||||
setStats(nextStats);
|
||||
setRefreshKey((key) => key + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (playing && isMobile) setOpenMobile(false);
|
||||
}, [isMobile, playing, setOpenMobile]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col items-center gap-6">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Choose a qualification lane</CardTitle>
|
||||
<CardDescription>
|
||||
Each course lasts 30 seconds. Accuracy keeps your score climbing when the range gets
|
||||
busy.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<fieldset
|
||||
disabled={playing}
|
||||
className={cn(
|
||||
"m-0 min-w-0 border-0 p-0 transition-opacity duration-200 motion-reduce:transition-none",
|
||||
playing && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<RadioGroup
|
||||
value={difficulty}
|
||||
onValueChange={(value) => {
|
||||
if (isAimTrainerDifficulty(value)) setDifficulty(value);
|
||||
}}
|
||||
aria-label="Qualification difficulty"
|
||||
className="grid gap-2 md:grid-cols-3"
|
||||
>
|
||||
{TIER_OPTIONS.map((option) => {
|
||||
const statKeys = TIER_STAT_KEYS[option.value];
|
||||
const optionStats = {
|
||||
bestScore: stats[statKeys.bestScore],
|
||||
bestAccuracy: stats[statKeys.bestAccuracy],
|
||||
};
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
htmlFor={`qualification-tier-${option.value}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-3 rounded-lg border border-border bg-background px-3 py-3 transition-colors focus-within:ring-2 focus-within:ring-ring motion-reduce:transition-none",
|
||||
difficulty === option.value && "border-primary bg-accent",
|
||||
playing && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={`qualification-tier-${option.value}`}
|
||||
value={option.value}
|
||||
aria-label={`${option.label} qualification lane`}
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
{optionStats.bestScore} pts
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{option.detail}</span>
|
||||
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||
Best accuracy {Math.round(optionStats.bestAccuracy * 100)}%
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AimTrainerGame
|
||||
key={difficulty}
|
||||
initialStats={stats}
|
||||
difficulty={difficulty}
|
||||
onGameRecordedAction={handleGameRecorded}
|
||||
onPhaseChangeAction={handlePhaseChange}
|
||||
/>
|
||||
|
||||
<Card className="w-full max-w-3xl">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm">Qualification protocol</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-xs text-muted-foreground sm:grid-cols-3">
|
||||
<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.
|
||||
</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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-medium text-foreground">Qualify</span>
|
||||
<span>
|
||||
The course adds an accuracy bonus and records the best score for each lane. Current
|
||||
target window: {AIM_TRAINER_DIFFICULTIES[difficulty].targetLifetimeMs / 1000}s.
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AimTrainerLeaderboard
|
||||
refreshKey={refreshKey}
|
||||
difficulty={difficulty}
|
||||
currentUserId={currentUserId}
|
||||
activeTrainees={activeTrainees}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/components/frontend/qualification/AimTrainerCanvas.ts
Normal file
259
src/components/frontend/qualification/AimTrainerCanvas.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
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;
|
||||
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 despawnedUnhitTargets: number;
|
||||
};
|
||||
|
||||
export function createAimTrainerGameState(): AimTrainerGameState {
|
||||
return {
|
||||
targets: [],
|
||||
pendingSpawnAts: [],
|
||||
startedAt: 0,
|
||||
headshots: 0,
|
||||
torsoHits: 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,
|
||||
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,
|
||||
),
|
||||
accuracy: calculateAimTrainerAccuracy(hits, game.shotsFired, game.despawnedUnhitTargets),
|
||||
shotsFired: game.shotsFired,
|
||||
headshots: game.headshots,
|
||||
torsoHits: game.torsoHits,
|
||||
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);
|
||||
}
|
||||
|
||||
function drawTarget(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
activeTarget: AimTrainerActiveTarget,
|
||||
frame: AimTrainerSceneFrame,
|
||||
): void {
|
||||
const { target, spawnedAt } = activeTarget;
|
||||
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, "#9b956d");
|
||||
torso.addColorStop(0.5, "#706f52");
|
||||
torso.addColorStop(1, "#4d5542");
|
||||
ctx.fillStyle = torso;
|
||||
ctx.fillRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
|
||||
ctx.fillStyle = "rgba(29, 38, 30, 0.42)";
|
||||
ctx.fillRect(-target.torsoW / 2, target.torsoH * 0.54, target.torsoW, target.torsoH * 0.46);
|
||||
ctx.fillStyle = "rgba(217, 207, 151, 0.32)";
|
||||
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.lineWidth = 1.5;
|
||||
ctx.strokeRect(-target.torsoW / 2, 0, target.torsoW, target.torsoH);
|
||||
|
||||
ctx.fillStyle = "#afa678";
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, -target.headRadius, target.headRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(48, 55, 40, 0.48)";
|
||||
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.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.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 = "rgba(211, 202, 149, 0.12)";
|
||||
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);
|
||||
}
|
||||
}
|
||||
664
src/components/frontend/qualification/AimTrainerGame.tsx
Normal file
664
src/components/frontend/qualification/AimTrainerGame.tsx
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
"use client";
|
||||
|
||||
// allow: SIZE_OK — the course state machine keeps its rAF loop and submission lifecycle together.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Crosshair, RotateCcw, Target, Timer, Trophy } from "lucide-react";
|
||||
|
||||
import {
|
||||
AIM_TRAINER_DIFFICULTIES,
|
||||
calculateAimTrainerAccuracy,
|
||||
calculateAimTrainerScore,
|
||||
COURSE_DURATION_MS,
|
||||
HEADSHOT_POINTS,
|
||||
createAimTrainerTarget,
|
||||
resolveAimTrainerTargetHit,
|
||||
stepAimTrainerTarget,
|
||||
TORSO_POINTS,
|
||||
type AimTrainerDifficulty,
|
||||
} from "@/lib/aim-trainer";
|
||||
import {
|
||||
type AimTrainerResultInput,
|
||||
type AimTrainerStats,
|
||||
submitAimTrainerResult,
|
||||
} from "@/app/(frontend)/qualification/actions";
|
||||
import { LoginLink } from "@/components/frontend/auth/LoginLink";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { PROFILE_UPDATE_EVENT } from "@/hooks/useGameTick";
|
||||
import { useSessionExpired } from "@/hooks/useSessionExpired";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AIM_TRAINER_CANVAS_HEIGHT as CANVAS_H,
|
||||
AIM_TRAINER_CANVAS_WIDTH as CANVAS_W,
|
||||
AIM_TRAINER_FEEDBACK_DURATION_MS as FEEDBACK_DURATION_MS,
|
||||
AIM_TRAINER_FLASH_DURATION_MS as FLASH_DURATION_MS,
|
||||
createAimTrainerGameState,
|
||||
drawAimTrainerScene,
|
||||
emptyAimTrainerLiveStats,
|
||||
getAimTrainerLiveStats,
|
||||
type AimTrainerActiveTarget,
|
||||
type AimTrainerGameState,
|
||||
type AimTrainerLiveStats,
|
||||
} from "./AimTrainerCanvas";
|
||||
import {
|
||||
playCountdownTick,
|
||||
playFire,
|
||||
playHeadshot,
|
||||
playMiss,
|
||||
playRoundComplete,
|
||||
playTorsoHit,
|
||||
playXp,
|
||||
} from "./sounds";
|
||||
|
||||
const FINAL_COUNTDOWN_WINDOW_MS = 10_000;
|
||||
const CRITICAL_COUNTDOWN_WINDOW_MS = 3_000;
|
||||
const MAX_FRAME_DT_SEC = 0.05;
|
||||
const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000;
|
||||
|
||||
const TIER_META = {
|
||||
recruit: {
|
||||
label: "Recruit",
|
||||
description: "Stationary silhouettes with a forgiving acquisition window.",
|
||||
},
|
||||
veteran: {
|
||||
label: "Veteran",
|
||||
description: "A mixed range with faster movement and tighter hit zones.",
|
||||
},
|
||||
elite: {
|
||||
label: "Elite",
|
||||
description: "High-speed targets. Every sight picture matters.",
|
||||
},
|
||||
} as const satisfies Record<
|
||||
AimTrainerDifficulty,
|
||||
{ readonly label: string; readonly description: string }
|
||||
>;
|
||||
|
||||
const TIER_SCORE_KEYS = {
|
||||
recruit: "recruitBestScore",
|
||||
veteran: "veteranBestScore",
|
||||
elite: "eliteBestScore",
|
||||
} as const satisfies Record<
|
||||
AimTrainerDifficulty,
|
||||
"recruitBestScore" | "veteranBestScore" | "eliteBestScore"
|
||||
>;
|
||||
|
||||
export type AimTrainerPhase = "ready" | "playing" | "over";
|
||||
|
||||
type LastRun = {
|
||||
readonly hitScore: number;
|
||||
readonly accuracyBonus: number;
|
||||
readonly totalScore: number;
|
||||
readonly accuracy: number;
|
||||
readonly headshots: number;
|
||||
readonly torsoHits: number;
|
||||
readonly shotsFired: number;
|
||||
readonly despawnedUnhitTargets: number;
|
||||
readonly xpEarned: number;
|
||||
readonly bestScore: number;
|
||||
readonly isNewRecord: boolean;
|
||||
readonly error?: string;
|
||||
};
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected hit zone: ${String(value)}`);
|
||||
}
|
||||
|
||||
function tierBestScore(stats: AimTrainerStats, difficulty: AimTrainerDifficulty): number {
|
||||
return stats[TIER_SCORE_KEYS[difficulty]];
|
||||
}
|
||||
|
||||
function formatAccuracy(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function HudMetric({
|
||||
label,
|
||||
value,
|
||||
urgent = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
urgent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col gap-1 rounded-md border border-border bg-background/70 px-3 py-2",
|
||||
urgent && "border-destructive/50 bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-base font-semibold tabular-nums",
|
||||
urgent && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryMetric({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 rounded-md border border-border bg-background/60 px-3 py-2">
|
||||
<dt className="text-xs uppercase tracking-wider text-muted-foreground">{label}</dt>
|
||||
<dd className="font-mono text-sm font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AimTrainerGame({
|
||||
initialStats: serverStats,
|
||||
difficulty,
|
||||
onGameRecordedAction,
|
||||
onPhaseChangeAction,
|
||||
}: {
|
||||
readonly initialStats: AimTrainerStats;
|
||||
readonly difficulty: AimTrainerDifficulty;
|
||||
readonly onGameRecordedAction?: (stats: AimTrainerStats) => void;
|
||||
readonly onPhaseChangeAction?: (phase: AimTrainerPhase) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { expired, expiredRef } = useSessionExpired();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const phaseRef = useRef<AimTrainerPhase>("ready");
|
||||
const submittingRef = useRef(false);
|
||||
const statsRef = useRef<AimTrainerStats>(serverStats);
|
||||
const gameRef = useRef<AimTrainerGameState>(createAimTrainerGameState());
|
||||
const [phase, setPhase] = useState<AimTrainerPhase>("ready");
|
||||
const [timerSeconds, setTimerSeconds] = useState(COURSE_DURATION_SECONDS);
|
||||
const [live, setLive] = useState<AimTrainerLiveStats>(emptyAimTrainerLiveStats);
|
||||
const [stats, setStats] = useState<AimTrainerStats>(serverStats);
|
||||
const [lastRun, setLastRun] = useState<LastRun | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const tier = TIER_META[difficulty];
|
||||
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
|
||||
|
||||
const setPhaseSafe = useCallback(
|
||||
(nextPhase: AimTrainerPhase) => {
|
||||
phaseRef.current = nextPhase;
|
||||
setPhase(nextPhase);
|
||||
onPhaseChangeAction?.(nextPhase);
|
||||
},
|
||||
[onPhaseChangeAction],
|
||||
);
|
||||
|
||||
const handleRoundComplete = useCallback(
|
||||
async (round: AimTrainerResultInput) => {
|
||||
if (submittingRef.current) return;
|
||||
const hits = round.headshots + round.torsoHits;
|
||||
const accuracy = calculateAimTrainerAccuracy(
|
||||
hits,
|
||||
round.shotsFired,
|
||||
round.despawnedUnhitTargets,
|
||||
);
|
||||
const totalScore = calculateAimTrainerScore(
|
||||
round.headshots,
|
||||
round.torsoHits,
|
||||
round.shotsFired,
|
||||
round.despawnedUnhitTargets,
|
||||
);
|
||||
const hitScore = round.headshots * HEADSHOT_POINTS + round.torsoHits * TORSO_POINTS;
|
||||
const previousTierBest = tierBestScore(statsRef.current, difficulty);
|
||||
const preview: LastRun = {
|
||||
hitScore,
|
||||
accuracyBonus: Math.max(0, totalScore - hitScore),
|
||||
totalScore,
|
||||
accuracy,
|
||||
headshots: round.headshots,
|
||||
torsoHits: round.torsoHits,
|
||||
shotsFired: round.shotsFired,
|
||||
despawnedUnhitTargets: round.despawnedUnhitTargets,
|
||||
xpEarned: 0,
|
||||
bestScore: previousTierBest,
|
||||
isNewRecord: false,
|
||||
};
|
||||
setLastRun(preview);
|
||||
|
||||
if (expiredRef.current) {
|
||||
setLastRun({ ...preview, error: "Your session expired. Result not recorded." });
|
||||
return;
|
||||
}
|
||||
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await submitAimTrainerResult(round);
|
||||
if (result.success && result.data) {
|
||||
statsRef.current = result.data;
|
||||
setStats(result.data);
|
||||
onGameRecordedAction?.(result.data);
|
||||
if (result.data.xpEarned > 0) {
|
||||
playXp();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(PROFILE_UPDATE_EVENT, {
|
||||
detail: { reason: "xp", xpGained: result.data.xpEarned },
|
||||
}),
|
||||
);
|
||||
window.setTimeout(() => router.refresh(), 1700);
|
||||
}
|
||||
setLastRun({
|
||||
...preview,
|
||||
accuracy: result.data.accuracy,
|
||||
accuracyBonus: Math.max(0, result.data.score - hitScore),
|
||||
totalScore: result.data.score,
|
||||
xpEarned: result.data.xpEarned,
|
||||
bestScore: tierBestScore(result.data, difficulty),
|
||||
isNewRecord: result.data.score > previousTierBest,
|
||||
});
|
||||
} else {
|
||||
setLastRun({ ...preview, error: result.error ?? "Could not record qualification." });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Could not record qualification.";
|
||||
setLastRun({ ...preview, error: message });
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[difficulty, expiredRef, onGameRecordedAction, router],
|
||||
);
|
||||
|
||||
const handleRoundCompleteRef = useRef(handleRoundComplete);
|
||||
useEffect(() => {
|
||||
handleRoundCompleteRef.current = handleRoundComplete;
|
||||
}, [handleRoundComplete]);
|
||||
|
||||
const resetToReady = useCallback(() => {
|
||||
if (expiredRef.current || submittingRef.current) return;
|
||||
gameRef.current = createAimTrainerGameState();
|
||||
setLive(emptyAimTrainerLiveStats());
|
||||
setTimerSeconds(COURSE_DURATION_SECONDS);
|
||||
setLastRun(null);
|
||||
setPhaseSafe("ready");
|
||||
}, [expiredRef, setPhaseSafe]);
|
||||
|
||||
const startGame = useCallback(() => {
|
||||
if (expiredRef.current || submittingRef.current) return;
|
||||
const now = performance.now();
|
||||
const game = createAimTrainerGameState();
|
||||
game.pendingSpawnAts = Array.from(
|
||||
{ length: config.maxConcurrentTargets },
|
||||
(_, index) => now + index * config.replacementDelayMs,
|
||||
);
|
||||
game.startedAt = now;
|
||||
gameRef.current = game;
|
||||
setLive(emptyAimTrainerLiveStats());
|
||||
setTimerSeconds(COURSE_DURATION_SECONDS);
|
||||
setLastRun(null);
|
||||
setPhaseSafe("playing");
|
||||
wrapperRef.current?.focus({ preventScroll: true });
|
||||
}, [config.maxConcurrentTargets, config.replacementDelayMs, expiredRef, setPhaseSafe]);
|
||||
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
const currentPhase = phaseRef.current;
|
||||
switch (currentPhase) {
|
||||
case "ready":
|
||||
startGame();
|
||||
return;
|
||||
case "playing":
|
||||
return;
|
||||
case "over":
|
||||
resetToReady();
|
||||
return;
|
||||
default:
|
||||
assertNever(currentPhase);
|
||||
}
|
||||
}, [resetToReady, startGame]);
|
||||
|
||||
const handleShot = useCallback(
|
||||
(event: PointerEvent<HTMLCanvasElement>) => {
|
||||
if (event.button !== 0 || phaseRef.current !== "playing") return;
|
||||
if (expiredRef.current || submittingRef.current) return;
|
||||
event.preventDefault();
|
||||
wrapperRef.current?.focus({ preventScroll: true });
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = ((event.clientX - rect.left) / rect.width) * CANVAS_W;
|
||||
const y = ((event.clientY - rect.top) / rect.height) * CANVAS_H;
|
||||
const game = gameRef.current;
|
||||
const now = performance.now();
|
||||
game.shotsFired += 1;
|
||||
playFire();
|
||||
|
||||
const hit = resolveAimTrainerTargetHit(
|
||||
game.targets.map((activeTarget) => activeTarget.target),
|
||||
x,
|
||||
y,
|
||||
);
|
||||
if (!hit) {
|
||||
playMiss();
|
||||
game.feedback.push({ x, y, text: "MISS", color: "#b8b49c", createdAt: now });
|
||||
setLive(getAimTrainerLiveStats(game));
|
||||
return;
|
||||
}
|
||||
|
||||
const [activeTarget] = game.targets.splice(hit.index, 1);
|
||||
if (!activeTarget) return;
|
||||
const target = activeTarget.target;
|
||||
game.pendingSpawnAts.push(now + config.replacementDelayMs);
|
||||
switch (hit.zone) {
|
||||
case "head":
|
||||
game.headshots += 1;
|
||||
game.feedback.push({
|
||||
x: target.x,
|
||||
y: target.y - target.headRadius,
|
||||
text: `+${HEADSHOT_POINTS}`,
|
||||
color: "#f0e6a8",
|
||||
createdAt: now,
|
||||
});
|
||||
game.flashColor = "#e5d98a";
|
||||
game.flashUntil = now + FLASH_DURATION_MS;
|
||||
playHeadshot();
|
||||
break;
|
||||
case "torso":
|
||||
game.torsoHits += 1;
|
||||
game.feedback.push({
|
||||
x: target.x,
|
||||
y: target.y + target.torsoH * 0.45,
|
||||
text: `+${TORSO_POINTS}`,
|
||||
color: "#c8c59d",
|
||||
createdAt: now,
|
||||
});
|
||||
game.flashColor = "#aaa878";
|
||||
game.flashUntil = now + FLASH_DURATION_MS;
|
||||
playTorsoHit();
|
||||
break;
|
||||
default:
|
||||
assertNever(hit.zone);
|
||||
}
|
||||
setLive(getAimTrainerLiveStats(game));
|
||||
},
|
||||
[config.replacementDelayMs, expiredRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = CANVAS_W * dpr;
|
||||
canvas.height = CANVAS_H * dpr;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
let raf = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
const finish = () => {
|
||||
const game = gameRef.current;
|
||||
if (phaseRef.current !== "playing") return;
|
||||
game.targets = [];
|
||||
game.pendingSpawnAts = [];
|
||||
setTimerSeconds(0);
|
||||
setLive(getAimTrainerLiveStats(game));
|
||||
setPhaseSafe("over");
|
||||
playRoundComplete();
|
||||
void handleRoundCompleteRef.current({
|
||||
difficulty,
|
||||
headshots: game.headshots,
|
||||
torsoHits: game.torsoHits,
|
||||
shotsFired: game.shotsFired,
|
||||
despawnedUnhitTargets: game.despawnedUnhitTargets,
|
||||
});
|
||||
};
|
||||
|
||||
const step = (now: number) => {
|
||||
raf = requestAnimationFrame(step);
|
||||
const dtSec = Math.min(Math.max((now - lastTime) / 1000, 0), MAX_FRAME_DT_SEC);
|
||||
lastTime = now;
|
||||
const game = gameRef.current;
|
||||
|
||||
if (phaseRef.current === "playing") {
|
||||
const elapsed = now - game.startedAt;
|
||||
const remaining = Math.max(0, COURSE_DURATION_MS - elapsed);
|
||||
if (remaining === 0) {
|
||||
finish();
|
||||
} else {
|
||||
const displayedSecond =
|
||||
remaining <= FINAL_COUNTDOWN_WINDOW_MS
|
||||
? Math.ceil(remaining / 1000)
|
||||
: COURSE_DURATION_SECONDS;
|
||||
if (displayedSecond !== game.lastDisplayedSecond) {
|
||||
game.lastDisplayedSecond = displayedSecond;
|
||||
setTimerSeconds(displayedSecond);
|
||||
if (remaining <= CRITICAL_COUNTDOWN_WINDOW_MS) playCountdownTick(true);
|
||||
else if (remaining <= FINAL_COUNTDOWN_WINDOW_MS) playCountdownTick();
|
||||
}
|
||||
|
||||
const survivingTargets: AimTrainerActiveTarget[] = [];
|
||||
let expiredTargets = 0;
|
||||
for (const activeTarget of game.targets) {
|
||||
if (now - activeTarget.spawnedAt >= config.targetLifetimeMs) {
|
||||
expiredTargets += 1;
|
||||
game.pendingSpawnAts.push(now + config.replacementDelayMs);
|
||||
continue;
|
||||
}
|
||||
survivingTargets.push({
|
||||
target: stepAimTrainerTarget(activeTarget.target, dtSec, CANVAS_W, CANVAS_H),
|
||||
spawnedAt: activeTarget.spawnedAt,
|
||||
});
|
||||
}
|
||||
game.targets = survivingTargets;
|
||||
if (expiredTargets > 0) {
|
||||
game.despawnedUnhitTargets += expiredTargets;
|
||||
setLive(getAimTrainerLiveStats(game));
|
||||
}
|
||||
|
||||
while (game.targets.length < 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),
|
||||
spawnedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
game.feedback = game.feedback.filter((item) => now - item.createdAt < FEEDBACK_DURATION_MS);
|
||||
drawAimTrainerScene(ctx, game, {
|
||||
now,
|
||||
targetLifetimeMs: config.targetLifetimeMs,
|
||||
});
|
||||
};
|
||||
|
||||
raf = requestAnimationFrame(step);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Enter" && event.code !== "Space") return;
|
||||
if (event.repeat) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handlePrimaryAction();
|
||||
};
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.code !== "Space") return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown, { capture: true });
|
||||
window.addEventListener("keyup", onKeyUp, { capture: true });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("keydown", onKeyDown, { capture: true });
|
||||
window.removeEventListener("keyup", onKeyUp, { capture: true });
|
||||
};
|
||||
}, [
|
||||
config.replacementDelayMs,
|
||||
config.maxConcurrentTargets,
|
||||
config.targetLifetimeMs,
|
||||
difficulty,
|
||||
handlePrimaryAction,
|
||||
setPhaseSafe,
|
||||
]);
|
||||
|
||||
const timerIsCritical = phase === "playing" && timerSeconds <= 3;
|
||||
const timerLabel = phase === "ready" ? `${COURSE_DURATION_SECONDS}s` : `${timerSeconds}s`;
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-3xl overflow-hidden">
|
||||
<CardHeader className="gap-3 border-b border-border">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-md border border-border bg-background p-2 text-primary">
|
||||
<Crosshair aria-hidden="true" className="size-5" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle>Live-fire qualification</CardTitle>
|
||||
<CardDescription>{tier.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={timerIsCritical ? "destructive" : "outline"}>
|
||||
<Timer data-icon="inline-start" />
|
||||
{timerLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5" aria-live="polite">
|
||||
<HudMetric label="Score" value={live.score} />
|
||||
<HudMetric label="Accuracy" value={formatAccuracy(live.accuracy)} />
|
||||
<HudMetric label="Shots" value={live.shotsFired} />
|
||||
<HudMetric label="Tier" value={tier.label} />
|
||||
<HudMetric label="Time" value={timerLabel} urgent={timerIsCritical} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4 p-4">
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
tabIndex={-1}
|
||||
className="relative mx-auto w-full cursor-crosshair select-none overflow-hidden rounded-lg border border-border bg-background outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={CANVAS_W}
|
||||
height={CANVAS_H}
|
||||
onPointerDown={handleShot}
|
||||
className="block h-auto w-full"
|
||||
aria-label="Qualification Course target range"
|
||||
/>
|
||||
|
||||
{phase === "ready" && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/75 p-4 text-center">
|
||||
<Badge variant="outline">{tier.label} lane</Badge>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-lg font-semibold tracking-wide text-foreground">
|
||||
Ready on the line
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Acquire every silhouette before it clears the range.
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
className="motion-reduce:transition-none"
|
||||
onClick={startGame}
|
||||
disabled={expired || submitting}
|
||||
>
|
||||
<Target data-icon="inline-start" />
|
||||
Begin Qualification
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Click the target or press Enter / Space to begin. Course duration:{" "}
|
||||
{COURSE_DURATION_SECONDS}s.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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" />
|
||||
<span className="text-lg font-semibold tracking-wide">Course complete</span>
|
||||
</div>
|
||||
{lastRun.isNewRecord && <Badge className="mx-auto">New record!</Badge>}
|
||||
<dl className="mx-auto grid w-full max-w-xl grid-cols-2 gap-2 text-left sm:grid-cols-3">
|
||||
<SummaryMetric label="Hit score" value={lastRun.hitScore} />
|
||||
<SummaryMetric label="Accuracy bonus" value={`+${lastRun.accuracyBonus}`} />
|
||||
<SummaryMetric label="Total score" value={lastRun.totalScore} />
|
||||
<SummaryMetric label="Accuracy" value={formatAccuracy(lastRun.accuracy)} />
|
||||
<SummaryMetric label="Headshots" value={lastRun.headshots} />
|
||||
<SummaryMetric label="Torso hits" value={lastRun.torsoHits} />
|
||||
</dl>
|
||||
<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>
|
||||
</div>
|
||||
{lastRun.error && <span className="text-xs text-destructive">{lastRun.error}</span>}
|
||||
{submitting && (
|
||||
<span className="text-xs text-muted-foreground">Recording result…</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mx-auto motion-reduce:transition-none"
|
||||
onClick={resetToReady}
|
||||
disabled={submitting || expired}
|
||||
>
|
||||
<RotateCcw data-icon="inline-start" />
|
||||
Play Again
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Press Enter or Space to return to the line.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expired && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 p-4 text-center">
|
||||
<span className="text-xl font-semibold tracking-wide text-destructive">
|
||||
Session ending soon
|
||||
</span>
|
||||
<span className="max-w-md text-sm text-muted-foreground">
|
||||
Your result will not be recorded after the session expires. Log in again to
|
||||
continue.
|
||||
</span>
|
||||
<LoginLink>
|
||||
<Button type="button" size="sm" className="motion-reduce:transition-none">
|
||||
Log in
|
||||
</Button>
|
||||
</LoginLink>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-x-6 gap-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Games</span>
|
||||
<span className="font-semibold text-foreground">{stats.gamesPlayed}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>Best score</span>
|
||||
<span className="font-semibold text-foreground">{stats.bestScore}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{tier.label} best</span>
|
||||
<span className="font-semibold text-foreground">
|
||||
{tierBestScore(stats, difficulty)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>XP from the course</span>
|
||||
<span className="font-semibold text-foreground">{stats.aimTrainerXp}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Headshots score {HEADSHOT_POINTS}. Torso hits score {TORSO_POINTS}.
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
145
src/components/frontend/qualification/sounds.ts
Normal file
145
src/components/frontend/qualification/sounds.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
let audioCtx: AudioContext | null = null;
|
||||
|
||||
function getCtx(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const AudioCtor = window.AudioContext ?? window.webkitAudioContext;
|
||||
if (!AudioCtor) return null;
|
||||
if (!audioCtx) audioCtx = new AudioCtor();
|
||||
if (audioCtx.state === "suspended") void audioCtx.resume();
|
||||
return audioCtx;
|
||||
}
|
||||
|
||||
type ToneOptions = {
|
||||
readonly frequency: number;
|
||||
readonly endFrequency?: number;
|
||||
readonly duration: number;
|
||||
readonly type?: OscillatorType;
|
||||
readonly volume?: number;
|
||||
readonly delay?: number;
|
||||
};
|
||||
|
||||
function playTone(ctx: AudioContext, options: ToneOptions): void {
|
||||
const { frequency, endFrequency, duration, type = "sine", volume = 0.2, delay = 0 } = options;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
const startAt = ctx.currentTime + delay;
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.setValueAtTime(Math.max(frequency, 1), startAt);
|
||||
if (endFrequency !== undefined && endFrequency !== frequency) {
|
||||
oscillator.frequency.exponentialRampToValueAtTime(
|
||||
Math.max(endFrequency, 1),
|
||||
startAt + duration,
|
||||
);
|
||||
}
|
||||
gain.gain.setValueAtTime(0.0001, startAt);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, startAt + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + duration);
|
||||
oscillator.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
oscillator.start(startAt);
|
||||
oscillator.stop(startAt + duration + 0.05);
|
||||
}
|
||||
|
||||
function playNoise(
|
||||
ctx: AudioContext,
|
||||
options: { readonly duration: number; readonly volume?: number; readonly lowpass?: number },
|
||||
): void {
|
||||
const { duration, volume = 0.2, lowpass = 800 } = options;
|
||||
const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * duration));
|
||||
const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let index = 0; index < bufferSize; index += 1) {
|
||||
data[index] = Math.random() * 2 - 1;
|
||||
}
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = "lowpass";
|
||||
filter.frequency.setValueAtTime(lowpass, ctx.currentTime);
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(volume, ctx.currentTime + 0.005);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
|
||||
source.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
source.start();
|
||||
}
|
||||
|
||||
export function playFire(): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, {
|
||||
frequency: 180,
|
||||
endFrequency: 90,
|
||||
duration: 0.055,
|
||||
type: "square",
|
||||
volume: 0.12,
|
||||
});
|
||||
}
|
||||
|
||||
export function playHeadshot(): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, {
|
||||
frequency: 720,
|
||||
endFrequency: 1180,
|
||||
duration: 0.12,
|
||||
type: "triangle",
|
||||
volume: 0.16,
|
||||
});
|
||||
playTone(ctx, { frequency: 1040, duration: 0.16, type: "sine", volume: 0.12, delay: 0.06 });
|
||||
}
|
||||
|
||||
export function playTorsoHit(): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, {
|
||||
frequency: 360,
|
||||
endFrequency: 220,
|
||||
duration: 0.1,
|
||||
type: "triangle",
|
||||
volume: 0.15,
|
||||
});
|
||||
}
|
||||
|
||||
export function playMiss(): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, {
|
||||
frequency: 120,
|
||||
endFrequency: 55,
|
||||
duration: 0.18,
|
||||
type: "sawtooth",
|
||||
volume: 0.14,
|
||||
});
|
||||
playNoise(ctx, { duration: 0.11, volume: 0.11, lowpass: 480 });
|
||||
}
|
||||
|
||||
export function playCountdownTick(critical = false): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
const frequency = critical ? 880 : 520;
|
||||
playTone(ctx, { frequency, duration: critical ? 0.09 : 0.06, type: "square", volume: 0.1 });
|
||||
}
|
||||
|
||||
export function playRoundComplete(): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 420, duration: 0.12, type: "triangle", volume: 0.14 });
|
||||
playTone(ctx, { frequency: 630, duration: 0.12, type: "triangle", volume: 0.14, delay: 0.1 });
|
||||
playTone(ctx, { frequency: 940, duration: 0.24, type: "sine", volume: 0.16, delay: 0.2 });
|
||||
}
|
||||
|
||||
export function playXp(): void {
|
||||
const ctx = getCtx();
|
||||
if (!ctx) return;
|
||||
playTone(ctx, { frequency: 660, duration: 0.09, type: "sine", volume: 0.18 });
|
||||
playTone(ctx, { frequency: 880, duration: 0.16, type: "sine", volume: 0.18, delay: 0.09 });
|
||||
}
|
||||
Loading…
Reference in a new issue