From c2b16434fbc9cad2f6c5c5ecd28528be880a1765 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Wed, 27 May 2026 08:30:54 +0200 Subject: [PATCH] feat(editor): edit hierarchical map nodes --- docs/technical/editor.md | 29 +- docs/user/editor.md | 39 ++- src/components/editor/EditorControls.tsx | 99 +++++- src/components/editor/scene/EditorMap.tsx | 19 +- src/components/editor/scene/EditorScene.tsx | 6 + src/hooks/editor/useEditorHistory.ts | 75 ++++- src/hooks/three/useTerrainHeight.ts | 10 + src/index.css | 46 +++ src/pages/editor/page.tsx | 285 +++++++++++++++++- src/types/editor/editor.ts | 7 +- src/utils/editor/loadEditorScene.ts | 6 +- src/utils/map/loadMapSceneData.ts | 99 +++++- src/utils/map/mapNodeValidation.ts | 24 +- src/world/GameMap.tsx | 16 +- .../map-instancing/InstancedMapAsset.tsx | 19 +- src/world/vegetation/InstancedVegetation.tsx | 25 +- 16 files changed, 740 insertions(+), 64 deletions(-) diff --git a/docs/technical/editor.md b/docs/technical/editor.md index 688c46b..483b76f 100644 --- a/docs/technical/editor.md +++ b/docs/technical/editor.md @@ -4,7 +4,7 @@ This document describes the map editor that exists in the current codebase. ## Purpose -The editor is a React route used to inspect and adjust the `public/map.json` scene data from inside the La-Fabrik app. It shares the same `MapNode` data format as the game scene and uses React Three Fiber for rendering. +The editor is a React route used to inspect and adjust the current hierarchical `public/map.json` scene data from inside the La-Fabrik app. It exposes editable object nodes as a flat list for UI selection, while preserving and saving the full map tree. ## Routing @@ -72,7 +72,7 @@ src/ `src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation. -`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json` and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`. +`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json`, validates the hierarchical payload, exposes editable nodes with their tree path, and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`. `src/utils/editor/loadEditorScene.ts` contains editor-only upload handling for user-selected folders. @@ -90,19 +90,9 @@ interface MapNode { } ``` -`public/map.json` is expected to be a `MapNode[]`. +`public/map.json` may be hierarchical. The editor keeps the hierarchy in `SceneData.mapTree` and stores editable entries in `SceneData.mapNodes` with a `path` back to the real tree node. -```json -[ - { - "name": "pylone", - "type": "Mesh", - "position": [0, 5, 0], - "rotation": [0, 1.57, 0], - "scale": [1, 1, 1] - } -] -``` +Group nodes use `role: "group"`; editable nodes keep `name`, `type`, `position`, `rotation`, and `scale`. Each node `name` maps to a model folder: @@ -124,7 +114,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback 4. If `/map.json` is missing, the page displays a folder-upload flow. 5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load. 6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`. -7. `EditorControls` exposes transform mode, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors. +7. `EditorControls` exposes transform mode, terrain snap, add/delete node, precise scale inputs, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors. ## Controls @@ -136,6 +126,9 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback - `T`: translate mode. - `R`: rotate mode. - `S`: scale mode. +- Snap terrain on move: enabled by default and applied when releasing a translated object. +- Add node: creates a fallback cube under `blocking` using the requested model folder name. +- Delete selected node: removes the editable node from the preserved map tree. - `Ctrl+Z` or `Cmd+Z`: undo. - `Ctrl+Y` or `Cmd+Y`: redo. - `WASD`, `ZQSD`, or arrow keys: move in player-controller mode. @@ -146,10 +139,10 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback The editor supports two output paths: -- Export JSON downloads the current `MapNode[]` as `map.json`. -- Save to Server posts the current `MapNode[]` to `/api/save-map`. +- Export JSON downloads the current hierarchical map tree as `map.json`. +- Save to Server posts the current hierarchical map tree to `/api/save-map`. -The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It writes to `public/map.json` and enforces a maximum payload size. +The dev-only `/api/save-map` endpoint is implemented by the Vite plugin in `vite.config.ts`. It validates the payload through the shared map parser, writes to `public/map.json`, and enforces a maximum payload size. ## Editor Loading Overlay diff --git a/docs/user/editor.md b/docs/user/editor.md index 58091f8..66d9d56 100644 --- a/docs/user/editor.md +++ b/docs/user/editor.md @@ -6,7 +6,7 @@ The map editor is available at `/editor`. It is a browser-based tool for editing Use the editor when you need to: -- move, rotate, or scale objects from `public/map.json` +- move, rotate, scale, add, or delete objects from `public/map.json` - inspect the raw JSON generated by the editor - preview and edit cinematics from `public/cinematics.json` - create, preview, and validate dialogue entries from `public/sounds/dialogue/dialogues.json` @@ -14,13 +14,13 @@ Use the editor when you need to: The map editor reads the same map data as the runtime scene: -- `public/map.json` contains the object list. +- `public/map.json` contains the current hierarchical runtime map. - `public/models/{name}/model.glb` contains the matching 3D model for each object name. `model.gltf` is still supported as a fallback during migration. - Missing models are displayed as gray fallback cubes, so incomplete maps remain editable. ## Map Node Format -Each entry in `public/map.json` represents one object: +`public/map.json` is hierarchical. Group nodes such as `Scene`, `blocking`, `vegetation`, or `agriculture` organize the map. Editable object nodes still use the same transform fields: | Field | Description | | ---------- | ------------------------------------------------- | @@ -47,9 +47,22 @@ Only the `Editor` group is open by default. Open the other groups when you need 2. Click an object in the scene to select it. 3. Choose a transform mode: translate, rotate, or scale. 4. Drag the transform gizmo in the 3D view. -5. Check the JSON inspector if you need exact values. -6. Use undo or redo if the transform is not correct. -7. Export the JSON or save it to the dev server. +5. Keep `Snap terrain on move` enabled when placing objects on the terrain. +6. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough. +7. Check the JSON inspector if you need exact values. +8. Use undo or redo if the transform is not correct. +9. Export the JSON or save it to the dev server. + +## Adding And Deleting Nodes + +Use `Add Node` to create a new editable object under the `blocking` group. + +- The new object starts as a fallback cube at `[0, 0, 0]`. +- Name it with the model folder name you want, for example `maison1`. +- If `public/models/{name}/model.glb` or `model.gltf` exists, saving and reloading will display the matching model. +- If no matching model exists, the node stays editable as a gray cube. + +Use the trash button in `Selection` to delete the selected node from the map tree. ## Controls @@ -76,6 +89,14 @@ The `Selection` section shows the selected object name and its index in `public/ - Click empty space or press `Esc` to clear the selection. - Use the `X` button to clear the selection explicitly. - Use the `Lock` button to protect the current selection while editing. +- Use the scale fields to edit X/Y/Z scale precisely. +- Use the trash button to remove the selected object. + +## Terrain Snapping + +`Snap terrain on move` is enabled by default. When you move an object and release the transform gizmo, the editor samples the terrain height at the object's X/Z position and updates its Y position. + +This is intended for map objects that should sit on the ground. Disable it when you intentionally need a floating object. When selection is locked: @@ -90,7 +111,7 @@ The `Lock view` action switches the editor into a movement mode closer to the ru ## JSON Inspector -The `JSON` section shows the raw map data that will be exported or saved: +The `JSON` section shows the editable node data: - When no object is selected, it shows the full map node list. - When an object is selected, it highlights the JSON lines for that object. @@ -101,11 +122,11 @@ Use it to verify exact numeric transform values before saving or exporting. The ### Export JSON -`Export JSON` downloads the current map node list as `map.json`. Use this when you want to manually replace `public/map.json`. +`Export JSON` downloads the current hierarchical map tree as `map.json`. Use this when you want to manually replace `public/map.json`. ### Save To Server -`Save to server` is available only during local development. It writes the edited map back to `public/map.json` through the Vite dev-server endpoint. +`Save to server` is available only during local development. It writes the edited hierarchical map back to `public/map.json` through the Vite dev-server endpoint. The button is hidden in production builds because production persistence is not implemented. diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index e9b099d..84a0b2e 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -8,9 +8,11 @@ import { Lock, MousePointer2, Move3D, + Plus, Redo2, RotateCw, Save, + Trash2, Undo2, Unlock, X, @@ -19,18 +21,27 @@ import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinemati import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel"; import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel"; import type { CinematicDefinition } from "@/types/cinematics/cinematics"; -import type { MapNode, TransformMode } from "@/types/editor/editor"; +import type { EditableMapNode, TransformMode } from "@/types/editor/editor"; +import type { Vector3Tuple } from "@/types/three/three"; interface EditorControlsProps { transformMode: TransformMode; onTransformModeChange: (mode: TransformMode) => void; selectedNodeIndex: number | null; - mapNodes: MapNode[]; + mapNodes: EditableMapNode[]; nodesCount: number; selectedNodeName: string | null; + selectedNodeScale: Vector3Tuple | null; isSelectionLocked: boolean; onSelectionLockToggle: () => void; onClearSelection: () => void; + snapToTerrain: boolean; + onSnapToTerrainToggle: () => void; + newNodeName: string; + onNewNodeNameChange: (value: string) => void; + onAddNode: () => void; + onDeleteSelectedNode: () => void; + onSelectedScaleChange: (axis: 0 | 1 | 2, value: number) => void; undoCount: number; redoCount: number; onUndo: () => void; @@ -90,9 +101,17 @@ export function EditorControls({ mapNodes, nodesCount, selectedNodeName, + selectedNodeScale, isSelectionLocked, onSelectionLockToggle, onClearSelection, + snapToTerrain, + onSnapToTerrainToggle, + newNodeName, + onNewNodeNameChange, + onAddNode, + onDeleteSelectedNode, + onSelectedScaleChange, undoCount, redoCount, onUndo, @@ -181,6 +200,15 @@ export function EditorControls({ {redoCount} + +
+
+ {selectedNodeScale ? ( +
+ {selectedNodeScale.map((value, axis) => ( + + ))} +
+ ) : null} ) : (
@@ -239,6 +295,32 @@ export function EditorControls({ )}
+
+
+

Add Node

+
+ +
+ onNewNodeNameChange(event.target.value)} + placeholder="model-folder-name" + /> + +
+
+
; } { @@ -378,7 +460,14 @@ function formatMapNodesWithRanges(mapNodes: MapNode[]): { const ranges: Array<{ start: number; end: number }> = []; mapNodes.forEach((node, index) => { - const objectLines = JSON.stringify(node, null, 2) + const serializableNode = { + name: node.name, + position: node.position, + rotation: node.rotation, + scale: node.scale, + type: node.type, + }; + const objectLines = JSON.stringify(serializableNode, null, 2) .split("\n") .map((line) => ` ${line}`); diff --git a/src/components/editor/scene/EditorMap.tsx b/src/components/editor/scene/EditorMap.tsx index b6eeedc..48c379c 100644 --- a/src/components/editor/scene/EditorMap.tsx +++ b/src/components/editor/scene/EditorMap.tsx @@ -5,6 +5,7 @@ import * as THREE from "three"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; +import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight"; import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor"; interface EditorMapProps { @@ -15,6 +16,7 @@ interface EditorMapProps { hoveredNodeIndex: number | null; onHoverNode: (index: number | null) => void; transformMode: TransformMode; + snapToTerrain: boolean; onTransformStart: () => void; onTransformEnd: () => void; onNodeTransform: (nodeIndex: number, transform: MapNode) => void; @@ -138,11 +140,13 @@ export function EditorMap({ hoveredNodeIndex, onHoverNode, transformMode, + snapToTerrain, onTransformStart, onTransformEnd, onNodeTransform, }: EditorMapProps): React.JSX.Element { const objectsMapRef = useRef>(new Map()); + const terrainHeight = useTerrainHeightSampler(); const handleTransformMouseDown = () => { onTransformStart(); @@ -154,9 +158,22 @@ export function EditorMap({ if (!obj) return; const node = sceneData.mapNodes[selectedNodeIndex]; if (node) { + const terrainY = snapToTerrain + ? terrainHeight.getHeight(obj.position.x, obj.position.z) + : null; + if (terrainY !== null && transformMode === "translate") { + obj.position.y = terrainY; + } + const updatedNode: MapNode = { ...node, - position: [obj.position.x, obj.position.y, obj.position.z], + position: [ + obj.position.x, + terrainY !== null && transformMode === "translate" + ? terrainY + : obj.position.y, + obj.position.z, + ], rotation: [obj.rotation.x, obj.rotation.y, obj.rotation.z], scale: [obj.scale.x, obj.scale.y, obj.scale.z], }; diff --git a/src/components/editor/scene/EditorScene.tsx b/src/components/editor/scene/EditorScene.tsx index c9d585c..4749b4a 100644 --- a/src/components/editor/scene/EditorScene.tsx +++ b/src/components/editor/scene/EditorScene.tsx @@ -4,6 +4,7 @@ import { useThree } from "@react-three/fiber"; import gsap from "gsap"; import * as THREE from "three"; import { EditorMap } from "@/components/editor/scene/EditorMap"; +import { TerrainModel } from "@/components/three/world/TerrainModel"; import { FlyController } from "@/controls/editor/FlyController"; import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor"; @@ -21,6 +22,7 @@ interface EditorSceneProps { hoveredNodeIndex: number | null; onHoverNode: (index: number | null) => void; transformMode: TransformMode; + snapToTerrain: boolean; onTransformModeChange: (mode: TransformMode) => void; onTransformStart: () => void; onTransformEnd: () => void; @@ -40,6 +42,7 @@ export function EditorScene({ hoveredNodeIndex, onHoverNode, transformMode, + snapToTerrain, onTransformModeChange, onTransformStart, onTransformEnd, @@ -126,11 +129,14 @@ export function EditorScene({ hoveredNodeIndex={hoveredNodeIndex} onHoverNode={onHoverNode} transformMode={transformMode} + snapToTerrain={snapToTerrain} onTransformStart={onTransformStart} onTransformEnd={onTransformEnd} onNodeTransform={onNodeTransform} /> + + diff --git a/src/hooks/editor/useEditorHistory.ts b/src/hooks/editor/useEditorHistory.ts index 4d27554..7ff4a13 100644 --- a/src/hooks/editor/useEditorHistory.ts +++ b/src/hooks/editor/useEditorHistory.ts @@ -1,13 +1,76 @@ import { useCallback, useRef, useState } from "react"; -import type { MapNode, SceneData } from "@/types/editor/editor"; +import type { + HierarchicalMapNode, + MapNode, + SceneData, +} from "@/types/editor/editor"; interface ObjectTransform { uuid: string; + path: number[]; position: { x: number; y: number; z: number }; rotation: { x: number; y: number; z: number }; scale: { x: number; y: number; z: number }; } +function cloneMapTree( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): HierarchicalMapNode | HierarchicalMapNode[] { + return JSON.parse(JSON.stringify(mapTree)) as + | HierarchicalMapNode + | HierarchicalMapNode[]; +} + +function updateTreeNodeAtPath( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], + path: number[], + transform: ObjectTransform, +): 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; + const updateNode = (node: HierarchicalMapNode): HierarchicalMapNode => ({ + ...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], + }); + + if (isRootTarget) { + rootNodes[targetIndex] = updateNode( + rootNodes[targetIndex] as HierarchicalMapNode, + ); + 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]) { + parent.children[targetIndex] = updateNode(parent.children[targetIndex]); + } + + return nextTree; +} + class HistoryManager { private history: ObjectTransform[][] = []; private currentIndex = -1; @@ -81,13 +144,14 @@ export function useEditorHistory( setSceneData((prev) => { if (!prev) return null; + let mapTree = prev.mapTree; const mapNodes = prev.mapNodes.map((node, index) => { const transform = snapshot.find( (item) => item.uuid === `node-${index}`, ); if (!transform) return node; - return { + const nextNode = { ...node, position: [ transform.position.x, @@ -101,9 +165,13 @@ export function useEditorHistory( ], scale: [transform.scale.x, transform.scale.y, transform.scale.z], } satisfies MapNode; + + mapTree = updateTreeNodeAtPath(mapTree, node.path, transform); + + return nextNode; }); - return { ...prev, mapNodes }; + return { ...prev, mapNodes, mapTree }; }); }, [setSceneData], @@ -149,6 +217,7 @@ export function useEditorHistory( function createSnapshot(sceneData: SceneData): ObjectTransform[] { return sceneData.mapNodes.map((node, index) => ({ uuid: `node-${index}`, + path: node.path, position: { x: node.position[0], y: node.position[1], diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts index 5587962..8348ab7 100644 --- a/src/hooks/three/useTerrainHeight.ts +++ b/src/hooks/three/useTerrainHeight.ts @@ -57,6 +57,16 @@ export function useTerrainSnappedPosition( }, [position, terrainHeight]); } +export function getObjectBottomOffset( + object: THREE.Object3D, + scale: Vector3Tuple = [1, 1, 1], +): number { + const bounds = new THREE.Box3().setFromObject(object); + if (bounds.isEmpty()) return 0; + + return -bounds.min.y * scale[1]; +} + export function normalizeMapScale(scale: Vector3Tuple): Vector3Tuple { const [x, y, z] = scale; const isUniform = Math.abs(x - y) < 0.001 && Math.abs(x - z) < 0.001; diff --git a/src/index.css b/src/index.css index cd732fe..a1e10a6 100644 --- a/src/index.css +++ b/src/index.css @@ -1390,6 +1390,52 @@ canvas { color: #050505; } +.editor-scale-fields { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin-top: 2px; +} + +.editor-scale-fields label, +.editor-checkbox-row, +.editor-add-node-row { + display: flex; + align-items: center; + gap: 8px; +} + +.editor-scale-fields label { + flex-direction: column; + align-items: stretch; +} + +.editor-scale-fields input, +.editor-add-node-row input { + width: 100%; + min-width: 0; + color: #f4f4f4; + background: #101010; + border: 1px solid #2a2a2a; + border-radius: 10px; + padding: 8px 9px; + font: inherit; +} + +.editor-checkbox-row { + color: #d5d5d5; + font-size: 0.82rem; +} + +.editor-add-node-row { + align-items: stretch; +} + +.editor-add-node-row .editor-action-button { + white-space: nowrap; +} + .editor-selected-actions { display: inline-flex; gap: 6px; diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx index 5da7919..42d0719 100644 --- a/src/pages/editor/page.tsx +++ b/src/pages/editor/page.tsx @@ -9,7 +9,13 @@ 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 { MapNode, SceneData, TransformMode } from "@/types/editor/editor"; +import type { + EditableMapNode, + HierarchicalMapNode, + MapNode, + SceneData, + TransformMode, +} from "@/types/editor/editor"; import { INITIAL_SCENE_LOADING_STATE, type SceneLoadingChangeHandler, @@ -18,13 +24,200 @@ import { import { logger } from "@/utils/core/Logger"; const SAVE_ERROR_MESSAGE = "Erreur lors de l'enregistrement"; +const DEFAULT_NEW_NODE_NAME = "new-model"; interface EditorSceneLoadingTrackerProps { onLoadingStateChange: SceneLoadingChangeHandler; } function serializeMapNodes(sceneData: SceneData): string { - return JSON.stringify(sceneData.mapNodes, null, 2); + return JSON.stringify(sceneData.mapTree, null, 2); +} + +function cloneMapTree( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): HierarchicalMapNode | HierarchicalMapNode[] { + return JSON.parse(JSON.stringify(mapTree)) as + | HierarchicalMapNode + | HierarchicalMapNode[]; +} + +function toEditableMapNode( + node: HierarchicalMapNode, + path: number[], +): EditableMapNode | null { + if (node.name === "terrain" || node.role === "group") return null; + + return { + name: node.name, + path, + position: node.position, + rotation: node.rotation, + scale: node.scale, + type: node.type, + }; +} + +function collectEditableMapNodes( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): EditableMapNode[] { + const nodes: EditableMapNode[] = []; + + function visit(node: HierarchicalMapNode, path: number[]): void { + const editableNode = toEditableMapNode(node, path); + if (editableNode) { + nodes.push(editableNode); + return; + } + + 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) { + rootNodes[targetIndex] = update( + rootNodes[targetIndex] as HierarchicalMapNode, + ); + 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 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 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 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], + }, + ], + }; } function EditorSceneLoadingTracker({ @@ -69,6 +262,8 @@ export function EditorPage(): React.JSX.Element { 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 [sceneLoadingState, setSceneLoadingState] = useState( { ...INITIAL_SCENE_LOADING_STATE, @@ -122,6 +317,14 @@ export function EditorPage(): React.JSX.Element { setIsSelectionLocked((locked) => !locked); }, []); + const handleSnapToTerrainToggle = useCallback(() => { + setSnapToTerrain((enabled) => !enabled); + }, []); + + const handleNewNodeNameChange = useCallback((value: string) => { + setNewNodeName(value); + }, []); + const handleHoverNode = useCallback((index: number | null) => { setHoveredNodeIndex(index); }, []); @@ -186,14 +389,73 @@ export function EditorPage(): React.JSX.Element { (nodeIndex: number, updatedNode: MapNode) => { setSceneData((prev) => { if (!prev) return null; - const newMapNodes = [...prev.mapNodes]; - newMapNodes[nodeIndex] = updatedNode; - return { ...prev, mapNodes: newMapNodes }; + const currentNode = prev.mapNodes[nodeIndex]; + if (!currentNode) return prev; + + const mapTree = updateTreeNodeAtPath( + prev.mapTree, + currentNode.path, + (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; + + const mapTree = updateTreeNodeAtPath( + prev.mapTree, + currentNode.path, + (node) => ({ ...node, scale: nextScale }), + ); + + return updateSceneDataTree(prev, mapTree); + }); + }, + [selectedNodeIndex, setSceneData], + ); + + const handleAddNode = useCallback(() => { + setSceneData((prev) => { + if (!prev) return null; + const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName)); + const nextSceneData = updateSceneDataTree(prev, mapTree); + setSelectedNodeIndex(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; + const mapTree = removeTreeNodeAtPath(prev.mapTree, currentNode.path); + setSelectedNodeIndex(null); + return updateSceneDataTree(prev, mapTree); + }); + }, [selectedNodeIndex, setSceneData]); + if (isMapLoading) { return (
@@ -279,6 +541,7 @@ export function EditorPage(): React.JSX.Element { hoveredNodeIndex={hoveredNodeIndex} onHoverNode={handleHoverNode} transformMode={transformMode} + snapToTerrain={snapToTerrain} onTransformModeChange={handleTransformModeChange} onTransformStart={handleTransformStart} onTransformEnd={handleTransformEnd} @@ -306,9 +569,21 @@ export function EditorPage(): React.JSX.Element { ? sceneData.mapNodes[selectedNodeIndex].name || null : null } + selectedNodeScale={ + selectedNodeIndex !== null && sceneData.mapNodes[selectedNodeIndex] + ? sceneData.mapNodes[selectedNodeIndex].scale + : null + } isSelectionLocked={isSelectionLocked} onSelectionLockToggle={handleSelectionLockToggle} onClearSelection={handleClearSelection} + snapToTerrain={snapToTerrain} + onSnapToTerrainToggle={handleSnapToTerrainToggle} + newNodeName={newNodeName} + onNewNodeNameChange={handleNewNodeNameChange} + onAddNode={handleAddNode} + onDeleteSelectedNode={handleDeleteSelectedNode} + onSelectedScaleChange={handleSelectedScaleChange} undoCount={undoCount} redoCount={redoCount} onUndo={handleUndo} diff --git a/src/types/editor/editor.ts b/src/types/editor/editor.ts index ceda325..0871fac 100644 --- a/src/types/editor/editor.ts +++ b/src/types/editor/editor.ts @@ -8,13 +8,18 @@ export interface MapNode { scale: Vector3Tuple; } +export interface EditableMapNode extends MapNode { + path: number[]; +} + export interface HierarchicalMapNode extends MapNode { role?: "group"; children?: HierarchicalMapNode[]; } export interface SceneData { - mapNodes: MapNode[]; + mapNodes: EditableMapNode[]; + mapTree: HierarchicalMapNode | HierarchicalMapNode[]; models: Map; } diff --git a/src/utils/editor/loadEditorScene.ts b/src/utils/editor/loadEditorScene.ts index 3da9c53..aaa88b6 100644 --- a/src/utils/editor/loadEditorScene.ts +++ b/src/utils/editor/loadEditorScene.ts @@ -1,5 +1,5 @@ import type { SceneData } from "@/types/editor/editor"; -import { parseMapNodes } from "@/utils/map/mapNodeValidation"; +import { createSceneDataFromMapPayload } from "@/utils/map/loadMapSceneData"; const MAP_JSON_PATH = "/map.json"; @@ -18,7 +18,7 @@ export async function createSceneDataFromFiles( } const mapPayload: unknown = JSON.parse(await mapFile.text()); - const mapNodes = parseMapNodes(mapPayload); + const sceneData = await createSceneDataFromMapPayload(mapPayload); const models = new Map(); for (const [path, file] of fileMap.entries()) { @@ -31,7 +31,7 @@ export async function createSceneDataFromFiles( } } - return { mapNodes, models }; + return { ...sceneData, models }; } function getProjectRelativePath(file: File): string { diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts index 2efef22..bda7fcc 100644 --- a/src/utils/map/loadMapSceneData.ts +++ b/src/utils/map/loadMapSceneData.ts @@ -1,5 +1,13 @@ -import type { MapNode, SceneData } from "@/types/editor/editor"; -import { parseMapNodes } from "@/utils/map/mapNodeValidation"; +import type { + EditableMapNode, + HierarchicalMapNode, + MapNode, + SceneData, +} from "@/types/editor/editor"; +import { + parseHierarchicalMapPayload, + parseMapNodes, +} from "@/utils/map/mapNodeValidation"; const MAP_JSON_PATH = "/map.json"; const MODEL_FILE_NAMES = ["model.glb", "model.gltf"]; @@ -45,9 +53,59 @@ async function loadMapSceneDataInternal(): Promise { } const mapPayload: unknown = await response.json(); - const mapNodes = parseMapNodes(mapPayload); + return createSceneDataFromMapPayload(mapPayload); +} + +export async function createSceneDataFromMapPayload( + mapPayload: unknown, +): Promise { + const mapTree = parseHierarchicalMapPayload(mapPayload); + const mapNodes = parseMapNodes(mapTree); + const editableNodes = createEditableMapNodes(mapTree); const deduplicatedNodes = deduplicateMapNodes(mapNodes); - return createSceneData(deduplicatedNodes); + const deduplicatedEditableNodes = deduplicateEditableMapNodes(editableNodes); + return createSceneData(mapTree, deduplicatedEditableNodes, deduplicatedNodes); +} + +function toMapNode(node: HierarchicalMapNode): MapNode { + return { + name: node.name, + position: node.position, + rotation: node.rotation, + scale: node.scale, + type: node.type, + }; +} + +function flattenEditableMapNode( + node: HierarchicalMapNode, + path: number[], +): EditableMapNode[] { + if (node.name === "terrain") { + return []; + } + + if (node.role === "group") { + return ( + node.children?.flatMap((child, index) => + flattenEditableMapNode(child, [...path, index]), + ) ?? [] + ); + } + + return [{ ...toMapNode(node), path }]; +} + +function createEditableMapNodes( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): EditableMapNode[] { + if (Array.isArray(mapTree)) { + return mapTree.flatMap((node, index) => + flattenEditableMapNode(node, [index]), + ); + } + + return flattenEditableMapNode(mapTree, []); } function createPositionKey(node: MapNode): string { @@ -84,9 +142,36 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] { return result; } -async function createSceneData(mapNodes: MapNode[]): Promise { - const models = await loadMapModelUrls(mapNodes); - return { mapNodes, models }; +function deduplicateEditableMapNodes( + nodes: EditableMapNode[], +): EditableMapNode[] { + const seen = new Set(); + const result: EditableMapNode[] = []; + + const sortedNodes = [...nodes].sort((a, b) => { + if (a.type === "Object3D" && b.type !== "Object3D") return -1; + if (a.type !== "Object3D" && b.type === "Object3D") return 1; + return 0; + }); + + for (const node of sortedNodes) { + const key = createPositionKey(node); + if (!seen.has(key)) { + seen.add(key); + result.push(node); + } + } + + return result; +} + +async function createSceneData( + mapTree: HierarchicalMapNode | HierarchicalMapNode[], + mapNodes: EditableMapNode[], + modelLookupNodes: MapNode[], +): Promise { + const models = await loadMapModelUrls(modelLookupNodes); + return { mapNodes, mapTree, models }; } async function loadMapModelUrls( diff --git a/src/utils/map/mapNodeValidation.ts b/src/utils/map/mapNodeValidation.ts index 7610bd8..b5b83cf 100644 --- a/src/utils/map/mapNodeValidation.ts +++ b/src/utils/map/mapNodeValidation.ts @@ -26,7 +26,9 @@ function isMapNode(value: unknown): value is MapNode { ); } -function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode { +export function isHierarchicalMapNode( + value: unknown, +): value is HierarchicalMapNode { if (!isMapNode(value)) { return false; } @@ -54,13 +56,25 @@ function flattenMapNode(node: HierarchicalMapNode): MapNode[] { rotation: node.rotation, scale: node.scale, }; - const childNodes = node.children?.flatMap(flattenMapNode) ?? []; - if (node.role === "group") { - return childNodes; + return node.children?.flatMap(flattenMapNode) ?? []; } - return [mapNode, ...childNodes]; + return [mapNode]; +} + +export function parseHierarchicalMapPayload( + value: unknown, +): HierarchicalMapNode | HierarchicalMapNode[] { + if (Array.isArray(value) && value.every(isHierarchicalMapNode)) { + return value; + } + + if (isHierarchicalMapNode(value)) { + return value; + } + + throw new Error("Invalid map node data"); } export function parseMapNodes(value: unknown): MapNode[] { diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 8de9b7b..6723962 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -4,6 +4,7 @@ import { Suspense, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; @@ -11,8 +12,9 @@ import * as THREE from "three"; import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { + getObjectBottomOffset, normalizeMapScale, - useTerrainSnappedPosition, + useTerrainHeightSampler, } from "@/hooks/three/useTerrainHeight"; import { TerrainModel } from "@/components/three/world/TerrainModel"; import { @@ -356,15 +358,21 @@ function ModelInstance({ onLoaded: () => void; }): React.JSX.Element { const { position, rotation, scale } = node; - const snappedPosition = useTerrainSnappedPosition(position); const normalizedScale = normalizeMapScale(scale); + const terrainHeight = useTerrainHeightSampler(); const { scene } = useLoggedGLTF(modelUrl, { scope: "GameMap.ModelInstance", - position: snappedPosition, + position, rotation, scale: normalizedScale, }); const sceneInstance = useClonedObject(scene); + const groundedPosition = useMemo(() => { + const [x, y, z] = position; + const height = terrainHeight.getHeight(x, z); + const bottomOffset = getObjectBottomOffset(sceneInstance, normalizedScale); + return [x, height !== null ? height + bottomOffset : y, z] as const; + }, [normalizedScale, position, sceneInstance, terrainHeight]); useEffect(() => { sceneInstance.traverse((child) => { @@ -379,7 +387,7 @@ function ModelInstance({ return ( diff --git a/src/world/map-instancing/InstancedMapAsset.tsx b/src/world/map-instancing/InstancedMapAsset.tsx index 38dd5c6..2e06433 100644 --- a/src/world/map-instancing/InstancedMapAsset.tsx +++ b/src/world/map-instancing/InstancedMapAsset.tsx @@ -130,6 +130,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] { function setInstanceMatrices( instancedMesh: THREE.InstancedMesh, instances: MapAssetInstance[], + geometryBottomY: number, ): void { const position = new THREE.Vector3(); const rotation = new THREE.Euler(); @@ -145,6 +146,7 @@ function setInstanceMatrices( rotation.set(...instance.rotation); quaternion.setFromEuler(rotation); scale.set(...instance.scale); + position.y += -geometryBottomY * scale.y; matrix.compose(position, quaternion, scale); instancedMesh.setMatrixAt(i, matrix); } @@ -152,6 +154,20 @@ function setInstanceMatrices( instancedMesh.instanceMatrix.needsUpdate = true; } +function getMeshBottomY(meshDataList: MeshData[]): number { + let bottomY = Number.POSITIVE_INFINITY; + + for (const meshData of meshDataList) { + meshData.geometry.computeBoundingBox(); + const minY = meshData.geometry.boundingBox?.min.y; + if (minY !== undefined) { + bottomY = Math.min(bottomY, minY); + } + } + + return Number.isFinite(bottomY) ? bottomY : 0; +} + export function InstancedMapAsset({ modelPath, instances, @@ -185,6 +201,7 @@ export function InstancedMapAsset({ optimizeGLTFSceneTextures(scene, maxAnisotropy); const meshDataList = extractMeshes(scene); + const geometryBottomY = getMeshBottomY(meshDataList); const instancedMeshes = meshDataList.map((meshData, index) => { const instancedMesh = new THREE.InstancedMesh( meshData.geometry, @@ -192,7 +209,7 @@ export function InstancedMapAsset({ groundedInstances.length, ); - setInstanceMatrices(instancedMesh, groundedInstances); + setInstanceMatrices(instancedMesh, groundedInstances, geometryBottomY); instancedMesh.castShadow = castShadow; instancedMesh.receiveShadow = receiveShadow; instancedMesh.name = `instanced-map-asset-${index}`; diff --git a/src/world/vegetation/InstancedVegetation.tsx b/src/world/vegetation/InstancedVegetation.tsx index ff8598a..c17078e 100644 --- a/src/world/vegetation/InstancedVegetation.tsx +++ b/src/world/vegetation/InstancedVegetation.tsx @@ -75,6 +75,7 @@ function extractMeshes(scene: THREE.Group): MeshData[] { function createInstanceMatrices( instances: VegetationInstance[], scaleMultiplier: number, + geometryBottomY: number, ): THREE.Matrix4[] { const matrices: THREE.Matrix4[] = []; const position = new THREE.Vector3(); @@ -90,6 +91,7 @@ function createInstanceMatrices( const matrix = new THREE.Matrix4(); position.set(...instance.position); + position.y += -geometryBottomY * scaleMultiplier; rotation.set(...instance.rotation); quaternion.setFromEuler(rotation); matrix.compose(position, quaternion, scale); @@ -99,6 +101,20 @@ function createInstanceMatrices( return matrices; } +function getMeshBottomY(meshDataList: MeshData[]): number { + let bottomY = Number.POSITIVE_INFINITY; + + for (const meshData of meshDataList) { + meshData.geometry.computeBoundingBox(); + const minY = meshData.geometry.boundingBox?.min.y; + if (minY !== undefined) { + bottomY = Math.min(bottomY, minY); + } + } + + return Number.isFinite(bottomY) ? bottomY : 0; +} + export function InstancedVegetation({ modelPath, instances, @@ -130,8 +146,13 @@ export function InstancedVegetation({ [instances, terrainHeight], ); const matrices = useMemo( - () => createInstanceMatrices(groundedInstances, scaleMultiplier), - [groundedInstances, scaleMultiplier], + () => + createInstanceMatrices( + groundedInstances, + scaleMultiplier, + getMeshBottomY(meshDataList), + ), + [groundedInstances, meshDataList, scaleMultiplier], ); const instancedMeshes = useMemo(() => {