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

Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-05-12 08:59:27 +00:00
20 changed files with 860 additions and 285 deletions
+1
View File
@@ -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. - `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`. - Supported audio categories are `music`, `sfx`, and `dialogue`.
- Trigger interactions may play SFX directly through `AudioManager`. - Trigger interactions may play SFX directly through `AudioManager`.
- Detailed audio documentation lives in `docs/technical/audio.md`.
## Settings Menu ## Settings Menu
+217
View File
@@ -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.
+53 -4
View File
@@ -52,7 +52,7 @@ src/
## Responsibilities ## 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. `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/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. `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()`. 2. `useEditorSceneData` calls `loadMapSceneData()`.
3. `loadMapSceneData()` loads `/map.json` and available model URLs. 3. `loadMapSceneData()` loads `/map.json` and available model URLs.
4. If `/map.json` is missing, the page displays a folder-upload flow. 4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorScene` renders the grid, lights, camera controls, and map nodes. 5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
6. `EditorControls` exposes transform mode, history actions, export, save, and selection info. 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 ## Controls
- Click: select a node. - Click: select a node.
- `Esc`: clear selection. - `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. - `T`: translate mode.
- `R`: rotate mode. - `R`: rotate mode.
- `S`: scale 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. 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 SRT Editing
Dialogue subtitle editing is part of the `/editor` side panel. Dialogue subtitle editing is part of the `/editor` side panel.
+85 -7
View File
@@ -1,12 +1,18 @@
# Editor User Guide # 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 ## 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/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. - `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 | | `rotation` | Object rotation as `[x, y, z]`, expressed radians |
| `scale` | Object scale as `[x, y, z]` | | `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. 1. Open `/editor` in the local app.
2. Click an object in the scene to select it. 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 | | Select object | Click object |
| Deselect | `Esc` or click empty space | | Deselect | `Esc` or click empty space |
| Lock selection | `Lock` button in Selection |
| Clear selection | `X` button in Selection |
| Translate mode | `T` | | Translate mode | `T` |
| Rotate mode | `R` | | Rotate mode | `R` |
| Scale mode | `S` | | Scale mode | `S` |
@@ -49,18 +68,34 @@ Each entry in `public/map.json` represents one object:
| Move up | `Space` | | Move up | `Space` |
| Move down | `Shift` | | 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 ## 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. 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 ## 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 no object is selected, it shows the full map node list.
- When an object is selected, it highlights the JSON lines for that object. - 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 ## Saving Changes
@@ -76,12 +111,27 @@ The button is hidden in production builds because production persistence is not
## Editing Dialogue Subtitles ## 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 ### Dialogue Manifest
Use the `Dialogues` panel to edit `public/sounds/dialogue/dialogues.json` without opening the JSON file manually. 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: Available actions:
- `Reload` reloads the manifest from disk. - `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. 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 ### SRT Editor
Use the `SRT` panel to edit one subtitle file at a time. 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. 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 ## Validating Dialogue Assets
Use `Validate` in the SRT panel to check the dialogue manifest and linked 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 - `time`: seconds relative to the cinematic start
- `dialogueId`: an entry from `public/sounds/dialogue/dialogues.json` - `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: Available actions:
- `Reload` reloads the cinematic manifest from disk. - `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. 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 ## Current Limitations
- The editor only modifies existing nodes. - The editor only modifies existing nodes.
+137 -59
View File
@@ -1,6 +1,7 @@
import { import {
Box, Box,
Braces, Braces,
ChevronDown,
Download, Download,
Expand, Expand,
Keyboard, Keyboard,
@@ -11,6 +12,8 @@ import {
RotateCw, RotateCw,
Save, Save,
Undo2, Undo2,
Unlock,
X,
} from "lucide-react"; } from "lucide-react";
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel"; import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel"; import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
@@ -25,6 +28,9 @@ interface EditorControlsProps {
mapNodes: MapNode[]; mapNodes: MapNode[];
nodesCount: number; nodesCount: number;
selectedNodeName: string | null; selectedNodeName: string | null;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
undoCount: number; undoCount: number;
redoCount: number; redoCount: number;
onUndo: () => void; onUndo: () => void;
@@ -50,6 +56,33 @@ const EDITOR_SHORTCUTS = [
["WASD", "Move when locked"], ["WASD", "Move when locked"],
] as const; ] 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({ export function EditorControls({
transformMode, transformMode,
onTransformModeChange, onTransformModeChange,
@@ -57,6 +90,9 @@ export function EditorControls({
mapNodes, mapNodes,
nodesCount, nodesCount,
selectedNodeName, selectedNodeName,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
undoCount, undoCount,
redoCount, redoCount,
onUndo, onUndo,
@@ -79,6 +115,28 @@ export function EditorControls({
<p>Select an object, choose a transform mode, then drag the gizmo.</p> <p>Select an object, choose a transform mode, then drag the gizmo.</p>
</header> </header>
<EditorPanelGroup title="Editor" summary="Map tools" defaultOpen>
<EditorPanelGroup title="Shortcuts" summary="Keys">
<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>
</EditorPanelGroup>
<section <section
className="editor-control-section" className="editor-control-section"
aria-labelledby="transform-heading" aria-labelledby="transform-heading"
@@ -127,25 +185,57 @@ export function EditorControls({
<section <section
className="editor-control-section" className="editor-control-section"
aria-labelledby="file-heading" aria-labelledby="selection-heading"
> >
<div className="editor-section-heading"> <div className="editor-section-heading">
<h3 id="file-heading">File</h3> <h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div> </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-selected-actions">
<button <button
className="editor-action-button editor-action-button-primary" type="button"
onClick={onExportJson} onClick={onSelectionLockToggle}
aria-pressed={isSelectionLocked}
aria-label={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
title={
isSelectionLocked ? "Unlock selection" : "Lock selection"
}
> >
<Download size={16} aria-hidden="true" /> {isSelectionLocked ? (
Export JSON <Lock size={14} aria-hidden="true" />
) : (
<Unlock size={14} aria-hidden="true" />
)}
</button> </button>
<button
{onSaveToServer && ( type="button"
<button className="editor-action-button" onClick={onSaveToServer}> onClick={onClearSelection}
<Save size={16} aria-hidden="true" /> aria-label="Clear selection"
Save to server title="Clear selection"
>
<X size={14} aria-hidden="true" />
</button> </button>
</div>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</div>
)} )}
</section> </section>
@@ -170,54 +260,9 @@ export function EditorControls({
</section> </section>
<section <section
className="editor-control-section" className="editor-json-section"
aria-labelledby="selection-heading" aria-labelledby="json-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>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</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"> <div className="editor-section-heading">
<h3 id="json-heading">JSON</h3> <h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span> <span>{jsonPreview.label}</span>
@@ -243,9 +288,42 @@ export function EditorControls({
</div> </div>
</section> </section>
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} /> <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 /> <EditorDialogueManifestPanel />
</EditorPanelGroup>
<EditorPanelGroup title="SRT" summary="Subtitles">
<EditorSrtPanel /> <EditorSrtPanel />
</EditorPanelGroup>
</aside> </aside>
</> </>
); );
+14 -2
View File
@@ -11,6 +11,7 @@ interface EditorMapProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
@@ -28,6 +29,7 @@ interface EditorNodeCommonProps {
isHovered: boolean; isHovered: boolean;
objectsMapRef: EditorNodeObjectRef; objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
} }
@@ -108,11 +110,13 @@ function getNodeHighlightColor(
function createEditorNodePointerHandlers( function createEditorNodePointerHandlers(
index: number, index: number,
onSelectNode: (index: number | null) => void, onSelectNode: (index: number | null) => void,
isSelectionLocked: boolean,
onHoverNode: (index: number | null) => void, onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers { ): EditorNodePointerHandlers {
return { return {
onClick: (event) => { onClick: (event) => {
event.stopPropagation(); event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(index); onSelectNode(index);
}, },
onPointerEnter: (event) => { onPointerEnter: (event) => {
@@ -130,6 +134,7 @@ export function EditorMap({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
onSelectNode, onSelectNode,
isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
@@ -192,8 +197,9 @@ export function EditorMap({
<axesHelper args={[10]} /> <axesHelper args={[10]} />
<group <group
onClick={(e: ThreeEvent<MouseEvent>) => { onClick={(event: ThreeEvent<MouseEvent>) => {
e.stopPropagation(); event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null); onSelectNode(null);
}} }}
> >
@@ -211,6 +217,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
); );
@@ -224,6 +231,7 @@ export function EditorMap({
isHovered={hoveredNodeIndex === index} isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef} objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
/> />
); );
@@ -251,6 +259,7 @@ function EditorModelNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps & { }: EditorNodeCommonProps & {
modelUrl: string; modelUrl: string;
@@ -269,6 +278,7 @@ function EditorModelNode({
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(groupRef, index, node, objectsMapRef); useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
@@ -343,12 +353,14 @@ function EditorFallbackNode({
isHovered, isHovered,
objectsMapRef, objectsMapRef,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
}: EditorNodeCommonProps) { }: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null); const meshRef = useRef<THREE.Mesh>(null);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(meshRef, index, node, objectsMapRef); useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
+12 -2
View File
@@ -17,6 +17,7 @@ interface EditorSceneProps {
sceneData: SceneData; sceneData: SceneData;
selectedNodeIndex: number | null; selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void; onSelectNode: (index: number | null) => void;
isSelectionLocked: boolean;
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
@@ -35,6 +36,7 @@ export function EditorScene({
sceneData, sceneData,
selectedNodeIndex, selectedNodeIndex,
onSelectNode, onSelectNode,
isSelectionLocked,
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
@@ -68,7 +70,7 @@ export function EditorScene({
if (selectedNodeIndex !== null) { if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case "escape": case "escape":
onSelectNode(null); if (!isSelectionLocked) onSelectNode(null);
break; break;
case "t": case "t":
onTransformModeChange("translate"); onTransformModeChange("translate");
@@ -85,7 +87,14 @@ export function EditorScene({
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]); }, [
isSelectionLocked,
selectedNodeIndex,
onSelectNode,
onTransformModeChange,
onUndo,
onRedo,
]);
return ( return (
<> <>
@@ -113,6 +122,7 @@ export function EditorScene({
sceneData={sceneData} sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode} onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
transformMode={transformMode} transformMode={transformMode}
+12 -6
View File
@@ -38,17 +38,23 @@ export const docGroups: DocGroup[] = [
subtitle: "Implementation details", subtitle: "Implementation details",
meta: "04", meta: "04",
}, },
{
path: "/docs/audio",
title: "Audio Technical Notes",
subtitle: "Music, dialogue, SRT, and SFX",
meta: "05",
},
{ {
path: "/docs/hand-tracking", path: "/docs/hand-tracking",
title: "Hand Tracking Technical Notes", title: "Hand Tracking Technical Notes",
subtitle: "Webcam interaction pipeline", subtitle: "Webcam interaction pipeline",
meta: "05", meta: "06",
}, },
{ {
path: "/docs/zustand", path: "/docs/zustand",
title: "Zustand Game State", title: "Zustand Game State",
subtitle: "Progression store", subtitle: "Progression store",
meta: "06", meta: "07",
}, },
], ],
}, },
@@ -59,25 +65,25 @@ export const docGroups: DocGroup[] = [
path: "/docs/features", path: "/docs/features",
title: "Features", title: "Features",
subtitle: "Implemented scope", subtitle: "Implemented scope",
meta: "07", meta: "08",
}, },
{ {
path: "/docs/main-feature", path: "/docs/main-feature",
title: "Main Feature", title: "Main Feature",
subtitle: "Repair-game prototype", subtitle: "Repair-game prototype",
meta: "08", meta: "09",
}, },
{ {
path: "/docs/editor", path: "/docs/editor",
title: "Editor User Guide", title: "Editor User Guide",
subtitle: "Editing workflow", subtitle: "Editing workflow",
meta: "09", meta: "10",
}, },
{ {
path: "/docs/animation", path: "/docs/animation",
title: "Animation & 3D Model System", title: "Animation & 3D Model System",
subtitle: "Components and usage", subtitle: "Components and usage",
meta: "010", meta: "11",
}, },
], ],
}, },
+103 -100
View File
@@ -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 - 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" ## Organisation du panneau
- "type" : catégorie de l'objet
- "position" : "[x, y, z]"
- "rotation" : "[x, y, z]"
- "scale" : "[x, y, z]"
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". ## Carte et transforms
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.
## 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 | | Action | Input |
| --- | --- | | --- | --- |
| Sélectionner un objet | Clic sur l'objet | | Sélectionner | Clic objet |
| Désélectionner | "Esc" ou clic dans le vide | | Désélectionner | \`Esc\` ou clic vide |
| Mode translation | "T" | | Verrouiller la sélection | bouton lock |
| Mode rotation | "R" | | Vider la sélection | bouton \`X\` |
| Mode scale | "S" | | Translate | \`T\` |
| Undo | "Ctrl+Z" | | Rotate | \`R\` |
| Redo | "Ctrl+Y" | | Scale | \`S\` |
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches | | Undo / redo | \`Ctrl+Z\` / \`Ctrl+Y\` |
| Monter / descendre | "Space", "Shift" | | 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. Une cinématique contient :
- \`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 :
- un \`id\` - un \`id\`
- un \`timecode\` global optionnel - un \`timecode\` global optionnel
- au moins deux keyframes caméra - 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. Les temps de keyframes et de dialogue cues sont relatifs au début de la cinématique.
- \`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 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 - \`id\` : identifiant stable utilisé par les cinématiques et le runtime
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné - \`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 ## Limites actuelles
- L'éditeur modifie uniquement les nodes existants. - L'éditeur modifie les nodes existants mais ne crée pas encore d'objet de carte.
- Il n'y a pas encore d'interface pour créer ou supprimer des objets. - Les sauvegardes serveur sont limitées au développement local.
- La sauvegarde production n'est pas implémentée. - L'éditeur SRT reste textuel, sans waveform.
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur. - Les modèles manquants sont représentés par des cubes de fallback.
- 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.
`; `;
+89 -1
View File
@@ -1142,6 +1142,61 @@ canvas {
line-height: 1.45; 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 { .editor-control-section {
padding: 14px 12px; padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09); border-top: 1px solid rgba(255, 255, 255, 0.09);
@@ -1313,7 +1368,8 @@ canvas {
} }
.editor-selected-info { .editor-selected-info {
display: flex; display: grid;
grid-template-columns: 17px 1fr auto;
align-items: center; align-items: center;
gap: 11px; gap: 11px;
background: #ffffff; background: #ffffff;
@@ -1323,6 +1379,38 @@ canvas {
color: #050505; 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 strong,
.editor-selected-info span { .editor-selected-info span {
display: block; display: block;
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsAnimationPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={animation} content={animation}
frContent={animation} frContent={animation}
meta="08" meta="11"
title="Animation & 3D Model System" title="Animation & 3D Model System"
/> />
); );
+13
View File
@@ -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"
/>
);
}
+1 -1
View File
@@ -7,7 +7,7 @@ export function DocsEditorPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={editor} content={editor}
frContent={editorFr} frContent={editorFr}
meta="09" meta="10"
title="Editor User Guide" title="Editor User Guide"
/> />
); );
+1 -1
View File
@@ -7,7 +7,7 @@ export function DocsFeaturesPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={features} content={features}
frContent={featuresFr} frContent={featuresFr}
meta="06" meta="08"
title="Features" title="Features"
/> />
); );
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsHandTrackingPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={handTracking} content={handTracking}
frContent={handTracking} frContent={handTracking}
meta="05" meta="06"
title="Hand Tracking Technical Notes" title="Hand Tracking Technical Notes"
/> />
); );
+1 -1
View File
@@ -6,7 +6,7 @@ export function DocsMainFeaturePage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={mainFeature} content={mainFeature}
frContent={mainFeature} frContent={mainFeature}
meta="07" meta="09"
title="Main Feature" title="Main Feature"
/> />
); );
+1 -1
View File
@@ -7,7 +7,7 @@ export function DocsZustandPage(): React.JSX.Element {
<DocsDocument <DocsDocument
content={zustand} content={zustand}
frContent={zustandFr} frContent={zustandFr}
meta="05" meta="07"
title="Zustand Game State" title="Zustand Game State"
/> />
); );
+13
View File
@@ -67,6 +67,7 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] = const [transformMode, setTransformMode] =
useState<TransformMode>("translate"); useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false); const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>( const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
{ {
...INITIAL_SCENE_LOADING_STATE, ...INITIAL_SCENE_LOADING_STATE,
@@ -112,6 +113,14 @@ export function EditorPage(): React.JSX.Element {
setSelectedNodeIndex(index); setSelectedNodeIndex(index);
}, []); }, []);
const handleClearSelection = useCallback(() => {
setSelectedNodeIndex(null);
}, []);
const handleSelectionLockToggle = useCallback(() => {
setIsSelectionLocked((locked) => !locked);
}, []);
const handleHoverNode = useCallback((index: number | null) => { const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index); setHoveredNodeIndex(index);
}, []); }, []);
@@ -246,6 +255,7 @@ export function EditorPage(): React.JSX.Element {
sceneData={sceneData!} sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex} selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode} onSelectNode={handleSelectNode}
isSelectionLocked={isSelectionLocked}
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode} onHoverNode={handleHoverNode}
transformMode={transformMode} transformMode={transformMode}
@@ -276,6 +286,9 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null ? sceneData.mapNodes[selectedNodeIndex].name || null
: null : null
} }
isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection}
undoCount={undoCount} undoCount={undoCount}
redoCount={redoCount} redoCount={redoCount}
onUndo={handleUndo} onUndo={handleUndo}
+2
View File
@@ -8,6 +8,7 @@ import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page"; import { EditorPage } from "@/pages/editor/page";
import { import {
DocsAnimationRoute, DocsAnimationRoute,
DocsAudioRoute,
DocsArchitectureRoute, DocsArchitectureRoute,
DocsEditorRoute, DocsEditorRoute,
DocsFeaturesRoute, DocsFeaturesRoute,
@@ -47,6 +48,7 @@ const docsChildRoutes = [
{ path: "architecture", component: DocsArchitectureRoute }, { path: "architecture", component: DocsArchitectureRoute },
{ path: "target-architecture", component: DocsTargetArchitectureRoute }, { path: "target-architecture", component: DocsTargetArchitectureRoute },
{ path: "technical-editor", component: DocsTechnicalEditorRoute }, { path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "audio", component: DocsAudioRoute },
{ path: "hand-tracking", component: DocsHandTrackingRoute }, { path: "hand-tracking", component: DocsHandTrackingRoute },
{ path: "zustand", component: DocsZustandRoute }, { path: "zustand", component: DocsZustandRoute },
{ path: "features", component: DocsFeaturesRoute }, { path: "features", component: DocsFeaturesRoute },
+5
View File
@@ -47,6 +47,10 @@ const LazyDocsTechnicalEditorPage = lazyNamed(
() => import("@/pages/docs/technical-editor/page"), () => import("@/pages/docs/technical-editor/page"),
"DocsTechnicalEditorPage", "DocsTechnicalEditorPage",
); );
const LazyDocsAudioPage = lazyNamed(
() => import("@/pages/docs/audio/page"),
"DocsAudioPage",
);
const LazyDocsHandTrackingPage = lazyNamed( const LazyDocsHandTrackingPage = lazyNamed(
() => import("@/pages/docs/hand-tracking/page"), () => import("@/pages/docs/hand-tracking/page"),
"DocsHandTrackingPage", "DocsHandTrackingPage",
@@ -81,6 +85,7 @@ export const DocsTargetArchitectureRoute = createDocsRoute(
export const DocsTechnicalEditorRoute = createDocsRoute( export const DocsTechnicalEditorRoute = createDocsRoute(
LazyDocsTechnicalEditorPage, LazyDocsTechnicalEditorPage,
); );
export const DocsAudioRoute = createDocsRoute(LazyDocsAudioPage);
export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage); export const DocsHandTrackingRoute = createDocsRoute(LazyDocsHandTrackingPage);
export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage); export const DocsZustandRoute = createDocsRoute(LazyDocsZustandPage);
export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage); export const DocsFeaturesRoute = createDocsRoute(LazyDocsFeaturesPage);