diff --git a/.agent/skills/debug.md b/.agent/skills/debug.md index dd6999a..7a0cd78 100644 --- a/.agent/skills/debug.md +++ b/.agent/skills/debug.md @@ -58,19 +58,18 @@ if (debug.active) { r3f-perf is loaded only in debug mode to avoid dependency issues in production: ```tsx -// src/components/debug/DebugPerf.tsx import { Suspense, lazy } from "react"; -import { Debug } from "@/utils/debug/Debug"; +import { useShowDebugPerf } from "@/hooks/debug/useShowDebugPerf"; const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf }))); export function DebugPerf() { - const debug = Debug.getInstance(); - if (!debug.active) return null; + const showDebugPerf = useShowDebugPerf(); + if (!showDebugPerf) return null; return ( - + ); } @@ -90,5 +89,8 @@ Usage in Canvas: - All debug UI goes through `Debug.getInstance()` — never inline `if (isDev)` checks - r3f-perf is always lazy-imported, never a hard dependency in scene components - Debug folders should be organized by domain (Lighting, Player, Zone, Interaction) +- Global debug controls include camera mode, scene mode, `R3F Perf`, and `Debug Overlay` +- Interaction-specific controls such as interaction spheres belong in the `Interaction` folder +- HTML debug panels should be grouped under `src/components/ui/debug/DebugOverlayLayout.tsx` - Debug panel must not affect production builds — it simply doesn't mount when `?debug` is absent - Clean up debug folders in `destroy()` when relevant diff --git a/README.md b/README.md index 8ab330c..6abc3be 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,10 @@ la-fabrik/ │ │ └── world/ # Environment-specific 3D objects │ └── ui/ # HTML overlays — outside Canvas │ ├── Crosshair.tsx - │ ├── HandTrackingOverlay.tsx + │ ├── debug/ # Debug-only HTML overlay panels + │ │ ├── DebugOverlayLayout.tsx + │ │ ├── GameStateDebugPanel.tsx + │ │ └── HandTrackingDebugPanel.tsx │ ├── HandTrackingVisualizer.tsx │ └── InteractPrompt.tsx │ diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index 7d16ed9..aea9388 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -39,8 +39,12 @@ This document describes the code that exists today in the repository. - `src/utils/debug/Debug.ts` owns the `lil-gui` instance and debug controls. - `src/hooks/debug/useCameraMode.ts` and `src/hooks/debug/useSceneMode.ts` subscribe to debug state. - `src/components/debug/DebugPerf.tsx` lazily mounts `r3f-perf` in debug mode. +- `src/components/ui/debug/DebugOverlayLayout.tsx` mounts the compact HTML debug overlay when enabled from `lil-gui`. +- `src/components/ui/debug/GameStateDebugPanel.tsx` exposes current game state, main/sub-state switching, previous/next step controls, and reset. +- `src/components/ui/debug/HandTrackingDebugPanel.tsx` shows hand tracking status, usage, model-loaded placeholder, hand count, and fist state while hand tracking is active. - `src/components/debug/scene/DebugHelpers.tsx` mounts debug helpers. - `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera. +- `lil-gui` global debug controls include camera mode, scene mode, `R3F Perf`, and `Debug Overlay`; interaction-specific controls live in the `Interaction` folder. ## 3D Component Domains diff --git a/docs/technical/hand-tracking.md b/docs/technical/hand-tracking.md index da1ff88..b5c0f31 100644 --- a/docs/technical/hand-tracking.md +++ b/docs/technical/hand-tracking.md @@ -104,12 +104,12 @@ The final hold distance is clamped between the configured grab minimum and maxim The current debug UI includes: -- `HandTrackingOverlay` for status, usage, server state, hand count, and fist state +- `HandTrackingDebugPanel` inside `DebugOverlayLayout` for status, usage, model-loaded placeholder, server state, hand count, and fist state - `HandTrackingVisualizer` for the SVG landmark wireframe - `r3f-perf` for render performance - `lil-gui` for scene, camera, lighting, interaction, and grab controls -The hand tracking overlay is an HTML overlay outside the canvas. The hand wireframe is also HTML/SVG, not a 3D hand model. +The hand tracking debug panel is a compact HTML grid outside the canvas. `Model loaded` is currently hardcoded to `none` until model-loading information is wired into the hand tracking flow. The hand wireframe is also HTML/SVG, not a 3D hand model. ## Known Limitations diff --git a/docs/technical/zustand.md b/docs/technical/zustand.md index 8e05717..80b6f5c 100644 --- a/docs/technical/zustand.md +++ b/docs/technical/zustand.md @@ -143,7 +143,8 @@ In React Three Fiber, mounting and unmounting JSX controls what appears in the T Current overlays: -- `GameStateHUD`: debug-only progression panel shown with `?debug` +- `DebugOverlayLayout`: debug-only overlay shown with `?debug`, including the `GameStateDebugPanel` progression panel +- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store - `Crosshair`: player aiming helper - `InteractPrompt`: interaction prompt diff --git a/docs/user/features.md b/docs/user/features.md index ddf5212..1a3a210 100644 --- a/docs/user/features.md +++ b/docs/user/features.md @@ -33,7 +33,8 @@ This document lists features that are implemented in the current codebase. ## Debug Tooling - `?debug` query param enables the debug panel -- `lil-gui` controls for camera mode, scene mode, and interaction spheres +- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning +- Compact debug overlay for game state controls and hand tracking status - Debug scene helpers - Free debug camera - `r3f-perf` overlay diff --git a/src/components/debug/DebugPerf.tsx b/src/components/debug/DebugPerf.tsx index 31bdfed..f86f61a 100644 --- a/src/components/debug/DebugPerf.tsx +++ b/src/components/debug/DebugPerf.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy } from "react"; -import { Debug } from "@/utils/debug/Debug"; +import { useShowDebugPerf } from "@/hooks/debug/useShowDebugPerf"; const Perf = lazy(() => import("r3f-perf").then((m) => ({ default: m.Perf }))); @@ -7,9 +7,9 @@ const DEBUG_GUI_WIDTH = 245; const DEBUG_PANEL_GAP = 20; export function DebugPerf(): React.JSX.Element | null { - const debug = Debug.getInstance(); + const showDebugPerf = useShowDebugPerf(); - if (!debug.active) { + if (!showDebugPerf) { return null; } diff --git a/src/components/three/interaction/InteractableObject.tsx b/src/components/three/interaction/InteractableObject.tsx index 501eb9d..21264fe 100644 --- a/src/components/three/interaction/InteractableObject.tsx +++ b/src/components/three/interaction/InteractableObject.tsx @@ -115,6 +115,18 @@ export function InteractableObject( }, []); const setupInteractionDebugFolder = useCallback((folder: GUI) => { + const debug = Debug.getInstance(); + const controls = { + showInteractionSpheres: debug.getShowInteractionSpheres(), + }; + + folder + .add(controls, "showInteractionSpheres") + .name("Interaction Spheres") + .onChange((value: boolean) => { + debug.setShowInteractionSpheres(value); + }); + folder .add({ radius: INTERACTION_RADIUS }, "radius") .name("Interaction radius") diff --git a/src/components/ui/GameStateHUD.tsx b/src/components/ui/GameStateHUD.tsx deleted file mode 100644 index f38bafd..0000000 --- a/src/components/ui/GameStateHUD.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { Debug } from "@/utils/debug/Debug"; -import { - type MainGameState, - useGameStore, -} from "@/managers/stores/useGameStore"; - -const MAIN_STATES: MainGameState[] = [ - "intro", - "bike", - "pylone", - "ferme", - "outro", -]; - -export function GameStateHUD(): React.JSX.Element | null { - const debug = Debug.getInstance(); - const mainState = useGameStore((state) => state.mainState); - const detail = useGameStore((state) => { - switch (state.mainState) { - case "intro": - return state.intro.hasCompleted ? "completed" : "waiting"; - case "bike": - return state.bike.currentStep; - case "pylone": - return state.pylone.currentStep; - case "ferme": - return state.ferme.currentStep; - case "outro": - return state.outro.hasStarted ? "started" : "waiting"; - } - }); - const setMainState = useGameStore((state) => state.setMainState); - const advanceGameState = useGameStore((state) => state.advanceGameState); - const resetGame = useGameStore((state) => state.resetGame); - - if (!debug.active) return null; - - return ( - - ); -} diff --git a/src/components/ui/GameUI.tsx b/src/components/ui/GameUI.tsx index 5a333e8..6b3482a 100644 --- a/src/components/ui/GameUI.tsx +++ b/src/components/ui/GameUI.tsx @@ -1,17 +1,15 @@ import { Crosshair } from "@/components/ui/Crosshair"; -import { GameStateHUD } from "@/components/ui/GameStateHUD"; -import { HandTrackingOverlay } from "@/components/ui/HandTrackingOverlay"; +import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout"; import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer"; import { InteractPrompt } from "@/components/ui/InteractPrompt"; export function GameUI(): React.JSX.Element { return ( <> - + - ); } diff --git a/src/components/ui/HandTrackingOverlay.tsx b/src/components/ui/HandTrackingOverlay.tsx deleted file mode 100644 index 50bb879..0000000 --- a/src/components/ui/HandTrackingOverlay.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; -import type { HandTrackingStatus } from "@/types/handTracking/handTracking"; - -const STATUS_LABELS: Record = { - idle: "Idle", - requesting_camera: "Requesting camera", - starting_camera: "Starting camera", - connecting_server: "Connecting server", - connecting: "Connecting", - connected: "Connected", - disconnected: "Disconnected", - error: "Error", -}; - -export function HandTrackingOverlay(): React.JSX.Element | null { - const { hands, status, usageStatus, serverStatus, error } = - useHandTrackingSnapshot(); - - if (status === "idle") { - return null; - } - - const fist = hands.some((hand) => hand.isFist); - - return ( - - ); -} diff --git a/src/components/ui/debug/DebugOverlayLayout.tsx b/src/components/ui/debug/DebugOverlayLayout.tsx new file mode 100644 index 0000000..9bfa71c --- /dev/null +++ b/src/components/ui/debug/DebugOverlayLayout.tsx @@ -0,0 +1,22 @@ +import { GameStateDebugPanel } from "@/components/ui/debug/GameStateDebugPanel"; +import { HandTrackingDebugPanel } from "@/components/ui/debug/HandTrackingDebugPanel"; +import { useShowDebugOverlay } from "@/hooks/debug/useShowDebugOverlay"; + +export function DebugOverlayLayout(): React.JSX.Element | null { + const showDebugOverlay = useShowDebugOverlay(); + + if (!showDebugOverlay) return null; + + return ( + + ); +} diff --git a/src/components/ui/debug/GameStateDebugPanel.tsx b/src/components/ui/debug/GameStateDebugPanel.tsx new file mode 100644 index 0000000..f2a8591 --- /dev/null +++ b/src/components/ui/debug/GameStateDebugPanel.tsx @@ -0,0 +1,164 @@ +import { RotateCcw, StepBack, StepForward } from "lucide-react"; +import { + type MainGameState, + type MissionStep, + useGameStore, +} from "@/managers/stores/useGameStore"; + +const MAIN_STATES: MainGameState[] = [ + "intro", + "bike", + "pylone", + "ferme", + "outro", +]; + +const MISSION_STEPS: MissionStep[] = [ + "locked", + "waiting", + "inspected", + "fragmented", + "scanning", + "repairing", + "done", +]; + +function toPascalCase(value: string): string { + return value + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +export function GameStateDebugPanel(): React.JSX.Element { + const mainState = useGameStore((state) => state.mainState); + const detail = useGameStore((state) => { + switch (state.mainState) { + case "intro": + return state.intro.hasCompleted ? "completed" : "waiting"; + case "bike": + return state.bike.currentStep; + case "pylone": + return state.pylone.currentStep; + case "ferme": + return state.ferme.currentStep; + case "outro": + return state.outro.hasStarted ? "started" : "waiting"; + } + }); + const setMainState = useGameStore((state) => state.setMainState); + const setIntroState = useGameStore((state) => state.setIntroState); + const setBikeState = useGameStore((state) => state.setBikeState); + const setPyloneState = useGameStore((state) => state.setPyloneState); + const setFermeState = useGameStore((state) => state.setFermeState); + const setOutroState = useGameStore((state) => state.setOutroState); + const advanceGameState = useGameStore((state) => state.advanceGameState); + const rewindGameState = useGameStore((state) => state.rewindGameState); + const resetGame = useGameStore((state) => state.resetGame); + + const subStateOptions = + mainState === "intro" + ? ["waiting", "completed"] + : mainState === "outro" + ? ["waiting", "started"] + : MISSION_STEPS; + + function setSubState(nextSubState: string): void { + if (mainState === "intro") { + setIntroState({ hasCompleted: nextSubState === "completed" }); + return; + } + + if (mainState === "bike") { + setBikeState({ currentStep: nextSubState as MissionStep }); + return; + } + + if (mainState === "pylone") { + setPyloneState({ currentStep: nextSubState as MissionStep }); + return; + } + + if (mainState === "ferme") { + setFermeState({ currentStep: nextSubState as MissionStep }); + return; + } + + setOutroState({ hasStarted: nextSubState === "started" }); + } + + return ( +
+
+

Game State

+
+ +
+
+ Main state + {toPascalCase(mainState)} +
+
+ {MAIN_STATES.map((state) => ( + + ))} +
+
+ +
+
+ Sub state + {toPascalCase(detail)} +
+
+ {subStateOptions.map((subState) => ( + + ))} +
+
+ +
+ + + +
+
+ ); +} diff --git a/src/components/ui/debug/HandTrackingDebugPanel.tsx b/src/components/ui/debug/HandTrackingDebugPanel.tsx new file mode 100644 index 0000000..d156bbe --- /dev/null +++ b/src/components/ui/debug/HandTrackingDebugPanel.tsx @@ -0,0 +1,65 @@ +import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot"; +import type { HandTrackingStatus } from "@/types/handTracking/handTracking"; + +const STATUS_LABELS: Record = { + idle: "Idle", + requesting_camera: "Requesting camera", + starting_camera: "Starting camera", + connecting_server: "Connecting server", + connecting: "Connecting", + connected: "Connected", + disconnected: "Disconnected", + error: "Error", +}; + +export function HandTrackingDebugPanel(): React.JSX.Element | null { + const { hands, status, usageStatus, serverStatus, error } = + useHandTrackingSnapshot(); + + if (status === "idle") { + return null; + } + + const fist = hands.some((hand) => hand.isFist); + + return ( +
+
+

Hand tracking

+ {STATUS_LABELS[status]} +
+ +
+
+
Usage
+
{usageStatus}
+
+
+
Model loaded
+
none
+
+ {serverStatus ? ( +
+
Server
+
{serverStatus}
+
+ ) : null} +
+
Hands
+
{hands.length}
+
+
+
Fist
+
{fist ? "yes" : "no"}
+
+
+ + {error ? ( + {error} + ) : null} +
+ ); +} diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts index 48699fa..05064d3 100644 --- a/src/data/docs/docsTranslations.ts +++ b/src/data/docs/docsTranslations.ts @@ -122,8 +122,12 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt. - \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug. - \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug. - \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug. +- \`src/components/ui/debug/DebugOverlayLayout.tsx\` monte l'overlay HTML debug compact quand il est activé depuis \`lil-gui\`. +- \`src/components/ui/debug/GameStateDebugPanel.tsx\` expose l'état de jeu courant, le changement de main/sub-state, les contrôles previous/next step et le reset. +- \`src/components/ui/debug/HandTrackingDebugPanel.tsx\` affiche le statut hand tracking, l'usage, le placeholder de modèle chargé, le nombre de mains et l'état fist pendant l'activation du hand tracking. - \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug. - \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug. +- Les contrôles globaux \`lil-gui\` incluent camera mode, scene mode, \`R3F Perf\` et \`Debug Overlay\`; les contrôles d'interaction vivent dans le dossier \`Interaction\`. ## Limites actuelles @@ -345,7 +349,8 @@ Dans React Three Fiber, monter ou démonter du JSX contrôle ce qui apparaît da Overlays actuels : -- \`GameStateHUD\` : panneau de progression debug visible avec \`?debug\` +- \`DebugOverlayLayout\` : layout compact des panels debug HTML visible avec \`?debug\` +- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store - \`Crosshair\` : aide de visée joueur - \`InteractPrompt\` : prompt d'interaction @@ -400,7 +405,8 @@ Ce document liste les fonctionnalités présentes dans le code actuel. ## Outils debug - Le paramètre \`?debug\` active le panneau debug -- Contrôles \`lil-gui\` pour le mode caméra, le mode scène et les sphères d'interaction +- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction +- Overlay debug compact pour les contrôles de game state et le statut hand tracking - Helpers de scène debug - Caméra libre debug - Overlay \`r3f-perf\` diff --git a/src/hooks/debug/useShowDebugOverlay.ts b/src/hooks/debug/useShowDebugOverlay.ts new file mode 100644 index 0000000..b930e8e --- /dev/null +++ b/src/hooks/debug/useShowDebugOverlay.ts @@ -0,0 +1,5 @@ +import { useDebugStore } from "@/hooks/debug/useDebugStore"; + +export function useShowDebugOverlay(): boolean { + return useDebugStore((debug) => debug.getShowDebugOverlay()); +} diff --git a/src/hooks/debug/useShowDebugPerf.ts b/src/hooks/debug/useShowDebugPerf.ts new file mode 100644 index 0000000..99c3b4a --- /dev/null +++ b/src/hooks/debug/useShowDebugPerf.ts @@ -0,0 +1,5 @@ +import { useDebugStore } from "@/hooks/debug/useDebugStore"; + +export function useShowDebugPerf(): boolean { + return useDebugStore((debug) => debug.getShowPerf()); +} diff --git a/src/index.css b/src/index.css index 9fa843a..6ea0915 100644 --- a/src/index.css +++ b/src/index.css @@ -397,32 +397,128 @@ canvas { letter-spacing: 0.03em; } -/* Hand tracking debug UI */ -.hand-tracking-overlay { +/* Debug overlay panels */ +.debug-overlay-layout { position: fixed; - top: 16px; - left: 16px; + top: 12px; + left: 12px; z-index: 20; display: flex; flex-direction: column; - gap: 4px; - min-width: 180px; - padding: 12px; - color: rgba(255, 255, 255, 0.9); - background: rgba(4, 7, 13, 0.78); - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 8px; - font-size: 12px; - pointer-events: none; + width: min(260px, calc(100vw - 24px)); + max-height: calc(100vh - 24px); + overflow-y: auto; + color: #f8f8f8; + background: rgba(8, 8, 8, 0.88); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 18px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(18px); + scrollbar-width: thin; + scrollbar-color: #3a3a3a transparent; + pointer-events: auto; } -.hand-tracking-overlay strong { - color: white; - font-size: 13px; +.debug-overlay-layout::-webkit-scrollbar { + width: 6px; } -.hand-tracking-overlay__error { +.debug-overlay-layout::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 999px; +} + +.debug-overlay-layout__header { + padding: 12px 12px 10px; +} + +.debug-overlay-layout__kicker { + color: #8f8f8f; + font-size: 0.58rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.debug-overlay-layout__header h2 { + margin: 0.2rem 0 0; + color: #ffffff; + font-size: 1rem; + font-weight: 720; + letter-spacing: -0.06em; +} + +.debug-overlay-layout__sections { + display: flex; + flex-direction: column; +} + +.debug-overlay-section { + padding: 10px 12px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.debug-overlay-section__heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.debug-overlay-section__heading h3, +.game-state-debug-panel__header h3 { + margin: 0; + color: #ffffff; + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.debug-overlay-section__heading span { + color: #a3a3a3; + font-size: 0.66rem; + font-weight: 650; +} + +.debug-overlay-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin: 0; +} + +.debug-overlay-metrics div { + display: grid; + gap: 3px; + min-height: 0; + padding: 7px 8px; + background: #101010; + border: 1px solid #242424; + border-radius: 10px; +} + +.debug-overlay-metrics dt { + color: #8f8f8f; + font-size: 0.56rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.debug-overlay-metrics dd { + margin: 0; + color: #ffffff; + font-size: 0.72rem; + font-weight: 650; +} + +.hand-tracking-debug-panel__error { color: #fca5a5; + display: block; + margin-top: 8px; + font-size: 0.68rem; } .hand-tracking-visualizer { @@ -476,77 +572,99 @@ canvas { } /* Zustand game state debug UI */ -.game-state-hud { - position: fixed; - top: 18px; - right: 18px; - z-index: 20; +.game-state-debug-panel { display: grid; - gap: 12px; - width: min(320px, calc(100vw - 36px)); - padding: 14px; - border: 1px solid rgba(255, 255, 255, 0.18); - border-radius: 18px; - background: rgba(4, 7, 13, 0.78); - box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35); - color: #f8fafc; - backdrop-filter: blur(16px); + gap: 10px; } -.game-state-hud__header { +.game-state-debug-panel__header { display: flex; align-items: center; justify-content: space-between; - gap: 12px; -} - -.game-state-hud__header span, -.game-state-hud__detail { - color: rgba(248, 250, 252, 0.68); - font-size: 12px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.game-state-hud__header strong { - font-size: 16px; - letter-spacing: -0.03em; - text-transform: uppercase; -} - -.game-state-hud__detail { - margin: 0; - text-transform: none; -} - -.game-state-hud__states, -.game-state-hud__actions { - display: flex; - flex-wrap: wrap; gap: 8px; } -.game-state-hud button { - min-height: 32px; - padding: 0 10px; - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: 999px; - background: rgba(255, 255, 255, 0.08); - color: #f8fafc; - font-size: 12px; +.game-state-debug-panel__switch-heading span { + color: #8f8f8f; + font-size: 0.56rem; font-weight: 700; - cursor: pointer; + letter-spacing: 0.1em; + text-transform: uppercase; } -.game-state-hud button:hover, -.game-state-hud button:focus-visible, -.game-state-hud button.is-active { - border-color: rgba(125, 211, 252, 0.75); - background: rgba(125, 211, 252, 0.18); +.game-state-debug-panel__switch-heading strong { + color: #ffffff; + font-size: 0.68rem; + font-weight: 720; + letter-spacing: -0.03em; +} + +.game-state-debug-panel__switch-group { + display: grid; + gap: 7px; +} + +.game-state-debug-panel__switch-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.game-state-debug-panel__states, +.game-state-debug-panel__actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.game-state-debug-panel__actions { + grid-template-columns: 1fr; + margin-top: 2px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.game-state-debug-panel button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + min-height: 28px; + padding: 0 8px; + border: 1px solid #2f2f2f; + border-radius: 10px; + background: #101010; + color: #d9d9d9; + font-size: 0.68rem; + font-weight: 650; + text-align: center; + cursor: pointer; + transition: + background 160ms ease, + border-color 160ms ease, + color 160ms ease, + transform 160ms ease; +} + +.game-state-debug-panel button:hover, +.game-state-debug-panel button:focus-visible, +.game-state-debug-panel button.is-active { + color: #ffffff; + border-color: #ffffff; + background: #181818; outline: none; } +.game-state-debug-panel button:hover { + transform: translateY(-1px); +} + +.game-state-debug-panel button.is-active { + border-color: rgba(56, 189, 248, 0.75); + background: rgba(56, 189, 248, 0.14); +} + /* Editor page shell */ .editor-container { position: fixed; diff --git a/src/managers/stores/useGameStore.ts b/src/managers/stores/useGameStore.ts index a47d505..c58bc89 100644 --- a/src/managers/stores/useGameStore.ts +++ b/src/managers/stores/useGameStore.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; export type MainGameState = "intro" | "bike" | "pylone" | "ferme" | "outro"; -type MissionStep = +export type MissionStep = | "locked" | "waiting" | "inspected" @@ -52,6 +52,7 @@ interface GameActions { completeFerme: () => void; startOutro: () => void; advanceGameState: () => void; + rewindGameState: () => void; resetGame: () => void; } @@ -76,6 +77,24 @@ function getNextMissionStep(step: MissionStep): MissionStep { } } +function getPreviousMissionStep(step: MissionStep): MissionStep { + switch (step) { + case "locked": + case "waiting": + return "locked"; + case "inspected": + return "waiting"; + case "fragmented": + return "inspected"; + case "scanning": + return "fragmented"; + case "repairing": + return "scanning"; + case "done": + return "repairing"; + } +} + function completeIntroState(state: GameState): GameStateUpdate { return { mainState: "bike", @@ -229,5 +248,40 @@ export const useGameStore = create()((set) => ({ return startOutroState(state); }), + rewindGameState: () => + set((state) => { + if (state.mainState === "intro") { + return { intro: { ...state.intro, hasCompleted: false } }; + } + + if (state.mainState === "bike") { + return { + bike: { + ...state.bike, + currentStep: getPreviousMissionStep(state.bike.currentStep), + }, + }; + } + + if (state.mainState === "pylone") { + return { + pylone: { + ...state.pylone, + currentStep: getPreviousMissionStep(state.pylone.currentStep), + }, + }; + } + + if (state.mainState === "ferme") { + return { + ferme: { + ...state.ferme, + currentStep: getPreviousMissionStep(state.ferme.currentStep), + }, + }; + } + + return { outro: { ...state.outro, hasStarted: false } }; + }), resetGame: () => set(createInitialGameState()), })); diff --git a/src/utils/debug/Debug.ts b/src/utils/debug/Debug.ts index a49de56..d45c15a 100644 --- a/src/utils/debug/Debug.ts +++ b/src/utils/debug/Debug.ts @@ -52,7 +52,9 @@ export class Debug { private readonly listeners = new Set<() => void>(); private readonly controls: { cameraMode: CameraMode; + showDebugOverlay: boolean; showInteractionSpheres: boolean; + showPerf: boolean; sceneMode: SceneMode; }; @@ -70,7 +72,9 @@ export class Debug { this.controls = { cameraMode: storedControls.cameraMode ?? "player", + showDebugOverlay: true, showInteractionSpheres: false, + showPerf: true, sceneMode: storedControls.sceneMode ?? "game", }; @@ -98,10 +102,18 @@ export class Debug { }); folder - .add(this.controls, "showInteractionSpheres") - .name("Interaction Spheres") + .add(this.controls, "showPerf") + .name("R3F Perf") .onChange((value: boolean) => { - this.controls.showInteractionSpheres = value; + this.controls.showPerf = value; + this.emit(); + }); + + folder + .add(this.controls, "showDebugOverlay") + .name("Debug Overlay") + .onChange((value: boolean) => { + this.controls.showDebugOverlay = value; this.emit(); }); } @@ -159,10 +171,23 @@ export class Debug { return this.controls.sceneMode; } + getShowDebugOverlay(): boolean { + return this.active && this.controls.showDebugOverlay; + } + getShowInteractionSpheres(): boolean { return this.controls.showInteractionSpheres; } + 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()); }