Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ee13b0336 | |||
| fdd530a3e7 | |||
| 2b676d985d | |||
| b89eedd5be | |||
| d38ad242d6 |
@@ -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}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
@@ -257,6 +267,20 @@ export function EditorControls({
|
||||
{viewModeLabel}
|
||||
</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
|
||||
@@ -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 {
|
||||
number: number;
|
||||
content: string;
|
||||
|
||||
@@ -3,9 +3,19 @@ import { Grid, TransformControls } from "@react-three/drei";
|
||||
import type { ThreeEvent } from "@react-three/fiber";
|
||||
import * as THREE from "three";
|
||||
|
||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||
import {
|
||||
normalizeMapScale,
|
||||
useTerrainSnappedPosition,
|
||||
} from "@/hooks/three/useTerrainHeight";
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||
import {
|
||||
isEditorVisibleMapNode,
|
||||
getTerrainMapNode,
|
||||
} from "@/utils/map/mapRuntimeClassification";
|
||||
import { getVegetationScaleMultiplier } from "@/world/vegetation/vegetationConfig";
|
||||
|
||||
interface EditorMapProps {
|
||||
sceneData: SceneData;
|
||||
@@ -15,6 +25,7 @@ interface EditorMapProps {
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
lockTerrainSelection: boolean;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
@@ -138,6 +149,7 @@ export function EditorMap({
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
lockTerrainSelection,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
@@ -169,6 +181,13 @@ export function EditorMap({
|
||||
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | 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(() => {
|
||||
if (selectedNodeIndex !== null) {
|
||||
@@ -196,14 +215,29 @@ export function EditorMap({
|
||||
/>
|
||||
<axesHelper args={[10]} />
|
||||
|
||||
<group
|
||||
onClick={(event: ThreeEvent<MouseEvent>) => {
|
||||
event.stopPropagation();
|
||||
if (isSelectionLocked) return;
|
||||
onSelectNode(null);
|
||||
}}
|
||||
>
|
||||
<group>
|
||||
{terrainNode ? (
|
||||
<EditorTerrainNode
|
||||
index={terrainNodeIndex}
|
||||
node={terrainNode}
|
||||
isSelected={selectedNodeIndex === terrainNodeIndex}
|
||||
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
objectsMapRef={objectsMapRef}
|
||||
onSelectNode={onSelectNode}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onHoverNode={onHoverNode}
|
||||
/>
|
||||
) : null}
|
||||
{sceneData.mapNodes.map((node, index) => {
|
||||
if (!isEditorVisibleMapNode(node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (selectedModelName && node.name !== selectedModelName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
|
||||
if (modelUrl) {
|
||||
@@ -265,14 +299,23 @@ function EditorModelNode({
|
||||
modelUrl: string;
|
||||
}) {
|
||||
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(
|
||||
new Map<THREE.Mesh, THREE.Material | THREE.Material[]>(),
|
||||
);
|
||||
const { scene } = useLoggedGLTF(modelUrl, {
|
||||
scope: "EditorMap.EditorModelNode",
|
||||
position: node.position,
|
||||
position: snappedPosition,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
scale: normalizedScale,
|
||||
});
|
||||
const sceneInstance = useClonedObject(scene);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
@@ -281,7 +324,16 @@ function EditorModelNode({
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
|
||||
useRegisteredEditorNode(
|
||||
groupRef,
|
||||
index,
|
||||
{
|
||||
...node,
|
||||
position: snappedPosition,
|
||||
scale: normalizedScale,
|
||||
},
|
||||
objectsMapRef,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) return;
|
||||
@@ -338,11 +390,42 @@ function EditorModelNode({
|
||||
<primitive
|
||||
ref={groupRef}
|
||||
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}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
{...pointerHandlers}
|
||||
/>
|
||||
{...(lockTerrainSelection ? {} : pointerHandlers)}
|
||||
>
|
||||
<TerrainModel receiveShadow visible />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -357,22 +440,33 @@ function EditorFallbackNode({
|
||||
onHoverNode,
|
||||
}: EditorNodeCommonProps) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
const snappedPosition = useTerrainSnappedPosition(node.position);
|
||||
const normalizedScale = normalizeMapScale(node.scale);
|
||||
const pointerHandlers = createEditorNodePointerHandlers(
|
||||
index,
|
||||
onSelectNode,
|
||||
isSelectionLocked,
|
||||
onHoverNode,
|
||||
);
|
||||
useRegisteredEditorNode(meshRef, index, node, objectsMapRef);
|
||||
useRegisteredEditorNode(
|
||||
meshRef,
|
||||
index,
|
||||
{
|
||||
...node,
|
||||
position: snappedPosition,
|
||||
scale: normalizedScale,
|
||||
},
|
||||
objectsMapRef,
|
||||
);
|
||||
|
||||
const color = getNodeHighlightColor(isSelected, isHovered) ?? "#6f6f6f";
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={node.position}
|
||||
position={snappedPosition}
|
||||
rotation={node.rotation}
|
||||
scale={node.scale}
|
||||
scale={normalizedScale}
|
||||
{...pointerHandlers}
|
||||
>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
|
||||
@@ -21,6 +21,7 @@ interface EditorSceneProps {
|
||||
hoveredNodeIndex: number | null;
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
lockTerrainSelection: boolean;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
@@ -40,6 +41,7 @@ export function EditorScene({
|
||||
hoveredNodeIndex,
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
lockTerrainSelection,
|
||||
onTransformModeChange,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
@@ -126,6 +128,7 @@ export function EditorScene({
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+61
-1
@@ -1244,7 +1244,7 @@ canvas {
|
||||
|
||||
.editor-transform-button {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
@@ -1264,6 +1264,30 @@ canvas {
|
||||
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 {
|
||||
background: #ffffff;
|
||||
color: #050505;
|
||||
@@ -1378,6 +1402,42 @@ canvas {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 17px 1fr auto;
|
||||
|
||||
@@ -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<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({
|
||||
@@ -69,6 +141,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
useState<TransformMode>("translate");
|
||||
const [isPlayerMode, setIsPlayerMode] = useState(false);
|
||||
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
|
||||
const [lockTerrainSelection, setLockTerrainSelection] = useState(true);
|
||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||
{
|
||||
...INITIAL_SCENE_LOADING_STATE,
|
||||
@@ -122,6 +195,22 @@ export function EditorPage(): React.JSX.Element {
|
||||
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) => {
|
||||
setHoveredNodeIndex(index);
|
||||
}, []);
|
||||
@@ -279,6 +368,7 @@ export function EditorPage(): React.JSX.Element {
|
||||
hoveredNodeIndex={hoveredNodeIndex}
|
||||
onHoverNode={handleHoverNode}
|
||||
transformMode={transformMode}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
onTransformStart={handleTransformStart}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
@@ -306,6 +396,8 @@ export function EditorPage(): React.JSX.Element {
|
||||
? sceneData.mapNodes[selectedNodeIndex].name || null
|
||||
: null
|
||||
}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onLockTerrainSelectionChange={handleTerrainSelectionLockChange}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onSelectionLockToggle={handleSelectionLockToggle}
|
||||
onClearSelection={handleClearSelection}
|
||||
|
||||
@@ -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<string, string>;
|
||||
mapTree?: HierarchicalMapNode | HierarchicalMapNode[];
|
||||
}
|
||||
|
||||
export type TransformMode = "translate" | "rotate" | "scale";
|
||||
|
||||
@@ -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<string, string>();
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<SceneData | null> {
|
||||
}
|
||||
|
||||
loadingPromise = loadMapSceneDataInternal();
|
||||
cachedSceneData = await loadingPromise;
|
||||
loadingPromise = null;
|
||||
|
||||
try {
|
||||
cachedSceneData = await loadingPromise;
|
||||
} finally {
|
||||
loadingPromise = null;
|
||||
}
|
||||
|
||||
return cachedSceneData;
|
||||
}
|
||||
@@ -45,9 +53,9 @@ async function loadMapSceneDataInternal(): Promise<SceneData | null> {
|
||||
}
|
||||
|
||||
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<SceneData> {
|
||||
async function createSceneData(
|
||||
mapNodes: MapNode[],
|
||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||
): Promise<SceneData> {
|
||||
const models = await loadMapModelUrls(mapNodes);
|
||||
return { mapNodes, models };
|
||||
return { mapNodes, models, mapTree };
|
||||
}
|
||||
|
||||
async function loadMapModelUrls(
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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");
|
||||
|
||||
@@ -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
@@ -24,13 +24,16 @@ import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
|
||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||
import { WorldPlane } from "@/world/WorldPlane";
|
||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||
import { logger } from "@/utils/core/Logger";
|
||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||
import {
|
||||
getTerrainMapNode,
|
||||
isRuntimeSingleMapNode,
|
||||
} from "@/utils/map/mapRuntimeClassification";
|
||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||
import type { MapNode } from "@/types/editor/editor";
|
||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||
@@ -40,16 +43,6 @@ interface LoadedMapNode {
|
||||
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 {
|
||||
children: ReactNode;
|
||||
fallback: ReactNode;
|
||||
@@ -118,9 +111,10 @@ export function GameMap({
|
||||
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
|
||||
[],
|
||||
);
|
||||
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||
const mapReady = mapLoaded && settledMapNodeCount >= renderMapNodes.length;
|
||||
const mapReady = mapLoaded;
|
||||
|
||||
const handleMapNodeSettled = useCallback((index: number) => {
|
||||
if (settledMapNodesRef.current.has(index)) return;
|
||||
@@ -133,6 +127,7 @@ export function GameMap({
|
||||
(currentStep: string) => {
|
||||
setRenderMapNodes([]);
|
||||
setCollisionMapNodes([]);
|
||||
setTerrainNode(null);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
setSettledMapNodeCount(0);
|
||||
@@ -167,7 +162,9 @@ export function GameMap({
|
||||
status: "loading",
|
||||
});
|
||||
|
||||
const visibleMapNodes = sceneData.mapNodes.filter(liteMap);
|
||||
const visibleMapNodes = sceneData.mapNodes.filter(
|
||||
isRuntimeSingleMapNode,
|
||||
);
|
||||
const skippedMapNodeCount =
|
||||
sceneData.mapNodes.length - visibleMapNodes.length;
|
||||
|
||||
@@ -187,6 +184,7 @@ export function GameMap({
|
||||
const modelUrl = sceneData.models.get(node.name);
|
||||
return { node, modelUrl: modelUrl ?? null };
|
||||
});
|
||||
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||
const missingModelCount = loadedMapNodes.filter(
|
||||
(mapNode) => mapNode.modelUrl === null,
|
||||
).length;
|
||||
@@ -203,6 +201,7 @@ export function GameMap({
|
||||
|
||||
setRenderMapNodes(loadedMapNodes);
|
||||
setCollisionMapNodes(loadedCollisionNodes);
|
||||
setTerrainNode(loadedTerrainNode);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
setSettledMapNodeCount(0);
|
||||
@@ -265,7 +264,15 @@ export function GameMap({
|
||||
<CloudSystem />
|
||||
<VegetationSystem />
|
||||
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||
<TerrainModel />
|
||||
terrainNode ? (
|
||||
<TerrainModel
|
||||
position={terrainNode.position}
|
||||
rotation={terrainNode.rotation}
|
||||
scale={terrainNode.scale}
|
||||
/>
|
||||
) : (
|
||||
<TerrainModel />
|
||||
)
|
||||
) : null}
|
||||
<GameMapCollision
|
||||
buildOctree={buildOctree}
|
||||
@@ -287,29 +294,6 @@ function HiddenMapNode({ onSettled }: { onSettled: () => void }): 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({
|
||||
node,
|
||||
modelUrl,
|
||||
|
||||
@@ -62,3 +62,11 @@ export const INSTANCED_MAP_EXCEPTIONS = new Set([
|
||||
"blocking",
|
||||
"terrain",
|
||||
]);
|
||||
|
||||
export function getVegetationScaleMultiplier(name: string): number | null {
|
||||
const config = Object.values(VEGETATION_TYPES).find(
|
||||
(vegetationConfig) => vegetationConfig.mapName === name,
|
||||
);
|
||||
|
||||
return config?.scaleMultiplier ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user