Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
181 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|