From e9808f8473c7cb43ca56a46d73cdd0a7d7fb616f Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 02:35:37 +0200 Subject: [PATCH] fix(ebike): force-stop narrator audio + clear subtitle when leaving ebike state --- src/components/game/EbikeRepairNarrator.tsx | 43 ++++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/src/components/game/EbikeRepairNarrator.tsx b/src/components/game/EbikeRepairNarrator.tsx index 6cf0f44..477f108 100644 --- a/src/components/game/EbikeRepairNarrator.tsx +++ b/src/components/game/EbikeRepairNarrator.tsx @@ -5,6 +5,7 @@ import { EBIKE_SCAN_HINT_DIALOGUE_ID, } from "@/data/ebike/ebikeConfig"; import { useGameStore } from "@/managers/stores/useGameStore"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import type { MissionStep } from "@/types/gameplay/repairMission"; import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; 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 * mission state rolls back to `locked`/`waiting` so debug-panel replays * 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> = { fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID, @@ -25,10 +31,19 @@ const STEP_TO_DIALOGUE_ID: Partial> = { 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 { const mainState = useGameStore((state) => state.mainState); const ebikeStep = useGameStore((state) => state.ebike.currentStep); const playedRef = useRef>(new Set()); + const activeAudioRef = useRef(null); useEffect(() => { if (ebikeStep === "locked" || ebikeStep === "waiting") { @@ -36,6 +51,18 @@ export function EbikeRepairNarrator(): null { } }, [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(() => { if (mainState !== "ebike") return; @@ -46,28 +73,24 @@ export function EbikeRepairNarrator(): null { playedRef.current.add(ebikeStep); let cancelled = false; - let activeAudio: HTMLAudioElement | null = null; void (async () => { const manifest = await loadDialogueManifest(); if (cancelled || !manifest) return; const audio = await playDialogueById(manifest, dialogueId); if (cancelled) { - if (audio && !audio.paused) { - audio.pause(); - audio.currentTime = 0; - } + stopAudio(audio); + useSubtitleStore.getState().clearActiveSubtitle(); return; } - activeAudio = audio; + activeAudioRef.current = audio; })(); return () => { cancelled = true; - if (activeAudio && !activeAudio.paused) { - activeAudio.pause(); - activeAudio.currentTime = 0; - } + stopAudio(activeAudioRef.current); + activeAudioRef.current = null; + useSubtitleStore.getState().clearActiveSubtitle(); }; }, [mainState, ebikeStep]);