Merge remote map editor updates

This commit is contained in:
Tom Boullay
2026-05-27 22:24:59 +02:00
20 changed files with 718 additions and 209 deletions
+75 -5
View File
@@ -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 }>;
} {
+73 -8
View File
@@ -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,
+87 -1
View File
@@ -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;
+6 -4
View File
@@ -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],
+29 -4
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -124,7 +124,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
},
bike: {
...state.bike,
currentStep: "waiting",
currentStep: "locked",
},
};
}
+179 -44
View File
@@ -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}
+3 -6
View File
@@ -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";
+12 -80
View File
@@ -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(
+27 -6
View File
@@ -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");
+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;
}
+37 -38
View File
@@ -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} />}>
+29 -1
View File
@@ -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}
+8
View File
@@ -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;
}