feat(qualification): add crosshair preferences module and settings popover
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
c204581366
commit
51f9d57aaa
2 changed files with 294 additions and 0 deletions
|
|
@ -0,0 +1,201 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useId, type ChangeEvent } from "react";
|
||||||
|
import { Crosshair } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
AIM_TRAINER_CROSSHAIR_SCALE_MAX,
|
||||||
|
AIM_TRAINER_CROSSHAIR_SCALE_MIN,
|
||||||
|
AIM_TRAINER_CROSSHAIR_SCALE_STEP,
|
||||||
|
AIM_TRAINER_CROSSHAIR_STYLES,
|
||||||
|
normalizeAimTrainerCrosshairScale,
|
||||||
|
type AimTrainerCrosshairPrefs,
|
||||||
|
type AimTrainerCrosshairStyle,
|
||||||
|
} from "./crosshair";
|
||||||
|
|
||||||
|
const STYLE_LABELS: Record<AimTrainerCrosshairStyle, string> = {
|
||||||
|
classic: "Classic",
|
||||||
|
dot: "Dot",
|
||||||
|
cross: "Cross",
|
||||||
|
circle: "Circle",
|
||||||
|
chevron: "Chevron",
|
||||||
|
};
|
||||||
|
|
||||||
|
const COLOR_PRESETS = [
|
||||||
|
"#d8d3b6",
|
||||||
|
"#4ade80",
|
||||||
|
"#f87171",
|
||||||
|
"#22d3ee",
|
||||||
|
"#e879f9",
|
||||||
|
"#ffffff",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function CrosshairPreview({ style }: { style: AimTrainerCrosshairStyle }) {
|
||||||
|
const lineProps = {
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 1.75,
|
||||||
|
strokeLinecap: "butt" as const,
|
||||||
|
strokeLinejoin: "miter" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (style) {
|
||||||
|
case "classic":
|
||||||
|
return (
|
||||||
|
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
|
||||||
|
<path d="M0 -11V-3 M0 3V11 M-11 0H-3 M3 0H11" fill="none" {...lineProps} />
|
||||||
|
<circle cx="0" cy="0" r="0.75" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "dot":
|
||||||
|
return (
|
||||||
|
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
|
||||||
|
<circle cx="0" cy="0" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "cross":
|
||||||
|
return (
|
||||||
|
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
|
||||||
|
<path d="M0 -11V11 M-11 0H11" fill="none" {...lineProps} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "circle":
|
||||||
|
return (
|
||||||
|
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
|
||||||
|
<circle cx="0" cy="0" r="9" fill="none" {...lineProps} />
|
||||||
|
<circle cx="0" cy="0" r="0.75" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
case "chevron":
|
||||||
|
return (
|
||||||
|
<svg viewBox="-12 -12 24 24" className="size-5" aria-hidden="true">
|
||||||
|
<path d="M-5 0L0 -6L5 0" fill="none" {...lineProps} />
|
||||||
|
<circle cx="0" cy="0" r="0.75" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrosshairSettings({
|
||||||
|
value,
|
||||||
|
onPrefsChangeAction,
|
||||||
|
}: {
|
||||||
|
readonly value: AimTrainerCrosshairPrefs;
|
||||||
|
readonly onPrefsChangeAction: (prefs: AimTrainerCrosshairPrefs) => void;
|
||||||
|
}) {
|
||||||
|
const colorInputId = useId();
|
||||||
|
|
||||||
|
const handleCustomColorChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onPrefsChangeAction({ ...value, color: event.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScaleChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
onPrefsChangeAction({
|
||||||
|
...value,
|
||||||
|
scale: normalizeAimTrainerCrosshairScale(Number(event.target.value)),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button type="button" variant="ghost" size="icon" aria-label="Crosshair settings">
|
||||||
|
<Crosshair aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" collisionPadding={8} className="w-[300px] p-3">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Crosshair style
|
||||||
|
</span>
|
||||||
|
<div className="grid grid-cols-5 gap-1">
|
||||||
|
{AIM_TRAINER_CROSSHAIR_STYLES.map((style) => (
|
||||||
|
<Button
|
||||||
|
key={style}
|
||||||
|
type="button"
|
||||||
|
variant={value.style === style ? "default" : "outline"}
|
||||||
|
className="h-auto min-w-0 flex-col gap-1 whitespace-normal px-1 py-1.5 text-center text-xs"
|
||||||
|
aria-label={`${STYLE_LABELS[style]} crosshair`}
|
||||||
|
aria-pressed={value.style === style}
|
||||||
|
onClick={() => onPrefsChangeAction({ ...value, style })}
|
||||||
|
>
|
||||||
|
<CrosshairPreview style={style} />
|
||||||
|
<span>{STYLE_LABELS[style]}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Crosshair color
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{COLOR_PRESETS.map((color) => {
|
||||||
|
const selected = value.color.toLowerCase() === color.toLowerCase();
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={color}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={
|
||||||
|
selected ? "size-7 p-1 ring-2 ring-ring ring-offset-2" : "size-7 p-1"
|
||||||
|
}
|
||||||
|
aria-label={`Use ${color} crosshair color`}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => onPrefsChangeAction({ ...value, color })}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-4 rounded-sm border border-black/30"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
htmlFor={colorInputId}
|
||||||
|
className="flex w-full items-center justify-between gap-3 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span>Custom</span>
|
||||||
|
<Input
|
||||||
|
id={colorInputId}
|
||||||
|
type="color"
|
||||||
|
value={value.color}
|
||||||
|
aria-label="Custom crosshair color"
|
||||||
|
className="h-8 w-10 cursor-pointer p-1"
|
||||||
|
onChange={handleCustomColorChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Crosshair scale
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-muted-foreground">
|
||||||
|
{value.scale.toFixed(1)}x
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={AIM_TRAINER_CROSSHAIR_SCALE_MIN}
|
||||||
|
max={AIM_TRAINER_CROSSHAIR_SCALE_MAX}
|
||||||
|
step={AIM_TRAINER_CROSSHAIR_SCALE_STEP}
|
||||||
|
value={value.scale}
|
||||||
|
aria-label="Crosshair scale"
|
||||||
|
className="h-4 w-full cursor-pointer accent-primary"
|
||||||
|
onChange={handleScaleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
src/components/frontend/minigames/qualification/crosshair.ts
Normal file
93
src/components/frontend/minigames/qualification/crosshair.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
export type AimTrainerCrosshairStyle = "classic" | "dot" | "cross" | "circle" | "chevron";
|
||||||
|
|
||||||
|
export type AimTrainerCrosshairPrefs = {
|
||||||
|
style: AimTrainerCrosshairStyle;
|
||||||
|
color: string;
|
||||||
|
scale: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIM_TRAINER_CROSSHAIR_STORAGE_KEY = "ptf.aimTrainer.crosshair";
|
||||||
|
export const AIM_TRAINER_CROSSHAIR_SCALE_MIN = 1;
|
||||||
|
export const AIM_TRAINER_CROSSHAIR_SCALE_MAX = 2.5;
|
||||||
|
export const AIM_TRAINER_CROSSHAIR_SCALE_STEP = 0.1;
|
||||||
|
|
||||||
|
export const AIM_TRAINER_CROSSHAIR_STYLES = [
|
||||||
|
"classic",
|
||||||
|
"dot",
|
||||||
|
"cross",
|
||||||
|
"circle",
|
||||||
|
"chevron",
|
||||||
|
] as const satisfies readonly AimTrainerCrosshairStyle[];
|
||||||
|
|
||||||
|
export const DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS: AimTrainerCrosshairPrefs = {
|
||||||
|
style: "classic",
|
||||||
|
color: "#d8d3b6",
|
||||||
|
scale: AIM_TRAINER_CROSSHAIR_SCALE_MIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDefaultAimTrainerCrosshairPrefs(): AimTrainerCrosshairPrefs {
|
||||||
|
return { ...DEFAULT_AIM_TRAINER_CROSSHAIR_PREFS };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAimTrainerCrosshairStyle(value: unknown): value is AimTrainerCrosshairStyle {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
AIM_TRAINER_CROSSHAIR_STYLES.some((style) => style === value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHexColor(value: unknown): value is string {
|
||||||
|
return typeof value === "string" && /^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeAimTrainerCrosshairScale(value: unknown): number {
|
||||||
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||||
|
return AIM_TRAINER_CROSSHAIR_SCALE_MIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamped = Math.min(
|
||||||
|
AIM_TRAINER_CROSSHAIR_SCALE_MAX,
|
||||||
|
Math.max(AIM_TRAINER_CROSSHAIR_SCALE_MIN, value),
|
||||||
|
);
|
||||||
|
return Number(
|
||||||
|
(Math.round(clamped / AIM_TRAINER_CROSSHAIR_SCALE_STEP) * AIM_TRAINER_CROSSHAIR_SCALE_STEP).toFixed(
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAimTrainerCrosshairPrefs(): AimTrainerCrosshairPrefs {
|
||||||
|
const defaults = createDefaultAimTrainerCrosshairPrefs();
|
||||||
|
if (typeof window === "undefined") return defaults;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(AIM_TRAINER_CROSSHAIR_STORAGE_KEY);
|
||||||
|
if (!stored) return defaults;
|
||||||
|
|
||||||
|
const parsed: unknown = JSON.parse(stored);
|
||||||
|
if (!isRecord(parsed) || !isAimTrainerCrosshairStyle(parsed.style)) return defaults;
|
||||||
|
if (!isHexColor(parsed.color)) return defaults;
|
||||||
|
|
||||||
|
return {
|
||||||
|
style: parsed.style,
|
||||||
|
color: parsed.color,
|
||||||
|
scale: normalizeAimTrainerCrosshairScale(parsed.scale),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAimTrainerCrosshairPrefs(prefs: AimTrainerCrosshairPrefs): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(AIM_TRAINER_CROSSHAIR_STORAGE_KEY, JSON.stringify(prefs));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue