Merge branch 'develop' into feat/docs-routing
This commit is contained in:
@@ -74,12 +74,12 @@ jobs:
|
|||||||
|
|
||||||
- name: 📏 Check bundle size
|
- name: 📏 Check bundle size
|
||||||
run: |
|
run: |
|
||||||
# Get bundle size in KB
|
# Check generated app assets only; public/ model files are runtime assets copied to dist.
|
||||||
SIZE=$(du -k dist | cut -f1)
|
SIZE=$(du -k dist/assets | cut -f1)
|
||||||
echo "Bundle size: ${SIZE}KB"
|
echo "Bundle size: ${SIZE}KB"
|
||||||
|
|
||||||
# Threshold: 1000KB (configurable)
|
# Threshold: 5000KB (configurable)
|
||||||
THRESHOLD=1000
|
THRESHOLD=5000
|
||||||
|
|
||||||
if [ "$SIZE" -gt "$THRESHOLD" ]; then
|
if [ "$SIZE" -gt "$THRESHOLD" ]; then
|
||||||
echo "❌ Bundle size ${SIZE}KB exceeds threshold ${THRESHOLD}KB"
|
echo "❌ Bundle size ${SIZE}KB exceeds threshold ${THRESHOLD}KB"
|
||||||
|
|||||||
@@ -38,3 +38,7 @@ Thumbs.db
|
|||||||
# 3D Assets Cache (drei, GLTFJSX)
|
# 3D Assets Cache (drei, GLTFJSX)
|
||||||
.drei/
|
.drei/
|
||||||
.glitchdrei-cache/
|
.glitchdrei-cache/
|
||||||
|
|
||||||
|
# Temporaire
|
||||||
|
.backend/
|
||||||
|
backend/
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ This document describes the code that exists today in the repository.
|
|||||||
|
|
||||||
## Runtime Structure
|
## 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:
|
- `src/world/World.tsx` composes the active scene, including:
|
||||||
- environment and lighting
|
- environment and lighting
|
||||||
- debug helpers and debug camera mode
|
- debug helpers and debug camera mode
|
||||||
- either the map scene or the debug physics test scene
|
- either the map scene or the debug physics test scene
|
||||||
- the player rig when the active camera mode is `player`
|
- 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/debug/TestScene.tsx` provides a debug-oriented interaction and physics scene.
|
||||||
- `src/world/player/PlayerComponent.tsx` mounts the camera and controller.
|
- `src/world/player/PlayerComponent.tsx` mounts the camera and controller.
|
||||||
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, and interaction input.
|
- `src/world/player/PlayerController.tsx` owns pointer lock movement, jump handling, 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/DebugHelpers.tsx` mounts debug helpers.
|
||||||
- `src/utils/debug/scene/DebugCameraControls.tsx` mounts the free debug camera.
|
- `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
|
## Current Limitations
|
||||||
|
|
||||||
- The repository is still a prototype, not the full intended game runtime.
|
- 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.
|
- There is no central gameplay orchestrator such as `GameManager` yet.
|
||||||
- Missions, zones, cinematics, and dialogue systems are not implemented.
|
- Missions, zones, cinematics, and dialogue systems are not implemented.
|
||||||
- The player uses octree collision and simple movement rules, not a complete gameplay physics stack.
|
- 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.
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,7 @@ This document lists features that are implemented in the current codebase.
|
|||||||
## Scene
|
## Scene
|
||||||
|
|
||||||
- Fullscreen React Three Fiber 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
|
- Debug physics test scene selectable from the debug panel
|
||||||
- Ambient and directional lighting
|
- Ambient and directional lighting
|
||||||
- Environment background setup
|
- Environment background setup
|
||||||
@@ -38,6 +38,20 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- Free debug camera
|
- Free debug camera
|
||||||
- `r3f-perf` overlay
|
- `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
|
## Not Implemented Yet
|
||||||
|
|
||||||
- mission system
|
- mission system
|
||||||
@@ -47,3 +61,4 @@ This document lists features that are implemented in the current codebase.
|
|||||||
- loading flow
|
- loading flow
|
||||||
- minimap and mission HUD
|
- minimap and mission HUD
|
||||||
- full production separation between gameplay and debug scenes
|
- full production separation between gameplay and debug scenes
|
||||||
|
- production backend persistence for editor saves
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[phases.setup]
|
||||||
|
nixPkgs = ["nodejs"]
|
||||||
Generated
+62
-62
@@ -41,7 +41,7 @@
|
|||||||
"vite": "^8.0.4"
|
"vite": "^8.0.4"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19"
|
"node": ">=20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -1354,17 +1354,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||||
"integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==",
|
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.59.0",
|
"@typescript-eslint/scope-manager": "8.59.1",
|
||||||
"@typescript-eslint/type-utils": "8.59.0",
|
"@typescript-eslint/type-utils": "8.59.1",
|
||||||
"@typescript-eslint/utils": "8.59.0",
|
"@typescript-eslint/utils": "8.59.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.0",
|
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@@ -1377,7 +1377,7 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.59.0",
|
"@typescript-eslint/parser": "^8.59.1",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
@@ -1393,16 +1393,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
|
||||||
"integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==",
|
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.0",
|
"@typescript-eslint/scope-manager": "8.59.1",
|
||||||
"@typescript-eslint/types": "8.59.0",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.0",
|
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.0",
|
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1418,14 +1418,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.1.tgz",
|
||||||
"integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==",
|
"integrity": "sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.59.0",
|
"@typescript-eslint/tsconfig-utils": "^8.59.1",
|
||||||
"@typescript-eslint/types": "^8.59.0",
|
"@typescript-eslint/types": "^8.59.1",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1440,14 +1440,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||||
"integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==",
|
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.0",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.0"
|
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1458,9 +1458,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz",
|
||||||
"integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==",
|
"integrity": "sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1475,15 +1475,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
|
||||||
"integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==",
|
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.0",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.0",
|
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||||
"@typescript-eslint/utils": "8.59.0",
|
"@typescript-eslint/utils": "8.59.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@@ -1500,9 +1500,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||||
"integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==",
|
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1514,16 +1514,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||||
"integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==",
|
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.59.0",
|
"@typescript-eslint/project-service": "8.59.1",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.59.0",
|
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||||
"@typescript-eslint/types": "8.59.0",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
"@typescript-eslint/visitor-keys": "8.59.0",
|
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@@ -1594,16 +1594,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
||||||
"integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==",
|
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.59.0",
|
"@typescript-eslint/scope-manager": "8.59.1",
|
||||||
"@typescript-eslint/types": "8.59.0",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.0"
|
"@typescript-eslint/typescript-estree": "8.59.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@@ -1618,13 +1618,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||||
"integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==",
|
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.59.0",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5339,16 +5339,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.59.0",
|
"version": "8.59.1",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.1.tgz",
|
||||||
"integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==",
|
"integrity": "sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.59.0",
|
"@typescript-eslint/eslint-plugin": "8.59.1",
|
||||||
"@typescript-eslint/parser": "8.59.0",
|
"@typescript-eslint/parser": "8.59.1",
|
||||||
"@typescript-eslint/typescript-estree": "8.59.0",
|
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||||
"@typescript-eslint/utils": "8.59.0"
|
"@typescript-eslint/utils": "8.59.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
|
|||||||
@@ -48,5 +48,8 @@
|
|||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.0",
|
"typescript-eslint": "^8.58.0",
|
||||||
"vite": "^8.0.4"
|
"vite": "^8.0.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0 || >=22.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4587
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.
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -375,3 +375,573 @@ canvas {
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
letter-spacing: 0.03em;
|
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
@@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
export type InteractableKind = "grab" | "trigger";
|
export type InteractableKind = "grab" | "trigger";
|
||||||
|
|
||||||
export interface TriggerInteractableHandle {
|
interface TriggerInteractableHandle {
|
||||||
kind: "trigger";
|
kind: "trigger";
|
||||||
label: string;
|
label: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
export type LogLevel = "debug" | "info" | "warn" | "error";
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
||||||
|
|
||||||
export type LogValue =
|
type LogValue =
|
||||||
| string
|
| string
|
||||||
| number
|
| number
|
||||||
| boolean
|
| boolean
|
||||||
|
|||||||
@@ -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("/")}`;
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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
@@ -10,7 +10,7 @@ import { DebugCameraControls } from "@/utils/debug/scene/DebugCameraControls";
|
|||||||
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
|
import { DebugHelpers } from "@/utils/debug/scene/DebugHelpers";
|
||||||
import { Environment } from "@/world/Environment";
|
import { Environment } from "@/world/Environment";
|
||||||
import { Lighting } from "@/world/Lighting";
|
import { Lighting } from "@/world/Lighting";
|
||||||
import { Map } from "@/world/Map";
|
import { GameMap } from "@/world/GameMap";
|
||||||
import { PlayerComponent } from "@/world/player/PlayerComponent";
|
import { PlayerComponent } from "@/world/player/PlayerComponent";
|
||||||
import { TestScene } from "@/world/debug/TestScene";
|
import { TestScene } from "@/world/debug/TestScene";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export function World(): React.JSX.Element {
|
|||||||
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
{cameraMode === "debug" ? <DebugCameraControls /> : null}
|
||||||
|
|
||||||
{sceneMode === "game" ? (
|
{sceneMode === "game" ? (
|
||||||
<Map onOctreeReady={setOctree} />
|
<GameMap onOctreeReady={setOctree} />
|
||||||
) : (
|
) : (
|
||||||
<TestScene onOctreeReady={setOctree} />
|
<TestScene onOctreeReady={setOctree} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+95
-1
@@ -1,9 +1,103 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "node:path";
|
||||||
|
import fs from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
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({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), saveMapPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
|
|||||||
Reference in New Issue
Block a user