docs: document editor architecture and user features
This commit is contained in:
@@ -39,5 +39,6 @@ Thumbs.db
|
|||||||
.drei/
|
.drei/
|
||||||
.glitchdrei-cache/
|
.glitchdrei-cache/
|
||||||
|
|
||||||
|
# Temporaire
|
||||||
.backend/
|
.backend/
|
||||||
backend/
|
backend/
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
# Editor Integration - La-Fabrik
|
|
||||||
|
|
||||||
## ✅ Intégration terminée
|
|
||||||
|
|
||||||
L'éditeur du POC-Editor a été entièrement intégré dans La-Fabrik sous la route `/editor`.
|
|
||||||
|
|
||||||
## 🎯 Fonctionnalités implémentées
|
|
||||||
|
|
||||||
### 1. **Routing React** (React Router DOM)
|
|
||||||
|
|
||||||
- Route `/` → Jeu original La-Fabrik
|
|
||||||
- Route `/editor` → Éditeur de map 3D
|
|
||||||
- Navigation fluide entre les deux
|
|
||||||
|
|
||||||
### 2. **Chargement automatique de map.json**
|
|
||||||
|
|
||||||
- Cherche `/map.json` dans `public/` au démarrage
|
|
||||||
- Scan automatique des modèles dans `public/models/`
|
|
||||||
- Fallback: interface d'upload de dossier
|
|
||||||
|
|
||||||
### 3. **Système de caméra hybride**
|
|
||||||
|
|
||||||
- Réutilise `useCameraMode()` existant de La-Fabrik
|
|
||||||
- **Mode debug** : OrbitControls (édition libre)
|
|
||||||
- **Mode player** : FPSController custom (WASD/ZQSD + souris + jump)
|
|
||||||
- Pas de dépendance à `octree` dans l'éditeur
|
|
||||||
|
|
||||||
### 4. **Visualisation 3D avancée**
|
|
||||||
|
|
||||||
- Support complet des **MapNode** (position, rotation, scale)
|
|
||||||
- Chargement de modèles GLB/GLTF depuis `models/{nom}/model.glb`
|
|
||||||
- Fallback: cubes colorés pour modèles manquants
|
|
||||||
- Highlight orange pour sélection
|
|
||||||
|
|
||||||
### 5. **Panneau de contrôle latéral**
|
|
||||||
|
|
||||||
- Transformations: Translate (T), Rotate (R), Scale (S)
|
|
||||||
- Undo/Redo avec compteurs (Ctrl+Z / Ctrl+Y)
|
|
||||||
- Export JSON des modifications
|
|
||||||
- Info sur la sélection et contrôles
|
|
||||||
|
|
||||||
### 6. **Interaction et sélection**
|
|
||||||
|
|
||||||
- Click pour sélectionner objets
|
|
||||||
- ESC pour désélectionner
|
|
||||||
- Intégration avec `InteractionManager` (système existant)
|
|
||||||
- Visual feedback avec highlighting
|
|
||||||
|
|
||||||
## 📁 Structure des fichiers
|
|
||||||
|
|
||||||
```
|
|
||||||
src/components/editor/
|
|
||||||
├── EditorPage.tsx # Composant route /editor + CSS + upload
|
|
||||||
├── EditorViewer.tsx # Composant principal (logique état + effets)
|
|
||||||
├── EditorCamera.tsx # Caméra (switch OrbitControls/FPS)
|
|
||||||
├── EditorFPSController.tsx # Controller FPS sans collisions
|
|
||||||
├── MapViewer.tsx # Visualisation map.json + modèles
|
|
||||||
├── EditorControls.tsx # Panneau latéral UI
|
|
||||||
├── types.ts # Types: MapNode, SceneData, TransformMode
|
|
||||||
└── EditorPage.css # Styles scoped (~150 lignes)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Setup initial
|
|
||||||
|
|
||||||
1. **Dépendances installées** :
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install react-router-dom --legacy-peer-deps
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Modifications apportées** :
|
|
||||||
|
|
||||||
- `main.tsx` → Enveloppé par `<BrowserRouter>`
|
|
||||||
- `App.tsx` → Routes avec `<Route path="/editor">`
|
|
||||||
- `vite.config.ts` → Pas de modif nécessaire
|
|
||||||
|
|
||||||
## 🚀 Utilisation
|
|
||||||
|
|
||||||
### Accès à l'éditeur
|
|
||||||
|
|
||||||
1. Lancer le serveur : `npm run dev`
|
|
||||||
2. Ouvrir `http://localhost:5176/editor`
|
|
||||||
3. Si `map.json` manque → uploader un dossier
|
|
||||||
|
|
||||||
### Structure du dossier
|
|
||||||
|
|
||||||
```
|
|
||||||
public/
|
|
||||||
├── map.json # Fichier JSON avec MapNode[]
|
|
||||||
└── models/
|
|
||||||
├── arbre/
|
|
||||||
│ └── model.glb # Modèle 3D
|
|
||||||
├── building/
|
|
||||||
│ └── model.glb
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Format map.json
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"name": "arbre",
|
|
||||||
"type": "Mesh",
|
|
||||||
"position": [0, 5, 0],
|
|
||||||
"rotation": [0, 1.57, 0],
|
|
||||||
"scale": [1, 1, 1]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
### Contrôles claviers
|
|
||||||
|
|
||||||
- **T** : Translate (déplacement)
|
|
||||||
- **R** : Rotate (rotation)
|
|
||||||
- **S** : Scale (échelle)
|
|
||||||
- **Ctrl+Z** : Undo
|
|
||||||
- **Ctrl+Y** : Redo
|
|
||||||
- **ESC** : Désélection
|
|
||||||
- **WASD/ZQSD** : Déplacement (mode player)
|
|
||||||
- **Espace** : Saut (mode player)
|
|
||||||
- **Clic souris** : Sélection objet
|
|
||||||
|
|
||||||
## 🧪 Tests recommandés
|
|
||||||
|
|
||||||
1. **Navigation** : `/` ↔ `/editor`
|
|
||||||
2. **Upload dossier** : Télécharger un dossier test
|
|
||||||
3. **Caméra** : Tester debug vs player mode
|
|
||||||
4. **Transformations** : T/R/S sur objets sélectionnés
|
|
||||||
5. **Undo/Redo** : Modifier puis annuler/rétablir
|
|
||||||
6. **Export JSON** : Exporter map.json modifié
|
|
||||||
7. **Performances** : Avec maps complexes (test map.json 12K lignes)
|
|
||||||
|
|
||||||
## 📝 Points d'attention
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- Map.json de 12K+ lignes peut impacter perfs
|
|
||||||
- Optimization: implémenter LOD si nécessaire
|
|
||||||
- Cleanup des blob URLs après upload
|
|
||||||
|
|
||||||
### Intégration systèmes existants
|
|
||||||
|
|
||||||
- Réutilise `useCameraMode()` de La-Fabrik
|
|
||||||
- Compatible avec `InteractionManager`
|
|
||||||
- Styles scoped (`editor-` prefix) pour éviter conflits
|
|
||||||
|
|
||||||
### Évolution future
|
|
||||||
|
|
||||||
- Ajouter snap/toggle grid
|
|
||||||
- Implémenter duplication objets
|
|
||||||
- Ajouter outils texturing/matériaux
|
|
||||||
- Synchronisation avec backend
|
|
||||||
|
|
||||||
## 🔗 URLs de test
|
|
||||||
|
|
||||||
- Jeu : `http://localhost:5176/`
|
|
||||||
- Éditeur : `http://localhost:5176/editor`
|
|
||||||
- Page de test : `file:///C:/Users/mathc/Documents/Programation/LA-FABRIK/La-Fabrik/test-editor.html`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Statut** : ✅ Intégration complète avec toutes les fonctionnalités demandées.
|
|
||||||
**Prochaines étapes** : Tests utilisateur + optimisation performance.
|
|
||||||
@@ -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/components/game/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/features/editor/utils/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
|
||||||
|
│ └── utils/
|
||||||
|
│ └── loadEditorScene.ts
|
||||||
|
├── types/
|
||||||
|
│ └── editor.ts
|
||||||
|
└── utils/
|
||||||
|
└── 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/features/editor/utils/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
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import { Crosshair } from "@/components/ui/Crosshair";
|
|||||||
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
import { InteractPrompt } from "@/components/ui/InteractPrompt";
|
||||||
import { DebugPerf } from "@/utils/debug/DebugPerf";
|
import { DebugPerf } from "@/utils/debug/DebugPerf";
|
||||||
import { World } from "@/world/World";
|
import { World } from "@/world/World";
|
||||||
import { EditorPage } from "@/pages/EditorPage";
|
import { EditorPage } from "@/pages/editor/EditorPage";
|
||||||
|
|
||||||
function App(): React.JSX.Element {
|
function App(): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useCameraMode } from "@/hooks/debug/useCameraMode";
|
|
||||||
import { OrbitControls } from "@react-three/drei";
|
|
||||||
|
|
||||||
export default function EditorCamera() {
|
|
||||||
const cameraMode = useCameraMode();
|
|
||||||
|
|
||||||
if (cameraMode === "debug") {
|
|
||||||
return (
|
|
||||||
<OrbitControls
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.05}
|
|
||||||
minDistance={10}
|
|
||||||
maxDistance={500}
|
|
||||||
target={[0, 0, 0]}
|
|
||||||
makeDefault
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from "react";
|
|
||||||
import { useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import * as THREE from "three";
|
|
||||||
|
|
||||||
const WALK_SPEED = 8;
|
|
||||||
const JUMP_SPEED = 7;
|
|
||||||
const MOUSE_SENSITIVITY = 0.002;
|
|
||||||
|
|
||||||
export default function EditorFPSController() {
|
|
||||||
const { camera: rawCamera } = useThree();
|
|
||||||
const cameraRef = useRef(rawCamera);
|
|
||||||
const keys = useRef<Set<string>>(new Set());
|
|
||||||
const velocity = useRef(new THREE.Vector3());
|
|
||||||
const wantsJump = useRef(false);
|
|
||||||
const mouseLocked = useRef(false);
|
|
||||||
|
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
||||||
keys.current.add(e.code);
|
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
|
||||||
case " ":
|
|
||||||
wantsJump.current = true;
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
case "e":
|
|
||||||
e.preventDefault();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleKeyUp = useCallback((e: KeyboardEvent) => {
|
|
||||||
keys.current.delete(e.code);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback(
|
|
||||||
(e: MouseEvent) => {
|
|
||||||
if (!mouseLocked.current) return;
|
|
||||||
|
|
||||||
const movementX = e.movementX || 0;
|
|
||||||
const movementY = e.movementY || 0;
|
|
||||||
|
|
||||||
cameraRef.current.rotation.y -= movementX * MOUSE_SENSITIVITY;
|
|
||||||
cameraRef.current.rotation.x -= movementY * MOUSE_SENSITIVITY;
|
|
||||||
cameraRef.current.rotation.x = Math.max(
|
|
||||||
-Math.PI / 2,
|
|
||||||
Math.min(Math.PI / 2, cameraRef.current.rotation.x),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[cameraRef],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseDown = useCallback((e: MouseEvent) => {
|
|
||||||
if (e.button === 0) {
|
|
||||||
if (!mouseLocked.current) {
|
|
||||||
mouseLocked.current = true;
|
|
||||||
document.body.requestPointerLock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
window.addEventListener("keyup", handleKeyUp);
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
|
||||||
window.addEventListener("mousedown", handleMouseDown);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
window.removeEventListener("keyup", handleKeyUp);
|
|
||||||
window.removeEventListener("mousemove", handleMouseMove);
|
|
||||||
window.removeEventListener("mousedown", handleMouseDown);
|
|
||||||
};
|
|
||||||
}, [handleKeyDown, handleKeyUp, handleMouseMove, handleMouseDown]);
|
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
|
||||||
const dt = Math.min(delta, 0.05);
|
|
||||||
|
|
||||||
if (!mouseLocked.current) return;
|
|
||||||
|
|
||||||
const forward = new THREE.Vector3(0, 0, -1);
|
|
||||||
const right = new THREE.Vector3(1, 0, 0);
|
|
||||||
const up = new THREE.Vector3(0, 1, 0);
|
|
||||||
|
|
||||||
forward.applyQuaternion(cameraRef.current.quaternion);
|
|
||||||
right.applyQuaternion(cameraRef.current.quaternion);
|
|
||||||
|
|
||||||
forward.setY(0);
|
|
||||||
right.setY(0);
|
|
||||||
|
|
||||||
if (forward.lengthSq() > 0) forward.normalize();
|
|
||||||
if (right.lengthSq() > 0) right.normalize();
|
|
||||||
if (up.lengthSq() > 0) up.normalize();
|
|
||||||
|
|
||||||
const isForward =
|
|
||||||
keys.current.has("KeyW") ||
|
|
||||||
keys.current.has("ArrowUp") ||
|
|
||||||
keys.current.has("KeyZ");
|
|
||||||
const isBackward =
|
|
||||||
keys.current.has("KeyS") || keys.current.has("ArrowDown");
|
|
||||||
const isLeft =
|
|
||||||
keys.current.has("KeyA") ||
|
|
||||||
keys.current.has("ArrowLeft") ||
|
|
||||||
keys.current.has("KeyQ");
|
|
||||||
const isRight = keys.current.has("KeyD") || keys.current.has("ArrowRight");
|
|
||||||
|
|
||||||
const wishDir = new THREE.Vector3();
|
|
||||||
if (isForward) wishDir.add(forward);
|
|
||||||
if (isBackward) wishDir.sub(forward);
|
|
||||||
if (isLeft) wishDir.sub(right);
|
|
||||||
if (isRight) wishDir.add(right);
|
|
||||||
|
|
||||||
if (wishDir.lengthSq() > 0) {
|
|
||||||
wishDir.normalize().multiplyScalar(WALK_SPEED * dt * 10);
|
|
||||||
velocity.current.x += wishDir.x;
|
|
||||||
velocity.current.z += wishDir.z;
|
|
||||||
}
|
|
||||||
|
|
||||||
const damping = Math.exp(-8 * dt);
|
|
||||||
velocity.current.x *= damping;
|
|
||||||
velocity.current.z *= damping;
|
|
||||||
|
|
||||||
if (wantsJump.current) {
|
|
||||||
velocity.current.y = JUMP_SPEED;
|
|
||||||
wantsJump.current = false;
|
|
||||||
} else {
|
|
||||||
velocity.current.y -= 20 * dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
cameraRef.current.position.copy(
|
|
||||||
cameraRef.current.position
|
|
||||||
.clone()
|
|
||||||
.add(velocity.current.clone().multiplyScalar(dt)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (cameraRef.current.position.y < 2) {
|
|
||||||
cameraRef.current.position.y = 2;
|
|
||||||
velocity.current.y = 0;
|
|
||||||
velocity.current.x *= 0.9;
|
|
||||||
velocity.current.z *= 0.9;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import { useEffect, useState, useMemo, useRef } from "react";
|
import { useEffect, useState, useRef } from "react";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
|
||||||
|
import { loadMapSceneData } from "@/utils/loadMapSceneData";
|
||||||
import type { OctreeReadyHandler } from "@/types/3d";
|
import type { OctreeReadyHandler } from "@/types/3d";
|
||||||
import type { MapNode } from "@/types/editor";
|
import type { MapNode } from "@/types/editor";
|
||||||
|
|
||||||
const MAP_JSON_PATH = "/map.json";
|
|
||||||
|
|
||||||
interface GameMapProps {
|
interface GameMapProps {
|
||||||
onOctreeReady: OctreeReadyHandler;
|
onOctreeReady: OctreeReadyHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
||||||
const [mapNodes, setMapNodes] = useState<MapNode[]>([]);
|
const [mapNodes, setMapNodes] = useState<MapNode[]>([]);
|
||||||
const [availableModels, setAvailableModels] = useState<Set<string>>(
|
|
||||||
new Set(),
|
|
||||||
);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
@@ -24,32 +20,16 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMap = async () => {
|
const loadMap = async () => {
|
||||||
try {
|
try {
|
||||||
const nodesResponse = await fetch(MAP_JSON_PATH);
|
const sceneData = await loadMapSceneData();
|
||||||
if (!nodesResponse.ok) {
|
if (!sceneData) {
|
||||||
console.warn("map.json not found");
|
console.warn("map.json not found");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodes: MapNode[] = await nodesResponse.json();
|
setMapNodes(
|
||||||
setMapNodes(nodes);
|
sceneData.mapNodes.filter((node) => sceneData.models.has(node.name)),
|
||||||
|
);
|
||||||
const uniqueModelNames = [...new Set(nodes.map((n) => n.name))];
|
|
||||||
const available = new Set<string>();
|
|
||||||
|
|
||||||
for (const modelName of uniqueModelNames) {
|
|
||||||
try {
|
|
||||||
const modelUrl = `/models/${modelName}/model.gltf`;
|
|
||||||
const modelResponse = await fetch(modelUrl);
|
|
||||||
const contentType = modelResponse.headers.get("content-type") || "";
|
|
||||||
if (contentType.includes("gltf") || contentType.includes("model")) {
|
|
||||||
available.add(modelName);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setAvailableModels(available);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading map:", error);
|
console.error("Error loading map:", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -60,17 +40,13 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
|
|||||||
loadMap();
|
loadMap();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const nodesToRender = useMemo(() => {
|
|
||||||
return mapNodes.filter((node) => availableModels.has(node.name));
|
|
||||||
}, [mapNodes, availableModels]);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={groupRef}>
|
<group ref={groupRef}>
|
||||||
{nodesToRender.map((node, index) => (
|
{mapNodes.map((node, index) => (
|
||||||
<ModelInstance key={index} node={node} />
|
<ModelInstance key={index} node={node} />
|
||||||
))}
|
))}
|
||||||
</group>
|
</group>
|
||||||
|
|||||||
+17
-25
@@ -12,12 +12,11 @@ interface EditorControlsProps {
|
|||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
onExportJson: () => void;
|
onExportJson: () => void;
|
||||||
onSaveToServer?: () => void;
|
onSaveToServer?: () => void;
|
||||||
onResetCamera?: () => void;
|
|
||||||
onPlayerMode?: () => void;
|
onPlayerMode?: () => void;
|
||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditorControls({
|
export function EditorControls({
|
||||||
transformMode,
|
transformMode,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
selectedNodeIndex,
|
selectedNodeIndex,
|
||||||
@@ -29,10 +28,9 @@ export default function EditorControls({
|
|||||||
onRedo,
|
onRedo,
|
||||||
onExportJson,
|
onExportJson,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
onResetCamera,
|
|
||||||
onPlayerMode,
|
onPlayerMode,
|
||||||
isPlayerMode,
|
isPlayerMode,
|
||||||
}: EditorControlsProps) {
|
}: EditorControlsProps): React.JSX.Element {
|
||||||
const cameraPosition = [0, 50, 100];
|
const cameraPosition = [0, 50, 100];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -47,30 +45,30 @@ export default function EditorControls({
|
|||||||
<div className="editor-controls-panel">
|
<div className="editor-controls-panel">
|
||||||
<h3>Transform</h3>
|
<h3>Transform</h3>
|
||||||
|
|
||||||
<div className="transform-buttons">
|
<div className="editor-transform-buttons">
|
||||||
<button
|
<button
|
||||||
className={`transform-button ${transformMode === "translate" ? "active" : ""}`}
|
className={`editor-transform-button ${transformMode === "translate" ? "active" : ""}`}
|
||||||
onClick={() => onTransformModeChange("translate")}
|
onClick={() => onTransformModeChange("translate")}
|
||||||
>
|
>
|
||||||
✋ Translate (T)
|
✋ Translate (T)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`transform-button ${transformMode === "rotate" ? "active" : ""}`}
|
className={`editor-transform-button ${transformMode === "rotate" ? "active" : ""}`}
|
||||||
onClick={() => onTransformModeChange("rotate")}
|
onClick={() => onTransformModeChange("rotate")}
|
||||||
>
|
>
|
||||||
🔄 Rotate (R)
|
🔄 Rotate (R)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`transform-button ${transformMode === "scale" ? "active" : ""}`}
|
className={`editor-transform-button ${transformMode === "scale" ? "active" : ""}`}
|
||||||
onClick={() => onTransformModeChange("scale")}
|
onClick={() => onTransformModeChange("scale")}
|
||||||
>
|
>
|
||||||
📐 Scale (S)
|
📐 Scale (S)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="history-buttons">
|
<div className="editor-history-buttons">
|
||||||
<button
|
<button
|
||||||
className="history-button"
|
className="editor-history-button"
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={undoCount === 0}
|
disabled={undoCount === 0}
|
||||||
style={{ color: undoCount > 0 ? "#00ff00" : "#555" }}
|
style={{ color: undoCount > 0 ? "#00ff00" : "#555" }}
|
||||||
@@ -78,7 +76,7 @@ export default function EditorControls({
|
|||||||
↩ Undo ({undoCount})
|
↩ Undo ({undoCount})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="history-button"
|
className="editor-history-button"
|
||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={redoCount === 0}
|
disabled={redoCount === 0}
|
||||||
style={{ color: redoCount > 0 ? "#00ff00" : "#555" }}
|
style={{ color: redoCount > 0 ? "#00ff00" : "#555" }}
|
||||||
@@ -87,27 +85,21 @@ export default function EditorControls({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="export-button" onClick={onExportJson}>
|
<button className="editor-export-button" onClick={onExportJson}>
|
||||||
💾 Export JSON
|
💾 Export JSON
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{onSaveToServer && (
|
{onSaveToServer && (
|
||||||
<button className="save-button" onClick={onSaveToServer}>
|
<button className="editor-save-button" onClick={onSaveToServer}>
|
||||||
💾 Save to Server
|
💾 Save to Server
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3>View</h3>
|
<h3>View</h3>
|
||||||
|
|
||||||
{onResetCamera && (
|
|
||||||
<button className="reset-button" onClick={onResetCamera}>
|
|
||||||
🔄 Reset Camera
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onPlayerMode && (
|
{onPlayerMode && (
|
||||||
<button
|
<button
|
||||||
className={`player-button ${isPlayerMode ? "active" : ""}`}
|
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
|
||||||
onClick={onPlayerMode}
|
onClick={onPlayerMode}
|
||||||
>
|
>
|
||||||
🎮 Player Controller
|
🎮 Player Controller
|
||||||
@@ -116,23 +108,23 @@ export default function EditorControls({
|
|||||||
|
|
||||||
<h3>Selection</h3>
|
<h3>Selection</h3>
|
||||||
{selectedNodeIndex !== null ? (
|
{selectedNodeIndex !== null ? (
|
||||||
<div className="selected-info">
|
<div className="editor-selected-info">
|
||||||
<div className="selected-name">
|
<div className="editor-selected-name">
|
||||||
Selected:{" "}
|
Selected:{" "}
|
||||||
<strong>
|
<strong>
|
||||||
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="selected-index">
|
<div className="editor-selected-index">
|
||||||
Index: {selectedNodeIndex + 1} / {nodesCount}
|
Index: {selectedNodeIndex + 1} / {nodesCount}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="no-selection">No object selected</div>
|
<div className="editor-no-selection">No object selected</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3>Controls</h3>
|
<h3>Controls</h3>
|
||||||
<div className="controls-help">
|
<div className="editor-controls-help">
|
||||||
<p>Click - Select object</p>
|
<p>Click - Select object</p>
|
||||||
<p>T/R/S - Transform mode</p>
|
<p>T/R/S - Transform mode</p>
|
||||||
<p>Ctrl+Z - Undo</p>
|
<p>Ctrl+Z - Undo</p>
|
||||||
+2
-2
@@ -21,7 +21,7 @@ export interface FlyControllerRef {
|
|||||||
controls: OrbitControlsType | null;
|
controls: OrbitControlsType | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FlyControllerInner = forwardRef<FlyControllerRef, FlyControllerProps>(
|
export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
|
||||||
(
|
(
|
||||||
{ speed = 10, verticalSpeed = 5, onPositionChange, disabled = false },
|
{ speed = 10, verticalSpeed = 5, onPositionChange, disabled = false },
|
||||||
ref,
|
ref,
|
||||||
@@ -122,4 +122,4 @@ const FlyControllerInner = forwardRef<FlyControllerRef, FlyControllerProps>(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export default FlyControllerInner;
|
FlyController.displayName = "FlyController";
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
constructor(private maxSize = 50) {}
|
||||||
|
|
||||||
|
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 "@/features/editor/utils/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useMemo, useRef, useEffect, useState } from "react";
|
import { useMemo, useRef, useEffect, useState } from "react";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { Grid, TransformControls, useGLTF } from "@react-three/drei";
|
||||||
import { Grid, TransformControls } from "@react-three/drei";
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor";
|
import type { SceneData, MapNode, TransformMode } from "@/types/editor";
|
||||||
|
|
||||||
interface MapViewerProps {
|
interface EditorMapProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
onSelectNode: (index: number | null) => void;
|
onSelectNode: (index: number | null) => void;
|
||||||
@@ -17,7 +16,7 @@ interface MapViewerProps {
|
|||||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MapViewer({
|
export function EditorMap({
|
||||||
sceneData,
|
sceneData,
|
||||||
selectedNodeIndex,
|
selectedNodeIndex,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
@@ -27,7 +26,7 @@ export default function MapViewer({
|
|||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
}: MapViewerProps): React.JSX.Element {
|
}: EditorMapProps): React.JSX.Element {
|
||||||
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
|
||||||
|
|
||||||
const handleTransformMouseDown = () => {
|
const handleTransformMouseDown = () => {
|
||||||
@@ -93,7 +92,7 @@ export default function MapViewer({
|
|||||||
|
|
||||||
if (modelUrl) {
|
if (modelUrl) {
|
||||||
return (
|
return (
|
||||||
<ModelNodeWithRef
|
<EditorModelNode
|
||||||
key={index}
|
key={index}
|
||||||
index={index}
|
index={index}
|
||||||
node={node}
|
node={node}
|
||||||
@@ -107,7 +106,7 @@ export default function MapViewer({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<FallbackNodeWithRef
|
<EditorFallbackNode
|
||||||
key={index}
|
key={index}
|
||||||
index={index}
|
index={index}
|
||||||
node={node}
|
node={node}
|
||||||
@@ -134,7 +133,7 @@ export default function MapViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ModelNodeWithRef({
|
function EditorModelNode({
|
||||||
index,
|
index,
|
||||||
node,
|
node,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
@@ -233,7 +232,7 @@ function ModelNodeWithRef({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FallbackNodeWithRef({
|
function EditorFallbackNode({
|
||||||
index,
|
index,
|
||||||
node,
|
node,
|
||||||
isSelected,
|
isSelected,
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { OrbitControls } from "@react-three/drei";
|
||||||
import EditorCamera from "./EditorCamera";
|
import { FlyController } from "@/features/editor/controls/FlyController";
|
||||||
import FlyController from "./FlyController";
|
import { EditorMap } from "@/features/editor/scene/EditorMap";
|
||||||
import MapViewer from "./MapViewer";
|
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor";
|
import type { MapNode, TransformMode, SceneData } from "@/types/editor";
|
||||||
|
|
||||||
interface EditorViewerProps {
|
interface EditorSceneProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
onSelectNode: (index: number | null) => void;
|
onSelectNode: (index: number | null) => void;
|
||||||
@@ -21,7 +20,7 @@ interface EditorViewerProps {
|
|||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditorViewer({
|
export function EditorScene({
|
||||||
sceneData,
|
sceneData,
|
||||||
selectedNodeIndex,
|
selectedNodeIndex,
|
||||||
onSelectNode,
|
onSelectNode,
|
||||||
@@ -35,7 +34,7 @@ export default function EditorViewer({
|
|||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
isPlayerMode = false,
|
isPlayerMode = false,
|
||||||
}: EditorViewerProps) {
|
}: EditorSceneProps): React.JSX.Element {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
@@ -75,8 +74,6 @@ export default function EditorViewer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<EditorCamera />
|
|
||||||
|
|
||||||
{isPlayerMode ? (
|
{isPlayerMode ? (
|
||||||
<FlyController disabled={false} />
|
<FlyController disabled={false} />
|
||||||
) : (
|
) : (
|
||||||
@@ -91,7 +88,7 @@ export default function EditorViewer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MapViewer
|
<EditorMap
|
||||||
sceneData={sceneData}
|
sceneData={sceneData}
|
||||||
selectedNodeIndex={selectedNodeIndex}
|
selectedNodeIndex={selectedNodeIndex}
|
||||||
onSelectNode={onSelectNode}
|
onSelectNode={onSelectNode}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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 as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
||||||
|
file.name;
|
||||||
|
|
||||||
|
if (!relativePath.includes("/")) {
|
||||||
|
return `/${relativePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, ...pathParts] = relativePath.split("/");
|
||||||
|
return `/${pathParts.join("/")}`;
|
||||||
|
}
|
||||||
+32
-48
@@ -136,7 +136,7 @@ canvas {
|
|||||||
font-family: "Courier New", monospace;
|
font-family: "Courier New", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-section {
|
.editor-upload-section {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@@ -145,13 +145,13 @@ canvas {
|
|||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-section h3 {
|
.editor-upload-section h3 {
|
||||||
color: #ff6600;
|
color: #ff6600;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.editor-drop-zone {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
@@ -167,28 +167,28 @@ canvas {
|
|||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone:hover {
|
.editor-drop-zone:hover {
|
||||||
background: rgba(255, 102, 0, 0.2);
|
background: rgba(255, 102, 0, 0.2);
|
||||||
border-color: #ff8533;
|
border-color: #ff8533;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-input {
|
.editor-folder-input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-structure {
|
.editor-folder-structure {
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-structure h4 {
|
.editor-folder-structure h4 {
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.folder-structure pre {
|
.editor-folder-structure pre {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -234,13 +234,13 @@ canvas {
|
|||||||
color: #ff6600;
|
color: #ff6600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transform-buttons {
|
.editor-transform-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transform-button {
|
.editor-transform-button {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #333;
|
background: #333;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -251,27 +251,27 @@ canvas {
|
|||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transform-button.active {
|
.editor-transform-button.active {
|
||||||
background: #ff6600;
|
background: #ff6600;
|
||||||
color: black;
|
color: black;
|
||||||
border-color: #ff6600;
|
border-color: #ff6600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transform-button:hover {
|
.editor-transform-button:hover {
|
||||||
background: #444;
|
background: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.transform-button.active:hover {
|
.editor-transform-button.active:hover {
|
||||||
background: #ff8533;
|
background: #ff8533;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-buttons {
|
.editor-history-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-button {
|
.editor-history-button {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: #333;
|
background: #333;
|
||||||
@@ -282,12 +282,12 @@ canvas {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-button:disabled {
|
.editor-history-button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-button {
|
.editor-export-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -300,11 +300,11 @@ canvas {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-button:hover {
|
.editor-export-button:hover {
|
||||||
background: #ff8533;
|
background: #ff8533;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button {
|
.editor-save-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
@@ -317,27 +317,11 @@ canvas {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-button:hover {
|
.editor-save-button:hover {
|
||||||
background: #16a34a;
|
background: #16a34a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reset-button {
|
.editor-player-button {
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
background: #444;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reset-button:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-button {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #444;
|
background: #444;
|
||||||
@@ -348,20 +332,20 @@ canvas {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-button.active {
|
.editor-player-button.active {
|
||||||
background: #ff6600;
|
background: #ff6600;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-button:hover {
|
.editor-player-button:hover {
|
||||||
background: #555;
|
background: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-button.active:hover {
|
.editor-player-button.active:hover {
|
||||||
background: #ff8533;
|
background: #ff8533;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-info {
|
.editor-selected-info {
|
||||||
background: rgba(255, 102, 0, 0.1);
|
background: rgba(255, 102, 0, 0.1);
|
||||||
border: 1px solid #ff6600;
|
border: 1px solid #ff6600;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -369,17 +353,17 @@ canvas {
|
|||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-name {
|
.editor-selected-name {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-index {
|
.editor-selected-index {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-selection {
|
.editor-no-selection {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px dashed #555;
|
border: 1px dashed #555;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -389,14 +373,14 @@ canvas {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-help {
|
.editor-controls-help {
|
||||||
background: #222;
|
background: #222;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-help p {
|
.editor-controls-help p {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
@@ -407,11 +391,11 @@ canvas {
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-section {
|
.editor-upload-section {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drop-zone {
|
.editor-drop-zone {
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,442 +0,0 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
|
||||||
import { Canvas } from "@react-three/fiber";
|
|
||||||
import EditorViewer from "@/components/editor/EditorViewer";
|
|
||||||
import EditorControls from "@/components/editor/EditorControls";
|
|
||||||
import type {
|
|
||||||
TransformMode,
|
|
||||||
MapNode,
|
|
||||||
SceneData,
|
|
||||||
ObjectTransform,
|
|
||||||
} from "@/types/editor";
|
|
||||||
|
|
||||||
class HistoryManager {
|
|
||||||
private history: ObjectTransform[][] = [];
|
|
||||||
private currentIndex = -1;
|
|
||||||
private maxSize: number;
|
|
||||||
|
|
||||||
constructor(maxSize = 50) {
|
|
||||||
this.maxSize = maxSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
saveSnapshot(objects: ObjectTransform[]) {
|
|
||||||
if (this.currentIndex < this.history.length - 1) {
|
|
||||||
this.history = this.history.slice(0, this.currentIndex + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.history.push(objects.map((obj) => ({ ...obj })));
|
|
||||||
|
|
||||||
this.currentIndex = this.history.length - 1;
|
|
||||||
|
|
||||||
if (this.history.length > this.maxSize) {
|
|
||||||
this.history.shift();
|
|
||||||
this.currentIndex--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
undo(): ObjectTransform[] | undefined {
|
|
||||||
if (this.currentIndex > 0) {
|
|
||||||
this.currentIndex--;
|
|
||||||
return this.history[this.currentIndex];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
redo(): ObjectTransform[] | undefined {
|
|
||||||
if (this.currentIndex < this.history.length - 1) {
|
|
||||||
this.currentIndex++;
|
|
||||||
return this.history[this.currentIndex];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
getUndoCount(): number {
|
|
||||||
return this.currentIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
getRedoCount(): number {
|
|
||||||
return this.history.length - 1 - this.currentIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EditorPage(): React.JSX.Element {
|
|
||||||
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
|
|
||||||
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
|
|
||||||
const [sceneData, setSceneData] = useState<SceneData | null>(null);
|
|
||||||
|
|
||||||
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
|
|
||||||
const [transformMode, setTransformMode] =
|
|
||||||
useState<TransformMode>("translate");
|
|
||||||
const [undoCount, setUndoCount] = useState(0);
|
|
||||||
const [redoCount, setRedoCount] = useState(0);
|
|
||||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
|
||||||
|
|
||||||
const historyManager = useRef<HistoryManager>(new HistoryManager(50));
|
|
||||||
|
|
||||||
const handleSelectNode = useCallback((index: number | null) => {
|
|
||||||
setSelectedNodeIndex(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleHoverNode = useCallback((index: number | null) => {
|
|
||||||
setHoveredNodeIndex(index);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleTransformModeChange = useCallback((mode: TransformMode) => {
|
|
||||||
setTransformMode(mode);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const applySnapshot = useCallback(
|
|
||||||
(snapshot: ObjectTransform[]) => {
|
|
||||||
if (!sceneData) return;
|
|
||||||
setSceneData((prev) => {
|
|
||||||
if (!prev) return null;
|
|
||||||
const newMapNodes = prev.mapNodes.map((node, index) => {
|
|
||||||
const transform = snapshot.find((s) => s.uuid === `node-${index}`);
|
|
||||||
if (transform) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
position: [
|
|
||||||
transform.position.x,
|
|
||||||
transform.position.y,
|
|
||||||
transform.position.z,
|
|
||||||
] as [number, number, number],
|
|
||||||
rotation: [
|
|
||||||
transform.rotation.x,
|
|
||||||
transform.rotation.y,
|
|
||||||
transform.rotation.z,
|
|
||||||
] as [number, number, number],
|
|
||||||
scale: [
|
|
||||||
transform.scale.x,
|
|
||||||
transform.scale.y,
|
|
||||||
transform.scale.z,
|
|
||||||
] as [number, number, number],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
});
|
|
||||||
return { ...prev, mapNodes: newMapNodes };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sceneData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleUndo = useCallback(() => {
|
|
||||||
const snapshot = historyManager.current.undo();
|
|
||||||
if (snapshot) {
|
|
||||||
applySnapshot(snapshot);
|
|
||||||
setUndoCount(historyManager.current.getUndoCount());
|
|
||||||
setRedoCount(historyManager.current.getRedoCount());
|
|
||||||
}
|
|
||||||
}, [applySnapshot]);
|
|
||||||
|
|
||||||
const handleRedo = useCallback(() => {
|
|
||||||
const snapshot = historyManager.current.redo();
|
|
||||||
if (snapshot) {
|
|
||||||
applySnapshot(snapshot);
|
|
||||||
setUndoCount(historyManager.current.getUndoCount());
|
|
||||||
setRedoCount(historyManager.current.getRedoCount());
|
|
||||||
}
|
|
||||||
}, [applySnapshot]);
|
|
||||||
|
|
||||||
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();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}, [sceneData]);
|
|
||||||
|
|
||||||
const handleResetCamera = useCallback(() => {
|
|
||||||
console.log("Reset camera");
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handlePlayerMode = useCallback(() => {
|
|
||||||
setIsPlayerMode((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createSnapshot = useCallback((): ObjectTransform[] => {
|
|
||||||
if (!sceneData) return [];
|
|
||||||
|
|
||||||
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] },
|
|
||||||
}));
|
|
||||||
}, [sceneData]);
|
|
||||||
|
|
||||||
const handleTransformStart = useCallback(() => {
|
|
||||||
if (!sceneData) return;
|
|
||||||
historyManager.current.saveSnapshot(createSnapshot());
|
|
||||||
}, [createSnapshot, sceneData]);
|
|
||||||
|
|
||||||
const handleTransformEnd = useCallback(() => {
|
|
||||||
if (!sceneData) return;
|
|
||||||
historyManager.current.saveSnapshot(createSnapshot());
|
|
||||||
setUndoCount(historyManager.current.getUndoCount());
|
|
||||||
setRedoCount(historyManager.current.getRedoCount());
|
|
||||||
}, [createSnapshot, sceneData]);
|
|
||||||
|
|
||||||
const handleNodeTransform = useCallback(
|
|
||||||
(nodeIndex: number, updatedNode: MapNode) => {
|
|
||||||
if (!sceneData) return;
|
|
||||||
setSceneData((prev) => {
|
|
||||||
if (!prev) return null;
|
|
||||||
const newMapNodes = [...prev.mapNodes];
|
|
||||||
newMapNodes[nodeIndex] = updatedNode;
|
|
||||||
return { ...prev, mapNodes: newMapNodes };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[sceneData],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadMapData = async (): Promise<void> => {
|
|
||||||
setIsMapLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/map.json");
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setHasMapJson(false);
|
|
||||||
setIsMapLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapNodes: MapNode[] = await response.json();
|
|
||||||
|
|
||||||
const models = new Map<string, string>();
|
|
||||||
|
|
||||||
const uniqueModelNames = [
|
|
||||||
...new Set(mapNodes.map((n: MapNode) => n.name)),
|
|
||||||
];
|
|
||||||
console.log("Unique model names in map:", uniqueModelNames);
|
|
||||||
|
|
||||||
for (const modelName of uniqueModelNames) {
|
|
||||||
try {
|
|
||||||
const modelUrl = `/models/${modelName}/model.gltf`;
|
|
||||||
const modelResponse = await fetch(modelUrl);
|
|
||||||
|
|
||||||
if (modelResponse.ok) {
|
|
||||||
const contentType =
|
|
||||||
modelResponse.headers.get("content-type") || "";
|
|
||||||
|
|
||||||
if (
|
|
||||||
contentType.includes("gltf") ||
|
|
||||||
contentType.includes("json") ||
|
|
||||||
contentType.includes("model")
|
|
||||||
) {
|
|
||||||
const text = await modelResponse.text();
|
|
||||||
if (
|
|
||||||
text.includes('"glTF"') ||
|
|
||||||
text.includes('"scene"') ||
|
|
||||||
text.includes('"nodes"')
|
|
||||||
) {
|
|
||||||
models.set(modelName, modelUrl);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`Invalid GLTF content for ${modelName}:`,
|
|
||||||
text.substring(0, 100),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`Invalid Content-Type for ${modelName}:`,
|
|
||||||
contentType,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* empty */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log("Loaded models:", Array.from(models.keys()));
|
|
||||||
|
|
||||||
setSceneData({ mapNodes, models });
|
|
||||||
setHasMapJson(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error loading map data:", error);
|
|
||||||
setHasMapJson(false);
|
|
||||||
} finally {
|
|
||||||
setIsMapLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadMapData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFolderUpload = async (
|
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
|
||||||
): Promise<void> => {
|
|
||||||
const files = event.target.files;
|
|
||||||
if (!files) return;
|
|
||||||
|
|
||||||
const fileMap = new Map<string, File>();
|
|
||||||
for (const file of Array.from(files)) {
|
|
||||||
const webkitRelativePath =
|
|
||||||
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
|
|
||||||
"/" + file.name;
|
|
||||||
fileMap.set(webkitRelativePath, file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapFile = fileMap.get("/map.json");
|
|
||||||
if (!mapFile) {
|
|
||||||
alert("Fichier map.json manquant à la racine du dossier");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const mapText = await mapFile.text();
|
|
||||||
const mapNodes = JSON.parse(mapText);
|
|
||||||
|
|
||||||
const models = new Map<string, string>();
|
|
||||||
|
|
||||||
for (const [path, file] of fileMap.entries()) {
|
|
||||||
const modelMatch = path && path.match(/^\/models\/(.+)\/model\.gltf$/);
|
|
||||||
if (modelMatch && modelMatch[1]) {
|
|
||||||
const modelName = modelMatch[1];
|
|
||||||
const blobUrl = URL.createObjectURL(file);
|
|
||||||
models.set(modelName, blobUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSceneData({ mapNodes, models });
|
|
||||||
setHasMapJson(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing upload:", error);
|
|
||||||
alert("Erreur lors du traitement du dossier");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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="upload-section">
|
|
||||||
<h3>Télécharger un dossier contenant map.json</h3>
|
|
||||||
|
|
||||||
<label className="drop-zone">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
className="folder-input"
|
|
||||||
onChange={handleFolderUpload}
|
|
||||||
/>
|
|
||||||
Choisir un dossier contenant map.json
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="folder-structure">
|
|
||||||
<h4>Structure requise :</h4>
|
|
||||||
<pre>
|
|
||||||
public/ ├── <strong>map.json</strong> (à la racine) └── models/
|
|
||||||
├── arbre/ │ └── model.glb ├── building/ │ └── model.glb └── ...
|
|
||||||
</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("#1e293b");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditorViewer
|
|
||||||
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}
|
|
||||||
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={handleSaveToServer}
|
|
||||||
onResetCamera={handleResetCamera}
|
|
||||||
onPlayerMode={handlePlayerMode}
|
|
||||||
isPlayerMode={isPlayerMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}, [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("#1e293b");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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}
|
||||||
|
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={handleSaveToServer}
|
||||||
|
onPlayerMode={handlePlayerMode}
|
||||||
|
isPlayerMode={isPlayerMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,14 +12,3 @@ export interface SceneData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TransformMode = "translate" | "rotate" | "scale";
|
export type TransformMode = "translate" | "rotate" | "scale";
|
||||||
|
|
||||||
export 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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SceneSnapshot {
|
|
||||||
objects: ObjectTransform[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 models = new Map<string, string>();
|
||||||
|
const uniqueModelNames = [...new Set(mapNodes.map((node) => node.name))];
|
||||||
|
|
||||||
|
for (const modelName of uniqueModelNames) {
|
||||||
|
const modelUrl = `/models/${modelName}/${MODEL_FILE_NAME}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const modelResponse = await fetch(modelUrl);
|
||||||
|
if (!modelResponse.ok) continue;
|
||||||
|
|
||||||
|
const text = await modelResponse.text();
|
||||||
|
if (isGltfContent(text)) {
|
||||||
|
models.set(modelName, modelUrl);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Missing models are expected while editing incomplete maps. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGltfContent(text: string): boolean {
|
||||||
|
return (
|
||||||
|
text.includes('"glTF"') ||
|
||||||
|
text.includes('"scene"') ||
|
||||||
|
text.includes('"nodes"')
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user