Merge branch 'develop' into feat/repair-game
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import type {
|
||||
CinematicCameraKeyframe,
|
||||
CinematicDefinition,
|
||||
CinematicDialogueCue,
|
||||
CinematicManifest,
|
||||
} from "@/types/cinematics/cinematics";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export function parseCinematicManifest(data: unknown): CinematicManifest {
|
||||
if (!isRecord(data) || data.version !== 1) {
|
||||
throw new Error("Invalid cinematic manifest version");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.cinematics)) {
|
||||
throw new Error("Cinematic manifest requires a cinematics array");
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
cinematics: data.cinematics.map(parseCinematicDefinition),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCinematicDefinition(data: unknown): CinematicDefinition {
|
||||
if (!isRecord(data) || typeof data.id !== "string") {
|
||||
throw new Error("Invalid cinematic definition");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.cameraKeyframes)) {
|
||||
throw new Error(`Cinematic ${data.id} requires cameraKeyframes`);
|
||||
}
|
||||
|
||||
const cameraKeyframes = data.cameraKeyframes.map(parseCameraKeyframe);
|
||||
if (cameraKeyframes.length < 2) {
|
||||
throw new Error(`Cinematic ${data.id} requires at least two keyframes`);
|
||||
}
|
||||
|
||||
cameraKeyframes.forEach((keyframe, index) => {
|
||||
const previousKeyframe = cameraKeyframes[index - 1];
|
||||
if (previousKeyframe && keyframe.time <= previousKeyframe.time) {
|
||||
throw new Error(`Cinematic ${data.id} keyframe times must increase`);
|
||||
}
|
||||
});
|
||||
|
||||
const cinematic: CinematicDefinition = {
|
||||
id: data.id,
|
||||
cameraKeyframes,
|
||||
};
|
||||
|
||||
if (typeof data.timecode === "number") {
|
||||
cinematic.timecode = data.timecode;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.dialogueCues)) {
|
||||
cinematic.dialogueCues = data.dialogueCues.map(parseDialogueCue);
|
||||
}
|
||||
|
||||
return cinematic;
|
||||
}
|
||||
|
||||
function parseDialogueCue(data: unknown): CinematicDialogueCue {
|
||||
if (
|
||||
!isRecord(data) ||
|
||||
typeof data.time !== "number" ||
|
||||
typeof data.dialogueId !== "string"
|
||||
) {
|
||||
throw new Error("Invalid cinematic dialogue cue");
|
||||
}
|
||||
|
||||
return {
|
||||
time: data.time,
|
||||
dialogueId: data.dialogueId,
|
||||
};
|
||||
}
|
||||
|
||||
function parseCameraKeyframe(data: unknown): CinematicCameraKeyframe {
|
||||
if (!isRecord(data) || typeof data.time !== "number") {
|
||||
throw new Error("Invalid cinematic camera keyframe");
|
||||
}
|
||||
|
||||
return {
|
||||
time: data.time,
|
||||
position: parseVector3(data.position),
|
||||
target: parseVector3(data.target),
|
||||
};
|
||||
}
|
||||
|
||||
function parseVector3(value: unknown): Vector3Tuple {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.length !== 3 ||
|
||||
value.some((item) => typeof item !== "number")
|
||||
) {
|
||||
throw new Error("Invalid cinematic vector");
|
||||
}
|
||||
|
||||
return [value[0], value[1], value[2]];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CinematicManifest } from "@/types/cinematics/cinematics";
|
||||
import { parseCinematicManifest } from "@/utils/cinematics/cinematicManifestValidation";
|
||||
|
||||
const CINEMATIC_MANIFEST_PATH = "/cinematics.json";
|
||||
|
||||
export async function loadCinematicManifest(): Promise<CinematicManifest | null> {
|
||||
const response = await fetch(CINEMATIC_MANIFEST_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseCinematicManifest(await response.json());
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueSpeaker,
|
||||
DialogueVoice,
|
||||
DialogueVoiceId,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
|
||||
const VALID_VOICE_IDS = new Set<DialogueVoiceId>([
|
||||
"narrateur",
|
||||
"fermier",
|
||||
"electricienne",
|
||||
]);
|
||||
const VALID_SPEAKERS = new Set<DialogueSpeaker>([
|
||||
"Narrateur",
|
||||
"Fermier",
|
||||
"Electricienne",
|
||||
]);
|
||||
|
||||
export function parseDialogueManifest(data: unknown): DialogueManifest {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Dialogue manifest must be an object");
|
||||
}
|
||||
|
||||
if (data.version !== 1) {
|
||||
throw new Error("Unsupported dialogue manifest version");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.voices) || !Array.isArray(data.dialogues)) {
|
||||
throw new Error("Dialogue manifest requires voices and dialogues arrays");
|
||||
}
|
||||
|
||||
const voices = data.voices.map(parseDialogueVoice);
|
||||
const voiceIds = new Set(voices.map((voice) => voice.id));
|
||||
const dialogues = data.dialogues.map((dialogue) =>
|
||||
parseDialogueDefinition(dialogue, voiceIds),
|
||||
);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
voices,
|
||||
dialogues,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDialogueVoice(data: unknown): DialogueVoice {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Dialogue voice must be an object");
|
||||
}
|
||||
|
||||
if (!isDialogueVoiceId(data.id)) {
|
||||
throw new Error("Dialogue voice has an invalid id");
|
||||
}
|
||||
|
||||
if (!isDialogueSpeaker(data.speaker)) {
|
||||
throw new Error(`Dialogue voice ${data.id} has an invalid speaker`);
|
||||
}
|
||||
|
||||
if (!isRecord(data.subtitles)) {
|
||||
throw new Error(`Dialogue voice ${data.id} must define subtitles`);
|
||||
}
|
||||
|
||||
const subtitles: DialogueVoice["subtitles"] = {};
|
||||
const frSubtitle = getOptionalPath(data.subtitles.fr);
|
||||
const enSubtitle = getOptionalPath(data.subtitles.en);
|
||||
if (frSubtitle) subtitles.fr = frSubtitle;
|
||||
if (enSubtitle) subtitles.en = enSubtitle;
|
||||
|
||||
return {
|
||||
id: data.id,
|
||||
speaker: data.speaker,
|
||||
subtitles,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDialogueDefinition(
|
||||
data: unknown,
|
||||
voiceIds: Set<DialogueVoiceId>,
|
||||
): DialogueDefinition {
|
||||
if (!isRecord(data)) {
|
||||
throw new Error("Dialogue definition must be an object");
|
||||
}
|
||||
|
||||
if (typeof data.id !== "string" || data.id.length === 0) {
|
||||
throw new Error("Dialogue definition has an invalid id");
|
||||
}
|
||||
|
||||
if (!isDialogueVoiceId(data.voice) || !voiceIds.has(data.voice)) {
|
||||
throw new Error(`Dialogue ${data.id} references an unknown voice`);
|
||||
}
|
||||
|
||||
if (typeof data.audio !== "string" || data.audio.length === 0) {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid audio path`);
|
||||
}
|
||||
|
||||
const subtitleCueIndex = data.subtitleCueIndex;
|
||||
if (
|
||||
typeof subtitleCueIndex !== "number" ||
|
||||
!Number.isInteger(subtitleCueIndex) ||
|
||||
subtitleCueIndex < 1
|
||||
) {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid subtitle cue index`);
|
||||
}
|
||||
|
||||
const timecode = data.timecode;
|
||||
if (timecode !== undefined && typeof timecode !== "number") {
|
||||
throw new Error(`Dialogue ${data.id} has an invalid timecode`);
|
||||
}
|
||||
|
||||
const dialogue: DialogueDefinition = {
|
||||
id: data.id,
|
||||
voice: data.voice,
|
||||
audio: data.audio,
|
||||
subtitleCueIndex,
|
||||
};
|
||||
|
||||
if (timecode !== undefined) dialogue.timecode = timecode;
|
||||
|
||||
return dialogue;
|
||||
}
|
||||
|
||||
function getOptionalPath(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function isDialogueVoiceId(value: unknown): value is DialogueVoiceId {
|
||||
return (
|
||||
typeof value === "string" && VALID_VOICE_IDS.has(value as DialogueVoiceId)
|
||||
);
|
||||
}
|
||||
|
||||
function isDialogueSpeaker(value: unknown): value is DialogueSpeaker {
|
||||
return (
|
||||
typeof value === "string" && VALID_SPEAKERS.has(value as DialogueSpeaker)
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type {
|
||||
DialogueDefinition,
|
||||
DialogueManifest,
|
||||
DialogueVoice,
|
||||
} from "@/types/dialogues/dialogues";
|
||||
import type { SubtitleLanguage } from "@/managers/stores/useSettingsStore";
|
||||
import { parseDialogueManifest } from "@/utils/dialogues/dialogueManifestValidation";
|
||||
import { parseSrt } from "@/utils/subtitles/parseSrt";
|
||||
import type { SubtitleCue } from "@/utils/subtitles/parseSrt";
|
||||
|
||||
const DIALOGUE_MANIFEST_PATH = "/sounds/dialogue/dialogues.json";
|
||||
const DEFAULT_SUBTITLE_LANGUAGE: SubtitleLanguage = "fr";
|
||||
|
||||
export interface DialogueSubtitleCue {
|
||||
voice: DialogueVoice;
|
||||
cue: SubtitleCue;
|
||||
subtitlePath: string;
|
||||
}
|
||||
|
||||
export async function loadDialogueManifest(): Promise<DialogueManifest | null> {
|
||||
const response = await fetch(DIALOGUE_MANIFEST_PATH);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseDialogueManifest(await response.json());
|
||||
}
|
||||
|
||||
export function resolveDialogueSubtitlePath(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
language: SubtitleLanguage,
|
||||
): string | null {
|
||||
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||
if (!voice) return null;
|
||||
|
||||
return getVoiceSubtitlePath(voice, language);
|
||||
}
|
||||
|
||||
export function getDialogueVoice(
|
||||
manifest: DialogueManifest,
|
||||
voiceId: DialogueDefinition["voice"],
|
||||
): DialogueVoice | null {
|
||||
return manifest.voices.find((voice) => voice.id === voiceId) ?? null;
|
||||
}
|
||||
|
||||
export async function loadDialogueSubtitleCue(
|
||||
manifest: DialogueManifest,
|
||||
dialogue: DialogueDefinition,
|
||||
language: SubtitleLanguage,
|
||||
): Promise<DialogueSubtitleCue | null> {
|
||||
const voice = getDialogueVoice(manifest, dialogue.voice);
|
||||
if (!voice) return null;
|
||||
|
||||
const subtitles = await loadVoiceSubtitleCues(voice, language);
|
||||
if (!subtitles) return null;
|
||||
|
||||
const cue = subtitles.cues.find(
|
||||
(item) => item.index === dialogue.subtitleCueIndex,
|
||||
);
|
||||
|
||||
if (!cue) return null;
|
||||
|
||||
return {
|
||||
voice,
|
||||
cue,
|
||||
subtitlePath: subtitles.path,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadVoiceSubtitleCues(
|
||||
voice: DialogueVoice,
|
||||
language: SubtitleLanguage,
|
||||
): Promise<{ path: string; cues: SubtitleCue[] } | null> {
|
||||
const paths = getVoiceSubtitlePaths(voice, language);
|
||||
|
||||
for (const path of paths) {
|
||||
const srtContent = await loadSrtContent(path);
|
||||
if (srtContent !== null) {
|
||||
return { path, cues: parseSrt(srtContent) };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadSrtContent(path: string): Promise<string | null> {
|
||||
const response = await fetch(path);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
function getVoiceSubtitlePaths(
|
||||
voice: DialogueVoice,
|
||||
language: SubtitleLanguage,
|
||||
): string[] {
|
||||
return [voice.subtitles[language], voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE]]
|
||||
.filter((path): path is string => Boolean(path))
|
||||
.filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function getVoiceSubtitlePath(
|
||||
voice: DialogueVoice,
|
||||
language: SubtitleLanguage,
|
||||
): string | null {
|
||||
return (
|
||||
voice.subtitles[language] ??
|
||||
voice.subtitles[DEFAULT_SUBTITLE_LANGUAGE] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import { useSettingsStore } from "@/managers/stores/useSettingsStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type { DialogueManifest } from "@/types/dialogues/dialogues";
|
||||
import {
|
||||
loadDialogueManifest,
|
||||
loadDialogueSubtitleCue,
|
||||
} from "@/utils/dialogues/loadDialogueManifest";
|
||||
|
||||
interface QueuedDialogueRequest {
|
||||
manifest: DialogueManifest;
|
||||
dialogueId: string;
|
||||
resolve: (audio: HTMLAudioElement | null) => void;
|
||||
}
|
||||
|
||||
const DIALOGUE_PLAY_START_TIMEOUT_MS = 800;
|
||||
const dialogueQueue: QueuedDialogueRequest[] = [];
|
||||
let gameplayDialogueManifestPromise: Promise<DialogueManifest | null> | null =
|
||||
null;
|
||||
let isDialogueQueuePlaying = false;
|
||||
|
||||
export function queueDialogueById(
|
||||
manifest: DialogueManifest,
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
return new Promise((resolve) => {
|
||||
dialogueQueue.push({ manifest, dialogueId, resolve });
|
||||
void playNextQueuedDialogue();
|
||||
});
|
||||
}
|
||||
|
||||
export function clearQueuedDialogues(): void {
|
||||
while (dialogueQueue.length > 0) {
|
||||
dialogueQueue.shift()?.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
export async function playGameplayDialogueById(
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
gameplayDialogueManifestPromise ??= loadDialogueManifest();
|
||||
const manifest = await gameplayDialogueManifestPromise;
|
||||
if (!manifest) return null;
|
||||
|
||||
return queueDialogueById(manifest, dialogueId);
|
||||
}
|
||||
|
||||
export async function playDialogueById(
|
||||
manifest: DialogueManifest,
|
||||
dialogueId: string,
|
||||
): Promise<HTMLAudioElement | null> {
|
||||
const dialogue = manifest.dialogues.find((item) => item.id === dialogueId);
|
||||
if (!dialogue) return null;
|
||||
|
||||
const subtitleLanguage = useSettingsStore.getState().subtitleLanguage;
|
||||
const subtitle = await loadDialogueSubtitleCue(
|
||||
manifest,
|
||||
dialogue,
|
||||
subtitleLanguage,
|
||||
);
|
||||
const audio = AudioManager.getInstance().playSound(dialogue.audio, 1, {
|
||||
category: "dialogue",
|
||||
});
|
||||
|
||||
if (!subtitle) return audio;
|
||||
|
||||
const clearSubtitle = (): void => {
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
};
|
||||
|
||||
const cleanup = (): void => {
|
||||
audio.removeEventListener("play", syncSubtitle);
|
||||
audio.removeEventListener("timeupdate", syncSubtitle);
|
||||
audio.removeEventListener("ended", cleanup);
|
||||
audio.removeEventListener("pause", cleanup);
|
||||
clearSubtitle();
|
||||
};
|
||||
|
||||
const syncSubtitle = (): void => {
|
||||
const currentTime = audio.currentTime;
|
||||
const shouldShowSubtitle =
|
||||
currentTime >= subtitle.cue.startTime &&
|
||||
currentTime <= subtitle.cue.endTime;
|
||||
|
||||
if (shouldShowSubtitle) {
|
||||
useSubtitleStore.getState().setActiveSubtitle({
|
||||
speaker: subtitle.voice.speaker,
|
||||
text: subtitle.cue.text,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
clearSubtitle();
|
||||
};
|
||||
|
||||
audio.addEventListener("play", syncSubtitle);
|
||||
audio.addEventListener("timeupdate", syncSubtitle);
|
||||
audio.addEventListener("ended", cleanup);
|
||||
audio.addEventListener("pause", cleanup);
|
||||
|
||||
return audio;
|
||||
}
|
||||
|
||||
async function playNextQueuedDialogue(): Promise<void> {
|
||||
if (isDialogueQueuePlaying) return;
|
||||
|
||||
isDialogueQueuePlaying = true;
|
||||
|
||||
while (dialogueQueue.length > 0) {
|
||||
const request = dialogueQueue.shift();
|
||||
if (!request) continue;
|
||||
|
||||
try {
|
||||
const audio = await playDialogueById(
|
||||
request.manifest,
|
||||
request.dialogueId,
|
||||
);
|
||||
request.resolve(audio);
|
||||
if (audio) await waitForDialogueToFinish(audio);
|
||||
} catch {
|
||||
request.resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
isDialogueQueuePlaying = false;
|
||||
}
|
||||
|
||||
function waitForDialogueToFinish(audio: HTMLAudioElement): Promise<void> {
|
||||
if (audio.ended) return Promise.resolve();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let hasStarted = !audio.paused;
|
||||
let startTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function cleanup(): void {
|
||||
if (startTimeout) clearTimeout(startTimeout);
|
||||
audio.removeEventListener("play", handlePlay);
|
||||
audio.removeEventListener("ended", finish);
|
||||
audio.removeEventListener("pause", finish);
|
||||
audio.removeEventListener("error", finish);
|
||||
}
|
||||
|
||||
function finish(): void {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
|
||||
function handlePlay(): void {
|
||||
hasStarted = true;
|
||||
if (startTimeout) clearTimeout(startTimeout);
|
||||
}
|
||||
|
||||
audio.addEventListener("play", handlePlay);
|
||||
audio.addEventListener("ended", finish);
|
||||
audio.addEventListener("pause", finish);
|
||||
audio.addEventListener("error", finish);
|
||||
|
||||
startTimeout = setTimeout(() => {
|
||||
if (!hasStarted && audio.paused) finish();
|
||||
}, DIALOGUE_PLAY_START_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface SubtitleCue {
|
||||
index: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
const SRT_TIME_SEPARATOR = " --> ";
|
||||
const SRT_TIME_PATTERN = /^(\d{2}):(\d{2}):(\d{2}),(\d{3})$/;
|
||||
|
||||
export function parseSrt(srtContent: string): SubtitleCue[] {
|
||||
return srtContent
|
||||
.replace(/^\uFEFF/, "")
|
||||
.replace(/\r/g, "")
|
||||
.trim()
|
||||
.split(/\n{2,}/)
|
||||
.map(parseSrtBlock)
|
||||
.filter((cue): cue is SubtitleCue => cue !== null);
|
||||
}
|
||||
|
||||
function parseSrtBlock(block: string): SubtitleCue | null {
|
||||
const lines = block
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (lines.length < 3) return null;
|
||||
|
||||
const index = Number(lines[0]);
|
||||
if (!Number.isInteger(index)) return null;
|
||||
|
||||
const [start, end] = lines[1]?.split(SRT_TIME_SEPARATOR) ?? [];
|
||||
if (!start || !end) return null;
|
||||
|
||||
const startTime = parseSrtTime(start);
|
||||
const endTime = parseSrtTime(end);
|
||||
if (startTime === null || endTime === null || endTime <= startTime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
startTime,
|
||||
endTime,
|
||||
text: lines.slice(2).join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSrtTime(value: string): number | null {
|
||||
const match = value.match(SRT_TIME_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
const [, hours, minutes, seconds, milliseconds] = match;
|
||||
if (!hours || !minutes || !seconds || !milliseconds) return null;
|
||||
|
||||
return (
|
||||
Number(hours) * 3600 +
|
||||
Number(minutes) * 60 +
|
||||
Number(seconds) +
|
||||
Number(milliseconds) / 1000
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user