10 Commits

Author SHA1 Message Date
Tom Boullay 6854f52b23 fix: a pb with octree
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
2026-05-11 16:43:02 +02:00
Tom Boullay 601cc4b6be update: en dialogue sub 2026-05-11 13:37:12 +02:00
Tom Boullay ee41361a90 update: cinematic references 2026-05-11 13:22:15 +02:00
Tom Boullay b077b65640 update: doc 2026-05-11 13:14:08 +02:00
Tom Boullay 808fd1631b update: doc dialogue and cinematic tools 2026-05-11 13:10:26 +02:00
Tom Boullay 85c45029f2 update: assit dialogue and srt creation 2026-05-11 13:05:03 +02:00
Tom Boullay 5802d5adf8 update: edit cinematic dialogue 2026-05-11 13:01:56 +02:00
Tom Boullay 35f8d8fc87 update: sync dialogue and cinematic 2026-05-11 12:58:12 +02:00
Tom Boullay 0b58b9aeef add: cinematic preview 2026-05-11 12:53:18 +02:00
Tom Boullay f9e7243659 update: add dialogue preview 2026-05-11 12:48:59 +02:00
24 changed files with 1075 additions and 66 deletions
+16 -2
View File
@@ -56,6 +56,18 @@ This document describes the code that exists today in the repository.
- `src/managers/stores/useSubtitleStore.ts` stores the currently displayed subtitle cue.
- `src/components/ui/Subtitles.tsx` renders the subtitle overlay.
- `src/world/GameDialogues.tsx` currently triggers dialogue entries that define a `timecode`.
- Dialogue playback is queued so multiple dialogue requests do not overlap.
## Cinematics
- `public/cinematics.json` is the runtime cinematic manifest.
- `src/types/cinematics/cinematics.ts` contains cinematic manifest types.
- `src/utils/cinematics/cinematicManifestValidation.ts` validates manifest shape at runtime.
- `src/utils/cinematics/loadCinematicManifest.ts` loads `/cinematics.json`.
- `src/world/GameCinematics.tsx` triggers cinematics that define a global `timecode`.
- Cinematics use GSAP timelines to animate the active camera position and look target.
- `dialogueCues` on a cinematic trigger dialogue IDs at times relative to the cinematic start.
- `src/managers/stores/useGameStore.ts` exposes `isCinematicPlaying`, used to lock player input during cinematics.
## Debug System
@@ -83,6 +95,8 @@ This document describes the code that exists today in the repository.
- `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.
@@ -105,7 +119,7 @@ This document describes the code that exists today in the repository.
- 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`.
- Missions, zones, and cinematics are not implemented.
- Dialogue playback exists, but queueing, branching, and gameplay-triggered dialogue orchestration are still limited.
- Missions and zones are not implemented.
- Dialogue branching and gameplay-triggered orchestration are still limited.
- 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.
+48 -2
View File
@@ -23,6 +23,8 @@ src/
├── components/
│ └── editor/
│ ├── EditorControls.tsx
│ ├── EditorCinematicManifestPanel.tsx
│ ├── EditorDialogueManifestPanel.tsx
│ ├── EditorSrtPanel.tsx
│ └── scene/
│ ├── EditorMap.tsx
@@ -62,6 +64,10 @@ 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.
@@ -172,7 +178,46 @@ The current model is one SRT file per voice and language. A dialogue entry refer
SRT timecodes are relative to the dialogue audio file being previewed, not to the global game timeline.
Missing English SRT files are warnings because runtime loading falls back to French subtitles when the selected language is not available.
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
@@ -185,4 +230,5 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas
- 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.
- The editor validates dialogue assets but does not yet create, delete, or reorder dialogue manifest entries.
- 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.
+58 -2
View File
@@ -76,7 +76,28 @@ The button is hidden in production builds because production persistence is not
## Editing Dialogue Subtitles
The side panel also includes an SRT editor for 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`.
@@ -98,7 +119,41 @@ The validation checks:
- French SRT files
- subtitle cue indexes referenced by the manifest
Missing English SRT files are warnings because the runtime falls back to French subtitles.
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
@@ -108,3 +163,4 @@ Missing English SRT files are warnings because the runtime falls back to French
- 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.
+12 -2
View File
@@ -40,6 +40,15 @@ This document lists features that are implemented in the current codebase.
- 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
@@ -75,14 +84,15 @@ This document lists features that are implemented in the current codebase.
- 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
- mission system
- zone system
- cinematic system
- dialogue queueing and gameplay-triggered dialogue branches
- gameplay-triggered dialogue branches beyond current prototype triggers
- loading flow
- minimap and mission HUD
- full production separation between gameplay and debug scenes
+6
View File
@@ -4,6 +4,12 @@
{
"id": "intro_overview",
"timecode": 0,
"dialogueCues": [
{
"time": 0,
"dialogueId": "narrateur_bienvenueaaltera"
}
],
"cameraKeyframes": [
{
"time": 0,
+1 -2
View File
@@ -31,8 +31,7 @@
"id": "narrateur_bienvenueaaltera",
"voice": "narrateur",
"audio": "/sounds/dialogue/narrateur_bienvenueaaltera.mp3",
"subtitleCueIndex": 1,
"timecode": 0
"subtitleCueIndex": 1
},
{
"id": "narrateur_intro_prenom",
@@ -0,0 +1,11 @@
# English Subtitle Fallback
English SRT files are intentionally optional for now.
The dialogue runtime first tries the selected subtitle language, then falls back to French. Missing English files should therefore remain validation warnings, not blocking errors, until the English translation workflow is ready.
Expected future files:
- `narrateur.srt`
- `fermier.srt`
- `electricienne.srt`
@@ -0,0 +1,11 @@
1
00:00:00,000 --> 00:00:08,000
Hey!! How are you? Do you need help placing the rollers?
2
00:00:00,000 --> 00:00:08,000
Don't hesitate if you need anything else!
3
00:00:00,000 --> 00:00:08,000
See you next time!
@@ -0,0 +1,11 @@
1
00:00:00,000 --> 00:00:04,032
Wait, wait, young man! I'll give you a hand.
2
00:00:00,000 --> 00:00:03,744
I did puzzles all through my youth. Try this!
3
00:00:00,000 --> 00:00:07,104
If you need anything else, don't hesitate, my boy. I'm getting old, but my mind is still sharp, hehehe!
@@ -0,0 +1,91 @@
1
00:00:00,000 --> 00:00:02,760
Hello there, future resident of Altera! Today, you are going to discover the technician role at La Fabrik, which handles Low-Tech technologies and repairs.
2
00:00:00,000 --> 00:00:11,592
Before we start, what's your name?
3
00:00:00,000 --> 00:00:10,824
Very good! We'll begin step by step to show you how the workshop works. Then you'll start your day and see the positive impact La Fabrik has on the community and the neighborhood.
4
00:00:00,000 --> 00:00:06,072
Let's go! You need to head to the farm, we're looking to improve something! Hop on your E-Bike.
5
00:00:00,000 --> 00:00:12,720
What? Your E-Bike is broken? Well, that's not too serious, it happens! Use the two rollers on your gloves. They're real technological gems. Place one under the bike, and one above it.
6
00:00:00,000 --> 00:00:08,064
So? Pretty amazing, right? Anyway, these rollers will scan the components to find out what we need to repair and/or replace.
7
00:00:00,000 --> 00:00:04,992
Perfect! The cooler gave out, you can replace it with one of the components from your pack. Aaaand there we go! It runs like clockwork! Go on, hurry!
8
00:00:00,000 --> 00:00:04,512
Don't hesitate to ask for help if you need it, everyone is super welcoming here.
9
00:00:00,000 --> 00:00:08,880
Oh woooow!! Did you see that???? All the traffic lights, computers and lights went out!! Hurry to the Energy Center, we can't even send repaired devices back out!
10
00:00:00,000 --> 00:00:09,840
Ah! A power pole fell down! Damn! Aaah, those little moles, they cause so much trouble... But they're so cuuuute!
11
00:00:00,000 --> 00:00:07,632
Woohoo! Great! Power is back across the whole neighborhood! Well done! Now head to the farm.
12
00:00:00,000 --> 00:00:05,352
Well, thanks to you I was able to finish my emergency! Oh, you're almost at the farm!
13
00:00:00,000 --> 00:00:11,760
Okay, enough of the emotional moment haha! For the farm, as I told you, we need to change the irrigation here. During drought periods, residents complain about an issue. See what you can do.
14
00:00:00,000 --> 00:00:04,560
Okay, perfect, you're there! Search the Center to find where the problem is coming from.
15
00:00:00,000 --> 00:00:06,864
Yeees! That's it! We'd like to stop pumping water from the lake, otherwise we'll drain all its reserves. What do you suggest?
16
00:00:00,000 --> 00:00:10,944
The old cooler from your E-Bike?? Yes!! Hahaha, great idea! Combined with the old lake pipes, we'll be able to make something cool! Put all that between your pads!
17
00:00:00,000 --> 00:00:05,712
The cooler from your E-Bike is broken, but we can still make something useful out of it.
18
00:00:00,000 --> 00:00:10,032
Ma-gni-fi-cent! I can see Gilbert helped you haha, he's such a sweetheart! You did a great job! And thanks to you, the neighborhood has been improved.
19
00:00:00,000 --> 00:00:11,520
Thank you so much for what you've brought to the community. The electrician and Gilbert really enjoyed helping you and told me they're looking forward to the next village party to get to know you better.
20
00:00:00,000 --> 00:00:02,352
Good luck! I've got work to do!
21
00:00:00,000 --> 00:00:33,600
Welcome to your workshop!! So? Pretty impressive, right? Okay, quick tour of what's here: this is your workbench. In the pipes are items from neighborhood residents that broke down and are waiting to be repaired. Once repaired, you put the item in this pipe and it goes back to the right person.
22
00:00:00,000 --> 00:00:14,760
Here, this is a dashboard. You can imagine that if your fridge or oven breaks down, you won't be able to put it in the pipe haha! So here, it tells you when residents have a bulky item that broke down, or when there's a problem in the city. Uh oh... I've got an emergency, I'll have to leave you soon! So here, take your tools to repair most things: a mini 3D printer powered by electronic waste, Push-Parts gloves to disassemble objects, and a Relaunch pack!
23
00:00:00,000 --> 00:00:54,000
The electrician helped you at the Power Plant? Aaaaah, that's what I love here: everyone helps each other, nobody judges anyone, it's like a real little family. You should know the electrician has quite a special story. She was born in the north of the continent, in the city of Kalska. She grew up happily with her mother Edith, her father Jordan, and her two little brothers, Malo and Justin. A few years ago, as you know, the northern countries were, quite unexpectedly, the first ones forced to migrate. So they began their journey, country by country, city by city, village by village. On a day of walking like so many others after several months, a climate storm caught them off guard. Having split up to find food in the village, her father and one of her two brothers sadly disappeared. It's tragic. But one day, they happened upon this place during their journey. We welcomed them with open arms, and they were slowly able to rebuild their lives among us. Today, they are an integral part of the community.
@@ -1,12 +1,18 @@
import { useEffect, useState } from "react";
import { Plus, RefreshCw, Save, Trash2 } from "lucide-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;
@@ -39,7 +45,20 @@ function createKeyframe(
};
}
function getManifestErrors(manifest: CinematicManifest | null): string[] {
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[] = [];
@@ -74,6 +93,18 @@ function getManifestErrors(manifest: CinematicManifest | null): string[] {
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;
@@ -105,6 +136,11 @@ function getPatchedCinematic(
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) {
@@ -124,12 +160,23 @@ function updateVector(
return nextVector;
}
export function EditorCinematicManifestPanel(): React.JSX.Element {
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 errors = getManifestErrors(manifest);
const dialogueIds = new Set(
dialogueManifest?.dialogues.map((dialogue) => dialogue.id) ?? [],
);
const errors = getManifestErrors(manifest, dialogueIds);
const selectedCinematic =
manifest?.cinematics.find(
(cinematic) => cinematic.id === selectedCinematicId,
@@ -141,8 +188,12 @@ export function EditorCinematicManifestPanel(): React.JSX.Element {
setStatus("Chargement des cinematics...");
try {
const loadedManifest = await loadCinematicManifest();
const [loadedManifest, loadedDialogueManifest] = await Promise.all([
loadCinematicManifest(),
loadDialogueManifest(),
]);
setManifest(loadedManifest);
setDialogueManifest(loadedDialogueManifest);
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
setStatus(
loadedManifest
@@ -260,14 +311,54 @@ export function EditorCinematicManifestPanel(): React.JSX.Element {
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 loadCinematicManifest()
.then((loadedManifest) => {
void Promise.all([loadCinematicManifest(), loadDialogueManifest()])
.then(([loadedManifest, loadedDialogueManifest]) => {
if (!mounted) return;
setManifest(loadedManifest);
setDialogueManifest(loadedDialogueManifest);
setSelectedCinematicId(loadedManifest?.cinematics[0]?.id ?? "");
setStatus(
loadedManifest
@@ -431,6 +522,87 @@ export function EditorCinematicManifestPanel(): React.JSX.Element {
)}
</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"
+4 -1
View File
@@ -15,6 +15,7 @@ import {
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 {
@@ -31,6 +32,7 @@ interface EditorControlsProps {
onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined;
onPreviewCinematic?: ((cinematic: CinematicDefinition) => void) | undefined;
isPlayerMode?: boolean;
}
@@ -62,6 +64,7 @@ export function EditorControls({
onExportJson,
onSaveToServer,
onPlayerMode,
onPreviewCinematic,
isPlayerMode,
}: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
@@ -240,7 +243,7 @@ export function EditorControls({
</div>
</section>
<EditorCinematicManifestPanel />
<EditorCinematicManifestPanel onPreviewCinematic={onPreviewCinematic} />
<EditorDialogueManifestPanel />
<EditorSrtPanel />
</aside>
@@ -1,26 +1,110 @@
import { useEffect, useState } from "react";
import { Plus, RefreshCw, Save, Trash2 } from "lucide-react";
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): DialogueDefinition {
function createDialogue(
index: number,
manifest: DialogueManifest,
voice: DialogueVoiceId,
): DialogueDefinition {
return {
id: `new_dialogue_${index}`,
voice: DEFAULT_VOICE,
audio: "/sounds/dialogue/new_dialogue.mp3",
subtitleCueIndex: 1,
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."];
@@ -89,10 +173,13 @@ function getPatchedDialogue(
}
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(
@@ -144,16 +231,38 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
}
}
function handleAddDialogue(): void {
async function handleAddDialogue(): Promise<void> {
if (!manifest) return;
const dialogue = createDialogue(manifest.dialogues.length + 1);
setManifest({
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);
setStatus("Nouveau dialogue ajoute localement.");
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 {
@@ -184,6 +293,60 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
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;
@@ -209,6 +372,8 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
return () => {
mounted = false;
previewAudioRef.current?.pause();
previewAudioRef.current = null;
};
}, []);
@@ -227,9 +392,13 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
<RefreshCw size={14} aria-hidden="true" />
Reload
</button>
<button type="button" disabled={!manifest} onClick={handleAddDialogue}>
<button
type="button"
disabled={!manifest || isCreatingSrtCue}
onClick={() => void handleAddDialogue()}
>
<Plus size={14} aria-hidden="true" />
Add
{isCreatingSrtCue ? "Adding..." : "Add"}
</button>
<button
type="button"
@@ -332,6 +501,26 @@ export function EditorDialogueManifestPanel(): React.JSX.Element {
/>
</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"
+96 -2
View File
@@ -1,9 +1,18 @@
import { useEffect } from "react";
import { useEffect, useRef } 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;
@@ -18,6 +27,8 @@ interface EditorSceneProps {
onUndo: () => void;
onRedo: () => void;
isPlayerMode?: boolean;
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
onCinematicPreviewComplete?: (() => void) | undefined;
}
export function EditorScene({
@@ -34,7 +45,11 @@ 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) {
@@ -74,10 +89,16 @@ export function EditorScene({
return (
<>
<EditorCinematicPreviewPlayer
request={cinematicPreviewRequest}
onComplete={onCinematicPreviewComplete}
/>
{isPlayerMode ? (
<FlyController disabled={false} />
<FlyController disabled={isCinematicPreviewing} />
) : (
<OrbitControls
enabled={!isCinematicPreviewing}
enableDamping
dampingFactor={0.05}
mouseButtons={{
@@ -106,3 +127,76 @@ export function EditorScene({
</>
);
}
interface EditorCinematicPreviewPlayerProps {
request: EditorCinematicPreviewRequest | null;
onComplete?: (() => void) | undefined;
}
function EditorCinematicPreviewPlayer({
request,
onComplete,
}: EditorCinematicPreviewPlayerProps): null {
const camera = useThree((state) => state.camera);
const timelineRef = useRef<gsap.core.Timeline | null>(null);
useEffect(() => {
timelineRef.current?.kill();
timelineRef.current = null;
if (!request) return undefined;
const firstKeyframe = request.cinematic.cameraKeyframes[0];
if (!firstKeyframe) return undefined;
const target = new THREE.Vector3(...firstKeyframe.target);
camera.position.set(...firstKeyframe.position);
camera.lookAt(target);
const timeline = gsap.timeline({
onUpdate: () => camera.lookAt(target),
onComplete: () => {
timelineRef.current = null;
onComplete?.();
},
});
request.cinematic.cameraKeyframes.slice(1).forEach((keyframe, index) => {
const previousKeyframe = request.cinematic.cameraKeyframes[index];
if (!previousKeyframe) return;
const duration = keyframe.time - previousKeyframe.time;
timeline.to(
camera.position,
{
x: keyframe.position[0],
y: keyframe.position[1],
z: keyframe.position[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
timeline.to(
target,
{
x: keyframe.target[0],
y: keyframe.target[1],
z: keyframe.target[2],
duration,
ease: "power2.inOut",
},
previousKeyframe.time,
);
});
timelineRef.current = timeline;
return () => {
timeline.kill();
if (timelineRef.current === timeline) timelineRef.current = null;
};
}, [camera, onComplete, request]);
return null;
}
+71 -6
View File
@@ -139,6 +139,18 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
- \`src/managers/stores/useSubtitleStore.ts\` stocke la cue de sous-titre affichée.
- \`src/components/ui/Subtitles.tsx\` rend l'overlay de sous-titres.
- \`src/world/GameDialogues.tsx\` déclenche actuellement les dialogues qui définissent un \`timecode\`.
- La lecture de dialogue est mise en file pour éviter les chevauchements.
## Cinématiques
- \`public/cinematics.json\` est le manifeste runtime des cinématiques.
- \`src/types/cinematics/cinematics.ts\` contient les types du manifeste.
- \`src/utils/cinematics/cinematicManifestValidation.ts\` valide la forme du manifeste.
- \`src/utils/cinematics/loadCinematicManifest.ts\` charge \`/cinematics.json\`.
- \`src/world/GameCinematics.tsx\` déclenche les cinématiques qui définissent un \`timecode\` global.
- Les cinématiques utilisent GSAP pour animer la position caméra et sa cible de regard.
- Les \`dialogueCues\` d'une cinématique déclenchent des dialogues à des temps relatifs au début de la cinématique.
- \`useGameStore.isCinematicPlaying\` sert à bloquer les inputs joueur pendant une cinématique.
## Système debug
@@ -159,8 +171,8 @@ Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
- \`src/world/debug/TestMap.tsx\` fait encore partie de la composition active.
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
- Les systèmes de missions, zones et cinématiques ne sont pas implémentés.
- La lecture de dialogues existe, mais la file d'attente, les branches et l'orchestration par gameplay restent limitées.
- Les systèmes de missions et zones ne sont pas implémentés.
- Les branches de dialogue et l'orchestration gameplay restent limitées.
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
`;
@@ -438,6 +450,15 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
- Fallback vers les sous-titres français quand le fichier de langue sélectionné manque
- Overlay de sous-titres runtime avec couleurs par speaker
- Déclenchement timecodé pour les dialogues qui définissent \`timecode\`
- File d'attente pour éviter les dialogues superposés
## Cinématiques
- Manifeste de cinématiques dans \`public/cinematics.json\`
- Déclenchement timecodé des cinématiques
- Lecture de keyframes caméra via GSAP
- Dialogue cues optionnelles synchronisées avec les timelines de cinématique
- Blocage des inputs joueur pendant une cinématique
## Menu options
@@ -471,13 +492,14 @@ Ce document liste les fonctionnalités présentes dans le code actuel.
- Preview audio et outils de timing pour les cues SRT
- Endpoint de sauvegarde dev-server pour les fichiers SRT
- Validation du manifeste de dialogues depuis l'UI de l'éditeur
- Éditeur de manifeste dialogues avec preview et création assistée de cue SRT FR
- Éditeur de manifeste cinématiques avec keyframes caméra, dialogue cues et preview canvas
## Pas encore implémenté
- système de missions
- système de zones
- système de cinématiques
- file d'attente de dialogues et branches déclenchées par gameplay
- branches de dialogues gameplay au-delà des déclencheurs prototype actuels
- flow de chargement
- minimap et HUD de mission
- séparation complète production / debug pour les scènes gameplay
@@ -536,9 +558,26 @@ Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'édi
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
## Éditer les sous-titres de dialogue
## Éditer les dialogues et sous-titres
Le panneau latéral contient aussi un éditeur SRT pour les sous-titres de dialogue.
Le panneau latéral contient aussi des outils pour les dialogues et les sous-titres.
### Manifeste dialogues
Le panneau \`Dialogues\` permet d'éditer \`public/sounds/dialogue/dialogues.json\` sans ouvrir le JSON à la main.
- \`Reload\` recharge le manifeste depuis le disque.
- \`Add\` crée un dialogue local pour la voix courante et assigne le prochain index SRT disponible.
- \`Save\` écrit le manifeste via le serveur Vite local.
- \`Preview dialogue\` joue le dialogue sélectionné avec les sous-titres dans l'éditeur.
- \`Create FR SRT cue\` crée la cue française si elle manque.
- \`Delete dialogue\` supprime localement l'entrée sélectionnée.
Après \`Add\`, il faut cliquer \`Save\` pour conserver le dialogue dans le manifeste. La cue SRT FR est écrite directement, mais le manifeste reste local tant qu'il n'est pas sauvegardé.
Les nouveaux dialogues utilisent un chemin audio placeholder comme \`/sounds/dialogue/new_dialogue_24.mp3\`. Remplace-le par un vrai MP3 avant validation finale.
### Éditeur SRT
1. Choisir une voix : \`narrateur\`, \`fermier\` ou \`electricienne\`.
2. Choisir une langue : \`FR\` ou \`EN\`.
@@ -562,6 +601,31 @@ La validation vérifie :
Les fichiers SRT anglais manquants sont des warnings parce que le runtime retombe sur les sous-titres français.
## Éditer les cinématiques
Le panneau \`Cinematics\` permet d'éditer \`public/cinematics.json\`.
Chaque cinématique contient :
- un \`id\`
- un \`timecode\` global optionnel
- au moins deux keyframes caméra
- des dialogue cues optionnelles synchronisées avec la timeline
Les keyframes caméra définissent un temps relatif, une position caméra et une cible de regard. Les dialogue cues définissent un temps relatif et un \`dialogueId\` issu de \`dialogues.json\`.
Actions disponibles :
- \`Reload\` recharge le manifeste.
- \`Add\` crée une cinématique locale avec deux keyframes.
- \`Save\` écrit \`public/cinematics.json\` via le serveur Vite local.
- \`Preview cinematic\` joue l'animation caméra dans le canvas éditeur.
- \`Add keyframe\` et \`Remove\` modifient le chemin caméra.
- \`Add dialogue\` et \`Remove\` modifient les dialogues synchronisés.
- \`Delete cinematic\` supprime localement la cinématique sélectionnée.
Les dialogue cues sont la manière recommandée de synchroniser un dialogue avec une cinématique. Évite de donner aussi un \`timecode\` global au même dialogue dans \`dialogues.json\`, sinon il peut être lancé deux fois.
## Inspecteur JSON
Le panneau latéral affiche le JSON brut de la carte :
@@ -578,4 +642,5 @@ Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauveg
- La sauvegarde production n'est pas implémentée.
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
- La sauvegarde SRT est un helper local du serveur Vite, pas une API backend de production.
- Les sauvegardes dialogues et cinématiques sont aussi des helpers locaux du serveur Vite.
`;
+4 -1
View File
@@ -8,6 +8,7 @@ export function useOctreeGraphNode(
graphNodeRef: RefObject<Object3D | null>,
onOctreeReady: OctreeReadyHandler,
rebuildKey: string | number = 0,
enabled = true,
): void {
const octreeBuilt = useRef(false);
@@ -16,6 +17,8 @@ export function useOctreeGraphNode(
}, [rebuildKey]);
useEffect(() => {
if (!enabled) return;
const graphNode = graphNodeRef.current;
if (octreeBuilt.current || !graphNode) return;
octreeBuilt.current = true;
@@ -25,5 +28,5 @@ export function useOctreeGraphNode(
const octree = new Octree();
octree.fromGraphNode(graphNode);
onOctreeReady(octree);
}, [graphNodeRef, onOctreeReady, rebuildKey]);
}, [enabled, graphNodeRef, onOctreeReady, rebuildKey]);
}
+62 -10
View File
@@ -1731,6 +1731,8 @@ canvas {
}
.editor-dialogue-manifest-actions button,
.editor-dialogue-manifest-srt-cue,
.editor-dialogue-manifest-preview,
.editor-dialogue-manifest-delete {
display: inline-flex;
align-items: center;
@@ -1747,6 +1749,8 @@ canvas {
}
.editor-dialogue-manifest-actions button:hover,
.editor-dialogue-manifest-srt-cue:hover,
.editor-dialogue-manifest-preview:hover,
.editor-dialogue-manifest-delete:hover {
border-color: #ffffff;
background: #202020;
@@ -1757,6 +1761,12 @@ canvas {
opacity: 0.45;
}
.editor-dialogue-manifest-srt-cue:disabled,
.editor-dialogue-manifest-preview:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.editor-dialogue-manifest-select,
.editor-dialogue-manifest-form label {
display: grid;
@@ -1801,6 +1811,16 @@ canvas {
color: #fca5a5;
}
.editor-dialogue-manifest-preview {
border-color: rgba(125, 211, 252, 0.24);
color: #bae6fd;
}
.editor-dialogue-manifest-srt-cue {
border-color: rgba(134, 239, 172, 0.24);
color: #bbf7d0;
}
.editor-dialogue-manifest-status {
margin: 0;
color: #8d8d8d;
@@ -1851,9 +1871,12 @@ canvas {
}
.editor-cinematic-manifest-actions button,
.editor-cinematic-manifest-preview,
.editor-cinematic-manifest-delete,
.editor-cinematic-keyframes-heading button,
.editor-cinematic-keyframe-heading button {
.editor-cinematic-keyframe-heading button,
.editor-cinematic-dialogue-cues-heading button,
.editor-cinematic-dialogue-cue-heading button {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -1869,22 +1892,28 @@ canvas {
}
.editor-cinematic-manifest-actions button:hover,
.editor-cinematic-manifest-preview:hover,
.editor-cinematic-manifest-delete:hover,
.editor-cinematic-keyframes-heading button:hover,
.editor-cinematic-keyframe-heading button:hover {
.editor-cinematic-keyframe-heading button:hover,
.editor-cinematic-dialogue-cues-heading button:hover,
.editor-cinematic-dialogue-cue-heading button:hover {
border-color: #ffffff;
background: #202020;
}
.editor-cinematic-manifest-actions button:disabled,
.editor-cinematic-keyframe-heading button:disabled {
.editor-cinematic-manifest-preview:disabled,
.editor-cinematic-keyframe-heading button:disabled,
.editor-cinematic-dialogue-cue-heading button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.editor-cinematic-manifest-select,
.editor-cinematic-manifest-form label,
.editor-cinematic-vector-inputs label {
.editor-cinematic-vector-inputs label,
.editor-cinematic-dialogue-cue label {
display: grid;
gap: 5px;
color: #8d8d8d;
@@ -1896,6 +1925,7 @@ canvas {
.editor-cinematic-manifest-select select,
.editor-cinematic-manifest-form input,
.editor-cinematic-manifest-form select,
.editor-cinematic-vector-inputs input {
width: 100%;
box-sizing: border-box;
@@ -1908,6 +1938,7 @@ canvas {
.editor-cinematic-manifest-select select:focus,
.editor-cinematic-manifest-form input:focus,
.editor-cinematic-manifest-form select:focus,
.editor-cinematic-vector-inputs input:focus {
border-color: #ffffff;
outline: none;
@@ -1915,7 +1946,9 @@ canvas {
.editor-cinematic-manifest-form,
.editor-cinematic-keyframes,
.editor-cinematic-keyframe {
.editor-cinematic-keyframe,
.editor-cinematic-dialogue-cues,
.editor-cinematic-dialogue-cue {
display: grid;
gap: 8px;
}
@@ -1927,7 +1960,8 @@ canvas {
background: #070707;
}
.editor-cinematic-keyframes {
.editor-cinematic-keyframes,
.editor-cinematic-dialogue-cues {
padding: 10px;
border: 1px solid #242424;
border-radius: 14px;
@@ -1935,7 +1969,9 @@ canvas {
}
.editor-cinematic-keyframes-heading,
.editor-cinematic-keyframe-heading {
.editor-cinematic-keyframe-heading,
.editor-cinematic-dialogue-cues-heading,
.editor-cinematic-dialogue-cue-heading {
display: flex;
align-items: center;
justify-content: space-between;
@@ -1943,13 +1979,16 @@ canvas {
}
.editor-cinematic-keyframes-heading strong,
.editor-cinematic-keyframe-heading strong {
.editor-cinematic-keyframe-heading strong,
.editor-cinematic-dialogue-cues-heading strong,
.editor-cinematic-dialogue-cue-heading strong {
color: #f2f2f2;
font-size: 0.76rem;
font-weight: 800;
}
.editor-cinematic-keyframe {
.editor-cinematic-keyframe,
.editor-cinematic-dialogue-cue {
padding: 9px;
border: 1px solid #1f1f1f;
border-radius: 12px;
@@ -1976,11 +2015,24 @@ canvas {
color: #fca5a5;
}
.editor-cinematic-keyframe-heading button {
.editor-cinematic-manifest-preview {
border-color: rgba(125, 211, 252, 0.24);
color: #bae6fd;
}
.editor-cinematic-keyframe-heading button,
.editor-cinematic-dialogue-cue-heading button {
padding: 6px 8px;
color: #fca5a5;
}
.editor-cinematic-dialogue-cues p {
margin: 0;
color: #8d8d8d;
font-size: 0.72rem;
line-height: 1.4;
}
.editor-cinematic-manifest-status {
margin: 0;
color: #8d8d8d;
+23
View File
@@ -2,7 +2,10 @@ import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene";
import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene";
import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type { MapNode, SceneData, TransformMode } from "@/types/editor/editor";
@@ -28,6 +31,8 @@ export function EditorPage(): React.JSX.Element {
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const [cinematicPreviewRequest, setCinematicPreviewRequest] =
useState<EditorCinematicPreviewRequest | null>(null);
const {
undoCount,
@@ -88,6 +93,20 @@ export function EditorPage(): React.JSX.Element {
setIsPlayerMode((prev) => !prev);
}, []);
const handlePreviewCinematic = useCallback(
(cinematic: CinematicDefinition) => {
setCinematicPreviewRequest({
id: window.crypto.randomUUID(),
cinematic,
});
},
[],
);
const handleCinematicPreviewComplete = useCallback(() => {
setCinematicPreviewRequest(null);
}, []);
const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => {
@@ -171,6 +190,8 @@ export function EditorPage(): React.JSX.Element {
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
cinematicPreviewRequest={cinematicPreviewRequest}
onCinematicPreviewComplete={handleCinematicPreviewComplete}
/>
</Canvas>
@@ -193,9 +214,11 @@ export function EditorPage(): React.JSX.Element {
onExportJson={handleExportJson}
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
onPlayerMode={handlePlayerMode}
onPreviewCinematic={handlePreviewCinematic}
isPlayerMode={isPlayerMode}
/>
)}
<Subtitles />
</div>
);
}
+6
View File
@@ -6,10 +6,16 @@ export interface CinematicCameraKeyframe {
target: Vector3Tuple;
}
export interface CinematicDialogueCue {
time: number;
dialogueId: string;
}
export interface CinematicDefinition {
id: string;
timecode?: number;
cameraKeyframes: CinematicCameraKeyframe[];
dialogueCues?: CinematicDialogueCue[];
}
export interface CinematicManifest {
@@ -1,6 +1,7 @@
import type {
CinematicCameraKeyframe,
CinematicDefinition,
CinematicDialogueCue,
CinematicManifest,
} from "@/types/cinematics/cinematics";
import type { Vector3Tuple } from "@/types/three/three";
@@ -50,9 +51,28 @@ function parseCinematicDefinition(data: unknown): CinematicDefinition {
cinematic.timecode = data.timecode;
}
if (Array.isArray(data.dialogueCues)) {
cinematic.dialogueCues = data.dialogueCues.map(parseDialogueCue);
}
return cinematic;
}
function parseDialogueCue(data: unknown): CinematicDialogueCue {
if (
!isRecord(data) ||
typeof data.time !== "number" ||
typeof data.dialogueId !== "string"
) {
throw new Error("Invalid cinematic dialogue cue");
}
return {
time: data.time,
dialogueId: data.dialogueId,
};
}
function parseCameraKeyframe(data: unknown): CinematicCameraKeyframe {
if (!isRecord(data) || typeof data.time !== "number") {
throw new Error("Invalid cinematic camera keyframe");
+52 -1
View File
@@ -8,17 +8,24 @@ import type {
CinematicDefinition,
CinematicManifest,
} from "@/types/cinematics/cinematics";
import type { DialogueManifest } from "@/types/dialogues/dialogues";
import { logger } from "@/utils/core/logger";
import { loadCinematicManifest } from "@/utils/cinematics/loadCinematicManifest";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { queueDialogueById } from "@/utils/dialogues/playDialogue";
export function GameCinematics(): null {
const camera = useThree((state) => state.camera);
const [manifest, setManifest] = useState<CinematicManifest | null>(null);
const [dialogueManifest, setDialogueManifest] =
useState<DialogueManifest | null>(null);
const playedCinematicsRef = useRef(new Set<string>());
const timelineRef = useRef<gsap.core.Timeline | null>(null);
const activeAudiosRef = useRef(new Set<HTMLAudioElement>());
useEffect(() => {
let mounted = true;
const activeAudios = activeAudiosRef.current;
void loadCinematicManifest()
.then((loadedManifest) => {
@@ -30,9 +37,21 @@ export function GameCinematics(): null {
});
});
void loadDialogueManifest()
.then((loadedManifest) => {
if (mounted) setDialogueManifest(loadedManifest);
})
.catch((error: unknown) => {
logger.error("GameCinematics", "Failed to load dialogue manifest", {
error: error instanceof Error ? error : String(error),
});
});
return () => {
mounted = false;
stopActiveCinematic(timelineRef);
activeAudios.forEach((audio) => audio.pause());
activeAudios.clear();
useGameStore.getState().setCinematicPlaying(false);
};
}, []);
@@ -45,10 +64,14 @@ export function GameCinematics(): null {
manifest.cinematics.forEach((cinematic) => {
if (cinematic.timecode === undefined) return;
if (cinematic.timecode > elapsedTime) return;
if (cinematic.dialogueCues && !dialogueManifest) return;
if (playedCinematicsRef.current.has(cinematic.id)) return;
playedCinematicsRef.current.add(cinematic.id);
playCinematic(camera, cinematic, timelineRef);
playCinematic(camera, cinematic, timelineRef, {
dialogueManifest,
activeAudiosRef,
});
});
});
@@ -66,6 +89,10 @@ function playCinematic(
camera: THREE.Camera,
cinematic: CinematicDefinition,
timelineRef: MutableRefObject<gsap.core.Timeline | null>,
dialogueOptions: {
dialogueManifest: DialogueManifest | null;
activeAudiosRef: MutableRefObject<Set<HTMLAudioElement>>;
},
): void {
const firstKeyframe = cinematic.cameraKeyframes[0];
if (!firstKeyframe) return;
@@ -115,5 +142,29 @@ function playCinematic(
);
});
cinematic.dialogueCues?.forEach((cue) => {
timeline.call(
() => {
if (!dialogueOptions.dialogueManifest) return;
void queueDialogueById(
dialogueOptions.dialogueManifest,
cue.dialogueId,
).then((audio) => {
if (!audio) return;
dialogueOptions.activeAudiosRef.current.add(audio);
audio.addEventListener(
"ended",
() => dialogueOptions.activeAudiosRef.current.delete(audio),
{ once: true },
);
});
},
undefined,
cue.time,
);
});
timelineRef.current = timeline;
}
+6 -2
View File
@@ -62,13 +62,17 @@ class ModelErrorBoundary extends Component<
interface GameMapProps {
onOctreeReady: OctreeReadyHandler;
buildOctree?: boolean;
}
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
export function GameMap({
onOctreeReady,
buildOctree = true,
}: GameMapProps): React.JSX.Element {
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const groupRef = useRef<THREE.Group>(null);
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length);
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length, buildOctree);
useEffect(() => {
const loadMap = async () => {
+18 -5
View File
@@ -19,10 +19,21 @@ import { GameStageContent } from "@/world/GameStageContent";
import { Player } from "@/world/player/Player";
import { TestMap } from "@/world/debug/TestMap";
function hasBootFlag(name: string): boolean {
if (typeof window === "undefined") return false;
return new URLSearchParams(window.location.search).has(name);
}
export function World(): React.JSX.Element {
const cameraMode = useCameraMode();
const sceneMode = useSceneMode();
const [octree, setOctree] = useState<Octree | null>(null);
const noCinematics = hasBootFlag("noCinematics");
const noDialogues = hasBootFlag("noDialogues");
const noMap = hasBootFlag("noMap");
const noMusic = hasBootFlag("noMusic");
const noOctree = hasBootFlag("noOctree");
const noPlayer = hasBootFlag("noPlayer");
const playerSpawnPosition =
sceneMode === "game"
? PLAYER_SPAWN_POSITION_GAME
@@ -43,17 +54,19 @@ export function World(): React.JSX.Element {
{sceneMode === "game" ? (
<>
<GameMusic />
<GameCinematics />
<GameDialogues />
<GameMap onOctreeReady={setOctree} />
{noMusic ? null : <GameMusic />}
{noCinematics ? null : <GameCinematics />}
{noDialogues ? null : <GameDialogues />}
{noMap ? null : (
<GameMap onOctreeReady={setOctree} buildOctree={!noOctree} />
)}
<GameStageContent />
</>
) : (
<TestMap onOctreeReady={setOctree} />
)}
{cameraMode !== "debug" ? (
{cameraMode !== "debug" && !noPlayer ? (
<Player octree={octree} spawnPosition={playerSpawnPosition} />
) : null}
</>
+67 -8
View File
@@ -233,7 +233,9 @@ const saveCinematicManifestPlugin = (): Plugin => ({
try {
const data = JSON.parse(Buffer.concat(chunks).toString()) as unknown;
parseCinematicManifestData(data);
const manifest = parseCinematicManifestData(data);
const dialogueManifest = await loadDialogueManifestData();
validateCinematicDialogueCues(manifest, dialogueManifest);
const manifestPath = path.resolve(__dirname, "public/cinematics.json");
await fs.promises.writeFile(
@@ -283,9 +285,15 @@ interface CinematicManifestData {
interface CinematicData {
id: string;
timecode?: number;
dialogueCues?: CinematicDialogueCueData[];
cameraKeyframes: CinematicKeyframeData[];
}
interface CinematicDialogueCueData {
time: number;
dialogueId: string;
}
interface CinematicKeyframeData {
time: number;
position: [number, number, number];
@@ -334,12 +342,7 @@ interface DialogueValidationResult extends JsonObject {
async function validateDialogueAssets(): Promise<DialogueValidationResult> {
const errors: string[] = [];
const warnings: string[] = [];
const manifestPath = path.resolve(
__dirname,
"public/sounds/dialogue/dialogues.json",
);
const manifestContent = await fs.promises.readFile(manifestPath, "utf8");
const manifest = parseDialogueManifestData(JSON.parse(manifestContent));
const manifest = await loadDialogueManifestData();
const subtitleCueCache = new Map<string, Set<number>>();
@@ -355,7 +358,9 @@ async function validateDialogueAssets(): Promise<DialogueValidationResult> {
if (enSubtitlePath) {
const resolvedEnPath = resolvePublicPath(enSubtitlePath);
if (!resolvedEnPath || !fs.existsSync(resolvedEnPath)) {
warnings.push(`English subtitle file missing for voice ${voice.id}`);
warnings.push(
`English subtitle file missing for voice ${voice.id}; runtime will fall back to French`,
);
}
}
}
@@ -388,6 +393,15 @@ async function validateDialogueAssets(): Promise<DialogueValidationResult> {
};
}
async function loadDialogueManifestData(): Promise<DialogueManifestData> {
const manifestPath = path.resolve(
__dirname,
"public/sounds/dialogue/dialogues.json",
);
const manifestContent = await fs.promises.readFile(manifestPath, "utf8");
return parseDialogueManifestData(JSON.parse(manifestContent));
}
function parseDialogueManifestData(data: unknown): DialogueManifestData {
if (!isRecord(data) || data.version !== 1) {
throw new Error("Invalid dialogue manifest");
@@ -510,9 +524,54 @@ function parseCinematicData(data: unknown): CinematicData {
cinematic.timecode = data.timecode;
}
if (data.dialogueCues !== undefined) {
if (!Array.isArray(data.dialogueCues)) {
throw new Error(`Cinematic ${data.id} has invalid dialogue cues`);
}
cinematic.dialogueCues = data.dialogueCues.map(
parseCinematicDialogueCueData,
);
}
return cinematic;
}
function validateCinematicDialogueCues(
cinematicManifest: CinematicManifestData,
dialogueManifest: DialogueManifestData,
): void {
const dialogueIds = new Set(
dialogueManifest.dialogues.map((dialogue) => dialogue.id),
);
for (const cinematic of cinematicManifest.cinematics) {
for (const cue of cinematic.dialogueCues ?? []) {
if (!dialogueIds.has(cue.dialogueId)) {
throw new Error(
`Cinematic ${cinematic.id} references unknown dialogue ${cue.dialogueId}`,
);
}
}
}
}
function parseCinematicDialogueCueData(
data: unknown,
): CinematicDialogueCueData {
if (
!isRecord(data) ||
typeof data.time !== "number" ||
typeof data.dialogueId !== "string"
) {
throw new Error("Invalid cinematic dialogue cue");
}
return {
time: data.time,
dialogueId: data.dialogueId,
};
}
function parseCinematicKeyframeData(data: unknown): CinematicKeyframeData {
if (!isRecord(data) || typeof data.time !== "number") {
throw new Error("Invalid cinematic camera keyframe");