refactor(repair): unify exploded model across phases, simplify ebike flow

- RepairGame: lift a single ExplodableModel mounted across fragmented
  -> done so the model loads once, animates from its real original
  positions, and never re-instantiates between phases. Eliminates the
  position/rotation jumps and re-explosion that occurred when each
  step instantiated its own model.
- ExplodableModel: expose splitSpeed prop so the explode/reassemble
  lerp can be slowed down (REPAIR_FRAGMENT_SPLIT_SPEED = 1.8) for a
  more deliberate visual where each node is seen leaving its origin.
- RepairScanSequence: drop its own ExplodableModel, receive parts
  from the upstream shared instance. Logs the available part names
  when broken-part nodes can't be matched so config drift is visible.
- RepairReassemblyStep: reduced to the completion particles + a
  delayed onSettled callback. The collapse animation is now driven by
  the shared ExplodableModel switching split=false at the reassembling
  phase. After REPAIR_REASSEMBLY_HOLD_MS (1500ms) the upstream flow
  auto-advances to done.
- RepairEbikeRepairTrigger: new minimal interactable for the ebike
  repairing step. Replaces the heavier grabbable-parts UX (cercles,
  ranger pieces) with a single 'Changez le refroidisseur' prompt that
  advances directly to reassembling. Pylon/farm keep RepairRepairingStep.
- RepairCompletionStep: drop the duplicated RepairObjectModel; the
  shared ExplodableModel renders the repaired model at done.
- RepairGame ebike-done: play narrateur_ebikerepare and call
  completeMission on the audio's ended event (with REPAIR_DONE_DIALOGUE_FALLBACK_MS
  fallback). Hands off to pylon without a Validate button.
- EbikeRepairNarrator: drop the done entry; RepairGame owns it now so
  the audio's end event can drive the mission completion handoff.
- RepairGame: drop the window.ebikeParkedPosition livePosition logic.
  Ebike movement is disabled during the repair flow so the static zone
  position is the source of truth, fixing the floating-bike issue
  observed in TestMap.
This commit is contained in:
Tom Boullay
2026-06-03 06:21:29 +02:00
parent 9841b14388
commit 5a6596b755
8 changed files with 279 additions and 110 deletions
+7 -12
View File
@@ -1,8 +1,5 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { import { EBIKE_SCAN_HINT_DIALOGUE_ID } from "@/data/ebike/ebikeConfig";
EBIKE_REPAIRED_DIALOGUE_ID,
EBIKE_SCAN_HINT_DIALOGUE_ID,
} from "@/data/ebike/ebikeConfig";
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore"; import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { MissionStep } from "@/types/gameplay/repairMission"; import type { MissionStep } from "@/types/gameplay/repairMission";
@@ -12,14 +9,13 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue";
/** /**
* Plays narrator cues during the ebike repair game: * Plays narrator cues during the ebike repair game:
* - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..." * - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..."
* - `done` -> "Eeeet voilà! Il fonctionne comme une horloge!..."
* *
* The `narrateur_refroidisseur_diagnostic` line is no longer played * The `narrateur_refroidisseur_diagnostic` line is triggered by the
* here on the `repairing` step. It is now triggered by the scan * scan sequence itself when it lands on the refroidisseur node
* sequence itself when it lands on the refroidisseur node (configured * (configured via `RepairMissionPartConfig.voiceLineId` on the broken
* via `RepairMissionPartConfig.voiceLineId` on the broken part). That * part). The `narrateur_ebikerepare` line is triggered by RepairGame
* keeps the audio synchronized with the red broken-part highlight and * directly at the `done` step so its `ended` event can drive the
* gates the `scanning -> repairing` transition on the audio's `ended`. * mission completion handoff.
* *
* Each cue is one-shot per mission run; the played-set resets when the * Each cue is one-shot per mission run; the played-set resets when the
* mission state rolls back to `waiting` so debug-panel replays still * mission state rolls back to `waiting` so debug-panel replays still
@@ -32,7 +28,6 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue";
*/ */
const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = { const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = {
fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID, fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID,
done: EBIKE_REPAIRED_DIALOGUE_ID,
}; };
function stopAudio(audio: HTMLAudioElement | null): void { function stopAudio(audio: HTMLAudioElement | null): void {
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo"; import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
@@ -40,11 +39,12 @@ export function RepairCompletionStep({
onExitComplete={onComplete} onExitComplete={onComplete}
/> />
<RepairObjectModel {/*
label={config.label} The repaired model is now rendered by the shared ExplodableModel
modelPath={config.modelPath} in RepairGame (split=false at done) so a single instance covers
scale={config.modelScale ?? 1} the whole repair flow. Rendering RepairObjectModel here would
/> duplicate the model on top of the unified one.
*/}
{!isClosingCase ? ( {!isClosingCase ? (
<TriggerObject <TriggerObject
@@ -0,0 +1,34 @@
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairEbikeRepairTriggerProps {
onRepair: () => void;
}
const TRIGGER_POSITION: Vector3Tuple = [0, 1.4, 0];
/**
* Minimal interactable used for the ebike `repairing` step. Replaces
* the heavier RepairRepairingStep (grabbable parts + placeholder
* circles) with a single "Changez le refroidisseur" prompt. The
* collider is invisible — the player just walks up and presses E.
*/
export function RepairEbikeRepairTrigger({
onRepair,
}: RepairEbikeRepairTriggerProps): React.JSX.Element {
return (
<TriggerObject
position={TRIGGER_POSITION}
colliders="ball"
label="Changez le refroidisseur"
radius={REPAIR_INTERACTION_RADIUS}
onTrigger={onRepair}
>
<mesh>
<sphereGeometry args={[0.6, 16, 16]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
</TriggerObject>
);
}
+162 -42
View File
@@ -1,4 +1,4 @@
import { Suspense, useEffect, useMemo, useState } from "react"; import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { ExplodableModel } from "@/components/three/models/ExplodableModel"; import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel"; import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel";
@@ -7,6 +7,7 @@ import type {
RepairCasePlaceholder, RepairCasePlaceholder,
} from "@/components/three/gameplay/RepairCaseModel"; } from "@/components/three/gameplay/RepairCaseModel";
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep"; import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
import { RepairEbikeRepairTrigger } from "@/components/three/gameplay/RepairEbikeRepairTrigger";
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject"; import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase"; import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
import { BUBBLE_GROW_DURATION_SECONDS } from "@/components/three/gameplay/RepairFocusBubble"; import { BUBBLE_GROW_DURATION_SECONDS } from "@/components/three/gameplay/RepairFocusBubble";
@@ -14,11 +15,20 @@ import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairing
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep"; import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence"; import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig"; import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig"; import {
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS,
REPAIR_FRAGMENT_SPLIT_SPEED,
REPAIR_REASSEMBLY_HOLD_MS,
} from "@/data/gameplay/repairGameConfig";
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions"; import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
import { EBIKE_REPAIRED_DIALOGUE_ID } from "@/data/ebike/ebikeConfig";
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput"; import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep"; import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight"; import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
import { playDialogueById } from "@/utils/dialogues/playDialogue";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { import type {
MissionStep, MissionStep,
RepairMissionConfig, RepairMissionConfig,
@@ -28,6 +38,7 @@ import type {
import { useGameStore } from "@/managers/stores/useGameStore"; import { useGameStore } from "@/managers/stores/useGameStore";
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore"; import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
import { toVector3Scale } from "@/utils/three/scale"; import { toVector3Scale } from "@/utils/three/scale";
interface RepairGameProps extends Required< interface RepairGameProps extends Required<
@@ -55,6 +66,20 @@ function RepairMissionAssetPreloader({
return null; return null;
} }
const REPAIR_PHASES: readonly MissionStep[] = [
"fragmented",
"scanning",
"repairing",
"reassembling",
"done",
];
const SPLIT_PHASES: readonly MissionStep[] = [
"fragmented",
"scanning",
"repairing",
];
export function RepairGame({ export function RepairGame({
mission, mission,
position, position,
@@ -74,22 +99,22 @@ export function RepairGame({
const [scannedBrokenParts, setScannedBrokenParts] = useState< const [scannedBrokenParts, setScannedBrokenParts] = useState<
readonly RepairScannedBrokenPart[] readonly RepairScannedBrokenPart[]
>([]); >([]);
// For the ebike mission, use the bike's live parked world position once const [explodedParts, setExplodedParts] = useState<readonly ExplodedPart[]>(
// the repair flow leaves the waiting phase so the repair happens [],
// wherever the player parked the bike, not at the static zone anchor. );
// window.ebikeParkedPosition is set by Ebike when the player drops the // Position of the repair flow is the static zone position. Ebike
// bike and stays stable through the rest of the repair flow. // movement is disabled during the mission so we don't need to track
const livePosition = useMemo<Vector3Tuple>(() => { // window.ebikeParkedPosition: the bike, the case and the exploded
if (mission !== "ebike" || mainState !== mission) return position; // model all sit at the zone's anchor.
if (step === "waiting") return position;
const parked = window.ebikeParkedPosition;
if (!parked) return position;
return [parked[0], parked[1], parked[2]];
}, [mainState, mission, position, step]);
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
const snappedPosition = useTerrainSnappedPosition(livePosition); const snappedPosition = useTerrainSnappedPosition(position);
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(
step,
);
const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step);
const isRepairing = step === "repairing";
useRepairFragmentationInput({ useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation, enabled: mainState === mission && readyForFragmentation,
@@ -150,22 +175,19 @@ export function RepairGame({
}, [mainState, mission, setMissionStep, step]); }, [mainState, mission, setMissionStep, step]);
// fragmented -> scanning is now driven by `onSplitSettled` from the // fragmented -> scanning is now driven by `onSplitSettled` from the
// ExplodableModel below (fires once the lerp actually converges on // shared ExplodableModel below (fires once the lerp actually
// progress=1). The legacy REPAIR_FRAGMENTATION_SEQUENCE_SECONDS timer // converges on progress=1). The legacy
// is kept as a safety-net fallback in case the model fails to load // REPAIR_FRAGMENTATION_SEQUENCE_SECONDS timer is kept as a safety-net
// (no part anchors -> no settled event) so the flow can never get // fallback in case the model fails to load (no settled event) so the
// stuck on the fragmented step. // 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"); 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, (REPAIR_FRAGMENTATION_SEQUENCE_SECONDS + 2) * 1000,
); );
@@ -174,6 +196,95 @@ export function RepairGame({
}; };
}, [mainState, mission, setMissionStep, step]); }, [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.
useEffect(() => {
if (mainState !== mission) return undefined;
if (mission !== "ebike") return undefined;
if (step !== "done") return undefined;
let cancelled = false;
let activeAudio: HTMLAudioElement | null = null;
let fallbackTimeoutId: number | null = null;
const finish = (): void => {
if (cancelled) return;
cancelled = true;
completeMission(mission);
};
void (async () => {
const manifest = await loadDialogueManifest();
if (cancelled) return;
const audio = manifest
? await playDialogueById(manifest, EBIKE_REPAIRED_DIALOGUE_ID)
: null;
if (cancelled) {
if (audio && !audio.paused) {
audio.pause();
audio.currentTime = 0;
}
useSubtitleStore.getState().clearActiveSubtitle();
return;
}
activeAudio = audio;
if (audio) {
audio.addEventListener("ended", finish, { once: true });
fallbackTimeoutId = window.setTimeout(
finish,
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
);
} else {
fallbackTimeoutId = window.setTimeout(
finish,
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
);
}
})();
return () => {
cancelled = true;
if (activeAudio) {
activeAudio.removeEventListener("ended", finish);
if (!activeAudio.paused) {
activeAudio.pause();
activeAudio.currentTime = 0;
}
}
if (fallbackTimeoutId !== null) {
window.clearTimeout(fallbackTimeoutId);
}
useSubtitleStore.getState().clearActiveSubtitle();
};
}, [completeMission, mainState, mission, step]);
// The shared ExplodableModel resets its parts to a fresh array each
// time it remounts (i.e. when leaving the repair flow back to
// waiting/inspected). The cached `explodedParts` will be overwritten
// by `onPartsReady` on the next mount; we don't need an explicit
// reset because no rendered code path uses the stale parts outside
// the repair phases.
// Settled callback: drives event-based transitions out of the
// explode/reassemble lerp.
const stepRef = useRef(step);
useEffect(() => {
stepRef.current = step;
}, [step]);
const handleSplitSettled = useMemo(
() => (settledAt: 0 | 1) => {
const currentStep = stepRef.current;
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`.
},
[mission, setMissionStep],
);
if (mainState !== mission) return null; if (mainState !== mission) return null;
if (step === "locked") return null; if (step === "locked") return null;
@@ -190,37 +301,47 @@ export function RepairGame({
onInspect={() => setMissionStep(mission, "inspected")} onInspect={() => setMissionStep(mission, "inspected")}
/> />
) : null} ) : null}
{step === "fragmented" ? ( {/*
Single ExplodableModel mounted across the entire repair flow
(fragmented -> done) so the model loads once, animates from
its real original positions, never re-instantiates between
phases, and stays at a stable transform. `split` toggles drive
the explode/reassemble lerps in place.
*/}
{isRepairPhase ? (
<ExplodableModel <ExplodableModel
modelPath={config.modelPath} modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]} rotation={config.modelRotation ?? [0, 0, 0]}
scale={config.modelScale ?? 1} scale={config.modelScale ?? 1}
split split={isSplitPhase}
onSplitSettled={(settledAt) => { splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED}
if (settledAt === 1) setMissionStep(mission, "scanning"); onPartsReady={setExplodedParts}
}} onSplitSettled={handleSplitSettled}
{...(isRepairing
? {
hideNodeNames: brokenNodeNames,
nodeAnchorNames: brokenNodeNames,
onNodeAnchorsChange: setBrokenAnchors,
}
: {})}
/> />
) : null} ) : null}
{step === "scanning" ? ( {step === "scanning" ? (
<RepairScanSequence <RepairScanSequence
config={config} config={config}
parts={explodedParts}
onComplete={(brokenParts) => { onComplete={(brokenParts) => {
setScannedBrokenParts(brokenParts); setScannedBrokenParts(brokenParts);
setMissionStep(mission, "repairing"); setMissionStep(mission, "repairing");
}} }}
/> />
) : null} ) : null}
{step === "repairing" ? ( {step === "repairing" && mission === "ebike" ? (
<> <RepairEbikeRepairTrigger
<ExplodableModel onRepair={() => setMissionStep(mission, "reassembling")}
modelPath={config.modelPath}
rotation={config.modelRotation ?? [0, 0, 0]}
scale={config.modelScale ?? 1}
split
hideNodeNames={brokenNodeNames}
nodeAnchorNames={brokenNodeNames}
onNodeAnchorsChange={setBrokenAnchors}
/> />
) : null}
{step === "repairing" && mission !== "ebike" ? (
<RepairRepairingStep <RepairRepairingStep
anchors={caseAnchors} anchors={caseAnchors}
brokenAnchors={brokenAnchors} brokenAnchors={brokenAnchors}
@@ -229,15 +350,14 @@ export function RepairGame({
placeholders={casePlaceholders} placeholders={casePlaceholders}
onRepair={() => setMissionStep(mission, "reassembling")} onRepair={() => setMissionStep(mission, "reassembling")}
/> />
</>
) : null} ) : null}
{step === "reassembling" ? ( {step === "reassembling" ? (
<RepairReassemblyStep <RepairReassemblyStep
config={config} delayMs={REPAIR_REASSEMBLY_HOLD_MS}
onComplete={() => setMissionStep(mission, "done")} onSettled={() => setMissionStep(mission, "done")}
/> />
) : null} ) : null}
{step === "done" && mission !== "pylon" ? ( {step === "done" && mission !== "pylon" && mission !== "ebike" ? (
<RepairCompletionStep <RepairCompletionStep
config={config} config={config}
onComplete={() => completeMission(mission)} onComplete={() => completeMission(mission)}
@@ -1,45 +1,37 @@
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles"; import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
interface RepairReassemblyStepProps { interface RepairReassemblyStepProps {
config: RepairMissionConfig; onSettled?: () => void;
onComplete: () => void; delayMs?: number;
} }
/**
* Visual layer for the reassembly phase. The actual collapse animation
* (parts lerping back to their original positions) is driven by the
* shared ExplodableModel mounted upstream by RepairGame, which keeps a
* single instance alive across fragmented -> done so the model never
* reloads or jumps between phases.
*
* This component now only renders the completion particles and emits a
* settled signal after `delayMs` so the upstream flow can advance.
*/
export function RepairReassemblyStep({ export function RepairReassemblyStep({
config, onSettled,
onComplete, delayMs = 0,
}: RepairReassemblyStepProps): React.JSX.Element { }: RepairReassemblyStepProps): React.JSX.Element {
const [split, setSplit] = useState(true);
const reassemblySeconds =
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
useEffect(() => { useEffect(() => {
const closeTimeoutId = window.setTimeout(() => { if (!onSettled) return undefined;
setSplit(false); if (delayMs <= 0) {
}, 50); onSettled();
const completeTimeoutId = window.setTimeout(() => { return undefined;
onComplete(); }
}, reassemblySeconds * 1000);
const timeoutId = window.setTimeout(onSettled, delayMs);
return () => { return () => {
window.clearTimeout(closeTimeoutId); window.clearTimeout(timeoutId);
window.clearTimeout(completeTimeoutId);
}; };
}, [onComplete, reassemblySeconds]); }, [onSettled, delayMs]);
return ( return <RepairCompletionParticles />;
<group>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split={split}
splitDistance={1.2}
/>
<RepairCompletionParticles />
</group>
);
} }
@@ -2,7 +2,6 @@ import { useEffect, 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";
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual"; import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
import type { import type {
@@ -18,6 +17,14 @@ import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
interface RepairScanSequenceProps { interface RepairScanSequenceProps {
config: RepairMissionConfig; config: RepairMissionConfig;
/**
* Parts of the (already mounted) ExplodableModel managed upstream by
* RepairGame. The scan sequence drives its visuals against these
* parts so the model isn't re-instantiated when entering the scanning
* phase (which would cause the explosion animation to replay and the
* world transform to differ between phases).
*/
parts: readonly ExplodedPart[];
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void; onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
} }
@@ -30,9 +37,9 @@ const warnedMissingScanParts = new Set<string>();
export function RepairScanSequence({ export function RepairScanSequence({
config, config,
parts,
onComplete, onComplete,
}: RepairScanSequenceProps): React.JSX.Element { }: RepairScanSequenceProps): React.JSX.Element {
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
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;
@@ -145,12 +152,6 @@ export function RepairScanSequence({
return ( return (
<group> <group>
<ExplodableModel
modelPath={config.modelPath}
scale={config.modelScale ?? 1}
split
onPartsReady={setParts}
/>
<RepairScanVisual target={activePart?.object} /> <RepairScanVisual target={activePart?.object} />
{visibleBrokenPartMatches.map((match) => { {visibleBrokenPartMatches.map((match) => {
const part = parts[match.partIndex]; const part = parts[match.partIndex];
@@ -218,6 +219,7 @@ function getBrokenPartMatches(
logger.warn("RepairScan", "Broken parts missing from exploded model", { logger.warn("RepairScan", "Broken parts missing from exploded model", {
missionId: config.id, missionId: config.id,
missingIds, missingIds,
availablePartNames: parts.map((part) => part.object.name),
}); });
} }
} }
@@ -71,6 +71,11 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string; modelPath: string;
split: boolean; split: boolean;
splitDistance?: number; splitDistance?: number;
/**
* Lerp speed for the explode/reassemble animation. Lower = slower.
* Defaults to ExplodedModel's internal default (6) when omitted.
*/
splitSpeed?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void; onPartsReady?: (parts: readonly ExplodedPart[]) => void;
/** /**
* Fired once each time the explode/reassemble lerp converges on its * Fired once each time the explode/reassemble lerp converges on its
@@ -106,6 +111,7 @@ function ExplodableModelInner({
rotation = [0, 0, 0], rotation = [0, 0, 0],
scale = 1, scale = 1,
splitDistance = 1.2, splitDistance = 1.2,
splitSpeed,
onPartsReady, onPartsReady,
onSplitSettled, onSplitSettled,
hideNodeNames, hideNodeNames,
@@ -138,9 +144,10 @@ function ExplodableModelInner({
// eslint-disable-next-line react-hooks/refs // eslint-disable-next-line react-hooks/refs
new ExplodedModel(model, { new ExplodedModel(model, {
distance: splitDistance, distance: splitDistance,
...(splitSpeed !== undefined ? { speed: splitSpeed } : {}),
onSettled: handleSettled, onSettled: handleSettled,
}), }),
[model, splitDistance, handleSettled], [model, splitDistance, splitSpeed, handleSettled],
); );
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
const anchorSignatureRef = useRef(""); const anchorSignatureRef = useRef("");
+19
View File
@@ -3,3 +3,22 @@ export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
export const REPAIR_INTERACTION_RADIUS = 10; export const REPAIR_INTERACTION_RADIUS = 10;
export const REPAIR_SCAN_PART_SECONDS = 1.2; export const REPAIR_SCAN_PART_SECONDS = 1.2;
export const REPAIR_REASSEMBLY_SECONDS = 1.4; export const REPAIR_REASSEMBLY_SECONDS = 1.4;
/**
* Lerp speed used by the shared ExplodableModel during the repair flow.
* Lower = slower, more deliberate explosion so the player can see each
* node clearly leave its original position. The default ExplodedModel
* speed (6) finishes in ~0.5s which feels rushed.
*/
export const REPAIR_FRAGMENT_SPLIT_SPEED = 1.8;
/**
* Delay between the end of the inverse-explosion (parts settled back to
* their original positions) and the auto-transition to the `done` step.
* Used by the ebike repair flow so the reassembly particles can play
* before the bubble starts shrinking.
*/
export const REPAIR_REASSEMBLY_HOLD_MS = 1500;
/**
* Fallback timer for the ebike `done` -> mission-complete transition
* when the narrator audio fails to fire its `ended` event.
*/
export const REPAIR_DONE_DIALOGUE_FALLBACK_MS = 6000;