Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88194828ce | |||
| e146c4e8e2 | |||
| 20deb208ec | |||
| b0bb127459 | |||
| f84aa748cd | |||
| a1ff534aa7 | |||
| 5824ae162a | |||
| d7dd76a853 | |||
| 9a1849b0f8 | |||
| 74a901a48b | |||
| 584a68bce6 | |||
| 94cea80af4 | |||
| 0b950a4557 | |||
| 442bfbc8d4 | |||
| 5bd0680b64 | |||
| 04ece5b1d2 | |||
| 8fbb2e9428 | |||
| 2aa662669f | |||
| 4031f0de87 | |||
| e6d78d203a | |||
| 50ddd35979 | |||
| 6f264969ee | |||
| 106b68d487 | |||
| aaedd9e3a4 | |||
| 1b50fe4f5b | |||
| 4bc385fb09 | |||
| 65450d9208 | |||
| 9fa4439de8 | |||
| c7128d58ed | |||
| 0858525c44 | |||
| e7bb4d2b63 | |||
| 0f845f28c5 | |||
| d740e2a436 | |||
| cf20aa8ea4 | |||
| 85b91e63cb | |||
| 9998fb65f8 | |||
| c5b672cdb5 | |||
| fda70bade2 | |||
| c698b9ef78 | |||
| 081e87c96d | |||
| ab8376b03e | |||
| d5f537eb8b | |||
| 475a4c7c5e | |||
| d7b77b2f44 | |||
| 793997ed06 | |||
| 72e4047420 | |||
| 638e10a132 | |||
| 4e594d36fa | |||
| 3a0639bdaa | |||
| d0361c0a38 | |||
| 471424f83d | |||
| 60c966be93 | |||
| 62deb6e322 | |||
| bc3f28bdb2 | |||
| 2a3b088294 | |||
| 4e8a68b04a | |||
| 5627373752 | |||
| b997f576c5 | |||
| 20142b7e5f | |||
| 2b6b045f4a | |||
| 5b14a1d971 | |||
| 29cd03fc21 | |||
| 7c5d7f3834 | |||
| 7a3dd976e7 | |||
| fffabc01c2 | |||
| 93744b15f7 | |||
| c5bf10a7fb | |||
| d9fc9d0a15 | |||
| d4dd0fa283 | |||
| e42c06b888 | |||
| 3230b644e4 | |||
| 3503ff52ed | |||
| a8ece3a448 | |||
| 3f1e15f616 | |||
| b0f0f3cb91 | |||
| 35cd3c7c64 | |||
| cfa1bd9e16 | |||
| 359417ecd4 | |||
| b9a3fbfc99 | |||
| 9d814c9924 | |||
| eb875068eb | |||
| e8a5a44218 | |||
| 9c12c7a9e5 | |||
| 1907f2623b | |||
| cf5be3d45d | |||
| 9ada4298c3 | |||
| 9ff75e0516 | |||
| 3b8c59db87 | |||
| 5e0125e05a | |||
| b81f85cd50 | |||
| 8f1a553601 | |||
| 9b8bb1a182 | |||
| a8c6fafbcd | |||
| 14a55e8dd1 | |||
| 2dd5bfeda1 | |||
| e20ead88e1 | |||
| 7e99d455b4 | |||
| 8c6af0ed6d | |||
| 324aa9dc0f | |||
| 356bb5ef88 | |||
| ece9b1268f | |||
| d2735b72a0 | |||
| fc5e4acba4 | |||
| a3db0b2f0d | |||
| f9a0480121 | |||
| aa7db176e6 | |||
| 0f83f57e23 | |||
| d4e7edaa89 | |||
| ab21df18cb | |||
| 7e067ecccd | |||
| 3fac43d5f1 | |||
| abfbb284f5 | |||
| e3162d6588 | |||
| 7588f7f736 | |||
| 5ff5b89302 | |||
| af35150452 | |||
| 31a99902dd | |||
| a259c3d2e2 | |||
| e19cc72ad5 | |||
| e1d2bfdc75 | |||
| 8f40bb8133 | |||
| 7b38f04a0d | |||
| 7dea0f99a8 | |||
| eade051241 | |||
| e868e72402 | |||
| 4783784fb3 | |||
| bfe8c49323 | |||
| 06e59a972f | |||
| 1a91fcaca0 | |||
| 055e7b2e63 | |||
| 149f9aa26c | |||
| 21d91f1de1 | |||
| 68b0ceb593 | |||
| 868f7a1cfd | |||
| e25152b3e5 | |||
| 641d2f8871 | |||
| 7fd39f58d8 | |||
| 2b6bcc4d92 | |||
| 2001955625 | |||
| 3254291ba7 | |||
| 753a767662 | |||
| bcf3a63fc5 | |||
| ab8c84e006 | |||
| 8abc69ebc3 | |||
| b63412de13 | |||
| 9fdf065c1d | |||
| 4a697ab790 | |||
| 29144f8844 | |||
| 74b9bf57c8 | |||
| 5402c343fa | |||
| 5569da07c1 | |||
| 38abeb3b49 | |||
| eb0db21d29 | |||
| 393b653cca | |||
| e87004652f | |||
| 9d6693b5b6 | |||
| 1db7c22a90 | |||
| bf858645fd | |||
| 8c84663472 | |||
| 6b8ba3d58d | |||
| d0cf876372 | |||
| 38f9f087d1 | |||
| dcbc1c73f5 | |||
| f9c4495610 | |||
| 23d4291458 | |||
| 638022339e | |||
| 20fbaf05e1 | |||
| ed7681a293 | |||
| b26da614f0 | |||
| 106727256b | |||
| 86e9860121 | |||
| 1eed905e8b | |||
| 7769959135 | |||
| fd7571fbe1 | |||
| 3506858c96 | |||
| 61d7495ec9 | |||
| d486f6f381 | |||
| f67799db30 | |||
| 76dc306d4d | |||
| 8300ff844f | |||
| 4c9594e260 | |||
| 27214b02c1 | |||
| 3da749c73e | |||
| 922df5b2b4 | |||
| cd1abd504e | |||
| 648c6d9992 | |||
| 361a52d84b | |||
| d07e7ac62a | |||
| 726c9abae8 | |||
| 0fc4b5ecbe | |||
| 0c7eca9396 | |||
| d3102d4e1c | |||
| a72d312494 | |||
| 455071ed40 | |||
| 283efef321 | |||
| 0c49d63bbf | |||
| 1e3832454c | |||
| 47572d3793 | |||
| ff6bb8b986 | |||
| 8dae23acc3 | |||
| 82c4b612bf | |||
| afd72b9f6c | |||
| dbb3c46e35 | |||
| 25e3d503b2 | |||
| c12026a331 | |||
| 9966bb8e25 | |||
| 96976de21b | |||
| 86b889e2fc | |||
| b55e60bb16 | |||
| dfd46d420b |
+1
-1
@@ -11,7 +11,7 @@ You are working on **La Fabrik**, an interactive 3D web experience built with Re
|
||||
## Current Implementation
|
||||
|
||||
- Stack: React 19, Three.js, `@react-three/fiber`, `@react-three/drei`, `@react-three/rapier`, TypeScript, Vite
|
||||
- Zustand is used for shared game progression state.
|
||||
- No external global state library is used.
|
||||
- Current singleton-style services are limited to:
|
||||
- `InteractionManager`
|
||||
- `AudioManager`
|
||||
|
||||
@@ -49,8 +49,7 @@ la-fabrik/
|
||||
└── src/
|
||||
├── world/ # Persistent 3D world composition
|
||||
│ ├── World.tsx # Active scene composition
|
||||
│ ├── GameMap.tsx # Map loading and progressive rendering
|
||||
│ ├── GameMapCollision.tsx # Collision-only octree source
|
||||
│ ├── GameMap.tsx # Map loading and octree collision
|
||||
│ ├── Lighting.tsx # Ambient, directional, point lights
|
||||
│ ├── Environment.tsx # Scene background / sky model
|
||||
│ ├── GameMusic.tsx # Game scene music lifecycle
|
||||
@@ -102,8 +101,7 @@ la-fabrik/
|
||||
│ ├── editor/ # Editor-only parsing utilities
|
||||
│ ├── map/ # Map loading and validation
|
||||
│ └── three/ # Three.js helpers
|
||||
├── types/ # Shared TypeScript domain types
|
||||
├── App.tsx # App bootstrap and route switch
|
||||
├── App.tsx # Canvas bootstrap
|
||||
└── main.tsx
|
||||
```
|
||||
|
||||
|
||||
@@ -37,8 +37,9 @@ Use `useClonedObject` when a GLTF scene is reused by a component instance. It me
|
||||
src/components/three/
|
||||
├── gameplay/
|
||||
│ ├── RepairCaseModel.tsx
|
||||
│ ├── RepairGame.tsx
|
||||
│ └── RepairRepairingStep.tsx
|
||||
│ ├── RepairCaseObject.tsx
|
||||
│ ├── RepairGameZone.tsx
|
||||
│ └── RepairModuleSlot.tsx
|
||||
├── interaction/
|
||||
│ ├── GrabbableObject.tsx
|
||||
│ ├── InteractableObject.tsx
|
||||
|
||||
@@ -14,24 +14,10 @@ This document describes the code that exists today in the repository.
|
||||
- debug helpers and debug camera mode
|
||||
- either the map scene or the debug physics test scene
|
||||
- the player rig when the active camera mode is `player`
|
||||
- `src/hooks/world/useWorldSceneLoading.ts` owns the production scene loading state shared by `World`, `GameMap`, and the player octree readiness.
|
||||
- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, renders them progressively, and shows fallback cubes for missing models.
|
||||
- `src/world/GameMapCollision.tsx` builds the player collision octree from dedicated collision nodes only.
|
||||
- `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/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree.
|
||||
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map.
|
||||
- `src/world/player/Player.tsx` mounts the camera and controller.
|
||||
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, repair-step movement locking, and interaction input.
|
||||
|
||||
## Physics Boundaries
|
||||
|
||||
The project currently uses two collision layers with separate responsibilities:
|
||||
|
||||
- `GameMapCollision` builds an octree used by the player controller for map collision.
|
||||
- The player octree must be built from a small collision-only subset of map nodes. It currently uses the `terrain` node only instead of traversing the full visible map, because building an octree from all rendered props can overload the browser renderer.
|
||||
- `GameStageContent` is wrapped in Rapier `Physics` for gameplay objects such as repair triggers, cases, grabbables, and future mission-specific objects.
|
||||
- `TestMap` owns its own Rapier `Physics` playground so repair gameplay can be tuned per mission state without depending on the production map layout.
|
||||
|
||||
Keep the player and map octree outside the Rapier provider until there is a deliberate migration plan. This avoids mixing player movement rules with object physics before the gameplay systems need it.
|
||||
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
|
||||
|
||||
## Interaction Model
|
||||
|
||||
@@ -44,44 +30,8 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
||||
|
||||
## Audio
|
||||
|
||||
- `src/managers/AudioManager.ts` provides pooled one-shot playback, looped music playback, category volumes, and optional stereo pan for one-shot sounds.
|
||||
- Supported audio categories are `music`, `sfx`, and `dialogue`.
|
||||
- Trigger interactions may play SFX directly through `AudioManager`.
|
||||
|
||||
## 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.
|
||||
- `src/managers/AudioManager.ts` currently provides pooled one-shot sound playback and looped music playback.
|
||||
- Trigger interactions may play audio directly through `AudioManager`.
|
||||
|
||||
## Debug System
|
||||
|
||||
@@ -92,7 +42,6 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
||||
- `src/components/ui/debug/DebugOverlayLayout.tsx` mounts the compact HTML debug overlay when enabled from `lil-gui`.
|
||||
- `src/components/ui/debug/GameStateDebugPanel.tsx` exposes current game state, main/sub-state switching, previous/next step controls, and reset.
|
||||
- `src/components/ui/debug/HandTrackingDebugPanel.tsx` shows hand tracking status, usage, loaded glove model, hand count, and fist state while hand tracking is active.
|
||||
- `src/components/ui/SceneLoadingOverlay.tsx` displays the fullscreen loading state for 3D scenes, including the production game scene, debug physics scene, and editor scene.
|
||||
- `src/components/three/handTracking/HandTrackingGlove.tsx` places the rigged `gant_l` and `gant_r` models on detected hands in the debug physics scene.
|
||||
- `src/components/debug/scene/DebugHelpers.tsx` mounts debug helpers.
|
||||
- `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
|
||||
@@ -103,16 +52,13 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
||||
- `src/components/three/models/` contains reusable model helpers such as `ExplodableModel`.
|
||||
- `src/components/three/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`.
|
||||
- `src/components/three/handTracking/` contains R3F hand tracking debug models such as the glove overlays.
|
||||
- `src/components/three/gameplay/` contains the reusable production `RepairGame` flow, repair case, repair steps, and repair prompt components.
|
||||
- `src/components/three/gameplay/` contains the current core repair gameplay prototype: the repair case, repair game zone, and module slots.
|
||||
- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`.
|
||||
|
||||
## Editor System
|
||||
|
||||
- `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/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/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
|
||||
- `src/controls/editor/FlyController.tsx` provides player-style editor navigation.
|
||||
@@ -121,22 +67,19 @@ Keep the player and map octree outside the Rapier provider until there is a deli
|
||||
- `src/utils/editor/loadEditorScene.ts` handles editor-only folder upload parsing.
|
||||
- `src/utils/map/loadMapSceneData.ts` is shared by the game scene and editor to load `public/map.json` and resolve model URLs.
|
||||
- `src/types/editor/editor.ts` contains the shared `MapNode`, `SceneData`, and `TransformMode` types.
|
||||
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used across store, config, debug UI, and gameplay components.
|
||||
|
||||
## Map Data
|
||||
|
||||
- `public/map.json` is expected to be a `MapNode[]`.
|
||||
- Each map node `name` maps to `public/models/{name}/model.glb` when available, with `public/models/{name}/model.gltf` kept as fallback.
|
||||
- The editor renders a fallback cube for missing models.
|
||||
- The game scene renders fallback cubes for nodes whose model cannot be resolved.
|
||||
- The game scene currently uses `terrain` as the collision source for the player octree. Additional collision nodes should be explicit lightweight collision assets, not arbitrary visible decoration models.
|
||||
- The game scene filters out nodes whose model cannot be resolved.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- The repository is a prototype, not the full intended game runtime.
|
||||
- `src/world/debug/TestMap.tsx` is part of the active scene composition.
|
||||
- There is no central gameplay orchestrator such as `GameManager`.
|
||||
- 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.
|
||||
- Missions, zones, cinematics, and dialogue systems are not implemented.
|
||||
- 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.
|
||||
|
||||
@@ -23,9 +23,6 @@ src/
|
||||
├── components/
|
||||
│ └── editor/
|
||||
│ ├── EditorControls.tsx
|
||||
│ ├── EditorCinematicManifestPanel.tsx
|
||||
│ ├── EditorDialogueManifestPanel.tsx
|
||||
│ ├── EditorSrtPanel.tsx
|
||||
│ └── scene/
|
||||
│ ├── EditorMap.tsx
|
||||
│ └── EditorScene.tsx
|
||||
@@ -40,14 +37,10 @@ src/
|
||||
│ └── editor/
|
||||
│ └── editor.ts
|
||||
└── utils/
|
||||
├── dialogues/
|
||||
│ └── loadDialogueManifest.ts
|
||||
├── editor/
|
||||
│ └── loadEditorScene.ts
|
||||
├── map/
|
||||
│ └── loadMapSceneData.ts
|
||||
└── subtitles/
|
||||
└── parseSrt.ts
|
||||
└── map/
|
||||
└── loadMapSceneData.ts
|
||||
```
|
||||
|
||||
## Responsibilities
|
||||
@@ -64,12 +57,6 @@ src/
|
||||
|
||||
`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/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`.
|
||||
@@ -147,78 +134,6 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI.
|
||||
@@ -229,6 +144,3 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas
|
||||
- Large `map.json` files are not virtualized, culled, or LOD-managed.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -4,9 +4,9 @@ This document describes the hand tracking system that exists in the current code
|
||||
|
||||
## Purpose
|
||||
|
||||
Hand tracking started as a debug-stage interaction system used to test direct 3D object manipulation with a webcam. It allows a user to close their fist to grab a nearby object and move it in 3D space without relying on the center crosshair.
|
||||
Hand tracking is a debug-stage interaction system used to test direct 3D object manipulation with a webcam. It allows a user to close their fist to grab a nearby object and move it in 3D space without relying on the center crosshair.
|
||||
|
||||
It is now also available to the production repair flow when a mission reaches a hand-driven step.
|
||||
The feature is scoped to the debug physics scene rather than production gameplay input.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
@@ -16,13 +16,13 @@ It is now also available to the production repair flow when a mission reaches a
|
||||
4. The backend returns hand data including landmarks, handedness, score, center point, and `isFist`.
|
||||
5. React stores the latest snapshot in the hand tracking provider.
|
||||
6. `GrabbableObject` reads that snapshot each frame and uses fist state plus raycasting to grab objects.
|
||||
7. `HandTrackingGlove` reads the same snapshot and places the rigged `gant_l` and `gant_r` models on the detected hands when hand tracking is active.
|
||||
7. `HandTrackingGlove` reads the same snapshot and places the rigged `gant_l` and `gant_r` models on the detected hands in the debug physics scene.
|
||||
|
||||
## Activation Rules
|
||||
|
||||
Hand tracking is intentionally gated so the webcam and backend are not used all the time.
|
||||
|
||||
The debug activation conditions are:
|
||||
The current activation conditions are:
|
||||
|
||||
- debug mode is active with `?debug`
|
||||
- scene mode is `physics`
|
||||
@@ -30,15 +30,6 @@ The debug activation conditions are:
|
||||
|
||||
This keeps hand tracking active while the player is inside an interaction zone, even if the camera is not aimed directly at the object.
|
||||
|
||||
The production repair activation conditions are:
|
||||
|
||||
- active `mainState` is `bike`, `pylone`, or `ferme`
|
||||
- the active mission step is `inspected`, `repairing`, `reassembling`, or `done`
|
||||
|
||||
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. 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
|
||||
|
||||
The backend lives in `backend/` and exposes:
|
||||
@@ -130,7 +121,7 @@ The glove models are intentionally smaller than the raw SVG overlay so they do n
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Production usage is currently limited to repair mission steps that explicitly need hands.
|
||||
- The feature is debug-only and focused on the physics test scene.
|
||||
- MediaPipe depth is relative and can be noisy.
|
||||
- The virtual hit zone is an approximation based on multiple raycasts, not a real 3D collider.
|
||||
- There is no smoothing layer for hand position or depth yet.
|
||||
|
||||
@@ -75,7 +75,6 @@ The mission steps currently use this sequence:
|
||||
"fragmented" |
|
||||
"scanning" |
|
||||
"repairing" |
|
||||
"reassembling" |
|
||||
"done";
|
||||
```
|
||||
|
||||
@@ -115,32 +114,10 @@ setMainState("bike");
|
||||
|
||||
Direct setters are useful for debug panels, but production gameplay should prefer business actions such as `advanceGameState`, `completeBike`, or `completePylone`.
|
||||
|
||||
Mission gameplay that can target `bike`, `pylone`, or `ferme` should prefer the generic mission actions:
|
||||
|
||||
```ts
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
setMissionStep("bike", "inspected");
|
||||
completeMission("bike");
|
||||
```
|
||||
|
||||
This keeps reusable gameplay components such as repair flows from duplicating mission-specific branches like `setBikeState`, `setPyloneState`, and `setFermeState`.
|
||||
|
||||
## World Integration
|
||||
|
||||
`src/world/GameStageContent.tsx` subscribes to `mainState` and mounts stage-specific content.
|
||||
|
||||
For repair missions, it mounts the reusable `RepairGame` component with a mission id:
|
||||
|
||||
```tsx
|
||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
||||
```
|
||||
|
||||
`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`. Shared repair ids, mission steps, and runtime guards live in `src/types/gameplay/repairMission.ts` so static mission config does not depend on the Zustand store. The production repair flow currently supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` state transitions.
|
||||
|
||||
Mission-specific behavior stays in `src/data/gameplay/repairMissions.ts`: each mission can define its broken nodes, placeholder targets, scan duration, and reassembly duration without adding mission branches to `RepairGame`.
|
||||
|
||||
That means the scene can progressively move toward this pattern:
|
||||
|
||||
```tsx
|
||||
@@ -170,7 +147,6 @@ Current overlays:
|
||||
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store
|
||||
- `Crosshair`: player aiming helper
|
||||
- `InteractPrompt`: interaction prompt
|
||||
- `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`.
|
||||
|
||||
@@ -185,4 +161,4 @@ Current overlays:
|
||||
|
||||
## Next Steps
|
||||
|
||||
Move repair validation into mission data once each mission has distinct broken module nodes, replacement assets, and completion events.
|
||||
The next natural step is to replace the temporary stage anchors in `GameStageContent` with real stage components, for example `IntroContent`, `BikeContent`, `PyloneContent`, `FermeContent`, and `OutroContent`.
|
||||
|
||||
@@ -74,87 +74,6 @@ 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.
|
||||
|
||||
## 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
|
||||
|
||||
- The editor only modifies existing nodes.
|
||||
@@ -162,5 +81,3 @@ Cinematic dialogue cues are the preferred way to synchronize a dialogue with a c
|
||||
- It does not edit model files or textures.
|
||||
- It does not provide production persistence.
|
||||
- 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.
|
||||
|
||||
+7
-54
@@ -6,9 +6,7 @@ This document lists features that are implemented in the current codebase.
|
||||
|
||||
- Fullscreen React Three Fiber scene
|
||||
- Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.glb` or `model.gltf` assets
|
||||
- Minimal fullscreen scene loading overlay for 3D scenes, with a global progress bar used by the production map, debug physics scene, and editor scene
|
||||
- Debug physics test scene selectable from the debug panel, including grab/trigger tests, an animated model preview, and separate repair playground zones for `bike`, `pylone`, and `ferme`
|
||||
- Rapier physics context available for production stage gameplay objects
|
||||
- Debug physics test scene selectable from the debug panel
|
||||
- Ambient and directional lighting
|
||||
- Environment background setup
|
||||
|
||||
@@ -18,64 +16,25 @@ This document lists features that are implemented in the current codebase.
|
||||
- Pointer lock mouse look
|
||||
- Movement with `ZQSD`
|
||||
- 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 the loaded map
|
||||
|
||||
## Interactions
|
||||
|
||||
- Focus detection by distance and raycast
|
||||
- Trigger interactions activated with `E`
|
||||
- Grab interactions activated with the primary mouse button
|
||||
- Physics-backed gameplay objects can be mounted inside stage content without replacing player octree collision
|
||||
- Interaction prompt shown for trigger interactions
|
||||
|
||||
## Repair Gameplay
|
||||
|
||||
- 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
|
||||
- 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, 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
|
||||
|
||||
- Category-based volumes for music, SFX, and dialogue
|
||||
- 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 `/`
|
||||
- One-shot sound playback for trigger interactions
|
||||
- Simple per-sound pooling through `AudioManager`
|
||||
|
||||
## Debug Tooling
|
||||
|
||||
- `?debug` query param enables the debug panel
|
||||
- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning
|
||||
- Compact debug overlay for game state controls and hand tracking status
|
||||
- Debug game-state mission switching unlocks locked repair missions at `waiting` for faster testing
|
||||
- Debug scene helpers
|
||||
- Free debug camera
|
||||
- `r3f-perf` overlay
|
||||
@@ -93,19 +52,13 @@ This document lists features that are implemented in the current codebase.
|
||||
- Player-style navigation mode with `WASD`, `ZQSD`, arrow keys, `Space`, and `Shift`
|
||||
- JSON export for downloading the edited map
|
||||
- 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
|
||||
|
||||
- complete mission system
|
||||
- mission system
|
||||
- zone system
|
||||
- full cinematic system beyond current timecode prototype
|
||||
- gameplay-triggered dialogue branches beyond current prototype triggers
|
||||
- cinematic system
|
||||
- dialogue system
|
||||
- loading flow
|
||||
- minimap and mission HUD
|
||||
- full production separation between gameplay and debug scenes
|
||||
|
||||
+29
-59
@@ -1,78 +1,50 @@
|
||||
# Main Feature
|
||||
|
||||
This document explains the current repair-game flow in La-Fabrik.
|
||||
This document explains the current repair-game prototype in La-Fabrik.
|
||||
|
||||
## What It Does
|
||||
|
||||
The main feature is a reusable repair flow mounted in the production game scene. It lets the player approach the active mission object, inspect it, fragment it, scan the broken part, install the correct replacement, validate completion, and move to the next mission state.
|
||||
The main feature is a repair interaction sandbox mounted in the debug physics scene. It lets the player approach a repair case, open it, and interact with module slots that can show selectable models and exploded-model states.
|
||||
|
||||
The current user flow is:
|
||||
|
||||
1. Enter a mission state such as `bike`, `pylone`, or `ferme`.
|
||||
2. Move close to the active repair object in the game scene.
|
||||
3. Aim at the object and press the interaction key when prompted.
|
||||
4. The mission step moves from `waiting` to `inspected`.
|
||||
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. 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`.
|
||||
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.
|
||||
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.
|
||||
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` 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`.
|
||||
1. Open the app with `?debug`.
|
||||
2. Switch the scene to `Physics` in the debug panel.
|
||||
3. Move close to the repair case.
|
||||
4. Press the interaction key when prompted.
|
||||
5. Watch the case open or close with sound feedback.
|
||||
6. Interact with repair module slots to cycle/select repair models.
|
||||
|
||||
## Why It Matters
|
||||
|
||||
This feature validates the repair loop before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene.
|
||||
This feature validates the core repair fantasy before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene.
|
||||
|
||||
## Current Behavior
|
||||
|
||||
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.
|
||||
The repair case reacts to player proximity. When the player is close enough, it floats upward and rotates gently to signal interactivity. When the player moves away, it returns to its resting transform.
|
||||
|
||||
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.
|
||||
Interacting with the case toggles its open state. The lid animation is handled with GSAP because it is a discrete interaction animation, not a continuous per-frame loop.
|
||||
|
||||
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. 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.
|
||||
Repair module slots are configured from static gameplay data. They render selectable repair models and can use exploded model visualization to show parts separated from their original positions.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`.
|
||||
- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, case exit animation, and mission UI prompt.
|
||||
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
|
||||
- `src/components/three/gameplay/RepairBrokenPartHighlight.tsx` renders the red halo and wire marker around detected broken parts during scanning.
|
||||
- `src/components/three/gameplay/RepairBrokenPartPrompt.tsx` centers the configured broken UI video on detected broken parts during scanning.
|
||||
- `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction.
|
||||
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
|
||||
- `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, grabbable scanned broken parts, placeholder placement markers, snap placement behavior, correct-part and broken-part placement validation, and the install trigger in `repairing`.
|
||||
- `src/components/three/gameplay/RepairReassemblyStep.tsx` renders the inverse fragmentation animation before the final completion step.
|
||||
- `src/components/three/gameplay/RepairCompletionParticles.tsx` renders the green completion particles during reassembly.
|
||||
- `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/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
|
||||
- `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/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/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
|
||||
- `src/world/debug/TestMap.tsx` mounts the repair-game prototype in the debug physics scene.
|
||||
- `src/components/three/gameplay/RepairGameZone.tsx` composes the repair-game zone.
|
||||
- `src/components/three/gameplay/RepairCaseObject.tsx` connects the repair case to trigger interaction and audio.
|
||||
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model.
|
||||
- `src/components/three/gameplay/RepairModuleSlot.tsx` renders repair slots and model selection behavior.
|
||||
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
|
||||
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
|
||||
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
|
||||
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`.
|
||||
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
|
||||
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used by the store, data config, debug UI, and gameplay components.
|
||||
- `src/data/gameplay/repairGameConfig.ts` stores repair zone and slot positions.
|
||||
- `src/data/gameplay/repairGameModelCatalog.ts` stores selectable repair models.
|
||||
|
||||
## Runtime Requirements
|
||||
## Debug Requirements
|
||||
|
||||
The production repair flow currently requires:
|
||||
The repair-game prototype currently requires:
|
||||
|
||||
- the active `mainState` to be one of `bike`, `pylone`, or `ferme`
|
||||
- `GameStageContent` mounted inside the game scene Rapier `Physics` boundary
|
||||
- the app opened with `?debug`
|
||||
- the debug scene set to `Physics`
|
||||
- model assets available under `public/models/`
|
||||
- sound assets available under `public/sounds/`
|
||||
|
||||
@@ -82,17 +54,15 @@ Frontend command:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Debug URL for state switching and inspection:
|
||||
Debug URL:
|
||||
|
||||
```txt
|
||||
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`; 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
|
||||
|
||||
Hand tracking can move grabbable physics objects with webcam input in debug scenes. In the production repair flow, it is also used for the `inspected -> fragmented` transition through the two-fists hold gesture.
|
||||
Hand tracking is a separate debug interaction layer. It can move grabbable physics objects with webcam input, but it is not yet integrated into the repair-game mission flow.
|
||||
|
||||
For hand tracking, run the Python backend separately:
|
||||
|
||||
@@ -103,8 +73,8 @@ python -m backend.main
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- 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.
|
||||
- There is no central `GameManager` in this branch.
|
||||
- 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.
|
||||
- It is mounted only in the debug physics scene.
|
||||
- There is no mission progression system yet.
|
||||
- There is no central `GameManager` or Zustand store in this branch.
|
||||
- Hand tracking is available as debug interaction input, not as final repair gameplay.
|
||||
- The repair-game content is configured statically in `src/data/gameplay/`.
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,187 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,11 +0,0 @@
|
||||
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!
|
||||
@@ -1,11 +0,0 @@
|
||||
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!
|
||||
@@ -1,91 +0,0 @@
|
||||
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.
|
||||
@@ -1,11 +0,0 @@
|
||||
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 !
|
||||
@@ -1,11 +0,0 @@
|
||||
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 !
|
||||
@@ -1,91 +0,0 @@
|
||||
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.
|
||||
@@ -1,665 +0,0 @@
|
||||
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,10 +12,6 @@ import {
|
||||
Save,
|
||||
Undo2,
|
||||
} 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";
|
||||
|
||||
interface EditorControlsProps {
|
||||
@@ -32,7 +28,6 @@ interface EditorControlsProps {
|
||||
onExportJson: () => void;
|
||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||
onPlayerMode?: (() => void) | undefined;
|
||||
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
|
||||
isPlayerMode?: boolean;
|
||||
}
|
||||
|
||||
@@ -64,7 +59,6 @@ export function EditorControls({
|
||||
onExportJson,
|
||||
onSaveToServer,
|
||||
onPlayerMode,
|
||||
onPreviewCinematic,
|
||||
isPlayerMode,
|
||||
}: EditorControlsProps): React.JSX.Element {
|
||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||
@@ -242,10 +236,6 @@ export function EditorControls({
|
||||
: `Selected node ${selectedNodeIndex + 1} raw lines`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
|
||||
<EditorDialogueManifestPanel />
|
||||
<EditorSrtPanel />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,554 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
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,18 +1,9 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
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 { FlyController } from "@/controls/editor/FlyController";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
||||
|
||||
export interface EditorCinematicPreviewRequest {
|
||||
id: string;
|
||||
cinematic: CinematicDefinition;
|
||||
}
|
||||
|
||||
interface EditorSceneProps {
|
||||
sceneData: SceneData;
|
||||
selectedNodeIndex: number | null;
|
||||
@@ -27,8 +18,6 @@ interface EditorSceneProps {
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
isPlayerMode?: boolean;
|
||||
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function EditorScene({
|
||||
@@ -45,11 +34,7 @@ export function EditorScene({
|
||||
onUndo,
|
||||
onRedo,
|
||||
isPlayerMode = false,
|
||||
cinematicPreviewRequest = null,
|
||||
onCinematicPreviewComplete,
|
||||
}: EditorSceneProps): React.JSX.Element {
|
||||
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
@@ -89,16 +74,10 @@ export function EditorScene({
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditorCinematicPreviewPlayer
|
||||
request={cinematicPreviewRequest}
|
||||
onComplete={onCinematicPreviewComplete}
|
||||
/>
|
||||
|
||||
{isPlayerMode ? (
|
||||
<FlyController disabled={isCinematicPreviewing} />
|
||||
<FlyController disabled={false} />
|
||||
) : (
|
||||
<OrbitControls
|
||||
enabled={!isCinematicPreviewing}
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
mouseButtons={{
|
||||
@@ -127,76 +106,3 @@ 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,56 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairBrokenPartHighlightProps {
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _worldPosition = new THREE.Vector3();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartHighlight({
|
||||
target,
|
||||
}: RepairBrokenPartHighlightProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
|
||||
_worldPosition.copy(_sphere.center);
|
||||
_localPosition.copy(_worldPosition);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5) * 0.08;
|
||||
const radius = Math.max(_sphere.radius, 0.35) * pulse;
|
||||
group.scale.setScalar(radius);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.14} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.06, 32, 16]} />
|
||||
<meshBasicMaterial
|
||||
color="#ef4444"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.12, 0.025, 8, 96]} />
|
||||
<meshBasicMaterial color="#dc2626" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
|
||||
interface RepairBrokenPartPromptProps {
|
||||
src: string;
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartPrompt({
|
||||
src,
|
||||
target,
|
||||
}: RepairBrokenPartPromptProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
_localPosition.copy(_sphere.center);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairPromptVideo src={src} position={[0, 0, 0]} size={72} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -8,39 +8,20 @@ import {
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
|
||||
REPAIR_CASE_FLOAT_DOWN_SPEED,
|
||||
REPAIR_CASE_FLOAT_HEIGHT,
|
||||
REPAIR_CASE_EXIT_DURATION,
|
||||
REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
REPAIR_CASE_FLOAT_UP_SPEED,
|
||||
REPAIR_CASE_LID_NODE_NAME,
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
|
||||
REPAIR_CASE_POP_DURATION,
|
||||
REPAIR_CASE_POP_Y_OFFSET,
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
export interface RepairCasePlaceholder {
|
||||
name: string;
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
interface RepairCaseModelProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
open: boolean;
|
||||
exiting?: boolean;
|
||||
floating?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
@@ -56,10 +37,6 @@ const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
|
||||
export function RepairCaseModel({
|
||||
modelPath,
|
||||
open,
|
||||
exiting = false,
|
||||
floating = true,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
@@ -78,80 +55,22 @@ export function RepairCaseModel({
|
||||
const floatHeight = useRef(0);
|
||||
const animationActiveRef = useRef(false);
|
||||
const phase = useRef({ x: 0, y: 0, z: 0 });
|
||||
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
|
||||
const onExitCompleteRef = useRef(onExitComplete);
|
||||
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
|
||||
const initialOpen = useRef(open);
|
||||
const previousOpen = useRef(open);
|
||||
const openedRotationZ = useRef(0);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const placeholderNodes = useRef<THREE.Object3D[]>([]);
|
||||
const placeholderSignature = useRef("__initial__");
|
||||
const placeholderPosition = useRef(new THREE.Vector3());
|
||||
const placeholderLocalPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
onExitCompleteRef.current = onExitComplete;
|
||||
}, [onExitComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlaceholdersChangeRef.current = onPlaceholdersChange;
|
||||
}, [onPlaceholdersChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const popAnimation = pop.current;
|
||||
|
||||
phase.current = {
|
||||
x: Math.random() * Math.PI * 2,
|
||||
y: Math.random() * Math.PI * 2,
|
||||
z: Math.random() * Math.PI * 2,
|
||||
};
|
||||
|
||||
gsap.to(popAnimation, {
|
||||
scale: 1,
|
||||
yOffset: 0,
|
||||
duration: REPAIR_CASE_POP_DURATION,
|
||||
ease: "back.out(1.7)",
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exiting) return undefined;
|
||||
|
||||
const popAnimation = pop.current;
|
||||
gsap.to(popAnimation, {
|
||||
scale: 0.001,
|
||||
yOffset: REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
duration: REPAIR_CASE_EXIT_DURATION,
|
||||
ease: "back.in(1.4)",
|
||||
overwrite: true,
|
||||
onComplete: () => {
|
||||
onExitCompleteRef.current?.();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, [exiting]);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
|
||||
lidRef.current = lid ?? null;
|
||||
openedRotationZ.current = lid?.rotation.z ?? 0;
|
||||
placeholderNodes.current = [];
|
||||
|
||||
model.traverse((child) => {
|
||||
if (
|
||||
child.name.toLowerCase().startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
|
||||
) {
|
||||
placeholderNodes.current.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
if (lid) {
|
||||
lid.rotation.z =
|
||||
@@ -181,24 +100,12 @@ export function RepairCaseModel({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousOpen.current === open) return;
|
||||
|
||||
previousOpen.current = open;
|
||||
AudioManager.getInstance().playSound(
|
||||
open ? REPAIR_CASE_OPEN_SOUND_PATH : REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
0.85,
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.getWorldPosition(worldPosition.current);
|
||||
const isNear =
|
||||
floating &&
|
||||
!exiting &&
|
||||
worldPosition.current.distanceTo(camera.position) <=
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
|
||||
@@ -212,43 +119,7 @@ export function RepairCaseModel({
|
||||
floatSpeed,
|
||||
delta,
|
||||
);
|
||||
group.position.y = position[1] + floatHeight.current + pop.current.yOffset;
|
||||
group.scale.set(
|
||||
parsedScale[0] * pop.current.scale,
|
||||
parsedScale[1] * pop.current.scale,
|
||||
parsedScale[2] * pop.current.scale,
|
||||
);
|
||||
|
||||
if (placeholderNodes.current.length > 0) {
|
||||
const placeholders: RepairCasePlaceholder[] = [];
|
||||
placeholderNodes.current.forEach((child) => {
|
||||
child.getWorldPosition(placeholderPosition.current);
|
||||
placeholderLocalPosition.current.copy(placeholderPosition.current);
|
||||
group.parent?.worldToLocal(placeholderLocalPosition.current);
|
||||
placeholders.push({
|
||||
name: child.name,
|
||||
position: [
|
||||
placeholderLocalPosition.current.x,
|
||||
placeholderLocalPosition.current.y,
|
||||
placeholderLocalPosition.current.z,
|
||||
],
|
||||
});
|
||||
});
|
||||
placeholders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const nextSignature = placeholders
|
||||
.map(
|
||||
(placeholder) =>
|
||||
`${placeholder.name}:${placeholder.position
|
||||
.map((value) => value.toFixed(3))
|
||||
.join(",")}`,
|
||||
)
|
||||
.join("|");
|
||||
if (nextSignature !== placeholderSignature.current) {
|
||||
placeholderSignature.current = nextSignature;
|
||||
onPlaceholdersChangeRef.current?.(placeholders);
|
||||
}
|
||||
}
|
||||
group.position.y = position[1] + floatHeight.current;
|
||||
|
||||
animationActiveRef.current = isNear;
|
||||
|
||||
@@ -287,7 +158,12 @@ export function RepairCaseModel({
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={parsedScale}
|
||||
>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import {
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
|
||||
interface RepairCaseErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RepairCaseErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class RepairCaseErrorBoundary extends Component<
|
||||
RepairCaseErrorBoundaryProps,
|
||||
RepairCaseErrorBoundaryState
|
||||
> {
|
||||
constructor(props: RepairCaseErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): RepairCaseErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: REPAIR_CASE_MODEL_PATH,
|
||||
scope: "RepairCaseObject",
|
||||
position: [0, -0.45, 0],
|
||||
scale: 1.5,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return <RepairCaseFallback />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface RepairCaseObjectProps {
|
||||
position: Vector3Tuple;
|
||||
open: boolean;
|
||||
onInspect: () => void;
|
||||
}
|
||||
|
||||
export function RepairCaseObject({
|
||||
position,
|
||||
open,
|
||||
onInspect,
|
||||
}: RepairCaseObjectProps): React.JSX.Element {
|
||||
return (
|
||||
<TriggerObject
|
||||
position={position}
|
||||
colliders="cuboid"
|
||||
label={open ? "Mallette inspectée" : "Inspecter la mallette"}
|
||||
onTrigger={() => {
|
||||
if (open) return;
|
||||
AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH);
|
||||
onInspect();
|
||||
}}
|
||||
>
|
||||
<RepairCaseErrorBoundary>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
open={open}
|
||||
position={[0, -0.45, 0]}
|
||||
scale={1.5}
|
||||
/>
|
||||
</RepairCaseErrorBoundary>
|
||||
</TriggerObject>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairCaseFallback(): React.JSX.Element {
|
||||
return (
|
||||
<group position={[0, -0.25, 0]}>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1.5, 0.5, 1]} />
|
||||
<meshStandardMaterial color="#2563eb" roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.35, -0.25]} castShadow receiveShadow>
|
||||
<boxGeometry args={[1.5, 0.12, 0.65]} />
|
||||
<meshStandardMaterial color="#1d4ed8" roughness={0.55} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
const PARTICLES = Array.from({ length: 24 }, (_, index) => {
|
||||
const angle = (index / 24) * Math.PI * 2;
|
||||
const ring = index % 3;
|
||||
return {
|
||||
angle,
|
||||
radius: 0.45 + ring * 0.28,
|
||||
y: 0.35 + (index % 5) * 0.16,
|
||||
speed: 0.8 + (index % 4) * 0.18,
|
||||
};
|
||||
});
|
||||
|
||||
export function RepairCompletionParticles(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.rotation.y = clock.elapsedTime * 0.9;
|
||||
group.children.forEach((child, index) => {
|
||||
const particle = PARTICLES[index];
|
||||
if (!particle) return;
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5 + index) * 0.35;
|
||||
child.position.y =
|
||||
particle.y + Math.sin(clock.elapsedTime * particle.speed) * 0.08;
|
||||
child.scale.setScalar(pulse);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{PARTICLES.map((particle, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[
|
||||
Math.cos(particle.angle) * particle.radius,
|
||||
particle.y,
|
||||
Math.sin(particle.angle) * particle.radius,
|
||||
]}
|
||||
>
|
||||
<sphereGeometry args={[0.045, 12, 12]} />
|
||||
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
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";
|
||||
|
||||
interface RepairCompletionStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairCompletionStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairCompletionStepProps): React.JSX.Element {
|
||||
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 (
|
||||
<group>
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
exiting={isExitingCase}
|
||||
open={!isClosingCase}
|
||||
onExitComplete={onComplete}
|
||||
/>
|
||||
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
/>
|
||||
|
||||
{!isClosingCase ? (
|
||||
<TriggerObject
|
||||
position={[0, 1.1, 0]}
|
||||
colliders="ball"
|
||||
label={`Valider ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => setIsClosingCase(true)}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[1.35, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.2, 1.25, 96]} />
|
||||
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
|
||||
</mesh>
|
||||
</TriggerObject>
|
||||
) : null}
|
||||
|
||||
{!isClosingCase ? (
|
||||
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||
import {
|
||||
RepairScanSequence,
|
||||
type RepairScannedBrokenPart,
|
||||
} from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import {
|
||||
REPAIR_MISSIONS,
|
||||
type RepairMissionConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairGameProps extends Required<
|
||||
Pick<ModelTransformProps, "position">
|
||||
> {
|
||||
mission: RepairMissionId;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: ModelTransformProps["scale"];
|
||||
}
|
||||
|
||||
interface RepairMissionAssetPreloaderProps {
|
||||
config: RepairMissionConfig;
|
||||
}
|
||||
|
||||
function RepairMissionAssetPreloader({
|
||||
config,
|
||||
}: RepairMissionAssetPreloaderProps): null {
|
||||
const modelPaths = useMemo(
|
||||
() => getRepairMissionModelPaths(config),
|
||||
[config],
|
||||
);
|
||||
|
||||
useGLTF(modelPaths);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RepairGame({
|
||||
mission,
|
||||
position,
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairGameProps): React.JSX.Element | null {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const step = useRepairMissionStep(mission);
|
||||
const [casePlaceholders, setCasePlaceholders] = useState<
|
||||
readonly RepairCasePlaceholder[]
|
||||
>([]);
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
enabled: mainState === mission && readyForFragmentation,
|
||||
keyboardEnabled: false,
|
||||
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(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
|
||||
if (step !== "fragmented") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep(mission, "scanning");
|
||||
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "fragmented" ? (
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
/>
|
||||
) : null}
|
||||
{step === "scanning" ? (
|
||||
<RepairScanSequence
|
||||
config={config}
|
||||
onComplete={(brokenParts) => {
|
||||
setScannedBrokenParts(brokenParts);
|
||||
setMissionStep(mission, "repairing");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === "repairing" ? (
|
||||
<RepairRepairingStep
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "reassembling" ? (
|
||||
<RepairReassemblyStep
|
||||
config={config}
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
onPlaceholdersChange={setCasePlaceholders}
|
||||
open={step === "repairing"}
|
||||
zoomed={step === "repairing"}
|
||||
showFragmentationPrompt={readyForFragmentation}
|
||||
onInteract={
|
||||
readyForFragmentation
|
||||
? () => setMissionStep(mission, "fragmented")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||
return step === "repairing" || step === "reassembling" || step === "done";
|
||||
}
|
||||
|
||||
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
config.modelPath,
|
||||
...config.brokenParts.flatMap((part) => part.modelPath ?? []),
|
||||
...config.replacementParts.flatMap((part) => part.modelPath ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Text } from "@react-three/drei";
|
||||
import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject";
|
||||
import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot";
|
||||
import {
|
||||
REPAIR_GAME_MODULE_SLOTS,
|
||||
REPAIR_GAME_ZONE_LABEL,
|
||||
REPAIR_GAME_ZONE_ORIGIN,
|
||||
REPAIR_GAME_ZONE_RADIUS,
|
||||
} from "@/data/gameplay/repairGameConfig";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
|
||||
const CASE_CLOSED_STEPS = new Set(["locked", "waiting"]);
|
||||
|
||||
export function RepairGameZone(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const setMainState = useGameStore((state) => state.setMainState);
|
||||
const setBikeState = useGameStore((state) => state.setBikeState);
|
||||
const caseOpen = !CASE_CLOSED_STEPS.has(bikeStep);
|
||||
const slotsDisabled = !caseOpen;
|
||||
|
||||
const inspectRepairCase = (): void => {
|
||||
if (mainState !== "bike") {
|
||||
setMainState("bike");
|
||||
}
|
||||
|
||||
if (CASE_CLOSED_STEPS.has(bikeStep)) {
|
||||
setBikeState({ currentStep: "inspected" });
|
||||
}
|
||||
};
|
||||
|
||||
const markModelSelected = (): void => {
|
||||
if (mainState !== "bike") {
|
||||
setMainState("bike");
|
||||
}
|
||||
|
||||
if (bikeStep === "inspected") {
|
||||
setBikeState({ currentStep: "fragmented" });
|
||||
}
|
||||
};
|
||||
|
||||
const markModuleSplit = (): void => {
|
||||
if (mainState !== "bike") {
|
||||
setMainState("bike");
|
||||
}
|
||||
|
||||
if (bikeStep === "fragmented") {
|
||||
setBikeState({ currentStep: "scanning" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
0.025,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry
|
||||
args={[REPAIR_GAME_ZONE_RADIUS - 0.08, REPAIR_GAME_ZONE_RADIUS, 96]}
|
||||
/>
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.72} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
0.02,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<circleGeometry args={[REPAIR_GAME_ZONE_RADIUS, 96]} />
|
||||
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
|
||||
<Text
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
3.1,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2] - 1.8,
|
||||
]}
|
||||
rotation={[0, 0, 0]}
|
||||
fontSize={0.55}
|
||||
maxWidth={5.5}
|
||||
textAlign="center"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
color="#f8fafc"
|
||||
outlineWidth={0.025}
|
||||
outlineColor="#0f172a"
|
||||
>
|
||||
{REPAIR_GAME_ZONE_LABEL}
|
||||
</Text>
|
||||
|
||||
<RepairCaseObject
|
||||
position={REPAIR_GAME_ZONE_ORIGIN}
|
||||
open={caseOpen}
|
||||
onInspect={inspectRepairCase}
|
||||
/>
|
||||
|
||||
{REPAIR_GAME_MODULE_SLOTS.map((slot) => (
|
||||
<RepairModuleSlot
|
||||
key={slot.label}
|
||||
label={slot.label}
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0] + slot.offset[0],
|
||||
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
|
||||
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
|
||||
]}
|
||||
disabled={slotsDisabled}
|
||||
onModelSelected={markModelSelected}
|
||||
onSplit={markModuleSplit}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairInspectionObjectProps {
|
||||
config: RepairMissionConfig;
|
||||
worldPosition: Vector3Tuple;
|
||||
onInspect: () => void;
|
||||
}
|
||||
|
||||
export function RepairInspectionObject({
|
||||
config,
|
||||
worldPosition,
|
||||
onInspect,
|
||||
}: RepairInspectionObjectProps): React.JSX.Element {
|
||||
return (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={`Inspecter ${config.label}`}
|
||||
position={worldPosition}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onPress={onInspect}
|
||||
>
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 0.9}
|
||||
/>
|
||||
<RepairPromptVideo src={config.stageUiPath} />
|
||||
</InteractableObject>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import {
|
||||
RepairCaseModel,
|
||||
type RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_FOCUS_SCALE,
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairMissionCaseProps {
|
||||
config: RepairMissionConfig;
|
||||
exiting?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
open?: boolean;
|
||||
zoomed?: boolean;
|
||||
showFragmentationPrompt?: boolean;
|
||||
onInteract?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function RepairMissionCase({
|
||||
config,
|
||||
exiting = false,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
open = false,
|
||||
zoomed = false,
|
||||
showFragmentationPrompt = false,
|
||||
onInteract,
|
||||
}: RepairMissionCaseProps): React.JSX.Element {
|
||||
const casePosition = zoomed
|
||||
? REPAIR_CASE_FOCUS_POSITION
|
||||
: config.case.position;
|
||||
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
|
||||
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{onInteract ? (
|
||||
<TriggerObject
|
||||
position={casePosition}
|
||||
colliders="ball"
|
||||
label={`Ouvrir ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={onInteract}
|
||||
>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
</TriggerObject>
|
||||
) : (
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
)}
|
||||
{showFragmentationPrompt && !exiting ? (
|
||||
<RepairPromptVideo
|
||||
src={config.interactUiPath}
|
||||
position={[casePosition[0], 2.4, casePosition[2]]}
|
||||
size={80}
|
||||
/>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useCallback, useState } from "react";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_GAME_MODEL_CATALOG } from "@/data/gameplay/repairGameModelCatalog";
|
||||
import type { ModelCatalogItem } from "@/data/gameplay/repairGameModelCatalog";
|
||||
import { useModelSelection } from "@/hooks/gameplay/useModelSelection";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairModuleSlotProps {
|
||||
position: Vector3Tuple;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onModelSelected?: () => void;
|
||||
onSplit?: () => void;
|
||||
}
|
||||
|
||||
export function RepairModuleSlot({
|
||||
position,
|
||||
label,
|
||||
disabled = false,
|
||||
onModelSelected,
|
||||
onSplit,
|
||||
}: RepairModuleSlotProps): React.JSX.Element {
|
||||
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
|
||||
null,
|
||||
);
|
||||
const [split, setSplit] = useState(false);
|
||||
const handleSelect = useCallback(
|
||||
(model: ModelCatalogItem) => {
|
||||
setSelectedModel(model);
|
||||
setSplit(false);
|
||||
onModelSelected?.();
|
||||
},
|
||||
[onModelSelected],
|
||||
);
|
||||
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
|
||||
const triggerLabel = disabled
|
||||
? "Ouvrir la mallette d'abord"
|
||||
: selectedModel
|
||||
? split
|
||||
? `Réassembler ${label}`
|
||||
: `Démonter ${label}`
|
||||
: `Choisir ${label}`;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<TriggerObject
|
||||
position={position}
|
||||
colliders="cuboid"
|
||||
label={triggerLabel}
|
||||
onTrigger={() => {
|
||||
if (disabled) return;
|
||||
|
||||
if (selectedModel) {
|
||||
setSplit((value) => {
|
||||
const nextSplit = !value;
|
||||
if (nextSplit) {
|
||||
onSplit?.();
|
||||
}
|
||||
return nextSplit;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
selection.open();
|
||||
}}
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ExplodableModel
|
||||
modelPath={selectedModel.path}
|
||||
split={split}
|
||||
position={[0, -0.35, 0]}
|
||||
scale={0.45}
|
||||
/>
|
||||
) : (
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1, 0.18, 1]} />
|
||||
<meshStandardMaterial
|
||||
color="#38bdf8"
|
||||
emissive="#082f49"
|
||||
roughness={0.55}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</TriggerObject>
|
||||
|
||||
{selection.isOpen ? (
|
||||
<Html position={[position[0], position[1] + 1.2, position[2]]} center>
|
||||
<div className="model-selector-panel">
|
||||
<strong>{label}</strong>
|
||||
<span>Fleches: choisir</span>
|
||||
<span>E/Enter: valider</span>
|
||||
<ul>
|
||||
{REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
|
||||
<li
|
||||
key={model.path}
|
||||
className={
|
||||
index === selection.selectedIndex
|
||||
? "is-selected"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{model.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Html>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { SimpleModel } from "@/components/three/models/SimpleModel";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairObjectModelProps extends ModelTransformProps {
|
||||
label: string;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface RepairObjectFallbackProps {
|
||||
label: string;
|
||||
position?: ModelTransformProps["position"] | undefined;
|
||||
rotation?: ModelTransformProps["rotation"] | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}
|
||||
|
||||
class RepairObjectModelBoundary extends Component<
|
||||
RepairObjectModelBoundaryProps,
|
||||
RepairObjectModelBoundaryState
|
||||
> {
|
||||
constructor(props: RepairObjectModelBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): RepairObjectModelBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelPath,
|
||||
position: this.props.position,
|
||||
rotation: this.props.rotation,
|
||||
scale: this.props.scale,
|
||||
scope: `RepairObjectModel.${this.props.label}`,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<RepairObjectFallback
|
||||
label={this.props.label}
|
||||
position={this.props.position}
|
||||
rotation={this.props.rotation}
|
||||
scale={this.props.scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function RepairObjectModel({
|
||||
label,
|
||||
modelPath,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairObjectModelProps): React.JSX.Element {
|
||||
return (
|
||||
<RepairObjectModelBoundary
|
||||
label={label}
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
>
|
||||
<SimpleModel
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
</RepairObjectModelBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairObjectFallback({
|
||||
label,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: Pick<
|
||||
RepairObjectFallbackProps,
|
||||
"label" | "position" | "rotation" | "scale"
|
||||
>): React.JSX.Element {
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={toVector3Scale(scale)}
|
||||
>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1.4, 1.4, 1.4]} />
|
||||
<meshStandardMaterial color="#facc15" roughness={0.6} wireframe />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.05, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshBasicMaterial color={label ? "#f8fafc" : "#facc15"} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { WorldVideoPrompt } from "@/components/three/ui/WorldVideoPrompt";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairPromptVideoProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function RepairPromptVideo({
|
||||
src,
|
||||
position = [0, 1.8, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: RepairPromptVideoProps): React.JSX.Element {
|
||||
return (
|
||||
<WorldVideoPrompt
|
||||
billboard={billboard}
|
||||
position={position}
|
||||
size={size}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
|
||||
interface RepairReassemblyStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairReassemblyStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairReassemblyStepProps): React.JSX.Element {
|
||||
const [split, setSplit] = useState(true);
|
||||
const reassemblySeconds =
|
||||
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
|
||||
|
||||
useEffect(() => {
|
||||
const closeTimeoutId = window.setTimeout(() => {
|
||||
setSplit(false);
|
||||
}, 50);
|
||||
const completeTimeoutId = window.setTimeout(() => {
|
||||
onComplete();
|
||||
}, reassemblySeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(closeTimeoutId);
|
||||
window.clearTimeout(completeTimeoutId);
|
||||
};
|
||||
}, [onComplete, reassemblySeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split={split}
|
||||
splitDistance={1.2}
|
||||
/>
|
||||
<RepairCompletionParticles />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
||||
const _placeholderPosition = new THREE.Vector3();
|
||||
const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.15, 1, 0.25],
|
||||
[0, 1.05, 0.45],
|
||||
[1.15, 1, 0.25],
|
||||
];
|
||||
const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.35, 0.55, -0.85],
|
||||
[0, 0.6, -1],
|
||||
[1.35, 0.55, -0.85],
|
||||
];
|
||||
const REPAIR_INSTALL_RADIUS = 1.1;
|
||||
const VALID_PART_COLOR = "#22c55e";
|
||||
const INVALID_PART_COLOR = "#ef4444";
|
||||
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||
|
||||
interface RepairRepairingStepProps {
|
||||
brokenParts: readonly RepairScannedBrokenPart[];
|
||||
config: RepairMissionConfig;
|
||||
placeholders: readonly RepairCasePlaceholder[];
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairInstallTargetProps {
|
||||
blockedFeedback: boolean;
|
||||
fillColor: string;
|
||||
isReadyToInstall: boolean;
|
||||
label: string;
|
||||
ringColor: string;
|
||||
onBlocked: () => void;
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairPlaceholderMarkersProps {
|
||||
positions: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface RepairPartPlacementFeedbackProps {
|
||||
state: "valid" | "invalid" | "stored" | null;
|
||||
}
|
||||
|
||||
export function RepairRepairingStep({
|
||||
brokenParts,
|
||||
config,
|
||||
placeholders,
|
||||
onRepair,
|
||||
}: RepairRepairingStepProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
|
||||
useState(false);
|
||||
const replacementParts = getReplacementParts(config);
|
||||
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
||||
const requiredReplacementPart = replacementParts.find(
|
||||
(part) => part.id === config.requiredReplacementPartId,
|
||||
);
|
||||
const requiredReplacementLabel =
|
||||
requiredReplacementPart?.label ?? config.label;
|
||||
const placeholderTargets = getPlaceholderTargets(placeholders);
|
||||
const placeholderPositions = placeholderTargets.map(
|
||||
(target) => target.position,
|
||||
);
|
||||
const hasCorrectPartPlaced = Boolean(
|
||||
placedPartIds[config.requiredReplacementPartId],
|
||||
);
|
||||
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
||||
(part) => depositedBrokenPartIds[part.id],
|
||||
);
|
||||
const hasWrongPartPlaced = replacementParts.some(
|
||||
(part) =>
|
||||
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
|
||||
);
|
||||
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
||||
const installColor = isReadyToInstall
|
||||
? "#22c55e"
|
||||
: hasWrongPartPlaced
|
||||
? "#ef4444"
|
||||
: "#f97316";
|
||||
const installFillColor = isReadyToInstall
|
||||
? "#86efac"
|
||||
: hasWrongPartPlaced
|
||||
? "#fecaca"
|
||||
: "#fed7aa";
|
||||
const installLabel = isReadyToInstall
|
||||
? `Installer ${requiredReplacementLabel}`
|
||||
: hasWrongPartPlaced
|
||||
? `Mauvaise pièce`
|
||||
: hasCorrectPartPlaced
|
||||
? `Ranger pièce cassée`
|
||||
: `Approcher ${requiredReplacementLabel}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBlockedInstallFeedback) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowBlockedInstallFeedback(false);
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showBlockedInstallFeedback]);
|
||||
|
||||
function handleReplacementPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
): void {
|
||||
const isPlaced = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
placeholderPositions,
|
||||
);
|
||||
setPlacedPartIds((current) => {
|
||||
if (!current[partId] || isPlaced) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleReplacementSnap(partId: string): void {
|
||||
setPlacedPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
targets: readonly Vector3Tuple[],
|
||||
): void {
|
||||
const isDeposited = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
targets,
|
||||
);
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (!current[partId] || isDeposited) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartSnap(partId: string): void {
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairInstallTarget
|
||||
blockedFeedback={showBlockedInstallFeedback}
|
||||
fillColor={installFillColor}
|
||||
isReadyToInstall={isReadyToInstall}
|
||||
label={installLabel}
|
||||
ringColor={installColor}
|
||||
onBlocked={() => setShowBlockedInstallFeedback(true)}
|
||||
onRepair={onRepair}
|
||||
/>
|
||||
|
||||
<RepairPlaceholderMarkers positions={placeholderPositions} />
|
||||
|
||||
{replacementParts.map((part, index) => {
|
||||
const placeholderPosition =
|
||||
placeholderPositions[index % placeholderPositions.length] ??
|
||||
placeholderPositions[0]!;
|
||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||
const feedbackState = getReplacementFeedbackState(
|
||||
part.id,
|
||||
config.requiredReplacementPartId,
|
||||
isPlaced,
|
||||
);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={placeholderPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Prendre ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleReplacementPosition(part.id, position);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleReplacementSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={placeholderPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath ?? config.modelPath}
|
||||
scale={0.36}
|
||||
/>
|
||||
<RepairPartPlacementFeedback state={feedbackState} />
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{brokenPartsToDeposit.map((part, index) => {
|
||||
const startOffset =
|
||||
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
|
||||
BROKEN_PART_START_OFFSETS[0]!;
|
||||
const startPosition: Vector3Tuple = [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
|
||||
];
|
||||
const targetPositions = getBrokenPartTargetPositions(
|
||||
part,
|
||||
placeholderTargets,
|
||||
);
|
||||
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={startPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Ranger ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleBrokenPartPosition(part.id, position, targetPositions);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleBrokenPartSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={targetPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath}
|
||||
scale={0.24}
|
||||
/>
|
||||
<mesh position={[0, 0.42, 0]}>
|
||||
<sphereGeometry args={[0.11, 16, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<RepairPartPlacementFeedback
|
||||
state={isDeposited ? "stored" : null}
|
||||
/>
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{isReadyToInstall ? (
|
||||
<RepairPromptVideo src={config.interactUiPath} position={[0, 2.3, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairInstallTarget({
|
||||
blockedFeedback,
|
||||
fillColor,
|
||||
isReadyToInstall,
|
||||
label,
|
||||
ringColor,
|
||||
onBlocked,
|
||||
onRepair,
|
||||
}: RepairInstallTargetProps): React.JSX.Element {
|
||||
return (
|
||||
<TriggerObject
|
||||
position={INSTALL_TARGET_POSITION}
|
||||
colliders="ball"
|
||||
label={label}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => {
|
||||
if (!isReadyToInstall) {
|
||||
onBlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
onRepair();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.95, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 0.9, 96]} />
|
||||
<meshBasicMaterial color={fillColor} transparent opacity={0.35} />
|
||||
</mesh>
|
||||
{blockedFeedback ? (
|
||||
<group position={[0, 0.28, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.08, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.12, 16, 16]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
) : null}
|
||||
</TriggerObject>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPlaceholderMarkers({
|
||||
positions,
|
||||
}: RepairPlaceholderMarkersProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{positions.map((position, index) => (
|
||||
<mesh
|
||||
key={`${position.join(":")}-${index}`}
|
||||
position={position}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<torusGeometry args={[0.26, 0.018, 8, 48]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPartPlacementFeedback({
|
||||
state,
|
||||
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
|
||||
if (!state) return null;
|
||||
|
||||
const color = getPlacementFeedbackColor(state);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.72, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.48, 0.035, 12, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getPlacementFeedbackColor(
|
||||
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
|
||||
): string {
|
||||
if (state === "valid") return VALID_PART_COLOR;
|
||||
if (state === "stored") return STORED_BROKEN_PART_COLOR;
|
||||
|
||||
return INVALID_PART_COLOR;
|
||||
}
|
||||
|
||||
function getReplacementFeedbackState(
|
||||
partId: string,
|
||||
requiredPartId: string,
|
||||
isPlaced: boolean,
|
||||
): RepairPartPlacementFeedbackProps["state"] {
|
||||
if (!isPlaced) return null;
|
||||
|
||||
return partId === requiredPartId ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
function getPlaceholderTargets(
|
||||
placeholders: readonly RepairCasePlaceholder[],
|
||||
): readonly RepairCasePlaceholder[] {
|
||||
if (placeholders.length > 0) {
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
||||
(offset, index): RepairCasePlaceholder => ({
|
||||
name: `placeholder_${index + 1}`,
|
||||
position: [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getBrokenPartTargetPositions(
|
||||
part: RepairScannedBrokenPart,
|
||||
placeholderTargets: readonly RepairCasePlaceholder[],
|
||||
): readonly Vector3Tuple[] {
|
||||
if (!part.placeholderName) {
|
||||
return placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
const matchingPlaceholder = placeholderTargets.find(
|
||||
(placeholder) => placeholder.name === part.placeholderName,
|
||||
);
|
||||
|
||||
return matchingPlaceholder
|
||||
? [matchingPlaceholder.position]
|
||||
: placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
function isNearPlaceholder(
|
||||
position: THREE.Vector3,
|
||||
placeholderPositions: readonly Vector3Tuple[],
|
||||
): boolean {
|
||||
return placeholderPositions.some(
|
||||
(placeholderPosition) =>
|
||||
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
|
||||
REPAIR_INSTALL_RADIUS,
|
||||
);
|
||||
}
|
||||
|
||||
function getStepLocalPosition(
|
||||
worldPosition: THREE.Vector3,
|
||||
group: THREE.Group | null,
|
||||
target: THREE.Vector3,
|
||||
): THREE.Vector3 {
|
||||
target.copy(worldPosition);
|
||||
group?.worldToLocal(target);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function getReplacementParts(
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairMissionPartConfig[] {
|
||||
if (config.replacementParts.length > 0) return config.replacementParts;
|
||||
|
||||
return [
|
||||
{
|
||||
id: config.requiredReplacementPartId,
|
||||
label: config.label,
|
||||
modelPath: config.modelPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getBrokenPartsToDeposit(
|
||||
config: RepairMissionConfig,
|
||||
brokenParts: readonly RepairScannedBrokenPart[],
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
if (brokenParts.length > 0) return brokenParts;
|
||||
|
||||
return config.brokenParts.map((part) => ({
|
||||
id: part.id,
|
||||
label: part.label,
|
||||
modelPath: part.modelPath ?? config.modelPath,
|
||||
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
|
||||
}));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user