diff --git a/public/cinematics.json b/public/cinematics.json index b7be4c7..c05949a 100644 --- a/public/cinematics.json +++ b/public/cinematics.json @@ -4,6 +4,12 @@ { "id": "intro_overview", "timecode": 0, + "dialogueCues": [ + { + "time": 0, + "dialogueId": "narrateur_bienvenueaaltera" + } + ], "cameraKeyframes": [ { "time": 0, diff --git a/public/sounds/dialogue/dialogues.json b/public/sounds/dialogue/dialogues.json index af596ef..ff0d23d 100644 --- a/public/sounds/dialogue/dialogues.json +++ b/public/sounds/dialogue/dialogues.json @@ -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", diff --git a/src/components/editor/EditorCinematicManifestPanel.tsx b/src/components/editor/EditorCinematicManifestPanel.tsx index d360dda..82cb50b 100644 --- a/src/components/editor/EditorCinematicManifestPanel.tsx +++ b/src/components/editor/EditorCinematicManifestPanel.tsx @@ -74,6 +74,16 @@ 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.`); + } + }); }); return errors; @@ -105,6 +115,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) { diff --git a/src/types/cinematics/cinematics.ts b/src/types/cinematics/cinematics.ts index 589149e..7512911 100644 --- a/src/types/cinematics/cinematics.ts +++ b/src/types/cinematics/cinematics.ts @@ -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 { diff --git a/src/utils/cinematics/cinematicManifestValidation.ts b/src/utils/cinematics/cinematicManifestValidation.ts index 655a47b..fd4117f 100644 --- a/src/utils/cinematics/cinematicManifestValidation.ts +++ b/src/utils/cinematics/cinematicManifestValidation.ts @@ -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"); diff --git a/src/world/GameCinematics.tsx b/src/world/GameCinematics.tsx index 7cda405..50074a7 100644 --- a/src/world/GameCinematics.tsx +++ b/src/world/GameCinematics.tsx @@ -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(null); + const [dialogueManifest, setDialogueManifest] = + useState(null); const playedCinematicsRef = useRef(new Set()); const timelineRef = useRef(null); + const activeAudiosRef = useRef(new Set()); 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, + dialogueOptions: { + dialogueManifest: DialogueManifest | null; + activeAudiosRef: MutableRefObject>; + }, ): 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; } diff --git a/vite.config.ts b/vite.config.ts index 902df25..f623806 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -283,9 +283,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]; @@ -510,9 +516,35 @@ 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 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");