diff --git a/src/components/game/EbikeRepairNarrator.tsx b/src/components/game/EbikeRepairNarrator.tsx index cba4b5f..d4dea19 100644 --- a/src/components/game/EbikeRepairNarrator.tsx +++ b/src/components/game/EbikeRepairNarrator.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef } from "react"; import { - EBIKE_DIAGNOSTIC_DIALOGUE_ID, EBIKE_REPAIRED_DIALOGUE_ID, EBIKE_SCAN_HINT_DIALOGUE_ID, } from "@/data/ebike/ebikeConfig"; @@ -13,9 +12,15 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue"; /** * Plays narrator cues during the ebike repair game: * - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..." - * - `repairing` -> "Parfait! C'est le refroidisseur qui a lâché..." * - `done` -> "Eeeet voilà! Il fonctionne comme une horloge!..." * + * The `narrateur_refroidisseur_diagnostic` line is no longer played + * here on the `repairing` step. It is now triggered by the scan + * sequence itself when it lands on the refroidisseur node (configured + * via `RepairMissionPartConfig.voiceLineId` on the broken part). That + * keeps the audio synchronized with the red broken-part highlight and + * gates the `scanning -> repairing` transition on the audio's `ended`. + * * Each cue is one-shot per mission run; the played-set resets when the * mission state rolls back to `waiting` so debug-panel replays still * trigger the narration. @@ -27,7 +32,6 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue"; */ const STEP_TO_DIALOGUE_ID: Partial> = { fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID, - repairing: EBIKE_DIAGNOSTIC_DIALOGUE_ID, done: EBIKE_REPAIRED_DIALOGUE_ID, }; diff --git a/src/components/three/gameplay/RepairScanSequence.tsx b/src/components/three/gameplay/RepairScanSequence.tsx index a0b73b5..76a1412 100644 --- a/src/components/three/gameplay/RepairScanSequence.tsx +++ b/src/components/three/gameplay/RepairScanSequence.tsx @@ -12,6 +12,9 @@ import type { } from "@/types/gameplay/repairMission"; import { logger } from "@/utils/core/Logger"; import type { ExplodedPart } from "@/utils/three/ExplodedModel"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; interface RepairScanSequenceProps { config: RepairMissionConfig; @@ -41,6 +44,81 @@ export function RepairScanSequence({ useEffect(() => { if (parts.length === 0) return undefined; + // Look up which (if any) broken-part config corresponds to the + // currently active scan part. When the active part has a + // `voiceLineId`, gate the advance on the audio's `ended` event so + // the diagnostic line plays in full (with its red broken-part + // highlight already on screen) before transitioning to the next + // scan part — and ultimately to the repairing step. + const activeBrokenMatch = brokenPartMatches.find( + (match) => match.partIndex === activePartIndex, + ); + const activeVoiceLineId = activeBrokenMatch?.config.voiceLineId; + + if (activeVoiceLineId) { + let cancelled = false; + let activeAudio: HTMLAudioElement | null = null; + let fallbackTimeoutId: number | null = null; + + const advance = (): void => { + if (cancelled) return; + cancelled = true; + setActivePartIndex((currentIndex) => { + const nextIndex = currentIndex + 1; + if (nextIndex >= parts.length) { + onComplete(getScannedBrokenParts(parts, config)); + return currentIndex; + } + return nextIndex; + }); + }; + + void (async () => { + const manifest = await loadDialogueManifest(); + if (cancelled) return; + const audio = manifest + ? await playDialogueById(manifest, activeVoiceLineId) + : null; + if (cancelled) { + if (audio && !audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + useSubtitleStore.getState().clearActiveSubtitle(); + return; + } + activeAudio = audio; + if (audio) { + audio.addEventListener("ended", advance, { once: true }); + // Fallback: if the audio errors or never fires `ended`, still + // advance after a generous ceiling so the flow can't stall. + fallbackTimeoutId = window.setTimeout(advance, 15000); + } else { + // No audio (manifest missing) — advance after the default + // per-part dwell so we don't get stuck on this part. + fallbackTimeoutId = window.setTimeout( + advance, + scanPartSeconds * 1000, + ); + } + })(); + + return () => { + cancelled = true; + if (activeAudio) { + activeAudio.removeEventListener("ended", advance); + if (!activeAudio.paused) { + activeAudio.pause(); + activeAudio.currentTime = 0; + } + } + if (fallbackTimeoutId !== null) { + window.clearTimeout(fallbackTimeoutId); + } + useSubtitleStore.getState().clearActiveSubtitle(); + }; + } + const timeoutId = window.setTimeout(() => { setActivePartIndex((currentIndex) => { const nextIndex = currentIndex + 1; @@ -56,7 +134,14 @@ export function RepairScanSequence({ return () => { window.clearTimeout(timeoutId); }; - }, [activePartIndex, config, onComplete, parts, scanPartSeconds]); + }, [ + activePartIndex, + brokenPartMatches, + config, + onComplete, + parts, + scanPartSeconds, + ]); return ( diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts index aa93b50..7790878 100644 --- a/src/data/gameplay/repairMissions.ts +++ b/src/data/gameplay/repairMissions.ts @@ -4,6 +4,7 @@ import type { RepairMissionId, } from "@/types/gameplay/repairMission"; import { + EBIKE_DIAGNOSTIC_DIALOGUE_ID, EBIKE_WORLD_ROTATION_Y, EBIKE_WORLD_SCALE, } from "@/data/ebike/ebikeConfig"; @@ -39,6 +40,9 @@ export const REPAIR_MISSIONS: Record = { nodeName: "refroidisseur", targetNodeName: "refroidisseur", caseSlotName: "placeholder_1", + // Plays during the scan landing on the refroidisseur node; + // the scan sequence advances on this audio's `ended` event. + voiceLineId: EBIKE_DIAGNOSTIC_DIALOGUE_ID, }, ], replacementParts: [ diff --git a/src/types/gameplay/repairMission.ts b/src/types/gameplay/repairMission.ts index bc330a6..a95c09c 100644 --- a/src/types/gameplay/repairMission.ts +++ b/src/types/gameplay/repairMission.ts @@ -48,6 +48,15 @@ export interface RepairMissionPartConfig { */ caseLockGroup?: string; modelPath?: string; + /** + * Optional dialogue id to play when the scan sequence lands on this + * part. The scan sequence will pause on this part for the duration + * of the audio (instead of the default `scanPartSeconds` timer) and + * advance to the next part on the audio's `ended` event. Use this to + * deliver a node-specific diagnostic line (e.g. ebike refroidisseur + * -> "narrateur_refroidisseur_diagnostic"). + */ + voiceLineId?: string; } export interface RepairScannedBrokenPart {