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
+7 -4
View File
@@ -72,7 +72,7 @@ src/
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json`, validates the hierarchical payload, exposes editable nodes with their tree path, and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`.
`src/utils/map/loadMapSceneData.ts` is shared by the game map and editor. It loads `/map.json`, validates the hierarchical payload, exposes editable nodes with their `sourcePath` back to the tree, and resolves available `public/models/{name}/model.glb` files first, then falls back to `public/models/{name}/model.gltf`.
`src/utils/editor/loadEditorScene.ts` contains editor-only upload handling for user-selected folders.
@@ -87,10 +87,11 @@ interface MapNode {
position: [number, number, number];
rotation: [number, number, number];
scale: [number, number, number];
sourcePath?: number[];
}
```
`public/map.json` may be hierarchical. The editor keeps the hierarchy in `SceneData.mapTree` and stores editable entries in `SceneData.mapNodes` with a `path` back to the real tree node.
`public/map.json` may be hierarchical. The editor keeps the hierarchy in `SceneData.mapTree` and stores editable entries in `SceneData.mapNodes` with a `sourcePath` back to the real tree node.
Group nodes use `role: "group"`; editable nodes keep `name`, `type`, `position`, `rotation`, and `scale`.
@@ -114,7 +115,7 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
4. If `/map.json` is missing, the page displays a folder-upload flow.
5. `EditorSceneLoadingTracker` uses drei `useProgress()` to update the fullscreen editor loading overlay while models load.
6. `EditorScene` renders the grid, lights, camera controls, and map nodes inside `Suspense`.
7. `EditorControls` exposes transform mode, terrain snap, add/delete node, precise scale inputs, history actions, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
7. `EditorControls` exposes transform mode, terrain snap, terrain-selection lock, add/delete node, precise scale inputs, history actions, camera focus/reset, export, save, JSON preview, selection lock, and the cinematic/dialogue/SRT editors.
## Controls
@@ -126,7 +127,9 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
- `T`: translate mode.
- `R`: rotate mode.
- `S`: scale mode.
- Snap terrain on move: enabled by default and applied when releasing a translated object.
- Snap terrain on move: enabled by default and applied while translating an object.
- Lock terrain: enabled by default so terrain remains visible but ignores selection clicks.
- Camera action: centers on the selected object or resets to the editor home view.
- Add node: creates a fallback cube under `blocking` using the requested model folder name.
- Delete selected node: removes the editable node from the preserved map tree.
- `Ctrl+Z` or `Cmd+Z`: undo.
+10 -5
View File
@@ -48,10 +48,11 @@ Only the `Editor` group is open by default. Open the other groups when you need
3. Choose a transform mode: translate, rotate, or scale.
4. Drag the transform gizmo in the 3D view.
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
6. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
7. Check the JSON inspector if you need exact values.
8. Use undo or redo if the transform is not correct.
9. Export the JSON or save it to the dev server.
6. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
8. Check the JSON inspector if you need exact values.
9. Use undo or redo if the transform is not correct.
10. Export the JSON or save it to the dev server.
## Adding And Deleting Nodes
@@ -94,10 +95,12 @@ The `Selection` section shows the selected object name and its index in `public/
## Terrain Snapping
`Snap terrain on move` is enabled by default. When you move an object and release the transform gizmo, the editor samples the terrain height at the object's X/Z position and updates its Y position.
`Snap terrain on move` is enabled by default. When you move an object, the editor samples the terrain height at the object's X/Z position and updates its Y position.
This is intended for map objects that should sit on the ground. Disable it when you intentionally need a floating object.
`Lock terrain` is also enabled by default. The terrain stays visible, but terrain clicks are ignored so normal objects remain easier to select. Disable it only when you need to select or transform the terrain node itself.
When selection is locked:
- clicking another object does not change the selection
@@ -109,6 +112,8 @@ When selection is locked:
The `Lock view` action switches the editor into a movement mode closer to the runtime player camera. Use it to navigate larger scenes while keeping the transform tools available.
The camera action switches between `Center on object` and `Reset camera`. Selecting an object also focuses the camera on that object automatically.
## JSON Inspector
The `JSON` section shows the editable node data:
+17
View File
@@ -37602,6 +37602,23 @@
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "ebike",
"type": "Object3D",
"role": "group",
"position": [0, 0, 0],
"rotation": [0, 0, 0],
"scale": [1, 1, 1],
"children": [
{
"name": "ebike",
"type": "Object3D",
"position": [42.2399, 4.5484, 34.6468],
"rotation": [0, 0, 0],
"scale": [1, 1, 1]
}
]
},
{
"name": "zone1_residence",
"type": "Object3D",
+11
View File
@@ -105,6 +105,16 @@ function addRenderable(parent, objectNode, meshNode) {
getOrCreateModelGroup(parent, renderable.name).children.push(renderable);
}
function addStandaloneObject(rawData, parent, name) {
const node = rawData.find(
(rawNode) => rawNode?.type === "Object3D" && rawNode.name === name,
);
if (!node) return;
getOrCreateModelGroup(parent, name).children.push(cloneNode(node));
}
function addObjectsByRange(rawData, parent, start, end, allowedNames) {
let currentObject = null;
@@ -279,6 +289,7 @@ function transformMap() {
agriculture.children.push(champs, ferme);
addObjectsByRange(rawData, direction, 6, 12, DIRECTION_MESH_NAMES);
addStandaloneObject(rawData, residence, "ebike");
createResidenceZones(rawData, residence);
addObjectsByRange(rawData, energie, 61, 96, new Set(["pyloneelectrique"]));
addObjectsByRange(rawData, vegetation, 98, 829, VEGETATION_MESH_NAMES);
+74 -4
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 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",
},
};
}
+177 -42
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;
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): MapNode[] {
const nodes: MapNode[] = [];
return {
function visit(node: HierarchicalMapNode, path: number[]): void {
if (node.role !== "group" && node.type !== "Mesh") {
nodes.push({
name: node.name,
path,
position: node.position,
rotation: node.rotation,
scale: node.scale,
sourcePath: path,
type: node.type,
};
}
function collectEditableMapNodes(
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
): EditableMapNode[] {
const nodes: EditableMapNode[] = [];
function visit(node: HierarchicalMapNode, path: number[]): void {
const editableNode = toEditableMapNode(node, path);
if (editableNode) {
nodes.push(editableNode);
return;
});
}
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";
+10 -78
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();
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;
}
+36 -37
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 }) ? (
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;
}