feat(web-platform): release V0.8.1 常规地图优化
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { BadgeCheck, CheckCircle2, Mountain, Plus } from 'lucide-react';
|
||||
import {
|
||||
BadgeCheck,
|
||||
CheckCircle2,
|
||||
Layers3,
|
||||
MapPinned,
|
||||
Mountain,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { Button, Select } from '../components/ui';
|
||||
import {
|
||||
CERTIFIED_MAP_ASSETS,
|
||||
MAP_ASSET_DRAG_MIME,
|
||||
MAP_ASSET_PLACEMENT_MIME,
|
||||
encodeMapLibraryDragPayload,
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
} from './editor/assetCatalog';
|
||||
import {
|
||||
MAP_OBJECT_PLACEMENT_LABELS,
|
||||
@@ -12,9 +22,11 @@ import {
|
||||
type EditableMapObjectType,
|
||||
type MapObjectPlacementMode,
|
||||
} from './editor/types';
|
||||
import type { MapEntry } from '../project/types';
|
||||
import {
|
||||
PHYSICAL_MAP_PRESET_LABELS,
|
||||
SYSTEM_TERRAIN_PRESETS,
|
||||
type PlacedMapAsset,
|
||||
type SystemTerrainPreset,
|
||||
} from './types';
|
||||
|
||||
@@ -136,52 +148,224 @@ export function MapAssetLibrary({
|
||||
disabled,
|
||||
terrainSize,
|
||||
document,
|
||||
documents,
|
||||
maps = [],
|
||||
placedMaps = [],
|
||||
activeMapId,
|
||||
pendingSceneChangeCount = 0,
|
||||
pendingSceneIds = [],
|
||||
onAdd,
|
||||
onAddProjectMap,
|
||||
onSelectTerrain,
|
||||
onApplyScene,
|
||||
onDiscardScene,
|
||||
onSelectMap,
|
||||
onRemoveMap,
|
||||
onSelectObject,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
terrainSize: number;
|
||||
document?: EditableMapDocument | null;
|
||||
documents?: ReadonlyMap<string, EditableMapDocument>;
|
||||
maps?: MapEntry[];
|
||||
placedMaps?: PlacedMapAsset[];
|
||||
activeMapId?: string;
|
||||
pendingSceneChangeCount?: number;
|
||||
pendingSceneIds?: string[];
|
||||
onAdd: (
|
||||
type: EditableMapObjectType,
|
||||
placementMode: MapObjectPlacementMode,
|
||||
) => void | Promise<void>;
|
||||
onAddProjectMap?: (descriptorPath: string) => void;
|
||||
onSelectTerrain: (preset: SystemTerrainPreset) => void;
|
||||
onSelectObject?: (id: string) => void;
|
||||
onApplyScene?: () => void;
|
||||
onDiscardScene?: () => void;
|
||||
onSelectMap?: (id: string) => void;
|
||||
onRemoveMap?: (id: string) => void;
|
||||
onSelectObject?: (mapId: string, objectId: string) => void;
|
||||
}) {
|
||||
const [placementMode, setPlacementMode] = useState<MapObjectPlacementMode>('auto_ground');
|
||||
const pendingSceneIdSet = new Set(pendingSceneIds);
|
||||
const placedObjectCount = placedMaps.reduce(
|
||||
(total, map) =>
|
||||
total +
|
||||
(map.selection.kind === 'project'
|
||||
? (documents?.get(map.selection.descriptorPath)?.objects.length ?? 0)
|
||||
: 0),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<section aria-label="认证资产" className="space-y-3 p-3">
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<section aria-label="地图资产库" className="space-y-3 p-3">
|
||||
<header>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-text-primary">
|
||||
<MapPinned className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
统一地图资产库
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
工程地图、认证资产和程序地形统一执行“放置 → 预览 → 一次应用”,不会因资产类型切换编译边界。
|
||||
</p>
|
||||
</header>
|
||||
<div
|
||||
className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5"
|
||||
aria-label="场景资产树"
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs font-medium text-text-primary">
|
||||
<span>场景结构</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Layers3 className="h-3.5 w-3.5 text-accent" aria-hidden="true" />
|
||||
已放置地图
|
||||
</span>
|
||||
<span className="technical-value text-[10px] text-text-tertiary">
|
||||
{document?.objects.length ?? 0} 个对象
|
||||
{placedMaps.length} 个实例
|
||||
{placedObjectCount ? ` · ${placedObjectCount} 个对象` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{document?.objects.length ? (
|
||||
<div className="mt-2 max-h-36 space-y-0.5 overflow-auto panel-scroll">
|
||||
{document.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[11px] text-text-secondary hover:bg-element-hover hover:text-text-primary"
|
||||
onClick={() => onSelectObject?.(object.id)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||
</button>
|
||||
))}
|
||||
{placedMaps.length ? (
|
||||
<div className="mt-2 max-h-52 space-y-0.5 overflow-auto panel-scroll" role="tree">
|
||||
{placedMaps.map((map) => {
|
||||
const active = map.id === activeMapId;
|
||||
const mapDocument =
|
||||
map.selection.kind === 'project'
|
||||
? (documents?.get(map.selection.descriptorPath) ?? (active ? document : null))
|
||||
: null;
|
||||
return (
|
||||
<div key={map.id} role="treeitem" aria-selected={active}>
|
||||
<div
|
||||
className={`flex items-center gap-1 rounded ${active ? 'bg-accent-soft text-accent' : 'text-text-secondary hover:bg-element-hover'}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-[11px] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label={`选择地图实例 ${map.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onSelectMap?.(map.id)}
|
||||
>
|
||||
<MapPinned className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">{map.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">
|
||||
{pendingSceneIdSet.has(map.id)
|
||||
? '待应用'
|
||||
: map.selection.kind === 'builtin'
|
||||
? '参数地形'
|
||||
: '工程地图'}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-1 rounded p-1 text-text-tertiary hover:bg-danger/10 hover:text-danger"
|
||||
aria-label={`删除地图实例 ${map.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onRemoveMap?.(map.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{mapDocument?.objects.length ? (
|
||||
<div className="ml-4 border-l border-border pl-1" role="group">
|
||||
{mapDocument.objects.map((object) => (
|
||||
<button
|
||||
key={object.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[10px] hover:bg-element-hover hover:text-text-primary ${active ? 'text-text-secondary' : 'text-text-tertiary'}`}
|
||||
onClick={() => onSelectObject?.(map.id, object.id)}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
|
||||
<span className="min-w-0 flex-1 truncate">{object.name}</span>
|
||||
<span className="text-[9px] text-text-tertiary">{object.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[10px] leading-relaxed text-text-tertiary">
|
||||
选择工程地图后,这里显示场景对象层级;可从下方资产库创建场景。
|
||||
尚未放置地图。从下方工程地图、认证资产或参数地形添加,实例会在这里统一管理。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingSceneChangeCount > 0 && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-lg border border-accent/30 bg-accent-soft p-2.5 text-[10px] text-text-secondary"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 font-medium text-accent">
|
||||
<Zap className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{pendingSceneChangeCount} 项场景更改待应用
|
||||
</div>
|
||||
<p className="mt-1 leading-relaxed">
|
||||
工程地图、认证资产与参数地形共享同一草稿;继续编辑不会触发 MuJoCo 编译。
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={disabled}
|
||||
onClick={onApplyScene}
|
||||
>
|
||||
一次编译应用
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
aria-label="放弃场景更改"
|
||||
disabled={disabled}
|
||||
onClick={onDiscardScene}
|
||||
>
|
||||
<RotateCcw className="mr-1 h-3 w-3" aria-hidden="true" />
|
||||
放弃
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maps.length > 0 && (
|
||||
<div className="rounded-lg border border-border-subtle bg-panel-muted/50 p-2.5">
|
||||
<div className="text-xs font-medium text-text-primary">工程地图源</div>
|
||||
<p className="mt-1 text-[10px] text-text-tertiary">
|
||||
点击添加或拖到画布定位;实例位姿独立,源内容由同源实例共享。
|
||||
</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{maps.map((map) => (
|
||||
<div
|
||||
key={map.descriptorPath}
|
||||
draggable={!disabled}
|
||||
data-project-map={map.descriptorPath}
|
||||
className={`flex items-center gap-2 rounded border border-border-subtle px-2 py-1.5 ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({
|
||||
kind: 'project',
|
||||
descriptorPath: map.descriptorPath,
|
||||
}),
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', map.name);
|
||||
}}
|
||||
>
|
||||
<MapPinned className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-text-secondary">
|
||||
{map.name}
|
||||
</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
aria-label={`放置工程地图 ${map.name}`}
|
||||
onClick={() => onAddProjectMap?.(map.descriptorPath)}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-text-primary">
|
||||
@@ -222,8 +406,14 @@ export function MapAssetLibrary({
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(MAP_ASSET_DRAG_MIME, asset.type);
|
||||
event.dataTransfer.setData(MAP_ASSET_PLACEMENT_MIME, placementMode);
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({
|
||||
kind: 'certified',
|
||||
type: asset.type,
|
||||
placementMode,
|
||||
}),
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', asset.name);
|
||||
}}
|
||||
>
|
||||
@@ -257,15 +447,28 @@ export function MapAssetLibrary({
|
||||
系统参数化地形
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] leading-relaxed text-text-tertiary">
|
||||
本地确定性生成;点击“添加”后在右侧“地图”调整尺寸、难度和随机种子。
|
||||
点击添加或拖到画布定位。新增先进入轻量预览,可连续放置后一次编译。
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SYSTEM_TERRAIN_PRESETS.map((preset) => (
|
||||
<article
|
||||
key={preset}
|
||||
draggable={!disabled}
|
||||
data-system-terrain={preset}
|
||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'hover:border-accent'}`}
|
||||
className={`group relative overflow-hidden rounded-lg border border-border-subtle bg-panel-muted shadow-sm transition-colors ${disabled ? 'opacity-50' : 'cursor-grab hover:border-accent active:cursor-grabbing'}`}
|
||||
onDragStart={(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData(
|
||||
MAP_LIBRARY_DRAG_MIME,
|
||||
encodeMapLibraryDragPayload({ kind: 'terrain', preset }),
|
||||
);
|
||||
event.dataTransfer.setData('text/plain', PHYSICAL_MAP_PRESET_LABELS[preset]);
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-2 top-2 z-10 rounded-full bg-panel-bg/90 p-0.5 text-success">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
|
||||
Reference in New Issue
Block a user