import { useCallback, useState } from "react"; import { Canvas } from "@react-three/fiber"; import { EditorControls } from "@/components/editor/EditorControls"; import { EditorScene } from "@/components/editor/scene/EditorScene"; import type { EditorCinematicPreviewRequest } from "@/components/editor/scene/EditorScene"; import { SceneLoadingOverlay } from "@/components/ui/SceneLoadingOverlay"; import { Subtitles } from "@/components/ui/Subtitles"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; import type { HierarchicalMapNode, MapNode, SceneData, TransformMode, } from "@/types/editor/editor"; import type { SceneLoadingState } from "@/types/world/sceneLoading"; import { logger } from "@/utils/core/Logger"; const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement"; const DEFAULT_NEW_NODE_NAME = "new-model"; function serializeMapNodes(sceneData: SceneData): string { const mapPayload = sceneData.mapTree ? mergeFlatNodeTransformsIntoTree(sceneData) : sceneData.mapNodes.map(removeEditorMetadata); return JSON.stringify(mapPayload, null, 2); } function createSourcePathKey(sourcePath: readonly number[]): string { return sourcePath.join("."); } function removeEditorMetadata(node: MapNode): MapNode { return { ...(node.id ? { id: node.id } : {}), name: node.name, type: node.type, position: node.position, rotation: node.rotation, scale: node.scale, }; } function mergeFlatNodeTransformsIntoTree( sceneData: SceneData, ): HierarchicalMapNode | HierarchicalMapNode[] { const nodesBySourcePath = new Map(); for (const node of sceneData.mapNodes) { if (!node.sourcePath) continue; nodesBySourcePath.set(createSourcePathKey(node.sourcePath), node); } const cloneNode = ( node: HierarchicalMapNode, path: number[], ): HierarchicalMapNode => { const updatedNode = nodesBySourcePath.get(createSourcePathKey(path)); const nextNode: HierarchicalMapNode = { ...((updatedNode?.id ?? node.id) ? { id: updatedNode?.id ?? node.id } : {}), name: node.name, type: node.type, position: updatedNode?.position ?? node.position, rotation: updatedNode?.rotation ?? node.rotation, scale: updatedNode?.scale ?? node.scale, }; if (node.role) { nextNode.role = node.role; } if (node.children) { nextNode.children = node.children.map((child, index) => cloneNode(child, [...path, index]), ); } return nextNode; }; const mapTree = sceneData.mapTree; if (!mapTree) { return sceneData.mapNodes.map(removeEditorMetadata); } if (Array.isArray(mapTree)) { return mapTree.map((node, index) => cloneNode(node, [index])); } return cloneNode(mapTree, []); } function cloneMapTree( mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): HierarchicalMapNode | HierarchicalMapNode[] { return JSON.parse(JSON.stringify(mapTree)) as | HierarchicalMapNode | HierarchicalMapNode[]; } function collectEditableMapNodes( mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): MapNode[] { const nodes: MapNode[] = []; function visit(node: HierarchicalMapNode, path: number[]): void { if (node.role !== "group" && node.type !== "Mesh") { nodes.push({ ...(node.id ? { id: node.id } : {}), name: node.name, position: node.position, rotation: node.rotation, scale: node.scale, sourcePath: path, type: node.type, }); } node.children?.forEach((child, index) => visit(child, [...path, index])); } if (Array.isArray(mapTree)) { mapTree.forEach((node, index) => visit(node, [index])); } else { visit(mapTree, []); } return nodes; } function updateTreeNodeAtPath( mapTree: HierarchicalMapNode | HierarchicalMapNode[], path: number[], update: (node: HierarchicalMapNode) => HierarchicalMapNode, ): HierarchicalMapNode | HierarchicalMapNode[] { const nextTree = cloneMapTree(mapTree); const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree]; const targetIndex = path[path.length - 1] ?? 0; const isRootTarget = Array.isArray(nextTree) ? path.length === 1 : path.length === 0; if (isRootTarget) { const targetNode = rootNodes[targetIndex]; if (targetNode) { rootNodes[targetIndex] = update(targetNode); } return nextTree; } const parentPath = path.slice(0, -1); let parent = Array.isArray(nextTree) ? rootNodes[parentPath[0] ?? 0] : rootNodes[0]; const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath; for (const index of childPath) { parent = parent?.children?.[index]; } if (!parent?.children?.[targetIndex]) return nextTree; parent.children[targetIndex] = update(parent.children[targetIndex]); return nextTree; } function removeTreeNodeAtPath( mapTree: HierarchicalMapNode | HierarchicalMapNode[], path: number[], ): HierarchicalMapNode | HierarchicalMapNode[] { const nextTree = cloneMapTree(mapTree); const rootNodes = Array.isArray(nextTree) ? nextTree : [nextTree]; const targetIndex = path[path.length - 1]; if (targetIndex === undefined) return nextTree; if (Array.isArray(nextTree) && path.length === 1) { nextTree.splice(targetIndex, 1); return nextTree; } const parentPath = path.slice(0, -1); let parent = Array.isArray(nextTree) ? rootNodes[parentPath[0] ?? 0] : rootNodes[0]; const childPath = Array.isArray(nextTree) ? parentPath.slice(1) : parentPath; for (const index of childPath) { parent = parent?.children?.[index]; } parent?.children?.splice(targetIndex, 1); return nextTree; } function updateSceneDataTree( sceneData: SceneData, mapTree: HierarchicalMapNode | HierarchicalMapNode[], ): SceneData { return { ...sceneData, mapNodes: collectEditableMapNodes(mapTree), mapTree, }; } function findNodePathByName( mapTree: HierarchicalMapNode | HierarchicalMapNode[], name: string, ): number[] | null { function visit(node: HierarchicalMapNode, path: number[]): number[] | null { if (node.name === name) return path; for (let index = 0; index < (node.children?.length ?? 0); index++) { const child = node.children?.[index]; if (!child) continue; const result = visit(child, [...path, index]); if (result) return result; } return null; } if (Array.isArray(mapTree)) { for (let index = 0; index < mapTree.length; index++) { const node = mapTree[index]; if (!node) continue; const result = visit(node, [index]); if (result) return result; } return null; } return visit(mapTree, []); } function addTreeNode( mapTree: HierarchicalMapNode | HierarchicalMapNode[], node: HierarchicalMapNode, ): HierarchicalMapNode | HierarchicalMapNode[] { const blockingPath = findNodePathByName(mapTree, "blocking"); if (!blockingPath) return mapTree; return updateTreeNodeAtPath(mapTree, blockingPath, (blockingNode) => ({ ...blockingNode, children: [...(blockingNode.children ?? []), node], })); } function createNewMapNode(name: string): HierarchicalMapNode { const safeName = name.trim() || DEFAULT_NEW_NODE_NAME; return { name: safeName, type: "Object3D", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], children: [ { name: safeName, type: "Mesh", position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], }, ], }; } export function EditorPage(): React.JSX.Element { const { hasMapJson, isMapLoading, sceneData, setSceneData, handleFolderUpload, } = useEditorSceneData(); const [selectedNodeIndex, setSelectedNodeIndex] = useState( null, ); const [selectedNodeIndexes, setSelectedNodeIndexes] = useState([]); const [hoveredNodeIndex, setHoveredNodeIndex] = useState(null); const [transformMode, setTransformMode] = useState("translate"); const [isPlayerMode, setIsPlayerMode] = useState(false); const [isSelectionLocked, setIsSelectionLocked] = useState(false); const [snapToTerrain, setSnapToTerrain] = useState(true); const [newNodeName, setNewNodeName] = useState(DEFAULT_NEW_NODE_NAME); const [lockTerrainSelection, setLockTerrainSelection] = useState(true); const [resetCameraRequest, setResetCameraRequest] = useState(0); const [snapAllToTerrainRequest, setSnapAllToTerrainRequest] = useState(0); const [focusSelectedCameraRequest, setFocusSelectedCameraRequest] = useState(0); const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">( "home", ); const editorLoadingState: SceneLoadingState = isMapLoading ? { currentStep: "Récupération blocking", progress: 0.08, status: "loading" as const, } : { currentStep: "Gameplay prêt", progress: 1, status: "ready" as const, }; const [cinematicPreviewRequest, setCinematicPreviewRequest] = useState(null); const { undoCount, redoCount, handleUndo, handleRedo, handleTransformStart, handleTransformEnd, } = useEditorHistory(sceneData, setSceneData); const handleSelectNode = useCallback((index: number | null) => { setSelectedNodeIndex(index); setSelectedNodeIndexes(index === null ? [] : [index]); if (index !== null) { setCameraViewMode("object"); return; } setCameraViewMode("home"); setResetCameraRequest((request) => request + 1); }, []); const handleToggleNodeSelection = useCallback((index: number) => { setSelectedNodeIndexes((currentIndexes) => { const isSelected = currentIndexes.includes(index); const nextIndexes = isSelected ? currentIndexes.filter((item) => item !== index) : [...currentIndexes, index]; setSelectedNodeIndex(nextIndexes.at(-1) ?? null); if (nextIndexes.length > 0) { setCameraViewMode("object"); } else { setCameraViewMode("home"); setResetCameraRequest((request) => request + 1); } return nextIndexes; }); }, []); const handleClearSelection = useCallback(() => { setSelectedNodeIndex(null); setSelectedNodeIndexes([]); setCameraViewMode("home"); setResetCameraRequest((request) => request + 1); }, []); const handleSelectionLockToggle = useCallback(() => { setIsSelectionLocked((locked) => !locked); }, []); const handleSnapToTerrainToggle = useCallback(() => { setSnapToTerrain((enabled) => !enabled); }, []); const handleSnapAllToTerrainRequest = useCallback(() => { setSnapAllToTerrainRequest((request) => request + 1); }, []); const handleSnapAllToTerrain = useCallback( (mapNodes: MapNode[]) => { setSceneData((prev) => { if (!prev) return null; const nextSceneData = { ...prev, mapNodes }; if (!prev.mapTree) return nextSceneData; const mapTree = mergeFlatNodeTransformsIntoTree(nextSceneData); return updateSceneDataTree(nextSceneData, mapTree); }); }, [setSceneData], ); const handleNewNodeNameChange = useCallback((value: string) => { setNewNodeName(value); }, []); const handleTerrainSelectionLockChange = useCallback( (locked: boolean) => { setLockTerrainSelection(locked); if (!locked) return; setSelectedNodeIndex((currentIndex) => { if (currentIndex === null) return null; const selectedNode = sceneData?.mapNodes[currentIndex]; if (selectedNode?.name === "terrain") { setSelectedNodeIndexes((indexes) => indexes.filter( (index) => sceneData?.mapNodes[index]?.name !== "terrain", ), ); return null; } setSelectedNodeIndexes((indexes) => indexes.filter( (index) => sceneData?.mapNodes[index]?.name !== "terrain", ), ); return currentIndex; }); }, [sceneData], ); const handleHoverNode = useCallback((index: number | null) => { setHoveredNodeIndex(index); }, []); const handleTransformModeChange = useCallback((mode: TransformMode) => { setTransformMode(mode); }, []); const handleSaveToServer = useCallback(async () => { if (!sceneData) return; const json = serializeMapNodes(sceneData); 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(SAVE_ERROR_MESSAGE); } } catch (err) { console.error("Error saving map:", err); alert(SAVE_ERROR_MESSAGE); } }, [sceneData]); const handleExportJson = useCallback(() => { if (!sceneData) return; const json = serializeMapNodes(sceneData); 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(); window.setTimeout(() => URL.revokeObjectURL(url), 0); }, [sceneData]); const handlePlayerMode = useCallback(() => { setIsPlayerMode((prev) => !prev); }, []); const handleCameraAction = useCallback(() => { if (selectedNodeIndex !== null && cameraViewMode === "home") { setFocusSelectedCameraRequest((request) => request + 1); setCameraViewMode("object"); return; } setResetCameraRequest((request) => request + 1); setCameraViewMode("home"); }, [cameraViewMode, selectedNodeIndex]); const handlePreviewCinematic = useCallback( (cinematic: CinematicDefinition) => { setCinematicPreviewRequest({ id: window.crypto.randomUUID(), cinematic, }); }, [], ); const handleCinematicPreviewComplete = useCallback(() => { setCinematicPreviewRequest(null); }, []); const handleNodeTransform = useCallback( (nodeIndex: number, updatedNode: MapNode) => { setSceneData((prev) => { if (!prev) return null; const currentNode = prev.mapNodes[nodeIndex]; if (!currentNode) return prev; if (!prev.mapTree || !currentNode.sourcePath) { const mapNodes = [...prev.mapNodes]; mapNodes[nodeIndex] = updatedNode; return { ...prev, mapNodes }; } const mapTree = updateTreeNodeAtPath( prev.mapTree, currentNode.sourcePath, (node) => ({ ...node, position: updatedNode.position, rotation: updatedNode.rotation, scale: updatedNode.scale, }), ); return updateSceneDataTree(prev, mapTree); }); }, [setSceneData], ); const handleSelectedScaleChange = useCallback( (axis: 0 | 1 | 2, value: number) => { if (selectedNodeIndex === null || Number.isNaN(value)) return; setSceneData((prev) => { if (!prev) return null; const currentNode = prev.mapNodes[selectedNodeIndex]; if (!currentNode) return prev; const nextScale = [...currentNode.scale] as [number, number, number]; nextScale[axis] = value; if (!prev.mapTree || !currentNode.sourcePath) { const mapNodes = [...prev.mapNodes]; mapNodes[selectedNodeIndex] = { ...currentNode, scale: nextScale }; return { ...prev, mapNodes }; } const mapTree = updateTreeNodeAtPath( prev.mapTree, currentNode.sourcePath, (node) => ({ ...node, scale: nextScale }), ); return updateSceneDataTree(prev, mapTree); }); }, [selectedNodeIndex, setSceneData], ); const handleAddNode = useCallback(() => { setSceneData((prev) => { if (!prev) return null; if (!prev.mapTree) { const newNode = createNewMapNode(newNodeName); const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)]; setSelectedNodeIndex(mapNodes.length - 1); setSelectedNodeIndexes([mapNodes.length - 1]); return { ...prev, mapNodes }; } const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName)); const nextSceneData = updateSceneDataTree(prev, mapTree); setSelectedNodeIndex(nextSceneData.mapNodes.length - 1); setSelectedNodeIndexes([nextSceneData.mapNodes.length - 1]); return nextSceneData; }); }, [newNodeName, setSceneData]); const handleDeleteSelectedNode = useCallback(() => { if (selectedNodeIndex === null) return; setSceneData((prev) => { if (!prev) return null; const currentNode = prev.mapNodes[selectedNodeIndex]; if (!currentNode) return prev; if (!prev.mapTree || !currentNode.sourcePath) { setSelectedNodeIndex(null); setSelectedNodeIndexes([]); return { ...prev, mapNodes: prev.mapNodes.filter( (_node, index) => index !== selectedNodeIndex, ), }; } const mapTree = removeTreeNodeAtPath( prev.mapTree, currentNode.sourcePath, ); setSelectedNodeIndex(null); setSelectedNodeIndexes([]); return updateSceneDataTree(prev, mapTree); }); }, [selectedNodeIndex, setSceneData]); if (isMapLoading) { return (
); } if (!hasMapJson) { return (

Erreur : map.json introuvable

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

Télécharger un dossier contenant map.json

Structure requise :

                public/ ├── map.json (à la racine) └── models/
                ├── arbre/ │ └── model.glb ├── building/ │ └── model.gltf └──
                ...
              
); } return (
{ gl.setClearColor("#050505"); const canvas = gl.domElement; const handleContextLost = (event: Event) => { event.preventDefault(); logger.error("WebGL", "Context lost - GPU resources exhausted"); }; const handleContextRestored = () => { logger.info("WebGL", "Context restored"); }; canvas.addEventListener("webglcontextlost", handleContextLost); canvas.addEventListener( "webglcontextrestored", handleContextRestored, ); }} > {sceneData && ( )}
); }