Merge remote map editor updates
This commit is contained in:
@@ -72,7 +72,7 @@ src/
|
|||||||
|
|
||||||
`src/controls/editor/FlyController.tsx` provides editor movement controls for player-style navigation.
|
`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.
|
`src/utils/editor/loadEditorScene.ts` contains editor-only upload handling for user-selected folders.
|
||||||
|
|
||||||
@@ -87,10 +87,11 @@ interface MapNode {
|
|||||||
position: [number, number, number];
|
position: [number, number, number];
|
||||||
rotation: [number, number, number];
|
rotation: [number, number, number];
|
||||||
scale: [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`.
|
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.
|
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.
|
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`.
|
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
|
## Controls
|
||||||
|
|
||||||
@@ -126,7 +127,9 @@ If `model.glb` and `model.gltf` are both missing, the editor renders a fallback
|
|||||||
- `T`: translate mode.
|
- `T`: translate mode.
|
||||||
- `R`: rotate mode.
|
- `R`: rotate mode.
|
||||||
- `S`: scale 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.
|
- 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.
|
- Delete selected node: removes the editable node from the preserved map tree.
|
||||||
- `Ctrl+Z` or `Cmd+Z`: undo.
|
- `Ctrl+Z` or `Cmd+Z`: undo.
|
||||||
|
|||||||
+10
-5
@@ -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.
|
3. Choose a transform mode: translate, rotate, or scale.
|
||||||
4. Drag the transform gizmo in the 3D view.
|
4. Drag the transform gizmo in the 3D view.
|
||||||
5. Keep `Snap terrain on move` enabled when placing objects on the terrain.
|
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.
|
6. Use `Center on object` or `Reset camera` from the `View` section when navigating large maps.
|
||||||
7. Check the JSON inspector if you need exact values.
|
7. Adjust scale numerically from the `Selection` section if the gizmo is not precise enough.
|
||||||
8. Use undo or redo if the transform is not correct.
|
8. Check the JSON inspector if you need exact values.
|
||||||
9. Export the JSON or save it to the dev server.
|
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
|
## Adding And Deleting Nodes
|
||||||
|
|
||||||
@@ -94,10 +95,12 @@ The `Selection` section shows the selected object name and its index in `public/
|
|||||||
|
|
||||||
## Terrain Snapping
|
## 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.
|
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:
|
When selection is locked:
|
||||||
|
|
||||||
- clicking another object does not change the selection
|
- 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 `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
|
## JSON Inspector
|
||||||
|
|
||||||
The `JSON` section shows the editable node data:
|
The `JSON` section shows the editable node data:
|
||||||
|
|||||||
@@ -37602,6 +37602,23 @@
|
|||||||
"rotation": [0, 0, 0],
|
"rotation": [0, 0, 0],
|
||||||
"scale": [1, 1, 1],
|
"scale": [1, 1, 1],
|
||||||
"children": [
|
"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",
|
"name": "zone1_residence",
|
||||||
"type": "Object3D",
|
"type": "Object3D",
|
||||||
|
|||||||
@@ -105,6 +105,16 @@ function addRenderable(parent, objectNode, meshNode) {
|
|||||||
getOrCreateModelGroup(parent, renderable.name).children.push(renderable);
|
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) {
|
function addObjectsByRange(rawData, parent, start, end, allowedNames) {
|
||||||
let currentObject = null;
|
let currentObject = null;
|
||||||
|
|
||||||
@@ -279,6 +289,7 @@ function transformMap() {
|
|||||||
agriculture.children.push(champs, ferme);
|
agriculture.children.push(champs, ferme);
|
||||||
|
|
||||||
addObjectsByRange(rawData, direction, 6, 12, DIRECTION_MESH_NAMES);
|
addObjectsByRange(rawData, direction, 6, 12, DIRECTION_MESH_NAMES);
|
||||||
|
addStandaloneObject(rawData, residence, "ebike");
|
||||||
createResidenceZones(rawData, residence);
|
createResidenceZones(rawData, residence);
|
||||||
addObjectsByRange(rawData, energie, 61, 96, new Set(["pyloneelectrique"]));
|
addObjectsByRange(rawData, energie, 61, 96, new Set(["pyloneelectrique"]));
|
||||||
addObjectsByRange(rawData, vegetation, 98, 829, VEGETATION_MESH_NAMES);
|
addObjectsByRange(rawData, vegetation, 98, 829, VEGETATION_MESH_NAMES);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
ScanSearch,
|
||||||
Undo2,
|
Undo2,
|
||||||
Unlock,
|
Unlock,
|
||||||
X,
|
X,
|
||||||
@@ -21,17 +22,19 @@ import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinemati
|
|||||||
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
|
||||||
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
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";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
|
||||||
interface EditorControlsProps {
|
interface EditorControlsProps {
|
||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
onTransformModeChange: (mode: TransformMode) => void;
|
onTransformModeChange: (mode: TransformMode) => void;
|
||||||
selectedNodeIndex: number | null;
|
selectedNodeIndex: number | null;
|
||||||
mapNodes: EditableMapNode[];
|
mapNodes: MapNode[];
|
||||||
nodesCount: number;
|
nodesCount: number;
|
||||||
selectedNodeName: string | null;
|
selectedNodeName: string | null;
|
||||||
selectedNodeScale: Vector3Tuple | null;
|
selectedNodeScale: Vector3Tuple | null;
|
||||||
|
lockTerrainSelection: boolean;
|
||||||
|
onLockTerrainSelectionChange: (locked: boolean) => void;
|
||||||
isSelectionLocked: boolean;
|
isSelectionLocked: boolean;
|
||||||
onSelectionLockToggle: () => void;
|
onSelectionLockToggle: () => void;
|
||||||
onClearSelection: () => void;
|
onClearSelection: () => void;
|
||||||
@@ -46,6 +49,8 @@ interface EditorControlsProps {
|
|||||||
redoCount: number;
|
redoCount: number;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
|
cameraActionLabel: string;
|
||||||
|
onCameraAction: () => void;
|
||||||
onExportJson: () => void;
|
onExportJson: () => void;
|
||||||
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
onSaveToServer?: (() => void | Promise<void>) | undefined;
|
||||||
onPlayerMode?: (() => void) | undefined;
|
onPlayerMode?: (() => void) | undefined;
|
||||||
@@ -102,6 +107,8 @@ export function EditorControls({
|
|||||||
nodesCount,
|
nodesCount,
|
||||||
selectedNodeName,
|
selectedNodeName,
|
||||||
selectedNodeScale,
|
selectedNodeScale,
|
||||||
|
lockTerrainSelection,
|
||||||
|
onLockTerrainSelectionChange,
|
||||||
isSelectionLocked,
|
isSelectionLocked,
|
||||||
onSelectionLockToggle,
|
onSelectionLockToggle,
|
||||||
onClearSelection,
|
onClearSelection,
|
||||||
@@ -116,6 +123,8 @@ export function EditorControls({
|
|||||||
redoCount,
|
redoCount,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
|
cameraActionLabel,
|
||||||
|
onCameraAction,
|
||||||
onExportJson,
|
onExportJson,
|
||||||
onSaveToServer,
|
onSaveToServer,
|
||||||
onPlayerMode,
|
onPlayerMode,
|
||||||
@@ -124,6 +133,9 @@ export function EditorControls({
|
|||||||
}: EditorControlsProps): React.JSX.Element {
|
}: EditorControlsProps): React.JSX.Element {
|
||||||
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
|
||||||
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
|
||||||
|
const selectedNode =
|
||||||
|
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
|
||||||
|
const transformValues = getTransformValues(selectedNode ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -174,7 +186,10 @@ export function EditorControls({
|
|||||||
aria-pressed={transformMode === mode}
|
aria-pressed={transformMode === mode}
|
||||||
>
|
>
|
||||||
<Icon size={16} aria-hidden="true" />
|
<Icon size={16} aria-hidden="true" />
|
||||||
|
<span className="editor-transform-label">
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
|
<small>{transformValues[mode]}</small>
|
||||||
|
</span>
|
||||||
<kbd>{shortcut}</kbd>
|
<kbd>{shortcut}</kbd>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -339,6 +354,25 @@ export function EditorControls({
|
|||||||
{viewModeLabel}
|
{viewModeLabel}
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<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 {
|
interface JsonPreviewLine {
|
||||||
number: number;
|
number: number;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -423,7 +493,7 @@ interface JsonPreview {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getJsonPreview(
|
function getJsonPreview(
|
||||||
mapNodes: EditableMapNode[],
|
mapNodes: MapNode[],
|
||||||
selectedNodeIndex: number | null,
|
selectedNodeIndex: number | null,
|
||||||
): JsonPreview {
|
): JsonPreview {
|
||||||
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
|
||||||
@@ -452,7 +522,7 @@ function getJsonPreview(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMapNodesWithRanges(mapNodes: EditableMapNode[]): {
|
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
|
||||||
lines: string[];
|
lines: string[];
|
||||||
ranges: Array<{ start: number; end: number }>;
|
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 type { ThreeEvent } from "@react-three/fiber";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
import { useClonedObject } from "@/hooks/three/useClonedObject";
|
||||||
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
|
||||||
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
|
||||||
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
|
||||||
|
import {
|
||||||
|
isEditorVisibleMapNode,
|
||||||
|
getTerrainMapNode,
|
||||||
|
} from "@/utils/map/mapRuntimeClassification";
|
||||||
|
|
||||||
interface EditorMapProps {
|
interface EditorMapProps {
|
||||||
sceneData: SceneData;
|
sceneData: SceneData;
|
||||||
@@ -17,6 +22,7 @@ interface EditorMapProps {
|
|||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
snapToTerrain: boolean;
|
snapToTerrain: boolean;
|
||||||
|
lockTerrainSelection: boolean;
|
||||||
onTransformStart: () => void;
|
onTransformStart: () => void;
|
||||||
onTransformEnd: () => void;
|
onTransformEnd: () => void;
|
||||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||||
@@ -141,6 +147,7 @@ export function EditorMap({
|
|||||||
onHoverNode,
|
onHoverNode,
|
||||||
transformMode,
|
transformMode,
|
||||||
snapToTerrain,
|
snapToTerrain,
|
||||||
|
lockTerrainSelection,
|
||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
@@ -153,6 +160,11 @@ export function EditorMap({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleTransformMouseUp = () => {
|
const handleTransformMouseUp = () => {
|
||||||
|
syncSelectedObjectTransform();
|
||||||
|
onTransformEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncSelectedObjectTransform = () => {
|
||||||
if (selectedNodeIndex !== null) {
|
if (selectedNodeIndex !== null) {
|
||||||
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
const obj = objectsMapRef.current.get(selectedNodeIndex);
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
@@ -180,12 +192,18 @@ export function EditorMap({
|
|||||||
onNodeTransform(selectedNodeIndex, updatedNode);
|
onNodeTransform(selectedNodeIndex, updatedNode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onTransformEnd();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
|
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||||
|
const terrainNodeIndex = terrainNode
|
||||||
|
? sceneData.mapNodes.indexOf(terrainNode)
|
||||||
|
: -1;
|
||||||
|
const selectedNode =
|
||||||
|
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
|
||||||
|
const selectedModelName = selectedNode?.name ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedNodeIndex !== null) {
|
if (selectedNodeIndex !== null) {
|
||||||
@@ -213,14 +231,29 @@ export function EditorMap({
|
|||||||
/>
|
/>
|
||||||
<axesHelper args={[10]} />
|
<axesHelper args={[10]} />
|
||||||
|
|
||||||
<group
|
<group>
|
||||||
onClick={(event: ThreeEvent<MouseEvent>) => {
|
{terrainNode ? (
|
||||||
event.stopPropagation();
|
<EditorTerrainNode
|
||||||
if (isSelectionLocked) return;
|
index={terrainNodeIndex}
|
||||||
onSelectNode(null);
|
node={terrainNode}
|
||||||
}}
|
isSelected={selectedNodeIndex === terrainNodeIndex}
|
||||||
>
|
isHovered={hoveredNodeIndex === terrainNodeIndex}
|
||||||
|
lockTerrainSelection={lockTerrainSelection}
|
||||||
|
objectsMapRef={objectsMapRef}
|
||||||
|
onSelectNode={onSelectNode}
|
||||||
|
isSelectionLocked={isSelectionLocked}
|
||||||
|
onHoverNode={onHoverNode}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{sceneData.mapNodes.map((node, index) => {
|
{sceneData.mapNodes.map((node, index) => {
|
||||||
|
if (!isEditorVisibleMapNode(node)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedModelName && node.name !== selectedModelName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const modelUrl = sceneData.models.get(node.name);
|
const modelUrl = sceneData.models.get(node.name);
|
||||||
|
|
||||||
if (modelUrl) {
|
if (modelUrl) {
|
||||||
@@ -262,6 +295,7 @@ export function EditorMap({
|
|||||||
mode={transformMode}
|
mode={transformMode}
|
||||||
onMouseDown={handleTransformMouseDown}
|
onMouseDown={handleTransformMouseDown}
|
||||||
onMouseUp={handleTransformMouseUp}
|
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({
|
function EditorFallbackNode({
|
||||||
index,
|
index,
|
||||||
node,
|
node,
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { OrbitControls } from "@react-three/drei";
|
||||||
import { useThree } from "@react-three/fiber";
|
import { useThree } from "@react-three/fiber";
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
|
||||||
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
import { EditorMap } from "@/components/editor/scene/EditorMap";
|
||||||
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
import { TerrainModel } from "@/components/three/world/TerrainModel";
|
||||||
import { FlyController } from "@/controls/editor/FlyController";
|
import { FlyController } from "@/controls/editor/FlyController";
|
||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
|
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 {
|
export interface EditorCinematicPreviewRequest {
|
||||||
id: string;
|
id: string;
|
||||||
cinematic: CinematicDefinition;
|
cinematic: CinematicDefinition;
|
||||||
@@ -23,12 +27,15 @@ interface EditorSceneProps {
|
|||||||
onHoverNode: (index: number | null) => void;
|
onHoverNode: (index: number | null) => void;
|
||||||
transformMode: TransformMode;
|
transformMode: TransformMode;
|
||||||
snapToTerrain: boolean;
|
snapToTerrain: boolean;
|
||||||
|
lockTerrainSelection: boolean;
|
||||||
onTransformModeChange: (mode: TransformMode) => void;
|
onTransformModeChange: (mode: TransformMode) => void;
|
||||||
onTransformStart: () => void;
|
onTransformStart: () => void;
|
||||||
onTransformEnd: () => void;
|
onTransformEnd: () => void;
|
||||||
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
|
||||||
onUndo: () => void;
|
onUndo: () => void;
|
||||||
onRedo: () => void;
|
onRedo: () => void;
|
||||||
|
resetCameraRequest: number;
|
||||||
|
focusSelectedCameraRequest: number;
|
||||||
isPlayerMode?: boolean;
|
isPlayerMode?: boolean;
|
||||||
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
|
||||||
onCinematicPreviewComplete?: (() => void) | undefined;
|
onCinematicPreviewComplete?: (() => void) | undefined;
|
||||||
@@ -43,17 +50,94 @@ export function EditorScene({
|
|||||||
onHoverNode,
|
onHoverNode,
|
||||||
transformMode,
|
transformMode,
|
||||||
snapToTerrain,
|
snapToTerrain,
|
||||||
|
lockTerrainSelection,
|
||||||
onTransformModeChange,
|
onTransformModeChange,
|
||||||
onTransformStart,
|
onTransformStart,
|
||||||
onTransformEnd,
|
onTransformEnd,
|
||||||
onNodeTransform,
|
onNodeTransform,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
|
resetCameraRequest,
|
||||||
|
focusSelectedCameraRequest,
|
||||||
isPlayerMode = false,
|
isPlayerMode = false,
|
||||||
cinematicPreviewRequest = null,
|
cinematicPreviewRequest = null,
|
||||||
onCinematicPreviewComplete,
|
onCinematicPreviewComplete,
|
||||||
}: EditorSceneProps): React.JSX.Element {
|
}: EditorSceneProps): React.JSX.Element {
|
||||||
const isCinematicPreviewing = cinematicPreviewRequest !== null;
|
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(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -110,6 +194,7 @@ export function EditorScene({
|
|||||||
<FlyController disabled={isCinematicPreviewing} />
|
<FlyController disabled={isCinematicPreviewing} />
|
||||||
) : (
|
) : (
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
|
ref={orbitControlsRef}
|
||||||
enabled={!isCinematicPreviewing}
|
enabled={!isCinematicPreviewing}
|
||||||
enableDamping
|
enableDamping
|
||||||
dampingFactor={0.05}
|
dampingFactor={0.05}
|
||||||
@@ -130,6 +215,7 @@ export function EditorScene({
|
|||||||
onHoverNode={onHoverNode}
|
onHoverNode={onHoverNode}
|
||||||
transformMode={transformMode}
|
transformMode={transformMode}
|
||||||
snapToTerrain={snapToTerrain}
|
snapToTerrain={snapToTerrain}
|
||||||
|
lockTerrainSelection={lockTerrainSelection}
|
||||||
onTransformStart={onTransformStart}
|
onTransformStart={onTransformStart}
|
||||||
onTransformEnd={onTransformEnd}
|
onTransformEnd={onTransformEnd}
|
||||||
onNodeTransform={onNodeTransform}
|
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 {
|
interface ObjectTransform {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
path: number[];
|
sourcePath?: number[];
|
||||||
position: { x: number; y: number; z: number };
|
position: { x: number; y: number; z: number };
|
||||||
rotation: { x: number; y: number; z: number };
|
rotation: { x: number; y: number; z: number };
|
||||||
scale: { 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],
|
scale: [transform.scale.x, transform.scale.y, transform.scale.z],
|
||||||
} satisfies MapNode;
|
} satisfies MapNode;
|
||||||
|
|
||||||
mapTree = updateTreeNodeAtPath(mapTree, node.path, transform);
|
if (mapTree && node.sourcePath) {
|
||||||
|
mapTree = updateTreeNodeAtPath(mapTree, node.sourcePath, transform);
|
||||||
|
}
|
||||||
|
|
||||||
return nextNode;
|
return nextNode;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...prev, mapNodes, mapTree };
|
return mapTree ? { ...prev, mapNodes, mapTree } : { ...prev, mapNodes };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setSceneData],
|
[setSceneData],
|
||||||
@@ -217,7 +219,7 @@ export function useEditorHistory(
|
|||||||
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
|
function createSnapshot(sceneData: SceneData): ObjectTransform[] {
|
||||||
return sceneData.mapNodes.map((node, index) => ({
|
return sceneData.mapNodes.map((node, index) => ({
|
||||||
uuid: `node-${index}`,
|
uuid: `node-${index}`,
|
||||||
path: node.path,
|
...(node.sourcePath ? { sourcePath: node.sourcePath } : {}),
|
||||||
position: {
|
position: {
|
||||||
x: node.position[0],
|
x: node.position[0],
|
||||||
y: node.position[1],
|
y: node.position[1],
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useGLTF } from "@react-three/drei";
|
import { useGLTF } from "@react-three/drei";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { TERRAIN_MODEL_PATH } from "@/data/world/terrainConfig";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
|
import { getMapNodesByName } from "@/utils/map/loadMapSceneData";
|
||||||
|
|
||||||
const TERRAIN_MODEL_PATH = "/models/terrain/model.gltf";
|
|
||||||
const RAYCAST_Y = 500;
|
const RAYCAST_Y = 500;
|
||||||
const RAYCAST_FAR = 1000;
|
const RAYCAST_FAR = 1000;
|
||||||
const DOWN = new THREE.Vector3(0, -1, 0);
|
const DOWN = new THREE.Vector3(0, -1, 0);
|
||||||
|
const DEFAULT_TERRAIN_POSITION: Vector3Tuple = [0, 0, 0];
|
||||||
|
const DEFAULT_TERRAIN_ROTATION: Vector3Tuple = [0, 0, 0];
|
||||||
|
const DEFAULT_TERRAIN_SCALE: Vector3Tuple = [1, 1, 1];
|
||||||
|
|
||||||
interface TerrainHeightSampler {
|
interface TerrainHeightSampler {
|
||||||
getHeight: (x: number, z: number) => number | null;
|
getHeight: (x: number, z: number) => number | null;
|
||||||
@@ -14,8 +18,17 @@ interface TerrainHeightSampler {
|
|||||||
|
|
||||||
function createTerrainHeightSampler(
|
function createTerrainHeightSampler(
|
||||||
scene: THREE.Object3D,
|
scene: THREE.Object3D,
|
||||||
|
position: Vector3Tuple,
|
||||||
|
rotation: Vector3Tuple,
|
||||||
|
scale: Vector3Tuple,
|
||||||
): TerrainHeightSampler {
|
): TerrainHeightSampler {
|
||||||
const meshes: THREE.Mesh[] = [];
|
const meshes: THREE.Mesh[] = [];
|
||||||
|
const terrainMatrix = new THREE.Matrix4().compose(
|
||||||
|
new THREE.Vector3(...position),
|
||||||
|
new THREE.Quaternion().setFromEuler(new THREE.Euler(...rotation)),
|
||||||
|
new THREE.Vector3(...scale),
|
||||||
|
);
|
||||||
|
const inverseTerrainMatrix = terrainMatrix.clone().invert();
|
||||||
const raycaster = new THREE.Raycaster(
|
const raycaster = new THREE.Raycaster(
|
||||||
new THREE.Vector3(),
|
new THREE.Vector3(),
|
||||||
DOWN,
|
DOWN,
|
||||||
@@ -32,17 +45,29 @@ function createTerrainHeightSampler(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
getHeight: (x, z) => {
|
getHeight: (x, z) => {
|
||||||
raycaster.set(new THREE.Vector3(x, RAYCAST_Y, z), DOWN);
|
const localOrigin = new THREE.Vector3(x, RAYCAST_Y, z).applyMatrix4(
|
||||||
|
inverseTerrainMatrix,
|
||||||
|
);
|
||||||
|
const localDirection =
|
||||||
|
DOWN.clone().transformDirection(inverseTerrainMatrix);
|
||||||
|
raycaster.set(localOrigin, localDirection);
|
||||||
const hit = raycaster.intersectObjects(meshes, false)[0];
|
const hit = raycaster.intersectObjects(meshes, false)[0];
|
||||||
return hit?.point.y ?? null;
|
return hit?.point.applyMatrix4(terrainMatrix).y ?? null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTerrainHeightSampler(): TerrainHeightSampler {
|
export function useTerrainHeightSampler(): TerrainHeightSampler {
|
||||||
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
const { scene } = useGLTF(TERRAIN_MODEL_PATH);
|
||||||
|
const terrainNode = getMapNodesByName("terrain")[0];
|
||||||
|
const position = terrainNode?.position ?? DEFAULT_TERRAIN_POSITION;
|
||||||
|
const rotation = terrainNode?.rotation ?? DEFAULT_TERRAIN_ROTATION;
|
||||||
|
const scale = terrainNode?.scale ?? DEFAULT_TERRAIN_SCALE;
|
||||||
|
|
||||||
return useMemo(() => createTerrainHeightSampler(scene), [scene]);
|
return useMemo(
|
||||||
|
() => createTerrainHeightSampler(scene, position, rotation, scale),
|
||||||
|
[position, rotation, scale, scene],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTerrainSnappedPosition(
|
export function useTerrainSnappedPosition(
|
||||||
|
|||||||
+63
-2
@@ -1244,7 +1244,7 @@ canvas {
|
|||||||
|
|
||||||
.editor-transform-button {
|
.editor-transform-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 18px 1fr auto;
|
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1264,6 +1264,30 @@ canvas {
|
|||||||
transform 160ms ease;
|
transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-transform-label {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-transform-label span,
|
||||||
|
.editor-transform-label small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-transform-label small {
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
font-weight: 620;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-transform-button.active .editor-transform-label small {
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-transform-button.active {
|
.editor-transform-button.active {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
color: #050505;
|
color: #050505;
|
||||||
@@ -1347,7 +1371,8 @@ canvas {
|
|||||||
transform 160ms ease;
|
transform 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-action-button + .editor-action-button {
|
.editor-action-button + .editor-action-button,
|
||||||
|
.editor-player-button + .editor-action-button {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1378,6 +1403,42 @@ canvas {
|
|||||||
color: #050505;
|
color: #050505;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-checkbox-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 9px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
background: #101010;
|
||||||
|
border: 1px solid #242424;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: #f2f2f2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-checkbox-row input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-checkbox-row span {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-checkbox-row strong {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-checkbox-row small {
|
||||||
|
color: #8f8f8f;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-selected-info {
|
.editor-selected-info {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 17px 1fr auto;
|
grid-template-columns: 17px 1fr auto;
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function completeIntroState(state: GameState): GameStateUpdate {
|
|||||||
},
|
},
|
||||||
bike: {
|
bike: {
|
||||||
...state.bike,
|
...state.bike,
|
||||||
currentStep: "waiting",
|
currentStep: "locked",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-42
@@ -10,7 +10,6 @@ import { useEditorHistory } from "@/hooks/editor/useEditorHistory";
|
|||||||
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
|
||||||
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
import { useEditorSceneData } from "@/hooks/editor/useEditorSceneData";
|
||||||
import type {
|
import type {
|
||||||
EditableMapNode,
|
|
||||||
HierarchicalMapNode,
|
HierarchicalMapNode,
|
||||||
MapNode,
|
MapNode,
|
||||||
SceneData,
|
SceneData,
|
||||||
@@ -31,7 +30,74 @@ interface EditorSceneLoadingTrackerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeMapNodes(sceneData: SceneData): string {
|
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(
|
function cloneMapTree(
|
||||||
@@ -42,32 +108,21 @@ function cloneMapTree(
|
|||||||
| HierarchicalMapNode[];
|
| HierarchicalMapNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function toEditableMapNode(
|
function collectEditableMapNodes(
|
||||||
node: HierarchicalMapNode,
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
path: number[],
|
): MapNode[] {
|
||||||
): EditableMapNode | null {
|
const nodes: MapNode[] = [];
|
||||||
if (node.name === "terrain" || node.role === "group") return null;
|
|
||||||
|
|
||||||
return {
|
function visit(node: HierarchicalMapNode, path: number[]): void {
|
||||||
|
if (node.role !== "group" && node.type !== "Mesh") {
|
||||||
|
nodes.push({
|
||||||
name: node.name,
|
name: node.name,
|
||||||
path,
|
|
||||||
position: node.position,
|
position: node.position,
|
||||||
rotation: node.rotation,
|
rotation: node.rotation,
|
||||||
scale: node.scale,
|
scale: node.scale,
|
||||||
|
sourcePath: path,
|
||||||
type: node.type,
|
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]));
|
node.children?.forEach((child, index) => visit(child, [...path, index]));
|
||||||
@@ -95,9 +150,10 @@ function updateTreeNodeAtPath(
|
|||||||
: path.length === 0;
|
: path.length === 0;
|
||||||
|
|
||||||
if (isRootTarget) {
|
if (isRootTarget) {
|
||||||
rootNodes[targetIndex] = update(
|
const targetNode = rootNodes[targetIndex];
|
||||||
rootNodes[targetIndex] as HierarchicalMapNode,
|
if (targetNode) {
|
||||||
);
|
rootNodes[targetIndex] = update(targetNode);
|
||||||
|
}
|
||||||
return nextTree;
|
return nextTree;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,19 +201,6 @@ function removeTreeNodeAtPath(
|
|||||||
return nextTree;
|
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(
|
function updateSceneDataTree(
|
||||||
sceneData: SceneData,
|
sceneData: SceneData,
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
@@ -199,6 +242,19 @@ function findNodePathByName(
|
|||||||
return visit(mapTree, []);
|
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 {
|
function createNewMapNode(name: string): HierarchicalMapNode {
|
||||||
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
|
const safeName = name.trim() || DEFAULT_NEW_NODE_NAME;
|
||||||
|
|
||||||
@@ -264,6 +320,13 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
|
const [isSelectionLocked, setIsSelectionLocked] = useState(false);
|
||||||
const [snapToTerrain, setSnapToTerrain] = useState(true);
|
const [snapToTerrain, setSnapToTerrain] = useState(true);
|
||||||
const [newNodeName, setNewNodeName] = useState(DEFAULT_NEW_NODE_NAME);
|
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>(
|
const [sceneLoadingState, setSceneLoadingState] = useState<SceneLoadingState>(
|
||||||
{
|
{
|
||||||
...INITIAL_SCENE_LOADING_STATE,
|
...INITIAL_SCENE_LOADING_STATE,
|
||||||
@@ -307,6 +370,9 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
|
|
||||||
const handleSelectNode = useCallback((index: number | null) => {
|
const handleSelectNode = useCallback((index: number | null) => {
|
||||||
setSelectedNodeIndex(index);
|
setSelectedNodeIndex(index);
|
||||||
|
if (index !== null) {
|
||||||
|
setCameraViewMode("object");
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
@@ -325,6 +391,22 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setNewNodeName(value);
|
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) => {
|
const handleHoverNode = useCallback((index: number | null) => {
|
||||||
setHoveredNodeIndex(index);
|
setHoveredNodeIndex(index);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -371,6 +453,17 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
setIsPlayerMode((prev) => !prev);
|
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(
|
const handlePreviewCinematic = useCallback(
|
||||||
(cinematic: CinematicDefinition) => {
|
(cinematic: CinematicDefinition) => {
|
||||||
setCinematicPreviewRequest({
|
setCinematicPreviewRequest({
|
||||||
@@ -392,9 +485,15 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const currentNode = prev.mapNodes[nodeIndex];
|
const currentNode = prev.mapNodes[nodeIndex];
|
||||||
if (!currentNode) return prev;
|
if (!currentNode) return prev;
|
||||||
|
|
||||||
|
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||||
|
const mapNodes = [...prev.mapNodes];
|
||||||
|
mapNodes[nodeIndex] = updatedNode;
|
||||||
|
return { ...prev, mapNodes };
|
||||||
|
}
|
||||||
|
|
||||||
const mapTree = updateTreeNodeAtPath(
|
const mapTree = updateTreeNodeAtPath(
|
||||||
prev.mapTree,
|
prev.mapTree,
|
||||||
currentNode.path,
|
currentNode.sourcePath,
|
||||||
(node) => ({
|
(node) => ({
|
||||||
...node,
|
...node,
|
||||||
position: updatedNode.position,
|
position: updatedNode.position,
|
||||||
@@ -402,7 +501,6 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
scale: updatedNode.scale,
|
scale: updatedNode.scale,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return updateSceneDataTree(prev, mapTree);
|
return updateSceneDataTree(prev, mapTree);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -421,9 +519,15 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const nextScale = [...currentNode.scale] as [number, number, number];
|
const nextScale = [...currentNode.scale] as [number, number, number];
|
||||||
nextScale[axis] = value;
|
nextScale[axis] = value;
|
||||||
|
|
||||||
|
if (!prev.mapTree || !currentNode.sourcePath) {
|
||||||
|
const mapNodes = [...prev.mapNodes];
|
||||||
|
mapNodes[selectedNodeIndex] = { ...currentNode, scale: nextScale };
|
||||||
|
return { ...prev, mapNodes };
|
||||||
|
}
|
||||||
|
|
||||||
const mapTree = updateTreeNodeAtPath(
|
const mapTree = updateTreeNodeAtPath(
|
||||||
prev.mapTree,
|
prev.mapTree,
|
||||||
currentNode.path,
|
currentNode.sourcePath,
|
||||||
(node) => ({ ...node, scale: nextScale }),
|
(node) => ({ ...node, scale: nextScale }),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -436,6 +540,13 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
const handleAddNode = useCallback(() => {
|
const handleAddNode = useCallback(() => {
|
||||||
setSceneData((prev) => {
|
setSceneData((prev) => {
|
||||||
if (!prev) return null;
|
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 mapTree = addTreeNode(prev.mapTree, createNewMapNode(newNodeName));
|
||||||
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
const nextSceneData = updateSceneDataTree(prev, mapTree);
|
||||||
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
setSelectedNodeIndex(nextSceneData.mapNodes.length - 1);
|
||||||
@@ -450,7 +561,20 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
const currentNode = prev.mapNodes[selectedNodeIndex];
|
const currentNode = prev.mapNodes[selectedNodeIndex];
|
||||||
if (!currentNode) return prev;
|
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);
|
setSelectedNodeIndex(null);
|
||||||
return updateSceneDataTree(prev, mapTree);
|
return updateSceneDataTree(prev, mapTree);
|
||||||
});
|
});
|
||||||
@@ -542,12 +666,15 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
onHoverNode={handleHoverNode}
|
onHoverNode={handleHoverNode}
|
||||||
transformMode={transformMode}
|
transformMode={transformMode}
|
||||||
snapToTerrain={snapToTerrain}
|
snapToTerrain={snapToTerrain}
|
||||||
|
lockTerrainSelection={lockTerrainSelection}
|
||||||
onTransformModeChange={handleTransformModeChange}
|
onTransformModeChange={handleTransformModeChange}
|
||||||
onTransformStart={handleTransformStart}
|
onTransformStart={handleTransformStart}
|
||||||
onTransformEnd={handleTransformEnd}
|
onTransformEnd={handleTransformEnd}
|
||||||
onNodeTransform={handleNodeTransform}
|
onNodeTransform={handleNodeTransform}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
|
resetCameraRequest={resetCameraRequest}
|
||||||
|
focusSelectedCameraRequest={focusSelectedCameraRequest}
|
||||||
isPlayerMode={isPlayerMode}
|
isPlayerMode={isPlayerMode}
|
||||||
cinematicPreviewRequest={cinematicPreviewRequest}
|
cinematicPreviewRequest={cinematicPreviewRequest}
|
||||||
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
onCinematicPreviewComplete={handleCinematicPreviewComplete}
|
||||||
@@ -574,6 +701,8 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
? sceneData.mapNodes[selectedNodeIndex].scale
|
? sceneData.mapNodes[selectedNodeIndex].scale
|
||||||
: null
|
: null
|
||||||
}
|
}
|
||||||
|
lockTerrainSelection={lockTerrainSelection}
|
||||||
|
onLockTerrainSelectionChange={handleTerrainSelectionLockChange}
|
||||||
isSelectionLocked={isSelectionLocked}
|
isSelectionLocked={isSelectionLocked}
|
||||||
onSelectionLockToggle={handleSelectionLockToggle}
|
onSelectionLockToggle={handleSelectionLockToggle}
|
||||||
onClearSelection={handleClearSelection}
|
onClearSelection={handleClearSelection}
|
||||||
@@ -588,6 +717,12 @@ export function EditorPage(): React.JSX.Element {
|
|||||||
redoCount={redoCount}
|
redoCount={redoCount}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
|
cameraActionLabel={
|
||||||
|
selectedNodeIndex !== null && cameraViewMode === "home"
|
||||||
|
? "Center on object"
|
||||||
|
: "Reset camera"
|
||||||
|
}
|
||||||
|
onCameraAction={handleCameraAction}
|
||||||
onExportJson={handleExportJson}
|
onExportJson={handleExportJson}
|
||||||
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
onSaveToServer={import.meta.env.DEV ? handleSaveToServer : undefined}
|
||||||
onPlayerMode={handlePlayerMode}
|
onPlayerMode={handlePlayerMode}
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ export interface MapNode {
|
|||||||
position: Vector3Tuple;
|
position: Vector3Tuple;
|
||||||
rotation: Vector3Tuple;
|
rotation: Vector3Tuple;
|
||||||
scale: Vector3Tuple;
|
scale: Vector3Tuple;
|
||||||
}
|
sourcePath?: number[];
|
||||||
|
|
||||||
export interface EditableMapNode extends MapNode {
|
|
||||||
path: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HierarchicalMapNode extends MapNode {
|
export interface HierarchicalMapNode extends MapNode {
|
||||||
@@ -18,9 +15,9 @@ export interface HierarchicalMapNode extends MapNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SceneData {
|
export interface SceneData {
|
||||||
mapNodes: EditableMapNode[];
|
mapNodes: MapNode[];
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[];
|
|
||||||
models: Map<string, string>;
|
models: Map<string, string>;
|
||||||
|
mapTree?: HierarchicalMapNode | HierarchicalMapNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TransformMode = "translate" | "rotate" | "scale";
|
export type TransformMode = "translate" | "rotate" | "scale";
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
EditableMapNode,
|
|
||||||
HierarchicalMapNode,
|
HierarchicalMapNode,
|
||||||
MapNode,
|
MapNode,
|
||||||
SceneData,
|
SceneData,
|
||||||
} from "@/types/editor/editor";
|
} from "@/types/editor/editor";
|
||||||
import {
|
import { parseMapData } from "@/utils/map/mapNodeValidation";
|
||||||
parseHierarchicalMapPayload,
|
|
||||||
parseMapNodes,
|
|
||||||
} from "@/utils/map/mapNodeValidation";
|
|
||||||
|
|
||||||
const MAP_JSON_PATH = "/map.json";
|
const MAP_JSON_PATH = "/map.json";
|
||||||
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
|
const MODEL_FILE_NAMES = ["model.glb", "model.gltf"];
|
||||||
@@ -29,8 +25,12 @@ export async function loadMapSceneData(): Promise<SceneData | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadingPromise = loadMapSceneDataInternal();
|
loadingPromise = loadMapSceneDataInternal();
|
||||||
|
|
||||||
|
try {
|
||||||
cachedSceneData = await loadingPromise;
|
cachedSceneData = await loadingPromise;
|
||||||
|
} finally {
|
||||||
loadingPromise = null;
|
loadingPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
return cachedSceneData;
|
return cachedSceneData;
|
||||||
}
|
}
|
||||||
@@ -59,53 +59,9 @@ async function loadMapSceneDataInternal(): Promise<SceneData | null> {
|
|||||||
export async function createSceneDataFromMapPayload(
|
export async function createSceneDataFromMapPayload(
|
||||||
mapPayload: unknown,
|
mapPayload: unknown,
|
||||||
): Promise<SceneData> {
|
): Promise<SceneData> {
|
||||||
const mapTree = parseHierarchicalMapPayload(mapPayload);
|
const { mapNodes, mapTree } = parseMapData(mapPayload);
|
||||||
const mapNodes = parseMapNodes(mapTree);
|
|
||||||
const editableNodes = createEditableMapNodes(mapTree);
|
|
||||||
const deduplicatedNodes = deduplicateMapNodes(mapNodes);
|
const deduplicatedNodes = deduplicateMapNodes(mapNodes);
|
||||||
const deduplicatedEditableNodes = deduplicateEditableMapNodes(editableNodes);
|
return createSceneData(deduplicatedNodes, mapTree);
|
||||||
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, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPositionKey(node: MapNode): string {
|
function createPositionKey(node: MapNode): string {
|
||||||
@@ -142,36 +98,12 @@ function deduplicateMapNodes(nodes: MapNode[]): MapNode[] {
|
|||||||
return result;
|
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(
|
async function createSceneData(
|
||||||
|
mapNodes: MapNode[],
|
||||||
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[],
|
||||||
mapNodes: EditableMapNode[],
|
|
||||||
modelLookupNodes: MapNode[],
|
|
||||||
): Promise<SceneData> {
|
): Promise<SceneData> {
|
||||||
const models = await loadMapModelUrls(modelLookupNodes);
|
const models = await loadMapModelUrls(mapNodes);
|
||||||
return { mapNodes, mapTree, models };
|
return { mapNodes, models, mapTree };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMapModelUrls(
|
async function loadMapModelUrls(
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { HierarchicalMapNode, MapNode } from "../../types/editor/editor";
|
import type { HierarchicalMapNode, MapNode } from "../../types/editor/editor";
|
||||||
|
|
||||||
|
export interface ParsedMapNodes {
|
||||||
|
mapNodes: MapNode[];
|
||||||
|
mapTree: HierarchicalMapNode | HierarchicalMapNode[];
|
||||||
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null;
|
return typeof value === "object" && value !== null;
|
||||||
}
|
}
|
||||||
@@ -48,19 +53,25 @@ export function isHierarchicalMapNode(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenMapNode(node: HierarchicalMapNode): MapNode[] {
|
function flattenMapNode(node: HierarchicalMapNode, path: number[]): MapNode[] {
|
||||||
const mapNode: MapNode = {
|
const mapNode: MapNode = {
|
||||||
name: node.name,
|
name: node.name,
|
||||||
type: node.type,
|
type: node.type,
|
||||||
position: node.position,
|
position: node.position,
|
||||||
rotation: node.rotation,
|
rotation: node.rotation,
|
||||||
scale: node.scale,
|
scale: node.scale,
|
||||||
|
sourcePath: path,
|
||||||
};
|
};
|
||||||
if (node.role === "group") {
|
const childNodes =
|
||||||
return node.children?.flatMap(flattenMapNode) ?? [];
|
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(
|
export function parseHierarchicalMapPayload(
|
||||||
@@ -78,12 +89,22 @@ export function parseHierarchicalMapPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function parseMapNodes(value: unknown): MapNode[] {
|
export function parseMapNodes(value: unknown): MapNode[] {
|
||||||
|
return parseMapData(value).mapNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMapData(value: unknown): ParsedMapNodes {
|
||||||
if (Array.isArray(value) && value.every(isHierarchicalMapNode)) {
|
if (Array.isArray(value) && value.every(isHierarchicalMapNode)) {
|
||||||
return value.flatMap(flattenMapNode);
|
return {
|
||||||
|
mapNodes: value.flatMap((node, index) => flattenMapNode(node, [index])),
|
||||||
|
mapTree: value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHierarchicalMapNode(value)) {
|
if (isHierarchicalMapNode(value)) {
|
||||||
return flattenMapNode(value);
|
return {
|
||||||
|
mapNodes: flattenMapNode(value, []),
|
||||||
|
mapTree: value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("Invalid map node data");
|
throw new Error("Invalid map node data");
|
||||||
|
|||||||
@@ -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
@@ -21,18 +21,22 @@ import {
|
|||||||
isMapModelVisible,
|
isMapModelVisible,
|
||||||
useMapPerformanceStore,
|
useMapPerformanceStore,
|
||||||
} from "@/managers/stores/useMapPerformanceStore";
|
} from "@/managers/stores/useMapPerformanceStore";
|
||||||
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import { GameMapCollision } from "@/world/GameMapCollision";
|
import { GameMapCollision } from "@/world/GameMapCollision";
|
||||||
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
import { CloudSystem } from "@/world/clouds/CloudSystem";
|
||||||
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
import { GeneratedMapNodeInstance } from "@/world/map-generated/GeneratedMapNodeInstance";
|
||||||
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
import { isGeneratedMapModelName } from "@/world/map-generated/generatedMapModelConfig";
|
||||||
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
import { MapInstancingSystem } from "@/world/map-instancing/MapInstancingSystem";
|
||||||
import { isInstancedMapNodeName } from "@/world/map-instancing/mapInstancingConfig";
|
|
||||||
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
import { VegetationSystem } from "@/world/vegetation/VegetationSystem";
|
||||||
import { WaterSystem } from "@/world/water/WaterSystem";
|
import { WaterSystem } from "@/world/water/WaterSystem";
|
||||||
import { WorldPlane } from "@/world/WorldPlane";
|
import { WorldPlane } from "@/world/WorldPlane";
|
||||||
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
import type { SceneLoadingChangeHandler } from "@/types/world/sceneLoading";
|
||||||
import { logger } from "@/utils/core/Logger";
|
import { logger } from "@/utils/core/Logger";
|
||||||
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
import { loadMapSceneData } from "@/utils/map/loadMapSceneData";
|
||||||
|
import {
|
||||||
|
getTerrainMapNode,
|
||||||
|
isRuntimeSingleMapNode,
|
||||||
|
} from "@/utils/map/mapRuntimeClassification";
|
||||||
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
import { logModelLoadError } from "@/utils/three/modelLoadLogger";
|
||||||
import type { MapNode } from "@/types/editor/editor";
|
import type { MapNode } from "@/types/editor/editor";
|
||||||
import type { OctreeReadyHandler } from "@/types/three/three";
|
import type { OctreeReadyHandler } from "@/types/three/three";
|
||||||
@@ -42,16 +46,6 @@ interface LoadedMapNode {
|
|||||||
modelUrl: string | null;
|
modelUrl: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAP_STRUCTURE_NODE_NAMES = new Set(["Scene", "blocking", "terrain"]);
|
|
||||||
const LITE_MAP_SKIPPED_NODE_NAMES = new Set([
|
|
||||||
"arbre",
|
|
||||||
"buisson",
|
|
||||||
"champdeble",
|
|
||||||
"champdesoja",
|
|
||||||
"champsdetournesol",
|
|
||||||
"sapin",
|
|
||||||
]);
|
|
||||||
|
|
||||||
interface ErrorBoundaryProps {
|
interface ErrorBoundaryProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fallback: ReactNode;
|
fallback: ReactNode;
|
||||||
@@ -120,9 +114,10 @@ export function GameMap({
|
|||||||
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
|
const [collisionMapNodes, setCollisionMapNodes] = useState<LoadedMapNode[]>(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
const [terrainNode, setTerrainNode] = useState<MapNode | null>(null);
|
||||||
const [mapLoaded, setMapLoaded] = useState(false);
|
const [mapLoaded, setMapLoaded] = useState(false);
|
||||||
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
const [settledMapNodeCount, setSettledMapNodeCount] = useState(0);
|
||||||
const mapReady = mapLoaded && settledMapNodeCount >= renderMapNodes.length;
|
const mapReady = mapLoaded;
|
||||||
|
|
||||||
const handleMapNodeSettled = useCallback((index: number) => {
|
const handleMapNodeSettled = useCallback((index: number) => {
|
||||||
if (settledMapNodesRef.current.has(index)) return;
|
if (settledMapNodesRef.current.has(index)) return;
|
||||||
@@ -135,6 +130,7 @@ export function GameMap({
|
|||||||
(currentStep: string) => {
|
(currentStep: string) => {
|
||||||
setRenderMapNodes([]);
|
setRenderMapNodes([]);
|
||||||
setCollisionMapNodes([]);
|
setCollisionMapNodes([]);
|
||||||
|
setTerrainNode(null);
|
||||||
setMapLoaded(true);
|
setMapLoaded(true);
|
||||||
settledMapNodesRef.current.clear();
|
settledMapNodesRef.current.clear();
|
||||||
setSettledMapNodeCount(0);
|
setSettledMapNodeCount(0);
|
||||||
@@ -169,7 +165,9 @@ export function GameMap({
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
});
|
});
|
||||||
|
|
||||||
const visibleMapNodes = sceneData.mapNodes.filter(liteMap);
|
const visibleMapNodes = sceneData.mapNodes.filter(
|
||||||
|
isRuntimeSingleMapNode,
|
||||||
|
);
|
||||||
const skippedMapNodeCount =
|
const skippedMapNodeCount =
|
||||||
sceneData.mapNodes.length - visibleMapNodes.length;
|
sceneData.mapNodes.length - visibleMapNodes.length;
|
||||||
|
|
||||||
@@ -189,6 +187,7 @@ export function GameMap({
|
|||||||
const modelUrl = sceneData.models.get(node.name);
|
const modelUrl = sceneData.models.get(node.name);
|
||||||
return { node, modelUrl: modelUrl ?? null };
|
return { node, modelUrl: modelUrl ?? null };
|
||||||
});
|
});
|
||||||
|
const loadedTerrainNode = getTerrainMapNode(sceneData.mapNodes);
|
||||||
const missingModelCount = loadedMapNodes.filter(
|
const missingModelCount = loadedMapNodes.filter(
|
||||||
(mapNode) => mapNode.modelUrl === null,
|
(mapNode) => mapNode.modelUrl === null,
|
||||||
).length;
|
).length;
|
||||||
@@ -205,6 +204,7 @@ export function GameMap({
|
|||||||
|
|
||||||
setRenderMapNodes(loadedMapNodes);
|
setRenderMapNodes(loadedMapNodes);
|
||||||
setCollisionMapNodes(loadedCollisionNodes);
|
setCollisionMapNodes(loadedCollisionNodes);
|
||||||
|
setTerrainNode(loadedTerrainNode);
|
||||||
setMapLoaded(true);
|
setMapLoaded(true);
|
||||||
settledMapNodesRef.current.clear();
|
settledMapNodesRef.current.clear();
|
||||||
setSettledMapNodeCount(0);
|
setSettledMapNodeCount(0);
|
||||||
@@ -267,7 +267,15 @@ export function GameMap({
|
|||||||
<CloudSystem />
|
<CloudSystem />
|
||||||
<VegetationSystem />
|
<VegetationSystem />
|
||||||
{isMapModelVisible("terrain", { groups, models }) ? (
|
{isMapModelVisible("terrain", { groups, models }) ? (
|
||||||
|
terrainNode ? (
|
||||||
|
<TerrainModel
|
||||||
|
position={terrainNode.position}
|
||||||
|
rotation={terrainNode.rotation}
|
||||||
|
scale={terrainNode.scale}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<TerrainModel />
|
<TerrainModel />
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
<GameMapCollision
|
<GameMapCollision
|
||||||
buildOctree={buildOctree}
|
buildOctree={buildOctree}
|
||||||
@@ -289,29 +297,6 @@ function HiddenMapNode({ onSettled }: { onSettled: () => void }): null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Temporary development-only map reducer.
|
|
||||||
*
|
|
||||||
* TODO: replace this with a real map performance pass: merged static geometry,
|
|
||||||
* instancing for repeated props, LOD, and/or zone-based loading. For now this
|
|
||||||
* keeps the app usable on local machines by not rendering the densest exported
|
|
||||||
* nodes from map.json.
|
|
||||||
*/
|
|
||||||
function liteMap(node: MapNode): boolean {
|
|
||||||
if (MAP_STRUCTURE_NODE_NAMES.has(node.name)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === "Mesh") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
!LITE_MAP_SKIPPED_NODE_NAMES.has(node.name) &&
|
|
||||||
!isInstancedMapNodeName(node.name)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapNodeInstance({
|
function MapNodeInstance({
|
||||||
node,
|
node,
|
||||||
modelUrl,
|
modelUrl,
|
||||||
@@ -320,8 +305,12 @@ function MapNodeInstance({
|
|||||||
node: MapNode;
|
node: MapNode;
|
||||||
modelUrl: string | null;
|
modelUrl: string | null;
|
||||||
onSettled: () => void;
|
onSettled: () => void;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element | null {
|
||||||
const isGeneratedModel = isGeneratedMapModelName(node.name);
|
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(() => {
|
useEffect(() => {
|
||||||
if (modelUrl !== null || isGeneratedModel) return;
|
if (modelUrl !== null || isGeneratedModel) return;
|
||||||
@@ -329,6 +318,16 @@ function MapNodeInstance({
|
|||||||
onSettled();
|
onSettled();
|
||||||
}, [isGeneratedModel, modelUrl, onSettled]);
|
}, [isGeneratedModel, modelUrl, onSettled]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hideEbikeMapModel) return;
|
||||||
|
|
||||||
|
onSettled();
|
||||||
|
}, [hideEbikeMapModel, onSettled]);
|
||||||
|
|
||||||
|
if (hideEbikeMapModel) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (isGeneratedModel) {
|
if (isGeneratedModel) {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<FallbackMapNode node={node} />}>
|
<Suspense fallback={<FallbackMapNode node={node} />}>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { InteractableObject } from "@/components/three/interaction/InteractableObject";
|
||||||
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
import { RepairGame } from "@/components/three/gameplay/RepairGame";
|
||||||
|
import { EBIKE_REPAIR_POSITION } from "@/data/gameplay/repairMissionAnchors";
|
||||||
import { useGameStore } from "@/managers/stores/useGameStore";
|
import { useGameStore } from "@/managers/stores/useGameStore";
|
||||||
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
||||||
import type { Vector3Tuple } from "@/types/three/three";
|
import type { Vector3Tuple } from "@/types/three/three";
|
||||||
@@ -17,7 +19,7 @@ interface GameRepairZone {
|
|||||||
const GAME_REPAIR_ZONES = [
|
const GAME_REPAIR_ZONES = [
|
||||||
{
|
{
|
||||||
mission: "bike",
|
mission: "bike",
|
||||||
position: [8, 0, -6],
|
position: EBIKE_REPAIR_POSITION,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
mission: "pylone",
|
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 {
|
export function GameStageContent(): React.JSX.Element {
|
||||||
const mainState = useGameStore((state) => state.mainState);
|
const mainState = useGameStore((state) => state.mainState);
|
||||||
|
|
||||||
@@ -63,6 +90,7 @@ export function GameStageContent(): React.JSX.Element {
|
|||||||
position={zone.position}
|
position={zone.position}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
<EbikeMissionTrigger />
|
||||||
{mainState === "outro" ? (
|
{mainState === "outro" ? (
|
||||||
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
|
<StageAnchor color="#fb7185" position={[0, 6, 10]} scale={1.25} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -62,3 +62,11 @@ export const INSTANCED_MAP_EXCEPTIONS = new Set([
|
|||||||
"blocking",
|
"blocking",
|
||||||
"terrain",
|
"terrain",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function getVegetationScaleMultiplier(name: string): number | null {
|
||||||
|
const config = Object.values(VEGETATION_TYPES).find(
|
||||||
|
(vegetationConfig) => vegetationConfig.mapName === name,
|
||||||
|
);
|
||||||
|
|
||||||
|
return config?.scaleMultiplier ?? null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user