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[]>(
[],
);
// 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<number | null>(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<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 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" ? (
<RepairReassemblyStep
delayMs={REPAIR_REASSEMBLY_HOLD_MS}
onSettled={() => setMissionStep(mission, "done")}
/>
) : null}
{step === "reassembling" ? <RepairReassemblyStep /> : null}
{step === "done" && mission !== "pylon" && mission !== "ebike" ? (
<RepairCompletionStep
config={config}
@@ -1,11 +1,5 @@
import { useEffect } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
interface RepairReassemblyStepProps {
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
@@ -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 <RepairCompletionParticles />;
}
@@ -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 (
<group>
@@ -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;
}
});
+1 -1
View File
@@ -37,7 +37,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
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;