import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import { useFogQueryState } from "./useQueryParams"; import { useTouchDevice } from "./useTouchDevice"; export const MIN_SPEED_MULTIPLIER = 0.01; export const MAX_SPEED_MULTIPLIER = 1; export const DEFAULT_MOUSE_SENSITIVITY = 32 / 16000; // 0.002 export const MIN_MOUSE_SENSITIVITY = 1 / 16000; export const MAX_MOUSE_SENSITIVITY = 256 / 16000; type StateSetter = ReturnType>[1]; export type TouchMode = "dualStick" | "moveLookStick"; type SettingsContext = { fogEnabled: boolean; setFogEnabled: StateSetter; clearFogEnabledOverride: () => void; highQualityFog: boolean; setHighQualityFog: StateSetter; fov: number; setFov: StateSetter; audioEnabled: boolean; setAudioEnabled: StateSetter; animationEnabled: boolean; setAnimationEnabled: StateSetter; warriorName: string; setWarriorName: StateSetter; audioVolume: number; setAudioVolume: StateSetter; sidebarOpen: boolean; setSidebarOpen: StateSetter; }; type DebugContext = { debugMode: boolean; setDebugMode: StateSetter; renderOnDemand: boolean; setRenderOnDemand: StateSetter; }; type ControlsContext = { speedMultiplier: number; setSpeedMultiplier: StateSetter; mouseSensitivity: number; setMouseSensitivity: StateSetter; touchMode: TouchMode; setTouchMode: StateSetter; invertScroll: boolean; setInvertScroll: StateSetter; invertDrag: boolean; setInvertDrag: StateSetter; invertJoystick: boolean; setInvertJoystick: StateSetter; }; const SettingsContext = createContext(null); const DebugContext = createContext(null); const ControlsContext = createContext(null); type PersistedSettings = { fogEnabled?: boolean; highQualityFog?: boolean; speedMultiplier?: number; mouseSensitivity?: number; fov?: number; audioEnabled?: boolean; animationEnabled?: boolean; debugMode?: boolean; touchMode?: TouchMode; warriorName?: string; audioVolume?: number; invertScroll?: boolean; invertDrag?: boolean; invertJoystick?: boolean; sidebarOpen?: boolean; }; export function useSettings() { const context = useContext(SettingsContext); if (!context) { throw new Error( "No SettingsContext found. Did you remember to add a ?", ); } return context; } export function useDebug() { const context = useContext(DebugContext); if (!context) { throw new Error( "No DebugContext found. Did you remember to add a ?", ); } return context; } export function useControls() { const context = useContext(ControlsContext); if (!context) { throw new Error( "No ControlsContext found. Did you remember to add a ?", ); } return context; } export function SettingsProvider({ children }: { children: ReactNode }) { const [fogEnabled, setFogEnabled] = useState(true); const [highQualityFog, setHighQualityFog] = useState(false); const [speedMultiplier, setSpeedMultiplier] = useState(0.15); const [mouseSensitivity, setMouseSensitivity] = useState( DEFAULT_MOUSE_SENSITIVITY, ); const [fov, setFov] = useState(90); const [audioEnabled, setAudioEnabled] = useState(false); const [audioVolume, setAudioVolume] = useState(0.75); const [animationEnabled, setAnimationEnabled] = useState(true); const [debugMode, setDebugMode] = useState(false); const [touchMode, setTouchMode] = useState("moveLookStick"); const [warriorName, setWarriorName] = useState("MapGenius"); const [invertScroll, setInvertScroll] = useState(false); const [invertDrag, setInvertDrag] = useState(false); const [invertJoystick, setInvertJoystick] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); const [renderOnDemand, setRenderOnDemand] = useState(false); const [fogEnabledOverride, setFogEnabledOverride] = useFogQueryState(); const clearFogEnabledOverride = useCallback(() => { setFogEnabledOverride(null); }, [setFogEnabledOverride]); const setFogEnabledWithoutOverride: StateSetter = useCallback( (value) => { setFogEnabled(value); clearFogEnabledOverride(); }, [clearFogEnabledOverride], ); const settingsContext: SettingsContext = useMemo( () => ({ fogEnabled: fogEnabledOverride ?? fogEnabled, setFogEnabled: setFogEnabledWithoutOverride, clearFogEnabledOverride, highQualityFog, setHighQualityFog, fov, setFov, audioEnabled, setAudioEnabled, animationEnabled, setAnimationEnabled, warriorName, setWarriorName, audioVolume, setAudioVolume, sidebarOpen, setSidebarOpen, }), [ fogEnabled, fogEnabledOverride, setFogEnabledWithoutOverride, clearFogEnabledOverride, highQualityFog, fov, audioEnabled, animationEnabled, warriorName, audioVolume, sidebarOpen, ], ); const debugContext: DebugContext = useMemo( () => ({ debugMode, setDebugMode, renderOnDemand, setRenderOnDemand, }), [debugMode, setDebugMode, renderOnDemand], ); const controlsContext: ControlsContext = useMemo( () => ({ speedMultiplier, setSpeedMultiplier, mouseSensitivity, setMouseSensitivity, touchMode, setTouchMode, invertScroll, setInvertScroll, invertDrag, setInvertDrag, invertJoystick, setInvertJoystick, }), [ speedMultiplier, setSpeedMultiplier, mouseSensitivity, touchMode, setTouchMode, invertScroll, invertDrag, invertJoystick, ], ); const isTouch = useTouchDevice(); // Read persisted settings from localStorage. useEffect(() => { // Defer until we know whether or not we're on a touch device... if (isTouch == null) return; let savedSettings: PersistedSettings = {}; try { savedSettings = JSON.parse(localStorage.getItem("settings")) || {}; } catch (err) { // Ignore. } if (savedSettings.debugMode != null) { setDebugMode(savedSettings.debugMode); } if (savedSettings.audioEnabled != null) { setAudioEnabled(savedSettings.audioEnabled); } if (savedSettings.animationEnabled != null) { setAnimationEnabled(savedSettings.animationEnabled); } if (savedSettings.fogEnabled != null) { setFogEnabled(savedSettings.fogEnabled); } if (savedSettings.highQualityFog != null) { setHighQualityFog(savedSettings.highQualityFog); } if (savedSettings.speedMultiplier != null) { setSpeedMultiplier( Math.max( MIN_SPEED_MULTIPLIER, Math.min(MAX_SPEED_MULTIPLIER, savedSettings.speedMultiplier), ), ); } if (savedSettings.mouseSensitivity != null) { setMouseSensitivity( Math.max( MIN_MOUSE_SENSITIVITY, Math.min(MAX_MOUSE_SENSITIVITY, savedSettings.mouseSensitivity), ), ); } if (savedSettings.fov != null) { setFov(savedSettings.fov); } if (savedSettings.touchMode != null) { setTouchMode(savedSettings.touchMode); } if (savedSettings.warriorName != null) { setWarriorName(savedSettings.warriorName); } if (savedSettings.audioVolume != null) { setAudioVolume(savedSettings.audioVolume); } if (savedSettings.invertScroll != null) { setInvertScroll(savedSettings.invertScroll); } if (savedSettings.invertDrag != null) { setInvertDrag(savedSettings.invertDrag); } if (savedSettings.invertJoystick != null) { setInvertJoystick(savedSettings.invertJoystick); } if (savedSettings.sidebarOpen != null) { // Don't restore on touch devices! if (!isTouch) { setSidebarOpen(savedSettings.sidebarOpen); } } }, [isTouch]); // Persist settings to localStorage with debouncing to avoid excessive writes const saveTimerRef = useRef | null>(null); useEffect(() => { // Clear any pending save if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } // Debounce localStorage writes saveTimerRef.current = setTimeout(() => { const settingsToSave: PersistedSettings = { fogEnabled, highQualityFog, speedMultiplier, mouseSensitivity, fov, audioEnabled, animationEnabled, debugMode, touchMode, warriorName, audioVolume, invertScroll, invertDrag, invertJoystick, sidebarOpen, }; try { localStorage.setItem("settings", JSON.stringify(settingsToSave)); } catch (err) { // Probably forbidden by browser settings. } }, 500); return () => { if (saveTimerRef.current) { clearTimeout(saveTimerRef.current); } }; }, [ fogEnabled, highQualityFog, speedMultiplier, mouseSensitivity, fov, audioEnabled, animationEnabled, debugMode, touchMode, warriorName, audioVolume, invertScroll, invertDrag, invertJoystick, sidebarOpen, ]); return ( {children} ); }