From 08c10acd486c19c0d2af07fe3172e52940202f19 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 3 Jun 2026 06:47:10 +0200 Subject: [PATCH] fix(repair-ebike): stop subtitle leak and fake cooling swap --- .../gameplay/RepairEbikeRepairTrigger.tsx | 85 +++++++++++++++---- src/components/three/gameplay/RepairGame.tsx | 33 ++++++- .../three/models/ExplodableModel.tsx | 7 +- src/data/gameplay/repairGameConfig.ts | 1 + src/data/gameplay/repairMissions.ts | 4 +- src/utils/three/ExplodedModel.ts | 7 ++ 6 files changed, 114 insertions(+), 23 deletions(-) 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); }