Merge pull request 'Feat/env-manager2' (#4) from feat/env-manager into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
+85
-7
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<details className="editor-panel-group" open={defaultOpen}>
|
||||
<summary className="editor-panel-group-summary">
|
||||
<span>{title}</span>
|
||||
<span className="editor-panel-group-meta">
|
||||
{summary ? <span>{summary}</span> : null}
|
||||
<ChevronDown size={15} aria-hidden="true" />
|
||||
</span>
|
||||
</summary>
|
||||
<div className="editor-panel-group-content">{children}</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
<p>Select an object, choose a transform mode, then drag the gizmo.</p>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="transform-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="transform-heading">Transform</h3>
|
||||
<span>T / R / S</span>
|
||||
</div>
|
||||
|
||||
<div className="editor-transform-buttons">
|
||||
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
|
||||
onClick={() => onTransformModeChange(mode)}
|
||||
aria-pressed={transformMode === mode}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<kbd>{shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="editor-history-buttons">
|
||||
<button
|
||||
className="editor-history-button"
|
||||
onClick={onUndo}
|
||||
disabled={undoCount === 0}
|
||||
<EditorPanelGroup title="Editor" summary="Map tools" defaultOpen>
|
||||
<EditorPanelGroup title="Shortcuts" summary="Keys">
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="shortcuts-heading"
|
||||
>
|
||||
<Undo2 size={15} aria-hidden="true" />
|
||||
Undo
|
||||
<span>{undoCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className="editor-history-button"
|
||||
onClick={onRedo}
|
||||
disabled={redoCount === 0}
|
||||
>
|
||||
<Redo2 size={15} aria-hidden="true" />
|
||||
Redo
|
||||
<span>{redoCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="shortcuts-heading">Shortcuts</h3>
|
||||
<Keyboard size={15} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="file-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="file-heading">File</h3>
|
||||
</div>
|
||||
<dl className="editor-shortcuts-list">
|
||||
{EDITOR_SHORTCUTS.map(([keys, description]) => (
|
||||
<div key={keys}>
|
||||
<dt>{keys}</dt>
|
||||
<dd>{description}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
</EditorPanelGroup>
|
||||
|
||||
<button
|
||||
className="editor-action-button editor-action-button-primary"
|
||||
onClick={onExportJson}
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="transform-heading"
|
||||
>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
Export JSON
|
||||
</button>
|
||||
|
||||
{onSaveToServer && (
|
||||
<button className="editor-action-button" onClick={onSaveToServer}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save to server
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="view-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="view-heading">View</h3>
|
||||
</div>
|
||||
|
||||
{onPlayerMode && (
|
||||
<button
|
||||
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
|
||||
onClick={onPlayerMode}
|
||||
aria-pressed={isPlayerMode}
|
||||
>
|
||||
<Lock size={16} aria-hidden="true" />
|
||||
{viewModeLabel}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="selection-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="selection-heading">Selection</h3>
|
||||
<span>{nodesCount} nodes</span>
|
||||
</div>
|
||||
|
||||
{selectedNodeIndex !== null ? (
|
||||
<div className="editor-selected-info">
|
||||
<Box size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||
</strong>
|
||||
<span>
|
||||
Index {selectedNodeIndex + 1} of {nodesCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="transform-heading">Transform</h3>
|
||||
<span>T / R / S</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-no-selection">
|
||||
<MousePointer2 size={17} aria-hidden="true" />
|
||||
No object selected
|
||||
|
||||
<div className="editor-transform-buttons">
|
||||
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
|
||||
<button
|
||||
key={mode}
|
||||
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
|
||||
onClick={() => onTransformModeChange(mode)}
|
||||
aria-pressed={transformMode === mode}
|
||||
>
|
||||
<Icon size={16} aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
<kbd>{shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="shortcuts-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="shortcuts-heading">Shortcuts</h3>
|
||||
<Keyboard size={15} aria-hidden="true" />
|
||||
</div>
|
||||
|
||||
<dl className="editor-shortcuts-list">
|
||||
{EDITOR_SHORTCUTS.map(([keys, description]) => (
|
||||
<div key={keys}>
|
||||
<dt>{keys}</dt>
|
||||
<dd>{description}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="editor-json-section" aria-labelledby="json-heading">
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="json-heading">JSON</h3>
|
||||
<span>{jsonPreview.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="editor-json-view" aria-label={jsonPreview.label}>
|
||||
{jsonPreview.lines.map((line) => (
|
||||
<code
|
||||
key={line.number}
|
||||
className={line.isSelected ? "is-selected" : undefined}
|
||||
<div className="editor-history-buttons">
|
||||
<button
|
||||
className="editor-history-button"
|
||||
onClick={onUndo}
|
||||
disabled={undoCount === 0}
|
||||
>
|
||||
<span>{line.number}</span>
|
||||
{line.content || " "}
|
||||
</code>
|
||||
))}
|
||||
</pre>
|
||||
<Undo2 size={15} aria-hidden="true" />
|
||||
Undo
|
||||
<span>{undoCount}</span>
|
||||
</button>
|
||||
<button
|
||||
className="editor-history-button"
|
||||
onClick={onRedo}
|
||||
disabled={redoCount === 0}
|
||||
>
|
||||
<Redo2 size={15} aria-hidden="true" />
|
||||
Redo
|
||||
<span>{redoCount}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="editor-json-hint">
|
||||
<Braces size={14} aria-hidden="true" />
|
||||
{selectedNodeIndex === null
|
||||
? "Raw map JSON"
|
||||
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="selection-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="selection-heading">Selection</h3>
|
||||
<span>{nodesCount} nodes</span>
|
||||
</div>
|
||||
|
||||
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||
<EditorDialogueManifestPanel />
|
||||
<EditorSrtPanel />
|
||||
{selectedNodeIndex !== null ? (
|
||||
<div className="editor-selected-info">
|
||||
<Box size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||
</strong>
|
||||
<span>
|
||||
Index {selectedNodeIndex + 1} of {nodesCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="editor-selected-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelectionLockToggle}
|
||||
aria-pressed={isSelectionLocked}
|
||||
aria-label={
|
||||
isSelectionLocked ? "Unlock selection" : "Lock selection"
|
||||
}
|
||||
title={
|
||||
isSelectionLocked ? "Unlock selection" : "Lock selection"
|
||||
}
|
||||
>
|
||||
{isSelectionLocked ? (
|
||||
<Lock size={14} aria-hidden="true" />
|
||||
) : (
|
||||
<Unlock size={14} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="editor-no-selection">
|
||||
<MousePointer2 size={17} aria-hidden="true" />
|
||||
No object selected
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="view-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="view-heading">View</h3>
|
||||
</div>
|
||||
|
||||
{onPlayerMode && (
|
||||
<button
|
||||
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
|
||||
onClick={onPlayerMode}
|
||||
aria-pressed={isPlayerMode}
|
||||
>
|
||||
<Lock size={16} aria-hidden="true" />
|
||||
{viewModeLabel}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-json-section"
|
||||
aria-labelledby="json-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="json-heading">JSON</h3>
|
||||
<span>{jsonPreview.label}</span>
|
||||
</div>
|
||||
|
||||
<pre className="editor-json-view" aria-label={jsonPreview.label}>
|
||||
{jsonPreview.lines.map((line) => (
|
||||
<code
|
||||
key={line.number}
|
||||
className={line.isSelected ? "is-selected" : undefined}
|
||||
>
|
||||
<span>{line.number}</span>
|
||||
{line.content || " "}
|
||||
</code>
|
||||
))}
|
||||
</pre>
|
||||
|
||||
<div className="editor-json-hint">
|
||||
<Braces size={14} aria-hidden="true" />
|
||||
{selectedNodeIndex === null
|
||||
? "Raw map JSON"
|
||||
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="editor-control-section"
|
||||
aria-labelledby="file-heading"
|
||||
>
|
||||
<div className="editor-section-heading">
|
||||
<h3 id="file-heading">File</h3>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="editor-action-button editor-action-button-primary"
|
||||
onClick={onExportJson}
|
||||
>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
Export JSON
|
||||
</button>
|
||||
|
||||
{onSaveToServer && (
|
||||
<button className="editor-action-button" onClick={onSaveToServer}>
|
||||
<Save size={16} aria-hidden="true" />
|
||||
Save to server
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</EditorPanelGroup>
|
||||
|
||||
<EditorPanelGroup title="Cinematics" summary="Timeline">
|
||||
<EditorCinematicManifestPanel
|
||||
onPreviewCinematic={onPreviewCinematic}
|
||||
/>
|
||||
</EditorPanelGroup>
|
||||
<EditorPanelGroup title="Dialogues" summary="Manifest">
|
||||
<EditorDialogueManifestPanel />
|
||||
</EditorPanelGroup>
|
||||
<EditorPanelGroup title="SRT" summary="Subtitles">
|
||||
<EditorSrtPanel />
|
||||
</EditorPanelGroup>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
<axesHelper args={[10]} />
|
||||
|
||||
<group
|
||||
onClick={(e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation();
|
||||
onClick={(event: ThreeEvent<MouseEvent>) => {
|
||||
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<THREE.Mesh>(null);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+103
-100
@@ -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.
|
||||
`;
|
||||
|
||||
+89
-1
@@ -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;
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={animation}
|
||||
frContent={animation}
|
||||
meta="08"
|
||||
meta="11"
|
||||
title="Animation & 3D Model System"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<DocsDocument
|
||||
content={audio}
|
||||
frContent={audio}
|
||||
meta="05"
|
||||
title="Audio Technical Notes"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={editor}
|
||||
frContent={editorFr}
|
||||
meta="09"
|
||||
meta="10"
|
||||
title="Editor User Guide"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={features}
|
||||
frContent={featuresFr}
|
||||
meta="06"
|
||||
meta="08"
|
||||
title="Features"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={handTracking}
|
||||
frContent={handTracking}
|
||||
meta="05"
|
||||
meta="06"
|
||||
title="Hand Tracking Technical Notes"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={mainFeature}
|
||||
frContent={mainFeature}
|
||||
meta="07"
|
||||
meta="09"
|
||||
title="Main Feature"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ export function DocsZustandPage(): React.JSX.Element {
|
||||
<DocsDocument
|
||||
content={zustand}
|
||||
frContent={zustandFr}
|
||||
meta="05"
|
||||
meta="07"
|
||||
title="Zustand Game State"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
const [transformMode, setTransformMode] =
|
||||
useState<TransformMode>("translate");
|
||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
|
||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||
{
|
||||
...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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user