update: sync dialogue and cinematic

This commit is contained in:
Tom Boullay
2026-05-11 12:58:12 +02:00
parent 0b58b9aeef
commit 35f8d8fc87
7 changed files with 132 additions and 3 deletions
+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",
@@ -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) {
+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;
}
+32
View File
@@ -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");