From a0482aa04bd4b2ce37bc0d452179ea65dedf5eb6 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 07:00:16 +0200 Subject: [PATCH] fix(repair-ebike): freeze repair transform and case-driven cooling swap --- .../gameplay/RepairEbikeRepairTrigger.tsx | 40 ++-------- src/components/three/gameplay/RepairGame.tsx | 79 +++++++++++++++++-- .../three/gameplay/RepairMissionCase.tsx | 4 +- .../three/interaction/GrabbableObject.tsx | 9 ++- 4 files changed, 89 insertions(+), 43 deletions(-) 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); }}