feat editor
This commit is contained in:
+18
-8
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user