Merge remote map editor updates

This commit is contained in:
Tom Boullay
2026-05-27 22:24:59 +02:00
20 changed files with 718 additions and 209 deletions
+75 -5
View File
@@ -13,6 +13,7 @@ import {
RotateCw,
Save,
Trash2,
ScanSearch,
Undo2,
Unlock,
X,
@@ -21,17 +22,19 @@ import { EditorCinematicManifestPanel } from "@/components/editor/EditorCinemati
import { EditorDialogueManifestPanel } from "@/components/editor/EditorDialogueManifestPanel";
import { EditorSrtPanel } from "@/components/editor/EditorSrtPanel";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { EditableMapNode, TransformMode } from "@/types/editor/editor";
import type { MapNode, TransformMode } from "@/types/editor/editor";
import type { Vector3Tuple } from "@/types/three/three";
interface EditorControlsProps {
transformMode: TransformMode;
onTransformModeChange: (mode: TransformMode) => void;
selectedNodeIndex: number | null;
mapNodes: EditableMapNode[];
mapNodes: MapNode[];
nodesCount: number;
selectedNodeName: string | null;
selectedNodeScale: Vector3Tuple | null;
lockTerrainSelection: boolean;
onLockTerrainSelectionChange: (locked: boolean) => void;
isSelectionLocked: boolean;
onSelectionLockToggle: () => void;
onClearSelection: () => void;
@@ -46,6 +49,8 @@ interface EditorControlsProps {
redoCount: number;
onUndo: () => void;
onRedo: () => void;
cameraActionLabel: string;
onCameraAction: () => void;
onExportJson: () => void;
onSaveToServer?: (() => void | Promise<void>) | undefined;
onPlayerMode?: (() => void) | undefined;
@@ -102,6 +107,8 @@ export function EditorControls({
nodesCount,
selectedNodeName,
selectedNodeScale,
lockTerrainSelection,
onLockTerrainSelectionChange,
isSelectionLocked,
onSelectionLockToggle,
onClearSelection,
@@ -116,6 +123,8 @@ export function EditorControls({
redoCount,
onUndo,
onRedo,
cameraActionLabel,
onCameraAction,
onExportJson,
onSaveToServer,
onPlayerMode,
@@ -124,6 +133,9 @@ export function EditorControls({
}: EditorControlsProps): React.JSX.Element {
const viewModeLabel = isPlayerMode ? "View locked" : "Lock view";
const jsonPreview = getJsonPreview(mapNodes, selectedNodeIndex);
const selectedNode =
selectedNodeIndex !== null ? mapNodes[selectedNodeIndex] : null;
const transformValues = getTransformValues(selectedNode ?? null);
return (
<>
@@ -174,7 +186,10 @@ export function EditorControls({
aria-pressed={transformMode === mode}
>
<Icon size={16} aria-hidden="true" />
<span>{label}</span>
<span className="editor-transform-label">
<span>{label}</span>
<small>{transformValues[mode]}</small>
</span>
<kbd>{shortcut}</kbd>
</button>
))}
@@ -339,6 +354,25 @@ export function EditorControls({
{viewModeLabel}
</button>
)}
<button className="editor-action-button" onClick={onCameraAction}>
<ScanSearch size={16} aria-hidden="true" />
{cameraActionLabel}
</button>
<label className="editor-checkbox-row">
<input
type="checkbox"
checked={lockTerrainSelection}
onChange={(event) =>
onLockTerrainSelectionChange(event.currentTarget.checked)
}
/>
<span>
<strong>Lock terrain</strong>
<small>Keep terrain visible but ignore terrain clicks</small>
</span>
</label>
</section>
<section
@@ -411,6 +445,42 @@ export function EditorControls({
);
}
function formatNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
function formatVector(values: readonly [number, number, number]): string {
return `X ${formatNumber(values[0])} · Y ${formatNumber(values[1])} · Z ${formatNumber(values[2])}`;
}
function formatRotation(values: readonly [number, number, number]): string {
const degrees = values.map((value) => (value * 180) / Math.PI) as [
number,
number,
number,
];
return `X ${formatNumber(degrees[0])}° · Y ${formatNumber(degrees[1])}° · Z ${formatNumber(degrees[2])}°`;
}
function getTransformValues(
node: MapNode | null,
): Record<TransformMode, string> {
if (!node) {
return {
translate: "No selection",
rotate: "No selection",
scale: "No selection",
};
}
return {
translate: formatVector(node.position),
rotate: formatRotation(node.rotation),
scale: formatVector(node.scale),
};
}
interface JsonPreviewLine {
number: number;
content: string;
@@ -423,7 +493,7 @@ interface JsonPreview {
}
function getJsonPreview(
mapNodes: EditableMapNode[],
mapNodes: MapNode[],
selectedNodeIndex: number | null,
): JsonPreview {
const { lines, ranges } = formatMapNodesWithRanges(mapNodes);
@@ -452,7 +522,7 @@ function getJsonPreview(
};
}
function formatMapNodesWithRanges(mapNodes: EditableMapNode[]): {
function formatMapNodesWithRanges(mapNodes: MapNode[]): {
lines: string[];
ranges: Array<{ start: number; end: number }>;
} {
+73 -8
View File
@@ -3,10 +3,15 @@ import { Grid, TransformControls } from "@react-three/drei";
import type { ThreeEvent } from "@react-three/fiber";
import * as THREE from "three";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { useClonedObject } from "@/hooks/three/useClonedObject";
import { useLoggedGLTF } from "@/hooks/three/useLoggedGLTF";
import { useTerrainHeightSampler } from "@/hooks/three/useTerrainHeight";
import type { SceneData, MapNode, TransformMode } from "@/types/editor/editor";
import {
isEditorVisibleMapNode,
getTerrainMapNode,
} from "@/utils/map/mapRuntimeClassification";
interface EditorMapProps {
sceneData: SceneData;
@@ -17,6 +22,7 @@ interface EditorMapProps {
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
snapToTerrain: boolean;
lockTerrainSelection: boolean;
onTransformStart: () => void;
onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
@@ -141,6 +147,7 @@ export function EditorMap({
onHoverNode,
transformMode,
snapToTerrain,
lockTerrainSelection,
onTransformStart,
onTransformEnd,
onNodeTransform,
@@ -153,6 +160,11 @@ export function EditorMap({
};
const handleTransformMouseUp = () => {
syncSelectedObjectTransform();
onTransformEnd();
};
const syncSelectedObjectTransform = () => {
if (selectedNodeIndex !== null) {
const obj = objectsMapRef.current.get(selectedNodeIndex);
if (!obj) return;
@@ -180,12 +192,18 @@ export function EditorMap({
onNodeTransform(selectedNodeIndex, updatedNode);
}
}
onTransformEnd();
};
const [selectedObject, setSelectedObject] = useState<THREE.Object3D | null>(
null,
);
const terrainNode = getTerrainMapNode(sceneData.mapNodes);
const terrainNodeIndex = terrainNode
? sceneData.mapNodes.indexOf(terrainNode)
: -1;
const selectedNode =
selectedNodeIndex !== null ? sceneData.mapNodes[selectedNodeIndex] : null;
const selectedModelName = selectedNode?.name ?? null;
useEffect(() => {
if (selectedNodeIndex !== null) {
@@ -213,14 +231,29 @@ export function EditorMap({
/>
<axesHelper args={[10]} />
<group
onClick={(event: ThreeEvent<MouseEvent>) => {
event.stopPropagation();
if (isSelectionLocked) return;
onSelectNode(null);
}}
>
<group>
{terrainNode ? (
<EditorTerrainNode
index={terrainNodeIndex}
node={terrainNode}
isSelected={selectedNodeIndex === terrainNodeIndex}
isHovered={hoveredNodeIndex === terrainNodeIndex}
lockTerrainSelection={lockTerrainSelection}
objectsMapRef={objectsMapRef}
onSelectNode={onSelectNode}
isSelectionLocked={isSelectionLocked}
onHoverNode={onHoverNode}
/>
) : null}
{sceneData.mapNodes.map((node, index) => {
if (!isEditorVisibleMapNode(node)) {
return null;
}
if (selectedModelName && node.name !== selectedModelName) {
return null;
}
const modelUrl = sceneData.models.get(node.name);
if (modelUrl) {
@@ -262,6 +295,7 @@ export function EditorMap({
mode={transformMode}
onMouseDown={handleTransformMouseDown}
onMouseUp={handleTransformMouseUp}
onObjectChange={syncSelectedObjectTransform}
/>
)}
</>
@@ -363,6 +397,37 @@ function EditorModelNode({
);
}
function EditorTerrainNode({
index,
node,
lockTerrainSelection,
objectsMapRef,
onSelectNode,
isSelectionLocked,
onHoverNode,
}: EditorNodeCommonProps & { lockTerrainSelection: boolean }) {
const groupRef = useRef<THREE.Group>(null);
const pointerHandlers = createEditorNodePointerHandlers(
index,
onSelectNode,
isSelectionLocked,
onHoverNode,
);
useRegisteredEditorNode(groupRef, index, node, objectsMapRef);
return (
<group
ref={groupRef}
position={node.position}
rotation={node.rotation}
scale={node.scale}
{...(lockTerrainSelection ? {} : pointerHandlers)}
>
<TerrainModel receiveShadow visible />
</group>
);
}
function EditorFallbackNode({
index,
node,
+87 -1
View File
@@ -1,14 +1,18 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { OrbitControls } from "@react-three/drei";
import { useThree } from "@react-three/fiber";
import gsap from "gsap";
import * as THREE from "three";
import type { OrbitControls as OrbitControlsImpl } from "three-stdlib";
import { EditorMap } from "@/components/editor/scene/EditorMap";
import { TerrainModel } from "@/components/three/world/TerrainModel";
import { FlyController } from "@/controls/editor/FlyController";
import type { CinematicDefinition } from "@/types/cinematics/cinematics";
import type { MapNode, TransformMode, SceneData } from "@/types/editor/editor";
const EDITOR_CAMERA_HOME_POSITION = new THREE.Vector3(0, 50, 100);
const EDITOR_CAMERA_HOME_TARGET = new THREE.Vector3(0, 0, 0);
export interface EditorCinematicPreviewRequest {
id: string;
cinematic: CinematicDefinition;
@@ -23,12 +27,15 @@ interface EditorSceneProps {
onHoverNode: (index: number | null) => void;
transformMode: TransformMode;
snapToTerrain: boolean;
lockTerrainSelection: boolean;
onTransformModeChange: (mode: TransformMode) => void;
onTransformStart: () => void;
onTransformEnd: () => void;
onNodeTransform: (nodeIndex: number, transform: MapNode) => void;
onUndo: () => void;
onRedo: () => void;
resetCameraRequest: number;
focusSelectedCameraRequest: number;
isPlayerMode?: boolean;
cinematicPreviewRequest?: EditorCinematicPreviewRequest | null;
onCinematicPreviewComplete?: (() => void) | undefined;
@@ -43,17 +50,94 @@ export function EditorScene({
onHoverNode,
transformMode,
snapToTerrain,
lockTerrainSelection,
onTransformModeChange,
onTransformStart,
onTransformEnd,
onNodeTransform,
onUndo,
onRedo,
resetCameraRequest,
focusSelectedCameraRequest,
isPlayerMode = false,
cinematicPreviewRequest = null,
onCinematicPreviewComplete,
}: EditorSceneProps): React.JSX.Element {
const isCinematicPreviewing = cinematicPreviewRequest !== null;
const camera = useThree((state) => state.camera);
const orbitControlsRef = useRef<OrbitControlsImpl | null>(null);
const previousSelectedNodeIndexRef = useRef<number | null>(null);
const focusCameraOnNode = useCallback(
(node: MapNode): void => {
const controls = orbitControlsRef.current;
const target = new THREE.Vector3(...node.position);
const currentTarget = controls?.target ?? EDITOR_CAMERA_HOME_TARGET;
const cameraOffset = camera.position.clone().sub(currentTarget);
camera.position.copy(target).add(cameraOffset);
camera.lookAt(target);
controls?.target.copy(target);
controls?.update();
},
[camera],
);
useEffect(() => {
if (selectedNodeIndex === previousSelectedNodeIndexRef.current) return;
previousSelectedNodeIndexRef.current = selectedNodeIndex;
if (selectedNodeIndex === null || isPlayerMode || isCinematicPreviewing) {
return;
}
const selectedNode = sceneData.mapNodes[selectedNodeIndex];
if (!selectedNode) return;
focusCameraOnNode(selectedNode);
}, [
camera,
isCinematicPreviewing,
isPlayerMode,
focusCameraOnNode,
sceneData,
selectedNodeIndex,
]);
useEffect(() => {
if (
focusSelectedCameraRequest === 0 ||
selectedNodeIndex === null ||
isPlayerMode ||
isCinematicPreviewing
) {
return;
}
const selectedNode = sceneData.mapNodes[selectedNodeIndex];
if (!selectedNode) return;
focusCameraOnNode(selectedNode);
}, [
focusSelectedCameraRequest,
focusCameraOnNode,
isCinematicPreviewing,
isPlayerMode,
sceneData,
selectedNodeIndex,
]);
useEffect(() => {
if (resetCameraRequest === 0 || isPlayerMode || isCinematicPreviewing) {
return;
}
const controls = orbitControlsRef.current;
camera.position.copy(EDITOR_CAMERA_HOME_POSITION);
camera.lookAt(EDITOR_CAMERA_HOME_TARGET);
controls?.target.copy(EDITOR_CAMERA_HOME_TARGET);
controls?.update();
}, [camera, isCinematicPreviewing, isPlayerMode, resetCameraRequest]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -110,6 +194,7 @@ export function EditorScene({
<FlyController disabled={isCinematicPreviewing} />
) : (
<OrbitControls
ref={orbitControlsRef}
enabled={!isCinematicPreviewing}
enableDamping
dampingFactor={0.05}
@@ -130,6 +215,7 @@ export function EditorScene({
onHoverNode={onHoverNode}
transformMode={transformMode}
snapToTerrain={snapToTerrain}
lockTerrainSelection={lockTerrainSelection}
onTransformStart={onTransformStart}
onTransformEnd={onTransformEnd}
onNodeTransform={onNodeTransform}