refactor: clean architecture and remove unused code

This commit is contained in:
Tom Boullay
2026-04-30 13:33:28 +02:00
parent c698b9ef78
commit fda70bade2
30 changed files with 303 additions and 696 deletions
+1 -1
View File
@@ -89,6 +89,6 @@ Usage in Canvas:
- All debug UI goes through `Debug.getInstance()` — never inline `if (isDev)` checks - All debug UI goes through `Debug.getInstance()` — never inline `if (isDev)` checks
- r3f-perf is always lazy-imported, never a hard dependency in scene components - r3f-perf is always lazy-imported, never a hard dependency in scene components
- Debug folders should be organized by domain (Lighting, PostFX, Player, Zone) - Debug folders should be organized by domain (Lighting, Player, Zone, Interaction)
- Debug panel must not affect production builds — it simply doesn't mount when `?debug` is absent - Debug panel must not affect production builds — it simply doesn't mount when `?debug` is absent
- Clean up debug folders in `destroy()` when relevant - Clean up debug folders in `destroy()` when relevant
+5 -11
View File
@@ -24,7 +24,6 @@ Built with React, Three.js, and Vite. Runs in the browser, no installation requi
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) | | [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) | | [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) | | [@react-three/rapier](https://rapier.rs/docs/) |
| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) |
| [GSAP](https://gsap.com/docs/v3/Installation/) | | [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance & Effects ### Performance & Effects
@@ -97,16 +96,11 @@ la-fabrik/
│ └── fragment.glsl │ └── fragment.glsl
├── utils/ ├── utils/
│ ├── EventEmitter.ts # Simple typed pub/sub utility │ ├── core/ # Logger and generic utilities
│ ├── Sizes.ts # Viewport size tracking │ ├── debug/ # Dev-only tools and scene inspection
│ ├── Time.ts # Animation frame timing utility │ ├── editor/ # Editor-only parsing utilities
── debug/ # Dev-only tools and scene inspection ── map/ # Map loading and validation
├── Debug.ts # Global lil-gui manager └── three/ # Three.js helpers
│ ├── DebugPerf.tsx # r3f-perf overlay mounted in Canvas
│ ├── isDebugEnabled.ts # Debug query-string helper
│ └── scene/
│ ├── DebugHelpers.tsx # Grid + axes helpers shown in debug mode
│ └── DebugCameraControls.tsx # Free debug camera for map inspection
├── hooks/ ├── hooks/
│ └── debug/ │ └── debug/
│ ├── useCameraMode.ts │ ├── useCameraMode.ts
+36 -328
View File
@@ -1,343 +1,51 @@
# Animation & 3D Model System # Animation & 3D Components
This document describes how to use the 3D model components and animation system in La-Fabrik. This document describes the 3D components that are currently used in the runtime.
## Table of Contents ## Runtime Components
1. [Model Types Overview](#model-types-overview) | Domain | Component | Role |
2. [SimpleModel - Static Models](#simplemodel---static-models) | ----------- | -------------------- | --------------------------------------------------------------------- |
3. [AnimatedModel - Animated Models](#animatedmodel---animated-models) | Interaction | `InteractableObject` | Focus detection through distance and raycasting |
4. [Animation Control](#animation-control) | Interaction | `TriggerObject` | Press-to-trigger interactions, optional sound, optional spawned model |
5. [Other 3D Components](#other-3d-components) | Interaction | `GrabbableObject` | Physics grab and hand-tracking grab behavior |
6. [Technical Notes](#technical-notes) | Model | `ExplodableModel` | Split/reassemble a GLTF model into separated parts |
| Gameplay | `RepairCaseModel` | Repair case lid animation, proximity float, and wobble |
--- ## Continuous Animation
## Model Types Overview Use `useFrame` for per-frame 3D behavior. Current examples:
The project provides three main types of model instantiation: - `GrabbableObject` updates held object velocity every frame.
- `ExplodableModel` updates split part positions every frame.
- `RepairCaseModel` updates proximity float and rotation wobble every frame.
- `SkyModel` follows the camera position every frame.
| Type | Component | Use Case | ## Timeline Animation
| ----------- | -------------------------------------------------------- | -------------------------------------------- |
| Static | `SimpleModel` | Props, decoration, objects without animation |
| Animated | `AnimatedModel` | Characters, animated objects with skeleton |
| Interactive | `GrabbableObject`, `TriggerObject`, `InteractableObject` | Objects player can interact with |
--- Use GSAP only for discrete timeline-style transitions. Current example:
## SimpleModel - Static Models - `RepairCaseModel` animates the case lid between open and closed rotations.
Use for GLTF models **without** skeleton/armature and no animations. ## GLTF Reuse
```tsx Use `useClonedObject` when a GLTF scene is reused by a component instance. It memoizes `scene.clone(true)` and keeps clone creation out of render churn.
import { SimpleModel } from "@/components/three/models/SimpleModel";
<SimpleModel
modelPath="/models/elecsimple/model.gltf"
position={[0, 0, -5]}
rotation={[0, 45, 0]}
scale={1}
castShadow={true}
receiveShadow={true}
/>;
```
### Props
| Prop | Type | Default | Description |
| --------------- | ------------------------ | ----------- | --------------------------------- |
| `modelPath` | `string` | required | Path to GLTF file in `/public` |
| `position` | `Vector3Tuple` | `[0, 0, 0]` | World position [x, y, z] |
| `rotation` | `Vector3Tuple` | `[0, 0, 0]` | Rotation in radians [x, y, z] |
| `scale` | `number \| Vector3Tuple` | `1` | Scale factor or [x, y, z] |
| `castShadow` | `boolean` | `true` | Enable shadow casting |
| `receiveShadow` | `boolean` | `true` | Enable shadow receiving |
| `children` | `ReactNode` | - | Child components to render inside |
---
## AnimatedModel - Animated Models
Use for GLTF models **with** skeleton/armature and animations (like Mixamo characters).
```tsx
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { useAnimatedModel } from "@/components/three/models/useAnimatedModel";
// Basic usage
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, -5]}
rotation={[0, 0, 0]}
scale={0.01}
autoPlay={true}
speed={1}
fadeDuration={0.3}
/>;
```
### Props
| Prop | Type | Default | Description |
| ------------------ | ------------------------ | ----------- | --------------------------------------------- |
| `modelPath` | `string` | required | Path to GLTF file in `/public` |
| `defaultAnimation` | `string` | `"Idle"` | Animation name to play by default |
| `animations` | `string[]` | `[]` | List of animation names (optional) |
| `position` | `Vector3Tuple` | `[0, 0, 0]` | World position [x, y, z] |
| `rotation` | `Vector3Tuple` | `[0, 0, 0]` | Rotation in radians [x, y, z] |
| `scale` | `number \| Vector3Tuple` | `1` | Scale factor |
| `autoPlay` | `boolean` | `true` | Auto-play default animation |
| `speed` | `number` | `1` | Animation playback speed |
| `fadeDuration` | `number` | `0.3` | Transition duration in seconds |
| `onLoaded` | `() => void` | - | Callback when model loads |
| `onAnimationEnd` | `(name: string) => void` | - | Callback when animation ends |
| `children` | `ReactNode` | - | Child components (can use `useAnimatedModel`) |
### Important: Scale
Animated models (like Mixamo exports) often need a small scale (e.g., `0.01`) because they are exported in meters while Three.js uses different units. Adjust until the model appears at the right size.
---
## Animation Control
To control animations from inside or outside the `AnimatedModel`, use the `useAnimatedModel` hook.
### Basic Control
```tsx
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { useAnimatedModel } from "@/components/three/models/useAnimatedModel";
// Create a controller component to use inside AnimatedModel
function AnimationController() {
const { play, stop, fadeTo, currentAnimation, names, setSpeed, isReady } =
useAnimatedModel();
// names contains all available animation names
// currentAnimation is the name of the currently playing animation
// isReady is true when model and animations are loaded
return (
<mesh onClick={() => play("Run", 0.5)}>
<boxGeometry />
</mesh>
);
}
// Usage
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, -5]}
scale={0.01}
>
<AnimationController />
</AnimatedModel>;
```
### Available Methods
| Method | Signature | Description |
| ------------------ | --------------------------------------- | ------------------------------------ |
| `play` | `(name: string, fade?: number) => void` | Play animation with optional fade |
| `fadeTo` | `(name: string, fade?: number) => void` | Fade to another animation |
| `stop` | `(fade?: number) => void` | Stop and return to default animation |
| `setSpeed` | `(speed: number) => void` | Set animation speed |
| `currentAnimation` | `string` | Current animation name (getter) |
| `names` | `string[]` | Available animation names |
| `isReady` | `boolean` | Whether model is loaded |
### Transition Example
```tsx
function Character() {
const { play, fadeTo, currentAnimation } = useAnimatedModel();
const handleWalk = () => fadeTo("Walk", 0.5); // 0.5s fade
const handleRun = () => play("Run", 0.3); // 0.3s fade
const handleIdle = () => play("Idle", 0.5); // return to idle
return (
<group>
<mesh onClick={handleWalk} position={[-1, 0, 0]}>
<boxGeometry />
</mesh>
<mesh onClick={handleRun} position={[0, 0, 0]}>
<boxGeometry />
</mesh>
<mesh onClick={handleIdle} position={[1, 0, 0]}>
<boxGeometry />
</mesh>
</group>
);
}
```
### Combined: GrabbableObject with Animation
You can combine `AnimatedModel` inside `GrabbableObject` to create animated objects that can be picked up:
```tsx
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
// Animated weapon/tool that player can pick up
<GrabbableObject position={[0, 1, 0]} colliders="cuboid">
<AnimatedModel
modelPath="/models/sword/model.gltf"
defaultAnimation="Idle"
position={[0, 0, 0]}
scale={0.02}
autoPlay={true}
/>
</GrabbableObject>;
```
Or create an animated character that can be grabbed:
```tsx
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { AnimatedModel } from "@/components/three/models/AnimatedModel";
import { useAnimatedModel } from "@/components/three/models/useAnimatedModel";
// Controller that triggers animations when grabbed
function AnimatedGrabber() {
const { play, fadeTo } = useAnimatedModel();
return (
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, 0]}
scale={0.01}
autoPlay={true}
/>
);
}
// When grabbed, play "Grab" animation
<GrabbableObject
position={[0, 1, 0]}
colliders="cuboid"
onGrab={() => {
// This would require a context or store to trigger
console.log("Object grabbed!");
}}
>
<AnimatedGrabber />
</GrabbableObject>;
```
**Note:** For complex interactions (like playing specific animations when grabbing), you'll need to connect the grab events to animation controls via a state manager or context.
---
## Other 3D Components
### GrabbableObject
Objects that can be picked up by the player.
```tsx
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
<GrabbableObject position={[0, 1, 0]} colliders="cuboid">
<mesh>
<boxGeometry args={[0.5, 0.5, 0.5]} />
<meshStandardMaterial color="red" />
</mesh>
</GrabbableObject>;
```
### TriggerObject
Objects that trigger events when interacted with.
```tsx
import { TriggerObject } from "@/components/three/interaction/TriggerObject";
<TriggerObject
position={[0, 1, 0]}
soundPath="/sounds/click.mp3"
onTrigger={() => console.log("Triggered!")}
>
<mesh>
<sphereGeometry />
<meshStandardMaterial color="blue" />
</mesh>
</TriggerObject>;
```
### InteractableObject
Base object for interactions.
```tsx
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
<InteractableObject
kind="trigger"
label="Interact"
position={[0, 1, 0]}
onPress={() => console.log("Interacted!")}
>
<mesh>
<cylinderGeometry />
<meshStandardMaterial color="green" />
</mesh>
</InteractableObject>;
```
---
## Technical Notes
### GLTF Models
- Models should be placed in `/public/models/`
- Supported formats: `.gltf`, `.glb`
- Animated models must have an Armature/skeleton for animations to work
### Model Scale Issue
If animated models don't appear, they may be too small or too large. Try:
- Scale `0.01` for Mixamo-exported models
- Scale `1` for models in correct units
### Cloning
- `SimpleModel` memoizes a cloned scene for proper React lifecycle
- `AnimatedModel` memoizes a cloned scene and binds animations through a group ref
### Animation System
The animation system uses:
- `@react-three/drei`: `useGLTF` for loading, `useAnimations` for animation control
- Three.js: `AnimationMixer` for playback
### No State Machine
This system intentionally avoids complex state machines (like Unity's Animator). For simple animation transitions, use the `play`, `fadeTo`, and `stop` methods directly.
---
## File Structure ## File Structure
``` ```txt
src/ src/components/three/
├── components/three/ ├── gameplay/
│ ├── models/ │ ├── RepairCaseModel.tsx
│ ├── AnimatedModel.tsx # Animated model component + context │ ├── RepairCaseObject.tsx
│ ├── SimpleModel.tsx # Static model component │ ├── RepairGameZone.tsx
│ └── useAnimatedModel.ts # Animated model context hook │ └── RepairModuleSlot.tsx
│ └── interaction/ ── interaction/
├── GrabbableObject.tsx # Pickable object │ ├── GrabbableObject.tsx
├── TriggerObject.tsx # Trigger event object ├── InteractableObject.tsx
└── InteractableObject.tsx └── TriggerObject.tsx
── hooks/ ── models/
└── useCharacterAnimation.ts # Animation hook (legacy) └── ExplodableModel.tsx
└── world/
└── SkyModel.tsx
``` ```
+1 -1
View File
@@ -44,7 +44,7 @@ This document describes the code that exists today in the repository.
## 3D Component Domains ## 3D Component Domains
- `src/components/three/models/` contains reusable model loaders such as `SimpleModel`, `AnimatedModel`, and `ExplodableModel`. - `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/interaction/` contains reusable interaction wrappers such as `InteractableObject`, `TriggerObject`, and `GrabbableObject`.
- `src/components/three/gameplay/repairGame/` contains the current core repair gameplay prototype: the repair case, repair game zone, and module slots. - `src/components/three/gameplay/repairGame/` contains the current core repair gameplay prototype: the repair case, repair game zone, and module slots.
- `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`. - `src/components/three/world/` contains reusable world/environment objects such as `SkyModel`.
+1 -1
View File
@@ -138,7 +138,7 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas
## Known Limitations ## Known Limitations
- Uploaded model object URLs are not currently revoked after replacement or unmount. - Uploaded model object URLs are not revoked after replacement or unmount.
- Large `map.json` files are not virtualized, culled, or LOD-managed. - Large `map.json` files are not virtualized, culled, or LOD-managed.
- There is no snap-to-grid, duplication, material editing, or object creation workflow. - There is no snap-to-grid, duplication, material editing, or object creation workflow.
- Save to Server is a Vite dev-server helper, not a production backend API. - Save to Server is a Vite dev-server helper, not a production backend API.
+4 -4
View File
@@ -6,7 +6,7 @@ This document describes the hand tracking system that exists in the current code
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 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.
The feature is currently scoped to the debug physics scene and is not yet a production gameplay input system. The feature is scoped to the debug physics scene rather than production gameplay input.
## Runtime Flow ## Runtime Flow
@@ -27,7 +27,7 @@ The current activation conditions are:
- scene mode is `physics` - scene mode is `physics`
- the player is near an interaction, is holding an object, or is hand-holding an object - the player is near an interaction, is holding an object, or is hand-holding an object
This prevents the previous issue where hand tracking depended on crosshair focus. The system now remains active while the player is inside an interaction zone, even if the camera is not aimed directly at the object. This keeps hand tracking active while the player is inside an interaction zone, even if the camera is not aimed directly at the object.
## Backend ## Backend
@@ -113,8 +113,8 @@ The hand tracking overlay is an HTML overlay outside the canvas. The hand wirefr
## Known Limitations ## Known Limitations
- The feature is debug-only and currently focused on the physics test scene. - The feature is debug-only and focused on the physics test scene.
- MediaPipe depth is relative and can be noisy. - MediaPipe depth is relative and can be noisy.
- The virtual hit zone is an approximation based on multiple raycasts, not a real 3D collider. - 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. - There is no smoothing layer for hand position or depth yet.
- The hand visualization is a temporary SVG wireframe. - The hand visualization is an SVG landmark wireframe.
+5 -13
View File
@@ -1,6 +1,6 @@
# Main Feature # Main Feature
This document explains the main interactive feature currently being prototyped in La-Fabrik: grabbing and moving 3D objects with hand tracking. This document explains La-Fabrik's debug hand-tracking feature: grabbing and moving 3D objects with a webcam.
## What It Does ## What It Does
@@ -18,7 +18,7 @@ The intended user flow is:
## Why It Matters ## Why It Matters
This prototype tests whether La-Fabrik interactions can feel more physical and embodied than a classic mouse or keyboard interaction. This feature tests whether La-Fabrik interactions can feel more physical and embodied than a classic mouse or keyboard interaction.
For the final experience, this can support low-tech repair gestures, object manipulation, and more expressive interaction sequences. For the final experience, this can support low-tech repair gestures, object manipulation, and more expressive interaction sequences.
@@ -32,7 +32,7 @@ Moving the hand left, right, up, or down moves the object in that direction. Mov
## Debug Requirements ## Debug Requirements
Hand tracking currently requires: Hand tracking requires:
- Chrome or another browser that allows `getUserMedia()` reliably - Chrome or another browser that allows `getUserMedia()` reliably
- the local Python backend running - the local Python backend running
@@ -73,15 +73,7 @@ The wireframe turns yellow when the detected hand is a fist.
## Current Limitations ## Current Limitations
- The feature is still a prototype.
- It is enabled only in the debug physics scene. - It is enabled only in the debug physics scene.
- The SVG hand wireframe is temporary. - The SVG hand wireframe is a debug visualization, not final gameplay UI.
- Depth movement depends on relative webcam tracking and may need tuning. - Depth movement depends on relative webcam tracking and may need tuning.
- The system has not yet been integrated into final mission gameplay. - The system is not integrated into mission gameplay.
## Expected Next Improvements
- Smooth the hand position and depth signal.
- Add a better 3D hand representation.
- Add calibration controls for grab radius and depth sensitivity.
- Connect hand gestures to final repair or transformation tasks.
-144
View File
@@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.0", "@react-three/fiber": "^9.6.0",
"@react-three/postprocessing": "^3.0.4",
"@react-three/rapier": "^2.2.0", "@react-three/rapier": "^2.2.0",
"@tanstack/react-router": "^1.168.25", "@tanstack/react-router": "^1.168.25",
"gsap": "^3.15.0", "gsap": "^3.15.0",
@@ -30,8 +29,6 @@
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
@@ -653,19 +650,6 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@pkgr/core": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
"integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.20.0 || ^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/pkgr"
}
},
"node_modules/@radix-ui/react-icons": { "node_modules/@radix-ui/react-icons": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz",
@@ -763,32 +747,6 @@
} }
} }
}, },
"node_modules/@react-three/postprocessing": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@react-three/postprocessing/-/postprocessing-3.0.4.tgz",
"integrity": "sha512-e4+F5xtudDYvhxx3y0NtWXpZbwvQ0x1zdOXWTbXMK6fFLVDd4qucN90YaaStanZGS4Bd5siQm0lGL/5ogf8iDQ==",
"license": "MIT",
"dependencies": {
"maath": "^0.6.0",
"n8ao": "^1.9.4",
"postprocessing": "^6.36.6"
},
"peerDependencies": {
"@react-three/fiber": "^9.0.0",
"react": "^19.0",
"three": ">= 0.156.0"
}
},
"node_modules/@react-three/postprocessing/node_modules/maath": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.6.0.tgz",
"integrity": "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw==",
"license": "MIT",
"peerDependencies": {
"@types/three": ">=0.144.0",
"three": ">=0.144.0"
}
},
"node_modules/@react-three/rapier": { "node_modules/@react-three/rapier": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-three/rapier/-/rapier-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@react-three/rapier/-/rapier-2.2.0.tgz",
@@ -2249,53 +2207,6 @@
} }
} }
}, },
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
"funding": {
"url": "https://opencollective.com/eslint-config-prettier"
},
"peerDependencies": {
"eslint": ">=7.0.0"
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz",
"integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.11.12"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint-plugin-prettier"
},
"peerDependencies": {
"@types/eslint": ">=8.0.0",
"eslint": ">=8.0.0",
"eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
"prettier": ">=3.0.0"
},
"peerDependenciesMeta": {
"@types/eslint": {
"optional": true
},
"eslint-config-prettier": {
"optional": true
}
}
},
"node_modules/eslint-plugin-react-hooks": { "node_modules/eslint-plugin-react-hooks": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
@@ -2449,13 +2360,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-diff": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-json-stable-stringify": { "node_modules/fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -4190,16 +4094,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/n8ao": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/n8ao/-/n8ao-1.10.1.tgz",
"integrity": "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w==",
"license": "ISC",
"peerDependencies": {
"postprocessing": ">=6.30.0",
"three": ">=0.137"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -4389,15 +4283,6 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postprocessing": {
"version": "6.39.1",
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.1.tgz",
"integrity": "sha512-R2dG2zy+BAx3USl5EHw+PvnrlbT5PKnZVp3se0HCR0pWH8WQdh742yNG4YWOsq6c0bFpffk0Gd2RqPeoP/wKng==",
"license": "Zlib",
"peerDependencies": {
"three": ">= 0.168.0 < 0.185.0"
}
},
"node_modules/potpack": { "node_modules/potpack": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz",
@@ -4430,19 +4315,6 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/prettier-linter-helpers": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
"integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-diff": "^1.1.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/promise-worker-transferable": { "node_modules/promise-worker-transferable": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz",
@@ -4891,22 +4763,6 @@
"react": ">=17.0" "react": ">=17.0"
} }
}, },
"node_modules/synckit": {
"version": "0.11.12",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz",
"integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@pkgr/core": "^0.2.9"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/synckit"
}
},
"node_modules/three": { "node_modules/three": {
"version": "0.183.2", "version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
+6 -6
View File
@@ -1,8 +1,9 @@
import { useMemo, useRef, useEffect, useState } from "react"; import { useRef, useEffect, useState } from "react";
import { Grid, TransformControls, useGLTF } from "@react-three/drei"; import { Grid, TransformControls, useGLTF } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor"; import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
interface EditorMapProps { interface EditorMapProps {
@@ -138,7 +139,7 @@ export function EditorMap({
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map()); const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
const handleTransformMouseDown = () => { const handleTransformMouseDown = () => {
onTransformStart?.(); onTransformStart();
}; };
const handleTransformMouseUp = () => { const handleTransformMouseUp = () => {
@@ -153,10 +154,10 @@ export function EditorMap({
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z], rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z], scale: [obj.scale.x, obj.scale.y, obj.scale.z],
}; };
onNodeTransform?.(selectedNodeIndex, updatedNode); onNodeTransform(selectedNodeIndex, updatedNode);
} }
} }
onTransformEnd?.(); onTransformEnd();
}; };
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>( const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
@@ -258,8 +259,7 @@ function EditorModelNode({
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(), new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
); );
const { scene } = useGLTF(modelUrl); const { scene } = useGLTF(modelUrl);
const sceneInstance = useClonedObject(scene);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useRef } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import gsap from "gsap"; import gsap from "gsap";
@@ -15,14 +15,13 @@ import {
REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES, REPAIR_CASE_ROTATION_AMPLITUDE_DEGREES,
REPAIR_CASE_ROTATION_RESET_SPEED, REPAIR_CASE_ROTATION_RESET_SPEED,
} from "@/data/gameplay/repairCaseConfig"; } from "@/data/gameplay/repairCaseConfig";
import type { Vector3Tuple } from "@/types/three/three"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import type { ModelTransformProps } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
interface RepairCaseModelProps { interface RepairCaseModelProps extends ModelTransformProps {
modelPath: string; modelPath: string;
open: boolean; open: boolean;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: number | Vector3Tuple;
} }
const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad( const CASE_CLOSED_ROTATION_OFFSET_Z = THREE.MathUtils.degToRad(
@@ -44,7 +43,7 @@ export function RepairCaseModel({
}: RepairCaseModelProps): React.JSX.Element { }: RepairCaseModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const { scene } = useGLTF(modelPath); const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]); const model = useClonedObject(scene);
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const lidRef = useRef<THREE.Object3D | null>(null); const lidRef = useRef<THREE.Object3D | null>(null);
const worldPosition = useRef(new THREE.Vector3()); const worldPosition = useRef(new THREE.Vector3());
@@ -53,8 +52,7 @@ export function RepairCaseModel({
const phase = useRef({ x: 0, y: 0, z: 0 }); const phase = useRef({ x: 0, y: 0, z: 0 });
const initialOpen = useRef(open); const initialOpen = useRef(open);
const openedRotationZ = useRef(0); const openedRotationZ = useRef(0);
const parsedScale = const parsedScale = toVector3Scale(scale);
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
useEffect(() => { useEffect(() => {
phase.current = { phase.current = {
@@ -1,7 +1,8 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier"; import { RigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/three/interaction/InteractableObject"; import { InteractableObject } from "@/components/three/interaction/InteractableObject";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { import {
TRIGGER_DEFAULT_COLLIDERS, TRIGGER_DEFAULT_COLLIDERS,
TRIGGER_DEFAULT_LABEL, TRIGGER_DEFAULT_LABEL,
@@ -38,7 +39,7 @@ function SpawnedModelInstance({
position: Vector3Tuple; position: Vector3Tuple;
}): React.JSX.Element { }): React.JSX.Element {
const { scene } = useGLTF(path); const { scene } = useGLTF(path);
const model = useMemo(() => scene.clone(true), [scene]); const model = useClonedObject(scene);
return <primitive object={model} position={position} />; return <primitive object={model} position={position} />;
} }
+13 -15
View File
@@ -2,12 +2,14 @@ import type { ReactNode } from "react";
import { Component, useEffect, useMemo } from "react"; import { Component, useEffect, useMemo } from "react";
import { useFrame } from "@react-three/fiber"; import { useFrame } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { ExplodedModel } from "@/utils/three/ExplodedModel"; import { ExplodedModel } from "@/utils/three/ExplodedModel";
import type { Vector3Tuple } from "@/types/three/three"; import type { ModelTransformProps, Vector3Tuple } from "@/types/three/three";
import { toVector3Scale } from "@/utils/three/scale";
interface ModelErrorBoundaryProps { interface ModelErrorBoundaryProps {
children: ReactNode; children: ReactNode;
fallback?: ReactNode; position?: Vector3Tuple | undefined;
} }
interface ModelErrorBoundaryState { interface ModelErrorBoundaryState {
@@ -32,17 +34,17 @@ class ModelErrorBoundary extends Component<
} }
render(): ReactNode { render(): ReactNode {
if (this.state.hasError) return this.props.fallback ?? null; if (this.state.hasError) {
return <MissingModelFallback position={this.props.position} />;
}
return this.props.children; return this.props.children;
} }
} }
interface ExplodableModelInnerProps { interface ExplodableModelInnerProps extends ModelTransformProps {
modelPath: string; modelPath: string;
split: boolean; split: boolean;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: number | Vector3Tuple;
splitDistance?: number; splitDistance?: number;
} }
@@ -50,10 +52,7 @@ export function ExplodableModel(
props: ExplodableModelInnerProps, props: ExplodableModelInnerProps,
): React.JSX.Element { ): React.JSX.Element {
return ( return (
<ModelErrorBoundary <ModelErrorBoundary key={props.modelPath} position={props.position}>
key={props.modelPath}
fallback={<MissingModelFallback position={props.position ?? [0, 0, 0]} />}
>
<ExplodableModelInner {...props} /> <ExplodableModelInner {...props} />
</ModelErrorBoundary> </ModelErrorBoundary>
); );
@@ -68,13 +67,12 @@ function ExplodableModelInner({
splitDistance = 1.2, splitDistance = 1.2,
}: ExplodableModelInnerProps): React.JSX.Element { }: ExplodableModelInnerProps): React.JSX.Element {
const { scene } = useGLTF(modelPath); const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]); const model = useClonedObject(scene);
const explodedModel = useMemo( const explodedModel = useMemo(
() => new ExplodedModel(model, { distance: splitDistance }), () => new ExplodedModel(model, { distance: splitDistance }),
[model, splitDistance], [model, splitDistance],
); );
const parsedScale = const parsedScale = toVector3Scale(scale);
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
useEffect(() => { useEffect(() => {
explodedModel.setSplit(split); explodedModel.setSplit(split);
@@ -94,7 +92,7 @@ function ExplodableModelInner({
function MissingModelFallback({ function MissingModelFallback({
position = [0, 0, 0], position = [0, 0, 0],
}: { }: {
position?: Vector3Tuple; position?: Vector3Tuple | undefined;
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<mesh position={position}> <mesh position={position}>
+3 -2
View File
@@ -1,7 +1,8 @@
import { useFrame, useThree } from "@react-three/fiber"; import { useFrame, useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import { useMemo, useRef } from "react"; import { useRef } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
interface SkyModelProps { interface SkyModelProps {
modelPath: string; modelPath: string;
@@ -13,7 +14,7 @@ export function SkyModel({ modelPath }: SkyModelProps): React.JSX.Element {
const camera = useThree((state) => state.camera); const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath); const { scene } = useGLTF(modelPath);
const model = useMemo(() => scene.clone(true), [scene]); const model = useClonedObject(scene);
useFrame(() => { useFrame(() => {
groupRef.current?.position.copy(camera.position); groupRef.current?.position.copy(camera.position);
+8 -9
View File
@@ -24,7 +24,6 @@ Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans inst
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) | | [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) | | [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) | | [@react-three/rapier](https://rapier.rs/docs/) |
| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) |
| [GSAP](https://gsap.com/docs/v3/Installation/) | | [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance et effets ### Performance et effets
@@ -48,17 +47,17 @@ la-fabrik/
│ └── sounds/ │ └── sounds/
└── src/ └── src/
├── world/ # Monde 3D persistant ├── world/ # Composition du monde 3D persistant
│ ├── World.tsx # Composition principale de la scène │ ├── World.tsx # Composition de la scène active
│ ├── Map.tsx # Carte de base, toujours montée │ ├── GameMap.tsx # Chargement de carte et collision octree
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles │ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
│ ├── Environment.tsx # HDRI, brouillard, ciel │ ├── Environment.tsx # Arrière-plan et modèle de ciel
│ ├── PostFX.tsx # Bloom, SSAO, aberration chromatique │ ├── GameMusic.tsx # Cycle de vie de la musique de jeu
│ ├── zones/ # Zones spatiales, LOD par zone │ ├── debug/ # Scène de test debug
│ └── player/ # Contrôleur joueur et caméra │ └── player/ # Contrôleur joueur et caméra
├── components/ ├── components/
│ ├── 3d/ # Éléments 3D réutilisables │ ├── three/ # Composants R3F par domaine
│ └── ui/ # Overlays HTML hors Canvas │ └── ui/ # Overlays HTML hors Canvas
├── managers/ # Logique, état et orchestration ├── managers/ # Logique, état et orchestration
@@ -142,7 +141,7 @@ Ce document décrit l'architecture visée à moyen terme pour le projet.
## Relation avec le code actuel ## Relation avec le code actuel
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant. - \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
- Ce document est volontairement aspirational. - Ce document décrit une direction d'architecture, pas un comportement implémenté.
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne. - Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
## Objectifs ## Objectifs
+2 -2
View File
@@ -1,5 +1,5 @@
export const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws"; const HAND_TRACKING_LOCAL_WS_URL = "ws://localhost:8000/ws";
export const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws"; const HAND_TRACKING_PROD_WS_URL = "wss://handtracking.la-fabrik.fr/ws";
export const HAND_TRACKING_FRAME_WIDTH = 320; export const HAND_TRACKING_FRAME_WIDTH = 320;
export const HAND_TRACKING_FRAME_HEIGHT = 240; export const HAND_TRACKING_FRAME_HEIGHT = 240;
+10 -3
View File
@@ -15,6 +15,13 @@ export function useModelSelection(
): UseModelSelectionResult { ): UseModelSelectionResult {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const firstModel = models[0];
if (!firstModel) {
throw new Error("useModelSelection requires at least one model");
}
const selectedModel = models[selectedIndex] ?? firstModel;
const close = useCallback(() => setIsOpen(false), []); const close = useCallback(() => setIsOpen(false), []);
const open = useCallback(() => setIsOpen(true), []); const open = useCallback(() => setIsOpen(true), []);
@@ -42,7 +49,7 @@ export function useModelSelection(
} }
if (key === "e" || key === "enter") { if (key === "e" || key === "enter") {
onSelect(models[selectedIndex]); onSelect(selectedModel);
close(); close();
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -60,12 +67,12 @@ export function useModelSelection(
return () => { return () => {
window.removeEventListener("keydown", handleKeyDown, { capture: true }); window.removeEventListener("keydown", handleKeyDown, { capture: true });
}; };
}, [close, isOpen, models, onSelect, selectedIndex]); }, [close, isOpen, models, onSelect, selectedModel]);
return { return {
isOpen, isOpen,
selectedIndex, selectedIndex,
selectedModel: models[selectedIndex], selectedModel,
open, open,
close, close,
}; };
@@ -10,6 +10,7 @@ import {
} from "@/data/handTrackingConfig"; } from "@/data/handTrackingConfig";
import type { import type {
HandTrackingFrameMessage, HandTrackingFrameMessage,
HandTrackingHand,
HandTrackingServerMessage, HandTrackingServerMessage,
HandTrackingSnapshot, HandTrackingSnapshot,
} from "@/types/handTracking/handTracking"; } from "@/types/handTracking/handTracking";
@@ -31,6 +32,58 @@ function getBase64Payload(dataUrl: string): string {
return dataUrl.slice(dataUrl.indexOf(",") + 1); return dataUrl.slice(dataUrl.indexOf(",") + 1);
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isHandTrackingLandmark(value: unknown): boolean {
return (
isRecord(value) &&
isFiniteNumber(value.x) &&
isFiniteNumber(value.y) &&
isFiniteNumber(value.z)
);
}
function isHandTrackingHand(value: unknown): value is HandTrackingHand {
return (
isRecord(value) &&
isFiniteNumber(value.x) &&
isFiniteNumber(value.y) &&
isFiniteNumber(value.z) &&
Array.isArray(value.landmarks) &&
value.landmarks.every(isHandTrackingLandmark) &&
typeof value.handedness === "string" &&
typeof value.isFist === "boolean" &&
isFiniteNumber(value.score)
);
}
function isHandTrackingServerMessage(
value: unknown,
): value is HandTrackingServerMessage {
if (!isRecord(value) || !isFiniteNumber(value.timestamp)) return false;
if (value.type === "hands") {
return Array.isArray(value.hands) && value.hands.every(isHandTrackingHand);
}
if (value.type === "status") {
return typeof value.status === "string";
}
return (
value.type === "error" &&
Array.isArray(value.hands) &&
value.hands.every(isHandTrackingHand) &&
typeof value.message === "string"
);
}
function getCameraStreamWithTimeout( function getCameraStreamWithTimeout(
constraints: MediaStreamConstraints, constraints: MediaStreamConstraints,
): Promise<MediaStream> { ): Promise<MediaStream> {
@@ -106,6 +159,16 @@ export function useRemoteHandTracking({
clearResponseTimeout(); clearResponseTimeout();
}; };
const markInvalidResponse = (): void => {
setSnapshot((current) => ({
...current,
hands: [],
status: "error",
usageStatus: "inactive",
error: "Invalid hand tracking response",
}));
};
const sendFrame = (): void => { const sendFrame = (): void => {
const ws = wsRef.current; const ws = wsRef.current;
const video = videoRef.current; const video = videoRef.current;
@@ -201,7 +264,23 @@ export function useRemoteHandTracking({
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
markResponseReceived(); markResponseReceived();
const data = JSON.parse(event.data) as HandTrackingServerMessage; if (typeof event.data !== "string") {
markInvalidResponse();
return;
}
let data: unknown;
try {
data = JSON.parse(event.data);
} catch {
markInvalidResponse();
return;
}
if (!isHandTrackingServerMessage(data)) {
markInvalidResponse();
return;
}
if (data.type === "hands") { if (data.type === "hands") {
setSnapshot((current) => ({ setSnapshot((current) => ({
+6
View File
@@ -0,0 +1,6 @@
import { useMemo } from "react";
import type * as THREE from "three";
export function useClonedObject<T extends THREE.Object3D>(object: T): T {
return useMemo(() => object.clone(true) as T, [object]);
}
+59 -99
View File
@@ -1,141 +1,101 @@
import { Suspense, lazy } from "react"; import { Suspense, lazy } from "react";
const LazyDocsLayout = lazy(() => function lazyNamed<T extends Record<string, React.ComponentType>>(
import("@/components/docs/DocsLayout").then((module) => ({ loader: () => Promise<T>,
default: module.DocsLayout, exportName: keyof T,
})), ): React.LazyExoticComponent<T[keyof T]> {
); return lazy(() =>
loader().then((module) => ({ default: module[exportName] })),
);
}
const LazyDocsReadmePage = lazy(() => function withDocsSuspense(
import("@/pages/docs/page").then((module) => ({ Component: React.LazyExoticComponent<React.ComponentType>,
default: module.DocsReadmePage, ): React.JSX.Element {
})), return (
); <Suspense fallback={null}>
<Component />
</Suspense>
);
}
const LazyDocsArchitecturePage = lazy(() => const LazyDocsLayout = lazyNamed(
import("@/pages/docs/architecture/page").then((module) => ({ () => import("@/components/docs/DocsLayout"),
default: module.DocsArchitecturePage, "DocsLayout",
})),
); );
const LazyDocsReadmePage = lazyNamed(
const LazyDocsTargetArchitecturePage = lazy(() => () => import("@/pages/docs/page"),
import("@/pages/docs/target-architecture/page").then((module) => ({ "DocsReadmePage",
default: module.DocsTargetArchitecturePage,
})),
); );
const LazyDocsArchitecturePage = lazyNamed(
const LazyDocsTechnicalEditorPage = lazy(() => () => import("@/pages/docs/architecture/page"),
import("@/pages/docs/technical-editor/page").then((module) => ({ "DocsArchitecturePage",
default: module.DocsTechnicalEditorPage,
})),
); );
const LazyDocsTargetArchitecturePage = lazyNamed(
const LazyDocsHandTrackingPage = lazy(() => () => import("@/pages/docs/target-architecture/page"),
import("@/pages/docs/hand-tracking/page").then((module) => ({ "DocsTargetArchitecturePage",
default: module.DocsHandTrackingPage,
})),
); );
const LazyDocsTechnicalEditorPage = lazyNamed(
const LazyDocsFeaturesPage = lazy(() => () => import("@/pages/docs/technical-editor/page"),
import("@/pages/docs/features/page").then((module) => ({ "DocsTechnicalEditorPage",
default: module.DocsFeaturesPage,
})),
); );
const LazyDocsHandTrackingPage = lazyNamed(
const LazyDocsMainFeaturePage = lazy(() => () => import("@/pages/docs/hand-tracking/page"),
import("@/pages/docs/main-feature/page").then((module) => ({ "DocsHandTrackingPage",
default: module.DocsMainFeaturePage,
})),
); );
const LazyDocsFeaturesPage = lazyNamed(
const LazyDocsEditorPage = lazy(() => () => import("@/pages/docs/features/page"),
import("@/pages/docs/editor/page").then((module) => ({ "DocsFeaturesPage",
default: module.DocsEditorPage,
})),
); );
const LazyDocsMainFeaturePage = lazyNamed(
const LazyDocsAnimationPage = lazy(() => () => import("@/pages/docs/main-feature/page"),
import("@/pages/docs/animation/page").then((module) => ({ "DocsMainFeaturePage",
default: module.DocsAnimationPage, );
})), const LazyDocsEditorPage = lazyNamed(
() => import("@/pages/docs/editor/page"),
"DocsEditorPage",
);
const LazyDocsAnimationPage = lazyNamed(
() => import("@/pages/docs/animation/page"),
"DocsAnimationPage",
); );
export function DocsLayoutRoute(): React.JSX.Element { export function DocsLayoutRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsLayout);
<Suspense fallback={null}>
<LazyDocsLayout />
</Suspense>
);
} }
export function DocsReadmeRoute(): React.JSX.Element { export function DocsReadmeRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsReadmePage);
<Suspense fallback={null}>
<LazyDocsReadmePage />
</Suspense>
);
} }
export function DocsArchitectureRoute(): React.JSX.Element { export function DocsArchitectureRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsArchitecturePage);
<Suspense fallback={null}>
<LazyDocsArchitecturePage />
</Suspense>
);
} }
export function DocsTargetArchitectureRoute(): React.JSX.Element { export function DocsTargetArchitectureRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsTargetArchitecturePage);
<Suspense fallback={null}>
<LazyDocsTargetArchitecturePage />
</Suspense>
);
} }
export function DocsTechnicalEditorRoute(): React.JSX.Element { export function DocsTechnicalEditorRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsTechnicalEditorPage);
<Suspense fallback={null}>
<LazyDocsTechnicalEditorPage />
</Suspense>
);
} }
export function DocsHandTrackingRoute(): React.JSX.Element { export function DocsHandTrackingRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsHandTrackingPage);
<Suspense fallback={null}>
<LazyDocsHandTrackingPage />
</Suspense>
);
} }
export function DocsFeaturesRoute(): React.JSX.Element { export function DocsFeaturesRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsFeaturesPage);
<Suspense fallback={null}>
<LazyDocsFeaturesPage />
</Suspense>
);
} }
export function DocsMainFeatureRoute(): React.JSX.Element { export function DocsMainFeatureRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsMainFeaturePage);
<Suspense fallback={null}>
<LazyDocsMainFeaturePage />
</Suspense>
);
} }
export function DocsEditorRoute(): React.JSX.Element { export function DocsEditorRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsEditorPage);
<Suspense fallback={null}>
<LazyDocsEditorPage />
</Suspense>
);
} }
export function DocsAnimationRoute(): React.JSX.Element { export function DocsAnimationRoute(): React.JSX.Element {
return ( return withDocsSuspense(LazyDocsAnimationPage);
<Suspense fallback={null}>
<LazyDocsAnimationPage />
</Suspense>
);
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Vector3Tuple } from "@/types/three/three"; import type { Vector3Tuple } from "../three/three";
export interface MapNode { export interface MapNode {
name: string; name: string;
+4 -4
View File
@@ -14,7 +14,7 @@ export interface HandTrackingHand {
score: number; score: number;
} }
export type HandTrackingUsageStatus = "inactive" | "available" | "active"; type HandTrackingUsageStatus = "inactive" | "available" | "active";
export type HandTrackingStatus = export type HandTrackingStatus =
| "idle" | "idle"
@@ -42,19 +42,19 @@ export interface HandTrackingFrameMessage {
image: string; image: string;
} }
export interface HandTrackingHandsMessage { interface HandTrackingHandsMessage {
type: "hands"; type: "hands";
timestamp: number; timestamp: number;
hands: HandTrackingHand[]; hands: HandTrackingHand[];
} }
export interface HandTrackingStatusMessage { interface HandTrackingStatusMessage {
type: "status"; type: "status";
timestamp: number; timestamp: number;
status: string; status: string;
} }
export interface HandTrackingErrorMessage { interface HandTrackingErrorMessage {
type: "error"; type: "error";
timestamp: number; timestamp: number;
hands: HandTrackingHand[]; hands: HandTrackingHand[];
-2
View File
@@ -1,5 +1,3 @@
export type InteractableKind = "grab" | "trigger";
interface TriggerInteractableHandle { interface TriggerInteractableHandle {
kind: "trigger"; kind: "trigger";
label: string; label: string;
+8
View File
@@ -2,6 +2,14 @@ import type { Octree } from "three/addons/math/Octree.js";
export type Vector3Tuple = [number, number, number]; export type Vector3Tuple = [number, number, number];
export type Vector3Scale = Vector3Tuple | number;
export interface ModelTransformProps {
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Scale;
}
export type ColliderShape = "cuboid" | "ball" | "hull"; export type ColliderShape = "cuboid" | "ball" | "hull";
export type OctreeReadyHandler = (octree: Octree) => void; export type OctreeReadyHandler = (octree: Octree) => void;
+6 -1
View File
@@ -9,6 +9,10 @@ interface StoredDebugControls {
sceneMode: SceneMode; sceneMode: SceneMode;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isCameraMode(value: unknown): value is CameraMode { function isCameraMode(value: unknown): value is CameraMode {
return value === "player" || value === "debug"; return value === "player" || value === "debug";
} }
@@ -22,7 +26,8 @@ function getStoredDebugControls(): Partial<StoredDebugControls> {
const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY); const rawValue = window.localStorage.getItem(DEBUG_CONTROLS_STORAGE_KEY);
if (!rawValue) return {}; if (!rawValue) return {};
const parsedValue = JSON.parse(rawValue) as Partial<StoredDebugControls>; const parsedValue: unknown = JSON.parse(rawValue);
if (!isRecord(parsedValue)) return {};
return { return {
...(isCameraMode(parsedValue.cameraMode) ...(isCameraMode(parsedValue.cameraMode)
+12 -9
View File
@@ -1,4 +1,8 @@
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "../../types/editor/editor";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isVector3Tuple(value: unknown): value is [number, number, number] { function isVector3Tuple(value: unknown): value is [number, number, number] {
return ( return (
@@ -8,18 +12,17 @@ function isVector3Tuple(value: unknown): value is [number, number, number] {
); );
} }
export function isMapNode(value: unknown): value is MapNode { function isMapNode(value: unknown): value is MapNode {
if (typeof value !== "object" || value === null) { if (!isRecord(value)) {
return false; return false;
} }
const node = value as Record<string, unknown>;
return ( return (
typeof node.name === "string" && typeof value.name === "string" &&
typeof node.type === "string" && typeof value.type === "string" &&
isVector3Tuple(node.position) && isVector3Tuple(value.position) &&
isVector3Tuple(node.rotation) && isVector3Tuple(value.rotation) &&
isVector3Tuple(node.scale) isVector3Tuple(value.scale)
); );
} }
+4 -1
View File
@@ -49,7 +49,10 @@ export class ExplodedModel {
} }
private createParts(model: THREE.Object3D): ExplodedPart[] { private createParts(model: THREE.Object3D): ExplodedPart[] {
const root = model.children.length === 1 ? model.children[0] : model; const root =
model.children.length === 1 && model.children[0]
? model.children[0]
: model;
const directChildren = root.children.filter((child) => hasMesh(child)); const directChildren = root.children.filter((child) => hasMesh(child));
const sourceObjects = const sourceObjects =
directChildren.length > 1 ? directChildren : getMeshes(root); directChildren.length > 1 ? directChildren : getMeshes(root);
+5
View File
@@ -0,0 +1,5 @@
import type { Vector3Scale, Vector3Tuple } from "@/types/three/three";
export function toVector3Scale(scale: Vector3Scale): Vector3Tuple {
return typeof scale === "number" ? [scale, scale, scale] : scale;
}
+9 -24
View File
@@ -1,7 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Component, useEffect, useMemo, useRef, useState } from "react"; import { Component, useEffect, useRef, useState } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode"; import { useOctreeGraphNode } from "@/hooks/three/useOctreeGraphNode";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/editor/editor";
@@ -14,7 +15,6 @@ interface LoadedMapNode {
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode;
fallback?: ReactNode;
} }
interface ErrorBoundaryState { interface ErrorBoundaryState {
@@ -40,7 +40,7 @@ class ModelErrorBoundary extends Component<
render(): ReactNode { render(): ReactNode {
if (this.state.hasError) { if (this.state.hasError) {
return this.props.fallback ?? null; return null;
} }
return this.props.children; return this.props.children;
@@ -53,7 +53,6 @@ interface GameMapProps {
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element { export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]); const [mapNodes, setMapNodes] = useState<LoadedMapNode[]>([]);
const [isLoading, setIsLoading] = useState(true);
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length); useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length);
@@ -64,7 +63,6 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const sceneData = await loadMapSceneData(); const sceneData = await loadMapSceneData();
if (!sceneData) { if (!sceneData) {
console.warn("map.json not found"); console.warn("map.json not found");
setIsLoading(false);
return; return;
} }
@@ -84,8 +82,6 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
setMapNodes(loadedMapNodes); setMapNodes(loadedMapNodes);
} catch (error) { } catch (error) {
console.error("Error loading map:", error); console.error("Error loading map:", error);
} finally {
setIsLoading(false);
} }
}; };
@@ -94,12 +90,11 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
return ( return (
<group ref={groupRef}> <group ref={groupRef}>
{!isLoading && {mapNodes.map((mapNode, index) => (
mapNodes.map((mapNode, index) => ( <ModelErrorBoundary key={index}>
<ModelErrorBoundary key={index}> <ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} />
<ModelInstance node={mapNode.node} modelUrl={mapNode.modelUrl} /> </ModelErrorBoundary>
</ModelErrorBoundary> ))}
))}
</group> </group>
); );
} }
@@ -111,22 +106,12 @@ function ModelInstance({
node: MapNode; node: MapNode;
modelUrl: string; modelUrl: string;
}): React.JSX.Element { }): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelUrl); const { scene } = useGLTF(modelUrl);
const sceneInstance = useMemo(() => scene.clone(true), [scene]); const sceneInstance = useClonedObject(scene);
const { position, rotation, scale } = node; const { position, rotation, scale } = node;
useEffect(() => {
if (groupRef.current) {
groupRef.current.position.set(...position);
groupRef.current.rotation.set(...rotation);
groupRef.current.scale.set(...scale);
}
}, [position, rotation, scale]);
return ( return (
<primitive <primitive
ref={groupRef}
object={sceneInstance} object={sceneInstance}
position={position} position={position}
rotation={rotation} rotation={rotation}
+1 -1
View File
@@ -1,7 +1,7 @@
import { useRef } from "react"; import { useRef } from "react";
import * as THREE from "three"; import * as THREE from "three";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier"; import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { RepairGameZone } from "@/components/three/gameplay/repairGame/RepairGameZone"; import { RepairGameZone } from "@/components/three/gameplay/RepairGameZone";
import { GrabbableObject } from "@/components/three/interaction/GrabbableObject"; import { GrabbableObject } from "@/components/three/interaction/GrabbableObject";
import { TriggerObject } from "@/components/three/interaction/TriggerObject"; import { TriggerObject } from "@/components/three/interaction/TriggerObject";
import { import {
+3 -2
View File
@@ -5,17 +5,18 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import type { ServerResponse } from "node:http"; import type { ServerResponse } from "node:http";
import type { Plugin } from "vite"; import type { Plugin } from "vite";
import { parseMapNodes } from "./src/utils/mapNodeValidation"; import { parseMapNodes } from "./src/utils/map/mapNodeValidation";
const __dirname = fileURLToPath(new URL(".", import.meta.url)); const __dirname = fileURLToPath(new URL(".", import.meta.url));
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024; const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
const JSON_HEADERS = { "Content-Type": "application/json" }; const JSON_HEADERS = { "Content-Type": "application/json" };
type JsonResponseBody = Readonly<Record<string, string | boolean>>;
function sendJson( function sendJson(
res: ServerResponse, res: ServerResponse,
status: number, status: number,
body: unknown, body: JsonResponseBody,
headers: Record<string, string> = {}, headers: Record<string, string> = {},
): void { ): void {
res res