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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user