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
🔍 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:
@@ -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 (
|
||||||
<TriggerObject
|
<group>
|
||||||
position={TRIGGER_POSITION}
|
{!isInstalled ? (
|
||||||
colliders="ball"
|
<GrabbableObject
|
||||||
label="Changez le refroidisseur"
|
position={anchor}
|
||||||
radius={REPAIR_INTERACTION_RADIUS}
|
colliders="ball"
|
||||||
onTrigger={onRepair}
|
handControlled
|
||||||
>
|
label="Retirer le refroidisseur"
|
||||||
<mesh>
|
>
|
||||||
<sphereGeometry args={[0.6, 16, 16]} />
|
<RepairObjectModel
|
||||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
label="Refroidisseur"
|
||||||
</mesh>
|
modelPath={REPLACEMENT_MODEL_PATH}
|
||||||
</TriggerObject>
|
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
|
||||||
|
position={[
|
||||||
|
anchor[0] + TRIGGER_OFFSET[0],
|
||||||
|
anchor[1] + TRIGGER_OFFSET[1],
|
||||||
|
anchor[2] + TRIGGER_OFFSET[2],
|
||||||
|
]}
|
||||||
|
colliders="ball"
|
||||||
|
label="Changez le refroidisseur"
|
||||||
|
radius={REPAIR_INTERACTION_RADIUS}
|
||||||
|
onTrigger={handleRepair}
|
||||||
|
>
|
||||||
|
<mesh>
|
||||||
|
<sphereGeometry args={[0.55, 16, 16]} />
|
||||||
|
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||||
|
</mesh>
|
||||||
|
</TriggerObject>
|
||||||
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user