feat(repair): make fragmented -> scanning event-driven via onSplitSettled
The fragmented -> scanning transition used to fire on a blind
setTimeout of REPAIR_FRAGMENTATION_SEQUENCE_SECONDS regardless of
whether the explode lerp had actually finished. With the new sphere-
reveal flow this got out of sync (sphere grows for 2.5s before
fragmented even mounts), so the timer could fire too early or while
the parts were still flying out.
Now the ExplodedModel emits a single 'settled' callback when its
internal lerp converges on its target (1 = fully exploded, 0 = fully
reassembled). RepairGame listens for settled-at-1 on the fragmented
ExplodableModel and advances to scanning on that event.
The legacy timer is kept as a generous safety net
(REPAIR_FRAGMENTATION_SEQUENCE_SECONDS + 2 seconds) so that if the
model fails to load (no parts -> no settled event ever fires) the
flow can never get stuck on the fragmented step.
Changes:
- ExplodedModel.ts:
- new ExplodedModelOptions.onSettled: (settledAt: 0 | 1) => void
- track settledAtTarget to ensure the callback fires exactly once
per lerp (re-armed when setSplit() flips the target).
- ExplodableModel.tsx: new onSplitSettled prop, forwarded to the
underlying ExplodedModel via a stable useCallback that reads the
latest prop through a ref so the instance is not recreated mid-anim.
- RepairGame.tsx:
- wire onSplitSettled on the fragmented ExplodableModel to
setMissionStep(mission, 'scanning').
- keep the existing setTimeout but extend it as a fallback only.
Pylon and farm benefit from the same fix automatically since they
share the same RepairGame fragmented branch.
This commit is contained in:
@@ -149,14 +149,25 @@ export function RepairGame({
|
|||||||
};
|
};
|
||||||
}, [mainState, mission, setMissionStep, step]);
|
}, [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.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mainState !== mission) return undefined;
|
if (mainState !== mission) return undefined;
|
||||||
|
|
||||||
if (step !== "fragmented") return undefined;
|
if (step !== "fragmented") return undefined;
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
const timeoutId = window.setTimeout(
|
||||||
setMissionStep(mission, "scanning");
|
() => {
|
||||||
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
@@ -185,6 +196,9 @@ export function RepairGame({
|
|||||||
rotation={config.modelRotation ?? [0, 0, 0]}
|
rotation={config.modelRotation ?? [0, 0, 0]}
|
||||||
scale={config.modelScale ?? 1}
|
scale={config.modelScale ?? 1}
|
||||||
split
|
split
|
||||||
|
onSplitSettled={(settledAt) => {
|
||||||
|
if (settledAt === 1) setMissionStep(mission, "scanning");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{step === "scanning" ? (
|
{step === "scanning" ? (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Component, useEffect, useMemo, useRef } from "react";
|
import { Component, useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useFrame } from "@react-three/fiber";
|
import { useFrame } from "@react-three/fiber";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
@@ -72,6 +72,12 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
|
|||||||
split: boolean;
|
split: boolean;
|
||||||
splitDistance?: number;
|
splitDistance?: number;
|
||||||
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
|
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
|
||||||
|
/**
|
||||||
|
* Fired once each time the explode/reassemble lerp converges on its
|
||||||
|
* target. `settledAt` is 1 when the parts have fully separated, 0
|
||||||
|
* when they have fully snapped back to their original positions.
|
||||||
|
*/
|
||||||
|
onSplitSettled?: (settledAt: 0 | 1) => void;
|
||||||
hideNodeNames?: readonly string[];
|
hideNodeNames?: readonly string[];
|
||||||
nodeAnchorNames?: readonly string[];
|
nodeAnchorNames?: readonly string[];
|
||||||
onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void;
|
onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void;
|
||||||
@@ -101,6 +107,7 @@ function ExplodableModelInner({
|
|||||||
scale = 1,
|
scale = 1,
|
||||||
splitDistance = 1.2,
|
splitDistance = 1.2,
|
||||||
onPartsReady,
|
onPartsReady,
|
||||||
|
onSplitSettled,
|
||||||
hideNodeNames,
|
hideNodeNames,
|
||||||
nodeAnchorNames,
|
nodeAnchorNames,
|
||||||
onNodeAnchorsChange,
|
onNodeAnchorsChange,
|
||||||
@@ -112,9 +119,28 @@ function ExplodableModelInner({
|
|||||||
scale,
|
scale,
|
||||||
});
|
});
|
||||||
const model = useClonedObject(scene);
|
const model = useClonedObject(scene);
|
||||||
|
// Keep the latest callback in a ref so the ExplodedModel instance can
|
||||||
|
// be created once per `model` and still call the most recent prop
|
||||||
|
// when the lerp settles. Reading `.current` happens only inside the
|
||||||
|
// settled-callback (invoked from update(), never during render).
|
||||||
|
const onSplitSettledRef = useRef(onSplitSettled);
|
||||||
|
useEffect(() => {
|
||||||
|
onSplitSettledRef.current = onSplitSettled;
|
||||||
|
}, [onSplitSettled]);
|
||||||
|
const handleSettled = useCallback((settledAt: 0 | 1) => {
|
||||||
|
onSplitSettledRef.current?.(settledAt);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const explodedModel = useMemo(
|
const explodedModel = useMemo(
|
||||||
() => new ExplodedModel(model, { distance: splitDistance }),
|
() =>
|
||||||
[model, splitDistance],
|
// The `handleSettled` callback only reads `onSplitSettledRef.current`
|
||||||
|
// when invoked from `update()` (useFrame), never during render.
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
|
new ExplodedModel(model, {
|
||||||
|
distance: splitDistance,
|
||||||
|
onSettled: handleSettled,
|
||||||
|
}),
|
||||||
|
[model, splitDistance, handleSettled],
|
||||||
);
|
);
|
||||||
const parsedScale = toVector3Scale(scale);
|
const parsedScale = toVector3Scale(scale);
|
||||||
const anchorSignatureRef = useRef("");
|
const anchorSignatureRef = useRef("");
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ export interface ExplodedPart {
|
|||||||
interface ExplodedModelOptions {
|
interface ExplodedModelOptions {
|
||||||
distance?: number;
|
distance?: number;
|
||||||
speed?: number;
|
speed?: number;
|
||||||
|
/**
|
||||||
|
* Fired exactly once each time the lerp converges on a target value
|
||||||
|
* (1 = fully exploded, 0 = fully reassembled). Useful for chaining
|
||||||
|
* the next mission step on actual animation completion rather than a
|
||||||
|
* blind timer.
|
||||||
|
*/
|
||||||
|
onSettled?: (settledAt: 0 | 1) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _center = new THREE.Vector3();
|
const _center = new THREE.Vector3();
|
||||||
@@ -18,17 +25,24 @@ export class ExplodedModel {
|
|||||||
private readonly parts: ExplodedPart[] = [];
|
private readonly parts: ExplodedPart[] = [];
|
||||||
private readonly distance: number;
|
private readonly distance: number;
|
||||||
private readonly speed: number;
|
private readonly speed: number;
|
||||||
|
private readonly onSettled?: (settledAt: 0 | 1) => void;
|
||||||
private progress = 0;
|
private progress = 0;
|
||||||
private targetProgress = 0;
|
private targetProgress = 0;
|
||||||
|
private settledAtTarget = true;
|
||||||
|
|
||||||
constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) {
|
constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) {
|
||||||
this.distance = options.distance ?? 1.2;
|
this.distance = options.distance ?? 1.2;
|
||||||
this.speed = options.speed ?? 6;
|
this.speed = options.speed ?? 6;
|
||||||
|
if (options.onSettled) this.onSettled = options.onSettled;
|
||||||
this.parts = this.createParts(model);
|
this.parts = this.createParts(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
setSplit(split: boolean): void {
|
setSplit(split: boolean): void {
|
||||||
this.targetProgress = split ? 1 : 0;
|
const next = split ? 1 : 0;
|
||||||
|
if (next !== this.targetProgress) {
|
||||||
|
this.targetProgress = next;
|
||||||
|
this.settledAtTarget = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getParts(): readonly ExplodedPart[] {
|
getParts(): readonly ExplodedPart[] {
|
||||||
@@ -39,6 +53,10 @@ export class ExplodedModel {
|
|||||||
const diff = this.targetProgress - this.progress;
|
const diff = this.targetProgress - this.progress;
|
||||||
if (Math.abs(diff) < 0.001) {
|
if (Math.abs(diff) < 0.001) {
|
||||||
this.progress = this.targetProgress;
|
this.progress = this.targetProgress;
|
||||||
|
if (!this.settledAtTarget) {
|
||||||
|
this.settledAtTarget = true;
|
||||||
|
this.onSettled?.(this.targetProgress === 1 ? 1 : 0);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.progress += diff * Math.min(delta * this.speed, 1);
|
this.progress += diff * Math.min(delta * this.speed, 1);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user