docs: document editor architecture and user features

This commit is contained in:
2026-04-28 09:43:51 +02:00
parent 7b38f04a0d
commit 8f40bb8133
21 changed files with 776 additions and 911 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ 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 "@/pages/EditorPage";
import { EditorPage } from "@/pages/editor/EditorPage";
function App(): React.JSX.Element {
return (
-21
View File
@@ -1,21 +0,0 @@
import { useCameraMode } from "@/hooks/debug/useCameraMode";
import { OrbitControls } from "@react-three/drei";
export default function EditorCamera() {
const cameraMode = useCameraMode();
if (cameraMode === "debug") {
return (
<OrbitControls
enableDamping
dampingFactor={0.05}
minDistance={10}
maxDistance={500}
target={[0, 0, 0]}
makeDefault
/>
);
}
return null;
}
@@ -1,144 +0,0 @@
import { useEffect, useRef, useCallback } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
const WALK_SPEED = 8;
const JUMP_SPEED = 7;
const MOUSE_SENSITIVITY = 0.002;
export default function EditorFPSController() {
const { camera: rawCamera } = useThree();
const cameraRef = useRef(rawCamera);
const keys = useRef<Set<string>>(new Set());
const velocity = useRef(new THREE.Vector3());
const wantsJump = useRef(false);
const mouseLocked = useRef(false);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
keys.current.add(e.code);
switch (e.key.toLowerCase()) {
case " ":
wantsJump.current = true;
e.preventDefault();
break;
case "e":
e.preventDefault();
break;
}
}, []);
const handleKeyUp = useCallback((e: KeyboardEvent) => {
keys.current.delete(e.code);
}, []);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!mouseLocked.current) return;
const movementX = e.movementX || 0;
const movementY = e.movementY || 0;
cameraRef.current.rotation.y -= movementX * MOUSE_SENSITIVITY;
cameraRef.current.rotation.x -= movementY * MOUSE_SENSITIVITY;
cameraRef.current.rotation.x = Math.max(
-Math.PI / 2,
Math.min(Math.PI / 2, cameraRef.current.rotation.x),
);
},
[cameraRef],
);
const handleMouseDown = useCallback((e: MouseEvent) => {
if (e.button === 0) {
if (!mouseLocked.current) {
mouseLocked.current = true;
document.body.requestPointerLock();
}
}
}, []);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousedown", handleMouseDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousedown", handleMouseDown);
};
}, [handleKeyDown, handleKeyUp, handleMouseMove, handleMouseDown]);
useFrame((_, delta) => {
const dt = Math.min(delta, 0.05);
if (!mouseLocked.current) return;
const forward = new THREE.Vector3(0, 0, -1);
const right = new THREE.Vector3(1, 0, 0);
const up = new THREE.Vector3(0, 1, 0);
forward.applyQuaternion(cameraRef.current.quaternion);
right.applyQuaternion(cameraRef.current.quaternion);
forward.setY(0);
right.setY(0);
if (forward.lengthSq() > 0) forward.normalize();
if (right.lengthSq() > 0) right.normalize();
if (up.lengthSq() > 0) up.normalize();
const isForward =
keys.current.has("KeyW") ||
keys.current.has("ArrowUp") ||
keys.current.has("KeyZ");
const isBackward =
keys.current.has("KeyS") || keys.current.has("ArrowDown");
const isLeft =
keys.current.has("KeyA") ||
keys.current.has("ArrowLeft") ||
keys.current.has("KeyQ");
const isRight = keys.current.has("KeyD") || keys.current.has("ArrowRight");
const wishDir = new THREE.Vector3();
if (isForward) wishDir.add(forward);
if (isBackward) wishDir.sub(forward);
if (isLeft) wishDir.sub(right);
if (isRight) wishDir.add(right);
if (wishDir.lengthSq() > 0) {
wishDir.normalize().multiplyScalar(WALK_SPEED * dt * 10);
velocity.current.x += wishDir.x;
velocity.current.z += wishDir.z;
}
const damping = Math.exp(-8 * dt);
velocity.current.x *= damping;
velocity.current.z *= damping;
if (wantsJump.current) {
velocity.current.y = JUMP_SPEED;
wantsJump.current = false;
} else {
velocity.current.y -= 20 * dt;
}
cameraRef.current.position.copy(
cameraRef.current.position
.clone()
.add(velocity.current.clone().multiplyScalar(dt)),
);
if (cameraRef.current.position.y < 2) {
cameraRef.current.position.y = 2;
velocity.current.y = 0;
velocity.current.x *= 0.9;
velocity.current.z *= 0.9;
}
});
return null;
}
+8 -32
View File
@@ -1,21 +1,17 @@
import { useEffect, useState, useMemo, useRef } from "react";
import { useEffect, useState, useRef } from "react";
import { useGLTF } from "@react-three/drei";
import * as THREE from "three";
import { useOctreeGraphNode } from "@/hooks/useOctreeGraphNode";
import { loadMapSceneData } from "@/utils/loadMapSceneData";
import type { OctreeReadyHandler } from "@/types/3d";
import type { MapNode } from "@/types/editor";
const MAP_JSON_PATH = "/map.json";
interface GameMapProps {
onOctreeReady: OctreeReadyHandler;
}
export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
const [mapNodes, setMapNodes] = useState<MapNode[]>([]);
const [availableModels, setAvailableModels] = useState<Set<string>>(
new Set(),
);
const [isLoading, setIsLoading] = useState(true);
const groupRef = useRef<THREE.Group>(null);
@@ -24,32 +20,16 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
useEffect(() => {
const loadMap = async () => {
try {
const nodesResponse = await fetch(MAP_JSON_PATH);
if (!nodesResponse.ok) {
const sceneData = await loadMapSceneData();
if (!sceneData) {
console.warn("map.json not found");
setIsLoading(false);
return;
}
const nodes: MapNode[] = await nodesResponse.json();
setMapNodes(nodes);
const uniqueModelNames = [...new Set(nodes.map((n) => n.name))];
const available = new Set<string>();
for (const modelName of uniqueModelNames) {
try {
const modelUrl = `/models/${modelName}/model.gltf`;
const modelResponse = await fetch(modelUrl);
const contentType = modelResponse.headers.get("content-type") || "";
if (contentType.includes("gltf") || contentType.includes("model")) {
available.add(modelName);
}
} catch {
/* empty */
}
}
setAvailableModels(available);
setMapNodes(
sceneData.mapNodes.filter((node) => sceneData.models.has(node.name)),
);
} catch (error) {
console.error("Error loading map:", error);
} finally {
@@ -60,17 +40,13 @@ export function GameMap({ onOctreeReady }: GameMapProps): React.JSX.Element {
loadMap();
}, []);
const nodesToRender = useMemo(() => {
return mapNodes.filter((node) => availableModels.has(node.name));
}, [mapNodes, availableModels]);
if (isLoading) {
return <></>;
}
return (
<group ref={groupRef}>
{nodesToRender.map((node, index) => (
{mapNodes.map((node, index) => (
<ModelInstance key={index} node={node} />
))}
</group>
@@ -12,12 +12,11 @@ interface EditorControlsProps {
onRedo: () => void;
onExportJson: () => void;
onSaveToServer?: () => void;
onResetCamera?: () => void;
onPlayerMode?: () => void;
isPlayerMode?: boolean;
}
export default function EditorControls({
export function EditorControls({
transformMode,
onTransformModeChange,
selectedNodeIndex,
@@ -29,10 +28,9 @@ export default function EditorControls({
onRedo,
onExportJson,
onSaveToServer,
onResetCamera,
onPlayerMode,
isPlayerMode,
}: EditorControlsProps) {
}: EditorControlsProps): React.JSX.Element {
const cameraPosition = [0, 50, 100];
return (
@@ -47,30 +45,30 @@ export default function EditorControls({
<div className="editor-controls-panel">
<h3>Transform</h3>
<div className="transform-buttons">
<div className="editor-transform-buttons">
<button
className={`transform-button ${transformMode === "translate" ? "active" : ""}`}
className={`editor-transform-button ${transformMode === "translate" ? "active" : ""}`}
onClick={() => onTransformModeChange("translate")}
>
Translate (T)
</button>
<button
className={`transform-button ${transformMode === "rotate" ? "active" : ""}`}
className={`editor-transform-button ${transformMode === "rotate" ? "active" : ""}`}
onClick={() => onTransformModeChange("rotate")}
>
🔄 Rotate (R)
</button>
<button
className={`transform-button ${transformMode === "scale" ? "active" : ""}`}
className={`editor-transform-button ${transformMode === "scale" ? "active" : ""}`}
onClick={() => onTransformModeChange("scale")}
>
📐 Scale (S)
</button>
</div>
<div className="history-buttons">
<div className="editor-history-buttons">
<button
className="history-button"
className="editor-history-button"
onClick={onUndo}
disabled={undoCount === 0}
style={{ color: undoCount > 0 ? "#00ff00" : "#555" }}
@@ -78,7 +76,7 @@ export default function EditorControls({
Undo ({undoCount})
</button>
<button
className="history-button"
className="editor-history-button"
onClick={onRedo}
disabled={redoCount === 0}
style={{ color: redoCount > 0 ? "#00ff00" : "#555" }}
@@ -87,27 +85,21 @@ export default function EditorControls({
</button>
</div>
<button className="export-button" onClick={onExportJson}>
<button className="editor-export-button" onClick={onExportJson}>
💾 Export JSON
</button>
{onSaveToServer && (
<button className="save-button" onClick={onSaveToServer}>
<button className="editor-save-button" onClick={onSaveToServer}>
💾 Save to Server
</button>
)}
<h3>View</h3>
{onResetCamera && (
<button className="reset-button" onClick={onResetCamera}>
🔄 Reset Camera
</button>
)}
{onPlayerMode && (
<button
className={`player-button ${isPlayerMode ? "active" : ""}`}
className={`editor-player-button ${isPlayerMode ? "active" : ""}`}
onClick={onPlayerMode}
>
🎮 Player Controller
@@ -116,23 +108,23 @@ export default function EditorControls({
<h3>Selection</h3>
{selectedNodeIndex !== null ? (
<div className="selected-info">
<div className="selected-name">
<div className="editor-selected-info">
<div className="editor-selected-name">
Selected:{" "}
<strong>
{selectedNodeName || `Node ${selectedNodeIndex + 1}`}
</strong>
</div>
<div className="selected-index">
<div className="editor-selected-index">
Index: {selectedNodeIndex + 1} / {nodesCount}
</div>
</div>
) : (
<div className="no-selection">No object selected</div>
<div className="editor-no-selection">No object selected</div>
)}
<h3>Controls</h3>
<div className="controls-help">
<div className="editor-controls-help">
<p>Click - Select object</p>
<p>T/R/S - Transform mode</p>
<p>Ctrl+Z - Undo</p>
@@ -21,7 +21,7 @@ export interface FlyControllerRef {
controls: OrbitControlsType | null;
}
const FlyControllerInner = forwardRef<FlyControllerRef, FlyControllerProps>(
export const FlyController = forwardRef<FlyControllerRef, FlyControllerProps>(
(
{ speed = 10, verticalSpeed = 5, onPositionChange, disabled = false },
ref,
@@ -122,4 +122,4 @@ const FlyControllerInner = forwardRef<FlyControllerRef, FlyControllerProps>(
},
);
export default FlyControllerInner;
FlyController.displayName = "FlyController";
@@ -0,0 +1,161 @@
import { useCallback, useRef, useState } from "react";
import type { MapNode, SceneData } from "@/types/editor";
interface ObjectTransform {
uuid: string;
position: { x: number; y: number; z: number };
rotation: { x: number; y: number; z: number };
scale: { x: number; y: number; z: number };
}
class HistoryManager {
private history: ObjectTransform[][] = [];
private currentIndex = -1;
constructor(private maxSize = 50) {}
saveSnapshot(objects: ObjectTransform[]): void {
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
this.history.push(objects.map((object) => ({ ...object })));
this.currentIndex = this.history.length - 1;
if (this.history.length > this.maxSize) {
this.history.shift();
this.currentIndex--;
}
}
undo(): ObjectTransform[] | undefined {
if (this.currentIndex <= 0) return undefined;
this.currentIndex--;
return this.history[this.currentIndex];
}
redo(): ObjectTransform[] | undefined {
if (this.currentIndex >= this.history.length - 1) return undefined;
this.currentIndex++;
return this.history[this.currentIndex];
}
getUndoCount(): number {
return this.currentIndex;
}
getRedoCount(): number {
return this.history.length - 1 - this.currentIndex;
}
}
interface UseEditorHistoryResult {
undoCount: number;
redoCount: number;
handleUndo: () => void;
handleRedo: () => void;
handleTransformStart: () => void;
handleTransformEnd: () => void;
}
export function useEditorHistory(
sceneData: SceneData | null,
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>,
): UseEditorHistoryResult {
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const historyManager = useRef(new HistoryManager(50));
const updateHistoryCounts = useCallback(() => {
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}, []);
const applySnapshot = useCallback(
(snapshot: ObjectTransform[]): void => {
setSceneData((prev) => {
if (!prev) return null;
const mapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find(
(item) => item.uuid === `node-${index}`,
);
if (!transform) return node;
return {
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
],
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
} satisfies MapNode;
});
return { ...prev, mapNodes };
});
},
[setSceneData],
);
const handleUndo = useCallback(() => {
const snapshot = historyManager.current.undo();
if (!snapshot) return;
applySnapshot(snapshot);
updateHistoryCounts();
}, [applySnapshot, updateHistoryCounts]);
const handleRedo = useCallback(() => {
const snapshot = historyManager.current.redo();
if (!snapshot) return;
applySnapshot(snapshot);
updateHistoryCounts();
}, [applySnapshot, updateHistoryCounts]);
const handleTransformStart = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot(sceneData));
}, [sceneData]);
const handleTransformEnd = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot(sceneData));
updateHistoryCounts();
}, [sceneData, updateHistoryCounts]);
return {
undoCount,
redoCount,
handleUndo,
handleRedo,
handleTransformStart,
handleTransformEnd,
};
}
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`,
position: {
x: node.position[0],
y: node.position[1],
z: node.position[2],
},
rotation: {
x: node.rotation[0],
y: node.rotation[1],
z: node.rotation[2],
},
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
}));
}
@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from "react";
import { createSceneDataFromFiles } from "@/features/editor/utils/loadEditorScene";
import { loadMapSceneData } from "@/utils/loadMapSceneData";
import type { SceneData } from "@/types/editor";
interface UseEditorSceneDataResult {
hasMapJson: boolean;
isMapLoading: boolean;
sceneData: SceneData | null;
setSceneData: React.Dispatch<React.SetStateAction<SceneData | null>>;
handleFolderUpload: (
event: React.ChangeEvent<HTMLInputElement>,
) => Promise<void>;
}
export function useEditorSceneData(): UseEditorSceneDataResult {
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null);
useEffect(() => {
const loadScene = async (): Promise<void> => {
setIsMapLoading(true);
try {
const loadedSceneData = await loadMapSceneData();
setSceneData(loadedSceneData);
setHasMapJson(Boolean(loadedSceneData));
} catch (error) {
console.error("Error loading map data:", error);
setHasMapJson(false);
} finally {
setIsMapLoading(false);
}
};
loadScene();
}, []);
const handleFolderUpload = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
const files = event.target.files;
if (!files) return;
try {
const uploadedSceneData = await createSceneDataFromFiles(files);
setSceneData(uploadedSceneData);
setHasMapJson(true);
} catch (error) {
const message = error instanceof Error ? error.message : "Erreur";
console.error("Error processing upload:", error);
alert(message);
}
},
[],
);
return {
hasMapJson,
isMapLoading,
sceneData,
setSceneData,
handleFolderUpload,
};
}
@@ -1,11 +1,10 @@
import { useMemo, useRef, useEffect, useState } from "react";
import { useGLTF } from "@react-three/drei";
import { Grid, TransformControls } from "@react-three/drei";
import { Grid, TransformControls, useGLTF } from "@react-three/drei";
import * as THREE from "three";
import type { SceneData, MapNode, TransformMode } from "@/types/editor";
interface MapViewerProps {
interface EditorMapProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
@@ -17,7 +16,7 @@ interface MapViewerProps {
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
}
export default function MapViewer({
export function EditorMap({
sceneData,
selectedNodeIndex,
onSelectNode,
@@ -27,7 +26,7 @@ export default function MapViewer({
onTransformStart,
onTransformEnd,
onNodeTransform,
}: MapViewerProps): React.JSX.Element {
}: EditorMapProps): React.JSX.Element {
const objectsMapRef = useRef<Map<number, THREE.Object3D>>(new Map());
const handleTransformMouseDown = () => {
@@ -93,7 +92,7 @@ export default function MapViewer({
if (modelUrl) {
return (
<ModelNodeWithRef
<EditorModelNode
key={index}
index={index}
node={node}
@@ -107,7 +106,7 @@ export default function MapViewer({
);
} else {
return (
<FallbackNodeWithRef
<EditorFallbackNode
key={index}
index={index}
node={node}
@@ -134,7 +133,7 @@ export default function MapViewer({
);
}
function ModelNodeWithRef({
function EditorModelNode({
index,
node,
modelUrl,
@@ -233,7 +232,7 @@ function ModelNodeWithRef({
);
}
function FallbackNodeWithRef({
function EditorFallbackNode({
index,
node,
isSelected,
@@ -1,11 +1,10 @@
import { useEffect } from "react";
import { OrbitControls } from "@react-three/drei";
import EditorCamera from "./EditorCamera";
import FlyController from "./FlyController";
import MapViewer from "./MapViewer";
import { FlyController } from "@/features/editor/controls/FlyController";
import { EditorMap } from "@/features/editor/scene/EditorMap";
import type { MapNode, TransformMode, SceneData } from "@/types/editor";
interface EditorViewerProps {
interface EditorSceneProps {
sceneData: SceneData;
selectedNodeIndex: number | null;
onSelectNode: (index: number | null) => void;
@@ -21,7 +20,7 @@ interface EditorViewerProps {
isPlayerMode?: boolean;
}
export default function EditorViewer({
export function EditorScene({
sceneData,
selectedNodeIndex,
onSelectNode,
@@ -35,7 +34,7 @@ export default function EditorViewer({
onUndo,
onRedo,
isPlayerMode = false,
}: EditorViewerProps) {
}: EditorSceneProps): React.JSX.Element {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.ctrlKey || e.metaKey) {
@@ -75,8 +74,6 @@ export default function EditorViewer({
return (
<>
<EditorCamera />
{isPlayerMode ? (
<FlyController disabled={false} />
) : (
@@ -91,7 +88,7 @@ export default function EditorViewer({
/>
)}
<MapViewer
<EditorMap
sceneData={sceneData}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={onSelectNode}
@@ -0,0 +1,43 @@
import type { MapNode, SceneData } from "@/types/editor";
const MAP_JSON_PATH = "/map.json";
export async function createSceneDataFromFiles(
files: FileList,
): Promise<SceneData> {
const fileMap = new Map<string, File>();
for (const file of Array.from(files)) {
fileMap.set(getProjectRelativePath(file), file);
}
const mapFile = fileMap.get(MAP_JSON_PATH);
if (!mapFile) {
throw new Error("Fichier map.json manquant à la racine du dossier");
}
const mapNodes: MapNode[] = JSON.parse(await mapFile.text());
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
const modelMatch = path.match(/^\/models\/(.+)\/model\.gltf$/);
if (modelMatch?.[1]) {
models.set(modelMatch[1], URL.createObjectURL(file));
}
}
return { mapNodes, models };
}
function getProjectRelativePath(file: File): string {
const relativePath =
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
file.name;
if (!relativePath.includes("/")) {
return `/${relativePath}`;
}
const [, ...pathParts] = relativePath.split("/");
return `/${pathParts.join("/")}`;
}
+32 -48
View File
@@ -136,7 +136,7 @@ canvas {
font-family: "Courier New", monospace;
}
.upload-section {
.editor-upload-section {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 2rem;
@@ -145,13 +145,13 @@ canvas {
margin-top: 2rem;
}
.upload-section h3 {
.editor-upload-section h3 {
color: #ff6600;
margin-bottom: 1rem;
font-size: 1.4rem;
}
.drop-zone {
.editor-drop-zone {
display: block;
width: 100%;
padding: 2rem 1rem;
@@ -167,28 +167,28 @@ canvas {
margin-bottom: 1.5rem;
}
.drop-zone:hover {
.editor-drop-zone:hover {
background: rgba(255, 102, 0, 0.2);
border-color: #ff8533;
}
.folder-input {
.editor-folder-input {
display: none;
}
.folder-structure {
.editor-folder-structure {
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.folder-structure h4 {
.editor-folder-structure h4 {
color: #aaa;
margin-bottom: 0.5rem;
}
.folder-structure pre {
.editor-folder-structure pre {
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
border-radius: 6px;
@@ -234,13 +234,13 @@ canvas {
color: #ff6600;
}
.transform-buttons {
.editor-transform-buttons {
display: flex;
flex-direction: column;
gap: 8px;
}
.transform-button {
.editor-transform-button {
padding: 12px;
background: #333;
color: white;
@@ -251,27 +251,27 @@ canvas {
transition: all 0.2s;
}
.transform-button.active {
.editor-transform-button.active {
background: #ff6600;
color: black;
border-color: #ff6600;
}
.transform-button:hover {
.editor-transform-button:hover {
background: #444;
}
.transform-button.active:hover {
.editor-transform-button.active:hover {
background: #ff8533;
}
.history-buttons {
.editor-history-buttons {
display: flex;
gap: 8px;
margin-top: 10px;
}
.history-button {
.editor-history-button {
flex: 1;
padding: 8px;
background: #333;
@@ -282,12 +282,12 @@ canvas {
font-size: 12px;
}
.history-button:disabled {
.editor-history-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.export-button {
.editor-export-button {
width: 100%;
margin-top: 10px;
padding: 12px;
@@ -300,11 +300,11 @@ canvas {
font-weight: bold;
}
.export-button:hover {
.editor-export-button:hover {
background: #ff8533;
}
.save-button {
.editor-save-button {
width: 100%;
margin-top: 10px;
padding: 12px;
@@ -317,27 +317,11 @@ canvas {
font-weight: bold;
}
.save-button:hover {
.editor-save-button:hover {
background: #16a34a;
}
.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 {
.editor-player-button {
width: 100%;
padding: 12px;
background: #444;
@@ -348,20 +332,20 @@ canvas {
font-size: 14px;
}
.player-button.active {
.editor-player-button.active {
background: #ff6600;
color: black;
}
.player-button:hover {
.editor-player-button:hover {
background: #555;
}
.player-button.active:hover {
.editor-player-button.active:hover {
background: #ff8533;
}
.selected-info {
.editor-selected-info {
background: rgba(255, 102, 0, 0.1);
border: 1px solid #ff6600;
border-radius: 6px;
@@ -369,17 +353,17 @@ canvas {
margin-bottom: 15px;
}
.selected-name {
.editor-selected-name {
font-size: 16px;
margin-bottom: 5px;
}
.selected-index {
.editor-selected-index {
font-size: 14px;
color: #aaa;
}
.no-selection {
.editor-no-selection {
background: rgba(255, 255, 255, 0.05);
border: 1px dashed #555;
border-radius: 6px;
@@ -389,14 +373,14 @@ canvas {
font-style: italic;
}
.controls-help {
.editor-controls-help {
background: #222;
border-radius: 6px;
padding: 15px;
border: 1px solid #444;
}
.controls-help p {
.editor-controls-help p {
margin: 4px 0;
font-size: 12px;
color: #aaa;
@@ -407,11 +391,11 @@ canvas {
font-size: 1.5rem;
}
.upload-section {
.editor-upload-section {
padding: 1.5rem;
}
.drop-zone {
.editor-drop-zone {
padding: 1.5rem 1rem;
}
}
-442
View File
@@ -1,442 +0,0 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { Canvas } from "@react-three/fiber";
import EditorViewer from "@/components/editor/EditorViewer";
import EditorControls from "@/components/editor/EditorControls";
import type {
TransformMode,
MapNode,
SceneData,
ObjectTransform,
} from "@/types/editor";
class HistoryManager {
private history: ObjectTransform[][] = [];
private currentIndex = -1;
private maxSize: number;
constructor(maxSize = 50) {
this.maxSize = maxSize;
}
saveSnapshot(objects: ObjectTransform[]) {
if (this.currentIndex < this.history.length - 1) {
this.history = this.history.slice(0, this.currentIndex + 1);
}
this.history.push(objects.map((obj) => ({ ...obj })));
this.currentIndex = this.history.length - 1;
if (this.history.length > this.maxSize) {
this.history.shift();
this.currentIndex--;
}
}
undo(): ObjectTransform[] | undefined {
if (this.currentIndex > 0) {
this.currentIndex--;
return this.history[this.currentIndex];
}
return undefined;
}
redo(): ObjectTransform[] | undefined {
if (this.currentIndex < this.history.length - 1) {
this.currentIndex++;
return this.history[this.currentIndex];
}
return undefined;
}
getUndoCount(): number {
return this.currentIndex;
}
getRedoCount(): number {
return this.history.length - 1 - this.currentIndex;
}
}
export function EditorPage(): React.JSX.Element {
const [hasMapJson, setHasMapJson] = useState<boolean>(false);
const [isMapLoading, setIsMapLoading] = useState<boolean>(true);
const [sceneData, setSceneData] = useState<SceneData | null>(null);
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [undoCount, setUndoCount] = useState(0);
const [redoCount, setRedoCount] = useState(0);
const [isPlayerMode, setIsPlayerMode] = useState(false);
const historyManager = useRef<HistoryManager>(new HistoryManager(50));
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
const handleTransformModeChange = useCallback((mode: TransformMode) => {
setTransformMode(mode);
}, []);
const applySnapshot = useCallback(
(snapshot: ObjectTransform[]) => {
if (!sceneData) return;
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = prev.mapNodes.map((node, index) => {
const transform = snapshot.find((s) => s.uuid === `node-${index}`);
if (transform) {
return {
...node,
position: [
transform.position.x,
transform.position.y,
transform.position.z,
] as [number, number, number],
rotation: [
transform.rotation.x,
transform.rotation.y,
transform.rotation.z,
] as [number, number, number],
scale: [
transform.scale.x,
transform.scale.y,
transform.scale.z,
] as [number, number, number],
};
}
return node;
});
return { ...prev, mapNodes: newMapNodes };
});
},
[sceneData],
);
const handleUndo = useCallback(() => {
const snapshot = historyManager.current.undo();
if (snapshot) {
applySnapshot(snapshot);
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}
}, [applySnapshot]);
const handleRedo = useCallback(() => {
const snapshot = historyManager.current.redo();
if (snapshot) {
applySnapshot(snapshot);
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}
}, [applySnapshot]);
const handleSaveToServer = useCallback(async () => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
try {
const response = await fetch("/api/save-map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (response.ok) {
alert("Map enregistrée avec succès!");
} else {
alert("Erreur lors de l'enregistrement");
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "map.json";
a.click();
URL.revokeObjectURL(url);
}, [sceneData]);
const handleResetCamera = useCallback(() => {
console.log("Reset camera");
}, []);
const handlePlayerMode = useCallback(() => {
setIsPlayerMode((prev) => !prev);
}, []);
const createSnapshot = useCallback((): ObjectTransform[] => {
if (!sceneData) return [];
return sceneData.mapNodes.map((node, index) => ({
uuid: `node-${index}`,
position: {
x: node.position[0],
y: node.position[1],
z: node.position[2],
},
rotation: {
x: node.rotation[0],
y: node.rotation[1],
z: node.rotation[2],
},
scale: { x: node.scale[0], y: node.scale[1], z: node.scale[2] },
}));
}, [sceneData]);
const handleTransformStart = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot());
}, [createSnapshot, sceneData]);
const handleTransformEnd = useCallback(() => {
if (!sceneData) return;
historyManager.current.saveSnapshot(createSnapshot());
setUndoCount(historyManager.current.getUndoCount());
setRedoCount(historyManager.current.getRedoCount());
}, [createSnapshot, sceneData]);
const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => {
if (!sceneData) return;
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
});
},
[sceneData],
);
useEffect(() => {
const loadMapData = async (): Promise<void> => {
setIsMapLoading(true);
try {
const response = await fetch("/map.json");
if (!response.ok) {
setHasMapJson(false);
setIsMapLoading(false);
return;
}
const mapNodes: MapNode[] = await response.json();
const models = new Map<string, string>();
const uniqueModelNames = [
...new Set(mapNodes.map((n: MapNode) => n.name)),
];
console.log("Unique model names in map:", uniqueModelNames);
for (const modelName of uniqueModelNames) {
try {
const modelUrl = `/models/${modelName}/model.gltf`;
const modelResponse = await fetch(modelUrl);
if (modelResponse.ok) {
const contentType =
modelResponse.headers.get("content-type") || "";
if (
contentType.includes("gltf") ||
contentType.includes("json") ||
contentType.includes("model")
) {
const text = await modelResponse.text();
if (
text.includes('"glTF"') ||
text.includes('"scene"') ||
text.includes('"nodes"')
) {
models.set(modelName, modelUrl);
} else {
console.warn(
`Invalid GLTF content for ${modelName}:`,
text.substring(0, 100),
);
}
} else {
console.warn(
`Invalid Content-Type for ${modelName}:`,
contentType,
);
}
}
} catch {
/* empty */
}
}
console.log("Loaded models:", Array.from(models.keys()));
setSceneData({ mapNodes, models });
setHasMapJson(true);
} catch (error) {
console.error("Error loading map data:", error);
setHasMapJson(false);
} finally {
setIsMapLoading(false);
}
};
loadMapData();
}, []);
const handleFolderUpload = async (
event: React.ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const files = event.target.files;
if (!files) return;
const fileMap = new Map<string, File>();
for (const file of Array.from(files)) {
const webkitRelativePath =
(file as File & { webkitRelativePath?: string }).webkitRelativePath ||
"/" + file.name;
fileMap.set(webkitRelativePath, file);
}
const mapFile = fileMap.get("/map.json");
if (!mapFile) {
alert("Fichier map.json manquant à la racine du dossier");
return;
}
try {
const mapText = await mapFile.text();
const mapNodes = JSON.parse(mapText);
const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) {
const modelMatch = path && path.match(/^\/models\/(.+)\/model\.gltf$/);
if (modelMatch && modelMatch[1]) {
const modelName = modelMatch[1];
const blobUrl = URL.createObjectURL(file);
models.set(modelName, blobUrl);
}
}
setSceneData({ mapNodes, models });
setHasMapJson(true);
} catch (error) {
console.error("Error processing upload:", error);
alert("Erreur lors du traitement du dossier");
}
};
if (isMapLoading) {
return (
<div className="editor-container">
<div className="editor-loading">
<h2>Chargement de l'éditeur...</h2>
<p>Vérification de map.json dans public/</p>
</div>
</div>
);
}
if (!hasMapJson) {
return (
<div className="editor-container">
<div className="editor-error">
<h2>Erreur : map.json introuvable</h2>
<p>
Le fichier map.json est requis dans le dossier <code>public/</code>.
</p>
<div className="upload-section">
<h3>Télécharger un dossier contenant map.json</h3>
<label className="drop-zone">
<input
type="file"
className="folder-input"
onChange={handleFolderUpload}
/>
Choisir un dossier contenant map.json
</label>
<div className="folder-structure">
<h4>Structure requise :</h4>
<pre>
public/ <strong>map.json</strong> (à la racine) models/
arbre/ model.glb building/ model.glb ...
</pre>
</div>
</div>
</div>
</div>
);
}
return (
<div className="editor-container">
<Canvas
camera={{ position: [0, 50, 100], fov: 50 }}
style={{ width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor("#1e293b");
}}
>
<EditorViewer
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
/>
</Canvas>
{sceneData && (
<EditorControls
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
onRedo={handleRedo}
onExportJson={handleExportJson}
onSaveToServer={handleSaveToServer}
onResetCamera={handleResetCamera}
onPlayerMode={handlePlayerMode}
isPlayerMode={isPlayerMode}
/>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useCallback, useState } from "react";
import { Canvas } from "@react-three/fiber";
import { EditorControls } from "@/features/editor/components/EditorControls";
import { useEditorHistory } from "@/features/editor/hooks/useEditorHistory";
import { useEditorSceneData } from "@/features/editor/hooks/useEditorSceneData";
import { EditorScene } from "@/features/editor/scene/EditorScene";
import type { MapNode, TransformMode } from "@/types/editor";
export function EditorPage(): React.JSX.Element {
const {
hasMapJson,
isMapLoading,
sceneData,
setSceneData,
handleFolderUpload,
} = useEditorSceneData();
const [selectedNodeIndex, setSelectedNodeIndex] = useState<number | null>(
null,
);
const [hoveredNodeIndex, setHoveredNodeIndex] = useState<number | null>(null);
const [transformMode, setTransformMode] =
useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false);
const {
undoCount,
redoCount,
handleUndo,
handleRedo,
handleTransformStart,
handleTransformEnd,
} = useEditorHistory(sceneData, setSceneData);
const handleSelectNode = useCallback((index: number | null) => {
setSelectedNodeIndex(index);
}, []);
const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index);
}, []);
const handleTransformModeChange = useCallback((mode: TransformMode) => {
setTransformMode(mode);
}, []);
const handleSaveToServer = useCallback(async () => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
try {
const response = await fetch("/api/save-map", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (response.ok) {
alert("Map enregistrée avec succès!");
} else {
alert("Erreur lors de l'enregistrement");
}
} catch (err) {
console.error("Error saving map:", err);
alert("Erreur lors de l'enregistrement");
}
}, [sceneData]);
const handleExportJson = useCallback(() => {
if (!sceneData) return;
const json = JSON.stringify(sceneData.mapNodes, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "map.json";
a.click();
URL.revokeObjectURL(url);
}, [sceneData]);
const handlePlayerMode = useCallback(() => {
setIsPlayerMode((prev) => !prev);
}, []);
const handleNodeTransform = useCallback(
(nodeIndex: number, updatedNode: MapNode) => {
setSceneData((prev) => {
if (!prev) return null;
const newMapNodes = [...prev.mapNodes];
newMapNodes[nodeIndex] = updatedNode;
return { ...prev, mapNodes: newMapNodes };
});
},
[setSceneData],
);
if (isMapLoading) {
return (
<div className="editor-container">
<div className="editor-loading">
<h2>Chargement de l'éditeur...</h2>
<p>Vérification de map.json dans public/</p>
</div>
</div>
);
}
if (!hasMapJson) {
return (
<div className="editor-container">
<div className="editor-error">
<h2>Erreur : map.json introuvable</h2>
<p>
Le fichier map.json est requis dans le dossier <code>public/</code>.
</p>
<div className="editor-upload-section">
<h3>Télécharger un dossier contenant map.json</h3>
<label className="editor-drop-zone">
<input
type="file"
className="editor-folder-input"
onChange={handleFolderUpload}
multiple
{...{ webkitdirectory: "" }}
/>
Choisir un dossier contenant map.json
</label>
<div className="editor-folder-structure">
<h4>Structure requise :</h4>
<pre>
public/ <strong>map.json</strong> (à la racine) models/
arbre/ model.gltf building/ model.gltf
...
</pre>
</div>
</div>
</div>
</div>
);
}
return (
<div className="editor-container">
<Canvas
camera={{ position: [0, 50, 100], fov: 50 }}
style={{ width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor("#1e293b");
}}
>
<EditorScene
sceneData={sceneData!}
selectedNodeIndex={selectedNodeIndex}
onSelectNode={handleSelectNode}
hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode}
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd}
onNodeTransform={handleNodeTransform}
onUndo={handleUndo}
onRedo={handleRedo}
isPlayerMode={isPlayerMode}
/>
</Canvas>
{sceneData && (
<EditorControls
transformMode={transformMode}
onTransformModeChange={handleTransformModeChange}
selectedNodeIndex={selectedNodeIndex}
nodesCount={sceneData.mapNodes.length}
selectedNodeName={
selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex]
? sceneData.mapNodes[selectedNodeIndex].name || null
: null
}
undoCount={undoCount}
redoCount={redoCount}
onUndo={handleUndo}
onRedo={handleRedo}
onExportJson={handleExportJson}
onSaveToServer={handleSaveToServer}
onPlayerMode={handlePlayerMode}
isPlayerMode={isPlayerMode}
/>
)}
</div>
);
}
-11
View File
@@ -12,14 +12,3 @@ export interface SceneData {
}
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[];
}
+53
View File
@@ -0,0 +1,53 @@
import type { MapNode, SceneData } from "@/types/editor";
const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAME = "model.gltf";
export async function loadMapSceneData(): Promise<SceneData | null> {
const response = await fetch(MAP_JSON_PATH);
if (!response.ok) {
return null;
}
const mapNodes: MapNode[] = await response.json();
return createSceneData(mapNodes);
}
export async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> {
const models = await loadMapModelUrls(mapNodes);
return { mapNodes, models };
}
async function loadMapModelUrls(
mapNodes: MapNode[],
): Promise<Map<string, string>> {
const models = new Map<string, string>();
const uniqueModelNames = [...new Set(mapNodes.map((node) => node.name))];
for (const modelName of uniqueModelNames) {
const modelUrl = `/models/${modelName}/${MODEL_FILE_NAME}`;
try {
const modelResponse = await fetch(modelUrl);
if (!modelResponse.ok) continue;
const text = await modelResponse.text();
if (isGltfContent(text)) {
models.set(modelName, modelUrl);
}
} catch {
/* Missing models are expected while editing incomplete maps. */
}
}
return models;
}
function isGltfContent(text: string): boolean {
return (
text.includes('"glTF"') ||
text.includes('"scene"') ||
text.includes('"nodes"')
);
}