diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index 0ea96e6..aac32bc 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -102,12 +102,25 @@ export function RepairGame({ 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 reassemblyDoneTimeoutRef = useRef(null); + // Ebike-specific: once the repair starts, keep the entire repair flow + // exactly where the bike currently is. `Ebike` owns the live parked + // position while inspected is showing; RepairGame takes over the model + // from fragmented onward and must reuse that same world transform. + const livePosition = useMemo(() => { + if (mission !== "ebike" || step === "waiting") return position; + + const parked = window.ebikeParkedPosition; + if (!parked) return position; + + return [parked[0], parked[1], parked[2]]; + }, [mission, position, step]); + const usesLiveEbikePosition = mission === "ebike" && step !== "waiting"; const parsedScale = toVector3Scale(scale); - const snappedPosition = useTerrainSnappedPosition(position); + const terrainSnappedPosition = useTerrainSnappedPosition(livePosition); + const snappedPosition = usesLiveEbikePosition + ? livePosition + : terrainSnappedPosition; const readyForFragmentation = step === "inspected"; const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]); const isRepairPhase = (REPAIR_PHASES as readonly MissionStep[]).includes( @@ -196,6 +209,19 @@ export function RepairGame({ }; }, [mainState, mission, setMissionStep, step]); + useEffect(() => { + if (mainState !== mission) return undefined; + if (step !== "reassembling") return undefined; + + const timeoutId = window.setTimeout(() => { + setMissionStep(mission, "done"); + }, REPAIR_REASSEMBLY_HOLD_MS + 4000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [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. @@ -278,13 +304,27 @@ export function RepairGame({ 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`. + if (settledAt === 0 && currentStep === "reassembling") { + if (reassemblyDoneTimeoutRef.current !== null) { + window.clearTimeout(reassemblyDoneTimeoutRef.current); + } + reassemblyDoneTimeoutRef.current = window.setTimeout(() => { + reassemblyDoneTimeoutRef.current = null; + setMissionStep(mission, "done"); + }, REPAIR_REASSEMBLY_HOLD_MS); + } }, [mission, setMissionStep], ); + useEffect(() => { + return () => { + if (reassemblyDoneTimeoutRef.current !== null) { + window.clearTimeout(reassemblyDoneTimeoutRef.current); + } + }; + }, []); + if (mainState !== mission) return null; if (step === "locked") return null; @@ -351,12 +391,7 @@ export function RepairGame({ onRepair={() => setMissionStep(mission, "reassembling")} /> ) : null} - {step === "reassembling" ? ( - setMissionStep(mission, "done")} - /> - ) : null} + {step === "reassembling" ? : null} {step === "done" && mission !== "pylon" && mission !== "ebike" ? ( void; - delayMs?: number; -} - /** * Visual layer for the reassembly phase. The actual collapse animation * (parts lerping back to their original positions) is driven by the @@ -16,22 +10,6 @@ interface RepairReassemblyStepProps { * This component now only renders the completion particles and emits a * settled signal after `delayMs` so the upstream flow can advance. */ -export function RepairReassemblyStep({ - onSettled, - delayMs = 0, -}: RepairReassemblyStepProps): React.JSX.Element { - useEffect(() => { - if (!onSettled) return undefined; - if (delayMs <= 0) { - onSettled(); - return undefined; - } - - const timeoutId = window.setTimeout(onSettled, delayMs); - return () => { - window.clearTimeout(timeoutId); - }; - }, [onSettled, delayMs]); - +export function RepairReassemblyStep(): React.JSX.Element { return ; } diff --git a/src/components/three/gameplay/RepairScanSequence.tsx b/src/components/three/gameplay/RepairScanSequence.tsx index bd9a4ff..3d0b829 100644 --- a/src/components/three/gameplay/RepairScanSequence.tsx +++ b/src/components/three/gameplay/RepairScanSequence.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight"; import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt"; @@ -43,10 +43,18 @@ export function RepairScanSequence({ const [activePartIndex, setActivePartIndex] = useState(0); const activePart = parts[activePartIndex]; const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS; - const brokenPartMatches = getBrokenPartMatches(parts, config); + const brokenPartMatches = useMemo( + () => getBrokenPartMatches(parts, config), + [parts, config], + ); const visibleBrokenPartMatches = brokenPartMatches.filter( (match) => match.partIndex <= activePartIndex, ); + const onCompleteRef = useRef(onComplete); + + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); useEffect(() => { if (parts.length === 0) return undefined; @@ -73,7 +81,9 @@ export function RepairScanSequence({ setActivePartIndex((currentIndex) => { const nextIndex = currentIndex + 1; if (nextIndex >= parts.length) { - onComplete(getScannedBrokenParts(parts, config)); + window.setTimeout(() => { + onCompleteRef.current(getScannedBrokenParts(parts, config)); + }, 0); return currentIndex; } return nextIndex; @@ -130,7 +140,9 @@ export function RepairScanSequence({ setActivePartIndex((currentIndex) => { const nextIndex = currentIndex + 1; if (nextIndex >= parts.length) { - onComplete(getScannedBrokenParts(parts, config)); + window.setTimeout(() => { + onCompleteRef.current(getScannedBrokenParts(parts, config)); + }, 0); return currentIndex; } @@ -141,14 +153,7 @@ export function RepairScanSequence({ return () => { window.clearTimeout(timeoutId); }; - }, [ - activePartIndex, - brokenPartMatches, - config, - onComplete, - parts, - scanPartSeconds, - ]); + }, [activePartIndex, brokenPartMatches, config, parts, scanPartSeconds]); return ( @@ -235,11 +240,20 @@ function objectContainsNodeName( object: THREE.Object3D, nodeName: string, ): boolean { - if (object.name === nodeName) return true; + const normalizedNodeName = nodeName.toLowerCase(); + const objectName = object.name.toLowerCase(); + if (objectName === normalizedNodeName) return true; + if (objectName.includes(normalizedNodeName)) return true; + if (normalizedNodeName.includes(objectName)) return true; let found = false; object.traverse((child) => { - if (child.name === nodeName) { + const childName = child.name.toLowerCase(); + if ( + childName === normalizedNodeName || + childName.includes(normalizedNodeName) || + normalizedNodeName.includes(childName) + ) { found = true; } }); diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts index 7790878..4a350fd 100644 --- a/src/data/gameplay/repairMissions.ts +++ b/src/data/gameplay/repairMissions.ts @@ -37,7 +37,7 @@ export const REPAIR_MISSIONS: Record = { id: "ebike-cooling-core", label: "Cooling core", modelPath: "/models/refroidisseur/model.gltf", - nodeName: "refroidisseur", + nodeName: "Radiateur", targetNodeName: "refroidisseur", caseSlotName: "placeholder_1", // Plays during the scan landing on the refroidisseur node;