import GUI from "lil-gui"; import type { CameraMode, SceneMode } from "@/types/debug/debug"; import type { HandTrackingSource } from "@/types/handTracking/handTracking"; import { isDebugEnabled } from "@/utils/debug/isDebugEnabled"; const DEBUG_CONTROLS_STORAGE_KEY = "la-fabrik-debug-controls"; interface StoredDebugControls { cameraMode: CameraMode; sceneMode: SceneMode; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } function isCameraMode(value: unknown): value is CameraMode { return value === "player" || value === "debug"; } function isSceneMode(value: unknown): value is SceneMode { return value === "game" || value === "physics"; } function getStoredDebugControls(): Partial { try { const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY); if (!rawValue) return {}; const parsedValue: unknown = JSON.parse(rawValue); if (!isRecord(parsedValue)) return {}; return { ...(isCameraMode(parsedValue.cameraMode) ? { cameraMode: parsedValue.cameraMode } : {}), ...(isSceneMode(parsedValue.sceneMode) ? { sceneMode: parsedValue.sceneMode } : {}), }; } catch { return {}; } } export class Debug { private static instance: Debug | null = null; public readonly active: boolean; private readonly gui: GUI | null; private readonly folders = new Map(); private readonly folderRefCounts = new Map(); private readonly listeners = new Set<() => void>(); private readonly controls: { cameraMode: CameraMode; handTrackingSource: HandTrackingSource; showDebugOverlay: boolean; showHandTrackingSvg: boolean; showInteractionSpheres: boolean; showPerf: boolean; sceneMode: SceneMode; }; static getInstance(): Debug { if (!Debug.instance) { Debug.instance = new Debug(); } return Debug.instance; } private constructor() { this.active = isDebugEnabled(); const storedControls = getStoredDebugControls(); this.controls = { cameraMode: storedControls.cameraMode ?? "player", handTrackingSource: "backend", showDebugOverlay: true, showHandTrackingSvg: false, showInteractionSpheres: false, showPerf: true, sceneMode: storedControls.sceneMode ?? "game", }; this.gui = this.active ? new GUI({ title: "La-Fabrik Debug" }) : null; if (this.gui) { const folder = this.createFolder("Debug"); if (!folder) return; folder .add(this.controls, "cameraMode", { Player: "player", Debug: "debug" }) .name("Camera Mode") .onChange((value: CameraMode) => { this.controls.cameraMode = value; this.saveAndEmit(); }); folder .add(this.controls, "sceneMode", { Game: "game", Physics: "physics" }) .name("Scene") .onChange((value: SceneMode) => { this.controls.sceneMode = value; this.saveAndEmit(); }); folder .add(this.controls, "showPerf") .name("R3F Perf") .onChange((value: boolean) => { this.controls.showPerf = value; this.emit(); }); folder .add(this.controls, "showDebugOverlay") .name("Debug Overlay") .onChange((value: boolean) => { this.controls.showDebugOverlay = value; this.emit(); }); const handTrackingFolder = this.createFolder("Hand Tracking"); handTrackingFolder ?.add(this.controls, "showHandTrackingSvg") .name("Show SVG") .onChange((value: boolean) => { this.controls.showHandTrackingSvg = value; this.emit(); }); handTrackingFolder ?.add(this.controls, "handTrackingSource", { Backend: "backend", "Browser JS": "browser", }) .name("Source") .onChange((value: HandTrackingSource) => { this.controls.handTrackingSource = value; this.emit(); }); } } /** * Acquires a named GUI folder. Returns the folder on first acquisition and null * on subsequent acquisitions so callers only register controls once. */ createFolder(name: string): GUI | null { if (!this.gui) return null; const existing = this.folders.get(name); if (existing) { this.folderRefCounts.set(name, (this.folderRefCounts.get(name) ?? 0) + 1); return null; } const folder = this.gui.addFolder(name); this.folders.set(name, folder); this.folderRefCounts.set(name, 1); return folder; } destroyFolder(name: string): void { const folder = this.folders.get(name); const refCount = this.folderRefCounts.get(name); if (!folder || refCount === undefined) return; if (refCount > 1) { this.folderRefCounts.set(name, refCount - 1); return; } folder.destroy(); this.folders.delete(name); this.folderRefCounts.delete(name); } subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } getCameraMode(): CameraMode { return this.controls.cameraMode; } getSceneMode(): SceneMode { return this.controls.sceneMode; } getShowDebugOverlay(): boolean { return this.active && this.controls.showDebugOverlay; } getHandTrackingSource(): HandTrackingSource { return this.controls.handTrackingSource; } getShowInteractionSpheres(): boolean { return this.controls.showInteractionSpheres; } getShowHandTrackingSvg(): boolean { return this.controls.showHandTrackingSvg; } setShowHandTrackingSvg(value: boolean): void { this.controls.showHandTrackingSvg = value; this.emit(); } setShowInteractionSpheres(value: boolean): void { this.controls.showInteractionSpheres = value; this.emit(); } getShowPerf(): boolean { return this.active && this.controls.showPerf; } private emit(): void { this.listeners.forEach((listener) => listener()); } private saveAndEmit(): void { try { window.localStorage.setItem( DEBUG_CONTROLS_STORAGE_KEY, JSON.stringify({ cameraMode: this.controls.cameraMode, sceneMode: this.controls.sceneMode, }), ); } catch { // Debug persistence is optional; controls still work if storage is blocked. } this.emit(); } }