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:
@@ -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,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: [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user