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:
YHH
2025-12-13 19:44:08 +08:00
committed by GitHub
parent a716d8006c
commit beaa1d09de
258 changed files with 17725 additions and 3030 deletions

View File

@@ -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;

View File

@@ -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">&#x1F4E6;</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}
>
&#x26D3;
</button>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.asset-field__label {

View File

@@ -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]);

View File

@@ -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)

View File

@@ -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} />}

View File

@@ -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>
);
}

View File

@@ -3,3 +3,4 @@ export { ExtensionInspector } from './ExtensionInspector';
export { AssetFileInspector } from './AssetFileInspector';
export { RemoteEntityInspector } from './RemoteEntityInspector';
export { EntityInspector } from './EntityInspector';
export { PrefabInspector } from './PrefabInspector';