598 lines
21 KiB
TypeScript
598 lines
21 KiB
TypeScript
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<EditableMapObjectType, string> = {
|
|
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<string, number> {
|
|
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<boolean>;
|
|
onExport: () => void;
|
|
onConvert: () => Promise<boolean>;
|
|
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<string>(),
|
|
[selectedType, setSelectedType] = useState<EditableMapObjectType>('box'),
|
|
[transformMode, setTransformMode] = useState<MapEditorTransformMode>('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 (
|
|
<div className="space-y-2 rounded border border-border-subtle p-3 text-xs text-text-tertiary">
|
|
<p>当前地图没有 V3 authoring.source,保持只读。</p>
|
|
<p className="text-[10px] leading-relaxed">
|
|
仅由 box、cylinder、capsule 组成且不含资产、材质或碰撞过滤的静态 MJCF
|
|
可以安全转换;遇到无法表达的语义会整体拒绝,不会静默丢失内容。
|
|
</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
variant="primary"
|
|
disabled={loading || converting || !canConvert}
|
|
onClick={() => {
|
|
setConverting(true);
|
|
void onConvert().finally(() => setConverting(false));
|
|
}}
|
|
>
|
|
{converting ? '正在转换…' : '创建可编辑副本'}
|
|
</Button>
|
|
<Button disabled={loading || converting} onClick={onExport}>
|
|
导出原地图 ZIP
|
|
</Button>
|
|
</div>
|
|
{!canConvert && <p className="text-warning">该地图没有 MJCF 物理层,无法转换。</p>}
|
|
</div>
|
|
);
|
|
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 (
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-3 gap-2 rounded border border-border-subtle p-2">
|
|
<Button
|
|
variant={transformMode === 'translate' ? 'primary' : 'secondary'}
|
|
aria-pressed={transformMode === 'translate'}
|
|
disabled={loading}
|
|
onClick={() => setTransformMode('translate')}
|
|
>
|
|
移动工具 W
|
|
</Button>
|
|
<Button
|
|
variant={transformMode === 'rotate' ? 'primary' : 'secondary'}
|
|
aria-pressed={transformMode === 'rotate'}
|
|
disabled={loading}
|
|
onClick={() => setTransformMode('rotate')}
|
|
>
|
|
旋转工具 E
|
|
</Button>
|
|
<Button
|
|
variant={transformMode === 'scale' ? 'primary' : 'secondary'}
|
|
aria-pressed={transformMode === 'scale'}
|
|
disabled={loading}
|
|
onClick={() => setTransformMode('scale')}
|
|
>
|
|
缩放工具 S
|
|
</Button>
|
|
<label className="col-span-3 flex items-center justify-between text-[11px] text-text-secondary">
|
|
<span>吸附(移动 0.1 m / 旋转 5°)</span>
|
|
<input
|
|
aria-label="地图编辑吸附"
|
|
type="checkbox"
|
|
checked={snapping}
|
|
disabled={loading}
|
|
onChange={(event) => setSnapping(event.target.checked)}
|
|
/>
|
|
</label>
|
|
<p className="col-span-3 text-[10px] text-text-tertiary">
|
|
在视口点击对象后拖动操纵轴;只允许绕世界 Z 轴旋转,缩放会写入原语尺寸。
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Select
|
|
aria-label="新增地图对象类型"
|
|
className="min-w-0 flex-1"
|
|
value={selectedType}
|
|
disabled={loading}
|
|
onChange={(event) => setSelectedType(event.target.value as EditableMapObjectType)}
|
|
>
|
|
{(Object.keys(labels) as EditableMapObjectType[]).map((type) => (
|
|
<option key={type} value={type}>
|
|
{labels[type]}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Button
|
|
disabled={loading}
|
|
onClick={() => {
|
|
const object = session.add(selectedType);
|
|
setSelected(object.id);
|
|
refresh();
|
|
onSelectPreview(object.id);
|
|
}}
|
|
>
|
|
新增
|
|
</Button>
|
|
</div>
|
|
<div className="max-h-36 space-y-1 overflow-auto" aria-label="地图对象列表">
|
|
{current.objects.map((object) => (
|
|
<button
|
|
key={object.id}
|
|
type="button"
|
|
className={`w-full rounded border px-2 py-1 text-left text-xs ${selected === object.id ? 'border-accent bg-accent/10' : 'border-border-subtle'}`}
|
|
onClick={() => {
|
|
setSelected(object.id);
|
|
onSelectPreview(object.id);
|
|
}}
|
|
>
|
|
{object.name} · {labels[object.type]}
|
|
{object.placementMode === 'locked' ? ' · 已锁定' : ''}
|
|
</button>
|
|
))}
|
|
{!current.objects.length && <div className="text-xs text-text-tertiary">暂无对象</div>}
|
|
</div>
|
|
{active && (
|
|
<div className="space-y-2 rounded border border-border-subtle p-2">
|
|
<label className="block text-[11px] text-text-secondary">
|
|
名称
|
|
<input
|
|
aria-label="对象名称"
|
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
value={active.name}
|
|
onChange={(event) => {
|
|
if (!event.target.value.trim()) return;
|
|
session.update(active.id, { name: event.target.value });
|
|
refresh();
|
|
}}
|
|
/>
|
|
</label>
|
|
<label className="block text-[11px] text-text-secondary">
|
|
放置方式
|
|
<Select
|
|
aria-label="对象放置方式"
|
|
className="mt-1 w-full"
|
|
value={active.placementMode}
|
|
disabled={loading}
|
|
onChange={(event) => {
|
|
session.update(active.id, {
|
|
placementMode: event.target.value as MapObjectPlacementMode,
|
|
});
|
|
refresh();
|
|
}}
|
|
>
|
|
{(Object.keys(MAP_OBJECT_PLACEMENT_LABELS) as MapObjectPlacementMode[]).map(
|
|
(mode) => (
|
|
<option key={mode} value={mode}>
|
|
{MAP_OBJECT_PLACEMENT_LABELS[mode]}
|
|
</option>
|
|
),
|
|
)}
|
|
</Select>
|
|
<span className="mt-1 block text-[9px] leading-3.5 text-text-tertiary">
|
|
{active.placementMode === 'auto_ground'
|
|
? '始终将对象底部对齐 z=0。'
|
|
: active.placementMode === 'gravity'
|
|
? '沿世界 -Z 落到 XY 范围内最高的静态承载面。'
|
|
: '保持当前位姿,并禁用表单和视口变换。'}
|
|
</span>
|
|
</label>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{active.pose.position.map((value, index) => (
|
|
<label key={index} className="text-[11px] text-text-secondary">
|
|
{['X', 'Y', 'Z'][index]}
|
|
<input
|
|
aria-label={`对象位置${['X', 'Y', 'Z'][index]}`}
|
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
type="number"
|
|
step="0.1"
|
|
value={value}
|
|
disabled={loading || poseLocked || index === 2}
|
|
onChange={(event) => updatePosition(index, Number(event.target.value))}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<label className="block text-[11px] text-text-secondary">
|
|
绕 Z 旋转(°)
|
|
<input
|
|
aria-label="对象绕Z旋转"
|
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
type="number"
|
|
step="1"
|
|
value={Number(yawDegrees(active).toFixed(4))}
|
|
disabled={loading || poseLocked}
|
|
onChange={(event) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isFinite(value)) return;
|
|
const half = (value * Math.PI) / 360;
|
|
session.update(active.id, {
|
|
pose: { ...active.pose, quaternion: [Math.cos(half), 0, 0, Math.sin(half)] },
|
|
});
|
|
refresh();
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{Object.entries(active.parameters).map(([key, value]) => (
|
|
<label key={key} className="text-[11px] text-text-secondary">
|
|
{key}
|
|
<input
|
|
aria-label={`对象参数${key}`}
|
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
type="number"
|
|
min="0.001"
|
|
step={key === 'count' ? 1 : 0.1}
|
|
value={value}
|
|
onChange={(event) => updateParameter(key, Number(event.target.value))}
|
|
/>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<label className="flex items-center justify-between text-[11px] text-text-secondary">
|
|
<span>启用对象</span>
|
|
<input
|
|
aria-label="启用地图对象"
|
|
type="checkbox"
|
|
checked={active.enabled}
|
|
onChange={(event) => {
|
|
session.update(active.id, { enabled: event.target.checked });
|
|
refresh();
|
|
}}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
disabled={loading || poseLocked}
|
|
onClick={() => {
|
|
session.update(active.id, {
|
|
pose: {
|
|
...active.pose,
|
|
position: [
|
|
active.pose.position[0],
|
|
active.pose.position[1],
|
|
editableObjectGroundHeight(active),
|
|
],
|
|
},
|
|
});
|
|
refresh();
|
|
}}
|
|
>
|
|
对齐地面
|
|
</Button>
|
|
<Button
|
|
disabled={loading}
|
|
onClick={() => {
|
|
const copy = session.duplicate(active.id);
|
|
setSelected(copy.id);
|
|
refresh();
|
|
onSelectPreview(copy.id);
|
|
}}
|
|
>
|
|
复制对象
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
disabled={loading}
|
|
onClick={() => {
|
|
session.remove(active.id);
|
|
setSelected(undefined);
|
|
refresh();
|
|
onSelectPreview(null);
|
|
}}
|
|
>
|
|
删除对象
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2 rounded border border-border-subtle p-2">
|
|
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
|
<span>出生点</span>
|
|
<Button
|
|
disabled={loading}
|
|
onClick={() => {
|
|
session.addSpawn();
|
|
refresh();
|
|
}}
|
|
>
|
|
新增出生点
|
|
</Button>
|
|
</div>
|
|
{current.spawnPoints.map((spawn) => (
|
|
<div key={spawn.id} className="space-y-2 rounded bg-panel-muted p-2">
|
|
<input
|
|
aria-label={`出生点名称 ${spawn.id}`}
|
|
className="h-7 w-full rounded border border-border-strong bg-input px-1 text-xs"
|
|
value={spawn.name}
|
|
onChange={(event) => {
|
|
if (!event.target.value.trim()) return;
|
|
session.updateSpawn(spawn.id, { name: event.target.value });
|
|
refresh();
|
|
}}
|
|
/>
|
|
<div className="grid grid-cols-4 gap-1">
|
|
{spawn.position.map((value, index) => (
|
|
<label key={index} className="text-[10px] text-text-secondary">
|
|
{['X', 'Y', 'Z'][index]}
|
|
<input
|
|
aria-label={`出生点${spawn.id}位置${['X', 'Y', 'Z'][index]}`}
|
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
type="number"
|
|
step="0.1"
|
|
value={value}
|
|
onChange={(event) =>
|
|
updateSpawnPosition(
|
|
spawn.id,
|
|
spawn.position,
|
|
index,
|
|
Number(event.target.value),
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
))}
|
|
<label className="text-[10px] text-text-secondary">
|
|
Yaw°
|
|
<input
|
|
aria-label={`出生点${spawn.id}朝向`}
|
|
className="mt-1 h-7 w-full rounded border border-border-strong bg-input px-1"
|
|
type="number"
|
|
step="1"
|
|
value={spawn.yawDeg}
|
|
onChange={(event) => {
|
|
const value = Number(event.target.value);
|
|
if (!Number.isFinite(value)) return;
|
|
session.updateSpawn(spawn.id, { yawDeg: value });
|
|
refresh();
|
|
}}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<Button
|
|
variant="danger"
|
|
disabled={loading}
|
|
onClick={() => {
|
|
session.removeSpawn(spawn.id);
|
|
refresh();
|
|
}}
|
|
>
|
|
删除出生点
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{!current.spawnPoints.length && (
|
|
<div className="text-[11px] text-text-tertiary">暂无出生点</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Button
|
|
disabled={loading || !session.canUndo}
|
|
onClick={() => {
|
|
session.undo();
|
|
refresh();
|
|
}}
|
|
>
|
|
撤销
|
|
</Button>
|
|
<Button
|
|
disabled={loading || !session.canRedo}
|
|
onClick={() => {
|
|
session.redo();
|
|
refresh();
|
|
}}
|
|
>
|
|
重做
|
|
</Button>
|
|
<Button
|
|
disabled={loading || !session.dirty}
|
|
onClick={() => {
|
|
session.discard();
|
|
setSelected(undefined);
|
|
refresh();
|
|
onSelectPreview(null);
|
|
}}
|
|
>
|
|
放弃草稿
|
|
</Button>
|
|
<Button disabled={loading} onClick={onExport}>
|
|
导出地图 ZIP
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
disabled={loading || !session.dirty}
|
|
onClick={async () => {
|
|
const latest = session.document;
|
|
const candidate = { ...latest, revision: latest.revision + 1 };
|
|
if (await onApply(candidate)) onPreview(null);
|
|
else onPreview(session.document);
|
|
}}
|
|
>
|
|
应用并重新编译
|
|
</Button>
|
|
{session.dirty && (
|
|
<div role="status" className="text-xs text-warning">
|
|
地图草稿尚未应用
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|