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

181 lines
7.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useState } from "react";
import {
AIM_TRAINER_DIFFICULTIES,
isAimTrainerDifficulty,
type AimTrainerDifficulty,
} from "@/lib/minigames/aim-trainer";
import type { AimTrainerStats } from "@/app/(frontend)/minigames/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} hostile silhouettes
are active at once, plus an occasional blue civilian that never counts toward the
limit. Targets rebound off each other and the range walls.
</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. Blue civilians cost 100
points, so never shoot them.
</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>
);
}