diff --git a/src/utils/dialogues/playDialogue.ts b/src/utils/dialogues/playDialogue.ts index 5c4d7da..3c05489 100644 --- a/src/utils/dialogues/playDialogue.ts +++ b/src/utils/dialogues/playDialogue.ts @@ -4,6 +4,32 @@ import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import type { DialogueManifest } from "@/types/dialogues/dialogues"; import { 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 isDialogueQueuePlaying = false; + +export function queueDialogueById( + manifest: DialogueManifest, + dialogueId: string, +): Promise { + 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 playDialogueById( manifest: DialogueManifest, dialogueId: string, @@ -28,6 +54,7 @@ export async function playDialogueById( }; const cleanup = (): void => { + audio.removeEventListener("play", syncSubtitle); audio.removeEventListener("timeupdate", syncSubtitle); audio.removeEventListener("ended", cleanup); audio.removeEventListener("pause", cleanup); @@ -51,10 +78,70 @@ export async function playDialogueById( clearSubtitle(); }; + audio.addEventListener("play", syncSubtitle); audio.addEventListener("timeupdate", syncSubtitle); audio.addEventListener("ended", cleanup); audio.addEventListener("pause", cleanup); - syncSubtitle(); return audio; } + +async function playNextQueuedDialogue(): Promise { + 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 { + if (audio.ended) return Promise.resolve(); + + return new Promise((resolve) => { + let hasStarted = !audio.paused; + let startTimeout: ReturnType | 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); + }); +} diff --git a/src/world/GameDialogues.tsx b/src/world/GameDialogues.tsx index 845cd20..4e37c5e 100644 --- a/src/world/GameDialogues.tsx +++ b/src/world/GameDialogues.tsx @@ -2,7 +2,10 @@ import { useEffect, useRef, useState } from "react"; import { useFrame } from "@react-three/fiber"; import type { DialogueManifest } from "@/types/dialogues/dialogues"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; -import { playDialogueById } from "@/utils/dialogues/playDialogue"; +import { + clearQueuedDialogues, + queueDialogueById, +} from "@/utils/dialogues/playDialogue"; import { logger } from "@/utils/core/logger"; export function GameDialogues(): null { @@ -26,6 +29,7 @@ export function GameDialogues(): null { return () => { mounted = false; + clearQueuedDialogues(); activeAudios.forEach((audio) => audio.pause()); activeAudios.clear(); }; @@ -43,7 +47,7 @@ export function GameDialogues(): null { playedDialoguesRef.current.add(dialogue.id); - void playDialogueById(manifest, dialogue.id).then((audio) => { + void queueDialogueById(manifest, dialogue.id).then((audio) => { if (!audio) return; activeAudiosRef.current.add(audio); audio.addEventListener(