Merge pull request 'Feat/repair game' (#2) from feat/repair-game into develop
🔍 Lint / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (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 / 🪄 Check lint (push) Has been cancelled
🔍 Lint / 🎨 Check format (push) Has been cancelled
🔍 Lint / 🔎 Typecheck (push) Has been cancelled
🔍 Lint / 🏗 Build (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
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@ You are working on **La Fabrik**, an interactive 3D web experience built with Re
|
||||
## Current Implementation
|
||||
|
||||
- Stack: React 19, Three.js, `@react-three/fiber`, `@react-three/drei`, `@react-three/rapier`, TypeScript, Vite
|
||||
- No external global state library is used.
|
||||
- Zustand is used for shared game progression state.
|
||||
- Current singleton-style services are limited to:
|
||||
- `InteractionManager`
|
||||
- `AudioManager`
|
||||
|
||||
@@ -49,7 +49,8 @@ la-fabrik/
|
||||
└── src/
|
||||
├── world/ # Persistent 3D world composition
|
||||
│ ├── World.tsx # Active scene composition
|
||||
│ ├── GameMap.tsx # Map loading and octree collision
|
||||
│ ├── GameMap.tsx # Map loading and progressive rendering
|
||||
│ ├── GameMapCollision.tsx # Collision-only octree source
|
||||
│ ├── Lighting.tsx # Ambient, directional, point lights
|
||||
│ ├── Environment.tsx # Scene background / sky model
|
||||
│ ├── GameMusic.tsx # Game scene music lifecycle
|
||||
@@ -101,7 +102,8 @@ la-fabrik/
|
||||
│ ├── editor/ # Editor-only parsing utilities
|
||||
│ ├── map/ # Map loading and validation
|
||||
│ └── three/ # Three.js helpers
|
||||
├── App.tsx # Canvas bootstrap
|
||||
├── types/ # Shared TypeScript domain types
|
||||
├── App.tsx # App bootstrap and route switch
|
||||
└── main.tsx
|
||||
```
|
||||
|
||||
|
||||
@@ -37,9 +37,8 @@ Use `useClonedObject` when a GLTF scene is reused by a component instance. It me
|
||||
src/components/three/
|
||||
├── gameplay/
|
||||
│ ├── RepairCaseModel.tsx
|
||||
│ ├── RepairCaseObject.tsx
|
||||
│ ├── RepairGameZone.tsx
|
||||
│ └── RepairModuleSlot.tsx
|
||||
│ ├── RepairGame.tsx
|
||||
│ └── RepairRepairingStep.tsx
|
||||
├── interaction/
|
||||
│ ├── GrabbableObject.tsx
|
||||
│ ├── InteractableObject.tsx
|
||||
|
||||
@@ -14,10 +14,24 @@ This document describes the code that exists today in the repository.
|
||||
- debug helpers and debug camera mode
|
||||
- either the map scene or the debug physics test scene
|
||||
- the player rig when the active camera mode is `player`
|
||||
- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree.
|
||||
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map.
|
||||
- `src/hooks/world/useWorldSceneLoading.ts` owns the production scene loading state shared by `World`, `GameMap`, and the player octree readiness.
|
||||
- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, renders them progressively, and shows fallback cubes for missing models.
|
||||
- `src/world/GameMapCollision.tsx` builds the player collision octree from dedicated collision nodes only.
|
||||
- `src/world/GameStageContent.tsx` is wrapped in Rapier `Physics` in the production game scene so stage gameplay objects can use physics without moving the map or player to Rapier. It now mounts reusable `RepairGame` instances for `bike`, `pylone`, and `ferme` mission states.
|
||||
- `src/world/debug/TestMap.tsx` provides a debug-oriented interaction and physics map with the existing grab/trigger/model-preview objects plus separate `Bike`, `Pylone`, and `Farm` repair playground zones.
|
||||
- `src/world/player/Player.tsx` mounts the camera and controller.
|
||||
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
|
||||
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, repair-step movement locking, and interaction input.
|
||||
|
||||
## Physics Boundaries
|
||||
|
||||
The project currently uses two collision layers with separate responsibilities:
|
||||
|
||||
- `GameMapCollision` builds an octree used by the player controller for map collision.
|
||||
- The player octree must be built from a small collision-only subset of map nodes. It currently uses the `terrain` node only instead of traversing the full visible map, because building an octree from all rendered props can overload the browser renderer.
|
||||
- `GameStageContent` is wrapped in Rapier `Physics` for gameplay objects such as repair triggers, cases, grabbables, and future mission-specific objects.
|
||||
- `TestMap` owns its own Rapier `Physics` playground so repair gameplay can be tuned per mission state without depending on the production map layout.
|
||||
|
||||
Keep the player and map octree outside the Rapier provider until there is a deliberate migration plan. This avoids mixing player movement rules with object physics before the gameplay systems need it.
|
||||
|
||||
## Interaction Model
|
||||
|
||||
@@ -78,6 +92,7 @@ This document describes the code that exists today in the repository.
|
||||
- `src/components/ui/debug/DebugOverlayLayout.tsx` mounts the compact HTML debug overlay when enabled from `lil-gui`.
|
||||
- `src/components/ui/debug/GameStateDebugPanel.tsx` exposes current game state, main/sub-state switching, previous/next step controls, and reset.
|
||||
- `src/components/ui/debug/HandTrackingDebugPanel.tsx` shows hand tracking status, usage, loaded glove model, hand count, and fist state while hand tracking is active.
|
||||
- `src/components/ui/SceneLoadingOverlay.tsx` displays the fullscreen loading state for 3D scenes, including the production game scene, debug physics scene, and editor scene.
|
||||
- `src/components/three/handTracking/HandTrackingGlove.tsx` places the rigged `gant_l` and `gant_r` models on detected hands in the debug physics scene.
|
||||
- `src/components/debug/scene/DebugHelpers.tsx` mounts debug helpers.
|
||||
- `src/components/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
|
||||
@@ -88,7 +103,7 @@ This document describes the code that exists today in the repository.
|
||||
- `src/components/three/models/` contains reusable model helpers such as `ExplodableModel`.
|
||||
- `src/components/three/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`.
|
||||
- `src/components/three/handTracking/` contains R3F hand tracking debug models such as the glove overlays.
|
||||
- `src/components/three/gameplay/` contains the current core repair gameplay prototype: the repair case, repair game zone, and module slots.
|
||||
- `src/components/three/gameplay/` contains the reusable production `RepairGame` flow, repair case, repair steps, and repair prompt components.
|
||||
- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`.
|
||||
|
||||
## Editor System
|
||||
@@ -106,20 +121,22 @@ This document describes the code that exists today in the repository.
|
||||
- `src/utils/editor/loadEditorScene.ts` handles editor-only folder upload parsing.
|
||||
- `src/utils/map/loadMapSceneData.ts` is shared by the game scene and editor to load `public/map.json` and resolve model URLs.
|
||||
- `src/types/editor/editor.ts` contains the shared `MapNode`, `SceneData`, and `TransformMode` types.
|
||||
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used across store, config, debug UI, and gameplay components.
|
||||
|
||||
## Map Data
|
||||
|
||||
- `public/map.json` is expected to be a `MapNode[]`.
|
||||
- Each map node `name` maps to `public/models/{name}/model.glb` when available, with `public/models/{name}/model.gltf` kept as fallback.
|
||||
- The editor renders a fallback cube for missing models.
|
||||
- The game scene filters out nodes whose model cannot be resolved.
|
||||
- The game scene renders fallback cubes for nodes whose model cannot be resolved.
|
||||
- The game scene currently uses `terrain` as the collision source for the player octree. Additional collision nodes should be explicit lightweight collision assets, not arbitrary visible decoration models.
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- The repository is a prototype, not the full intended game runtime.
|
||||
- `src/world/debug/TestMap.tsx` is part of the active scene composition.
|
||||
- There is no central gameplay orchestrator such as `GameManager`.
|
||||
- Missions and zones are not implemented.
|
||||
- Dialogue branching and gameplay-triggered orchestration are still limited.
|
||||
- Mission state exists in Zustand and the repair flow is implemented as a prototype for the current repair missions.
|
||||
- Cinematics and dialogues exist as prototype timecode-driven systems; dialogue branching and broader gameplay orchestration are still limited.
|
||||
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
|
||||
- Editor save-to-server is implemented as a Vite dev-server plugin, not a production backend API.
|
||||
|
||||
@@ -4,9 +4,9 @@ This document describes the hand tracking system that exists in the current code
|
||||
|
||||
## Purpose
|
||||
|
||||
Hand tracking is a debug-stage interaction system used to test direct 3D object manipulation with a webcam. It allows a user to close their fist to grab a nearby object and move it in 3D space without relying on the center crosshair.
|
||||
Hand tracking started as a debug-stage interaction system used to test direct 3D object manipulation with a webcam. It allows a user to close their fist to grab a nearby object and move it in 3D space without relying on the center crosshair.
|
||||
|
||||
The feature is scoped to the debug physics scene rather than production gameplay input.
|
||||
It is now also available to the production repair flow when a mission reaches a hand-driven step.
|
||||
|
||||
## Runtime Flow
|
||||
|
||||
@@ -16,13 +16,13 @@ The feature is scoped to the debug physics scene rather than production gameplay
|
||||
4. The backend returns hand data including landmarks, handedness, score, center point, and `isFist`.
|
||||
5. React stores the latest snapshot in the hand tracking provider.
|
||||
6. `GrabbableObject` reads that snapshot each frame and uses fist state plus raycasting to grab objects.
|
||||
7. `HandTrackingGlove` reads the same snapshot and places the rigged `gant_l` and `gant_r` models on the detected hands in the debug physics scene.
|
||||
7. `HandTrackingGlove` reads the same snapshot and places the rigged `gant_l` and `gant_r` models on the detected hands when hand tracking is active.
|
||||
|
||||
## Activation Rules
|
||||
|
||||
Hand tracking is intentionally gated so the webcam and backend are not used all the time.
|
||||
|
||||
The current activation conditions are:
|
||||
The debug activation conditions are:
|
||||
|
||||
- debug mode is active with `?debug`
|
||||
- scene mode is `physics`
|
||||
@@ -30,6 +30,15 @@ The current activation conditions are:
|
||||
|
||||
This keeps hand tracking active while the player is inside an interaction zone, even if the camera is not aimed directly at the object.
|
||||
|
||||
The production repair activation conditions are:
|
||||
|
||||
- active `mainState` is `bike`, `pylone`, or `ferme`
|
||||
- the active mission step is `inspected`, `repairing`, `reassembling`, or `done`
|
||||
|
||||
This keeps the webcam off during `waiting`, `fragmented`, and `scanning`, then enables hand input only when the repair flow is expected to use hands.
|
||||
|
||||
In the current production repair flow, `inspected` uses a two-fists hold gesture to advance to `fragmented`. The hold must last one second and is independent from local object interaction distance once the mission is in the correct state. Keyboard input for the same transition is handled separately by the repair case trigger, so pressing `E` requires the case to be focused through the shared interaction system.
|
||||
|
||||
## Backend
|
||||
|
||||
The backend lives in `backend/` and exposes:
|
||||
@@ -121,7 +130,7 @@ The glove models are intentionally smaller than the raw SVG overlay so they do n
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- The feature is debug-only and focused on the physics test scene.
|
||||
- Production usage is currently limited to repair mission steps that explicitly need hands.
|
||||
- MediaPipe depth is relative and can be noisy.
|
||||
- The virtual hit zone is an approximation based on multiple raycasts, not a real 3D collider.
|
||||
- There is no smoothing layer for hand position or depth yet.
|
||||
|
||||
@@ -75,6 +75,7 @@ The mission steps currently use this sequence:
|
||||
"fragmented" |
|
||||
"scanning" |
|
||||
"repairing" |
|
||||
"reassembling" |
|
||||
"done";
|
||||
```
|
||||
|
||||
@@ -114,10 +115,32 @@ setMainState("bike");
|
||||
|
||||
Direct setters are useful for debug panels, but production gameplay should prefer business actions such as `advanceGameState`, `completeBike`, or `completePylone`.
|
||||
|
||||
Mission gameplay that can target `bike`, `pylone`, or `ferme` should prefer the generic mission actions:
|
||||
|
||||
```ts
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
|
||||
setMissionStep("bike", "inspected");
|
||||
completeMission("bike");
|
||||
```
|
||||
|
||||
This keeps reusable gameplay components such as repair flows from duplicating mission-specific branches like `setBikeState`, `setPyloneState`, and `setFermeState`.
|
||||
|
||||
## World Integration
|
||||
|
||||
`src/world/GameStageContent.tsx` subscribes to `mainState` and mounts stage-specific content.
|
||||
|
||||
For repair missions, it mounts the reusable `RepairGame` component with a mission id:
|
||||
|
||||
```tsx
|
||||
<RepairGame mission="bike" position={[8, 0, -6]} />
|
||||
```
|
||||
|
||||
`RepairGame` reads the active mission step from the store and writes transitions through generic actions such as `setMissionStep` and `completeMission`. Shared repair ids, mission steps, and runtime guards live in `src/types/gameplay/repairMission.ts` so static mission config does not depend on the Zustand store. The production repair flow currently supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` state transitions.
|
||||
|
||||
Mission-specific behavior stays in `src/data/gameplay/repairMissions.ts`: each mission can define its broken nodes, placeholder targets, scan duration, and reassembly duration without adding mission branches to `RepairGame`.
|
||||
|
||||
That means the scene can progressively move toward this pattern:
|
||||
|
||||
```tsx
|
||||
@@ -147,6 +170,7 @@ Current overlays:
|
||||
- `GameStateDebugPanel`: compact debug UI for viewing and switching main/sub states, stepping backward or forward, and resetting the store
|
||||
- `Crosshair`: player aiming helper
|
||||
- `InteractPrompt`: interaction prompt
|
||||
- `RepairMovementLockIndicator`: player-facing indicator shown while repair steps temporarily disable movement
|
||||
|
||||
`src/pages/page.tsx` should stay thin and mount only the canvas and `GameUI`.
|
||||
|
||||
@@ -161,4 +185,4 @@ Current overlays:
|
||||
|
||||
## Next Steps
|
||||
|
||||
The next natural step is to replace the temporary stage anchors in `GameStageContent` with real stage components, for example `IntroContent`, `BikeContent`, `PyloneContent`, `FermeContent`, and `OutroContent`.
|
||||
Move repair validation into mission data once each mission has distinct broken module nodes, replacement assets, and completion events.
|
||||
|
||||
+16
-3
@@ -6,7 +6,9 @@ This document lists features that are implemented in the current codebase.
|
||||
|
||||
- Fullscreen React Three Fiber scene
|
||||
- Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.glb` or `model.gltf` assets
|
||||
- Debug physics test scene selectable from the debug panel
|
||||
- Minimal fullscreen scene loading overlay for 3D scenes, with a global progress bar used by the production map, debug physics scene, and editor scene
|
||||
- Debug physics test scene selectable from the debug panel, including grab/trigger tests, an animated model preview, and separate repair playground zones for `bike`, `pylone`, and `ferme`
|
||||
- Rapier physics context available for production stage gameplay objects
|
||||
- Ambient and directional lighting
|
||||
- Environment background setup
|
||||
|
||||
@@ -16,15 +18,24 @@ This document lists features that are implemented in the current codebase.
|
||||
- Pointer lock mouse look
|
||||
- Movement with `ZQSD`
|
||||
- Jumping
|
||||
- Octree-based collision against the loaded map
|
||||
- Movement lock during active repair steps, with an on-screen indicator while keeping trigger interactions available
|
||||
- Octree-based collision against dedicated map collision nodes, currently scoped to `terrain`
|
||||
|
||||
## Interactions
|
||||
|
||||
- Focus detection by distance and raycast
|
||||
- Trigger interactions activated with `E`
|
||||
- Grab interactions activated with the primary mouse button
|
||||
- Physics-backed gameplay objects can be mounted inside stage content without replacing player octree collision
|
||||
- Interaction prompt shown for trigger interactions
|
||||
|
||||
## Repair Gameplay
|
||||
|
||||
- Reusable production `RepairGame` mounted for `bike`, `pylone`, and `ferme` mission states
|
||||
- Debug physics playground mounts the same reusable `RepairGame` in `Bike`, `Pylone`, and `Farm` zones so each state can be tuned with isolated positioning before moving placement into the production map
|
||||
- Repair mission config shared through `src/data/gameplay/repairMissions.ts`, including per-mission broken nodes, placeholder targets, scan timing, and reassembly timing
|
||||
- Repair-game flow supports `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission` with `.webm` prompts, repair case spawn/opening/exit, focused repair-case view, movement lock indicator during active repair, repair-case trigger interaction, case placeholder traversal, snap-to-placeholder placement, broken-part deposit feedback, `E`, two-fists hold input, exploded and inverse reassembly transitions, completion particles, per-part scan visuals, persistent red broken-part markers, centered broken-part UI videos, multiple grabbable replacement choices, correct-part install validation feedback, and mission completion
|
||||
|
||||
## Audio
|
||||
|
||||
- Category-based volumes for music, SFX, and dialogue
|
||||
@@ -64,6 +75,7 @@ This document lists features that are implemented in the current codebase.
|
||||
- `?debug` query param enables the debug panel
|
||||
- `lil-gui` controls for camera mode, scene mode, `R3F Perf`, `Debug Overlay`, and interaction tuning
|
||||
- Compact debug overlay for game state controls and hand tracking status
|
||||
- Debug game-state mission switching unlocks locked repair missions at `waiting` for faster testing
|
||||
- Debug scene helpers
|
||||
- Free debug camera
|
||||
- `r3f-perf` overlay
|
||||
@@ -90,8 +102,9 @@ This document lists features that are implemented in the current codebase.
|
||||
|
||||
## Not Implemented Yet
|
||||
|
||||
- mission system
|
||||
- complete mission system
|
||||
- zone system
|
||||
- full cinematic system beyond current timecode prototype
|
||||
- gameplay-triggered dialogue branches beyond current prototype triggers
|
||||
- loading flow
|
||||
- minimap and mission HUD
|
||||
|
||||
+59
-29
@@ -1,50 +1,78 @@
|
||||
# Main Feature
|
||||
|
||||
This document explains the current repair-game prototype in La-Fabrik.
|
||||
This document explains the current repair-game flow in La-Fabrik.
|
||||
|
||||
## What It Does
|
||||
|
||||
The main feature is a repair interaction sandbox mounted in the debug physics scene. It lets the player approach a repair case, open it, and interact with module slots that can show selectable models and exploded-model states.
|
||||
The main feature is a reusable repair flow mounted in the production game scene. It lets the player approach the active mission object, inspect it, fragment it, scan the broken part, install the correct replacement, validate completion, and move to the next mission state.
|
||||
|
||||
The current user flow is:
|
||||
|
||||
1. Open the app with `?debug`.
|
||||
2. Switch the scene to `Physics` in the debug panel.
|
||||
3. Move close to the repair case.
|
||||
4. Press the interaction key when prompted.
|
||||
5. Watch the case open or close with sound feedback.
|
||||
6. Interact with repair module slots to cycle/select repair models.
|
||||
1. Enter a mission state such as `bike`, `pylone`, or `ferme`.
|
||||
2. Move close to the active repair object in the game scene.
|
||||
3. Aim at the object and press the interaction key when prompted.
|
||||
4. The mission step moves from `waiting` to `inspected`.
|
||||
5. The repair case appears near the mission object, the player movement controls are locked, and the case can float when the player approaches it.
|
||||
6. Aim at the repair case and press `E`, or hold both fists closed for one second, to move from `inspected` to `fragmented`.
|
||||
7. The mission object uses an exploded-model transition, then moves to `scanning`.
|
||||
8. The scan visual moves across the fragmented model one part at a time and keeps a red marker plus the `cassé.webm` prompt centered on any configured broken part once it has been found.
|
||||
9. In `repairing`, the case opens in a larger focused view and several grabbable replacement parts appear on the case placeholders.
|
||||
10. Move the correct replacement part close to a placeholder. When released near a placeholder, it snaps into place with a short animation.
|
||||
11. Move each scanned broken part into a compatible placeholder so the damaged parts are stored in the case.
|
||||
12. Press `E` on the green install target to move to `reassembling`. Wrong parts turn the target red and cannot finish the repair.
|
||||
13. The exploded object animates back into its assembled form with completion particles, then moves to `done` and restores player movement controls.
|
||||
14. Press `E` on the completion target. The repair case closes, returns to the ground, disappears, then `completeMission` moves to the next mission or to `outro` after `ferme`.
|
||||
|
||||
## Why It Matters
|
||||
|
||||
This feature validates the core repair fantasy before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene.
|
||||
This feature validates the repair loop before a full mission system exists. It tests whether repair objects, physical proximity, model selection, audio feedback, and exploded model visualization can work together in the 3D scene.
|
||||
|
||||
## Current Behavior
|
||||
|
||||
The repair case reacts to player proximity. When the player is close enough, it floats upward and rotates gently to signal interactivity. When the player moves away, it returns to its resting transform.
|
||||
In `waiting`, the active mission renders its repair object and the `interagir.webm` prompt in the game scene. The interaction uses the shared focus/raycast interaction system, so the player still gets the normal `E` prompt.
|
||||
|
||||
Interacting with the case toggles its open state. The lid animation is handled with GSAP because it is a discrete interaction animation, not a continuous per-frame loop.
|
||||
When the player inspects the object, `RepairGame` writes `inspected` through the generic mission store action. The repair case then appears from the mission config with a small pop animation, player movement is locked while the repair sequence is active, and a small HTML indicator confirms that movement is temporarily unavailable. When the player is close enough, the existing case model floats upward and rotates gently to signal interactivity.
|
||||
|
||||
Repair module slots are configured from static gameplay data. They render selectable repair models and can use exploded model visualization to show parts separated from their original positions.
|
||||
In `inspected`, `RepairGame` can also move to `fragmented`. Keyboard input goes through the shared focus/raycast interaction system on the repair case, so the player must be close enough and aim at the case before pressing `E`. The hand-tracking path still uses a two-fists hold gesture and is state-based, so it does not depend on being inside a local object interaction radius.
|
||||
|
||||
In `fragmented`, the repair object is rendered with `ExplodableModel`, then automatically advances to `scanning`. In `scanning`, the exploded model remains visible, a blue scan visual moves from part to part, and a red halo/wire marker plus the configured broken UI video stay attached to configured broken parts after the scanner reaches them. The scan can match a specific `nodeName` when mission data provides one, otherwise it falls back to the first scanned parts as placeholder broken parts. In `repairing`, the case opens in a larger focused transform, `RepairCaseModel` traverses the case GLTF for empty nodes named `placeholder_*`, several grabbable replacement parts appear on those placeholder positions, and releasing a part near a placeholder snaps it into place with a short GSAP animation. Scanned broken parts are also rendered as grabbable objects and must be deposited into a compatible placeholder before the final install target validates. If `brokenParts[].placeholderName` is configured, that broken part snaps only to the matching placeholder; otherwise it can use any available placeholder. If the current case asset has no placeholder nodes, the flow keeps using fallback focus positions. Replacement parts show green or red placement feedback after snapping, broken parts show stored feedback after deposit, and the install target gives a short blocked feedback if the player tries to validate too early. The install target only validates when the configured correct replacement part is placed and all scanned broken parts have been deposited. Player movement stays locked through `inspected`, `fragmented`, `scanning`, `repairing`, and `reassembling`, while trigger interactions remain available. In `reassembling`, the exploded model animates back into its assembled position with green completion particles before the flow moves to `done`. In `done`, player movement is available again and the repaired object remains visible with a completion target; validating closes the repair case first, then plays the case exit animation before advancing the global mission progression.
|
||||
|
||||
The mission config now carries the mission-specific variations. `bike` repairs one cooling core, `pylone` scans and stores both the lamp relay and a damaged panel with slower scan/reassembly timing, and `ferme` scans and stores an irrigation pump plus humidity sensor with faster scan/reassembly timing.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `src/world/debug/TestMap.tsx` mounts the repair-game prototype in the debug physics scene.
|
||||
- `src/components/three/gameplay/RepairGameZone.tsx` composes the repair-game zone.
|
||||
- `src/components/three/gameplay/RepairCaseObject.tsx` connects the repair case to trigger interaction and audio.
|
||||
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model.
|
||||
- `src/components/three/gameplay/RepairModuleSlot.tsx` renders repair slots and model selection behavior.
|
||||
- `src/world/GameStageContent.tsx` mounts production `RepairGame` instances for `bike`, `pylone`, and `ferme`.
|
||||
- `src/components/three/gameplay/RepairCompletionStep.tsx` renders the final repaired object, completion target, case exit animation, and mission UI prompt.
|
||||
- `src/components/three/gameplay/RepairGame.tsx` composes the reusable production repair flow.
|
||||
- `src/components/three/gameplay/RepairBrokenPartHighlight.tsx` renders the red halo and wire marker around detected broken parts during scanning.
|
||||
- `src/components/three/gameplay/RepairBrokenPartPrompt.tsx` centers the configured broken UI video on detected broken parts during scanning.
|
||||
- `src/components/three/gameplay/RepairInspectionObject.tsx` handles the `waiting` inspection interaction.
|
||||
- `src/components/three/gameplay/RepairMissionCase.tsx` renders the mission repair case after inspection.
|
||||
- `src/components/three/gameplay/RepairRepairingStep.tsx` renders grabbable replacement choices, grabbable scanned broken parts, placeholder placement markers, snap placement behavior, correct-part and broken-part placement validation, and the install trigger in `repairing`.
|
||||
- `src/components/three/gameplay/RepairReassemblyStep.tsx` renders the inverse fragmentation animation before the final completion step.
|
||||
- `src/components/three/gameplay/RepairCompletionParticles.tsx` renders the green completion particles during reassembly.
|
||||
- `src/components/three/gameplay/RepairPromptVideo.tsx` renders `.webm` prompts inside the 3D scene.
|
||||
- `src/components/three/gameplay/RepairScanSequence.tsx` keeps the exploded model visible and advances the scan from part to part.
|
||||
- `src/components/three/gameplay/RepairScanVisual.tsx` renders the scan halo and scan line around the active part.
|
||||
- `src/components/ui/RepairMovementLockIndicator.tsx` renders the HTML indicator shown while repair movement is locked.
|
||||
- `src/hooks/gameplay/useRepairFragmentationInput.ts` handles the `inspected -> fragmented` two-fists input and can optionally bind keyboard input for non-trigger flows.
|
||||
- `src/hooks/gameplay/useRepairMissionStep.ts` reads the active mission step from the game store.
|
||||
- `src/hooks/gameplay/useRepairMovementLocked.ts` exposes the shared repair movement-lock rule used by the player controller and UI indicator.
|
||||
- `src/hooks/handTracking/useBothFistsHold.ts` detects the reusable two-fists hold gesture.
|
||||
- `src/components/three/gameplay/RepairCaseModel.tsx` renders and animates the case model, and exposes `placeholder_*` transforms when the GLTF provides them.
|
||||
- `src/components/three/models/ExplodableModel.tsx` renders selectable models with split/exploded visualization.
|
||||
- `src/data/gameplay/repairCaseConfig.ts` stores repair case model, sound, and animation constants.
|
||||
- `src/data/gameplay/repairGameConfig.ts` stores repair zone and slot positions.
|
||||
- `src/data/gameplay/repairGameModelCatalog.ts` stores selectable repair models.
|
||||
- `src/data/gameplay/repairGameConfig.ts` stores repair flow timing constants.
|
||||
- `src/data/gameplay/repairMissions.ts` stores reusable repair mission config for `bike`, `pylone`, and `ferme`.
|
||||
- `src/managers/stores/useGameStore.ts` stores mission progression state and generic mission step helpers.
|
||||
- `src/types/gameplay/repairMission.ts` contains shared repair mission ids, mission steps, and guards used by the store, data config, debug UI, and gameplay components.
|
||||
|
||||
## Debug Requirements
|
||||
## Runtime Requirements
|
||||
|
||||
The repair-game prototype currently requires:
|
||||
The production repair flow currently requires:
|
||||
|
||||
- the app opened with `?debug`
|
||||
- the debug scene set to `Physics`
|
||||
- the active `mainState` to be one of `bike`, `pylone`, or `ferme`
|
||||
- `GameStageContent` mounted inside the game scene Rapier `Physics` boundary
|
||||
- model assets available under `public/models/`
|
||||
- sound assets available under `public/sounds/`
|
||||
|
||||
@@ -54,15 +82,17 @@ Frontend command:
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Debug URL:
|
||||
Debug URL for state switching and inspection:
|
||||
|
||||
```txt
|
||||
http://localhost:5173/?debug
|
||||
```
|
||||
|
||||
The debug physics scene keeps the existing grab, trigger, and animated model tests, and also exposes separate `Bike`, `Pylone`, and `Farm` repair playground zones. Use the debug game-state panel to switch `mainState`; selecting a locked repair mission in that panel opens it at `waiting`, and the matching repair zone mounts the same reusable `RepairGame` flow with that mission's model, broken parts, replacement parts, prompts, and timings.
|
||||
|
||||
## Related Hand Tracking
|
||||
|
||||
Hand tracking is a separate debug interaction layer. It can move grabbable physics objects with webcam input, but it is not yet integrated into the repair-game mission flow.
|
||||
Hand tracking can move grabbable physics objects with webcam input in debug scenes. In the production repair flow, it is also used for the `inspected -> fragmented` transition through the two-fists hold gesture.
|
||||
|
||||
For hand tracking, run the Python backend separately:
|
||||
|
||||
@@ -73,8 +103,8 @@ python -m backend.main
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- It is mounted only in the debug physics scene.
|
||||
- There is no mission progression system yet.
|
||||
- There is no central `GameManager` or Zustand store in this branch.
|
||||
- Hand tracking is available as debug interaction input, not as final repair gameplay.
|
||||
- The reusable production `RepairGame` currently covers `waiting -> inspected -> fragmented -> scanning -> repairing -> reassembling -> done -> next mission`.
|
||||
- Mission progression is wired through Zustand using `completeMission` at the end of each repair.
|
||||
- There is no central `GameManager` in this branch.
|
||||
- Hand tracking is available for the two-fists input and grabbable repair parts; case interaction and final installation still use the shared `E` trigger path.
|
||||
- The repair-game content is configured statically in `src/data/gameplay/`.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairBrokenPartHighlightProps {
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _worldPosition = new THREE.Vector3();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartHighlight({
|
||||
target,
|
||||
}: RepairBrokenPartHighlightProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
|
||||
_worldPosition.copy(_sphere.center);
|
||||
_localPosition.copy(_worldPosition);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5) * 0.08;
|
||||
const radius = Math.max(_sphere.radius, 0.35) * pulse;
|
||||
group.scale.setScalar(radius);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1, 32, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.14} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.06, 32, 16]} />
|
||||
<meshBasicMaterial
|
||||
color="#ef4444"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.65}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.12, 0.025, 8, 96]} />
|
||||
<meshBasicMaterial color="#dc2626" transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
|
||||
interface RepairBrokenPartPromptProps {
|
||||
src: string;
|
||||
target: THREE.Object3D;
|
||||
}
|
||||
|
||||
const _box = new THREE.Box3();
|
||||
const _sphere = new THREE.Sphere();
|
||||
const _localPosition = new THREE.Vector3();
|
||||
|
||||
export function RepairBrokenPartPrompt({
|
||||
src,
|
||||
target,
|
||||
}: RepairBrokenPartPromptProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(() => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
_box.setFromObject(target).getBoundingSphere(_sphere);
|
||||
_localPosition.copy(_sphere.center);
|
||||
group.parent?.worldToLocal(_localPosition);
|
||||
group.position.copy(_localPosition);
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairPromptVideo src={src} position={[0, 0, 0]} size={72} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -8,20 +8,39 @@ import {
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE,
|
||||
REPAIR_CASE_FLOAT_DOWN_SPEED,
|
||||
REPAIR_CASE_FLOAT_HEIGHT,
|
||||
REPAIR_CASE_EXIT_DURATION,
|
||||
REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
REPAIR_CASE_FLOAT_UP_SPEED,
|
||||
REPAIR_CASE_LID_NODE_NAME,
|
||||
REPAIR_CASE_OPEN_ROTATION_OFFSET_DEGREES,
|
||||
REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
REPAIR_CASE_PLACEHOLDER_NAME_PREFIX,
|
||||
REPAIR_CASE_POP_DURATION,
|
||||
REPAIR_CASE_POP_Y_OFFSET,
|
||||
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
|
||||
REPAIR_CASE_ROTATION_RESET_SPEED,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
export interface RepairCasePlaceholder {
|
||||
name: string;
|
||||
position: Vector3Tuple;
|
||||
}
|
||||
|
||||
interface RepairCaseModelProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
open: boolean;
|
||||
exiting?: boolean;
|
||||
floating?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
|
||||
@@ -37,6 +56,10 @@ const ROTATION_AMPLITUDE = THREE.MathUtils.degToRad(
|
||||
export function RepairCaseModel({
|
||||
modelPath,
|
||||
open,
|
||||
exiting = false,
|
||||
floating = true,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
@@ -55,22 +78,80 @@ export function RepairCaseModel({
|
||||
const floatHeight = useRef(0);
|
||||
const animationActiveRef = useRef(false);
|
||||
const phase = useRef({ x: 0, y: 0, z: 0 });
|
||||
const pop = useRef({ scale: 0.001, yOffset: REPAIR_CASE_POP_Y_OFFSET });
|
||||
const onExitCompleteRef = useRef(onExitComplete);
|
||||
const onPlaceholdersChangeRef = useRef(onPlaceholdersChange);
|
||||
const initialOpen = useRef(open);
|
||||
const previousOpen = useRef(open);
|
||||
const openedRotationZ = useRef(0);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const placeholderNodes = useRef<THREE.Object3D[]>([]);
|
||||
const placeholderSignature = useRef("__initial__");
|
||||
const placeholderPosition = useRef(new THREE.Vector3());
|
||||
const placeholderLocalPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useEffect(() => {
|
||||
onExitCompleteRef.current = onExitComplete;
|
||||
}, [onExitComplete]);
|
||||
|
||||
useEffect(() => {
|
||||
onPlaceholdersChangeRef.current = onPlaceholdersChange;
|
||||
}, [onPlaceholdersChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const popAnimation = pop.current;
|
||||
|
||||
phase.current = {
|
||||
x: Math.random() * Math.PI * 2,
|
||||
y: Math.random() * Math.PI * 2,
|
||||
z: Math.random() * Math.PI * 2,
|
||||
};
|
||||
|
||||
gsap.to(popAnimation, {
|
||||
scale: 1,
|
||||
yOffset: 0,
|
||||
duration: REPAIR_CASE_POP_DURATION,
|
||||
ease: "back.out(1.7)",
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!exiting) return undefined;
|
||||
|
||||
const popAnimation = pop.current;
|
||||
gsap.to(popAnimation, {
|
||||
scale: 0.001,
|
||||
yOffset: REPAIR_CASE_EXIT_Y_OFFSET,
|
||||
duration: REPAIR_CASE_EXIT_DURATION,
|
||||
ease: "back.in(1.4)",
|
||||
overwrite: true,
|
||||
onComplete: () => {
|
||||
onExitCompleteRef.current?.();
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
gsap.killTweensOf(popAnimation);
|
||||
};
|
||||
}, [exiting]);
|
||||
|
||||
useEffect(() => {
|
||||
const lid = model.getObjectByName(REPAIR_CASE_LID_NODE_NAME);
|
||||
lidRef.current = lid ?? null;
|
||||
openedRotationZ.current = lid?.rotation.z ?? 0;
|
||||
placeholderNodes.current = [];
|
||||
|
||||
model.traverse((child) => {
|
||||
if (
|
||||
child.name.toLowerCase().startsWith(REPAIR_CASE_PLACEHOLDER_NAME_PREFIX)
|
||||
) {
|
||||
placeholderNodes.current.push(child);
|
||||
}
|
||||
});
|
||||
|
||||
if (lid) {
|
||||
lid.rotation.z =
|
||||
@@ -100,14 +181,26 @@ export function RepairCaseModel({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousOpen.current === open) return;
|
||||
|
||||
previousOpen.current = open;
|
||||
AudioManager.getInstance().playSound(
|
||||
open ? REPAIR_CASE_OPEN_SOUND_PATH : REPAIR_CASE_CLOSE_SOUND_PATH,
|
||||
0.85,
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
useFrame(({ clock }, delta) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.getWorldPosition(worldPosition.current);
|
||||
const isNear =
|
||||
floating &&
|
||||
!exiting &&
|
||||
worldPosition.current.distanceTo(camera.position) <=
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
REPAIR_CASE_FLOAT_ACTIVATION_DISTANCE;
|
||||
const targetHeight = isNear ? REPAIR_CASE_FLOAT_HEIGHT : 0;
|
||||
const floatSpeed = isNear
|
||||
? REPAIR_CASE_FLOAT_UP_SPEED
|
||||
@@ -119,7 +212,43 @@ export function RepairCaseModel({
|
||||
floatSpeed,
|
||||
delta,
|
||||
);
|
||||
group.position.y = position[1] + floatHeight.current;
|
||||
group.position.y = position[1] + floatHeight.current + pop.current.yOffset;
|
||||
group.scale.set(
|
||||
parsedScale[0] * pop.current.scale,
|
||||
parsedScale[1] * pop.current.scale,
|
||||
parsedScale[2] * pop.current.scale,
|
||||
);
|
||||
|
||||
if (placeholderNodes.current.length > 0) {
|
||||
const placeholders: RepairCasePlaceholder[] = [];
|
||||
placeholderNodes.current.forEach((child) => {
|
||||
child.getWorldPosition(placeholderPosition.current);
|
||||
placeholderLocalPosition.current.copy(placeholderPosition.current);
|
||||
group.parent?.worldToLocal(placeholderLocalPosition.current);
|
||||
placeholders.push({
|
||||
name: child.name,
|
||||
position: [
|
||||
placeholderLocalPosition.current.x,
|
||||
placeholderLocalPosition.current.y,
|
||||
placeholderLocalPosition.current.z,
|
||||
],
|
||||
});
|
||||
});
|
||||
placeholders.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const nextSignature = placeholders
|
||||
.map(
|
||||
(placeholder) =>
|
||||
`${placeholder.name}:${placeholder.position
|
||||
.map((value) => value.toFixed(3))
|
||||
.join(",")}`,
|
||||
)
|
||||
.join("|");
|
||||
if (nextSignature !== placeholderSignature.current) {
|
||||
placeholderSignature.current = nextSignature;
|
||||
onPlaceholdersChangeRef.current?.(placeholders);
|
||||
}
|
||||
}
|
||||
|
||||
animationActiveRef.current = isNear;
|
||||
|
||||
@@ -158,12 +287,7 @@ export function RepairCaseModel({
|
||||
});
|
||||
|
||||
return (
|
||||
<group
|
||||
ref={groupRef}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={parsedScale}
|
||||
>
|
||||
<group ref={groupRef} position={position} rotation={rotation} scale={0.001}>
|
||||
<primitive object={model} />
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { RepairCaseModel } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import {
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
REPAIR_CASE_OPEN_SOUND_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { AudioManager } from "@/managers/AudioManager";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
|
||||
const REPAIR_CASE_PAN_RANGE = 20;
|
||||
|
||||
interface RepairCaseErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RepairCaseErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
class RepairCaseErrorBoundary extends Component<
|
||||
RepairCaseErrorBoundaryProps,
|
||||
RepairCaseErrorBoundaryState
|
||||
> {
|
||||
constructor(props: RepairCaseErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): RepairCaseErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: REPAIR_CASE_MODEL_PATH,
|
||||
scope: "RepairCaseObject",
|
||||
position: [0, -0.45, 0],
|
||||
scale: 1.5,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return <RepairCaseFallback />;
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
interface RepairCaseObjectProps {
|
||||
position: Vector3Tuple;
|
||||
open: boolean;
|
||||
onInspect: () => void;
|
||||
}
|
||||
|
||||
export function RepairCaseObject({
|
||||
position,
|
||||
open,
|
||||
onInspect,
|
||||
}: RepairCaseObjectProps): React.JSX.Element {
|
||||
const pan = Math.max(-1, Math.min(1, position[0] / REPAIR_CASE_PAN_RANGE));
|
||||
|
||||
return (
|
||||
<TriggerObject
|
||||
position={position}
|
||||
colliders="cuboid"
|
||||
label={open ? "Mallette inspectée" : "Inspecter la mallette"}
|
||||
onTrigger={() => {
|
||||
if (open) return;
|
||||
AudioManager.getInstance().playSound(REPAIR_CASE_OPEN_SOUND_PATH, 1, {
|
||||
category: "sfx",
|
||||
pan,
|
||||
});
|
||||
onInspect();
|
||||
}}
|
||||
>
|
||||
<RepairCaseErrorBoundary>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
open={open}
|
||||
position={[0, -0.45, 0]}
|
||||
scale={1.5}
|
||||
/>
|
||||
</RepairCaseErrorBoundary>
|
||||
</TriggerObject>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairCaseFallback(): React.JSX.Element {
|
||||
return (
|
||||
<group position={[0, -0.25, 0]}>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1.5, 0.5, 1]} />
|
||||
<meshStandardMaterial color="#2563eb" roughness={0.55} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.35, -0.25]} castShadow receiveShadow>
|
||||
<boxGeometry args={[1.5, 0.12, 0.65]} />
|
||||
<meshStandardMaterial color="#1d4ed8" roughness={0.55} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
const PARTICLES = Array.from({ length: 24 }, (_, index) => {
|
||||
const angle = (index / 24) * Math.PI * 2;
|
||||
const ring = index % 3;
|
||||
return {
|
||||
angle,
|
||||
radius: 0.45 + ring * 0.28,
|
||||
y: 0.35 + (index % 5) * 0.16,
|
||||
speed: 0.8 + (index % 4) * 0.18,
|
||||
};
|
||||
});
|
||||
|
||||
export function RepairCompletionParticles(): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
if (!group) return;
|
||||
|
||||
group.rotation.y = clock.elapsedTime * 0.9;
|
||||
group.children.forEach((child, index) => {
|
||||
const particle = PARTICLES[index];
|
||||
if (!particle) return;
|
||||
|
||||
const pulse = 1 + Math.sin(clock.elapsedTime * 5 + index) * 0.35;
|
||||
child.position.y =
|
||||
particle.y + Math.sin(clock.elapsedTime * particle.speed) * 0.08;
|
||||
child.scale.setScalar(pulse);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
{PARTICLES.map((particle, index) => (
|
||||
<mesh
|
||||
key={index}
|
||||
position={[
|
||||
Math.cos(particle.angle) * particle.radius,
|
||||
particle.y,
|
||||
Math.sin(particle.angle) * particle.radius,
|
||||
]}
|
||||
>
|
||||
<sphereGeometry args={[0.045, 12, 12]} />
|
||||
<meshBasicMaterial color="#86efac" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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";
|
||||
import { REPAIR_CASE_ANIMATION_DURATION } from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
|
||||
interface RepairCompletionStepProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function RepairCompletionStep({
|
||||
config,
|
||||
onComplete,
|
||||
}: RepairCompletionStepProps): React.JSX.Element {
|
||||
const [isClosingCase, setIsClosingCase] = useState(false);
|
||||
const [isExitingCase, setIsExitingCase] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isClosingCase) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setIsExitingCase(true);
|
||||
}, REPAIR_CASE_ANIMATION_DURATION * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isClosingCase]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
exiting={isExitingCase}
|
||||
open={!isClosingCase}
|
||||
onExitComplete={onComplete}
|
||||
/>
|
||||
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
/>
|
||||
|
||||
{!isClosingCase ? (
|
||||
<TriggerObject
|
||||
position={[0, 1.1, 0]}
|
||||
colliders="ball"
|
||||
label={`Valider ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => setIsClosingCase(true)}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[1.35, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color="#22c55e" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.2, 1.25, 96]} />
|
||||
<meshBasicMaterial color="#bbf7d0" transparent opacity={0.3} />
|
||||
</mesh>
|
||||
</TriggerObject>
|
||||
) : null}
|
||||
|
||||
{!isClosingCase ? (
|
||||
<RepairPromptVideo src={config.stageUiPath} position={[0, 2.55, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { useGLTF } from "@react-three/drei";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairCompletionStep } from "@/components/three/gameplay/RepairCompletionStep";
|
||||
import { RepairInspectionObject } from "@/components/three/gameplay/RepairInspectionObject";
|
||||
import { RepairMissionCase } from "@/components/three/gameplay/RepairMissionCase";
|
||||
import { RepairRepairingStep } from "@/components/three/gameplay/RepairRepairingStep";
|
||||
import { RepairReassemblyStep } from "@/components/three/gameplay/RepairReassemblyStep";
|
||||
import {
|
||||
RepairScanSequence,
|
||||
type RepairScannedBrokenPart,
|
||||
} 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_MISSIONS,
|
||||
type RepairMissionConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import { useRepairFragmentationInput } from "@/hooks/gameplay/useRepairFragmentationInput";
|
||||
import { useRepairMissionStep } from "@/hooks/gameplay/useRepairMissionStep";
|
||||
import type {
|
||||
MissionStep,
|
||||
RepairMissionId,
|
||||
} from "@/types/gameplay/repairMission";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairGameProps extends Required<
|
||||
Pick<ModelTransformProps, "position">
|
||||
> {
|
||||
mission: RepairMissionId;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: ModelTransformProps["scale"];
|
||||
}
|
||||
|
||||
interface RepairMissionAssetPreloaderProps {
|
||||
config: RepairMissionConfig;
|
||||
}
|
||||
|
||||
function RepairMissionAssetPreloader({
|
||||
config,
|
||||
}: RepairMissionAssetPreloaderProps): null {
|
||||
const modelPaths = useMemo(
|
||||
() => getRepairMissionModelPaths(config),
|
||||
[config],
|
||||
);
|
||||
|
||||
useGLTF(modelPaths);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RepairGame({
|
||||
mission,
|
||||
position,
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairGameProps): React.JSX.Element | null {
|
||||
const config = REPAIR_MISSIONS[mission];
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const completeMission = useGameStore((state) => state.completeMission);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
const step = useRepairMissionStep(mission);
|
||||
const [casePlaceholders, setCasePlaceholders] = useState<
|
||||
readonly RepairCasePlaceholder[]
|
||||
>([]);
|
||||
const [scannedBrokenParts, setScannedBrokenParts] = useState<
|
||||
readonly RepairScannedBrokenPart[]
|
||||
>([]);
|
||||
const parsedScale = toVector3Scale(scale);
|
||||
const readyForFragmentation = step === "inspected";
|
||||
|
||||
useRepairFragmentationInput({
|
||||
enabled: mainState === mission && readyForFragmentation,
|
||||
keyboardEnabled: false,
|
||||
onFragment: () => setMissionStep(mission, "fragmented"),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState === mission && shouldKeepRepairRuntimeState(step)) return;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setCasePlaceholders([]);
|
||||
setScannedBrokenParts([]);
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, step]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mainState !== mission) return undefined;
|
||||
|
||||
if (step !== "fragmented") return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setMissionStep(mission, "scanning");
|
||||
}, REPAIR_FRAGMENTATION_SEQUENCE_SECONDS * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [mainState, mission, setMissionStep, step]);
|
||||
|
||||
if (mainState !== mission) return null;
|
||||
if (step === "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={position} rotation={rotation} scale={parsedScale}>
|
||||
<Suspense fallback={null}>
|
||||
<RepairMissionAssetPreloader config={config} />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{step === "waiting" ? (
|
||||
<RepairInspectionObject
|
||||
config={config}
|
||||
worldPosition={position}
|
||||
onInspect={() => setMissionStep(mission, "inspected")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "fragmented" ? (
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
/>
|
||||
) : null}
|
||||
{step === "scanning" ? (
|
||||
<RepairScanSequence
|
||||
config={config}
|
||||
onComplete={(brokenParts) => {
|
||||
setScannedBrokenParts(brokenParts);
|
||||
setMissionStep(mission, "repairing");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{step === "repairing" ? (
|
||||
<RepairRepairingStep
|
||||
brokenParts={scannedBrokenParts}
|
||||
config={config}
|
||||
placeholders={casePlaceholders}
|
||||
onRepair={() => setMissionStep(mission, "reassembling")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "reassembling" ? (
|
||||
<RepairReassemblyStep
|
||||
config={config}
|
||||
onComplete={() => setMissionStep(mission, "done")}
|
||||
/>
|
||||
) : null}
|
||||
{step === "done" ? (
|
||||
<RepairCompletionStep
|
||||
config={config}
|
||||
onComplete={() => completeMission(mission)}
|
||||
/>
|
||||
) : null}
|
||||
{step !== "waiting" && step !== "done" && step !== "reassembling" ? (
|
||||
<RepairMissionCase
|
||||
config={config}
|
||||
onPlaceholdersChange={setCasePlaceholders}
|
||||
open={step === "repairing"}
|
||||
zoomed={step === "repairing"}
|
||||
showFragmentationPrompt={readyForFragmentation}
|
||||
onInteract={
|
||||
readyForFragmentation
|
||||
? () => setMissionStep(mission, "fragmented")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Suspense>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldKeepRepairRuntimeState(step: MissionStep): boolean {
|
||||
return step === "repairing" || step === "reassembling" || step === "done";
|
||||
}
|
||||
|
||||
function getRepairMissionModelPaths(config: RepairMissionConfig): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
config.modelPath,
|
||||
...config.brokenParts.flatMap((part) => part.modelPath ?? []),
|
||||
...config.replacementParts.flatMap((part) => part.modelPath ?? []),
|
||||
]),
|
||||
];
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Text } from "@react-three/drei";
|
||||
import { RepairCaseObject } from "@/components/three/gameplay/RepairCaseObject";
|
||||
import { RepairModuleSlot } from "@/components/three/gameplay/RepairModuleSlot";
|
||||
import {
|
||||
REPAIR_GAME_MODULE_SLOTS,
|
||||
REPAIR_GAME_ZONE_LABEL,
|
||||
REPAIR_GAME_ZONE_ORIGIN,
|
||||
REPAIR_GAME_ZONE_RADIUS,
|
||||
} from "@/data/gameplay/repairGameConfig";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { playGameplayDialogueById } from "@/utils/dialogues/playDialogue";
|
||||
|
||||
const CASE_CLOSED_STEPS = new Set(["locked", "waiting"]);
|
||||
|
||||
export function RepairGameZone(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const setMainState = useGameStore((state) => state.setMainState);
|
||||
const setBikeState = useGameStore((state) => state.setBikeState);
|
||||
const caseOpen = !CASE_CLOSED_STEPS.has(bikeStep);
|
||||
const slotsDisabled = !caseOpen;
|
||||
|
||||
const inspectRepairCase = (): void => {
|
||||
if (mainState !== "bike") {
|
||||
setMainState("bike");
|
||||
}
|
||||
|
||||
if (CASE_CLOSED_STEPS.has(bikeStep)) {
|
||||
setBikeState({ currentStep: "inspected" });
|
||||
void playGameplayDialogueById("narrateur_ebikecasse");
|
||||
}
|
||||
};
|
||||
|
||||
const markModelSelected = (): void => {
|
||||
if (mainState !== "bike") {
|
||||
setMainState("bike");
|
||||
}
|
||||
|
||||
if (bikeStep === "inspected") {
|
||||
setBikeState({ currentStep: "fragmented" });
|
||||
}
|
||||
};
|
||||
|
||||
const markModuleSplit = (): void => {
|
||||
if (mainState !== "bike") {
|
||||
setMainState("bike");
|
||||
}
|
||||
|
||||
if (bikeStep === "fragmented") {
|
||||
setBikeState({ currentStep: "scanning" });
|
||||
void playGameplayDialogueById("narrateur_galetscan");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<group>
|
||||
<mesh
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
0.025,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<ringGeometry
|
||||
args={[REPAIR_GAME_ZONE_RADIUS - 0.08, REPAIR_GAME_ZONE_RADIUS, 96]}
|
||||
/>
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.72} />
|
||||
</mesh>
|
||||
|
||||
<mesh
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
0.02,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2],
|
||||
]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<circleGeometry args={[REPAIR_GAME_ZONE_RADIUS, 96]} />
|
||||
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
|
||||
<Text
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0],
|
||||
3.1,
|
||||
REPAIR_GAME_ZONE_ORIGIN[2] - 1.8,
|
||||
]}
|
||||
rotation={[0, 0, 0]}
|
||||
fontSize={0.55}
|
||||
maxWidth={5.5}
|
||||
textAlign="center"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
color="#f8fafc"
|
||||
outlineWidth={0.025}
|
||||
outlineColor="#0f172a"
|
||||
>
|
||||
{REPAIR_GAME_ZONE_LABEL}
|
||||
</Text>
|
||||
|
||||
<RepairCaseObject
|
||||
position={REPAIR_GAME_ZONE_ORIGIN}
|
||||
open={caseOpen}
|
||||
onInspect={inspectRepairCase}
|
||||
/>
|
||||
|
||||
{REPAIR_GAME_MODULE_SLOTS.map((slot) => (
|
||||
<RepairModuleSlot
|
||||
key={slot.label}
|
||||
label={slot.label}
|
||||
position={[
|
||||
REPAIR_GAME_ZONE_ORIGIN[0] + slot.offset[0],
|
||||
REPAIR_GAME_ZONE_ORIGIN[1] + slot.offset[1],
|
||||
REPAIR_GAME_ZONE_ORIGIN[2] + slot.offset[2],
|
||||
]}
|
||||
disabled={slotsDisabled}
|
||||
onModelSelected={markModelSelected}
|
||||
onSplit={markModuleSplit}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairInspectionObjectProps {
|
||||
config: RepairMissionConfig;
|
||||
worldPosition: Vector3Tuple;
|
||||
onInspect: () => void;
|
||||
}
|
||||
|
||||
export function RepairInspectionObject({
|
||||
config,
|
||||
worldPosition,
|
||||
onInspect,
|
||||
}: RepairInspectionObjectProps): React.JSX.Element {
|
||||
return (
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={`Inspecter ${config.label}`}
|
||||
position={worldPosition}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onPress={onInspect}
|
||||
>
|
||||
<RepairObjectModel
|
||||
label={config.label}
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 0.9}
|
||||
/>
|
||||
<RepairPromptVideo src={config.stageUiPath} />
|
||||
</InteractableObject>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
RepairCaseModel,
|
||||
type RepairCasePlaceholder,
|
||||
} from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_FOCUS_SCALE,
|
||||
REPAIR_CASE_MODEL_PATH,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type { RepairMissionConfig } from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairMissionCaseProps {
|
||||
config: RepairMissionConfig;
|
||||
exiting?: boolean;
|
||||
onPlaceholdersChange?:
|
||||
| ((placeholders: readonly RepairCasePlaceholder[]) => void)
|
||||
| undefined;
|
||||
onExitComplete?: (() => void) | undefined;
|
||||
open?: boolean;
|
||||
zoomed?: boolean;
|
||||
showFragmentationPrompt?: boolean;
|
||||
onInteract?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
export function RepairMissionCase({
|
||||
config,
|
||||
exiting = false,
|
||||
onPlaceholdersChange,
|
||||
onExitComplete,
|
||||
open = false,
|
||||
zoomed = false,
|
||||
showFragmentationPrompt = false,
|
||||
onInteract,
|
||||
}: RepairMissionCaseProps): React.JSX.Element {
|
||||
const casePosition = zoomed
|
||||
? REPAIR_CASE_FOCUS_POSITION
|
||||
: config.case.position;
|
||||
const caseScale = zoomed ? REPAIR_CASE_FOCUS_SCALE : config.case.scale;
|
||||
const modelPosition: Vector3Tuple = onInteract ? [0, 0, 0] : casePosition;
|
||||
|
||||
return (
|
||||
<group>
|
||||
{onInteract ? (
|
||||
<TriggerObject
|
||||
position={casePosition}
|
||||
colliders="ball"
|
||||
label={`Ouvrir ${config.label}`}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={onInteract}
|
||||
>
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
</TriggerObject>
|
||||
) : (
|
||||
<RepairCaseModel
|
||||
modelPath={REPAIR_CASE_MODEL_PATH}
|
||||
exiting={exiting}
|
||||
onExitComplete={onExitComplete}
|
||||
onPlaceholdersChange={onPlaceholdersChange}
|
||||
open={open}
|
||||
floating={!zoomed}
|
||||
position={modelPosition}
|
||||
rotation={config.case.rotation}
|
||||
scale={caseScale}
|
||||
/>
|
||||
)}
|
||||
{showFragmentationPrompt && !exiting ? (
|
||||
<RepairPromptVideo
|
||||
src={config.interactUiPath}
|
||||
position={[casePosition[0], 2.4, casePosition[2]]}
|
||||
size={80}
|
||||
/>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import { useCallback, useState } from "react";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import { ExplodableModel } from "@/components/three/models/ExplodableModel";
|
||||
import { REPAIR_GAME_MODEL_CATALOG } from "@/data/gameplay/repairGameModelCatalog";
|
||||
import type { ModelCatalogItem } from "@/data/gameplay/repairGameModelCatalog";
|
||||
import { useModelSelection } from "@/hooks/gameplay/useModelSelection";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairModuleSlotProps {
|
||||
position: Vector3Tuple;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onModelSelected?: () => void;
|
||||
onSplit?: () => void;
|
||||
}
|
||||
|
||||
export function RepairModuleSlot({
|
||||
position,
|
||||
label,
|
||||
disabled = false,
|
||||
onModelSelected,
|
||||
onSplit,
|
||||
}: RepairModuleSlotProps): React.JSX.Element {
|
||||
const [selectedModel, setSelectedModel] = useState<ModelCatalogItem | null>(
|
||||
null,
|
||||
);
|
||||
const [split, setSplit] = useState(false);
|
||||
const handleSelect = useCallback(
|
||||
(model: ModelCatalogItem) => {
|
||||
setSelectedModel(model);
|
||||
setSplit(false);
|
||||
onModelSelected?.();
|
||||
},
|
||||
[onModelSelected],
|
||||
);
|
||||
const selection = useModelSelection(REPAIR_GAME_MODEL_CATALOG, handleSelect);
|
||||
const triggerLabel = disabled
|
||||
? "Ouvrir la mallette d'abord"
|
||||
: selectedModel
|
||||
? split
|
||||
? `Réassembler ${label}`
|
||||
: `Démonter ${label}`
|
||||
: `Choisir ${label}`;
|
||||
|
||||
return (
|
||||
<group>
|
||||
<TriggerObject
|
||||
position={position}
|
||||
colliders="cuboid"
|
||||
label={triggerLabel}
|
||||
onTrigger={() => {
|
||||
if (disabled) return;
|
||||
|
||||
if (selectedModel) {
|
||||
setSplit((value) => {
|
||||
const nextSplit = !value;
|
||||
if (nextSplit) {
|
||||
onSplit?.();
|
||||
}
|
||||
return nextSplit;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
selection.open();
|
||||
}}
|
||||
>
|
||||
{selectedModel ? (
|
||||
<ExplodableModel
|
||||
modelPath={selectedModel.path}
|
||||
split={split}
|
||||
position={[0, -0.35, 0]}
|
||||
scale={0.45}
|
||||
/>
|
||||
) : (
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1, 0.18, 1]} />
|
||||
<meshStandardMaterial
|
||||
color="#38bdf8"
|
||||
emissive="#082f49"
|
||||
roughness={0.55}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
</TriggerObject>
|
||||
|
||||
{selection.isOpen ? (
|
||||
<Html position={[position[0], position[1] + 1.2, position[2]]} center>
|
||||
<div className="model-selector-panel">
|
||||
<strong>{label}</strong>
|
||||
<span>Fleches: choisir</span>
|
||||
<span>E/Enter: valider</span>
|
||||
<ul>
|
||||
{REPAIR_GAME_MODEL_CATALOG.map((model, index) => (
|
||||
<li
|
||||
key={model.path}
|
||||
className={
|
||||
index === selection.selectedIndex
|
||||
? "is-selected"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{model.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Html>
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Component } from "react";
|
||||
import { SimpleModel } from "@/components/three/models/SimpleModel";
|
||||
import type { ModelTransformProps } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
|
||||
interface RepairObjectModelProps extends ModelTransformProps {
|
||||
label: string;
|
||||
modelPath: string;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryProps extends RepairObjectModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface RepairObjectModelBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
interface RepairObjectFallbackProps {
|
||||
label: string;
|
||||
position?: ModelTransformProps["position"] | undefined;
|
||||
rotation?: ModelTransformProps["rotation"] | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}
|
||||
|
||||
class RepairObjectModelBoundary extends Component<
|
||||
RepairObjectModelBoundaryProps,
|
||||
RepairObjectModelBoundaryState
|
||||
> {
|
||||
constructor(props: RepairObjectModelBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): RepairObjectModelBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error): void {
|
||||
logModelLoadError(
|
||||
{
|
||||
modelPath: this.props.modelPath,
|
||||
position: this.props.position,
|
||||
rotation: this.props.rotation,
|
||||
scale: this.props.scale,
|
||||
scope: `RepairObjectModel.${this.props.label}`,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<RepairObjectFallback
|
||||
label={this.props.label}
|
||||
position={this.props.position}
|
||||
rotation={this.props.rotation}
|
||||
scale={this.props.scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function RepairObjectModel({
|
||||
label,
|
||||
modelPath,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: RepairObjectModelProps): React.JSX.Element {
|
||||
return (
|
||||
<RepairObjectModelBoundary
|
||||
label={label}
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
>
|
||||
<SimpleModel
|
||||
modelPath={modelPath}
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={scale}
|
||||
/>
|
||||
</RepairObjectModelBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairObjectFallback({
|
||||
label,
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: Pick<
|
||||
RepairObjectFallbackProps,
|
||||
"label" | "position" | "rotation" | "scale"
|
||||
>): React.JSX.Element {
|
||||
return (
|
||||
<group
|
||||
position={position}
|
||||
rotation={rotation}
|
||||
scale={toVector3Scale(scale)}
|
||||
>
|
||||
<mesh castShadow receiveShadow>
|
||||
<boxGeometry args={[1.4, 1.4, 1.4]} />
|
||||
<meshStandardMaterial color="#facc15" roughness={0.6} wireframe />
|
||||
</mesh>
|
||||
<mesh position={[0, 1.05, 0]}>
|
||||
<sphereGeometry args={[0.08, 16, 16]} />
|
||||
<meshBasicMaterial color={label ? "#f8fafc" : "#facc15"} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { WorldVideoPrompt } from "@/components/three/ui/WorldVideoPrompt";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface RepairPromptVideoProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function RepairPromptVideo({
|
||||
src,
|
||||
position = [0, 1.8, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: RepairPromptVideoProps): React.JSX.Element {
|
||||
return (
|
||||
<WorldVideoPrompt
|
||||
billboard={billboard}
|
||||
position={position}
|
||||
size={size}
|
||||
src={src}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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 "@/data/gameplay/repairMissions";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import type { RepairCasePlaceholder } from "@/components/three/gameplay/RepairCaseModel";
|
||||
import { RepairObjectModel } from "@/components/three/gameplay/RepairObjectModel";
|
||||
import { RepairPromptVideo } from "@/components/three/gameplay/RepairPromptVideo";
|
||||
import type { RepairScannedBrokenPart } from "@/components/three/gameplay/RepairScanSequence";
|
||||
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
|
||||
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
|
||||
import {
|
||||
REPAIR_CASE_FOCUS_POSITION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_DURATION,
|
||||
REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS,
|
||||
} from "@/data/gameplay/repairCaseConfig";
|
||||
import { REPAIR_INTERACTION_RADIUS } from "@/data/gameplay/repairGameConfig";
|
||||
import type {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
const INSTALL_TARGET_POSITION: Vector3Tuple = [0, 0.8, 0];
|
||||
const _placeholderPosition = new THREE.Vector3();
|
||||
const FALLBACK_PLACEHOLDER_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.15, 1, 0.25],
|
||||
[0, 1.05, 0.45],
|
||||
[1.15, 1, 0.25],
|
||||
];
|
||||
const BROKEN_PART_START_OFFSETS: Vector3Tuple[] = [
|
||||
[-1.35, 0.55, -0.85],
|
||||
[0, 0.6, -1],
|
||||
[1.35, 0.55, -0.85],
|
||||
];
|
||||
const REPAIR_INSTALL_RADIUS = 1.1;
|
||||
const VALID_PART_COLOR = "#22c55e";
|
||||
const INVALID_PART_COLOR = "#ef4444";
|
||||
const STORED_BROKEN_PART_COLOR = "#38bdf8";
|
||||
|
||||
interface RepairRepairingStepProps {
|
||||
brokenParts: readonly RepairScannedBrokenPart[];
|
||||
config: RepairMissionConfig;
|
||||
placeholders: readonly RepairCasePlaceholder[];
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairInstallTargetProps {
|
||||
blockedFeedback: boolean;
|
||||
fillColor: string;
|
||||
isReadyToInstall: boolean;
|
||||
label: string;
|
||||
ringColor: string;
|
||||
onBlocked: () => void;
|
||||
onRepair: () => void;
|
||||
}
|
||||
|
||||
interface RepairPlaceholderMarkersProps {
|
||||
positions: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface RepairPartPlacementFeedbackProps {
|
||||
state: "valid" | "invalid" | "stored" | null;
|
||||
}
|
||||
|
||||
export function RepairRepairingStep({
|
||||
brokenParts,
|
||||
config,
|
||||
placeholders,
|
||||
onRepair,
|
||||
}: RepairRepairingStepProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
const [placedPartIds, setPlacedPartIds] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [depositedBrokenPartIds, setDepositedBrokenPartIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [showBlockedInstallFeedback, setShowBlockedInstallFeedback] =
|
||||
useState(false);
|
||||
const replacementParts = getReplacementParts(config);
|
||||
const brokenPartsToDeposit = getBrokenPartsToDeposit(config, brokenParts);
|
||||
const requiredReplacementPart = replacementParts.find(
|
||||
(part) => part.id === config.requiredReplacementPartId,
|
||||
);
|
||||
const requiredReplacementLabel =
|
||||
requiredReplacementPart?.label ?? config.label;
|
||||
const placeholderTargets = getPlaceholderTargets(placeholders);
|
||||
const placeholderPositions = placeholderTargets.map(
|
||||
(target) => target.position,
|
||||
);
|
||||
const hasCorrectPartPlaced = Boolean(
|
||||
placedPartIds[config.requiredReplacementPartId],
|
||||
);
|
||||
const hasDepositedBrokenParts = brokenPartsToDeposit.every(
|
||||
(part) => depositedBrokenPartIds[part.id],
|
||||
);
|
||||
const hasWrongPartPlaced = replacementParts.some(
|
||||
(part) =>
|
||||
part.id !== config.requiredReplacementPartId && placedPartIds[part.id],
|
||||
);
|
||||
const isReadyToInstall = hasCorrectPartPlaced && hasDepositedBrokenParts;
|
||||
const installColor = isReadyToInstall
|
||||
? "#22c55e"
|
||||
: hasWrongPartPlaced
|
||||
? "#ef4444"
|
||||
: "#f97316";
|
||||
const installFillColor = isReadyToInstall
|
||||
? "#86efac"
|
||||
: hasWrongPartPlaced
|
||||
? "#fecaca"
|
||||
: "#fed7aa";
|
||||
const installLabel = isReadyToInstall
|
||||
? `Installer ${requiredReplacementLabel}`
|
||||
: hasWrongPartPlaced
|
||||
? `Mauvaise pièce`
|
||||
: hasCorrectPartPlaced
|
||||
? `Ranger pièce cassée`
|
||||
: `Approcher ${requiredReplacementLabel}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!showBlockedInstallFeedback) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShowBlockedInstallFeedback(false);
|
||||
}, 900);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [showBlockedInstallFeedback]);
|
||||
|
||||
function handleReplacementPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
): void {
|
||||
const isPlaced = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
placeholderPositions,
|
||||
);
|
||||
setPlacedPartIds((current) => {
|
||||
if (!current[partId] || isPlaced) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleReplacementSnap(partId: string): void {
|
||||
setPlacedPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartPosition(
|
||||
partId: string,
|
||||
position: THREE.Vector3,
|
||||
targets: readonly Vector3Tuple[],
|
||||
): void {
|
||||
const isDeposited = isNearPlaceholder(
|
||||
getStepLocalPosition(position, groupRef.current, localPosition.current),
|
||||
targets,
|
||||
);
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (!current[partId] || isDeposited) return current;
|
||||
|
||||
return { ...current, [partId]: false };
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrokenPartSnap(partId: string): void {
|
||||
setDepositedBrokenPartIds((current) => {
|
||||
if (current[partId]) return current;
|
||||
|
||||
return { ...current, [partId]: true };
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<RepairInstallTarget
|
||||
blockedFeedback={showBlockedInstallFeedback}
|
||||
fillColor={installFillColor}
|
||||
isReadyToInstall={isReadyToInstall}
|
||||
label={installLabel}
|
||||
ringColor={installColor}
|
||||
onBlocked={() => setShowBlockedInstallFeedback(true)}
|
||||
onRepair={onRepair}
|
||||
/>
|
||||
|
||||
<RepairPlaceholderMarkers positions={placeholderPositions} />
|
||||
|
||||
{replacementParts.map((part, index) => {
|
||||
const placeholderPosition =
|
||||
placeholderPositions[index % placeholderPositions.length] ??
|
||||
placeholderPositions[0]!;
|
||||
const isPlaced = Boolean(placedPartIds[part.id]);
|
||||
const feedbackState = getReplacementFeedbackState(
|
||||
part.id,
|
||||
config.requiredReplacementPartId,
|
||||
isPlaced,
|
||||
);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={placeholderPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Prendre ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleReplacementPosition(part.id, position);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleReplacementSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={placeholderPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath ?? config.modelPath}
|
||||
scale={0.36}
|
||||
/>
|
||||
<RepairPartPlacementFeedback state={feedbackState} />
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{brokenPartsToDeposit.map((part, index) => {
|
||||
const startOffset =
|
||||
BROKEN_PART_START_OFFSETS[index % BROKEN_PART_START_OFFSETS.length] ??
|
||||
BROKEN_PART_START_OFFSETS[0]!;
|
||||
const startPosition: Vector3Tuple = [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + startOffset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + startOffset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + startOffset[2],
|
||||
];
|
||||
const targetPositions = getBrokenPartTargetPositions(
|
||||
part,
|
||||
placeholderTargets,
|
||||
);
|
||||
const isDeposited = Boolean(depositedBrokenPartIds[part.id]);
|
||||
|
||||
return (
|
||||
<GrabbableObject
|
||||
key={part.id}
|
||||
position={startPosition}
|
||||
colliders="ball"
|
||||
handControlled
|
||||
label={`Ranger ${part.label}`}
|
||||
onPositionChange={(position) => {
|
||||
handleBrokenPartPosition(part.id, position, targetPositions);
|
||||
}}
|
||||
onSnap={() => {
|
||||
handleBrokenPartSnap(part.id);
|
||||
}}
|
||||
snapDuration={REPAIR_CASE_PLACEHOLDER_SNAP_DURATION}
|
||||
snapRadius={REPAIR_CASE_PLACEHOLDER_SNAP_RADIUS}
|
||||
snapTargets={targetPositions}
|
||||
>
|
||||
<group>
|
||||
<RepairObjectModel
|
||||
label={part.label}
|
||||
modelPath={part.modelPath}
|
||||
scale={0.24}
|
||||
/>
|
||||
<mesh position={[0, 0.42, 0]}>
|
||||
<sphereGeometry args={[0.11, 16, 16]} />
|
||||
<meshBasicMaterial color="#ef4444" transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<RepairPartPlacementFeedback
|
||||
state={isDeposited ? "stored" : null}
|
||||
/>
|
||||
</group>
|
||||
</GrabbableObject>
|
||||
);
|
||||
})}
|
||||
|
||||
{isReadyToInstall ? (
|
||||
<RepairPromptVideo src={config.interactUiPath} position={[0, 2.3, 0]} />
|
||||
) : null}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairInstallTarget({
|
||||
blockedFeedback,
|
||||
fillColor,
|
||||
isReadyToInstall,
|
||||
label,
|
||||
ringColor,
|
||||
onBlocked,
|
||||
onRepair,
|
||||
}: RepairInstallTargetProps): React.JSX.Element {
|
||||
return (
|
||||
<TriggerObject
|
||||
position={INSTALL_TARGET_POSITION}
|
||||
colliders="ball"
|
||||
label={label}
|
||||
radius={REPAIR_INTERACTION_RADIUS}
|
||||
onTrigger={() => {
|
||||
if (!isReadyToInstall) {
|
||||
onBlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
onRepair();
|
||||
}}
|
||||
>
|
||||
<mesh>
|
||||
<torusGeometry args={[0.95, 0.045, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.02, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 0.9, 96]} />
|
||||
<meshBasicMaterial color={fillColor} transparent opacity={0.35} />
|
||||
</mesh>
|
||||
{blockedFeedback ? (
|
||||
<group position={[0, 0.28, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.08, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.12, 16, 16]} />
|
||||
<meshBasicMaterial color={ringColor} transparent opacity={0.95} />
|
||||
</mesh>
|
||||
</group>
|
||||
) : null}
|
||||
</TriggerObject>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPlaceholderMarkers({
|
||||
positions,
|
||||
}: RepairPlaceholderMarkersProps): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{positions.map((position, index) => (
|
||||
<mesh
|
||||
key={`${position.join(":")}-${index}`}
|
||||
position={position}
|
||||
rotation={[Math.PI / 2, 0, 0]}
|
||||
>
|
||||
<torusGeometry args={[0.26, 0.018, 8, 48]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.55} />
|
||||
</mesh>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RepairPartPlacementFeedback({
|
||||
state,
|
||||
}: RepairPartPlacementFeedbackProps): React.JSX.Element | null {
|
||||
if (!state) return null;
|
||||
|
||||
const color = getPlacementFeedbackColor(state);
|
||||
|
||||
return (
|
||||
<group position={[0, 0.72, 0]}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[0.48, 0.035, 12, 64]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.85} />
|
||||
</mesh>
|
||||
<mesh position={[0, 0.08, 0]}>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial color={color} transparent opacity={0.9} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getPlacementFeedbackColor(
|
||||
state: NonNullable<RepairPartPlacementFeedbackProps["state"]>,
|
||||
): string {
|
||||
if (state === "valid") return VALID_PART_COLOR;
|
||||
if (state === "stored") return STORED_BROKEN_PART_COLOR;
|
||||
|
||||
return INVALID_PART_COLOR;
|
||||
}
|
||||
|
||||
function getReplacementFeedbackState(
|
||||
partId: string,
|
||||
requiredPartId: string,
|
||||
isPlaced: boolean,
|
||||
): RepairPartPlacementFeedbackProps["state"] {
|
||||
if (!isPlaced) return null;
|
||||
|
||||
return partId === requiredPartId ? "valid" : "invalid";
|
||||
}
|
||||
|
||||
function getPlaceholderTargets(
|
||||
placeholders: readonly RepairCasePlaceholder[],
|
||||
): readonly RepairCasePlaceholder[] {
|
||||
if (placeholders.length > 0) {
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
return FALLBACK_PLACEHOLDER_OFFSETS.map(
|
||||
(offset, index): RepairCasePlaceholder => ({
|
||||
name: `placeholder_${index + 1}`,
|
||||
position: [
|
||||
REPAIR_CASE_FOCUS_POSITION[0] + offset[0],
|
||||
REPAIR_CASE_FOCUS_POSITION[1] + offset[1],
|
||||
REPAIR_CASE_FOCUS_POSITION[2] + offset[2],
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getBrokenPartTargetPositions(
|
||||
part: RepairScannedBrokenPart,
|
||||
placeholderTargets: readonly RepairCasePlaceholder[],
|
||||
): readonly Vector3Tuple[] {
|
||||
if (!part.placeholderName) {
|
||||
return placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
const matchingPlaceholder = placeholderTargets.find(
|
||||
(placeholder) => placeholder.name === part.placeholderName,
|
||||
);
|
||||
|
||||
return matchingPlaceholder
|
||||
? [matchingPlaceholder.position]
|
||||
: placeholderTargets.map((placeholder) => placeholder.position);
|
||||
}
|
||||
|
||||
function isNearPlaceholder(
|
||||
position: THREE.Vector3,
|
||||
placeholderPositions: readonly Vector3Tuple[],
|
||||
): boolean {
|
||||
return placeholderPositions.some(
|
||||
(placeholderPosition) =>
|
||||
position.distanceTo(_placeholderPosition.set(...placeholderPosition)) <=
|
||||
REPAIR_INSTALL_RADIUS,
|
||||
);
|
||||
}
|
||||
|
||||
function getStepLocalPosition(
|
||||
worldPosition: THREE.Vector3,
|
||||
group: THREE.Group | null,
|
||||
target: THREE.Vector3,
|
||||
): THREE.Vector3 {
|
||||
target.copy(worldPosition);
|
||||
group?.worldToLocal(target);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
function getReplacementParts(
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairMissionPartConfig[] {
|
||||
if (config.replacementParts.length > 0) return config.replacementParts;
|
||||
|
||||
return [
|
||||
{
|
||||
id: config.requiredReplacementPartId,
|
||||
label: config.label,
|
||||
modelPath: config.modelPath,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getBrokenPartsToDeposit(
|
||||
config: RepairMissionConfig,
|
||||
brokenParts: readonly RepairScannedBrokenPart[],
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
if (brokenParts.length > 0) return brokenParts;
|
||||
|
||||
return config.brokenParts.map((part) => ({
|
||||
id: part.id,
|
||||
label: part.label,
|
||||
modelPath: part.modelPath ?? config.modelPath,
|
||||
...(part.placeholderName ? { placeholderName: part.placeholderName } : {}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useEffect, 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 {
|
||||
RepairMissionConfig,
|
||||
RepairMissionPartConfig,
|
||||
} from "@/data/gameplay/repairMissions";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
|
||||
interface RepairScanSequenceProps {
|
||||
config: RepairMissionConfig;
|
||||
onComplete: (brokenParts: readonly RepairScannedBrokenPart[]) => void;
|
||||
}
|
||||
|
||||
export interface RepairScannedBrokenPart {
|
||||
id: string;
|
||||
label: string;
|
||||
modelPath: string;
|
||||
placeholderName?: string;
|
||||
}
|
||||
|
||||
export function RepairScanSequence({
|
||||
config,
|
||||
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 brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
const visibleBrokenPartIndexes = brokenPartIndexes.filter(
|
||||
(partIndex) => partIndex <= activePartIndex,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (parts.length === 0) return undefined;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setActivePartIndex((currentIndex) => {
|
||||
const nextIndex = currentIndex + 1;
|
||||
if (nextIndex >= parts.length) {
|
||||
onComplete(getScannedBrokenParts(parts, config));
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
return nextIndex;
|
||||
});
|
||||
}, scanPartSeconds * 1000);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [activePartIndex, config, onComplete, parts, scanPartSeconds]);
|
||||
|
||||
return (
|
||||
<group>
|
||||
<ExplodableModel
|
||||
modelPath={config.modelPath}
|
||||
scale={config.modelScale ?? 1}
|
||||
split
|
||||
onPartsReady={setParts}
|
||||
/>
|
||||
<RepairScanVisual target={activePart?.object} />
|
||||
{visibleBrokenPartIndexes.map((partIndex) => {
|
||||
const part = parts[partIndex];
|
||||
if (!part) return null;
|
||||
|
||||
return (
|
||||
<group key={part.object.uuid}>
|
||||
<RepairBrokenPartHighlight target={part.object} />
|
||||
<RepairBrokenPartPrompt
|
||||
src={config.brokenUiPath}
|
||||
target={part.object}
|
||||
/>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function getScannedBrokenParts(
|
||||
parts: readonly ExplodedPart[],
|
||||
config: RepairMissionConfig,
|
||||
): readonly RepairScannedBrokenPart[] {
|
||||
const brokenPartIndexes = getBrokenPartIndexes(parts, config.brokenParts);
|
||||
|
||||
return brokenPartIndexes.map((_, index) => {
|
||||
const configuredPart = config.brokenParts[index] ?? config.brokenParts[0];
|
||||
|
||||
return {
|
||||
id: configuredPart?.id ?? `${config.id}-broken-part-${index}`,
|
||||
label: configuredPart?.label ?? `${config.label} broken part`,
|
||||
modelPath: configuredPart?.modelPath ?? config.modelPath,
|
||||
...(configuredPart?.placeholderName
|
||||
? { placeholderName: configuredPart.placeholderName }
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getBrokenPartIndexes(
|
||||
parts: readonly ExplodedPart[],
|
||||
brokenParts: readonly RepairMissionPartConfig[],
|
||||
): number[] {
|
||||
if (parts.length === 0 || brokenParts.length === 0) return [];
|
||||
|
||||
const matchedIndexes = brokenParts.flatMap((brokenPart) => {
|
||||
const { nodeName } = brokenPart;
|
||||
if (!nodeName) return [];
|
||||
|
||||
const index = parts.findIndex((part) =>
|
||||
objectContainsNodeName(part.object, nodeName),
|
||||
);
|
||||
|
||||
return index >= 0 ? [index] : [];
|
||||
});
|
||||
|
||||
if (matchedIndexes.length > 0) return [...new Set(matchedIndexes)];
|
||||
|
||||
return parts.slice(0, brokenParts.length).map((_, index) => index);
|
||||
}
|
||||
|
||||
function objectContainsNodeName(
|
||||
object: THREE.Object3D,
|
||||
nodeName: string,
|
||||
): boolean {
|
||||
if (object.name === nodeName) return true;
|
||||
|
||||
let found = false;
|
||||
object.traverse((child) => {
|
||||
if (child.name === nodeName) {
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
|
||||
return found;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef } from "react";
|
||||
import { useFrame } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
interface RepairScanVisualProps {
|
||||
target?: THREE.Object3D | null | undefined;
|
||||
}
|
||||
|
||||
export function RepairScanVisual({
|
||||
target = null,
|
||||
}: RepairScanVisualProps): React.JSX.Element {
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const scanLineRef = useRef<THREE.Mesh>(null);
|
||||
const worldPosition = useRef(new THREE.Vector3());
|
||||
const localPosition = useRef(new THREE.Vector3());
|
||||
|
||||
useFrame(({ clock }) => {
|
||||
const group = groupRef.current;
|
||||
const scanLine = scanLineRef.current;
|
||||
if (!group || !scanLine) return;
|
||||
|
||||
if (target) {
|
||||
target.getWorldPosition(worldPosition.current);
|
||||
localPosition.current.copy(worldPosition.current);
|
||||
group.parent?.worldToLocal(localPosition.current);
|
||||
group.position.copy(localPosition.current);
|
||||
}
|
||||
|
||||
scanLine.position.y = 0.35 + Math.sin(clock.elapsedTime * 4) * 0.7;
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
<mesh rotation={[Math.PI / 2, 0, 0]}>
|
||||
<torusGeometry args={[1.35, 0.035, 12, 96]} />
|
||||
<meshBasicMaterial color="#38bdf8" transparent opacity={0.75} />
|
||||
</mesh>
|
||||
<mesh ref={scanLineRef} rotation={[Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.15, 1.25, 96]} />
|
||||
<meshBasicMaterial
|
||||
color="#7dd3fc"
|
||||
side={THREE.DoubleSide}
|
||||
transparent
|
||||
opacity={0.45}
|
||||
/>
|
||||
</mesh>
|
||||
<mesh position={[0, 0.85, 0]}>
|
||||
<sphereGeometry args={[1.25, 32, 16]} />
|
||||
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} />
|
||||
</mesh>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
@@ -32,7 +32,6 @@ const GLOVE_CONFIGS: Record<
|
||||
|
||||
const GLOVE_MODEL_SCALE = 0.33;
|
||||
const HAND_SPACE_DISTANCE = 0.5;
|
||||
const HAND_DEPTH_SCALE = 0.5;
|
||||
const HAND_TRACKING_HIDE_DELAY_MS = 250;
|
||||
|
||||
const FINGER_LANDMARK_CHAINS = [
|
||||
@@ -126,12 +125,7 @@ function landmarkToWorldPoint(
|
||||
target.unproject(camera);
|
||||
|
||||
_direction.copy(target).sub(_cameraPosition).normalize();
|
||||
target
|
||||
.copy(_cameraPosition)
|
||||
.addScaledVector(
|
||||
_direction,
|
||||
HAND_SPACE_DISTANCE - landmark.z * HAND_DEPTH_SCALE,
|
||||
);
|
||||
target.copy(_cameraPosition).addScaledVector(_direction, HAND_SPACE_DISTANCE);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFrame, useThree } from "@react-three/fiber";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import {
|
||||
@@ -24,10 +25,7 @@ import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
|
||||
import { useHandTrackingSnapshot } from "@/hooks/handTracking/useHandTrackingSnapshot";
|
||||
import { InteractionManager } from "@/managers/InteractionManager";
|
||||
import type {
|
||||
HandTrackingHand,
|
||||
HandTrackingLandmark,
|
||||
} from "@/types/handTracking/handTracking";
|
||||
import type { HandTrackingHand } from "@/types/handTracking/handTracking";
|
||||
import type { ColliderShape, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface GrabbableObjectProps {
|
||||
@@ -36,6 +34,16 @@ interface GrabbableObjectProps {
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
handControlled?: boolean;
|
||||
onPositionChange?: (position: THREE.Vector3) => void;
|
||||
onSnap?: (position: THREE.Vector3) => void;
|
||||
snapDuration?: number;
|
||||
snapRadius?: number;
|
||||
snapTargets?: readonly Vector3Tuple[];
|
||||
}
|
||||
|
||||
interface HandScreenPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const grabDebugParams = {
|
||||
@@ -55,10 +63,11 @@ const _handDirection = new THREE.Vector3();
|
||||
const _handHitDirection = new THREE.Vector3();
|
||||
const _cameraPos = new THREE.Vector3();
|
||||
const _objectPos = new THREE.Vector3();
|
||||
const _snapPosition = new THREE.Vector3();
|
||||
const _snapTargetWorldPosition = new THREE.Vector3();
|
||||
const _handRaycaster = new THREE.Raycaster();
|
||||
|
||||
const HAND_GRAB_SCREEN_RADIUS = 0.04;
|
||||
const HAND_DEPTH_SENSITIVITY = 4;
|
||||
const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[HAND_GRAB_SCREEN_RADIUS, 0],
|
||||
@@ -67,10 +76,10 @@ const HAND_HIT_OFFSETS: Array<[number, number]> = [
|
||||
[0, -HAND_GRAB_SCREEN_RADIUS],
|
||||
];
|
||||
|
||||
function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
|
||||
const landmarks = hand.landmarks ?? [];
|
||||
function getHandCenterPoint(hand: HandTrackingHand): HandScreenPoint {
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) {
|
||||
return { x: hand.x, y: hand.y, z: hand.z };
|
||||
return { x: hand.x, y: hand.y };
|
||||
}
|
||||
|
||||
let minX = landmarks[0]!.x;
|
||||
@@ -88,7 +97,6 @@ function getHandCenterPoint(hand: HandTrackingHand): HandTrackingLandmark {
|
||||
return {
|
||||
x: (minX + maxX) / 2,
|
||||
y: (minY + maxY) / 2,
|
||||
z: hand.z,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ function getHandHit(
|
||||
group: THREE.Group | null,
|
||||
camera: THREE.Camera,
|
||||
cameraPos: THREE.Vector3,
|
||||
handCenter: HandTrackingLandmark,
|
||||
handCenter: HandScreenPoint,
|
||||
): THREE.Intersection | null {
|
||||
if (!group) return null;
|
||||
|
||||
@@ -123,15 +131,83 @@ export function GrabbableObject({
|
||||
colliders = GRAB_DEFAULT_COLLIDERS,
|
||||
label = GRAB_DEFAULT_LABEL,
|
||||
handControlled = false,
|
||||
onPositionChange,
|
||||
onSnap,
|
||||
snapDuration = 0.25,
|
||||
snapRadius = 0,
|
||||
snapTargets = [],
|
||||
}: GrabbableObjectProps): React.JSX.Element {
|
||||
const camera = useThree((state) => state.camera);
|
||||
const { hands } = useHandTrackingSnapshot();
|
||||
const spaceRef = useRef<THREE.Group>(null);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
const isHolding = useRef(false);
|
||||
const isHandHolding = useRef(false);
|
||||
const handHoldDistance = useRef<number | null>(null);
|
||||
const handHoldStartZ = useRef<number | null>(null);
|
||||
const snapTween = useRef<gsap.core.Tween | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
snapTween.current?.kill();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function snapToNearestTarget(): void {
|
||||
const body = rbRef.current;
|
||||
if (!body || snapTargets.length === 0 || snapRadius <= 0) return;
|
||||
|
||||
const translation = body.translation();
|
||||
_currentPos.set(translation.x, translation.y, translation.z);
|
||||
|
||||
let nearestTarget: Vector3Tuple | null = null;
|
||||
let nearestTargetWorld: Vector3Tuple | null = null;
|
||||
let nearestDistance = snapRadius;
|
||||
snapTargets.forEach((target) => {
|
||||
_snapTargetWorldPosition.set(target[0], target[1], target[2]);
|
||||
spaceRef.current?.localToWorld(_snapTargetWorldPosition);
|
||||
const distance = _currentPos.distanceTo(_snapTargetWorldPosition);
|
||||
if (distance <= nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestTarget = target;
|
||||
nearestTargetWorld = [
|
||||
_snapTargetWorldPosition.x,
|
||||
_snapTargetWorldPosition.y,
|
||||
_snapTargetWorldPosition.z,
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
if (!nearestTarget || !nearestTargetWorld) return;
|
||||
|
||||
snapTween.current?.kill();
|
||||
const animatedPosition = {
|
||||
x: _currentPos.x,
|
||||
y: _currentPos.y,
|
||||
z: _currentPos.z,
|
||||
};
|
||||
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
body.setAngvel(ZERO_ANGULAR_VELOCITY, true);
|
||||
snapTween.current = gsap.to(animatedPosition, {
|
||||
x: nearestTargetWorld[0],
|
||||
y: nearestTargetWorld[1],
|
||||
z: nearestTargetWorld[2],
|
||||
duration: snapDuration,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
body.setTranslation(animatedPosition, true);
|
||||
body.setLinvel({ x: 0, y: 0, z: 0 }, true);
|
||||
},
|
||||
onComplete: () => {
|
||||
_snapPosition.set(
|
||||
animatedPosition.x,
|
||||
animatedPosition.y,
|
||||
animatedPosition.z,
|
||||
);
|
||||
onSnap?.(_snapPosition);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
useDebugFolder("GrabbableObject", (folder) => {
|
||||
folder
|
||||
@@ -172,6 +248,7 @@ export function GrabbableObject({
|
||||
|
||||
const t = rbRef.current.translation();
|
||||
_currentPos.set(t.x, t.y, t.z);
|
||||
onPositionChange?.(_currentPos);
|
||||
|
||||
if (fistHand) {
|
||||
const handCenter = getHandCenterPoint(fistHand);
|
||||
@@ -191,34 +268,22 @@ export function GrabbableObject({
|
||||
: null;
|
||||
|
||||
isHandHolding.current = Boolean(hit);
|
||||
handHoldDistance.current = hit ? GRAB_HOLD_DISTANCE_DEFAULT : null;
|
||||
handHoldStartZ.current = hit ? fistHand.z : null;
|
||||
InteractionManager.getInstance().setHandHolding(isHandHolding.current);
|
||||
}
|
||||
} else {
|
||||
if (isHandHolding.current) {
|
||||
snapToNearestTarget();
|
||||
}
|
||||
isHandHolding.current = false;
|
||||
handHoldDistance.current = null;
|
||||
handHoldStartZ.current = null;
|
||||
InteractionManager.getInstance().setHandHolding(false);
|
||||
}
|
||||
|
||||
if (!isHolding.current && !isHandHolding.current) return;
|
||||
|
||||
if (fistHand && isHandHolding.current) {
|
||||
const depthOffset =
|
||||
handHoldStartZ.current === null
|
||||
? 0
|
||||
: (fistHand.z - handHoldStartZ.current) * HAND_DEPTH_SENSITIVITY;
|
||||
const holdDistance = THREE.MathUtils.clamp(
|
||||
(handHoldDistance.current ?? grabDebugParams.holdDistance) +
|
||||
depthOffset,
|
||||
GRAB_HOLD_DISTANCE_MIN,
|
||||
GRAB_HOLD_DISTANCE_MAX,
|
||||
);
|
||||
|
||||
_holdTarget
|
||||
.copy(_cameraPos)
|
||||
.addScaledVector(_handDirection, holdDistance);
|
||||
.addScaledVector(_handDirection, grabDebugParams.holdDistance);
|
||||
} else {
|
||||
camera.getWorldDirection(_holdTarget);
|
||||
_holdTarget
|
||||
@@ -238,42 +303,45 @@ export function GrabbableObject({
|
||||
});
|
||||
|
||||
return (
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="dynamic"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<group ref={groupRef}>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
</group>
|
||||
</RigidBody>
|
||||
<group ref={spaceRef}>
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="dynamic"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<group ref={groupRef}>
|
||||
<InteractableObject
|
||||
kind="grab"
|
||||
label={label}
|
||||
position={position}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
isHolding.current = true;
|
||||
}}
|
||||
onRelease={() => {
|
||||
isHolding.current = false;
|
||||
snapToNearestTarget();
|
||||
if (
|
||||
!rbRef.current ||
|
||||
grabDebugParams.throwBoost === GRAB_THROW_BOOST_DEFAULT
|
||||
)
|
||||
return;
|
||||
const v = rbRef.current.linvel();
|
||||
rbRef.current.setLinvel(
|
||||
{
|
||||
x: v.x * grabDebugParams.throwBoost,
|
||||
y: v.y * grabDebugParams.throwBoost,
|
||||
z: v.z * grabDebugParams.throwBoost,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</InteractableObject>
|
||||
</group>
|
||||
</RigidBody>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Vector3Tuple } from "@/types/three/three";
|
||||
interface InteractableObjectBaseProps {
|
||||
label: string;
|
||||
position: Vector3Tuple;
|
||||
radius?: number;
|
||||
bodyRef?: RefObject<RapierRigidBody | null>;
|
||||
onPress: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -64,7 +65,15 @@ function createInteractableHandle(
|
||||
export function InteractableObject(
|
||||
props: InteractableObjectProps,
|
||||
): React.JSX.Element {
|
||||
const { kind, label, position, bodyRef, onPress, children } = props;
|
||||
const {
|
||||
kind,
|
||||
label,
|
||||
position,
|
||||
radius = INTERACTION_RADIUS,
|
||||
bodyRef,
|
||||
onPress,
|
||||
children,
|
||||
} = props;
|
||||
const onRelease = props.kind === "grab" ? props.onRelease : null;
|
||||
const camera = useThree((state) => state.camera);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
@@ -148,13 +157,15 @@ export function InteractableObject(
|
||||
if (bodyRef?.current) {
|
||||
const t = bodyRef.current.translation();
|
||||
_objectPos.set(t.x, t.y, t.z);
|
||||
} else if (group) {
|
||||
group.getWorldPosition(_objectPos);
|
||||
} else {
|
||||
_objectPos.set(...position);
|
||||
}
|
||||
|
||||
camera.getWorldPosition(_cameraPos);
|
||||
const dist = _cameraPos.distanceTo(_objectPos);
|
||||
const isNearby = dist <= INTERACTION_RADIUS;
|
||||
const isNearby = dist <= radius;
|
||||
|
||||
manager.setNearby(handle.current, isNearby);
|
||||
|
||||
@@ -167,7 +178,7 @@ export function InteractableObject(
|
||||
|
||||
camera.getWorldDirection(_cameraDir);
|
||||
_raycaster.set(_cameraPos, _cameraDir);
|
||||
_raycaster.far = INTERACTION_RADIUS;
|
||||
_raycaster.far = radius;
|
||||
|
||||
const hits = group ? _raycaster.intersectObject(group, true) : [];
|
||||
const validHit = hits.find((h) => h.object !== debugSphereRef.current);
|
||||
@@ -185,7 +196,7 @@ export function InteractableObject(
|
||||
<mesh ref={debugSphereRef} visible={false}>
|
||||
<sphereGeometry
|
||||
args={[
|
||||
INTERACTION_RADIUS,
|
||||
radius,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
INTERACTION_DEBUG_SPHERE_SEGMENTS,
|
||||
]}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { RigidBody } from "@react-three/rapier";
|
||||
import type { RapierRigidBody } from "@react-three/rapier";
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
|
||||
import {
|
||||
TRIGGER_DEFAULT_COLLIDERS,
|
||||
TRIGGER_DEFAULT_LABEL,
|
||||
@@ -22,6 +24,7 @@ interface TriggerObjectProps {
|
||||
children: React.ReactNode;
|
||||
colliders?: ColliderShape;
|
||||
label?: string;
|
||||
radius?: number;
|
||||
soundPath?: string;
|
||||
soundVolume?: number;
|
||||
spawnModel?: string;
|
||||
@@ -52,6 +55,7 @@ export function TriggerObject({
|
||||
children,
|
||||
colliders = TRIGGER_DEFAULT_COLLIDERS,
|
||||
label = TRIGGER_DEFAULT_LABEL,
|
||||
radius = INTERACTION_RADIUS,
|
||||
soundPath,
|
||||
soundVolume = TRIGGER_DEFAULT_SOUND_VOLUME,
|
||||
spawnModel,
|
||||
@@ -59,14 +63,22 @@ export function TriggerObject({
|
||||
onTrigger,
|
||||
}: TriggerObjectProps): React.JSX.Element {
|
||||
const [spawned, setSpawned] = useState<SpawnedModel[]>([]);
|
||||
const rbRef = useRef<RapierRigidBody>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RigidBody type="fixed" colliders={colliders} position={position}>
|
||||
<RigidBody
|
||||
ref={rbRef}
|
||||
type="fixed"
|
||||
colliders={colliders}
|
||||
position={position}
|
||||
>
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label={label}
|
||||
position={position}
|
||||
radius={radius}
|
||||
bodyRef={rbRef}
|
||||
onPress={() => {
|
||||
if (soundPath) {
|
||||
AudioManager.getInstance().playSound(soundPath, soundVolume, {
|
||||
|
||||
@@ -7,15 +7,12 @@ import {
|
||||
type AnimatedModelContextValue,
|
||||
} from "@/components/three/models/useAnimatedModel";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface AnimatedModelConfig {
|
||||
export interface AnimatedModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
animations?: string[];
|
||||
defaultAnimation?: string;
|
||||
position?: Vector3Tuple;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: Vector3Tuple | number;
|
||||
fadeDuration?: number;
|
||||
speed?: number;
|
||||
autoPlay?: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useFrame } from "@react-three/fiber";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { ExplodedModel } from "@/utils/three/ExplodedModel";
|
||||
import type { ExplodedPart } from "@/utils/three/ExplodedModel";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import { toVector3Scale } from "@/utils/three/scale";
|
||||
@@ -12,6 +13,8 @@ interface ModelErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
modelPath: string;
|
||||
position?: Vector3Tuple | undefined;
|
||||
rotation?: Vector3Tuple | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}
|
||||
|
||||
interface ModelErrorBoundaryState {
|
||||
@@ -37,6 +40,8 @@ class ModelErrorBoundary extends Component<
|
||||
modelPath: this.props.modelPath,
|
||||
scope: "ExplodableModel",
|
||||
position: this.props.position,
|
||||
rotation: this.props.rotation,
|
||||
scale: this.props.scale,
|
||||
},
|
||||
error,
|
||||
);
|
||||
@@ -44,7 +49,13 @@ class ModelErrorBoundary extends Component<
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return <MissingModelFallback position={this.props.position} />;
|
||||
return (
|
||||
<MissingModelFallback
|
||||
position={this.props.position}
|
||||
rotation={this.props.rotation}
|
||||
scale={this.props.scale}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
@@ -55,6 +66,7 @@ interface ExplodableModelInnerProps extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
split: boolean;
|
||||
splitDistance?: number;
|
||||
onPartsReady?: (parts: readonly ExplodedPart[]) => void;
|
||||
}
|
||||
|
||||
export function ExplodableModel(
|
||||
@@ -65,6 +77,8 @@ export function ExplodableModel(
|
||||
key={props.modelPath}
|
||||
modelPath={props.modelPath}
|
||||
position={props.position}
|
||||
rotation={props.rotation}
|
||||
scale={props.scale}
|
||||
>
|
||||
<ExplodableModelInner {...props} />
|
||||
</ModelErrorBoundary>
|
||||
@@ -78,6 +92,7 @@ function ExplodableModelInner({
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
splitDistance = 1.2,
|
||||
onPartsReady,
|
||||
}: ExplodableModelInnerProps): React.JSX.Element {
|
||||
const { scene } = useLoggedGLTF(modelPath, {
|
||||
scope: "ExplodableModel",
|
||||
@@ -96,6 +111,10 @@ function ExplodableModelInner({
|
||||
explodedModel.setSplit(split);
|
||||
}, [explodedModel, split]);
|
||||
|
||||
useEffect(() => {
|
||||
onPartsReady?.(explodedModel.getParts());
|
||||
}, [explodedModel, onPartsReady]);
|
||||
|
||||
useFrame((_, delta) => {
|
||||
explodedModel.update(delta);
|
||||
});
|
||||
@@ -109,11 +128,15 @@ function ExplodableModelInner({
|
||||
|
||||
function MissingModelFallback({
|
||||
position = [0, 0, 0],
|
||||
rotation = [0, 0, 0],
|
||||
scale = 1,
|
||||
}: {
|
||||
position?: Vector3Tuple | undefined;
|
||||
rotation?: Vector3Tuple | undefined;
|
||||
scale?: ModelTransformProps["scale"] | undefined;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<mesh position={position}>
|
||||
<mesh position={position} rotation={rotation} scale={toVector3Scale(scale)}>
|
||||
<boxGeometry args={[0.7, 0.7, 0.7]} />
|
||||
<meshStandardMaterial color="#7f1d1d" wireframe />
|
||||
</mesh>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export interface SimpleModelConfig {
|
||||
export interface SimpleModelConfig extends ModelTransformProps {
|
||||
modelPath: string;
|
||||
position?: Vector3Tuple;
|
||||
rotation?: Vector3Tuple;
|
||||
scale?: Vector3Tuple | number;
|
||||
castShadow?: boolean;
|
||||
receiveShadow?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { createContext } from "react";
|
||||
|
||||
export interface AnimatedModelContextValue {
|
||||
play: (name: string, fade?: number) => void;
|
||||
@@ -12,12 +12,3 @@ export interface AnimatedModelContextValue {
|
||||
|
||||
export const AnimatedModelContext =
|
||||
createContext<AnimatedModelContextValue | null>(null);
|
||||
|
||||
export function useAnimatedModel(): AnimatedModelContextValue {
|
||||
const context = useContext(AnimatedModelContext);
|
||||
if (!context) {
|
||||
throw new Error("useAnimatedModel must be used inside AnimatedModel");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Html } from "@react-three/drei";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface WorldVideoPromptProps {
|
||||
src: string;
|
||||
position?: Vector3Tuple;
|
||||
size?: number;
|
||||
billboard?: boolean;
|
||||
}
|
||||
|
||||
export function WorldVideoPrompt({
|
||||
src,
|
||||
position = [0, 0, 0],
|
||||
size = 96,
|
||||
billboard = true,
|
||||
}: WorldVideoPromptProps): React.JSX.Element {
|
||||
return (
|
||||
<Html
|
||||
position={position}
|
||||
center
|
||||
transform
|
||||
sprite={billboard}
|
||||
occlude={false}
|
||||
>
|
||||
<video
|
||||
aria-hidden="true"
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
src={src}
|
||||
style={{
|
||||
display: "block",
|
||||
height: size,
|
||||
objectFit: "contain",
|
||||
pointerEvents: "none",
|
||||
width: size,
|
||||
}}
|
||||
/>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { DebugOverlayLayout } from "@/components/ui/debug/DebugOverlayLayout";
|
||||
import { GameSettingsMenu } from "@/components/ui/GameSettingsMenu";
|
||||
import { HandTrackingVisualizer } from "@/components/ui/HandTrackingVisualizer";
|
||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||
import { RepairMovementLockIndicator } from "@/components/ui/RepairMovementLockIndicator";
|
||||
import { Subtitles } from "@/components/ui/Subtitles";
|
||||
|
||||
export function GameUI(): React.JSX.Element {
|
||||
@@ -10,6 +11,7 @@ export function GameUI(): React.JSX.Element {
|
||||
<>
|
||||
<DebugOverlayLayout />
|
||||
<Crosshair />
|
||||
<RepairMovementLockIndicator />
|
||||
<InteractPrompt />
|
||||
<HandTrackingVisualizer />
|
||||
<Subtitles />
|
||||
|
||||
@@ -47,7 +47,7 @@ export function HandTrackingVisualizer(): React.JSX.Element | null {
|
||||
return (
|
||||
<svg className="hand-tracking-visualizer" aria-hidden="true">
|
||||
{hands.map((hand, handIndex) => {
|
||||
const landmarks = hand.landmarks ?? [];
|
||||
const landmarks = hand.landmarks;
|
||||
if (landmarks.length === 0) return null;
|
||||
|
||||
const color = hand.isFist ? "#facc15" : "#38bdf8";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
||||
import { useRepairMovementLocked } from "@/hooks/gameplay/useRepairMovementLocked";
|
||||
|
||||
export function RepairMovementLockIndicator(): React.JSX.Element | null {
|
||||
const cameraMode = useCameraMode();
|
||||
const movementLocked = useRepairMovementLocked();
|
||||
|
||||
if (cameraMode !== "player") return null;
|
||||
if (!movementLocked) return null;
|
||||
|
||||
return (
|
||||
<div className="repair-movement-lock-indicator" aria-live="polite">
|
||||
<span
|
||||
className="repair-movement-lock-indicator__dot"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>Déplacement verrouillé pendant la réparation</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SceneLoadingState } from "@/types/world/sceneLoading";
|
||||
|
||||
interface SceneLoadingOverlayProps {
|
||||
state: SceneLoadingState;
|
||||
}
|
||||
|
||||
export function SceneLoadingOverlay({
|
||||
state,
|
||||
}: SceneLoadingOverlayProps): React.JSX.Element | null {
|
||||
const isReady = state.status === "ready";
|
||||
const progress = Math.round(Math.max(0, Math.min(1, state.progress)) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`scene-loading-overlay${isReady ? " scene-loading-overlay--ready" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="scene-loading-overlay__content">
|
||||
<strong>{state.currentStep}</strong>
|
||||
<div className="scene-loading-overlay__track">
|
||||
<span style={{ width: `${progress}%` }} />
|
||||
<em>{progress}%</em>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RotateCcw, StepBack, StepForward } from "lucide-react";
|
||||
import {
|
||||
type MainGameState,
|
||||
type MissionStep,
|
||||
useGameStore,
|
||||
} from "@/managers/stores/useGameStore";
|
||||
import { isMissionStep, MISSION_STEPS } from "@/types/gameplay/repairMission";
|
||||
|
||||
const MAIN_STATES: MainGameState[] = [
|
||||
"intro",
|
||||
@@ -13,16 +13,6 @@ const MAIN_STATES: MainGameState[] = [
|
||||
"outro",
|
||||
];
|
||||
|
||||
const MISSION_STEPS: MissionStep[] = [
|
||||
"locked",
|
||||
"waiting",
|
||||
"inspected",
|
||||
"fragmented",
|
||||
"scanning",
|
||||
"repairing",
|
||||
"done",
|
||||
];
|
||||
|
||||
function toPascalCase(value: string): string {
|
||||
return value
|
||||
.split(/[-_\s]+/)
|
||||
@@ -33,6 +23,9 @@ function toPascalCase(value: string): string {
|
||||
|
||||
export function GameStateDebugPanel(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const pyloneStep = useGameStore((state) => state.pylone.currentStep);
|
||||
const fermeStep = useGameStore((state) => state.ferme.currentStep);
|
||||
const detail = useGameStore((state) => {
|
||||
switch (state.mainState) {
|
||||
case "intro":
|
||||
@@ -70,22 +63,45 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "outro") {
|
||||
setOutroState({ hasStarted: nextSubState === "started" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMissionStep(nextSubState)) return;
|
||||
|
||||
if (mainState === "bike") {
|
||||
setBikeState({ currentStep: nextSubState as MissionStep });
|
||||
setBikeState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "pylone") {
|
||||
setPyloneState({ currentStep: nextSubState as MissionStep });
|
||||
setPyloneState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
|
||||
if (mainState === "ferme") {
|
||||
setFermeState({ currentStep: nextSubState as MissionStep });
|
||||
setFermeState({ currentStep: nextSubState });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function setDebugMainState(nextMainState: MainGameState): void {
|
||||
setMainState(nextMainState);
|
||||
|
||||
if (nextMainState === "bike" && bikeStep === "locked") {
|
||||
setBikeState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
setOutroState({ hasStarted: nextSubState === "started" });
|
||||
if (nextMainState === "pylone" && pyloneStep === "locked") {
|
||||
setPyloneState({ currentStep: "waiting" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextMainState === "ferme" && fermeStep === "locked") {
|
||||
setFermeState({ currentStep: "waiting" });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -113,7 +129,7 @@ export function GameStateDebugPanel(): React.JSX.Element {
|
||||
aria-pressed={state === mainState}
|
||||
className={state === mainState ? "is-active" : undefined}
|
||||
type="button"
|
||||
onClick={() => setMainState(state)}
|
||||
onClick={() => setDebugMainState(state)}
|
||||
>
|
||||
{toPascalCase(state)}
|
||||
</button>
|
||||
|
||||
@@ -90,7 +90,6 @@ export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
|
||||
cameraRef.current.position.add(direction);
|
||||
}
|
||||
|
||||
// Space moves up; Shift moves down.
|
||||
if (keys.current["Space"]) {
|
||||
cameraRef.current.position.y += verticalSpeed * delta;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user