feat(web-platform): release V0.8.1 常规地图优化
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-03 13:18:35 +08:00
parent deead17a9a
commit a9b07e0abf
49 changed files with 4987 additions and 569 deletions
+230 -27
View File
@@ -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" />