feat(repair): per-node scan voicelines, refroidisseur diagnostic gates scanning -> repairing

The ebike refroidisseur diagnostic line used to fire on the
'repairing' step via EbikeRepairNarrator, AFTER the scan sequence had
already raced through every part on a fixed timer. Visually the red
broken-part highlight appeared and disappeared before the player ever
heard which part was actually broken.

Now the scan sequence itself can carry per-node voice lines via a new
optional config field on each broken part. When the scan lands on a
part that has a voice line:
- the audio is played immediately (with its subtitle);
- the red broken-part highlight is on screen the entire time
  (existing cumulative highlight behaviour from RepairScanSequence);
- the next-part advance is gated on the audio's  event;
- a 15s ceiling fallback (and per-part fallback when manifest is
  missing) keeps the flow from stalling if the audio never resolves.

Cancel paths (component unmount, mission switch, debug rewind) pause
the audio, clear the subtitle, and drop both the  listener and
the fallback timer to avoid leaks or double-advances.

Changes:
- types/repairMission: new optional .
- data/repairMissions: ebike refroidisseur broken part now declares
  .
- RepairScanSequence: per-part effect now branches on .
  Default per-part timer is preserved for parts without an audio line
  (incl. all pylon/farm broken parts and ebike's non-diagnostic parts).
- EbikeRepairNarrator: drop the 'repairing' entry from the step ->
  dialogue map (the diagnostic now plays earlier, during scanning).
  'fragmented' (scan hint) and 'done' (repaired voiceover) are
  unchanged.

End result: player hears 'le refroidisseur a laché...' exactly while
the red sphere is pulsing on the cooling node, and the case opens for
the repairing step the moment the line ends.
This commit is contained in:
Tom Boullay
2026-06-03 04:20:14 +02:00
parent 317db48bcc
commit 9841b14388
4 changed files with 106 additions and 4 deletions
+7 -3
View File
@@ -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<Record<MissionStep, string>> = {
fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID,
repairing: EBIKE_DIAGNOSTIC_DIALOGUE_ID,
done: EBIKE_REPAIRED_DIALOGUE_ID,
};
@@ -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 (
<group>
+4
View File
@@ -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<RepairMissionId, RepairMissionConfig> = {
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: [
+9
View File
@@ -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 {