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:
parent
51f9d57aaa
commit
2406df82dd
2 changed files with 153 additions and 5 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue