feat: 预制体系统与架构改进 (#303)
* feat(prefab): 实现预制体系统和编辑器 UX 改进 ## 预制体系统 - 新增 PrefabSerializer: 预制体序列化/反序列化 - 新增 PrefabInstanceComponent: 追踪预制体实例来源和修改 - 新增 PrefabService: 预制体核心服务 - 新增 PrefabLoader: 预制体资产加载器 - 新增预制体命令: Create/Instantiate/Apply/Revert/BreakLink ## 预制体编辑模式 - 支持双击 .prefab 文件进入编辑模式 - 预制体编辑模式工具栏 (保存/退出) - 预制体实例指示器和操作菜单 ## 编辑器 UX 改进 - SceneHierarchy 快捷键: F2 重命名, Ctrl+D 复制, ↑↓ 导航 - 支持双击实体名称内联编辑 - 删除实体时显示子节点数量警告 - 右键菜单添加重命名/复制选项及快捷键提示 - 布局持久化和重置功能 ## Bug 修复 - 修复 editor-runtime 组件类重复导致的 TransformComponent 不识别问题 - 修复 .prefab-name 样式覆盖导致预制体工具栏文字不可见 - 修复 Inspector 资源字段高度不正确问题 * feat(editor): 改进编辑器 UX 交互体验 - ContentBrowser: 加载动画 spinner、搜索高亮、改进空状态设计 - SceneHierarchy: 选中项自动滚动到视图、搜索清除按钮 - PropertyInspector: 输入框本地状态管理、Enter/Escape 键处理 - EntityInspector: 组件折叠状态持久化、属性搜索清除按钮 - Viewport: 变换操作实时数值显示 - 国际化: 添加相关文本 (en/zh) * fix(build): 修复 Web 构建资产加载和编辑器 UX 改进 构建系统修复: - 修复 asset-catalog.json 字段名不匹配 (entries vs assets) - 修复 BrowserFileSystemService 支持两种目录格式 - 修复 bundle 策略检测逻辑 (空对象判断) - 修复 module.json 中 assetExtensions 声明和类型推断 行为树修复: - 修复 BehaviorTreeExecutionSystem 使用 loadAsset 替代 loadAssetByPath - 修复 BehaviorTreeAssetType 常量与 module.json 类型名一致 (behavior-tree) 编辑器 UX 改进: - 构建完成对话框添加"打开文件夹"按钮 - 构建完成对话框样式优化 (圆形图标背景、按钮布局) - SceneHierarchy 响应式布局 (窄窗口自动隐藏 Type 列) - SceneHierarchy 隐藏滚动条 错误追踪: - 添加全局错误处理器写入日志文件 (%TEMP%/esengine-editor-crash.log) - 添加 append_to_log Tauri 命令 * feat(render): 修复 UI 渲染和点击特效系统 ## UI 渲染修复 - 修复 GUID 验证 bug,使用统一的 isValidGUID() 函数 - 修复 UI 渲染顺序随机问题,Rust 端使用 IndexMap 替代 HashMap - Web 运行时添加 assetPathResolver 支持 GUID 解析 - UIInteractableComponent.blockEvents 默认值改为 false ## 点击特效系统 - 新增 ClickFxComponent 和 ClickFxSystem - 支持在点击位置播放粒子效果 - 支持多种触发模式和粒子轮换 ## Camera 系统重构 - CameraSystem 从 ecs-engine-bindgen 移至 camera 包 - 新增 CameraManager 统一管理相机 ## 编辑器改进 - 改进属性面板 UI 交互 - 粒子编辑器面板优化 - Transform 命令系统 * feat(render): 实现 Sorting Layer 系统和 Overlay 渲染层 - 新增 SortingLayerManager 管理排序层级 (Background, Default, Foreground, UI, Overlay) - 实现 ISortable 接口,统一 Sprite、UI、Particle 的排序属性 - 修复粒子 Overlay 层被 UI 遮挡问题:添加独立的 Overlay Pass 在 UI 之后渲染 - 更新粒子资产格式:从 sortingOrder 改为 sortingLayer + orderInLayer - 更新粒子编辑器面板支持新的排序属性 - 优化 UI 渲染系统使用新的排序层级 * feat(ci): 集成 SignPath 代码签名服务 - 添加 SignPath 自动签名工作流(Windows) - 配置 release-editor.yml 支持代码签名 - 将构建改为草稿模式,等待签名完成后发布 - 添加证书文件到 .gitignore 防止泄露 * fix(asset): 修复 Web 构建资产路径解析和全局单例移除 ## 资产路径修复 - 修复 Tauri 本地服务器 `/asset?path=...` 路径解析,正确与 root 目录连接 - BrowserPathResolver 支持两种模式: - 'proxy': 使用 /asset?path=... 格式(编辑器 Run in Browser) - 'direct': 使用直接路径 /assets/path.png(独立 Web 构建) - BrowserRuntime 使用 'direct' 模式,无需 Tauri 代理 ## 架构改进 - 移除全局单例 - 移除 globalAssetManager 导出,改用 AssetManagerToken 依赖注入 - 移除 globalPathResolver 导出,改用 PathResolutionService - 移除 globalPathResolutionService 导出 - ParticleUpdateSystem/ClickFxSystem 通过 setAssetManager() 注入依赖 - EngineService 使用 new AssetManager() 替代全局实例 ## 新增服务 - PathResolutionService: 统一路径解析接口 - RuntimeModeService: 运行时模式查询服务 - SerializationContext: EntityRef 序列化上下文 ## 其他改进 - 完善 ServiceToken 注释说明本地定义的意图 - 导出 BrowserPathResolveMode 类型 * fix(build): 添加 world-streaming composite 设置修复类型检查 * fix(build): 移除 world-streaming 引用避免 composite 冲突 * fix(build): 将 const enum 改为 enum 兼容 isolatedModules * fix(build): 添加缺失的 IAssetManager 导入
This commit is contained in:
@@ -1,164 +1,41 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Entity } from '@esengine/ecs-framework';
|
||||
import { TauriAPI } from '../../api/tauri';
|
||||
import { SettingsService } from '../../services/SettingsService';
|
||||
import { InspectorProps, InspectorTarget, AssetFileInfo, RemoteEntity } from './types';
|
||||
/**
|
||||
* 检查器面板组件
|
||||
* Inspector panel component
|
||||
*
|
||||
* 使用 InspectorStore 管理状态,减少 useEffect 数量
|
||||
* Uses InspectorStore for state management to reduce useEffect count
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useInspectorStore } from '../../stores';
|
||||
import { InspectorProps } from './types';
|
||||
import { getProfilerService } from './utils';
|
||||
import {
|
||||
EmptyInspector,
|
||||
ExtensionInspector,
|
||||
AssetFileInspector,
|
||||
RemoteEntityInspector,
|
||||
EntityInspector
|
||||
EntityInspector,
|
||||
PrefabInspector
|
||||
} from './views';
|
||||
|
||||
export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegistry, projectPath, commandManager }: InspectorProps) {
|
||||
const [target, setTarget] = useState<InspectorTarget>(null);
|
||||
const [componentVersion, setComponentVersion] = useState(0);
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const [decimalPlaces, setDecimalPlaces] = useState(() => {
|
||||
const settings = SettingsService.getInstance();
|
||||
return settings.get<number>('inspector.decimalPlaces', 4);
|
||||
});
|
||||
const targetRef = useRef<InspectorTarget>(null);
|
||||
// ===== 从 InspectorStore 获取状态 | Get state from InspectorStore =====
|
||||
const {
|
||||
target,
|
||||
componentVersion,
|
||||
autoRefresh,
|
||||
setAutoRefresh,
|
||||
isLocked,
|
||||
setIsLocked,
|
||||
decimalPlaces,
|
||||
} = useInspectorStore();
|
||||
|
||||
useEffect(() => {
|
||||
targetRef.current = target;
|
||||
}, [target]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSettingsChanged = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const changedSettings = customEvent.detail;
|
||||
if ('inspector.decimalPlaces' in changedSettings) {
|
||||
setDecimalPlaces(changedSettings['inspector.decimalPlaces']);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('settings:changed', handleSettingsChanged);
|
||||
return () => {
|
||||
window.removeEventListener('settings:changed', handleSettingsChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEntitySelection = (data: { entity: Entity | null }) => {
|
||||
if (data.entity) {
|
||||
setTarget({ type: 'entity', data: data.entity });
|
||||
} else {
|
||||
setTarget(null);
|
||||
}
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const handleRemoteEntitySelection = (data: { entity: RemoteEntity }) => {
|
||||
setTarget({ type: 'remote-entity', data: data.entity });
|
||||
const profilerService = getProfilerService();
|
||||
if (profilerService && data.entity?.id !== undefined) {
|
||||
profilerService.requestEntityDetails(data.entity.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntityDetails = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const details = customEvent.detail;
|
||||
const currentTarget = targetRef.current;
|
||||
if (currentTarget?.type === 'remote-entity' && details?.id === currentTarget.data.id) {
|
||||
setTarget({ ...currentTarget, details });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionSelection = (data: { data: unknown }) => {
|
||||
setTarget({ type: 'extension', data: data.data as Record<string, any> });
|
||||
};
|
||||
|
||||
const handleAssetFileSelection = async (data: { fileInfo: AssetFileInfo }) => {
|
||||
const fileInfo = data.fileInfo;
|
||||
|
||||
if (fileInfo.isDirectory) {
|
||||
setTarget({ type: 'asset-file', data: fileInfo });
|
||||
return;
|
||||
}
|
||||
|
||||
const textExtensions = [
|
||||
'txt',
|
||||
'json',
|
||||
'md',
|
||||
'ts',
|
||||
'tsx',
|
||||
'js',
|
||||
'jsx',
|
||||
'css',
|
||||
'html',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'toml',
|
||||
'ini',
|
||||
'cfg',
|
||||
'conf',
|
||||
'log',
|
||||
'btree',
|
||||
'ecs',
|
||||
'mat',
|
||||
'shader',
|
||||
'tilemap',
|
||||
'tileset'
|
||||
];
|
||||
const imageExtensions = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'];
|
||||
const isTextFile = fileInfo.extension && textExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
const isImageFile = fileInfo.extension && imageExtensions.includes(fileInfo.extension.toLowerCase());
|
||||
|
||||
if (isTextFile) {
|
||||
try {
|
||||
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||
setTarget({ type: 'asset-file', data: fileInfo, content });
|
||||
} catch (error) {
|
||||
console.error('Failed to read file content:', error);
|
||||
setTarget({ type: 'asset-file', data: fileInfo });
|
||||
}
|
||||
} else if (isImageFile) {
|
||||
setTarget({ type: 'asset-file', data: fileInfo, isImage: true });
|
||||
} else {
|
||||
setTarget({ type: 'asset-file', data: fileInfo });
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentChange = () => {
|
||||
setComponentVersion((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleSceneRestored = () => {
|
||||
// 场景恢复后,清除当前选中的实体(因为旧引用已无效)
|
||||
// 用户需要重新选择实体
|
||||
setTarget(null);
|
||||
setComponentVersion(0);
|
||||
};
|
||||
|
||||
const unsubEntitySelect = messageHub.subscribe('entity:selected', handleEntitySelection);
|
||||
const unsubSceneRestored = messageHub.subscribe('scene:restored', handleSceneRestored);
|
||||
const unsubRemoteSelect = messageHub.subscribe('remote-entity:selected', handleRemoteEntitySelection);
|
||||
const unsubNodeSelect = messageHub.subscribe('behavior-tree:node-selected', handleExtensionSelection);
|
||||
const unsubAssetFileSelect = messageHub.subscribe('asset-file:selected', handleAssetFileSelection);
|
||||
const unsubComponentAdded = messageHub.subscribe('component:added', handleComponentChange);
|
||||
const unsubComponentRemoved = messageHub.subscribe('component:removed', handleComponentChange);
|
||||
const unsubPropertyChanged = messageHub.subscribe('component:property:changed', handleComponentChange);
|
||||
|
||||
window.addEventListener('profiler:entity-details', handleEntityDetails);
|
||||
|
||||
return () => {
|
||||
unsubEntitySelect();
|
||||
unsubSceneRestored();
|
||||
unsubRemoteSelect();
|
||||
unsubNodeSelect();
|
||||
unsubAssetFileSelect();
|
||||
unsubComponentAdded();
|
||||
unsubComponentRemoved();
|
||||
unsubPropertyChanged();
|
||||
window.removeEventListener('profiler:entity-details', handleEntityDetails);
|
||||
};
|
||||
}, [messageHub]);
|
||||
// Ref 用于 profiler 回调访问最新状态 | Ref for profiler callback to access latest state
|
||||
const targetRef = useRef(target);
|
||||
targetRef.current = target;
|
||||
|
||||
// 自动刷新远程实体详情 | Auto-refresh remote entity details
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || target?.type !== 'remote-entity') {
|
||||
return;
|
||||
@@ -183,6 +60,7 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
};
|
||||
}, [autoRefresh, target?.type]);
|
||||
|
||||
// ===== 渲染 | Render =====
|
||||
if (!target) {
|
||||
return <EmptyInspector />;
|
||||
}
|
||||
@@ -192,12 +70,17 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'asset-file') {
|
||||
// Check if a plugin provides a custom inspector for this asset type
|
||||
// 预制体文件使用专用检查器 | Prefab files use dedicated inspector
|
||||
if (target.data.extension?.toLowerCase() === 'prefab') {
|
||||
return <PrefabInspector fileInfo={target.data} messageHub={messageHub} commandManager={commandManager} />;
|
||||
}
|
||||
|
||||
// 检查插件是否提供自定义检查器 | Check if a plugin provides a custom inspector
|
||||
const customInspector = inspectorRegistry.render(target, { target, projectPath });
|
||||
if (customInspector) {
|
||||
return customInspector;
|
||||
}
|
||||
// Fall back to default asset file inspector
|
||||
// 回退到默认资产文件检查器 | Fall back to default asset file inspector
|
||||
return <AssetFileInspector fileInfo={target.data} content={target.content} isImage={target.isImage} />;
|
||||
}
|
||||
|
||||
@@ -217,7 +100,16 @@ export function Inspector({ entityStore: _entityStore, messageHub, inspectorRegi
|
||||
}
|
||||
|
||||
if (target.type === 'entity') {
|
||||
return <EntityInspector entity={target.data} messageHub={messageHub} commandManager={commandManager} componentVersion={componentVersion} />;
|
||||
return (
|
||||
<EntityInspector
|
||||
entity={target.data}
|
||||
messageHub={messageHub}
|
||||
commandManager={commandManager}
|
||||
componentVersion={componentVersion}
|
||||
isLocked={isLocked}
|
||||
onLockChange={setIsLocked}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 预制体实例信息组件
|
||||
* Prefab instance info component
|
||||
*
|
||||
* 显示预制体实例状态和操作按钮(Open, Select, Revert, Apply)。
|
||||
* Displays prefab instance status and action buttons.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import type { MessageHub, PrefabService, CommandManager } from '@esengine/editor-core';
|
||||
import { ApplyPrefabCommand, RevertPrefabCommand, BreakPrefabLinkCommand } from '../../../application/commands/prefab';
|
||||
import { useLocale } from '../../../hooks/useLocale';
|
||||
import '../../../styles/PrefabInstanceInfo.css';
|
||||
|
||||
interface PrefabInstanceInfoProps {
|
||||
entity: Entity;
|
||||
prefabService: PrefabService;
|
||||
messageHub: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预制体实例信息组件
|
||||
* Prefab instance info component
|
||||
*/
|
||||
export function PrefabInstanceInfo({
|
||||
entity,
|
||||
prefabService,
|
||||
messageHub,
|
||||
commandManager
|
||||
}: PrefabInstanceInfoProps) {
|
||||
const { t } = useLocale();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
// 获取预制体实例组件 | Get prefab instance component
|
||||
const prefabComp = entity.getComponent(PrefabInstanceComponent);
|
||||
if (!prefabComp) return null;
|
||||
|
||||
// 只显示根实例的完整信息 | Only show full info for root instances
|
||||
if (!prefabComp.isRoot) return null;
|
||||
|
||||
// 提取预制体名称 | Extract prefab name
|
||||
const prefabPath = prefabComp.sourcePrefabPath;
|
||||
const prefabName = prefabPath
|
||||
? prefabPath.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab'
|
||||
: 'Unknown';
|
||||
|
||||
// 修改数量 | Modification count
|
||||
const modificationCount = prefabComp.modifiedProperties.length;
|
||||
const hasModifications = modificationCount > 0;
|
||||
|
||||
// 打开预制体编辑模式 | Open prefab edit mode
|
||||
const handleOpen = useCallback(() => {
|
||||
messageHub.publish('prefab:editMode:enter', {
|
||||
prefabPath: prefabComp.sourcePrefabPath
|
||||
});
|
||||
}, [messageHub, prefabComp.sourcePrefabPath]);
|
||||
|
||||
// 在内容浏览器中选择 | Select in content browser
|
||||
const handleSelect = useCallback(() => {
|
||||
messageHub.publish('content-browser:select', {
|
||||
path: prefabComp.sourcePrefabPath
|
||||
});
|
||||
}, [messageHub, prefabComp.sourcePrefabPath]);
|
||||
|
||||
// 还原所有修改 | Revert all modifications
|
||||
const handleRevert = useCallback(async () => {
|
||||
if (!hasModifications) return;
|
||||
|
||||
const confirmed = window.confirm(t('inspector.prefab.revertConfirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (commandManager) {
|
||||
const command = new RevertPrefabCommand(prefabService, messageHub, entity);
|
||||
await commandManager.execute(command);
|
||||
} else {
|
||||
await prefabService.revertInstance(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Revert failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [hasModifications, commandManager, prefabService, messageHub, entity, t]);
|
||||
|
||||
// 应用修改到预制体 | Apply modifications to prefab
|
||||
const handleApply = useCallback(async () => {
|
||||
if (!hasModifications) return;
|
||||
|
||||
const confirmed = window.confirm(t('inspector.prefab.applyConfirm', { name: prefabName }));
|
||||
if (!confirmed) return;
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
if (commandManager) {
|
||||
const command = new ApplyPrefabCommand(prefabService, messageHub, entity);
|
||||
await commandManager.execute(command);
|
||||
} else {
|
||||
await prefabService.applyToPrefab(entity);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Apply failed:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}, [hasModifications, commandManager, prefabService, messageHub, entity, prefabName, t]);
|
||||
|
||||
// 解包预制体(断开链接)| Unpack prefab (break link)
|
||||
const handleUnpack = useCallback(() => {
|
||||
const confirmed = window.confirm(t('inspector.prefab.unpackConfirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
if (commandManager) {
|
||||
const command = new BreakPrefabLinkCommand(prefabService, messageHub, entity);
|
||||
commandManager.execute(command);
|
||||
} else {
|
||||
prefabService.breakPrefabLink(entity);
|
||||
}
|
||||
}, [commandManager, prefabService, messageHub, entity, t]);
|
||||
|
||||
return (
|
||||
<div className="prefab-instance-info">
|
||||
<div className="prefab-instance-header">
|
||||
<span className="prefab-icon">📦</span>
|
||||
<span className="prefab-label">{t('inspector.prefab.source')}:</span>
|
||||
<span className="prefab-name" title={prefabPath}>{prefabName}</span>
|
||||
{hasModifications && (
|
||||
<span className="prefab-modified-badge" title={t('inspector.prefab.modifications', { count: modificationCount })}>
|
||||
{modificationCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prefab-instance-actions">
|
||||
<button
|
||||
className="prefab-action-btn"
|
||||
onClick={handleOpen}
|
||||
title={t('inspector.prefab.open')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('inspector.prefab.open')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn"
|
||||
onClick={handleSelect}
|
||||
title={t('inspector.prefab.select')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('inspector.prefab.select')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-revert"
|
||||
onClick={handleRevert}
|
||||
title={t('inspector.prefab.revertAll')}
|
||||
disabled={isProcessing || !hasModifications}
|
||||
>
|
||||
{t('inspector.prefab.revert')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-apply"
|
||||
onClick={handleApply}
|
||||
title={t('inspector.prefab.applyAll')}
|
||||
disabled={isProcessing || !hasModifications}
|
||||
>
|
||||
{t('inspector.prefab.apply')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-unpack"
|
||||
onClick={handleUnpack}
|
||||
title={t('inspector.prefab.unpack')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
⛓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asset-field__label {
|
||||
|
||||
@@ -119,18 +119,18 @@ export function AssetField({
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (readonly) return;
|
||||
if (readonly || !assetRegistry) return;
|
||||
|
||||
// Try to get GUID from drag data first
|
||||
const assetGuid = e.dataTransfer.getData('asset-guid');
|
||||
if (assetGuid && isGUID(assetGuid)) {
|
||||
// Validate extension if needed
|
||||
if (fileExtension && assetRegistry) {
|
||||
if (fileExtension) {
|
||||
const path = assetRegistry.getPathByGuid(assetGuid);
|
||||
if (path && !path.endsWith(fileExtension)) {
|
||||
return; // Extension mismatch
|
||||
@@ -140,50 +140,63 @@ export function AssetField({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: handle asset-path and convert to GUID
|
||||
// Handle asset-path: convert to GUID or register
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
|
||||
// Try to get GUID from path
|
||||
if (assetRegistry) {
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = assetPath;
|
||||
if (assetPath.includes(':') || assetPath.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
|
||||
}
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = assetPath;
|
||||
if (assetPath.includes(':') || assetPath.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
|
||||
}
|
||||
|
||||
// 尝试多种路径格式 | Try multiple path formats
|
||||
const pathVariants = [relativePath, relativePath.replace(/\\/g, '/')];
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
onChange(guid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback to path if GUID not found (backward compatibility)
|
||||
onChange(assetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle file drops
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const file = files.find((f) =>
|
||||
!fileExtension || f.name.endsWith(fileExtension)
|
||||
);
|
||||
|
||||
if (file) {
|
||||
// For file drops, we still use filename (need to register first)
|
||||
onChange(file.name);
|
||||
|
||||
// GUID 不存在,尝试注册 | GUID not found, try to register
|
||||
const absolutePath = assetPath.includes(':') ? assetPath : null;
|
||||
if (absolutePath) {
|
||||
try {
|
||||
const newGuid = await assetRegistry.registerAsset(absolutePath);
|
||||
if (newGuid) {
|
||||
console.log(`[AssetField] Registered dropped asset with GUID: ${newGuid}`);
|
||||
onChange(newGuid);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetField] Failed to register dropped asset:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[AssetField] Cannot use dropped asset without GUID: "${assetPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text/plain drops (might be GUID or path)
|
||||
const text = e.dataTransfer.getData('text/plain');
|
||||
if (text && (!fileExtension || text.endsWith(fileExtension))) {
|
||||
// Try to convert to GUID if it's a path
|
||||
if (assetRegistry && !isGUID(text)) {
|
||||
const guid = assetRegistry.getGuidByPath(text);
|
||||
if (isGUID(text)) {
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get GUID from path
|
||||
const pathVariants = [text, text.replace(/\\/g, '/')];
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
onChange(guid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onChange(text);
|
||||
|
||||
console.error(`[AssetField] Cannot use dropped text without GUID: "${text}"`);
|
||||
}
|
||||
}, [onChange, fileExtension, readonly, assetRegistry]);
|
||||
|
||||
@@ -192,23 +205,60 @@ export function AssetField({
|
||||
setShowPicker(true);
|
||||
}, [readonly]);
|
||||
|
||||
const handlePickerSelect = useCallback((path: string) => {
|
||||
// Convert path to GUID if possible
|
||||
if (assetRegistry) {
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = path;
|
||||
if (path.includes(':') || path.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(path) || path;
|
||||
}
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
const handlePickerSelect = useCallback(async (path: string) => {
|
||||
// Convert path to GUID - 必须使用 GUID,不能使用路径!
|
||||
// Must use GUID, cannot use path!
|
||||
if (!assetRegistry) {
|
||||
console.error(`[AssetField] AssetRegistry not available, cannot select asset`);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = path;
|
||||
if (path.includes(':') || path.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(path) || path;
|
||||
}
|
||||
|
||||
// 尝试多种路径格式 | Try multiple path formats
|
||||
const pathVariants = [
|
||||
relativePath,
|
||||
relativePath.replace(/\\/g, '/'), // 统一为正斜杠
|
||||
];
|
||||
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
console.log(`[AssetField] Found GUID for path "${path}": ${guid}`);
|
||||
onChange(guid);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback to path if GUID not found
|
||||
onChange(path);
|
||||
|
||||
// GUID 不存在,尝试注册资产(创建 .meta 文件)
|
||||
// GUID not found, try to register asset (create .meta file)
|
||||
console.warn(`[AssetField] GUID not found for path "${path}", registering asset...`);
|
||||
|
||||
try {
|
||||
// 使用绝对路径注册 | Register using absolute path
|
||||
const absolutePath = path.includes(':') ? path : null;
|
||||
if (absolutePath) {
|
||||
const newGuid = await assetRegistry.registerAsset(absolutePath);
|
||||
if (newGuid) {
|
||||
console.log(`[AssetField] Registered new asset with GUID: ${newGuid}`);
|
||||
onChange(newGuid);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetField] Failed to register asset:`, error);
|
||||
}
|
||||
|
||||
// 注册失败,不能使用路径(会导致打包后找不到)
|
||||
// Registration failed, cannot use path (will fail after build)
|
||||
console.error(`[AssetField] Cannot use asset without GUID: "${path}". Please ensure the asset is in a managed directory (assets/, scripts/, scenes/).`);
|
||||
setShowPicker(false);
|
||||
}, [onChange, assetRegistry]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Setting
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetRegistryService } from '@esengine/editor-core';
|
||||
import { assetManager as globalAssetManager } from '@esengine/asset-system';
|
||||
import { EngineService } from '../../../services/EngineService';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
@@ -77,7 +77,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
setDetectedType(meta.type);
|
||||
|
||||
// Get available loader types from assetManager
|
||||
const loaderFactory = globalAssetManager.getLoaderFactory();
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
const loaderFactory = assetManager?.getLoaderFactory();
|
||||
const registeredTypes = loaderFactory?.getRegisteredTypes() || [];
|
||||
|
||||
// Combine built-in types with registered types (deduplicated)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search, Lock, Unlock } from 'lucide-react';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName, isComponentInstanceHiddenInInspector, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry, PrefabService } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from '../../PropertyInspector';
|
||||
import { NotificationService } from '../../../services/NotificationService';
|
||||
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||
import { PrefabInstanceInfo } from '../common/PrefabInstanceInfo';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
@@ -35,19 +36,49 @@ interface EntityInspectorProps {
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
componentVersion: number;
|
||||
/** 是否锁定检视器 | Whether inspector is locked */
|
||||
isLocked?: boolean;
|
||||
/** 锁定状态变化回调 | Lock state change callback */
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
|
||||
// 默认展开所有组件
|
||||
return new Set(entity.components.map((_, index) => index));
|
||||
export function EntityInspector({
|
||||
entity,
|
||||
messageHub,
|
||||
commandManager,
|
||||
componentVersion,
|
||||
isLocked = false,
|
||||
onLockChange
|
||||
}: EntityInspectorProps) {
|
||||
// 使用组件类型名追踪折叠状态(持久化到 localStorage)
|
||||
// Use component type names to track collapsed state (persisted to localStorage)
|
||||
const [collapsedComponentTypes, setCollapsedComponentTypes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('inspector-collapsed-components');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 保存折叠状态到 localStorage | Save collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'inspector-collapsed-components',
|
||||
JSON.stringify([...collapsedComponentTypes])
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [collapsedComponentTypes]);
|
||||
|
||||
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [selectedComponentIndex, setSelectedComponentIndex] = useState(-1);
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [propertySearchQuery, setPropertySearchQuery] = useState('');
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -56,29 +87,13 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
|
||||
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
|
||||
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
// 只有当索引不在集合中时才添加(即新组件)
|
||||
if (!prev.has(index) && index >= prev.size) {
|
||||
newSet.add(index);
|
||||
}
|
||||
});
|
||||
// 移除不存在的索引(组件被删除的情况)
|
||||
for (const idx of prev) {
|
||||
if (idx >= entity.components.length) {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length]);
|
||||
// 检查实体是否为预制体实例 | Check if entity is a prefab instance
|
||||
const isPrefabInstance = useMemo(() => {
|
||||
return entity.hasComponent(PrefabInstanceComponent);
|
||||
}, [entity, componentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
@@ -121,6 +136,46 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
return grouped;
|
||||
}, [availableComponents, searchQuery]);
|
||||
|
||||
// 创建扁平化的可见组件列表(用于键盘导航)
|
||||
// Create flat list of visible components for keyboard navigation
|
||||
const flatVisibleComponents = useMemo(() => {
|
||||
const result: ComponentInfo[] = [];
|
||||
for (const [category, components] of filteredAndGroupedComponents.entries()) {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
if (!isCollapsed) {
|
||||
result.push(...components);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [filteredAndGroupedComponents, collapsedCategories, searchQuery]);
|
||||
|
||||
// 重置选中索引当搜索变化时 | Reset selected index when search changes
|
||||
useEffect(() => {
|
||||
setSelectedComponentIndex(searchQuery ? 0 : -1);
|
||||
}, [searchQuery]);
|
||||
|
||||
// 处理组件搜索的键盘导航 | Handle keyboard navigation for component search
|
||||
const handleComponentSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedComponentIndex(prev =>
|
||||
prev < flatVisibleComponents.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedComponentIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||
} else if (e.key === 'Enter' && selectedComponentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selectedComponent = flatVisibleComponents[selectedComponentIndex];
|
||||
if (selectedComponent?.type) {
|
||||
handleAddComponent(selectedComponent.type);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowComponentMenu(false);
|
||||
}
|
||||
}, [flatVisibleComponents, selectedComponentIndex]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setCollapsedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -130,13 +185,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
});
|
||||
};
|
||||
|
||||
const toggleComponentExpanded = (index: number) => {
|
||||
setExpandedComponents((prev) => {
|
||||
const toggleComponentExpanded = (componentTypeName: string) => {
|
||||
setCollapsedComponentTypes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
if (newSet.has(componentTypeName)) {
|
||||
// 已折叠,展开它 | Was collapsed, expand it
|
||||
newSet.delete(componentTypeName);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
// 已展开,折叠它 | Was expanded, collapse it
|
||||
newSet.add(componentTypeName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -244,6 +301,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
const filteredComponents = useMemo(() => {
|
||||
return entity.components.filter((component: Component) => {
|
||||
// 过滤掉标记为隐藏的组件(如 Hierarchy, PrefabInstance)
|
||||
// Filter out components marked as hidden (e.g., Hierarchy, PrefabInstance)
|
||||
if (isComponentInstanceHiddenInInspector(component)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
@@ -271,7 +334,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
<div className="inspector-header-left">
|
||||
<button
|
||||
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => setIsLocked(!isLocked)}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? '解锁检视器' : '锁定检视器'}
|
||||
>
|
||||
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
@@ -282,6 +345,16 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
<span className="inspector-object-count">1 object</span>
|
||||
</div>
|
||||
|
||||
{/* Prefab Instance Info | 预制体实例信息 */}
|
||||
{isPrefabInstance && prefabService && (
|
||||
<PrefabInstanceInfo
|
||||
entity={entity}
|
||||
prefabService={prefabService}
|
||||
messageHub={messageHub}
|
||||
commandManager={commandManager}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="inspector-search">
|
||||
<Search size={14} />
|
||||
@@ -290,7 +363,27 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
placeholder="Search..."
|
||||
value={propertySearchQuery}
|
||||
onChange={(e) => setPropertySearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && propertySearchQuery) {
|
||||
e.preventDefault();
|
||||
setPropertySearchQuery('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{propertySearchQuery && (
|
||||
<button
|
||||
className="inspector-search-clear"
|
||||
onClick={() => setPropertySearchQuery('')}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
{propertySearchQuery && (
|
||||
<span className="inspector-search-count">
|
||||
{filteredComponents.length} / {entity.components.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
@@ -335,6 +428,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
placeholder="搜索组件..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleComponentSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{filteredAndGroupedComponents.size === 0 ? (
|
||||
@@ -343,35 +437,45 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-dropdown-list">
|
||||
{Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
const label = categoryLabels[category] || category;
|
||||
return (
|
||||
<div key={category} className="component-category-group">
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
let globalIndex = 0;
|
||||
return Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
const label = categoryLabels[category] || category;
|
||||
const startIndex = globalIndex;
|
||||
if (!isCollapsed) {
|
||||
globalIndex += components.length;
|
||||
}
|
||||
return (
|
||||
<div key={category} className="component-category-group">
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info, idx) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
const itemIndex = startIndex + idx;
|
||||
const isSelected = itemIndex === selectedComponentIndex;
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className={`component-dropdown-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
onMouseEnter={() => setSelectedComponentIndex(itemIndex)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -386,8 +490,9 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
) : (
|
||||
filteredComponents.map((component: Component) => {
|
||||
const originalIndex = entity.components.indexOf(component);
|
||||
const isExpanded = expandedComponents.has(originalIndex);
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
// 使用组件类型名判断展开状态(未在折叠集合中 = 展开)
|
||||
const isExpanded = !collapsedComponentTypes.has(componentName);
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
|
||||
@@ -399,7 +504,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
>
|
||||
<div
|
||||
className="component-item-header"
|
||||
onClick={() => toggleComponentExpanded(originalIndex)}
|
||||
onClick={() => toggleComponentExpanded(componentName)}
|
||||
>
|
||||
<span className="component-expand-icon">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 预制体检查器
|
||||
* Prefab Inspector
|
||||
*
|
||||
* 显示预制体文件的信息、实体层级预览和实例化功能。
|
||||
* Displays prefab file information, entity hierarchy preview, and instantiation features.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PackageOpen, Box, Layers, Clock, HardDrive, Tag, Play, ChevronRight, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Core, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, SerializedPrefabEntity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, CommandManager } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../../../api/tauri';
|
||||
import { InstantiatePrefabCommand } from '../../../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
|
||||
interface PrefabInspectorProps {
|
||||
fileInfo: AssetFileInfo;
|
||||
messageHub?: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDate(timestamp?: number): string {
|
||||
if (!timestamp) return '未知';
|
||||
// 如果是毫秒级时间戳,不需要转换 | If millisecond timestamp, no conversion needed
|
||||
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体层级节点组件
|
||||
* Entity hierarchy node component
|
||||
*/
|
||||
function EntityNode({ entity, depth = 0 }: { entity: SerializedPrefabEntity; depth?: number }) {
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
const hasChildren = entity.children && entity.children.length > 0;
|
||||
const componentCount = entity.components?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="prefab-entity-node">
|
||||
<div
|
||||
className="prefab-entity-row"
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
>
|
||||
<span className="prefab-entity-expand">
|
||||
{hasChildren ? (
|
||||
expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
</span>
|
||||
<Box size={14} className="prefab-entity-icon" />
|
||||
<span className="prefab-entity-name">{entity.name}</span>
|
||||
<span className="prefab-entity-components">
|
||||
({componentCount} 组件)
|
||||
</span>
|
||||
</div>
|
||||
{expanded && hasChildren && (
|
||||
<div className="prefab-entity-children">
|
||||
{entity.children.map((child, index) => (
|
||||
<EntityNode
|
||||
key={child.id || index}
|
||||
entity={child as SerializedPrefabEntity}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrefabInspector({ fileInfo, messageHub, commandManager }: PrefabInspectorProps) {
|
||||
const [prefabData, setPrefabData] = useState<PrefabData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [instantiating, setInstantiating] = useState(false);
|
||||
|
||||
// 加载预制体数据 | Load prefab data
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadPrefab() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||
const data = PrefabSerializer.deserialize(content);
|
||||
|
||||
// 验证预制体数据 | Validate prefab data
|
||||
const validation = PrefabSerializer.validate(data);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`无效的预制体: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setPrefabData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '加载预制体失败');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadPrefab();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileInfo.path]);
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
const handleInstantiate = useCallback(async () => {
|
||||
if (!prefabData || instantiating) return;
|
||||
|
||||
setInstantiating(true);
|
||||
try {
|
||||
// 从 Core.services 获取服务,使用 tryResolve 避免类型问题
|
||||
// Get services from Core.services, use tryResolve to avoid type issues
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
const hub = messageHub || Core.services.tryResolve(MessageHub) as MessageHub | null;
|
||||
const cmdManager = commandManager;
|
||||
|
||||
if (!entityStore || !hub || !cmdManager) {
|
||||
throw new Error('必要的服务未初始化 | Required services not initialized');
|
||||
}
|
||||
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
hub,
|
||||
prefabData,
|
||||
{ trackInstance: true }
|
||||
);
|
||||
cmdManager.execute(command);
|
||||
|
||||
console.log(`[PrefabInspector] Prefab instantiated: ${prefabData.metadata.name}`);
|
||||
} catch (err) {
|
||||
console.error('[PrefabInspector] Failed to instantiate prefab:', err);
|
||||
} finally {
|
||||
setInstantiating(false);
|
||||
}
|
||||
}, [prefabData, instantiating, messageHub, commandManager]);
|
||||
|
||||
// 统计实体和组件数量 | Count entities and components
|
||||
const countEntities = useCallback((entity: SerializedPrefabEntity): { entities: number; components: number } => {
|
||||
let entities = 1;
|
||||
let components = entity.components?.length || 0;
|
||||
|
||||
if (entity.children) {
|
||||
for (const child of entity.children) {
|
||||
const childCounts = countEntities(child as SerializedPrefabEntity);
|
||||
entities += childCounts.entities;
|
||||
components += childCounts.components;
|
||||
}
|
||||
}
|
||||
|
||||
return { entities, components };
|
||||
}, []);
|
||||
|
||||
const counts = prefabData ? countEntities(prefabData.root) : { entities: 0, components: 0 };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#4ade80' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#f87171' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#f87171' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="entity-inspector prefab-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#4ade80' }} />
|
||||
<span className="entity-name">{prefabData?.metadata.name || fileInfo.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="inspector-content">
|
||||
{/* 预制体信息 | Prefab Information */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">预制体信息</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">版本</label>
|
||||
<span className="property-value-text">v{prefabData?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Layers size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
实体数量
|
||||
</label>
|
||||
<span className="property-value-text">{counts.entities}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Box size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
组件总数
|
||||
</label>
|
||||
<span className="property-value-text">{counts.components}</span>
|
||||
</div>
|
||||
|
||||
{prefabData?.metadata.description && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">描述</label>
|
||||
<span className="property-value-text">{prefabData.metadata.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.tags && prefabData.metadata.tags.length > 0 && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Tag size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
标签
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{prefabData.metadata.tags.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 | File Information */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">文件信息</div>
|
||||
|
||||
{fileInfo.size !== undefined && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
大小
|
||||
</label>
|
||||
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.createdAt && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
创建时间
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{formatDate(prefabData.metadata.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.modifiedAt && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
修改时间
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{formatDate(prefabData.metadata.modifiedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 组件类型 | Component Types */}
|
||||
{prefabData?.metadata.componentTypes && prefabData.metadata.componentTypes.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件类型</div>
|
||||
<div className="prefab-component-types">
|
||||
{prefabData.metadata.componentTypes.map((type) => (
|
||||
<span key={type} className="prefab-component-type-tag">
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 实体层级 | Entity Hierarchy */}
|
||||
{prefabData?.root && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">实体层级</div>
|
||||
<div className="prefab-hierarchy">
|
||||
<EntityNode entity={prefabData.root} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 | Action Buttons */}
|
||||
<div className="inspector-section">
|
||||
<button
|
||||
className="prefab-instantiate-btn"
|
||||
onClick={handleInstantiate}
|
||||
disabled={instantiating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
backgroundColor: '#4ade80',
|
||||
color: '#1a1a1a',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: instantiating ? 'wait' : 'pointer',
|
||||
opacity: instantiating ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
<Play size={14} />
|
||||
{instantiating ? '实例化中...' : '实例化到场景'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export { ExtensionInspector } from './ExtensionInspector';
|
||||
export { AssetFileInspector } from './AssetFileInspector';
|
||||
export { RemoteEntityInspector } from './RemoteEntityInspector';
|
||||
export { EntityInspector } from './EntityInspector';
|
||||
export { PrefabInspector } from './PrefabInspector';
|
||||
|
||||
Reference in New Issue
Block a user