feat editor

This commit is contained in:
math-pixel
2026-04-23 15:24:40 +02:00
parent 20fbaf05e1
commit d0cf876372
18 changed files with 2252 additions and 40 deletions
+18 -8
View File
@@ -1,19 +1,29 @@
import { Routes, Route } from "react-router-dom";
import { Canvas } from "@react-three/fiber";
import { Crosshair } from "@/components/ui/Crosshair";
import { InteractPrompt } from "@/components/ui/InteractPrompt";
import { DebugPerf } from "@/utils/debug/DebugPerf";
import { World } from "@/world/World";
import { EditorPage } from "@/components/editor/EditorPage";
function App(): React.JSX.Element {
return (
<>
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
<World />
<DebugPerf />
</Canvas>
<Crosshair />
<InteractPrompt />
</>
<Routes>
<Route
path="/"
element={
<>
<Canvas camera={{ position: [85, 60, 85], fov: 42 }} shadows>
<World />
<DebugPerf />
</Canvas>
<Crosshair />
<InteractPrompt />
</>
}
/>
<Route path="/editor" element={<EditorPage />} />
</Routes>
);
}
+21
View File
@@ -0,0 +1,21 @@
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;
}
+139
View File
@@ -0,0 +1,139 @@
import type { TransformMode } from "./types";
interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
nodesCount: number;
selectedNodeName: string | null;
undoCount: number;
redoCount: number;
onUndo: () => void;
onRedo: () => void;
onExportJson: () => void;
onResetCamera?: () => void;
onPlayerMode?: () => void;
isPlayerMode?: boolean;
}
export default function EditorControls({
transformMode,
onTransformModeChange,
selectedNodeIndex,
nodesCount,
selectedNodeName,
undoCount,
redoCount,
onUndo,
onRedo,
onExportJson,
onResetCamera,
onPlayerMode,
isPlayerMode,
}: EditorControlsProps) {
const cameraPosition = [0, 50, 100];
return (
<>
<div className="editor-camera-info">
<div>Camera Position:</div>
<div>X: {cameraPosition[0]!.toFixed(2)}</div>
<div>Y: {cameraPosition[1]!.toFixed(2)}</div>
<div>Z: {cameraPosition[2]!.toFixed(2)}</div>
</div>
<div className="editor-controls-panel">
<h3>Transform</h3>
<div className="transform-buttons">
<button
className={`transform-button ${transformMode === "translate" ? "active" : ""}`}
onClick={() => onTransformModeChange("translate")}
>
Translate (T)
</button>
<button
className={`transform-button ${transformMode === "rotate" ? "active" : ""}`}
onClick={() => onTransformModeChange("rotate")}
>
🔄 Rotate (R)
</button>
<button
className={`transform-button ${transformMode === "scale" ? "active" : ""}`}
onClick={() => onTransformModeChange("scale")}
>
📐 Scale (S)
</button>
</div>
<div className="history-buttons">
<button
className="history-button"
onClick={onUndo}
disabled={undoCount === 0}
style={{ color: undoCount > 0 ? "#00ff00" : "#555" }}
>
Undo ({undoCount})
</button>
<button
className="history-button"
onClick={onRedo}
disabled={redoCount === 0}
style={{ color: redoCount > 0 ? "#00ff00" : "#555" }}
>
Redo ({redoCount})
</button>
</div>
<button className="export-button" onClick={onExportJson}>
💾 Export JSON
</button>
<h3>View</h3>
{onResetCamera && (
<button className="reset-button" onClick={onResetCamera}>
🔄 Reset Camera
</button>
)}
{onPlayerMode && (
<button
className={`player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
>
🎮 Player Controller
</button>
)}
<h3>Selection</h3>
{selectedNodeIndex !== null ? (
<div className="selected-info">
<div className="selected-name">
Selected:{" "}
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
</div>
<div className="selected-index">
Index: {selectedNodeIndex + 1} / {nodesCount}
</div>
</div>
) : (
<div className="no-selection">No object selected</div>
)}
<h3>Controls</h3>
<div className="controls-help">
<p>Click - Select object</p>
<p>T/R/S - Transform mode</p>
<p>Ctrl+Z - Undo</p>
<p>Ctrl+Y - Redo</p>
<p>ESC - Deselect</p>
<p>WASD/ZQSD - Move (Player mode)</p>
<p>Space - Jump (Player mode)</p>
</div>
</div>
</>
);
}
@@ -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<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;
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;
}
+318
View File
@@ -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;
}
+416
View File
@@ -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<boolean>(false);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null);
// État partagé entre Canvas (3D) et EditorControls (HTML)
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 historyManagerRef = useCallback(() => new HistoryManager(50), []);
const historyManager = useRef<HistoryManager>(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<void> => {
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<string, string>();
try {
const traverseModels = async (path: string): Promise<void> => {
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<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 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<string, string>();
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 (
<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>
{/* EditorControls rendu en dehors du Canvas (HTML overlay) */}
{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}
onResetCamera={handleResetCamera}
onPlayerMode={handlePlayerMode}
isPlayerMode={isPlayerMode}
/>
)}
</div>
);
}
+112
View File
@@ -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 (
<>
<EditorCamera />
{isPlayerMode ? (
<FlyController disabled={false} />
) : (
<OrbitControls
enableDamping
dampingFactor={0.05}
mouseButtons={{
LEFT: 0,
MIDDLE: 1,
RIGHT: 2,
}}
/>
)}
<MapViewer
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode}
transformMode={transformMode}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}
/>
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
</>
);
}
+102
View File
@@ -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<FlyControllerRef, FlyControllerProps>(({
speed = 10,
verticalSpeed = 5,
onPositionChange,
disabled = false
}, ref) => {
const { camera } = useThree();
const keys = useRef<{ [key: string]: boolean }>({});
const controlsRef = useRef<any>(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 (
<OrbitControls
ref={controlsRef}
makeDefault
enableDamping
dampingFactor={0.05}
mouseButtons={{
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN,
}}
/>
);
});
export default FlyControllerInner;
+299
View File
@@ -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<string, THREE.Group>();
export default function MapViewer({
sceneData,
selectedNodeIndex,
onSelectNode,
hoveredNodeIndex,
onHoverNode,
transformMode,
onTransformStart,
onTransformEnd,
onNodeTransform,
}: MapViewerProps) {
const isTransforming = useRef(false);
const objectsRef = useRef<Map<number, THREE.Object3D>>(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 (
<>
<Grid
args={[100, 100]}
cellSize={1}
cellThickness={0.5}
cellColor="#444444"
sectionSize={5}
sectionThickness={1}
sectionColor="#666666"
fadeDistance={50}
fadeStrength={1}
followCamera={false}
infiniteGrid={false}
/>
<axesHelper args={[10]} />
<group
onClick={(e) => {
e.stopPropagation();
if (!(window as any).isTransforming) {
onSelectNode(null);
}
}}
>
{sceneData.mapNodes.map((node, index) => {
const modelUrl = sceneData.models.get(node.name);
if (modelUrl) {
return (
<ModelNodeWithRef
key={index}
index={index}
node={node}
modelUrl={modelUrl}
isSelected={selectedNodeIndex === index}
isHovered={hoveredNodeIndex === index}
objectsRef={objectsRef}
onSelectNode={onSelectNode}
onHoverNode={onHoverNode}
/>
);
} else {
return (
<FallbackNodeWithRef
key={index}
index={index}
node={node}
isSelected={selectedNodeIndex === index}
isHovered={hoveredNodeIndex === index}
objectsRef={objectsRef}
onSelectNode={onSelectNode}
onHoverNode={onHoverNode}
/>
);
}
})}
</group>
{selectedObject && (
<TransformControls
object={selectedObject}
mode={transformMode}
onMouseDown={handleTransformMouseDown}
onMouseUp={handleTransformMouseUp}
/>
)}
</>
);
}
function ModelNodeWithRef({
index,
node,
modelUrl,
isSelected,
isHovered,
objectsRef,
onSelectNode,
onHoverNode,
}: {
index: number;
node: MapNode;
modelUrl: string;
isSelected: boolean;
isHovered: boolean;
objectsRef: React.RefObject<Map<number, THREE.Object3D>>;
onSelectNode: (index: number | null) => void;
onHoverNode: (index: number | null) => void;
}) {
const groupRef = useRef<THREE.Group>(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 (
<primitive
ref={groupRef}
object={instance}
onClick={(e: any) => {
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<Map<number, THREE.Object3D>>;
onSelectNode: (index: number | null) => void;
onHoverNode: (index: number | null) => void;
}) {
const meshRef = useRef<THREE.Mesh>(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 (
<mesh
ref={meshRef}
position={node.position}
rotation={node.rotation}
scale={node.scale}
onClick={(e) => {
e.stopPropagation();
if (!(window as any).isTransforming) {
onSelectNode(index);
}
}}
onPointerEnter={(e) => {
e.stopPropagation();
onHoverNode(index);
}}
onPointerLeave={(e) => {
e.stopPropagation();
onHoverNode(null);
}}
>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color={color} />
</mesh>
);
}
+25
View File
@@ -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<string, string>;
}
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[];
}
+5 -2
View File
@@ -1,10 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);