fix(repair-ebike): preserve bike position, unblock scan and reassembly
This commit is contained in:
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user