acdcb5515b
The ebike mission previously had two redundant entry-point sub-states
('locked' and 'waiting') that were behaviorally identical from the
player's perspective:
- both showed the same 'Lancer le Repair Game' prompt
- both allowed the press-E handler in Ebike.tsx to jump to 'inspected'
In addition, the locked state caused two latent bugs:
- the static-map ebike node (GameMap) and the live <Ebike> component
were rendered simultaneously at the same world position
- a generic RepairMissionTrigger anchor sphere was rendered in
parallel to Ebike's own InteractableObject (two triggers, same area)
Changes:
- useGameStore: ebike's initial currentStep + completeIntroState target
is now 'waiting' (pylon/farm still init at 'locked' — they need it).
- Ebike.tsx: drop dead === 'locked' branches in repairGameOwnsEbikeModel
and the press-E handler.
- EbikeRepairNarrator: only reset the played-set on 'waiting'.
- RepairGame: drop 'locked' from the ebike livePosition guard.
- REPAIR_MISSION_TRIGGERS: empty array (the duplicate ebike anchor
sphere is gone). Keep the array + RepairMissionTrigger component for
future re-use.
- GameMap: hide the static-map ebike node as soon as
mainState === 'ebike' (was: only when ebikeStep !== 'locked').
- repairMissionState.getPreviousMissionStep: ebike rewinds from
'waiting' to 'waiting' (cap), pylon to 'npc-return', farm to 'locked'.
The 'locked' value is intentionally kept in the MissionStep type union
because the farm mission still uses it as a meaningful kickoff state
driving FarmNarrativeFlow's auto-transition to electricienne_history.
99 lines
3.4 KiB
TypeScript
99 lines
3.4 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import {
|
|
EBIKE_DIAGNOSTIC_DIALOGUE_ID,
|
|
EBIKE_REPAIRED_DIALOGUE_ID,
|
|
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";
|
|
|
|
/**
|
|
* 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!..."
|
|
*
|
|
* 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.
|
|
*
|
|
* 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<Record<MissionStep, string>> = {
|
|
fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID,
|
|
repairing: EBIKE_DIAGNOSTIC_DIALOGUE_ID,
|
|
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<Set<MissionStep>>(new Set());
|
|
const activeAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (ebikeStep === "waiting") {
|
|
playedRef.current.clear();
|
|
}
|
|
}, [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;
|
|
|
|
const dialogueId = STEP_TO_DIALOGUE_ID[ebikeStep];
|
|
if (!dialogueId) return;
|
|
if (playedRef.current.has(ebikeStep)) return;
|
|
|
|
playedRef.current.add(ebikeStep);
|
|
|
|
let cancelled = false;
|
|
|
|
void (async () => {
|
|
const manifest = await loadDialogueManifest();
|
|
if (cancelled || !manifest) return;
|
|
const audio = await playDialogueById(manifest, dialogueId);
|
|
if (cancelled) {
|
|
stopAudio(audio);
|
|
useSubtitleStore.getState().clearActiveSubtitle();
|
|
return;
|
|
}
|
|
activeAudioRef.current = audio;
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
stopAudio(activeAudioRef.current);
|
|
activeAudioRef.current = null;
|
|
useSubtitleStore.getState().clearActiveSubtitle();
|
|
};
|
|
}, [mainState, ebikeStep]);
|
|
|
|
return null;
|
|
}
|