diff --git a/EDITOR-README.md b/EDITOR-README.md new file mode 100644 index 0000000..ed0b8ea --- /dev/null +++ b/EDITOR-README.md @@ -0,0 +1,164 @@ +# 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 `` +- `App.tsx` → Routes avec `` +- `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. diff --git a/R3F-ERROR-FIX.md b/R3F-ERROR-FIX.md new file mode 100644 index 0000000..dd81831 --- /dev/null +++ b/R3F-ERROR-FIX.md @@ -0,0 +1,127 @@ +# ✅ Résolution de l'erreur R3F "Div is not part of the THREE namespace!" + +## ❌ Problème initial + +**Erreur** : `Uncaught Error: R3F: Div is not part of the THREE namespace! Did you forget to extend?` + +**Cause** : Le composant `EditorControls` était rendu à l'intérieur du `` de React Three Fiber, alors qu'il contient des éléments HTML (`
`, ` + + +
+ +
+ + +
+ + + +

View

+ + {onResetCamera && ( + + )} + + {onPlayerMode && ( + + )} + +

Selection

+ {selectedNodeIndex !== null ? ( +
+
+ Selected:{" "} + + {selectedNodeName || `Node ${selectedNodeIndex + 1}`} + +
+
+ Index: {selectedNodeIndex + 1} / {nodesCount} +
+
+ ) : ( +
No object selected
+ )} + +

Controls

+
+

Click - Select object

+

T/R/S - Transform mode

+

Ctrl+Z - Undo

+

Ctrl+Y - Redo

+

ESC - Deselect

+

WASD/ZQSD - Move (Player mode)

+

Space - Jump (Player mode)

+
+ + + ); +} diff --git a/src/components/editor/EditorFPSController.tsx b/src/components/editor/EditorFPSController.tsx new file mode 100644 index 0000000..1129acb --- /dev/null +++ b/src/components/editor/EditorFPSController.tsx @@ -0,0 +1,141 @@ +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 } = useThree(); + const keys = useRef>(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; + + camera.rotation.y -= movementX * MOUSE_SENSITIVITY; + camera.rotation.x -= movementY * MOUSE_SENSITIVITY; + camera.rotation.x = Math.max( + -Math.PI / 2, + Math.min(Math.PI / 2, camera.rotation.x), + ); + }, + [camera], + ); + + 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(camera.quaternion); + right.applyQuaternion(camera.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; + } + + camera.position.copy( + camera.position.clone().add(velocity.current.clone().multiplyScalar(dt)), + ); + + if (camera.position.y < 2) { + camera.position.y = 2; + velocity.current.y = 0; + velocity.current.x *= 0.9; + velocity.current.z *= 0.9; + } + }); + + return null; +} diff --git a/src/components/editor/EditorPage.css b/src/components/editor/EditorPage.css new file mode 100644 index 0000000..9ae97fc --- /dev/null +++ b/src/components/editor/EditorPage.css @@ -0,0 +1,318 @@ +.editor-container { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + font-family: system-ui, sans-serif; + overflow: hidden; +} + +.editor-loading, +.editor-error { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100%; + color: white; + text-align: center; + padding: 2rem; +} + +.editor-loading h2 { + font-size: 2rem; + color: #ff6600; + margin-bottom: 1rem; +} + +.editor-loading p { + font-size: 1rem; + color: #aaa; +} + +.editor-error h2 { + font-size: 1.8rem; + color: #ff4444; + margin-bottom: 1rem; +} + +.editor-error p { + font-size: 1.1rem; + color: #ccc; + margin-bottom: 2rem; + max-width: 600px; +} + +.upload-section { + background: rgba(255, 255, 255, 0.05); + border-radius: 12px; + padding: 2rem; + border: 2px dashed rgba(255, 102, 0, 0.3); + max-width: 500px; + margin-top: 2rem; +} + +.upload-section h3 { + color: #ff6600; + margin-bottom: 1rem; + font-size: 1.4rem; +} + +.drop-zone { + display: block; + width: 100%; + padding: 2rem 1rem; + border: 2px dashed #ff6600; + border-radius: 8px; + background: rgba(255, 102, 0, 0.1); + color: #ff6600; + font-weight: bold; + text-align: center; + cursor: pointer; + transition: all 0.2s; + font-size: 1.1rem; + margin-bottom: 1.5rem; +} + +.drop-zone:hover { + background: rgba(255, 102, 0, 0.2); + border-color: #ff8533; +} + +.folder-input { + display: none; +} + +.folder-structure { + background: rgba(0, 0, 0, 0.3); + border-radius: 8px; + padding: 1rem; + margin-top: 1rem; +} + +.folder-structure h4 { + color: #aaa; + margin-bottom: 0.5rem; +} + +.folder-structure pre { + background: rgba(0, 0, 0, 0.5); + padding: 1rem; + border-radius: 6px; + color: #ddd; + font-family: "Courier New", monospace; + font-size: 0.9rem; + overflow-x: auto; + white-space: pre-wrap; +} + +code { + background: rgba(255, 102, 0, 0.2); + padding: 0.2rem 0.4rem; + border-radius: 4px; + color: #ff8533; + font-family: "Courier New", monospace; +} + +@media (max-width: 768px) { + .editor-error h2 { + font-size: 1.5rem; + } + + .upload-section { + padding: 1.5rem; + } + + .drop-zone { + padding: 1.5rem 1rem; + } +} + +/* Editor Controls Styles */ +.editor-camera-info { + position: absolute; + top: 10px; + left: 10px; + background: rgba(0, 0, 0, 0.8); + color: #00ff00; + padding: 10px 15px; + border-radius: 4px; + border: 1px solid #00ff00; + font-family: monospace; + font-size: 12px; + line-height: 1.5; +} + +.editor-controls-panel { + position: absolute; + right: 0; + top: 0; + width: 250px; + height: 100%; + background: rgba(30, 30, 30, 0.95); + padding: 20px; + color: white; + border-left: 2px solid #ff6600; + overflow-y: auto; + font-family: system-ui, sans-serif; +} + +.editor-controls-panel h3 { + margin-top: 20px; + margin-bottom: 15px; + font-size: 18px; + color: #ff6600; +} + +.transform-buttons { + display: flex; + flex-direction: column; + gap: 8px; +} + +.transform-button { + padding: 12px; + background: #333; + color: white; + border: 1px solid #555; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.2s; +} + +.transform-button.active { + background: #ff6600; + color: black; + border-color: #ff6600; +} + +.transform-button:hover { + background: #444; +} + +.transform-button.active:hover { + background: #ff8533; +} + +.history-buttons { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.history-button { + flex: 1; + padding: 8px; + background: #333; + color: #aaa; + border: 1px solid #555; + border-radius: 4px; + cursor: pointer; + font-size: 12px; +} + +.history-button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.export-button { + width: 100%; + margin-top: 10px; + padding: 12px; + background: #ff6600; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: bold; +} + +.export-button:hover { + background: #ff8533; +} + +.reset-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%; + padding: 12px; + background: #444; + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; +} + +.player-button.active { + background: #ff6600; + color: black; +} + +.player-button:hover { + background: #555; +} + +.player-button.active:hover { + background: #ff8533; +} + +.selected-info { + background: rgba(255, 102, 0, 0.1); + border: 1px solid #ff6600; + border-radius: 6px; + padding: 15px; + margin-bottom: 15px; +} + +.selected-name { + font-size: 16px; + margin-bottom: 5px; +} + +.selected-index { + font-size: 14px; + color: #aaa; +} + +.no-selection { + background: rgba(255, 255, 255, 0.05); + border: 1px dashed #555; + border-radius: 6px; + padding: 15px; + text-align: center; + color: #888; + font-style: italic; +} + +.controls-help { + background: #222; + border-radius: 6px; + padding: 15px; + border: 1px solid #444; +} + +.controls-help p { + margin: 4px 0; + font-size: 12px; + color: #aaa; +} diff --git a/src/components/editor/EditorPage.tsx b/src/components/editor/EditorPage.tsx new file mode 100644 index 0000000..5ff6a67 --- /dev/null +++ b/src/components/editor/EditorPage.tsx @@ -0,0 +1,416 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { Canvas } from "@react-three/fiber"; +import EditorViewer from "./EditorViewer"; +import EditorControls from "./EditorControls"; +import type { TransformMode, MapNode } from "./types"; +import type { SceneData } from "./types"; +import "./EditorPage.css"; + +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[]) { + 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; + } + + clear() { + this.history = []; + this.currentIndex = -1; + } + + canUndo(): boolean { + return this.currentIndex > 0; + } + + canRedo(): boolean { + return this.currentIndex < this.history.length - 1; + } +} + +export function EditorPage(): React.JSX.Element { + const [hasMapJson, setHasMapJson] = useState(false); + const [isMapLoading, setIsMapLoading] = useState(true); + const [sceneData, setSceneData] = useState(null); + + // État partagé entre Canvas (3D) et EditorControls (HTML) + const [selectedNodeIndex, setSelectedNodeIndex] = useState( + null, + ); + const [hoveredNodeIndex, setHoveredNodeIndex] = useState(null); + const [transformMode, setTransformMode] = + useState("translate"); + const [undoCount, setUndoCount] = useState(0); + const [redoCount, setRedoCount] = useState(0); + const [isPlayerMode, setIsPlayerMode] = useState(false); + + const historyManagerRef = useCallback(() => new HistoryManager(50), []); + const historyManager = useRef(historyManagerRef()); + + // Callbacks partagés + 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 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(() => { + // Logique pour reset camera + console.log("Reset camera"); + }, []); + + const handlePlayerMode = useCallback(() => { + setIsPlayerMode((prev) => !prev); + }, []); + + const handleTransformStart = useCallback(() => { + if (!sceneData) return; + const snapshot = 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] }, + })); + historyManager.current.saveSnapshot(snapshot); + }, [sceneData]); + + const handleTransformEnd = useCallback(() => { + if (!sceneData) return; + const snapshot = 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] }, + })); + historyManager.current.saveSnapshot(snapshot); + setUndoCount(historyManager.current.getUndoCount()); + setRedoCount(historyManager.current.getRedoCount()); + }, [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 }; + }); + setUndoCount((prev) => prev + 1); + console.log("Node transformed:", nodeIndex); + }, + [sceneData], + ); + + useEffect(() => { + const loadMapData = async (): Promise => { + setIsMapLoading(true); + + try { + const response = await fetch("/map.json"); + + if (!response.ok) { + setHasMapJson(false); + setIsMapLoading(false); + return; + } + + const mapNodes = await response.json(); + + const models = new Map(); + + try { + const traverseModels = async (path: string): Promise => { + try { + const response = await fetch(path); + if (!response.ok) return; + const text = await response.text(); + + if (text.includes("index")) { + const modelUrl = path.replace(/\/$/, "") + "/model.glb"; + const modelResponse = await fetch(modelUrl); + if (modelResponse.ok) { + const blob = await modelResponse.blob(); + const blobUrl = URL.createObjectURL(blob); + const pathParts = path.split("/").filter(Boolean); + const modelName = pathParts[pathParts.length - 1] || ""; + models.set(modelName, blobUrl); + } + } + } catch {} + }; + + const baseResponse = await fetch("/models/"); + if (baseResponse.ok) { + const text = await baseResponse.text(); + const lines = text.split("\n"); + for (const line of lines) { + if (line.includes("href") && line.includes("/")) { + const match = line.match(/href="([^"]+)"/); + if (match && match[1]) { + const href = match[1]; + if (href.endsWith("/")) { + await traverseModels(href); + } + } + } + } + } + } catch (error) { + console.warn("Could not scan models directory:", error); + } + + 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, + ): Promise => { + const files = event.target.files; + if (!files) return; + + const fileMap = new Map(); + for (const file of Array.from(files)) { + const webkitRelativePath = + (file as any).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(); + + for (const [path, file] of fileMap.entries()) { + const modelMatch = path && path.match(/^\/models\/(.+)\/model\.glb$/); + 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 ( +
+
+

Chargement de l'éditeur...

+

Vérification de map.json dans public/

+
+
+ ); + } + + if (!hasMapJson) { + return ( +
+
+

Erreur : map.json introuvable

+

+ Le fichier map.json est requis dans le dossier public/. +

+ +
+

Télécharger un dossier contenant map.json

+ + + +
+

Structure requise :

+
+                public/ ├── map.json (à la racine) └── models/
+                ├── arbre/ │ └── model.glb ├── building/ │ └── model.glb └── ...
+              
+
+
+
+
+ ); + } + + return ( +
+ { + gl.setClearColor("#1e293b"); + }} + > + + + + {/* EditorControls rendu en dehors du Canvas (HTML overlay) */} + {sceneData && ( + + )} +
+ ); +} diff --git a/src/components/editor/EditorViewer.tsx b/src/components/editor/EditorViewer.tsx new file mode 100644 index 0000000..76edcda --- /dev/null +++ b/src/components/editor/EditorViewer.tsx @@ -0,0 +1,112 @@ +import { useEffect } from "react"; +import { OrbitControls } from "@react-three/drei"; +import EditorCamera from "./EditorCamera"; +import FlyController from "./FlyController"; +import MapViewer from "./MapViewer"; +import type { MapNode, TransformMode } from "./types"; +import type { SceneData } from "./types"; + +interface EditorViewerProps { + 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 default function EditorViewer({ + sceneData, + selectedNodeIndex, + onSelectNode, + hoveredNodeIndex, + onHoverNode, + transformMode, + onTransformModeChange, + onTransformStart, + onTransformEnd, + onNodeTransform, + onUndo, + onRedo, + isPlayerMode = false, +}: EditorViewerProps) { + 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 ? ( + + ) : ( + + )} + + + + + + + + ); +} diff --git a/src/components/editor/FlyController.tsx b/src/components/editor/FlyController.tsx new file mode 100644 index 0000000..e48dac4 --- /dev/null +++ b/src/components/editor/FlyController.tsx @@ -0,0 +1,102 @@ +import { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { OrbitControls } from '@react-three/drei'; +import * as THREE from 'three'; + +interface FlyControllerProps { + speed?: number; + verticalSpeed?: number; + onPositionChange?: (position: THREE.Vector3) => void; + disabled?: boolean; +} + +export interface FlyControllerRef { + controls: any; +} + +const FlyControllerInner = forwardRef(({ + speed = 10, + verticalSpeed = 5, + onPositionChange, + disabled = false +}, ref) => { + const { camera } = useThree(); + const keys = useRef<{ [key: string]: boolean }>({}); + const controlsRef = useRef(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) => { + // En mode disabled: ZQSD désactivé, on garde que OrbitControls + if (disabled) { + return; + } + + // ZQSD (AZERTY): Z=forward, S=backward, Q=left, D=right + // Support aussi QWERTY et flèches + 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(camera.quaternion); + camera.position.add(direction); + } + + // Space = monter, Shift = descendre + if (keys.current['Space']) { + camera.position.y += verticalSpeed * delta; + } + if (keys.current['ShiftLeft'] || keys.current['ShiftRight']) { + camera.position.y -= verticalSpeed * delta; + } + + if (onPositionChange && !camera.position.equals(lastPosition.current)) { + lastPosition.current.copy(camera.position); + onPositionChange(camera.position); + } + }); + + return ( + + ); +}); + +export default FlyControllerInner; \ No newline at end of file diff --git a/src/components/editor/MapViewer.tsx b/src/components/editor/MapViewer.tsx new file mode 100644 index 0000000..20eeb20 --- /dev/null +++ b/src/components/editor/MapViewer.tsx @@ -0,0 +1,299 @@ +import { useMemo, useRef, useEffect } from "react"; +import { useGLTF } from "@react-three/drei"; +import { Grid, TransformControls } from "@react-three/drei"; +import * as THREE from "three"; + +import type { SceneData, MapNode, TransformMode } from "./types"; + +interface MapViewerProps { + 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; +} + +const clonedScenesCache = new Map(); + +export default function MapViewer({ + sceneData, + selectedNodeIndex, + onSelectNode, + hoveredNodeIndex, + onHoverNode, + transformMode, + onTransformStart, + onTransformEnd, + onNodeTransform, +}: MapViewerProps) { + const isTransforming = useRef(false); + const objectsRef = useRef>(new Map()); + + const handleTransformMouseDown = () => { + isTransforming.current = true; + onTransformStart?.(); + }; + + const handleTransformMouseUp = () => { + isTransforming.current = false; + onTransformEnd?.(); + + if (selectedObject && selectedObject.userData?.nodeIndex !== undefined) { + const index = selectedObject.userData.nodeIndex as number; + const node = sceneData.mapNodes[index]; + if (node) { + const updatedNode: MapNode = { + ...node, + position: [ + selectedObject.position.x, + selectedObject.position.y, + selectedObject.position.z, + ], + rotation: [ + selectedObject.rotation.x, + selectedObject.rotation.y, + selectedObject.rotation.z, + ], + scale: [ + selectedObject.scale.x, + selectedObject.scale.y, + selectedObject.scale.z, + ], + }; + onNodeTransform?.(index, updatedNode); + } + } + }; + + const selectedObject = useMemo(() => { + if (selectedNodeIndex === null) return null; + return objectsRef.current.get(selectedNodeIndex) || null; + }, [selectedNodeIndex]); + + return ( + <> + + + + { + e.stopPropagation(); + if (!(window as any).isTransforming) { + onSelectNode(null); + } + }} + > + {sceneData.mapNodes.map((node, index) => { + const modelUrl = sceneData.models.get(node.name); + + if (modelUrl) { + return ( + + ); + } else { + return ( + + ); + } + })} + + + {selectedObject && ( + + )} + + ); +} + +function ModelNodeWithRef({ + index, + node, + modelUrl, + isSelected, + isHovered, + objectsRef, + onSelectNode, + onHoverNode, +}: { + index: number; + node: MapNode; + modelUrl: string; + isSelected: boolean; + isHovered: boolean; + objectsRef: React.RefObject>; + onSelectNode: (index: number | null) => void; + onHoverNode: (index: number | null) => void; +}) { + const groupRef = useRef(null); + const { scene } = useGLTF(modelUrl); + + const clonedScene = useMemo(() => { + if (!clonedScenesCache.has(modelUrl)) { + const clone = scene.clone(true); + clonedScenesCache.set(modelUrl, clone); + return clone; + } + return clonedScenesCache.get(modelUrl)!; + }, [modelUrl, scene]); + + useEffect(() => { + if (groupRef.current) { + groupRef.current.position.set(...node.position); + groupRef.current.rotation.set(...node.rotation); + groupRef.current.scale.set(...node.scale); + groupRef.current.userData = { nodeIndex: index, nodeName: node.name }; + objectsRef.current.set(index, groupRef.current); + } + return () => { + objectsRef.current.delete(index); + }; + }, [index, node, objectsRef]); + + const instance = useMemo(() => { + const inst = clonedScene.clone(true); + + if (isSelected) { + inst.traverse((child: any) => { + if (child.isMesh && child.material) { + child.material = child.material.clone(); + child.material.color.set("#ff6600"); + } + }); + } else if (isHovered) { + inst.traverse((child: any) => { + if (child.isMesh && child.material) { + child.material = child.material.clone(); + child.material.color.set("#ff9900"); + } + }); + } + + inst.position.set(...node.position); + inst.rotation.set(...node.rotation); + inst.scale.set(...node.scale); + + return inst; + }, [clonedScene, node, isSelected, isHovered]); + + return ( + { + e.stopPropagation(); + if (!(window as any).isTransforming) { + onSelectNode(index); + } + }} + onPointerEnter={(e: any) => { + e.stopPropagation(); + onHoverNode(index); + }} + onPointerLeave={(e: any) => { + e.stopPropagation(); + onHoverNode(null); + }} + /> + ); +} + +function FallbackNodeWithRef({ + index, + node, + isSelected, + isHovered, + objectsRef, + onSelectNode, + onHoverNode, +}: { + index: number; + node: MapNode; + isSelected: boolean; + isHovered: boolean; + objectsRef: React.RefObject>; + onSelectNode: (index: number | null) => void; + onHoverNode: (index: number | null) => void; +}) { + const meshRef = useRef(null); + + useEffect(() => { + if (meshRef.current) { + meshRef.current.position.set(...node.position); + meshRef.current.rotation.set(...node.rotation); + meshRef.current.scale.set(...node.scale); + meshRef.current.userData = { nodeIndex: index, nodeName: node.name }; + objectsRef.current.set(index, meshRef.current); + } + return () => { + objectsRef.current.delete(index); + }; + }, [index, node, objectsRef]); + + const color = isSelected ? "#ff6600" : isHovered ? "#ff9900" : "#cccccc"; + + return ( + { + e.stopPropagation(); + if (!(window as any).isTransforming) { + onSelectNode(index); + } + }} + onPointerEnter={(e) => { + e.stopPropagation(); + onHoverNode(index); + }} + onPointerLeave={(e) => { + e.stopPropagation(); + onHoverNode(null); + }} + > + + + + ); +} diff --git a/src/components/editor/types.ts b/src/components/editor/types.ts new file mode 100644 index 0000000..746ca1c --- /dev/null +++ b/src/components/editor/types.ts @@ -0,0 +1,25 @@ +export interface MapNode { + name: string; + type: string; + position: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; +} + +export interface SceneData { + mapNodes: MapNode[]; + models: Map; +} + +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[]; +} diff --git a/src/main.tsx b/src/main.tsx index ef474bf..b3c2e37 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import App from "./App.tsx"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; import "./index.css"; createRoot(document.getElementById("root")!).render( - + + + , ); diff --git a/test-editor.html b/test-editor.html new file mode 100644 index 0000000..83e86f8 --- /dev/null +++ b/test-editor.html @@ -0,0 +1,124 @@ + + + + + + La-Fabrik Editor - Test Page + + + +

La-Fabrik Editor Integration

+ +
+

✅ Integration Status: COMPLETED

+

+ L'éditeur est maintenant intégré à la route /editor du + projet La-Fabrik. +

+
+ + + +

Fonctionnalités de l'éditeur

+
    +
  • + Routing React : React Router pour naviguer entre jeu et + éditeur +
  • +
  • + Chargement automatique : Recherche de + map.json dans public/ +
  • +
  • + Upload de dossier : Si pas de map.json, possibilité + d'upload +
  • +
  • + Visualisation 3D : Canvas Three.js avec SceneData +
  • +
  • + Control de caméra : +
      +
    • Mode debug : OrbitControls (rotation/pan/zoom)
    • +
    • Mode player : FPS controller custom (WASD/ZQSD + souris)
    • +
    +
  • +
  • Sélection d'objets : Click sur cubes/modèles
  • +
  • Transformations : Panneau avec boutons T/R/S
  • +
  • Undo/Redo : Ctrl+Z / Ctrl+Y (compte affiché)
  • +
  • + Export JSON : Bouton pour exporter map.json modifié +
  • +
+ +

Structure de fichiers

+
+src/components/editor/
+├── EditorPage.tsx         # Page route /editor
+├── EditorViewer.tsx      # Composant principal 3D
+├── EditorCamera.tsx      # Caméra (OrbitControls + useCameraMode)
+├── EditorFPSController.tsx # Controller FPS custom
+├── MapViewer.tsx         # Visualisation map.json + modèles
+├── EditorControls.tsx    # Panneau latéral UI
+├── types.ts              # Types MapNode, SceneData, etc.
+└── EditorPage.css        # Styles scoped
+    
+ +

À tester

+
    +
  1. + Accéder à /editor - devrait montrer erreur "map.json + introuvable" +
  2. +
  3. Uploader un dossier test avec map.json + models/
  4. +
  5. Tester la visualisation 3D (cubes de test existent dans map.json)
  6. +
  7. Tester le mode player (WASD + souris)
  8. +
  9. Tester les transformations T/R/S
  10. +
  11. Tester Undo/Redo (Ctrl+Z / Ctrl+Y)
  12. +
  13. Tester export JSON (bouton "Export JSON")
  14. +
  15. Naviguer vers / - retour au jeu original
  16. +
+ +