1
0
Fork 0

feat(qualification): draw configurable crosshair on the aim trainer canvas

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jason Fraley 2026-09-11 03:25:21 -04:00
parent 51f9d57aaa
commit 2406df82dd
2 changed files with 153 additions and 5 deletions

View file

@ -4,6 +4,7 @@ import {
COURSE_DURATION_MS, COURSE_DURATION_MS,
type AimTrainerTarget, type AimTrainerTarget,
} from "@/lib/minigames/aim-trainer"; } from "@/lib/minigames/aim-trainer";
import type { AimTrainerCrosshairPrefs } from "./crosshair";
export const AIM_TRAINER_CANVAS_WIDTH = 720; export const AIM_TRAINER_CANVAS_WIDTH = 720;
export const AIM_TRAINER_CANVAS_HEIGHT = 480; export const AIM_TRAINER_CANVAS_HEIGHT = 480;
@ -12,6 +13,10 @@ export const AIM_TRAINER_FLASH_DURATION_MS = 150;
export const AIM_TRAINER_TARGET_FADE_DURATION_MS = 250; export const AIM_TRAINER_TARGET_FADE_DURATION_MS = 250;
const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000; const COURSE_DURATION_SECONDS = COURSE_DURATION_MS / 1000;
const AIM_TRAINER_CROSSHAIR_RECOIL_DURATION_MS = 120;
const AIM_TRAINER_CROSSHAIR_GAP = 8;
const AIM_TRAINER_CROSSHAIR_ARM_LENGTH = 8;
const AIM_TRAINER_CROSSHAIR_MAX_RECOIL = 4.5;
export type AimTrainerPointFeedback = { export type AimTrainerPointFeedback = {
readonly x: number; readonly x: number;
@ -29,6 +34,7 @@ export type AimTrainerActiveTarget = {
type AimTrainerSceneFrame = { type AimTrainerSceneFrame = {
readonly now: number; readonly now: number;
readonly targetLifetimeMs: number; readonly targetLifetimeMs: number;
readonly crosshair: AimTrainerCrosshairPrefs;
}; };
export type AimTrainerGameState = { export type AimTrainerGameState = {
@ -41,6 +47,8 @@ export type AimTrainerGameState = {
shotsFired: number; shotsFired: number;
despawnedUnhitTargets: number; despawnedUnhitTargets: number;
feedback: AimTrainerPointFeedback[]; feedback: AimTrainerPointFeedback[];
cursor: { x: number; y: number } | null;
recoilAt: number;
flashColor: string; flashColor: string;
flashUntil: number; flashUntil: number;
lastDisplayedSecond: number; lastDisplayedSecond: number;
@ -67,6 +75,8 @@ export function createAimTrainerGameState(): AimTrainerGameState {
shotsFired: 0, shotsFired: 0,
despawnedUnhitTargets: 0, despawnedUnhitTargets: 0,
feedback: [], feedback: [],
cursor: null,
recoilAt: 0,
flashColor: "#d9d19b", flashColor: "#d9d19b",
flashUntil: 0, flashUntil: 0,
lastDisplayedSecond: COURSE_DURATION_SECONDS, lastDisplayedSecond: COURSE_DURATION_SECONDS,
@ -269,6 +279,95 @@ function drawFeedback(
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
function drawAimTrainerCrosshair(
ctx: CanvasRenderingContext2D,
game: AimTrainerGameState,
now: number,
crosshair: AimTrainerCrosshairPrefs,
): void {
if (!game.cursor) return;
const recoilAge = now - game.recoilAt;
const recoilRemaining =
game.recoilAt > 0 && recoilAge >= 0 && recoilAge < AIM_TRAINER_CROSSHAIR_RECOIL_DURATION_MS
? 1 - recoilAge / AIM_TRAINER_CROSSHAIR_RECOIL_DURATION_MS
: 0;
const recoilOffset = recoilRemaining * recoilRemaining * AIM_TRAINER_CROSSHAIR_MAX_RECOIL;
const scale = crosshair.scale;
const gap = AIM_TRAINER_CROSSHAIR_GAP * scale + recoilOffset;
const armLength = AIM_TRAINER_CROSSHAIR_ARM_LENGTH * scale + recoilOffset;
const { x, y } = game.cursor;
const dotRadius = 1.5 * scale + recoilOffset / 3;
const centerDotRadius = 0.75 * scale;
const ringRadius = 11 * scale + recoilOffset;
const crossHalfLength = 15 * scale + recoilOffset;
const chevronApexY = y - 6 * scale - recoilOffset;
const chevronHalfWidth = 5 * scale + recoilOffset;
const chevronEndpointY = y - scale;
const drawPass = (color: string, lineWidth: number, dotExtraRadius: number): void => {
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lineWidth;
switch (crosshair.style) {
case "classic":
ctx.beginPath();
ctx.moveTo(x, y - gap - armLength);
ctx.lineTo(x, y - gap);
ctx.moveTo(x, y + gap);
ctx.lineTo(x, y + gap + armLength);
ctx.moveTo(x - gap - armLength, y);
ctx.lineTo(x - gap, y);
ctx.moveTo(x + gap, y);
ctx.lineTo(x + gap + armLength, y);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, centerDotRadius, 0, Math.PI * 2);
ctx.fill();
return;
case "dot":
ctx.beginPath();
ctx.arc(x, y, dotRadius + dotExtraRadius, 0, Math.PI * 2);
ctx.fill();
return;
case "cross":
ctx.beginPath();
ctx.moveTo(x, y - crossHalfLength);
ctx.lineTo(x, y + crossHalfLength);
ctx.moveTo(x - crossHalfLength, y);
ctx.lineTo(x + crossHalfLength, y);
ctx.stroke();
return;
case "circle":
ctx.beginPath();
ctx.arc(x, y, ringRadius, 0, Math.PI * 2);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, centerDotRadius, 0, Math.PI * 2);
ctx.fill();
return;
case "chevron":
ctx.beginPath();
ctx.moveTo(x - chevronHalfWidth, chevronEndpointY);
ctx.lineTo(x, chevronApexY);
ctx.lineTo(x + chevronHalfWidth, chevronEndpointY);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, centerDotRadius, 0, Math.PI * 2);
ctx.fill();
return;
}
};
ctx.save();
ctx.lineCap = "butt";
ctx.lineJoin = "miter";
drawPass("rgba(0, 0, 0, 0.5)", 4 * scale, crosshair.style === "dot" ? scale : 0);
drawPass(crosshair.color, 1.75 * scale, 0);
ctx.restore();
}
export function drawAimTrainerScene( export function drawAimTrainerScene(
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
game: AimTrainerGameState, game: AimTrainerGameState,
@ -287,4 +386,5 @@ export function drawAimTrainerScene(
.padStart(2, "0")}`; .padStart(2, "0")}`;
ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT); ctx.fillRect(0, 0, AIM_TRAINER_CANVAS_WIDTH, AIM_TRAINER_CANVAS_HEIGHT);
} }
drawAimTrainerCrosshair(ctx, game, frame.now, frame.crosshair);
} }

View file

@ -45,6 +45,13 @@ import {
type AimTrainerGameState, type AimTrainerGameState,
type AimTrainerLiveStats, type AimTrainerLiveStats,
} from "./AimTrainerCanvas"; } from "./AimTrainerCanvas";
import { CrosshairSettings } from "./CrosshairSettings";
import {
DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS,
loadAimTrainerCrosshairPrefs,
saveAimTrainerCrosshairPrefs,
type AimTrainerCrosshairPrefs,
} from "./crosshair";
import { import {
playCivilianHit, playCivilianHit,
playCountdownTick, playCountdownTick,
@ -183,9 +190,27 @@ export function AimTrainerGame({
const [stats, setStats] = useState<AimTrainerStats>(serverStats); const [stats, setStats] = useState<AimTrainerStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null); const [lastRun, setLastRun] = useState<LastRun | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const crosshairPrefsRef = useRef<AimTrainerCrosshairPrefs>(
DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS,
);
const [crosshairPrefs, setCrosshairPrefs] = useState<AimTrainerCrosshairPrefs>(
DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS,
);
const tier = TIER_META[difficulty]; const tier = TIER_META[difficulty];
const config = AIM_TRAINER_DIFFICULTIES[difficulty]; const config = AIM_TRAINER_DIFFICULTIES[difficulty];
useEffect(() => {
const prefs = loadAimTrainerCrosshairPrefs();
crosshairPrefsRef.current = prefs;
setCrosshairPrefs(prefs);
}, []);
const handleCrosshairPrefsChange = useCallback((prefs: AimTrainerCrosshairPrefs) => {
crosshairPrefsRef.current = prefs;
setCrosshairPrefs(prefs);
saveAimTrainerCrosshairPrefs(prefs);
}, []);
const setPhaseSafe = useCallback( const setPhaseSafe = useCallback(
(nextPhase: AimTrainerPhase) => { (nextPhase: AimTrainerPhase) => {
phaseRef.current = nextPhase; phaseRef.current = nextPhase;
@ -345,6 +370,7 @@ export function AimTrainerGame({
const now = performance.now(); const now = performance.now();
game.shotsFired += 1; game.shotsFired += 1;
playFire(); playFire();
game.recoilAt = now;
const hit = resolveAimTrainerTargetHit( const hit = resolveAimTrainerTargetHit(
game.targets.map((activeTarget) => activeTarget.target), game.targets.map((activeTarget) => activeTarget.target),
@ -414,6 +440,19 @@ export function AimTrainerGame({
[config.replacementDelayMs, expiredRef], [config.replacementDelayMs, expiredRef],
); );
const handlePointerMove = useCallback((event: PointerEvent<HTMLCanvasElement>) => {
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;
gameRef.current.cursor = { x, y };
}, []);
const handlePointerLeave = useCallback(() => {
gameRef.current.cursor = null;
}, []);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
@ -529,6 +568,7 @@ export function AimTrainerGame({
drawAimTrainerScene(ctx, game, { drawAimTrainerScene(ctx, game, {
now, now,
targetLifetimeMs: config.targetLifetimeMs, targetLifetimeMs: config.targetLifetimeMs,
crosshair: crosshairPrefsRef.current,
}); });
}; };
@ -587,11 +627,17 @@ export function AimTrainerGame({
<CardDescription>{tier.description}</CardDescription> <CardDescription>{tier.description}</CardDescription>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5">
<CrosshairSettings
value={crosshairPrefs}
onPrefsChangeAction={handleCrosshairPrefsChange}
/>
<Badge variant={timerIsCritical ? "destructive" : "outline"}> <Badge variant={timerIsCritical ? "destructive" : "outline"}>
<Timer data-icon="inline-start" /> <Timer data-icon="inline-start" />
{timerLabel} {timerLabel}
</Badge> </Badge>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5" aria-live="polite"> <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="Score" value={live.score} />
<HudMetric label="Accuracy" value={formatAccuracy(live.accuracy)} /> <HudMetric label="Accuracy" value={formatAccuracy(live.accuracy)} />
@ -612,7 +658,9 @@ export function AimTrainerGame({
width={CANVAS_W} width={CANVAS_W}
height={CANVAS_H} height={CANVAS_H}
onPointerDown={handleShot} onPointerDown={handleShot}
className="block h-auto w-full" onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className="block h-auto w-full cursor-none"
aria-label="Qualification Course target range" aria-label="Qualification Course target range"
/> />