diff --git a/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx b/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
index 5862afd..03e8c1b 100644
--- a/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
+++ b/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
@@ -1,34 +1,85 @@
+import { useState } from "react";
+import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
+import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { Vector3Tuple } from "@/types/three/three";
interface RepairEbikeRepairTriggerProps {
+ anchor: Vector3Tuple;
onRepair: () => void;
}
-const TRIGGER_POSITION: Vector3Tuple = [0, 1.4, 0];
+const REPLACEMENT_MODEL_PATH = "/models/refroidisseur/model.gltf";
+const TRIGGER_OFFSET: Vector3Tuple = [0, 0.9, 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.
+ * Ebike-specific fake replacement flow: the broken radiator node is
+ * hidden in the shared ExplodableModel, a grabbable copy appears at the
+ * same anchor, then pressing E respawns a fresh part with a halo before
+ * the reassembly step starts.
*/
export function RepairEbikeRepairTrigger({
+ anchor,
onRepair,
}: RepairEbikeRepairTriggerProps): React.JSX.Element {
+ const [isInstalled, setIsInstalled] = useState(false);
+
+ function handleRepair(): void {
+ if (isInstalled) return;
+ setIsInstalled(true);
+ window.setTimeout(onRepair, 450);
+ }
+
return (
-
-
-
-
-
-
+
+ {!isInstalled ? (
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+
+
+
+
);
}
diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx
index aac32bc..4d5ece5 100644
--- a/src/components/three/gameplay/RepairGame.tsx
+++ b/src/components/three/gameplay/RepairGame.tsx
@@ -16,6 +16,7 @@ import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemb
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
import {
+ REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS,
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS,
REPAIR_FRAGMENT_SPLIT_SPEED,
@@ -27,7 +28,11 @@ import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmenta
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
-import { playDialogueById } from "@/utils/dialogues/playDialogue";
+import {
+ clearQueuedDialogues,
+ playDialogueById,
+ stopCurrentDialogue,
+} from "@/utils/dialogues/playDialogue";
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type {
MissionStep,
@@ -128,6 +133,17 @@ export function RepairGame({
);
const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step);
const isRepairing = step === "repairing";
+ const ebikeBrokenNodeName = config.brokenParts[0]?.targetNodeName;
+ const ebikeBrokenWorldAnchor = ebikeBrokenNodeName
+ ? brokenAnchors[ebikeBrokenNodeName]
+ : undefined;
+ const ebikeBrokenLocalAnchor = ebikeBrokenWorldAnchor
+ ? ([
+ ebikeBrokenWorldAnchor[0] - snappedPosition[0],
+ ebikeBrokenWorldAnchor[1] - snappedPosition[1],
+ ebikeBrokenWorldAnchor[2] - snappedPosition[2],
+ ] satisfies Vector3Tuple)
+ : ([0, 1, 0] satisfies Vector3Tuple);
useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation,
@@ -150,6 +166,15 @@ export function RepairGame({
};
}, [mainState, mission, step]);
+ useEffect(() => {
+ if (mission !== "ebike") return;
+ if (mainState === "ebike") return;
+
+ clearQueuedDialogues();
+ stopCurrentDialogue();
+ useSubtitleStore.getState().clearActiveSubtitle();
+ }, [mainState, mission]);
+
// Drive the global focus bubble: active during the immersive repair
// phases so the world dims/hides outside the dark sphere shroud.
const focusCenterX = snappedPosition[0];
@@ -355,6 +380,7 @@ export function RepairGame({
scale={config.modelScale ?? 1}
split={isSplitPhase}
splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED}
+ splitDurationSeconds={REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS}
onPartsReady={setExplodedParts}
onSplitSettled={handleSplitSettled}
{...(isRepairing
@@ -378,6 +404,7 @@ export function RepairGame({
) : null}
{step === "repairing" && mission === "ebike" ? (
setMissionStep(mission, "reassembling")}
/>
) : null}
@@ -409,8 +436,8 @@ export function RepairGame({
config={config}
onPlaceholdersChange={setCasePlaceholders}
onAnchorsChange={setCaseAnchors}
- open={step === "repairing"}
- zoomed={step === "repairing"}
+ open={mission !== "ebike" && step === "repairing"}
+ zoomed={mission !== "ebike" && step === "repairing"}
showFragmentationPrompt={
readyForFragmentation && mission !== "ebike"
}
diff --git a/src/components/three/models/ExplodableModel.tsx b/src/components/three/models/ExplodableModel.tsx
index 37e0def..5aab223 100644
--- a/src/components/three/models/ExplodableModel.tsx
+++ b/src/components/three/models/ExplodableModel.tsx
@@ -76,6 +76,7 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
* Defaults to ExplodedModel's internal default (6) when omitted.
*/
splitSpeed?: number;
+ splitDurationSeconds?: number;
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
/**
* Fired once each time the explode/reassemble lerp converges on its
@@ -112,6 +113,7 @@ function ExplodableModelInner({
scale = 1,
splitDistance = 1.2,
splitSpeed,
+ splitDurationSeconds,
onPartsReady,
onSplitSettled,
hideNodeNames,
@@ -144,10 +146,13 @@ function ExplodableModelInner({
// eslint-disable-next-line react-hooks/refs
new ExplodedModel(model, {
distance: splitDistance,
+ ...(splitDurationSeconds !== undefined
+ ? { durationSeconds: splitDurationSeconds }
+ : {}),
...(splitSpeed !== undefined ? { speed: splitSpeed } : {}),
onSettled: handleSettled,
}),
- [model, splitDistance, splitSpeed, handleSettled],
+ [model, splitDistance, splitDurationSeconds, splitSpeed, handleSettled],
);
const parsedScale = toVector3Scale(scale);
const anchorSignatureRef = useRef("");
diff --git a/src/data/gameplay/repairGameConfig.ts b/src/data/gameplay/repairGameConfig.ts
index e6251f2..53c3ac8 100644
--- a/src/data/gameplay/repairGameConfig.ts
+++ b/src/data/gameplay/repairGameConfig.ts
@@ -10,6 +10,7 @@ export const REPAIR_REASSEMBLY_SECONDS = 1.4;
* speed (6) finishes in ~0.5s which feels rushed.
*/
export const REPAIR_FRAGMENT_SPLIT_SPEED = 1.8;
+export const REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS = 1.5;
/**
* Delay between the end of the inverse-explosion (parts settled back to
* their original positions) and the auto-transition to the `done` step.
diff --git a/src/data/gameplay/repairMissions.ts b/src/data/gameplay/repairMissions.ts
index 4a350fd..2cdb8df 100644
--- a/src/data/gameplay/repairMissions.ts
+++ b/src/data/gameplay/repairMissions.ts
@@ -38,7 +38,7 @@ export const REPAIR_MISSIONS: Record = {
label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf",
nodeName: "Radiateur",
- targetNodeName: "refroidisseur",
+ targetNodeName: "Radiateur",
caseSlotName: "placeholder_1",
// Plays during the scan landing on the refroidisseur node;
// the scan sequence advances on this audio's `ended` event.
@@ -51,7 +51,7 @@ export const REPAIR_MISSIONS: Record = {
label: "Refroidisseur",
modelPath: "/models/refroidisseur/model.gltf",
caseAnchor: "refroidisseur",
- targetNodeName: "refroidisseur",
+ targetNodeName: "Radiateur",
},
{
id: "ebike-cable-right-distractor",
diff --git a/src/utils/three/ExplodedModel.ts b/src/utils/three/ExplodedModel.ts
index 3de379b..722a12f 100644
--- a/src/utils/three/ExplodedModel.ts
+++ b/src/utils/three/ExplodedModel.ts
@@ -9,6 +9,7 @@ export interface ExplodedPart {
interface ExplodedModelOptions {
distance?: number;
speed?: number;
+ durationSeconds?: number;
/**
* Fired exactly once each time the lerp converges on a target value
* (1 = fully exploded, 0 = fully reassembled). Useful for chaining
@@ -25,6 +26,7 @@ export class ExplodedModel {
private readonly parts: ExplodedPart[] = [];
private readonly distance: number;
private readonly speed: number;
+ private readonly durationSeconds: number | undefined;
private readonly onSettled?: (settledAt: 0 | 1) => void;
private progress = 0;
private targetProgress = 0;
@@ -33,6 +35,7 @@ export class ExplodedModel {
constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) {
this.distance = options.distance ?? 1.2;
this.speed = options.speed ?? 6;
+ this.durationSeconds = options.durationSeconds;
if (options.onSettled) this.onSettled = options.onSettled;
this.parts = this.createParts(model);
}
@@ -57,6 +60,10 @@ export class ExplodedModel {
this.settledAtTarget = true;
this.onSettled?.(this.targetProgress === 1 ? 1 : 0);
}
+ } else if (this.durationSeconds !== undefined) {
+ const direction = diff > 0 ? 1 : -1;
+ this.progress += direction * (delta / this.durationSeconds);
+ this.progress = THREE.MathUtils.clamp(this.progress, 0, 1);
} else {
this.progress += diff * Math.min(delta * this.speed, 1);
}