From 2406df82dd58fca50aeca5c47ca5eefb3d438895 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Fri, 11 Sep 2026 03:25:21 -0400 Subject: [PATCH] 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 --- .../qualification/AimTrainerCanvas.ts | 100 ++++++++++++++++++ .../qualification/AimTrainerGame.tsx | 58 +++++++++- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/src/components/frontend/minigames/qualification/AimTrainerCanvas.ts b/src/components/frontend/minigames/qualification/AimTrainerCanvas.ts index f1be75a..9eb83b9 100644 --- a/src/components/frontend/minigames/qualification/AimTrainerCanvas.ts +++ b/src/components/frontend/minigames/qualification/AimTrainerCanvas.ts @@ -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); } diff --git a/src/components/frontend/minigames/qualification/AimTrainerGame.tsx b/src/components/frontend/minigames/qualification/AimTrainerGame.tsx index e0b7332..b51fac6 100644 --- a/src/components/frontend/minigames/qualification/AimTrainerGame.tsx +++ b/src/components/frontend/minigames/qualification/AimTrainerGame.tsx @@ -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(serverStats); const [lastRun, setLastRun] = useState(null); const [submitting, setSubmitting] = useState(false); + const crosshairPrefsRef = useRef( + DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS, + ); + const [crosshairPrefs, setCrosshairPrefs] = useState( + 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) => { + 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({ {tier.description} - - - {timerLabel} - +
+ + + + {timerLabel} + +
@@ -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" />