From d38ad242d6ff4573cf355f45f72356a98e72663c Mon Sep 17 00:00:00 2001 From: tom-boullay Date: Wed, 27 May 2026 09:47:01 +0200 Subject: [PATCH 1/8] fix(editor): preserve hierarchical map saves --- src/pages/editor/page.tsx | 76 ++++++++++++++++++++++++++++- src/types/editor/editor.ts | 2 + src/utils/editor/loadEditorScene.ts | 6 +-- src/utils/map/loadMapSceneData.ts | 27 +++++++--- src/utils/map/mapNodeValidation.ts | 27 ++++++++-- 5 files changed, 121 insertions(+), 17 deletions(-) diff --git a/src/pages/editor/page.tsx b/src/pages/editor/page.tsx index 5da7919..be4d228 100644 --- a/src/pages/editor/page.tsx +++ b/src/pages/editor/page.tsx @@ -9,7 +9,12 @@ 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 { + HierarchicalMapNode, + MapNode, + SceneData, + TransformMode, +} from "@/types/editor/editor"; import { INITIAL_SCENE_LOADING_STATE, type SceneLoadingChangeHandler, @@ -24,7 +29,74 @@ interface EditorSceneLoadingTrackerProps { } function serializeMapNodes(sceneData: SceneData): string { - return JSON.stringify(sceneData.mapNodes, null, 2); + 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 { + 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 = { + 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 EditorSceneLoadingTracker({ diff --git a/src/types/editor/editor.ts b/src/types/editor/editor.ts index ceda325..b000680 100644 --- a/src/types/editor/editor.ts +++ b/src/types/editor/editor.ts @@ -6,6 +6,7 @@ export interface MapNode { position: Vector3Tuple; rotation: Vector3Tuple; scale: Vector3Tuple; + sourcePath?: number[]; } export interface HierarchicalMapNode extends MapNode { @@ -16,6 +17,7 @@ export interface HierarchicalMapNode extends MapNode { export interface SceneData { mapNodes: MapNode[]; models: Map; + mapTree?: HierarchicalMapNode | HierarchicalMapNode[]; } export type TransformMode = "translate" | "rotate" | "scale"; diff --git a/src/utils/editor/loadEditorScene.ts b/src/utils/editor/loadEditorScene.ts index 3da9c53..17ec5bf 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 { parseMapData } from "@/utils/map/mapNodeValidation"; 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 { mapNodes, mapTree } = parseMapData(mapPayload); const models = new Map(); for (const [path, file] of fileMap.entries()) { @@ -31,7 +31,7 @@ export async function createSceneDataFromFiles( } } - return { mapNodes, models }; + return { mapNodes, models, mapTree }; } function getProjectRelativePath(file: File): string { diff --git a/src/utils/map/loadMapSceneData.ts b/src/utils/map/loadMapSceneData.ts index 2efef22..494de62 100644 --- a/src/utils/map/loadMapSceneData.ts +++ b/src/utils/map/loadMapSceneData.ts @@ -1,5 +1,9 @@ -import type { MapNode, SceneData } from "@/types/editor/editor"; -import { parseMapNodes } from "@/utils/map/mapNodeValidation"; +import type { + HierarchicalMapNode, + MapNode, + SceneData, +} from "@/types/editor/editor"; +import { parseMapData } from "@/utils/map/mapNodeValidation"; const MAP_JSON_PATH = "/map.json"; const MODEL_FILE_NAMES = ["model.glb", "model.gltf"]; @@ -21,8 +25,12 @@ export async function loadMapSceneData(): Promise { } loadingPromise = loadMapSceneDataInternal(); - cachedSceneData = await loadingPromise; - loadingPromise = null; + + try { + cachedSceneData = await loadingPromise; + } finally { + loadingPromise = null; + } return cachedSceneData; } @@ -45,9 +53,9 @@ async function loadMapSceneDataInternal(): Promise { } const mapPayload: unknown = await response.json(); - const mapNodes = parseMapNodes(mapPayload); + const { mapNodes, mapTree } = parseMapData(mapPayload); const deduplicatedNodes = deduplicateMapNodes(mapNodes); - return createSceneData(deduplicatedNodes); + return createSceneData(deduplicatedNodes, mapTree); } function createPositionKey(node: MapNode): string { @@ -84,9 +92,12 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] { return result; } -async function createSceneData(mapNodes: MapNode[]): Promise { +async function createSceneData( + mapNodes: MapNode[], + mapTree: HierarchicalMapNode | HierarchicalMapNode[], +): Promise { const models = await loadMapModelUrls(mapNodes); - return { mapNodes, models }; + return { mapNodes, models, mapTree }; } async function loadMapModelUrls( diff --git a/src/utils/map/mapNodeValidation.ts b/src/utils/map/mapNodeValidation.ts index 7610bd8..55af091 100644 --- a/src/utils/map/mapNodeValidation.ts +++ b/src/utils/map/mapNodeValidation.ts @@ -1,5 +1,10 @@ import type { HierarchicalMapNode, MapNode } from "../../types/editor/editor"; +export interface ParsedMapNodes { + mapNodes: MapNode[]; + mapTree: HierarchicalMapNode | HierarchicalMapNode[]; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -46,15 +51,19 @@ function isHierarchicalMapNode(value: unknown): value is HierarchicalMapNode { ); } -function flattenMapNode(node: HierarchicalMapNode): MapNode[] { +function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] { const mapNode: MapNode = { name: node.name, type: node.type, position: node.position, rotation: node.rotation, scale: node.scale, + sourcePath: path, }; - const childNodes = node.children?.flatMap(flattenMapNode) ?? []; + const childNodes = + node.children?.flatMap((child, index) => + flattenMapNode(child, [...path, index]), + ) ?? []; if (node.role === "group") { return childNodes; @@ -64,12 +73,22 @@ function flattenMapNode(node: HierarchicalMapNode): MapNode[] { } export function parseMapNodes(value: unknown): MapNode[] { + return parseMapData(value).mapNodes; +} + +export function parseMapData(value: unknown): ParsedMapNodes { if (Array.isArray(value) && value.every(isHierarchicalMapNode)) { - return value.flatMap(flattenMapNode); + return { + mapNodes: value.flatMap((node, index) => flattenMapNode(node, [index])), + mapTree: value, + }; } if (isHierarchicalMapNode(value)) { - return flattenMapNode(value); + return { + mapNodes: flattenMapNode(value, []), + mapTree: value, + }; } throw new Error("Invalid map node data"); From b89eedd5bed7e3f5a6c29a04cc178b666c3b89d0 Mon Sep 17 00:00:00 2001 From: tom-boullay Date: Wed, 27 May 2026 09:47:08 +0200 Subject: [PATCH 2/8] fix(map): align terrain visual collision and snapping --- src/hooks/three/useTerrainHeight.ts | 33 +++++++++++++++++++++++++---- src/world/GameMap.tsx | 14 +++++++++++- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/hooks/three/useTerrainHeight.ts b/src/hooks/three/useTerrainHeight.ts index 5587962..d1b6d85 100644 --- a/src/hooks/three/useTerrainHeight.ts +++ b/src/hooks/three/useTerrainHeight.ts @@ -1,12 +1,16 @@ import { useMemo } from "react"; import { useGLTF } from "@react-three/drei"; import * as THREE from "three"; +import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig"; import type { Vector3Tuple } from "@/types/three/three"; +import { getMapNodesByName } from "@/utils/map/loadMapSceneData"; -const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf"; const RAYCAST_Y = 500; const RAYCAST_FAR = 1000; const DOWN = new THREE.Vector3(0, -1, 0); +const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0]; +const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0]; +const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1]; interface TerrainHeightSampler { getHeight: (x: number, z: number) => number | null; @@ -14,8 +18,17 @@ interface TerrainHeightSampler { function createTerrainHeightSampler( scene: THREE.Object3D, + position: Vector3Tuple, + rotation: Vector3Tuple, + scale: Vector3Tuple, ): TerrainHeightSampler { const meshes: THREE.Mesh[] = []; + const terrainMatrix = new THREE.Matrix4().compose( + new THREE.Vector3(...position), + new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)), + new THREE.Vector3(...scale), + ); + const inverseTerrainMatrix = terrainMatrix.clone().invert(); const raycaster = new THREE.Raycaster( new THREE.Vector3(), DOWN, @@ -32,17 +45,29 @@ function createTerrainHeightSampler( return { getHeight: (x, z) => { - raycaster.set(new THREE.Vector3(x, RAYCAST_Y, z), DOWN); + const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4( + inverseTerrainMatrix, + ); + const localDirection = + DOWN.clone().transformDirection(inverseTerrainMatrix); + raycaster.set(localOrigin, localDirection); const hit = raycaster.intersectObjects(meshes, false)[0]; - return hit?.point.y ?? null; + return hit?.point.applyMatrix4(terrainMatrix).y ?? null; }, }; } export function useTerrainHeightSampler(): TerrainHeightSampler { const { scene } = useGLTF(TERRAIN_MODEL_PATH); + const terrainNode = getMapNodesByName("terrain")[0]; + const position = terrainNode?.position ?? DEFAULT_TERRAIN_POSITION; + const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION; + const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE; - return useMemo(() => createTerrainHeightSampler(scene), [scene]); + return useMemo( + () => createTerrainHeightSampler(scene, position, rotation, scale), + [position, rotation, scale, scene], + ); } export function useTerrainSnappedPosition( diff --git a/src/world/GameMap.tsx b/src/world/GameMap.tsx index 8de9b7b..c027e46 100644 --- a/src/world/GameMap.tsx +++ b/src/world/GameMap.tsx @@ -118,6 +118,7 @@ export function GameMap({ const [collisionMapNodes, setCollisionMapNodes] = useState( [], ); + const [terrainNode, setTerrainNode] = useState(null); const [mapLoaded, setMapLoaded] = useState(false); const [settledMapNodeCount, setSettledMapNodeCount] = useState(0); const mapReady = mapLoaded && settledMapNodeCount >= renderMapNodes.length; @@ -133,6 +134,7 @@ export function GameMap({ (currentStep: string) => { setRenderMapNodes([]); setCollisionMapNodes([]); + setTerrainNode(null); setMapLoaded(true); settledMapNodesRef.current.clear(); setSettledMapNodeCount(0); @@ -187,6 +189,7 @@ export function GameMap({ const modelUrl = sceneData.models.get(node.name); return { node, modelUrl: modelUrl ?? null }; }); + const loadedTerrainNode = loadedCollisionNodes[0]?.node ?? null; const missingModelCount = loadedMapNodes.filter( (mapNode) => mapNode.modelUrl === null, ).length; @@ -203,6 +206,7 @@ export function GameMap({ setRenderMapNodes(loadedMapNodes); setCollisionMapNodes(loadedCollisionNodes); + setTerrainNode(loadedTerrainNode); setMapLoaded(true); settledMapNodesRef.current.clear(); setSettledMapNodeCount(0); @@ -265,7 +269,15 @@ export function GameMap({ {isMapModelVisible("terrain", { groups, models }) ? ( - + terrainNode ? ( + + ) : ( + + ) ) : null} Date: Wed, 27 May 2026 11:06:14 +0200 Subject: [PATCH 3/8] feat(editor): focus selected model editing --- src/components/editor/EditorControls.tsx | 62 ++++++++++- src/components/editor/scene/EditorMap.tsx | 116 ++++++++++++++++++-- src/components/editor/scene/EditorScene.tsx | 3 + src/index.css | 62 ++++++++++- src/pages/editor/page.tsx | 20 ++++ src/utils/map/mapRuntimeClassification.ts | 39 +++++++ src/world/vegetation/vegetationConfig.ts | 8 ++ 7 files changed, 300 insertions(+), 10 deletions(-) create mode 100644 src/utils/map/mapRuntimeClassification.ts diff --git a/src/components/editor/EditorControls.tsx b/src/components/editor/EditorControls.tsx index e9b099d..51d960a 100644 --- a/src/components/editor/EditorControls.tsx +++ b/src/components/editor/EditorControls.tsx @@ -28,6 +28,8 @@ interface EditorControlsProps { mapNodes: MapNode[]; nodesCount: number; selectedNodeName: string | null; + lockTerrainSelection: boolean; + onLockTerrainSelectionChange: (locked: boolean) => void; isSelectionLocked: boolean; onSelectionLockToggle: () => void; onClearSelection: () => void; @@ -90,6 +92,8 @@ export function EditorControls({ mapNodes, nodesCount, selectedNodeName, + lockTerrainSelection, + onLockTerrainSelectionChange, isSelectionLocked, onSelectionLockToggle, onClearSelection, @@ -105,6 +109,9 @@ export function EditorControls({ }: EditorControlsProps): React.JSX.Element { const viewModeLabel = isPlayerMode ? "View locked" : "Lock view"; const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex); + const selectedNode = + selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null; + const transformValues = getTransformValues(selectedNode ?? null); return ( <> @@ -155,7 +162,10 @@ export function EditorControls({ aria-pressed={transformMode === mode} >