From 5968f0f67c769190929bb95ed92ccc0684eb2378 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 07:04:44 +0200 Subject: [PATCH] fix(repair-ebike): gate scanning on scan intro dialogue --- src/components/game/EbikeRepairNarrator.tsx | 12 +-- src/components/three/gameplay/RepairGame.tsx | 80 +++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/components/game/EbikeRepairNarrator.tsx b/src/components/game/EbikeRepairNarrator.tsx index eecbf5e..c52e2b0 100644 --- a/src/components/game/EbikeRepairNarrator.tsx +++ b/src/components/game/EbikeRepairNarrator.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef } from "react"; -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"; @@ -7,8 +6,11 @@ import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; import { playDialogueById } from "@/utils/dialogues/playDialogue"; /** - * Plays narrator cues during the ebike repair game: - * - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..." + * Previously played the ebike repair cues directly. `RepairGame` now + * owns the repair-game cue timings that gate gameplay transitions + * (`fragmented` waits for `narrateur_galetscan`, `done` waits for + * `narrateur_ebikerepare`). This component remains as the central + * safety cleanup for legacy/queued ebike narrator audio. * * The `narrateur_refroidisseur_diagnostic` line is triggered by the * scan sequence itself when it lands on the refroidisseur node @@ -26,9 +28,7 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue"; * 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, -}; +const STEP_TO_DIALOGUE_ID: Partial> = {}; function stopAudio(audio: HTMLAudioElement | null): void { if (!audio) return; diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index 0f79ca7..f3c3f96 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -23,7 +23,10 @@ import { REPAIR_REASSEMBLY_HOLD_MS, } from "@/data/gameplay/repairGameConfig"; import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; -import { EBIKE_REPAIRED_DIALOGUE_ID } from "@/data/ebike/ebikeConfig"; +import { + EBIKE_REPAIRED_DIALOGUE_ID, + EBIKE_SCAN_HINT_DIALOGUE_ID, +} from "@/data/ebike/ebikeConfig"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; @@ -115,6 +118,8 @@ export function RepairGame({ const [ebikeRepairTransform, setEbikeRepairTransform] = useState(null); const [ebikeCoolingInstalled, setEbikeCoolingInstalled] = useState(false); + const fragmentedSplitSettledRef = useRef(false); + const fragmentedDialogueDoneRef = useRef(false); const reassemblyDoneTimeoutRef = useRef(null); // Ebike-specific: once the repair starts, keep the entire repair flow // exactly where the bike currently is. `Ebike` owns the live parked @@ -275,6 +280,7 @@ export function RepairGame({ useEffect(() => { if (mainState !== mission) return undefined; if (step !== "fragmented") return undefined; + if (mission === "ebike") return undefined; const timeoutId = window.setTimeout( () => { @@ -288,6 +294,71 @@ export function RepairGame({ }; }, [mainState, mission, setMissionStep, step]); + useEffect(() => { + if (mainState !== mission) return undefined; + if (mission !== "ebike") return undefined; + if (step !== "fragmented") return undefined; + + fragmentedSplitSettledRef.current = false; + fragmentedDialogueDoneRef.current = false; + + let cancelled = false; + let activeAudio: HTMLAudioElement | null = null; + let fallbackTimeoutId: number | null = null; + + const tryAdvance = (): void => { + if (cancelled) return; + if (!fragmentedSplitSettledRef.current) return; + if (!fragmentedDialogueDoneRef.current) return; + setMissionStep(mission, "scanning"); + }; + + const markDialogueDone = (): void => { + if (cancelled) return; + fragmentedDialogueDoneRef.current = true; + tryAdvance(); + }; + + void (async () => { + const manifest = await loadDialogueManifest(); + if (cancelled) return; + const audio = manifest + ? await playDialogueById(manifest, EBIKE_SCAN_HINT_DIALOGUE_ID) + : null; + if (cancelled) { + if (audio && !audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + useSubtitleStore.getState().clearActiveSubtitle(); + return; + } + + activeAudio = audio; + if (audio) { + audio.addEventListener("ended", markDialogueDone, { once: true }); + fallbackTimeoutId = window.setTimeout(markDialogueDone, 15000); + } else { + fallbackTimeoutId = window.setTimeout(markDialogueDone, 1000); + } + })(); + + return () => { + cancelled = true; + if (activeAudio) { + activeAudio.removeEventListener("ended", markDialogueDone); + if (!activeAudio.paused) { + activeAudio.pause(); + activeAudio.currentTime = 0; + } + } + if (fallbackTimeoutId !== null) { + window.clearTimeout(fallbackTimeoutId); + } + useSubtitleStore.getState().clearActiveSubtitle(); + }; + }, [mainState, mission, setMissionStep, step]); + useEffect(() => { if (mainState !== mission) return undefined; if (step !== "reassembling") return undefined; @@ -381,6 +452,13 @@ export function RepairGame({ () => (settledAt: 0 | 1) => { const currentStep = stepRef.current; if (settledAt === 1 && currentStep === "fragmented") { + if (mission === "ebike") { + fragmentedSplitSettledRef.current = true; + if (fragmentedDialogueDoneRef.current) { + setMissionStep(mission, "scanning"); + } + return; + } setMissionStep(mission, "scanning"); } if (settledAt === 0 && currentStep === "reassembling") {