fix(repair-ebike): stop subtitle leak and fake cooling swap
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
📊 Quality / 🔒 Security Audit (push) Has been cancelled
📊 Quality / 📋 Dependency Freshness (push) Has been cancelled
📊 Quality / 📦 Bundle Size (push) Has been cancelled
🔍 Lint / 🏗 Build (push) Has been cancelled

This commit is contained in:
Tom Boullay
2026-06-03 06:47:10 +02:00
parent 8d66391fa9
commit 08c10acd48
6 changed files with 114 additions and 23 deletions
@@ -1,34 +1,85 @@
import { useState } from "react";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig"; import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "@/types/three/three";
interface RepairEbikeRepairTriggerProps { interface RepairEbikeRepairTriggerProps {
anchor: Vector3Tuple;
onRepair: () => void; 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 * Ebike-specific fake replacement flow: the broken radiator node is
* the heavier RepairRepairingStep (grabbable parts + placeholder * hidden in the shared ExplodableModel, a grabbable copy appears at the
* circles) with a single "Changez le refroidisseur" prompt. The * same anchor, then pressing E respawns a fresh part with a halo before
* collider is invisible — the player just walks up and presses E. * the reassembly step starts.
*/ */
export function RepairEbikeRepairTrigger({ export function RepairEbikeRepairTrigger({
anchor,
onRepair, onRepair,
}: RepairEbikeRepairTriggerProps): React.JSX.Element { }: RepairEbikeRepairTriggerProps): React.JSX.Element {
const [isInstalled, setIsInstalled] = useState(false);
function handleRepair(): void {
if (isInstalled) return;
setIsInstalled(true);
window.setTimeout(onRepair, 450);
}
return ( return (
<group>
{!isInstalled ? (
<GrabbableObject
position={anchor}
colliders="ball"
handControlled
label="Retirer le refroidisseur"
>
<RepairObjectModel
label="Refroidisseur"
modelPath={REPLACEMENT_MODEL_PATH}
scale={0.24}
/>
</GrabbableObject>
) : (
<group position={anchor}>
<RepairObjectModel
label="Refroidisseur"
modelPath={REPLACEMENT_MODEL_PATH}
scale={0.24}
/>
<mesh>
<sphereGeometry args={[0.65, 32, 16]} />
<meshBasicMaterial color="#22c55e" transparent opacity={0.18} />
</mesh>
<mesh rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.72, 0.025, 8, 96]} />
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
</mesh>
</group>
)}
<TriggerObject <TriggerObject
position={TRIGGER_POSITION} position={[
anchor[0] + TRIGGER_OFFSET[0],
anchor[1] + TRIGGER_OFFSET[1],
anchor[2] + TRIGGER_OFFSET[2],
]}
colliders="ball" colliders="ball"
label="Changez le refroidisseur" label="Changez le refroidisseur"
radius={REPAIR_INTERACTION_RADIUS} radius={REPAIR_INTERACTION_RADIUS}
onTrigger={onRepair} onTrigger={handleRepair}
> >
<mesh> <mesh>
<sphereGeometry args={[0.6, 16, 16]} /> <sphereGeometry args={[0.55, 16, 16]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} /> <meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh> </mesh>
</TriggerObject> </TriggerObject>
</group>
); );
} }
+30 -3
View File
@@ -16,6 +16,7 @@ import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemb
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 { import {
REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS,
REPAIR_DONE_DIALOGUE_FALLBACK_MS, REPAIR_DONE_DIALOGUE_FALLBACK_MS,
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS,
REPAIR_FRAGMENT_SPLIT_SPEED, REPAIR_FRAGMENT_SPLIT_SPEED,
@@ -27,7 +28,11 @@ import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmenta
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 { 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 { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
import type { import type {
MissionStep, MissionStep,
@@ -128,6 +133,17 @@ export function RepairGame({
); );
const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step); const isSplitPhase = (SPLIT_PHASES as readonly MissionStep[]).includes(step);
const isRepairing = step === "repairing"; 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({ useRepairFragmentationInput({
enabled: mainState === mission && readyForFragmentation, enabled: mainState === mission && readyForFragmentation,
@@ -150,6 +166,15 @@ export function RepairGame({
}; };
}, [mainState, mission, step]); }, [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 // Drive the global focus bubble: active during the immersive repair
// phases so the world dims/hides outside the dark sphere shroud. // phases so the world dims/hides outside the dark sphere shroud.
const focusCenterX = snappedPosition[0]; const focusCenterX = snappedPosition[0];
@@ -355,6 +380,7 @@ export function RepairGame({
scale={config.modelScale ?? 1} scale={config.modelScale ?? 1}
split={isSplitPhase} split={isSplitPhase}
splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED} splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED}
splitDurationSeconds={REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS}
onPartsReady={setExplodedParts} onPartsReady={setExplodedParts}
onSplitSettled={handleSplitSettled} onSplitSettled={handleSplitSettled}
{...(isRepairing {...(isRepairing
@@ -378,6 +404,7 @@ export function RepairGame({
) : null} ) : null}
{step === "repairing" && mission === "ebike" ? ( {step === "repairing" && mission === "ebike" ? (
<RepairEbikeRepairTrigger <RepairEbikeRepairTrigger
anchor={ebikeBrokenLocalAnchor}
onRepair={() => setMissionStep(mission, "reassembling")} onRepair={() => setMissionStep(mission, "reassembling")}
/> />
) : null} ) : null}
@@ -409,8 +436,8 @@ export function RepairGame({
config={config} config={config}
onPlaceholdersChange={setCasePlaceholders} onPlaceholdersChange={setCasePlaceholders}
onAnchorsChange={setCaseAnchors} onAnchorsChange={setCaseAnchors}
open={step === "repairing"} open={mission !== "ebike" && step === "repairing"}
zoomed={step === "repairing"} zoomed={mission !== "ebike" && step === "repairing"}
showFragmentationPrompt={ showFragmentationPrompt={
readyForFragmentation && mission !== "ebike" readyForFragmentation && mission !== "ebike"
} }
@@ -76,6 +76,7 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
* Defaults to ExplodedModel's internal default (6) when omitted. * Defaults to ExplodedModel's internal default (6) when omitted.
*/ */
splitSpeed?: number; splitSpeed?: number;
splitDurationSeconds?: 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
@@ -112,6 +113,7 @@ function ExplodableModelInner({
scale = 1, scale = 1,
splitDistance = 1.2, splitDistance = 1.2,
splitSpeed, splitSpeed,
splitDurationSeconds,
onPartsReady, onPartsReady,
onSplitSettled, onSplitSettled,
hideNodeNames, hideNodeNames,
@@ -144,10 +146,13 @@ 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,
...(splitDurationSeconds !== undefined
? { durationSeconds: splitDurationSeconds }
: {}),
...(splitSpeed !== undefined ? { speed: splitSpeed } : {}), ...(splitSpeed !== undefined ? { speed: splitSpeed } : {}),
onSettled: handleSettled, onSettled: handleSettled,
}), }),
[model, splitDistance, splitSpeed, handleSettled], [model, splitDistance, splitDurationSeconds, splitSpeed, handleSettled],
); );
const parsedScale = toVector3Scale(scale); const parsedScale = toVector3Scale(scale);
const anchorSignatureRef = useRef(""); const anchorSignatureRef = useRef("");
+1
View File
@@ -10,6 +10,7 @@ export const REPAIR_REASSEMBLY_SECONDS = 1.4;
* speed (6) finishes in ~0.5s which feels rushed. * speed (6) finishes in ~0.5s which feels rushed.
*/ */
export const REPAIR_FRAGMENT_SPLIT_SPEED = 1.8; 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 * Delay between the end of the inverse-explosion (parts settled back to
* their original positions) and the auto-transition to the `done` step. * their original positions) and the auto-transition to the `done` step.
+2 -2
View File
@@ -38,7 +38,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
label: "Cooling core", label: "Cooling core",
modelPath: "/models/refroidisseur/model.gltf", modelPath: "/models/refroidisseur/model.gltf",
nodeName: "Radiateur", nodeName: "Radiateur",
targetNodeName: "refroidisseur", targetNodeName: "Radiateur",
caseSlotName: "placeholder_1", caseSlotName: "placeholder_1",
// Plays during the scan landing on the refroidisseur node; // Plays during the scan landing on the refroidisseur node;
// the scan sequence advances on this audio's `ended` event. // the scan sequence advances on this audio's `ended` event.
@@ -51,7 +51,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
label: "Refroidisseur", label: "Refroidisseur",
modelPath: "/models/refroidisseur/model.gltf", modelPath: "/models/refroidisseur/model.gltf",
caseAnchor: "refroidisseur", caseAnchor: "refroidisseur",
targetNodeName: "refroidisseur", targetNodeName: "Radiateur",
}, },
{ {
id: "ebike-cable-right-distractor", id: "ebike-cable-right-distractor",
+7
View File
@@ -9,6 +9,7 @@ export interface ExplodedPart {
interface ExplodedModelOptions { interface ExplodedModelOptions {
distance?: number; distance?: number;
speed?: number; speed?: number;
durationSeconds?: number;
/** /**
* Fired exactly once each time the lerp converges on a target value * Fired exactly once each time the lerp converges on a target value
* (1 = fully exploded, 0 = fully reassembled). Useful for chaining * (1 = fully exploded, 0 = fully reassembled). Useful for chaining
@@ -25,6 +26,7 @@ export class ExplodedModel {
private readonly parts: ExplodedPart[] = []; private readonly parts: ExplodedPart[] = [];
private readonly distance: number; private readonly distance: number;
private readonly speed: number; private readonly speed: number;
private readonly durationSeconds: number | undefined;
private readonly onSettled?: (settledAt: 0 | 1) => void; private readonly onSettled?: (settledAt: 0 | 1) => void;
private progress = 0; private progress = 0;
private targetProgress = 0; private targetProgress = 0;
@@ -33,6 +35,7 @@ export class ExplodedModel {
constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) { constructor(model: THREE.Object3D, options: ExplodedModelOptions = {}) {
this.distance = options.distance ?? 1.2; this.distance = options.distance ?? 1.2;
this.speed = options.speed ?? 6; this.speed = options.speed ?? 6;
this.durationSeconds = options.durationSeconds;
if (options.onSettled) this.onSettled = options.onSettled; if (options.onSettled) this.onSettled = options.onSettled;
this.parts = this.createParts(model); this.parts = this.createParts(model);
} }
@@ -57,6 +60,10 @@ export class ExplodedModel {
this.settledAtTarget = true; this.settledAtTarget = true;
this.onSettled?.(this.targetProgress === 1 ? 1 : 0); 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 { } else {
this.progress += diff * Math.min(delta * this.speed, 1); this.progress += diff * Math.min(delta * this.speed, 1);
} }