Compare commits
291 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c8c35cc72 | |||
| 711c336f1e | |||
| 12c96b43c1 | |||
| 2bb2fff310 | |||
| 052ab34740 | |||
| ff21b80d57 | |||
| 8088e67625 | |||
| 6f5137229d | |||
| 6d3a8fde52 | |||
| 26a9c1c4d4 | |||
| f91756bc27 | |||
| e9875206ff | |||
| 841f96f3fd | |||
| 00b1780b10 | |||
| c81bacc208 | |||
| 49d1a7324c | |||
| 131fe39311 | |||
| 3c72298a45 | |||
| 49e96c7f5a | |||
| e5aeab6534 | |||
| cec4d6ad0d | |||
| 202ac4628d | |||
| 6a4d0f7eb1 | |||
| 40368c22fc | |||
| 788e9f0fb3 | |||
| 7a439d0879 | |||
| 9f5c105c1b | |||
| 72f8acb61c | |||
| 3791ab71cd | |||
| 1ad4a164ea | |||
| cc8ea20536 | |||
| bd14042ca0 | |||
| 7036b7b0f9 | |||
| f4635def91 | |||
| e16d6e15fa | |||
| f9a6390260 | |||
| b521cdd361 | |||
| 4218fde63a | |||
| c2ba26ca86 | |||
| f74a0c5eae | |||
| a93d2dc92c | |||
| 4b7498ae13 | |||
| a34396b958 | |||
| 8cbf696b6e | |||
| 311c243506 | |||
| e8fb859f79 | |||
| 17836ec889 | |||
| 6101bca84f | |||
| 5889a325ec | |||
| 08f89c9de5 | |||
| 5ec10f4a35 | |||
| 71b46b5fea | |||
| 4a8a1368b2 | |||
| c9e5ce3279 | |||
| f55be58c0b | |||
| 48f2c9ef80 | |||
| 807503fde5 | |||
| 75b77a52bf | |||
| 916b5d304f | |||
| 3d81a9281a | |||
| 0c8b9070bb | |||
| 0fbf6bfa0e | |||
| 53fdf3cb1e | |||
| 8ef1da0e9a | |||
| 1c30b73253 | |||
| f5bc7cb08e | |||
| d9525b0aaf | |||
| 5b43f503ac | |||
| 974f1e33fb | |||
| ce67d07107 | |||
| 53add29a48 | |||
| 33524f8409 | |||
| e0eae67ace | |||
| 254311bddf | |||
| 6d9eac291e | |||
| e4f6ec211c | |||
| ead3634aab | |||
| 19a83982a9 | |||
| bebb9ac5a3 | |||
| d02ef54bdc | |||
| 3f3f623832 | |||
| 0256dfa812 | |||
| 5c688fdaf7 | |||
| 7a3baa4c0b | |||
| 95d9bd4f3e | |||
| eee69825c6 | |||
| 15c3d1858f | |||
| 7ee842c535 | |||
| d4f215a948 | |||
| 7bbcf4359e | |||
| eed0077dd1 | |||
| f5da2f4994 | |||
| ed60114d06 | |||
| 861a369776 | |||
| 96796bca65 | |||
| 8fa4b087ba | |||
| 9371c50110 | |||
| fb230911a7 | |||
| 457eebc0e3 | |||
| 03dfef4aad | |||
| 4bcdbef974 | |||
| 553dc6eb0a | |||
| 7ae45d4cfa | |||
| 27928b00a6 | |||
| ac7f60060c | |||
| fe662ebe7d | |||
| bdc06f772f | |||
| f7a589a11f | |||
| c71cd35f4d | |||
| 0cb5f57182 | |||
| 1d64582383 | |||
| 4d7d2efdcc | |||
| d438c02ad9 | |||
| eef39ab53d | |||
| 1a783f1867 | |||
| bb08054722 | |||
| 1625895708 | |||
| a87ffac63f | |||
| 44a77a0197 | |||
| f301a1e318 | |||
| 39fbf2333d | |||
| 5f018e6de7 | |||
| a14f776e5d | |||
| 8884edb281 | |||
| b9970c4e03 | |||
| 6a29cfdb0b | |||
| 7c7dbdb588 | |||
| 2696289483 | |||
| b79f5c9314 | |||
| 5265cdc7e5 | |||
| cfb1eaf39a | |||
| b1187b68ae | |||
| 9ac5844182 | |||
| 37eded8d7e | |||
| 92097e5256 | |||
| 01c583ba96 | |||
| f66609178b | |||
| 2783b13488 | |||
| 8b3f24b90b | |||
| 5173002283 | |||
| fd5294fb54 | |||
| 95e7e9c0a5 | |||
| 94b8dd0385 | |||
| ad6f1552c6 | |||
| d3ba5f6cfb | |||
| 05ef9e012a | |||
| aa8b31033f | |||
| 719ddef3ba | |||
| 462b80e1c2 | |||
| 90bd216efe | |||
| ba9833d407 | |||
| b736de5f25 | |||
| d482ae4634 | |||
| 7958b2c62a | |||
| d3a3d5eeca | |||
| dca8f5a0d3 | |||
| 8e08c3d5e0 | |||
| 882f3cc71b | |||
| 6c85485622 | |||
| a14ff9d913 | |||
| cc4c11f934 | |||
| 0a0519cead | |||
| 7d09c29828 | |||
| 28e3ac4c06 | |||
| cc78420d9c | |||
| 2747a95847 | |||
| d116f26a42 | |||
| 9f746aa7b0 | |||
| 847d6834fd | |||
| bd641328b0 | |||
| dbbb67e55a | |||
| 10dac2e56b | |||
| aaad1b5884 | |||
| bf98f028fe | |||
| bff24030ee | |||
| 6531d9b183 | |||
| 38fa9c1ff2 | |||
| e8f621d35f | |||
| aa211d16b7 | |||
| 44c27be640 | |||
| 8a58f75864 | |||
| 0c7d5b61ac | |||
| 5c5ba0937e | |||
| dc7de253de | |||
| 97e0606439 | |||
| a11db585de | |||
| 6d858cfa7d | |||
| 19bad2c8be | |||
| e01d6f27ba | |||
| 64b53a762d | |||
| 9818e719ce | |||
| 2251a81ac1 | |||
| eebeee9ed8 | |||
| 9c7c59973e | |||
| dee232ac45 | |||
| 2117146de5 | |||
| 632d4b5c95 | |||
| 291e747f7b | |||
| 48b53746d1 | |||
| ddde5e33f3 | |||
| b5f2b7c990 | |||
| bce1a05615 | |||
| 81515e3e31 | |||
| ed9582562a | |||
| 2c295fa7ee | |||
| 4a2f9cfb50 | |||
| d70de8c9a4 | |||
| f83df3dd21 | |||
| 5e528a90f9 | |||
| f3888ed0bf | |||
| b8f6fe59db | |||
| d7425ac17d | |||
| f8b2c180cd | |||
| c6b317448f | |||
| bed5010f41 | |||
| 1b1f2088aa | |||
| 18d0c2baa5 | |||
| 4aaff26e82 | |||
| 31c3e43698 | |||
| c5b04764ec | |||
| 1e444620c1 | |||
| eeca87dd0e | |||
| fde724f3f0 | |||
| d5a295e18f | |||
| 14626cd6b1 | |||
| 9c602cdc63 | |||
| fa8bc229c3 | |||
| 7139ae559e | |||
| 1c9d113050 | |||
| 70720fbdcf | |||
| cd7ba7503c | |||
| 2bd0fe5b96 | |||
| 037174a409 | |||
| 7de5678db1 | |||
| b523e98bea | |||
| 99d3eb2a2a | |||
| 4a97e265c1 | |||
| 71d7ccbb4b | |||
| cd6831166b | |||
| 1b32fe9227 | |||
| 7a01d86761 | |||
| 208b43295c | |||
| 0e9d711be5 | |||
| 3c7fdbc6f5 | |||
| 0c43c58dfe | |||
| 089763713a | |||
| 1ca13ca13e | |||
| ca9abf3f3b | |||
| a8138a5180 | |||
| 0f96b5597b | |||
| 1c48441535 | |||
| 7e72f1e803 | |||
| 4b14295749 | |||
| 5111f2e558 | |||
| 1d4f223c35 | |||
| a3feb96bc3 | |||
| 3d490b7fdd | |||
| 269cf81595 | |||
| 71c22386be | |||
| fd8b462e1c | |||
| 02283946fa | |||
| 9e9ac8066c | |||
| aded4ee209 | |||
| e86d4f2077 | |||
| c1ca8ca8e0 | |||
| 753bdafd2e | |||
| 18fdd8b2a6 | |||
| 2c771d548b | |||
| 9eff211958 | |||
| 1ce0267d12 | |||
| f0471a3afe | |||
| 9815a38656 | |||
| 9d63bd6444 | |||
| 89af15683c | |||
| 980b16895e | |||
| 0b4d59223f | |||
| 7d5abfc2af | |||
| 4a833c4484 | |||
| c36d3631f2 | |||
| 5d7ce27285 | |||
| 32d4e07ceb | |||
| af3262376d | |||
| 2fcb0ca56f | |||
| e32be4be53 | |||
| 71916b2329 | |||
| 429ae66703 | |||
| af2cfe128c | |||
| a37e46ac49 | |||
| 843d61fd54 | |||
| e622646349 | |||
| 9dc89b438b |
@@ -8,6 +8,7 @@ __pycache__/
|
|||||||
# Build
|
# Build
|
||||||
dist/
|
dist/
|
||||||
dist-ssr/
|
dist-ssr/
|
||||||
|
.vite/
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ This document describes the code that exists today in the repository.
|
|||||||
- `src/world/GameStageContent.tsx` is wrapped in Rapier `Physics` in the production game scene so stage gameplay objects can use physics without moving the map or player to Rapier. It now mounts reusable `RepairGame` instances for `bike`, `pylone`, and `ferme` mission states.
|
- `src/world/GameStageContent.tsx` is wrapped in Rapier `Physics` in the production game scene so stage gameplay objects can use physics without moving the map or player to Rapier. It now mounts reusable `RepairGame` instances for `bike`, `pylone`, and `ferme` mission states.
|
||||||
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map with the existing grab/trigger/model-preview objects plus separate `Bike`, `Pylone`, and `Farm` repair playground zones.
|
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map with the existing grab/trigger/model-preview objects plus separate `Bike`, `Pylone`, and `Farm` repair playground zones.
|
||||||
- `src/world/player/Player.tsx` mounts the camera and controller.
|
- `src/world/player/Player.tsx` mounts the camera and controller.
|
||||||
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
|
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, repair-step movement locking, and interaction input.
|
||||||
|
|
||||||
## Physics Boundaries
|
## Physics Boundaries
|
||||||
|
|
||||||
@@ -44,8 +44,44 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
|||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
- `src/managers/AudioManager.ts` currently provides pooled one-shot sound playback and looped music playback.
|
- `src/managers/AudioManager.ts` provides pooled one-shot playback, looped music playback, category volumes, and optional stereo pan for one-shot sounds.
|
||||||
- Trigger interactions may play audio directly through `AudioManager`.
|
- Supported audio categories are `music`, `sfx`, and `dialogue`.
|
||||||
|
- Trigger interactions may play SFX directly through `AudioManager`.
|
||||||
|
|
||||||
|
## Settings Menu
|
||||||
|
|
||||||
|
- `src/managers/stores/useSettingsStore.ts` stores settings for music volume, SFX volume, dialogue volume, subtitle visibility, subtitle language, repair runtime, and menu visibility.
|
||||||
|
- `src/components/ui/GameSettingsMenu.tsx` renders the in-game options menu.
|
||||||
|
- `src/components/ui/GameUI.tsx` mounts the settings menu as an HTML overlay outside the canvas.
|
||||||
|
- `Esc` opens and closes the menu, and `src/world/player/PlayerController.tsx` ignores player input while the menu is open.
|
||||||
|
- Volume changes are forwarded to `AudioManager` by category.
|
||||||
|
|
||||||
|
## Dialogues And Subtitles
|
||||||
|
|
||||||
|
- `public/sounds/dialogue/dialogues.json` is the runtime dialogue manifest.
|
||||||
|
- Dialogue audio files live under `public/sounds/dialogue/`.
|
||||||
|
- Subtitle files live under `public/sounds/dialogue/subtitles/{fr|en}/`.
|
||||||
|
- The current subtitle model is one SRT file per voice and language.
|
||||||
|
- `src/types/dialogues/dialogues.ts` contains the dialogue manifest types.
|
||||||
|
- `src/utils/dialogues/dialogueManifestValidation.ts` validates manifest shape at runtime.
|
||||||
|
- `src/utils/dialogues/loadDialogueManifest.ts` loads the manifest and SRT cues, with French fallback when the selected language is missing.
|
||||||
|
- `src/utils/subtitles/parseSrt.ts` parses SRT blocks and timecodes.
|
||||||
|
- `src/utils/dialogues/playDialogue.ts` plays dialogue audio and synchronizes the active subtitle against the audio element time.
|
||||||
|
- `src/managers/stores/useSubtitleStore.ts` stores the currently displayed subtitle cue.
|
||||||
|
- `src/components/ui/Subtitles.tsx` renders the subtitle overlay.
|
||||||
|
- `src/world/GameDialogues.tsx` currently triggers dialogue entries that define a `timecode`.
|
||||||
|
- Dialogue playback is queued so multiple dialogue requests do not overlap.
|
||||||
|
|
||||||
|
## Cinematics
|
||||||
|
|
||||||
|
- `public/cinematics.json` is the runtime cinematic manifest.
|
||||||
|
- `src/types/cinematics/cinematics.ts` contains cinematic manifest types.
|
||||||
|
- `src/utils/cinematics/cinematicManifestValidation.ts` validates manifest shape at runtime.
|
||||||
|
- `src/utils/cinematics/loadCinematicManifest.ts` loads `/cinematics.json`.
|
||||||
|
- `src/world/GameCinematics.tsx` triggers cinematics that define a global `timecode`.
|
||||||
|
- Cinematics use GSAP timelines to animate the active camera position and look target.
|
||||||
|
- `dialogueCues` on a cinematic trigger dialogue IDs at times relative to the cinematic start.
|
||||||
|
- `src/managers/stores/useGameStore.ts` exposes `isCinematicPlaying`, used to lock player input during cinematics.
|
||||||
|
|
||||||
## Debug System
|
## Debug System
|
||||||
|
|
||||||
@@ -74,6 +110,9 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
|||||||
|
|
||||||
- `src/pages/editor/page.tsx` is the route-level editor page for `/editor`.
|
- `src/pages/editor/page.tsx` is the route-level editor page for `/editor`.
|
||||||
- `src/components/editor/EditorControls.tsx` renders the HTML editor control panel.
|
- `src/components/editor/EditorControls.tsx` renders the HTML editor control panel.
|
||||||
|
- `src/components/editor/EditorDialogueManifestPanel.tsx` edits `public/sounds/dialogue/dialogues.json`.
|
||||||
|
- `src/components/editor/EditorCinematicManifestPanel.tsx` edits `public/cinematics.json`.
|
||||||
|
- `src/components/editor/EditorSrtPanel.tsx` renders the dialogue SRT editor inside the editor control panel.
|
||||||
- `src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering.
|
- `src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering.
|
||||||
- `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/controls/editor/FlyController.tsx` provides player-style editor navigation.
|
- `src/controls/editor/FlyController.tsx` provides player-style editor navigation.
|
||||||
@@ -97,6 +136,7 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
|||||||
- The repository is a prototype, not the full intended game runtime.
|
- The repository is a prototype, not the full intended game runtime.
|
||||||
- `src/world/debug/TestMap.tsx` is part of the active scene composition.
|
- `src/world/debug/TestMap.tsx` is part of the active scene composition.
|
||||||
- There is no central gameplay orchestrator such as `GameManager`.
|
- There is no central gameplay orchestrator such as `GameManager`.
|
||||||
- The mission state exists in Zustand, but zones, cinematics, dialogue, and the full repair sequence are not implemented.
|
- Mission state exists in Zustand and the repair flow is implemented as a prototype for the current repair missions.
|
||||||
|
- Cinematics and dialogues exist as prototype timecode-driven systems; dialogue branching and broader gameplay orchestration are still limited.
|
||||||
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
|
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
|
||||||
- Editor save-to-server is implemented as a Vite dev-server plugin, not a production backend API.
|
- Editor save-to-server is implemented as a Vite dev-server plugin, not a production backend API.
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ src/
|
|||||||
├── components/
|
├── components/
|
||||||
│ └── editor/
|
│ └── editor/
|
||||||
│ ├── EditorControls.tsx
|
│ ├── EditorControls.tsx
|
||||||
|
│ ├── EditorCinematicManifestPanel.tsx
|
||||||
|
│ ├── EditorDialogueManifestPanel.tsx
|
||||||
|
│ ├── EditorSrtPanel.tsx
|
||||||
│ └── scene/
|
│ └── scene/
|
||||||
│ ├── EditorMap.tsx
|
│ ├── EditorMap.tsx
|
||||||
│ └── EditorScene.tsx
|
│ └── EditorScene.tsx
|
||||||
@@ -37,10 +40,14 @@ src/
|
|||||||
│ └── editor/
|
│ └── editor/
|
||||||
│ └── editor.ts
|
│ └── editor.ts
|
||||||
└── utils/
|
└── utils/
|
||||||
|
├── dialogues/
|
||||||
|
│ └── loadDialogueManifest.ts
|
||||||
├── editor/
|
├── editor/
|
||||||
│ └── loadEditorScene.ts
|
│ └── loadEditorScene.ts
|
||||||
└── map/
|
├── map/
|
||||||
└── loadMapSceneData.ts
|
│ └── loadMapSceneData.ts
|
||||||
|
└── subtitles/
|
||||||
|
└── parseSrt.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
## Responsibilities
|
## Responsibilities
|
||||||
@@ -57,6 +64,12 @@ src/
|
|||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
|
`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/EditorCinematicManifestPanel.tsx` renders the cinematic manifest editor. It loads `cinematics.json`, edits camera keyframes and dialogue cues, previews selected cinematics in the editor canvas, and saves the manifest through a dev-server endpoint.
|
||||||
|
|
||||||
|
`src/components/editor/EditorSrtPanel.tsx` renders the dialogue subtitle editor inside the control panel. It loads the dialogue manifest, loads one SRT file per voice/language, validates cue structure, previews dialogue audio, and can save SRT files through a dev-server endpoint.
|
||||||
|
|
||||||
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
|
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
|
||||||
|
|
||||||
`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json` and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`.
|
`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json` and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`.
|
||||||
@@ -134,6 +147,78 @@ 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.
|
||||||
|
|
||||||
|
## Dialogue SRT Editing
|
||||||
|
|
||||||
|
Dialogue subtitle editing is part of the `/editor` side panel.
|
||||||
|
|
||||||
|
Runtime dialogue files are grouped under `public/sounds/dialogue/`:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
public/
|
||||||
|
└── sounds/
|
||||||
|
└── dialogue/
|
||||||
|
├── dialogues.json
|
||||||
|
└── subtitles/
|
||||||
|
├── fr/
|
||||||
|
│ ├── narrateur.srt
|
||||||
|
│ ├── fermier.srt
|
||||||
|
│ └── electricienne.srt
|
||||||
|
└── en/
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The current model is one SRT file per voice and language. A dialogue entry references the cue it needs through `subtitleCueIndex`; it does not own a dedicated SRT file.
|
||||||
|
|
||||||
|
`EditorSrtPanel` uses:
|
||||||
|
|
||||||
|
- `loadDialogueManifest()` to read `/sounds/dialogue/dialogues.json`
|
||||||
|
- `parseSrt()` to validate local textarea content and find active cues during audio preview
|
||||||
|
- `/api/save-srt` to write edited SRT files during local development
|
||||||
|
- `/api/validate-dialogues` to validate the manifest, linked audio, French SRT files, and referenced cue indexes
|
||||||
|
|
||||||
|
SRT timecodes are relative to the dialogue audio file being previewed, not to the global game timeline.
|
||||||
|
|
||||||
|
Missing English SRT files are warnings, not errors, because runtime loading falls back to French subtitles when the selected language is not available. Keep this behavior until the English translation workflow is ready.
|
||||||
|
|
||||||
|
## Dialogue Manifest Editing
|
||||||
|
|
||||||
|
`EditorDialogueManifestPanel` edits `public/sounds/dialogue/dialogues.json` in memory and persists it through `/api/save-dialogues`.
|
||||||
|
|
||||||
|
The panel supports:
|
||||||
|
|
||||||
|
- adding a dialogue entry
|
||||||
|
- deleting a dialogue entry
|
||||||
|
- editing `id`, `voice`, `audio`, `subtitleCueIndex`, and optional `timecode`
|
||||||
|
- previewing the selected dialogue through `playDialogueById()`
|
||||||
|
- creating a missing French SRT cue through `/api/save-srt`
|
||||||
|
|
||||||
|
When a dialogue is added, the editor computes the next `subtitleCueIndex` for the selected voice from the manifest. The generated SRT cue is a valid placeholder block and should be edited later in the SRT panel.
|
||||||
|
|
||||||
|
`/api/save-dialogues` is implemented in `vite.config.ts`. It validates manifest shape before writing to `public/sounds/dialogue/dialogues.json`.
|
||||||
|
|
||||||
|
## Cinematic Manifest Editing
|
||||||
|
|
||||||
|
`EditorCinematicManifestPanel` edits `public/cinematics.json` in memory and persists it through `/api/save-cinematics`.
|
||||||
|
|
||||||
|
The manifest shape is:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CinematicDefinition {
|
||||||
|
id: string;
|
||||||
|
timecode?: number;
|
||||||
|
cameraKeyframes: CinematicCameraKeyframe[];
|
||||||
|
dialogueCues?: CinematicDialogueCue[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cameraKeyframes` are relative to the cinematic start. At least two keyframes are required and keyframe times must increase.
|
||||||
|
|
||||||
|
`dialogueCues` are also relative to the cinematic start and reference dialogue IDs from `dialogues.json`. They are used by `GameCinematics` to synchronize dialogue playback with camera timelines. A dialogue synchronized this way should not also define a global `timecode` in `dialogues.json`.
|
||||||
|
|
||||||
|
The editor preview sends the selected `CinematicDefinition` to `EditorScene`, where GSAP animates the current editor camera. Orbit and fly controls are disabled during preview.
|
||||||
|
|
||||||
|
`/api/save-cinematics` is implemented in `vite.config.ts`. It validates manifest shape before writing to `public/cinematics.json`.
|
||||||
|
|
||||||
## Styling
|
## Styling
|
||||||
|
|
||||||
Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI.
|
Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI.
|
||||||
@@ -144,3 +229,6 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas
|
|||||||
- Large `map.json` files are not virtualized, culled, or LOD-managed.
|
- Large `map.json` files are not virtualized, culled, or LOD-managed.
|
||||||
- There is no snap-to-grid, duplication, material editing, or object creation workflow.
|
- There is no snap-to-grid, duplication, material editing, or object creation workflow.
|
||||||
- Save to Server is a Vite dev-server helper, not a production backend API.
|
- Save to Server is a Vite dev-server helper, not a production backend API.
|
||||||
|
- SRT Save is also a Vite dev-server helper, not a production backend API.
|
||||||
|
- Dialogue and cinematic manifest saves are Vite dev-server helpers, not production backend APIs.
|
||||||
|
- Dialogue creation still uses placeholder audio paths until real MP3 files are added.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ The production repair activation conditions are:
|
|||||||
|
|
||||||
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
|
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
|
||||||
|
|
||||||
In the current production repair flow, `inspected` uses a two-fists hold gesture to advance to `fragmented`. The hold must last one second and is independent from local object interaction distance once the mission is in the correct state.
|
In the current production repair flow, `inspected` uses a two-fists hold gesture to advance to `fragmented`. The hold must last one second and is independent from local object interaction distance once the mission is in the correct state. Keyboard input for the same transition is handled separately by the repair case trigger, so pressing `E` requires the case to be focused through the shared interaction system.
|
||||||
|
|
||||||
## Backend
|
## Backend
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ Current overlays:
|
|||||||
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store
|
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store
|
||||||
- `Crosshair`: player aiming helper
|
- `Crosshair`: player aiming helper
|
||||||
- `InteractPrompt`: interaction prompt
|
- `InteractPrompt`: interaction prompt
|
||||||
|
- `RepairMovementLockIndicator`: player-facing indicator shown while repair steps temporarily disable movement
|
||||||
|
|
||||||
`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`.
|
`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`.
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,87 @@ This is useful for checking numeric transform values before saving or exporting.
|
|||||||
|
|
||||||
The button is hidden in production builds because production persistence is not implemented.
|
The button is hidden in production builds because production persistence is not implemented.
|
||||||
|
|
||||||
|
## Editing Dialogue Subtitles
|
||||||
|
|
||||||
|
The side panel also includes dialogue tools for the dialogue manifest and SRT subtitles.
|
||||||
|
|
||||||
|
### Dialogue Manifest
|
||||||
|
|
||||||
|
Use the `Dialogues` panel to edit `public/sounds/dialogue/dialogues.json` without opening the JSON file manually.
|
||||||
|
|
||||||
|
Available actions:
|
||||||
|
|
||||||
|
- `Reload` reloads the manifest from disk.
|
||||||
|
- `Add` creates a local dialogue entry for the current voice and assigns the next available SRT cue index.
|
||||||
|
- `Save` writes the manifest through the local Vite dev server.
|
||||||
|
- `Preview dialogue` plays the selected dialogue and shows subtitles in the editor overlay.
|
||||||
|
- `Create FR SRT cue` creates the matching French SRT cue if it is missing.
|
||||||
|
- `Delete dialogue` removes the selected entry locally.
|
||||||
|
|
||||||
|
After using `Add`, save the manifest to keep the new dialogue entry. The generated SRT cue is written immediately to the French SRT file, but the dialogue manifest is still only local until `Save` is clicked.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### SRT Editor
|
||||||
|
|
||||||
|
Use the `SRT` panel to edit one subtitle file at a time.
|
||||||
|
|
||||||
|
1. Choose a voice: `narrateur`, `fermier`, or `electricienne`.
|
||||||
|
2. Choose a language: `FR` or `EN`.
|
||||||
|
3. Edit the SRT text directly in the textarea.
|
||||||
|
4. Use the audio preview to check the selected dialogue.
|
||||||
|
5. Use `Set start`, `Set end`, `-100ms`, and `+100ms` to adjust the selected cue timing against the audio.
|
||||||
|
6. Use `Save SRT` during local development, or `Export SRT` to download the file manually.
|
||||||
|
|
||||||
|
Each SRT file belongs to one voice, not one dialogue. Cue indexes must match the `subtitleCueIndex` values referenced by the dialogue manifest.
|
||||||
|
|
||||||
|
## Validating Dialogue Assets
|
||||||
|
|
||||||
|
Use `Validate` in the SRT panel to check the dialogue manifest and linked assets.
|
||||||
|
|
||||||
|
The validation checks:
|
||||||
|
|
||||||
|
- `public/sounds/dialogue/dialogues.json`
|
||||||
|
- referenced dialogue audio files
|
||||||
|
- French SRT files
|
||||||
|
- subtitle cue indexes referenced by the manifest
|
||||||
|
|
||||||
|
Missing English SRT files are warnings, not errors, because the runtime falls back to French subtitles. This is intentional until the English translation workflow is ready.
|
||||||
|
|
||||||
|
## Editing Cinematics
|
||||||
|
|
||||||
|
Use the `Cinematics` panel to edit `public/cinematics.json`.
|
||||||
|
|
||||||
|
Each cinematic contains:
|
||||||
|
|
||||||
|
- an `id`
|
||||||
|
- an optional global `timecode`
|
||||||
|
- two or more camera keyframes
|
||||||
|
- optional dialogue cues synchronized to the cinematic timeline
|
||||||
|
|
||||||
|
Camera keyframes define:
|
||||||
|
|
||||||
|
- `time`: seconds relative to the cinematic start
|
||||||
|
- `position`: camera position `[x, y, z]`
|
||||||
|
- `target`: point the camera looks at `[x, y, z]`
|
||||||
|
|
||||||
|
Dialogue cues define:
|
||||||
|
|
||||||
|
- `time`: seconds relative to the cinematic start
|
||||||
|
- `dialogueId`: an entry from `public/sounds/dialogue/dialogues.json`
|
||||||
|
|
||||||
|
Available actions:
|
||||||
|
|
||||||
|
- `Reload` reloads the cinematic manifest from disk.
|
||||||
|
- `Add` creates a new local cinematic with two camera keyframes.
|
||||||
|
- `Save` writes `public/cinematics.json` through the local Vite dev server.
|
||||||
|
- `Preview cinematic` plays the selected camera animation in the editor canvas.
|
||||||
|
- `Add keyframe` and `Remove` edit the camera path.
|
||||||
|
- `Add dialogue` and `Remove` edit dialogue cues linked to the cinematic.
|
||||||
|
- `Delete cinematic` removes the selected cinematic locally.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Current Limitations
|
## Current Limitations
|
||||||
|
|
||||||
- The editor only modifies existing nodes.
|
- The editor only modifies existing nodes.
|
||||||
@@ -81,3 +162,5 @@ The button is hidden in production builds because production persistence is not
|
|||||||
- It does not edit model files or textures.
|
- It does not edit model files or textures.
|
||||||
- It does not provide production persistence.
|
- It does not provide production persistence.
|
||||||
- Fallback cubes indicate missing models; they are editor placeholders, not exported assets.
|
- Fallback cubes indicate missing models; they are editor placeholders, not exported assets.
|
||||||
|
- SRT saving is a local Vite dev-server helper, not a production backend feature.
|
||||||
|
- Dialogue and cinematic saves are local Vite dev-server helpers, not production backend features.
|
||||||
|
|||||||
+43
-5
@@ -18,6 +18,7 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- Pointer lock mouse look
|
- Pointer lock mouse look
|
||||||
- Movement with `ZQSD`
|
- Movement with `ZQSD`
|
||||||
- Jumping
|
- Jumping
|
||||||
|
- Movement lock during active repair steps, with an on-screen indicator while keeping trigger interactions available
|
||||||
- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain`
|
- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain`
|
||||||
|
|
||||||
## Interactions
|
## Interactions
|
||||||
@@ -33,18 +34,48 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
|
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
|
||||||
- Debug physics playground mounts the same reusable `RepairGame` in `Bike`, `Pylone`, and `Farm` zones so each state can be tuned with isolated positioning before moving placement into the production map
|
- Debug physics playground mounts the same reusable `RepairGame` in `Bike`, `Pylone`, and `Farm` zones so each state can be tuned with isolated positioning before moving placement into the production map
|
||||||
- Repair mission config shared through `src/data/gameplay/repairMissions.ts`, including per-mission broken nodes, placeholder targets, scan timing, and reassembly timing
|
- Repair mission config shared through `src/data/gameplay/repairMissions.ts`, including per-mission broken nodes, placeholder targets, scan timing, and reassembly timing
|
||||||
- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, focused repair-case view, case placeholder traversal, snap-to-placeholder placement, broken-part deposit, `E`, two-fists hold input, exploded and inverse reassembly transitions, completion particles, per-part scan visuals, persistent red broken-part markers, centered broken-part UI videos, multiple grabbable replacement choices, correct-part install validation, and mission completion
|
- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, focused repair-case view, movement lock indicator during active repair, repair-case trigger interaction, case placeholder traversal, snap-to-placeholder placement, broken-part deposit feedback, `E`, two-fists hold input, exploded and inverse reassembly transitions, completion particles, per-part scan visuals, persistent red broken-part markers, centered broken-part UI videos, multiple grabbable replacement choices, correct-part install validation feedback, and mission completion
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
- One-shot sound playback for trigger interactions
|
- Category-based volumes for music, SFX, and dialogue
|
||||||
- Simple per-sound pooling through `AudioManager`
|
- Looped background music playback through `AudioManager`
|
||||||
|
- One-shot sound playback for SFX and dialogue, with simple per-sound pooling
|
||||||
|
- Optional stereo pan for one-shot sounds
|
||||||
|
|
||||||
|
## Dialogue And Subtitles
|
||||||
|
|
||||||
|
- Dialogue manifest in `public/sounds/dialogue/dialogues.json`
|
||||||
|
- Dialogue audio loaded from `public/sounds/dialogue/`
|
||||||
|
- One SRT subtitle file per voice and language
|
||||||
|
- French subtitle fallback when the selected language file is missing
|
||||||
|
- Runtime subtitle overlay with speaker-specific colors
|
||||||
|
- Timecoded dialogue trigger support for dialogue entries that define `timecode`
|
||||||
|
- Dialogue queueing to avoid overlapping dialogue playback
|
||||||
|
|
||||||
|
## Cinematics
|
||||||
|
|
||||||
|
- Cinematic manifest in `public/cinematics.json`
|
||||||
|
- Timecoded cinematic trigger support
|
||||||
|
- GSAP camera keyframe playback
|
||||||
|
- Optional dialogue cues synchronized to cinematic timelines
|
||||||
|
- Player input lock while a cinematic is active
|
||||||
|
|
||||||
|
## Game Options Menu
|
||||||
|
|
||||||
|
- `Esc` opens and closes the in-game options menu
|
||||||
|
- Music, SFX, and dialogue volume sliders
|
||||||
|
- Subtitle visibility toggle
|
||||||
|
- Subtitle language choice between French and English
|
||||||
|
- Repair runtime choice between local JavaScript and Python server mode
|
||||||
|
- Quit action that clears browser-accessible cookies and returns to `/`
|
||||||
|
|
||||||
## Debug Tooling
|
## Debug Tooling
|
||||||
|
|
||||||
- `?debug` query param enables the debug panel
|
- `?debug` query param enables the debug panel
|
||||||
- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning
|
- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning
|
||||||
- Compact debug overlay for game state controls and hand tracking status
|
- Compact debug overlay for game state controls and hand tracking status
|
||||||
|
- Debug game-state mission switching unlocks locked repair missions at `waiting` for faster testing
|
||||||
- Debug scene helpers
|
- Debug scene helpers
|
||||||
- Free debug camera
|
- Free debug camera
|
||||||
- `r3f-perf` overlay
|
- `r3f-perf` overlay
|
||||||
@@ -62,13 +93,20 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- Player-style navigation mode with `WASD`, `ZQSD`, arrow keys, `Space`, and `Shift`
|
- Player-style navigation mode with `WASD`, `ZQSD`, arrow keys, `Space`, and `Shift`
|
||||||
- JSON export for downloading the edited map
|
- JSON export for downloading the edited map
|
||||||
- Dev-server save endpoint for writing changes back to `public/map.json`
|
- Dev-server save endpoint for writing changes back to `public/map.json`
|
||||||
|
- SRT editor for dialogue subtitles
|
||||||
|
- Audio preview and timing helpers for SRT cues
|
||||||
|
- Dev-server save endpoint for SRT files
|
||||||
|
- Dialogue manifest editor with preview and assisted French SRT cue creation
|
||||||
|
- Cinematic manifest editor with camera keyframes, dialogue cues, and canvas preview
|
||||||
|
- Dialogue manifest validation from the editor UI
|
||||||
|
|
||||||
## Not Implemented Yet
|
## Not Implemented Yet
|
||||||
|
|
||||||
- complete mission system
|
- complete mission system
|
||||||
- zone system
|
- zone system
|
||||||
- cinematic system
|
- full cinematic system beyond current timecode prototype
|
||||||
- dialogue system
|
- gameplay-triggered dialogue branches beyond current prototype triggers
|
||||||
|
- loading flow
|
||||||
- minimap and mission HUD
|
- minimap and mission HUD
|
||||||
- full production separation between gameplay and debug scenes
|
- full production separation between gameplay and debug scenes
|
||||||
- production backend persistence for editor saves
|
- production backend persistence for editor saves
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ The current user flow is:
|
|||||||
2. Move close to the active repair object in the game scene.
|
2. Move close to the active repair object in the game scene.
|
||||||
3. Aim at the object and press the interaction key when prompted.
|
3. Aim at the object and press the interaction key when prompted.
|
||||||
4. The mission step moves from `waiting` to `inspected`.
|
4. The mission step moves from `waiting` to `inspected`.
|
||||||
5. The repair case appears near the mission object and can float when the player approaches it.
|
5. The repair case appears near the mission object, the player movement controls are locked, and the case can float when the player approaches it.
|
||||||
6. Press `E` or hold both fists closed for one second to move from `inspected` to `fragmented`.
|
6. Aim at the repair case and press `E`, or hold both fists closed for one second, to move from `inspected` to `fragmented`.
|
||||||
7. The mission object uses an exploded-model transition, then moves to `scanning`.
|
7. The mission object uses an exploded-model transition, then moves to `scanning`.
|
||||||
8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found.
|
8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found.
|
||||||
9. In `repairing`, the case opens in a larger focused view and several grabbable replacement parts appear on the case placeholders.
|
9. In `repairing`, the case opens in a larger focused view and several grabbable replacement parts appear on the case placeholders.
|
||||||
10. Move the correct replacement part close to a placeholder. When released near a placeholder, it snaps into place with a short animation.
|
10. Move the correct replacement part close to a placeholder. When released near a placeholder, it snaps into place with a short animation.
|
||||||
11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case.
|
11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case.
|
||||||
12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair.
|
12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair.
|
||||||
13. The exploded object animates back into its assembled form with completion particles, then moves to `done`.
|
13. The exploded object animates back into its assembled form with completion particles, then moves to `done` and restores player movement controls.
|
||||||
14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
|
14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
|
||||||
|
|
||||||
## Why It Matters
|
## Why It Matters
|
||||||
@@ -31,11 +31,11 @@ This feature validates the repair loop before a full mission system exists. It t
|
|||||||
|
|
||||||
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
|
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
|
||||||
|
|
||||||
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation. When the player is close enough, the existing case model floats upward and rotates gently to signal interactivity.
|
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation, player movement is locked while the repair sequence is active, and a small HTML indicator confirms that movement is temporarily unavailable. When the player is close enough, the existing case model floats upward and rotates gently to signal interactivity.
|
||||||
|
|
||||||
In `inspected`, `RepairGame` can also move to `fragmented`. The player can use the interaction key or hold both fists closed for one second. The hand-tracking path is state-based, so it does not depend on being inside a local object interaction radius.
|
In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius.
|
||||||
|
|
||||||
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, the repaired object remains visible with a completion target that plays the case exit animation before advancing the global mission progression.
|
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. Player movement stays locked through `inspected`, `fragmented`, `scanning`, `repairing`, and `reassembling`, while trigger interactions remain available. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, player movement is available again and the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression.
|
||||||
|
|
||||||
The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing.
|
The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing.
|
||||||
|
|
||||||
@@ -54,8 +54,10 @@ The mission config now carries the mission-specific variations. `bike` repairs o
|
|||||||
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
|
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
|
||||||
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
|
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
|
||||||
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
|
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
|
||||||
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` keyboard and hand-tracking input.
|
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML indicator shown while repair movement is locked.
|
||||||
|
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows.
|
||||||
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
|
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
|
||||||
|
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator.
|
||||||
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
|
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
|
||||||
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
|
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
|
||||||
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
|
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
|
||||||
@@ -86,7 +88,7 @@ Debug URL for state switching and inspection:
|
|||||||
http://localhost:5173/?debug
|
http://localhost:5173/?debug
|
||||||
```
|
```
|
||||||
|
|
||||||
The debug physics scene keeps the existing grab, trigger, and animated model tests, and also exposes separate `Bike`, `Pylone`, and `Farm` repair playground zones. Use the debug game-state panel to switch `mainState`; the matching repair zone mounts the same reusable `RepairGame` flow with that mission's model, broken parts, replacement parts, prompts, and timings.
|
The debug physics scene keeps the existing grab, trigger, and animated model tests, and also exposes separate `Bike`, `Pylone`, and `Farm` repair playground zones. Use the debug game-state panel to switch `mainState`; selecting a locked repair mission in that panel opens it at `waiting`, and the matching repair zone mounts the same reusable `RepairGame` flow with that mission's model, broken parts, replacement parts, prompts, and timings.
|
||||||
|
|
||||||
## Related Hand Tracking
|
## Related Hand Tracking
|
||||||
|
|
||||||
@@ -104,5 +106,5 @@ python -m backend.main
|
|||||||
- The reusable production `RepairGame` currently covers `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission`.
|
- The reusable production `RepairGame` currently covers `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission`.
|
||||||
- Mission progression is wired through Zustand using `completeMission` at the end of each repair.
|
- Mission progression is wired through Zustand using `completeMission` at the end of each repair.
|
||||||
- There is no central `GameManager` in this branch.
|
- There is no central `GameManager` in this branch.
|
||||||
- Hand tracking is available for the two-fists input and grabbable replacement parts; final installation still uses the shared `E` trigger path.
|
- Hand tracking is available for the two-fists input and grabbable repair parts; case interaction and final installation still use the shared `E` trigger path.
|
||||||
- The repair-game content is configured statically in `src/data/gameplay/`.
|
- The repair-game content is configured statically in `src/data/gameplay/`.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"cinematics": [
|
||||||
|
{
|
||||||
|
"id": "intro_overview",
|
||||||
|
"timecode": 0,
|
||||||
|
"dialogueCues": [
|
||||||
|
{
|
||||||
|
"time": 0,
|
||||||
|
"dialogueId": "narrateur_bienvenueaaltera"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cameraKeyframes": [
|
||||||
|
{
|
||||||
|
"time": 0,
|
||||||
|
"position": [8, 5, 12],
|
||||||
|
"target": [0, 2, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"time": 4,
|
||||||
|
"position": [12, 4, -6],
|
||||||
|
"target": [10, 1.4, -8]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"voices": [
|
||||||
|
{
|
||||||
|
"id": "narrateur",
|
||||||
|
"speaker": "Narrateur",
|
||||||
|
"subtitles": {
|
||||||
|
"fr": "/sounds/dialogue/subtitles/fr/narrateur.srt",
|
||||||
|
"en": "/sounds/dialogue/subtitles/en/narrateur.srt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fermier",
|
||||||
|
"speaker": "Fermier",
|
||||||
|
"subtitles": {
|
||||||
|
"fr": "/sounds/dialogue/subtitles/fr/fermier.srt",
|
||||||
|
"en": "/sounds/dialogue/subtitles/en/fermier.srt"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "electricienne",
|
||||||
|
"speaker": "Electricienne",
|
||||||
|
"subtitles": {
|
||||||
|
"fr": "/sounds/dialogue/subtitles/fr/electricienne.srt",
|
||||||
|
"en": "/sounds/dialogue/subtitles/en/electricienne.srt"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dialogues": [
|
||||||
|
{
|
||||||
|
"id": "narrateur_bienvenueaaltera",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
|
||||||
|
"subtitleCueIndex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_intro_prenom",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_intro_prenom.mp3",
|
||||||
|
"subtitleCueIndex": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_intro_apresprenom",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_intro_apresprenom.mp3",
|
||||||
|
"subtitleCueIndex": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_ordreebike",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_ordreebike.mp3",
|
||||||
|
"subtitleCueIndex": 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_ebikecasse",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_ebikecassé.mp3",
|
||||||
|
"subtitleCueIndex": 5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_galetscan",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_galetscan.mp3",
|
||||||
|
"subtitleCueIndex": 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_ebikerepare",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_ebikeréparé.mp3",
|
||||||
|
"subtitleCueIndex": 7
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_ordredemandedelaide",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_ordredemandedelaide.mp3",
|
||||||
|
"subtitleCueIndex": 8
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_coupureelec",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_coupureélec.mp3",
|
||||||
|
"subtitleCueIndex": 9
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_poteaueleccasse",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_poteauéleccassé.mp3",
|
||||||
|
"subtitleCueIndex": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_courantrepare",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_courantréparé.mp3",
|
||||||
|
"subtitleCueIndex": 11
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_routeversferme",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_routeversferme.mp3",
|
||||||
|
"subtitleCueIndex": 12
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_arriveferme",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_arrivéferme.mp3",
|
||||||
|
"subtitleCueIndex": 13
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_fouillelecentre",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_fouillelecentre.mp3",
|
||||||
|
"subtitleCueIndex": 14
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_interactiontuyauxlac",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_interactiontuyauxlac.mp3",
|
||||||
|
"subtitleCueIndex": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_interactionrefroidisseur",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_interactionrefroidisseur.mp3",
|
||||||
|
"subtitleCueIndex": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_refroidisseurcasse",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_refroidisseurcassé.mp3",
|
||||||
|
"subtitleCueIndex": 17
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_createurdepluiecree",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_createurdepluiecréé.mp3",
|
||||||
|
"subtitleCueIndex": 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_remerciement",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_remerciement.mp3",
|
||||||
|
"subtitleCueIndex": 19
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_bonnechance",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_bonnechance.mp3",
|
||||||
|
"subtitleCueIndex": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_presentationatelier",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_présentationatelier.mp3",
|
||||||
|
"subtitleCueIndex": 21
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_presentationoutils",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_présentationoutils.mp3",
|
||||||
|
"subtitleCueIndex": 22
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "narrateur_histoireelectricienne",
|
||||||
|
"voice": "narrateur",
|
||||||
|
"audio": "/sounds/dialogue/narrateur_histoireelectricienne.mp3",
|
||||||
|
"subtitleCueIndex": 23
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fermier_coupdemain",
|
||||||
|
"voice": "fermier",
|
||||||
|
"audio": "/sounds/dialogue/fermier_coupdemain.mp3",
|
||||||
|
"subtitleCueIndex": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fermier_coupdemain_2",
|
||||||
|
"voice": "fermier",
|
||||||
|
"audio": "/sounds/dialogue/fermier_coupdemain_2.mp3",
|
||||||
|
"subtitleCueIndex": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fermier_findemission",
|
||||||
|
"voice": "fermier",
|
||||||
|
"audio": "/sounds/dialogue/fermier_findemission.mp3",
|
||||||
|
"subtitleCueIndex": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# English Subtitle Fallback
|
||||||
|
|
||||||
|
English SRT files are intentionally optional for now.
|
||||||
|
|
||||||
|
The dialogue runtime first tries the selected subtitle language, then falls back to French. Missing English files should therefore remain validation warnings, not blocking errors, until the English translation workflow is ready.
|
||||||
|
|
||||||
|
Expected future files:
|
||||||
|
|
||||||
|
- `narrateur.srt`
|
||||||
|
- `fermier.srt`
|
||||||
|
- `electricienne.srt`
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:08,000
|
||||||
|
Hey!! How are you? Do you need help placing the rollers?
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:08,000
|
||||||
|
Don't hesitate if you need anything else!
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:00,000 --> 00:00:08,000
|
||||||
|
See you next time!
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:04,032
|
||||||
|
Wait, wait, young man! I'll give you a hand.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:03,744
|
||||||
|
I did puzzles all through my youth. Try this!
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:00,000 --> 00:00:07,104
|
||||||
|
If you need anything else, don't hesitate, my boy. I'm getting old, but my mind is still sharp, hehehe!
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:02,760
|
||||||
|
Hello there, future resident of Altera! Today, you are going to discover the technician role at La Fabrik, which handles Low-Tech technologies and repairs.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:11,592
|
||||||
|
Before we start, what's your name?
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:00,000 --> 00:00:10,824
|
||||||
|
Very good! We'll begin step by step to show you how the workshop works. Then you'll start your day and see the positive impact La Fabrik has on the community and the neighborhood.
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:00,000 --> 00:00:06,072
|
||||||
|
Let's go! You need to head to the farm, we're looking to improve something! Hop on your E-Bike.
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:00,000 --> 00:00:12,720
|
||||||
|
What? Your E-Bike is broken? Well, that's not too serious, it happens! Use the two rollers on your gloves. They're real technological gems. Place one under the bike, and one above it.
|
||||||
|
|
||||||
|
6
|
||||||
|
00:00:00,000 --> 00:00:08,064
|
||||||
|
So? Pretty amazing, right? Anyway, these rollers will scan the components to find out what we need to repair and/or replace.
|
||||||
|
|
||||||
|
7
|
||||||
|
00:00:00,000 --> 00:00:04,992
|
||||||
|
Perfect! The cooler gave out, you can replace it with one of the components from your pack. Aaaand there we go! It runs like clockwork! Go on, hurry!
|
||||||
|
|
||||||
|
8
|
||||||
|
00:00:00,000 --> 00:00:04,512
|
||||||
|
Don't hesitate to ask for help if you need it, everyone is super welcoming here.
|
||||||
|
|
||||||
|
9
|
||||||
|
00:00:00,000 --> 00:00:08,880
|
||||||
|
Oh woooow!! Did you see that???? All the traffic lights, computers and lights went out!! Hurry to the Energy Center, we can't even send repaired devices back out!
|
||||||
|
|
||||||
|
10
|
||||||
|
00:00:00,000 --> 00:00:09,840
|
||||||
|
Ah! A power pole fell down! Damn! Aaah, those little moles, they cause so much trouble... But they're so cuuuute!
|
||||||
|
|
||||||
|
11
|
||||||
|
00:00:00,000 --> 00:00:07,632
|
||||||
|
Woohoo! Great! Power is back across the whole neighborhood! Well done! Now head to the farm.
|
||||||
|
|
||||||
|
12
|
||||||
|
00:00:00,000 --> 00:00:05,352
|
||||||
|
Well, thanks to you I was able to finish my emergency! Oh, you're almost at the farm!
|
||||||
|
|
||||||
|
13
|
||||||
|
00:00:00,000 --> 00:00:11,760
|
||||||
|
Okay, enough of the emotional moment haha! For the farm, as I told you, we need to change the irrigation here. During drought periods, residents complain about an issue. See what you can do.
|
||||||
|
|
||||||
|
14
|
||||||
|
00:00:00,000 --> 00:00:04,560
|
||||||
|
Okay, perfect, you're there! Search the Center to find where the problem is coming from.
|
||||||
|
|
||||||
|
15
|
||||||
|
00:00:00,000 --> 00:00:06,864
|
||||||
|
Yeees! That's it! We'd like to stop pumping water from the lake, otherwise we'll drain all its reserves. What do you suggest?
|
||||||
|
|
||||||
|
16
|
||||||
|
00:00:00,000 --> 00:00:10,944
|
||||||
|
The old cooler from your E-Bike?? Yes!! Hahaha, great idea! Combined with the old lake pipes, we'll be able to make something cool! Put all that between your pads!
|
||||||
|
|
||||||
|
17
|
||||||
|
00:00:00,000 --> 00:00:05,712
|
||||||
|
The cooler from your E-Bike is broken, but we can still make something useful out of it.
|
||||||
|
|
||||||
|
18
|
||||||
|
00:00:00,000 --> 00:00:10,032
|
||||||
|
Ma-gni-fi-cent! I can see Gilbert helped you haha, he's such a sweetheart! You did a great job! And thanks to you, the neighborhood has been improved.
|
||||||
|
|
||||||
|
19
|
||||||
|
00:00:00,000 --> 00:00:11,520
|
||||||
|
Thank you so much for what you've brought to the community. The electrician and Gilbert really enjoyed helping you and told me they're looking forward to the next village party to get to know you better.
|
||||||
|
|
||||||
|
20
|
||||||
|
00:00:00,000 --> 00:00:02,352
|
||||||
|
Good luck! I've got work to do!
|
||||||
|
|
||||||
|
21
|
||||||
|
00:00:00,000 --> 00:00:33,600
|
||||||
|
Welcome to your workshop!! So? Pretty impressive, right? Okay, quick tour of what's here: this is your workbench. In the pipes are items from neighborhood residents that broke down and are waiting to be repaired. Once repaired, you put the item in this pipe and it goes back to the right person.
|
||||||
|
|
||||||
|
22
|
||||||
|
00:00:00,000 --> 00:00:14,760
|
||||||
|
Here, this is a dashboard. You can imagine that if your fridge or oven breaks down, you won't be able to put it in the pipe haha! So here, it tells you when residents have a bulky item that broke down, or when there's a problem in the city. Uh oh... I've got an emergency, I'll have to leave you soon! So here, take your tools to repair most things: a mini 3D printer powered by electronic waste, Push-Parts gloves to disassemble objects, and a Relaunch pack!
|
||||||
|
|
||||||
|
23
|
||||||
|
00:00:00,000 --> 00:00:54,000
|
||||||
|
The electrician helped you at the Power Plant? Aaaaah, that's what I love here: everyone helps each other, nobody judges anyone, it's like a real little family. You should know the electrician has quite a special story. She was born in the north of the continent, in the city of Kalska. She grew up happily with her mother Edith, her father Jordan, and her two little brothers, Malo and Justin. A few years ago, as you know, the northern countries were, quite unexpectedly, the first ones forced to migrate. So they began their journey, country by country, city by city, village by village. On a day of walking like so many others after several months, a climate storm caught them off guard. Having split up to find food in the village, her father and one of her two brothers sadly disappeared. It's tragic. But one day, they happened upon this place during their journey. We welcomed them with open arms, and they were slowly able to rebuild their lives among us. Today, they are an integral part of the community.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:08,000
|
||||||
|
Hey !! Comment ça va ? Tu as besoin d'aide pour poser les galets ?
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:08,000
|
||||||
|
N'hésite pas, si tu as besoin d'autre chose !
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:00,000 --> 00:00:08,000
|
||||||
|
À la prochaine !
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:04,032
|
||||||
|
Attendez attendez jeune homme ! Je vais vous filer un coup de main.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:03,744
|
||||||
|
J'ai fait des puzzles toute ma jeunesse. Essayez donc ça !
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:00,000 --> 00:00:07,104
|
||||||
|
Si vous avez besoin d'autre chose hésitez pas mon grand. Je me fais vieux mais j'ai encore toute ma tête hehehe !
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
1
|
||||||
|
00:00:00,000 --> 00:00:02,760
|
||||||
|
Bonjour à toi, futur habitant d'Altéra ! Aujourd'hui tu vas découvrir le rôle de technicien au sein de La Fabrik qui s'occupe des technologies et réparation Low-Tech.
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:00,000 --> 00:00:11,592
|
||||||
|
Avant de commencer, comment tu t'appelles ?
|
||||||
|
|
||||||
|
3
|
||||||
|
00:00:00,000 --> 00:00:10,824
|
||||||
|
Très bien ! On va commencer pas à pas pour te montrer comment fonctionne l'atelier. Ensuite, tu commenceras ta journée et tu pourras te rendre compte de l'impact positif qu'a la Fabrik sur la communauté et le quartier.
|
||||||
|
|
||||||
|
4
|
||||||
|
00:00:00,000 --> 00:00:06,072
|
||||||
|
Allez go ! Il faudrait que tu ailles à la ferme, on cherche à améliorer quelque chose ! Monte sur ton E-Bike.
|
||||||
|
|
||||||
|
5
|
||||||
|
00:00:00,000 --> 00:00:12,720
|
||||||
|
Quoi ? Ton E-Bike est cassé ? Bon c'est pas très grave, ça arrive ! Utilise les deux galets qui sont sur tes gants. Ce sont de véritables bijoux technologiques. Poses en un en-dessous du vélo, et un au-dessus.
|
||||||
|
|
||||||
|
6
|
||||||
|
00:00:00,000 --> 00:00:08,064
|
||||||
|
Alors ? Pas magnifique ça ? Enfin bref, ces galets vont scanner les composants pour savoir ce qu'on doit réparer et / ou changer.
|
||||||
|
|
||||||
|
7
|
||||||
|
00:00:00,000 --> 00:00:04,992
|
||||||
|
Parfait ! C'est le refroidisseur qui a lâché, tu peux le remplacer avec un des composants de ton pack. Eeeet voilà ! Il fonctionne comme une horloge ! Allez fonce !
|
||||||
|
|
||||||
|
8
|
||||||
|
00:00:00,000 --> 00:00:04,512
|
||||||
|
N'hésite pas à aller demander de l'aide si tu as besoin, tout le monde est super accueillant ici.
|
||||||
|
|
||||||
|
9
|
||||||
|
00:00:00,000 --> 00:00:08,880
|
||||||
|
Oh woooow !! T'as vu ça ???? Tous les feux, ordinateurs et lumières se sont éteints !! Faut vite que t'aille au Centre de l'Énergie, on ne peut même plus renvoyer les appareils réparés !
|
||||||
|
|
||||||
|
10
|
||||||
|
00:00:00,000 --> 00:00:09,840
|
||||||
|
Ah ! C'est un poteau d'alimentation qui est tombé ! Mince ! Alalaaa, ces petites taupes, elles en font des bêtises... Mais elles sont si chouuuu !
|
||||||
|
|
||||||
|
11
|
||||||
|
00:00:00,000 --> 00:00:07,632
|
||||||
|
Wouuuuhouuu ! Super ! Le courant est revenu dans tout le quartier ! Bien joué ! Allez, fonce à la ferme.
|
||||||
|
|
||||||
|
12
|
||||||
|
00:00:00,000 --> 00:00:05,352
|
||||||
|
Booon, grâce à toi j'ai pu finir mon urgence ! Oh mais t'arrives bientôt à la ferme !
|
||||||
|
|
||||||
|
13
|
||||||
|
00:00:00,000 --> 00:00:11,760
|
||||||
|
Bon, fini le moment émotion haha ! Pour la ferme, comme je te l'ai dis, ici, il faut qu'on change l'irrigation. Durant les périodes de sécheresse, les habitants se plaignent d'un souci. Vois ce que tu peux faire.
|
||||||
|
|
||||||
|
14
|
||||||
|
00:00:00,000 --> 00:00:04,560
|
||||||
|
Ok parfait tu y es ! Fouille le Centre pour voir d'où vient le problème.
|
||||||
|
|
||||||
|
15
|
||||||
|
00:00:00,000 --> 00:00:06,864
|
||||||
|
Ouiii ! C'est ça ! On aimerait ne plus pomper l'eau dans le lac, sinon on va épuiser toutes ses réserves. Qu'est-ce que tu proposes ?
|
||||||
|
|
||||||
|
16
|
||||||
|
00:00:00,000 --> 00:00:10,944
|
||||||
|
L'ancien refroidisseur de ton E-Bike ?? Mais oui !! Hahaha, très bonne idée ! Combiné aux anciens tuyaux du lac, on va pouvoir faire quelque chose de cool ! Met tout ça entre tes pads !
|
||||||
|
|
||||||
|
17
|
||||||
|
00:00:00,000 --> 00:00:05,712
|
||||||
|
Le refroidisseur de ton E-Bike est cassé, mais on peut encore en faire quelque chose d'utile.
|
||||||
|
|
||||||
|
18
|
||||||
|
00:00:00,000 --> 00:00:10,032
|
||||||
|
Ma-gni-fique ! Je vois que Gilbert t'as aidé haha, il est adorable celui-là ! Tu as fait du super boulot ! Et grâce à toi, le quartier est amélioré.
|
||||||
|
|
||||||
|
19
|
||||||
|
00:00:00,000 --> 00:00:11,520
|
||||||
|
Merci beaucoup pour ce que tu as apporté à la communauté. L'électricienne et Gilbert ont beaucoup apprécié t'aider et m'ont dit qu'ils avaient hâte de la prochaine fête de village pour mieux apprendre à te connaître.
|
||||||
|
|
||||||
|
20
|
||||||
|
00:00:00,000 --> 00:00:02,352
|
||||||
|
Allez bonne chance ! J'ai du boulot !
|
||||||
|
|
||||||
|
21
|
||||||
|
00:00:00,000 --> 00:00:33,600
|
||||||
|
Bienvenue dans ton atelier !! Alors ? Ça claque hein ? Bon je te présente en rapide tout ce qu'il y a : ici c'est ton plan de travail. Dans les tuyaux, ce sont des objets des résidents du quartier qui sont tombés en panne qui attendent d'être réparés. Une fois réparé, tu mets l'objet dans ce tuyau et ça repart chez la bonne personne.
|
||||||
|
|
||||||
|
22
|
||||||
|
00:00:00,000 --> 00:00:14,760
|
||||||
|
Ici, c'est un tableau de bord. T'imagines bien que si ton frigo ou ton four tombe en panne, tu ne vas pas pouvoir le mettre dans le tuyau haha ! Donc ici, ça te signale quand des résidents ont un objet volumineux tombé en panne, ou quand il y a un problème dans la ville. Oh oh... j'ai une urgence, il va bientôt falloir que je te laisse ! Donc tiens, tes outils pour pouvoir réparer la plupart des choses : une mini imprimante 3D à base de déchets électroniques, des gants Pousse Pièces pour désassembler les objets, ainsi qu'un pack de Relance !
|
||||||
|
|
||||||
|
23
|
||||||
|
00:00:00,000 --> 00:00:54,000
|
||||||
|
L'électricienne t'a aidé à la Centrale ? Aaaaah c'est ça que j'adore ici, tout le monde s'entraide, personne se juge, une vraie petite famille. Sache que l'électricienne a une histoire assez particulière. Elle est née au nord du continent, dans la ville de Kalska. Elle a grandit heureuse, avec sa mère Edith, son père Jordan et ses deux petits frères Malo et Justin. Il y a quelques années de ça, comme tu le sais, c'est les pays du Nord, qui par grande surprise, ont été obligés de migrer en premier. Ils ont alors entamé leur périple, pays par pays, ville par ville, village par village. Un jour de marche comme les autres depuis plusieurs mois, une tempête climatique les a pris de court. S'étant séparés pour trouver des vivres dans le village, le père et un des deux frères sont malheureusement partis. C'est tragique. Mais un beau jour, ils sont tombés ici, par hasard dans leur périple. On les a accueillis les bras ouverts et ils ont pu se reconstruire doucement parmi nous et font partie intégrante de la communauté aujourd'hui.
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
CinematicCameraKeyframe,
|
||||||
|
CinematicDefinition,
|
||||||
|
CinematicDialogueCue,
|
||||||
|
CinematicManifest,
|
||||||
|
} from "@/types/cinematics/cinematics";
|
||||||
|
import type {
|
||||||
|
DialogueDefinition,
|
||||||
|
DialogueManifest,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
|
||||||
|
type CinematicPatch = Partial<Omit<CinematicDefinition, "timecode">> & {
|
||||||
|
timecode?: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type VectorAxis = 0 | 1 | 2;
|
||||||
|
const VECTOR_AXES: { label: "X" | "Y" | "Z"; axis: VectorAxis }[] = [
|
||||||
|
{ label: "X", axis: 0 },
|
||||||
|
{ label: "Y", axis: 1 },
|
||||||
|
{ label: "Z", axis: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createCinematic(index: number): CinematicDefinition {
|
||||||
|
return {
|
||||||
|
id: `new_cinematic_${index}`,
|
||||||
|
cameraKeyframes: [
|
||||||
|
{ time: 0, position: [0, 3, 8], target: [0, 1.5, 0] },
|
||||||
|
{ time: 3, position: [6, 3, 8], target: [0, 1.5, 0] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createKeyframe(
|
||||||
|
previousKeyframe: CinematicCameraKeyframe,
|
||||||
|
): CinematicCameraKeyframe {
|
||||||
|
return {
|
||||||
|
time: previousKeyframe.time + 3,
|
||||||
|
position: [...previousKeyframe.position],
|
||||||
|
target: [...previousKeyframe.target],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDialogueCue(
|
||||||
|
dialogues: DialogueDefinition[],
|
||||||
|
previousCue: CinematicDialogueCue | null,
|
||||||
|
): CinematicDialogueCue {
|
||||||
|
return {
|
||||||
|
time: previousCue ? previousCue.time + 1 : 0,
|
||||||
|
dialogueId: dialogues[0]?.id ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManifestErrors(
|
||||||
|
manifest: CinematicManifest | null,
|
||||||
|
dialogueIds: Set<string>,
|
||||||
|
): string[] {
|
||||||
|
if (!manifest) return ["Manifeste absent."];
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
manifest.cinematics.forEach((cinematic, cinematicIndex) => {
|
||||||
|
const label = cinematic.id || `Cinematique ${cinematicIndex + 1}`;
|
||||||
|
|
||||||
|
if (!cinematic.id.trim()) errors.push(`${label}: id obligatoire.`);
|
||||||
|
if (ids.has(cinematic.id)) errors.push(`${label}: id duplique.`);
|
||||||
|
ids.add(cinematic.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
cinematic.timecode !== undefined &&
|
||||||
|
(!Number.isFinite(cinematic.timecode) || cinematic.timecode < 0)
|
||||||
|
) {
|
||||||
|
errors.push(`${label}: timecode invalide.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cinematic.cameraKeyframes.length < 2) {
|
||||||
|
errors.push(`${label}: au moins deux keyframes camera sont requises.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cinematic.cameraKeyframes.forEach((keyframe, keyframeIndex) => {
|
||||||
|
const previousKeyframe = cinematic.cameraKeyframes[keyframeIndex - 1];
|
||||||
|
|
||||||
|
if (!Number.isFinite(keyframe.time) || keyframe.time < 0) {
|
||||||
|
errors.push(`${label}: keyframe ${keyframeIndex + 1} time invalide.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||||
|
errors.push(`${label}: les temps des keyframes doivent augmenter.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cinematic.dialogueCues?.forEach((cue, cueIndex) => {
|
||||||
|
if (!Number.isFinite(cue.time) || cue.time < 0) {
|
||||||
|
errors.push(`${label}: dialogue cue ${cueIndex + 1} time invalide.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cue.dialogueId.trim()) {
|
||||||
|
errors.push(`${label}: dialogue cue ${cueIndex + 1} id obligatoire.`);
|
||||||
|
} else if (dialogueIds.size > 0 && !dialogueIds.has(cue.dialogueId)) {
|
||||||
|
errors.push(`${label}: dialogue cue ${cueIndex + 1} dialogue inconnu.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCinematicManifest(
|
||||||
|
manifest: CinematicManifest,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch("/api/save-cinematics", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(manifest),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
throw new Error(body?.error ?? "Sauvegarde des cinematics impossible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatchedCinematic(
|
||||||
|
cinematic: CinematicDefinition,
|
||||||
|
patch: CinematicPatch,
|
||||||
|
): CinematicDefinition {
|
||||||
|
const nextCinematic: CinematicDefinition = {
|
||||||
|
id: patch.id ?? cinematic.id,
|
||||||
|
cameraKeyframes: patch.cameraKeyframes ?? cinematic.cameraKeyframes,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dialogueCues = patch.dialogueCues ?? cinematic.dialogueCues;
|
||||||
|
if (dialogueCues) {
|
||||||
|
nextCinematic.dialogueCues = dialogueCues;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("timecode" in patch) {
|
||||||
|
if (patch.timecode !== undefined) nextCinematic.timecode = patch.timecode;
|
||||||
|
} else if (cinematic.timecode !== undefined) {
|
||||||
|
nextCinematic.timecode = cinematic.timecode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextCinematic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVector(
|
||||||
|
vector: Vector3Tuple,
|
||||||
|
axis: VectorAxis,
|
||||||
|
value: number,
|
||||||
|
): Vector3Tuple {
|
||||||
|
const nextVector: Vector3Tuple = [...vector];
|
||||||
|
nextVector[axis] = value;
|
||||||
|
return nextVector;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorCinematicManifestPanelProps {
|
||||||
|
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorCinematicManifestPanel({
|
||||||
|
onPreviewCinematic,
|
||||||
|
}: EditorCinematicManifestPanelProps): React.JSX.Element {
|
||||||
|
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||||
|
const [dialogueManifest, setDialogueManifest] =
|
||||||
|
useState<DialogueManifest | null>(null);
|
||||||
|
const [selectedCinematicId, setSelectedCinematicId] = useState("");
|
||||||
|
const [status, setStatus] = useState("Chargement des cinematics...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const dialogueIds = new Set(
|
||||||
|
dialogueManifest?.dialogues.map((dialogue) => dialogue.id) ?? [],
|
||||||
|
);
|
||||||
|
const errors = getManifestErrors(manifest, dialogueIds);
|
||||||
|
const selectedCinematic =
|
||||||
|
manifest?.cinematics.find(
|
||||||
|
(cinematic) => cinematic.id === selectedCinematicId,
|
||||||
|
) ??
|
||||||
|
manifest?.cinematics[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
async function handleLoad(): Promise<void> {
|
||||||
|
setStatus("Chargement des cinematics...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [loadedManifest, loadedDialogueManifest] = await Promise.all([
|
||||||
|
loadCinematicManifest(),
|
||||||
|
loadDialogueManifest(),
|
||||||
|
]);
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setDialogueManifest(loadedDialogueManifest);
|
||||||
|
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
|
||||||
|
setStatus(
|
||||||
|
loadedManifest
|
||||||
|
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
|
||||||
|
: "Manifeste cinematics introuvable ou invalide.",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
setManifest(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!manifest) return;
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setStatus("Corrige les erreurs avant de sauvegarder.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Sauvegarde des cinematics...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveCinematicManifest(manifest);
|
||||||
|
setStatus("Manifeste sauvegarde dans public/cinematics.json.");
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddCinematic(): void {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const cinematic = createCinematic(manifest.cinematics.length + 1);
|
||||||
|
setManifest({
|
||||||
|
...manifest,
|
||||||
|
cinematics: [...manifest.cinematics, cinematic],
|
||||||
|
});
|
||||||
|
setSelectedCinematicId(cinematic.id);
|
||||||
|
setStatus("Nouvelle cinematic ajoutee localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveCinematic(cinematicId: string): void {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const nextCinematics = manifest.cinematics.filter(
|
||||||
|
(cinematic) => cinematic.id !== cinematicId,
|
||||||
|
);
|
||||||
|
setManifest({ ...manifest, cinematics: nextCinematics });
|
||||||
|
setSelectedCinematicId(nextCinematics[0]?.id ?? "");
|
||||||
|
setStatus("Cinematic supprimee localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedCinematic(
|
||||||
|
patch: CinematicPatch,
|
||||||
|
nextId = selectedCinematicId,
|
||||||
|
): void {
|
||||||
|
if (!manifest || !selectedCinematic) return;
|
||||||
|
|
||||||
|
setManifest({
|
||||||
|
...manifest,
|
||||||
|
cinematics: manifest.cinematics.map((cinematic) =>
|
||||||
|
cinematic.id === selectedCinematic.id
|
||||||
|
? getPatchedCinematic(cinematic, patch)
|
||||||
|
: cinematic,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setSelectedCinematicId(nextId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKeyframe(
|
||||||
|
keyframeIndex: number,
|
||||||
|
patch: Partial<CinematicCameraKeyframe>,
|
||||||
|
): void {
|
||||||
|
if (!selectedCinematic) return;
|
||||||
|
|
||||||
|
updateSelectedCinematic({
|
||||||
|
cameraKeyframes: selectedCinematic.cameraKeyframes.map(
|
||||||
|
(keyframe, index) =>
|
||||||
|
index === keyframeIndex ? { ...keyframe, ...patch } : keyframe,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddKeyframe(): void {
|
||||||
|
if (!selectedCinematic) return;
|
||||||
|
|
||||||
|
const previousKeyframe =
|
||||||
|
selectedCinematic.cameraKeyframes[
|
||||||
|
selectedCinematic.cameraKeyframes.length - 1
|
||||||
|
];
|
||||||
|
if (!previousKeyframe) return;
|
||||||
|
|
||||||
|
updateSelectedCinematic({
|
||||||
|
cameraKeyframes: [
|
||||||
|
...selectedCinematic.cameraKeyframes,
|
||||||
|
createKeyframe(previousKeyframe),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setStatus("Keyframe ajoutee localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveKeyframe(keyframeIndex: number): void {
|
||||||
|
if (!selectedCinematic) return;
|
||||||
|
|
||||||
|
updateSelectedCinematic({
|
||||||
|
cameraKeyframes: selectedCinematic.cameraKeyframes.filter(
|
||||||
|
(_keyframe, index) => index !== keyframeIndex,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setStatus("Keyframe supprimee localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDialogueCue(
|
||||||
|
cueIndex: number,
|
||||||
|
patch: Partial<CinematicDialogueCue>,
|
||||||
|
): void {
|
||||||
|
if (!selectedCinematic) return;
|
||||||
|
|
||||||
|
const dialogueCues = selectedCinematic.dialogueCues ?? [];
|
||||||
|
updateSelectedCinematic({
|
||||||
|
dialogueCues: dialogueCues.map((cue, index) =>
|
||||||
|
index === cueIndex ? { ...cue, ...patch } : cue,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddDialogueCue(): void {
|
||||||
|
if (!selectedCinematic) return;
|
||||||
|
|
||||||
|
const dialogueCues = selectedCinematic.dialogueCues ?? [];
|
||||||
|
const previousCue = dialogueCues[dialogueCues.length - 1] ?? null;
|
||||||
|
updateSelectedCinematic({
|
||||||
|
dialogueCues: [
|
||||||
|
...dialogueCues,
|
||||||
|
createDialogueCue(dialogueManifest?.dialogues ?? [], previousCue),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setStatus("Dialogue cue ajoutee localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveDialogueCue(cueIndex: number): void {
|
||||||
|
if (!selectedCinematic) return;
|
||||||
|
|
||||||
|
updateSelectedCinematic({
|
||||||
|
dialogueCues: (selectedCinematic.dialogueCues ?? []).filter(
|
||||||
|
(_cue, index) => index !== cueIndex,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setStatus("Dialogue cue supprimee localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
void Promise.all([loadCinematicManifest(), loadDialogueManifest()])
|
||||||
|
.then(([loadedManifest, loadedDialogueManifest]) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setDialogueManifest(loadedDialogueManifest);
|
||||||
|
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
|
||||||
|
setStatus(
|
||||||
|
loadedManifest
|
||||||
|
? `Manifeste charge: ${loadedManifest.cinematics.length} cinematics.`
|
||||||
|
: "Manifeste cinematics introuvable ou invalide.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
setManifest(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="editor-cinematic-manifest-section"
|
||||||
|
aria-labelledby="cinematic-manifest-heading"
|
||||||
|
>
|
||||||
|
<div className="editor-section-heading">
|
||||||
|
<h3 id="cinematic-manifest-heading">Cinematics</h3>
|
||||||
|
<span>{manifest?.cinematics.length ?? 0} items</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-cinematic-manifest-actions">
|
||||||
|
<button type="button" onClick={() => void handleLoad()}>
|
||||||
|
<RefreshCw size={14} aria-hidden="true" />
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!manifest} onClick={handleAddCinematic}>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!manifest || errors.length > 0 || isSaving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
<Save size={14} aria-hidden="true" />
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manifest && (
|
||||||
|
<label className="editor-cinematic-manifest-select">
|
||||||
|
Cinematic
|
||||||
|
<select
|
||||||
|
value={selectedCinematic?.id ?? ""}
|
||||||
|
onChange={(event) => setSelectedCinematicId(event.target.value)}
|
||||||
|
>
|
||||||
|
{manifest.cinematics.map((cinematic) => (
|
||||||
|
<option key={cinematic.id} value={cinematic.id}>
|
||||||
|
{cinematic.id || "Cinematic sans id"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedCinematic && (
|
||||||
|
<div className="editor-cinematic-manifest-form">
|
||||||
|
<label>
|
||||||
|
ID
|
||||||
|
<input
|
||||||
|
value={selectedCinematic.id}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedCinematic(
|
||||||
|
{ id: event.target.value },
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Timecode global optionnel
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={selectedCinematic.timecode ?? ""}
|
||||||
|
placeholder="Aucun"
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value.trim();
|
||||||
|
updateSelectedCinematic({
|
||||||
|
timecode: value === "" ? undefined : Number(value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="editor-cinematic-keyframes">
|
||||||
|
<div className="editor-cinematic-keyframes-heading">
|
||||||
|
<strong>Camera keyframes</strong>
|
||||||
|
<button type="button" onClick={handleAddKeyframe}>
|
||||||
|
<Plus size={13} aria-hidden="true" />
|
||||||
|
Add keyframe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCinematic.cameraKeyframes.map(
|
||||||
|
(keyframe, keyframeIndex) => (
|
||||||
|
<div
|
||||||
|
className="editor-cinematic-keyframe"
|
||||||
|
key={`${selectedCinematic.id}-${keyframeIndex}`}
|
||||||
|
>
|
||||||
|
<div className="editor-cinematic-keyframe-heading">
|
||||||
|
<strong>Keyframe {keyframeIndex + 1}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={selectedCinematic.cameraKeyframes.length <= 2}
|
||||||
|
onClick={() => handleRemoveKeyframe(keyframeIndex)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Time
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={keyframe.time}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateKeyframe(keyframeIndex, {
|
||||||
|
time: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<VectorInputs
|
||||||
|
label="Position"
|
||||||
|
value={keyframe.position}
|
||||||
|
onChange={(axis, value) =>
|
||||||
|
updateKeyframe(keyframeIndex, {
|
||||||
|
position: updateVector(keyframe.position, axis, value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<VectorInputs
|
||||||
|
label="Target"
|
||||||
|
value={keyframe.target}
|
||||||
|
onChange={(axis, value) =>
|
||||||
|
updateKeyframe(keyframeIndex, {
|
||||||
|
target: updateVector(keyframe.target, axis, value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-cinematic-dialogue-cues">
|
||||||
|
<div className="editor-cinematic-dialogue-cues-heading">
|
||||||
|
<strong>Dialogue cues</strong>
|
||||||
|
<button type="button" onClick={handleAddDialogueCue}>
|
||||||
|
<Plus size={13} aria-hidden="true" />
|
||||||
|
Add dialogue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(selectedCinematic.dialogueCues ?? []).length === 0 ? (
|
||||||
|
<p>Aucun dialogue synchronise avec cette cinematic.</p>
|
||||||
|
) : (
|
||||||
|
(selectedCinematic.dialogueCues ?? []).map((cue, cueIndex) => (
|
||||||
|
<div
|
||||||
|
className="editor-cinematic-dialogue-cue"
|
||||||
|
key={`${selectedCinematic.id}-dialogue-${cueIndex}`}
|
||||||
|
>
|
||||||
|
<div className="editor-cinematic-dialogue-cue-heading">
|
||||||
|
<strong>Dialogue {cueIndex + 1}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveDialogueCue(cueIndex)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Time
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={cue.time}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDialogueCue(cueIndex, {
|
||||||
|
time: Number(event.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Dialogue
|
||||||
|
<select
|
||||||
|
value={cue.dialogueId}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDialogueCue(cueIndex, {
|
||||||
|
dialogueId: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dialogueManifest?.dialogues.length ? (
|
||||||
|
dialogueManifest.dialogues.map((dialogue) => (
|
||||||
|
<option key={dialogue.id} value={dialogue.id}>
|
||||||
|
{dialogue.id}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<option value={cue.dialogueId}>
|
||||||
|
{cue.dialogueId || "Aucun dialogue disponible"}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-cinematic-manifest-preview"
|
||||||
|
type="button"
|
||||||
|
disabled={errors.length > 0 || !onPreviewCinematic}
|
||||||
|
onClick={() => onPreviewCinematic?.(selectedCinematic)}
|
||||||
|
>
|
||||||
|
<Play size={14} aria-hidden="true" />
|
||||||
|
Preview cinematic
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-cinematic-manifest-delete"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveCinematic(selectedCinematic.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete cinematic
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="editor-cinematic-manifest-status">{status}</p>
|
||||||
|
<div
|
||||||
|
className={`editor-cinematic-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{errors.length === 0
|
||||||
|
? "Manifeste local valide."
|
||||||
|
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
|
||||||
|
</strong>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{errors.map((error) => (
|
||||||
|
<li key={error}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VectorInputsProps {
|
||||||
|
label: string;
|
||||||
|
value: Vector3Tuple;
|
||||||
|
onChange: (axis: VectorAxis, value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VectorInputs({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: VectorInputsProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className="editor-cinematic-vector-inputs">
|
||||||
|
<span>{label}</span>
|
||||||
|
{VECTOR_AXES.map(({ label: axisLabel, axis }) => (
|
||||||
|
<label key={axisLabel}>
|
||||||
|
{axisLabel}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
value={value[axis]}
|
||||||
|
onChange={(event) => onChange(axis, Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Undo2,
|
Undo2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinematicManifestPanel";
|
||||||
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
|
|
||||||
interface EditorControlsProps {
|
interface EditorControlsProps {
|
||||||
@@ -28,6 +32,7 @@ interface EditorControlsProps {
|
|||||||
onExportJson: () => void;
|
onExportJson: () => void;
|
||||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||||
onPlayerMode?: (() => void) | undefined;
|
onPlayerMode?: (() => void) | undefined;
|
||||||
|
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +64,7 @@ export function EditorControls({
|
|||||||
onExportJson,
|
onExportJson,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
onPlayerMode,
|
onPlayerMode,
|
||||||
|
onPreviewCinematic,
|
||||||
isPlayerMode,
|
isPlayerMode,
|
||||||
}: EditorControlsProps): React.JSX.Element {
|
}: EditorControlsProps): React.JSX.Element {
|
||||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||||
@@ -236,6 +242,10 @@ export function EditorControls({
|
|||||||
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||||
|
<EditorDialogueManifestPanel />
|
||||||
|
<EditorSrtPanel />
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Play, Plus, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
DialogueDefinition,
|
||||||
|
DialogueManifest,
|
||||||
|
DialogueSpeaker,
|
||||||
|
DialogueVoiceId,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
|
const DEFAULT_VOICE: DialogueVoiceId = "narrateur";
|
||||||
|
type DialoguePatch = Partial<Omit<DialogueDefinition, "timecode">> & {
|
||||||
|
timecode?: number | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createDialogue(
|
||||||
|
index: number,
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): DialogueDefinition {
|
||||||
|
return {
|
||||||
|
id: `new_dialogue_${index}`,
|
||||||
|
voice,
|
||||||
|
audio: `/sounds/dialogue/new_dialogue_${index}.mp3`,
|
||||||
|
subtitleCueIndex: getNextCueIndex(manifest, voice),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextCueIndex(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): number {
|
||||||
|
const cueIndexes = manifest.dialogues
|
||||||
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
|
.map((dialogue) => dialogue.subtitleCueIndex);
|
||||||
|
|
||||||
|
return Math.max(0, ...cueIndexes) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVoiceSpeaker(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): DialogueSpeaker {
|
||||||
|
return (
|
||||||
|
manifest.voices.find((item) => item.id === voice)?.speaker ?? "Narrateur"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFrenchSrtPath(voice: DialogueVoiceId): string {
|
||||||
|
return `/sounds/dialogue/subtitles/fr/${voice}.srt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSrtCueBlock(cueIndex: number, speaker: DialogueSpeaker): string {
|
||||||
|
return `${cueIndex}\n00:00:00,000 --> 00:00:02,000\n${speaker}: Nouveau sous-titre ${cueIndex} a definir`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSrtCueIfMissing(
|
||||||
|
content: string,
|
||||||
|
cueIndex: number,
|
||||||
|
speaker: DialogueSpeaker,
|
||||||
|
): string {
|
||||||
|
const cues = parseSrt(content);
|
||||||
|
if (cues.some((cue) => cue.index === cueIndex)) return content;
|
||||||
|
|
||||||
|
const trimmedContent = content.trim();
|
||||||
|
const cueBlock = createSrtCueBlock(cueIndex, speaker);
|
||||||
|
return trimmedContent
|
||||||
|
? `${trimmedContent}\n\n${cueBlock}\n`
|
||||||
|
: `${cueBlock}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSrtFile(
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch("/api/save-srt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ voice, language: "fr", content }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFrenchSrtCue(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
): Promise<void> {
|
||||||
|
const srtPath = getFrenchSrtPath(dialogue.voice);
|
||||||
|
const response = await fetch(srtPath);
|
||||||
|
const content = response.ok ? await response.text() : "";
|
||||||
|
const nextContent = appendSrtCueIfMissing(
|
||||||
|
content,
|
||||||
|
dialogue.subtitleCueIndex,
|
||||||
|
getVoiceSpeaker(manifest, dialogue.voice),
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveSrtFile(dialogue.voice, nextContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getManifestErrors(manifest: DialogueManifest | null): string[] {
|
||||||
|
if (!manifest) return ["Manifeste absent."];
|
||||||
|
|
||||||
|
const errors: string[] = [];
|
||||||
|
const ids = new Set<string>();
|
||||||
|
|
||||||
|
manifest.dialogues.forEach((dialogue, index) => {
|
||||||
|
const label = dialogue.id || `Dialogue ${index + 1}`;
|
||||||
|
|
||||||
|
if (!dialogue.id.trim()) errors.push(`${label}: id obligatoire.`);
|
||||||
|
if (ids.has(dialogue.id)) errors.push(`${label}: id duplique.`);
|
||||||
|
ids.add(dialogue.id);
|
||||||
|
|
||||||
|
if (!dialogue.audio.startsWith("/sounds/dialogue/")) {
|
||||||
|
errors.push(`${label}: audio doit commencer par /sounds/dialogue/.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(dialogue.subtitleCueIndex)) {
|
||||||
|
errors.push(`${label}: cue SRT invalide.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dialogue.timecode !== undefined &&
|
||||||
|
(!Number.isFinite(dialogue.timecode) || dialogue.timecode < 0)
|
||||||
|
) {
|
||||||
|
errors.push(`${label}: timecode invalide.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDialogueManifest(manifest: DialogueManifest): Promise<void> {
|
||||||
|
const response = await fetch("/api/save-dialogues", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(manifest),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
throw new Error(body?.error ?? "Sauvegarde du manifeste impossible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPatchedDialogue(
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
patch: DialoguePatch,
|
||||||
|
): DialogueDefinition {
|
||||||
|
const nextDialogue: DialogueDefinition = {
|
||||||
|
id: patch.id ?? dialogue.id,
|
||||||
|
voice: patch.voice ?? dialogue.voice,
|
||||||
|
audio: patch.audio ?? dialogue.audio,
|
||||||
|
subtitleCueIndex: patch.subtitleCueIndex ?? dialogue.subtitleCueIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ("timecode" in patch) {
|
||||||
|
if (patch.timecode !== undefined) nextDialogue.timecode = patch.timecode;
|
||||||
|
} else if (dialogue.timecode !== undefined) {
|
||||||
|
nextDialogue.timecode = dialogue.timecode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextDialogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorDialogueManifestPanel(): React.JSX.Element {
|
||||||
|
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||||
|
const [selectedDialogueId, setSelectedDialogueId] = useState("");
|
||||||
|
const [status, setStatus] = useState("Chargement du manifeste...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isPreviewing, setIsPreviewing] = useState(false);
|
||||||
|
const [isCreatingSrtCue, setIsCreatingSrtCue] = useState(false);
|
||||||
|
const errors = getManifestErrors(manifest);
|
||||||
|
const selectedDialogue =
|
||||||
|
manifest?.dialogues.find(
|
||||||
|
(dialogue) => dialogue.id === selectedDialogueId,
|
||||||
|
) ??
|
||||||
|
manifest?.dialogues[0] ??
|
||||||
|
null;
|
||||||
|
const voices = manifest?.voices ?? [];
|
||||||
|
|
||||||
|
async function handleLoad(): Promise<void> {
|
||||||
|
setStatus("Chargement du manifeste...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loadedManifest = await loadDialogueManifest();
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
|
||||||
|
setStatus(
|
||||||
|
loadedManifest
|
||||||
|
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
|
||||||
|
: "Manifeste introuvable ou invalide.",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
setManifest(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!manifest) return;
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setStatus("Corrige les erreurs avant de sauvegarder.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Sauvegarde du manifeste...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveDialogueManifest(manifest);
|
||||||
|
setStatus(
|
||||||
|
"Manifeste sauvegarde dans public/sounds/dialogue/dialogues.json.",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddDialogue(): Promise<void> {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const voice = selectedDialogue?.voice ?? DEFAULT_VOICE;
|
||||||
|
const dialogue = createDialogue(
|
||||||
|
manifest.dialogues.length + 1,
|
||||||
|
manifest,
|
||||||
|
voice,
|
||||||
|
);
|
||||||
|
const nextManifest = {
|
||||||
|
...manifest,
|
||||||
|
dialogues: [...manifest.dialogues, dialogue],
|
||||||
|
};
|
||||||
|
|
||||||
|
setManifest(nextManifest);
|
||||||
|
setSelectedDialogueId(dialogue.id);
|
||||||
|
setIsCreatingSrtCue(true);
|
||||||
|
setStatus("Nouveau dialogue ajoute localement. Creation de la cue FR...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFrenchSrtCue(nextManifest, dialogue);
|
||||||
|
setStatus(
|
||||||
|
`Nouveau dialogue ajoute avec cue FR ${dialogue.subtitleCueIndex}. Sauvegarde le manifeste pour le garder.`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(
|
||||||
|
`Dialogue ajoute localement, mais cue FR non creee: ${message}`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingSrtCue(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemoveDialogue(dialogueId: string): void {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const nextDialogues = manifest.dialogues.filter(
|
||||||
|
(dialogue) => dialogue.id !== dialogueId,
|
||||||
|
);
|
||||||
|
setManifest({ ...manifest, dialogues: nextDialogues });
|
||||||
|
setSelectedDialogueId(nextDialogues[0]?.id ?? "");
|
||||||
|
setStatus("Dialogue supprime localement.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSelectedDialogue(
|
||||||
|
patch: DialoguePatch,
|
||||||
|
nextId = selectedDialogueId,
|
||||||
|
): void {
|
||||||
|
if (!manifest || !selectedDialogue) return;
|
||||||
|
|
||||||
|
setManifest({
|
||||||
|
...manifest,
|
||||||
|
dialogues: manifest.dialogues.map((dialogue) =>
|
||||||
|
dialogue.id === selectedDialogue.id
|
||||||
|
? getPatchedDialogue(dialogue, patch)
|
||||||
|
: dialogue,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
setSelectedDialogueId(nextId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePreviewDialogue(): Promise<void> {
|
||||||
|
if (!manifest || !selectedDialogue) return;
|
||||||
|
if (errors.length > 0) {
|
||||||
|
setStatus("Corrige les erreurs avant de lancer la preview.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewAudioRef.current?.pause();
|
||||||
|
previewAudioRef.current = null;
|
||||||
|
setIsPreviewing(true);
|
||||||
|
setStatus(`Preview dialogue: ${selectedDialogue.id}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audio = await playDialogueById(manifest, selectedDialogue.id);
|
||||||
|
previewAudioRef.current = audio;
|
||||||
|
|
||||||
|
if (!audio) {
|
||||||
|
setStatus("Dialogue introuvable pour la preview.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFinish = (): void => {
|
||||||
|
audio.removeEventListener("ended", handleFinish);
|
||||||
|
audio.removeEventListener("pause", handleFinish);
|
||||||
|
if (previewAudioRef.current === audio) previewAudioRef.current = null;
|
||||||
|
setIsPreviewing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("ended", handleFinish);
|
||||||
|
audio.addEventListener("pause", handleFinish);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
setIsPreviewing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateFrenchSrtCue(): Promise<void> {
|
||||||
|
if (!manifest || !selectedDialogue) return;
|
||||||
|
|
||||||
|
setIsCreatingSrtCue(true);
|
||||||
|
setStatus(`Creation de la cue FR ${selectedDialogue.subtitleCueIndex}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createFrenchSrtCue(manifest, selectedDialogue);
|
||||||
|
setStatus(`Cue FR ${selectedDialogue.subtitleCueIndex} prete.`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
} finally {
|
||||||
|
setIsCreatingSrtCue(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
void loadDialogueManifest()
|
||||||
|
.then((loadedManifest) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setManifest(loadedManifest);
|
||||||
|
setSelectedDialogueId(loadedManifest?.dialogues[0]?.id ?? "");
|
||||||
|
setStatus(
|
||||||
|
loadedManifest
|
||||||
|
? `Manifeste charge: ${loadedManifest.dialogues.length} dialogues.`
|
||||||
|
: "Manifeste introuvable ou invalide.",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(message);
|
||||||
|
setManifest(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
previewAudioRef.current?.pause();
|
||||||
|
previewAudioRef.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="editor-dialogue-manifest-section"
|
||||||
|
aria-labelledby="dialogue-manifest-heading"
|
||||||
|
>
|
||||||
|
<div className="editor-section-heading">
|
||||||
|
<h3 id="dialogue-manifest-heading">Dialogues</h3>
|
||||||
|
<span>{manifest?.dialogues.length ?? 0} items</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-dialogue-manifest-actions">
|
||||||
|
<button type="button" onClick={() => void handleLoad()}>
|
||||||
|
<RefreshCw size={14} aria-hidden="true" />
|
||||||
|
Reload
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!manifest || isCreatingSrtCue}
|
||||||
|
onClick={() => void handleAddDialogue()}
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
{isCreatingSrtCue ? "Adding..." : "Add"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!manifest || errors.length > 0 || isSaving}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
<Save size={14} aria-hidden="true" />
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{manifest && (
|
||||||
|
<label className="editor-dialogue-manifest-select">
|
||||||
|
Dialogue
|
||||||
|
<select
|
||||||
|
value={selectedDialogue?.id ?? ""}
|
||||||
|
onChange={(event) => setSelectedDialogueId(event.target.value)}
|
||||||
|
>
|
||||||
|
{manifest.dialogues.map((dialogue) => (
|
||||||
|
<option key={dialogue.id} value={dialogue.id}>
|
||||||
|
{dialogue.id || "Dialogue sans id"}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedDialogue && (
|
||||||
|
<div className="editor-dialogue-manifest-form">
|
||||||
|
<label>
|
||||||
|
ID
|
||||||
|
<input
|
||||||
|
value={selectedDialogue.id}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedDialogue(
|
||||||
|
{ id: event.target.value },
|
||||||
|
event.target.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Voix
|
||||||
|
<select
|
||||||
|
value={selectedDialogue.voice}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedDialogue({
|
||||||
|
voice: event.target.value as DialogueVoiceId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{voices.map((voice) => (
|
||||||
|
<option key={voice.id} value={voice.id}>
|
||||||
|
{voice.speaker}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Audio
|
||||||
|
<input
|
||||||
|
value={selectedDialogue.audio}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedDialogue({ audio: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Cue SRT
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={selectedDialogue.subtitleCueIndex}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSelectedDialogue({
|
||||||
|
subtitleCueIndex: Math.max(1, Number(event.target.value)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Timecode global optionnel
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="0.1"
|
||||||
|
value={selectedDialogue.timecode ?? ""}
|
||||||
|
placeholder="Aucun"
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value.trim();
|
||||||
|
updateSelectedDialogue({
|
||||||
|
timecode: value === "" ? undefined : Number(value),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-dialogue-manifest-srt-cue"
|
||||||
|
type="button"
|
||||||
|
disabled={isCreatingSrtCue}
|
||||||
|
onClick={() => void handleCreateFrenchSrtCue()}
|
||||||
|
>
|
||||||
|
<Plus size={14} aria-hidden="true" />
|
||||||
|
{isCreatingSrtCue ? "Creating..." : "Create FR SRT cue"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-dialogue-manifest-preview"
|
||||||
|
type="button"
|
||||||
|
disabled={errors.length > 0 || isPreviewing}
|
||||||
|
onClick={() => void handlePreviewDialogue()}
|
||||||
|
>
|
||||||
|
<Play size={14} aria-hidden="true" />
|
||||||
|
{isPreviewing ? "Playing..." : "Preview dialogue"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="editor-dialogue-manifest-delete"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveDialogue(selectedDialogue.id)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete dialogue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="editor-dialogue-manifest-status">{status}</p>
|
||||||
|
<div
|
||||||
|
className={`editor-dialogue-manifest-diagnostic ${errors.length === 0 ? "is-valid" : "is-invalid"}`}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{errors.length === 0
|
||||||
|
? "Manifeste local valide."
|
||||||
|
: `${errors.length} erreur${errors.length > 1 ? "s" : ""} locale${errors.length > 1 ? "s" : ""}.`}
|
||||||
|
</strong>
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{errors.map((error) => (
|
||||||
|
<li key={error}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,743 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Download, RefreshCw, Save } from "lucide-react";
|
||||||
|
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||||
|
import type {
|
||||||
|
DialogueDefinition,
|
||||||
|
DialogueManifest,
|
||||||
|
DialogueSpeaker,
|
||||||
|
DialogueVoiceId,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
|
interface SrtVoiceOption {
|
||||||
|
id: DialogueVoiceId;
|
||||||
|
label: DialogueSpeaker;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SrtDiagnostic {
|
||||||
|
cueCount: number;
|
||||||
|
expectedCueCount: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextRange {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogueValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CueTimeEdge = "start" | "end";
|
||||||
|
const CUE_NUDGE_SECONDS = 0.1;
|
||||||
|
|
||||||
|
const SRT_VOICES: SrtVoiceOption[] = [
|
||||||
|
{ id: "narrateur", label: "Narrateur" },
|
||||||
|
{ id: "fermier", label: "Fermier" },
|
||||||
|
{ id: "electricienne", label: "Electricienne" },
|
||||||
|
];
|
||||||
|
const DEFAULT_SRT_VOICE: SrtVoiceOption = {
|
||||||
|
id: "narrateur",
|
||||||
|
label: "Narrateur",
|
||||||
|
};
|
||||||
|
|
||||||
|
const SRT_LANGUAGES: SubtitleLanguage[] = ["fr", "en"];
|
||||||
|
const SRT_TIME_LINE_PATTERN =
|
||||||
|
/^\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}$/;
|
||||||
|
|
||||||
|
function getSrtPath(
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): string {
|
||||||
|
return `/sounds/dialogue/subtitles/${language}/${voice}.srt`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSrtTemplate(
|
||||||
|
speaker: DialogueSpeaker,
|
||||||
|
expectedCueIndexes: number[],
|
||||||
|
): string {
|
||||||
|
const cueIndexes = expectedCueIndexes.length > 0 ? expectedCueIndexes : [1];
|
||||||
|
|
||||||
|
return `${cueIndexes
|
||||||
|
.map((cueIndex, index) => {
|
||||||
|
const startTime = index * 3;
|
||||||
|
const endTime = startTime + 2;
|
||||||
|
|
||||||
|
return `${cueIndex}\n${formatSrtTime(startTime)} --> ${formatSrtTime(endTime)}\n${speaker}: Sous-titre ${cueIndex} a definir`;
|
||||||
|
})
|
||||||
|
.join("\n\n")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSrtTime(totalSeconds: number): string {
|
||||||
|
const safeSeconds = Math.max(0, totalSeconds);
|
||||||
|
const totalMilliseconds = Math.round(safeSeconds * 1000);
|
||||||
|
const milliseconds = totalMilliseconds % 1000;
|
||||||
|
const totalWholeSeconds = Math.floor(totalMilliseconds / 1000);
|
||||||
|
const hours = Math.floor(totalWholeSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalWholeSeconds % 3600) / 60);
|
||||||
|
const seconds = totalWholeSeconds % 60;
|
||||||
|
|
||||||
|
return `${padTime(hours)}:${padTime(minutes)}:${padTime(seconds)},${padMilliseconds(milliseconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPreviewTime(totalSeconds: number): string {
|
||||||
|
return `${Math.max(0, totalSeconds).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSrtTime(value: string): number | null {
|
||||||
|
const match = value.match(/^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, hours, minutes, seconds, milliseconds] = match;
|
||||||
|
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Number(hours) * 3600 +
|
||||||
|
Number(minutes) * 60 +
|
||||||
|
Number(seconds) +
|
||||||
|
Number(milliseconds) / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function padTime(value: number): string {
|
||||||
|
return value.toString().padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function padMilliseconds(value: number): string {
|
||||||
|
return value.toString().padStart(3, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSrtDiagnostic(
|
||||||
|
content: string,
|
||||||
|
expectedCueIndexes: number[],
|
||||||
|
): SrtDiagnostic {
|
||||||
|
const normalizedContent = content.replace(/^\uFEFF/, "").replace(/\r/g, "");
|
||||||
|
const blocks = normalizedContent
|
||||||
|
.trim()
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const cues = parseSrt(content);
|
||||||
|
const errors: string[] = [];
|
||||||
|
const indexes = new Set<number>();
|
||||||
|
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
errors.push("Le fichier SRT est vide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks.forEach((block, blockIndex) => {
|
||||||
|
const lines = block
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const displayIndex = blockIndex + 1;
|
||||||
|
const cueIndex = Number(lines[0]);
|
||||||
|
|
||||||
|
if (lines.length < 3) {
|
||||||
|
errors.push(
|
||||||
|
`Bloc ${displayIndex}: il manque un index, un timecode ou un texte.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(cueIndex)) {
|
||||||
|
errors.push(`Bloc ${displayIndex}: l'index doit etre un nombre entier.`);
|
||||||
|
} else if (indexes.has(cueIndex)) {
|
||||||
|
errors.push(`Bloc ${displayIndex}: l'index ${cueIndex} est duplique.`);
|
||||||
|
} else {
|
||||||
|
indexes.add(cueIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SRT_TIME_LINE_PATTERN.test(lines[1] ?? "")) {
|
||||||
|
errors.push(
|
||||||
|
`Bloc ${displayIndex}: le timecode doit utiliser HH:MM:SS,mmm --> HH:MM:SS,mmm.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (blocks.length > 0 && cues.length !== blocks.length) {
|
||||||
|
errors.push(
|
||||||
|
"Un ou plusieurs blocs ont une duree invalide ou un timecode illisible.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cueIndexes = new Set(cues.map((cue) => cue.index));
|
||||||
|
const missingCueIndexes = expectedCueIndexes.filter(
|
||||||
|
(cueIndex) => !cueIndexes.has(cueIndex),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (missingCueIndexes.length > 0) {
|
||||||
|
errors.push(
|
||||||
|
`Cues attendues par le manifeste manquantes: ${missingCueIndexes.join(", ")}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cueCount: cues.length,
|
||||||
|
expectedCueCount: expectedCueIndexes.length,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpectedCueIndexes(
|
||||||
|
manifest: DialogueManifest | null,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): number[] {
|
||||||
|
return getExpectedDialogues(manifest, voice)
|
||||||
|
.map((dialogue) => dialogue.subtitleCueIndex)
|
||||||
|
.filter(
|
||||||
|
(cueIndex, index, cueIndexes) => cueIndexes.indexOf(cueIndex) === index,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a - b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpectedDialogues(
|
||||||
|
manifest: DialogueManifest | null,
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
): DialogueDefinition[] {
|
||||||
|
if (!manifest) return [];
|
||||||
|
|
||||||
|
return [...manifest.dialogues]
|
||||||
|
.filter((dialogue) => dialogue.voice === voice)
|
||||||
|
.sort((a, b) => a.subtitleCueIndex - b.subtitleCueIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCueBlockRange(
|
||||||
|
content: string,
|
||||||
|
cueIndex: number,
|
||||||
|
): TextRange | null {
|
||||||
|
const normalizedContent = content.replace(/\r/g, "");
|
||||||
|
const cuePattern = new RegExp(`(^|\\n)${cueIndex}\\n`, "m");
|
||||||
|
const match = normalizedContent.match(cuePattern);
|
||||||
|
|
||||||
|
if (!match || match.index === undefined) return null;
|
||||||
|
|
||||||
|
const start = match.index + (match[1] ? 1 : 0);
|
||||||
|
const nextBlockIndex = normalizedContent.indexOf("\n\n", start);
|
||||||
|
const end = nextBlockIndex === -1 ? normalizedContent.length : nextBlockIndex;
|
||||||
|
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCueTimecode(
|
||||||
|
content: string,
|
||||||
|
cueIndex: number,
|
||||||
|
edge: CueTimeEdge,
|
||||||
|
time: number,
|
||||||
|
): string | null {
|
||||||
|
const range = findCueBlockRange(content, cueIndex);
|
||||||
|
if (!range) return null;
|
||||||
|
|
||||||
|
const block = content.slice(range.start, range.end);
|
||||||
|
const lines = block.split("\n");
|
||||||
|
const timecodeLine = lines[1];
|
||||||
|
if (!timecodeLine) return null;
|
||||||
|
|
||||||
|
const [start, end] = timecodeLine.split(" --> ");
|
||||||
|
if (!start || !end) return null;
|
||||||
|
|
||||||
|
lines[1] =
|
||||||
|
edge === "start"
|
||||||
|
? `${formatSrtTime(time)} --> ${end}`
|
||||||
|
: `${start} --> ${formatSrtTime(time)}`;
|
||||||
|
|
||||||
|
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nudgeCueTimecode(
|
||||||
|
content: string,
|
||||||
|
cueIndex: number,
|
||||||
|
delta: number,
|
||||||
|
): string | null {
|
||||||
|
const range = findCueBlockRange(content, cueIndex);
|
||||||
|
if (!range) return null;
|
||||||
|
|
||||||
|
const block = content.slice(range.start, range.end);
|
||||||
|
const lines = block.split("\n");
|
||||||
|
const timecodeLine = lines[1];
|
||||||
|
if (!timecodeLine) return null;
|
||||||
|
|
||||||
|
const [start, end] = timecodeLine.split(" --> ");
|
||||||
|
if (!start || !end) return null;
|
||||||
|
|
||||||
|
const startTime = parseSrtTime(start);
|
||||||
|
const endTime = parseSrtTime(end);
|
||||||
|
if (startTime === null || endTime === null) return null;
|
||||||
|
|
||||||
|
const nextStartTime = Math.max(0, startTime + delta);
|
||||||
|
const nextEndTime = Math.max(nextStartTime + 0.001, endTime + delta);
|
||||||
|
lines[1] = `${formatSrtTime(nextStartTime)} --> ${formatSrtTime(nextEndTime)}`;
|
||||||
|
|
||||||
|
return `${content.slice(0, range.start)}${lines.join("\n")}${content.slice(range.end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSrtFile(
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
content: string,
|
||||||
|
): void {
|
||||||
|
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${voice}.${language}.srt`;
|
||||||
|
link.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSrtFile(
|
||||||
|
voice: DialogueVoiceId,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
content: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await fetch("/api/save-srt", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ voice, language, content }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = (await response.json().catch(() => null)) as {
|
||||||
|
error?: string;
|
||||||
|
} | null;
|
||||||
|
throw new Error(body?.error ?? "Sauvegarde SRT impossible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
|
||||||
|
const response = await fetch("/api/validate-dialogues");
|
||||||
|
const body = (await response.json().catch(() => null)) as
|
||||||
|
| Partial<DialogueValidationResult>
|
||||||
|
| { error?: string }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
throw new Error("Validation dialogues impossible");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
"valid" in body &&
|
||||||
|
typeof body.valid === "boolean" &&
|
||||||
|
Array.isArray(body.errors) &&
|
||||||
|
Array.isArray(body.warnings)
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
valid: body.valid,
|
||||||
|
errors: body.errors.filter((item) => typeof item === "string"),
|
||||||
|
warnings: body.warnings.filter((item) => typeof item === "string"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"error" in body && body.error
|
||||||
|
? body.error
|
||||||
|
: "Validation dialogues impossible",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorSrtPanel(): React.JSX.Element {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const [voice, setVoice] = useState<DialogueVoiceId>("narrateur");
|
||||||
|
const [language, setLanguage] = useState<SubtitleLanguage>("fr");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [status, setStatus] = useState("Chargement du SRT...");
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isValidatingDialogues, setIsValidatingDialogues] = useState(false);
|
||||||
|
const [dialogueValidationResult, setDialogueValidationResult] =
|
||||||
|
useState<DialogueValidationResult | null>(null);
|
||||||
|
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||||
|
const [audioCurrentTime, setAudioCurrentTime] = useState(0);
|
||||||
|
const [selectedDialogueId, setSelectedDialogueId] = useState("");
|
||||||
|
const selectedVoice =
|
||||||
|
SRT_VOICES.find((item) => item.id === voice) ?? DEFAULT_SRT_VOICE;
|
||||||
|
const expectedDialogues = getExpectedDialogues(manifest, voice);
|
||||||
|
const expectedCueIndexes = getExpectedCueIndexes(manifest, voice);
|
||||||
|
const parsedCues = parseSrt(content);
|
||||||
|
const activeCue =
|
||||||
|
parsedCues.find(
|
||||||
|
(cue) =>
|
||||||
|
audioCurrentTime >= cue.startTime && audioCurrentTime < cue.endTime,
|
||||||
|
) ?? null;
|
||||||
|
const diagnostic = getSrtDiagnostic(content, expectedCueIndexes);
|
||||||
|
const isSrtValid = diagnostic.errors.length === 0;
|
||||||
|
const dialogueValidationClass = dialogueValidationResult
|
||||||
|
? dialogueValidationResult.valid
|
||||||
|
? "is-valid"
|
||||||
|
: "is-invalid"
|
||||||
|
: "is-idle";
|
||||||
|
const srtTemplate = createSrtTemplate(
|
||||||
|
selectedVoice.label,
|
||||||
|
expectedCueIndexes,
|
||||||
|
);
|
||||||
|
const selectedDialogue =
|
||||||
|
expectedDialogues.find((dialogue) => dialogue.id === selectedDialogueId) ??
|
||||||
|
expectedDialogues[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
async function handleSave(): Promise<void> {
|
||||||
|
if (!isSrtValid) {
|
||||||
|
setStatus("Corrige les erreurs SRT avant de sauvegarder.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setStatus("Sauvegarde du SRT...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveSrtFile(voice, language, content);
|
||||||
|
setStatus(`Sauvegarde dans ${getSrtPath(voice, language)}`);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(`${message}. Utilise Export SRT si le serveur dev est absent.`);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleValidateDialogues(): Promise<void> {
|
||||||
|
setIsValidatingDialogues(true);
|
||||||
|
setDialogueValidationResult(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateDialogueAssets();
|
||||||
|
setDialogueValidationResult(result);
|
||||||
|
setStatus(
|
||||||
|
result.valid
|
||||||
|
? "Validation dialogues terminee."
|
||||||
|
: "Validation dialogues terminee avec erreurs.",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setStatus(`${message}. Verifie que le serveur Vite est lance.`);
|
||||||
|
} finally {
|
||||||
|
setIsValidatingDialogues(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJumpToCue(cueIndex: number): void {
|
||||||
|
const range = findCueBlockRange(content, cueIndex);
|
||||||
|
|
||||||
|
if (!range || !textareaRef.current) {
|
||||||
|
setStatus(`Cue ${cueIndex} introuvable dans le SRT.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textareaRef.current.focus();
|
||||||
|
textareaRef.current.setSelectionRange(range.start, range.end);
|
||||||
|
setStatus(`Cue ${cueIndex} selectionnee dans le SRT.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSetCueTime(cueIndex: number, edge: CueTimeEdge): void {
|
||||||
|
const updatedContent = updateCueTimecode(
|
||||||
|
content,
|
||||||
|
cueIndex,
|
||||||
|
edge,
|
||||||
|
audioCurrentTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updatedContent) {
|
||||||
|
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(updatedContent);
|
||||||
|
setStatus(
|
||||||
|
`Cue ${cueIndex}: ${edge === "start" ? "debut" : "fin"} place a ${formatSrtTime(audioCurrentTime)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNudgeCue(cueIndex: number, delta: number): void {
|
||||||
|
const updatedContent = nudgeCueTimecode(content, cueIndex, delta);
|
||||||
|
|
||||||
|
if (!updatedContent) {
|
||||||
|
setStatus(`Cue ${cueIndex} introuvable ou timecode invalide.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(updatedContent);
|
||||||
|
setStatus(
|
||||||
|
`Cue ${cueIndex} decalee de ${delta > 0 ? "+" : ""}${delta.toFixed(1)}s.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
void loadDialogueManifest()
|
||||||
|
.then((loadedManifest) => {
|
||||||
|
if (mounted) setManifest(loadedManifest);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (mounted) setManifest(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const srtPath = getSrtPath(voice, language);
|
||||||
|
|
||||||
|
void fetch(srtPath)
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setContent(srtTemplate);
|
||||||
|
setStatus("Fichier absent, template local cree");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent(await response.text());
|
||||||
|
setStatus(`Charge depuis ${srtPath}`);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
setContent(srtTemplate);
|
||||||
|
setStatus("Erreur de chargement, template local cree");
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [language, selectedVoice.label, srtTemplate, voice]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="editor-srt-section" aria-labelledby="srt-heading">
|
||||||
|
<div className="editor-section-heading">
|
||||||
|
<h3 id="srt-heading">SRT</h3>
|
||||||
|
<span>{language.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-srt-controls">
|
||||||
|
<label>
|
||||||
|
Voix
|
||||||
|
<select
|
||||||
|
value={voice}
|
||||||
|
onChange={(event) =>
|
||||||
|
setVoice(event.target.value as DialogueVoiceId)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{SRT_VOICES.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Langue
|
||||||
|
<select
|
||||||
|
value={language}
|
||||||
|
onChange={(event) =>
|
||||||
|
setLanguage(event.target.value as SubtitleLanguage)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{SRT_LANGUAGES.map((item) => (
|
||||||
|
<option key={item} value={item}>
|
||||||
|
{item.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="editor-srt-preview">
|
||||||
|
<label>
|
||||||
|
Dialogue audio
|
||||||
|
<select
|
||||||
|
value={selectedDialogue?.id ?? ""}
|
||||||
|
onChange={(event) => setSelectedDialogueId(event.target.value)}
|
||||||
|
disabled={expectedDialogues.length === 0}
|
||||||
|
>
|
||||||
|
{expectedDialogues.length === 0 && (
|
||||||
|
<option value="">Aucun dialogue</option>
|
||||||
|
)}
|
||||||
|
{expectedDialogues.map((dialogue) => (
|
||||||
|
<option key={dialogue.id} value={dialogue.id}>
|
||||||
|
Cue {dialogue.subtitleCueIndex} - {dialogue.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedDialogue && (
|
||||||
|
<div className="editor-srt-audio-card">
|
||||||
|
<span>Cue {selectedDialogue.subtitleCueIndex}</span>
|
||||||
|
<strong>{selectedDialogue.id}</strong>
|
||||||
|
<audio
|
||||||
|
key={selectedDialogue.audio}
|
||||||
|
controls
|
||||||
|
src={selectedDialogue.audio}
|
||||||
|
onLoadedMetadata={() => setAudioCurrentTime(0)}
|
||||||
|
onTimeUpdate={(event) =>
|
||||||
|
setAudioCurrentTime(event.currentTarget.currentTime)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="editor-srt-active-cue">
|
||||||
|
<span>Temps audio: {formatPreviewTime(audioCurrentTime)}</span>
|
||||||
|
{activeCue ? (
|
||||||
|
<p>
|
||||||
|
<strong>Cue {activeCue.index}</strong> {activeCue.text}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>Aucune cue active a ce moment.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="editor-srt-time-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleSetCueTime(selectedDialogue.subtitleCueIndex, "start")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Set start
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleSetCueTime(selectedDialogue.subtitleCueIndex, "end")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Set end
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleNudgeCue(
|
||||||
|
selectedDialogue.subtitleCueIndex,
|
||||||
|
-CUE_NUDGE_SECONDS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
-100ms
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleNudgeCue(
|
||||||
|
selectedDialogue.subtitleCueIndex,
|
||||||
|
CUE_NUDGE_SECONDS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+100ms
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="editor-srt-jump-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleJumpToCue(selectedDialogue.subtitleCueIndex)}
|
||||||
|
>
|
||||||
|
Aller a la cue {selectedDialogue.subtitleCueIndex}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="editor-srt-textarea"
|
||||||
|
value={content}
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => setContent(event.target.value)}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
aria-label="SRT content"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="editor-srt-actions">
|
||||||
|
<button
|
||||||
|
className="editor-action-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setContent(srtTemplate)}
|
||||||
|
>
|
||||||
|
<RefreshCw size={15} aria-hidden="true" />
|
||||||
|
Template
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="editor-action-button editor-action-button-primary"
|
||||||
|
type="button"
|
||||||
|
disabled={isSaving || !isSrtValid}
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
>
|
||||||
|
<Save size={15} aria-hidden="true" />
|
||||||
|
{isSaving ? "Saving..." : "Save SRT"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="editor-action-button"
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadSrtFile(voice, language, content)}
|
||||||
|
>
|
||||||
|
<Download size={15} aria-hidden="true" />
|
||||||
|
Export SRT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="editor-srt-status">{status}</p>
|
||||||
|
<div className={`editor-dialogue-validation ${dialogueValidationClass}`}>
|
||||||
|
<div className="editor-dialogue-validation__heading">
|
||||||
|
<div>
|
||||||
|
<strong>Manifeste dialogues</strong>
|
||||||
|
<span>Audio, SRT FR et cues references</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={isValidatingDialogues}
|
||||||
|
onClick={() => void handleValidateDialogues()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} aria-hidden="true" />
|
||||||
|
{isValidatingDialogues ? "Validation..." : "Validate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialogueValidationResult && (
|
||||||
|
<div className="editor-dialogue-validation__result">
|
||||||
|
<p>
|
||||||
|
{dialogueValidationResult.valid
|
||||||
|
? "Manifeste valide."
|
||||||
|
: `${dialogueValidationResult.errors.length} erreur${dialogueValidationResult.errors.length > 1 ? "s" : ""} detectee${dialogueValidationResult.errors.length > 1 ? "s" : ""}.`}
|
||||||
|
{dialogueValidationResult.warnings.length > 0 &&
|
||||||
|
` ${dialogueValidationResult.warnings.length} warning${dialogueValidationResult.warnings.length > 1 ? "s" : ""}.`}
|
||||||
|
</p>
|
||||||
|
{dialogueValidationResult.errors.length > 0 && (
|
||||||
|
<ul className="editor-dialogue-validation__errors">
|
||||||
|
{dialogueValidationResult.errors.map((error, index) => (
|
||||||
|
<li key={`${error}-${index}`}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{dialogueValidationResult.warnings.length > 0 && (
|
||||||
|
<ul className="editor-dialogue-validation__warnings">
|
||||||
|
{dialogueValidationResult.warnings.map((warning, index) => (
|
||||||
|
<li key={`${warning}-${index}`}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`editor-srt-diagnostic ${isSrtValid ? "is-valid" : "is-invalid"}`}
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
{isSrtValid
|
||||||
|
? `${diagnostic.cueCount} cue${diagnostic.cueCount > 1 ? "s" : ""} valide${diagnostic.cueCount > 1 ? "s" : ""} / ${diagnostic.expectedCueCount} attendue${diagnostic.expectedCueCount > 1 ? "s" : ""}`
|
||||||
|
: `${diagnostic.errors.length} erreur${diagnostic.errors.length > 1 ? "s" : ""} SRT`}
|
||||||
|
</strong>
|
||||||
|
{!isSrtValid && (
|
||||||
|
<ul>
|
||||||
|
{diagnostic.errors.map((error) => (
|
||||||
|
<li key={error}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { OrbitControls } from "@react-three/drei";
|
||||||
|
import { useThree } from "@react-three/fiber";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import * as THREE from "three";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||||
|
|
||||||
|
export interface EditorCinematicPreviewRequest {
|
||||||
|
id: string;
|
||||||
|
cinematic: CinematicDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
interface EditorSceneProps {
|
interface EditorSceneProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
@@ -18,6 +27,8 @@ interface EditorSceneProps {
|
|||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
|
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||||
|
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditorScene({
|
export function EditorScene({
|
||||||
@@ -34,7 +45,11 @@ export function EditorScene({
|
|||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
isPlayerMode = false,
|
isPlayerMode = false,
|
||||||
|
cinematicPreviewRequest = null,
|
||||||
|
onCinematicPreviewComplete,
|
||||||
}: EditorSceneProps): React.JSX.Element {
|
}: EditorSceneProps): React.JSX.Element {
|
||||||
|
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -74,10 +89,16 @@ export function EditorScene({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<EditorCinematicPreviewPlayer
|
||||||
|
request={cinematicPreviewRequest}
|
||||||
|
onComplete={onCinematicPreviewComplete}
|
||||||
|
/>
|
||||||
|
|
||||||
{isPlayerMode ? (
|
{isPlayerMode ? (
|
||||||
<FlyController disabled={false} />
|
<FlyController disabled={isCinematicPreviewing} />
|
||||||
) : (
|
) : (
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
|
enabled={!isCinematicPreviewing}
|
||||||
enableDamping
|
enableDamping
|
||||||
dampingFactor={0.05}
|
dampingFactor={0.05}
|
||||||
mouseButtons={{
|
mouseButtons={{
|
||||||
@@ -106,3 +127,76 @@ export function EditorScene({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EditorCinematicPreviewPlayerProps {
|
||||||
|
request: EditorCinematicPreviewRequest | null;
|
||||||
|
onComplete?: (() => void) | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditorCinematicPreviewPlayer({
|
||||||
|
request,
|
||||||
|
onComplete,
|
||||||
|
}: EditorCinematicPreviewPlayerProps): null {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
timelineRef.current?.kill();
|
||||||
|
timelineRef.current = null;
|
||||||
|
|
||||||
|
if (!request) return undefined;
|
||||||
|
|
||||||
|
const firstKeyframe = request.cinematic.cameraKeyframes[0];
|
||||||
|
if (!firstKeyframe) return undefined;
|
||||||
|
|
||||||
|
const target = new THREE.Vector3(...firstKeyframe.target);
|
||||||
|
camera.position.set(...firstKeyframe.position);
|
||||||
|
camera.lookAt(target);
|
||||||
|
|
||||||
|
const timeline = gsap.timeline({
|
||||||
|
onUpdate: () => camera.lookAt(target),
|
||||||
|
onComplete: () => {
|
||||||
|
timelineRef.current = null;
|
||||||
|
onComplete?.();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
request.cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
|
||||||
|
const previousKeyframe = request.cinematic.cameraKeyframes[index];
|
||||||
|
if (!previousKeyframe) return;
|
||||||
|
|
||||||
|
const duration = keyframe.time - previousKeyframe.time;
|
||||||
|
timeline.to(
|
||||||
|
camera.position,
|
||||||
|
{
|
||||||
|
x: keyframe.position[0],
|
||||||
|
y: keyframe.position[1],
|
||||||
|
z: keyframe.position[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
previousKeyframe.time,
|
||||||
|
);
|
||||||
|
timeline.to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
x: keyframe.target[0],
|
||||||
|
y: keyframe.target[1],
|
||||||
|
z: keyframe.target[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
previousKeyframe.time,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
timeline.kill();
|
||||||
|
if (timelineRef.current === timeline) timelineRef.current = null;
|
||||||
|
};
|
||||||
|
}, [camera, onComplete, request]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||||
|
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
||||||
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||||
|
|
||||||
interface RepairCompletionStepProps {
|
interface RepairCompletionStepProps {
|
||||||
@@ -14,28 +16,43 @@ export function RepairCompletionStep({
|
|||||||
config,
|
config,
|
||||||
onComplete,
|
onComplete,
|
||||||
}: RepairCompletionStepProps): React.JSX.Element {
|
}: RepairCompletionStepProps): React.JSX.Element {
|
||||||
const [isCompleting, setIsCompleting] = useState(false);
|
const [isClosingCase, setIsClosingCase] = useState(false);
|
||||||
|
const [isExitingCase, setIsExitingCase] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isClosingCase) return undefined;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIsExitingCase(true);
|
||||||
|
}, REPAIR_CASE_ANIMATION_DURATION * 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isClosingCase]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<RepairMissionCase
|
<RepairMissionCase
|
||||||
config={config}
|
config={config}
|
||||||
exiting={isCompleting}
|
exiting={isExitingCase}
|
||||||
|
open={!isClosingCase}
|
||||||
onExitComplete={onComplete}
|
onExitComplete={onComplete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RepairObjectModel
|
<RepairObjectModel
|
||||||
label={config.label}
|
label={config.label}
|
||||||
modelPath={config.modelPath}
|
modelPath={config.modelPath}
|
||||||
scale={1}
|
scale={config.modelScale ?? 1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isCompleting ? (
|
{!isClosingCase ? (
|
||||||
<TriggerObject
|
<TriggerObject
|
||||||
position={[0, 1.1, 0]}
|
position={[0, 1.1, 0]}
|
||||||
colliders="ball"
|
colliders="ball"
|
||||||
label={`Valider ${config.label}`}
|
label={`Valider ${config.label}`}
|
||||||
onTrigger={() => setIsCompleting(true)}
|
radius={REPAIR_INTERACTION_RADIUS}
|
||||||
|
onTrigger={() => setIsClosingCase(true)}
|
||||||
>
|
>
|
||||||
<mesh>
|
<mesh>
|
||||||
<torusGeometry args={[1.35, 0.045, 12, 96]} />
|
<torusGeometry args={[1.35, 0.045, 12, 96]} />
|
||||||
@@ -48,7 +65,7 @@ export function RepairCompletionStep({
|
|||||||
</TriggerObject>
|
</TriggerObject>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!isCompleting ? (
|
{!isClosingCase ? (
|
||||||
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
|
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
|
||||||
) : null}
|
) : null}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import {
|
|||||||
} from "@/data/gameplay/repairMissions";
|
} from "@/data/gameplay/repairMissions";
|
||||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type {
|
||||||
|
MissionStep,
|
||||||
|
RepairMissionId,
|
||||||
|
} from "@/types/gameplay/repairMission";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||||
import { toVector3Scale } from "@/utils/three/scale";
|
import { toVector3Scale } from "@/utils/three/scale";
|
||||||
@@ -75,6 +78,19 @@ export function RepairGame({
|
|||||||
onFragment: () => setMissionStep(mission, "fragmented"),
|
onFragment: () => setMissionStep(mission, "fragmented"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mainState === mission && shouldKeepRepairRuntimeState(step)) return;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setCasePlaceholders([]);
|
||||||
|
setScannedBrokenParts([]);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [mainState, mission, step]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mainState !== mission) return undefined;
|
if (mainState !== mission) return undefined;
|
||||||
|
|
||||||
@@ -106,7 +122,11 @@ export function RepairGame({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{step === "fragmented" ? (
|
{step === "fragmented" ? (
|
||||||
<ExplodableModel modelPath={config.modelPath} split />
|
<ExplodableModel
|
||||||
|
modelPath={config.modelPath}
|
||||||
|
scale={config.modelScale ?? 1}
|
||||||
|
split
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{step === "scanning" ? (
|
{step === "scanning" ? (
|
||||||
<RepairScanSequence
|
<RepairScanSequence
|
||||||
@@ -156,6 +176,10 @@ export function RepairGame({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||||
|
return step === "repairing" || step === "reassembling" || step === "done";
|
||||||
|
}
|
||||||
|
|
||||||
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||||
return [
|
return [
|
||||||
...new Set([
|
...new Set([
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||||
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
@@ -20,14 +21,15 @@ export function RepairInspectionObject({
|
|||||||
kind="trigger"
|
kind="trigger"
|
||||||
label={`Inspecter ${config.label}`}
|
label={`Inspecter ${config.label}`}
|
||||||
position={worldPosition}
|
position={worldPosition}
|
||||||
|
radius={REPAIR_INTERACTION_RADIUS}
|
||||||
onPress={onInspect}
|
onPress={onInspect}
|
||||||
>
|
>
|
||||||
<RepairObjectModel
|
<RepairObjectModel
|
||||||
label={config.label}
|
label={config.label}
|
||||||
modelPath={config.modelPath}
|
modelPath={config.modelPath}
|
||||||
scale={0.9}
|
scale={config.modelScale ?? 0.9}
|
||||||
/>
|
/>
|
||||||
<RepairPromptVideo src={config.interactUiPath} />
|
<RepairPromptVideo src={config.stageUiPath} />
|
||||||
</InteractableObject>
|
</InteractableObject>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
REPAIR_CASE_FOCUS_SCALE,
|
REPAIR_CASE_FOCUS_SCALE,
|
||||||
REPAIR_CASE_MODEL_PATH,
|
REPAIR_CASE_MODEL_PATH,
|
||||||
} from "@/data/gameplay/repairCaseConfig";
|
} from "@/data/gameplay/repairCaseConfig";
|
||||||
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ export function RepairMissionCase({
|
|||||||
position={casePosition}
|
position={casePosition}
|
||||||
colliders="ball"
|
colliders="ball"
|
||||||
label={`Ouvrir ${config.label}`}
|
label={`Ouvrir ${config.label}`}
|
||||||
|
radius={REPAIR_INTERACTION_RADIUS}
|
||||||
onTrigger={onInteract}
|
onTrigger={onInteract}
|
||||||
>
|
>
|
||||||
<RepairCaseModel
|
<RepairCaseModel
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export function RepairReassemblyStep({
|
|||||||
<group>
|
<group>
|
||||||
<ExplodableModel
|
<ExplodableModel
|
||||||
modelPath={config.modelPath}
|
modelPath={config.modelPath}
|
||||||
|
scale={config.modelScale ?? 1}
|
||||||
split={split}
|
split={split}
|
||||||
splitDistance={1.2}
|
splitDistance={1.2}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
|
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
|
||||||
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
|
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
|
||||||
} from "@/data/gameplay/repairCaseConfig";
|
} from "@/data/gameplay/repairCaseConfig";
|
||||||
|
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||||
import type {
|
import type {
|
||||||
RepairMissionConfig,
|
RepairMissionConfig,
|
||||||
RepairMissionPartConfig,
|
RepairMissionPartConfig,
|
||||||
@@ -299,6 +300,7 @@ function RepairInstallTarget({
|
|||||||
position={INSTALL_TARGET_POSITION}
|
position={INSTALL_TARGET_POSITION}
|
||||||
colliders="ball"
|
colliders="ball"
|
||||||
label={label}
|
label={label}
|
||||||
|
radius={REPAIR_INTERACTION_RADIUS}
|
||||||
onTrigger={() => {
|
onTrigger={() => {
|
||||||
if (!isReadyToInstall) {
|
if (!isReadyToInstall) {
|
||||||
onBlocked();
|
onBlocked();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export function RepairScanSequence({
|
|||||||
<group>
|
<group>
|
||||||
<ExplodableModel
|
<ExplodableModel
|
||||||
modelPath={config.modelPath}
|
modelPath={config.modelPath}
|
||||||
|
scale={config.modelScale ?? 1}
|
||||||
split
|
split
|
||||||
onPartsReady={setParts}
|
onPartsReady={setParts}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { Vector3Tuple } from "@/types/three/three";
|
|||||||
interface InteractableObjectBaseProps {
|
interface InteractableObjectBaseProps {
|
||||||
label: string;
|
label: string;
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
|
radius?: number;
|
||||||
bodyRef?: RefObject<RapierRigidBody | null>;
|
bodyRef?: RefObject<RapierRigidBody | null>;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -64,7 +65,15 @@ function createInteractableHandle(
|
|||||||
export function InteractableObject(
|
export function InteractableObject(
|
||||||
props: InteractableObjectProps,
|
props: InteractableObjectProps,
|
||||||
): React.JSX.Element {
|
): React.JSX.Element {
|
||||||
const { kind, label, position, bodyRef, onPress, children } = props;
|
const {
|
||||||
|
kind,
|
||||||
|
label,
|
||||||
|
position,
|
||||||
|
radius = INTERACTION_RADIUS,
|
||||||
|
bodyRef,
|
||||||
|
onPress,
|
||||||
|
children,
|
||||||
|
} = props;
|
||||||
const onRelease = props.kind === "grab" ? props.onRelease : null;
|
const onRelease = props.kind === "grab" ? props.onRelease : null;
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
@@ -156,7 +165,7 @@ export function InteractableObject(
|
|||||||
|
|
||||||
camera.getWorldPosition(_cameraPos);
|
camera.getWorldPosition(_cameraPos);
|
||||||
const dist = _cameraPos.distanceTo(_objectPos);
|
const dist = _cameraPos.distanceTo(_objectPos);
|
||||||
const isNearby = dist <= INTERACTION_RADIUS;
|
const isNearby = dist <= radius;
|
||||||
|
|
||||||
manager.setNearby(handle.current, isNearby);
|
manager.setNearby(handle.current, isNearby);
|
||||||
|
|
||||||
@@ -169,7 +178,7 @@ export function InteractableObject(
|
|||||||
|
|
||||||
camera.getWorldDirection(_cameraDir);
|
camera.getWorldDirection(_cameraDir);
|
||||||
_raycaster.set(_cameraPos, _cameraDir);
|
_raycaster.set(_cameraPos, _cameraDir);
|
||||||
_raycaster.far = INTERACTION_RADIUS;
|
_raycaster.far = radius;
|
||||||
|
|
||||||
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
||||||
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
||||||
@@ -187,7 +196,7 @@ export function InteractableObject(
|
|||||||
<mesh ref={debugSphereRef} visible={false}>
|
<mesh ref={debugSphereRef} visible={false}>
|
||||||
<sphereGeometry
|
<sphereGeometry
|
||||||
args={[
|
args={[
|
||||||
INTERACTION_RADIUS,
|
radius,
|
||||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RapierRigidBody } from "@react-three/rapier";
|
|||||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
|
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||||
import {
|
import {
|
||||||
TRIGGER_DEFAULT_COLLIDERS,
|
TRIGGER_DEFAULT_COLLIDERS,
|
||||||
TRIGGER_DEFAULT_LABEL,
|
TRIGGER_DEFAULT_LABEL,
|
||||||
@@ -23,6 +24,7 @@ interface TriggerObjectProps {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
colliders?: ColliderShape;
|
colliders?: ColliderShape;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
radius?: number;
|
||||||
soundPath?: string;
|
soundPath?: string;
|
||||||
soundVolume?: number;
|
soundVolume?: number;
|
||||||
spawnModel?: string;
|
spawnModel?: string;
|
||||||
@@ -53,6 +55,7 @@ export function TriggerObject({
|
|||||||
children,
|
children,
|
||||||
colliders = TRIGGER_DEFAULT_COLLIDERS,
|
colliders = TRIGGER_DEFAULT_COLLIDERS,
|
||||||
label = TRIGGER_DEFAULT_LABEL,
|
label = TRIGGER_DEFAULT_LABEL,
|
||||||
|
radius = INTERACTION_RADIUS,
|
||||||
soundPath,
|
soundPath,
|
||||||
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
|
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||||
spawnModel,
|
spawnModel,
|
||||||
@@ -74,10 +77,13 @@ export function TriggerObject({
|
|||||||
kind="trigger"
|
kind="trigger"
|
||||||
label={label}
|
label={label}
|
||||||
position={position}
|
position={position}
|
||||||
|
radius={radius}
|
||||||
bodyRef={rbRef}
|
bodyRef={rbRef}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (soundPath) {
|
if (soundPath) {
|
||||||
AudioManager.getInstance().playSound(soundPath, soundVolume);
|
AudioManager.getInstance().playSound(soundPath, soundVolume, {
|
||||||
|
category: "sfx",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onTrigger?.();
|
onTrigger?.();
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
|
import type {
|
||||||
|
RepairRuntime,
|
||||||
|
SubtitleLanguage,
|
||||||
|
} from "@/managers/stores/useSettingsStore";
|
||||||
|
|
||||||
|
function formatPercent(value: number): string {
|
||||||
|
return `${Math.round(value * 100)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCookies(): void {
|
||||||
|
document.cookie.split(";").forEach((cookie) => {
|
||||||
|
const cookieName = cookie.split("=")[0]?.trim();
|
||||||
|
if (!cookieName) return;
|
||||||
|
|
||||||
|
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VolumeSliderProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VolumeSlider({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: VolumeSliderProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<label className="game-settings-menu__slider" htmlFor={id}>
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
<strong>{formatPercent(value)}</strong>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GameSettingsMenu(): React.JSX.Element | null {
|
||||||
|
const {
|
||||||
|
isSettingsMenuOpen,
|
||||||
|
musicVolume,
|
||||||
|
sfxVolume,
|
||||||
|
dialogueVolume,
|
||||||
|
subtitlesEnabled,
|
||||||
|
subtitleLanguage,
|
||||||
|
repairRuntime,
|
||||||
|
setMusicVolume,
|
||||||
|
setSfxVolume,
|
||||||
|
setDialogueVolume,
|
||||||
|
setSettingsMenuOpen,
|
||||||
|
setSubtitlesEnabled,
|
||||||
|
setSubtitleLanguage,
|
||||||
|
setRepairRuntime,
|
||||||
|
} = useSettingsStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!isSettingsMenuOpen) document.exitPointerLock();
|
||||||
|
setSettingsMenuOpen(!isSettingsMenuOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, { capture: true });
|
||||||
|
};
|
||||||
|
}, [isSettingsMenuOpen, setSettingsMenuOpen]);
|
||||||
|
|
||||||
|
if (!isSettingsMenuOpen) return null;
|
||||||
|
|
||||||
|
const handleQuit = (): void => {
|
||||||
|
clearCookies();
|
||||||
|
window.location.assign("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="game-settings-menu" role="dialog" aria-modal="true">
|
||||||
|
<div className="game-settings-menu__panel">
|
||||||
|
<header className="game-settings-menu__header">
|
||||||
|
<div>
|
||||||
|
<span>Pause</span>
|
||||||
|
<h2>Options</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="game-settings-menu__close"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSettingsMenuOpen(false)}
|
||||||
|
aria-label="Fermer le menu"
|
||||||
|
>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="game-settings-menu__section"
|
||||||
|
aria-labelledby="audio-settings-heading"
|
||||||
|
>
|
||||||
|
<h3 id="audio-settings-heading">Audio</h3>
|
||||||
|
<VolumeSlider
|
||||||
|
id="music-volume"
|
||||||
|
label="Musique"
|
||||||
|
value={musicVolume}
|
||||||
|
onChange={setMusicVolume}
|
||||||
|
/>
|
||||||
|
<VolumeSlider
|
||||||
|
id="sfx-volume"
|
||||||
|
label="Sound effects"
|
||||||
|
value={sfxVolume}
|
||||||
|
onChange={setSfxVolume}
|
||||||
|
/>
|
||||||
|
<VolumeSlider
|
||||||
|
id="dialogue-volume"
|
||||||
|
label="Dialogue"
|
||||||
|
value={dialogueVolume}
|
||||||
|
onChange={setDialogueVolume}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="game-settings-menu__section"
|
||||||
|
aria-labelledby="subtitle-settings-heading"
|
||||||
|
>
|
||||||
|
<h3 id="subtitle-settings-heading">Sous-titres</h3>
|
||||||
|
<label className="game-settings-menu__checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={subtitlesEnabled}
|
||||||
|
onChange={(event) => setSubtitlesEnabled(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Afficher sous-titres
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="game-settings-menu__choice-group"
|
||||||
|
aria-label="Langue des sous-titres"
|
||||||
|
>
|
||||||
|
{(["fr", "en"] satisfies SubtitleLanguage[]).map((language) => (
|
||||||
|
<button
|
||||||
|
key={language}
|
||||||
|
type="button"
|
||||||
|
className={subtitleLanguage === language ? "active" : undefined}
|
||||||
|
onClick={() => setSubtitleLanguage(language)}
|
||||||
|
aria-pressed={subtitleLanguage === language}
|
||||||
|
>
|
||||||
|
{language === "fr" ? "Francais" : "English"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="game-settings-menu__section"
|
||||||
|
aria-labelledby="repair-settings-heading"
|
||||||
|
>
|
||||||
|
<h3 id="repair-settings-heading">Repair game</h3>
|
||||||
|
<div className="game-settings-menu__choice-group game-settings-menu__choice-group--stacked">
|
||||||
|
{(["js", "python"] satisfies RepairRuntime[]).map((runtime) => (
|
||||||
|
<button
|
||||||
|
key={runtime}
|
||||||
|
type="button"
|
||||||
|
className={repairRuntime === runtime ? "active" : undefined}
|
||||||
|
onClick={() => setRepairRuntime(runtime)}
|
||||||
|
aria-pressed={repairRuntime === runtime}
|
||||||
|
>
|
||||||
|
{runtime === "js"
|
||||||
|
? "Repair game en JS (local)"
|
||||||
|
: "Repair game en Python (server)"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="game-settings-menu__quit"
|
||||||
|
type="button"
|
||||||
|
onClick={handleQuit}
|
||||||
|
>
|
||||||
|
Quitter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import { Crosshair } from "@/components/ui/Crosshair";
|
import { Crosshair } from "@/components/ui/Crosshair";
|
||||||
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
||||||
|
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
|
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||||
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
|
||||||
export function GameUI(): React.JSX.Element {
|
export function GameUI(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DebugOverlayLayout />
|
<DebugOverlayLayout />
|
||||||
<Crosshair />
|
<Crosshair />
|
||||||
|
<RepairMovementLockIndicator />
|
||||||
<InteractPrompt />
|
<InteractPrompt />
|
||||||
<HandTrackingVisualizer />
|
<HandTrackingVisualizer />
|
||||||
|
<Subtitles />
|
||||||
|
<GameSettingsMenu />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||||
|
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||||
|
|
||||||
|
export function RepairMovementLockIndicator(): React.JSX.Element | null {
|
||||||
|
const cameraMode = useCameraMode();
|
||||||
|
const movementLocked = useRepairMovementLocked();
|
||||||
|
|
||||||
|
if (cameraMode !== "player") return null;
|
||||||
|
if (!movementLocked) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="repair-movement-lock-indicator" aria-live="polite">
|
||||||
|
<span
|
||||||
|
className="repair-movement-lock-indicator__dot"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>Déplacement verrouillé pendant la réparation</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
|
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
||||||
|
|
||||||
|
export type SubtitleSpeaker = DialogueSpeaker;
|
||||||
|
|
||||||
|
interface SubtitlesProps {
|
||||||
|
speaker?: SubtitleSpeaker | null;
|
||||||
|
text?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Subtitles({
|
||||||
|
speaker = null,
|
||||||
|
text = null,
|
||||||
|
}: SubtitlesProps): React.JSX.Element | null {
|
||||||
|
const subtitlesEnabled = useSettingsStore((state) => state.subtitlesEnabled);
|
||||||
|
const activeSubtitle = useSubtitleStore((state) => state.activeSubtitle);
|
||||||
|
const subtitleSpeaker = speaker ?? activeSubtitle?.speaker ?? null;
|
||||||
|
const content = (text ?? activeSubtitle?.text)?.trim();
|
||||||
|
|
||||||
|
if (!subtitlesEnabled || !content) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subtitles" aria-live="polite">
|
||||||
|
<p>
|
||||||
|
{subtitleSpeaker ? (
|
||||||
|
<span
|
||||||
|
className={`subtitles__speaker subtitles__speaker--${subtitleSpeaker.toLowerCase()}`}
|
||||||
|
>
|
||||||
|
{subtitleSpeaker}:
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ function toPascalCase(value: string): string {
|
|||||||
|
|
||||||
export function GameStateDebugPanel(): React.JSX.Element {
|
export function GameStateDebugPanel(): React.JSX.Element {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||||
|
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
||||||
|
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
||||||
const detail = useGameStore((state) => {
|
const detail = useGameStore((state) => {
|
||||||
switch (state.mainState) {
|
switch (state.mainState) {
|
||||||
case "intro":
|
case "intro":
|
||||||
@@ -83,6 +86,24 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDebugMainState(nextMainState: MainGameState): void {
|
||||||
|
setMainState(nextMainState);
|
||||||
|
|
||||||
|
if (nextMainState === "bike" && bikeStep === "locked") {
|
||||||
|
setBikeState({ currentStep: "waiting" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||||
|
setPyloneState({ currentStep: "waiting" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||||
|
setFermeState({ currentStep: "waiting" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="game-state-debug-panel debug-overlay-section"
|
className="game-state-debug-panel debug-overlay-section"
|
||||||
@@ -108,7 +129,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
|||||||
aria-pressed={state === mainState}
|
aria-pressed={state === mainState}
|
||||||
className={state === mainState ? "is-active" : undefined}
|
className={state === mainState ? "is-active" : undefined}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMainState(state)}
|
onClick={() => setDebugMainState(state)}
|
||||||
>
|
>
|
||||||
{toPascalCase(state)}
|
{toPascalCase(state)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
|
|||||||
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
|
- \`src/world/GameStageContent.tsx\` est enveloppé dans le contexte Rapier \`Physics\` dans la scène de jeu de production afin que les objets gameplay de stage puissent utiliser la physique sans migrer la carte ou le joueur vers Rapier. Il monte maintenant des instances réutilisables de \`RepairGame\` pour les états de mission \`bike\`, \`pylone\` et \`ferme\`.
|
||||||
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
|
- \`src/world/debug/TestMap.tsx\` fournit une carte orientée debug pour les interactions et la physique, avec les objets existants de grab, trigger et preview de modèle, plus des zones playground de réparation séparées \`Bike\`, \`Pylone\` et \`Farm\`.
|
||||||
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
|
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
|
||||||
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut et les inputs d'interaction.
|
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut, le verrouillage de déplacement pendant les étapes repair et les inputs d'interaction.
|
||||||
|
|
||||||
## Frontières physiques
|
## Frontières physiques
|
||||||
|
|
||||||
@@ -124,8 +124,44 @@ Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il
|
|||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
- \`src/managers/AudioManager.ts\` fournit actuellement une lecture de sons one-shot avec pool.
|
- \`src/managers/AudioManager.ts\` fournit la lecture de sons one-shot avec pool, la musique en boucle, les volumes par catégorie et un pan stéréo optionnel pour les sons one-shot.
|
||||||
- Les interactions trigger peuvent lancer directement un son via \`AudioManager\`.
|
- Les catégories audio supportées sont \`music\`, \`sfx\` et \`dialogue\`.
|
||||||
|
- Les interactions trigger peuvent lancer directement des SFX via \`AudioManager\`.
|
||||||
|
|
||||||
|
## Menu options
|
||||||
|
|
||||||
|
- \`src/managers/stores/useSettingsStore.ts\` stocke les réglages de volume musique, volume SFX, volume dialogue, sous-titres, langue des sous-titres, runtime de réparation et visibilité du menu.
|
||||||
|
- \`src/components/ui/GameSettingsMenu.tsx\` rend le menu options en jeu.
|
||||||
|
- \`src/components/ui/GameUI.tsx\` monte le menu comme overlay HTML hors canvas.
|
||||||
|
- \`Esc\` ouvre et ferme le menu, et \`src/world/player/PlayerController.tsx\` ignore les inputs joueur pendant son ouverture.
|
||||||
|
- Les changements de volume sont transmis à \`AudioManager\` par catégorie.
|
||||||
|
|
||||||
|
## Dialogues et sous-titres
|
||||||
|
|
||||||
|
- \`public/sounds/dialogue/dialogues.json\` est le manifeste runtime des dialogues.
|
||||||
|
- Les fichiers audio de dialogue vivent dans \`public/sounds/dialogue/\`.
|
||||||
|
- Les fichiers de sous-titres vivent dans \`public/sounds/dialogue/subtitles/{fr|en}/\`.
|
||||||
|
- Le modèle actuel utilise un fichier SRT par voix et par langue.
|
||||||
|
- \`src/types/dialogues/dialogues.ts\` contient les types du manifeste.
|
||||||
|
- \`src/utils/dialogues/dialogueManifestValidation.ts\` valide la forme du manifeste au runtime.
|
||||||
|
- \`src/utils/dialogues/loadDialogueManifest.ts\` charge le manifeste et les cues SRT, avec fallback français si la langue sélectionnée manque.
|
||||||
|
- \`src/utils/subtitles/parseSrt.ts\` parse les blocs et timecodes SRT.
|
||||||
|
- \`src/utils/dialogues/playDialogue.ts\` joue l'audio de dialogue et synchronise le sous-titre actif avec le temps de l'élément audio.
|
||||||
|
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
|
||||||
|
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
|
||||||
|
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
|
||||||
|
- La lecture de dialogue est mise en file pour éviter les chevauchements.
|
||||||
|
|
||||||
|
## Cinématiques
|
||||||
|
|
||||||
|
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
|
||||||
|
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
|
||||||
|
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
|
||||||
|
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
|
||||||
|
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
|
||||||
|
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
|
||||||
|
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
|
||||||
|
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
|
||||||
|
|
||||||
## Système debug
|
## Système debug
|
||||||
|
|
||||||
@@ -154,7 +190,8 @@ Le joueur et l'octree de carte doivent rester hors du provider Rapier tant qu'il
|
|||||||
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
|
||||||
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
|
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
|
||||||
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
|
||||||
- L'état de mission existe dans Zustand, mais les zones, cinématiques, dialogues et le flow complet de réparation ne sont pas implémentés.
|
- L'état de mission existe dans Zustand et le flow de réparation est implémenté comme prototype pour les missions de réparation actuelles.
|
||||||
|
- Les cinématiques et dialogues existent comme systèmes prototype pilotés par timecode; les branches de dialogue et l'orchestration gameplay globale restent limitées.
|
||||||
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -393,6 +430,7 @@ Overlays actuels :
|
|||||||
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
|
- \`GameStateDebugPanel\` : panneau de progression debug pour consulter/changer le main state, le sub state, avancer/reculer et reset le store
|
||||||
- \`Crosshair\` : aide de visée joueur
|
- \`Crosshair\` : aide de visée joueur
|
||||||
- \`InteractPrompt\` : prompt d'interaction
|
- \`InteractPrompt\` : prompt d'interaction
|
||||||
|
- \`RepairMovementLockIndicator\` : indicateur joueur affiché quand les étapes repair désactivent temporairement le déplacement
|
||||||
|
|
||||||
\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`.
|
\`src/pages/page.tsx\` doit rester fin et monter seulement le canvas et \`GameUI\`.
|
||||||
|
|
||||||
@@ -429,6 +467,7 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
|||||||
- Orientation souris avec pointer lock
|
- Orientation souris avec pointer lock
|
||||||
- Déplacement avec \`ZQSD\`
|
- Déplacement avec \`ZQSD\`
|
||||||
- Saut
|
- Saut
|
||||||
|
- Verrouillage du déplacement pendant les étapes repair actives, avec indicateur à l'écran tout en gardant les interactions trigger disponibles
|
||||||
- Collision basée sur une octree contre la carte chargée
|
- Collision basée sur une octree contre la carte chargée
|
||||||
|
|
||||||
## Interactions
|
## Interactions
|
||||||
@@ -444,28 +483,74 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
|
|||||||
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
|
- \`RepairGame\` de production réutilisable monté pour les états de mission \`bike\`, \`pylone\` et \`ferme\`
|
||||||
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
|
- Le playground physics debug monte le même \`RepairGame\` réutilisable dans des zones \`Bike\`, \`Pylone\` et \`Farm\`, afin de peaufiner chaque state avec un placement isolé avant déplacement vers la carte de production
|
||||||
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
|
- Configuration de mission partagée via \`src/data/gameplay/repairMissions.ts\`, avec nodes cassés, placeholders cibles, timing de scan et timing de réassemblage propres à chaque mission
|
||||||
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, validation de la bonne pièce et complétion de mission
|
- Flow repair-game avec \`waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission\`, prompts \`.webm\`, apparition/ouverture/sortie de la mallette, vue focalisée de la mallette, indicateur de verrouillage de déplacement pendant la réparation active, interaction trigger sur la mallette, traverse des placeholders de mallette, placement avec snap vers placeholder, feedback de dépôt des pièces cassées, touche \`E\`, hold deux poings, transition de modèle explosé, réassemblage inverse avec particules, scan visuel par pièce, marqueur rouge persistant et vidéo UI centrée sur les pièces cassées, plusieurs choix de pièces grabbables, feedback de validation de la bonne pièce et complétion de mission
|
||||||
|
|
||||||
## Audio
|
## Audio
|
||||||
|
|
||||||
- Lecture de sons one-shot pour les interactions trigger
|
- Volumes par catégorie pour la musique, les SFX et les dialogues
|
||||||
- Pool simple par son via \`AudioManager\`
|
- Lecture de musique en boucle via \`AudioManager\`
|
||||||
|
- Lecture de sons one-shot pour les SFX et les dialogues, avec pool simple par son
|
||||||
|
- Pan stéréo optionnel pour les sons one-shot
|
||||||
|
|
||||||
|
## Dialogues et sous-titres
|
||||||
|
|
||||||
|
- Manifeste de dialogues dans \`public/sounds/dialogue/dialogues.json\`
|
||||||
|
- Audios de dialogue chargés depuis \`public/sounds/dialogue/\`
|
||||||
|
- Un fichier SRT par voix et par langue
|
||||||
|
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
|
||||||
|
- Overlay de sous-titres runtime avec couleurs par speaker
|
||||||
|
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
|
||||||
|
- File d'attente pour éviter les dialogues superposés
|
||||||
|
|
||||||
|
## Cinématiques
|
||||||
|
|
||||||
|
- Manifeste de cinématiques dans \`public/cinematics.json\`
|
||||||
|
- Déclenchement timecodé des cinématiques
|
||||||
|
- Lecture de keyframes caméra via GSAP
|
||||||
|
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
|
||||||
|
- Blocage des inputs joueur pendant une cinématique
|
||||||
|
|
||||||
|
## Menu options
|
||||||
|
|
||||||
|
- \`Esc\` ouvre et ferme le menu options en jeu
|
||||||
|
- Sliders de volume musique, SFX et dialogue
|
||||||
|
- Toggle d'affichage des sous-titres
|
||||||
|
- Choix de langue des sous-titres entre français et anglais
|
||||||
|
- Choix du runtime de réparation entre JavaScript local et serveur Python
|
||||||
|
- Action quitter qui nettoie les cookies accessibles au navigateur et retourne vers \`/\`
|
||||||
|
|
||||||
## Outils debug
|
## Outils debug
|
||||||
|
|
||||||
- Le paramètre \`?debug\` active le panneau debug
|
- Le paramètre \`?debug\` active le panneau debug
|
||||||
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
|
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène, \`R3F Perf\`, \`Debug Overlay\` et le tuning d'interaction
|
||||||
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
|
- Overlay debug compact pour les contrôles de game state et le statut hand tracking
|
||||||
|
- Le changement de mission dans le panneau game-state debug déverrouille les missions repair encore \`locked\` à \`waiting\` pour accélérer les tests
|
||||||
- Helpers de scène debug
|
- Helpers de scène debug
|
||||||
- Caméra libre debug
|
- Caméra libre debug
|
||||||
- Overlay \`r3f-perf\`
|
- Overlay \`r3f-perf\`
|
||||||
|
|
||||||
|
## Éditeur de carte
|
||||||
|
|
||||||
|
- Route \`/editor\` pour inspecter et éditer \`public/map.json\`
|
||||||
|
- Chargement automatique de \`public/map.json\` quand il existe
|
||||||
|
- Rendu des modèles disponibles depuis \`public/models/{name}/model.glb\` ou \`model.gltf\`
|
||||||
|
- Cubes de fallback pour les nodes dont le modèle manque
|
||||||
|
- Sélection d'objet au clic
|
||||||
|
- Modes de transformation translation, rotation et scale
|
||||||
|
- Export JSON pour télécharger la carte modifiée
|
||||||
|
- Endpoint de sauvegarde dev-server pour écrire \`public/map.json\`
|
||||||
|
- Éditeur SRT pour les sous-titres de dialogue
|
||||||
|
- Preview audio et outils de timing pour les cues SRT
|
||||||
|
- Endpoint de sauvegarde dev-server pour les fichiers SRT
|
||||||
|
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
|
||||||
|
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
|
||||||
|
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
|
||||||
|
|
||||||
## Pas encore implémenté
|
## Pas encore implémenté
|
||||||
|
|
||||||
- système de missions complet
|
- système de missions complet
|
||||||
- système de zones
|
- système de zones
|
||||||
- système de cinématiques
|
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
|
||||||
- système de dialogues
|
|
||||||
- flow de chargement
|
- flow de chargement
|
||||||
- minimap et HUD de mission
|
- minimap et HUD de mission
|
||||||
- séparation complète production / debug pour les scènes gameplay
|
- séparation complète production / debug pour les scènes gameplay
|
||||||
@@ -524,6 +609,74 @@ Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'édi
|
|||||||
|
|
||||||
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
|
||||||
|
|
||||||
|
## Éditer les dialogues et sous-titres
|
||||||
|
|
||||||
|
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
|
||||||
|
|
||||||
|
### Manifeste dialogues
|
||||||
|
|
||||||
|
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
|
||||||
|
|
||||||
|
- \`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 :
|
||||||
|
|
||||||
|
- un \`id\`
|
||||||
|
- un \`timecode\` global optionnel
|
||||||
|
- au moins deux keyframes caméra
|
||||||
|
- des dialogue cues optionnelles synchronisées avec la timeline
|
||||||
|
|
||||||
|
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\`.
|
||||||
|
|
||||||
|
Actions disponibles :
|
||||||
|
|
||||||
|
- \`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 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.
|
||||||
|
|
||||||
## Inspecteur JSON
|
## Inspecteur JSON
|
||||||
|
|
||||||
Le panneau latéral affiche le JSON brut de la carte :
|
Le panneau latéral affiche le JSON brut de la carte :
|
||||||
@@ -539,4 +692,6 @@ Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauveg
|
|||||||
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
|
||||||
- La sauvegarde production n'est pas implémentée.
|
- 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.
|
- 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.
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
|
export const REPAIR_FRAGMENTATION_FIST_HOLD_SECONDS = 1;
|
||||||
export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
|
export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
|
||||||
|
export const REPAIR_INTERACTION_RADIUS = 10;
|
||||||
export const REPAIR_SCAN_PART_SECONDS = 1.2;
|
export const REPAIR_SCAN_PART_SECONDS = 1.2;
|
||||||
export const REPAIR_REASSEMBLY_SECONDS = 1.4;
|
export const REPAIR_REASSEMBLY_SECONDS = 1.4;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Scale, Vector3Tuple } from "@/types/three/three";
|
import type {
|
||||||
|
ModelTransformProps,
|
||||||
|
Vector3Scale,
|
||||||
|
Vector3Tuple,
|
||||||
|
} from "@/types/three/three";
|
||||||
|
|
||||||
export interface RepairMissionCaseConfig {
|
export interface RepairMissionCaseConfig {
|
||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
@@ -20,6 +24,7 @@ export interface RepairMissionConfig {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
modelPath: string;
|
modelPath: string;
|
||||||
|
modelScale?: ModelTransformProps["scale"];
|
||||||
stageUiPath: string;
|
stageUiPath: string;
|
||||||
interactUiPath: string;
|
interactUiPath: string;
|
||||||
brokenUiPath: string;
|
brokenUiPath: string;
|
||||||
@@ -40,13 +45,14 @@ const DEFAULT_REPAIR_CASE = {
|
|||||||
scale: 1.5,
|
scale: 1.5,
|
||||||
} satisfies RepairMissionCaseConfig;
|
} satisfies RepairMissionCaseConfig;
|
||||||
|
|
||||||
export const REPAIR_MISSIONS = {
|
export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||||
bike: {
|
bike: {
|
||||||
id: "bike",
|
id: "bike",
|
||||||
label: "E-bike",
|
label: "E-bike",
|
||||||
description:
|
description:
|
||||||
"Repair the damaged cooling module before relaunching the bike",
|
"Repair the damaged cooling module before relaunching the bike",
|
||||||
modelPath: "/models/refroidisseur/model.gltf",
|
modelPath: "/models/ebike/model.gltf",
|
||||||
|
modelScale: 0.0055,
|
||||||
stageUiPath: "/assets/UI/ebike.webm",
|
stageUiPath: "/assets/UI/ebike.webm",
|
||||||
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
interactUiPath: REPAIR_INTERACT_UI_PATH,
|
||||||
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
brokenUiPath: REPAIR_BROKEN_UI_PATH,
|
||||||
@@ -56,7 +62,8 @@ export const REPAIR_MISSIONS = {
|
|||||||
{
|
{
|
||||||
id: "bike-cooling-core",
|
id: "bike-cooling-core",
|
||||||
label: "Cooling core",
|
label: "Cooling core",
|
||||||
nodeName: "Cylinder",
|
modelPath: "/models/refroidisseur/model.gltf",
|
||||||
|
nodeName: "refroidisseur",
|
||||||
placeholderName: "placeholder_1",
|
placeholderName: "placeholder_1",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -74,7 +81,7 @@ export const REPAIR_MISSIONS = {
|
|||||||
{
|
{
|
||||||
id: "bike-glove-decoy",
|
id: "bike-glove-decoy",
|
||||||
label: "Insulation glove",
|
label: "Insulation glove",
|
||||||
modelPath: "/models/gant/model.gltf",
|
modelPath: "/models/gant_l/model.gltf",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -166,4 +173,4 @@ export const REPAIR_MISSIONS = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
} satisfies Record<RepairMissionId, RepairMissionConfig>;
|
};
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||||
|
|
||||||
|
export function useRepairMovementLocked(): boolean {
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return useGameStore((state) => {
|
||||||
|
switch (state.mainState) {
|
||||||
|
case "bike":
|
||||||
|
return isRepairMovementLocked(state.bike.currentStep);
|
||||||
|
case "pylone":
|
||||||
|
return isRepairMovementLocked(state.pylone.currentStep);
|
||||||
|
case "ferme":
|
||||||
|
return isRepairMovementLocked(state.ferme.currentStep);
|
||||||
|
case "intro":
|
||||||
|
case "outro":
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRepairMovementLocked(step: MissionStep): boolean {
|
||||||
|
return (
|
||||||
|
step === "inspected" ||
|
||||||
|
step === "fragmented" ||
|
||||||
|
step === "scanning" ||
|
||||||
|
step === "repairing" ||
|
||||||
|
step === "reassembling"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ export function useOctreeGraphNode(
|
|||||||
}, [rebuildKey]);
|
}, [rebuildKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
const graphNode = graphNodeRef.current;
|
const graphNode = graphNodeRef.current;
|
||||||
if (!enabled || octreeBuilt.current || !graphNode) return;
|
if (!enabled || octreeBuilt.current || !graphNode) return;
|
||||||
octreeBuilt.current = true;
|
octreeBuilt.current = true;
|
||||||
|
|||||||
+890
@@ -397,6 +397,35 @@ canvas {
|
|||||||
letter-spacing: 0.03em;
|
letter-spacing: 0.03em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.repair-movement-lock-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 22px;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 10;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(5, 9, 16, 0.72);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repair-movement-lock-indicator__dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #38bdf8;
|
||||||
|
box-shadow: 0 0 14px rgba(56, 189, 248, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
.scene-loading-overlay {
|
.scene-loading-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -469,6 +498,194 @@ canvas {
|
|||||||
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
|
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Subtitles */
|
||||||
|
.subtitles {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 7vh;
|
||||||
|
z-index: 15;
|
||||||
|
width: min(780px, calc(100vw - 32px));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.82);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles__speaker {
|
||||||
|
margin-right: 0.35em;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles__speaker--narrateur {
|
||||||
|
color: #7dd3fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles__speaker--fermier {
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitles__speaker--electricienne {
|
||||||
|
color: #f9a8d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In-game settings menu */
|
||||||
|
.game-settings-menu {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__panel {
|
||||||
|
width: min(460px, 100%);
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: rgba(8, 8, 8, 0.94);
|
||||||
|
box-shadow: 0 28px 90px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 4px 4px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__header span {
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__header h2 {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
letter-spacing: -0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__close {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #111111;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 4px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__section h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #d7d7d7;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__slider {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__slider span,
|
||||||
|
.game-settings-menu__checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__slider strong {
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__slider input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
accent-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__checkbox {
|
||||||
|
justify-content: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__checkbox input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
accent-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__choice-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__choice-group--stacked {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__choice-group button,
|
||||||
|
.game-settings-menu__quit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #101010;
|
||||||
|
color: #f2f2f2;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 680;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__choice-group button.active {
|
||||||
|
border-color: #ffffff;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #050505;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-settings-menu__quit {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-color: rgba(248, 113, 113, 0.35);
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
/* Debug overlay panels */
|
/* Debug overlay panels */
|
||||||
.debug-overlay-layout {
|
.debug-overlay-layout {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -1076,6 +1293,12 @@ canvas {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-action-button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-action-button-primary,
|
.editor-action-button-primary,
|
||||||
.editor-player-button.active {
|
.editor-player-button.active {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
@@ -1239,6 +1462,673 @@ canvas {
|
|||||||
font-size: 0.74rem;
|
font-size: 0.74rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Editor SRT panel */
|
||||||
|
.editor-srt-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 12px 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-controls label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-controls select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 260px;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #1f1f1f;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #050505;
|
||||||
|
color: #d7d7d7;
|
||||||
|
font-family: "SFMono-Regular", "Courier New", monospace;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-textarea:focus,
|
||||||
|
.editor-srt-controls select:focus,
|
||||||
|
.editor-srt-preview select:focus {
|
||||||
|
border-color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #1f1f1f;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #070707;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-preview label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-preview select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-audio-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-audio-card span {
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-audio-card strong {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-audio-card audio {
|
||||||
|
width: 100%;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-active-cue {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-active-cue span {
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-active-cue p {
|
||||||
|
margin: 0;
|
||||||
|
color: #d7d7d7;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-active-cue strong {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-time-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-time-actions button {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(125, 211, 252, 0.24);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(125, 211, 252, 0.08);
|
||||||
|
color: #bae6fd;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-time-actions button:hover {
|
||||||
|
border-color: #7dd3fc;
|
||||||
|
background: rgba(125, 211, 252, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-jump-button {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #2f2f2f;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151515;
|
||||||
|
color: #f2f2f2;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-jump-button:hover {
|
||||||
|
border-color: #ffffff;
|
||||||
|
background: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-actions .editor-action-button + .editor-action-button {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-status {
|
||||||
|
margin: 0;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation.is-valid {
|
||||||
|
border-color: rgba(134, 239, 172, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation.is-invalid {
|
||||||
|
border-color: rgba(248, 113, 113, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading strong {
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading span {
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 92px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid #2f2f2f;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151515;
|
||||||
|
color: #f2f2f2;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading button:hover {
|
||||||
|
border-color: #ffffff;
|
||||||
|
background: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__heading button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__result {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__result p {
|
||||||
|
margin: 0;
|
||||||
|
color: #d7d7d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__errors,
|
||||||
|
.editor-dialogue-validation__warnings {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__errors {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-validation__warnings {
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-diagnostic {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-diagnostic.is-valid {
|
||||||
|
border-color: rgba(134, 239, 172, 0.32);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-diagnostic.is-invalid {
|
||||||
|
border-color: rgba(248, 113, 113, 0.38);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-diagnostic strong {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-srt-diagnostic ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor dialogue manifest panel */
|
||||||
|
.editor-dialogue-manifest-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 12px 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-actions button,
|
||||||
|
.editor-dialogue-manifest-srt-cue,
|
||||||
|
.editor-dialogue-manifest-preview,
|
||||||
|
.editor-dialogue-manifest-delete {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid #2f2f2f;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151515;
|
||||||
|
color: #f2f2f2;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-actions button:hover,
|
||||||
|
.editor-dialogue-manifest-srt-cue:hover,
|
||||||
|
.editor-dialogue-manifest-preview:hover,
|
||||||
|
.editor-dialogue-manifest-delete:hover {
|
||||||
|
border-color: #ffffff;
|
||||||
|
background: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-actions button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-srt-cue:disabled,
|
||||||
|
.editor-dialogue-manifest-preview:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-select,
|
||||||
|
.editor-dialogue-manifest-form label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-select select,
|
||||||
|
.editor-dialogue-manifest-form input,
|
||||||
|
.editor-dialogue-manifest-form select {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-select select:focus,
|
||||||
|
.editor-dialogue-manifest-form input:focus,
|
||||||
|
.editor-dialogue-manifest-form select:focus {
|
||||||
|
border-color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #1f1f1f;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #070707;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-delete {
|
||||||
|
border-color: rgba(248, 113, 113, 0.32);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-preview {
|
||||||
|
border-color: rgba(125, 211, 252, 0.24);
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-srt-cue {
|
||||||
|
border-color: rgba(134, 239, 172, 0.24);
|
||||||
|
color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-status {
|
||||||
|
margin: 0;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-diagnostic {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-diagnostic.is-valid {
|
||||||
|
border-color: rgba(134, 239, 172, 0.32);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-diagnostic.is-invalid {
|
||||||
|
border-color: rgba(248, 113, 113, 0.38);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-dialogue-manifest-diagnostic ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editor cinematic manifest panel */
|
||||||
|
.editor-cinematic-manifest-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 12px 12px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-actions button,
|
||||||
|
.editor-cinematic-manifest-preview,
|
||||||
|
.editor-cinematic-manifest-delete,
|
||||||
|
.editor-cinematic-keyframes-heading button,
|
||||||
|
.editor-cinematic-keyframe-heading button,
|
||||||
|
.editor-cinematic-dialogue-cues-heading button,
|
||||||
|
.editor-cinematic-dialogue-cue-heading button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 9px;
|
||||||
|
border: 1px solid #2f2f2f;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #151515;
|
||||||
|
color: #f2f2f2;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-actions button:hover,
|
||||||
|
.editor-cinematic-manifest-preview:hover,
|
||||||
|
.editor-cinematic-manifest-delete:hover,
|
||||||
|
.editor-cinematic-keyframes-heading button:hover,
|
||||||
|
.editor-cinematic-keyframe-heading button:hover,
|
||||||
|
.editor-cinematic-dialogue-cues-heading button:hover,
|
||||||
|
.editor-cinematic-dialogue-cue-heading button:hover {
|
||||||
|
border-color: #ffffff;
|
||||||
|
background: #202020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-actions button:disabled,
|
||||||
|
.editor-cinematic-manifest-preview:disabled,
|
||||||
|
.editor-cinematic-keyframe-heading button:disabled,
|
||||||
|
.editor-cinematic-dialogue-cue-heading button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-select,
|
||||||
|
.editor-cinematic-manifest-form label,
|
||||||
|
.editor-cinematic-vector-inputs label,
|
||||||
|
.editor-cinematic-dialogue-cue label {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-select select,
|
||||||
|
.editor-cinematic-manifest-form input,
|
||||||
|
.editor-cinematic-manifest-form select,
|
||||||
|
.editor-cinematic-vector-inputs input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
color: #f2f2f2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-select select:focus,
|
||||||
|
.editor-cinematic-manifest-form input:focus,
|
||||||
|
.editor-cinematic-manifest-form select:focus,
|
||||||
|
.editor-cinematic-vector-inputs input:focus {
|
||||||
|
border-color: #ffffff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-form,
|
||||||
|
.editor-cinematic-keyframes,
|
||||||
|
.editor-cinematic-keyframe,
|
||||||
|
.editor-cinematic-dialogue-cues,
|
||||||
|
.editor-cinematic-dialogue-cue {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-form {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #1f1f1f;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #070707;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-keyframes,
|
||||||
|
.editor-cinematic-dialogue-cues {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #101010;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-keyframes-heading,
|
||||||
|
.editor-cinematic-keyframe-heading,
|
||||||
|
.editor-cinematic-dialogue-cues-heading,
|
||||||
|
.editor-cinematic-dialogue-cue-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-keyframes-heading strong,
|
||||||
|
.editor-cinematic-keyframe-heading strong,
|
||||||
|
.editor-cinematic-dialogue-cues-heading strong,
|
||||||
|
.editor-cinematic-dialogue-cue-heading strong {
|
||||||
|
color: #f2f2f2;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-keyframe,
|
||||||
|
.editor-cinematic-dialogue-cue {
|
||||||
|
padding: 9px;
|
||||||
|
border: 1px solid #1f1f1f;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #070707;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-vector-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-vector-inputs span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-delete {
|
||||||
|
border-color: rgba(248, 113, 113, 0.32);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-preview {
|
||||||
|
border-color: rgba(125, 211, 252, 0.24);
|
||||||
|
color: #bae6fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-keyframe-heading button,
|
||||||
|
.editor-cinematic-dialogue-cue-heading button {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-dialogue-cues p {
|
||||||
|
margin: 0;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-status {
|
||||||
|
margin: 0;
|
||||||
|
color: #8d8d8d;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-diagnostic {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #101010;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-diagnostic.is-valid {
|
||||||
|
border-color: rgba(134, 239, 172, 0.32);
|
||||||
|
color: #86efac;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-diagnostic.is-invalid {
|
||||||
|
border-color: rgba(248, 113, 113, 0.38);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-cinematic-manifest-diagnostic ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Editor responsive layout */
|
/* Editor responsive layout */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.editor-error h2 {
|
.editor-error h2 {
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
|
export type AudioCategory = "music" | "sfx" | "dialogue";
|
||||||
|
export type OneShotAudioCategory = Exclude<AudioCategory, "music">;
|
||||||
|
|
||||||
|
interface AudioContextWindow extends Window {
|
||||||
|
webkitAudioContext?: typeof AudioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||||
|
music: 1,
|
||||||
|
sfx: 1,
|
||||||
|
dialogue: 1,
|
||||||
|
};
|
||||||
|
|
||||||
interface PlaySoundOptions {
|
interface PlaySoundOptions {
|
||||||
|
category?: OneShotAudioCategory;
|
||||||
|
pan?: number;
|
||||||
playbackRate?: number;
|
playbackRate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StereoNodes {
|
||||||
|
source: MediaElementAudioSourceNode;
|
||||||
|
panner: StereoPannerNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OneShotAudioState {
|
||||||
|
category: OneShotAudioCategory;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class AudioManager {
|
export class AudioManager {
|
||||||
private static _instance: AudioManager | null = null;
|
private static _instance: AudioManager | null = null;
|
||||||
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
private readonly _audioPools = new Map<string, HTMLAudioElement[]>();
|
||||||
|
private readonly _stereoNodes = new WeakMap<HTMLAudioElement, StereoNodes>();
|
||||||
|
private readonly _oneShotStates = new WeakMap<
|
||||||
|
HTMLAudioElement,
|
||||||
|
OneShotAudioState
|
||||||
|
>();
|
||||||
|
private readonly _categoryVolumes: Record<AudioCategory, number> = {
|
||||||
|
...DEFAULT_CATEGORY_VOLUMES,
|
||||||
|
};
|
||||||
|
private _audioContext: AudioContext | null = null;
|
||||||
private _music: HTMLAudioElement | null = null;
|
private _music: HTMLAudioElement | null = null;
|
||||||
private _musicPath: string | null = null;
|
private _musicPath: string | null = null;
|
||||||
|
private _musicVolume = 1;
|
||||||
private _musicUnlockHandler: (() => void) | null = null;
|
private _musicUnlockHandler: (() => void) | null = null;
|
||||||
|
|
||||||
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
private static readonly MAX_POOL_SIZE_PER_SOUND = 6;
|
||||||
|
private static readonly DEFAULT_SOUND_CATEGORY: OneShotAudioCategory = "sfx";
|
||||||
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
private static readonly IGNORED_PLAYBACK_ERRORS = new Set([
|
||||||
"AbortError",
|
"AbortError",
|
||||||
"NotAllowedError",
|
"NotAllowedError",
|
||||||
@@ -27,11 +63,38 @@ export class AudioManager {
|
|||||||
|
|
||||||
private constructor() {}
|
private constructor() {}
|
||||||
|
|
||||||
playSound(path: string, volume = 1, options: PlaySoundOptions = {}): void {
|
setCategoryVolume(category: AudioCategory, volume: number): void {
|
||||||
|
this._categoryVolumes[category] = AudioManager._clampVolume(volume);
|
||||||
|
|
||||||
|
if (category === "music" && this._music) {
|
||||||
|
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._updateOneShotVolumes(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
getCategoryVolume(category: AudioCategory): number {
|
||||||
|
return this._categoryVolumes[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
playSound(
|
||||||
|
path: string,
|
||||||
|
volume = 1,
|
||||||
|
options: PlaySoundOptions = {},
|
||||||
|
): HTMLAudioElement {
|
||||||
const audio = this._acquireAudio(path);
|
const audio = this._acquireAudio(path);
|
||||||
audio.volume = Math.max(0, Math.min(1, volume));
|
const category = options.category ?? AudioManager.DEFAULT_SOUND_CATEGORY;
|
||||||
|
const baseVolume = AudioManager._clampVolume(volume);
|
||||||
|
this._oneShotStates.set(audio, { category, volume: baseVolume });
|
||||||
|
audio.volume = this._getEffectiveVolume(category, baseVolume);
|
||||||
audio.playbackRate = options.playbackRate ?? 1;
|
audio.playbackRate = options.playbackRate ?? 1;
|
||||||
audio.currentTime = 0;
|
audio.currentTime = 0;
|
||||||
|
this._setStereoPan(audio, options.pan ?? 0);
|
||||||
|
|
||||||
|
if (this._audioContext?.state === "suspended") {
|
||||||
|
void this._audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
void audio.play().catch((error: unknown) => {
|
void audio.play().catch((error: unknown) => {
|
||||||
if (
|
if (
|
||||||
@@ -43,14 +106,19 @@ export class AudioManager {
|
|||||||
|
|
||||||
logger.error("AudioManager", "Failed to play sound", {
|
logger.error("AudioManager", "Failed to play sound", {
|
||||||
path,
|
path,
|
||||||
|
category,
|
||||||
error: AudioManager._toLogValue(error),
|
error: AudioManager._toLogValue(error),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return audio;
|
||||||
}
|
}
|
||||||
|
|
||||||
playMusic(path: string, volume = 1): void {
|
playMusic(path: string, volume = 1): void {
|
||||||
|
this._musicVolume = AudioManager._clampVolume(volume);
|
||||||
|
|
||||||
if (this._musicPath === path && this._music) {
|
if (this._musicPath === path && this._music) {
|
||||||
this._music.volume = Math.max(0, Math.min(1, volume));
|
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||||
if (!this._music.paused) return;
|
if (!this._music.paused) return;
|
||||||
} else {
|
} else {
|
||||||
this.stopMusic();
|
this.stopMusic();
|
||||||
@@ -59,7 +127,7 @@ export class AudioManager {
|
|||||||
this._musicPath = path;
|
this._musicPath = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._music.volume = Math.max(0, Math.min(1, volume));
|
this._music.volume = this._getEffectiveVolume("music", this._musicVolume);
|
||||||
|
|
||||||
void this._music.play().catch((error: unknown) => {
|
void this._music.play().catch((error: unknown) => {
|
||||||
if (
|
if (
|
||||||
@@ -93,6 +161,8 @@ export class AudioManager {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
this._audioPools.clear();
|
this._audioPools.clear();
|
||||||
|
void this._audioContext?.close();
|
||||||
|
this._audioContext = null;
|
||||||
AudioManager._instance = null;
|
AudioManager._instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,6 +229,61 @@ export class AudioManager {
|
|||||||
this._musicUnlockHandler = null;
|
this._musicUnlockHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _setStereoPan(audio: HTMLAudioElement, pan: number): void {
|
||||||
|
const audioContext = this._getAudioContext();
|
||||||
|
if (!audioContext || !("createStereoPanner" in audioContext)) return;
|
||||||
|
|
||||||
|
let nodes = this._stereoNodes.get(audio);
|
||||||
|
if (!nodes) {
|
||||||
|
nodes = {
|
||||||
|
source: audioContext.createMediaElementSource(audio),
|
||||||
|
panner: audioContext.createStereoPanner(),
|
||||||
|
};
|
||||||
|
nodes.source.connect(nodes.panner).connect(audioContext.destination);
|
||||||
|
this._stereoNodes.set(audio, nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.panner.pan.value = AudioManager._clampPan(pan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getAudioContext(): AudioContext | null {
|
||||||
|
if (this._audioContext) return this._audioContext;
|
||||||
|
|
||||||
|
const AudioContextConstructor =
|
||||||
|
window.AudioContext ??
|
||||||
|
(window as AudioContextWindow).webkitAudioContext ??
|
||||||
|
null;
|
||||||
|
if (!AudioContextConstructor) return null;
|
||||||
|
|
||||||
|
this._audioContext = new AudioContextConstructor();
|
||||||
|
return this._audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getEffectiveVolume(category: AudioCategory, volume: number): number {
|
||||||
|
return AudioManager._clampVolume(volume) * this._categoryVolumes[category];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _updateOneShotVolumes(category: AudioCategory): void {
|
||||||
|
if (category === "music") return;
|
||||||
|
|
||||||
|
this._audioPools.forEach((pool) => {
|
||||||
|
pool.forEach((audio) => {
|
||||||
|
const state = this._oneShotStates.get(audio);
|
||||||
|
if (!state || state.category !== category) return;
|
||||||
|
|
||||||
|
audio.volume = this._getEffectiveVolume(category, state.volume);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static _clampPan(pan: number): number {
|
||||||
|
return Math.max(-1, Math.min(1, pan));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static _clampVolume(volume: number): number {
|
||||||
|
return Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
private static _toLogValue(error: unknown): Error | DOMException | string {
|
private static _toLogValue(error: unknown): Error | DOMException | string {
|
||||||
if (error instanceof Error || error instanceof DOMException) {
|
if (error instanceof Error || error instanceof DOMException) {
|
||||||
return error;
|
return error;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface MissionState {
|
|||||||
|
|
||||||
interface GameState {
|
interface GameState {
|
||||||
mainState: MainGameState;
|
mainState: MainGameState;
|
||||||
|
isCinematicPlaying: boolean;
|
||||||
intro: IntroState;
|
intro: IntroState;
|
||||||
bike: MissionState & {
|
bike: MissionState & {
|
||||||
isRepaired: boolean;
|
isRepaired: boolean;
|
||||||
@@ -39,6 +40,7 @@ interface GameState {
|
|||||||
|
|
||||||
interface GameActions {
|
interface GameActions {
|
||||||
setMainState: (mainState: MainGameState) => void;
|
setMainState: (mainState: MainGameState) => void;
|
||||||
|
setCinematicPlaying: (isCinematicPlaying: boolean) => void;
|
||||||
setIntroState: (intro: Partial<IntroState>) => void;
|
setIntroState: (intro: Partial<IntroState>) => void;
|
||||||
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
setBikeState: (bike: Partial<GameState["bike"]>) => void;
|
||||||
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
setPyloneState: (pylone: Partial<GameState["pylone"]>) => void;
|
||||||
@@ -222,6 +224,7 @@ function startOutroState(state: GameState): GameStateUpdate {
|
|||||||
function createInitialGameState(): GameState {
|
function createInitialGameState(): GameState {
|
||||||
return {
|
return {
|
||||||
mainState: "intro",
|
mainState: "intro",
|
||||||
|
isCinematicPlaying: false,
|
||||||
intro: {
|
intro: {
|
||||||
dialogueAudio: null,
|
dialogueAudio: null,
|
||||||
hasCompleted: false,
|
hasCompleted: false,
|
||||||
@@ -252,6 +255,7 @@ function createInitialGameState(): GameState {
|
|||||||
export const useGameStore = create<GameStore>()((set) => ({
|
export const useGameStore = create<GameStore>()((set) => ({
|
||||||
...createInitialGameState(),
|
...createInitialGameState(),
|
||||||
setMainState: (mainState) => set({ mainState }),
|
setMainState: (mainState) => set({ mainState }),
|
||||||
|
setCinematicPlaying: (isCinematicPlaying) => set({ isCinematicPlaying }),
|
||||||
setIntroState: (intro) =>
|
setIntroState: (intro) =>
|
||||||
set((state) => ({ intro: { ...state.intro, ...intro } })),
|
set((state) => ({ intro: { ...state.intro, ...intro } })),
|
||||||
setBikeState: (bike) =>
|
setBikeState: (bike) =>
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
|
import type { AudioCategory } from "@/managers/AudioManager";
|
||||||
|
|
||||||
|
export type SubtitleLanguage = "fr" | "en";
|
||||||
|
export type RepairRuntime = "js" | "python";
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
isSettingsMenuOpen: boolean;
|
||||||
|
musicVolume: number;
|
||||||
|
sfxVolume: number;
|
||||||
|
dialogueVolume: number;
|
||||||
|
subtitlesEnabled: boolean;
|
||||||
|
subtitleLanguage: SubtitleLanguage;
|
||||||
|
repairRuntime: RepairRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsActions {
|
||||||
|
setSettingsMenuOpen: (open: boolean) => void;
|
||||||
|
setMusicVolume: (volume: number) => void;
|
||||||
|
setSfxVolume: (volume: number) => void;
|
||||||
|
setDialogueVolume: (volume: number) => void;
|
||||||
|
setSubtitlesEnabled: (enabled: boolean) => void;
|
||||||
|
setSubtitleLanguage: (language: SubtitleLanguage) => void;
|
||||||
|
setRepairRuntime: (runtime: RepairRuntime) => void;
|
||||||
|
resetSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsStore = SettingsState & SettingsActions;
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: SettingsState = {
|
||||||
|
isSettingsMenuOpen: false,
|
||||||
|
musicVolume: 1,
|
||||||
|
sfxVolume: 1,
|
||||||
|
dialogueVolume: 1,
|
||||||
|
subtitlesEnabled: true,
|
||||||
|
subtitleLanguage: "fr",
|
||||||
|
repairRuntime: "js",
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampVolume(volume: number): number {
|
||||||
|
return Math.max(0, Math.min(1, volume));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAudioCategoryVolume(
|
||||||
|
category: AudioCategory,
|
||||||
|
volume: number,
|
||||||
|
): number {
|
||||||
|
const nextVolume = clampVolume(volume);
|
||||||
|
AudioManager.getInstance().setCategoryVolume(category, nextVolume);
|
||||||
|
return nextVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDefaultAudioSettings(): void {
|
||||||
|
AudioManager.getInstance().setCategoryVolume(
|
||||||
|
"music",
|
||||||
|
DEFAULT_SETTINGS.musicVolume,
|
||||||
|
);
|
||||||
|
AudioManager.getInstance().setCategoryVolume(
|
||||||
|
"sfx",
|
||||||
|
DEFAULT_SETTINGS.sfxVolume,
|
||||||
|
);
|
||||||
|
AudioManager.getInstance().setCategoryVolume(
|
||||||
|
"dialogue",
|
||||||
|
DEFAULT_SETTINGS.dialogueVolume,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDefaultAudioSettings();
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsStore>()((set) => ({
|
||||||
|
...DEFAULT_SETTINGS,
|
||||||
|
setSettingsMenuOpen: (isSettingsMenuOpen) => set({ isSettingsMenuOpen }),
|
||||||
|
setMusicVolume: (volume) =>
|
||||||
|
set({ musicVolume: setAudioCategoryVolume("music", volume) }),
|
||||||
|
setSfxVolume: (volume) =>
|
||||||
|
set({ sfxVolume: setAudioCategoryVolume("sfx", volume) }),
|
||||||
|
setDialogueVolume: (volume) =>
|
||||||
|
set({ dialogueVolume: setAudioCategoryVolume("dialogue", volume) }),
|
||||||
|
setSubtitlesEnabled: (subtitlesEnabled) => set({ subtitlesEnabled }),
|
||||||
|
setSubtitleLanguage: (subtitleLanguage) => set({ subtitleLanguage }),
|
||||||
|
setRepairRuntime: (repairRuntime) => set({ repairRuntime }),
|
||||||
|
resetSettings: () => {
|
||||||
|
applyDefaultAudioSettings();
|
||||||
|
set(DEFAULT_SETTINGS);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import type { DialogueSpeaker } from "@/types/dialogues/dialogues";
|
||||||
|
|
||||||
|
interface ActiveSubtitle {
|
||||||
|
speaker: DialogueSpeaker;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleState {
|
||||||
|
activeSubtitle: ActiveSubtitle | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubtitleActions {
|
||||||
|
setActiveSubtitle: (subtitle: ActiveSubtitle | null) => void;
|
||||||
|
clearActiveSubtitle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubtitleStore = SubtitleState & SubtitleActions;
|
||||||
|
|
||||||
|
export const useSubtitleStore = create<SubtitleStore>()((set) => ({
|
||||||
|
activeSubtitle: null,
|
||||||
|
setActiveSubtitle: (activeSubtitle) => set({ activeSubtitle }),
|
||||||
|
clearActiveSubtitle: () => set({ activeSubtitle: null }),
|
||||||
|
}));
|
||||||
@@ -3,8 +3,11 @@ import { Canvas } from "@react-three/fiber";
|
|||||||
import { useProgress } from "@react-three/drei";
|
import { useProgress } from "@react-three/drei";
|
||||||
import { EditorControls } from "@/components/editor/EditorControls";
|
import { EditorControls } from "@/components/editor/EditorControls";
|
||||||
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
import { EditorScene } from "@/components/editor/scene/EditorScene";
|
||||||
|
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
|
||||||
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay";
|
||||||
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||||
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||||
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
|
||||||
import {
|
import {
|
||||||
@@ -93,6 +96,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
status: "loading" as const,
|
status: "loading" as const,
|
||||||
}
|
}
|
||||||
: sceneLoadingState;
|
: sceneLoadingState;
|
||||||
|
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
|
||||||
|
useState<EditorCinematicPreviewRequest | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
undoCount,
|
undoCount,
|
||||||
@@ -153,6 +158,20 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setIsPlayerMode((prev) => !prev);
|
setIsPlayerMode((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handlePreviewCinematic = useCallback(
|
||||||
|
(cinematic: CinematicDefinition) => {
|
||||||
|
setCinematicPreviewRequest({
|
||||||
|
id: window.crypto.randomUUID(),
|
||||||
|
cinematic,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCinematicPreviewComplete = useCallback(() => {
|
||||||
|
setCinematicPreviewRequest(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleNodeTransform = useCallback(
|
const handleNodeTransform = useCallback(
|
||||||
(nodeIndex: number, updatedNode: MapNode) => {
|
(nodeIndex: number, updatedNode: MapNode) => {
|
||||||
setSceneData((prev) => {
|
setSceneData((prev) => {
|
||||||
@@ -237,6 +256,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
isPlayerMode={isPlayerMode}
|
isPlayerMode={isPlayerMode}
|
||||||
|
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||||
|
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
@@ -262,9 +283,11 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
onExportJson={handleExportJson}
|
onExportJson={handleExportJson}
|
||||||
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
||||||
onPlayerMode={handlePlayerMode}
|
onPlayerMode={handlePlayerMode}
|
||||||
|
onPreviewCinematic={handlePreviewCinematic}
|
||||||
isPlayerMode={isPlayerMode}
|
isPlayerMode={isPlayerMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Subtitles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export interface CinematicCameraKeyframe {
|
||||||
|
time: number;
|
||||||
|
position: Vector3Tuple;
|
||||||
|
target: Vector3Tuple;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CinematicDialogueCue {
|
||||||
|
time: number;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CinematicDefinition {
|
||||||
|
id: string;
|
||||||
|
timecode?: number;
|
||||||
|
cameraKeyframes: CinematicCameraKeyframe[];
|
||||||
|
dialogueCues?: CinematicDialogueCue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CinematicManifest {
|
||||||
|
version: 1;
|
||||||
|
cinematics: CinematicDefinition[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||||
|
|
||||||
|
export type DialogueVoiceId = "narrateur" | "fermier" | "electricienne";
|
||||||
|
export type DialogueSpeaker = "Narrateur" | "Fermier" | "Electricienne";
|
||||||
|
|
||||||
|
export interface DialogueVoice {
|
||||||
|
id: DialogueVoiceId;
|
||||||
|
speaker: DialogueSpeaker;
|
||||||
|
subtitles: Partial<Record<SubtitleLanguage, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogueDefinition {
|
||||||
|
id: string;
|
||||||
|
voice: DialogueVoiceId;
|
||||||
|
audio: string;
|
||||||
|
subtitleCueIndex: number;
|
||||||
|
timecode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogueManifest {
|
||||||
|
version: 1;
|
||||||
|
voices: DialogueVoice[];
|
||||||
|
dialogues: DialogueDefinition[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type {
|
||||||
|
CinematicCameraKeyframe,
|
||||||
|
CinematicDefinition,
|
||||||
|
CinematicDialogueCue,
|
||||||
|
CinematicManifest,
|
||||||
|
} from "@/types/cinematics/cinematics";
|
||||||
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
|
export function parseCinematicManifest(data: unknown): CinematicManifest {
|
||||||
|
if (!isRecord(data) || data.version !== 1) {
|
||||||
|
throw new Error("Invalid cinematic manifest version");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.cinematics)) {
|
||||||
|
throw new Error("Cinematic manifest requires a cinematics array");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
cinematics: data.cinematics.map(parseCinematicDefinition),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCinematicDefinition(data: unknown): CinematicDefinition {
|
||||||
|
if (!isRecord(data) || typeof data.id !== "string") {
|
||||||
|
throw new Error("Invalid cinematic definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.cameraKeyframes)) {
|
||||||
|
throw new Error(`Cinematic ${data.id} requires cameraKeyframes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraKeyframes = data.cameraKeyframes.map(parseCameraKeyframe);
|
||||||
|
if (cameraKeyframes.length < 2) {
|
||||||
|
throw new Error(`Cinematic ${data.id} requires at least two keyframes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraKeyframes.forEach((keyframe, index) => {
|
||||||
|
const previousKeyframe = cameraKeyframes[index - 1];
|
||||||
|
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||||
|
throw new Error(`Cinematic ${data.id} keyframe times must increase`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cinematic: CinematicDefinition = {
|
||||||
|
id: data.id,
|
||||||
|
cameraKeyframes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof data.timecode === "number") {
|
||||||
|
cinematic.timecode = data.timecode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(data.dialogueCues)) {
|
||||||
|
cinematic.dialogueCues = data.dialogueCues.map(parseDialogueCue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cinematic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDialogueCue(data: unknown): CinematicDialogueCue {
|
||||||
|
if (
|
||||||
|
!isRecord(data) ||
|
||||||
|
typeof data.time !== "number" ||
|
||||||
|
typeof data.dialogueId !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid cinematic dialogue cue");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: data.time,
|
||||||
|
dialogueId: data.dialogueId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCameraKeyframe(data: unknown): CinematicCameraKeyframe {
|
||||||
|
if (!isRecord(data) || typeof data.time !== "number") {
|
||||||
|
throw new Error("Invalid cinematic camera keyframe");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: data.time,
|
||||||
|
position: parseVector3(data.position),
|
||||||
|
target: parseVector3(data.target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVector3(value: unknown): Vector3Tuple {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length !== 3 ||
|
||||||
|
value.some((item) => typeof item !== "number")
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid cinematic vector");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [value[0], value[1], value[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { CinematicManifest } from "@/types/cinematics/cinematics";
|
||||||
|
import { parseCinematicManifest } from "@/utils/cinematics/cinematicManifestValidation";
|
||||||
|
|
||||||
|
const CINEMATIC_MANIFEST_PATH = "/cinematics.json";
|
||||||
|
|
||||||
|
export async function loadCinematicManifest(): Promise<CinematicManifest | null> {
|
||||||
|
const response = await fetch(CINEMATIC_MANIFEST_PATH);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseCinematicManifest(await response.json());
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import type {
|
||||||
|
DialogueDefinition,
|
||||||
|
DialogueManifest,
|
||||||
|
DialogueSpeaker,
|
||||||
|
DialogueVoice,
|
||||||
|
DialogueVoiceId,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
|
|
||||||
|
const VALID_VOICE_IDS = new Set<DialogueVoiceId>([
|
||||||
|
"narrateur",
|
||||||
|
"fermier",
|
||||||
|
"electricienne",
|
||||||
|
]);
|
||||||
|
const VALID_SPEAKERS = new Set<DialogueSpeaker>([
|
||||||
|
"Narrateur",
|
||||||
|
"Fermier",
|
||||||
|
"Electricienne",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function parseDialogueManifest(data: unknown): DialogueManifest {
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
throw new Error("Dialogue manifest must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.version !== 1) {
|
||||||
|
throw new Error("Unsupported dialogue manifest version");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) {
|
||||||
|
throw new Error("Dialogue manifest requires voices and dialogues arrays");
|
||||||
|
}
|
||||||
|
|
||||||
|
const voices = data.voices.map(parseDialogueVoice);
|
||||||
|
const voiceIds = new Set(voices.map((voice) => voice.id));
|
||||||
|
const dialogues = data.dialogues.map((dialogue) =>
|
||||||
|
parseDialogueDefinition(dialogue, voiceIds),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
voices,
|
||||||
|
dialogues,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDialogueVoice(data: unknown): DialogueVoice {
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
throw new Error("Dialogue voice must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDialogueVoiceId(data.id)) {
|
||||||
|
throw new Error("Dialogue voice has an invalid id");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDialogueSpeaker(data.speaker)) {
|
||||||
|
throw new Error(`Dialogue voice ${data.id} has an invalid speaker`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(data.subtitles)) {
|
||||||
|
throw new Error(`Dialogue voice ${data.id} must define subtitles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitles: DialogueVoice["subtitles"] = {};
|
||||||
|
const frSubtitle = getOptionalPath(data.subtitles.fr);
|
||||||
|
const enSubtitle = getOptionalPath(data.subtitles.en);
|
||||||
|
if (frSubtitle) subtitles.fr = frSubtitle;
|
||||||
|
if (enSubtitle) subtitles.en = enSubtitle;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
speaker: data.speaker,
|
||||||
|
subtitles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDialogueDefinition(
|
||||||
|
data: unknown,
|
||||||
|
voiceIds: Set<DialogueVoiceId>,
|
||||||
|
): DialogueDefinition {
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
throw new Error("Dialogue definition must be an object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.id !== "string" || data.id.length === 0) {
|
||||||
|
throw new Error("Dialogue definition has an invalid id");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDialogueVoiceId(data.voice) || !voiceIds.has(data.voice)) {
|
||||||
|
throw new Error(`Dialogue ${data.id} references an unknown voice`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.audio !== "string" || data.audio.length === 0) {
|
||||||
|
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitleCueIndex = data.subtitleCueIndex;
|
||||||
|
if (
|
||||||
|
typeof subtitleCueIndex !== "number" ||
|
||||||
|
!Number.isInteger(subtitleCueIndex) ||
|
||||||
|
subtitleCueIndex < 1
|
||||||
|
) {
|
||||||
|
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timecode = data.timecode;
|
||||||
|
if (timecode !== undefined && typeof timecode !== "number") {
|
||||||
|
throw new Error(`Dialogue ${data.id} has an invalid timecode`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogue: DialogueDefinition = {
|
||||||
|
id: data.id,
|
||||||
|
voice: data.voice,
|
||||||
|
audio: data.audio,
|
||||||
|
subtitleCueIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (timecode !== undefined) dialogue.timecode = timecode;
|
||||||
|
|
||||||
|
return dialogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOptionalPath(value: unknown): string | undefined {
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDialogueVoiceId(value: unknown): value is DialogueVoiceId {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && VALID_VOICE_IDS.has(value as DialogueVoiceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDialogueSpeaker(value: unknown): value is DialogueSpeaker {
|
||||||
|
return (
|
||||||
|
typeof value === "string" && VALID_SPEAKERS.has(value as DialogueSpeaker)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type {
|
||||||
|
DialogueDefinition,
|
||||||
|
DialogueManifest,
|
||||||
|
DialogueVoice,
|
||||||
|
} from "@/types/dialogues/dialogues";
|
||||||
|
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||||
|
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
||||||
|
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||||
|
import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
|
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
||||||
|
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||||
|
|
||||||
|
export interface DialogueSubtitleCue {
|
||||||
|
voice: DialogueVoice;
|
||||||
|
cue: SubtitleCue;
|
||||||
|
subtitlePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
||||||
|
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseDialogueManifest(await response.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDialogueSubtitlePath(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): string | null {
|
||||||
|
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||||
|
if (!voice) return null;
|
||||||
|
|
||||||
|
return getVoiceSubtitlePath(voice, language);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDialogueVoice(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
voiceId: DialogueDefinition["voice"],
|
||||||
|
): DialogueVoice | null {
|
||||||
|
return manifest.voices.find((voice) => voice.id === voiceId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadDialogueSubtitleCue(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogue: DialogueDefinition,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): Promise<DialogueSubtitleCue | null> {
|
||||||
|
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||||
|
if (!voice) return null;
|
||||||
|
|
||||||
|
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
||||||
|
if (!subtitles) return null;
|
||||||
|
|
||||||
|
const cue = subtitles.cues.find(
|
||||||
|
(item) => item.index === dialogue.subtitleCueIndex,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!cue) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
voice,
|
||||||
|
cue,
|
||||||
|
subtitlePath: subtitles.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadVoiceSubtitleCues(
|
||||||
|
voice: DialogueVoice,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): Promise<{ path: string; cues: SubtitleCue[] } | null> {
|
||||||
|
const paths = getVoiceSubtitlePaths(voice, language);
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
const srtContent = await loadSrtContent(path);
|
||||||
|
if (srtContent !== null) {
|
||||||
|
return { path, cues: parseSrt(srtContent) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSrtContent(path: string): Promise<string | null> {
|
||||||
|
const response = await fetch(path);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVoiceSubtitlePaths(
|
||||||
|
voice: DialogueVoice,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): string[] {
|
||||||
|
return [voice.subtitles[language], voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE]]
|
||||||
|
.filter((path): path is string => Boolean(path))
|
||||||
|
.filter((path, index, paths) => paths.indexOf(path) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVoiceSubtitlePath(
|
||||||
|
voice: DialogueVoice,
|
||||||
|
language: SubtitleLanguage,
|
||||||
|
): string | null {
|
||||||
|
return (
|
||||||
|
voice.subtitles[language] ??
|
||||||
|
voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { AudioManager } from "@/managers/AudioManager";
|
||||||
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
|
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||||
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
|
import {
|
||||||
|
loadDialogueManifest,
|
||||||
|
loadDialogueSubtitleCue,
|
||||||
|
} from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
|
||||||
|
interface QueuedDialogueRequest {
|
||||||
|
manifest: DialogueManifest;
|
||||||
|
dialogueId: string;
|
||||||
|
resolve: (audio: HTMLAudioElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
|
||||||
|
const dialogueQueue: QueuedDialogueRequest[] = [];
|
||||||
|
let gameplayDialogueManifestPromise: Promise<DialogueManifest | null> | null =
|
||||||
|
null;
|
||||||
|
let isDialogueQueuePlaying = false;
|
||||||
|
|
||||||
|
export function queueDialogueById(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogueId: string,
|
||||||
|
): Promise<HTMLAudioElement | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
dialogueQueue.push({ manifest, dialogueId, resolve });
|
||||||
|
void playNextQueuedDialogue();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearQueuedDialogues(): void {
|
||||||
|
while (dialogueQueue.length > 0) {
|
||||||
|
dialogueQueue.shift()?.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playGameplayDialogueById(
|
||||||
|
dialogueId: string,
|
||||||
|
): Promise<HTMLAudioElement | null> {
|
||||||
|
gameplayDialogueManifestPromise ??= loadDialogueManifest();
|
||||||
|
const manifest = await gameplayDialogueManifestPromise;
|
||||||
|
if (!manifest) return null;
|
||||||
|
|
||||||
|
return queueDialogueById(manifest, dialogueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function playDialogueById(
|
||||||
|
manifest: DialogueManifest,
|
||||||
|
dialogueId: string,
|
||||||
|
): Promise<HTMLAudioElement | null> {
|
||||||
|
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
||||||
|
if (!dialogue) return null;
|
||||||
|
|
||||||
|
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
||||||
|
const subtitle = await loadDialogueSubtitleCue(
|
||||||
|
manifest,
|
||||||
|
dialogue,
|
||||||
|
subtitleLanguage,
|
||||||
|
);
|
||||||
|
const audio = AudioManager.getInstance().playSound(dialogue.audio, 1, {
|
||||||
|
category: "dialogue",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subtitle) return audio;
|
||||||
|
|
||||||
|
const clearSubtitle = (): void => {
|
||||||
|
useSubtitleStore.getState().clearActiveSubtitle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanup = (): void => {
|
||||||
|
audio.removeEventListener("play", syncSubtitle);
|
||||||
|
audio.removeEventListener("timeupdate", syncSubtitle);
|
||||||
|
audio.removeEventListener("ended", cleanup);
|
||||||
|
audio.removeEventListener("pause", cleanup);
|
||||||
|
clearSubtitle();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSubtitle = (): void => {
|
||||||
|
const currentTime = audio.currentTime;
|
||||||
|
const shouldShowSubtitle =
|
||||||
|
currentTime >= subtitle.cue.startTime &&
|
||||||
|
currentTime <= subtitle.cue.endTime;
|
||||||
|
|
||||||
|
if (shouldShowSubtitle) {
|
||||||
|
useSubtitleStore.getState().setActiveSubtitle({
|
||||||
|
speaker: subtitle.voice.speaker,
|
||||||
|
text: subtitle.cue.text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSubtitle();
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener("play", syncSubtitle);
|
||||||
|
audio.addEventListener("timeupdate", syncSubtitle);
|
||||||
|
audio.addEventListener("ended", cleanup);
|
||||||
|
audio.addEventListener("pause", cleanup);
|
||||||
|
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playNextQueuedDialogue(): Promise<void> {
|
||||||
|
if (isDialogueQueuePlaying) return;
|
||||||
|
|
||||||
|
isDialogueQueuePlaying = true;
|
||||||
|
|
||||||
|
while (dialogueQueue.length > 0) {
|
||||||
|
const request = dialogueQueue.shift();
|
||||||
|
if (!request) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audio = await playDialogueById(
|
||||||
|
request.manifest,
|
||||||
|
request.dialogueId,
|
||||||
|
);
|
||||||
|
request.resolve(audio);
|
||||||
|
if (audio) await waitForDialogueToFinish(audio);
|
||||||
|
} catch {
|
||||||
|
request.resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDialogueQueuePlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForDialogueToFinish(audio: HTMLAudioElement): Promise<void> {
|
||||||
|
if (audio.ended) return Promise.resolve();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let hasStarted = !audio.paused;
|
||||||
|
let startTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function cleanup(): void {
|
||||||
|
if (startTimeout) clearTimeout(startTimeout);
|
||||||
|
audio.removeEventListener("play", handlePlay);
|
||||||
|
audio.removeEventListener("ended", finish);
|
||||||
|
audio.removeEventListener("pause", finish);
|
||||||
|
audio.removeEventListener("error", finish);
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(): void {
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePlay(): void {
|
||||||
|
hasStarted = true;
|
||||||
|
if (startTimeout) clearTimeout(startTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.addEventListener("play", handlePlay);
|
||||||
|
audio.addEventListener("ended", finish);
|
||||||
|
audio.addEventListener("pause", finish);
|
||||||
|
audio.addEventListener("error", finish);
|
||||||
|
|
||||||
|
startTimeout = setTimeout(() => {
|
||||||
|
if (!hasStarted && audio.paused) finish();
|
||||||
|
}, DIALOGUE_PLAY_START_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
export interface SubtitleCue {
|
||||||
|
index: number;
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SRT_TIME_SEPARATOR = " --> ";
|
||||||
|
const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/;
|
||||||
|
|
||||||
|
export function parseSrt(srtContent: string): SubtitleCue[] {
|
||||||
|
return srtContent
|
||||||
|
.replace(/^\uFEFF/, "")
|
||||||
|
.replace(/\r/g, "")
|
||||||
|
.trim()
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map(parseSrtBlock)
|
||||||
|
.filter((cue): cue is SubtitleCue => cue !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSrtBlock(block: string): SubtitleCue | null {
|
||||||
|
const lines = block
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (lines.length < 3) return null;
|
||||||
|
|
||||||
|
const index = Number(lines[0]);
|
||||||
|
if (!Number.isInteger(index)) return null;
|
||||||
|
|
||||||
|
const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? [];
|
||||||
|
if (!start || !end) return null;
|
||||||
|
|
||||||
|
const startTime = parseSrtTime(start);
|
||||||
|
const endTime = parseSrtTime(end);
|
||||||
|
if (startTime === null || endTime === null || endTime <= startTime) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
text: lines.slice(2).join("\n"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSrtTime(value: string): number | null {
|
||||||
|
const match = value.match(SRT_TIME_PATTERN);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, hours, minutes, seconds, milliseconds] = match;
|
||||||
|
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
Number(hours) * 3600 +
|
||||||
|
Number(minutes) * 60 +
|
||||||
|
Number(seconds) +
|
||||||
|
Number(milliseconds) / 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { MutableRefObject } from "react";
|
||||||
|
import { useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import gsap from "gsap";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import type {
|
||||||
|
CinematicDefinition,
|
||||||
|
CinematicManifest,
|
||||||
|
} from "@/types/cinematics/cinematics";
|
||||||
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import { queueDialogueById } from "@/utils/dialogues/playDialogue";
|
||||||
|
|
||||||
|
export function GameCinematics(): null {
|
||||||
|
const camera = useThree((state) => state.camera);
|
||||||
|
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
|
||||||
|
const [dialogueManifest, setDialogueManifest] =
|
||||||
|
useState<DialogueManifest | null>(null);
|
||||||
|
const playedCinematicsRef = useRef(new Set<string>());
|
||||||
|
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
||||||
|
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const activeAudios = activeAudiosRef.current;
|
||||||
|
|
||||||
|
void loadCinematicManifest()
|
||||||
|
.then((loadedManifest) => {
|
||||||
|
if (mounted) setManifest(loadedManifest);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.error("GameCinematics", "Failed to load cinematic manifest", {
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
void loadDialogueManifest()
|
||||||
|
.then((loadedManifest) => {
|
||||||
|
if (mounted) setDialogueManifest(loadedManifest);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.error("GameCinematics", "Failed to load dialogue manifest", {
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
stopActiveCinematic(timelineRef);
|
||||||
|
activeAudios.forEach((audio) => audio.pause());
|
||||||
|
activeAudios.clear();
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const elapsedTime = clock.getElapsedTime();
|
||||||
|
|
||||||
|
manifest.cinematics.forEach((cinematic) => {
|
||||||
|
if (cinematic.timecode === undefined) return;
|
||||||
|
if (cinematic.timecode > elapsedTime) return;
|
||||||
|
if (cinematic.dialogueCues && !dialogueManifest) return;
|
||||||
|
if (playedCinematicsRef.current.has(cinematic.id)) return;
|
||||||
|
|
||||||
|
playedCinematicsRef.current.add(cinematic.id);
|
||||||
|
playCinematic(camera, cinematic, timelineRef, {
|
||||||
|
dialogueManifest,
|
||||||
|
activeAudiosRef,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopActiveCinematic(
|
||||||
|
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
|
||||||
|
): void {
|
||||||
|
timelineRef.current?.kill();
|
||||||
|
timelineRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCinematic(
|
||||||
|
camera: THREE.Camera,
|
||||||
|
cinematic: CinematicDefinition,
|
||||||
|
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
|
||||||
|
dialogueOptions: {
|
||||||
|
dialogueManifest: DialogueManifest | null;
|
||||||
|
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
const firstKeyframe = cinematic.cameraKeyframes[0];
|
||||||
|
if (!firstKeyframe) return;
|
||||||
|
|
||||||
|
document.exitPointerLock();
|
||||||
|
timelineRef.current?.kill();
|
||||||
|
useGameStore.getState().setCinematicPlaying(true);
|
||||||
|
|
||||||
|
const target = new THREE.Vector3(...firstKeyframe.target);
|
||||||
|
camera.position.set(...firstKeyframe.position);
|
||||||
|
camera.lookAt(target);
|
||||||
|
|
||||||
|
const timeline = gsap.timeline({
|
||||||
|
onUpdate: () => camera.lookAt(target),
|
||||||
|
onComplete: () => {
|
||||||
|
timelineRef.current = null;
|
||||||
|
useGameStore.getState().setCinematicPlaying(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
|
||||||
|
const previousKeyframe = cinematic.cameraKeyframes[index];
|
||||||
|
if (!previousKeyframe) return;
|
||||||
|
|
||||||
|
const duration = keyframe.time - previousKeyframe.time;
|
||||||
|
timeline.to(
|
||||||
|
camera.position,
|
||||||
|
{
|
||||||
|
x: keyframe.position[0],
|
||||||
|
y: keyframe.position[1],
|
||||||
|
z: keyframe.position[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
previousKeyframe.time,
|
||||||
|
);
|
||||||
|
timeline.to(
|
||||||
|
target,
|
||||||
|
{
|
||||||
|
x: keyframe.target[0],
|
||||||
|
y: keyframe.target[1],
|
||||||
|
z: keyframe.target[2],
|
||||||
|
duration,
|
||||||
|
ease: "power2.inOut",
|
||||||
|
},
|
||||||
|
previousKeyframe.time,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
cinematic.dialogueCues?.forEach((cue) => {
|
||||||
|
timeline.call(
|
||||||
|
() => {
|
||||||
|
if (!dialogueOptions.dialogueManifest) return;
|
||||||
|
|
||||||
|
void queueDialogueById(
|
||||||
|
dialogueOptions.dialogueManifest,
|
||||||
|
cue.dialogueId,
|
||||||
|
).then((audio) => {
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
dialogueOptions.activeAudiosRef.current.add(audio);
|
||||||
|
audio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => dialogueOptions.activeAudiosRef.current.delete(audio),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
cue.time,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
timelineRef.current = timeline;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useFrame } from "@react-three/fiber";
|
||||||
|
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||||
|
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||||
|
import {
|
||||||
|
clearQueuedDialogues,
|
||||||
|
queueDialogueById,
|
||||||
|
} from "@/utils/dialogues/playDialogue";
|
||||||
|
import { logger } from "@/utils/core/Logger";
|
||||||
|
|
||||||
|
export function GameDialogues(): null {
|
||||||
|
const [manifest, setManifest] = useState<DialogueManifest | null>(null);
|
||||||
|
const playedDialoguesRef = useRef(new Set<string>());
|
||||||
|
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
const activeAudios = activeAudiosRef.current;
|
||||||
|
|
||||||
|
void loadDialogueManifest()
|
||||||
|
.then((loadedManifest) => {
|
||||||
|
if (mounted) setManifest(loadedManifest);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logger.error("GameDialogues", "Failed to load dialogue manifest", {
|
||||||
|
error: error instanceof Error ? error : String(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
clearQueuedDialogues();
|
||||||
|
activeAudios.forEach((audio) => audio.pause());
|
||||||
|
activeAudios.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useFrame(({ clock }) => {
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const elapsedTime = clock.getElapsedTime();
|
||||||
|
|
||||||
|
manifest.dialogues.forEach((dialogue) => {
|
||||||
|
if (dialogue.timecode === undefined) return;
|
||||||
|
if (dialogue.timecode > elapsedTime) return;
|
||||||
|
if (playedDialoguesRef.current.has(dialogue.id)) return;
|
||||||
|
|
||||||
|
playedDialoguesRef.current.add(dialogue.id);
|
||||||
|
|
||||||
|
void queueDialogueById(manifest, dialogue.id).then((audio) => {
|
||||||
|
if (!audio) return;
|
||||||
|
activeAudiosRef.current.add(audio);
|
||||||
|
audio.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => activeAudiosRef.current.delete(audio),
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -65,11 +65,13 @@ interface GameMapProps {
|
|||||||
onLoaded?: (() => void) | undefined;
|
onLoaded?: (() => void) | undefined;
|
||||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||||
onOctreeReady: OctreeReadyHandler;
|
onOctreeReady: OctreeReadyHandler;
|
||||||
|
buildOctree?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_RENDER_BATCH_SIZE = 12;
|
const MAP_RENDER_BATCH_SIZE = 12;
|
||||||
|
|
||||||
export function GameMap({
|
export function GameMap({
|
||||||
|
buildOctree = true,
|
||||||
onLoaded,
|
onLoaded,
|
||||||
onLoadingStateChange,
|
onLoadingStateChange,
|
||||||
onOctreeReady,
|
onOctreeReady,
|
||||||
@@ -197,6 +199,7 @@ export function GameMap({
|
|||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
<GameMapCollision
|
<GameMapCollision
|
||||||
|
buildOctree={buildOctree}
|
||||||
mapReady={mapReady}
|
mapReady={mapReady}
|
||||||
nodes={mapNodes}
|
nodes={mapNodes}
|
||||||
onLoaded={onLoaded}
|
onLoaded={onLoaded}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ interface ResolvedGameMapCollisionNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface GameMapCollisionProps {
|
interface GameMapCollisionProps {
|
||||||
|
buildOctree?: boolean;
|
||||||
mapReady: boolean;
|
mapReady: boolean;
|
||||||
nodes: readonly GameMapCollisionNode[];
|
nodes: readonly GameMapCollisionNode[];
|
||||||
onLoaded?: (() => void) | undefined;
|
onLoaded?: (() => void) | undefined;
|
||||||
@@ -92,6 +93,7 @@ function isCollisionNode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GameMapCollision({
|
export function GameMapCollision({
|
||||||
|
buildOctree = true,
|
||||||
mapReady,
|
mapReady,
|
||||||
nodes,
|
nodes,
|
||||||
onLoaded,
|
onLoaded,
|
||||||
@@ -129,7 +131,7 @@ export function GameMapCollision({
|
|||||||
groupRef,
|
groupRef,
|
||||||
handleOctreeReady,
|
handleOctreeReady,
|
||||||
collisionReady ? collisionNodes.length : 0,
|
collisionReady ? collisionNodes.length : 0,
|
||||||
collisionReady && collisionNodes.length > 0,
|
buildOctree && collisionReady && collisionNodes.length > 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+22
-3
@@ -12,6 +12,8 @@ import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControl
|
|||||||
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
|
||||||
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
import { HandTrackingGlove } from "@/components/three/handTracking/HandTrackingGlove";
|
||||||
import { Environment } from "@/world/Environment";
|
import { Environment } from "@/world/Environment";
|
||||||
|
import { GameCinematics } from "@/world/GameCinematics";
|
||||||
|
import { GameDialogues } from "@/world/GameDialogues";
|
||||||
import { GameMusic } from "@/world/GameMusic";
|
import { GameMusic } from "@/world/GameMusic";
|
||||||
import { Lighting } from "@/world/Lighting";
|
import { Lighting } from "@/world/Lighting";
|
||||||
import { GameMap } from "@/world/GameMap";
|
import { GameMap } from "@/world/GameMap";
|
||||||
@@ -24,12 +26,23 @@ interface WorldProps {
|
|||||||
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
onLoadingStateChange?: SceneLoadingChangeHandler | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasBootFlag(name: string): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return new URLSearchParams(window.location.search).has(name);
|
||||||
|
}
|
||||||
|
|
||||||
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
||||||
const cameraMode = useCameraMode();
|
const cameraMode = useCameraMode();
|
||||||
const sceneMode = useSceneMode();
|
const sceneMode = useSceneMode();
|
||||||
const { status, usageStatus } = useHandTrackingSnapshot();
|
const { status, usageStatus } = useHandTrackingSnapshot();
|
||||||
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } =
|
const { octree, showGameStage, handleGameMapLoaded, handleOctreeReady } =
|
||||||
useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
useWorldSceneLoading({ sceneMode, onLoadingStateChange });
|
||||||
|
const noCinematics = hasBootFlag("noCinematics");
|
||||||
|
const noDialogues = hasBootFlag("noDialogues");
|
||||||
|
const noMap = hasBootFlag("noMap");
|
||||||
|
const noMusic = hasBootFlag("noMusic");
|
||||||
|
const noOctree = hasBootFlag("noOctree");
|
||||||
|
const noPlayer = hasBootFlag("noPlayer");
|
||||||
const playerSpawnPosition =
|
const playerSpawnPosition =
|
||||||
sceneMode === "game"
|
sceneMode === "game"
|
||||||
? PLAYER_SPAWN_POSITION_GAME
|
? PLAYER_SPAWN_POSITION_GAME
|
||||||
@@ -52,13 +65,18 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||||
{sceneMode === "game" ? (
|
{sceneMode === "game" ? (
|
||||||
<>
|
<>
|
||||||
<GameMusic />
|
{noMusic ? null : <GameMusic />}
|
||||||
|
{noCinematics ? null : <GameCinematics />}
|
||||||
|
{noDialogues ? null : <GameDialogues />}
|
||||||
|
{noMap ? null : (
|
||||||
<GameMap
|
<GameMap
|
||||||
|
buildOctree={!noOctree}
|
||||||
onLoaded={handleGameMapLoaded}
|
onLoaded={handleGameMapLoaded}
|
||||||
onLoadingStateChange={onLoadingStateChange}
|
onLoadingStateChange={onLoadingStateChange}
|
||||||
onOctreeReady={handleOctreeReady}
|
onOctreeReady={handleOctreeReady}
|
||||||
/>
|
/>
|
||||||
{showGameStage ? (
|
)}
|
||||||
|
{noMap || showGameStage ? (
|
||||||
<Physics>
|
<Physics>
|
||||||
<GameStageContent />
|
<GameStageContent />
|
||||||
</Physics>
|
</Physics>
|
||||||
@@ -67,7 +85,8 @@ export function World({ onLoadingStateChange }: WorldProps): React.JSX.Element {
|
|||||||
) : (
|
) : (
|
||||||
<TestMap onOctreeReady={handleOctreeReady} />
|
<TestMap onOctreeReady={handleOctreeReady} />
|
||||||
)}
|
)}
|
||||||
{cameraMode !== "debug" ? (
|
|
||||||
|
{cameraMode !== "debug" && !noPlayer ? (
|
||||||
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
<Player octree={octree} spawnPosition={playerSpawnPosition} />
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import {
|
|||||||
PLAYER_WALK_SPEED,
|
PLAYER_WALK_SPEED,
|
||||||
PLAYER_XZ_DAMPING_FACTOR,
|
PLAYER_XZ_DAMPING_FACTOR,
|
||||||
} from "@/data/player/playerConfig";
|
} from "@/data/player/playerConfig";
|
||||||
|
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||||
import { InteractionManager } from "@/managers/InteractionManager";
|
import { InteractionManager } from "@/managers/InteractionManager";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
|
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
type Keys = {
|
type Keys = {
|
||||||
@@ -54,6 +57,13 @@ const _up = new THREE.Vector3(0, 1, 0);
|
|||||||
const _translateVec = new THREE.Vector3();
|
const _translateVec = new THREE.Vector3();
|
||||||
const _collisionCorrection = new THREE.Vector3();
|
const _collisionCorrection = new THREE.Vector3();
|
||||||
|
|
||||||
|
function isPlayerInputLocked(): boolean {
|
||||||
|
return (
|
||||||
|
useSettingsStore.getState().isSettingsMenuOpen ||
|
||||||
|
useGameStore.getState().isCinematicPlaying
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
|
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
|
||||||
switch (key.toLowerCase()) {
|
switch (key.toLowerCase()) {
|
||||||
case MOVE_FORWARD_KEY:
|
case MOVE_FORWARD_KEY:
|
||||||
@@ -78,6 +88,8 @@ export function PlayerController({
|
|||||||
spawnPosition,
|
spawnPosition,
|
||||||
}: PlayerControllerProps): null {
|
}: PlayerControllerProps): null {
|
||||||
const camera = useThree((state) => state.camera);
|
const camera = useThree((state) => state.camera);
|
||||||
|
const movementLocked = useRepairMovementLocked();
|
||||||
|
const movementLockedRef = useRef(movementLocked);
|
||||||
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
const keys = useRef<Keys>({ ...DEFAULT_KEYS });
|
||||||
const velocity = useRef(new THREE.Vector3());
|
const velocity = useRef(new THREE.Vector3());
|
||||||
const onFloor = useRef(false);
|
const onFloor = useRef(false);
|
||||||
@@ -104,16 +116,38 @@ export function PlayerController({
|
|||||||
camera.position.copy(capsule.current.end);
|
camera.position.copy(capsule.current.end);
|
||||||
}, [camera, spawnPosition]);
|
}, [camera, spawnPosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
movementLockedRef.current = movementLocked;
|
||||||
|
|
||||||
|
if (!movementLocked) return;
|
||||||
|
|
||||||
|
keys.current = { ...DEFAULT_KEYS };
|
||||||
|
wantsJump.current = false;
|
||||||
|
velocity.current.setX(0);
|
||||||
|
velocity.current.setZ(0);
|
||||||
|
}, [movementLocked]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interaction = InteractionManager.getInstance();
|
const interaction = InteractionManager.getInstance();
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent): void => {
|
const handleKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (isPlayerInputLocked()) return;
|
||||||
|
|
||||||
if (setMovementKey(keys.current, event.key, true)) {
|
if (setMovementKey(keys.current, event.key, true)) {
|
||||||
|
if (movementLockedRef.current) {
|
||||||
|
keys.current = { ...DEFAULT_KEYS };
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.key === JUMP_KEY) {
|
if (event.key === JUMP_KEY) {
|
||||||
|
if (movementLockedRef.current) {
|
||||||
|
wantsJump.current = false;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
wantsJump.current = true;
|
wantsJump.current = true;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
return;
|
return;
|
||||||
@@ -128,12 +162,15 @@ export function PlayerController({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyUp = (event: KeyboardEvent): void => {
|
const handleKeyUp = (event: KeyboardEvent): void => {
|
||||||
|
if (isPlayerInputLocked()) return;
|
||||||
|
|
||||||
if (setMovementKey(keys.current, event.key, false)) {
|
if (setMovementKey(keys.current, event.key, false)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent): void => {
|
const handleMouseDown = (event: MouseEvent): void => {
|
||||||
|
if (isPlayerInputLocked()) return;
|
||||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||||
if (interaction.getState().focused?.kind === "grab") {
|
if (interaction.getState().focused?.kind === "grab") {
|
||||||
interaction.pressInteract();
|
interaction.pressInteract();
|
||||||
@@ -141,6 +178,7 @@ export function PlayerController({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = (event: MouseEvent): void => {
|
const handleMouseUp = (event: MouseEvent): void => {
|
||||||
|
if (isPlayerInputLocked()) return;
|
||||||
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
if (event.button !== PRIMARY_INTERACT_MOUSE_BUTTON) return;
|
||||||
if (interaction.getState().holding) {
|
if (interaction.getState().holding) {
|
||||||
interaction.releaseInteract();
|
interaction.releaseInteract();
|
||||||
@@ -162,6 +200,13 @@ export function PlayerController({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
|
if (isPlayerInputLocked()) {
|
||||||
|
keys.current = { ...DEFAULT_KEYS };
|
||||||
|
velocity.current.set(0, 0, 0);
|
||||||
|
wantsJump.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
const dt = Math.min(delta, PLAYER_MAX_DELTA);
|
||||||
|
|
||||||
camera.getWorldDirection(_forward);
|
camera.getWorldDirection(_forward);
|
||||||
@@ -172,10 +217,12 @@ export function PlayerController({
|
|||||||
}
|
}
|
||||||
|
|
||||||
_wishDir.set(0, 0, 0);
|
_wishDir.set(0, 0, 0);
|
||||||
|
if (!movementLocked) {
|
||||||
if (keys.current.forward) _wishDir.add(_forward);
|
if (keys.current.forward) _wishDir.add(_forward);
|
||||||
if (keys.current.backward) _wishDir.sub(_forward);
|
if (keys.current.backward) _wishDir.sub(_forward);
|
||||||
if (keys.current.left) _wishDir.sub(_right);
|
if (keys.current.left) _wishDir.sub(_right);
|
||||||
if (keys.current.right) _wishDir.add(_right);
|
if (keys.current.right) _wishDir.add(_right);
|
||||||
|
}
|
||||||
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
if (_wishDir.lengthSq() > 0) _wishDir.normalize();
|
||||||
|
|
||||||
const accel = onFloor.current
|
const accel = onFloor.current
|
||||||
|
|||||||
+571
-2
@@ -6,12 +6,20 @@ import { fileURLToPath } from "node:url";
|
|||||||
import type { ServerResponse } from "node:http";
|
import type { ServerResponse } from "node:http";
|
||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import { parseMapNodes } from "./src/utils/map/mapNodeValidation";
|
import { parseMapNodes } from "./src/utils/map/mapNodeValidation";
|
||||||
|
import { parseSrt } from "./src/utils/subtitles/parseSrt";
|
||||||
|
|
||||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
|
||||||
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
|
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
|
||||||
|
const MAX_SRT_PAYLOAD_BYTES = 256 * 1024;
|
||||||
|
const MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
|
const MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES = 256 * 1024;
|
||||||
const JSON_HEADERS = { "Content-Type": "application/json" };
|
const JSON_HEADERS = { "Content-Type": "application/json" };
|
||||||
type JsonResponseBody = Readonly<Record<string, string | boolean>>;
|
type JsonValue = string | number | boolean | null | JsonValue[] | JsonObject;
|
||||||
|
type JsonObject = { readonly [key: string]: JsonValue };
|
||||||
|
type JsonResponseBody = Readonly<Record<string, JsonValue>>;
|
||||||
|
const SRT_VOICES = new Set(["narrateur", "fermier", "electricienne"]);
|
||||||
|
const SRT_LANGUAGES = new Set(["fr", "en"]);
|
||||||
|
|
||||||
function sendJson(
|
function sendJson(
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -72,8 +80,569 @@ const saveMapPlugin = (): Plugin => ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveSrtPlugin = (): Plugin => ({
|
||||||
|
name: "save-srt-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api/save-srt", async (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (size > MAX_SRT_PAYLOAD_BYTES) {
|
||||||
|
sendJson(res, 413, { error: "Payload too large" });
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
|
||||||
|
if (!isSrtPayload(data)) {
|
||||||
|
sendJson(res, 400, { error: "Invalid SRT payload" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidSrtContent(data.content)) {
|
||||||
|
sendJson(res, 400, { error: "Invalid SRT content" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitlesRoot = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"public/sounds/dialogue/subtitles",
|
||||||
|
);
|
||||||
|
const srtPath = path.resolve(
|
||||||
|
subtitlesRoot,
|
||||||
|
data.language,
|
||||||
|
`${data.voice}.srt`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!srtPath.startsWith(`${subtitlesRoot}${path.sep}`)) {
|
||||||
|
sendJson(res, 400, { error: "Invalid SRT path" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(srtPath), { recursive: true });
|
||||||
|
await fs.promises.writeFile(srtPath, data.content, "utf8");
|
||||||
|
sendJson(res, 200, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof SyntaxError ? 400 : 500;
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
sendJson(res, status, { error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const validateDialoguesPlugin = (): Plugin => ({
|
||||||
|
name: "validate-dialogues-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api/validate-dialogues", async (req, res) => {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "GET" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await validateDialogueAssets();
|
||||||
|
sendJson(res, result.valid ? 200 : 400, result);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
sendJson(res, 500, { error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveDialogueManifestPlugin = (): Plugin => ({
|
||||||
|
name: "save-dialogue-manifest-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api/save-dialogues", async (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (size > MAX_DIALOGUE_MANIFEST_PAYLOAD_BYTES) {
|
||||||
|
sendJson(res, 413, { error: "Payload too large" });
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
|
||||||
|
parseDialogueManifestData(data);
|
||||||
|
|
||||||
|
const manifestPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"public/sounds/dialogue/dialogues.json",
|
||||||
|
);
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify(data, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
sendJson(res, 200, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof SyntaxError ? 400 : 500;
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
sendJson(res, status, { error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveCinematicManifestPlugin = (): Plugin => ({
|
||||||
|
name: "save-cinematic-manifest-api",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use("/api/save-cinematics", async (req, res) => {
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
for await (const chunk of req) {
|
||||||
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||||
|
size += buffer.length;
|
||||||
|
if (size > MAX_CINEMATIC_MANIFEST_PAYLOAD_BYTES) {
|
||||||
|
sendJson(res, 413, { error: "Payload too large" });
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
|
||||||
|
const manifest = parseCinematicManifestData(data);
|
||||||
|
const dialogueManifest = await loadDialogueManifestData();
|
||||||
|
validateCinematicDialogueCues(manifest, dialogueManifest);
|
||||||
|
|
||||||
|
const manifestPath = path.resolve(__dirname, "public/cinematics.json");
|
||||||
|
await fs.promises.writeFile(
|
||||||
|
manifestPath,
|
||||||
|
`${JSON.stringify(data, null, 2)}\n`,
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
sendJson(res, 200, { success: true });
|
||||||
|
} catch (err) {
|
||||||
|
const status = err instanceof SyntaxError ? 400 : 500;
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
sendJson(res, status, { error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SrtPayload {
|
||||||
|
voice: string;
|
||||||
|
language: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogueManifestData {
|
||||||
|
voices: DialogueVoiceData[];
|
||||||
|
dialogues: DialogueData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogueVoiceData {
|
||||||
|
id: string;
|
||||||
|
speaker: string;
|
||||||
|
subtitles: Partial<Record<"fr" | "en", string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogueData {
|
||||||
|
id: string;
|
||||||
|
voice: string;
|
||||||
|
audio: string;
|
||||||
|
subtitleCueIndex: number;
|
||||||
|
timecode?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CinematicManifestData {
|
||||||
|
cinematics: CinematicData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CinematicData {
|
||||||
|
id: string;
|
||||||
|
timecode?: number;
|
||||||
|
dialogueCues?: CinematicDialogueCueData[];
|
||||||
|
cameraKeyframes: CinematicKeyframeData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CinematicDialogueCueData {
|
||||||
|
time: number;
|
||||||
|
dialogueId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CinematicKeyframeData {
|
||||||
|
time: number;
|
||||||
|
position: [number, number, number];
|
||||||
|
target: [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSrtPayload(data: unknown): data is SrtPayload {
|
||||||
|
if (!data || typeof data !== "object") return false;
|
||||||
|
|
||||||
|
const payload = data as Partial<SrtPayload>;
|
||||||
|
return (
|
||||||
|
typeof payload.voice === "string" &&
|
||||||
|
SRT_VOICES.has(payload.voice) &&
|
||||||
|
typeof payload.language === "string" &&
|
||||||
|
SRT_LANGUAGES.has(payload.language) &&
|
||||||
|
typeof payload.content === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSrtContent(content: string): boolean {
|
||||||
|
const blocks = content
|
||||||
|
.replace(/^\uFEFF/, "")
|
||||||
|
.replace(/\r/g, "")
|
||||||
|
.trim()
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.filter(Boolean);
|
||||||
|
const cues = parseSrt(content);
|
||||||
|
|
||||||
|
if (blocks.length === 0 || cues.length !== blocks.length) return false;
|
||||||
|
|
||||||
|
const cueIndexes = new Set<number>();
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (cueIndexes.has(cue.index)) return false;
|
||||||
|
cueIndexes.add(cue.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DialogueValidationResult extends JsonObject {
|
||||||
|
valid: boolean;
|
||||||
|
errors: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const manifest = await loadDialogueManifestData();
|
||||||
|
|
||||||
|
const subtitleCueCache = new Map<string, Set<number>>();
|
||||||
|
|
||||||
|
for (const voice of manifest.voices) {
|
||||||
|
const frSubtitlePath = voice.subtitles.fr;
|
||||||
|
if (!frSubtitlePath) {
|
||||||
|
errors.push(`Voice ${voice.id} must define a French subtitle file`);
|
||||||
|
} else {
|
||||||
|
await validateSubtitleFile(frSubtitlePath, errors, subtitleCueCache);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enSubtitlePath = voice.subtitles.en;
|
||||||
|
if (enSubtitlePath) {
|
||||||
|
const resolvedEnPath = resolvePublicPath(enSubtitlePath);
|
||||||
|
if (!resolvedEnPath || !fs.existsSync(resolvedEnPath)) {
|
||||||
|
warnings.push(
|
||||||
|
`English subtitle file missing for voice ${voice.id}; runtime will fall back to French`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dialogue of manifest.dialogues) {
|
||||||
|
const audioPath = resolvePublicPath(dialogue.audio);
|
||||||
|
if (!audioPath || !fs.existsSync(audioPath)) {
|
||||||
|
errors.push(`Dialogue ${dialogue.id} audio file is missing`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const voice = manifest.voices.find(
|
||||||
|
(item: DialogueVoiceData) => item.id === dialogue.voice,
|
||||||
|
);
|
||||||
|
const frSubtitlePath = voice?.subtitles.fr;
|
||||||
|
const cueIndexes = frSubtitlePath
|
||||||
|
? subtitleCueCache.get(frSubtitlePath)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (!cueIndexes?.has(dialogue.subtitleCueIndex)) {
|
||||||
|
errors.push(
|
||||||
|
`Dialogue ${dialogue.id} references missing cue ${dialogue.subtitleCueIndex}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDialogueManifestData(): Promise<DialogueManifestData> {
|
||||||
|
const manifestPath = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"public/sounds/dialogue/dialogues.json",
|
||||||
|
);
|
||||||
|
const manifestContent = await fs.promises.readFile(manifestPath, "utf8");
|
||||||
|
return parseDialogueManifestData(JSON.parse(manifestContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDialogueManifestData(data: unknown): DialogueManifestData {
|
||||||
|
if (!isRecord(data) || data.version !== 1) {
|
||||||
|
throw new Error("Invalid dialogue manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) {
|
||||||
|
throw new Error("Dialogue manifest requires voices and dialogues arrays");
|
||||||
|
}
|
||||||
|
|
||||||
|
const voices = data.voices.map(parseDialogueVoiceData);
|
||||||
|
const voiceIds = new Set(voices.map((voice) => voice.id));
|
||||||
|
const dialogues = data.dialogues.map((dialogue) =>
|
||||||
|
parseDialogueData(dialogue, voiceIds),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { voices, dialogues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDialogueVoiceData(data: unknown): DialogueVoiceData {
|
||||||
|
if (!isRecord(data) || typeof data.id !== "string") {
|
||||||
|
throw new Error("Invalid dialogue voice");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.speaker !== "string") {
|
||||||
|
throw new Error(`Dialogue voice ${data.id} must define a speaker`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRecord(data.subtitles)) {
|
||||||
|
throw new Error(`Dialogue voice ${data.id} must define subtitles`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitles: DialogueVoiceData["subtitles"] = {};
|
||||||
|
if (typeof data.subtitles.fr === "string") subtitles.fr = data.subtitles.fr;
|
||||||
|
if (typeof data.subtitles.en === "string") subtitles.en = data.subtitles.en;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
speaker: data.speaker,
|
||||||
|
subtitles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDialogueData(data: unknown, voiceIds: Set<string>): DialogueData {
|
||||||
|
if (!isRecord(data)) {
|
||||||
|
throw new Error("Invalid dialogue definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof data.id !== "string" ||
|
||||||
|
typeof data.voice !== "string" ||
|
||||||
|
!voiceIds.has(data.voice) ||
|
||||||
|
typeof data.audio !== "string" ||
|
||||||
|
typeof data.subtitleCueIndex !== "number" ||
|
||||||
|
!Number.isInteger(data.subtitleCueIndex)
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid dialogue definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialogue: DialogueData = {
|
||||||
|
id: data.id,
|
||||||
|
voice: data.voice,
|
||||||
|
audio: data.audio,
|
||||||
|
subtitleCueIndex: data.subtitleCueIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.timecode !== undefined) {
|
||||||
|
if (typeof data.timecode !== "number") {
|
||||||
|
throw new Error("Invalid dialogue definition");
|
||||||
|
}
|
||||||
|
dialogue.timecode = data.timecode;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dialogue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCinematicManifestData(data: unknown): CinematicManifestData {
|
||||||
|
if (!isRecord(data) || data.version !== 1) {
|
||||||
|
throw new Error("Invalid cinematic manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.cinematics)) {
|
||||||
|
throw new Error("Cinematic manifest requires a cinematics array");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cinematics: data.cinematics.map(parseCinematicData),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCinematicData(data: unknown): CinematicData {
|
||||||
|
if (!isRecord(data) || typeof data.id !== "string") {
|
||||||
|
throw new Error("Invalid cinematic definition");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.cameraKeyframes)) {
|
||||||
|
throw new Error(`Cinematic ${data.id} requires cameraKeyframes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cameraKeyframes = data.cameraKeyframes.map(parseCinematicKeyframeData);
|
||||||
|
if (cameraKeyframes.length < 2) {
|
||||||
|
throw new Error(`Cinematic ${data.id} requires at least two keyframes`);
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraKeyframes.forEach((keyframe, index) => {
|
||||||
|
const previousKeyframe = cameraKeyframes[index - 1];
|
||||||
|
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||||
|
throw new Error(`Cinematic ${data.id} keyframe times must increase`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const cinematic: CinematicData = {
|
||||||
|
id: data.id,
|
||||||
|
cameraKeyframes,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.timecode !== undefined) {
|
||||||
|
if (typeof data.timecode !== "number") {
|
||||||
|
throw new Error(`Cinematic ${data.id} has an invalid timecode`);
|
||||||
|
}
|
||||||
|
cinematic.timecode = data.timecode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.dialogueCues !== undefined) {
|
||||||
|
if (!Array.isArray(data.dialogueCues)) {
|
||||||
|
throw new Error(`Cinematic ${data.id} has invalid dialogue cues`);
|
||||||
|
}
|
||||||
|
cinematic.dialogueCues = data.dialogueCues.map(
|
||||||
|
parseCinematicDialogueCueData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cinematic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateCinematicDialogueCues(
|
||||||
|
cinematicManifest: CinematicManifestData,
|
||||||
|
dialogueManifest: DialogueManifestData,
|
||||||
|
): void {
|
||||||
|
const dialogueIds = new Set(
|
||||||
|
dialogueManifest.dialogues.map((dialogue) => dialogue.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const cinematic of cinematicManifest.cinematics) {
|
||||||
|
for (const cue of cinematic.dialogueCues ?? []) {
|
||||||
|
if (!dialogueIds.has(cue.dialogueId)) {
|
||||||
|
throw new Error(
|
||||||
|
`Cinematic ${cinematic.id} references unknown dialogue ${cue.dialogueId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCinematicDialogueCueData(
|
||||||
|
data: unknown,
|
||||||
|
): CinematicDialogueCueData {
|
||||||
|
if (
|
||||||
|
!isRecord(data) ||
|
||||||
|
typeof data.time !== "number" ||
|
||||||
|
typeof data.dialogueId !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid cinematic dialogue cue");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: data.time,
|
||||||
|
dialogueId: data.dialogueId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCinematicKeyframeData(data: unknown): CinematicKeyframeData {
|
||||||
|
if (!isRecord(data) || typeof data.time !== "number") {
|
||||||
|
throw new Error("Invalid cinematic camera keyframe");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
time: data.time,
|
||||||
|
position: parseCinematicVector(data.position),
|
||||||
|
target: parseCinematicVector(data.target),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCinematicVector(value: unknown): [number, number, number] {
|
||||||
|
if (
|
||||||
|
!Array.isArray(value) ||
|
||||||
|
value.length !== 3 ||
|
||||||
|
value.some((item) => typeof item !== "number")
|
||||||
|
) {
|
||||||
|
throw new Error("Invalid cinematic vector");
|
||||||
|
}
|
||||||
|
|
||||||
|
return [value[0], value[1], value[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSubtitleFile(
|
||||||
|
publicPath: string,
|
||||||
|
errors: string[],
|
||||||
|
subtitleCueCache: Map<string, Set<number>>,
|
||||||
|
): Promise<void> {
|
||||||
|
const subtitlePath = resolvePublicPath(publicPath);
|
||||||
|
if (!subtitlePath || !fs.existsSync(subtitlePath)) {
|
||||||
|
errors.push(`Subtitle file ${publicPath} is missing`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.promises.readFile(subtitlePath, "utf8");
|
||||||
|
if (!isValidSrtContent(content)) {
|
||||||
|
errors.push(`Subtitle file ${publicPath} is invalid`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleCueCache.set(
|
||||||
|
publicPath,
|
||||||
|
new Set(parseSrt(content).map((cue) => cue.index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublicPath(publicPath: string): string | null {
|
||||||
|
if (!publicPath.startsWith("/")) return null;
|
||||||
|
|
||||||
|
const publicRoot = path.resolve(__dirname, "public");
|
||||||
|
const resolvedPath = path.resolve(publicRoot, publicPath.slice(1));
|
||||||
|
|
||||||
|
if (!resolvedPath.startsWith(`${publicRoot}${path.sep}`)) return null;
|
||||||
|
|
||||||
|
return resolvedPath;
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), saveMapPlugin()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
saveMapPlugin(),
|
||||||
|
saveSrtPlugin(),
|
||||||
|
saveDialogueManifestPlugin(),
|
||||||
|
saveCinematicManifestPlugin(),
|
||||||
|
validateDialoguesPlugin(),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user