diff --git a/src/components/frontend/minigames/qualification/CrosshairSettings.tsx b/src/components/frontend/minigames/qualification/CrosshairSettings.tsx new file mode 100644 index 0000000..ff4db5b --- /dev/null +++ b/src/components/frontend/minigames/qualification/CrosshairSettings.tsx @@ -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 = { + 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 ( + + ); + case "dot": + return ( + + ); + case "cross": + return ( + + ); + case "circle": + return ( + + ); + case "chevron": + return ( + + ); + } +} + +export function CrosshairSettings({ + value, + onPrefsChangeAction, +}: { + readonly value: AimTrainerCrosshairPrefs; + readonly onPrefsChangeAction: (prefs: AimTrainerCrosshairPrefs) => void; +}) { + const colorInputId = useId(); + + const handleCustomColorChange = (event: ChangeEvent) => { + onPrefsChangeAction({ ...value, color: event.target.value }); + }; + + const handleScaleChange = (event: ChangeEvent) => { + onPrefsChangeAction({ + ...value, + scale: normalizeAimTrainerCrosshairScale(Number(event.target.value)), + }); + }; + + return ( + + + + + +
+
+ + Crosshair style + +
+ {AIM_TRAINER_CROSSHAIR_STYLES.map((style) => ( + + ))} +
+
+ +
+ + Crosshair color + +
+ {COLOR_PRESETS.map((color) => { + const selected = value.color.toLowerCase() === color.toLowerCase(); + return ( + + ); + })} +
+ +
+ +
+
+ + Crosshair scale + + + {value.scale.toFixed(1)}x + +
+ +
+
+
+
+ ); +} diff --git a/src/components/frontend/minigames/qualification/crosshair.ts b/src/components/frontend/minigames/qualification/crosshair.ts new file mode 100644 index 0000000..3a7c733 --- /dev/null +++ b/src/components/frontend/minigames/qualification/crosshair.ts @@ -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 { + 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; + } +}