fix(ebike): force-stop narrator audio + clear subtitle when leaving ebike state

This commit is contained in:
Tom Boullay
2026-06-03 02:35:37 +02:00
parent 0ddecaa494
commit e9808f8473
+33 -10
View File
@@ -5,6 +5,7 @@ import {
EBIKE_SCAN_HINT_DIALOGUE_ID, EBIKE_SCAN_HINT_DIALOGUE_ID,
} from "@/data/ebike/ebikeConfig"; } from "@/data/ebike/ebikeConfig";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { MissionStep } from "@/types/gameplay/repairMission"; import type { MissionStep } from "@/types/gameplay/repairMission";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue"; import { playDialogueById } from "@/utils/dialogues/playDialogue";
@@ -18,6 +19,11 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue";
* Each cue is one-shot per mission run; the played-set resets when the * Each cue is one-shot per mission run; the played-set resets when the
* mission state rolls back to `locked`/`waiting` so debug-panel replays * mission state rolls back to `locked`/`waiting` so debug-panel replays
* still trigger the narration. * still trigger the narration.
*
* Audio AND subtitles are strictly scoped to `mainState === "ebike"`. If
* the player leaves the ebike main state mid-line (debug panel jump,
* mission transition, etc.), the active audio is paused and the
* subtitle is force-cleared so nothing bleeds into pylon/farm/outro.
*/ */
const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = { const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = {
fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID, fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID,
@@ -25,10 +31,19 @@ const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = {
done: EBIKE_REPAIRED_DIALOGUE_ID, done: EBIKE_REPAIRED_DIALOGUE_ID,
}; };
function stopAudio(audio: HTMLAudioElement | null): void {
if (!audio) return;
if (!audio.paused) {
audio.pause();
audio.currentTime = 0;
}
}
export function EbikeRepairNarrator(): null { export function EbikeRepairNarrator(): null {
const mainState = useGameStore((state) => state.mainState); const mainState = useGameStore((state) => state.mainState);
const ebikeStep = useGameStore((state) => state.ebike.currentStep); const ebikeStep = useGameStore((state) => state.ebike.currentStep);
const playedRef = useRef<Set<MissionStep>>(new Set()); const playedRef = useRef<Set<MissionStep>>(new Set());
const activeAudioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => { useEffect(() => {
if (ebikeStep === "locked" || ebikeStep === "waiting") { if (ebikeStep === "locked" || ebikeStep === "waiting") {
@@ -36,6 +51,18 @@ export function EbikeRepairNarrator(): null {
} }
}, [ebikeStep]); }, [ebikeStep]);
// Belt-and-suspenders: any time we are NOT in the ebike main state,
// make sure no narrator audio or subtitle from this component is
// lingering. This catches races where the audio started a tick before
// the main state flipped and the per-step cleanup hadn't propagated
// yet (subtitle event still queued, etc.).
useEffect(() => {
if (mainState === "ebike") return;
stopAudio(activeAudioRef.current);
activeAudioRef.current = null;
useSubtitleStore.getState().clearActiveSubtitle();
}, [mainState]);
useEffect(() => { useEffect(() => {
if (mainState !== "ebike") return; if (mainState !== "ebike") return;
@@ -46,28 +73,24 @@ export function EbikeRepairNarrator(): null {
playedRef.current.add(ebikeStep); playedRef.current.add(ebikeStep);
let cancelled = false; let cancelled = false;
let activeAudio: HTMLAudioElement | null = null;
void (async () => { void (async () => {
const manifest = await loadDialogueManifest(); const manifest = await loadDialogueManifest();
if (cancelled || !manifest) return; if (cancelled || !manifest) return;
const audio = await playDialogueById(manifest, dialogueId); const audio = await playDialogueById(manifest, dialogueId);
if (cancelled) { if (cancelled) {
if (audio && !audio.paused) { stopAudio(audio);
audio.pause(); useSubtitleStore.getState().clearActiveSubtitle();
audio.currentTime = 0;
}
return; return;
} }
activeAudio = audio; activeAudioRef.current = audio;
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
if (activeAudio && !activeAudio.paused) { stopAudio(activeAudioRef.current);
activeAudio.pause(); activeAudioRef.current = null;
activeAudio.currentTime = 0; useSubtitleStore.getState().clearActiveSubtitle();
}
}; };
}, [mainState, ebikeStep]); }, [mainState, ebikeStep]);