677 lines
24 KiB
TypeScript
677 lines
24 KiB
TypeScript
"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 handleRestartAction = useCallback(() => {
|
|
if (phaseRef.current !== "playing") return;
|
|
startGame();
|
|
}, [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 === "KeyR") {
|
|
if (event.repeat || event.ctrlKey || event.metaKey || event.altKey) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleRestartAction();
|
|
return;
|
|
}
|
|
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,
|
|
handleRestartAction,
|
|
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>
|
|
);
|
|
}
|