Compare commits

..

28 Commits

Author SHA1 Message Date
math-pixel 4e8a68b04a Merge pull request #10 from La-Fabrik-Durable/feat-animation
🔍 Lint / 🪄 Check lint (push) Waiting to run
🔍 Lint / 🎨 Check format (push) Waiting to run
🔍 Lint / 🔎 Typecheck (push) Waiting to run
🔍 Lint / 🏗 Build (push) Blocked by required conditions
📊 Quality / 🔒 Security Audit (push) Waiting to run
📊 Quality / 📋 Dependency Freshness (push) Waiting to run
📊 Quality / 📦 Bundle Size (push) Waiting to run
Feat/animation
2026-04-29 11:50:07 +02:00
math-pixel b997f576c5 fix: pr 2026-04-29 11:35:17 +02:00
math-pixel 20142b7e5f Merge branch 'feat-animation' of https://github.com/La-Fabrik-Durable/La-Fabrik into feat-animation 2026-04-29 11:23:57 +02:00
math-pixel 2b6b045f4a fix: pr issues 2026-04-29 11:23:40 +02:00
math-pixel 29cd03fc21 Update src/hooks/useCharacterAnimation.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-29 11:07:40 +02:00
math-pixel 7c5d7f3834 update: animation doc 2026-04-29 11:00:32 +02:00
math-pixel 93744b15f7 fix : comflic 2026-04-29 10:51:40 +02:00
math-pixel 359417ecd4 feat: animator 2026-04-28 20:14:37 +02:00
math-pixel 9ada4298c3 wip 2026-04-28 16:54:00 +02:00
Tom Boullay 9b8bb1a182 Merge pull request #6 from La-Fabrik-Durable/feat/docs-routing
Feat/docs-routing
2026-04-28 15:04:29 +02:00
Astro a8c6fafbcd address docs routing review feedback 2026-04-28 15:02:50 +02:00
Astro 14a55e8dd1 clean docs router declarations 2026-04-28 14:53:28 +02:00
Astro 2dd5bfeda1 move debug components out of utils 2026-04-28 14:47:26 +02:00
Astro e20ead88e1 standardize source naming conventions 2026-04-28 14:46:27 +02:00
Astro 7e99d455b4 fix runtime map loading lifecycle 2026-04-28 14:42:49 +02:00
Astro 8c6af0ed6d rename pages 2026-04-28 14:25:29 +02:00
Astro 324aa9dc0f clean branch-scoped code quality issues 2026-04-28 14:23:37 +02:00
Astro 356bb5ef88 organize data configs by domain 2026-04-28 14:17:21 +02:00
Astro ece9b1268f refactor feature folders by code type 2026-04-28 14:14:15 +02:00
Astro d2735b72a0 refactor docs into feature folder 2026-04-28 13:54:41 +02:00
Astro fc5e4acba4 group docs navigation by audience 2026-04-28 13:48:03 +02:00
Astro a3db0b2f0d add editor documentation pages 2026-04-28 13:47:56 +02:00
Astro f9a0480121 fix react three peer dependencies 2026-04-28 13:47:49 +02:00
Astro aa7db176e6 update: app and main 2026-04-28 13:32:54 +02:00
Astro 0f83f57e23 Merge branch 'develop' into feat/docs-routing 2026-04-28 13:31:40 +02:00
Astro 055e7b2e63 fix: address docs routing review 2026-04-27 16:32:23 +02:00
Astro 68b0ceb593 feat: add localized docs pages 2026-04-27 16:27:08 +02:00
Astro 7fd39f58d8 feat: add docs routing 2026-04-27 15:35:56 +02:00
83 changed files with 3985 additions and 689 deletions
+1
View File
@@ -42,3 +42,4 @@ Thumbs.db
# Temporaire
.backend/
backend/
temp/
+338
View File
@@ -0,0 +1,338 @@
# Animation & 3D Model System
This document describes how to use the 3D model components and animation system in La-Fabrik.
## Table of Contents
1. [Model Types Overview](#model-types-overview)
2. [SimpleModel - Static Models](#simplemodel---static-models)
3. [AnimatedModel - Animated Models](#animatedmodel---animated-models)
4. [Animation Control](#animation-control)
5. [Other 3D Components](#other-3d-components)
6. [Technical Notes](#technical-notes)
---
## Model Types Overview
The project provides three main types of model instantiation:
| Type | Component | Use Case |
| ----------- | -------------------------------------------------------- | -------------------------------------------- |
| Static | `SimpleModel` | Props, decoration, objects without animation |
| Animated | `AnimatedModel` | Characters, animated objects with skeleton |
| Interactive | `GrabbableObject`, `TriggerObject`, `InteractableObject` | Objects player can interact with |
---
## SimpleModel - Static Models
Use for GLTF models **without** skeleton/armature and no animations.
```tsx
import { SimpleModel } from "@/components/3d";
<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 degrees [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, useAnimatedModel } from "@/components/3d";
// 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 degrees [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, useAnimatedModel } from "@/components/3d";
// 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 { AnimatedModel, GrabbableObject } from "@/components/3d";
// 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 {
AnimatedModel,
GrabbableObject,
useAnimatedModel,
} from "@/components/3d";
// 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/3d";
<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/3d";
<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/3d";
<InteractableObject
position={[0, 1, 0]}
onInteract={() => 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` uses `scene.clone()` for proper React lifecycle
- `AnimatedModel` uses the original scene directly to preserve SkinnedMesh + Armature structure
### 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
```
src/
├── components/3d/
│ ├── AnimatedModel.tsx # Animated model component + context
│ ├── SimpleModel.tsx # Static model component
│ ├── GrabbableObject.tsx # Pickable object
│ ├── TriggerObject.tsx # Trigger event object
│ ├── InteractableObject.tsx
│ └── index.ts # Central exports
└── hooks/
└── useCharacterAnimation.ts # Animation hook (legacy)
```
+9 -9
View File
@@ -44,12 +44,12 @@ This document describes the code that exists today in the repository.
## Editor System
- `src/pages/editor/EditorPage.tsx` is the route-level editor page for `/editor`.
- `src/features/editor/components/EditorControls.tsx` renders the HTML editor control panel.
- `src/features/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering.
- `src/features/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
- `src/features/editor/controls/FlyController.tsx` provides player-style editor navigation.
- `src/features/editor/hooks/useEditorSceneData.ts` loads scene data and handles folder upload fallback.
- `src/features/editor/hooks/useEditorHistory.ts` owns editor undo and redo state.
- `src/components/editor/EditorControls.tsx` renders the HTML editor control panel.
- `src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, shortcuts, and map rendering.
- `src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
- `src/controls/editor/FlyController.tsx` provides player-style editor navigation.
- `src/hooks/editor/useEditorSceneData.ts` loads scene data and handles folder upload fallback.
- `src/hooks/editor/useEditorHistory.ts` owns editor undo and redo state.
- `src/utils/editor/loadEditorScene.ts` handles editor-only folder upload parsing.
- `src/utils/loadMapSceneData.ts` is shared by the game scene and editor to load `public/map.json` and resolve model URLs.
- `src/types/editor.ts` contains the shared `MapNode`, `SceneData`, and `TransformMode` types.
@@ -63,9 +63,9 @@ This document describes the code that exists today in the repository.
## Current Limitations
- The repository is still a prototype, not the full intended game runtime.
- `src/world/debug/TestScene.tsx` is still part of the active scene composition.
- There is no central gameplay orchestrator such as `GameManager` yet.
- The repository is a prototype, not the full intended game runtime.
- `src/world/debug/TestScene.tsx` is part of the active scene composition.
- There is no central gameplay orchestrator such as `GameManager`.
- Missions, zones, cinematics, and dialogue systems are not implemented.
- 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.
+24 -23
View File
@@ -10,8 +10,8 @@ The editor is a React route used to inspect and adjust the `public/map.json` sce
- `/` renders the playable La-Fabrik scene.
- `/editor` renders the map editor.
- `src/main.tsx` wraps the app with `BrowserRouter`.
- `src/App.tsx` defines the route and imports `EditorPage` from `src/pages/editor/EditorPage.tsx`.
- `src/App.tsx` mounts TanStack Router through `RouterProvider`.
- `src/router.tsx` defines the `/editor` route and imports `EditorPage` from `src/pages/editor/page.tsx`.
## File Structure
@@ -19,19 +19,20 @@ The editor is a React route used to inspect and adjust the `public/map.json` sce
src/
├── pages/
│ └── editor/
│ └── EditorPage.tsx
├── features/
│ └── page.tsx
├── components/
│ └── editor/
│ ├── components/
│ └── EditorControls.tsx
├── controls/
└── FlyController.tsx
│ ├── hooks/
│ ├── useEditorHistory.ts
│ └── useEditorSceneData.ts
│ ├── scene/
│ ├── EditorMap.tsx
│ └── EditorScene.tsx
│ ├── EditorControls.tsx
└── scene/
├── EditorMap.tsx
└── EditorScene.tsx
├── controls/
└── editor/
└── FlyController.tsx
├── hooks/
── editor/
├── useEditorHistory.ts
│ └── useEditorSceneData.ts
├── types/
│ └── editor.ts
└── utils/
@@ -42,19 +43,19 @@ src/
## Responsibilities
`src/pages/editor/EditorPage.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, and player-mode toggle.
`src/pages/editor/page.tsx` is the route-level composition component. It owns route-specific state such as selected object, hovered object, transform mode, and player-mode toggle.
`src/features/editor/hooks/useEditorSceneData.ts` loads the default map data and handles folder uploads.
`src/hooks/editor/useEditorSceneData.ts` loads the default map data and handles folder uploads.
`src/features/editor/hooks/useEditorHistory.ts` owns editor undo and redo history.
`src/hooks/editor/useEditorHistory.ts` owns editor undo and redo history.
`src/features/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, keyboard shortcuts, and `EditorMap`.
`src/components/editor/scene/EditorScene.tsx` composes the editor canvas scene, camera controls, lights, keyboard shortcuts, and `EditorMap`.
`src/features/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
`src/components/editor/scene/EditorMap.tsx` renders map nodes, fallback cubes, selection highlighting, and transform controls.
`src/features/editor/components/EditorControls.tsx` renders the HTML control panel outside the canvas.
`src/components/editor/EditorControls.tsx` renders the HTML control panel outside the canvas.
`src/features/editor/controls/FlyController.tsx` provides editor movement controls for player-style navigation.
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
`src/utils/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json` and resolves available `public/models/{name}/model.gltf` files.
@@ -138,6 +139,6 @@ Editor styles are in `src/index.css` under the `/* Editor page */` section. Clas
## Known Limitations
- Uploaded model object URLs are not currently revoked after replacement or unmount.
- Large `map.json` files may need virtualization, culling, or LOD support later.
- There is no snap-to-grid, duplication, material editing, or object creation workflow yet.
- Large `map.json` files are not virtualized, culled, or LOD-managed.
- 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.
+5 -5
View File
@@ -5,7 +5,7 @@ This document describes the intended medium-term architecture for the project.
## Relationship To The Current Code
- `docs/technical/architecture.md` is the source of truth for what exists now.
- This document is intentionally aspirational.
- This document describes intended direction, not implemented behavior.
- If this document conflicts with the current implementation, the current implementation wins.
## Goals
@@ -40,12 +40,12 @@ This document describes the intended medium-term architecture for the project.
- performance overlay
- scene helpers
- free camera and calibration controls
- temporary test scenes used during development
- debug test scenes used during development
### UI Layer
- `src/components/ui/` should contain player-facing HTML overlays.
- Expected future examples:
- Candidate examples:
- crosshair
- loading flow
- mission HUD
@@ -54,7 +54,7 @@ This document describes the intended medium-term architecture for the project.
### Gameplay Layer
- As the project grows, gameplay state can move toward a clearer orchestration layer.
- Likely future concerns:
- Likely concerns:
- missions
- zones
- cinematics
@@ -67,4 +67,4 @@ This document describes the intended medium-term architecture for the project.
- Prefer direct, working code over speculative scaffolding.
- Shared types should stay close to their domain until they have multiple real consumers.
- Avoid creating new managers or service layers without an active runtime need.
- Debug-only runtime paths should be clearly marked and easy to remove later.
- Debug-only runtime paths should be clearly marked and easy to remove when obsolete.
+83
View File
@@ -0,0 +1,83 @@
# Editor User Guide
The map editor is available at `/editor`. It is a browser-based tool for inspecting and adjusting the objects listed in `public/map.json`.
## Purpose
Use the editor when you need to move, rotate, or scale existing map objects without editing JSON by hand.
The editor reads the same map data as the runtime scene:
- `public/map.json` contains the object list.
- `public/models/{name}/model.gltf` contains the matching 3D model for each object name.
- Missing models are displayed as gray fallback cubes, so incomplete maps remain editable.
## Map Node Format
Each entry in `public/map.json` represents one object:
| Field | Description |
| ---------- | ------------------------------------------------- |
| `name` | Model folder name in `public/models/{name}` |
| `type` | Object category |
| `position` | Object position as `[x, y, z]` |
| `rotation` | Object rotation as `[x, y, z]`, expressed radians |
| `scale` | Object scale as `[x, y, z]` |
## Editing Workflow
1. Open `/editor` in the local app.
2. Click an object in the scene to select it.
3. Choose a transform mode: translate, rotate, or scale.
4. Drag the transform gizmo in the 3D view.
5. Check the JSON inspector if you need exact values.
6. Use undo or redo if the transform is not correct.
7. Export the JSON or save it to the dev server.
## Controls
| Action | Input |
| -------------------- | -------------------------- |
| Select object | Click object |
| Deselect | `Esc` or click empty space |
| Translate mode | `T` |
| Rotate mode | `R` |
| Scale mode | `S` |
| Undo | `Ctrl+Z` |
| Redo | `Ctrl+Y` |
| Locked view movement | `WASD`, `ZQSD`, arrows |
| Move up | `Space` |
| Move down | `Shift` |
## View Mode
The `Lock view` action switches the editor into a movement mode closer to the runtime player camera. Use it to navigate larger scenes while keeping the transform tools available.
## JSON Inspector
The side panel includes a raw JSON inspector:
- When no object is selected, it shows the full map node list.
- When an object is selected, it highlights the JSON lines for that object.
This is useful for checking numeric transform values before saving or exporting.
## Saving Changes
### Export JSON
`Export JSON` downloads the current map node list as `map.json`. Use this when you want to manually replace `public/map.json`.
### Save To Server
`Save to server` is available only during local development. It writes the edited map back to `public/map.json` through the Vite dev-server endpoint.
The button is hidden in production builds because production persistence is not implemented.
## Current Limitations
- The editor only modifies existing nodes.
- It does not create or delete objects.
- It does not edit model files or textures.
- It does not provide production persistence.
- Fallback cubes indicate missing models; they are editor placeholders, not exported assets.
+1639 -319
View File
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -3,6 +3,9 @@
"private": true,
"version": "0.0.1",
"type": "module",
"engines": {
"node": ">=20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@@ -18,13 +21,15 @@
"@react-three/fiber": "^9.6.0",
"@react-three/postprocessing": "^3.0.4",
"@react-three/rapier": "^2.2.0",
"@tanstack/react-router": "^1.168.25",
"gsap": "^3.15.0",
"lil-gui": "^0.21.0",
"lucide-react": "^1.11.0",
"r3f-perf": "^7.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.5.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"three": "^0.183.2"
},
"devDependencies": {
@@ -44,7 +49,9 @@
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
},
"engines": {
"node": ">=20.19.0 || >=22.12.0"
"overrides": {
"r3f-perf": {
"@react-three/drei": "$@react-three/drei"
}
}
}
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.
+3 -28
View File
@@ -1,33 +1,8 @@
import { Routes, Route } from "react-router-dom";
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/utils/debug/DebugPerf";
import { World } from "@/world/World";
import { EditorPage } from "@/pages/editor/EditorPage";
import { RouterProvider } from "@tanstack/react-router";
import { router } from "@/router";
function App(): React.JSX.Element {
return (
<Routes>
<Route
path="/"
element={
<>
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
<Suspense fallback={null}>
<World />
<DebugPerf />
</Suspense>
</Canvas>
<Crosshair />
<InteractPrompt />
</>
}
/>
<Route path="/editor" element={<EditorPage />} />
</Routes>
);
return <RouterProvider router={router} />;
}
export default App;
@@ -3,17 +3,18 @@ import {
DEBUG_CAMERA_DAMPING_FACTOR,
DEBUG_CAMERA_MAX_DISTANCE,
DEBUG_CAMERA_MIN_DISTANCE,
} from "@/data/debugConfig";
} from "@/data/debug/debugConfig";
import {
PLAYER_EYE_HEIGHT,
PLAYER_SPAWN_POSITION_GAME,
} from "@/data/playerConfig";
} from "@/data/player/playerConfig";
import type { Vector3Tuple } from "@/types/three";
const DEBUG_CAMERA_TARGET = [
const DEBUG_CAMERA_TARGET: Vector3Tuple = [
PLAYER_SPAWN_POSITION_GAME[0],
PLAYER_EYE_HEIGHT,
PLAYER_SPAWN_POSITION_GAME[2],
] as const;
];
export function DebugCameraControls(): React.JSX.Element {
return (
@@ -5,7 +5,7 @@ import {
DEBUG_GRID_SECONDARY_COLOR,
DEBUG_GRID_SIZE,
DEBUG_GRID_Y,
} from "@/data/debugConfig";
} from "@/data/debug/debugConfig";
import { Debug } from "@/utils/debug/Debug";
export function DebugHelpers(): React.JSX.Element | null {
+51
View File
@@ -0,0 +1,51 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useDocsLanguage } from "@/hooks/docs/useDocsLanguage";
interface DocsDocumentProps {
title: string;
meta: string;
content: string;
frContent: string;
}
export function DocsDocument({
title,
meta,
content,
frContent,
}: DocsDocumentProps): React.JSX.Element {
const { language, toggleLanguage } = useDocsLanguage();
const translatedContent = language === "fr" ? frContent : content;
return (
<div className="docs-content">
<header className="docs-content__header">
<span>{title}</span>
<button
className="docs-language-toggle"
type="button"
onClick={toggleLanguage}
aria-label="Changer la langue de la documentation"
>
<span className={language === "fr" ? "is-active" : undefined}>
FR
</span>
<span className={language === "en" ? "is-active" : undefined}>
EN
</span>
</button>
</header>
<article className="docs-section">
<div className="docs-section__eyebrow">
<span>{title}</span>
<span>{meta}</span>
</div>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{translatedContent}
</ReactMarkdown>
</article>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Link, Outlet } from "@tanstack/react-router";
import { Home } from "lucide-react";
import { docGroups } from "@/data/docs/docsSections";
import { DocsLanguageProvider } from "@/providers/docs/DocsLanguageProvider";
export function DocsLayout(): React.JSX.Element {
return (
<DocsLanguageProvider>
<main className="docs-page">
<aside className="docs-sidebar" aria-label="Documentation">
<header className="docs-sidebar__header">
<h1>Folders</h1>
<Link
className="docs-home-link"
to="/"
aria-label="Retour à l'accueil"
>
<Home size={18} strokeWidth={2.25} aria-hidden="true" />
</Link>
</header>
<nav>
{docGroups.map((group) => (
<section className="docs-nav-group" key={group.label}>
<h2>{group.label}</h2>
{group.sections.map((section) => (
<Link
activeProps={{
className: "docs-nav-item docs-nav-item--active",
}}
activeOptions={{ exact: true }}
className="docs-nav-item"
key={section.path}
to={section.path}
>
<span>
<strong>{section.title}</strong>
<small>{section.subtitle}</small>
</span>
<span className="docs-nav-item__meta">{section.meta}</span>
</Link>
))}
</section>
))}
</nav>
</aside>
<Outlet />
</main>
</DocsLanguageProvider>
);
}
@@ -31,6 +31,20 @@ interface EditorControlsProps {
isPlayerMode?: boolean;
}
const TRANSFORM_OPTIONS = [
{ mode: "translate", label: "Translate", shortcut: "T", Icon: Move3D },
{ mode: "rotate", label: "Rotate", shortcut: "R", Icon: RotateCw },
{ mode: "scale", label: "Scale", shortcut: "S", Icon: Expand },
] as const;
const EDITOR_SHORTCUTS = [
["Click", "Select object"],
["T / R / S", "Transform mode"],
["Ctrl Z / Y", "Undo / redo"],
["Esc", "Deselect"],
["WASD", "Move when locked"],
] as const;
export function EditorControls({
transformMode,
onTransformModeChange,
@@ -69,33 +83,18 @@ export function EditorControls({
</div>
<div className="editor-transform-buttons">
<button
className={`editor-transform-button ${transformMode === "translate" ? "active" : ""}`}
onClick={() => onTransformModeChange("translate")}
aria-pressed={transformMode === "translate"}
>
<Move3D size={16} aria-hidden="true" />
<span>Translate</span>
<kbd>T</kbd>
</button>
<button
className={`editor-transform-button ${transformMode === "rotate" ? "active" : ""}`}
onClick={() => onTransformModeChange("rotate")}
aria-pressed={transformMode === "rotate"}
>
<RotateCw size={16} aria-hidden="true" />
<span>Rotate</span>
<kbd>R</kbd>
</button>
<button
className={`editor-transform-button ${transformMode === "scale" ? "active" : ""}`}
onClick={() => onTransformModeChange("scale")}
aria-pressed={transformMode === "scale"}
>
<Expand size={16} aria-hidden="true" />
<span>Scale</span>
<kbd>S</kbd>
</button>
{TRANSFORM_OPTIONS.map(({ mode, label, shortcut, Icon }) => (
<button
key={mode}
className={`editor-transform-button ${transformMode === mode ? "active" : ""}`}
onClick={() => onTransformModeChange(mode)}
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<kbd>{shortcut}</kbd>
</button>
))}
</div>
<div className="editor-history-buttons">
@@ -203,26 +202,12 @@ export function EditorControls({
</div>
<dl className="editor-shortcuts-list">
<div>
<dt>Click</dt>
<dd>Select object</dd>
</div>
<div>
<dt>T / R / S</dt>
<dd>Transform mode</dd>
</div>
<div>
<dt>Ctrl Z / Y</dt>
<dd>Undo / redo</dd>
</div>
<div>
<dt>Esc</dt>
<dd>Deselect</dd>
</div>
<div>
<dt>WASD</dt>
<dd>Move when locked</dd>
</div>
{EDITOR_SHORTCUTS.map(([keys, description]) => (
<div key={keys}>
<dt>{keys}</dt>
<dd>{description}</dd>
</div>
))}
</dl>
</section>
@@ -29,6 +29,12 @@ interface EditorNodeCommonProps {
onHoverNode: (index: number | null) => void;
}
interface EditorNodePointerHandlers {
onClick: (event: ThreeEvent<MouseEvent>) => void;
onPointerEnter: (event: ThreeEvent<PointerEvent>) => void;
onPointerLeave: (event: ThreeEvent<PointerEvent>) => void;
}
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position);
object.rotation.set(...node.rotation);
@@ -88,6 +94,36 @@ function cloneHighlightedMaterial(
return clone;
}
function getNodeHighlightColor(
isSelected: boolean,
isHovered: boolean,
): string | null {
if (isSelected) return "#ffffff";
if (isHovered) return "#b8b8b8";
return null;
}
function createEditorNodePointerHandlers(
index: number,
onSelectNode: (index: number | null) => void,
onHoverNode: (index: number | null) => void,
): EditorNodePointerHandlers {
return {
onClick: (event) => {
event.stopPropagation();
onSelectNode(index);
},
onPointerEnter: (event) => {
event.stopPropagation();
onHoverNode(index);
},
onPointerLeave: (event) => {
event.stopPropagation();
onHoverNode(null);
},
};
}
export function EditorMap({
sceneData,
selectedNodeIndex,
@@ -224,15 +260,16 @@ function EditorModelNode({
const { scene } = useGLTF(modelUrl);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
useEffect(() => {
if (!groupRef.current) return;
const highlightColor = isSelected
? "#ffffff"
: isHovered
? "#b8b8b8"
: null;
const highlightColor = getNodeHighlightColor(isSelected, isHovered);
groupRef.current.traverse((child) => {
if (!(child instanceof THREE.Mesh)) {
@@ -288,18 +325,7 @@ function EditorModelNode({
position={node.position}
rotation={node.rotation}
scale={node.scale}
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onSelectNode(index);
}}
onPointerEnter={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(index);
}}
onPointerLeave={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(null);
}}
{...pointerHandlers}
/>
);
}
@@ -314,9 +340,14 @@ function EditorFallbackNode({
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
onHoverNode,
);
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
const color = isSelected ? "#ffffff" : isHovered ? "#b8b8b8" : "#6f6f6f";
const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f";
return (
<mesh
@@ -324,18 +355,7 @@ function EditorFallbackNode({
position={node.position}
rotation={node.rotation}
scale={node.scale}
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onSelectNode(index);
}}
onPointerEnter={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(index);
}}
onPointerLeave={(e: ThreeEvent<PointerEvent>) => {
e.stopPropagation();
onHoverNode(null);
}}
{...pointerHandlers}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={color} />
@@ -1,7 +1,7 @@
import { useEffect } from "react";
import { OrbitControls } from "@react-three/drei";
import { FlyController } from "@/features/editor/controls/FlyController";
import { EditorMap } from "@/features/editor/scene/EditorMap";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { FlyController } from "@/controls/editor/FlyController";
import type { MapNode, TransformMode, SceneData } from "@/types/editor";
interface EditorSceneProps {
+190
View File
@@ -0,0 +1,190 @@
/* eslint-disable react-hooks/immutability */
import { createContext, useRef, useState, useEffect, useCallback } from "react";
import { useGLTF, useAnimations } from "@react-three/drei";
import type { AnimationAction } from "three";
import * as THREE from "three";
import type { Vector3Tuple } from "@/types/three";
export interface AnimatedModelConfig {
modelPath: string;
animations?: string[];
defaultAnimation?: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
fadeDuration?: number;
speed?: number;
autoPlay?: boolean;
onLoaded?: () => void;
onAnimationEnd?: (animationName: string) => void;
}
export interface AnimatedModelContextValue {
play: (name: string, fade?: number) => void;
stop: (fade?: number) => void;
fadeTo: (name: string, fade?: number) => void;
currentAnimation: string;
isReady: boolean;
setSpeed: (speed: number) => void;
names: string[];
}
const AnimatedModelContext = createContext<AnimatedModelContextValue | null>(
null,
);
export { AnimatedModelContext };
interface AnimatedModelProps extends AnimatedModelConfig {
children?: React.ReactNode;
}
export function AnimatedModel({
modelPath,
defaultAnimation = "Idle",
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
fadeDuration = 0.3,
speed = 1,
autoPlay = true,
onLoaded,
onAnimationEnd,
children,
}: AnimatedModelProps): React.JSX.Element {
const groupRef = useRef<THREE.Group>(null);
void groupRef;
const { scene, animations } = useGLTF(modelPath);
const { actions, names, mixer } = useAnimations(animations, scene);
const [currentAnim, setCurrentAnim] = useState(defaultAnimation);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
if (mixer) {
mixer.timeScale = speed;
}
}, [mixer, speed]);
useEffect(() => {
const handleFinished = (e: { action: AnimationAction }) => {
const clipName = e.action.getClip().name;
onAnimationEnd?.(clipName);
};
if (mixer) {
mixer.addEventListener("finished", handleFinished);
return () => {
mixer.removeEventListener("finished", handleFinished);
};
}
}, [mixer, onAnimationEnd]);
const play = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fade);
});
action.reset().fadeIn(fade).play();
setCurrentAnim(name);
}
},
[actions, fadeDuration],
);
const stop = useCallback(
(fade = fadeDuration) => {
Object.values(actions).forEach((a) => a?.fadeOut(fade));
const defaultAction = actions[defaultAnimation];
if (defaultAction) {
defaultAction.reset().fadeIn(fade).play();
setCurrentAnim(defaultAnimation);
}
},
[actions, defaultAnimation, fadeDuration],
);
const fadeTo = useCallback(
(name: string, fade = fadeDuration) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fade);
});
action.reset().fadeIn(fade).play();
setCurrentAnim(name);
}
},
[actions, fadeDuration],
);
const setSpeed = useCallback(
(newSpeed: number) => {
if (mixer) {
mixer.timeScale = newSpeed;
}
},
[mixer],
);
useEffect(() => {
if (!autoPlay || names.length === 0) {
console.log("[AnimatedModel] No animation found in model");
return;
}
console.log(`[AnimatedModel] Available animations: ${names.join(", ")}`);
let defaultAction = actions[defaultAnimation as string];
if (!defaultAction && names.length > 0) {
console.log(
`[AnimatedModel] "${defaultAnimation}" not found, using: ${names[0]}`,
);
defaultAction = actions[names[0] as string];
}
if (defaultAction) {
defaultAction.play();
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsReady(true);
// eslint-disable-next-line react-hooks/set-state-in-effect
setCurrentAnim(defaultAction.getClip().name);
onLoaded?.();
} else {
console.log("[AnimatedModel] No available animation in actions");
}
}, [actions, defaultAnimation, names, autoPlay, onLoaded]);
const contextValue: AnimatedModelContextValue = {
play,
stop,
fadeTo,
currentAnimation: currentAnim,
isReady,
setSpeed,
names,
};
useEffect(() => {
scene.position.set(...position);
scene.rotation.set(
(rotation[0] * Math.PI) / 180,
(rotation[1] * Math.PI) / 180,
(rotation[2] * Math.PI) / 180,
);
const s =
typeof scale === "number" ? [scale, scale, scale] : (scale ?? [1, 1, 1]);
scene.scale.set(s[0] ?? 1, s[1] ?? 1, s[2] ?? 1);
}, [scene, position, rotation, scale]);
return (
<AnimatedModelContext.Provider value={contextValue}>
<primitive object={scene} />
{children}
</AnimatedModelContext.Provider>
);
}
@@ -3,7 +3,7 @@ import { useFrame, useThree } from "@react-three/fiber";
import { RigidBody } from "@react-three/rapier";
import type { RapierRigidBody } from "@react-three/rapier";
import * as THREE from "three";
import { InteractableObject } from "@/components/3d/InteractableObject";
import { InteractableObject } from "@/components/three/InteractableObject";
import {
GRAB_DEFAULT_COLLIDERS,
GRAB_DEFAULT_LABEL,
@@ -19,9 +19,9 @@ import {
GRAB_THROW_BOOST_MAX,
GRAB_THROW_BOOST_MIN,
GRAB_THROW_BOOST_STEP,
} from "@/data/grabConfig";
} from "@/data/interaction/grabConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
import type { ColliderShape, Vector3Tuple } from "@/types/three";
interface GrabbableObjectProps {
position: Vector3Tuple;
@@ -8,13 +8,13 @@ import {
INTERACTION_DEBUG_SPHERE_COLOR,
INTERACTION_DEBUG_SPHERE_OPACITY,
INTERACTION_DEBUG_SPHERE_SEGMENTS,
} from "@/data/debugConfig";
} from "@/data/debug/debugConfig";
import { Debug } from "@/utils/debug/Debug";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
import { InteractionManager } from "@/stateManager/InteractionManager";
import { INTERACTION_RADIUS } from "@/data/interactionConfig";
import type { Vector3Tuple } from "@/types/3d";
import type { InteractableHandle, InteractableKind } from "@/types/interaction";
import { InteractionManager } from "@/managers/InteractionManager";
import { INTERACTION_RADIUS } from "@/data/interaction/interactionConfig";
import type { Vector3Tuple } from "@/types/three";
import type { InteractableHandle } from "@/types/interaction";
interface InteractableObjectBaseProps {
label: string;
@@ -37,46 +37,67 @@ type InteractableObjectProps =
| TriggerInteractableObjectProps
| GrabInteractableObjectProps;
type MutableInteractableHandle = {
kind: InteractableKind;
label: string;
onPress: () => void;
onRelease?: () => void;
};
const _cameraPos = new THREE.Vector3();
const _cameraDir = new THREE.Vector3();
const _objectPos = new THREE.Vector3();
const _raycaster = new THREE.Raycaster();
function createInteractableHandle(
props: InteractableObjectProps,
): InteractableHandle {
if (props.kind === "grab") {
return {
kind: props.kind,
label: props.label,
onPress: props.onPress,
onRelease: props.onRelease,
};
}
return {
kind: props.kind,
label: props.label,
onPress: props.onPress,
};
}
export function InteractableObject(
props: InteractableObjectProps,
): React.JSX.Element {
const { kind, label, position, bodyRef, onPress, children } = props;
const onRelease = props.kind === "grab" ? props.onRelease : undefined;
const onRelease = props.kind === "grab" ? props.onRelease : null;
const camera = useThree((state) => state.camera);
const groupRef = useRef<THREE.Group>(null);
const debugSphereRef = useRef<THREE.Mesh>(null);
const handle = useRef<InteractableHandle>(
props.kind === "grab"
? { kind: props.kind, label, onPress, onRelease: props.onRelease }
: { kind: props.kind, label, onPress },
);
const handle = useRef<InteractableHandle>(createInteractableHandle(props));
useEffect(() => {
const current = handle.current as MutableInteractableHandle;
current.kind = kind;
current.label = label;
current.onPress = onPress;
const currentHandle = handle.current;
if (currentHandle.kind === kind) {
currentHandle.label = label;
currentHandle.onPress = onPress;
if (currentHandle.kind === "grab") {
if (!onRelease) return;
currentHandle.onRelease = onRelease;
}
if (kind === "grab" && onRelease) {
current.onRelease = onRelease;
return;
}
delete current.onRelease;
return undefined;
if (kind === "grab") {
if (!onRelease) return;
handle.current = { kind, label, onPress, onRelease };
} else {
handle.current = { kind, label, onPress };
}
const manager = InteractionManager.getInstance();
if (manager.getState().focused === currentHandle) {
manager.setFocused(handle.current);
}
}, [kind, label, onPress, onRelease]);
const setupInteractionDebugFolder = useCallback((folder: GUI) => {
+42
View File
@@ -0,0 +1,42 @@
import { useGLTF } from "@react-three/drei";
import type { Vector3Tuple } from "@/types/3d";
export interface SimpleModelConfig {
modelPath: string;
position?: Vector3Tuple;
rotation?: Vector3Tuple;
scale?: Vector3Tuple | number;
castShadow?: boolean;
receiveShadow?: boolean;
}
interface SimpleModelProps extends SimpleModelConfig {
children?: React.ReactNode;
}
export function SimpleModel({
modelPath,
position = [0, 0, 0],
rotation = [0, 0, 0],
scale = 1,
castShadow = true,
receiveShadow = true,
children,
}: SimpleModelProps): React.JSX.Element {
const { scene } = useGLTF(modelPath);
const parsedScale =
typeof scale === "number" ? ([scale, scale, scale] as Vector3Tuple) : scale;
return (
<group position={position} rotation={rotation} scale={parsedScale}>
{children ?? (
<primitive
object={scene.clone()}
castShadow={castShadow}
receiveShadow={receiveShadow}
/>
)}
</group>
);
}
@@ -1,15 +1,15 @@
import { useState } from "react";
import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import { InteractableObject } from "@/components/3d/InteractableObject";
import { InteractableObject } from "@/components/three/InteractableObject";
import {
TRIGGER_DEFAULT_COLLIDERS,
TRIGGER_DEFAULT_LABEL,
TRIGGER_DEFAULT_SOUND_VOLUME,
TRIGGER_DEFAULT_SPAWN_OFFSET,
} from "@/data/triggerConfig";
import { AudioManager } from "@/stateManager/AudioManager";
import type { ColliderShape, Vector3Tuple } from "@/types/3d";
} from "@/data/interaction/triggerConfig";
import { AudioManager } from "@/managers/AudioManager";
import type { ColliderShape, Vector3Tuple } from "@/types/three";
interface SpawnedModel {
id: number;
+8
View File
@@ -0,0 +1,8 @@
export { AnimatedModel, useAnimatedModel } from "./AnimatedModel";
export type { AnimatedModelConfig } from "./AnimatedModel";
export { SimpleModel } from "./SimpleModel";
export type { SimpleModelConfig } from "./SimpleModel";
export { useCharacterAnimation } from "@/hooks/useCharacterAnimation";
export type { CharacterAnimationConfig } from "@/hooks/useCharacterAnimation";
+1 -1
View File
@@ -1,4 +1,4 @@
import { INTERACT_KEY } from "@/data/keybindings";
import { INTERACT_KEY } from "@/data/input/keybindings";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useInteraction } from "@/hooks/useInteraction";
+11
View File
@@ -0,0 +1,11 @@
import { createContext } from "react";
export type DocsLanguage = "en" | "fr";
interface DocsLanguageContextValue {
language: DocsLanguage;
toggleLanguage: () => void;
}
export const DocsLanguageContext =
createContext<DocsLanguageContextValue | null>(null);
@@ -2,8 +2,6 @@ export const INTERACTION_DEBUG_SPHERE_SEGMENTS = 16;
export const INTERACTION_DEBUG_SPHERE_COLOR = "#facc15";
export const INTERACTION_DEBUG_SPHERE_OPACITY = 0.25;
export const MAP_DEBUG_BOX_HELPER_COLOR = 0x00ff88;
export const DEBUG_CAMERA_DAMPING_FACTOR = 0.05;
export const DEBUG_CAMERA_MIN_DISTANCE = 100;
export const DEBUG_CAMERA_MAX_DISTANCE = 1000;
@@ -1,4 +1,4 @@
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "@/types/three";
export const TEST_SCENE_FLOOR_POSITION: Vector3Tuple = [0, -0.5, 0];
export const TEST_SCENE_FLOOR_SIZE: Vector3Tuple = [200, 1, 200];
+66
View File
@@ -0,0 +1,66 @@
interface DocSection {
path: string;
title: string;
subtitle: string;
meta: string;
}
interface DocGroup {
label: string;
sections: DocSection[];
}
export const docGroups: DocGroup[] = [
{
label: "Technical",
sections: [
{
path: "/docs",
title: "README",
subtitle: "Project overview",
meta: "01",
},
{
path: "/docs/architecture",
title: "Current Architecture",
subtitle: "Runtime structure",
meta: "02",
},
{
path: "/docs/target-architecture",
title: "Target Architecture",
subtitle: "Next direction",
meta: "03",
},
{
path: "/docs/technical-editor",
title: "Editor Technical Notes",
subtitle: "Implementation details",
meta: "04",
},
],
},
{
label: "User",
sections: [
{
path: "/docs/features",
title: "Features",
subtitle: "Implemented scope",
meta: "05",
},
{
path: "/docs/editor",
title: "Editor User Guide",
subtitle: "Editing workflow",
meta: "06",
},
{
path: "/docs/animation",
title: "Animation & 3D Model System",
subtitle: "Components and usage",
meta: "07",
},
],
},
];
+329
View File
@@ -0,0 +1,329 @@
export const readmeFr = `# La-Fabrik
Une expérience web 3D interactive pour La Fabrik Durable, un service low-tech de réparation et de transformation situé à Altera, une ville post-capitaliste reconstruite en 2039. Les joueurs incarnent un technicien fraîchement intégré et vivent une journée de service : réparer un vélo électrique, remettre en état un réseau d'énergie et améliorer le système d'irrigation d'une ferme verticale.
Construit avec React, Three.js et Vite. Fonctionne dans le navigateur, sans installation côté utilisateur.
## Stack technique
### Build et langage
| Package |
| -------------------------------------------------- |
| [TypeScript](https://www.typescriptlang.org/docs/) |
| [React](https://react.dev/learn) |
| [Vite](https://vite.dev/guide/) |
| [ESLint](https://eslint.org/docs/latest/) |
| [Prettier](https://prettier.io/docs/) |
### Moteur 3D
| Package |
| ----------------------------------------------------------------------------------------- |
| [Three.js](https://threejs.org/docs/) |
| [@react-three/fiber](https://docs.pmnd.rs/react-three-fiber/getting-started/introduction) |
| [@react-three/drei](https://pmndrs.github.io/drei) |
| [@react-three/rapier](https://rapier.rs/docs/) |
| [@react-three/postprocessing](https://github.com/pmndrs/postprocessing) |
| [GSAP](https://gsap.com/docs/v3/Installation/) |
### Performance et effets
| Package |
| --------------------------------------------------------------------------- |
| [r3f-perf](https://github.com/utsuboco/r3f-perf) |
| [AnimationMixer](https://threejs.org/docs/#api/en/animation/AnimationMixer) |
## Structure du projet
\`\`\`
la-fabrik/
├── public/
│ ├── models/
│ │ ├── map/ # Carte de base, chargée au démarrage
│ │ ├── workshop/
│ │ ├── powerGrid/
│ │ └── farm/
│ ├── textures/
│ └── sounds/
└── src/
├── world/ # Monde 3D persistant
│ ├── World.tsx # Composition principale de la scène
│ ├── Map.tsx # Carte de base, toujours montée
│ ├── Lighting.tsx # Lumières ambiante, directionnelle et ponctuelles
│ ├── Environment.tsx # HDRI, brouillard, ciel
│ ├── PostFX.tsx # Bloom, SSAO, aberration chromatique
│ ├── zones/ # Zones spatiales, LOD par zone
│ └── player/ # Contrôleur joueur et caméra
├── components/
│ ├── 3d/ # Éléments 3D réutilisables
│ └── ui/ # Overlays HTML hors Canvas
├── managers/ # Logique, état et orchestration
├── hooks/ # Hooks React autour des managers
├── data/ # Configuration statique
├── shaders/ # Shaders GLSL
└── utils/ # Utilitaires partagés et debug
\`\`\`
## Démarrage
\`\`\`bash
git clone https://github.com/La-Fabrik-Durable/La-Fabrik.git
cd La-Fabrik
npm install
npm run dev
\`\`\`
- application : \`http://localhost:5173\`
- mode debug : \`http://localhost:5173?debug\`
## Licence
Voir le fichier [LICENSE](./LICENSE).
`;
export const architectureFr = `# Architecture actuelle
Ce document décrit le code réellement présent aujourd'hui dans le dépôt.
## Structure runtime
- \`src/App.tsx\` monte le \`RouterProvider\`, qui pilote l'affichage des vues de l'application.
- \`src/pages/page.tsx\` monte le \`Canvas\`, le \`World\` 3D, l'overlay de performance debug et les overlays HTML.
- \`src/world/World.tsx\` compose la scène active avec :
- l'environnement et l'éclairage
- les helpers debug et le mode caméra debug
- soit la carte principale, soit la scène de test physique debug
- le rig joueur quand le mode caméra actif est \`player\`
- \`src/world/GameMap.tsx\` charge les modèles de carte disponibles et construit l'octree de collision.
- \`src/world/debug/TestScene.tsx\` fournit une scène orientée debug pour les interactions et la physique.
- \`src/world/player/Player.tsx\` monte la caméra et le contrôleur.
- \`src/world/player/PlayerController.tsx\` gère le mouvement pointer lock, le saut et les inputs d'interaction.
## Modèle d'interaction
- \`src/managers/InteractionManager.ts\` est la source d'état actuelle des interactions.
- \`src/components/three/InteractableObject.tsx\` gère la détection de focus par distance et raycasting.
- \`src/components/three/TriggerObject.tsx\` implémente les interactions de type trigger.
- \`src/components/three/GrabbableObject.tsx\` implémente les interactions saisir / relâcher.
- \`src/hooks/useInteraction.ts\` expose un snapshot d'interaction à l'UI React.
- \`src/components/ui/InteractPrompt.tsx\` affiche le prompt \`E\` pour les interactions trigger.
## Audio
- \`src/managers/AudioManager.ts\` fournit actuellement une lecture de sons one-shot avec pool.
- Les interactions trigger peuvent lancer directement un son via \`AudioManager\`.
## Système debug
- Le mode debug est activé avec \`?debug\`.
- \`src/utils/debug/Debug.ts\` possède l'instance \`lil-gui\` et les contrôles debug.
- \`src/hooks/debug/useCameraMode.ts\` et \`src/hooks/debug/useSceneMode.ts\` s'abonnent à l'état debug.
- \`src/components/debug/DebugPerf.tsx\` monte \`r3f-perf\` en lazy uniquement en mode debug.
- \`src/components/debug/scene/DebugHelpers.tsx\` monte les helpers debug.
- \`src/components/debug/scene/DebugCameraControls.tsx\` monte la caméra libre debug.
## Limites actuelles
- Le dépôt est encore un prototype, pas le runtime complet du jeu.
- \`src/world/debug/TestScene.tsx\` fait encore partie de la composition active.
- Il n'existe pas encore d'orchestrateur gameplay central comme \`GameManager\`.
- Les systèmes de missions, zones, cinématiques et dialogues ne sont pas implémentés.
- Le joueur utilise une collision octree et des règles simples, pas une pile physique gameplay complète.
`;
export const targetArchitectureFr = `# Architecture cible
Ce document décrit l'architecture visée à moyen terme pour le projet.
## Relation avec le code actuel
- \`docs/technical/architecture.md\` reste la source de vérité de ce qui existe maintenant.
- Ce document est volontairement aspirational.
- Si ce document contredit l'implémentation actuelle, l'implémentation actuelle gagne.
## Objectifs
- Garder \`App.tsx\` petit et centré sur l'orchestration.
- Séparer le code de production du monde des chemins runtime uniquement debug.
- Garder une source de vérité claire par responsabilité.
- Faire grandir les systèmes gameplay progressivement, sans préconstruire une architecture vide.
## Couches prévues
### Couche App
- \`App.tsx\` monte la scène canvas et les overlays HTML de premier niveau.
- Il doit rester fin et éviter la logique gameplay.
### Couche World
- \`src/world/\` doit contenir la composition de scène de production et les objets de scène de production.
- Responsabilités attendues :
- composition du monde
- carte, environnement, éclairage
- contrôleur joueur
- ancres d'interaction de production
- post-processing de production si nécessaire
### Couche Debug
- Les scènes et outils uniquement debug doivent être isolés du chemin de production.
- Responsabilités attendues :
- \`lil-gui\`
- overlay de performance
- helpers de scène
- caméra libre et contrôles de calibration
- scènes temporaires de test utilisées pendant le développement
### Couche UI
- \`src/components/ui/\` doit contenir les overlays HTML visibles par le joueur.
- Exemples futurs :
- crosshair
- flow de chargement
- HUD de mission
- overlays narratifs
### Couche Gameplay
- À mesure que le projet grandit, l'état gameplay peut évoluer vers une couche d'orchestration plus claire.
- Sujets probables :
- missions
- zones
- cinématiques
- dialogues
- audio
- interactions
## Règles
- Préférer du code direct et fonctionnel plutôt qu'un échafaudage spéculatif.
- Les types partagés doivent rester proches de leur domaine jusqu'à avoir plusieurs vrais consommateurs.
- Éviter de créer de nouveaux managers ou services sans besoin runtime actif.
- Les chemins runtime uniquement debug doivent être clairement marqués et faciles à retirer plus tard.
`;
export const featuresFr = `# Fonctionnalités implémentées
Ce document liste les fonctionnalités présentes dans le code actuel.
## Scène
- Scène React Three Fiber plein écran
- Carte principale chargée depuis \`public/models/map/model.gltf\`
- Scène de test physique debug sélectionnable depuis le panneau debug
- Éclairage ambiant et directionnel
- Configuration de l'environnement de fond
## Joueur
- Mode caméra joueur
- Orientation souris avec pointer lock
- Déplacement avec \`ZQSD\`
- Saut
- Collision basée sur une octree contre la carte chargée
## Interactions
- Détection de focus par distance et raycast
- Interactions trigger activées avec \`E\`
- Interactions grab activées avec le bouton principal de la souris
- Prompt d'interaction affiché pour les interactions trigger
## Audio
- Lecture de sons one-shot pour les interactions trigger
- Pool simple par son via \`AudioManager\`
## Outils debug
- Le paramètre \`?debug\` active le panneau debug
- Contrôles \`lil-gui\` pour le mode caméra, le mode scène et les sphères d'interaction
- Helpers de scène debug
- Caméra libre debug
- Overlay \`r3f-perf\`
## Pas encore implémenté
- système de missions
- système de zones
- système de cinématiques
- système de dialogues
- flow de chargement
- minimap et HUD de mission
- séparation complète production / debug pour les scènes gameplay
`;
export const editorFr = `# Éditeur de carte
L'éditeur de carte est disponible sur "/editor". Il permet d'inspecter et d'ajuster les objets déclarés dans "/public/map.json" directement depuis le navigateur.
## Ce qui est édité
L'éditeur travaille sur la liste de nodes stockée dans "/public/map.json".
Chaque node décrit un objet de la scène :
- "name" : nom du dossier modèle dans "/public/models/{name}/model.gltf"
- "type" : catégorie de l'objet
- "position" : "[x, y, z]"
- "rotation" : "[x, y, z]"
- "scale" : "[x, y, z]"
Les modèles sont chargés depuis "/public/models". Si un modèle manque, l'éditeur affiche un cube gris de remplacement pour que le node reste sélectionnable et déplaçable.
## Workflow de base
1. Ouvrir "/editor".
2. Sélectionner un objet dans la vue 3D.
3. Choisir un mode de transformation : translation, rotation ou scale.
4. Déplacer la gizmo de transformation.
5. Utiliser undo ou redo si nécessaire.
6. Exporter le JSON mis à jour ou le sauvegarder sur le serveur de dev.
## Contrôles
| Action | Input |
| --- | --- |
| Sélectionner un objet | Clic sur l'objet |
| Désélectionner | "Esc" ou clic dans le vide |
| Mode translation | "T" |
| Mode rotation | "R" |
| Mode scale | "S" |
| Undo | "Ctrl+Z" |
| Redo | "Ctrl+Y" |
| Déplacement en vue verrouillée | "WASD", "ZQSD", flèches |
| Monter / descendre | "Space", "Shift" |
## Actions fichier
### Export JSON
"Export JSON" télécharge la liste actuelle des nodes sous le nom "map.json". À utiliser pour remplacer manuellement "/public/map.json".
### Save to server
"Save to server" est disponible uniquement en développement local. L'action écrit la carte modifiée dans "/public/map.json" via l'endpoint du serveur de dev Vite.
Cette action est masquée dans les builds de production car il n'existe pas encore d'API de persistance production.
## Inspecteur JSON
Le panneau latéral affiche le JSON brut de la carte :
- sans sélection, il affiche toute la liste des nodes
- avec un objet sélectionné, il met en évidence les lignes du node sélectionné
Utilise-le pour vérifier les valeurs numériques exactes avant export ou sauvegarde.
## Limites actuelles
- L'éditeur modifie uniquement les nodes existants.
- Il n'y a pas encore d'interface pour créer ou supprimer des objets.
- La sauvegarde production n'est pas implémentée.
- Les modèles manquants s'affichent comme cubes de fallback au lieu de bloquer tout l'éditeur.
`;
@@ -1,4 +1,4 @@
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "@/types/three";
export const TRIGGER_DEFAULT_COLLIDERS = "ball";
export const TRIGGER_DEFAULT_LABEL = "Interagir";
@@ -1,4 +1,4 @@
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "@/types/three";
export const PLAYER_EYE_HEIGHT = 1.75;
export const PLAYER_CAPSULE_RADIUS = 0.35;
+12
View File
@@ -0,0 +1,12 @@
import { useContext } from "react";
import { DocsLanguageContext } from "@/contexts/docs/DocsLanguageContext";
export function useDocsLanguage() {
const context = useContext(DocsLanguageContext);
if (!context) {
throw new Error("useDocsLanguage must be used inside DocsLanguageProvider");
}
return context;
}
+107
View File
@@ -0,0 +1,107 @@
/* eslint-disable react-hooks/immutability */
import { useRef, useEffect, useState, useCallback } from "react";
import { useGLTF, useAnimations } from "@react-three/drei";
import type { AnimationAction, AnimationMixer } from "three";
import * as THREE from "three";
export interface CharacterAnimationConfig {
modelPath: string;
initialAnimation?: string;
fadeDuration?: number;
}
interface UseCharacterAnimationReturn {
scene: THREE.Group;
actions: { [key: string]: AnimationAction | null };
names: string[];
mixer: AnimationMixer;
groupRef: React.MutableRefObject<THREE.Group | null>;
currentAnimation: string;
play: (name: string) => void;
stop: () => void;
fadeTo: (name: string, duration?: number) => void;
setAnimationSpeed: (speed: number) => void;
}
const DEFAULT_FADE_DURATION = 0.3;
export function useCharacterAnimation(
config: CharacterAnimationConfig,
): UseCharacterAnimationReturn {
const {
modelPath,
initialAnimation = "Idle",
fadeDuration = DEFAULT_FADE_DURATION,
} = config;
const groupRef = useRef<THREE.Group | null>(null);
const { scene, animations } = useGLTF(modelPath);
const { actions, names, mixer } = useAnimations(animations, groupRef);
const [currentAnimation, setCurrentAnimation] = useState(initialAnimation);
const play = useCallback(
(name: string) => {
const action = actions[name];
if (action) {
Object.values(actions).forEach((a) => {
if (a && a !== action) a.fadeOut(fadeDuration);
});
action.reset().fadeIn(fadeDuration).play();
setCurrentAnimation(name);
}
},
[actions, fadeDuration],
);
const stop = useCallback(() => {
Object.values(actions).forEach((a) => a?.fadeOut(fadeDuration));
const defaultAction = actions[initialAnimation as string];
if (defaultAction) {
defaultAction.reset().fadeIn(fadeDuration).play();
setCurrentAnimation(initialAnimation);
}
}, [actions, initialAnimation, fadeDuration]);
const fadeTo = useCallback(
(name: string, duration = fadeDuration) => {
const targetAction = actions[name];
if (targetAction) {
Object.values(actions).forEach((a) => {
if (a && a !== targetAction) a.fadeOut(duration);
});
targetAction.reset().fadeIn(duration).play();
setCurrentAnimation(name);
}
},
[actions, fadeDuration],
);
const setAnimationSpeed = useCallback(
(speed: number) => {
if (mixer) {
mixer.timeScale = speed;
}
},
[mixer],
);
useEffect(() => {
const defaultAction = actions[initialAnimation as string];
if (defaultAction) {
defaultAction.play();
}
}, [actions, initialAnimation]);
return {
scene,
actions,
names,
mixer,
groupRef,
currentAnimation,
play,
stop,
fadeTo,
setAnimationSpeed,
};
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { useSyncExternalStore } from "react";
import { InteractionManager } from "@/stateManager/InteractionManager";
import { InteractionManager } from "@/managers/InteractionManager";
import type { InteractionSnapshot } from "@/types/interaction";
const manager = InteractionManager.getInstance();
+7 -2
View File
@@ -2,14 +2,19 @@ import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import type { Object3D } from "three";
import { Octree } from "three/addons/math/Octree.js";
import type { OctreeReadyHandler } from "@/types/3d";
import type { OctreeReadyHandler } from "@/types/three";
export function useOctreeGraphNode(
graphNodeRef: RefObject<Object3D | null>,
onOctreeReady: OctreeReadyHandler,
rebuildKey: string | number = 0,
): void {
const octreeBuilt = useRef(false);
useEffect(() => {
octreeBuilt.current = false;
}, [rebuildKey]);
useEffect(() => {
const graphNode = graphNodeRef.current;
if (octreeBuilt.current || !graphNode) return;
@@ -20,5 +25,5 @@ export function useOctreeGraphNode(
const octree = new Octree();
octree.fromGraphNode(graphNode);
onOctreeReady(octree);
}, [graphNodeRef, onOctreeReady]);
}, [graphNodeRef, onOctreeReady, rebuildKey]);
}
+312 -1
View File
@@ -1,6 +1,8 @@
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
:root {
color-scheme: dark;
font-family: Inter;
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
}
html,
@@ -27,6 +29,315 @@ canvas {
display: block;
}
.docs-page {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
width: 100vw;
height: 100vh;
overflow: hidden;
background: #050505;
color: #f4efe7;
font-family: "Helvetica Neue", Helvetica, Inter, Arial, sans-serif;
}
.docs-sidebar {
border-right: 2px solid #d8d0c4;
background: #050505;
overflow-y: auto;
}
.docs-sidebar__header,
.docs-content__header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 78px;
padding: 0 18px;
border-bottom: 2px solid #d8d0c4;
}
.docs-sidebar__header h1,
.docs-content__header span {
margin: 0;
color: #f4efe7;
font-size: 21px;
font-weight: 700;
letter-spacing: -0.04em;
}
.docs-sidebar nav {
display: grid;
}
.docs-nav-group {
display: grid;
border-bottom: 2px solid #d8d0c4;
}
.docs-nav-group h2 {
margin: 0;
padding: 13px 16px 8px;
color: #a9a196;
font-size: 10px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.docs-sidebar a {
color: #f4efe7;
text-decoration: none;
}
.docs-nav-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 46px;
padding: 0 16px;
border-top: 1px solid rgba(216, 208, 196, 0.35);
color: #f4efe7;
transition:
background 160ms ease,
color 160ms ease;
}
.docs-home-link {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 2px solid currentColor;
border-radius: 999px;
transition:
background 160ms ease,
color 160ms ease;
}
.docs-nav-item span:first-child {
display: grid;
gap: 2px;
}
.docs-nav-item strong {
font-size: 13px;
font-weight: 700;
letter-spacing: -0.03em;
}
.docs-nav-item small,
.docs-nav-item__meta {
color: #a9a196;
font-size: 11px;
font-weight: 600;
letter-spacing: -0.01em;
}
.docs-sidebar a:hover,
.docs-sidebar a:focus-visible,
.docs-nav-item--active {
background: #f4efe7;
color: #050505;
outline: none;
}
.docs-sidebar a:hover small,
.docs-sidebar a:hover .docs-nav-item__meta,
.docs-sidebar a:focus-visible small,
.docs-sidebar a:focus-visible .docs-nav-item__meta,
.docs-nav-item--active small,
.docs-nav-item--active .docs-nav-item__meta {
color: #050505;
}
.docs-content {
overflow-y: auto;
scroll-behavior: smooth;
background: #050505;
}
.docs-content__header {
position: sticky;
top: 0;
z-index: 2;
background: #050505;
}
.docs-language-toggle {
display: inline-flex;
align-items: center;
gap: 0;
padding: 2px;
border: 2px solid #d8d0c4;
border-radius: 999px;
background: transparent;
color: #f4efe7;
cursor: pointer;
}
.docs-language-toggle span {
display: grid;
place-items: center;
min-width: 36px;
min-height: 26px;
border-radius: 999px;
color: #a9a196;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
}
.docs-language-toggle .is-active {
background: #f4efe7;
color: #050505;
}
.docs-language-toggle:hover,
.docs-language-toggle:focus-visible {
outline: none;
background: rgba(216, 208, 196, 0.1);
}
.docs-section {
max-width: 920px;
margin: 0 auto;
padding: 34px clamp(18px, 4vw, 56px) 48px;
}
.docs-section__eyebrow {
display: flex;
justify-content: space-between;
margin-bottom: 22px;
color: #a9a196;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.docs-section h1,
.docs-section h2,
.docs-section h3 {
color: #f4efe7;
letter-spacing: -0.06em;
line-height: 1.05;
}
.docs-section h1 {
margin-top: 0;
margin-bottom: 20px;
font-size: clamp(46px, 7vw, 88px);
font-weight: 700;
}
.docs-section h2 {
margin-top: 44px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 2px solid #d8d0c4;
font-size: clamp(28px, 4vw, 44px);
font-weight: 700;
}
.docs-section h3 {
margin-top: 30px;
margin-bottom: 10px;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.03em;
}
.docs-section p,
.docs-section li {
color: #d8d0c4;
font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 15px;
line-height: 1.75;
}
.docs-section ul,
.docs-section ol {
padding-left: 24px;
}
.docs-section a {
color: #f4efe7;
text-underline-offset: 4px;
}
.docs-section code {
border: 0;
border-radius: 2px;
padding: 2px 5px;
background: rgba(216, 208, 196, 0.22);
color: #f4efe7;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.92em;
}
.docs-section pre {
overflow-x: auto;
padding: 18px;
border: 0;
border-radius: 0;
background: #0d0d0d;
}
.docs-section pre code {
display: block;
padding: 0;
border: 0;
background: transparent;
color: #f4efe7;
line-height: 1.45;
white-space: pre;
}
.docs-section table {
display: block;
width: 100%;
margin: 20px 0;
overflow-x: auto;
border-collapse: collapse;
}
.docs-section th,
.docs-section td {
padding: 10px 12px;
border: 2px solid #d8d0c4;
text-align: left;
}
.docs-section th {
background: #111;
color: #f4efe7;
font-weight: 700;
}
.docs-section blockquote {
margin-left: 0;
padding-left: 18px;
border-left: 2px solid #d8d0c4;
color: #a9a196;
}
@media (max-width: 760px) {
.docs-page {
display: block;
overflow-y: auto;
}
.docs-sidebar {
border-right: 0;
border-bottom: 2px solid #d8d0c4;
}
.docs-content {
overflow: visible;
padding: 24px 16px;
}
}
.crosshair {
position: fixed;
top: 50%;
+1 -4
View File
@@ -1,13 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
<App />
</StrictMode>,
);
+13
View File
@@ -0,0 +1,13 @@
import animation from "../../../../docs/technical/animation.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsAnimationPage(): React.JSX.Element {
return (
<DocsDocument
content={animation}
frContent={animation}
meta="07"
title="Animation & 3D Model System"
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import architecture from "../../../../docs/technical/architecture.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { architectureFr } from "@/data/docs/docsTranslations";
export function DocsArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={architecture}
frContent={architectureFr}
meta="02"
title="Architecture actuelle"
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import editor from "../../../../docs/user/editor.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { editorFr } from "@/data/docs/docsTranslations";
export function DocsEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={editor}
frContent={editorFr}
meta="06"
title="Editor User Guide"
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import features from "../../../../docs/user/features.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { featuresFr } from "@/data/docs/docsTranslations";
export function DocsFeaturesPage(): React.JSX.Element {
return (
<DocsDocument
content={features}
frContent={featuresFr}
meta="05"
title="Features"
/>
);
}
+14
View File
@@ -0,0 +1,14 @@
import readme from "../../../README.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { readmeFr } from "@/data/docs/docsTranslations";
export function DocsReadmePage(): React.JSX.Element {
return (
<DocsDocument
content={readme}
frContent={readmeFr}
meta="01"
title="README"
/>
);
}
@@ -0,0 +1,14 @@
import targetArchitecture from "../../../../docs/technical/target-architecture.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
import { targetArchitectureFr } from "@/data/docs/docsTranslations";
export function DocsTargetArchitecturePage(): React.JSX.Element {
return (
<DocsDocument
content={targetArchitecture}
frContent={targetArchitectureFr}
meta="03"
title="Architecture cible"
/>
);
}
+13
View File
@@ -0,0 +1,13 @@
import technicalEditor from "../../../../docs/technical/editor.md?raw";
import { DocsDocument } from "@/components/docs/DocsDocument";
export function DocsTechnicalEditorPage(): React.JSX.Element {
return (
<DocsDocument
content={technicalEditor}
frContent={technicalEditor}
meta="04"
title="Editor Technical Notes"
/>
);
}
@@ -1,10 +1,16 @@
import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { EditorControls } from "@/features/editor/components/EditorControls";
import { useEditorHistory } from "@/features/editor/hooks/useEditorHistory";
import { useEditorSceneData } from "@/features/editor/hooks/useEditorSceneData";
import { EditorScene } from "@/features/editor/scene/EditorScene";
import type { MapNode, TransformMode } from "@/types/editor";
import { EditorControls } from "@/components/editor/EditorControls";
import { EditorScene } from "@/components/editor/scene/EditorScene";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
import type { MapNode, SceneData, TransformMode } from "@/types/editor";
const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement";
function serializeMapNodes(sceneData: SceneData): string {
return JSON.stringify(sceneData.mapNodes, null, 2);
}
export function EditorPage(): React.JSX.Element {
const {
@@ -46,7 +52,7 @@ export function EditorPage(): React.JSX.Element {
const handleSaveToServer = useCallback(async () => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const json = serializeMapNodes(sceneData);
try {
const response = await fetch("/api/save-map", {
@@ -58,17 +64,17 @@ export function EditorPage(): React.JSX.Element {
if (response.ok) {
alert("Map enregistrée avec succès!");
} else {
alert("Erreur lors de l'enregistrement");
alert(SAVE_ERROR_MESSAGE);
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
alert(SAVE_ERROR_MESSAGE);
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const json = serializeMapNodes(sceneData);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
+21
View File
@@ -0,0 +1,21 @@
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/components/debug/DebugPerf";
import { World } from "@/world/World";
export function HomePage(): React.JSX.Element {
return (
<>
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
<Suspense fallback={null}>
<World />
<DebugPerf />
</Suspense>
</Canvas>
<Crosshair />
<InteractPrompt />
</>
);
}
@@ -0,0 +1,25 @@
import { useState } from "react";
import {
DocsLanguageContext,
type DocsLanguage,
} from "@/contexts/docs/DocsLanguageContext";
interface DocsLanguageProviderProps {
children: React.ReactNode;
}
export function DocsLanguageProvider({
children,
}: DocsLanguageProviderProps): React.JSX.Element {
const [language, setLanguage] = useState<DocsLanguage>("en");
function toggleLanguage(): void {
setLanguage((currentLanguage) => (currentLanguage === "en" ? "fr" : "en"));
}
return (
<DocsLanguageContext.Provider value={{ language, toggleLanguage }}>
{children}
</DocsLanguageContext.Provider>
);
}
+70
View File
@@ -0,0 +1,70 @@
import {
Outlet,
createRootRoute,
createRoute,
createRouter,
} from "@tanstack/react-router";
import { HomePage } from "@/pages/page";
import { EditorPage } from "@/pages/editor/page";
import {
DocsAnimationRoute,
DocsArchitectureRoute,
DocsEditorRoute,
DocsFeaturesRoute,
DocsLayoutRoute,
DocsReadmeRoute,
DocsTargetArchitectureRoute,
DocsTechnicalEditorRoute,
} from "@/routes/docs/DocsRouteComponents";
const rootRoute = createRootRoute({
component: Outlet,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: HomePage,
});
const editorRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/editor",
component: EditorPage,
});
const docsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/docs",
component: DocsLayoutRoute,
});
const docsChildRoutes = [
{ path: "/", component: DocsReadmeRoute },
{ path: "architecture", component: DocsArchitectureRoute },
{ path: "target-architecture", component: DocsTargetArchitectureRoute },
{ path: "technical-editor", component: DocsTechnicalEditorRoute },
{ path: "features", component: DocsFeaturesRoute },
{ path: "editor", component: DocsEditorRoute },
{ path: "animation", component: DocsAnimationRoute },
].map(({ path, component }) =>
createRoute({
getParentRoute: () => docsRoute,
path,
component,
}),
);
const routeTree = rootRoute.addChildren([
indexRoute,
editorRoute,
docsRoute.addChildren(docsChildRoutes),
]);
export const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
+113
View File
@@ -0,0 +1,113 @@
import { Suspense, lazy } from "react";
const LazyDocsLayout = lazy(() =>
import("@/components/docs/DocsLayout").then((module) => ({
default: module.DocsLayout,
})),
);
const LazyDocsReadmePage = lazy(() =>
import("@/pages/docs/page").then((module) => ({
default: module.DocsReadmePage,
})),
);
const LazyDocsArchitecturePage = lazy(() =>
import("@/pages/docs/architecture/page").then((module) => ({
default: module.DocsArchitecturePage,
})),
);
const LazyDocsTargetArchitecturePage = lazy(() =>
import("@/pages/docs/target-architecture/page").then((module) => ({
default: module.DocsTargetArchitecturePage,
})),
);
const LazyDocsTechnicalEditorPage = lazy(() =>
import("@/pages/docs/technical-editor/page").then((module) => ({
default: module.DocsTechnicalEditorPage,
})),
);
const LazyDocsFeaturesPage = lazy(() =>
import("@/pages/docs/features/page").then((module) => ({
default: module.DocsFeaturesPage,
})),
);
const LazyDocsEditorPage = lazy(() =>
import("@/pages/docs/editor/page").then((module) => ({
default: module.DocsEditorPage,
})),
);
const LazyDocsAnimationPage = lazy(() =>
import("@/pages/docs/animation/page").then((module) => ({
default: module.DocsAnimationPage,
})),
);
export function DocsLayoutRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsLayout />
</Suspense>
);
}
export function DocsReadmeRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsReadmePage />
</Suspense>
);
}
export function DocsArchitectureRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsArchitecturePage />
</Suspense>
);
}
export function DocsTargetArchitectureRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsTargetArchitecturePage />
</Suspense>
);
}
export function DocsTechnicalEditorRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsTechnicalEditorPage />
</Suspense>
);
}
export function DocsFeaturesRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsFeaturesPage />
</Suspense>
);
}
export function DocsEditorRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsEditorPage />
</Suspense>
);
}
export function DocsAnimationRoute(): React.JSX.Element {
return (
<Suspense fallback={null}>
<LazyDocsAnimationPage />
</Suspense>
);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "./three";
export interface MapNode {
name: string;
+3 -2
View File
@@ -1,4 +1,5 @@
import type { MapNode, SceneData } from "@/types/editor";
import type { SceneData } from "@/types/editor";
import { parseMapNodes } from "@/utils/mapNodeValidation";
const MAP_JSON_PATH = "/map.json";
@@ -16,7 +17,7 @@ export async function createSceneDataFromFiles(
throw new Error("Fichier map.json manquant à la racine du dossier");
}
const mapNodes: MapNode[] = JSON.parse(await mapFile.text());
const mapNodes = parseMapNodes(JSON.parse(await mapFile.text()));
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
+5 -2
View File
@@ -1,7 +1,9 @@
import type { MapNode, SceneData } from "@/types/editor";
import { parseMapNodes } from "@/utils/mapNodeValidation";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAME = "model.gltf";
type ModelEntry = [modelName: string, modelUrl: string];
export async function loadMapSceneData(): Promise<SceneData | null> {
const response = await fetch(MAP_JSON_PATH);
@@ -10,7 +12,7 @@ export async function loadMapSceneData(): Promise<SceneData | null> {
return null;
}
const mapNodes: MapNode[] = await response.json();
const mapNodes = parseMapNodes(await response.json());
return createSceneData(mapNodes);
}
@@ -29,7 +31,8 @@ async function loadMapModelUrls(
try {
const response = await fetch(modelUrl, { method: "HEAD" });
return response.ok ? ([modelName, modelUrl] as const) : null;
const modelEntry: ModelEntry = [modelName, modelUrl];
return response.ok ? modelEntry : null;
} catch {
return null;
}
+32
View File
@@ -0,0 +1,32 @@
import type { MapNode } from "../types/editor";
function isVector3Tuple(value: unknown): value is [number, number, number] {
return (
Array.isArray(value) &&
value.length === 3 &&
value.every((item) => typeof item === "number" && Number.isFinite(item))
);
}
export function isMapNode(value: unknown): value is MapNode {
if (typeof value !== "object" || value === null) {
return false;
}
const node = value as Record<string, unknown>;
return (
typeof node.name === "string" &&
typeof node.type === "string" &&
isVector3Tuple(node.position) &&
isVector3Tuple(node.rotation) &&
isVector3Tuple(node.scale)
);
}
export function parseMapNodes(value: unknown): MapNode[] {
if (!Array.isArray(value) || !value.every(isMapNode)) {
throw new Error("Invalid map node data");
}
return value;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { Environment as DreiEnvironment } from "@react-three/drei";
import {
GAME_SCENE_SKYBOX_PATH,
PHYSICS_SCENE_BACKGROUND_COLOR,
} from "@/data/environmentConfig";
} from "@/data/world/environmentConfig";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
export function Environment(): React.JSX.Element {
+61 -13
View File
@@ -1,11 +1,49 @@
import { useEffect, useMemo, useState, useRef } from "react";
import type { ReactNode } from "react";
import { Component } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import { loadMapSceneData } from "@/utils/loadMapSceneData";
import type { OctreeReadyHandler } from "@/types/3d";
import type { OctreeReadyHandler } from "@/types/three";
import type { MapNode } from "@/types/editor";
interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
}
class ModelErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
static getDerivedStateFromError(_error: Error): ErrorBoundaryState {
return { hasError: true };
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
componentDidCatch(_error: Error): void {
console.warn(`Failed to load model`);
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback ?? null;
}
return this.props.children;
}
}
interface GameMapProps {
onOctreeReady: OctreeReadyHandler;
}
@@ -15,7 +53,7 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const [isLoading, setIsLoading] = useState(true);
const groupRef = useRef<THREE.Group>(null);
useOctreeGraphNode(groupRef, onOctreeReady);
useOctreeGraphNode(groupRef, onOctreeReady, mapNodes.length);
useEffect(() => {
const loadMap = async () => {
@@ -27,9 +65,19 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
return;
}
setMapNodes(
sceneData.mapNodes.filter((node) => sceneData.models.has(node.name)),
const loadedMapNodes = sceneData.mapNodes.filter((node) =>
sceneData.models.has(node.name),
);
const missingModelCount =
sceneData.mapNodes.length - loadedMapNodes.length;
if (missingModelCount > 0) {
console.warn(
`${missingModelCount} map nodes were skipped because their model files are missing.`,
);
}
setMapNodes(loadedMapNodes);
} catch (error) {
console.error("Error loading map:", error);
} finally {
@@ -40,21 +88,21 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
loadMap();
}, []);
if (isLoading) {
return <></>;
}
return (
<group ref={groupRef}>
{mapNodes.map((node, index) => (
<ModelInstance key={index} node={node} />
))}
{!isLoading &&
mapNodes.map((node, index) => (
<ModelErrorBoundary key={index}>
<ModelInstance node={node} />
</ModelErrorBoundary>
))}
</group>
);
}
function ModelInstance({ node }: { node: MapNode }): React.JSX.Element {
function ModelInstance({ node }: { node: MapNode }): React.JSX.Element | null {
const modelPath = `/models/${node.name}/model.gltf`;
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
+1 -1
View File
@@ -20,7 +20,7 @@ import {
SUN_Z_MAX,
SUN_Z_MIN,
SUN_Z_STEP,
} from "@/data/lightingConfig";
} from "@/data/world/lightingConfig";
import { useDebugFolder } from "@/hooks/debug/useDebugFolder";
type LightingState = {
-55
View File
@@ -1,55 +0,0 @@
import { useEffect, useRef } from "react";
import { useThree } from "@react-three/fiber";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { MAP_DEBUG_BOX_HELPER_COLOR } from "@/data/debugConfig";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/3d";
import { Debug } from "@/utils/debug/Debug";
const MAP_PATH = "/models/map/model.gltf";
interface MapProps {
onOctreeReady: OctreeReadyHandler;
}
export function Map({ onOctreeReady }: MapProps): React.JSX.Element {
const { scene: gltfScene } = useGLTF(MAP_PATH);
const groupRef = useRef<THREE.Group>(null);
const boxHelpersRef = useRef<THREE.BoxHelper[]>([]);
const { scene } = useThree();
useOctreeGraphNode(groupRef, onOctreeReady);
useEffect(() => {
const debug = Debug.getInstance();
if (!debug.active || !groupRef.current) return;
const helpers: THREE.BoxHelper[] = [];
groupRef.current.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return;
const helper = new THREE.BoxHelper(child, MAP_DEBUG_BOX_HELPER_COLOR);
scene.add(helper);
helpers.push(helper);
});
boxHelpersRef.current = helpers;
return () => {
helpers.forEach((h) => {
scene.remove(h);
h.dispose();
});
boxHelpersRef.current = [];
};
}, [scene]);
return (
<group ref={groupRef}>
<primitive object={gltfScene} />
</group>
);
}
useGLTF.preload(MAP_PATH);
+5 -5
View File
@@ -3,15 +3,15 @@ import type { Octree } from "three/addons/math/Octree.js";
import {
PLAYER_SPAWN_POSITION_GAME,
PLAYER_SPAWN_POSITION_PHYSICS,
} from "@/data/playerConfig";
} from "@/data/player/playerConfig";
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { useSceneMode } from "@/hooks/debug/useSceneMode";
import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
import { DebugCameraControls } from "@/components/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/components/debug/scene/DebugHelpers";
import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting";
import { GameMap } from "@/world/GameMap";
import { PlayerComponent } from "@/world/player/PlayerComponent";
import { Player } from "@/world/player/Player";
import { TestScene } from "@/world/debug/TestScene";
export function World(): React.JSX.Element {
@@ -37,7 +37,7 @@ export function World(): React.JSX.Element {
)}
{cameraMode !== "debug" ? (
<PlayerComponent octree={octree} spawnPosition={playerSpawnPosition} />
<Player octree={octree} spawnPosition={playerSpawnPosition} />
) : null}
</>
);
+13 -5
View File
@@ -1,8 +1,9 @@
import { useRef } from "react";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import * as THREE from "three";
import { GrabbableObject } from "@/components/3d/GrabbableObject";
import { TriggerObject } from "@/components/3d/TriggerObject";
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { GrabbableObject } from "@/components/three/GrabbableObject";
import { TriggerObject } from "@/components/three/TriggerObject";
import { AnimatedModel } from "@/components/three/AnimatedModel";
import {
TEST_SCENE_FLOOR_COLLIDER_HALF_EXTENTS,
TEST_SCENE_FLOOR_POSITION,
@@ -19,9 +20,9 @@ import {
TEST_SCENE_TRIGGER_ROUGHNESS,
TEST_SCENE_TRIGGER_SEGMENTS,
TEST_SCENE_TRIGGER_SOUND_PATH,
} from "@/data/testSceneConfig";
} from "@/data/debug/testSceneConfig";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import type { OctreeReadyHandler } from "@/types/3d";
import type { OctreeReadyHandler } from "@/types/three";
interface TestSceneProps {
onOctreeReady: OctreeReadyHandler;
@@ -85,6 +86,13 @@ export function TestScene({
</mesh>
</TriggerObject>
</Physics>
<AnimatedModel
modelPath="/models/elec/model.gltf"
defaultAnimation="Idle"
position={[0, 0, -5]}
scale={1}
/>
</>
);
}
@@ -1,19 +1,19 @@
import { useEffect } from "react";
import { useThree } from "@react-three/fiber";
import type { Octree } from "three/addons/math/Octree.js";
import type { Vector3Tuple } from "@/types/3d";
import type { Vector3Tuple } from "@/types/three";
import { PlayerCamera } from "@/world/player/PlayerCamera";
import { PlayerController } from "@/world/player/PlayerController";
interface PlayerComponentProps {
interface PlayerProps {
octree: Octree | null;
spawnPosition: Vector3Tuple;
}
export function PlayerComponent({
export function Player({
spawnPosition,
octree,
}: PlayerComponentProps): React.JSX.Element {
}: PlayerProps): React.JSX.Element {
const camera = useThree((state) => state.camera);
useEffect(() => {
+41 -44
View File
@@ -11,7 +11,7 @@ import {
MOVE_LEFT_KEY,
MOVE_RIGHT_KEY,
PRIMARY_INTERACT_MOUSE_BUTTON,
} from "@/data/keybindings";
} from "@/data/input/keybindings";
import {
PLAYER_ACCELERATION_MULTIPLIER,
PLAYER_AIR_CONTROL_FACTOR,
@@ -22,9 +22,9 @@ import {
PLAYER_MAX_DELTA,
PLAYER_WALK_SPEED,
PLAYER_XZ_DAMPING_FACTOR,
} from "@/data/playerConfig";
import { InteractionManager } from "@/stateManager/InteractionManager";
import type { Vector3Tuple } from "@/types/3d";
} from "@/data/player/playerConfig";
import { InteractionManager } from "@/managers/InteractionManager";
import type { Vector3Tuple } from "@/types/three";
type Keys = {
forward: boolean;
@@ -54,6 +54,25 @@ const _up = new THREE.Vector3(0, 1, 0);
const _translateVec = new THREE.Vector3();
const _collisionCorrection = new THREE.Vector3();
function setMovementKey(keys: Keys, key: string, pressed: boolean): boolean {
switch (key.toLowerCase()) {
case MOVE_FORWARD_KEY:
keys.forward = pressed;
return true;
case MOVE_BACKWARD_KEY:
keys.backward = pressed;
return true;
case MOVE_LEFT_KEY:
keys.left = pressed;
return true;
case MOVE_RIGHT_KEY:
keys.right = pressed;
return true;
default:
return false;
}
}
export function PlayerController({
octree,
spawnPosition,
@@ -89,51 +108,29 @@ export function PlayerController({
const interaction = InteractionManager.getInstance();
const handleKeyDown = (event: KeyboardEvent): void => {
switch (event.key.toLowerCase()) {
case MOVE_FORWARD_KEY:
keys.current.forward = true;
break;
case MOVE_BACKWARD_KEY:
keys.current.backward = true;
break;
case MOVE_LEFT_KEY:
keys.current.left = true;
break;
case MOVE_RIGHT_KEY:
keys.current.right = true;
break;
case JUMP_KEY:
wantsJump.current = true;
break;
case INTERACT_KEY:
if (interaction.getState().focused?.kind === "trigger") {
interaction.pressInteract();
}
break;
default:
return;
if (setMovementKey(keys.current, event.key, true)) {
event.preventDefault();
return;
}
if (event.key === JUMP_KEY) {
wantsJump.current = true;
event.preventDefault();
return;
}
if (event.key.toLowerCase() === INTERACT_KEY) {
if (interaction.getState().focused?.kind === "trigger") {
interaction.pressInteract();
}
event.preventDefault();
}
event.preventDefault();
};
const handleKeyUp = (event: KeyboardEvent): void => {
switch (event.key.toLowerCase()) {
case MOVE_FORWARD_KEY:
keys.current.forward = false;
break;
case MOVE_BACKWARD_KEY:
keys.current.backward = false;
break;
case MOVE_LEFT_KEY:
keys.current.left = false;
break;
case MOVE_RIGHT_KEY:
keys.current.right = false;
break;
default:
return;
if (setMovementKey(keys.current, event.key, false)) {
event.preventDefault();
}
event.preventDefault();
};
const handleMouseDown = (event: MouseEvent): void => {
+4 -29
View File
@@ -5,6 +5,7 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url";
import type { ServerResponse } from "node:http";
import type { Plugin } from "vite";
import { parseMapNodes } from "./src/utils/mapNodeValidation";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
@@ -22,34 +23,6 @@ function sendJson(
.end(JSON.stringify(body));
}
function isVector3(value: unknown): value is [number, number, number] {
return (
Array.isArray(value) &&
value.length === 3 &&
value.every((item) => typeof item === "number" && Number.isFinite(item))
);
}
function isMapNode(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) {
return false;
}
const node = value as Record<string, unknown>;
return (
typeof node.name === "string" &&
typeof node.type === "string" &&
isVector3(node.position) &&
isVector3(node.rotation) &&
isVector3(node.scale)
);
}
function isMapPayload(value: unknown): boolean {
return Array.isArray(value) && value.every(isMapNode);
}
const saveMapPlugin = (): Plugin => ({
name: "save-map-api",
configureServer(server) {
@@ -75,7 +48,9 @@ const saveMapPlugin = (): Plugin => ({
try {
const data = JSON.parse(Buffer.concat(chunks).toString());
if (!isMapPayload(data)) {
try {
parseMapNodes(data);
} catch {
sendJson(res, 400, { error: "Invalid map payload" });
return;
}