diff --git a/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx b/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
index 03e8c1b..038becf 100644
--- a/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
+++ b/src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
@@ -1,43 +1,32 @@
-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;
+ installed: boolean;
}
const REPLACEMENT_MODEL_PATH = "/models/refroidisseur/model.gltf";
-const TRIGGER_OFFSET: Vector3Tuple = [0, 0.9, 0];
/**
* 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.
+ * same anchor, then RepairGame/RepairMissionCase controls the install
+ * interaction and this component swaps the copy for a fresh glowing part.
*/
export function RepairEbikeRepairTrigger({
anchor,
- onRepair,
+ installed,
}: RepairEbikeRepairTriggerProps): React.JSX.Element {
- const [isInstalled, setIsInstalled] = useState(false);
-
- function handleRepair(): void {
- if (isInstalled) return;
- setIsInstalled(true);
- window.setTimeout(onRepair, 450);
- }
-
return (
- {!isInstalled ? (
+ {!installed ? (
)}
-
-
-
-
-
-
-
);
}
diff --git a/src/components/three/gameplay/RepairGame.tsx b/src/components/three/gameplay/RepairGame.tsx
index 4d5ece5..0f79ca7 100644
--- a/src/components/three/gameplay/RepairGame.tsx
+++ b/src/components/three/gameplay/RepairGame.tsx
@@ -58,6 +58,11 @@ interface RepairMissionAssetPreloaderProps {
config: RepairMissionConfig;
}
+interface EbikeRepairTransform {
+ position: Vector3Tuple;
+ rotationY: number;
+}
+
function RepairMissionAssetPreloader({
config,
}: RepairMissionAssetPreloaderProps): null {
@@ -107,6 +112,9 @@ export function RepairGame({
const [explodedParts, setExplodedParts] = useState(
[],
);
+ const [ebikeRepairTransform, setEbikeRepairTransform] =
+ useState(null);
+ const [ebikeCoolingInstalled, setEbikeCoolingInstalled] = useState(false);
const reassemblyDoneTimeoutRef = useRef(null);
// Ebike-specific: once the repair starts, keep the entire repair flow
// exactly where the bike currently is. `Ebike` owns the live parked
@@ -115,11 +123,13 @@ export function RepairGame({
const livePosition = useMemo(() => {
if (mission !== "ebike" || step === "waiting") return position;
+ if (ebikeRepairTransform) return ebikeRepairTransform.position;
+
const parked = window.ebikeParkedPosition;
if (!parked) return position;
return [parked[0], parked[1], parked[2]];
- }, [mission, position, step]);
+ }, [ebikeRepairTransform, mission, position, step]);
const usesLiveEbikePosition = mission === "ebike" && step !== "waiting";
const parsedScale = toVector3Scale(scale);
const terrainSnappedPosition = useTerrainSnappedPosition(livePosition);
@@ -133,6 +143,10 @@ export function RepairGame({
);
const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step);
const isRepairing = step === "repairing";
+ const repairModelRotation: Vector3Tuple =
+ mission === "ebike" && ebikeRepairTransform
+ ? [0, ebikeRepairTransform.rotationY, 0]
+ : (config.modelRotation ?? [0, 0, 0]);
const ebikeBrokenNodeName = config.brokenParts[0]?.targetNodeName;
const ebikeBrokenWorldAnchor = ebikeBrokenNodeName
? brokenAnchors[ebikeBrokenNodeName]
@@ -159,6 +173,7 @@ export function RepairGame({
setCaseAnchors({});
setBrokenAnchors({});
setScannedBrokenParts([]);
+ setEbikeCoolingInstalled(false);
}, 0);
return () => {
@@ -166,6 +181,45 @@ export function RepairGame({
};
}, [mainState, mission, step]);
+ useEffect(() => {
+ if (mission !== "ebike") return undefined;
+
+ if (mainState !== "ebike" || step === "waiting") {
+ const timeoutId = window.setTimeout(() => {
+ setEbikeRepairTransform(null);
+ setEbikeCoolingInstalled(false);
+ }, 0);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }
+
+ if (ebikeRepairTransform) return undefined;
+
+ const parked = window.ebikeParkedPosition;
+ const rotationY =
+ window.ebikeParkedRotation ?? config.modelRotation?.[1] ?? 0;
+ const snapshot: EbikeRepairTransform = {
+ position: parked ? [parked[0], parked[1], parked[2]] : position,
+ rotationY,
+ };
+ const timeoutId = window.setTimeout(() => {
+ setEbikeRepairTransform(snapshot);
+ }, 0);
+
+ return () => {
+ window.clearTimeout(timeoutId);
+ };
+ }, [
+ config.modelRotation,
+ ebikeRepairTransform,
+ mainState,
+ mission,
+ position,
+ step,
+ ]);
+
useEffect(() => {
if (mission !== "ebike") return;
if (mainState === "ebike") return;
@@ -350,6 +404,14 @@ export function RepairGame({
};
}, []);
+ function handleEbikeCoolingInstall(): void {
+ if (ebikeCoolingInstalled) return;
+ setEbikeCoolingInstalled(true);
+ window.setTimeout(() => {
+ setMissionStep(mission, "reassembling");
+ }, 450);
+ }
+
if (mainState !== mission) return null;
if (step === "locked") return null;
@@ -376,7 +438,7 @@ export function RepairGame({
{isRepairPhase ? (
setMissionStep(mission, "reassembling")}
+ installed={ebikeCoolingInstalled}
/>
) : null}
{step === "repairing" && mission !== "ebike" ? (
@@ -441,10 +503,15 @@ export function RepairGame({
showFragmentationPrompt={
readyForFragmentation && mission !== "ebike"
}
+ {...(mission === "ebike" && step === "repairing"
+ ? { interactLabel: "Changez le refroidisseur" }
+ : {})}
onInteract={
- readyForFragmentation && mission !== "ebike"
- ? () => setMissionStep(mission, "fragmented")
- : undefined
+ mission === "ebike" && step === "repairing"
+ ? handleEbikeCoolingInstall
+ : readyForFragmentation && mission !== "ebike"
+ ? () => setMissionStep(mission, "fragmented")
+ : undefined
}
/>
) : null}
diff --git a/src/components/three/gameplay/RepairMissionCase.tsx b/src/components/three/gameplay/RepairMissionCase.tsx
index ed3a52e..649af10 100644
--- a/src/components/three/gameplay/RepairMissionCase.tsx
+++ b/src/components/three/gameplay/RepairMissionCase.tsx
@@ -25,6 +25,7 @@ interface RepairMissionCaseProps {
open?: boolean;
zoomed?: boolean;
showFragmentationPrompt?: boolean;
+ interactLabel?: string;
onInteract?: (() => void) | undefined;
}
@@ -37,6 +38,7 @@ export function RepairMissionCase({
open = false,
zoomed = false,
showFragmentationPrompt = false,
+ interactLabel,
onInteract,
}: RepairMissionCaseProps): React.JSX.Element {
const casePosition = zoomed
@@ -51,7 +53,7 @@ export function RepairMissionCase({
diff --git a/src/components/three/interaction/GrabbableObject.tsx b/src/components/three/interaction/GrabbableObject.tsx
index f0cc054..ec33d0f 100644
--- a/src/components/three/interaction/GrabbableObject.tsx
+++ b/src/components/three/interaction/GrabbableObject.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
@@ -35,6 +35,7 @@ interface GrabbableObjectProps {
label?: string;
handControlled?: boolean;
disabled?: boolean;
+ lockUntilGrab?: boolean;
onGrabChange?: (held: boolean) => void;
onPositionChange?: (position: THREE.Vector3) => void;
onSnap?: (position: THREE.Vector3) => void;
@@ -134,6 +135,7 @@ export function GrabbableObject({
label = GRAB_DEFAULT_LABEL,
handControlled = false,
disabled = false,
+ lockUntilGrab = false,
onGrabChange,
onPositionChange,
onSnap,
@@ -148,6 +150,7 @@ export function GrabbableObject({
const rbRef = useRef(null);
const isHolding = useRef(false);
const isHandHolding = useRef(false);
+ const [hasBeenGrabbed, setHasBeenGrabbed] = useState(false);
const snapTween = useRef(null);
useEffect(() => {
@@ -288,6 +291,7 @@ export function GrabbableObject({
const hadHit = Boolean(hit);
if (hadHit) {
+ setHasBeenGrabbed(true);
isHandHolding.current = true;
InteractionManager.getInstance().setHandHolding(true);
onGrabChange?.(true);
@@ -330,7 +334,7 @@ export function GrabbableObject({
@@ -344,6 +348,7 @@ export function GrabbableObject({
position={position}
bodyRef={rbRef}
onPress={() => {
+ setHasBeenGrabbed(true);
isHolding.current = true;
onGrabChange?.(true);
}}