diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx index ca0b0d4..7c86294 100644 --- a/src/components/three/gameplay/RepairGame.tsx +++ b/src/components/three/gameplay/RepairGame.tsx @@ -149,14 +149,25 @@ export function RepairGame({ }; }, [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(() => { if (mainState !== mission) return undefined; if (step !== "fragmented") return undefined; - const timeoutId = window.setTimeout(() => { - setMissionStep(mission, "scanning"); - }, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000); + const timeoutId = window.setTimeout( + () => { + 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 () => { window.clearTimeout(timeoutId); @@ -185,6 +196,9 @@ export function RepairGame({ rotation={config.modelRotation ?? [0, 0, 0]} scale={config.modelScale ?? 1} split + onSplitSettled={(settledAt) => { + if (settledAt === 1) setMissionStep(mission, "scanning"); + }} /> ) : null} {step === "scanning" ? ( diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx index ee81f3d..14a832d 100644 --- a/src/components/three/models/ExplodableModel.tsx +++ b/src/components/three/models/ExplodableModel.tsx @@ -1,5 +1,5 @@ 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 { useFrame } from "@react-three/fiber"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; @@ -72,6 +72,12 @@ interface ExplodableModelInnerProps extends ModelTransformProps { split: boolean; splitDistance?: number; 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[]; nodeAnchorNames?: readonly string[]; onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void; @@ -101,6 +107,7 @@ function ExplodableModelInner({ scale = 1, splitDistance = 1.2, onPartsReady, + onSplitSettled, hideNodeNames, nodeAnchorNames, onNodeAnchorsChange, @@ -112,9 +119,28 @@ function ExplodableModelInner({ scale, }); 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( - () => 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 anchorSignatureRef = useRef(""); diff --git a/src/utils/three/ExplodedModel.ts b/src/utils/three/ExplodedModel.ts index d381a78..3de379b 100644 --- a/src/utils/three/ExplodedModel.ts +++ b/src/utils/three/ExplodedModel.ts @@ -9,6 +9,13 @@ export interface ExplodedPart { interface ExplodedModelOptions { distance?: 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(); @@ -18,17 +25,24 @@ export class ExplodedModel { private readonly parts: ExplodedPart[] = []; private readonly distance: number; private readonly speed: number; + private readonly onSettled?: (settledAt: 0 | 1) => void; private progress = 0; private targetProgress = 0; + private settledAtTarget = true; constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) { this.distance = options.distance ?? 1.2; this.speed = options.speed ?? 6; + if (options.onSettled) this.onSettled = options.onSettled; this.parts = this.createParts(model); } 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[] { @@ -39,6 +53,10 @@ export class ExplodedModel { const diff = this.targetProgress - this.progress; if (Math.abs(diff) < 0.001) { this.progress = this.targetProgress; + if (!this.settledAtTarget) { + this.settledAtTarget = true; + this.onSettled?.(this.targetProgress === 1 ? 1 : 0); + } } else { this.progress += diff * Math.min(delta * this.speed, 1); }