import { useEffect, useMemo, useState } from 'react'; import { Button, Select } from '../../components/ui'; import { MapEditSession } from '../../map/editor/MapEditSession'; import { editableObjectGroundHeight, MAP_OBJECT_PLACEMENT_LABELS, type EditableMapDocument, type EditableMapObject, type EditableMapObjectType, type MapEditorInteractionCallbacks, type MapEditorTransformMode, type MapObjectPlacementMode, } from '../../map/editor/types'; const labels: Record = { box: '方盒', cylinder: '圆柱', capsule: '胶囊', ramp: '坡道', stairs: '楼梯', }; function yawDegrees(object: EditableMapObject): number { const [w, x, y, z] = object.pose.quaternion; return (Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)) * 180) / Math.PI; } function scaledParameters( object: EditableMapObject, scale: [number, number, number], ): Record { const [sx, sy, sz] = scale.map((value) => Math.max(0.001, Math.abs(value))) as [ number, number, number, ]; const p = object.parameters; if (object.type === 'box') return { sizeX: p.sizeX * sx, sizeY: p.sizeY * sy, sizeZ: p.sizeZ * sz }; if (object.type === 'cylinder') return { radius: p.radius * Math.max(sx, sy), height: p.height * sz }; if (object.type === 'capsule') return { radius: p.radius * Math.max(sx, sy), length: p.length * sz }; if (object.type === 'ramp') return { length: p.length * sx, width: p.width * sy, rise: p.rise * sz, thickness: p.thickness * sz, }; return { stepDepth: p.stepDepth * sx, width: p.width * sy, stepHeight: p.stepHeight * sz, count: p.count, }; } export function MapEditorPanel({ document, loading, onPreview, onApply, onExport, onConvert, canConvert, onBindInteraction, onSelectPreview, onTransformMode, onSnapping, }: { document: EditableMapDocument | null; loading: boolean; onPreview: (document: EditableMapDocument | null) => void; onApply: (document: EditableMapDocument) => Promise; onExport: () => void; onConvert: () => Promise; canConvert: boolean; onBindInteraction: (callbacks: MapEditorInteractionCallbacks | null) => void; onSelectPreview: (id: string | null) => void; onTransformMode: (mode: MapEditorTransformMode) => void; onSnapping: (translation: number | null, rotationDegrees: number | null) => void; }) { const session = useMemo(() => (document ? new MapEditSession(document) : null), [document]); const [, render] = useState(0), [selected, setSelected] = useState(), [selectedType, setSelectedType] = useState('box'), [transformMode, setTransformMode] = useState('translate'), [snapping, setSnapping] = useState(true), [converting, setConverting] = useState(false); const refresh = () => { render((value) => value + 1); onPreview(session?.document ?? null); }; useEffect(() => { onPreview(session?.document ?? null); return () => onPreview(null); }, [session, onPreview]); useEffect(() => { if (!session) { onBindInteraction(null); return; } const callbacks: MapEditorInteractionCallbacks = { onSelect: (id) => setSelected(id ?? undefined), onTransform: ({ id, position, quaternion, scale }) => { const object = session.document.objects.find((item) => item.id === id); if (!object) return; session.update(id, { pose: { position, quaternion }, parameters: scaledParameters(object, scale), }); setSelected(id); render((value) => value + 1); onPreview(session.document); }, onAddAsset: (type, position, placementMode) => { const object = session.addAsset(type, position, placementMode); setSelected(object.id); render((value) => value + 1); onPreview(session.document); onSelectPreview(object.id); }, }; onBindInteraction(callbacks); return () => onBindInteraction(null); }, [session, onBindInteraction, onPreview, onSelectPreview]); useEffect(() => { onTransformMode(transformMode); onSnapping(snapping ? 0.1 : null, snapping ? 5 : null); }, [transformMode, snapping, onTransformMode, onSnapping]); useEffect(() => { if (!session) return; const keydown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; if (target?.matches('input, textarea, select') || target?.isContentEditable || event.altKey) return; const key = event.key.toLowerCase(); if ((event.ctrlKey || event.metaKey) && key === 'z') { event.preventDefault(); if (event.shiftKey) session.redo(); else session.undo(); if (selected && !session.document.objects.some((object) => object.id === selected)) { setSelected(undefined); onSelectPreview(null); } render((value) => value + 1); onPreview(session.document); } else if ((event.ctrlKey || event.metaKey) && key === 'y') { event.preventDefault(); session.redo(); render((value) => value + 1); onPreview(session.document); } else if (!event.ctrlKey && !event.metaKey && key === 'w') setTransformMode('translate'); else if (!event.ctrlKey && !event.metaKey && key === 'e') setTransformMode('rotate'); else if (!event.ctrlKey && !event.metaKey && key === 's') setTransformMode('scale'); else if (event.key === 'Escape') { setSelected(undefined); onSelectPreview(null); } else if ((event.key === 'Delete' || event.key === 'Backspace') && selected) { event.preventDefault(); session.remove(selected); setSelected(undefined); render((value) => value + 1); onPreview(session.document); onSelectPreview(null); } }; window.addEventListener('keydown', keydown); return () => window.removeEventListener('keydown', keydown); }, [session, selected, onPreview, onSelectPreview]); if (!session) return (

当前地图没有 V3 authoring.source,保持只读。

仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF 可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。

{!canConvert &&

该地图没有 MJCF 物理层,无法转换。

}
); const current = session.document; const active = current.objects.find((object) => object.id === selected); const poseLocked = active?.placementMode === 'locked'; const updatePosition = (index: number, value: number) => { if (!active || !Number.isFinite(value)) return; const position = [...active.pose.position] as [number, number, number]; position[index] = value; session.update(active.id, { pose: { ...active.pose, position } }); refresh(); }; const updateParameter = (key: string, value: number) => { if (!active || !Number.isFinite(value) || value <= 0) return; session.update(active.id, { parameters: { ...active.parameters, [key]: key === 'count' ? Math.round(value) : value }, }); refresh(); }; const updateSpawnPosition = ( id: string, position: [number, number, number], index: number, value: number, ) => { if (!Number.isFinite(value)) return; const next = [...position] as [number, number, number]; next[index] = value; session.updateSpawn(id, { position: next }); refresh(); }; return (

在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。

{current.objects.map((object) => ( ))} {!current.objects.length &&
暂无对象
}
{active && (
{active.pose.position.map((value, index) => ( ))}
{Object.entries(active.parameters).map(([key, value]) => ( ))}
)}
出生点
{current.spawnPoints.map((spawn) => (
{ if (!event.target.value.trim()) return; session.updateSpawn(spawn.id, { name: event.target.value }); refresh(); }} />
{spawn.position.map((value, index) => ( ))}
))} {!current.spawnPoints.length && (
暂无出生点
)}
{session.dirty && (
地图草稿尚未应用
)}
); }