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,
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,10 +627,16 @@ export function AimTrainerGame({
|
||||||
<CardDescription>{tier.description}</CardDescription>
|
<CardDescription>{tier.description}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={timerIsCritical ? "destructive" : "outline"}>
|
<div className="flex items-center gap-1.5">
|
||||||
<Timer data-icon="inline-start" />
|
<CrosshairSettings
|
||||||
{timerLabel}
|
value={crosshairPrefs}
|
||||||
</Badge>
|
onPrefsChangeAction={handleCrosshairPrefsChange}
|
||||||
|
/>
|
||||||
|
<Badge variant={timerIsCritical ? "destructive" : "outline"}>
|
||||||
|
<Timer data-icon="inline-start" />
|
||||||
|
{timerLabel}
|
||||||
|
</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} />
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue