5 Commits

Author SHA1 Message Date
tom-boullay 4ee13b0336 fix(editor): keep selection when clicking empty space
🔍 Lint / 🪄 Check lint (pull_request) Has been cancelled
🔍 Lint / 🎨 Check format (pull_request) Has been cancelled
🔍 Lint / 🔎 Typecheck (pull_request) Has been cancelled
📊 Quality / 🔒 Security Audit (pull_request) Has been cancelled
📊 Quality / 📋 Dependency Freshness (pull_request) Has been cancelled
📊 Quality / 📦 Bundle Size (pull_request) Has been cancelled
🔍 Lint / 🏗 Build (pull_request) Has been cancelled
2026-05-27 11:41:42 +02:00
tom-boullay fdd530a3e7 fix(map): prevent visual loading from blocking gameplay 2026-05-27 11:39:44 +02:00
tom-boullay 2b676d985d feat(editor): focus selected model editing 2026-05-27 11:06:14 +02:00
tom-boullay b89eedd5be fix(map): align terrain visual collision and snapping 2026-05-27 09:47:08 +02:00
tom-boullay d38ad242d6 fix(editor): preserve hierarchical map saves 2026-05-27 09:47:01 +02:00
13 changed files with 472 additions and 75 deletions
+61 -1
View File
@@ -28,6 +28,8 @@ interface EditorControlsProps {
mapNodes: MapNode[]; mapNodes: MapNode[];
nodesCount: number; nodesCount: number;
selectedNodeName: string | null; selectedNodeName: string | null;
lockTerrainSelection: boolean;
onLockTerrainSelectionChange: (locked: boolean) => void;
isSelectionLocked: boolean; isSelectionLocked: boolean;
onSelectionLockToggle: () => void; onSelectionLockToggle: () => void;
onClearSelection: () => void; onClearSelection: () => void;
@@ -90,6 +92,8 @@ export function EditorControls({
mapNodes, mapNodes,
nodesCount, nodesCount,
selectedNodeName, selectedNodeName,
lockTerrainSelection,
onLockTerrainSelectionChange,
isSelectionLocked, isSelectionLocked,
onSelectionLockToggle, onSelectionLockToggle,
onClearSelection, onClearSelection,
@@ -105,6 +109,9 @@ export function EditorControls({
}: EditorControlsProps): React.JSX.Element { }: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view"; const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex); const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
const transformValues = getTransformValues(selectedNode ?? null);
return ( return (
<> <>
@@ -155,7 +162,10 @@ export function EditorControls({
aria-pressed={transformMode === mode} aria-pressed={transformMode === mode}
> >
<Icon size={16} aria-hidden="true" /> <Icon size={16} aria-hidden="true" />
<span>{label}</span> <span className="editor-transform-label">
<span>{label}</span>
<small>{transformValues[mode]}</small>
</span>
<kbd>{shortcut}</kbd> <kbd>{shortcut}</kbd>
</button> </button>
))} ))}
@@ -257,6 +267,20 @@ export function EditorControls({
{viewModeLabel} {viewModeLabel}
</button> </button>
)} )}
<label className="editor-checkbox-row">
<input
type="checkbox"
checked={lockTerrainSelection}
onChange={(event) =>
onLockTerrainSelectionChange(event.currentTarget.checked)
}
/>
<span>
<strong>Lock terrain</strong>
<small>Keep terrain visible but ignore terrain clicks</small>
</span>
</label>
</section> </section>
<section <section
@@ -329,6 +353,42 @@ export function EditorControls({
); );
} }
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
function formatVector(values: readonly [number, number, number]): string {
return `X ${formatNumber(values[0])} · Y ${formatNumber(values[1])} · Z ${formatNumber(values[2])}`;
}
function formatRotation(values: readonly [number, number, number]): string {
const degrees = values.map((value) => (value * 180) / Math.PI) as [
number,
number,
number,
];
return `X ${formatNumber(degrees[0])}° · Y ${formatNumber(degrees[1])}° · Z ${formatNumber(degrees[2])}°`;
}
function getTransformValues(
node: MapNode | null,
): Record<TransformMode, string> {
if (!node) {
return {
translate: "No selection",
rotate: "No selection",
scale: "No selection",
};
}
return {
translate: formatVector(node.position),
rotate: formatRotation(node.rotation),
scale: formatVector(node.scale),
};
}
interface JsonPreviewLine { interface JsonPreviewLine {
number: number; number: number;
content: string; content: string;
+109 -15
View File
@@ -3,9 +3,19 @@ import { Grid, TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber"; import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three"; import * as THREE from "three";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { useClonedObject } from "@/hooks/three/useClonedObject"; import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF"; import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import {
normalizeMapScale,
useTerrainSnappedPosition,
} from "@/hooks/three/useTerrainHeight";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor"; import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
import {
isEditorVisibleMapNode,
getTerrainMapNode,
} from "@/utils/map/mapRuntimeClassification";
import { getVegetationScaleMultiplier } from "@/world/vegetation/vegetationConfig";
interface EditorMapProps { interface EditorMapProps {
sceneData: SceneData; sceneData: SceneData;
@@ -15,6 +25,7 @@ interface EditorMapProps {
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
lockTerrainSelection: boolean;
onTransformStart: () => void; onTransformStart: () => void;
onTransformEnd: () => void; onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void; onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
@@ -138,6 +149,7 @@ export function EditorMap({
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
lockTerrainSelection,
onTransformStart, onTransformStart,
onTransformEnd, onTransformEnd,
onNodeTransform, onNodeTransform,
@@ -169,6 +181,13 @@ export function EditorMap({
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>( const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
null, null,
); );
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
const terrainNodeIndex = terrainNode
? sceneData.mapNodes.indexOf(terrainNode)
: -1;
const selectedNode =
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
const selectedModelName = selectedNode?.name ?? null;
useEffect(() => { useEffect(() => {
if (selectedNodeIndex !== null) { if (selectedNodeIndex !== null) {
@@ -196,14 +215,29 @@ export function EditorMap({
/> />
<axesHelper args={[10]} /> <axesHelper args={[10]} />
<group <group>
onClick={(event: ThreeEvent<MouseEvent>) => { {terrainNode ? (
event.stopPropagation(); <EditorTerrainNode
if (isSelectionLocked) return; index={terrainNodeIndex}
onSelectNode(null); node={terrainNode}
}} isSelected={selectedNodeIndex === terrainNodeIndex}
> isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
) : null}
{sceneData.mapNodes.map((node, index) => { {sceneData.mapNodes.map((node, index) => {
if (!isEditorVisibleMapNode(node)) {
return null;
}
if (selectedModelName && node.name !== selectedModelName) {
return null;
}
const modelUrl = sceneData.models.get(node.name); const modelUrl = sceneData.models.get(node.name);
if (modelUrl) { if (modelUrl) {
@@ -265,14 +299,23 @@ function EditorModelNode({
modelUrl: string; modelUrl: string;
}) { }) {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const snappedPosition = useTerrainSnappedPosition(node.position);
const vegetationScaleMultiplier = getVegetationScaleMultiplier(node.name);
const normalizedScale = vegetationScaleMultiplier
? ([
vegetationScaleMultiplier,
vegetationScaleMultiplier,
vegetationScaleMultiplier,
] satisfies MapNode["scale"])
: normalizeMapScale(node.scale);
const originalMaterialsRef = useRef( const originalMaterialsRef = useRef(
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(), new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
); );
const { scene } = useLoggedGLTF(modelUrl, { const { scene } = useLoggedGLTF(modelUrl, {
scope: "EditorMap.EditorModelNode", scope: "EditorMap.EditorModelNode",
position: node.position, position: snappedPosition,
rotation: node.rotation, rotation: node.rotation,
scale: node.scale, scale: normalizedScale,
}); });
const sceneInstance = useClonedObject(scene); const sceneInstance = useClonedObject(scene);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
@@ -281,7 +324,16 @@ function EditorModelNode({
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(groupRef, index, node, objectsMapRef); useRegisteredEditorNode(
groupRef,
index,
{
...node,
position: snappedPosition,
scale: normalizedScale,
},
objectsMapRef,
);
useEffect(() => { useEffect(() => {
if (!groupRef.current) return; if (!groupRef.current) return;
@@ -338,11 +390,42 @@ function EditorModelNode({
<primitive <primitive
ref={groupRef} ref={groupRef}
object={sceneInstance} object={sceneInstance}
position={snappedPosition}
rotation={node.rotation}
scale={normalizedScale}
{...pointerHandlers}
/>
);
}
function EditorTerrainNode({
index,
node,
lockTerrainSelection,
objectsMapRef,
onSelectNode,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
const groupRef = useRef<THREE.Group>(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
return (
<group
ref={groupRef}
position={node.position} position={node.position}
rotation={node.rotation} rotation={node.rotation}
scale={node.scale} scale={node.scale}
{...pointerHandlers} {...(lockTerrainSelection ? {} : pointerHandlers)}
/> >
<TerrainModel receiveShadow visible />
</group>
); );
} }
@@ -357,22 +440,33 @@ function EditorFallbackNode({
onHoverNode, onHoverNode,
}: EditorNodeCommonProps) { }: EditorNodeCommonProps) {
const meshRef = useRef<THREE.Mesh>(null); const meshRef = useRef<THREE.Mesh>(null);
const snappedPosition = useTerrainSnappedPosition(node.position);
const normalizedScale = normalizeMapScale(node.scale);
const pointerHandlers = createEditorNodePointerHandlers( const pointerHandlers = createEditorNodePointerHandlers(
index, index,
onSelectNode, onSelectNode,
isSelectionLocked, isSelectionLocked,
onHoverNode, onHoverNode,
); );
useRegisteredEditorNode(meshRef, index, node, objectsMapRef); useRegisteredEditorNode(
meshRef,
index,
{
...node,
position: snappedPosition,
scale: normalizedScale,
},
objectsMapRef,
);
const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f"; const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f";
return ( return (
<mesh <mesh
ref={meshRef} ref={meshRef}
position={node.position} position={snappedPosition}
rotation={node.rotation} rotation={node.rotation}
scale={node.scale} scale={normalizedScale}
{...pointerHandlers} {...pointerHandlers}
> >
<boxGeometry args={[1, 1, 1]} /> <boxGeometry args={[1, 1, 1]} />
@@ -21,6 +21,7 @@ interface EditorSceneProps {
hoveredNodeIndex: number | null; hoveredNodeIndex: number | null;
onHoverNode: (index: number | null) => void; onHoverNode: (index: number | null) => void;
transformMode: TransformMode; transformMode: TransformMode;
lockTerrainSelection: boolean;
onTransformModeChange: (mode: TransformMode) => void; onTransformModeChange: (mode: TransformMode) => void;
onTransformStart: () => void; onTransformStart: () => void;
onTransformEnd: () => void; onTransformEnd: () => void;
@@ -40,6 +41,7 @@ export function EditorScene({
hoveredNodeIndex, hoveredNodeIndex,
onHoverNode, onHoverNode,
transformMode, transformMode,
lockTerrainSelection,
onTransformModeChange, onTransformModeChange,
onTransformStart, onTransformStart,
onTransformEnd, onTransformEnd,
@@ -126,6 +128,7 @@ export function EditorScene({
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={onHoverNode} onHoverNode={onHoverNode}
transformMode={transformMode} transformMode={transformMode}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart} onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd} onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform} onNodeTransform={onNodeTransform}
+29 -4
View File
@@ -1,12 +1,16 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useGLTF } from "@react-three/drei"; import { useGLTF } from "@react-three/drei";
import * as THREE from "three"; import * as THREE from "three";
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
import type { Vector3Tuple } from "@/types/three/three"; 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_Y = 500;
const RAYCAST_FAR = 1000; const RAYCAST_FAR = 1000;
const DOWN = new THREE.Vector3(0, -1, 0); 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 { interface TerrainHeightSampler {
getHeight: (x: number, z: number) => number | null; getHeight: (x: number, z: number) => number | null;
@@ -14,8 +18,17 @@ interface TerrainHeightSampler {
function createTerrainHeightSampler( function createTerrainHeightSampler(
scene: THREE.Object3D, scene: THREE.Object3D,
position: Vector3Tuple,
rotation: Vector3Tuple,
scale: Vector3Tuple,
): TerrainHeightSampler { ): TerrainHeightSampler {
const meshes: THREE.Mesh[] = []; 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( const raycaster = new THREE.Raycaster(
new THREE.Vector3(), new THREE.Vector3(),
DOWN, DOWN,
@@ -32,17 +45,29 @@ function createTerrainHeightSampler(
return { return {
getHeight: (x, z) => { 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]; const hit = raycaster.intersectObjects(meshes, false)[0];
return hit?.point.y ?? null; return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
}, },
}; };
} }
export function useTerrainHeightSampler(): TerrainHeightSampler { export function useTerrainHeightSampler(): TerrainHeightSampler {
const { scene } = useGLTF(TERRAIN_MODEL_PATH); 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( export function useTerrainSnappedPosition(
+61 -1
View File
@@ -1244,7 +1244,7 @@ canvas {
.editor-transform-button { .editor-transform-button {
display: grid; display: grid;
grid-template-columns: 18px 1fr auto; grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
@@ -1264,6 +1264,30 @@ canvas {
transform 160ms ease; transform 160ms ease;
} }
.editor-transform-label {
display: grid;
min-width: 0;
gap: 2px;
}
.editor-transform-label span,
.editor-transform-label small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editor-transform-label small {
color: #8f8f8f;
font-size: 0.64rem;
font-weight: 620;
letter-spacing: 0;
}
.editor-transform-button.active .editor-transform-label small {
color: #555555;
}
.editor-transform-button.active { .editor-transform-button.active {
background: #ffffff; background: #ffffff;
color: #050505; color: #050505;
@@ -1378,6 +1402,42 @@ canvas {
color: #050505; color: #050505;
} }
.editor-checkbox-row {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
margin-top: 9px;
padding: 10px 11px;
background: #101010;
border: 1px solid #242424;
border-radius: 14px;
color: #f2f2f2;
cursor: pointer;
}
.editor-checkbox-row input {
width: 16px;
height: 16px;
accent-color: #ffffff;
}
.editor-checkbox-row span {
display: grid;
gap: 2px;
}
.editor-checkbox-row strong {
font-size: 0.82rem;
line-height: 1.1;
}
.editor-checkbox-row small {
color: #8f8f8f;
font-size: 0.68rem;
line-height: 1.2;
}
.editor-selected-info { .editor-selected-info {
display: grid; display: grid;
grid-template-columns: 17px 1fr auto; grid-template-columns: 17px 1fr auto;
+94 -2
View File
@@ -9,7 +9,12 @@ import { Subtitles } from "@/components/ui/Subtitles";
import { useEditorHistory } from "@/hooks/editor/useEditorHistory"; import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
import type { CinematicDefinition } from "@/types/cinematics/cinematics"; import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData"; 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 { import {
INITIAL_SCENE_LOADING_STATE, INITIAL_SCENE_LOADING_STATE,
type SceneLoadingChangeHandler, type SceneLoadingChangeHandler,
@@ -24,7 +29,74 @@ interface EditorSceneLoadingTrackerProps {
} }
function serializeMapNodes(sceneData: SceneData): string { 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<string, MapNode>();
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({ function EditorSceneLoadingTracker({
@@ -69,6 +141,7 @@ export function EditorPage(): React.JSX.Element {
useState<TransformMode>("translate"); useState<TransformMode>("translate");
const [isPlayerMode, setIsPlayerMode] = useState(false); const [isPlayerMode, setIsPlayerMode] = useState(false);
const [isSelectionLocked, setIsSelectionLocked] = useState(false); const [isSelectionLocked, setIsSelectionLocked] = useState(false);
const [lockTerrainSelection, setLockTerrainSelection] = useState(true);
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>( const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
{ {
...INITIAL_SCENE_LOADING_STATE, ...INITIAL_SCENE_LOADING_STATE,
@@ -122,6 +195,22 @@ export function EditorPage(): React.JSX.Element {
setIsSelectionLocked((locked) => !locked); setIsSelectionLocked((locked) => !locked);
}, []); }, []);
const handleTerrainSelectionLockChange = useCallback(
(locked: boolean) => {
setLockTerrainSelection(locked);
if (!locked) return;
setSelectedNodeIndex((currentIndex) => {
if (currentIndex === null) return null;
const selectedNode = sceneData?.mapNodes[currentIndex];
return selectedNode?.name === "terrain" ? null : currentIndex;
});
},
[sceneData],
);
const handleHoverNode = useCallback((index: number | null) => { const handleHoverNode = useCallback((index: number | null) => {
setHoveredNodeIndex(index); setHoveredNodeIndex(index);
}, []); }, []);
@@ -279,6 +368,7 @@ export function EditorPage(): React.JSX.Element {
hoveredNodeIndex={hoveredNodeIndex} hoveredNodeIndex={hoveredNodeIndex}
onHoverNode={handleHoverNode} onHoverNode={handleHoverNode}
transformMode={transformMode} transformMode={transformMode}
lockTerrainSelection={lockTerrainSelection}
onTransformModeChange={handleTransformModeChange} onTransformModeChange={handleTransformModeChange}
onTransformStart={handleTransformStart} onTransformStart={handleTransformStart}
onTransformEnd={handleTransformEnd} onTransformEnd={handleTransformEnd}
@@ -306,6 +396,8 @@ export function EditorPage(): React.JSX.Element {
? sceneData.mapNodes[selectedNodeIndex].name || null ? sceneData.mapNodes[selectedNodeIndex].name || null
: null : null
} }
lockTerrainSelection={lockTerrainSelection}
onLockTerrainSelectionChange={handleTerrainSelectionLockChange}
isSelectionLocked={isSelectionLocked} isSelectionLocked={isSelectionLocked}
onSelectionLockToggle={handleSelectionLockToggle} onSelectionLockToggle={handleSelectionLockToggle}
onClearSelection={handleClearSelection} onClearSelection={handleClearSelection}
+2
View File
@@ -6,6 +6,7 @@ export interface MapNode {
position: Vector3Tuple; position: Vector3Tuple;
rotation: Vector3Tuple; rotation: Vector3Tuple;
scale: Vector3Tuple; scale: Vector3Tuple;
sourcePath?: number[];
} }
export interface HierarchicalMapNode extends MapNode { export interface HierarchicalMapNode extends MapNode {
@@ -16,6 +17,7 @@ export interface HierarchicalMapNode extends MapNode {
export interface SceneData { export interface SceneData {
mapNodes: MapNode[]; mapNodes: MapNode[];
models: Map<string, string>; models: Map<string, string>;
mapTree?: HierarchicalMapNode | HierarchicalMapNode[];
} }
export type TransformMode = "translate" | "rotate" | "scale"; export type TransformMode = "translate" | "rotate" | "scale";
+3 -3
View File
@@ -1,5 +1,5 @@
import type { SceneData } from "@/types/editor/editor"; 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"; const MAP_JSON_PATH = "/map.json";
@@ -18,7 +18,7 @@ export async function createSceneDataFromFiles(
} }
const mapPayload: unknown = JSON.parse(await mapFile.text()); const mapPayload: unknown = JSON.parse(await mapFile.text());
const mapNodes = parseMapNodes(mapPayload); const { mapNodes, mapTree } = parseMapData(mapPayload);
const models = new Map<string, string>(); const models = new Map<string, string>();
for (const [path, file] of fileMap.entries()) { 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 { function getProjectRelativePath(file: File): string {
+19 -8
View File
@@ -1,5 +1,9 @@
import type { MapNode, SceneData } from "@/types/editor/editor"; import type {
import { parseMapNodes } from "@/utils/map/mapNodeValidation"; HierarchicalMapNode,
MapNode,
SceneData,
} from "@/types/editor/editor";
import { parseMapData } from "@/utils/map/mapNodeValidation";
const MAP_JSON_PATH = "/map.json"; const MAP_JSON_PATH = "/map.json";
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"]; const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
@@ -21,8 +25,12 @@ export async function loadMapSceneData(): Promise<SceneData | null> {
} }
loadingPromise = loadMapSceneDataInternal(); loadingPromise = loadMapSceneDataInternal();
cachedSceneData = await loadingPromise;
loadingPromise = null; try {
cachedSceneData = await loadingPromise;
} finally {
loadingPromise = null;
}
return cachedSceneData; return cachedSceneData;
} }
@@ -45,9 +53,9 @@ async function loadMapSceneDataInternal(): Promise<SceneData | null> {
} }
const mapPayload: unknown = await response.json(); const mapPayload: unknown = await response.json();
const mapNodes = parseMapNodes(mapPayload); const { mapNodes, mapTree } = parseMapData(mapPayload);
const deduplicatedNodes = deduplicateMapNodes(mapNodes); const deduplicatedNodes = deduplicateMapNodes(mapNodes);
return createSceneData(deduplicatedNodes); return createSceneData(deduplicatedNodes, mapTree);
} }
function createPositionKey(node: MapNode): string { function createPositionKey(node: MapNode): string {
@@ -84,9 +92,12 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] {
return result; return result;
} }
async function createSceneData(mapNodes: MapNode[]): Promise<SceneData> { async function createSceneData(
mapNodes: MapNode[],
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): Promise<SceneData> {
const models = await loadMapModelUrls(mapNodes); const models = await loadMapModelUrls(mapNodes);
return { mapNodes, models }; return { mapNodes, models, mapTree };
} }
async function loadMapModelUrls( async function loadMapModelUrls(
+23 -4
View File
@@ -1,5 +1,10 @@
import type { HierarchicalMapNode, MapNode } from "../../types/editor/editor"; import type { HierarchicalMapNode, MapNode } from "../../types/editor/editor";
export interface ParsedMapNodes {
mapNodes: MapNode[];
mapTree: HierarchicalMapNode | HierarchicalMapNode[];
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; 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 = { const mapNode: MapNode = {
name: node.name, name: node.name,
type: node.type, type: node.type,
position: node.position, position: node.position,
rotation: node.rotation, rotation: node.rotation,
scale: node.scale, 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") { if (node.role === "group") {
return childNodes; return childNodes;
@@ -64,12 +73,22 @@ function flattenMapNode(node: HierarchicalMapNode): MapNode[] {
} }
export function parseMapNodes(value: unknown): MapNode[] { export function parseMapNodes(value: unknown): MapNode[] {
return parseMapData(value).mapNodes;
}
export function parseMapData(value: unknown): ParsedMapNodes {
if (Array.isArray(value) && value.every(isHierarchicalMapNode)) { 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)) { if (isHierarchicalMapNode(value)) {
return flattenMapNode(value); return {
mapNodes: flattenMapNode(value, []),
mapTree: value,
};
} }
throw new Error("Invalid map node data"); throw new Error("Invalid map node data");
+39
View File
@@ -0,0 +1,39 @@
import type { MapNode } from "@/types/editor/editor";
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
const RUNTIME_VEGETATION_NODE_NAMES = new Set([
"arbre",
"buisson",
"champdeble",
"champdesoja",
"champsdetournesol",
"sapin",
]);
export function isRuntimeStructureMapNode(name: string): boolean {
return MAP_STRUCTURE_NODE_NAMES.has(name);
}
export function isRuntimeSingleMapNode(node: MapNode): boolean {
if (isRuntimeStructureMapNode(node.name)) {
return false;
}
if (node.type === "Mesh") {
return false;
}
return (
!RUNTIME_VEGETATION_NODE_NAMES.has(node.name) &&
!isInstancedMapNodeName(node.name)
);
}
export function isEditorVisibleMapNode(node: MapNode): boolean {
return !isRuntimeStructureMapNode(node.name) && node.type !== "Mesh";
}
export function getTerrainMapNode(nodes: readonly MapNode[]): MapNode | null {
return nodes.find((node) => node.name === "terrain") ?? null;
}
+21 -37
View File
@@ -24,13 +24,16 @@ import { CloudSystem } from "@/world/clouds/CloudSystem";
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance"; import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig"; import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem"; import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
import { VegetationSystem } from "@/world/vegetation/VegetationSystem"; import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
import { WaterSystem } from "@/world/water/WaterSystem"; import { WaterSystem } from "@/world/water/WaterSystem";
import { WorldPlane } from "@/world/WorldPlane"; import { WorldPlane } from "@/world/WorldPlane";
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading"; import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
import { logger } from "@/utils/core/Logger"; import { logger } from "@/utils/core/Logger";
import { loadMapSceneData } from "@/utils/map/loadMapSceneData"; import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
import {
getTerrainMapNode,
isRuntimeSingleMapNode,
} from "@/utils/map/mapRuntimeClassification";
import { logModelLoadError } from "@/utils/three/modelLoadLogger"; import { logModelLoadError } from "@/utils/three/modelLoadLogger";
import type { MapNode } from "@/types/editor/editor"; import type { MapNode } from "@/types/editor/editor";
import type { OctreeReadyHandler } from "@/types/three/three"; import type { OctreeReadyHandler } from "@/types/three/three";
@@ -40,16 +43,6 @@ interface LoadedMapNode {
modelUrl: string | null; modelUrl: string | null;
} }
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
const LITE_MAP_SKIPPED_NODE_NAMES = new Set([
"arbre",
"buisson",
"champdeble",
"champdesoja",
"champsdetournesol",
"sapin",
]);
interface ErrorBoundaryProps { interface ErrorBoundaryProps {
children: ReactNode; children: ReactNode;
fallback: ReactNode; fallback: ReactNode;
@@ -118,9 +111,10 @@ export function GameMap({
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>( const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
[], [],
); );
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
const [mapLoaded, setMapLoaded] = useState(false); const [mapLoaded, setMapLoaded] = useState(false);
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0); const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
const mapReady = mapLoaded && settledMapNodeCount >= renderMapNodes.length; const mapReady = mapLoaded;
const handleMapNodeSettled = useCallback((index: number) => { const handleMapNodeSettled = useCallback((index: number) => {
if (settledMapNodesRef.current.has(index)) return; if (settledMapNodesRef.current.has(index)) return;
@@ -133,6 +127,7 @@ export function GameMap({
(currentStep: string) => { (currentStep: string) => {
setRenderMapNodes([]); setRenderMapNodes([]);
setCollisionMapNodes([]); setCollisionMapNodes([]);
setTerrainNode(null);
setMapLoaded(true); setMapLoaded(true);
settledMapNodesRef.current.clear(); settledMapNodesRef.current.clear();
setSettledMapNodeCount(0); setSettledMapNodeCount(0);
@@ -167,7 +162,9 @@ export function GameMap({
status: "loading", status: "loading",
}); });
const visibleMapNodes = sceneData.mapNodes.filter(liteMap); const visibleMapNodes = sceneData.mapNodes.filter(
isRuntimeSingleMapNode,
);
const skippedMapNodeCount = const skippedMapNodeCount =
sceneData.mapNodes.length - visibleMapNodes.length; sceneData.mapNodes.length - visibleMapNodes.length;
@@ -187,6 +184,7 @@ export function GameMap({
const modelUrl = sceneData.models.get(node.name); const modelUrl = sceneData.models.get(node.name);
return { node, modelUrl: modelUrl ?? null }; return { node, modelUrl: modelUrl ?? null };
}); });
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
const missingModelCount = loadedMapNodes.filter( const missingModelCount = loadedMapNodes.filter(
(mapNode) => mapNode.modelUrl === null, (mapNode) => mapNode.modelUrl === null,
).length; ).length;
@@ -203,6 +201,7 @@ export function GameMap({
setRenderMapNodes(loadedMapNodes); setRenderMapNodes(loadedMapNodes);
setCollisionMapNodes(loadedCollisionNodes); setCollisionMapNodes(loadedCollisionNodes);
setTerrainNode(loadedTerrainNode);
setMapLoaded(true); setMapLoaded(true);
settledMapNodesRef.current.clear(); settledMapNodesRef.current.clear();
setSettledMapNodeCount(0); setSettledMapNodeCount(0);
@@ -265,7 +264,15 @@ export function GameMap({
<CloudSystem /> <CloudSystem />
<VegetationSystem /> <VegetationSystem />
{isMapModelVisible("terrain", { groups, models }) ? ( {isMapModelVisible("terrain", { groups, models }) ? (
<TerrainModel /> terrainNode ? (
<TerrainModel
position={terrainNode.position}
rotation={terrainNode.rotation}
scale={terrainNode.scale}
/>
) : (
<TerrainModel />
)
) : null} ) : null}
<GameMapCollision <GameMapCollision
buildOctree={buildOctree} buildOctree={buildOctree}
@@ -287,29 +294,6 @@ function HiddenMapNode({ onSettled }: { onSettled: () => void }): null {
return null; return null;
} }
/**
* Temporary development-only map reducer.
*
* TODO: replace this with a real map performance pass: merged static geometry,
* instancing for repeated props, LOD, and/or zone-based loading. For now this
* keeps the app usable on local machines by not rendering the densest exported
* nodes from map.json.
*/
function liteMap(node: MapNode): boolean {
if (MAP_STRUCTURE_NODE_NAMES.has(node.name)) {
return false;
}
if (node.type === "Mesh") {
return false;
}
return (
!LITE_MAP_SKIPPED_NODE_NAMES.has(node.name) &&
!isInstancedMapNodeName(node.name)
);
}
function MapNodeInstance({ function MapNodeInstance({
node, node,
modelUrl, modelUrl,
+8
View File
@@ -62,3 +62,11 @@ export const INSTANCED_MAP_EXCEPTIONS = new Set([
"blocking", "blocking",
"terrain", "terrain",
]); ]);
export function getVegetationScaleMultiplier(name: string): number | null {
const config = Object.values(VEGETATION_TYPES).find(
(vegetationConfig) => vegetationConfig.mapName === name,
);
return config?.scaleMultiplier ?? null;
}