Merge remote map editor updates
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
RotateCw,
|
||||
Save,
|
||||
Trash2,
|
||||
ScanSearch,
|
||||
Undo2,
|
||||
Unlock,
|
||||
X,
|
||||
@@ -21,17 +22,19 @@ 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 { EditableMapNode, TransformMode } from "@/types/editor/editor";
|
||||
import type { MapNode, TransformMode } from "@/types/editor/editor";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
interface EditorControlsProps {
|
||||
transformMode: TransformMode;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
selectedNodeIndex: number | null;
|
||||
mapNodes: EditableMapNode[];
|
||||
mapNodes: MapNode[];
|
||||
nodesCount: number;
|
||||
selectedNodeName: string | null;
|
||||
selectedNodeScale: Vector3Tuple | null;
|
||||
lockTerrainSelection: boolean;
|
||||
onLockTerrainSelectionChange: (locked: boolean) => void;
|
||||
isSelectionLocked: boolean;
|
||||
onSelectionLockToggle: () => void;
|
||||
onClearSelection: () => void;
|
||||
@@ -46,6 +49,8 @@ interface EditorControlsProps {
|
||||
redoCount: number;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
cameraActionLabel: string;
|
||||
onCameraAction: () => void;
|
||||
onExportJson: () => void;
|
||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||
onPlayerMode?: (() => void) | undefined;
|
||||
@@ -102,6 +107,8 @@ export function EditorControls({
|
||||
nodesCount,
|
||||
selectedNodeName,
|
||||
selectedNodeScale,
|
||||
lockTerrainSelection,
|
||||
onLockTerrainSelectionChange,
|
||||
isSelectionLocked,
|
||||
onSelectionLockToggle,
|
||||
onClearSelection,
|
||||
@@ -116,6 +123,8 @@ export function EditorControls({
|
||||
redoCount,
|
||||
onUndo,
|
||||
onRedo,
|
||||
cameraActionLabel,
|
||||
onCameraAction,
|
||||
onExportJson,
|
||||
onSaveToServer,
|
||||
onPlayerMode,
|
||||
@@ -124,6 +133,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 (
|
||||
<>
|
||||
@@ -174,7 +186,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>
|
||||
))}
|
||||
@@ -339,6 +354,25 @@ export function EditorControls({
|
||||
{viewModeLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button className="editor-action-button" onClick={onCameraAction}>
|
||||
<ScanSearch size={16} aria-hidden="true" />
|
||||
{cameraActionLabel}
|
||||
</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
|
||||
@@ -411,6 +445,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;
|
||||
@@ -423,7 +493,7 @@ interface JsonPreview {
|
||||
}
|
||||
|
||||
function getJsonPreview(
|
||||
mapNodes: EditableMapNode[],
|
||||
mapNodes: MapNode[],
|
||||
selectedNodeIndex: number | null,
|
||||
): JsonPreview {
|
||||
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
||||
@@ -452,7 +522,7 @@ function getJsonPreview(
|
||||
};
|
||||
}
|
||||
|
||||
function formatMapNodesWithRanges(mapNodes: EditableMapNode[]): {
|
||||
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
||||
lines: string[];
|
||||
ranges: Array<{ start: number; end: number }>;
|
||||
} {
|
||||
|
||||
@@ -3,10 +3,15 @@ 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 { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||
import {
|
||||
isEditorVisibleMapNode,
|
||||
getTerrainMapNode,
|
||||
} from "@/utils/map/mapRuntimeClassification";
|
||||
|
||||
interface EditorMapProps {
|
||||
sceneData: SceneData;
|
||||
@@ -17,6 +22,7 @@ interface EditorMapProps {
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
snapToTerrain: boolean;
|
||||
lockTerrainSelection: boolean;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
@@ -141,6 +147,7 @@ export function EditorMap({
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
snapToTerrain,
|
||||
lockTerrainSelection,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
@@ -153,6 +160,11 @@ export function EditorMap({
|
||||
};
|
||||
|
||||
const handleTransformMouseUp = () => {
|
||||
syncSelectedObjectTransform();
|
||||
onTransformEnd();
|
||||
};
|
||||
|
||||
const syncSelectedObjectTransform = () => {
|
||||
if (selectedNodeIndex !== null) {
|
||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||
if (!obj) return;
|
||||
@@ -180,12 +192,18 @@ export function EditorMap({
|
||||
onNodeTransform(selectedNodeIndex, updatedNode);
|
||||
}
|
||||
}
|
||||
onTransformEnd();
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -213,14 +231,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) {
|
||||
@@ -262,6 +295,7 @@ export function EditorMap({
|
||||
mode={transformMode}
|
||||
onMouseDown={handleTransformMouseDown}
|
||||
onMouseUp={handleTransformMouseUp}
|
||||
onObjectChange={syncSelectedObjectTransform}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -363,6 +397,37 @@ function EditorModelNode({
|
||||
);
|
||||
}
|
||||
|
||||
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}
|
||||
{...(lockTerrainSelection ? {} : pointerHandlers)}
|
||||
>
|
||||
<TerrainModel receiveShadow visible />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorFallbackNode({
|
||||
index,
|
||||
node,
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import { useThree } from "@react-three/fiber";
|
||||
import gsap from "gsap";
|
||||
import * as THREE from "three";
|
||||
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||
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";
|
||||
|
||||
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
|
||||
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
|
||||
|
||||
export interface EditorCinematicPreviewRequest {
|
||||
id: string;
|
||||
cinematic: CinematicDefinition;
|
||||
@@ -23,12 +27,15 @@ interface EditorSceneProps {
|
||||
onHoverNode: (index: number | null) => void;
|
||||
transformMode: TransformMode;
|
||||
snapToTerrain: boolean;
|
||||
lockTerrainSelection: boolean;
|
||||
onTransformModeChange: (mode: TransformMode) => void;
|
||||
onTransformStart: () => void;
|
||||
onTransformEnd: () => void;
|
||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
resetCameraRequest: number;
|
||||
focusSelectedCameraRequest: number;
|
||||
isPlayerMode?: boolean;
|
||||
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||
@@ -43,17 +50,94 @@ export function EditorScene({
|
||||
onHoverNode,
|
||||
transformMode,
|
||||
snapToTerrain,
|
||||
lockTerrainSelection,
|
||||
onTransformModeChange,
|
||||
onTransformStart,
|
||||
onTransformEnd,
|
||||
onNodeTransform,
|
||||
onUndo,
|
||||
onRedo,
|
||||
resetCameraRequest,
|
||||
focusSelectedCameraRequest,
|
||||
isPlayerMode = false,
|
||||
cinematicPreviewRequest = null,
|
||||
onCinematicPreviewComplete,
|
||||
}: EditorSceneProps): React.JSX.Element {
|
||||
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
||||
const camera = useThree((state) => state.camera);
|
||||
const orbitControlsRef = useRef<OrbitControlsImpl | null>(null);
|
||||
const previousSelectedNodeIndexRef = useRef<number | null>(null);
|
||||
|
||||
const focusCameraOnNode = useCallback(
|
||||
(node: MapNode): void => {
|
||||
const controls = orbitControlsRef.current;
|
||||
const target = new THREE.Vector3(...node.position);
|
||||
const currentTarget = controls?.target ?? EDITOR_CAMERA_HOME_TARGET;
|
||||
const cameraOffset = camera.position.clone().sub(currentTarget);
|
||||
|
||||
camera.position.copy(target).add(cameraOffset);
|
||||
camera.lookAt(target);
|
||||
controls?.target.copy(target);
|
||||
controls?.update();
|
||||
},
|
||||
[camera],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNodeIndex === previousSelectedNodeIndexRef.current) return;
|
||||
previousSelectedNodeIndexRef.current = selectedNodeIndex;
|
||||
|
||||
if (selectedNodeIndex === null || isPlayerMode || isCinematicPreviewing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedNode = sceneData.mapNodes[selectedNodeIndex];
|
||||
if (!selectedNode) return;
|
||||
|
||||
focusCameraOnNode(selectedNode);
|
||||
}, [
|
||||
camera,
|
||||
isCinematicPreviewing,
|
||||
isPlayerMode,
|
||||
focusCameraOnNode,
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
focusSelectedCameraRequest === 0 ||
|
||||
selectedNodeIndex === null ||
|
||||
isPlayerMode ||
|
||||
isCinematicPreviewing
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedNode = sceneData.mapNodes[selectedNodeIndex];
|
||||
if (!selectedNode) return;
|
||||
|
||||
focusCameraOnNode(selectedNode);
|
||||
}, [
|
||||
focusSelectedCameraRequest,
|
||||
focusCameraOnNode,
|
||||
isCinematicPreviewing,
|
||||
isPlayerMode,
|
||||
sceneData,
|
||||
selectedNodeIndex,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resetCameraRequest === 0 || isPlayerMode || isCinematicPreviewing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = orbitControlsRef.current;
|
||||
camera.position.copy(EDITOR_CAMERA_HOME_POSITION);
|
||||
camera.lookAt(EDITOR_CAMERA_HOME_TARGET);
|
||||
controls?.target.copy(EDITOR_CAMERA_HOME_TARGET);
|
||||
controls?.update();
|
||||
}, [camera, isCinematicPreviewing, isPlayerMode, resetCameraRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -110,6 +194,7 @@ export function EditorScene({
|
||||
<FlyController disabled={isCinematicPreviewing} />
|
||||
) : (
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
enabled={!isCinematicPreviewing}
|
||||
enableDamping
|
||||
dampingFactor={0.05}
|
||||
@@ -130,6 +215,7 @@ export function EditorScene({
|
||||
onHoverNode={onHoverNode}
|
||||
transformMode={transformMode}
|
||||
snapToTerrain={snapToTerrain}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformStart={onTransformStart}
|
||||
onTransformEnd={onTransformEnd}
|
||||
onNodeTransform={onNodeTransform}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
|
||||
export const EBIKE_REPAIR_POSITION = [
|
||||
42.2399, 4.5484, 34.6468,
|
||||
] as const satisfies Vector3Tuple;
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
|
||||
interface ObjectTransform {
|
||||
uuid: string;
|
||||
path: number[];
|
||||
sourcePath?: number[];
|
||||
position: { x: number; y: number; z: number };
|
||||
rotation: { x: number; y: number; z: number };
|
||||
scale: { x: number; y: number; z: number };
|
||||
@@ -166,12 +166,14 @@ export function useEditorHistory(
|
||||
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
|
||||
} satisfies MapNode;
|
||||
|
||||
mapTree = updateTreeNodeAtPath(mapTree, node.path, transform);
|
||||
if (mapTree && node.sourcePath) {
|
||||
mapTree = updateTreeNodeAtPath(mapTree, node.sourcePath, transform);
|
||||
}
|
||||
|
||||
return nextNode;
|
||||
});
|
||||
|
||||
return { ...prev, mapNodes, mapTree };
|
||||
return mapTree ? { ...prev, mapNodes, mapTree } : { ...prev, mapNodes };
|
||||
});
|
||||
},
|
||||
[setSceneData],
|
||||
@@ -217,7 +219,7 @@ export function useEditorHistory(
|
||||
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
|
||||
return sceneData.mapNodes.map((node, index) => ({
|
||||
uuid: `node-${index}`,
|
||||
path: node.path,
|
||||
...(node.sourcePath ? { sourcePath: node.sourcePath } : {}),
|
||||
position: {
|
||||
x: node.position[0],
|
||||
y: node.position[1],
|
||||
|
||||
@@ -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(
|
||||
|
||||
+63
-2
@@ -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;
|
||||
@@ -1347,7 +1371,8 @@ canvas {
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.editor-action-button + .editor-action-button {
|
||||
.editor-action-button + .editor-action-button,
|
||||
.editor-player-button + .editor-action-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -1378,6 +1403,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;
|
||||
|
||||
@@ -124,7 +124,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
||||
},
|
||||
bike: {
|
||||
...state.bike,
|
||||
currentStep: "waiting",
|
||||
currentStep: "locked",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+179
-44
@@ -10,7 +10,6 @@ import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||
import type {
|
||||
EditableMapNode,
|
||||
HierarchicalMapNode,
|
||||
MapNode,
|
||||
SceneData,
|
||||
@@ -31,7 +30,74 @@ interface EditorSceneLoadingTrackerProps {
|
||||
}
|
||||
|
||||
function serializeMapNodes(sceneData: SceneData): string {
|
||||
return JSON.stringify(sceneData.mapTree, 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 cloneMapTree(
|
||||
@@ -42,32 +108,21 @@ function cloneMapTree(
|
||||
| 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[] = [];
|
||||
): MapNode[] {
|
||||
const nodes: MapNode[] = [];
|
||||
|
||||
function visit(node: HierarchicalMapNode, path: number[]): void {
|
||||
const editableNode = toEditableMapNode(node, path);
|
||||
if (editableNode) {
|
||||
nodes.push(editableNode);
|
||||
return;
|
||||
if (node.role !== "group" && node.type !== "Mesh") {
|
||||
nodes.push({
|
||||
name: node.name,
|
||||
position: node.position,
|
||||
rotation: node.rotation,
|
||||
scale: node.scale,
|
||||
sourcePath: path,
|
||||
type: node.type,
|
||||
});
|
||||
}
|
||||
|
||||
node.children?.forEach((child, index) => visit(child, [...path, index]));
|
||||
@@ -95,9 +150,10 @@ function updateTreeNodeAtPath(
|
||||
: path.length === 0;
|
||||
|
||||
if (isRootTarget) {
|
||||
rootNodes[targetIndex] = update(
|
||||
rootNodes[targetIndex] as HierarchicalMapNode,
|
||||
);
|
||||
const targetNode = rootNodes[targetIndex];
|
||||
if (targetNode) {
|
||||
rootNodes[targetIndex] = update(targetNode);
|
||||
}
|
||||
return nextTree;
|
||||
}
|
||||
|
||||
@@ -145,19 +201,6 @@ function removeTreeNodeAtPath(
|
||||
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[],
|
||||
@@ -199,6 +242,19 @@ function findNodePathByName(
|
||||
return visit(mapTree, []);
|
||||
}
|
||||
|
||||
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 createNewMapNode(name: string): HierarchicalMapNode {
|
||||
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
|
||||
|
||||
@@ -264,6 +320,13 @@ export function EditorPage(): React.JSX.Element {
|
||||
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
|
||||
const [snapToTerrain, setSnapToTerrain] = useState(true);
|
||||
const [newNodeName, setNewNodeName] = useState(DEFAULT_NEW_NODE_NAME);
|
||||
const [lockTerrainSelection, setLockTerrainSelection] = useState(true);
|
||||
const [resetCameraRequest, setResetCameraRequest] = useState(0);
|
||||
const [focusSelectedCameraRequest, setFocusSelectedCameraRequest] =
|
||||
useState(0);
|
||||
const [cameraViewMode, setCameraViewMode] = useState<"home" | "object">(
|
||||
"home",
|
||||
);
|
||||
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||
{
|
||||
...INITIAL_SCENE_LOADING_STATE,
|
||||
@@ -307,6 +370,9 @@ export function EditorPage(): React.JSX.Element {
|
||||
|
||||
const handleSelectNode = useCallback((index: number | null) => {
|
||||
setSelectedNodeIndex(index);
|
||||
if (index !== null) {
|
||||
setCameraViewMode("object");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
@@ -325,6 +391,22 @@ export function EditorPage(): React.JSX.Element {
|
||||
setNewNodeName(value);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
}, []);
|
||||
@@ -371,6 +453,17 @@ export function EditorPage(): React.JSX.Element {
|
||||
setIsPlayerMode((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleCameraAction = useCallback(() => {
|
||||
if (selectedNodeIndex !== null && cameraViewMode === "home") {
|
||||
setFocusSelectedCameraRequest((request) => request + 1);
|
||||
setCameraViewMode("object");
|
||||
return;
|
||||
}
|
||||
|
||||
setResetCameraRequest((request) => request + 1);
|
||||
setCameraViewMode("home");
|
||||
}, [cameraViewMode, selectedNodeIndex]);
|
||||
|
||||
const handlePreviewCinematic = useCallback(
|
||||
(cinematic: CinematicDefinition) => {
|
||||
setCinematicPreviewRequest({
|
||||
@@ -392,9 +485,15 @@ export function EditorPage(): React.JSX.Element {
|
||||
const currentNode = prev.mapNodes[nodeIndex];
|
||||
if (!currentNode) return prev;
|
||||
|
||||
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||
const mapNodes = [...prev.mapNodes];
|
||||
mapNodes[nodeIndex] = updatedNode;
|
||||
return { ...prev, mapNodes };
|
||||
}
|
||||
|
||||
const mapTree = updateTreeNodeAtPath(
|
||||
prev.mapTree,
|
||||
currentNode.path,
|
||||
currentNode.sourcePath,
|
||||
(node) => ({
|
||||
...node,
|
||||
position: updatedNode.position,
|
||||
@@ -402,7 +501,6 @@ export function EditorPage(): React.JSX.Element {
|
||||
scale: updatedNode.scale,
|
||||
}),
|
||||
);
|
||||
|
||||
return updateSceneDataTree(prev, mapTree);
|
||||
});
|
||||
},
|
||||
@@ -421,9 +519,15 @@ export function EditorPage(): React.JSX.Element {
|
||||
const nextScale = [...currentNode.scale] as [number, number, number];
|
||||
nextScale[axis] = value;
|
||||
|
||||
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||
const mapNodes = [...prev.mapNodes];
|
||||
mapNodes[selectedNodeIndex] = { ...currentNode, scale: nextScale };
|
||||
return { ...prev, mapNodes };
|
||||
}
|
||||
|
||||
const mapTree = updateTreeNodeAtPath(
|
||||
prev.mapTree,
|
||||
currentNode.path,
|
||||
currentNode.sourcePath,
|
||||
(node) => ({ ...node, scale: nextScale }),
|
||||
);
|
||||
|
||||
@@ -436,6 +540,13 @@ export function EditorPage(): React.JSX.Element {
|
||||
const handleAddNode = useCallback(() => {
|
||||
setSceneData((prev) => {
|
||||
if (!prev) return null;
|
||||
if (!prev.mapTree) {
|
||||
const newNode = createNewMapNode(newNodeName);
|
||||
const mapNodes = [...prev.mapNodes, removeEditorMetadata(newNode)];
|
||||
setSelectedNodeIndex(mapNodes.length - 1);
|
||||
return { ...prev, mapNodes };
|
||||
}
|
||||
|
||||
const mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
||||
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
||||
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
||||
@@ -450,7 +561,20 @@ export function EditorPage(): React.JSX.Element {
|
||||
if (!prev) return null;
|
||||
const currentNode = prev.mapNodes[selectedNodeIndex];
|
||||
if (!currentNode) return prev;
|
||||
const mapTree = removeTreeNodeAtPath(prev.mapTree, currentNode.path);
|
||||
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||
setSelectedNodeIndex(null);
|
||||
return {
|
||||
...prev,
|
||||
mapNodes: prev.mapNodes.filter(
|
||||
(_node, index) => index !== selectedNodeIndex,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const mapTree = removeTreeNodeAtPath(
|
||||
prev.mapTree,
|
||||
currentNode.sourcePath,
|
||||
);
|
||||
setSelectedNodeIndex(null);
|
||||
return updateSceneDataTree(prev, mapTree);
|
||||
});
|
||||
@@ -542,12 +666,15 @@ export function EditorPage(): React.JSX.Element {
|
||||
onHoverNode={handleHoverNode}
|
||||
transformMode={transformMode}
|
||||
snapToTerrain={snapToTerrain}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onTransformModeChange={handleTransformModeChange}
|
||||
onTransformStart={handleTransformStart}
|
||||
onTransformEnd={handleTransformEnd}
|
||||
onNodeTransform={handleNodeTransform}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
resetCameraRequest={resetCameraRequest}
|
||||
focusSelectedCameraRequest={focusSelectedCameraRequest}
|
||||
isPlayerMode={isPlayerMode}
|
||||
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||
@@ -574,6 +701,8 @@ export function EditorPage(): React.JSX.Element {
|
||||
? sceneData.mapNodes[selectedNodeIndex].scale
|
||||
: null
|
||||
}
|
||||
lockTerrainSelection={lockTerrainSelection}
|
||||
onLockTerrainSelectionChange={handleTerrainSelectionLockChange}
|
||||
isSelectionLocked={isSelectionLocked}
|
||||
onSelectionLockToggle={handleSelectionLockToggle}
|
||||
onClearSelection={handleClearSelection}
|
||||
@@ -588,6 +717,12 @@ export function EditorPage(): React.JSX.Element {
|
||||
redoCount={redoCount}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
cameraActionLabel={
|
||||
selectedNodeIndex !== null && cameraViewMode === "home"
|
||||
? "Center on object"
|
||||
: "Reset camera"
|
||||
}
|
||||
onCameraAction={handleCameraAction}
|
||||
onExportJson={handleExportJson}
|
||||
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
||||
onPlayerMode={handlePlayerMode}
|
||||
|
||||
@@ -6,10 +6,7 @@ export interface MapNode {
|
||||
position: Vector3Tuple;
|
||||
rotation: Vector3Tuple;
|
||||
scale: Vector3Tuple;
|
||||
}
|
||||
|
||||
export interface EditableMapNode extends MapNode {
|
||||
path: number[];
|
||||
sourcePath?: number[];
|
||||
}
|
||||
|
||||
export interface HierarchicalMapNode extends MapNode {
|
||||
@@ -18,9 +15,9 @@ export interface HierarchicalMapNode extends MapNode {
|
||||
}
|
||||
|
||||
export interface SceneData {
|
||||
mapNodes: EditableMapNode[];
|
||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[];
|
||||
mapNodes: MapNode[];
|
||||
models: Map<string, string>;
|
||||
mapTree?: HierarchicalMapNode | HierarchicalMapNode[];
|
||||
}
|
||||
|
||||
export type TransformMode = "translate" | "rotate" | "scale";
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type {
|
||||
EditableMapNode,
|
||||
HierarchicalMapNode,
|
||||
MapNode,
|
||||
SceneData,
|
||||
} from "@/types/editor/editor";
|
||||
import {
|
||||
parseHierarchicalMapPayload,
|
||||
parseMapNodes,
|
||||
} from "@/utils/map/mapNodeValidation";
|
||||
import { parseMapData } from "@/utils/map/mapNodeValidation";
|
||||
|
||||
const MAP_JSON_PATH = "/map.json";
|
||||
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
|
||||
@@ -29,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;
|
||||
}
|
||||
@@ -59,53 +59,9 @@ async function loadMapSceneDataInternal(): Promise<SceneData | null> {
|
||||
export async function createSceneDataFromMapPayload(
|
||||
mapPayload: unknown,
|
||||
): Promise<SceneData> {
|
||||
const mapTree = parseHierarchicalMapPayload(mapPayload);
|
||||
const mapNodes = parseMapNodes(mapTree);
|
||||
const editableNodes = createEditableMapNodes(mapTree);
|
||||
const { mapNodes, mapTree } = parseMapData(mapPayload);
|
||||
const deduplicatedNodes = deduplicateMapNodes(mapNodes);
|
||||
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, []);
|
||||
return createSceneData(deduplicatedNodes, mapTree);
|
||||
}
|
||||
|
||||
function createPositionKey(node: MapNode): string {
|
||||
@@ -142,36 +98,12 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
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(
|
||||
mapNodes: MapNode[],
|
||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||
mapNodes: EditableMapNode[],
|
||||
modelLookupNodes: MapNode[],
|
||||
): Promise<SceneData> {
|
||||
const models = await loadMapModelUrls(modelLookupNodes);
|
||||
return { mapNodes, mapTree, models };
|
||||
const models = await loadMapModelUrls(mapNodes);
|
||||
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;
|
||||
}
|
||||
@@ -48,19 +53,25 @@ export function isHierarchicalMapNode(
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
if (node.role === "group") {
|
||||
return node.children?.flatMap(flattenMapNode) ?? [];
|
||||
const childNodes =
|
||||
node.children?.flatMap((child, index) =>
|
||||
flattenMapNode(child, [...path, index]),
|
||||
) ?? [];
|
||||
|
||||
if (node.role === "group" || node.type === "Mesh") {
|
||||
return childNodes;
|
||||
}
|
||||
|
||||
return [mapNode];
|
||||
return [mapNode, ...childNodes];
|
||||
}
|
||||
|
||||
export function parseHierarchicalMapPayload(
|
||||
@@ -78,12 +89,22 @@ export function parseHierarchicalMapPayload(
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+37
-38
@@ -21,18 +21,22 @@ import {
|
||||
isMapModelVisible,
|
||||
useMapPerformanceStore,
|
||||
} from "@/managers/stores/useMapPerformanceStore";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||
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";
|
||||
@@ -42,16 +46,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;
|
||||
@@ -120,9 +114,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;
|
||||
@@ -135,6 +130,7 @@ export function GameMap({
|
||||
(currentStep: string) => {
|
||||
setRenderMapNodes([]);
|
||||
setCollisionMapNodes([]);
|
||||
setTerrainNode(null);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
setSettledMapNodeCount(0);
|
||||
@@ -169,7 +165,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;
|
||||
|
||||
@@ -189,6 +187,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;
|
||||
@@ -205,6 +204,7 @@ export function GameMap({
|
||||
|
||||
setRenderMapNodes(loadedMapNodes);
|
||||
setCollisionMapNodes(loadedCollisionNodes);
|
||||
setTerrainNode(loadedTerrainNode);
|
||||
setMapLoaded(true);
|
||||
settledMapNodesRef.current.clear();
|
||||
setSettledMapNodeCount(0);
|
||||
@@ -267,7 +267,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}
|
||||
@@ -289,29 +297,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,
|
||||
@@ -320,8 +305,12 @@ function MapNodeInstance({
|
||||
node: MapNode;
|
||||
modelUrl: string | null;
|
||||
onSettled: () => void;
|
||||
}): React.JSX.Element {
|
||||
}): React.JSX.Element | null {
|
||||
const isGeneratedModel = isGeneratedMapModelName(node.name);
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const hideEbikeMapModel =
|
||||
node.name === "ebike" && mainState === "bike" && bikeStep !== "locked";
|
||||
|
||||
useEffect(() => {
|
||||
if (modelUrl !== null || isGeneratedModel) return;
|
||||
@@ -329,6 +318,16 @@ function MapNodeInstance({
|
||||
onSettled();
|
||||
}, [isGeneratedModel, modelUrl, onSettled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hideEbikeMapModel) return;
|
||||
|
||||
onSettled();
|
||||
}, [hideEbikeMapModel, onSettled]);
|
||||
|
||||
if (hideEbikeMapModel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isGeneratedModel) {
|
||||
return (
|
||||
<Suspense fallback={<FallbackMapNode node={node} />}>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||
import { EBIKE_REPAIR_POSITION } from "@/data/gameplay/repairMissionAnchors";
|
||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||
import type { Vector3Tuple } from "@/types/three/three";
|
||||
@@ -17,7 +19,7 @@ interface GameRepairZone {
|
||||
const GAME_REPAIR_ZONES = [
|
||||
{
|
||||
mission: "bike",
|
||||
position: [8, 0, -6],
|
||||
position: EBIKE_REPAIR_POSITION,
|
||||
},
|
||||
{
|
||||
mission: "pylone",
|
||||
@@ -48,6 +50,31 @@ function StageAnchor({
|
||||
);
|
||||
}
|
||||
|
||||
function EbikeMissionTrigger(): React.JSX.Element | null {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
const bikeStep = useGameStore((state) => state.bike.currentStep);
|
||||
const setMissionStep = useGameStore((state) => state.setMissionStep);
|
||||
|
||||
if (mainState !== "bike" || bikeStep !== "locked") return null;
|
||||
|
||||
return (
|
||||
<group position={EBIKE_REPAIR_POSITION}>
|
||||
<InteractableObject
|
||||
kind="trigger"
|
||||
label="Réparer l'e-bike"
|
||||
position={EBIKE_REPAIR_POSITION}
|
||||
radius={4}
|
||||
onPress={() => setMissionStep("bike", "waiting")}
|
||||
>
|
||||
<mesh>
|
||||
<sphereGeometry args={[1.3, 16, 16]} />
|
||||
<meshBasicMaterial transparent opacity={0} depthWrite={false} />
|
||||
</mesh>
|
||||
</InteractableObject>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
export function GameStageContent(): React.JSX.Element {
|
||||
const mainState = useGameStore((state) => state.mainState);
|
||||
|
||||
@@ -63,6 +90,7 @@ export function GameStageContent(): React.JSX.Element {
|
||||
position={zone.position}
|
||||
/>
|
||||
))}
|
||||
<EbikeMissionTrigger />
|
||||
{mainState === "outro" ? (
|
||||
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
|
||||
) : null}
|
||||
|
||||
@@ -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