feat(editor): edit hierarchical map nodes

This commit is contained in:
Tom Boullay
2026-05-27 08:30:54 +02:00
parent ab100c683f
commit c2b16434fb
16 changed files with 740 additions and 64 deletions
+11 -18
View File
@@ -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
+30 -9
View File
@@ -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.
+94 -5
View File
@@ -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({
<span>{redoCount}</span>
</button>
</div>
<label className="editor-checkbox-row">
<input
type="checkbox"
checked={snapToTerrain}
onChange={onSnapToTerrainToggle}
/>
<span>Snap terrain on move</span>
</label>
</section>
<section
@@ -204,6 +232,14 @@ export function EditorControls({
</span>
</div>
<div className="editor-selected-actions">
<button
type="button"
onClick={onDeleteSelectedNode}
aria-label="Delete selected node"
title="Delete selected node"
>
<Trash2 size={14} aria-hidden="true" />
</button>
<button
type="button"
onClick={onSelectionLockToggle}
@@ -230,6 +266,26 @@ export function EditorControls({
<X size={14} aria-hidden="true" />
</button>
</div>
{selectedNodeScale ? (
<div className="editor-scale-fields">
{selectedNodeScale.map((value, axis) => (
<label key={axis}>
<span>{["X", "Y", "Z"][axis]}</span>
<input
type="number"
step="0.01"
value={Number(value.toFixed(4))}
onChange={(event) =>
onSelectedScaleChange(
axis as 0 | 1 | 2,
Number(event.target.value),
)
}
/>
</label>
))}
</div>
) : null}
</div>
) : (
<div className="editor-no-selection">
@@ -239,6 +295,32 @@ export function EditorControls({
)}
</section>
<section
className="editor-control-section"
aria-labelledby="add-node-heading"
>
<div className="editor-section-heading">
<h3 id="add-node-heading">Add Node</h3>
</div>
<div className="editor-add-node-row">
<input
type="text"
value={newNodeName}
onChange={(event) => onNewNodeNameChange(event.target.value)}
placeholder="model-folder-name"
/>
<button
type="button"
className="editor-action-button"
onClick={onAddNode}
>
<Plus size={16} aria-hidden="true" />
Add cube
</button>
</div>
</section>
<section
className="editor-control-section"
aria-labelledby="view-heading"
@@ -341,7 +423,7 @@ interface JsonPreview {
}
function getJsonPreview(
mapNodes: MapNode[],
mapNodes: EditableMapNode[],
selectedNodeIndex: number | null,
): JsonPreview {
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
@@ -370,7 +452,7 @@ function getJsonPreview(
};
}
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
function formatMapNodesWithRanges(mapNodes: EditableMapNode[]): {
lines: string[];
ranges: Array<{ start: number; end: number }>;
} {
@@ -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}`);
+18 -1
View File
@@ -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<Map<number, THREE.Object3D>>(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],
};
@@ -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}
/>
<TerrainModel />
<ambientLight intensity={0.6} />
<directionalLight position={[10, 20, 10]} intensity={1.5} castShadow />
<directionalLight position={[-10, 10, -10]} intensity={0.5} />
+72 -3
View File
@@ -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],
+10
View File
@@ -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;
+46
View File
@@ -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;
+280 -5
View File
@@ -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<TransformMode>("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<SceneLoadingState>(
{
...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 (
<div className="editor-container">
@@ -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}
+6 -1
View File
@@ -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<string, string>;
}
+3 -3
View File
@@ -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<string, string>();
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 {
+92 -7
View File
@@ -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<SceneData | null> {
}
const mapPayload: unknown = await response.json();
const mapNodes = parseMapNodes(mapPayload);
return createSceneDataFromMapPayload(mapPayload);
}
export async function createSceneDataFromMapPayload(
mapPayload: unknown,
): Promise<SceneData> {
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<SceneData> {
const models = await loadMapModelUrls(mapNodes);
return { mapNodes, models };
function deduplicateEditableMapNodes(
nodes: EditableMapNode[],
): EditableMapNode[] {
const seen = new Set<string>();
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<SceneData> {
const models = await loadMapModelUrls(modelLookupNodes);
return { mapNodes, mapTree, models };
}
async function loadMapModelUrls(
+19 -5
View File
@@ -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[] {
+12 -4
View File
@@ -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 (
<primitive
object={sceneInstance}
position={snappedPosition}
position={groundedPosition}
rotation={rotation}
scale={normalizedScale}
/>
+18 -1
View File
@@ -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}`;
+23 -2
View File
@@ -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(() => {