Compare commits
9 Commits
7bcbba4eb1
...
08c10acd48
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c10acd48 | |||
| 8d66391fa9 | |||
| 0ab5380b1e | |||
| 5a6596b755 | |||
| 9841b14388 | |||
| 317db48bcc | |||
| fe30596a5a | |||
| acdcb5515b | |||
| 5ad2e27a89 |
@@ -163,23 +163,20 @@ Both paths move to `fragmented`.
|
||||
|
||||
### Fragmented
|
||||
|
||||
File:
|
||||
Files:
|
||||
|
||||
```txt
|
||||
src/components/three/models/ExplodableModel.tsx
|
||||
src/utils/three/ExplodedModel.ts
|
||||
```
|
||||
|
||||
The mission object is shown split apart. A timer then moves the mission to `scanning`.
|
||||
The mission object is shown split apart. `RepairGame` mounts a **single** `ExplodableModel` instance for the entire repair flow (`fragmented` -> `done`) so the model loads once, animates from its real authored positions, and is never re-instantiated when the player advances to scanning, repairing, reassembling or done. This eliminates the visible position/rotation jumps and re-explosion that occurred when each step instantiated its own model.
|
||||
|
||||
`ExplodedModel.createParts` walks the GLTF tree recursively, descending through any single mesh-bearing wrapper node (e.g. `Scene > Moto > Eclatement` for the Ebike) until it reaches a node with multiple mesh-bearing children. Those children are the natural "explosion groups" authored by the modeler. This avoids exploding raw leaf meshes in local space when the model has extra empty wrapper nodes above the intended group.
|
||||
|
||||
When mounted, `RepairGame` applies `RepairMissionConfig.modelRotation` and `modelScale` to the fragmented model so it lines up with the source inspection model in world space (e.g. the parked Ebike using `EBIKE_WORLD_ROTATION_Y` / `EBIKE_WORLD_SCALE`).
|
||||
When mounted, `RepairGame` applies `RepairMissionConfig.modelRotation` and `modelScale` to the shared model so it lines up with the source inspection model in world space (e.g. the parked Ebike using `EBIKE_WORLD_ROTATION_Y` / `EBIKE_WORLD_SCALE`). The explode/reassemble lerp speed is configurable via `splitSpeed` (default `REPAIR_FRAGMENT_SPLIT_SPEED = 1.8`, ~1.5s) so each node is clearly seen leaving its origin.
|
||||
|
||||
The default delay comes from:
|
||||
|
||||
```txt
|
||||
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS
|
||||
```
|
||||
Transition out is event-driven: the model fires `onSplitSettled(1)` when the lerp converges and `RepairGame` advances to `scanning`. A `REPAIR_FRAGMENTATION_SEQUENCE_SECONDS + 2` fallback timer guards against load failures.
|
||||
|
||||
### Scanning
|
||||
|
||||
@@ -189,50 +186,33 @@ File:
|
||||
src/components/three/gameplay/RepairScanSequence.tsx
|
||||
```
|
||||
|
||||
The scan sequence:
|
||||
The scan sequence is now stateless w.r.t. the model: it receives `parts: ExplodedPart[]` from the upstream shared `ExplodableModel` and:
|
||||
|
||||
- keeps the exploded model visible
|
||||
- receives model parts from `ExplodableModel`
|
||||
- advances an active part index over time
|
||||
- renders `RepairScanVisual` on the active part
|
||||
- reveals broken-part highlights when configured broken parts have been reached
|
||||
- reveals broken-part highlights cumulatively as scan progresses
|
||||
- when the active part has a `voiceLineId`, gates the advance on the audio's `ended` event (with a 15s ceiling fallback) so the diagnostic line plays in full
|
||||
- returns `RepairScannedBrokenPart[]` when done
|
||||
|
||||
Broken-part lookup first tries `brokenParts[].nodeName`. If no configured node matches, it falls back to the first available exploded parts. This fallback is useful while GLTF node names are still unstable, but precise `nodeName` config is safer for production.
|
||||
Broken-part lookup uses `brokenParts[].nodeName` against the exploded parts (deep traverse). When a configured node can't be matched, the available part names are logged so config drift is visible in the console.
|
||||
|
||||
### Repairing
|
||||
|
||||
File:
|
||||
For pylon/farm:
|
||||
|
||||
```txt
|
||||
src/components/three/gameplay/RepairRepairingStep.tsx
|
||||
```
|
||||
|
||||
This is the densest gameplay step.
|
||||
This is the densest gameplay step. It renders install target, placeholder markers, grabbable replacement parts, grabbable broken parts to store, placement feedback and a ready-to-install prompt. Validation requires the correct replacement part placed AND every scanned broken part deposited.
|
||||
|
||||
It renders:
|
||||
|
||||
- install target
|
||||
- placeholder markers
|
||||
- grabbable replacement parts
|
||||
- grabbable broken parts to store
|
||||
- placement feedback
|
||||
- ready-to-install prompt
|
||||
|
||||
Important local state:
|
||||
|
||||
- `placedPartIds`: replacement parts that snapped near a placeholder
|
||||
- `depositedBrokenPartIds`: broken parts stored in the case
|
||||
- `showBlockedInstallFeedback`: temporary visual feedback when install is attempted too early
|
||||
|
||||
Validation:
|
||||
For ebike (mission 1, simplified):
|
||||
|
||||
```txt
|
||||
correct replacement part placed
|
||||
AND every scanned broken part deposited
|
||||
src/components/three/gameplay/RepairEbikeRepairTrigger.tsx
|
||||
```
|
||||
|
||||
Only then does the install target call `onRepair()` and move to `reassembling`.
|
||||
Replaces the heavier grabbable UX with a single "Changez le refroidisseur" prompt. Pressing E advances directly to `reassembling`. The cercles décoratifs and grabbable parts are omitted to keep the first repair experience low-friction.
|
||||
|
||||
### Reassembling
|
||||
|
||||
@@ -242,23 +222,24 @@ File:
|
||||
src/components/three/gameplay/RepairReassemblyStep.tsx
|
||||
```
|
||||
|
||||
The exploded model animates back into assembled form and completion particles play. A timer then moves the mission to `done`.
|
||||
The shared `ExplodableModel` flips `split=false`, animating each node back to its original position (inverse of fragmented). `RepairReassemblyStep` itself is now reduced to:
|
||||
|
||||
Mission configs can override the default reassembly duration.
|
||||
- the completion particles
|
||||
- a `delayMs` timer (`REPAIR_REASSEMBLY_HOLD_MS = 1500`) that fires `onSettled` so `RepairGame` auto-advances to `done`
|
||||
|
||||
### Done
|
||||
|
||||
File:
|
||||
For pylon/farm:
|
||||
|
||||
```txt
|
||||
src/components/three/gameplay/RepairCompletionStep.tsx
|
||||
```
|
||||
|
||||
The repaired object remains visible. The player validates the completion target, then:
|
||||
The shared exploded model (now reassembled) remains visible. The player validates a green completion target, the case closes and exits, then `completeMission(mission)` advances the global game progression.
|
||||
|
||||
1. the repair case closes
|
||||
2. the case plays its exit animation
|
||||
3. `completeMission(mission)` advances the global game progression
|
||||
For ebike (mission 1, auto-complete):
|
||||
|
||||
`RepairGame` plays `narrateur_ebikerepare` directly on entry to `done`. When the audio's `ended` event fires (with `REPAIR_DONE_DIALOGUE_FALLBACK_MS = 6000` fallback) `completeMission("ebike")` is called automatically and the world hands off to the pylon mission. The bubble shrinks via `shouldFocusBubbleBeActive(done) === false`. No Validate button is shown.
|
||||
|
||||
## Focus Bubble
|
||||
|
||||
@@ -279,11 +260,15 @@ The bubble is mounted both in `GameStageContent` (production scene) and `TestMap
|
||||
|
||||
`EbikeRepairNarrator` (`src/components/game/EbikeRepairNarrator.tsx`) is a headless component mounted in `src/pages/page.tsx` next to `EbikeIntroSequence`. It subscribes to `useGameStore` and plays one-shot narrator cues at specific repair-step transitions for the `ebike` mission only:
|
||||
|
||||
| Step entered | Dialogue ID | Audio file | Subtitle |
|
||||
| ------------ | ------------------------------------ | ---------------------------------- | -------- |
|
||||
| `fragmented` | `narrateur_galetscan` | `narrateur_galetscan.mp3` | cue 6 |
|
||||
| `repairing` | `narrateur_refroidisseur_diagnostic` | `narrateur_refroidisseurcassé.mp3` | cue 24 |
|
||||
| `done` | `narrateur_ebikerepare` | `narrateur_ebikeréparé.mp3` | cue 7 |
|
||||
| Step entered | Dialogue ID | Audio file | Subtitle | Owner |
|
||||
| ------------ | ------------------------------------ | ---------------------------------- | -------- | ---------------------- |
|
||||
| `fragmented` | `narrateur_galetscan` | `narrateur_galetscan.mp3` | cue 6 | `EbikeRepairNarrator` |
|
||||
| `scanning` | `narrateur_refroidisseur_diagnostic` | `narrateur_refroidisseurcassé.mp3` | cue 24 | `RepairScanSequence`\* |
|
||||
| `done` | `narrateur_ebikerepare` | `narrateur_ebikeréparé.mp3` | cue 7 | `RepairGame`\*\* |
|
||||
|
||||
\* The diagnostic line is triggered by the scan sequence when it lands on the broken part configured with `voiceLineId` (refroidisseur for ebike). The advance to `repairing` is gated on the audio's `ended` event so the line plays in full with the red highlight on screen.
|
||||
|
||||
\*\* `RepairGame` plays the success line directly on entering `done` so the audio's `ended` event can drive `completeMission` and hand off to pylon. A `REPAIR_DONE_DIALOGUE_FALLBACK_MS` timer guards against load failures. `EbikeRepairNarrator` no longer owns this cue.
|
||||
|
||||
A `useRef<Set<MissionStep>>` guards against double-fires (StrictMode, re-renders) and is cleared when the mission rolls back to `locked` or `waiting`, so debug-panel replays still trigger the narration.
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ export function Ebike({
|
||||
const updateEbikeSounds = useEbikeSounds();
|
||||
const repairGameOwnsEbikeModel =
|
||||
mainState === "ebike" &&
|
||||
ebikeStep !== "locked" &&
|
||||
ebikeStep !== "waiting" &&
|
||||
ebikeStep !== "inspected";
|
||||
|
||||
@@ -362,18 +361,15 @@ export function Ebike({
|
||||
if (window.ebikeBreakdownActive === true) return;
|
||||
|
||||
if (movementMode === "walk") {
|
||||
if (
|
||||
mainState === "ebike" &&
|
||||
(ebikeStep === "locked" || ebikeStep === "waiting")
|
||||
) {
|
||||
if (mainState === "ebike" && ebikeStep === "waiting") {
|
||||
setMissionStep("ebike", "inspected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ebike" && ebikeStep === "inspected") {
|
||||
setMissionStep("ebike", "fragmented");
|
||||
return;
|
||||
}
|
||||
// Note: inspected -> fragmented is no longer driven by press-E.
|
||||
// It auto-advances after the focus bubble's grow tween (see
|
||||
// RepairGame, gated on BUBBLE_GROW_DURATION_SECONDS), so the
|
||||
// sphere visibly engulfs the bike before the explode animation.
|
||||
|
||||
const cameraOffset = new THREE.Vector3(
|
||||
...EBIKE_CAMERA_TRANSFORM.position,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
EBIKE_DIAGNOSTIC_DIALOGUE_ID,
|
||||
EBIKE_REPAIRED_DIALOGUE_ID,
|
||||
EBIKE_SCAN_HINT_DIALOGUE_ID,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
import { EBIKE_SCAN_HINT_DIALOGUE_ID } from "@/data/ebike/ebikeConfig";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type { MissionStep } from "@/types/gameplay/repairMission";
|
||||
@@ -13,12 +9,17 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
/**
|
||||
* Plays narrator cues during the ebike repair game:
|
||||
* - `fragmented` -> "Alors? Pas magnifique ça?... ces galets vont scanner..."
|
||||
* - `repairing` -> "Parfait! C'est le refroidisseur qui a lâché..."
|
||||
* - `done` -> "Eeeet voilà! Il fonctionne comme une horloge!..."
|
||||
*
|
||||
* The `narrateur_refroidisseur_diagnostic` line is triggered by the
|
||||
* scan sequence itself when it lands on the refroidisseur node
|
||||
* (configured via `RepairMissionPartConfig.voiceLineId` on the broken
|
||||
* part). The `narrateur_ebikerepare` line is triggered by RepairGame
|
||||
* directly at the `done` step so its `ended` event can drive the
|
||||
* mission completion handoff.
|
||||
*
|
||||
* Each cue is one-shot per mission run; the played-set resets when the
|
||||
* mission state rolls back to `locked`/`waiting` so debug-panel replays
|
||||
* still trigger the narration.
|
||||
* mission state rolls back to `waiting` so debug-panel replays still
|
||||
* trigger the narration.
|
||||
*
|
||||
* Audio AND subtitles are strictly scoped to `mainState === "ebike"`. If
|
||||
* the player leaves the ebike main state mid-line (debug panel jump,
|
||||
@@ -27,8 +28,6 @@ import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
*/
|
||||
const STEP_TO_DIALOGUE_ID: Partial<Record<MissionStep, string>> = {
|
||||
fragmented: EBIKE_SCAN_HINT_DIALOGUE_ID,
|
||||
repairing: EBIKE_DIAGNOSTIC_DIALOGUE_ID,
|
||||
done: EBIKE_REPAIRED_DIALOGUE_ID,
|
||||
};
|
||||
|
||||
function stopAudio(audio: HTMLAudioElement | null): void {
|
||||
@@ -46,7 +45,7 @@ export function EbikeRepairNarrator(): null {
|
||||
const activeAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ebikeStep === "locked" || ebikeStep === "waiting") {
|
||||
if (ebikeStep === "waiting") {
|
||||
playedRef.current.clear();
|
||||
}
|
||||
}, [ebikeStep]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
@@ -40,11 +39,12 @@ export function RepairCompletionStep({
|
||||
onExitComplete={onComplete}
|
||||
/>
|
||||
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
/>
|
||||
{/*
|
||||
The repaired model is now rendered by the shared ExplodableModel
|
||||
in RepairGame (split=false at done) so a single instance covers
|
||||
the whole repair flow. Rendering RepairObjectModel here would
|
||||
duplicate the model on top of the unified one.
|
||||
*/}
|
||||
|
||||
{!isClosingCase ? (
|
||||
<TriggerObject
|
||||
|
||||
@@ -0,0 +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 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.
|
||||
*/
|
||||
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 (
|
||||
<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
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import * as THREE from "three";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
|
||||
const BUBBLE_RADIUS_METERS = 10;
|
||||
const BUBBLE_GROW_DURATION_SECONDS = 2.5;
|
||||
/**
|
||||
* Duration of the GSAP `expo.out` grow tween. Exported so step-driven
|
||||
* code (e.g. `RepairGame` advancing inspected -> fragmented) can wait
|
||||
* the same amount of time before triggering the next phase.
|
||||
*/
|
||||
export const BUBBLE_GROW_DURATION_SECONDS = 2.5;
|
||||
const BUBBLE_SHRINK_DURATION_SECONDS = 1.2;
|
||||
const BUBBLE_COLOR = "#060814";
|
||||
const BUBBLE_OPACITY = 0.92;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { ExplodedNodeAnchors } from "@/components/three/models/ExplodableModel";
|
||||
@@ -7,17 +7,33 @@ import type {
|
||||
RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairEbikeRepairTrigger } from "@/components/three/gameplay/RepairEbikeRepairTrigger";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { BUBBLE_GROW_DURATION_SECONDS } from "@/components/three/gameplay/RepairFocusBubble";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||
import { RepairScanSequence } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { REPAIR_CASE_MODEL_PATH } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_FRAGMENTATION_SEQUENCE_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import {
|
||||
REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS,
|
||||
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
|
||||
REPAIR_FRAGMENTATION_SEQUENCE_SECONDS,
|
||||
REPAIR_FRAGMENT_SPLIT_SPEED,
|
||||
REPAIR_REASSEMBLY_HOLD_MS,
|
||||
} from "@/data/gameplay/repairGameConfig";
|
||||
import { REPAIR_MISSIONS } from "@/data/gameplay/repairMissions";
|
||||
import { EBIKE_REPAIRED_DIALOGUE_ID } from "@/data/ebike/ebikeConfig";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import { useTerrainSnappedPosition } from "@/hooks/three/useTerrainHeight";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import {
|
||||
clearQueuedDialogues,
|
||||
playDialogueById,
|
||||
stopCurrentDialogue,
|
||||
} from "@/utils/dialogues/playDialogue";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionConfig,
|
||||
@@ -27,6 +43,7 @@ import type {
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { useRepairFocusStore } from "@/managers/stores/useRepairFocusStore";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairGameProps extends Required<
|
||||
@@ -54,6 +71,20 @@ function RepairMissionAssetPreloader({
|
||||
return null;
|
||||
}
|
||||
|
||||
const REPAIR_PHASES: readonly MissionStep[] = [
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
"reassembling",
|
||||
"done",
|
||||
];
|
||||
|
||||
const SPLIT_PHASES: readonly MissionStep[] = [
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
];
|
||||
|
||||
export function RepairGame({
|
||||
mission,
|
||||
position,
|
||||
@@ -73,22 +104,46 @@ export function RepairGame({
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
// For the ebike mission, use the bike's live parked world position once
|
||||
// the repair flow leaves the waiting/locked phase so the repair happens
|
||||
// wherever the player parked the bike, not at the static zone anchor.
|
||||
// window.ebikeParkedPosition is set by Ebike when the player drops the
|
||||
// bike and stays stable through the rest of the repair flow.
|
||||
const [explodedParts, setExplodedParts] = useState<readonly ExplodedPart[]>(
|
||||
[],
|
||||
);
|
||||
const reassemblyDoneTimeoutRef = useRef<number | null>(null);
|
||||
// Ebike-specific: once the repair starts, keep the entire repair flow
|
||||
// exactly where the bike currently is. `Ebike` owns the live parked
|
||||
// position while inspected is showing; RepairGame takes over the model
|
||||
// from fragmented onward and must reuse that same world transform.
|
||||
const livePosition = useMemo<Vector3Tuple>(() => {
|
||||
if (mission !== "ebike" || mainState !== mission) return position;
|
||||
if (step === "locked" || step === "waiting") return position;
|
||||
if (mission !== "ebike" || step === "waiting") return position;
|
||||
|
||||
const parked = window.ebikeParkedPosition;
|
||||
if (!parked) return position;
|
||||
|
||||
return [parked[0], parked[1], parked[2]];
|
||||
}, [mainState, mission, position, step]);
|
||||
}, [mission, position, step]);
|
||||
const usesLiveEbikePosition = mission === "ebike" && step !== "waiting";
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const snappedPosition = useTerrainSnappedPosition(livePosition);
|
||||
const terrainSnappedPosition = useTerrainSnappedPosition(livePosition);
|
||||
const snappedPosition = usesLiveEbikePosition
|
||||
? livePosition
|
||||
: terrainSnappedPosition;
|
||||
const readyForFragmentation = step === "inspected";
|
||||
const brokenNodeNames = useMemo(() => getBrokenNodeNames(config), [config]);
|
||||
const isRepairPhase = (REPAIR_PHASES as readonly MissionStep[]).includes(
|
||||
step,
|
||||
);
|
||||
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,
|
||||
@@ -111,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];
|
||||
@@ -118,7 +182,7 @@ export function RepairGame({
|
||||
const focusCenterZ = snappedPosition[2];
|
||||
useEffect(() => {
|
||||
const inFocusPhase =
|
||||
mainState === mission && shouldFocusBubbleBeActive(step);
|
||||
mainState === mission && shouldFocusBubbleBeActive(step, mission);
|
||||
if (inFocusPhase) {
|
||||
useRepairFocusStore
|
||||
.getState()
|
||||
@@ -130,20 +194,162 @@ export function RepairGame({
|
||||
return undefined;
|
||||
}, [mainState, mission, step, focusCenterX, focusCenterY, focusCenterZ]);
|
||||
|
||||
// Ebike-only: auto-advance inspected -> fragmented once the focus
|
||||
// bubble's grow tween has finished isolating the bike inside the dark
|
||||
// cocoon. The 2.5s delay matches BUBBLE_GROW_DURATION_SECONDS so the
|
||||
// fragmentation visual coincides with the fully-formed shroud.
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
|
||||
if (step !== "fragmented") return undefined;
|
||||
if (mission !== "ebike") return undefined;
|
||||
if (step !== "inspected") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep(mission, "scanning");
|
||||
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
|
||||
setMissionStep(mission, "fragmented");
|
||||
}, BUBBLE_GROW_DURATION_SECONDS * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
// fragmented -> scanning is now driven by `onSplitSettled` from the
|
||||
// shared ExplodableModel below (fires once the lerp actually
|
||||
// converges on progress=1). The legacy
|
||||
// REPAIR_FRAGMENTATION_SEQUENCE_SECONDS timer is kept as a safety-net
|
||||
// fallback in case the model fails to load (no settled event) so the
|
||||
// flow can never get stuck on the fragmented step.
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
if (step !== "fragmented") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(
|
||||
() => {
|
||||
setMissionStep(mission, "scanning");
|
||||
},
|
||||
(REPAIR_FRAGMENTATION_SEQUENCE_SECONDS + 2) * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
if (step !== "reassembling") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep(mission, "done");
|
||||
}, REPAIR_REASSEMBLY_HOLD_MS + 4000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
// Ebike-only: at `done`, play the success narrator line and complete
|
||||
// the mission when the audio ends (handing off to pylon). A fallback
|
||||
// timer guarantees the transition even if the audio fails.
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
if (mission !== "ebike") return undefined;
|
||||
if (step !== "done") return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
let activeAudio: HTMLAudioElement | null = null;
|
||||
let fallbackTimeoutId: number | null = null;
|
||||
|
||||
const finish = (): void => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
completeMission(mission);
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (cancelled) return;
|
||||
const audio = manifest
|
||||
? await playDialogueById(manifest, EBIKE_REPAIRED_DIALOGUE_ID)
|
||||
: null;
|
||||
if (cancelled) {
|
||||
if (audio && !audio.paused) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
return;
|
||||
}
|
||||
activeAudio = audio;
|
||||
if (audio) {
|
||||
audio.addEventListener("ended", finish, { once: true });
|
||||
fallbackTimeoutId = window.setTimeout(
|
||||
finish,
|
||||
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
|
||||
);
|
||||
} else {
|
||||
fallbackTimeoutId = window.setTimeout(
|
||||
finish,
|
||||
REPAIR_DONE_DIALOGUE_FALLBACK_MS,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (activeAudio) {
|
||||
activeAudio.removeEventListener("ended", finish);
|
||||
if (!activeAudio.paused) {
|
||||
activeAudio.pause();
|
||||
activeAudio.currentTime = 0;
|
||||
}
|
||||
}
|
||||
if (fallbackTimeoutId !== null) {
|
||||
window.clearTimeout(fallbackTimeoutId);
|
||||
}
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
};
|
||||
}, [completeMission, mainState, mission, step]);
|
||||
|
||||
// The shared ExplodableModel resets its parts to a fresh array each
|
||||
// time it remounts (i.e. when leaving the repair flow back to
|
||||
// waiting/inspected). The cached `explodedParts` will be overwritten
|
||||
// by `onPartsReady` on the next mount; we don't need an explicit
|
||||
// reset because no rendered code path uses the stale parts outside
|
||||
// the repair phases.
|
||||
|
||||
// Settled callback: drives event-based transitions out of the
|
||||
// explode/reassemble lerp.
|
||||
const stepRef = useRef(step);
|
||||
useEffect(() => {
|
||||
stepRef.current = step;
|
||||
}, [step]);
|
||||
const handleSplitSettled = useMemo(
|
||||
() => (settledAt: 0 | 1) => {
|
||||
const currentStep = stepRef.current;
|
||||
if (settledAt === 1 && currentStep === "fragmented") {
|
||||
setMissionStep(mission, "scanning");
|
||||
}
|
||||
if (settledAt === 0 && currentStep === "reassembling") {
|
||||
if (reassemblyDoneTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reassemblyDoneTimeoutRef.current);
|
||||
}
|
||||
reassemblyDoneTimeoutRef.current = window.setTimeout(() => {
|
||||
reassemblyDoneTimeoutRef.current = null;
|
||||
setMissionStep(mission, "done");
|
||||
}, REPAIR_REASSEMBLY_HOLD_MS);
|
||||
}
|
||||
},
|
||||
[mission, setMissionStep],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reassemblyDoneTimeoutRef.current !== null) {
|
||||
window.clearTimeout(reassemblyDoneTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
@@ -160,66 +366,83 @@ export function RepairGame({
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "fragmented" ? (
|
||||
{/*
|
||||
Single ExplodableModel mounted across the entire repair flow
|
||||
(fragmented -> done) so the model loads once, animates from
|
||||
its real original positions, never re-instantiates between
|
||||
phases, and stays at a stable transform. `split` toggles drive
|
||||
the explode/reassemble lerps in place.
|
||||
*/}
|
||||
{isRepairPhase ? (
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
rotation={config.modelRotation ?? [0, 0, 0]}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
split={isSplitPhase}
|
||||
splitSpeed={REPAIR_FRAGMENT_SPLIT_SPEED}
|
||||
splitDurationSeconds={REPAIR_FRAGMENT_SPLIT_DURATION_SECONDS}
|
||||
onPartsReady={setExplodedParts}
|
||||
onSplitSettled={handleSplitSettled}
|
||||
{...(isRepairing
|
||||
? {
|
||||
hideNodeNames: brokenNodeNames,
|
||||
nodeAnchorNames: brokenNodeNames,
|
||||
onNodeAnchorsChange: setBrokenAnchors,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
{step === "scanning" ? (
|
||||
<RepairScanSequence
|
||||
config={config}
|
||||
parts={explodedParts}
|
||||
onComplete={(brokenParts) => {
|
||||
setScannedBrokenParts(brokenParts);
|
||||
setMissionStep(mission, "repairing");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === "repairing" ? (
|
||||
<>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
rotation={config.modelRotation ?? [0, 0, 0]}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
hideNodeNames={brokenNodeNames}
|
||||
nodeAnchorNames={brokenNodeNames}
|
||||
onNodeAnchorsChange={setBrokenAnchors}
|
||||
/>
|
||||
<RepairRepairingStep
|
||||
anchors={caseAnchors}
|
||||
brokenAnchors={brokenAnchors}
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{step === "reassembling" ? (
|
||||
<RepairReassemblyStep
|
||||
config={config}
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
{step === "repairing" && mission === "ebike" ? (
|
||||
<RepairEbikeRepairTrigger
|
||||
anchor={ebikeBrokenLocalAnchor}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" && mission !== "pylon" ? (
|
||||
{step === "repairing" && mission !== "ebike" ? (
|
||||
<RepairRepairingStep
|
||||
anchors={caseAnchors}
|
||||
brokenAnchors={brokenAnchors}
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "reassembling" ? <RepairReassemblyStep /> : null}
|
||||
{step === "done" && mission !== "pylon" && mission !== "ebike" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
{step !== "waiting" &&
|
||||
step !== "done" &&
|
||||
step !== "reassembling" &&
|
||||
// Ebike's inspected phase is a 2.5s sphere-reveal cinematic that
|
||||
// auto-advances to fragmented; the case + "press to fragment"
|
||||
// prompt would only flash on screen, so suppress them here.
|
||||
!(mission === "ebike" && step === "inspected") ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
onPlaceholdersChange={setCasePlaceholders}
|
||||
onAnchorsChange={setCaseAnchors}
|
||||
open={step === "repairing"}
|
||||
zoomed={step === "repairing"}
|
||||
showFragmentationPrompt={readyForFragmentation}
|
||||
open={mission !== "ebike" && step === "repairing"}
|
||||
zoomed={mission !== "ebike" && step === "repairing"}
|
||||
showFragmentationPrompt={
|
||||
readyForFragmentation && mission !== "ebike"
|
||||
}
|
||||
onInteract={
|
||||
readyForFragmentation
|
||||
readyForFragmentation && mission !== "ebike"
|
||||
? () => setMissionStep(mission, "fragmented")
|
||||
: undefined
|
||||
}
|
||||
@@ -234,7 +457,15 @@ function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||
return step === "repairing" || step === "reassembling" || step === "done";
|
||||
}
|
||||
|
||||
function shouldFocusBubbleBeActive(step: MissionStep): boolean {
|
||||
function shouldFocusBubbleBeActive(
|
||||
step: MissionStep,
|
||||
mission: RepairMissionId,
|
||||
): boolean {
|
||||
// Ebike opens the focus bubble one phase earlier (inspected) so the
|
||||
// sphere visibly engulfs the bike during the inspect-then-explode
|
||||
// build-up. Pylon/farm keep their original behaviour where the bubble
|
||||
// appears once the model has fragmented.
|
||||
if (mission === "ebike" && step === "inspected") return true;
|
||||
return (
|
||||
step === "fragmented" ||
|
||||
step === "scanning" ||
|
||||
|
||||
@@ -1,45 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RepairCompletionParticles } from "@/components/three/gameplay/RepairCompletionParticles";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_REASSEMBLY_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/types/gameplay/repairMission";
|
||||
|
||||
interface RepairReassemblyStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairReassemblyStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairReassemblyStepProps): React.JSX.Element {
|
||||
const [split, setSplit] = useState(true);
|
||||
const reassemblySeconds =
|
||||
config.reassemblySeconds ?? REPAIR_REASSEMBLY_SECONDS;
|
||||
|
||||
useEffect(() => {
|
||||
const closeTimeoutId = window.setTimeout(() => {
|
||||
setSplit(false);
|
||||
}, 50);
|
||||
const completeTimeoutId = window.setTimeout(() => {
|
||||
onComplete();
|
||||
}, reassemblySeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(closeTimeoutId);
|
||||
window.clearTimeout(completeTimeoutId);
|
||||
};
|
||||
}, [onComplete, reassemblySeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split={split}
|
||||
splitDistance={1.2}
|
||||
/>
|
||||
<RepairCompletionParticles />
|
||||
</group>
|
||||
);
|
||||
/**
|
||||
* Visual layer for the reassembly phase. The actual collapse animation
|
||||
* (parts lerping back to their original positions) is driven by the
|
||||
* shared ExplodableModel mounted upstream by RepairGame, which keeps a
|
||||
* single instance alive across fragmented -> done so the model never
|
||||
* reloads or jumps between phases.
|
||||
*
|
||||
* This component now only renders the completion particles and emits a
|
||||
* settled signal after `delayMs` so the upstream flow can advance.
|
||||
*/
|
||||
export function RepairReassemblyStep(): React.JSX.Element {
|
||||
return <RepairCompletionParticles />;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { RepairBrokenPartHighlight } from "@/components/three/gameplay/RepairBrokenPartHighlight";
|
||||
import { RepairBrokenPartPrompt } from "@/components/three/gameplay/RepairBrokenPartPrompt";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { RepairScanVisual } from "@/components/three/gameplay/RepairScanVisual";
|
||||
import { REPAIR_SCAN_PART_SECONDS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
@@ -12,9 +11,20 @@ import type {
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
|
||||
import { playDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
import { useSubtitleStore } from "@/managers/stores/useSubtitleStore";
|
||||
|
||||
interface RepairScanSequenceProps {
|
||||
config: RepairMissionConfig;
|
||||
/**
|
||||
* Parts of the (already mounted) ExplodableModel managed upstream by
|
||||
* RepairGame. The scan sequence drives its visuals against these
|
||||
* parts so the model isn't re-instantiated when entering the scanning
|
||||
* phase (which would cause the explosion animation to replay and the
|
||||
* world transform to differ between phases).
|
||||
*/
|
||||
parts: readonly ExplodedPart[];
|
||||
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
||||
}
|
||||
|
||||
@@ -27,25 +37,112 @@ const warnedMissingScanParts = new Set<string>();
|
||||
|
||||
export function RepairScanSequence({
|
||||
config,
|
||||
parts,
|
||||
onComplete,
|
||||
}: RepairScanSequenceProps): React.JSX.Element {
|
||||
const [parts, setParts] = useState<readonly ExplodedPart[]>([]);
|
||||
const [activePartIndex, setActivePartIndex] = useState(0);
|
||||
const activePart = parts[activePartIndex];
|
||||
const scanPartSeconds = config.scanPartSeconds ?? REPAIR_SCAN_PART_SECONDS;
|
||||
const brokenPartMatches = getBrokenPartMatches(parts, config);
|
||||
const brokenPartMatches = useMemo(
|
||||
() => getBrokenPartMatches(parts, config),
|
||||
[parts, config],
|
||||
);
|
||||
const visibleBrokenPartMatches = brokenPartMatches.filter(
|
||||
(match) => match.partIndex <= activePartIndex,
|
||||
);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = onComplete;
|
||||
}, [onComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
// Look up which (if any) broken-part config corresponds to the
|
||||
// currently active scan part. When the active part has a
|
||||
// `voiceLineId`, gate the advance on the audio's `ended` event so
|
||||
// the diagnostic line plays in full (with its red broken-part
|
||||
// highlight already on screen) before transitioning to the next
|
||||
// scan part — and ultimately to the repairing step.
|
||||
const activeBrokenMatch = brokenPartMatches.find(
|
||||
(match) => match.partIndex === activePartIndex,
|
||||
);
|
||||
const activeVoiceLineId = activeBrokenMatch?.config.voiceLineId;
|
||||
|
||||
if (activeVoiceLineId) {
|
||||
let cancelled = false;
|
||||
let activeAudio: HTMLAudioElement | null = null;
|
||||
let fallbackTimeoutId: number | null = null;
|
||||
|
||||
const advance = (): void => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
window.setTimeout(() => {
|
||||
onCompleteRef.current(getScannedBrokenParts(parts, config));
|
||||
}, 0);
|
||||
return currentIndex;
|
||||
}
|
||||
return nextIndex;
|
||||
});
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
const manifest = await loadDialogueManifest();
|
||||
if (cancelled) return;
|
||||
const audio = manifest
|
||||
? await playDialogueById(manifest, activeVoiceLineId)
|
||||
: null;
|
||||
if (cancelled) {
|
||||
if (audio && !audio.paused) {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
}
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
return;
|
||||
}
|
||||
activeAudio = audio;
|
||||
if (audio) {
|
||||
audio.addEventListener("ended", advance, { once: true });
|
||||
// Fallback: if the audio errors or never fires `ended`, still
|
||||
// advance after a generous ceiling so the flow can't stall.
|
||||
fallbackTimeoutId = window.setTimeout(advance, 15000);
|
||||
} else {
|
||||
// No audio (manifest missing) — advance after the default
|
||||
// per-part dwell so we don't get stuck on this part.
|
||||
fallbackTimeoutId = window.setTimeout(
|
||||
advance,
|
||||
scanPartSeconds * 1000,
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (activeAudio) {
|
||||
activeAudio.removeEventListener("ended", advance);
|
||||
if (!activeAudio.paused) {
|
||||
activeAudio.pause();
|
||||
activeAudio.currentTime = 0;
|
||||
}
|
||||
}
|
||||
if (fallbackTimeoutId !== null) {
|
||||
window.clearTimeout(fallbackTimeoutId);
|
||||
}
|
||||
useSubtitleStore.getState().clearActiveSubtitle();
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
window.setTimeout(() => {
|
||||
onCompleteRef.current(getScannedBrokenParts(parts, config));
|
||||
}, 0);
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
@@ -56,16 +153,10 @@ export function RepairScanSequence({
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activePartIndex, config, onComplete, parts, scanPartSeconds]);
|
||||
}, [activePartIndex, brokenPartMatches, config, parts, scanPartSeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
onPartsReady={setParts}
|
||||
/>
|
||||
<RepairScanVisual target={activePart?.object} />
|
||||
{visibleBrokenPartMatches.map((match) => {
|
||||
const part = parts[match.partIndex];
|
||||
@@ -133,6 +224,7 @@ function getBrokenPartMatches(
|
||||
logger.warn("RepairScan", "Broken parts missing from exploded model", {
|
||||
missionId: config.id,
|
||||
missingIds,
|
||||
availablePartNames: parts.map((part) => part.object.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -148,11 +240,20 @@ function objectContainsNodeName(
|
||||
object: THREE.Object3D,
|
||||
nodeName: string,
|
||||
): boolean {
|
||||
if (object.name === nodeName) return true;
|
||||
const normalizedNodeName = nodeName.toLowerCase();
|
||||
const objectName = object.name.toLowerCase();
|
||||
if (objectName === normalizedNodeName) return true;
|
||||
if (objectName.includes(normalizedNodeName)) return true;
|
||||
if (normalizedNodeName.includes(objectName)) return true;
|
||||
|
||||
let found = false;
|
||||
object.traverse((child) => {
|
||||
if (child.name === nodeName) {
|
||||
const childName = child.name.toLowerCase();
|
||||
if (
|
||||
childName === normalizedNodeName ||
|
||||
childName.includes(normalizedNodeName) ||
|
||||
normalizedNodeName.includes(childName)
|
||||
) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component, useEffect, useMemo, useRef } from "react";
|
||||
import { Component, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
@@ -71,7 +71,19 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
split: boolean;
|
||||
splitDistance?: number;
|
||||
/**
|
||||
* Lerp speed for the explode/reassemble animation. Lower = slower.
|
||||
* 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
|
||||
* target. `settledAt` is 1 when the parts have fully separated, 0
|
||||
* when they have fully snapped back to their original positions.
|
||||
*/
|
||||
onSplitSettled?: (settledAt: 0 | 1) => void;
|
||||
hideNodeNames?: readonly string[];
|
||||
nodeAnchorNames?: readonly string[];
|
||||
onNodeAnchorsChange?: (anchors: ExplodedNodeAnchors) => void;
|
||||
@@ -100,7 +112,10 @@ function ExplodableModelInner({
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
splitDistance = 1.2,
|
||||
splitSpeed,
|
||||
splitDurationSeconds,
|
||||
onPartsReady,
|
||||
onSplitSettled,
|
||||
hideNodeNames,
|
||||
nodeAnchorNames,
|
||||
onNodeAnchorsChange,
|
||||
@@ -112,9 +127,32 @@ function ExplodableModelInner({
|
||||
scale,
|
||||
});
|
||||
const model = useClonedObject(scene);
|
||||
// Keep the latest callback in a ref so the ExplodedModel instance can
|
||||
// be created once per `model` and still call the most recent prop
|
||||
// when the lerp settles. Reading `.current` happens only inside the
|
||||
// settled-callback (invoked from update(), never during render).
|
||||
const onSplitSettledRef = useRef(onSplitSettled);
|
||||
useEffect(() => {
|
||||
onSplitSettledRef.current = onSplitSettled;
|
||||
}, [onSplitSettled]);
|
||||
const handleSettled = useCallback((settledAt: 0 | 1) => {
|
||||
onSplitSettledRef.current?.(settledAt);
|
||||
}, []);
|
||||
|
||||
const explodedModel = useMemo(
|
||||
() => new ExplodedModel(model, { distance: splitDistance }),
|
||||
[model, splitDistance],
|
||||
() =>
|
||||
// The `handleSettled` callback only reads `onSplitSettledRef.current`
|
||||
// when invoked from `update()` (useFrame), never during render.
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
new ExplodedModel(model, {
|
||||
distance: splitDistance,
|
||||
...(splitDurationSeconds !== undefined
|
||||
? { durationSeconds: splitDurationSeconds }
|
||||
: {}),
|
||||
...(splitSpeed !== undefined ? { speed: splitSpeed } : {}),
|
||||
onSettled: handleSettled,
|
||||
}),
|
||||
[model, splitDistance, splitDurationSeconds, splitSpeed, handleSettled],
|
||||
);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const anchorSignatureRef = useRef("");
|
||||
|
||||
@@ -6,6 +6,12 @@ import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
// <video> element renders at the wrong dimensions and shifts the layout.
|
||||
const NOTIFICATION_ASPECT_RATIO = "589 / 211";
|
||||
|
||||
// Same clip-path as `.mission-notification__image-wrap` in index.css. Inlined
|
||||
// here so the video branch can re-use the silhouette without inheriting the
|
||||
// scan-line `::before` and CRT animations applied to the PNG branch.
|
||||
const NOTIFICATION_CLIP_PATH =
|
||||
"polygon(0 0, 100% 0, 100% 69%, 88% 100%, 0 100%)";
|
||||
|
||||
interface MissionNotificationProps {
|
||||
mission?: RepairMissionId;
|
||||
imagePath?: string;
|
||||
@@ -24,14 +30,34 @@ export function MissionNotification({
|
||||
return (
|
||||
<div
|
||||
className={`mission-notification${visible ? "" : " mission-notification--hidden"}`}
|
||||
// Webm assets already animate themselves; suppress the CRT entrance
|
||||
// flicker + drop-shadow that index.css applies to all .mission-notification
|
||||
// nodes so the video plays in a clean container.
|
||||
style={
|
||||
isVideo
|
||||
? {
|
||||
animation: "none",
|
||||
filter: "none",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="mission-notification__glow" />
|
||||
<span className="mission-notification__image-wrap">
|
||||
{isVideo ? (
|
||||
{isVideo ? null : <div className="mission-notification__glow" />}
|
||||
{isVideo ? (
|
||||
<span
|
||||
style={{
|
||||
position: "relative",
|
||||
display: "block",
|
||||
overflow: "hidden",
|
||||
clipPath: NOTIFICATION_CLIP_PATH,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
className="mission-notification__image"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
aspectRatio: NOTIFICATION_ASPECT_RATIO,
|
||||
objectFit: "cover",
|
||||
}}
|
||||
@@ -43,14 +69,16 @@ export function MissionNotification({
|
||||
playsInline
|
||||
preload="auto"
|
||||
/>
|
||||
) : (
|
||||
</span>
|
||||
) : (
|
||||
<span className="mission-notification__image-wrap">
|
||||
<img
|
||||
className="mission-notification__image"
|
||||
src={src}
|
||||
alt="Nouvel objectif de mission"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,3 +3,23 @@ export const REPAIR_FRAGMENTATION_SEQUENCE_SECONDS = 4;
|
||||
export const REPAIR_INTERACTION_RADIUS = 10;
|
||||
export const REPAIR_SCAN_PART_SECONDS = 1.2;
|
||||
export const REPAIR_REASSEMBLY_SECONDS = 1.4;
|
||||
/**
|
||||
* Lerp speed used by the shared ExplodableModel during the repair flow.
|
||||
* Lower = slower, more deliberate explosion so the player can see each
|
||||
* node clearly leave its original position. The default ExplodedModel
|
||||
* 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.
|
||||
* Used by the ebike repair flow so the reassembly particles can play
|
||||
* before the bubble starts shrinking.
|
||||
*/
|
||||
export const REPAIR_REASSEMBLY_HOLD_MS = 1500;
|
||||
/**
|
||||
* Fallback timer for the ebike `done` -> mission-complete transition
|
||||
* when the narrator audio fails to fire its `ended` event.
|
||||
*/
|
||||
export const REPAIR_DONE_DIALOGUE_FALLBACK_MS = 6000;
|
||||
|
||||
@@ -20,13 +20,14 @@ const REPAIR_MISSION_POSITIONS = {
|
||||
farm: [-24, 0, 42],
|
||||
} as const satisfies Record<RepairMissionId, Vector3Tuple>;
|
||||
|
||||
export const REPAIR_MISSION_TRIGGERS = [
|
||||
{
|
||||
mission: "ebike",
|
||||
label: "Réparer l'e-bike",
|
||||
radius: 4,
|
||||
},
|
||||
] as const satisfies readonly RepairMissionTriggerConfig[];
|
||||
// Currently empty: the ebike mission entry point is handled directly by
|
||||
// `Ebike.tsx`'s own InteractableObject ("Lancer le Repair Game"), and the
|
||||
// pylon/farm missions transition through their narrative flows
|
||||
// (PylonNarrativeFlow / FarmNarrativeFlow). Keep the array typed so we
|
||||
// can re-introduce a generic anchor trigger in the future without
|
||||
// touching the consumer in `GameStageContent.tsx`.
|
||||
export const REPAIR_MISSION_TRIGGERS: readonly RepairMissionTriggerConfig[] =
|
||||
[];
|
||||
|
||||
export const REPAIR_MISSION_POSITION_ENTRIES = Object.entries(
|
||||
REPAIR_MISSION_POSITIONS,
|
||||
|
||||
@@ -105,7 +105,12 @@ export function getPreviousMissionStep(
|
||||
case "npc-return":
|
||||
return "arrived";
|
||||
case "waiting":
|
||||
return mission === "pylon" ? "npc-return" : "locked";
|
||||
// Ebike no longer has a "locked" entry state — its mission starts
|
||||
// directly at "waiting". Pylon rewinds to its NPC return loop, farm
|
||||
// rewinds to its narrative-driven locked kickoff.
|
||||
if (mission === "pylon") return "npc-return";
|
||||
if (mission === "farm") return "locked";
|
||||
return "waiting";
|
||||
case "inspected":
|
||||
return "waiting";
|
||||
case "fragmented":
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import {
|
||||
EBIKE_DIAGNOSTIC_DIALOGUE_ID,
|
||||
EBIKE_WORLD_ROTATION_Y,
|
||||
EBIKE_WORLD_SCALE,
|
||||
} from "@/data/ebike/ebikeConfig";
|
||||
@@ -36,9 +37,12 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
id: "ebike-cooling-core",
|
||||
label: "Cooling core",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
nodeName: "refroidisseur",
|
||||
targetNodeName: "refroidisseur",
|
||||
nodeName: "Radiateur",
|
||||
targetNodeName: "Radiateur",
|
||||
caseSlotName: "placeholder_1",
|
||||
// Plays during the scan landing on the refroidisseur node;
|
||||
// the scan sequence advances on this audio's `ended` event.
|
||||
voiceLineId: EBIKE_DIAGNOSTIC_DIALOGUE_ID,
|
||||
},
|
||||
],
|
||||
replacementParts: [
|
||||
@@ -47,7 +51,7 @@ export const REPAIR_MISSIONS: Record<RepairMissionId, RepairMissionConfig> = {
|
||||
label: "Refroidisseur",
|
||||
modelPath: "/models/refroidisseur/model.gltf",
|
||||
caseAnchor: "refroidisseur",
|
||||
targetNodeName: "refroidisseur",
|
||||
targetNodeName: "Radiateur",
|
||||
},
|
||||
{
|
||||
id: "ebike-cable-right-distractor",
|
||||
|
||||
@@ -131,7 +131,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
ebike: {
|
||||
...state.ebike,
|
||||
currentStep: "locked",
|
||||
currentStep: "waiting",
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -265,7 +265,7 @@ function createInitialGameState(): GameState {
|
||||
isEbikeUnlocked: false,
|
||||
},
|
||||
ebike: {
|
||||
currentStep: "locked",
|
||||
currentStep: "waiting",
|
||||
dialogueAudio: null,
|
||||
isRepaired: false,
|
||||
},
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HAND_TRACKING_LINGER_MS } from "@/data/handTrackingConfig";
|
||||
import { useSceneMode } from "@/hooks/debug/useSceneMode";
|
||||
import { useDebugStore } from "@/hooks/debug/useDebugStore";
|
||||
import { useInteraction } from "@/hooks/interaction/useInteraction";
|
||||
import {
|
||||
HAND_TRACKING_IDLE_SNAPSHOT,
|
||||
HandTrackingContext,
|
||||
@@ -25,8 +23,14 @@ export function HandTrackingProvider({
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.JSX.Element {
|
||||
const sceneMode = useSceneMode();
|
||||
const repairNeedsHands = useGameStore((state) => {
|
||||
// Hand tracking is gated *only* by the active repair-mission step. We
|
||||
// intentionally do NOT activate it from generic interactable proximity
|
||||
// (e.g. standing next to the ebike to mount it) — that previously caused
|
||||
// hand tracking to spin up around any interactable in the physics
|
||||
// (TestMap) scene mode, even though the player wasn't in a step that
|
||||
// actually uses hands. Use the GameStateDebugPanel to set
|
||||
// mainState=ebike + currentStep=inspected when testing in TestMap.
|
||||
const requested = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "ebike":
|
||||
return REPAIR_HAND_TRACKING_STEPS.has(state.ebike.currentStep);
|
||||
@@ -39,10 +43,6 @@ export function HandTrackingProvider({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const { nearby, holding, handHolding } = useInteraction();
|
||||
const requested =
|
||||
repairNeedsHands ||
|
||||
(sceneMode === "physics" && (nearby || holding || handHolding));
|
||||
|
||||
// Keep the runtime active a little after `requested` turns off so
|
||||
// MediaPipe has time to initialize the webcam + model + first frame
|
||||
|
||||
@@ -48,6 +48,15 @@ export interface RepairMissionPartConfig {
|
||||
*/
|
||||
caseLockGroup?: string;
|
||||
modelPath?: string;
|
||||
/**
|
||||
* Optional dialogue id to play when the scan sequence lands on this
|
||||
* part. The scan sequence will pause on this part for the duration
|
||||
* of the audio (instead of the default `scanPartSeconds` timer) and
|
||||
* advance to the next part on the audio's `ended` event. Use this to
|
||||
* deliver a node-specific diagnostic line (e.g. ebike refroidisseur
|
||||
* -> "narrateur_refroidisseur_diagnostic").
|
||||
*/
|
||||
voiceLineId?: string;
|
||||
}
|
||||
|
||||
export interface RepairScannedBrokenPart {
|
||||
|
||||
@@ -9,6 +9,14 @@ 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
|
||||
* the next mission step on actual animation completion rather than a
|
||||
* blind timer.
|
||||
*/
|
||||
onSettled?: (settledAt: 0 | 1) => void;
|
||||
}
|
||||
|
||||
const _center = new THREE.Vector3();
|
||||
@@ -18,17 +26,26 @@ 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;
|
||||
private settledAtTarget = true;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
setSplit(split: boolean): void {
|
||||
this.targetProgress = split ? 1 : 0;
|
||||
const next = split ? 1 : 0;
|
||||
if (next !== this.targetProgress) {
|
||||
this.targetProgress = next;
|
||||
this.settledAtTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
getParts(): readonly ExplodedPart[] {
|
||||
@@ -39,6 +56,14 @@ export class ExplodedModel {
|
||||
const diff = this.targetProgress - this.progress;
|
||||
if (Math.abs(diff) < 0.001) {
|
||||
this.progress = this.targetProgress;
|
||||
if (!this.settledAtTarget) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -323,9 +323,11 @@ function MapNodeInstance({
|
||||
}): React.JSX.Element | null {
|
||||
const isGeneratedModel = isGeneratedMapModelName(node.name);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const ebikeStep = useGameStore((state) => state.ebike.currentStep);
|
||||
const hideEbikeMapModel =
|
||||
node.name === "ebike" && mainState === "ebike" && ebikeStep !== "locked";
|
||||
// The static-map ebike node is replaced by the live `Ebike` component
|
||||
// (rendered from GameStageContent) as soon as the ebike mission begins,
|
||||
// so hide the static one to avoid a dual-render at the same world
|
||||
// position.
|
||||
const hideEbikeMapModel = node.name === "ebike" && mainState === "ebike";
|
||||
|
||||
useEffect(() => {
|
||||
if (modelUrl !== null || isGeneratedModel) return;
|
||||
|
||||
Reference in New Issue
Block a user