Compare commits

..

31 Commits

Author SHA1 Message Date
Tom Boullay d4e7edaa89 Merge pull request #4 from La-Fabrik-Durable/feat-editor
Feat/editor
2026-04-28 13:22:44 +02:00
Astro ab21df18cb fix editor map reliability 2026-04-28 11:06:09 +02:00
Astro 7e067ecccd Update Map.tsx 2026-04-28 10:53:57 +02:00
Astro 3fac43d5f1 Create Map.tsx 2026-04-28 10:52:05 +02:00
Astro abfbb284f5 Update debugConfig.ts 2026-04-28 10:48:42 +02:00
math-pixel 7588f7f736 Merge pull request #8 from La-Fabrik-Durable/feat/deploy-test
Feat/deploy test
2026-04-28 10:44:12 +02:00
Astro 5ff5b89302 Merge branch 'feat-editor' of https://github.com/La-Fabrik-Durable/La-Fabrik into feat-editor 2026-04-28 10:43:08 +02:00
Astro af35150452 cleaaning 2026-04-28 10:42:57 +02:00
math-pixel 31a99902dd Delete test-editor.html 2026-04-28 10:39:58 +02:00
Astro a259c3d2e2 fix: style 2026-04-28 10:30:31 +02:00
Astro e19cc72ad5 tyle: refresh editor controls with monochrome UI 2026-04-28 10:08:17 +02:00
Astro e1d2bfdc75 refactor: move game map into world folder 2026-04-28 09:47:09 +02:00
Astro 8f40bb8133 docs: document editor architecture and user features 2026-04-28 09:43:51 +02:00
Astro 7b38f04a0d refactor: move editor page and types to conventional folders 2026-04-28 09:29:18 +02:00
math-pixel eade051241 update: deploy file 2026-04-27 20:46:52 +02:00
math-pixel e868e72402 update: deploy file 2026-04-27 20:40:05 +02:00
math-pixel 4783784fb3 feat: change version 2026-04-27 20:27:21 +02:00
math-pixel bfe8c49323 fix :editor 2026-04-27 17:25:56 +02:00
math-pixel 1a91fcaca0 fix: main model map 2026-04-27 16:38:05 +02:00
math-pixel 21d91f1de1 update models loading in /editor 2026-04-27 16:27:56 +02:00
math-pixel 868f7a1cfd fix: load all models/ 2026-04-27 16:07:57 +02:00
math-pixel 2001955625 fix: address code review comments
- vite.config.ts: fix __dirname for ESM, add 1MB payload limit + JSON validation
- MapViewer.tsx: remove broken window.isTransforming checks, fix callback order
- EditorPage.tsx: derive undoCount from historyManager in handleTransformEnd
- package.json: support Node 20 or 22 in engines
2026-04-27 14:21:50 +02:00
math-pixel 3254291ba7 fix: lint 2026-04-27 14:19:26 +02:00
math-pixel 753a767662 fix: format 2026-04-27 13:57:17 +02:00
math-pixel bcf3a63fc5 Merge branch 'develop' into feat-editor 2026-04-27 13:55:13 +02:00
math-pixel ab8c84e006 Merge remote-tracking branch 'origin/develop' into feat-editor 2026-04-27 13:52:08 +02:00
Astro 8abc69ebc3 Merge branch 'develop' of https://github.com/La-Fabrik-Durable/La-Fabrik into develop 2026-04-27 13:50:50 +02:00
math-pixel b63412de13 update: map & update: package json for CI 2026-04-27 13:44:14 +02:00
math-pixel 6b8ba3d58d feat : save map.json on project 2026-04-23 15:40:10 +02:00
math-pixel d0cf876372 feat editor 2026-04-23 15:24:40 +02:00
Astro fd7571fbe1 Update hero.png 2026-04-16 09:25:23 +02:00
79 changed files with 7210 additions and 55 deletions
+4 -4
View File
@@ -74,12 +74,12 @@ jobs:
- name: 📏 Check bundle size
run: |
# Get bundle size in KB
SIZE=$(du -k dist | cut -f1)
# Check generated app assets only; public/ model files are runtime assets copied to dist.
SIZE=$(du -k dist/assets | cut -f1)
echo "Bundle size: ${SIZE}KB"
# Threshold: 1000KB (configurable)
THRESHOLD=1000
# Threshold: 5000KB (configurable)
THRESHOLD=5000
if [ "$SIZE" -gt "$THRESHOLD" ]; then
echo "❌ Bundle size ${SIZE}KB exceeds threshold ${THRESHOLD}KB"
+5 -1
View File
@@ -37,4 +37,8 @@ Thumbs.db
# 3D Assets Cache (drei, GLTFJSX)
.drei/
.glitchdrei-cache/
.glitchdrei-cache/
# Temporaire
.backend/
backend/
+1
View File
@@ -0,0 +1 @@
22.12.0
+26 -2
View File
@@ -4,13 +4,16 @@ This document describes the code that exists today in the repository.
## Runtime Structure
- `src/App.tsx` mounts the `Canvas`, the 3D `World`, the debug perf overlay, and the HTML overlays.
- `src/main.tsx` mounts React and wraps the app in `BrowserRouter`.
- `src/App.tsx` declares the top-level routes:
- `/` mounts the playable 3D scene, debug perf overlay, and HTML overlays.
- `/editor` mounts the map editor page.
- `src/world/World.tsx` composes the active scene, including:
- environment and lighting
- debug helpers and debug camera mode
- either the map scene or the debug physics test scene
- the player rig when the active camera mode is `player`
- `src/world/Map.tsx` loads the main map model and builds the collision octree.
- `src/world/GameMap.tsx` loads map nodes from `public/map.json`, resolves available models, and builds the collision octree.
- `src/world/debug/TestScene.tsx` provides a debug-oriented interaction and physics scene.
- `src/world/player/PlayerComponent.tsx` mounts the camera and controller.
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
@@ -38,6 +41,26 @@ This document describes the code that exists today in the repository.
- `src/utils/debug/scene/DebugHelpers.tsx` mounts debug helpers.
- `src/utils/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
## 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/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.
## Map Data
- `public/map.json` is expected to be a `MapNode[]`.
- Each map node `name` maps to `public/models/{name}/model.gltf`.
- The editor renders a fallback cube for missing models.
- The game scene filters out nodes whose model cannot be resolved.
## Current Limitations
- The repository is still a prototype, not the full intended game runtime.
@@ -45,3 +68,4 @@ This document describes the code that exists today in the repository.
- There is no central gameplay orchestrator such as `GameManager` yet.
- 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.
+143
View File
@@ -0,0 +1,143 @@
# Editor Technical Notes
This document describes the map editor that exists in the current codebase.
## Purpose
The editor is a React route used to inspect and adjust the `public/map.json` scene data from inside the La-Fabrik app. It shares the same `MapNode` data format as the game scene and uses React Three Fiber for rendering.
## Routing
- `/` 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`.
## File Structure
```txt
src/
├── pages/
│ └── editor/
│ └── EditorPage.tsx
├── features/
│ └── editor/
│ ├── components/
│ │ └── EditorControls.tsx
│ ├── controls/
│ │ └── FlyController.tsx
│ ├── hooks/
│ │ ├── useEditorHistory.ts
│ │ └── useEditorSceneData.ts
│ ├── scene/
│ │ ├── EditorMap.tsx
│ │ └── EditorScene.tsx
├── types/
│ └── editor.ts
└── utils/
├── editor/
│ └── loadEditorScene.ts
└── loadMapSceneData.ts
```
## 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/features/editor/hooks/useEditorSceneData.ts` loads the default map data and handles folder uploads.
`src/features/editor/hooks/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/features/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/features/editor/controls/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.
`src/utils/editor/loadEditorScene.ts` contains editor-only upload handling for user-selected folders.
## Data Format
The shared editor type lives in `src/types/editor.ts`.
```ts
interface MapNode {
name: string;
type: string;
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
}
```
`public/map.json` is expected to be a `MapNode[]`.
```json
[
{
"name": "pylone",
"type": "Mesh",
"position": [0, 5, 0],
"rotation": [0, 1.57, 0],
"scale": [1, 1, 1]
}
]
```
Each node `name` maps to a model folder:
```txt
public/
├── map.json
└── models/
└── pylone/
└── model.gltf
```
If a model is missing, the editor renders a fallback cube so the node can still be selected and transformed.
## Editor Flow
1. `EditorPage` mounts on `/editor`.
2. `useEditorSceneData` calls `loadMapSceneData()`.
3. `loadMapSceneData()` loads `/map.json` and available model URLs.
4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorScene` renders the grid, lights, camera controls, and map nodes.
6. `EditorControls` exposes transform mode, history actions, export, save, and selection info.
## Controls
- Click: select a node.
- `Esc`: clear selection.
- `T`: translate mode.
- `R`: rotate mode.
- `S`: scale mode.
- `Ctrl+Z` or `Cmd+Z`: undo.
- `Ctrl+Y` or `Cmd+Y`: redo.
- `WASD`, `ZQSD`, or arrow keys: move in player-controller mode.
- `Space`: move upward in player-controller mode.
- `Shift`: move downward in player-controller mode.
## Saving And Exporting
The editor supports two output paths:
- Export JSON downloads the current `MapNode[]` as `map.json`.
- Save to Server posts the current `MapNode[]` to `/api/save-map`.
The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size.
## Styling
Editor styles are in `src/index.css` under the `/* Editor page */` section. Classes are prefixed with `editor-` to avoid collisions with the game UI.
## 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.
- Save to Server is a Vite dev-server helper, not a production backend API.
+16 -1
View File
@@ -5,7 +5,7 @@ This document lists features that are implemented in the current codebase.
## Scene
- Fullscreen React Three Fiber scene
- Main map scene loaded from `public/models/map/model.gltf`
- Main map scene loaded from `public/map.json` and matching `public/models/{name}/model.gltf` assets
- Debug physics test scene selectable from the debug panel
- Ambient and directional lighting
- Environment background setup
@@ -38,6 +38,20 @@ This document lists features that are implemented in the current codebase.
- Free debug camera
- `r3f-perf` overlay
## Map Editor
- `/editor` route for inspecting and editing `public/map.json`
- Automatic loading of `public/map.json` when available
- Folder upload fallback when `map.json` is missing
- Rendering of available `public/models/{name}/model.gltf` assets
- Fallback cubes for nodes whose model is missing
- Object selection by click
- Transform modes for translate, rotate, and scale
- Keyboard shortcuts for `T`, `R`, `S`, `Esc`, undo, and redo
- Player-style navigation mode with `WASD`, `ZQSD`, arrow keys, `Space`, and `Shift`
- JSON export for downloading the edited map
- Dev-server save endpoint for writing changes back to `public/map.json`
## Not Implemented Yet
- mission system
@@ -47,3 +61,4 @@ This document lists features that are implemented in the current codebase.
- loading flow
- minimap and mission HUD
- full production separation between gameplay and debug scenes
- production backend persistence for editor saves
+2
View File
@@ -0,0 +1,2 @@
[phases.setup]
nixPkgs = ["nodejs"]
+71 -30
View File
@@ -14,9 +14,11 @@
"@react-three/rapier": "^2.2.0",
"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",
"three": "^0.183.2"
},
"devDependencies": {
@@ -35,6 +37,9 @@
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
},
"engines": {
"node": ">=20.19.0 || >=22.12.0"
}
},
"node_modules/@babel/code-frame": {
@@ -902,9 +907,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -922,9 +924,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -942,9 +941,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -962,9 +958,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -982,9 +975,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1002,9 +992,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1845,6 +1832,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-env": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz",
@@ -2751,9 +2751,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2775,9 +2772,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2799,9 +2793,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2823,9 +2814,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2932,6 +2920,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/lucide-react": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz",
"integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/maath": {
"version": "0.10.8",
"resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz",
@@ -3492,6 +3489,44 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
"license": "MIT",
"dependencies": {
"react-router": "7.14.2"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-use-measure": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz",
@@ -3583,6 +3618,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+5
View File
@@ -20,9 +20,11 @@
"@react-three/rapier": "^2.2.0",
"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",
"three": "^0.183.2"
},
"devDependencies": {
@@ -41,5 +43,8 @@
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
},
"engines": {
"node": ">=20.19.0 || >=22.12.0"
}
}
+4587
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+20 -10
View File
@@ -1,22 +1,32 @@
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";
function App(): React.JSX.Element {
return (
<>
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
<Suspense fallback={null}>
<World />
<DebugPerf />
</Suspense>
</Canvas>
<Crosshair />
<InteractPrompt />
</>
<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>
);
}
@@ -0,0 +1,324 @@
import {
Box,
Braces,
Download,
Expand,
Keyboard,
Lock,
MousePointer2,
Move3D,
Redo2,
RotateCw,
Save,
Undo2,
} from "lucide-react";
import type { MapNode, TransformMode } from "@/types/editor";
interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
undoCount: number;
redoCount: number;
onUndo: () => void;
onRedo: () => void;
onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined;
isPlayerMode?: boolean;
}
export function EditorControls({
transformMode,
onTransformModeChange,
selectedNodeIndex,
mapNodes,
nodesCount,
selectedNodeName,
undoCount,
redoCount,
onUndo,
onRedo,
onExportJson,
onSaveToServer,
onPlayerMode,
isPlayerMode,
}: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
return (
<>
<aside className="editor-controls-panel" aria-label="Editor controls">
<header className="editor-panel-header">
<span className="editor-panel-kicker">Map Editor</span>
<h2>Scene controls</h2>
<p>Select an object, choose a transform mode, then drag the gizmo.</p>
</header>
<section
className="editor-control-section"
aria-labelledby="transform-heading"
>
<div className="editor-section-heading">
<h3 id="transform-heading">Transform</h3>
<span>T / R / S</span>
</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>
</div>
<div className="editor-history-buttons">
<button
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
>
<Undo2 size={15} aria-hidden="true" />
Undo
<span>{undoCount}</span>
</button>
<button
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
>
<Redo2 size={15} aria-hidden="true" />
Redo
<span>{redoCount}</span>
</button>
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="file-heading"
>
<div className="editor-section-heading">
<h3 id="file-heading">File</h3>
</div>
<button
className="editor-action-button editor-action-button-primary"
onClick={onExportJson}
>
<Download size={16} aria-hidden="true" />
Export JSON
</button>
{onSaveToServer && (
<button className="editor-action-button" onClick={onSaveToServer}>
<Save size={16} aria-hidden="true" />
Save to server
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
>
<div className="editor-section-heading">
<h3 id="view-heading">View</h3>
</div>
{onPlayerMode && (
<button
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
aria-pressed={isPlayerMode}
>
<Lock size={16} aria-hidden="true" />
{viewModeLabel}
</button>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="selection-heading"
>
<div className="editor-section-heading">
<h3 id="selection-heading">Selection</h3>
<span>{nodesCount} nodes</span>
</div>
{selectedNodeIndex !== null ? (
<div className="editor-selected-info">
<Box size={17} aria-hidden="true" />
<div>
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
<span>
Index {selectedNodeIndex + 1} of {nodesCount}
</span>
</div>
</div>
) : (
<div className="editor-no-selection">
<MousePointer2 size={17} aria-hidden="true" />
No object selected
</div>
)}
</section>
<section
className="editor-control-section"
aria-labelledby="shortcuts-heading"
>
<div className="editor-section-heading">
<h3 id="shortcuts-heading">Shortcuts</h3>
<Keyboard size={15} aria-hidden="true" />
</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>
</dl>
</section>
<section className="editor-json-section" aria-labelledby="json-heading">
<div className="editor-section-heading">
<h3 id="json-heading">JSON</h3>
<span>{jsonPreview.label}</span>
</div>
<pre className="editor-json-view" aria-label={jsonPreview.label}>
{jsonPreview.lines.map((line) => (
<code
key={line.number}
className={line.isSelected ? "is-selected" : undefined}
>
<span>{line.number}</span>
{line.content || " "}
</code>
))}
</pre>
<div className="editor-json-hint">
<Braces size={14} aria-hidden="true" />
{selectedNodeIndex === null
? "Raw map JSON"
: `Selected node ${selectedNodeIndex + 1} raw lines`}
</div>
</section>
</aside>
</>
);
}
interface JsonPreviewLine {
number: number;
content: string;
isSelected: boolean;
}
interface JsonPreview {
label: string;
lines: JsonPreviewLine[];
}
function getJsonPreview(
mapNodes: MapNode[],
selectedNodeIndex: number | null,
): JsonPreview {
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
if (selectedNodeIndex === null || !ranges[selectedNodeIndex]) {
return {
label: `${lines.length} raw lines`,
lines: lines.map((content, index) => ({
number: index + 1,
content,
isSelected: false,
})),
};
}
const range = ranges[selectedNodeIndex];
const selectedLines = lines.slice(range.start - 1, range.end);
return {
label: `Lines ${range.start}-${range.end}`,
lines: selectedLines.map((content, index) => ({
number: range.start + index,
content,
isSelected: true,
})),
};
}
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
lines: string[];
ranges: Array<{ start: number; end: number }>;
} {
const lines = ["["];
const ranges: Array<{ start: number; end: number }> = [];
mapNodes.forEach((node, index) => {
const objectLines = JSON.stringify(node, null, 2)
.split("\n")
.map((line) => ` ${line}`);
if (index < mapNodes.length - 1) {
objectLines[objectLines.length - 1] += ",";
}
const start = lines.length + 1;
lines.push(...objectLines);
ranges.push({ start, end: lines.length });
});
lines.push("]");
return { lines, ranges };
}
@@ -0,0 +1,126 @@
import {
useRef,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
type ElementRef,
} from "react";
import { useFrame, useThree } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import * as THREE from "three";
type OrbitControlsRef = ElementRef<typeof OrbitControls>;
interface FlyControllerProps {
speed?: number;
verticalSpeed?: number;
onPositionChange?: (position: THREE.Vector3) => void;
disabled?: boolean;
}
interface FlyControllerRef {
controls: OrbitControlsRef | null;
}
export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
(
{ speed = 10, verticalSpeed = 5, onPositionChange, disabled = false },
ref,
) => {
const { camera: rawCamera } = useThree();
const cameraRef = useRef(rawCamera);
const keys = useRef<{ [key: string]: boolean }>({});
const controlsRef = useRef<OrbitControlsRef | null>(null);
const lastPosition = useRef(new THREE.Vector3());
useImperativeHandle(ref, () => ({
controls: controlsRef.current,
}));
const handleKeyDown = useCallback((e: KeyboardEvent) => {
keys.current[e.code] = true;
}, []);
const handleKeyUp = useCallback((e: KeyboardEvent) => {
keys.current[e.code] = false;
}, []);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, [handleKeyDown, handleKeyUp]);
useFrame((_, delta) => {
// Disabled mode keeps OrbitControls active without keyboard movement.
if (disabled) {
return;
}
// Supports AZERTY, QWERTY, and arrow-key movement.
const isForward =
keys.current["KeyW"] || keys.current["KeyZ"] || keys.current["ArrowUp"];
const isBackward = keys.current["KeyS"] || keys.current["ArrowDown"];
const isLeft =
keys.current["KeyQ"] ||
keys.current["KeyA"] ||
keys.current["ArrowLeft"];
const isRight = keys.current["KeyD"] || keys.current["ArrowRight"];
const direction = new THREE.Vector3();
const frontVector = new THREE.Vector3(
0,
0,
Number(isBackward) - Number(isForward),
);
const sideVector = new THREE.Vector3(
Number(isRight) - Number(isLeft),
0,
0,
);
direction.subVectors(frontVector, sideVector);
if (direction.lengthSq() > 0) {
direction.normalize().multiplyScalar(speed * delta);
direction.applyQuaternion(cameraRef.current.quaternion);
cameraRef.current.position.add(direction);
}
// Space moves up; Shift moves down.
if (keys.current["Space"]) {
cameraRef.current.position.y += verticalSpeed * delta;
}
if (keys.current["ShiftLeft"] || keys.current["ShiftRight"]) {
cameraRef.current.position.y -= verticalSpeed * delta;
}
if (
onPositionChange &&
!cameraRef.current.position.equals(lastPosition.current)
) {
lastPosition.current.copy(cameraRef.current.position);
onPositionChange(cameraRef.current.position);
}
});
return (
<OrbitControls
ref={controlsRef}
makeDefault
enableDamping
dampingFactor={0.05}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}}
/>
);
},
);
FlyController.displayName = "FlyController";
@@ -0,0 +1,164 @@
import { useCallback, useRef, useState } from "react";
import type { MapNode, SceneData } from "@/types/editor";
interface ObjectTransform {
uuid: string;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
class HistoryManager {
private history: ObjectTransform[][] = [];
private currentIndex = -1;
private maxSize: number;
constructor(maxSize = 50) {
this.maxSize = maxSize;
}
saveSnapshot(objects: ObjectTransform[]): void {
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
this.history.push(objects.map((object) => ({ ...object })));
this.currentIndex = this.history.length - 1;
if (this.history.length > this.maxSize) {
this.history.shift();
this.currentIndex--;
}
}
undo(): ObjectTransform[] | undefined {
if (this.currentIndex <= 0) return undefined;
this.currentIndex--;
return this.history[this.currentIndex];
}
redo(): ObjectTransform[] | undefined {
if (this.currentIndex >= this.history.length - 1) return undefined;
this.currentIndex++;
return this.history[this.currentIndex];
}
getUndoCount(): number {
return this.currentIndex;
}
getRedoCount(): number {
return this.history.length - 1 - this.currentIndex;
}
}
interface UseEditorHistoryResult {
undoCount: number;
redoCount: number;
handleUndo: () => void;
handleRedo: () => void;
handleTransformStart: () => void;
handleTransformEnd: () => void;
}
export function useEditorHistory(
sceneData: SceneData | null,
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>,
): UseEditorHistoryResult {
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const historyManager = useRef(new HistoryManager(50));
const updateHistoryCounts = useCallback(() => {
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}, []);
const applySnapshot = useCallback(
(snapshot: ObjectTransform[]): void => {
setSceneData((prev) => {
if (!prev) return null;
const mapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find(
(item) => item.uuid === `node-${index}`,
);
if (!transform) return node;
return {
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
} satisfies MapNode;
});
return { ...prev, mapNodes };
});
},
[setSceneData],
);
const handleUndo = useCallback(() => {
const snapshot = historyManager.current.undo();
if (!snapshot) return;
applySnapshot(snapshot);
updateHistoryCounts();
}, [applySnapshot, updateHistoryCounts]);
const handleRedo = useCallback(() => {
const snapshot = historyManager.current.redo();
if (!snapshot) return;
applySnapshot(snapshot);
updateHistoryCounts();
}, [applySnapshot, updateHistoryCounts]);
const handleTransformStart = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot(sceneData));
}, [sceneData]);
const handleTransformEnd = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot(sceneData));
updateHistoryCounts();
}, [sceneData, updateHistoryCounts]);
return {
undoCount,
redoCount,
handleUndo,
handleRedo,
handleTransformStart,
handleTransformEnd,
};
}
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`,
position: {
x: node.position[0],
y: node.position[1],
z: node.position[2],
},
rotation: {
x: node.rotation[0],
y: node.rotation[1],
z: node.rotation[2],
},
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
}));
}
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from "react";
import { createSceneDataFromFiles } from "@/utils/editor/loadEditorScene";
import { loadMapSceneData } from "@/utils/loadMapSceneData";
import type { SceneData } from "@/types/editor";
interface UseEditorSceneDataResult {
hasMapJson: boolean;
isMapLoading: boolean;
sceneData: SceneData | null;
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>;
handleFolderUpload: (
event: React.ChangeEvent<HTMLInputElement>,
) => Promise<void>;
}
export function useEditorSceneData(): UseEditorSceneDataResult {
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null);
useEffect(() => {
const loadScene = async (): Promise<void> => {
setIsMapLoading(true);
try {
const loadedSceneData = await loadMapSceneData();
setSceneData(loadedSceneData);
setHasMapJson(Boolean(loadedSceneData));
} catch (error) {
console.error("Error loading map data:", error);
setHasMapJson(false);
} finally {
setIsMapLoading(false);
}
};
loadScene();
}, []);
const handleFolderUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = event.target.files;
if (!files) return;
try {
const uploadedSceneData = await createSceneDataFromFiles(files);
setSceneData(uploadedSceneData);
setHasMapJson(true);
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur";
console.error("Error processing upload:", error);
alert(message);
}
},
[],
);
return {
hasMapJson,
isMapLoading,
sceneData,
setSceneData,
handleFolderUpload,
};
}
+344
View File
@@ -0,0 +1,344 @@
import { useMemo, useRef, useEffect, useState } from "react";
import { Grid, TransformControls, useGLTF } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import type { SceneData, MapNode, TransformMode } from "@/types/editor";
interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
onTransformStart: () => void;
onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
}
type EditorNodeObjectRef = React.RefObject<Map<number, THREE.Object3D>>;
interface EditorNodeCommonProps {
index: number;
node: MapNode;
isSelected: boolean;
isHovered: boolean;
objectsMapRef: EditorNodeObjectRef;
onSelectNode: (index: number | null) => void;
onHoverNode: (index: number | null) => void;
}
function applyNodeTransform(object: THREE.Object3D, node: MapNode): void {
object.position.set(...node.position);
object.rotation.set(...node.rotation);
object.scale.set(...node.scale);
}
function useRegisteredEditorNode(
objectRef: React.RefObject<THREE.Object3D | null>,
index: number,
node: MapNode,
objectsMapRef: EditorNodeObjectRef,
): void {
useEffect(() => {
const object = objectRef.current;
if (object) {
applyNodeTransform(object, node);
object.userData = { nodeIndex: index, nodeName: node.name };
objectsMapRef.current.set(index, object);
}
const currentMap = objectsMapRef.current;
const currentIndex = index;
return () => {
currentMap.delete(currentIndex);
};
}, [index, node, objectRef, objectsMapRef]);
useEffect(() => {
const object = objectRef.current;
if (object) {
applyNodeTransform(object, node);
}
}, [node, objectRef]);
}
function disposeMaterial(material: THREE.Material | THREE.Material[]): void {
if (Array.isArray(material)) {
material.forEach((item) => item.dispose());
return;
}
material.dispose();
}
function cloneHighlightedMaterial(
material: THREE.Material | THREE.Material[],
color: string,
): THREE.Material | THREE.Material[] {
if (Array.isArray(material)) {
return material.map((item) => cloneHighlightedMaterial(item, color)).flat();
}
const clone = material.clone();
if (clone instanceof THREE.MeshStandardMaterial) {
clone.color.set(color);
}
return clone;
}
export function EditorMap({
sceneData,
selectedNodeIndex,
onSelectNode,
hoveredNodeIndex,
onHoverNode,
transformMode,
onTransformStart,
onTransformEnd,
onNodeTransform,
}: EditorMapProps): React.JSX.Element {
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
const handleTransformMouseDown = () => {
onTransformStart?.();
};
const handleTransformMouseUp = () => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
if (!obj) return;
const node = sceneData.mapNodes[selectedNodeIndex];
if (node) {
const updatedNode: MapNode = {
...node,
position: [obj.position.x, obj.position.y, obj.position.z],
rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z],
scale: [obj.scale.x, obj.scale.y, obj.scale.z],
};
onNodeTransform?.(selectedNodeIndex, updatedNode);
}
}
onTransformEnd?.();
};
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
null,
);
useEffect(() => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
setSelectedObject(obj || null);
} else {
setSelectedObject(null);
}
}, [selectedNodeIndex]);
return (
<>
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#242424"
sectionSize={5}
sectionThickness={1}
sectionColor="#3a3a3a"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid={false}
/>
<axesHelper args={[10]} />
<group
onClick={(e: ThreeEvent<MouseEvent>) => {
e.stopPropagation();
onSelectNode(null);
}}
>
{sceneData.mapNodes.map((node, index) => {
const modelUrl = sceneData.models.get(node.name);
if (modelUrl) {
return (
<EditorModelNode
key={index}
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedNodeIndex === index}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onHoverNode={onHoverNode}
/>
);
} else {
return (
<EditorFallbackNode
key={index}
index={index}
node={node}
isSelected={selectedNodeIndex === index}
isHovered={hoveredNodeIndex === index}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
onHoverNode={onHoverNode}
/>
);
}
})}
</group>
{selectedObject && (
<TransformControls
object={selectedObject}
mode={transformMode}
onMouseDown={handleTransformMouseDown}
onMouseUp={handleTransformMouseUp}
/>
)}
</>
);
}
function EditorModelNode({
index,
node,
modelUrl,
isSelected,
isHovered,
objectsMapRef,
onSelectNode,
onHoverNode,
}: EditorNodeCommonProps & {
modelUrl: string;
}) {
const groupRef = useRef<THREE.Group>(null);
const originalMaterialsRef = useRef(
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
);
const { scene } = useGLTF(modelUrl);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
useEffect(() => {
if (!groupRef.current) return;
const highlightColor = isSelected
? "#ffffff"
: isHovered
? "#b8b8b8"
: null;
groupRef.current.traverse((child) => {
if (!(child instanceof THREE.Mesh)) {
return;
}
const originalMaterial = originalMaterialsRef.current.get(child);
if (!originalMaterial) {
originalMaterialsRef.current.set(child, child.material);
}
if (child.material !== originalMaterial && originalMaterial) {
disposeMaterial(child.material);
}
if (highlightColor) {
child.material = cloneHighlightedMaterial(
originalMaterial ?? child.material,
highlightColor,
);
} else if (originalMaterial) {
child.material = originalMaterial;
}
});
}, [isSelected, isHovered]);
useEffect(() => {
const group = groupRef.current;
const originalMaterials = originalMaterialsRef.current;
return () => {
if (!group) return;
group.traverse((child) => {
if (!(child instanceof THREE.Mesh)) {
return;
}
const originalMaterial = originalMaterials.get(child);
if (originalMaterial && child.material !== originalMaterial) {
disposeMaterial(child.material);
child.material = originalMaterial;
}
});
};
}, []);
return (
<primitive
ref={groupRef}
object={sceneInstance}
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);
}}
/>
);
}
function EditorFallbackNode({
index,
node,
isSelected,
isHovered,
objectsMapRef,
onSelectNode,
onHoverNode,
}: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null);
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
const color = isSelected ? "#ffffff" : isHovered ? "#b8b8b8" : "#6f6f6f";
return (
<mesh
ref={meshRef}
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);
}}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={color} />
</mesh>
);
}
+108
View File
@@ -0,0 +1,108 @@
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 type { MapNode, TransformMode, SceneData } from "@/types/editor";
interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
onTransformStart: () => void;
onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
onUndo: () => void;
onRedo: () => void;
isPlayerMode?: boolean;
}
export function EditorScene({
sceneData,
selectedNodeIndex,
onSelectNode,
hoveredNodeIndex,
onHoverNode,
transformMode,
onTransformModeChange,
onTransformStart,
onTransformEnd,
onNodeTransform,
onUndo,
onRedo,
isPlayerMode = false,
}: EditorSceneProps): React.JSX.Element {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === "z" || e.key === "Z") {
e.preventDefault();
onUndo();
return;
}
if (e.key === "y" || e.key === "Y") {
e.preventDefault();
onRedo();
return;
}
}
if (selectedNodeIndex !== null) {
switch (e.key.toLowerCase()) {
case "escape":
onSelectNode(null);
break;
case "t":
onTransformModeChange("translate");
break;
case "r":
onTransformModeChange("rotate");
break;
case "s":
onTransformModeChange("scale");
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [selectedNodeIndex, onSelectNode, onTransformModeChange, onUndo, onRedo]);
return (
<>
{isPlayerMode ? (
<FlyController disabled={false} />
) : (
<OrbitControls
enableDamping
dampingFactor={0.05}
mouseButtons={{
LEFT: 0,
MIDDLE: 1,
RIGHT: 2,
}}
/>
)}
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
/>
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
</>
);
}
+570
View File
@@ -79,3 +79,573 @@ canvas {
color: rgba(255, 255, 255, 0.85);
letter-spacing: 0.03em;
}
/* Editor page */
.editor-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #050505;
color: #f8f8f8;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
overflow: hidden;
}
.editor-loading,
.editor-error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
color: #f8f8f8;
text-align: center;
padding: 2rem;
}
.editor-loading h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
color: #ffffff;
margin: 0 0 0.75rem;
letter-spacing: -0.05em;
}
.editor-loading p {
font-size: 1rem;
color: #9b9b9b;
}
.editor-error h2 {
font-size: clamp(1.8rem, 4vw, 3rem);
color: #ffffff;
margin: 0 0 0.75rem;
letter-spacing: -0.05em;
}
.editor-error p {
font-size: 1.1rem;
color: #b7b7b7;
margin: 0 0 2rem;
max-width: 600px;
}
.editor-container code {
background: #171717;
padding: 0.2rem 0.4rem;
border-radius: 4px;
color: #ffffff;
font-family: "SFMono-Regular", "Courier New", monospace;
}
.editor-upload-section {
width: min(520px, calc(100vw - 2rem));
background: #0d0d0d;
border-radius: 24px;
padding: 1.25rem;
border: 1px solid #2a2a2a;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
}
.editor-upload-section h3 {
color: #ffffff;
margin: 0 0 1rem;
font-size: 0.9rem;
font-weight: 650;
letter-spacing: -0.02em;
}
.editor-drop-zone {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 116px;
padding: 1.25rem;
border: 1px dashed #5b5b5b;
border-radius: 18px;
background: #111111;
color: #f8f8f8;
font-weight: 650;
text-align: center;
cursor: pointer;
transition:
background 160ms ease,
border-color 160ms ease,
transform 160ms ease;
font-size: 0.95rem;
margin-bottom: 1rem;
}
.editor-drop-zone:hover {
background: #181818;
border-color: #ffffff;
transform: translateY(-1px);
}
.editor-folder-input {
display: none;
}
.editor-folder-structure {
background: #080808;
border: 1px solid #202020;
border-radius: 16px;
padding: 1rem;
}
.editor-folder-structure h4 {
color: #ffffff;
margin: 0 0 0.5rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.editor-folder-structure pre {
margin: 0;
background: transparent;
color: #a7a7a7;
font-family: "SFMono-Regular", "Courier New", monospace;
font-size: 0.78rem;
line-height: 1.55;
overflow-x: auto;
white-space: pre-wrap;
}
.editor-camera-info {
position: absolute;
top: 16px;
left: 16px;
display: flex;
align-items: center;
gap: 10px;
z-index: 2;
background: rgba(5, 5, 5, 0.78);
color: #f8f8f8;
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 16px 50px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(18px);
font-size: 11px;
line-height: 1;
}
.editor-camera-info span {
color: #9b9b9b;
}
.editor-camera-info strong {
color: #ffffff;
font-weight: 600;
}
.editor-controls-panel {
position: absolute;
right: 16px;
top: 16px;
bottom: 16px;
width: min(340px, calc(100vw - 32px));
background: rgba(8, 8, 8, 0.88);
padding: 14px;
color: #f8f8f8;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 28px;
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.45);
overflow-y: auto;
display: flex;
flex-direction: column;
backdrop-filter: blur(22px);
scrollbar-width: thin;
scrollbar-color: #3a3a3a transparent;
}
.editor-controls-panel::-webkit-scrollbar {
width: 6px;
}
.editor-controls-panel::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 999px;
}
.editor-panel-header {
padding: 12px 12px 16px;
}
.editor-panel-kicker {
color: #8f8f8f;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.editor-panel-header h2 {
margin: 0.35rem 0 0.45rem;
color: #ffffff;
font-size: 1.55rem;
font-weight: 720;
letter-spacing: -0.06em;
}
.editor-panel-header p {
margin: 0;
color: #a3a3a3;
font-size: 0.84rem;
line-height: 1.45;
}
.editor-control-section {
padding: 14px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.editor-section-heading h3 {
margin: 0;
color: #ffffff;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.editor-section-heading span,
.editor-section-heading svg {
color: #777777;
font-size: 0.74rem;
}
.editor-transform-buttons {
display: grid;
grid-template-columns: 1fr;
gap: 6px;
}
.editor-transform-button {
display: grid;
grid-template-columns: 18px 1fr auto;
align-items: center;
gap: 10px;
width: 100%;
padding: 10px 11px;
background: #101010;
color: #d9d9d9;
border: 1px solid #242424;
border-radius: 14px;
cursor: pointer;
font-size: 0.88rem;
font-weight: 620;
text-align: left;
transition:
background 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.editor-transform-button.active {
background: #ffffff;
color: #050505;
border-color: #ffffff;
}
.editor-transform-button:hover {
background: #191919;
border-color: #5c5c5c;
color: #ffffff;
transform: translateY(-1px);
}
.editor-transform-button.active:hover {
background: #ffffff;
color: #050505;
}
.editor-transform-button kbd {
min-width: 22px;
padding: 3px 6px;
border-radius: 7px;
background: rgba(0, 0, 0, 0.08);
color: currentColor;
font-family: inherit;
font-size: 0.7rem;
font-weight: 720;
text-align: center;
}
.editor-history-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.editor-history-button {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px;
background: #101010;
color: #f2f2f2;
border: 1px solid #242424;
border-radius: 13px;
cursor: pointer;
font-size: 0.78rem;
font-weight: 650;
}
.editor-history-button span {
color: #8e8e8e;
}
.editor-history-button:disabled {
cursor: not-allowed;
opacity: 0.38;
}
.editor-action-button,
.editor-player-button {
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
width: 100%;
padding: 11px 12px;
background: #101010;
color: #f2f2f2;
border: 1px solid #242424;
border-radius: 14px;
cursor: pointer;
font-size: 0.88rem;
font-weight: 680;
transition:
background 160ms ease,
border-color 160ms ease,
color 160ms ease,
transform 160ms ease;
}
.editor-action-button + .editor-action-button {
margin-top: 8px;
}
.editor-action-button:hover,
.editor-player-button:hover {
background: #191919;
border-color: #5c5c5c;
color: #ffffff;
transform: translateY(-1px);
}
.editor-action-button-primary,
.editor-player-button.active {
background: #ffffff;
color: #050505;
border-color: #ffffff;
}
.editor-action-button-primary:hover,
.editor-player-button.active:hover {
background: #ffffff;
color: #050505;
}
.editor-selected-info {
display: flex;
align-items: center;
gap: 11px;
background: #ffffff;
border: 1px solid #ffffff;
border-radius: 16px;
padding: 12px;
color: #050505;
}
.editor-selected-info strong,
.editor-selected-info span {
display: block;
}
.editor-selected-info strong {
font-size: 0.92rem;
line-height: 1.2;
}
.editor-selected-info span {
color: #555555;
font-size: 0.75rem;
margin-top: 2px;
}
.editor-no-selection {
display: flex;
align-items: center;
gap: 10px;
background: #101010;
border: 1px dashed #363636;
border-radius: 16px;
padding: 12px;
color: #8f8f8f;
font-size: 0.86rem;
}
.editor-shortcuts-list {
display: grid;
gap: 7px;
margin: 0;
}
.editor-shortcuts-list div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.editor-shortcuts-list div:last-child {
border-bottom: 0;
}
.editor-shortcuts-list dt,
.editor-shortcuts-list dd {
margin: 0;
font-size: 0.76rem;
}
.editor-shortcuts-list dt {
color: #ffffff;
font-weight: 700;
}
.editor-shortcuts-list dd {
color: #8d8d8d;
text-align: right;
}
.editor-json-section {
display: flex;
flex-direction: column;
min-height: 240px;
padding: 14px 12px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
}
.editor-json-view {
flex: 1;
max-height: 320px;
margin: 0;
padding: 8px 0;
overflow: auto;
background: #050505;
border: 1px solid #1f1f1f;
border-radius: 16px;
color: #d7d7d7;
font-family: "SFMono-Regular", "Courier New", monospace;
font-size: 0.72rem;
line-height: 1.55;
scrollbar-width: thin;
scrollbar-color: #3a3a3a transparent;
}
.editor-json-view::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.editor-json-view::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 999px;
}
.editor-json-view code {
display: grid;
grid-template-columns: 34px max-content;
gap: 10px;
min-width: 100%;
padding: 0 12px;
background: transparent;
color: inherit;
font-family: inherit;
white-space: pre;
}
.editor-json-view code span {
color: #5f5f5f;
text-align: right;
user-select: none;
}
.editor-json-view code.is-selected {
background: #111111;
color: #f2f2f2;
}
.editor-json-view code.is-selected * {
color: #f2f2f2;
}
.editor-json-view code.is-selected span {
color: #8a8a8a;
}
.editor-json-hint {
display: flex;
align-items: center;
gap: 7px;
margin-top: 8px;
color: #8d8d8d;
font-size: 0.74rem;
}
@media (max-width: 768px) {
.editor-error h2 {
font-size: 1.5rem;
}
.editor-upload-section {
padding: 1.5rem;
}
.editor-drop-zone {
padding: 1.5rem 1rem;
}
.editor-camera-info {
display: none;
}
.editor-controls-panel {
top: auto;
right: 10px;
bottom: 10px;
left: 10px;
width: auto;
max-height: 46vh;
border-radius: 22px;
}
.editor-json-section {
min-height: 180px;
}
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
+195
View File
@@ -0,0 +1,195 @@
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";
export function EditorPage(): React.JSX.Element {
const {
hasMapJson,
isMapLoading,
sceneData,
setSceneData,
handleFolderUpload,
} = useEditorSceneData();
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const {
undoCount,
redoCount,
handleUndo,
handleRedo,
handleTransformStart,
handleTransformEnd,
} = useEditorHistory(sceneData, setSceneData);
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
const handleTransformModeChange = useCallback((mode: TransformMode) => {
setTransformMode(mode);
}, []);
const handleSaveToServer = useCallback(async () => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
try {
const response = await fetch("/api/save-map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (response.ok) {
alert("Map enregistrée avec succès!");
} else {
alert("Erreur lors de l'enregistrement");
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "map.json";
a.click();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}, [sceneData]);
const handlePlayerMode = useCallback(() => {
setIsPlayerMode((prev) => !prev);
}, []);
const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
});
},
[setSceneData],
);
if (isMapLoading) {
return (
<div className="editor-container">
<div className="editor-loading">
<h2>Chargement de l'éditeur...</h2>
<p>Vérification de map.json dans public/</p>
</div>
</div>
);
}
if (!hasMapJson) {
return (
<div className="editor-container">
<div className="editor-error">
<h2>Erreur : map.json introuvable</h2>
<p>
Le fichier map.json est requis dans le dossier <code>public/</code>.
</p>
<div className="editor-upload-section">
<h3>Télécharger un dossier contenant map.json</h3>
<label className="editor-drop-zone">
<input
type="file"
className="editor-folder-input"
onChange={handleFolderUpload}
multiple
{...{ webkitdirectory: "" }}
/>
Choisir un dossier contenant map.json
</label>
<div className="editor-folder-structure">
<h4>Structure requise :</h4>
<pre>
public/ <strong>map.json</strong> (à la racine) models/
arbre/ model.gltf building/ model.gltf
...
</pre>
</div>
</div>
</div>
</div>
);
}
return (
<div className="editor-container">
<Canvas
camera={{ position: [0, 50, 100], fov: 50 }}
style={{ width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor("#050505");
}}
>
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
/>
</Canvas>
{sceneData && (
<EditorControls
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
mapNodes={sceneData.mapNodes}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
onRedo={handleRedo}
onExportJson={handleExportJson}
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
onPlayerMode={handlePlayerMode}
isPlayerMode={isPlayerMode}
/>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Vector3Tuple } from "@/types/3d";
export interface MapNode {
name: string;
type: string;
position: Vector3Tuple;
rotation: Vector3Tuple;
scale: Vector3Tuple;
}
export interface SceneData {
mapNodes: MapNode[];
models: Map<string, string>;
}
export type TransformMode = "translate" | "rotate" | "scale";
+1 -1
View File
@@ -1,6 +1,6 @@
export type InteractableKind = "grab" | "trigger";
export interface TriggerInteractableHandle {
interface TriggerInteractableHandle {
kind: "trigger";
label: string;
onPress: () => void;
+1 -1
View File
@@ -1,6 +1,6 @@
export type LogLevel = "debug" | "info" | "warn" | "error";
export type LogValue =
type LogValue =
| string
| number
| boolean
+41
View File
@@ -0,0 +1,41 @@
import type { MapNode, SceneData } from "@/types/editor";
const MAP_JSON_PATH = "/map.json";
export async function createSceneDataFromFiles(
files: FileList,
): Promise<SceneData> {
const fileMap = new Map<string, File>();
for (const file of Array.from(files)) {
fileMap.set(getProjectRelativePath(file), file);
}
const mapFile = fileMap.get(MAP_JSON_PATH);
if (!mapFile) {
throw new Error("Fichier map.json manquant à la racine du dossier");
}
const mapNodes: MapNode[] = JSON.parse(await mapFile.text());
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
const modelMatch = path.match(/^\/models\/(.+)\/model\.gltf$/);
if (modelMatch?.[1]) {
models.set(modelMatch[1], URL.createObjectURL(file));
}
}
return { mapNodes, models };
}
function getProjectRelativePath(file: File): string {
const relativePath = file.webkitRelativePath || file.name;
if (!relativePath.includes("/")) {
return `/${relativePath}`;
}
const [, ...pathParts] = relativePath.split("/");
return `/${pathParts.join("/")}`;
}
+40
View File
@@ -0,0 +1,40 @@
import type { MapNode, SceneData } from "@/types/editor";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAME = "model.gltf";
export async function loadMapSceneData(): Promise<SceneData | null> {
const response = await fetch(MAP_JSON_PATH);
if (!response.ok) {
return null;
}
const mapNodes: MapNode[] = await response.json();
return createSceneData(mapNodes);
}
async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> {
const models = await loadMapModelUrls(mapNodes);
return { mapNodes, models };
}
async function loadMapModelUrls(
mapNodes: MapNode[],
): Promise<Map<string, string>> {
const uniqueModelNames = [...new Set(mapNodes.map((node) => node.name))];
const modelEntries = await Promise.all(
uniqueModelNames.map(async (modelName) => {
const modelUrl = `/models/${modelName}/${MODEL_FILE_NAME}`;
try {
const response = await fetch(modelUrl, { method: "HEAD" });
return response.ok ? ([modelName, modelUrl] as const) : null;
} catch {
return null;
}
}),
);
return new Map(modelEntries.filter((entry) => entry !== null));
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState, useRef } 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 { MapNode } from "@/types/editor";
interface GameMapProps {
onOctreeReady: OctreeReadyHandler;
}
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const [mapNodes, setMapNodes] = useState<MapNode[]>([]);
const [isLoading, setIsLoading] = useState(true);
const groupRef = useRef<THREE.Group>(null);
useOctreeGraphNode(groupRef, onOctreeReady);
useEffect(() => {
const loadMap = async () => {
try {
const sceneData = await loadMapSceneData();
if (!sceneData) {
console.warn("map.json not found");
setIsLoading(false);
return;
}
setMapNodes(
sceneData.mapNodes.filter((node) => sceneData.models.has(node.name)),
);
} catch (error) {
console.error("Error loading map:", error);
} finally {
setIsLoading(false);
}
};
loadMap();
}, []);
if (isLoading) {
return <></>;
}
return (
<group ref={groupRef}>
{mapNodes.map((node, index) => (
<ModelInstance key={index} node={node} />
))}
</group>
);
}
function ModelInstance({ node }: { node: MapNode }): React.JSX.Element {
const modelPath = `/models/${node.name}/model.gltf`;
const groupRef = useRef<THREE.Group>(null);
const { scene } = useGLTF(modelPath);
const sceneInstance = useMemo(() => scene.clone(true), [scene]);
const { position, rotation, scale } = node;
useEffect(() => {
if (groupRef.current) {
groupRef.current.position.set(...position);
groupRef.current.rotation.set(...rotation);
groupRef.current.scale.set(...scale);
}
}, [position, rotation, scale]);
return (
<primitive
ref={groupRef}
object={sceneInstance}
position={position}
rotation={rotation}
scale={scale}
/>
);
}
+2 -2
View File
@@ -10,7 +10,7 @@ import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
import { Environment } from "@/world/Environment";
import { Lighting } from "@/world/Lighting";
import { Map } from "@/world/Map";
import { GameMap } from "@/world/GameMap";
import { PlayerComponent } from "@/world/player/PlayerComponent";
import { TestScene } from "@/world/debug/TestScene";
@@ -31,7 +31,7 @@ export function World(): React.JSX.Element {
{cameraMode === "debug" ? <DebugCameraControls /> : null}
{sceneMode === "game" ? (
<Map onOctreeReady={setOctree} />
<GameMap onOctreeReady={setOctree} />
) : (
<TestScene onOctreeReady={setOctree} />
)}
+95 -1
View File
@@ -1,9 +1,103 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import type { ServerResponse } from "node:http";
import type { Plugin } from "vite";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const MAX_MAP_PAYLOAD_BYTES = 1024 * 1024;
const JSON_HEADERS = { "Content-Type": "application/json" };
function sendJson(
res: ServerResponse,
status: number,
body: unknown,
headers: Record<string, string> = {},
): void {
res
.writeHead(status, { ...JSON_HEADERS, ...headers })
.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) {
server.middlewares.use("/api/save-map", async (req, res) => {
if (req.method !== "POST") {
sendJson(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
return;
}
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
size += buffer.length;
if (size > MAX_MAP_PAYLOAD_BYTES) {
sendJson(res, 413, { error: "Payload too large" });
req.destroy();
return;
}
chunks.push(buffer);
}
try {
const data = JSON.parse(Buffer.concat(chunks).toString());
if (!isMapPayload(data)) {
sendJson(res, 400, { error: "Invalid map payload" });
return;
}
const mapPath = path.resolve(__dirname, "public/map.json");
await fs.promises.writeFile(
mapPath,
JSON.stringify(data, null, 2),
"utf8",
);
sendJson(res, 200, { success: true });
} catch (err) {
const status = err instanceof SyntaxError ? 400 : 500;
const message = err instanceof Error ? err.message : "Unknown error";
sendJson(res, status, { error: message });
}
});
},
});
export default defineConfig({
plugins: [react()],
plugins: [react(), saveMapPlugin()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),