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