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,
type AimTrainerTarget,
} from "@/lib/minigames/aim-trainer";
import type { AimTrainerCrosshairPrefs } from "./crosshair";
export const AIM_TRAINER_CANVAS_WIDTH = 720;
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;
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 = {
readonly x: number;
@ -29,6 +34,7 @@ export type AimTrainerActiveTarget = {
type AimTrainerSceneFrame = {
readonly now: number;
readonly targetLifetimeMs: number;
readonly crosshair: AimTrainerCrosshairPrefs;
};
export type AimTrainerGameState = {
@ -41,6 +47,8 @@ export type AimTrainerGameState = {
shotsFired: number;
despawnedUnhitTargets: number;
feedback: AimTrainerPointFeedback[];
cursor: { x: number; y: number } | null;
recoilAt: number;
flashColor: string;
flashUntil: number;
lastDisplayedSecond: number;
@ -67,6 +75,8 @@ export function createAimTrainerGameState(): AimTrainerGameState {
shotsFired: 0,
despawnedUnhitTargets: 0,
feedback: [],
cursor: null,
recoilAt: 0,
flashColor: "#d9d19b",
flashUntil: 0,
lastDisplayedSecond: COURSE_DURATION_SECONDS,
@ -269,6 +279,95 @@ function drawFeedback(
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(
ctx: CanvasRenderingContext2D,
game: AimTrainerGameState,
@ -287,4 +386,5 @@ export function drawAimTrainerScene(
.padStart(2, "0")}`;
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 AimTrainerLiveStats,
} from "./AimTrainerCanvas";
import { CrosshairSettings } from "./CrosshairSettings";
import {
DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS,
loadAimTrainerCrosshairPrefs,
saveAimTrainerCrosshairPrefs,
type AimTrainerCrosshairPrefs,
} from "./crosshair";
import {
playCivilianHit,
playCountdownTick,
@ -183,9 +190,27 @@ export function AimTrainerGame({
const [stats, setStats] = useState<AimTrainerStats>(serverStats);
const [lastRun, setLastRun] = useState<LastRun | null>(null);
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 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(
(nextPhase: AimTrainerPhase) => {
phaseRef.current = nextPhase;
@ -345,6 +370,7 @@ export function AimTrainerGame({
const now = performance.now();
game.shotsFired += 1;
playFire();
game.recoilAt = now;
const hit = resolveAimTrainerTargetHit(
game.targets.map((activeTarget) => activeTarget.target),
@ -414,6 +440,19 @@ export function AimTrainerGame({
[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(() => {
const canvas = canvasRef.current;
if (!canvas) return;
@ -529,6 +568,7 @@ export function AimTrainerGame({
drawAimTrainerScene(ctx, game, {
now,
targetLifetimeMs: config.targetLifetimeMs,
crosshair: crosshairPrefsRef.current,
});
};
@ -587,10 +627,16 @@ export function AimTrainerGame({
<CardDescription>{tier.description}</CardDescription>
</div>
</div>
<Badge variant={timerIsCritical ? "destructive" : "outline"}>
<Timer data-icon="inline-start" />
{timerLabel}
</Badge>
<div className="flex items-center gap-1.5">
<CrosshairSettings
value={crosshairPrefs}
onPrefsChangeAction={handleCrosshairPrefsChange}
/>
<Badge variant={timerIsCritical ? "destructive" : "outline"}>
<Timer data-icon="inline-start" />
{timerLabel}
</Badge>
</div>
</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} />
@ -612,7 +658,9 @@ export function AimTrainerGame({
width={CANVAS_W}
height={CANVAS_H}
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"
/>