fix(repair-ebike): preserve bike position, unblock scan and reassembly

This commit is contained in:
Tom Boullay
2026-06-03 06:34:18 +02:00
parent 0ab5380b1e
commit 8d66391fa9
4 changed files with 79 additions and 52 deletions
+49 -14
View File
@@ -102,12 +102,25 @@ export function RepairGame({
const [explodedParts, setExplodedParts] = useState<readonly ExplodedPart[]>( const [explodedParts, setExplodedParts] = useState<readonly ExplodedPart[]>(
[], [],
); );
// Position of the repair flow is the static zone position. Ebike const reassemblyDoneTimeoutRef = useRef<number | null>(null);
// movement is disabled during the mission so we don't need to track // Ebike-specific: once the repair starts, keep the entire repair flow
// window.ebikeParkedPosition: the bike, the case and the exploded // exactly where the bike currently is. `Ebike` owns the live parked
// model all sit at the zone's anchor. // position while inspected is showing; RepairGame takes over the model
// from fragmented onward and must reuse that same world transform.
const livePosition = useMemo<Vector3Tuple>(() => {
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 parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(position); const terrainSnappedPosition = useTerrainSnappedPosition(livePosition);
const snappedPosition = usesLiveEbikePosition
? livePosition
: terrainSnappedPosition;
const readyForFragmentation = step === "inspected"; const readyForFragmentation = step === "inspected";
const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]); const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]);
const isRepairPhase = (REPAIR_PHASES as readonly MissionStep[]).includes( const isRepairPhase = (REPAIR_PHASES as readonly MissionStep[]).includes(
@@ -196,6 +209,19 @@ export function RepairGame({
}; };
}, [mainState, mission, setMissionStep, step]); }, [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 // Ebike-only: at `done`, play the success narrator line and complete
// the mission when the audio ends (handing off to pylon). A fallback // the mission when the audio ends (handing off to pylon). A fallback
// timer guarantees the transition even if the audio fails. // timer guarantees the transition even if the audio fails.
@@ -278,13 +304,27 @@ export function RepairGame({
if (settledAt === 1 && currentStep === "fragmented") { if (settledAt === 1 && currentStep === "fragmented") {
setMissionStep(mission, "scanning"); setMissionStep(mission, "scanning");
} }
// settledAt === 0 happens when the model finishes the inverse if (settledAt === 0 && currentStep === "reassembling") {
// explosion at reassembling. The reassembly step's particle hold if (reassemblyDoneTimeoutRef.current !== null) {
// takes care of advancing to `done`. window.clearTimeout(reassemblyDoneTimeoutRef.current);
}
reassemblyDoneTimeoutRef.current = window.setTimeout(() => {
reassemblyDoneTimeoutRef.current = null;
setMissionStep(mission, "done");
}, REPAIR_REASSEMBLY_HOLD_MS);
}
}, },
[mission, setMissionStep], [mission, setMissionStep],
); );
useEffect(() => {
return () => {
if (reassemblyDoneTimeoutRef.current !== null) {
window.clearTimeout(reassemblyDoneTimeoutRef.current);
}
};
}, []);
if (mainState !== mission) return null; if (mainState !== mission) return null;
if (step === "locked") return null; if (step === "locked") return null;
@@ -351,12 +391,7 @@ export function RepairGame({
onRepair={() => setMissionStep(mission, "reassembling")} onRepair={() => setMissionStep(mission, "reassembling")}
/> />
) : null} ) : null}
{step === "reassembling" ? ( {step === "reassembling" ? <RepairReassemblyStep /> : null}
<RepairReassemblyStep
delayMs={REPAIR_REASSEMBLY_HOLD_MS}
onSettled={() => setMissionStep(mission, "done")}
/>
) : null}
{step === "done" && mission !== "pylon" && mission !== "ebike" ? ( {step === "done" && mission !== "pylon" && mission !== "ebike" ? (
<RepairCompletionStep <RepairCompletionStep
config={config} config={config}
@@ -1,11 +1,5 @@
import { useEffect } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles"; import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
interface RepairReassemblyStepProps {
onSettled?: () => void;
delayMs?: number;
}
/** /**
* Visual layer for the reassembly phase. The actual collapse animation * Visual layer for the reassembly phase. The actual collapse animation
* (parts lerping back to their original positions) is driven by the * (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 * This component now only renders the completion particles and emits a
* settled signal after `delayMs` so the upstream flow can advance. * settled signal after `delayMs` so the upstream flow can advance.
*/ */
export function RepairReassemblyStep({ export function RepairReassemblyStep(): React.JSX.Element {
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]);
return <RepairCompletionParticles />; return <RepairCompletionParticles />;
} }
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight"; import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight";
import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt"; import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt";
@@ -43,10 +43,18 @@ export function RepairScanSequence({
const [activePartIndex, setActivePartIndex] = useState(0); const [activePartIndex, setActivePartIndex] = useState(0);
const activePart = parts[activePartIndex]; const activePart = parts[activePartIndex];
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS; 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( const visibleBrokenPartMatches = brokenPartMatches.filter(
(match) => match.partIndex <= activePartIndex, (match) => match.partIndex <= activePartIndex,
); );
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => { useEffect(() => {
if (parts.length === 0) return undefined; if (parts.length === 0) return undefined;
@@ -73,7 +81,9 @@ export function RepairScanSequence({
setActivePartIndex((currentIndex) => { setActivePartIndex((currentIndex) => {
const nextIndex = currentIndex + 1; const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) { if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config)); window.setTimeout(() => {
onCompleteRef.current(getScannedBrokenParts(parts, config));
}, 0);
return currentIndex; return currentIndex;
} }
return nextIndex; return nextIndex;
@@ -130,7 +140,9 @@ export function RepairScanSequence({
setActivePartIndex((currentIndex) => { setActivePartIndex((currentIndex) => {
const nextIndex = currentIndex + 1; const nextIndex = currentIndex + 1;
if (nextIndex >= parts.length) { if (nextIndex >= parts.length) {
onComplete(getScannedBrokenParts(parts, config)); window.setTimeout(() => {
onCompleteRef.current(getScannedBrokenParts(parts, config));
}, 0);
return currentIndex; return currentIndex;
} }
@@ -141,14 +153,7 @@ export function RepairScanSequence({
return () => { return () => {
window.clearTimeout(timeoutId); window.clearTimeout(timeoutId);
}; };
}, [ }, [activePartIndex, brokenPartMatches, config, parts, scanPartSeconds]);
activePartIndex,
brokenPartMatches,
config,
onComplete,
parts,
scanPartSeconds,
]);
return ( return (
<group> <group>
@@ -235,11 +240,20 @@ function objectContainsNodeName(
object: THREE.Object3D, object: THREE.Object3D,
nodeName: string, nodeName: string,
): boolean { ): 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; let found = false;
object.traverse((child) => { object.traverse((child) => {
if (child.name === nodeName) { const childName = child.name.toLowerCase();
if (
childName === normalizedNodeName ||
childName.includes(normalizedNodeName) ||
normalizedNodeName.includes(childName)
) {
found = true; found = true;
} }
}); });
+1 -1
View File
@@ -37,7 +37,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
id: "ebike-cooling-core", id: "ebike-cooling-core",
label: "Cooling core", label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf", modelPath: "/models/refroidisseur/model.gltf",
nodeName: "refroidisseur", nodeName: "Radiateur",
targetNodeName: "refroidisseur", targetNodeName: "refroidisseur",
caseSlotName: "placeholder_1", caseSlotName: "placeholder_1",
// Plays during the scan landing on the refroidisseur node; // Plays during the scan landing on the refroidisseur node;