Compare commits
8 commits
72ef20a044
...
891e235397
| Author | SHA1 | Date | |
|---|---|---|---|
| 891e235397 | |||
| 8e9262ca68 | |||
| d04b2d3ecb | |||
| 320fe4b36e | |||
| e3e4760f49 | |||
| 282e23edc2 | |||
| a6a7a5639d | |||
| 73d689a680 |
17 changed files with 2671 additions and 6 deletions
239
src/app/(frontend)/qualification/actions.ts
Normal file
239
src/app/(frontend)/qualification/actions.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { emitGameEvent } from "@/utils/event-log/emit";
|
||||||
|
import { EventTypes } from "@/utils/event-log/eventTypes";
|
||||||
|
import { broadcastToUser } from "@/lib/realtime/bus";
|
||||||
|
import {
|
||||||
|
isAimTrainerDifficulty,
|
||||||
|
calculateAimTrainerAccuracy,
|
||||||
|
calculateAimTrainerScore,
|
||||||
|
calculateAimTrainerXp,
|
||||||
|
MAX_SHOTS_FIRED,
|
||||||
|
MAX_HITS,
|
||||||
|
MAX_DESPAWNED_TARGETS,
|
||||||
|
} from "@/lib/aim-trainer";
|
||||||
|
|
||||||
|
interface ActionResult<T = undefined> {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
data?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AimTrainerStats {
|
||||||
|
gamesPlayed: number;
|
||||||
|
bestScore: number;
|
||||||
|
recruitBestScore: number;
|
||||||
|
veteranBestScore: number;
|
||||||
|
eliteBestScore: number;
|
||||||
|
recruitBestAccuracy: number;
|
||||||
|
veteranBestAccuracy: number;
|
||||||
|
eliteBestAccuracy: number;
|
||||||
|
aimTrainerXp: number;
|
||||||
|
xpEarned: number;
|
||||||
|
score: number;
|
||||||
|
accuracy: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AimTrainerResultInput {
|
||||||
|
difficulty: string;
|
||||||
|
headshots: number;
|
||||||
|
torsoHits: number;
|
||||||
|
shotsFired: number;
|
||||||
|
despawnedUnhitTargets: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authenticate() {
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
const hdrs = await headers();
|
||||||
|
const { user } = await payload.auth({
|
||||||
|
headers: hdrs,
|
||||||
|
canSetHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitAimTrainerResult(
|
||||||
|
input: AimTrainerResultInput,
|
||||||
|
): Promise<ActionResult<AimTrainerStats>> {
|
||||||
|
try {
|
||||||
|
const { payload, user } = await authenticate();
|
||||||
|
|
||||||
|
if (!isAimTrainerDifficulty(input.difficulty)) {
|
||||||
|
return { success: false, error: "Invalid difficulty." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { headshots, torsoHits, shotsFired, despawnedUnhitTargets } = input;
|
||||||
|
if (
|
||||||
|
!Number.isInteger(headshots) ||
|
||||||
|
headshots < 0 ||
|
||||||
|
!Number.isInteger(torsoHits) ||
|
||||||
|
torsoHits < 0 ||
|
||||||
|
!Number.isInteger(shotsFired) ||
|
||||||
|
shotsFired < 0 ||
|
||||||
|
!Number.isInteger(despawnedUnhitTargets) ||
|
||||||
|
despawnedUnhitTargets < 0
|
||||||
|
) {
|
||||||
|
return { success: false, error: "Invalid result." };
|
||||||
|
}
|
||||||
|
if (shotsFired > MAX_SHOTS_FIRED) {
|
||||||
|
return { success: false, error: "Too many shots fired." };
|
||||||
|
}
|
||||||
|
const hits = headshots + torsoHits;
|
||||||
|
if (hits > MAX_HITS) {
|
||||||
|
return { success: false, error: "Too many hits." };
|
||||||
|
}
|
||||||
|
if (hits > shotsFired) {
|
||||||
|
return { success: false, error: "Hits cannot exceed shots fired." };
|
||||||
|
}
|
||||||
|
if (despawnedUnhitTargets > MAX_DESPAWNED_TARGETS) {
|
||||||
|
return { success: false, error: "Too many despawned targets." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const profile = profileRes.docs[0];
|
||||||
|
if (!profile) {
|
||||||
|
return { success: false, error: "No profile found for this user." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const progression = (profile.progression ?? {}) as {
|
||||||
|
xp?: unknown;
|
||||||
|
minigames?: {
|
||||||
|
aimTrainer?: {
|
||||||
|
gamesPlayed?: number;
|
||||||
|
bestScore?: number;
|
||||||
|
recruitBestScore?: number;
|
||||||
|
veteranBestScore?: number;
|
||||||
|
eliteBestScore?: number;
|
||||||
|
recruitBestAccuracy?: number;
|
||||||
|
veteranBestAccuracy?: number;
|
||||||
|
eliteBestAccuracy?: number;
|
||||||
|
xpEarned?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const current = progression.minigames?.aimTrainer ?? {};
|
||||||
|
|
||||||
|
const accuracy = calculateAimTrainerAccuracy(hits, shotsFired, despawnedUnhitTargets);
|
||||||
|
const score = calculateAimTrainerScore(headshots, torsoHits, shotsFired, despawnedUnhitTargets);
|
||||||
|
|
||||||
|
const difficultyBestScoreKey = `${input.difficulty}BestScore` as
|
||||||
|
| "recruitBestScore"
|
||||||
|
| "veteranBestScore"
|
||||||
|
| "eliteBestScore";
|
||||||
|
const difficultyBestAccuracyKey = `${input.difficulty}BestAccuracy` as
|
||||||
|
| "recruitBestAccuracy"
|
||||||
|
| "veteranBestAccuracy"
|
||||||
|
| "eliteBestAccuracy";
|
||||||
|
|
||||||
|
const isNewTierBest = score > (current[difficultyBestScoreKey] ?? 0);
|
||||||
|
const xp = calculateAimTrainerXp(score, isNewTierBest);
|
||||||
|
|
||||||
|
const gamesPlayed = (current.gamesPlayed ?? 0) + 1;
|
||||||
|
const bestScore = Math.max(current.bestScore ?? 0, score);
|
||||||
|
const difficultyBestScore = Math.max(current[difficultyBestScoreKey] ?? 0, score);
|
||||||
|
const difficultyBestAccuracy = Math.max(current[difficultyBestAccuracyKey] ?? 0, accuracy);
|
||||||
|
const aimTrainerXp = (current.xpEarned ?? 0) + xp;
|
||||||
|
|
||||||
|
const { xp: _xp, ...progressionRest } = progression;
|
||||||
|
|
||||||
|
const recruitBestScore = input.difficulty === "recruit" ? difficultyBestScore : (current.recruitBestScore ?? 0);
|
||||||
|
const veteranBestScore = input.difficulty === "veteran" ? difficultyBestScore : (current.veteranBestScore ?? 0);
|
||||||
|
const eliteBestScore = input.difficulty === "elite" ? difficultyBestScore : (current.eliteBestScore ?? 0);
|
||||||
|
const recruitBestAccuracy =
|
||||||
|
input.difficulty === "recruit" ? difficultyBestAccuracy : (current.recruitBestAccuracy ?? 0);
|
||||||
|
const veteranBestAccuracy =
|
||||||
|
input.difficulty === "veteran" ? difficultyBestAccuracy : (current.veteranBestAccuracy ?? 0);
|
||||||
|
const eliteBestAccuracy =
|
||||||
|
input.difficulty === "elite" ? difficultyBestAccuracy : (current.eliteBestAccuracy ?? 0);
|
||||||
|
|
||||||
|
await payload.update({
|
||||||
|
collection: "profiles",
|
||||||
|
id: profile.id,
|
||||||
|
data: {
|
||||||
|
progression: {
|
||||||
|
...progressionRest,
|
||||||
|
minigames: {
|
||||||
|
...(progressionRest.minigames ?? {}),
|
||||||
|
aimTrainer: {
|
||||||
|
gamesPlayed,
|
||||||
|
bestScore,
|
||||||
|
recruitBestScore,
|
||||||
|
veteranBestScore,
|
||||||
|
eliteBestScore,
|
||||||
|
recruitBestAccuracy,
|
||||||
|
veteranBestAccuracy,
|
||||||
|
eliteBestAccuracy,
|
||||||
|
xpEarned: aimTrainerXp,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
});
|
||||||
|
broadcastToUser("profile-update", Number(user.id), {
|
||||||
|
reason: "xp",
|
||||||
|
xpGained: xp,
|
||||||
|
profileId: profile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (score > 0) {
|
||||||
|
const pct = Math.round(accuracy * 100);
|
||||||
|
await emitGameEvent(payload, {
|
||||||
|
type: EventTypes.MinigameAimTrainer,
|
||||||
|
message: `completed the Qualification Course (${input.difficulty}) with a score of ${score} (${pct}% accuracy)`,
|
||||||
|
actor: user.id as number,
|
||||||
|
targetCollection: "users",
|
||||||
|
targetId: user.id as number,
|
||||||
|
data: {
|
||||||
|
difficulty: input.difficulty,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
xpEarned: xp,
|
||||||
|
bestScore,
|
||||||
|
difficultyBestScore,
|
||||||
|
aimTrainerXp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
gamesPlayed,
|
||||||
|
bestScore,
|
||||||
|
recruitBestScore,
|
||||||
|
veteranBestScore,
|
||||||
|
eliteBestScore,
|
||||||
|
recruitBestAccuracy,
|
||||||
|
veteranBestAccuracy,
|
||||||
|
eliteBestAccuracy,
|
||||||
|
aimTrainerXp,
|
||||||
|
xpEarned: xp,
|
||||||
|
score,
|
||||||
|
accuracy,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
if (message === "Unauthorized") {
|
||||||
|
return { success: false, error: "You must be logged in." };
|
||||||
|
}
|
||||||
|
return { success: false, error: message };
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/app/(frontend)/qualification/page.tsx
Normal file
70
src/app/(frontend)/qualification/page.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayload } from "payload";
|
||||||
|
import { headers as nextHeaders } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Crosshair } from "lucide-react";
|
||||||
|
|
||||||
|
import type { AimTrainerStats } from "@/app/(frontend)/qualification/actions";
|
||||||
|
import { AimTrainerArena } from "@/components/frontend/qualification/AimTrainerArena";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Qualification Course — Polaris Task Force",
|
||||||
|
description: "Complete a timed qualification course to sharpen your marksmanship and earn XP.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function QualificationPage() {
|
||||||
|
const headers = await nextHeaders();
|
||||||
|
const payloadConfig = await config;
|
||||||
|
const payload = await getPayload({ config: payloadConfig });
|
||||||
|
const { user } = await payload.auth({ headers, canSetHeaders: false });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect(`/login?returnTo=${encodeURIComponent("/qualification")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileRes = await payload.find({
|
||||||
|
collection: "profiles",
|
||||||
|
where: { user: { equals: user.id as number } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
select: {
|
||||||
|
progression: {
|
||||||
|
minigames: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const aimTrainer = profileRes.docs[0]?.progression?.minigames?.aimTrainer ?? {};
|
||||||
|
const stats: AimTrainerStats = {
|
||||||
|
gamesPlayed: aimTrainer.gamesPlayed ?? 0,
|
||||||
|
bestScore: aimTrainer.bestScore ?? 0,
|
||||||
|
recruitBestScore: aimTrainer.recruitBestScore ?? 0,
|
||||||
|
veteranBestScore: aimTrainer.veteranBestScore ?? 0,
|
||||||
|
eliteBestScore: aimTrainer.eliteBestScore ?? 0,
|
||||||
|
recruitBestAccuracy: aimTrainer.recruitBestAccuracy ?? 0,
|
||||||
|
veteranBestAccuracy: aimTrainer.veteranBestAccuracy ?? 0,
|
||||||
|
eliteBestAccuracy: aimTrainer.eliteBestAccuracy ?? 0,
|
||||||
|
aimTrainerXp: aimTrainer.xpEarned ?? 0,
|
||||||
|
xpEarned: 0,
|
||||||
|
score: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-6 p-5">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crosshair className="size-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Qualification Course</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Acquire targets, place your shots, and qualify under pressure.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AimTrainerArena initialStats={stats} currentUserId={user.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -122,6 +122,7 @@ export const Profiles: CollectionConfig = {
|
||||||
flappyBird?: { xpEarned?: number | null };
|
flappyBird?: { xpEarned?: number | null };
|
||||||
eodMinesweeper?: { xpEarned?: number | null };
|
eodMinesweeper?: { xpEarned?: number | null };
|
||||||
radioTraffic?: { xpEarned?: number | null };
|
radioTraffic?: { xpEarned?: number | null };
|
||||||
|
aimTrainer?: { xpEarned?: number | null };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const awardsList = progressionData?.awards || [];
|
const awardsList = progressionData?.awards || [];
|
||||||
|
|
@ -135,6 +136,7 @@ export const Profiles: CollectionConfig = {
|
||||||
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
|
const flappyXp = progressionData?.minigames?.flappyBird?.xpEarned ?? 0;
|
||||||
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
const eodXp = progressionData?.minigames?.eodMinesweeper?.xpEarned ?? 0;
|
||||||
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
const radioTrafficXp = progressionData?.minigames?.radioTraffic?.xpEarned ?? 0;
|
||||||
|
const aimTrainerXp = progressionData?.minigames?.aimTrainer?.xpEarned ?? 0;
|
||||||
|
|
||||||
if (awardIds.length > 0) {
|
if (awardIds.length > 0) {
|
||||||
await req.payload
|
await req.payload
|
||||||
|
|
@ -175,6 +177,7 @@ export const Profiles: CollectionConfig = {
|
||||||
// EOD Minesweeper: XP from cleared minefields
|
// EOD Minesweeper: XP from cleared minefields
|
||||||
xpAcc += eodXp;
|
xpAcc += eodXp;
|
||||||
xpAcc += radioTrafficXp;
|
xpAcc += radioTrafficXp;
|
||||||
|
xpAcc += aimTrainerXp;
|
||||||
|
|
||||||
return xpAcc;
|
return xpAcc;
|
||||||
},
|
},
|
||||||
|
|
@ -482,6 +485,66 @@ export const Profiles: CollectionConfig = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "aimTrainer",
|
||||||
|
label: "Aim Trainer",
|
||||||
|
type: "group",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "gamesPlayed",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bestScore",
|
||||||
|
label: "Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recruitBestScore",
|
||||||
|
label: "Recruit Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "veteranBestScore",
|
||||||
|
label: "Veteran Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eliteBestScore",
|
||||||
|
label: "Elite Best Score",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recruitBestAccuracy",
|
||||||
|
label: "Recruit Best Accuracy",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "veteranBestAccuracy",
|
||||||
|
label: "Veteran Best Accuracy",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "eliteBestAccuracy",
|
||||||
|
label: "Elite Best Accuracy",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "xpEarned",
|
||||||
|
label: "XP Earned",
|
||||||
|
type: "number",
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Bird,
|
Bird,
|
||||||
Bomb,
|
Bomb,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
|
Crosshair,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
LogOut,
|
LogOut,
|
||||||
Radio,
|
Radio,
|
||||||
|
|
@ -30,7 +31,6 @@ import {
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import type { User } from "@/payload-types";
|
|
||||||
import { ImpersonationModal } from "@/components/frontend/impersonation/ImpersonationModal";
|
import { ImpersonationModal } from "@/components/frontend/impersonation/ImpersonationModal";
|
||||||
import { getImpersonationUsers } from "@/app/(frontend)/impersonate/actions";
|
import { getImpersonationUsers } from "@/app/(frontend)/impersonate/actions";
|
||||||
|
|
||||||
|
|
@ -52,7 +52,6 @@ export function NavUser({
|
||||||
const [loggingOut, setLoggingOut] = useState(false);
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
const [showImpersonationModal, setShowImpersonationModal] = useState(false);
|
const [showImpersonationModal, setShowImpersonationModal] = useState(false);
|
||||||
const [impersonationUsers, setImpersonationUsers] = useState<any[]>([]);
|
const [impersonationUsers, setImpersonationUsers] = useState<any[]>([]);
|
||||||
const [loadingImpersonationUsers, setLoadingImpersonationUsers] = useState(false);
|
|
||||||
|
|
||||||
const primaryLine = showCallsign ? user.username : user.displayName;
|
const primaryLine = showCallsign ? user.username : user.displayName;
|
||||||
const secondaryLine = showCallsign ? user.displayName : user.username;
|
const secondaryLine = showCallsign ? user.displayName : user.username;
|
||||||
|
|
@ -71,14 +70,11 @@ export function NavUser({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenImpersonationModal = async () => {
|
const handleOpenImpersonationModal = async () => {
|
||||||
setLoadingImpersonationUsers(true);
|
|
||||||
try {
|
try {
|
||||||
const users = await getImpersonationUsers();
|
const users = await getImpersonationUsers();
|
||||||
setImpersonationUsers(users);
|
setImpersonationUsers(users);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load impersonation users:", error);
|
console.error("Failed to load impersonation users:", error);
|
||||||
} finally {
|
|
||||||
setLoadingImpersonationUsers(false);
|
|
||||||
}
|
}
|
||||||
setShowImpersonationModal(true);
|
setShowImpersonationModal(true);
|
||||||
};
|
};
|
||||||
|
|
@ -154,6 +150,12 @@ export function NavUser({
|
||||||
Radio Traffic
|
Radio Traffic
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem asChild>
|
||||||
|
<Link href="/qualification">
|
||||||
|
<Crosshair />
|
||||||
|
Qualification Course
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
220
src/components/frontend/qualification/AimTrainerLeaderboard.tsx
Normal file
220
src/components/frontend/qualification/AimTrainerLeaderboard.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Trophy } from "lucide-react";
|
||||||
|
|
||||||
|
import type { AimTrainerDifficulty } from "@/lib/aim-trainer";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { PresenceParticipant } from "@/hooks/useRealtimePresence";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
AIM_TRAINER_LEADERBOARD_FIELDS,
|
||||||
|
readAimTrainerProfiles,
|
||||||
|
} from "./aimTrainerLeaderboardData";
|
||||||
|
|
||||||
|
type LeaderboardRow = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly gamesPlayed: number;
|
||||||
|
readonly bestScore: number;
|
||||||
|
readonly bestAccuracy: number;
|
||||||
|
readonly isCurrentUser: boolean;
|
||||||
|
readonly isOutsideTopTen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rankClass(index: number): string {
|
||||||
|
if (index === 0) return "font-bold text-primary";
|
||||||
|
if (index === 1) return "font-semibold text-foreground";
|
||||||
|
if (index === 2) return "font-semibold text-muted-foreground";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AimTrainerLeaderboard({
|
||||||
|
refreshKey = 0,
|
||||||
|
difficulty,
|
||||||
|
currentUserId,
|
||||||
|
activeTrainees = [],
|
||||||
|
}: {
|
||||||
|
readonly refreshKey?: number;
|
||||||
|
readonly difficulty: AimTrainerDifficulty;
|
||||||
|
readonly currentUserId?: number | string;
|
||||||
|
readonly activeTrainees?: readonly PresenceParticipant[];
|
||||||
|
}) {
|
||||||
|
const [rows, setRows] = useState<readonly LeaderboardRow[] | null>(null);
|
||||||
|
const [failed, setFailed] = useState<string | null>(null);
|
||||||
|
const tier = AIM_TRAINER_LEADERBOARD_FIELDS[difficulty];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setFailed(null);
|
||||||
|
setRows(null);
|
||||||
|
const field = `progression.minigames.aimTrainer.${tier.scoreField}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: `-${field}`,
|
||||||
|
limit: "100",
|
||||||
|
depth: "1",
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch(`/api/profiles?${params.toString()}`)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) throw new Error("leaderboard fetch failed");
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
return readAimTrainerProfiles(payload);
|
||||||
|
})
|
||||||
|
.then((profiles) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const mapped: LeaderboardRow[] = profiles.map((profile) => {
|
||||||
|
const user =
|
||||||
|
typeof profile.user === "object" && profile.user !== null ? profile.user : null;
|
||||||
|
const userId = user?.id ?? (typeof profile.user === "number" ? profile.user : null);
|
||||||
|
const name =
|
||||||
|
user?.displayName ||
|
||||||
|
user?.payloadDisplayName ||
|
||||||
|
profile.profileTitle ||
|
||||||
|
user?.username ||
|
||||||
|
`Operator #${profile.id}`;
|
||||||
|
const aimTrainer = profile.aimTrainer;
|
||||||
|
return {
|
||||||
|
id: profile.id,
|
||||||
|
name,
|
||||||
|
gamesPlayed: aimTrainer?.gamesPlayed ?? 0,
|
||||||
|
bestScore: aimTrainer?.[tier.scoreField] ?? 0,
|
||||||
|
bestAccuracy: aimTrainer?.[tier.accuracyField] ?? 0,
|
||||||
|
isCurrentUser: userId !== null && String(userId) === String(currentUserId),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const scored = mapped
|
||||||
|
.filter((row) => row.bestScore > 0)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
right.bestScore - left.bestScore || right.bestAccuracy - left.bestAccuracy,
|
||||||
|
);
|
||||||
|
const visible = scored.slice(0, 10);
|
||||||
|
const current = mapped.find((row) => row.isCurrentUser);
|
||||||
|
if (current && !visible.some((row) => row.id === current.id)) {
|
||||||
|
visible.push({ ...current, isOutsideTopTen: true });
|
||||||
|
}
|
||||||
|
setRows(visible);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFailed(error instanceof Error ? error.message : "leaderboard fetch failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentUserId, difficulty, refreshKey, tier.accuracyField, tier.scoreField]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full max-w-3xl overflow-hidden">
|
||||||
|
<CardHeader className="gap-1 border-b border-border px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trophy aria-hidden="true" className="size-4 text-primary" />
|
||||||
|
<CardTitle className="text-sm">Qualification leaderboard</CardTitle>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">{tier.label} · Top 10</span>
|
||||||
|
</div>
|
||||||
|
<CardDescription>Best score for the selected qualification lane.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<div
|
||||||
|
className="border-b border-border px-4 py-2 text-xs text-muted-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span className="font-medium text-primary">{activeTrainees.length}</span>{" "}
|
||||||
|
{activeTrainees.length === 1 ? "trainee is" : "trainees are"} on the line
|
||||||
|
{activeTrainees.length > 0 && (
|
||||||
|
<span className="ml-1 text-foreground">
|
||||||
|
({activeTrainees.map((trainee) => trainee.name).join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CardContent className="p-0">
|
||||||
|
{failed !== null ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">Could not load the leaderboard.</p>
|
||||||
|
) : rows === null ? (
|
||||||
|
<div className="flex flex-col gap-2 px-4 py-4">
|
||||||
|
<Skeleton className="h-4 w-full motion-reduce:animate-none" />
|
||||||
|
<Skeleton className="h-4 w-3/4 motion-reduce:animate-none" />
|
||||||
|
<Skeleton className="h-4 w-1/2 motion-reduce:animate-none" />
|
||||||
|
</div>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-muted-foreground">
|
||||||
|
No scores logged for this lane yet — take the first qualification slot.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-lg text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-4 py-2 font-medium">#</th>
|
||||||
|
<th className="px-2 py-2 font-medium">Operator</th>
|
||||||
|
<th className="px-2 py-2 text-right font-medium">Games</th>
|
||||||
|
<th
|
||||||
|
aria-sort="descending"
|
||||||
|
className="px-2 py-2 text-right font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Score
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Accuracy</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
row.isCurrentUser && "bg-accent/40",
|
||||||
|
row.isOutsideTopTen && "text-muted-foreground opacity-70",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
row.isOutsideTopTen
|
||||||
|
? "Operator status — not currently ranked in the top 10"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<span className={rankClass(index)}>
|
||||||
|
{row.isOutsideTopTen ? "—" : index + 1}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 font-medium">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="max-w-48 truncate">{row.name}</span>
|
||||||
|
{row.isCurrentUser && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">(you)</span>
|
||||||
|
)}
|
||||||
|
{row.isOutsideTopTen && (
|
||||||
|
<span className="shrink-0 text-xs font-normal uppercase tracking-wide text-muted-foreground">
|
||||||
|
unranked
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{activeTrainees.some((trainee) => trainee.id === row.id) && (
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full bg-primary"
|
||||||
|
title="Playing now"
|
||||||
|
aria-label="Playing now"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-mono tabular-nums text-muted-foreground">
|
||||||
|
{row.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2 text-right font-mono font-semibold tabular-nums">
|
||||||
|
{row.bestScore}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-mono font-semibold tabular-nums">
|
||||||
|
{Math.round(row.bestAccuracy * 100)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import type { AimTrainerDifficulty } from "@/lib/aim-trainer";
|
||||||
|
|
||||||
|
export type AimTrainerLeaderboardFields = {
|
||||||
|
readonly label: string;
|
||||||
|
readonly scoreField: keyof AimTrainerApiStats;
|
||||||
|
readonly accuracyField: keyof AimTrainerApiStats;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AimTrainerApiStats = {
|
||||||
|
readonly gamesPlayed: number | null;
|
||||||
|
readonly recruitBestScore: number | null;
|
||||||
|
readonly veteranBestScore: number | null;
|
||||||
|
readonly eliteBestScore: number | null;
|
||||||
|
readonly recruitBestAccuracy: number | null;
|
||||||
|
readonly veteranBestAccuracy: number | null;
|
||||||
|
readonly eliteBestAccuracy: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiUser = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly username: string | null;
|
||||||
|
readonly displayName: string | null;
|
||||||
|
readonly payloadDisplayName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ApiProfile = {
|
||||||
|
readonly id: number;
|
||||||
|
readonly profileTitle: string | null;
|
||||||
|
readonly user: ApiUser | number | null;
|
||||||
|
readonly aimTrainer: AimTrainerApiStats | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIM_TRAINER_LEADERBOARD_FIELDS = {
|
||||||
|
recruit: {
|
||||||
|
label: "Recruit",
|
||||||
|
scoreField: "recruitBestScore",
|
||||||
|
accuracyField: "recruitBestAccuracy",
|
||||||
|
},
|
||||||
|
veteran: {
|
||||||
|
label: "Veteran",
|
||||||
|
scoreField: "veteranBestScore",
|
||||||
|
accuracyField: "veteranBestAccuracy",
|
||||||
|
},
|
||||||
|
elite: {
|
||||||
|
label: "Elite",
|
||||||
|
scoreField: "eliteBestScore",
|
||||||
|
accuracyField: "eliteBestAccuracy",
|
||||||
|
},
|
||||||
|
} as const satisfies Record<AimTrainerDifficulty, AimTrainerLeaderboardFields>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(value: unknown): number | null {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUser(value: unknown): ApiUser | number | null {
|
||||||
|
const numericId = readNumber(value);
|
||||||
|
if (numericId !== null) return numericId;
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const id = readNumber(value.id);
|
||||||
|
if (id === null) return null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
username: readString(value.username),
|
||||||
|
displayName: readString(value.displayName),
|
||||||
|
payloadDisplayName: readString(value.payloadDisplayName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAimTrainer(value: unknown): AimTrainerApiStats | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
return {
|
||||||
|
gamesPlayed: readNumber(value.gamesPlayed),
|
||||||
|
recruitBestScore: readNumber(value.recruitBestScore),
|
||||||
|
veteranBestScore: readNumber(value.veteranBestScore),
|
||||||
|
eliteBestScore: readNumber(value.eliteBestScore),
|
||||||
|
recruitBestAccuracy: readNumber(value.recruitBestAccuracy),
|
||||||
|
veteranBestAccuracy: readNumber(value.veteranBestAccuracy),
|
||||||
|
eliteBestAccuracy: readNumber(value.eliteBestAccuracy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProfile(value: unknown): ApiProfile | null {
|
||||||
|
if (!isRecord(value)) return null;
|
||||||
|
const id = readNumber(value.id);
|
||||||
|
if (id === null) return null;
|
||||||
|
const progression = isRecord(value.progression) ? value.progression : null;
|
||||||
|
const minigames = progression && isRecord(progression.minigames) ? progression.minigames : null;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
profileTitle: readString(value.profileTitle),
|
||||||
|
user: readUser(value.user),
|
||||||
|
aimTrainer: minigames ? readAimTrainer(minigames.aimTrainer) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAimTrainerProfiles(value: unknown): readonly ApiProfile[] {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.docs)) return [];
|
||||||
|
return value.docs.flatMap((item) => {
|
||||||
|
const profile = readProfile(item);
|
||||||
|
return profile ? [profile] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
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 });
|
||||||
|
}
|
||||||
251
src/lib/aim-trainer.ts
Normal file
251
src/lib/aim-trainer.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
/**
|
||||||
|
* Aim Trainer (Qualification Course) — deterministic, pure game-rules module.
|
||||||
|
*
|
||||||
|
* Framework-free and client-safe: no React, Next.js, or Payload imports, no
|
||||||
|
* side effects. All randomness is injected via an `rng` callback so every
|
||||||
|
* function is reproducible and unit-testable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AimTrainerDifficulty = "recruit" | "veteran" | "elite";
|
||||||
|
|
||||||
|
export interface AimTrainerTierConfig {
|
||||||
|
readonly torsoW: number;
|
||||||
|
readonly torsoH: number;
|
||||||
|
readonly headRadius: number;
|
||||||
|
readonly movingRatio: number;
|
||||||
|
readonly speed: number;
|
||||||
|
readonly maxConcurrentTargets: number;
|
||||||
|
readonly targetLifetimeMs: number;
|
||||||
|
readonly replacementDelayMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIM_TRAINER_DIFFICULTIES: Readonly<
|
||||||
|
Record<AimTrainerDifficulty, AimTrainerTierConfig>
|
||||||
|
> = {
|
||||||
|
recruit: {
|
||||||
|
torsoW: 76,
|
||||||
|
torsoH: 118,
|
||||||
|
headRadius: 21,
|
||||||
|
movingRatio: 0.25,
|
||||||
|
speed: 80,
|
||||||
|
maxConcurrentTargets: 1,
|
||||||
|
targetLifetimeMs: 2000,
|
||||||
|
replacementDelayMs: 400,
|
||||||
|
},
|
||||||
|
veteran: {
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
movingRatio: 0.55,
|
||||||
|
speed: 140,
|
||||||
|
maxConcurrentTargets: 2,
|
||||||
|
targetLifetimeMs: 1500,
|
||||||
|
replacementDelayMs: 300,
|
||||||
|
},
|
||||||
|
elite: {
|
||||||
|
torsoW: 50,
|
||||||
|
torsoH: 82,
|
||||||
|
headRadius: 15,
|
||||||
|
movingRatio: 0.8,
|
||||||
|
speed: 220,
|
||||||
|
maxConcurrentTargets: 2,
|
||||||
|
targetLifetimeMs: 1100,
|
||||||
|
replacementDelayMs: 200,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COURSE_DURATION_MS = 30_000;
|
||||||
|
export const TORSO_POINTS = 100;
|
||||||
|
export const HEADSHOT_POINTS = 250;
|
||||||
|
export const ACCURACY_BONUS_MAX = 500;
|
||||||
|
export const POINTS_PER_XP = 500;
|
||||||
|
/** XP awarded per full POINTS_PER_XP score block — one XP per 4,000 score. */
|
||||||
|
export const SCORE_XP_RATE = 0.125;
|
||||||
|
export const COMPLETION_XP = 1;
|
||||||
|
export const NEW_TIER_RECORD_XP = 2;
|
||||||
|
export const XP_CAP = 50;
|
||||||
|
|
||||||
|
export const MAX_SHOTS_FIRED = 500;
|
||||||
|
export const MAX_HITS = 180;
|
||||||
|
export const MAX_DESPAWNED_TARGETS = 60;
|
||||||
|
|
||||||
|
const DIFFICULTY_VALUES: readonly AimTrainerDifficulty[] = ["recruit", "veteran", "elite"];
|
||||||
|
|
||||||
|
export function isAimTrainerDifficulty(value: unknown): value is AimTrainerDifficulty {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
(DIFFICULTY_VALUES as readonly string[]).includes(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateAimTrainerAccuracy(
|
||||||
|
hits: number,
|
||||||
|
shotsFired: number,
|
||||||
|
despawnedUnhitTargets: number,
|
||||||
|
): number {
|
||||||
|
const denominator = shotsFired + despawnedUnhitTargets;
|
||||||
|
if (denominator === 0) return 0;
|
||||||
|
return Math.min(1, Math.max(0, hits / denominator));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateAimTrainerScore(
|
||||||
|
headshots: number,
|
||||||
|
torsoHits: number,
|
||||||
|
shotsFired: number,
|
||||||
|
despawnedUnhitTargets: number,
|
||||||
|
): number {
|
||||||
|
const accuracy = calculateAimTrainerAccuracy(headshots + torsoHits, shotsFired, despawnedUnhitTargets);
|
||||||
|
return (
|
||||||
|
headshots * HEADSHOT_POINTS +
|
||||||
|
torsoHits * TORSO_POINTS +
|
||||||
|
Math.round(accuracy * ACCURACY_BONUS_MAX)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateAimTrainerXp(score: number, isNewTierBest: boolean): number {
|
||||||
|
const xp =
|
||||||
|
COMPLETION_XP +
|
||||||
|
Math.floor(score / POINTS_PER_XP) * SCORE_XP_RATE +
|
||||||
|
(isNewTierBest ? NEW_TIER_RECORD_XP : 0);
|
||||||
|
return Math.min(XP_CAP, xp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AimTrainerTarget {
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly vx: number;
|
||||||
|
readonly vy: number;
|
||||||
|
readonly moving: boolean;
|
||||||
|
readonly torsoW: number;
|
||||||
|
readonly torsoH: number;
|
||||||
|
readonly headRadius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AimTrainerTargetHit {
|
||||||
|
readonly index: number;
|
||||||
|
readonly zone: "head" | "torso";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a target whose whole body (torso rectangle plus the circular head
|
||||||
|
* sitting above it) stays inside the canvas. The head circle is centered at
|
||||||
|
* `(x, y - headRadius)` and the torso spans `[y, y + torsoH]`, so the target's
|
||||||
|
* vertical extent is `[y - 2*headRadius, y + torsoH]`.
|
||||||
|
*/
|
||||||
|
export function createAimTrainerTarget(
|
||||||
|
rng: () => number,
|
||||||
|
canvasW: number,
|
||||||
|
canvasH: number,
|
||||||
|
difficulty: AimTrainerDifficulty,
|
||||||
|
): AimTrainerTarget {
|
||||||
|
const config = AIM_TRAINER_DIFFICULTIES[difficulty];
|
||||||
|
const minX = config.torsoW / 2;
|
||||||
|
const maxX = canvasW - config.torsoW / 2;
|
||||||
|
const minY = 2 * config.headRadius;
|
||||||
|
const maxY = canvasH - config.torsoH;
|
||||||
|
|
||||||
|
const x = minX + rng() * (maxX - minX);
|
||||||
|
const y = minY + rng() * (maxY - minY);
|
||||||
|
|
||||||
|
const moving = rng() < config.movingRatio;
|
||||||
|
if (!moving) {
|
||||||
|
return { x, y, vx: 0, vy: 0, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
|
||||||
|
}
|
||||||
|
|
||||||
|
const angle = rng() * 2 * Math.PI;
|
||||||
|
const vx = Math.cos(angle) * config.speed;
|
||||||
|
const vy = Math.sin(angle) * config.speed;
|
||||||
|
return { x, y, vx, vy, moving, torsoW: config.torsoW, torsoH: config.torsoH, headRadius: config.headRadius };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance a target by `dt` seconds, bouncing it off the playable bounds so the
|
||||||
|
* whole body stays inside the canvas. Pure: returns a new target, never mutates
|
||||||
|
* the input.
|
||||||
|
*/
|
||||||
|
export function stepAimTrainerTarget(
|
||||||
|
target: AimTrainerTarget,
|
||||||
|
dt: number,
|
||||||
|
canvasW: number,
|
||||||
|
canvasH: number,
|
||||||
|
): AimTrainerTarget {
|
||||||
|
if (!target.moving) return target;
|
||||||
|
|
||||||
|
let x = target.x + target.vx * dt;
|
||||||
|
let y = target.y + target.vy * dt;
|
||||||
|
let vx = target.vx;
|
||||||
|
let vy = target.vy;
|
||||||
|
|
||||||
|
const minX = target.torsoW / 2;
|
||||||
|
const maxX = canvasW - target.torsoW / 2;
|
||||||
|
const minY = 2 * target.headRadius;
|
||||||
|
const maxY = canvasH - target.torsoH;
|
||||||
|
|
||||||
|
if (x < minX) {
|
||||||
|
x = minX;
|
||||||
|
vx = -vx;
|
||||||
|
} else if (x > maxX) {
|
||||||
|
x = maxX;
|
||||||
|
vx = -vx;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y < minY) {
|
||||||
|
y = minY;
|
||||||
|
vy = -vy;
|
||||||
|
} else if (y > maxY) {
|
||||||
|
y = maxY;
|
||||||
|
vy = -vy;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...target, x, y, vx, vy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a click at `(x, y)` against a target. The circular head zone is
|
||||||
|
* checked first, then the torso rectangle. Returns `null` for a miss.
|
||||||
|
*/
|
||||||
|
export function resolveAimTrainerHitZone(
|
||||||
|
target: AimTrainerTarget,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): "head" | "torso" | null {
|
||||||
|
const headCx = target.x;
|
||||||
|
const headCy = target.y - target.headRadius;
|
||||||
|
const dx = x - headCx;
|
||||||
|
const dy = y - headCy;
|
||||||
|
if (dx * dx + dy * dy <= target.headRadius * target.headRadius) {
|
||||||
|
return "head";
|
||||||
|
}
|
||||||
|
|
||||||
|
const halfW = target.torsoW / 2;
|
||||||
|
if (
|
||||||
|
x >= target.x - halfW &&
|
||||||
|
x <= target.x + halfW &&
|
||||||
|
y >= target.y &&
|
||||||
|
y <= target.y + target.torsoH
|
||||||
|
) {
|
||||||
|
return "torso";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a click against the topmost matching target. Later targets are drawn
|
||||||
|
* above earlier targets, so overlapping targets are checked from last to first.
|
||||||
|
*/
|
||||||
|
export function resolveAimTrainerTargetHit(
|
||||||
|
targets: readonly AimTrainerTarget[],
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): AimTrainerTargetHit | null {
|
||||||
|
for (let targetIndex = targets.length - 1; targetIndex >= 0; targetIndex -= 1) {
|
||||||
|
const target = targets[targetIndex];
|
||||||
|
if (!target) continue;
|
||||||
|
|
||||||
|
const zone = resolveAimTrainerHitZone(target, x, y);
|
||||||
|
if (zone) return { index: targetIndex, zone };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
25
src/migrations/20260903_062000_add_aim_trainer_stats.ts
Normal file
25
src/migrations/20260903_062000_add_aim_trainer_stats.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { MigrateDownArgs, MigrateUpArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
'progression_minigames_aim_trainer_games_played',
|
||||||
|
'progression_minigames_aim_trainer_best_score',
|
||||||
|
'progression_minigames_aim_trainer_recruit_best_score',
|
||||||
|
'progression_minigames_aim_trainer_veteran_best_score',
|
||||||
|
'progression_minigames_aim_trainer_elite_best_score',
|
||||||
|
'progression_minigames_aim_trainer_recruit_best_accuracy',
|
||||||
|
'progression_minigames_aim_trainer_veteran_best_accuracy',
|
||||||
|
'progression_minigames_aim_trainer_elite_best_accuracy',
|
||||||
|
'progression_minigames_aim_trainer_xp_earned',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export async function up({ db }: MigrateUpArgs): Promise<void> {
|
||||||
|
for (const column of columns) {
|
||||||
|
await db.execute(sql.raw(`ALTER TABLE "profiles" ADD COLUMN "${column}" numeric DEFAULT 0;`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db }: MigrateDownArgs): Promise<void> {
|
||||||
|
for (const column of [...columns].reverse()) {
|
||||||
|
await db.execute(sql.raw(`ALTER TABLE "profiles" DROP COLUMN "${column}";`))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import * as migration_20260903_044047 from './20260903_044047';
|
||||||
import * as migration_20260903_053419 from './20260903_053419';
|
import * as migration_20260903_053419 from './20260903_053419';
|
||||||
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
|
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats';
|
||||||
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
|
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts';
|
||||||
|
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -132,4 +133,9 @@ export const migrations = [
|
||||||
down: migration_20260903_061000_add_radio_incorrect_counts.down,
|
down: migration_20260903_061000_add_radio_incorrect_counts.down,
|
||||||
name: '20260903_061000_add_radio_incorrect_counts',
|
name: '20260903_061000_add_radio_incorrect_counts',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260903_062000_add_aim_trainer_stats.up,
|
||||||
|
down: migration_20260903_062000_add_aim_trainer_stats.down,
|
||||||
|
name: '20260903_062000_add_aim_trainer_stats',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1803,6 +1803,17 @@ export interface Profile {
|
||||||
watchBestAccuracy?: number | null;
|
watchBestAccuracy?: number | null;
|
||||||
xpEarned?: number | null;
|
xpEarned?: number | null;
|
||||||
};
|
};
|
||||||
|
aimTrainer?: {
|
||||||
|
gamesPlayed?: number | null;
|
||||||
|
bestScore?: number | null;
|
||||||
|
recruitBestScore?: number | null;
|
||||||
|
veteranBestScore?: number | null;
|
||||||
|
eliteBestScore?: number | null;
|
||||||
|
recruitBestAccuracy?: number | null;
|
||||||
|
veteranBestAccuracy?: number | null;
|
||||||
|
eliteBestAccuracy?: number | null;
|
||||||
|
xpEarned?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|
@ -3832,6 +3843,19 @@ export interface ProfilesSelect<T extends boolean = true> {
|
||||||
watchBestAccuracy?: T;
|
watchBestAccuracy?: T;
|
||||||
xpEarned?: T;
|
xpEarned?: T;
|
||||||
};
|
};
|
||||||
|
aimTrainer?:
|
||||||
|
| T
|
||||||
|
| {
|
||||||
|
gamesPlayed?: T;
|
||||||
|
bestScore?: T;
|
||||||
|
recruitBestScore?: T;
|
||||||
|
veteranBestScore?: T;
|
||||||
|
eliteBestScore?: T;
|
||||||
|
recruitBestAccuracy?: T;
|
||||||
|
veteranBestAccuracy?: T;
|
||||||
|
eliteBestAccuracy?: T;
|
||||||
|
xpEarned?: T;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ export const EventTypes = {
|
||||||
MinigameFlappy: "minigame:flappy",
|
MinigameFlappy: "minigame:flappy",
|
||||||
MinigameEod: "minigame:eod",
|
MinigameEod: "minigame:eod",
|
||||||
MinigameRadioTraffic: "minigame:radio-traffic",
|
MinigameRadioTraffic: "minigame:radio-traffic",
|
||||||
|
MinigameAimTrainer: "minigame:aim-trainer",
|
||||||
|
|
||||||
// Market
|
// Market
|
||||||
MarketListingCreate: "market:listing-create",
|
MarketListingCreate: "market:listing-create",
|
||||||
|
|
|
||||||
409
tests/int/aim-trainer.int.spec.ts
Normal file
409
tests/int/aim-trainer.int.spec.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ACCURACY_BONUS_MAX,
|
||||||
|
AIM_TRAINER_DIFFICULTIES,
|
||||||
|
COMPLETION_XP,
|
||||||
|
COURSE_DURATION_MS,
|
||||||
|
HEADSHOT_POINTS,
|
||||||
|
MAX_DESPAWNED_TARGETS,
|
||||||
|
MAX_HITS,
|
||||||
|
MAX_SHOTS_FIRED,
|
||||||
|
NEW_TIER_RECORD_XP,
|
||||||
|
POINTS_PER_XP,
|
||||||
|
SCORE_XP_RATE,
|
||||||
|
TORSO_POINTS,
|
||||||
|
XP_CAP,
|
||||||
|
type AimTrainerTarget,
|
||||||
|
calculateAimTrainerAccuracy,
|
||||||
|
calculateAimTrainerScore,
|
||||||
|
calculateAimTrainerXp,
|
||||||
|
createAimTrainerTarget,
|
||||||
|
isAimTrainerDifficulty,
|
||||||
|
resolveAimTrainerHitZone,
|
||||||
|
resolveAimTrainerTargetHit,
|
||||||
|
stepAimTrainerTarget,
|
||||||
|
} from "@/lib/aim-trainer";
|
||||||
|
|
||||||
|
/** Deterministic LCG-style RNG so target creation is reproducible across runs. */
|
||||||
|
function seededRng(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state = (state * 1664525 + 1013904223) >>> 0;
|
||||||
|
return state / 0x100000000;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Aim Trainer constants", () => {
|
||||||
|
it("exposes the course duration and scoring constants", () => {
|
||||||
|
expect(COURSE_DURATION_MS).toBe(30_000);
|
||||||
|
expect(TORSO_POINTS).toBe(100);
|
||||||
|
expect(HEADSHOT_POINTS).toBe(250);
|
||||||
|
expect(ACCURACY_BONUS_MAX).toBe(500);
|
||||||
|
expect(POINTS_PER_XP).toBe(500);
|
||||||
|
expect(SCORE_XP_RATE).toBe(0.125);
|
||||||
|
expect(COMPLETION_XP).toBe(1);
|
||||||
|
expect(NEW_TIER_RECORD_XP).toBe(2);
|
||||||
|
expect(XP_CAP).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the server safety caps", () => {
|
||||||
|
expect(MAX_SHOTS_FIRED).toBe(500);
|
||||||
|
expect(MAX_HITS).toBe(180);
|
||||||
|
expect(MAX_DESPAWNED_TARGETS).toBe(60);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AIM_TRAINER_DIFFICULTIES", () => {
|
||||||
|
it("defines exactly the three tiers with the specified values", () => {
|
||||||
|
expect(AIM_TRAINER_DIFFICULTIES).toEqual({
|
||||||
|
recruit: {
|
||||||
|
torsoW: 76,
|
||||||
|
torsoH: 118,
|
||||||
|
headRadius: 21,
|
||||||
|
movingRatio: 0.25,
|
||||||
|
speed: 80,
|
||||||
|
targetLifetimeMs: 2000,
|
||||||
|
replacementDelayMs: 400,
|
||||||
|
maxConcurrentTargets: 1,
|
||||||
|
},
|
||||||
|
veteran: {
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
movingRatio: 0.55,
|
||||||
|
speed: 140,
|
||||||
|
targetLifetimeMs: 1500,
|
||||||
|
replacementDelayMs: 300,
|
||||||
|
maxConcurrentTargets: 2,
|
||||||
|
},
|
||||||
|
elite: {
|
||||||
|
torsoW: 50,
|
||||||
|
torsoH: 82,
|
||||||
|
headRadius: 15,
|
||||||
|
movingRatio: 0.8,
|
||||||
|
speed: 220,
|
||||||
|
targetLifetimeMs: 1100,
|
||||||
|
replacementDelayMs: 200,
|
||||||
|
maxConcurrentTargets: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asserts every field of every tier explicitly", () => {
|
||||||
|
const recruit = AIM_TRAINER_DIFFICULTIES.recruit;
|
||||||
|
expect(recruit.torsoW).toBe(76);
|
||||||
|
expect(recruit.torsoH).toBe(118);
|
||||||
|
expect(recruit.headRadius).toBe(21);
|
||||||
|
expect(recruit.movingRatio).toBe(0.25);
|
||||||
|
expect(recruit.speed).toBe(80);
|
||||||
|
expect(recruit.targetLifetimeMs).toBe(2000);
|
||||||
|
expect(recruit.replacementDelayMs).toBe(400);
|
||||||
|
expect(recruit.maxConcurrentTargets).toBe(1);
|
||||||
|
|
||||||
|
const veteran = AIM_TRAINER_DIFFICULTIES.veteran;
|
||||||
|
expect(veteran.torsoW).toBe(62);
|
||||||
|
expect(veteran.torsoH).toBe(100);
|
||||||
|
expect(veteran.headRadius).toBe(18);
|
||||||
|
expect(veteran.movingRatio).toBe(0.55);
|
||||||
|
expect(veteran.speed).toBe(140);
|
||||||
|
expect(veteran.targetLifetimeMs).toBe(1500);
|
||||||
|
expect(veteran.replacementDelayMs).toBe(300);
|
||||||
|
expect(veteran.maxConcurrentTargets).toBe(2);
|
||||||
|
|
||||||
|
const elite = AIM_TRAINER_DIFFICULTIES.elite;
|
||||||
|
expect(elite.torsoW).toBe(50);
|
||||||
|
expect(elite.torsoH).toBe(82);
|
||||||
|
expect(elite.headRadius).toBe(15);
|
||||||
|
expect(elite.movingRatio).toBe(0.8);
|
||||||
|
expect(elite.speed).toBe(220);
|
||||||
|
expect(elite.targetLifetimeMs).toBe(1100);
|
||||||
|
expect(elite.replacementDelayMs).toBe(200);
|
||||||
|
expect(elite.maxConcurrentTargets).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isAimTrainerDifficulty", () => {
|
||||||
|
it("accepts the three valid tiers", () => {
|
||||||
|
expect(isAimTrainerDifficulty("recruit")).toBe(true);
|
||||||
|
expect(isAimTrainerDifficulty("veteran")).toBe(true);
|
||||||
|
expect(isAimTrainerDifficulty("elite")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown values", () => {
|
||||||
|
expect(isAimTrainerDifficulty("legendary")).toBe(false);
|
||||||
|
expect(isAimTrainerDifficulty("")).toBe(false);
|
||||||
|
expect(isAimTrainerDifficulty(42)).toBe(false);
|
||||||
|
expect(isAimTrainerDifficulty(null)).toBe(false);
|
||||||
|
expect(isAimTrainerDifficulty(undefined)).toBe(false);
|
||||||
|
expect(isAimTrainerDifficulty({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateAimTrainerAccuracy", () => {
|
||||||
|
it("returns 0 when there were zero attempts", () => {
|
||||||
|
expect(calculateAimTrainerAccuracy(0, 0, 0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts hits against shots fired plus despawned unhit targets", () => {
|
||||||
|
// 3 hits, 3 shots, 1 despawned unhit target -> 3 / 4
|
||||||
|
expect(calculateAimTrainerAccuracy(3, 3, 1)).toBeCloseTo(0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps the result to the [0, 1] range", () => {
|
||||||
|
expect(calculateAimTrainerAccuracy(10, 2, 0)).toBe(1);
|
||||||
|
expect(calculateAimTrainerAccuracy(0, 5, 5)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateAimTrainerScore", () => {
|
||||||
|
it("scores headshots, torso hits, and the accuracy bonus", () => {
|
||||||
|
// 2 headshots + 1 torso, 3 shots fired, 1 despawned unhit target.
|
||||||
|
// Hit score = 2*250 + 1*100 = 600. Accuracy = 3/4. Bonus = round(0.75*500) = 375. Total 975.
|
||||||
|
expect(calculateAimTrainerScore(2, 1, 3, 1)).toBe(975);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores a perfect run with no accuracy penalty", () => {
|
||||||
|
// 1 headshot, 1 shot, no despawned targets -> 250 + round(1*500) = 750.
|
||||||
|
expect(calculateAimTrainerScore(1, 0, 1, 0)).toBe(750);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("calculateAimTrainerXp", () => {
|
||||||
|
it("awards the completion XP for a zero score", () => {
|
||||||
|
expect(calculateAimTrainerXp(0, false)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("awards completion XP plus 0.125 per full 500 points", () => {
|
||||||
|
expect(calculateAimTrainerXp(499, false)).toBe(1);
|
||||||
|
expect(calculateAimTrainerXp(500, false)).toBe(1.125);
|
||||||
|
expect(calculateAimTrainerXp(999, false)).toBe(1.125);
|
||||||
|
expect(calculateAimTrainerXp(1000, false)).toBe(1.25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds the new-tier-best bonus", () => {
|
||||||
|
expect(calculateAimTrainerXp(0, true)).toBe(3);
|
||||||
|
expect(calculateAimTrainerXp(500, true)).toBe(3.125);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps the result at XP_CAP", () => {
|
||||||
|
expect(calculateAimTrainerXp(1_000_000, true)).toBe(50);
|
||||||
|
expect(calculateAimTrainerXp(1_000_000, false)).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAimTrainerTarget", () => {
|
||||||
|
const canvasW = 800;
|
||||||
|
const canvasH = 600;
|
||||||
|
|
||||||
|
it("returns a target with the tier's dimensions and a deterministic position", () => {
|
||||||
|
const rng = seededRng(1234);
|
||||||
|
const target = createAimTrainerTarget(rng, canvasW, canvasH, "veteran");
|
||||||
|
expect(target.torsoW).toBe(62);
|
||||||
|
expect(target.torsoH).toBe(100);
|
||||||
|
expect(target.headRadius).toBe(18);
|
||||||
|
expect(typeof target.moving).toBe("boolean");
|
||||||
|
expect(typeof target.x).toBe("number");
|
||||||
|
expect(typeof target.y).toBe("number");
|
||||||
|
expect(typeof target.vx).toBe("number");
|
||||||
|
expect(typeof target.vy).toBe("number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic for the same seed across repeated calls", () => {
|
||||||
|
// Given: a fixed seed for Elite target generation.
|
||||||
|
const seed = 42;
|
||||||
|
|
||||||
|
// When: identical seeded streams each generate their first target.
|
||||||
|
const a = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "elite");
|
||||||
|
const b = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "elite");
|
||||||
|
|
||||||
|
// Then: the complete targets match and the moving target has the balanced Elite speed.
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(a.moving).toBe(true);
|
||||||
|
expect(Math.hypot(a.vx, a.vy)).toBeCloseTo(220);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the whole target (torso plus head) inside the canvas", () => {
|
||||||
|
for (let seed = 0; seed < 200; seed++) {
|
||||||
|
const target = createAimTrainerTarget(seededRng(seed), canvasW, canvasH, "recruit");
|
||||||
|
const left = target.x - target.torsoW / 2;
|
||||||
|
const right = target.x + target.torsoW / 2;
|
||||||
|
const top = target.y - 2 * target.headRadius;
|
||||||
|
const bottom = target.y + target.torsoH;
|
||||||
|
expect(left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(right).toBeLessThanOrEqual(canvasW);
|
||||||
|
expect(top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bottom).toBeLessThanOrEqual(canvasH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects moving vs stationary based on the RNG roll against movingRatio", () => {
|
||||||
|
// A rng that always returns 0 -> always moving (0 < movingRatio).
|
||||||
|
const alwaysMoving = createAimTrainerTarget(() => 0, canvasW, canvasH, "recruit");
|
||||||
|
expect(alwaysMoving.moving).toBe(true);
|
||||||
|
// A rng that always returns 1 -> never moving (1 < movingRatio is false).
|
||||||
|
const neverMoving = createAimTrainerTarget(() => 1, canvasW, canvasH, "recruit");
|
||||||
|
expect(neverMoving.moving).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives stationary targets zero velocity", () => {
|
||||||
|
const stationary = createAimTrainerTarget(() => 1, canvasW, canvasH, "veteran");
|
||||||
|
expect(stationary.moving).toBe(false);
|
||||||
|
expect(stationary.vx).toBe(0);
|
||||||
|
expect(stationary.vy).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAimTrainerHitZone", () => {
|
||||||
|
const target = {
|
||||||
|
x: 400,
|
||||||
|
y: 300,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
moving: false,
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("resolves a head hit inside the head circle", () => {
|
||||||
|
// Head center is at (x, y - headRadius) = (400, 282).
|
||||||
|
expect(resolveAimTrainerHitZone(target, 400, 282)).toBe("head");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a torso hit inside the torso rectangle", () => {
|
||||||
|
// Torso spans x in [369, 431], y in [300, 400].
|
||||||
|
expect(resolveAimTrainerHitZone(target, 400, 350)).toBe("torso");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a miss outside both zones", () => {
|
||||||
|
expect(resolveAimTrainerHitZone(target, 100, 100)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks the head zone before the torso rectangle", () => {
|
||||||
|
// A point that is inside the head circle but also within the torso's x-range
|
||||||
|
// must resolve to "head", proving head is checked first.
|
||||||
|
expect(resolveAimTrainerHitZone(target, 400, 282)).toBe("head");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAimTrainerTargetHit", () => {
|
||||||
|
const rearTarget: AimTrainerTarget = {
|
||||||
|
x: 400,
|
||||||
|
y: 300,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
moving: false,
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
};
|
||||||
|
const frontTarget: AimTrainerTarget = {
|
||||||
|
...rearTarget,
|
||||||
|
y: 280,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("resolves the topmost target first when ordered targets overlap", () => {
|
||||||
|
// Given: index 1 is drawn above index 0, and (400, 282) hits its torso and index 0's head.
|
||||||
|
const targets = [rearTarget, frontTarget];
|
||||||
|
|
||||||
|
// When: the overlapping point is resolved against the ordered targets.
|
||||||
|
const hit = resolveAimTrainerTargetHit(targets, 400, 282);
|
||||||
|
|
||||||
|
// Then: the topmost target wins even though the lower target offers a headshot.
|
||||||
|
expect(hit).toEqual({ index: 1, zone: "torso" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when every ordered target misses", () => {
|
||||||
|
// Given: two overlapping targets whose complete geometry is away from the origin.
|
||||||
|
const targets = [rearTarget, frontTarget];
|
||||||
|
|
||||||
|
// When: a point outside every target is resolved.
|
||||||
|
const hit = resolveAimTrainerTargetHit(targets, 0, 0);
|
||||||
|
|
||||||
|
// Then: no target or zone is selected.
|
||||||
|
expect(hit).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stepAimTrainerTarget", () => {
|
||||||
|
const canvasW = 800;
|
||||||
|
const canvasH = 600;
|
||||||
|
|
||||||
|
it("moves a moving target by velocity times dt", () => {
|
||||||
|
const target = {
|
||||||
|
x: 400,
|
||||||
|
y: 300,
|
||||||
|
vx: 100,
|
||||||
|
vy: 0,
|
||||||
|
moving: true,
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
};
|
||||||
|
const stepped = stepAimTrainerTarget(target, 0.5, canvasW, canvasH);
|
||||||
|
expect(stepped.x).toBeCloseTo(450);
|
||||||
|
expect(stepped.y).toBeCloseTo(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a stationary target in place", () => {
|
||||||
|
const target = {
|
||||||
|
x: 400,
|
||||||
|
y: 300,
|
||||||
|
vx: 100,
|
||||||
|
vy: 50,
|
||||||
|
moving: false,
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
};
|
||||||
|
const stepped = stepAimTrainerTarget(target, 1, canvasW, canvasH);
|
||||||
|
expect(stepped.x).toBe(400);
|
||||||
|
expect(stepped.y).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounces off the horizontal bounds and keeps the target inside", () => {
|
||||||
|
const target = {
|
||||||
|
x: 400,
|
||||||
|
y: 300,
|
||||||
|
vx: 1000,
|
||||||
|
vy: 0,
|
||||||
|
moving: true,
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
};
|
||||||
|
let current = target;
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
current = stepAimTrainerTarget(current, 0.1, canvasW, canvasH);
|
||||||
|
const left = current.x - current.torsoW / 2;
|
||||||
|
const right = current.x + current.torsoW / 2;
|
||||||
|
const top = current.y - 2 * current.headRadius;
|
||||||
|
const bottom = current.y + current.torsoH;
|
||||||
|
expect(left).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(right).toBeLessThanOrEqual(canvasW);
|
||||||
|
expect(top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bottom).toBeLessThanOrEqual(canvasH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounces off the vertical bounds and keeps the target inside", () => {
|
||||||
|
const target = {
|
||||||
|
x: 400,
|
||||||
|
y: 300,
|
||||||
|
vx: 0,
|
||||||
|
vy: 1000,
|
||||||
|
moving: true,
|
||||||
|
torsoW: 62,
|
||||||
|
torsoH: 100,
|
||||||
|
headRadius: 18,
|
||||||
|
};
|
||||||
|
let current = target;
|
||||||
|
for (let i = 0; i < 1000; i++) {
|
||||||
|
current = stepAimTrainerTarget(current, 0.1, canvasW, canvasH);
|
||||||
|
const top = current.y - 2 * current.headRadius;
|
||||||
|
const bottom = current.y + current.torsoH;
|
||||||
|
expect(top).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(bottom).toBeLessThanOrEqual(canvasH);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue