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