diff --git a/docs/technical/architecture.md b/docs/technical/architecture.md index b9c508c..ebc9cde 100644 --- a/docs/technical/architecture.md +++ b/docs/technical/architecture.md @@ -47,6 +47,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli - `src/managers/AudioManager.ts` provides pooled one-shot playback, looped music playback, category volumes, and optional stereo pan for one-shot sounds. - Supported audio categories are `music`, `sfx`, and `dialogue`. - Trigger interactions may play SFX directly through `AudioManager`. +- Detailed audio documentation lives in `docs/technical/audio.md`. ## Settings Menu diff --git a/docs/technical/audio.md b/docs/technical/audio.md new file mode 100644 index 0000000..4c128ec --- /dev/null +++ b/docs/technical/audio.md @@ -0,0 +1,217 @@ +# Audio Technical Notes + +This document describes the audio systems that exist in the current codebase. + +## Scope + +Audio is currently split into three runtime categories: + +- `music`: looped background music +- `dialogue`: spoken dialogue audio linked to subtitles +- `sfx`: one-shot interaction and feedback sounds + +The shared runtime service is `src/managers/AudioManager.ts`. User-facing volume settings live in `src/managers/stores/useSettingsStore.ts` and are forwarded to `AudioManager` by category. + +## AudioManager + +`AudioManager` is a singleton side-effect service. It owns browser audio elements, category volumes, pooled one-shot sounds, music playback, and stereo panning for one-shot sounds. + +Supported public methods: + +- `playMusic(path, volume)`: starts or updates a looped music track. +- `stopMusic()`: stops the active music track. +- `playSound(path, volume, options)`: plays a pooled one-shot sound and returns its `HTMLAudioElement`. +- `setCategoryVolume(category, volume)`: updates `music`, `sfx`, or `dialogue` volume. +- `getCategoryVolume(category)`: reads the current category volume. +- `destroy()`: stops music, clears pools, closes the audio context, and resets the singleton. + +One-shot sounds are pooled by path with a maximum pool size per sound. If every element in a pool is busy, the pool grows until the limit, then recycles an existing element. + +Browser autoplay restrictions are handled in `playMusic()`: if playback is blocked by the browser, the manager waits for a user `pointerdown` or `keydown`, then retries the music. + +## Music + +Runtime music is mounted by `src/world/GameMusic.tsx`. + +Current behavior: + +- `GameMusic` calls `AudioManager.getInstance().playMusic()` on mount. +- The current music path is `/sounds/musique/test.mp3`. +- The base music volume is `0.33` before category volume is applied. +- On unmount, `GameMusic` calls `stopMusic()`. + +Effective music volume is: + +```txt +base music volume * settings music volume +``` + +Use `music` only for long-running looped background tracks. Do not use `playSound()` for music, because one-shot pooling is designed for short overlapping sounds. + +## Sound Effects + +SFX are short one-shot sounds. They should use `AudioManager.playSound()` with the default category or with `{ category: "sfx" }`. + +Example: + +```ts +AudioManager.getInstance().playSound("/sounds/sfx/click.mp3", 0.8, { + category: "sfx", + pan: 0, +}); +``` + +Useful options: + +- `category`: `sfx` or `dialogue`; defaults to `sfx`. +- `pan`: stereo panning from `-1` left to `1` right. +- `playbackRate`: playback speed multiplier. + +SFX volume is controlled by the settings menu through the `sfx` category volume. + +## Dialogues + +Runtime dialogue data lives under `public/sounds/dialogue/`. + +```txt +public/ +└── sounds/ + └── dialogue/ + ├── dialogues.json + └── subtitles/ + ├── fr/ + │ ├── narrateur.srt + │ ├── fermier.srt + │ └── electricienne.srt + └── en/ + ├── narrateur.srt + ├── fermier.srt + └── electricienne.srt +``` + +The dialogue manifest shape is defined in `src/types/dialogues/dialogues.ts`. + +Each dialogue entry contains: + +- `id`: stable dialogue identifier +- `voice`: voice group, currently `narrateur`, `fermier`, or `electricienne` +- `audio`: runtime audio path +- `subtitleCueIndex`: cue number inside that voice/language SRT file +- `timecode`: optional global trigger time in seconds from scene start + +Dialogues are played through `src/utils/dialogues/playDialogue.ts`. + +Important functions: + +- `playDialogueById(manifest, dialogueId)`: plays a dialogue from an already loaded manifest. +- `queueDialogueById(manifest, dialogueId)`: queues dialogue playback so multiple requests do not overlap. +- `playGameplayDialogueById(dialogueId)`: loads the gameplay manifest once and queues a dialogue by ID. +- `clearQueuedDialogues()`: resolves pending dialogue requests and clears the queue. + +Dialogue audio uses `AudioManager.playSound()` with `{ category: "dialogue" }`, so it follows the dialogue volume setting. + +## Dialogue And SRT Link + +The subtitle model is one SRT file per voice and language, not one SRT file per dialogue. + +A dialogue chooses its subtitle by combining: + +1. `voice` +2. selected subtitle language from settings +3. `subtitleCueIndex` + +For example, this dialogue: + +```json +{ + "id": "narrateur_bienvenueaaltera", + "voice": "narrateur", + "audio": "/sounds/dialogue/narrateur/bienvenueaaltera.mp3", + "subtitleCueIndex": 1 +} +``` + +loads cue `1` from: + +```txt +public/sounds/dialogue/subtitles/fr/narrateur.srt +``` + +when the subtitle language is French, or from: + +```txt +public/sounds/dialogue/subtitles/en/narrateur.srt +``` + +when the subtitle language is English. + +If the selected language is missing, the loader falls back to French. Missing English SRT files are warnings during validation, not runtime errors. + +SRT timecodes are relative to the dialogue audio file. They are not relative to the game clock and not relative to a cinematic timeline. + +## Subtitle Runtime + +`playDialogueById()` loads the matching subtitle cue with `loadDialogueSubtitleCue()` before playing the audio. + +While audio plays: + +- `timeupdate` checks `audio.currentTime` +- the active subtitle is written to `useSubtitleStore` +- `src/components/ui/Subtitles.tsx` renders the current speaker and text +- `ended` and `pause` clear the subtitle + +The subtitle overlay respects settings from `useSettingsStore`, including visibility and selected language. + +## Global Timecode Dialogues + +`src/world/GameDialogues.tsx` loads the dialogue manifest and triggers entries that define `timecode`. + +This is useful for simple global scene timing. It should not be used for dialogue that belongs to a cinematic. Cinematic-owned dialogue should be triggered by `dialogueCues` in `public/cinematics.json` instead, otherwise the same dialogue can play twice. + +## Cinematic Dialogue Cues + +`public/cinematics.json` can include `dialogueCues`. + +Each cue contains: + +- `time`: seconds relative to the cinematic start +- `dialogueId`: ID from `dialogues.json` + +`src/world/GameCinematics.tsx` uses those cues to play dialogue during camera timelines. This keeps camera movement and dialogue playback synchronized without relying on global scene time. + +## Editor Tooling + +The `/editor` route provides three audio-related tools: + +- `Dialogues`: edits `public/sounds/dialogue/dialogues.json` and previews dialogue playback. +- `SRT`: edits one SRT file at a time and validates dialogue assets. +- `Cinematics`: links dialogue IDs to cinematic timelines through `dialogueCues`. + +Dev-only Vite endpoints in `vite.config.ts` support local saves: + +- `POST /api/save-dialogues` +- `POST /api/save-srt` +- `GET /api/validate-dialogues` +- `POST /api/save-cinematics` + +These endpoints are local development helpers. They are not production APIs. + +## Validation + +`GET /api/validate-dialogues` validates: + +- manifest shape +- referenced dialogue audio files +- French SRT files +- referenced subtitle cue indexes +- optional English SRT files as warnings + +Run validation after adding or renaming dialogue audio, changing cue indexes, or editing SRT files. + +## Known Limitations + +- There is no production persistence for audio manifests or SRT files. +- Dialogue branching is not implemented. +- Dialogue interruption and priority rules are minimal; playback is queue-based. +- SRT editing is text-based and does not yet provide waveform editing. +- Music currently supports one active looped track at a time. diff --git a/docs/technical/editor.md b/docs/technical/editor.md index 0e6c9b1..688c46b 100644 --- a/docs/technical/editor.md +++ b/docs/technical/editor.md @@ -52,7 +52,7 @@ src/ ## Responsibilities -`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, and player-mode toggle. +`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, selection lock, player-mode toggle, cinematic preview requests, and editor scene loading state. `src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads. @@ -62,7 +62,7 @@ src/ `src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls. -`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. +`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas. The panel is organized into top-level `details` groups: `Editor`, `Cinematics`, `Dialogues`, and `SRT`. `src/components/editor/EditorDialogueManifestPanel.tsx` renders the dialogue manifest editor. It loads `dialogues.json`, edits dialogue entries, previews selected dialogue playback, creates missing French SRT cues, and saves the manifest through a dev-server endpoint. @@ -122,13 +122,17 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback 2. `useEditorSceneData` calls `loadMapSceneData()`. 3. `loadMapSceneData()` loads `/map.json` and available model URLs. 4. If `/map.json` is missing, the page displays a folder-upload flow. -5. `EditorScene` renders the grid, lights, camera controls, and map nodes. -6. `EditorControls` exposes transform mode, history actions, export, save, and selection info. +5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load. +6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`. +7. `EditorControls` exposes transform mode, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors. ## Controls - Click: select a node. - `Esc`: clear selection. +- Click empty space: clear selection. +- Selection lock button: prevent object clicks, empty-space clicks, and `Esc` from changing the current selection. +- Selection clear button: intentionally clear the current selection even when the lock is active. - `T`: translate mode. - `R`: rotate mode. - `S`: scale mode. @@ -147,6 +151,51 @@ The editor supports two output paths: The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size. +## Editor Loading Overlay + +The editor uses `SceneLoadingOverlay` like the runtime scene. `EditorSceneLoadingTracker` lives in `src/pages/editor/page.tsx` and reads drei `useProgress()` inside the canvas. + +The route tracks two loading phases: + +- map JSON loading through `useEditorSceneData()` +- model loading through `useProgress()` + +The overlay is rendered outside the canvas so it remains visible while the R3F scene mounts. The scene itself is wrapped in `Suspense` with a `null` fallback; the visual feedback is handled by the overlay instead of by the canvas fallback. + +## Panel Groups + +`EditorControls` uses the local `EditorPanelGroup` helper to keep the side panel navigable as tools grow. + +Current group order: + +1. `Editor` +2. `Cinematics` +3. `Dialogues` +4. `SRT` + +Inside the `Editor` group, the section order is: + +1. `Shortcuts` +2. `Transform` +3. `Selection` +4. `View` +5. `JSON` +6. `File` + +The `Shortcuts` group is nested and closed by default to reduce visual noise. + +## Selection Lock + +Selection lock is owned by `EditorPage` through `isSelectionLocked`. + +The state is passed to: + +- `EditorControls`, to render the lock/unlock button +- `EditorScene`, to block `Esc` deselection when locked +- `EditorMap`, to block object selection and empty-space deselection when locked + +The clear button calls `onClearSelection` directly from `EditorControls`. This is intentionally separate from scene click behavior so the user always has an explicit way to clear the selection. + ## Dialogue SRT Editing Dialogue subtitle editing is part of the `/editor` side panel. diff --git a/docs/user/editor.md b/docs/user/editor.md index af2dd5c..58091f8 100644 --- a/docs/user/editor.md +++ b/docs/user/editor.md @@ -1,12 +1,18 @@ # Editor User Guide -The map editor is available at `/editor`. It is a browser-based tool for inspecting and adjusting the objects listed in `public/map.json`. +The map editor is available at `/editor`. It is a browser-based tool for editing the runtime map, cinematic manifest, dialogue manifest, and SRT subtitle files without manually jumping between JSON and subtitle files. ## Purpose -Use the editor when you need to move, rotate, or scale existing map objects without editing JSON by hand. +Use the editor when you need to: -The editor reads the same map data as the runtime scene: +- move, rotate, or scale objects from `public/map.json` +- inspect the raw JSON generated by the editor +- preview and edit cinematics from `public/cinematics.json` +- create, preview, and validate dialogue entries from `public/sounds/dialogue/dialogues.json` +- edit FR/EN SRT subtitle files per voice + +The map editor reads the same map data as the runtime scene: - `public/map.json` contains the object list. - `public/models/{name}/model.glb` contains the matching 3D model for each object name. `model.gltf` is still supported as a fallback during migration. @@ -24,7 +30,18 @@ Each entry in `public/map.json` represents one object: | `rotation` | Object rotation as `[x, y, z]`, expressed radians | | `scale` | Object scale as `[x, y, z]` | -## Editing Workflow +## Panel Layout + +The right panel is split into dropdown groups: + +- `Editor`: map transform tools, shortcuts, selection, view mode, JSON preview, and file actions. +- `Cinematics`: editor for `public/cinematics.json`. +- `Dialogues`: editor for `public/sounds/dialogue/dialogues.json`. +- `SRT`: editor for subtitle files in `public/sounds/dialogue/subtitles/`. + +Only the `Editor` group is open by default. Open the other groups when you need audio or cinematic tooling. + +## Map Editing Workflow 1. Open `/editor` in the local app. 2. Click an object in the scene to select it. @@ -40,6 +57,8 @@ Each entry in `public/map.json` represents one object: | -------------------- | -------------------------- | | Select object | Click object | | Deselect | `Esc` or click empty space | +| Lock selection | `Lock` button in Selection | +| Clear selection | `X` button in Selection | | Translate mode | `T` | | Rotate mode | `R` | | Scale mode | `S` | @@ -49,18 +68,34 @@ Each entry in `public/map.json` represents one object: | Move up | `Space` | | Move down | `Shift` | +## Selection + +The `Selection` section shows the selected object name and its index in `public/map.json`. + +- Click an object to select it. +- Click empty space or press `Esc` to clear the selection. +- Use the `X` button to clear the selection explicitly. +- Use the `Lock` button to protect the current selection while editing. + +When selection is locked: + +- clicking another object does not change the selection +- clicking empty space does not clear the selection +- pressing `Esc` does not clear the selection +- the `X` button still clears the selection intentionally + ## View Mode The `Lock view` action switches the editor into a movement mode closer to the runtime player camera. Use it to navigate larger scenes while keeping the transform tools available. ## JSON Inspector -The side panel includes a raw JSON inspector: +The `JSON` section shows the raw map data that will be exported or saved: - When no object is selected, it shows the full map node list. - When an object is selected, it highlights the JSON lines for that object. -This is useful for checking numeric transform values before saving or exporting. +Use it to verify exact numeric transform values before saving or exporting. The JSON inspector is read-only; transform values are changed through the gizmo in the scene. ## Saving Changes @@ -76,12 +111,27 @@ The button is hidden in production builds because production persistence is not ## Editing Dialogue Subtitles -The side panel also includes dialogue tools for the dialogue manifest and SRT subtitles. +The side panel includes two separate audio text tools: + +- `Dialogues` edits the dialogue manifest, which links dialogue IDs to audio files and SRT cue indexes. +- `SRT` edits the actual subtitle text and cue timings. + +The important model is: one dialogue entry points to one cue inside one SRT file. The SRT file is grouped by voice and language, not by dialogue. ### Dialogue Manifest Use the `Dialogues` panel to edit `public/sounds/dialogue/dialogues.json` without opening the JSON file manually. +Each dialogue entry contains: + +| Field | Meaning | +| ------------------ | ----------------------------------------------------------------- | +| `id` | Unique dialogue ID used by cinematics and runtime triggers | +| `voice` | Voice file group: `narrateur`, `fermier`, or `electricienne` | +| `audio` | Runtime audio path, usually under `/sounds/dialogue/` | +| `subtitleCueIndex` | Cue number inside the selected voice/language SRT file | +| `timecode` | Optional global runtime trigger time, in seconds from scene start | + Available actions: - `Reload` reloads the manifest from disk. @@ -95,6 +145,19 @@ After using `Add`, save the manifest to keep the new dialogue entry. The generat New dialogue audio paths start as placeholders such as `/sounds/dialogue/new_dialogue_24.mp3`. Replace them with real MP3 paths before validating the final asset set. +Recommended workflow for a new dialogue: + +1. Open `Dialogues`. +2. Click `Add`. +3. Choose the correct `voice`. +4. Replace the generated `id` with a readable stable ID. +5. Replace the placeholder `audio` path with the real MP3 path. +6. Check the generated `subtitleCueIndex`. +7. Click `Create FR SRT cue` if the cue does not exist yet. +8. Click `Save`. +9. Open `SRT`, edit the cue text and timings, then save the SRT file. +10. Run `Validate` from the SRT panel. + ### SRT Editor Use the `SRT` panel to edit one subtitle file at a time. @@ -108,6 +171,8 @@ Use the `SRT` panel to edit one subtitle file at a time. Each SRT file belongs to one voice, not one dialogue. Cue indexes must match the `subtitleCueIndex` values referenced by the dialogue manifest. +SRT timings are relative to the dialogue audio file, not to the global game timeline and not to the cinematic timeline. For example, `00:00:01,000` means one second after that dialogue audio starts. + ## Validating Dialogue Assets Use `Validate` in the SRT panel to check the dialogue manifest and linked assets. @@ -143,6 +208,17 @@ Dialogue cues define: - `time`: seconds relative to the cinematic start - `dialogueId`: an entry from `public/sounds/dialogue/dialogues.json` +Recommended workflow for a cinematic: + +1. Open `Cinematics`. +2. Select an existing cinematic or click `Add`. +3. Set a stable `id`. +4. Add or adjust camera keyframes. +5. Keep keyframe `time` values increasing from start to end. +6. Add dialogue cues when a dialogue must start during the camera sequence. +7. Click `Preview cinematic` to test the camera path in the editor canvas. +8. Click `Save` when the manifest is correct. + Available actions: - `Reload` reloads the cinematic manifest from disk. @@ -155,6 +231,8 @@ Available actions: Cinematic dialogue cues are the preferred way to synchronize a dialogue with a cinematic. Avoid also giving the same dialogue a global `timecode`, or it can be triggered twice. +Use `dialogueCues` when the dialogue belongs to a cinematic. Use a dialogue `timecode` only for simple global scene timing outside a cinematic. + ## Current Limitations - The editor only modifies existing nodes. diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index b5cf0ee..e9b099d 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -1,6 +1,7 @@ import { Box, Braces, + ChevronDown, Download, Expand, Keyboard, @@ -11,6 +12,8 @@ import { RotateCw, Save, Undo2, + Unlock, + X, } from "lucide-react"; import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel"; import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel"; @@ -25,6 +28,9 @@ interface EditorControlsProps { mapNodes: MapNode[]; nodesCount: number; selectedNodeName: string | null; + isSelectionLocked: boolean; + onSelectionLockToggle: () => void; + onClearSelection: () => void; undoCount: number; redoCount: number; onUndo: () => void; @@ -50,6 +56,33 @@ const EDITOR_SHORTCUTS = [ ["WASD", "Move when locked"], ] as const; +interface EditorPanelGroupProps { + title: string; + summary?: string; + defaultOpen?: boolean; + children: React.ReactNode; +} + +function EditorPanelGroup({ + title, + summary, + defaultOpen = false, + children, +}: EditorPanelGroupProps): React.JSX.Element { + return ( +
+ + {title} + + {summary ? {summary} : null} + + +
{children}
+
+ ); +} + export function EditorControls({ transformMode, onTransformModeChange, @@ -57,6 +90,9 @@ export function EditorControls({ mapNodes, nodesCount, selectedNodeName, + isSelectionLocked, + onSelectionLockToggle, + onClearSelection, undoCount, redoCount, onUndo, @@ -79,173 +115,215 @@ export function EditorControls({

Select an object, choose a transform mode, then drag the gizmo.

-
-
-

Transform

- T / R / S -
- -
- {TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => ( - - ))} -
- -
- - -
-
+
+

Shortcuts

+
-
-
-

File

-
+
+ {EDITOR_SHORTCUTS.map(([keys, description]) => ( +
+
{keys}
+
{description}
+
+ ))} +
+
+ - - - {onSaveToServer && ( - - )} - - -
-
-

View

-
- - {onPlayerMode && ( - - )} -
- -
-
-

Selection

- {nodesCount} nodes -
- - {selectedNodeIndex !== null ? ( -
-
-
-
-

Shortcuts

-
- -
- {EDITOR_SHORTCUTS.map(([keys, description]) => ( -
-
{keys}
-
{description}
-
- ))} -
-
- -
-
-

JSON

- {jsonPreview.label} -
- -
-            {jsonPreview.lines.map((line) => (
-              
+              
+
-
-
- +
+
+

Selection

+ {nodesCount} nodes +
- - - + {selectedNodeIndex !== null ? ( +
+
+ ) : ( +
+
+ )} +
+ +
+
+

View

+
+ + {onPlayerMode && ( + + )} +
+ +
+
+

JSON

+ {jsonPreview.label} +
+ +
+              {jsonPreview.lines.map((line) => (
+                
+                  {line.number}
+                  {line.content || " "}
+                
+              ))}
+            
+ +
+
+
+ +
+
+

File

+
+ + + + {onSaveToServer && ( + + )} +
+ + + + + + + + + + + ); diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx index baea5a6..b6eeedc 100644 --- a/src/components/editor/scene/EditorMap.tsx +++ b/src/components/editor/scene/EditorMap.tsx @@ -11,6 +11,7 @@ interface EditorMapProps { sceneData: SceneData; selectedNodeIndex: number | null; onSelectNode: (index: number | null) => void; + isSelectionLocked: boolean; hoveredNodeIndex: number | null; onHoverNode: (index: number | null) => void; transformMode: TransformMode; @@ -28,6 +29,7 @@ interface EditorNodeCommonProps { isHovered: boolean; objectsMapRef: EditorNodeObjectRef; onSelectNode: (index: number | null) => void; + isSelectionLocked: boolean; onHoverNode: (index: number | null) => void; } @@ -108,11 +110,13 @@ function getNodeHighlightColor( function createEditorNodePointerHandlers( index: number, onSelectNode: (index: number | null) => void, + isSelectionLocked: boolean, onHoverNode: (index: number | null) => void, ): EditorNodePointerHandlers { return { onClick: (event) => { event.stopPropagation(); + if (isSelectionLocked) return; onSelectNode(index); }, onPointerEnter: (event) => { @@ -130,6 +134,7 @@ export function EditorMap({ sceneData, selectedNodeIndex, onSelectNode, + isSelectionLocked, hoveredNodeIndex, onHoverNode, transformMode, @@ -192,8 +197,9 @@ export function EditorMap({ ) => { - e.stopPropagation(); + onClick={(event: ThreeEvent) => { + event.stopPropagation(); + if (isSelectionLocked) return; onSelectNode(null); }} > @@ -211,6 +217,7 @@ export function EditorMap({ isHovered={hoveredNodeIndex === index} objectsMapRef={objectsMapRef} onSelectNode={onSelectNode} + isSelectionLocked={isSelectionLocked} onHoverNode={onHoverNode} /> ); @@ -224,6 +231,7 @@ export function EditorMap({ isHovered={hoveredNodeIndex === index} objectsMapRef={objectsMapRef} onSelectNode={onSelectNode} + isSelectionLocked={isSelectionLocked} onHoverNode={onHoverNode} /> ); @@ -251,6 +259,7 @@ function EditorModelNode({ isHovered, objectsMapRef, onSelectNode, + isSelectionLocked, onHoverNode, }: EditorNodeCommonProps & { modelUrl: string; @@ -269,6 +278,7 @@ function EditorModelNode({ const pointerHandlers = createEditorNodePointerHandlers( index, onSelectNode, + isSelectionLocked, onHoverNode, ); useRegisteredEditorNode(groupRef, index, node, objectsMapRef); @@ -343,12 +353,14 @@ function EditorFallbackNode({ isHovered, objectsMapRef, onSelectNode, + isSelectionLocked, onHoverNode, }: EditorNodeCommonProps) { const meshRef = useRef(null); const pointerHandlers = createEditorNodePointerHandlers( index, onSelectNode, + isSelectionLocked, onHoverNode, ); useRegisteredEditorNode(meshRef, index, node, objectsMapRef); diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx index c071ef0..c9d585c 100644 --- a/src/components/editor/scene/EditorScene.tsx +++ b/src/components/editor/scene/EditorScene.tsx @@ -17,6 +17,7 @@ interface EditorSceneProps { sceneData: SceneData; selectedNodeIndex: number | null; onSelectNode: (index: number | null) => void; + isSelectionLocked: boolean; hoveredNodeIndex: number | null; onHoverNode: (index: number | null) => void; transformMode: TransformMode; @@ -35,6 +36,7 @@ export function EditorScene({ sceneData, selectedNodeIndex, onSelectNode, + isSelectionLocked, hoveredNodeIndex, onHoverNode, transformMode, @@ -68,7 +70,7 @@ export function EditorScene({ if (selectedNodeIndex !== null) { switch (e.key.toLowerCase()) { case "escape": - onSelectNode(null); + if (!isSelectionLocked) onSelectNode(null); break; case "t": onTransformModeChange("translate"); @@ -85,7 +87,14 @@ export function EditorScene({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]); + }, [ + isSelectionLocked, + selectedNodeIndex, + onSelectNode, + onTransformModeChange, + onUndo, + onRedo, + ]); return ( <> @@ -113,6 +122,7 @@ export function EditorScene({ sceneData={sceneData} selectedNodeIndex={selectedNodeIndex} onSelectNode={onSelectNode} + isSelectionLocked={isSelectionLocked} hoveredNodeIndex={hoveredNodeIndex} onHoverNode={onHoverNode} transformMode={transformMode} diff --git a/src/data/docs/docsSections.ts b/src/data/docs/docsSections.ts index 86bc869..8f25919 100644 --- a/src/data/docs/docsSections.ts +++ b/src/data/docs/docsSections.ts @@ -38,17 +38,23 @@ export const docGroups: DocGroup[] = [ subtitle: "Implementation details", meta: "04", }, + { + path: "/docs/audio", + title: "Audio Technical Notes", + subtitle: "Music, dialogue, SRT, and SFX", + meta: "05", + }, { path: "/docs/hand-tracking", title: "Hand Tracking Technical Notes", subtitle: "Webcam interaction pipeline", - meta: "05", + meta: "06", }, { path: "/docs/zustand", title: "Zustand Game State", subtitle: "Progression store", - meta: "06", + meta: "07", }, ], }, @@ -59,25 +65,25 @@ export const docGroups: DocGroup[] = [ path: "/docs/features", title: "Features", subtitle: "Implemented scope", - meta: "07", + meta: "08", }, { path: "/docs/main-feature", title: "Main Feature", subtitle: "Repair-game prototype", - meta: "08", + meta: "09", }, { path: "/docs/editor", title: "Editor User Guide", subtitle: "Editing workflow", - meta: "09", + meta: "10", }, { path: "/docs/animation", title: "Animation & 3D Model System", subtitle: "Components and usage", - meta: "010", + meta: "11", }, ], }, diff --git a/src/data/docs/docsTranslations.ts b/src/data/docs/docsTranslations.ts index edd79bb..d15eec0 100644 --- a/src/data/docs/docsTranslations.ts +++ b/src/data/docs/docsTranslations.ts @@ -556,142 +556,145 @@ Ce document liste les fonctionnalités présentes dans le code actuel. - séparation complète production / debug pour les scènes gameplay `; -export const editorFr = `# Éditeur de carte +export const editorFr = `# Guide utilisateur de l'éditeur -L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur. +L'éditeur est disponible sur \`/editor\`. Il sert à modifier la carte runtime, les cinématiques, le manifeste de dialogues et les sous-titres SRT sans éditer tous les fichiers à la main. -## Ce qui est édité +## À quoi il sert -L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json". +Utilise l'éditeur pour : -Chaque node décrit un objet de la scène : +- déplacer, tourner ou scaler les objets de \`public/map.json\` +- inspecter le JSON généré avant export ou sauvegarde +- prévisualiser et modifier \`public/cinematics.json\` +- créer, prévisualiser et valider les dialogues de \`public/sounds/dialogue/dialogues.json\` +- modifier les fichiers SRT FR/EN par voix -- "name" : nom du dossier modèle dans "/public/models/{name}/model.glb", avec fallback vers "model.gltf" -- "type" : catégorie de l'objet -- "position" : "[x, y, z]" -- "rotation" : "[x, y, z]" -- "scale" : "[x, y, z]" +## Organisation du panneau -Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable. +Le panneau latéral est divisé en groupes repliables : -## Workflow de base +- \`Editor\` : transforms, raccourcis, sélection, vue, JSON et actions fichier. +- \`Cinematics\` : cinématiques et keyframes caméra. +- \`Dialogues\` : manifeste des dialogues. +- \`SRT\` : fichiers de sous-titres par voix et langue. -1. Ouvrir "/editor". -2. Sélectionner un objet dans la vue 3D. -3. Choisir un mode de transformation : translation, rotation ou scale. -4. Déplacer la gizmo de transformation. -5. Utiliser undo ou redo si nécessaire. -6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev. +## Carte et transforms -## Contrôles +1. Ouvre \`/editor\`. +2. Clique un objet pour le sélectionner. +3. Choisis \`Translate\`, \`Rotate\` ou \`Scale\`. +4. Déplace la gizmo dans la vue 3D. +5. Vérifie le bloc \`JSON\` si tu veux contrôler les valeurs exactes. +6. Utilise \`Undo\` ou \`Redo\` si besoin. +7. Utilise \`Export JSON\` ou \`Save to server\`. + +Contrôles utiles : | Action | Input | | --- | --- | -| Sélectionner un objet | Clic sur l'objet | -| Désélectionner | "Esc" ou clic dans le vide | -| Mode translation | "T" | -| Mode rotation | "R" | -| Mode scale | "S" | -| Undo | "Ctrl+Z" | -| Redo | "Ctrl+Y" | -| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches | -| Monter / descendre | "Space", "Shift" | +| Sélectionner | Clic objet | +| Désélectionner | \`Esc\` ou clic vide | +| Verrouiller la sélection | bouton lock | +| Vider la sélection | bouton \`X\` | +| Translate | \`T\` | +| Rotate | \`R\` | +| Scale | \`S\` | +| Undo / redo | \`Ctrl+Z\` / \`Ctrl+Y\` | +| Déplacement vue verrouillée | \`WASD\`, \`ZQSD\`, flèches | -## Actions fichier +Quand la sélection est verrouillée, cliquer un autre objet, cliquer dans le vide ou appuyer sur \`Esc\` ne change pas la sélection. Le bouton \`X\` reste le moyen volontaire de la vider. -### Export JSON +## Inspecteur JSON -"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json". +Le bloc \`JSON\` affiche le JSON qui sera exporté ou sauvegardé : -### Save to server +- sans sélection, il affiche toute la liste de nodes +- avec une sélection, il affiche les lignes du node sélectionné -"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite. +Cet inspecteur est en lecture seule. Les valeurs changent via la gizmo de transformation. -Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production. +## Sauvegarde -## Éditer les dialogues et sous-titres +- \`Export JSON\` télécharge un \`map.json\` local. +- \`Save to server\` écrit directement \`public/map.json\` via le serveur Vite local. -Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres. +Les sauvegardes serveur sont des helpers de développement, pas des APIs de production. -### Manifeste dialogues +## Cinématiques -Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main. +Le groupe \`Cinematics\` édite \`public/cinematics.json\`. -- \`Reload\` recharge le manifeste depuis le disque. -- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible. -- \`Save\` écrit le manifeste via le serveur Vite local. -- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur. -- \`Create FR SRT cue\` crée la cue française si elle manque. -- \`Delete dialogue\` supprime localement l'entrée sélectionnée. - -Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé. - -Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale. - -### Éditeur SRT - -1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`. -2. Choisir une langue : \`FR\` ou \`EN\`. -3. Modifier le texte SRT directement dans la textarea. -4. Utiliser la preview audio pour vérifier le dialogue sélectionné. -5. Utiliser \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster le timing de la cue sélectionnée avec l'audio. -6. Utiliser \`Save SRT\` en développement local, ou \`Export SRT\` pour télécharger le fichier manuellement. - -Chaque fichier SRT appartient à une voix, pas à un dialogue. Les indexes de cue doivent correspondre aux valeurs \`subtitleCueIndex\` référencées par le manifeste de dialogues. - -## Valider les assets de dialogue - -Utilise \`Validate\` dans le panneau SRT pour vérifier le manifeste et les assets liés. - -La validation vérifie : - -- \`public/sounds/dialogue/dialogues.json\` -- les fichiers audio de dialogue référencés -- les fichiers SRT français -- les indexes de cue référencés par le manifeste - -Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français. - -## Éditer les cinématiques - -Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`. - -Chaque cinématique contient : +Une cinématique contient : - un \`id\` - un \`timecode\` global optionnel - au moins deux keyframes caméra -- des dialogue cues optionnelles synchronisées avec la timeline +- des \`dialogueCues\` optionnelles -Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`. +Workflow conseillé : -Actions disponibles : +1. Sélectionne une cinématique ou clique \`Add\`. +2. Donne un \`id\` stable. +3. Ajoute ou ajuste les keyframes caméra. +4. Garde les temps de keyframes dans l'ordre croissant. +5. Ajoute des \`dialogueCues\` si un dialogue doit démarrer pendant la cinématique. +6. Clique \`Preview cinematic\` pour tester la caméra. +7. Clique \`Save\`. -- \`Reload\` recharge le manifeste. -- \`Add\` crée une cinématique locale avec deux keyframes. -- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local. -- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur. -- \`Add keyframe\` et \`Remove\` modifient le chemin caméra. -- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés. -- \`Delete cinematic\` supprime localement la cinématique sélectionnée. +Les temps de keyframes et de dialogue cues sont relatifs au début de la cinématique. -Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois. +## Dialogues -## Inspecteur JSON +Le groupe \`Dialogues\` édite \`public/sounds/dialogue/dialogues.json\`. -Le panneau latéral affiche le JSON brut de la carte : +Chaque dialogue contient : -- sans sélection, il affiche toute la liste des nodes -- avec un objet sélectionné, il met en évidence les lignes du node sélectionné +- \`id\` : identifiant stable utilisé par les cinématiques et le runtime +- \`voice\` : \`narrateur\`, \`fermier\` ou \`electricienne\` +- \`audio\` : chemin MP3 runtime +- \`subtitleCueIndex\` : numéro de cue dans le SRT de la voix +- \`timecode\` : déclenchement global optionnel -Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde. +Workflow conseillé pour créer un dialogue : + +1. Clique \`Add\`. +2. Choisis la bonne voix. +3. Remplace l'\`id\` généré par un ID lisible. +4. Remplace le chemin audio placeholder par le vrai MP3. +5. Vérifie le \`subtitleCueIndex\`. +6. Clique \`Create FR SRT cue\` si la cue manque. +7. Clique \`Save\`. +8. Passe dans \`SRT\` pour écrire le texte et régler les timings. +9. Lance \`Validate\`. + +## SRT + +Le groupe \`SRT\` édite un fichier de sous-titres à la fois. + +1. Choisis une voix. +2. Choisis \`FR\` ou \`EN\`. +3. Écris le texte SRT. +4. Prévisualise l'audio. +5. Utilise \`Set start\`, \`Set end\`, \`-100ms\` et \`+100ms\` pour ajuster les timings. +6. Clique \`Save SRT\` ou \`Export SRT\`. + +Il y a un fichier SRT par voix et par langue, pas un fichier par dialogue. Les timings SRT sont relatifs au fichier audio du dialogue. + +## Validation + +\`Validate\` vérifie : + +- le manifeste \`dialogues.json\` +- les fichiers audio référencés +- les fichiers SRT français +- les indexes de cues référencés +- les SRT anglais en warning si manquants ## Limites actuelles -- L'éditeur modifie uniquement les nodes existants. -- Il n'y a pas encore d'interface pour créer ou supprimer des objets. -- La sauvegarde production n'est pas implémentée. -- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur. -- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production. -- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite. +- L'éditeur modifie les nodes existants mais ne crée pas encore d'objet de carte. +- Les sauvegardes serveur sont limitées au développement local. +- L'éditeur SRT reste textuel, sans waveform. +- Les modèles manquants sont représentés par des cubes de fallback. `; diff --git a/src/index.css b/src/index.css index ce333ef..d52eddb 100644 --- a/src/index.css +++ b/src/index.css @@ -1142,6 +1142,61 @@ canvas { line-height: 1.45; } +.editor-panel-group { + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.editor-panel-group-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 13px 12px; + color: #ffffff; + cursor: pointer; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.12em; + list-style: none; + text-transform: uppercase; + user-select: none; +} + +.editor-panel-group-summary::-webkit-details-marker { + display: none; +} + +.editor-panel-group-summary:hover { + color: #f2f2f2; +} + +.editor-panel-group-meta { + display: inline-flex; + align-items: center; + gap: 8px; + color: #777777; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0; + text-transform: none; +} + +.editor-panel-group-meta svg { + transition: transform 160ms ease; +} + +.editor-panel-group[open] .editor-panel-group-meta svg { + transform: rotate(180deg); +} + +.editor-panel-group-content > .editor-control-section:first-child, +.editor-panel-group-content > .editor-json-section:first-child, +.editor-panel-group-content > .editor-cinematic-manifest-section:first-child, +.editor-panel-group-content > .editor-dialogue-manifest-section:first-child, +.editor-panel-group-content > .editor-srt-section:first-child { + border-top: 0; +} + .editor-control-section { padding: 14px 12px; border-top: 1px solid rgba(255, 255, 255, 0.09); @@ -1313,7 +1368,8 @@ canvas { } .editor-selected-info { - display: flex; + display: grid; + grid-template-columns: 17px 1fr auto; align-items: center; gap: 11px; background: #ffffff; @@ -1323,6 +1379,38 @@ canvas { color: #050505; } +.editor-selected-actions { + display: inline-flex; + gap: 6px; +} + +.editor-selected-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 27px; + height: 27px; + padding: 0; + color: #050505; + background: rgba(0, 0, 0, 0.06); + border: 0; + border-radius: 9px; + cursor: pointer; + transition: + background 160ms ease, + transform 160ms ease; +} + +.editor-selected-actions button:hover { + background: rgba(0, 0, 0, 0.12); + transform: translateY(-1px); +} + +.editor-selected-actions button[aria-pressed="true"] { + color: #ffffff; + background: #050505; +} + .editor-selected-info strong, .editor-selected-info span { display: block; diff --git a/src/pages/docs/animation/page.tsx b/src/pages/docs/animation/page.tsx index fb5404b..fb881fa 100644 --- a/src/pages/docs/animation/page.tsx +++ b/src/pages/docs/animation/page.tsx @@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element { ); diff --git a/src/pages/docs/audio/page.tsx b/src/pages/docs/audio/page.tsx new file mode 100644 index 0000000..2382d2f --- /dev/null +++ b/src/pages/docs/audio/page.tsx @@ -0,0 +1,13 @@ +import audio from "../../../../docs/technical/audio.md?raw"; +import { DocsDocument } from "@/components/docs/DocsDocument"; + +export function DocsAudioPage(): React.JSX.Element { + return ( + + ); +} diff --git a/src/pages/docs/editor/page.tsx b/src/pages/docs/editor/page.tsx index d44f7cd..c993678 100644 --- a/src/pages/docs/editor/page.tsx +++ b/src/pages/docs/editor/page.tsx @@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element { ); diff --git a/src/pages/docs/features/page.tsx b/src/pages/docs/features/page.tsx index 8010b5f..a075d1f 100644 --- a/src/pages/docs/features/page.tsx +++ b/src/pages/docs/features/page.tsx @@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element { ); diff --git a/src/pages/docs/hand-tracking/page.tsx b/src/pages/docs/hand-tracking/page.tsx index 26e7176..0d2e1a3 100644 --- a/src/pages/docs/hand-tracking/page.tsx +++ b/src/pages/docs/hand-tracking/page.tsx @@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element { ); diff --git a/src/pages/docs/main-feature/page.tsx b/src/pages/docs/main-feature/page.tsx index db324be..eacf31a 100644 --- a/src/pages/docs/main-feature/page.tsx +++ b/src/pages/docs/main-feature/page.tsx @@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element { ); diff --git a/src/pages/docs/zustand/page.tsx b/src/pages/docs/zustand/page.tsx index 529be3a..45d9ea9 100644 --- a/src/pages/docs/zustand/page.tsx +++ b/src/pages/docs/zustand/page.tsx @@ -7,7 +7,7 @@ export function DocsZustandPage(): React.JSX.Element { ); diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx index e57d176..0f8c5b1 100644 --- a/src/pages/editor/page.tsx +++ b/src/pages/editor/page.tsx @@ -67,6 +67,7 @@ export function EditorPage(): React.JSX.Element { const [transformMode, setTransformMode] = useState("translate"); const [isPlayerMode, setIsPlayerMode] = useState(false); + const [isSelectionLocked, setIsSelectionLocked] = useState(false); const [sceneLoadingState, setSceneLoadingState] = useState( { ...INITIAL_SCENE_LOADING_STATE, @@ -112,6 +113,14 @@ export function EditorPage(): React.JSX.Element { setSelectedNodeIndex(index); }, []); + const handleClearSelection = useCallback(() => { + setSelectedNodeIndex(null); + }, []); + + const handleSelectionLockToggle = useCallback(() => { + setIsSelectionLocked((locked) => !locked); + }, []); + const handleHoverNode = useCallback((index: number | null) => { setHoveredNodeIndex(index); }, []); @@ -246,6 +255,7 @@ export function EditorPage(): React.JSX.Element { sceneData={sceneData!} selectedNodeIndex={selectedNodeIndex} onSelectNode={handleSelectNode} + isSelectionLocked={isSelectionLocked} hoveredNodeIndex={hoveredNodeIndex} onHoverNode={handleHoverNode} transformMode={transformMode} @@ -276,6 +286,9 @@ export function EditorPage(): React.JSX.Element { ? sceneData.mapNodes[selectedNodeIndex].name || null : null } + isSelectionLocked={isSelectionLocked} + onSelectionLockToggle={handleSelectionLockToggle} + onClearSelection={handleClearSelection} undoCount={undoCount} redoCount={redoCount} onUndo={handleUndo} diff --git a/src/router.tsx b/src/router.tsx index 7be6634..7ccf38f 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -8,6 +8,7 @@ import { HomePage } from "@/pages/page"; import { EditorPage } from "@/pages/editor/page"; import { DocsAnimationRoute, + DocsAudioRoute, DocsArchitectureRoute, DocsEditorRoute, DocsFeaturesRoute, @@ -47,6 +48,7 @@ const docsChildRoutes = [ { path: "architecture", component: DocsArchitectureRoute }, { path: "target-architecture", component: DocsTargetArchitectureRoute }, { path: "technical-editor", component: DocsTechnicalEditorRoute }, + { path: "audio", component: DocsAudioRoute }, { path: "hand-tracking", component: DocsHandTrackingRoute }, { path: "zustand", component: DocsZustandRoute }, { path: "features", component: DocsFeaturesRoute }, diff --git a/src/routes/DocsRoute.tsx b/src/routes/DocsRoute.tsx index a9e42a7..ec5006f 100644 --- a/src/routes/DocsRoute.tsx +++ b/src/routes/DocsRoute.tsx @@ -47,6 +47,10 @@ const LazyDocsTechnicalEditorPage = lazyNamed( () => import("@/pages/docs/technical-editor/page"), "DocsTechnicalEditorPage", ); +const LazyDocsAudioPage = lazyNamed( + () => import("@/pages/docs/audio/page"), + "DocsAudioPage", +); const LazyDocsHandTrackingPage = lazyNamed( () => import("@/pages/docs/hand-tracking/page"), "DocsHandTrackingPage", @@ -81,6 +85,7 @@ export const DocsTargetArchitectureRoute = createDocsRoute( export const DocsTechnicalEditorRoute = createDocsRoute( LazyDocsTechnicalEditorPage, ); +export const DocsAudioRoute = createDocsRoute(LazyDocsAudioPage); export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage); export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage); export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);