diff --git a/src/components/game/EbikeRepairNarrator.tsx b/src/components/game/EbikeRepairNarrator.tsx index d4dea19..eecbf5e 100644 --- a/src/components/game/EbikeRepairNarrator.tsx +++ b/src/components/game/EbikeRepairNarrator.tsx @@ -1,8 +1,5 @@ import { useEffect, useRef } from "react"; -import { - EBIKE_REPAIRED_DIALOGUE_ID, - EBIKE_SCAN_HINT_DIALOGUE_ID, -} from "@/data/ebike/ebikeConfig"; +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"; @@ -12,14 +9,13 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue"; /** * Plays narrator cues during the ebike repair game: * - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..." - * - `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`. + * The `narrateur_refroidisseur_diagnostic` line is triggered by the + * scan sequence itself when it lands on the refroidisseur node + * (configured via `RepairMissionPartConfig.voiceLineId` on the broken + * part). The `narrateur_ebikerepare` line is triggered by RepairGame + * directly at the `done` step so its `ended` event can drive the + * mission completion handoff. * * 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 @@ -32,7 +28,6 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue"; */ const STEP_TO_DIALOGUE_ID: Partial> = { fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID, - done: EBIKE_REPAIRED_DIALOGUE_ID, }; function stopAudio(audio: HTMLAudioElement | null): void { diff --git a/src/components/three/gameplay/RepairCompletionStep.tsx b/src/components/three/gameplay/RepairCompletionStep.tsx index f0eef8e..cf9fc64 100644 --- a/src/components/three/gameplay/RepairCompletionStep.tsx +++ b/src/components/three/gameplay/RepairCompletionStep.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from "react"; -import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { TriggerObject } from "@/components/three/interaction/TriggerObject"; @@ -40,11 +39,12 @@ export function RepairCompletionStep({ onExitComplete={onComplete} /> - + {/* + The repaired model is now rendered by the shared ExplodableModel + in RepairGame (split=false at done) so a single instance covers + the whole repair flow. Rendering RepairObjectModel here would + duplicate the model on top of the unified one. + */} {!isClosingCase ? ( void; +} + +const TRIGGER_POSITION: Vector3Tuple = [0, 1.4, 0]; + +/** + * Minimal interactable used for the ebike `repairing` step. Replaces + * the heavier RepairRepairingStep (grabbable parts + placeholder + * circles) with a single "Changez le refroidisseur" prompt. The + * collider is invisible — the player just walks up and presses E. + */ +export function RepairEbikeRepairTrigger({ + onRepair, +}: RepairEbikeRepairTriggerProps): React.JSX.Element { + return ( + + + + + + + ); +} diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index 7c86294..0ea96e6 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -1,4 +1,4 @@ -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useGLTF } from "@react-three/drei"; import { ExplodableModel } from "@/components/three/models/ExplodableModel"; import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel"; @@ -7,6 +7,7 @@ import type { RepairCasePlaceholder, } from "@/components/three/gameplay/RepairCaseModel"; import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep"; +import { RepairEbikeRepairTrigger } from "@/components/three/gameplay/RepairEbikeRepairTrigger"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { BUBBLE_GROW_DURATION_SECONDS } from "@/components/three/gameplay/RepairFocusBubble"; @@ -14,11 +15,20 @@ import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairing import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep"; import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence"; import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig"; -import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig"; +import { + REPAIR_DONE_DIALOGUE_FALLBACK_MS, + REPAIR_FRAGMENTATION_SEQUENCE_SECONDS, + REPAIR_FRAGMENT_SPLIT_SPEED, + 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 { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; +import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest"; +import { playDialogueById } from "@/utils/dialogues/playDialogue"; +import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import type { MissionStep, RepairMissionConfig, @@ -28,6 +38,7 @@ import type { import { useGameStore } from "@/managers/stores/useGameStore"; import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; +import type { ExplodedPart } from "@/utils/three/ExplodedModel"; import { toVector3Scale } from "@/utils/three/scale"; interface RepairGameProps extends Required< @@ -55,6 +66,20 @@ function RepairMissionAssetPreloader({ return null; } +const REPAIR_PHASES: readonly MissionStep[] = [ + "fragmented", + "scanning", + "repairing", + "reassembling", + "done", +]; + +const SPLIT_PHASES: readonly MissionStep[] = [ + "fragmented", + "scanning", + "repairing", +]; + export function RepairGame({ mission, position, @@ -74,22 +99,22 @@ export function RepairGame({ const [scannedBrokenParts, setScannedBrokenParts] = useState< readonly RepairScannedBrokenPart[] >([]); - // For the ebike mission, use the bike's live parked world position once - // the repair flow leaves the waiting phase so the repair happens - // wherever the player parked the bike, not at the static zone anchor. - // window.ebikeParkedPosition is set by Ebike when the player drops the - // bike and stays stable through the rest of the repair flow. - const livePosition = useMemo(() => { - if (mission !== "ebike" || mainState !== mission) return position; - if (step === "waiting") return position; - const parked = window.ebikeParkedPosition; - if (!parked) return position; - return [parked[0], parked[1], parked[2]]; - }, [mainState, mission, position, step]); + const [explodedParts, setExplodedParts] = useState( + [], + ); + // Position of the repair flow is the static zone position. Ebike + // movement is disabled during the mission so we don't need to track + // window.ebikeParkedPosition: the bike, the case and the exploded + // model all sit at the zone's anchor. const parsedScale = toVector3Scale(scale); - const snappedPosition = useTerrainSnappedPosition(livePosition); + const snappedPosition = useTerrainSnappedPosition(position); const readyForFragmentation = step === "inspected"; const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]); + const isRepairPhase = (REPAIR_PHASES as readonly MissionStep[]).includes( + step, + ); + const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step); + const isRepairing = step === "repairing"; useRepairFragmentationInput({ enabled: mainState === mission && readyForFragmentation, @@ -150,22 +175,19 @@ export function RepairGame({ }, [mainState, mission, setMissionStep, step]); // fragmented -> scanning is now driven by `onSplitSettled` from the - // ExplodableModel below (fires once the lerp actually converges on - // progress=1). The legacy REPAIR_FRAGMENTATION_SEQUENCE_SECONDS timer - // is kept as a safety-net fallback in case the model fails to load - // (no part anchors -> no settled event) so the flow can never get - // stuck on the fragmented step. + // shared ExplodableModel below (fires once the lerp actually + // converges on progress=1). The legacy + // REPAIR_FRAGMENTATION_SEQUENCE_SECONDS timer is kept as a safety-net + // fallback in case the model fails to load (no settled event) so the + // flow can never get stuck on the fragmented step. useEffect(() => { if (mainState !== mission) return undefined; - if (step !== "fragmented") return undefined; const timeoutId = window.setTimeout( () => { setMissionStep(mission, "scanning"); }, - // Generous fallback: actual anim usually finishes in <1s, so this - // only fires if something went wrong. (REPAIR_FRAGMENTATION_SEQUENCE_SECONDS + 2) * 1000, ); @@ -174,6 +196,95 @@ export function RepairGame({ }; }, [mainState, mission, setMissionStep, step]); + // Ebike-only: at `done`, play the success narrator line and complete + // the mission when the audio ends (handing off to pylon). A fallback + // timer guarantees the transition even if the audio fails. + useEffect(() => { + if (mainState !== mission) return undefined; + if (mission !== "ebike") return undefined; + if (step !== "done") return undefined; + + let cancelled = false; + let activeAudio: HTMLAudioElement | null = null; + let fallbackTimeoutId: number | null = null; + + const finish = (): void => { + if (cancelled) return; + cancelled = true; + completeMission(mission); + }; + + void (async () => { + const manifest = await loadDialogueManifest(); + if (cancelled) return; + const audio = manifest + ? await playDialogueById(manifest, EBIKE_REPAIRED_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", finish, { once: true }); + fallbackTimeoutId = window.setTimeout( + finish, + REPAIR_DONE_DIALOGUE_FALLBACK_MS, + ); + } else { + fallbackTimeoutId = window.setTimeout( + finish, + REPAIR_DONE_DIALOGUE_FALLBACK_MS, + ); + } + })(); + + return () => { + cancelled = true; + if (activeAudio) { + activeAudio.removeEventListener("ended", finish); + if (!activeAudio.paused) { + activeAudio.pause(); + activeAudio.currentTime = 0; + } + } + if (fallbackTimeoutId !== null) { + window.clearTimeout(fallbackTimeoutId); + } + useSubtitleStore.getState().clearActiveSubtitle(); + }; + }, [completeMission, mainState, mission, step]); + + // The shared ExplodableModel resets its parts to a fresh array each + // time it remounts (i.e. when leaving the repair flow back to + // waiting/inspected). The cached `explodedParts` will be overwritten + // by `onPartsReady` on the next mount; we don't need an explicit + // reset because no rendered code path uses the stale parts outside + // the repair phases. + + // Settled callback: drives event-based transitions out of the + // explode/reassemble lerp. + const stepRef = useRef(step); + useEffect(() => { + stepRef.current = step; + }, [step]); + const handleSplitSettled = useMemo( + () => (settledAt: 0 | 1) => { + const currentStep = stepRef.current; + if (settledAt === 1 && currentStep === "fragmented") { + setMissionStep(mission, "scanning"); + } + // settledAt === 0 happens when the model finishes the inverse + // explosion at reassembling. The reassembly step's particle hold + // takes care of advancing to `done`. + }, + [mission, setMissionStep], + ); + if (mainState !== mission) return null; if (step === "locked") return null; @@ -190,54 +301,63 @@ export function RepairGame({ onInspect={() => setMissionStep(mission, "inspected")} /> ) : null} - {step === "fragmented" ? ( + {/* + Single ExplodableModel mounted across the entire repair flow + (fragmented -> done) so the model loads once, animates from + its real original positions, never re-instantiates between + phases, and stays at a stable transform. `split` toggles drive + the explode/reassemble lerps in place. + */} + {isRepairPhase ? ( { - if (settledAt === 1) setMissionStep(mission, "scanning"); - }} + split={isSplitPhase} + splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED} + onPartsReady={setExplodedParts} + onSplitSettled={handleSplitSettled} + {...(isRepairing + ? { + hideNodeNames: brokenNodeNames, + nodeAnchorNames: brokenNodeNames, + onNodeAnchorsChange: setBrokenAnchors, + } + : {})} /> ) : null} {step === "scanning" ? ( { setScannedBrokenParts(brokenParts); setMissionStep(mission, "repairing"); }} /> ) : null} - {step === "repairing" ? ( - <> - - setMissionStep(mission, "reassembling")} - /> - + {step === "repairing" && mission === "ebike" ? ( + setMissionStep(mission, "reassembling")} + /> + ) : null} + {step === "repairing" && mission !== "ebike" ? ( + setMissionStep(mission, "reassembling")} + /> ) : null} {step === "reassembling" ? ( setMissionStep(mission, "done")} + delayMs={REPAIR_REASSEMBLY_HOLD_MS} + onSettled={() => setMissionStep(mission, "done")} /> ) : null} - {step === "done" && mission !== "pylon" ? ( + {step === "done" && mission !== "pylon" && mission !== "ebike" ? ( completeMission(mission)} diff --git a/src/components/three/gameplay/RepairReassemblyStep.tsx b/src/components/three/gameplay/RepairReassemblyStep.tsx index 79f7c8e..7f7d6bc 100644 --- a/src/components/three/gameplay/RepairReassemblyStep.tsx +++ b/src/components/three/gameplay/RepairReassemblyStep.tsx @@ -1,45 +1,37 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles"; -import { ExplodableModel } from "@/components/three/models/ExplodableModel"; -import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig"; -import type { RepairMissionConfig } from "@/types/gameplay/repairMission"; interface RepairReassemblyStepProps { - config: RepairMissionConfig; - onComplete: () => void; + onSettled?: () => void; + delayMs?: number; } +/** + * Visual layer for the reassembly phase. The actual collapse animation + * (parts lerping back to their original positions) is driven by the + * shared ExplodableModel mounted upstream by RepairGame, which keeps a + * single instance alive across fragmented -> done so the model never + * reloads or jumps between phases. + * + * This component now only renders the completion particles and emits a + * settled signal after `delayMs` so the upstream flow can advance. + */ export function RepairReassemblyStep({ - config, - onComplete, + onSettled, + delayMs = 0, }: RepairReassemblyStepProps): React.JSX.Element { - const [split, setSplit] = useState(true); - const reassemblySeconds = - config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS; - useEffect(() => { - const closeTimeoutId = window.setTimeout(() => { - setSplit(false); - }, 50); - const completeTimeoutId = window.setTimeout(() => { - onComplete(); - }, reassemblySeconds * 1000); + if (!onSettled) return undefined; + if (delayMs <= 0) { + onSettled(); + return undefined; + } + const timeoutId = window.setTimeout(onSettled, delayMs); return () => { - window.clearTimeout(closeTimeoutId); - window.clearTimeout(completeTimeoutId); + window.clearTimeout(timeoutId); }; - }, [onComplete, reassemblySeconds]); + }, [onSettled, delayMs]); - return ( - - - - - ); + return ; } diff --git a/src/components/three/gameplay/RepairScanSequence.tsx b/src/components/three/gameplay/RepairScanSequence.tsx index 76a1412..bd9a4ff 100644 --- a/src/components/three/gameplay/RepairScanSequence.tsx +++ b/src/components/three/gameplay/RepairScanSequence.tsx @@ -2,7 +2,6 @@ import { useEffect, useState } from "react"; import * as THREE from "three"; import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight"; import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt"; -import { ExplodableModel } from "@/components/three/models/ExplodableModel"; import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual"; import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig"; import type { @@ -18,6 +17,14 @@ import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; interface RepairScanSequenceProps { config: RepairMissionConfig; + /** + * Parts of the (already mounted) ExplodableModel managed upstream by + * RepairGame. The scan sequence drives its visuals against these + * parts so the model isn't re-instantiated when entering the scanning + * phase (which would cause the explosion animation to replay and the + * world transform to differ between phases). + */ + parts: readonly ExplodedPart[]; onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void; } @@ -30,9 +37,9 @@ const warnedMissingScanParts = new Set(); export function RepairScanSequence({ config, + parts, onComplete, }: RepairScanSequenceProps): React.JSX.Element { - const [parts, setParts] = useState([]); const [activePartIndex, setActivePartIndex] = useState(0); const activePart = parts[activePartIndex]; const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS; @@ -145,12 +152,6 @@ export function RepairScanSequence({ return ( - {visibleBrokenPartMatches.map((match) => { const part = parts[match.partIndex]; @@ -218,6 +219,7 @@ function getBrokenPartMatches( logger.warn("RepairScan", "Broken parts missing from exploded model", { missionId: config.id, missingIds, + availablePartNames: parts.map((part) => part.object.name), }); } } diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx index 14a832d..37e0def 100644 --- a/src/components/three/models/ExplodableModel.tsx +++ b/src/components/three/models/ExplodableModel.tsx @@ -71,6 +71,11 @@ interface ExplodableModelInnerProps extends ModelTransformProps { modelPath: string; split: boolean; splitDistance?: number; + /** + * Lerp speed for the explode/reassemble animation. Lower = slower. + * Defaults to ExplodedModel's internal default (6) when omitted. + */ + splitSpeed?: number; onPartsReady?: (parts: readonly ExplodedPart[]) => void; /** * Fired once each time the explode/reassemble lerp converges on its @@ -106,6 +111,7 @@ function ExplodableModelInner({ rotation = [0, 0, 0], scale = 1, splitDistance = 1.2, + splitSpeed, onPartsReady, onSplitSettled, hideNodeNames, @@ -138,9 +144,10 @@ function ExplodableModelInner({ // eslint-disable-next-line react-hooks/refs new ExplodedModel(model, { distance: splitDistance, + ...(splitSpeed !== undefined ? { speed: splitSpeed } : {}), onSettled: handleSettled, }), - [model, splitDistance, handleSettled], + [model, splitDistance, splitSpeed, handleSettled], ); const parsedScale = toVector3Scale(scale); const anchorSignatureRef = useRef(""); diff --git a/src/data/gameplay/repairGameConfig.ts b/src/data/gameplay/repairGameConfig.ts index 1e28210..e6251f2 100644 --- a/src/data/gameplay/repairGameConfig.ts +++ b/src/data/gameplay/repairGameConfig.ts @@ -3,3 +3,22 @@ export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4; export const REPAIR_INTERACTION_RADIUS = 10; export const REPAIR_SCAN_PART_SECONDS = 1.2; export const REPAIR_REASSEMBLY_SECONDS = 1.4; +/** + * Lerp speed used by the shared ExplodableModel during the repair flow. + * Lower = slower, more deliberate explosion so the player can see each + * node clearly leave its original position. The default ExplodedModel + * speed (6) finishes in ~0.5s which feels rushed. + */ +export const REPAIR_FRAGMENT_SPLIT_SPEED = 1.8; +/** + * Delay between the end of the inverse-explosion (parts settled back to + * their original positions) and the auto-transition to the `done` step. + * Used by the ebike repair flow so the reassembly particles can play + * before the bubble starts shrinking. + */ +export const REPAIR_REASSEMBLY_HOLD_MS = 1500; +/** + * Fallback timer for the ebike `done` -> mission-complete transition + * when the narrator audio fails to fire its `ended` event. + */ +export const REPAIR_DONE_DIALOGUE_FALLBACK_MS = 6000;