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
+29 -2
View File
@@ -102,15 +102,42 @@ jobs:
tagName: ${{ github.event_name == 'workflow_dispatch' && format('editor-v{0}', github.event.inputs.version) || github.ref_name }}
releaseName: 'ECS Editor v${{ github.event.inputs.version || github.ref_name }}'
releaseBody: 'See the assets to download this version and install.'
releaseDraft: false
releaseDraft: true # 改为草稿,等待 SignPath 签名
prerelease: false
includeUpdaterJson: true
updaterJsonKeepUniversal: false
args: ${{ matrix.platform == 'macos-latest' && format('--target {0}', matrix.target) || '' }}
# SignPath 代码签名(Windows
sign-windows:
needs: build-tauri
runs-on: ubuntu-latest
if: success()
steps:
- name: Submit to SignPath for code signing
uses: signpath/github-action-submit-signing-request@v0.4
with:
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
organization-id: 'esengine-editor'
project-slug: 'ecs-framework'
signing-policy-slug: 'release-signing'
github-artifact-id: '*.exe'
wait-for-completion: true
output-artifact-directory: './signed'
- name: Publish signed release
uses: softprops/action-gh-release@v1
with:
files: ./signed/*
tag_name: ${{ github.event_name == 'workflow_dispatch' && format('editor-v{0}', github.event.inputs.version) || github.ref_name }}
draft: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 构建成功后,创建 PR 更新版本号
update-version-pr:
needs: build-tauri
needs: sign-windows
if: github.event_name == 'workflow_dispatch' && success()
runs-on: ubuntu-latest
+8
View File
@@ -48,6 +48,14 @@ logs/
.env.test.local
.env.production.local
# 代码签名证书(敏感文件)
certs/
*.pfx
*.p12
*.cer
*.pem
*.key
# 测试覆盖率
coverage/
*.lcov
+31 -1
View File
@@ -1,6 +1,7 @@
{
"id": "asset-system",
"name": "@esengine/asset-system",
"globalKey": "assetSystem",
"displayName": "Asset System",
"description": "Asset loading, caching and management | 资源加载、缓存和管理",
"version": "1.0.0",
@@ -28,7 +29,9 @@
"TextureLoader",
"JsonLoader",
"TextLoader",
"BinaryLoader"
"BinaryLoader",
"AudioLoader",
"PrefabLoader"
],
"other": [
"AssetManager",
@@ -36,6 +39,33 @@
"AssetCache"
]
},
"assetExtensions": {
".png": "texture",
".jpg": "texture",
".jpeg": "texture",
".gif": "texture",
".webp": "texture",
".bmp": "texture",
".svg": "texture",
".mp3": "audio",
".ogg": "audio",
".wav": "audio",
".m4a": "audio",
".aac": "audio",
".flac": "audio",
".json": "data",
".xml": "data",
".yaml": "data",
".yml": "data",
".txt": "text",
".ttf": "font",
".woff": "font",
".woff2": "font",
".otf": "font",
".fnt": "font",
".atlas": "atlas",
".prefab": "prefab"
},
"requiresWasm": false,
"outputPath": "dist/index.js"
}
@@ -608,6 +608,42 @@ export class AssetManager implements IAssetManager {
});
}
/**
* Unload assets by type
* 按类型卸载资产
*
* This is useful for clearing texture caches when restoring scene snapshots.
* 在恢复场景快照时清除纹理缓存时很有用。
*
* @param assetType 要卸载的资产类型 / Asset type to unload
* @param bForce 是否强制卸载(忽略引用计数)/ Whether to force unload (ignore reference count)
*/
unloadAssetsByType(assetType: AssetType, bForce: boolean = false): void {
const guids = Array.from(this._assets.keys());
guids.forEach((guid) => {
const entry = this._assets.get(guid);
if (entry && entry.metadata.type === assetType) {
if (bForce || entry.referenceCount === 0) {
// 获取加载器以释放资源 / Get loader to dispose resources
const loader = this._loaderFactory.createLoader(entry.metadata.type);
if (loader) {
loader.dispose(entry.asset);
}
// 清理条目 / Clean up entry
this._handleToGuid.delete(entry.handle);
this._assets.delete(guid);
this._cache.remove(guid);
// 更新统计 / Update statistics
this._statistics.loadedCount--;
entry.state = AssetState.Unloaded;
}
}
});
}
/**
* Add reference to asset
* 增加资产引用
@@ -233,9 +233,3 @@ export class AssetPathResolver {
return path.replace(/\\/g, '/').replace(/\/+/g, '/');
}
}
/**
* Global asset path resolver instance
* 全局资产路径解析器实例
*/
export const globalPathResolver = new AssetPathResolver();
+29 -17
View File
@@ -11,7 +11,17 @@
*/
// Service tokens (谁定义接口,谁导出 Token)
export { AssetManagerToken, type IAssetManager } from './tokens';
export {
AssetManagerToken,
PrefabServiceToken,
PathResolutionServiceToken,
type IAssetManager,
type IPrefabService,
type IPrefabAsset,
type IPrefabData,
type IPrefabMetadata,
type IPathResolutionService
} from './tokens';
// Types
export * from './types/AssetTypes';
@@ -34,7 +44,7 @@ export { AssetCache } from './core/AssetCache';
export { AssetDatabase } from './core/AssetDatabase';
export { AssetLoadQueue } from './core/AssetLoadQueue';
export { AssetReference, WeakAssetReference, AssetReferenceArray } from './core/AssetReference';
export { AssetPathResolver, globalPathResolver } from './core/AssetPathResolver';
export { AssetPathResolver } from './core/AssetPathResolver';
export type { IAssetPathConfig } from './core/AssetPathResolver';
// Loaders
@@ -44,14 +54,16 @@ export { JsonLoader } from './loaders/JsonLoader';
export { TextLoader } from './loaders/TextLoader';
export { BinaryLoader } from './loaders/BinaryLoader';
export { AudioLoader } from './loaders/AudioLoader';
export { PrefabLoader } from './loaders/PrefabLoader';
// Integration
export { EngineIntegration } from './integration/EngineIntegration';
export type { IEngineBridge } from './integration/EngineIntegration';
export type { ITextureEngineBridge } from './integration/EngineIntegration';
// Services
export { SceneResourceManager } from './services/SceneResourceManager';
export type { IResourceLoader } from './services/SceneResourceManager';
export { PathResolutionService } from './services/PathResolutionService';
// Utils
export { UVHelper } from './utils/UVHelper';
@@ -62,26 +74,26 @@ export {
hashString,
hashFileInfo
} from './utils/AssetUtils';
export {
collectAssetReferences,
extractUniqueGuids,
groupByComponentType,
DEFAULT_ASSET_PATTERNS,
type SceneAssetRef,
type AssetFieldPattern
} from './utils/AssetCollector';
// Default instance
// Re-export for initializeAssetSystem
import { AssetManager } from './core/AssetManager';
/**
* Default asset manager instance
* 默认资产管理器实例
*/
export const assetManager = new AssetManager();
import type { IAssetCatalog } from './types/AssetTypes';
/**
* Initialize asset system with catalog
* 使用目录初始化资产系统
*
* @param catalog 资产目录 | Asset catalog
* @returns 新的 AssetManager 实例 | New AssetManager instance
*/
export function initializeAssetSystem(catalog?: IAssetCatalog): AssetManager {
if (catalog) {
return new AssetManager(catalog);
}
return assetManager;
return new AssetManager(catalog);
}
// Re-export IAssetCatalog for initializeAssetSystem signature
import type { IAssetCatalog } from './types/AssetTypes';
@@ -4,15 +4,16 @@
*/
import { AssetManager } from '../core/AssetManager';
import { AssetGUID } from '../types/AssetTypes';
import { AssetGUID, AssetType } from '../types/AssetTypes';
import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader';
import { globalPathResolver } from '../core/AssetPathResolver';
import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService';
import { TextureLoader } from '../loaders/TextureLoader';
/**
* Engine bridge interface
* 引擎桥接接口
* Texture engine bridge interface (for asset system)
* 纹理引擎桥接接口(用于资产系统)
*/
export interface IEngineBridge {
export interface ITextureEngineBridge {
/**
* Load texture to GPU
* 加载纹理到GPU
@@ -36,6 +37,36 @@ export interface IEngineBridge {
* 获取纹理信息
*/
getTextureInfo(id: number): { width: number; height: number } | null;
/**
* Get or load texture by path.
* 按路径获取或加载纹理。
*
* This is the preferred method for getting texture IDs.
* The Rust engine is the single source of truth for texture ID allocation.
* 这是获取纹理 ID 的首选方法。
* Rust 引擎是纹理 ID 分配的唯一事实来源。
*
* @param path Image path/URL | 图片路径/URL
* @returns Texture ID allocated by Rust engine | Rust 引擎分配的纹理 ID
*/
getOrLoadTextureByPath?(path: string): number;
/**
* Clear the texture path cache (optional).
* 清除纹理路径缓存(可选)。
*
* This should be called when restoring scene snapshots to ensure
* textures are reloaded with correct IDs.
* 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。
*/
clearTexturePathCache?(): void;
/**
* Clear all textures and reset state (optional).
* 清除所有纹理并重置状态(可选)。
*/
clearAllTextures?(): void;
}
/**
@@ -64,7 +95,8 @@ interface DataAssetEntry {
*/
export class EngineIntegration {
private _assetManager: AssetManager;
private _engineBridge?: IEngineBridge;
private _engineBridge?: ITextureEngineBridge;
private _pathResolver: IPathResolutionService;
private _textureIdMap = new Map<AssetGUID, number>();
private _pathToTextureId = new Map<string, number>();
@@ -80,16 +112,25 @@ export class EngineIntegration {
private _dataAssets = new Map<number, DataAssetEntry>();
private static _nextDataId = 1;
constructor(assetManager: AssetManager, engineBridge?: IEngineBridge) {
constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) {
this._assetManager = assetManager;
this._engineBridge = engineBridge;
this._pathResolver = pathResolver ?? new PathResolutionService();
}
/**
* Set path resolver
* 设置路径解析器
*/
setPathResolver(resolver: IPathResolutionService): void {
this._pathResolver = resolver;
}
/**
* Set engine bridge
* 设置引擎桥接
*/
setEngineBridge(bridge: IEngineBridge): void {
setEngineBridge(bridge: ITextureEngineBridge): void {
this._engineBridge = bridge;
}
@@ -97,6 +138,9 @@ export class EngineIntegration {
* Load texture for component
* 为组件加载纹理
*
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
* Uses Rust engine as the single source of truth for texture ID allocation.
*
* AssetManager 内部会处理路径解析,这里只需传入原始路径。
* AssetManager handles path resolution internally, just pass the original path here.
*/
@@ -108,17 +152,33 @@ export class EngineIntegration {
return existingId;
}
// 通过资产系统加载(AssetManager 内部会解析路径)
// Load through asset system (AssetManager resolves path internally)
// 解析路径为引擎可用的 URL
// Resolve path to engine-compatible URL
const engineUrl = this._pathResolver.catalogToRuntime(texturePath);
// 优先使用 getOrLoadTextureByPathRust 分配 ID
// Prefer getOrLoadTextureByPath (Rust allocates ID)
// 这确保纹理 ID 由 Rust 引擎统一分配,避免 JS/Rust 层 ID 不同步问题
// This ensures texture IDs are allocated by Rust engine uniformly,
// avoiding JS/Rust layer ID desync issues
if (this._engineBridge?.getOrLoadTextureByPath) {
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
if (rustTextureId > 0) {
// 缓存映射
// Cache mapping
this._pathToTextureId.set(texturePath, rustTextureId);
return rustTextureId;
}
}
// 回退:通过资产系统加载(兼容旧流程)
// Fallback: Load through asset system (for backward compatibility)
const result = await this._assetManager.loadAssetByPath<ITextureAsset>(texturePath);
const textureAsset = result.asset;
// 如果有引擎桥接,上传到GPU
// Upload to GPU if bridge exists
// 使用 globalPathResolver 将路径转换为引擎可用的 URL
// Use globalPathResolver to convert path to engine-compatible URL
if (this._engineBridge && textureAsset.data) {
const engineUrl = globalPathResolver.resolve(texturePath);
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
}
@@ -132,6 +192,9 @@ export class EngineIntegration {
/**
* Load texture by GUID
* 通过GUID加载纹理
*
* 使用 Rust 引擎作为纹理 ID 的唯一分配源。
* Uses Rust engine as the single source of truth for texture ID allocation.
*/
async loadTextureByGuid(guid: AssetGUID): Promise<number> {
// 检查是否已有纹理ID / Check if texture ID exists
@@ -140,14 +203,28 @@ export class EngineIntegration {
return existingId;
}
// 通过资产系统加载 / Load through asset system
// 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path
const result = await this._assetManager.loadAsset<ITextureAsset>(guid);
const textureAsset = result.asset;
const metadata = result.metadata;
const engineUrl = this._pathResolver.catalogToRuntime(metadata.path);
// 如果有引擎桥接,上传到GPU / Upload to GPU if bridge exists
// 优先使用 getOrLoadTextureByPathRust 分配 ID
// Prefer getOrLoadTextureByPath (Rust allocates ID)
if (this._engineBridge?.getOrLoadTextureByPath) {
const rustTextureId = this._engineBridge.getOrLoadTextureByPath(engineUrl);
if (rustTextureId > 0) {
// 缓存映射
// Cache mapping
this._textureIdMap.set(guid, rustTextureId);
return rustTextureId;
}
}
// 回退:使用 TextureLoader 分配的 ID(兼容旧流程)
// Fallback: Use TextureLoader allocated ID (for backward compatibility)
const textureAsset = result.asset;
if (this._engineBridge && textureAsset.data) {
const metadata = result.metadata;
await this._engineBridge.loadTexture(textureAsset.textureId, metadata.path);
await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl);
}
// 缓存映射 / Cache mapping
@@ -486,10 +563,38 @@ export class EngineIntegration {
/**
* Clear all texture mappings
* 清空所有纹理映射
*
* This clears both local texture ID mappings and the AssetManager's
* texture cache to ensure textures are fully reloaded.
* 这会清除本地纹理 ID 映射和 AssetManager 的纹理缓存,确保纹理完全重新加载。
*
* IMPORTANT: This also clears the Rust engine's texture cache to ensure
* both JS and Rust layers are in sync.
* 重要:这也会清除 Rust 引擎的纹理缓存,确保 JS 和 Rust 层同步。
*/
clearTextureMappings(): void {
// 1. 清除本地映射
// Clear local mappings
this._textureIdMap.clear();
this._pathToTextureId.clear();
// 2. 清除 Rust 引擎的纹理缓存(如果可用)
// Clear Rust engine's texture cache (if available)
// 这确保下次加载时 Rust 会重新分配 ID
// This ensures Rust will reallocate IDs on next load
if (this._engineBridge?.clearAllTextures) {
this._engineBridge.clearAllTextures();
}
// 3. 清除 AssetManager 中的纹理资产缓存
// Clear texture asset cache in AssetManager
// 强制清除以确保纹理使用新的 ID 重新加载
// Force clear to ensure textures are reloaded with new IDs
this._assetManager.unloadAssetsByType(AssetType.Texture, true);
// 4. 重置 TextureLoader 的 ID 计数器(保持向后兼容)
// Reset TextureLoader's ID counter (for backward compatibility)
TextureLoader.resetTextureIdCounter();
}
/**
@@ -109,6 +109,22 @@ export interface IAssetLoaderFactory {
* 根据文件路径获取资产类型
*/
getAssetTypeByPath(path: string): AssetType | null;
/**
* Get all supported file extensions from all registered loaders.
* 获取所有注册加载器支持的文件扩展名。
*
* @returns Array of extension patterns (e.g., ['*.png', '*.jpg', '*.particle'])
*/
getAllSupportedExtensions(): string[];
/**
* Get extension to type mapping for all registered loaders.
* 获取所有注册加载器的扩展名到类型的映射。
*
* @returns Map of extension (without dot) to asset type string
*/
getExtensionTypeMap(): Record<string, string>;
}
/**
@@ -187,18 +203,8 @@ export interface IMaterialAsset {
};
}
/**
* Prefab asset interface
* 预制体资产接口
*/
export interface IPrefabAsset {
/** 根实体数据 / Serialized entity hierarchy */
root: unknown;
/** 包含的组件类型 / Component types used in prefab */
componentTypes: string[];
/** 引用的资产 / All referenced assets */
referencedAssets: AssetGUID[];
}
// 预制体资产接口从专用文件导出 | Prefab asset interface exported from dedicated file
export type { IPrefabAsset, IPrefabData, IPrefabMetadata, IPrefabService } from './IPrefabAsset';
/**
* Scene asset interface
@@ -0,0 +1,405 @@
/**
* 预制体资产接口定义
* Prefab asset interface definitions
*
* 定义预制体系统的核心类型,包括预制体数据格式、元数据、实例化选项等。
* Defines core types for the prefab system including data format, metadata, instantiation options, etc.
*/
import type { AssetGUID } from '../types/AssetTypes';
import type { SerializedEntity } from '@esengine/ecs-framework';
/**
* 预制体序列化实体(扩展自 SerializedEntity
* Serialized prefab entity (extends SerializedEntity)
*
* 在标准 SerializedEntity 基础上添加预制体特定属性。
* Adds prefab-specific properties on top of standard SerializedEntity.
*/
export interface SerializedPrefabEntity extends SerializedEntity {
/**
* 是否为预制体根节点
* Whether this is the prefab root entity
*/
isPrefabRoot?: boolean;
/**
* 嵌套预制体的 GUID(如果此实体是另一个预制体的实例)
* GUID of nested prefab (if this entity is an instance of another prefab)
*/
nestedPrefabGuid?: AssetGUID;
}
/**
* 预制体元数据
* Prefab metadata
*/
export interface IPrefabMetadata {
/**
* 预制体名称
* Prefab name
*/
name: string;
/**
* 资产 GUID(在保存为资产后填充)
* Asset GUID (populated after saving as asset)
*/
guid?: AssetGUID;
/**
* 创建时间戳
* Creation timestamp
*/
createdAt: number;
/**
* 最后修改时间戳
* Last modification timestamp
*/
modifiedAt: number;
/**
* 使用的组件类型列表
* List of component types used
*/
componentTypes: string[];
/**
* 引用的资产 GUID 列表
* List of referenced asset GUIDs
*/
referencedAssets: AssetGUID[];
/**
* 预制体描述
* Prefab description
*/
description?: string;
/**
* 预制体标签(用于分类和搜索)
* Prefab tags (for categorization and search)
*/
tags?: string[];
/**
* 缩略图数据(Base64 编码)
* Thumbnail data (Base64 encoded)
*/
thumbnail?: string;
}
/**
* 组件类型注册条目
* Component type registry entry
*/
export interface IPrefabComponentTypeEntry {
/**
* 组件类型名称
* Component type name
*/
typeName: string;
/**
* 组件版本号
* Component version number
*/
version: number;
}
/**
* 预制体文件数据格式
* Prefab file data format
*
* 这是 .prefab 文件的完整结构。
* This is the complete structure of a .prefab file.
*/
export interface IPrefabData {
/**
* 预制体格式版本号
* Prefab format version number
*/
version: number;
/**
* 预制体元数据
* Prefab metadata
*/
metadata: IPrefabMetadata;
/**
* 根实体数据(包含完整的实体层级)
* Root entity data (contains full entity hierarchy)
*/
root: SerializedPrefabEntity;
/**
* 组件类型注册表(用于版本管理和兼容性检查)
* Component type registry (for versioning and compatibility checks)
*/
componentTypeRegistry: IPrefabComponentTypeEntry[];
}
/**
* 预制体资产(加载后的内存表示)
* Prefab asset (in-memory representation after loading)
*/
export interface IPrefabAsset {
/**
* 预制体数据
* Prefab data
*/
data: IPrefabData;
/**
* 资产 GUID
* Asset GUID
*/
guid: AssetGUID;
/**
* 资产路径
* Asset path
*/
path: string;
/**
* 根实体数据(快捷访问)
* Root entity data (quick access)
*/
readonly root: SerializedPrefabEntity;
/**
* 包含的组件类型列表(快捷访问)
* List of component types used (quick access)
*/
readonly componentTypes: string[];
/**
* 引用的资产列表(快捷访问)
* List of referenced assets (quick access)
*/
readonly referencedAssets: AssetGUID[];
}
/**
* 预制体实例化选项
* Prefab instantiation options
*/
export interface IPrefabInstantiateOptions {
/**
* 父实体 ID(可选)
* Parent entity ID (optional)
*/
parentId?: number;
/**
* 位置覆盖
* Position override
*/
position?: { x: number; y: number };
/**
* 旋转覆盖(角度)
* Rotation override (in degrees)
*/
rotation?: number;
/**
* 缩放覆盖
* Scale override
*/
scale?: { x: number; y: number };
/**
* 实体名称覆盖
* Entity name override
*/
name?: string;
/**
* 是否保留原始实体 ID(默认 false,生成新 ID
* Whether to preserve original entity IDs (default false, generate new IDs)
*/
preserveIds?: boolean;
/**
* 是否标记为预制体实例(默认 true)
* Whether to mark as prefab instance (default true)
*/
trackInstance?: boolean;
/**
* 属性覆盖(组件属性覆盖)
* Property overrides (component property overrides)
*/
propertyOverrides?: IPrefabPropertyOverride[];
}
/**
* 预制体属性覆盖
* Prefab property override
*
* 用于记录预制体实例对原始预制体属性的修改。
* Used to record modifications to prefab properties in instances.
*/
export interface IPrefabPropertyOverride {
/**
* 目标实体路径(从根节点的相对路径,如 "Root/Child/GrandChild"
* Target entity path (relative path from root, e.g., "Root/Child/GrandChild")
*/
entityPath: string;
/**
* 组件类型名称
* Component type name
*/
componentType: string;
/**
* 属性路径(支持嵌套,如 "position.x"
* Property path (supports nesting, e.g., "position.x")
*/
propertyPath: string;
/**
* 覆盖值
* Override value
*/
value: unknown;
}
/**
* 预制体创建选项
* Prefab creation options
*/
export interface IPrefabCreateOptions {
/**
* 预制体名称
* Prefab name
*/
name: string;
/**
* 预制体描述
* Prefab description
*/
description?: string;
/**
* 预制体标签
* Prefab tags
*/
tags?: string[];
/**
* 是否包含子实体
* Whether to include child entities
*/
includeChildren?: boolean;
/**
* 保存路径(可选,用于指定保存位置)
* Save path (optional, for specifying save location)
*/
savePath?: string;
}
/**
* 预制体服务接口
* Prefab service interface
*
* 提供预制体的创建、实例化、管理等功能。
* Provides prefab creation, instantiation, management, etc.
*/
export interface IPrefabService {
/**
* 从实体创建预制体数据
* Create prefab data from entity
*
* @param entity - 源实体 | Source entity
* @param options - 创建选项 | Creation options
* @returns 预制体数据 | Prefab data
*/
createPrefab(entity: unknown, options: IPrefabCreateOptions): IPrefabData;
/**
* 实例化预制体
* Instantiate prefab
*
* @param prefab - 预制体资产 | Prefab asset
* @param scene - 目标场景 | Target scene
* @param options - 实例化选项 | Instantiation options
* @returns 创建的根实体 | Created root entity
*/
instantiate(prefab: IPrefabAsset, scene: unknown, options?: IPrefabInstantiateOptions): unknown;
/**
* 通过 GUID 实例化预制体
* Instantiate prefab by GUID
*
* @param guid - 预制体资产 GUID | Prefab asset GUID
* @param scene - 目标场景 | Target scene
* @param options - 实例化选项 | Instantiation options
* @returns 创建的根实体 | Created root entity
*/
instantiateByGuid(guid: AssetGUID, scene: unknown, options?: IPrefabInstantiateOptions): Promise<unknown>;
/**
* 检查实体是否为预制体实例
* Check if entity is a prefab instance
*
* @param entity - 要检查的实体 | Entity to check
* @returns 是否为预制体实例 | Whether it's a prefab instance
*/
isPrefabInstance(entity: unknown): boolean;
/**
* 获取预制体实例的源预制体 GUID
* Get source prefab GUID of a prefab instance
*
* @param entity - 预制体实例 | Prefab instance
* @returns 源预制体 GUID,如果不是实例则返回 null | Source prefab GUID, null if not an instance
*/
getSourcePrefabGuid(entity: unknown): AssetGUID | null;
/**
* 将实例的修改应用到源预制体
* Apply instance modifications to source prefab
*
* @param instance - 预制体实例 | Prefab instance
* @returns 是否成功应用 | Whether application was successful
*/
applyToPrefab?(instance: unknown): Promise<boolean>;
/**
* 将实例还原为源预制体的状态
* Revert instance to source prefab state
*
* @param instance - 预制体实例 | Prefab instance
* @returns 是否成功还原 | Whether revert was successful
*/
revertToPrefab?(instance: unknown): Promise<boolean>;
/**
* 获取实例相对于源预制体的属性覆盖
* Get property overrides of instance relative to source prefab
*
* @param instance - 预制体实例 | Prefab instance
* @returns 属性覆盖列表 | List of property overrides
*/
getPropertyOverrides?(instance: unknown): IPrefabPropertyOverride[];
}
/**
* 预制体文件格式版本
* Prefab file format version
*/
export const PREFAB_FORMAT_VERSION = 1;
/**
* 预制体文件扩展名
* Prefab file extension
*/
export const PREFAB_FILE_EXTENSION = '.prefab';
@@ -10,6 +10,7 @@ import { JsonLoader } from './JsonLoader';
import { TextLoader } from './TextLoader';
import { BinaryLoader } from './BinaryLoader';
import { AudioLoader } from './AudioLoader';
import { PrefabLoader } from './PrefabLoader';
/**
* Asset loader factory
@@ -42,6 +43,9 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
// 音频加载器 / Audio loader
this._loaders.set(AssetType.Audio, new AudioLoader());
// 预制体加载器 / Prefab loader
this._loaders.set(AssetType.Prefab, new PrefabLoader());
}
/**
@@ -142,4 +146,43 @@ export class AssetLoaderFactory implements IAssetLoaderFactory {
clear(): void {
this._loaders.clear();
}
/**
* Get all supported file extensions from all registered loaders.
* 获取所有注册加载器支持的文件扩展名。
*
* @returns Array of extension patterns (e.g., ['*.png', '*.jpg', '*.particle'])
*/
getAllSupportedExtensions(): string[] {
const extensions = new Set<string>();
for (const loader of this._loaders.values()) {
for (const ext of loader.supportedExtensions) {
// 转换为 glob 模式 | Convert to glob pattern
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
extensions.add(`*.${cleanExt}`);
}
}
return Array.from(extensions);
}
/**
* Get extension to type mapping for all registered loaders.
* 获取所有注册加载器的扩展名到类型的映射。
*
* @returns Map of extension (without dot) to asset type string
*/
getExtensionTypeMap(): Record<string, string> {
const map: Record<string, string> = {};
for (const [type, loader] of this._loaders) {
for (const ext of loader.supportedExtensions) {
const cleanExt = ext.startsWith('.') ? ext.substring(1) : ext;
map[cleanExt.toLowerCase()] = type;
}
}
return map;
}
}
@@ -0,0 +1,156 @@
/**
* 预制体资产加载器
* Prefab asset loader
*/
import { AssetType } from '../types/AssetTypes';
import type { IAssetLoader, IAssetParseContext } from '../interfaces/IAssetLoader';
import type { IAssetContent, AssetContentType } from '../interfaces/IAssetReader';
import type {
IPrefabAsset,
IPrefabData,
SerializedPrefabEntity
} from '../interfaces/IPrefabAsset';
import { PREFAB_FORMAT_VERSION } from '../interfaces/IPrefabAsset';
/**
* 预制体加载器实现
* Prefab loader implementation
*/
export class PrefabLoader implements IAssetLoader<IPrefabAsset> {
readonly supportedType = AssetType.Prefab;
readonly supportedExtensions = ['.prefab'];
readonly contentType: AssetContentType = 'text';
/**
* 从文本内容解析预制体
* Parse prefab from text content
*/
async parse(content: IAssetContent, context: IAssetParseContext): Promise<IPrefabAsset> {
if (!content.text) {
throw new Error('Prefab content is empty');
}
let prefabData: IPrefabData;
try {
prefabData = JSON.parse(content.text) as IPrefabData;
} catch (error) {
throw new Error(`Failed to parse prefab JSON: ${(error as Error).message}`);
}
// 验证预制体格式 | Validate prefab format
this.validatePrefabData(prefabData);
// 版本兼容性检查 | Version compatibility check
if (prefabData.version > PREFAB_FORMAT_VERSION) {
console.warn(
`Prefab version ${prefabData.version} is newer than supported version ${PREFAB_FORMAT_VERSION}. ` +
`Some features may not work correctly.`
);
}
// 构建资产对象 | Build asset object
const prefabAsset: IPrefabAsset = {
data: prefabData,
guid: context.metadata.guid,
path: context.metadata.path,
// 快捷访问属性 | Quick access properties
get root(): SerializedPrefabEntity {
return prefabData.root;
},
get componentTypes(): string[] {
return prefabData.metadata.componentTypes;
},
get referencedAssets(): string[] {
return prefabData.metadata.referencedAssets;
}
};
return prefabAsset;
}
/**
* 释放已加载的资产
* Dispose loaded asset
*/
dispose(asset: IPrefabAsset): void {
// 清空预制体数据 | Clear prefab data
(asset as { data: IPrefabData | null }).data = null;
}
/**
* 验证预制体数据格式
* Validate prefab data format
*/
private validatePrefabData(data: unknown): asserts data is IPrefabData {
if (!data || typeof data !== 'object') {
throw new Error('Invalid prefab data: expected object');
}
const prefab = data as Partial<IPrefabData>;
// 验证版本号 | Validate version
if (typeof prefab.version !== 'number') {
throw new Error('Invalid prefab data: missing or invalid version');
}
// 验证元数据 | Validate metadata
if (!prefab.metadata || typeof prefab.metadata !== 'object') {
throw new Error('Invalid prefab data: missing or invalid metadata');
}
const metadata = prefab.metadata;
if (typeof metadata.name !== 'string') {
throw new Error('Invalid prefab data: missing or invalid metadata.name');
}
if (!Array.isArray(metadata.componentTypes)) {
throw new Error('Invalid prefab data: missing or invalid metadata.componentTypes');
}
if (!Array.isArray(metadata.referencedAssets)) {
throw new Error('Invalid prefab data: missing or invalid metadata.referencedAssets');
}
// 验证根实体 | Validate root entity
if (!prefab.root || typeof prefab.root !== 'object') {
throw new Error('Invalid prefab data: missing or invalid root entity');
}
this.validateSerializedEntity(prefab.root);
// 验证组件类型注册表 | Validate component type registry
if (!Array.isArray(prefab.componentTypeRegistry)) {
throw new Error('Invalid prefab data: missing or invalid componentTypeRegistry');
}
}
/**
* 验证序列化实体格式
* Validate serialized entity format
*/
private validateSerializedEntity(entity: unknown): void {
if (!entity || typeof entity !== 'object') {
throw new Error('Invalid entity data: expected object');
}
const e = entity as Partial<SerializedPrefabEntity>;
if (typeof e.id !== 'number') {
throw new Error('Invalid entity data: missing or invalid id');
}
if (typeof e.name !== 'string') {
throw new Error('Invalid entity data: missing or invalid name');
}
if (!Array.isArray(e.components)) {
throw new Error('Invalid entity data: missing or invalid components array');
}
if (!Array.isArray(e.children)) {
throw new Error('Invalid entity data: missing or invalid children array');
}
// 递归验证子实体 | Recursively validate child entities
for (const child of e.children) {
this.validateSerializedEntity(child);
}
}
}
@@ -38,6 +38,18 @@ export class TextureLoader implements IAssetLoader<ITextureAsset> {
private static _nextTextureId = 1;
/**
* Reset texture ID counter
* 重置纹理 ID 计数器
*
* This should be called when restoring scene snapshots to ensure
* textures start with fresh IDs.
* 在恢复场景快照时应调用此方法,以确保纹理从新 ID 开始。
*/
static resetTextureIdCounter(): void {
TextureLoader._nextTextureId = 1;
}
/**
* Parse texture from image content.
* 从图片内容解析纹理。
@@ -0,0 +1,239 @@
/**
* 路径解析服务
* Path Resolution Service
*
* 提供统一的路径解析接口,处理编辑器、Catalog、运行时三层路径转换。
* Provides unified path resolution interface for editor, catalog, and runtime path conversion.
*
* 路径格式约定 | Path Format Convention:
* - 编辑器路径 (Editor Path): 绝对路径,如 `C:\Project\assets\textures\bg.png`
* - Catalog 路径 (Catalog Path): 相对于 assets 目录,不含 `assets/` 前缀,如 `textures/bg.png`
* - 运行时 URL (Runtime URL): 完整 URL,如 `./assets/textures/bg.png` 或 `https://cdn.example.com/assets/textures/bg.png`
*
* @example
* ```typescript
* import { PathResolutionServiceToken, type IPathResolutionService } from '@esengine/asset-system';
*
* // 获取服务
* const pathService = context.services.get(PathResolutionServiceToken);
*
* // Catalog 路径转运行时 URL
* const url = pathService.catalogToRuntime('textures/bg.png');
* // => './assets/textures/bg.png'
*
* // 编辑器路径转 Catalog 路径
* const catalogPath = pathService.editorToCatalog('C:\\Project\\assets\\textures\\bg.png', 'C:\\Project');
* // => 'textures/bg.png'
* ```
*/
import { createServiceToken } from '@esengine/ecs-framework';
// ============================================================================
// 接口定义 | Interface Definitions
// ============================================================================
/**
* 路径解析服务接口
* Path resolution service interface
*/
export interface IPathResolutionService {
/**
* 将 Catalog 路径转换为运行时 URL
* Convert catalog path to runtime URL
*
* @param catalogPath Catalog 路径(相对于 assets 目录,不含 assets/ 前缀)
* @returns 运行时 URL
*
* @example
* ```typescript
* // 输入: 'textures/bg.png'
* // 输出: './assets/textures/bg.png' (取决于 baseUrl 配置)
* pathService.catalogToRuntime('textures/bg.png');
* ```
*/
catalogToRuntime(catalogPath: string): string;
/**
* 将编辑器绝对路径转换为 Catalog 路径
* Convert editor absolute path to catalog path
*
* @param editorPath 编辑器绝对路径
* @param projectRoot 项目根目录
* @returns Catalog 路径(相对于 assets 目录,不含 assets/ 前缀)
*
* @example
* ```typescript
* // 输入: 'C:\\Project\\assets\\textures\\bg.png', 'C:\\Project'
* // 输出: 'textures/bg.png'
* pathService.editorToCatalog('C:\\Project\\assets\\textures\\bg.png', 'C:\\Project');
* ```
*/
editorToCatalog(editorPath: string, projectRoot: string): string;
/**
* 设置运行时基础 URL
* Set runtime base URL
*
* @param url 基础 URL(通常为 './assets' 或 CDN URL
*/
setBaseUrl(url: string): void;
/**
* 获取当前基础 URL
* Get current base URL
*/
getBaseUrl(): string;
/**
* 规范化路径(统一斜杠方向,移除重复斜杠)
* Normalize path (unify slash direction, remove duplicate slashes)
*
* @param path 输入路径
* @returns 规范化后的路径
*/
normalize(path: string): string;
/**
* 检查路径是否为绝对 URL
* Check if path is absolute URL
*
* @param path 输入路径
* @returns 是否为绝对 URL
*/
isAbsoluteUrl(path: string): boolean;
}
// ============================================================================
// 服务令牌 | Service Token
// ============================================================================
/**
* 路径解析服务令牌
* Path resolution service token
*/
export const PathResolutionServiceToken = createServiceToken<IPathResolutionService>('pathResolutionService');
// ============================================================================
// 默认实现 | Default Implementation
// ============================================================================
/**
* 路径解析服务默认实现
* Default path resolution service implementation
*/
export class PathResolutionService implements IPathResolutionService {
private _baseUrl: string = './assets';
private _assetsDir: string = 'assets';
/**
* 创建路径解析服务
* Create path resolution service
*
* @param baseUrl 基础 URL(默认 './assets'
*/
constructor(baseUrl?: string) {
if (baseUrl !== undefined) {
this._baseUrl = baseUrl;
}
}
/**
* 将 Catalog 路径转换为运行时 URL
* Convert catalog path to runtime URL
*/
catalogToRuntime(catalogPath: string): string {
// 空路径直接返回
if (!catalogPath) {
return catalogPath;
}
// 已经是绝对 URL 则直接返回
if (this.isAbsoluteUrl(catalogPath)) {
return catalogPath;
}
// Data URL 直接返回
if (catalogPath.startsWith('data:')) {
return catalogPath;
}
// 规范化路径
let normalized = this.normalize(catalogPath);
// 移除开头的斜杠
normalized = normalized.replace(/^\/+/, '');
// 如果路径以 'assets/' 开头,移除它(避免重复)
// Catalog 路径不应包含 assets/ 前缀
if (normalized.startsWith('assets/')) {
normalized = normalized.substring(7);
}
// 构建完整 URL
const base = this._baseUrl.replace(/\/+$/, ''); // 移除尾部斜杠
return `${base}/${normalized}`;
}
/**
* 将编辑器绝对路径转换为 Catalog 路径
* Convert editor absolute path to catalog path
*/
editorToCatalog(editorPath: string, projectRoot: string): string {
// 规范化路径
let normalizedPath = this.normalize(editorPath);
let normalizedRoot = this.normalize(projectRoot);
// 确保根路径以斜杠结尾
if (!normalizedRoot.endsWith('/')) {
normalizedRoot += '/';
}
// 移除项目根路径前缀
if (normalizedPath.startsWith(normalizedRoot)) {
normalizedPath = normalizedPath.substring(normalizedRoot.length);
}
// 移除 assets/ 前缀(如果存在)
const assetsPrefix = `${this._assetsDir}/`;
if (normalizedPath.startsWith(assetsPrefix)) {
normalizedPath = normalizedPath.substring(assetsPrefix.length);
}
return normalizedPath;
}
/**
* 设置运行时基础 URL
* Set runtime base URL
*/
setBaseUrl(url: string): void {
this._baseUrl = url;
}
/**
* 获取当前基础 URL
* Get current base URL
*/
getBaseUrl(): string {
return this._baseUrl;
}
/**
* 规范化路径
* Normalize path
*/
normalize(path: string): string {
return path
.replace(/\\/g, '/') // 反斜杠转正斜杠
.replace(/\/+/g, '/'); // 移除重复斜杠
}
/**
* 检查路径是否为绝对 URL
* Check if path is absolute URL
*/
isAbsoluteUrl(path: string): boolean {
return /^(https?:\/\/|file:\/\/|asset:\/\/|blob:)/.test(path);
}
}
+23 -1
View File
@@ -15,12 +15,16 @@
* ```
*/
import { createServiceToken } from '@esengine/engine-core';
import { createServiceToken } from '@esengine/ecs-framework';
import type { IAssetManager } from './interfaces/IAssetManager';
import type { IPrefabService } from './interfaces/IPrefabAsset';
import type { IPathResolutionService } from './services/PathResolutionService';
// 重新导出接口方便使用 | Re-export interface for convenience
export type { IAssetManager } from './interfaces/IAssetManager';
export type { IAssetLoadResult } from './types/AssetTypes';
export type { IPrefabService, IPrefabAsset, IPrefabData, IPrefabMetadata } from './interfaces/IPrefabAsset';
export type { IPathResolutionService } from './services/PathResolutionService';
/**
* 资产管理器服务令牌
@@ -30,3 +34,21 @@ export type { IAssetLoadResult } from './types/AssetTypes';
* For registering and getting asset manager service.
*/
export const AssetManagerToken = createServiceToken<IAssetManager>('assetManager');
/**
* 预制体服务令牌
* Prefab service token
*
* 用于注册和获取预制体服务。
* For registering and getting prefab service.
*/
export const PrefabServiceToken = createServiceToken<IPrefabService>('prefabService');
/**
* 路径解析服务令牌
* Path resolution service token
*
* 用于注册和获取路径解析服务。
* For registering and getting path resolution service.
*/
export const PathResolutionServiceToken = createServiceToken<IPathResolutionService>('pathResolutionService');
@@ -0,0 +1,239 @@
/**
* 通用资产收集器
* Generic Asset Collector
*
* 从序列化的场景数据中自动收集资产引用。
* 支持基于字段名模式和 Property 元数据两种识别方式。
*
* Automatically collects asset references from serialized scene data.
* Supports both field name pattern matching and Property metadata recognition.
*/
/**
* 场景资产引用信息(用于构建时收集)
* Scene asset reference info (for build-time collection)
*/
export interface SceneAssetRef {
/** 资产 GUID | Asset GUID */
guid: string;
/** 来源组件类型 | Source component type */
componentType: string;
/** 来源字段名 | Source field name */
fieldName: string;
/** 实体名称(可选)| Entity name (optional) */
entityName?: string;
}
/**
* 资产字段模式配置
* Asset field pattern configuration
*/
export interface AssetFieldPattern {
/** 字段名模式(正则表达式)| Field name pattern (regex) */
pattern: RegExp;
/** 字段类型(用于分类)| Field type (for categorization) */
type?: string;
}
/**
* 默认资产字段模式
* Default asset field patterns
*
* 这些模式用于识别常见的资产引用字段
* These patterns are used to identify common asset reference fields
*/
export const DEFAULT_ASSET_PATTERNS: AssetFieldPattern[] = [
// GUID 类字段 | GUID-like fields
{ pattern: /^.*[Gg]uid$/, type: 'guid' },
{ pattern: /^.*[Aa]sset[Ii]d$/, type: 'guid' },
{ pattern: /^.*[Aa]ssetGuid$/, type: 'guid' },
// 纹理/贴图字段 | Texture fields
{ pattern: /^texture$/, type: 'texture' },
{ pattern: /^.*[Tt]exture[Pp]ath$/, type: 'texture' },
// 音频字段 | Audio fields
{ pattern: /^clip$/, type: 'audio' },
{ pattern: /^.*[Aa]udio[Pp]ath$/, type: 'audio' },
// 通用路径字段 | Generic path fields
{ pattern: /^.*[Pp]ath$/, type: 'path' },
];
/**
* 检查值是否像 GUID
* Check if value looks like a GUID
*/
function isGuidLike(value: unknown): value is string {
if (typeof value !== 'string') return false;
// GUID 格式: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
// 或者简单的包含连字符的长字符串
return /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(value) ||
(value.includes('-') && value.length >= 30 && value.length <= 40);
}
/**
* 从组件数据中收集资产引用
* Collect asset references from component data
*/
function collectFromComponentData(
componentType: string,
data: Record<string, unknown>,
patterns: AssetFieldPattern[],
entityName?: string
): SceneAssetRef[] {
const references: SceneAssetRef[] = [];
for (const [fieldName, value] of Object.entries(data)) {
// 检查是否匹配任何资产字段模式
// Check if matches any asset field pattern
const matchesPattern = patterns.some(p => p.pattern.test(fieldName));
if (matchesPattern) {
// 处理单个值 | Handle single value
if (isGuidLike(value)) {
references.push({
guid: value,
componentType,
fieldName,
entityName
});
}
// 处理数组 | Handle array
else if (Array.isArray(value)) {
for (const item of value) {
if (isGuidLike(item)) {
references.push({
guid: item,
componentType,
fieldName,
entityName
});
}
}
}
}
// 特殊处理已知的数组字段(如 particleAssets
// Special handling for known array fields (like particleAssets)
if (fieldName === 'particleAssets' && Array.isArray(value)) {
for (const item of value) {
if (isGuidLike(item)) {
references.push({
guid: item,
componentType,
fieldName,
entityName
});
}
}
}
}
return references;
}
/**
* 实体类型定义(支持嵌套 children)
* Entity type definition (supports nested children)
*/
interface EntityData {
name?: string;
components?: Array<{ type: string; data?: Record<string, unknown> }>;
children?: EntityData[];
}
/**
* 递归处理实体及其子实体
* Recursively process entity and its children
*/
function collectFromEntity(
entity: EntityData,
patterns: AssetFieldPattern[],
references: SceneAssetRef[]
): void {
const entityName = entity.name;
// 处理当前实体的组件 | Process current entity's components
if (entity.components) {
for (const component of entity.components) {
if (!component.data) continue;
const componentRefs = collectFromComponentData(
component.type,
component.data,
patterns,
entityName
);
references.push(...componentRefs);
}
}
// 递归处理子实体 | Recursively process children
if (entity.children && Array.isArray(entity.children)) {
for (const child of entity.children) {
collectFromEntity(child, patterns, references);
}
}
}
/**
* 从序列化的场景数据中收集所有资产引用
* Collect all asset references from serialized scene data
*
* @param sceneData 序列化的场景数据(JSON 对象)| Serialized scene data (JSON object)
* @param patterns 资产字段模式(可选,默认使用内置模式)| Asset field patterns (optional, defaults to built-in patterns)
* @returns 资产引用列表 | List of asset references
*
* @example
* ```typescript
* const sceneData = JSON.parse(sceneJson);
* const references = collectAssetReferences(sceneData);
* for (const ref of references) {
* console.log(`Found asset ${ref.guid} in ${ref.componentType}.${ref.fieldName}`);
* }
* ```
*/
export function collectAssetReferences(
sceneData: { entities?: EntityData[] },
patterns: AssetFieldPattern[] = DEFAULT_ASSET_PATTERNS
): SceneAssetRef[] {
const references: SceneAssetRef[] = [];
if (!sceneData.entities) {
return references;
}
// 遍历顶层实体,递归处理嵌套的子实体
// Iterate top-level entities, recursively process nested children
for (const entity of sceneData.entities) {
collectFromEntity(entity, patterns, references);
}
return references;
}
/**
* 从资产引用列表中提取唯一的 GUID 集合
* Extract unique GUID set from asset references
*/
export function extractUniqueGuids(references: SceneAssetRef[]): Set<string> {
return new Set(references.map(ref => ref.guid));
}
/**
* 按组件类型分组资产引用
* Group asset references by component type
*/
export function groupByComponentType(references: SceneAssetRef[]): Map<string, SceneAssetRef[]> {
const groups = new Map<string, SceneAssetRef[]>();
for (const ref of references) {
const existing = groups.get(ref.componentType) || [];
existing.push(ref);
groups.set(ref.componentType, existing);
}
return groups;
}
+5 -45
View File
@@ -3,56 +3,16 @@
* 资产工具函数
*
* Provides common utilities for asset management:
* - GUID validation and generation
* - GUID validation and generation (re-exported from core)
* - Content hashing
* 提供资产管理的通用工具:
* - GUID 验证和生成
* - GUID 验证和生成(从 core 重导出)
* - 内容哈希
*/
import type { AssetGUID } from '../types/AssetTypes';
// ============================================================================
// GUID Utilities
// GUID 工具
// ============================================================================
/**
* UUID v4 regex pattern
* UUID v4 正则表达式
*/
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Check if a string is a valid UUID v4 format
* 检查字符串是否为有效的 UUID v4 格式
*/
export function isValidGUID(guid: string): boolean {
return UUID_REGEX.test(guid);
}
/**
* Generate a new UUID v4
* 生成新的 UUID v4
*
* Uses crypto.randomUUID() if available, otherwise falls back to manual generation.
* 如果可用则使用 crypto.randomUUID(),否则回退到手动生成。
*/
export function generateGUID(): AssetGUID {
// Use native crypto if available (Node.js, modern browsers)
// 如果可用则使用原生 crypto(Node.js、现代浏览器)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback: manual UUID v4 generation
// 回退:手动生成 UUID v4
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Re-export GUID utilities from core (single source of truth)
// 从 core 重导出 GUID 工具(单一来源)
export { generateGUID, isValidGUID } from '@esengine/ecs-framework';
// ============================================================================
// Hash Utilities
+1
View File
@@ -1,6 +1,7 @@
{
"id": "audio",
"name": "@esengine/audio",
"globalKey": "audio",
"displayName": "Audio",
"description": "Audio playback and sound effects | 音频播放和音效",
"version": "1.0.0",
+2 -2
View File
@@ -1,5 +1,5 @@
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
import { AudioSourceComponent } from './AudioSourceComponent';
class AudioRuntimeModule implements IRuntimeModule {
@@ -22,7 +22,7 @@ const manifest: ModuleManifest = {
exports: { components: ['AudioSourceComponent'] }
};
export const AudioPlugin: IPlugin = {
export const AudioPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new AudioRuntimeModule()
};
+1 -1
View File
@@ -12,7 +12,7 @@
* This file is reserved for potential future AudioManager service.
*/
// import { createServiceToken } from '@esengine/engine-core';
// import { createServiceToken } from '@esengine/ecs-framework';
// ============================================================================
// Reserved for future service tokens
+1 -1
View File
@@ -6,7 +6,7 @@
* Following the "who defines interface, who exports token" principle.
*/
import { createServiceToken } from '@esengine/engine-core';
import { createServiceToken } from '@esengine/ecs-framework';
import type { IService } from '@esengine/ecs-framework';
import type { BehaviorTree } from './domain/models/BehaviorTree';
+8 -2
View File
@@ -1,11 +1,17 @@
{
"extends": "../../tsconfig.json",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist"],
"references": [
{ "path": "../core" },
{ "path": "../editor-core" },
{ "path": "../behavior-tree" }
]
}
+7
View File
@@ -1,6 +1,7 @@
{
"id": "behavior-tree",
"name": "@esengine/behavior-tree",
"globalKey": "behaviorTree",
"displayName": "Behavior Tree",
"description": "AI behavior tree system | AI 行为树系统",
"version": "1.0.0",
@@ -29,6 +30,9 @@
"systems": [
"BehaviorTreeSystem"
],
"loaders": [
"BehaviorTreeLoader"
],
"other": [
"BehaviorTree",
"BTNode",
@@ -38,6 +42,9 @@
"Action"
]
},
"assetExtensions": {
".btree": "behavior-tree"
},
"editorPackage": "@esengine/behavior-tree-editor",
"requiresWasm": false,
"outputPath": "dist/index.js",
@@ -1,6 +1,6 @@
import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
import { ComponentRegistry } from '@esengine/ecs-framework';
import type { IRuntimeModule, IPlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { AssetManagerToken } from '@esengine/asset-system';
import { BehaviorTreeRuntimeComponent } from './execution/BehaviorTreeRuntimeComponent';
@@ -76,7 +76,7 @@ const manifest: ModuleManifest = {
editorPackage: '@esengine/behavior-tree-editor'
};
export const BehaviorTreePlugin: IPlugin = {
export const BehaviorTreePlugin: IRuntimePlugin = {
manifest,
runtimeModule: new BehaviorTreeRuntimeModule()
};
@@ -6,9 +6,10 @@ import { EditorFormatConverter, type EditorFormat } from './EditorFormatConverte
const logger = createLogger('BehaviorTreeAssetSerializer');
/**
*
*
* Behavior tree serialization format
*/
export type SerializationFormat = 'json' | 'binary';
export type BehaviorTreeSerializationFormat = 'json' | 'binary';
/**
*
@@ -17,7 +18,7 @@ export interface SerializationOptions {
/**
*
*/
format: SerializationFormat;
format: BehaviorTreeSerializationFormat;
/**
* JSON输出format='json'
@@ -221,7 +222,7 @@ export class BehaviorTreeAssetSerializer {
* @param data
* @returns
*/
static detectFormat(data: string | Uint8Array): SerializationFormat {
static detectFormat(data: string | Uint8Array): BehaviorTreeSerializationFormat {
if (typeof data === 'string') {
return 'json';
} else {
@@ -236,7 +237,7 @@ export class BehaviorTreeAssetSerializer {
* @returns
*/
static getInfo(data: string | Uint8Array): {
format: SerializationFormat;
format: BehaviorTreeSerializationFormat;
name: string;
version: string;
nodeCount: number;
@@ -288,7 +289,7 @@ export class BehaviorTreeAssetSerializer {
*/
static convert(
data: string | Uint8Array,
targetFormat: SerializationFormat,
targetFormat: BehaviorTreeSerializationFormat,
pretty: boolean = true
): string | Uint8Array {
const asset = this.deserialize(data, { validate: false });
@@ -14,9 +14,10 @@ export interface NodeDataJSON {
}
/**
*
*
* Behavior tree node property type constants
*/
export const PropertyType = {
export const NodePropertyType = {
/** 字符串 */
String: 'string',
/** 数值 */
@@ -36,26 +37,27 @@ export const PropertyType = {
} as const;
/**
*
*
* Node property type (supports custom extensions)
*
* @example
* ```typescript
* // 使用内置类型
* type: PropertyType.String
* type: NodePropertyType.String
*
* // 使用自定义类型
* type: 'color-picker'
* type: 'curve-editor'
* ```
*/
export type PropertyType = (typeof PropertyType)[keyof typeof PropertyType] | string;
export type NodePropertyType = (typeof NodePropertyType)[keyof typeof NodePropertyType] | string;
/**
*
*/
export interface PropertyDefinition {
name: string;
type: PropertyType;
type: NodePropertyType;
label: string;
description?: string;
defaultValue?: any;
@@ -342,22 +344,22 @@ export class NodeTemplates {
/**
*
*/
private static mapFieldTypeToPropertyType(field: ConfigFieldDefinition): PropertyType {
private static mapFieldTypeToPropertyType(field: ConfigFieldDefinition): NodePropertyType {
if (field.options && field.options.length > 0) {
return PropertyType.Select;
return NodePropertyType.Select;
}
switch (field.type) {
case 'string':
return PropertyType.String;
return NodePropertyType.String;
case 'number':
return PropertyType.Number;
return NodePropertyType.Number;
case 'boolean':
return PropertyType.Boolean;
return NodePropertyType.Boolean;
case 'array':
case 'object':
default:
return PropertyType.String;
return NodePropertyType.String;
}
}
+3 -1
View File
@@ -5,4 +5,6 @@
// Asset type constant for behavior tree
// 行为树资产类型常量
export const BehaviorTreeAssetType = 'behaviortree' as const;
// 必须与 module.json 中 assetExtensions 定义的类型一致
// Must match the type defined in module.json assetExtensions
export const BehaviorTreeAssetType = 'behavior-tree' as const;
@@ -98,28 +98,30 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
*
* Ensure behavior tree asset is loaded
*/
private async ensureAssetLoaded(assetIdOrPath: string): Promise<void> {
private async ensureAssetLoaded(assetGuid: string): Promise<void> {
const btAssetManager = this.getBTAssetManager();
// 如果资产已存在,直接返回
if (btAssetManager.hasAsset(assetIdOrPath)) {
if (btAssetManager.hasAsset(assetGuid)) {
return;
}
// 使用 AssetManager 加载(必须通过 setAssetManager 设置)
// Use AssetManager (must be set via setAssetManager)
if (!this._assetManager) {
this.logger.warn(`AssetManager not set, cannot load: ${assetIdOrPath}`);
this.logger.warn(`AssetManager not set, cannot load: ${assetGuid}`);
return;
}
try {
const result = await this._assetManager.loadAssetByPath(assetIdOrPath);
// 使用 loadAsset 通过 GUID 加载,而不是 loadAssetByPath
// Use loadAsset with GUID instead of loadAssetByPath
const result = await this._assetManager.loadAsset(assetGuid);
if (result && result.asset) {
this.logger.debug(`Behavior tree loaded via AssetManager: ${assetIdOrPath}`);
this.logger.debug(`Behavior tree loaded via AssetManager: ${assetGuid}`);
}
} catch (e) {
this.logger.warn(`Failed to load via AssetManager: ${assetIdOrPath}`, e);
this.logger.warn(`Failed to load via AssetManager: ${assetGuid}`, e);
}
}
@@ -142,11 +144,13 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
*
* AssetManager BehaviorTreeAssetManager
*/
private getTreeData(assetIdOrPath: string): BehaviorTreeData | undefined {
private getTreeData(assetGuid: string): BehaviorTreeData | undefined {
// 1. 优先从 AssetManager 获取(如果已加载)
// First try AssetManager (preferred way)
if (this._assetManager) {
const cachedAsset = this._assetManager.getAssetByPath<IBehaviorTreeAsset>(assetIdOrPath);
// 使用 getAsset 通过 GUID 获取,而不是 getAssetByPath
// Use getAsset with GUID instead of getAssetByPath
const cachedAsset = this._assetManager.getAsset<IBehaviorTreeAsset>(assetGuid);
if (cachedAsset?.data) {
return cachedAsset.data;
}
@@ -154,7 +158,7 @@ export class BehaviorTreeExecutionSystem extends EntitySystem {
// 2. 回退到 BehaviorTreeAssetManager(兼容旧方式)
// Fallback to BehaviorTreeAssetManager (legacy support)
return this.getBTAssetManager().getAsset(assetIdOrPath);
return this.getBTAssetManager().getAsset(assetGuid);
}
/**
+1 -1
View File
@@ -3,7 +3,7 @@
* Behavior tree module service tokens
*/
import { createServiceToken } from '@esengine/engine-core';
import { createServiceToken } from '@esengine/ecs-framework';
import type { BehaviorTreeExecutionSystem } from './execution/BehaviorTreeExecutionSystem';
// ============================================================================
+2 -2
View File
@@ -6,7 +6,7 @@
*
*/
import type { IPlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
import type { IRuntimePlugin, ModuleManifest, IRuntimeModule } from '@esengine/engine-core';
/**
* Blueprint Runtime Module.
@@ -54,7 +54,7 @@ const manifest: ModuleManifest = {
* Blueprint Plugin.
*
*/
export const BlueprintPlugin: IPlugin = {
export const BlueprintPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new BlueprintRuntimeModule()
};
+1
View File
@@ -1,6 +1,7 @@
{
"id": "camera",
"name": "@esengine/camera",
"globalKey": "camera",
"displayName": "Camera",
"description": "Camera and viewport management | 相机和视口管理",
"version": "1.0.0",
+1
View File
@@ -29,6 +29,7 @@
},
"devDependencies": {
"@esengine/ecs-framework": "workspace:*",
"@esengine/ecs-framework-math": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/build-config": "workspace:*",
"rimraf": "^5.0.5",
+320
View File
@@ -0,0 +1,320 @@
/**
* -
* Camera Manager - Provides global camera services
*
*
* -
* -
*
* Main features:
* - Manage main camera
* - Screen to world coordinate conversion
*/
import type { Entity, IScene } from '@esengine/ecs-framework';
import type { IVector2 } from '@esengine/ecs-framework-math';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent, ECameraProjection } from './CameraComponent';
/**
*
* Camera manager interface
*/
export interface ICameraManager {
/**
*
* Set scene reference
*/
setScene(scene: IScene | null): void;
/**
*
* Set viewport size
*/
setViewportSize(width: number, height: number): void;
/**
*
* Get main camera entity
*/
getMainCamera(): Entity | null;
/**
*
* Get main camera component
*/
getMainCameraComponent(): CameraComponent | null;
/**
*
* Convert screen coordinates to world coordinates
*
* @param screenX X | Screen X coordinate
* @param screenY Y | Screen Y coordinate
* @returns | World coordinates
*/
screenToWorld(screenX: number, screenY: number): IVector2;
/**
*
* Convert world coordinates to screen coordinates
*
* @param worldX X | World X coordinate
* @param worldY Y | World Y coordinate
* @returns | Screen coordinates
*/
worldToScreen(worldX: number, worldY: number): IVector2;
}
/**
*
* Camera manager implementation
*
* @example
* ```typescript
* // 获取全局实例
* import { CameraManager } from '@esengine/camera';
*
* // 设置场景和视口
* CameraManager.setScene(scene);
* CameraManager.setViewportSize(800, 600);
*
* // 屏幕坐标转世界坐标
* const worldPos = CameraManager.screenToWorld(mouseX, mouseY);
* console.log(`World position: ${worldPos.x}, ${worldPos.y}`);
* ```
*/
export class CameraManagerImpl implements ICameraManager {
private _scene: IScene | null = null;
private _viewportWidth: number = 800;
private _viewportHeight: number = 600;
private _mainCameraEntity: Entity | null = null;
private _mainCameraEntityDirty: boolean = true;
/**
*
* Set scene reference
*/
setScene(scene: IScene | null): void {
this._scene = scene;
this._mainCameraEntityDirty = true;
this._mainCameraEntity = null;
}
/**
*
* Set viewport size
*/
setViewportSize(width: number, height: number): void {
this._viewportWidth = Math.max(1, width);
this._viewportHeight = Math.max(1, height);
}
/**
*
* Get viewport width
*/
get viewportWidth(): number {
return this._viewportWidth;
}
/**
*
* Get viewport height
*/
get viewportHeight(): number {
return this._viewportHeight;
}
/**
*
* Get viewport aspect ratio
*/
get aspectRatio(): number {
return this._viewportWidth / this._viewportHeight;
}
/**
*
* Mark main camera as dirty (needs re-lookup)
*/
invalidateMainCamera(): void {
this._mainCameraEntityDirty = true;
}
/**
*
* Get main camera entity
*/
getMainCamera(): Entity | null {
if (this._mainCameraEntityDirty || !this._mainCameraEntity) {
this._mainCameraEntity = this._findMainCamera();
this._mainCameraEntityDirty = false;
}
return this._mainCameraEntity;
}
/**
*
* Get main camera component
*/
getMainCameraComponent(): CameraComponent | null {
const entity = this.getMainCamera();
return entity?.getComponent(CameraComponent) ?? null;
}
/**
* depth
* Find main camera (camera with lowest depth)
*/
private _findMainCamera(): Entity | null {
if (!this._scene) return null;
let mainCamera: Entity | null = null;
let lowestDepth = Infinity;
// 使用 entities.buffer 遍历实体列表
// Use entities.buffer to iterate entity list
const entities = this._scene.entities.buffer;
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity.enabled) continue;
const camera = entity.getComponent(CameraComponent);
if (camera && camera.depth < lowestDepth) {
lowestDepth = camera.depth;
mainCamera = entity;
}
}
return mainCamera;
}
/**
*
* Convert screen coordinates to world coordinates
*
*
* - (0, 0)
* - orthographicSize
*
* For orthographic camera:
* - Screen coordinates (0, 0) at top-left
* - orthographicSize is half-height of visible area
*/
screenToWorld(screenX: number, screenY: number): IVector2 {
const camera = this.getMainCameraComponent();
const cameraEntity = this.getMainCamera();
if (!camera || !cameraEntity) {
// 没有相机时,返回简单的偏移 | No camera, return simple offset
return {
x: screenX - this._viewportWidth / 2,
y: screenY - this._viewportHeight / 2
};
}
// 获取相机位置 | Get camera position
const transform = cameraEntity.getComponent(TransformComponent);
const cameraX = transform?.worldPosition.x ?? 0;
const cameraY = transform?.worldPosition.y ?? 0;
if (camera.projection === ECameraProjection.Orthographic) {
return this._screenToWorldOrthographic(screenX, screenY, camera, cameraX, cameraY);
} else {
// 透视相机暂不支持,返回正交结果
// Perspective camera not supported yet, return orthographic result
return this._screenToWorldOrthographic(screenX, screenY, camera, cameraX, cameraY);
}
}
/**
*
* Screen to world conversion for orthographic camera
*/
private _screenToWorldOrthographic(
screenX: number,
screenY: number,
camera: CameraComponent,
cameraX: number,
cameraY: number
): IVector2 {
const orthoSize = camera.orthographicSize;
const aspect = this.aspectRatio;
// 归一化设备坐标 (NDC) [-1, 1]
// Normalized Device Coordinates (NDC) [-1, 1]
const ndcX = (screenX / this._viewportWidth) * 2 - 1;
const ndcY = 1 - (screenY / this._viewportHeight) * 2; // Y 轴翻转 | Flip Y axis
// 世界坐标 | World coordinates
const worldX = cameraX + ndcX * orthoSize * aspect;
const worldY = cameraY + ndcY * orthoSize;
return { x: worldX, y: worldY };
}
/**
*
* Convert world coordinates to screen coordinates
*/
worldToScreen(worldX: number, worldY: number): IVector2 {
const camera = this.getMainCameraComponent();
const cameraEntity = this.getMainCamera();
if (!camera || !cameraEntity) {
// 没有相机时,返回简单的偏移 | No camera, return simple offset
return {
x: worldX + this._viewportWidth / 2,
y: worldY + this._viewportHeight / 2
};
}
// 获取相机位置 | Get camera position
const transform = cameraEntity.getComponent(TransformComponent);
const cameraX = transform?.worldPosition.x ?? 0;
const cameraY = transform?.worldPosition.y ?? 0;
if (camera.projection === ECameraProjection.Orthographic) {
return this._worldToScreenOrthographic(worldX, worldY, camera, cameraX, cameraY);
} else {
// 透视相机暂不支持 | Perspective camera not supported yet
return this._worldToScreenOrthographic(worldX, worldY, camera, cameraX, cameraY);
}
}
/**
*
* World to screen conversion for orthographic camera
*/
private _worldToScreenOrthographic(
worldX: number,
worldY: number,
camera: CameraComponent,
cameraX: number,
cameraY: number
): IVector2 {
const orthoSize = camera.orthographicSize;
const aspect = this.aspectRatio;
// 相对于相机的偏移 | Offset relative to camera
const offsetX = worldX - cameraX;
const offsetY = worldY - cameraY;
// NDC 坐标 | NDC coordinates
const ndcX = offsetX / (orthoSize * aspect);
const ndcY = offsetY / orthoSize;
// 屏幕坐标 | Screen coordinates
const screenX = (ndcX + 1) * 0.5 * this._viewportWidth;
const screenY = (1 - ndcY) * 0.5 * this._viewportHeight; // Y 轴翻转 | Flip Y axis
return { x: screenX, y: screenY };
}
}
/**
*
* Global camera manager instance
*/
export const CameraManager = new CameraManagerImpl();
+18 -3
View File
@@ -1,11 +1,26 @@
import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework';
import type { IRuntimeModule, IPlugin, ModuleManifest } from '@esengine/engine-core';
import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
import { EngineBridgeToken } from '@esengine/engine-core';
import { CameraComponent } from './CameraComponent';
import { CameraSystem } from './CameraSystem';
class CameraRuntimeModule implements IRuntimeModule {
registerComponents(registry: typeof ComponentRegistryType): void {
registry.register(CameraComponent);
}
createSystems(scene: IScene, context: SystemContext): void {
// 从服务注册表获取 EngineBridge | Get EngineBridge from service registry
const bridge = context.services.get(EngineBridgeToken);
if (!bridge) {
console.warn('[CameraPlugin] EngineBridge not found, CameraSystem will not be created');
return;
}
// 创建并添加 CameraSystem | Create and add CameraSystem
const cameraSystem = new CameraSystem(bridge);
scene.addSystem(cameraSystem);
}
}
const manifest: ModuleManifest = {
@@ -22,7 +37,7 @@ const manifest: ModuleManifest = {
exports: { components: ['CameraComponent'] }
};
export const CameraPlugin: IPlugin = {
export const CameraPlugin: IRuntimePlugin = {
manifest,
runtimeModule: new CameraRuntimeModule()
};
@@ -4,15 +4,15 @@
*/
import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
import { CameraComponent } from '@esengine/camera';
import type { EngineBridge } from '../core/EngineBridge';
import type { IEngineBridge } from '@esengine/engine-core';
import { CameraComponent } from './CameraComponent';
@ECSSystem('Camera', { updateOrder: -100 })
export class CameraSystem extends EntitySystem {
private bridge: EngineBridge;
private bridge: IEngineBridge;
private lastAppliedCameraId: number | null = null;
constructor(bridge: EngineBridge) {
constructor(bridge: IEngineBridge) {
// Match entities with CameraComponent
super(Matcher.empty().all(CameraComponent));
this.bridge = bridge;
+5 -3
View File
@@ -1,6 +1,8 @@
export { CameraComponent, ECameraProjection, CameraProjection } from './CameraComponent';
export { CameraSystem } from './CameraSystem';
export { CameraPlugin } from './CameraPlugin';
export { CameraManager, CameraManagerImpl, type ICameraManager } from './CameraManager';
// Service Tokens (reserved for future use)
// 服务令牌(预留用于未来扩展)
// export { CameraManagerToken, type ICameraManager } from './tokens';
// Service Tokens
// 服务令牌
export { CameraManagerToken } from './tokens';
+10 -21
View File
@@ -4,28 +4,17 @@
*
* "谁定义接口,谁导出 Token"
* Following "who defines interface, who exports Token" principle.
*
*
* CameraManager
*
* Currently this module only provides components, no services defined yet.
* This file is reserved for potential future CameraManager service.
*/
// import { createServiceToken } from '@esengine/engine-core';
import { createServiceToken } from '@esengine/ecs-framework';
import type { ICameraManager } from './CameraManager';
// ============================================================================
// Reserved for future service tokens
// 预留用于未来的服务令牌
// ============================================================================
// Re-export interface for consumers
// 重新导出接口供消费者使用
export type { ICameraManager };
// export interface ICameraManager {
// // 获取主相机 | Get main camera
// getMainCamera(): CameraComponent | null;
// // 设置主相机 | Set main camera
// setMainCamera(camera: CameraComponent): void;
// // 屏幕坐标转世界坐标 | Screen to world coordinates
// screenToWorld(screenX: number, screenY: number): { x: number; y: number };
// }
// export const CameraManagerToken = createServiceToken<ICameraManager>('cameraManager');
/**
*
* Camera manager service token
*/
export const CameraManagerToken = createServiceToken<ICameraManager>('cameraManager');
+1
View File
@@ -1,6 +1,7 @@
{
"id": "core",
"name": "@esengine/ecs-framework",
"globalKey": "ecsFramework",
"displayName": "Core ECS",
"outputPath": "dist/index.mjs",
"description": "Core Entity-Component-System framework | 核心 ECS 框架",
@@ -42,14 +42,23 @@ export interface ServiceToken<T> {
*
* Create a service token
*
* 使 Symbol.for() Symbol
* Uses Symbol.for() to ensure tokens with the same name reference the same Symbol across modules.
*
* 使 Symbol
* This fixes the issue where service registration and retrieval use different Symbols across packages.
*
* @param name | Token name
* @returns | Service token
*/
export function createServiceToken<T>(name: string): ServiceToken<T> {
// __phantom 仅用于类型推断,运行时不需要实际值
// __phantom is only for type inference, no actual value needed at runtime
// 使用 Symbol.for() 从全局 Symbol 注册表获取或创建 Symbol
// 这确保相同名称在任何地方都返回同一个 Symbol
// Use Symbol.for() to get or create Symbol from global Symbol registry
// This ensures the same name returns the same Symbol everywhere
const tokenKey = `@esengine/service:${name}`;
return {
id: Symbol(name),
id: Symbol.for(tokenKey),
name
} as ServiceToken<T>;
}
@@ -0,0 +1,284 @@
/**
*
* Runtime Mode Service
*
* 使
* Provides unified runtime mode query interface for third-party modules to be aware of current runtime environment.
*
* | Mode Definitions:
* - Editor Gizmos
* - Playing Play
* - Preview
*
* @example
* ```typescript
* import { RuntimeModeToken, type IRuntimeMode } from '@esengine/ecs-framework';
*
* // 获取服务
* const runtimeMode = context.services.get(RuntimeModeToken);
*
* // 检查当前模式
* if (runtimeMode?.isEditor) {
* // 编辑器特定逻辑
* }
*
* // 监听模式变化
* const unsubscribe = runtimeMode?.onModeChanged((mode) => {
* console.log('Mode changed:', mode.isPlaying ? 'Playing' : 'Stopped');
* });
* ```
*/
import { createServiceToken } from './PluginServiceRegistry';
// ============================================================================
// 接口定义 | Interface Definitions
// ============================================================================
/**
*
* Runtime mode interface
*/
export interface IRuntimeMode {
/**
*
* Whether in editor mode
*
* Gizmos
* In editor mode, grid, gizmos, axis indicator and other helper elements are shown.
*/
readonly isEditor: boolean;
/**
*
* Whether playing (game is running)
*
* Play true Stop false
* True after user clicks Play button, false after clicking Stop.
*/
readonly isPlaying: boolean;
/**
*
* Whether in preview mode
*
*
* Preview mode is scene preview in editor, not full game runtime.
*/
readonly isPreview: boolean;
/**
*
* Whether in standalone runtime (non-editor environment)
*
* Web true
* True in standalone runtime environments like web build, mobile, etc.
*/
readonly isStandalone: boolean;
/**
*
* Subscribe to mode change events
*
* @param callback
* @returns
*/
onModeChanged(callback: (mode: IRuntimeMode) => void): () => void;
}
// ============================================================================
// 服务令牌 | Service Token
// ============================================================================
/**
*
* Runtime mode service token
*/
export const RuntimeModeToken = createServiceToken<IRuntimeMode>('runtimeMode');
// ============================================================================
// 默认实现 | Default Implementation
// ============================================================================
/**
*
* Mode change callback type
*/
type ModeChangeCallback = (mode: IRuntimeMode) => void;
/**
*
* Runtime mode service configuration
*/
export interface RuntimeModeConfig {
/** 是否为编辑器模式 | Whether in editor mode */
isEditor?: boolean;
/** 是否正在播放 | Whether playing */
isPlaying?: boolean;
/** 是否为预览模式 | Whether in preview mode */
isPreview?: boolean;
}
/**
*
* Default runtime mode service implementation
*/
export class RuntimeModeService implements IRuntimeMode {
private _isEditor: boolean;
private _isPlaying: boolean;
private _isPreview: boolean;
private _callbacks: Set<ModeChangeCallback> = new Set();
/**
*
* Create runtime mode service
*
* @param config
*/
constructor(config: RuntimeModeConfig = {}) {
this._isEditor = config.isEditor ?? false;
this._isPlaying = config.isPlaying ?? false;
this._isPreview = config.isPreview ?? false;
}
// ========== IRuntimeMode 实现 ==========
get isEditor(): boolean {
return this._isEditor;
}
get isPlaying(): boolean {
return this._isPlaying;
}
get isPreview(): boolean {
return this._isPreview;
}
get isStandalone(): boolean {
return !this._isEditor;
}
onModeChanged(callback: ModeChangeCallback): () => void {
this._callbacks.add(callback);
return () => {
this._callbacks.delete(callback);
};
}
// ========== 设置方法(供运行时内部使用)==========
/**
*
* Set editor mode
*
* @internal
*/
setEditorMode(isEditor: boolean): void {
if (this._isEditor !== isEditor) {
this._isEditor = isEditor;
this._notifyChange();
}
}
/**
*
* Set playing state
*
* @internal
*/
setPlaying(isPlaying: boolean): void {
if (this._isPlaying !== isPlaying) {
this._isPlaying = isPlaying;
this._notifyChange();
}
}
/**
*
* Set preview mode
*
* @internal
*/
setPreview(isPreview: boolean): void {
if (this._isPreview !== isPreview) {
this._isPreview = isPreview;
this._notifyChange();
}
}
/**
*
* Batch update mode
*
* @internal
*/
updateMode(config: RuntimeModeConfig): void {
let changed = false;
if (config.isEditor !== undefined && this._isEditor !== config.isEditor) {
this._isEditor = config.isEditor;
changed = true;
}
if (config.isPlaying !== undefined && this._isPlaying !== config.isPlaying) {
this._isPlaying = config.isPlaying;
changed = true;
}
if (config.isPreview !== undefined && this._isPreview !== config.isPreview) {
this._isPreview = config.isPreview;
changed = true;
}
if (changed) {
this._notifyChange();
}
}
/**
*
* Notify mode change
*/
private _notifyChange(): void {
for (const callback of this._callbacks) {
try {
callback(this);
} catch (error) {
console.error('[RuntimeModeService] Callback error:', error);
}
}
}
/**
*
* Dispose resources
*/
dispose(): void {
this._callbacks.clear();
}
}
/**
*
* Create editor mode service
*/
export function createEditorModeService(): RuntimeModeService {
return new RuntimeModeService({
isEditor: true,
isPlaying: false,
isPreview: false
});
}
/**
*
* Create standalone runtime mode service
*/
export function createStandaloneModeService(): RuntimeModeService {
return new RuntimeModeService({
isEditor: false,
isPlaying: true,
isPreview: false
});
}
@@ -19,7 +19,7 @@ import { Serializable, Serialize } from '../Serialization/SerializationDecorator
* const children = hierarchySystem.getChildren(entity);
* ```
*/
@ECSComponent('Hierarchy')
@ECSComponent('Hierarchy', { editor: { hideInInspector: true } })
@Serializable({ version: 1, typeId: 'Hierarchy' })
export class HierarchyComponent extends Component {
/**
@@ -0,0 +1,211 @@
/**
* -
* Prefab instance component - for tracking prefab instances
*
*
* When an entity is instantiated from a prefab, this component is automatically added to track its source.
*/
import { Component } from '../Component';
import { ECSComponent } from '../Decorators';
import { Serializable, Serialize } from '../Serialization/SerializationDecorators';
/**
*
* Prefab instance component
*
*
* Marks an entity as a prefab instance and stores association with source prefab.
*
* @example
* ```typescript
* // 检查实体是否为预制体实例 | Check if entity is a prefab instance
* const prefabComp = entity.getComponent(PrefabInstanceComponent);
* if (prefabComp) {
* console.log(`Instance of prefab: ${prefabComp.sourcePrefabGuid}`);
* }
* ```
*/
@ECSComponent('PrefabInstance', { editor: { hideInInspector: true } })
@Serializable({ version: 1, typeId: 'PrefabInstance' })
export class PrefabInstanceComponent extends Component {
/**
* GUID
* Source prefab asset GUID
*/
@Serialize()
public sourcePrefabGuid: string = '';
/**
*
* Source prefab asset path (for display and debugging)
*/
@Serialize()
public sourcePrefabPath: string = '';
/**
*
* Whether this is the root entity of the prefab hierarchy
*/
@Serialize()
public isRoot: boolean = false;
/**
* ID
* Entity ID of the root prefab instance (for child entities to trace back to root)
*/
@Serialize()
public rootInstanceEntityId: number | null = null;
/**
*
* Property override records
*
* componentType.propertyPath
* Records which properties have been modified by user, format: componentType.propertyPath
*/
@Serialize()
public modifiedProperties: string[] = [];
/**
*
* Instantiation timestamp
*/
@Serialize()
public instantiatedAt: number = 0;
/**
*
* Original property values storage
*
*
* Stores original values of modified properties for revert operations.
* { "ComponentType.propertyPath": originalValue }
* Format: { "ComponentType.propertyPath": originalValue }
*/
@Serialize()
public originalValues: Record<string, unknown> = {};
constructor(
sourcePrefabGuid: string = '',
sourcePrefabPath: string = '',
isRoot: boolean = false
) {
super();
this.sourcePrefabGuid = sourcePrefabGuid;
this.sourcePrefabPath = sourcePrefabPath;
this.isRoot = isRoot;
this.instantiatedAt = Date.now();
}
/**
*
* Mark a property as modified
*
* @param componentType - | Component type name
* @param propertyPath - | Property path
*/
public markPropertyModified(componentType: string, propertyPath: string): void {
const key = `${componentType}.${propertyPath}`;
if (!this.modifiedProperties.includes(key)) {
this.modifiedProperties.push(key);
}
}
/**
*
* Check if a property has been modified
*
* @param componentType - | Component type name
* @param propertyPath - | Property path
* @returns | Whether it has been modified
*/
public isPropertyModified(componentType: string, propertyPath: string): boolean {
const key = `${componentType}.${propertyPath}`;
return this.modifiedProperties.includes(key);
}
/**
*
* Clear property modification mark
*
* @param componentType - | Component type name
* @param propertyPath - | Property path
*/
public clearPropertyModified(componentType: string, propertyPath: string): void {
const key = `${componentType}.${propertyPath}`;
const index = this.modifiedProperties.indexOf(key);
if (index !== -1) {
this.modifiedProperties.splice(index, 1);
}
}
/**
*
* Clear all property modification marks
*/
public clearAllModifications(): void {
this.modifiedProperties = [];
this.originalValues = {};
}
/**
*
* Store original value of a property
*
*
* Only stores on first modification, subsequent modifications don't overwrite.
*
* @param componentType - | Component type name
* @param propertyPath - | Property path
* @param value - | Original value
*/
public storeOriginalValue(componentType: string, propertyPath: string, value: unknown): void {
const key = `${componentType}.${propertyPath}`;
// 只在第一次修改时存储原始值 | Only store on first modification
if (!(key in this.originalValues)) {
// 深拷贝值以防止引用问题 | Deep clone to prevent reference issues
this.originalValues[key] = this.deepClone(value);
}
}
/**
*
* Get original value of a property
*
* @param key - componentType.propertyPath| Property key (format: componentType.propertyPath)
* @returns undefined | Original value or undefined if not found
*/
public getOriginalValue(key: string): unknown {
return this.originalValues[key];
}
/**
*
* Check if original value exists for a property
*
* @param componentType - | Component type name
* @param propertyPath - | Property path
* @returns | Whether original value exists
*/
public hasOriginalValue(componentType: string, propertyPath: string): boolean {
const key = `${componentType}.${propertyPath}`;
return key in this.originalValues;
}
/**
*
* Deep clone value
*/
private deepClone(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (typeof value === 'object') {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
return value;
}
}
@@ -1 +1,2 @@
export { HierarchyComponent } from './HierarchyComponent';
export { PrefabInstanceComponent } from './PrefabInstanceComponent';
@@ -29,6 +29,38 @@ export const COMPONENT_TYPE_NAME = Symbol('ComponentTypeName');
*/
export const COMPONENT_DEPENDENCIES = Symbol('ComponentDependencies');
/**
* Symbol
* Symbol key for storing component editor options
*/
export const COMPONENT_EDITOR_OPTIONS = Symbol('ComponentEditorOptions');
/**
*
* Component editor options
*/
export interface ComponentEditorOptions {
/**
* Inspector
* Whether to hide this component in Inspector
*
* @default false
*/
hideInInspector?: boolean;
/**
* Inspector
* Component category (for grouping in Inspector)
*/
category?: string;
/**
* Inspector
* Component icon (for display in Inspector)
*/
icon?: string;
}
/**
* 使 @ECSComponent
* Check if component has @ECSComponent decorator
@@ -81,3 +113,48 @@ export function getComponentInstanceTypeName(component: Component): string {
export function getComponentDependencies(componentType: ComponentType): string[] | undefined {
return (componentType as any)[COMPONENT_DEPENDENCIES];
}
/**
*
* Get component editor options
*
* @param componentType
* @returns
*/
export function getComponentEditorOptions(componentType: ComponentType): ComponentEditorOptions | undefined {
return (componentType as any)[COMPONENT_EDITOR_OPTIONS];
}
/**
*
* Get editor options from component instance
*
* @param component
* @returns
*/
export function getComponentInstanceEditorOptions(component: Component): ComponentEditorOptions | undefined {
return getComponentEditorOptions(component.constructor as ComponentType);
}
/**
* Inspector
* Check if component should be hidden in Inspector
*
* @param componentType
* @returns
*/
export function isComponentHiddenInInspector(componentType: ComponentType): boolean {
const options = getComponentEditorOptions(componentType);
return options?.hideInInspector ?? false;
}
/**
* Inspector
* Check if component instance should be hidden in Inspector
*
* @param component
* @returns
*/
export function isComponentInstanceHiddenInInspector(component: Component): boolean {
return isComponentHiddenInInspector(component.constructor as ComponentType);
}
@@ -145,3 +145,36 @@ export function getEntityRefMetadata(component: any): EntityRefMetadata | null {
export function hasEntityRef(component: any): boolean {
return getEntityRefMetadata(component) !== null;
}
/**
* EntityRef
*
* Check if a specific property is an EntityRef.
*
* @param component Component实例或Component类
* @param propertyKey
* @returns EntityRef属性返回true
*/
export function isEntityRefProperty(component: any, propertyKey: string): boolean {
const metadata = getEntityRefMetadata(component);
if (!metadata) {
return false;
}
return metadata.properties.has(propertyKey);
}
/**
* EntityRef属性名
*
* Get all EntityRef property names of a component.
*
* @param component Component实例或Component类
* @returns EntityRef属性名数组
*/
export function getEntityRefProperties(component: any): string[] {
const metadata = getEntityRefMetadata(component);
if (!metadata) {
return [];
}
return Array.from(metadata.properties);
}
@@ -1,12 +1,15 @@
import 'reflect-metadata';
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'animationClips' | 'collisionLayer' | 'collisionMask';
export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask';
/**
*
* Asset type for asset properties
*
* Asset type for property decorators
*/
export type AssetType = 'texture' | 'audio' | 'scene' | 'prefab' | 'animation' | 'any';
export type PropertyAssetType = 'texture' | 'audio' | 'scene' | 'prefab' | 'animation' | 'any';
/** @deprecated Use PropertyAssetType instead */
export type AssetType = PropertyAssetType;
/**
* -
@@ -119,11 +122,52 @@ interface EnumPropertyOptions extends PropertyOptionsBase {
interface AssetPropertyOptions extends PropertyOptionsBase {
type: 'asset';
/** 资源类型 | Asset type */
assetType?: AssetType;
assetType?: PropertyAssetType;
/** 文件扩展名过滤 | File extension filter */
extensions?: string[];
}
/**
*
* Array item type options
*/
export type ArrayItemType =
| { type: 'string' }
| { type: 'number'; min?: number; max?: number }
| { type: 'integer'; min?: number; max?: number }
| { type: 'boolean' }
| { type: 'asset'; assetType?: PropertyAssetType; extensions?: string[] }
| { type: 'vector2' }
| { type: 'vector3' }
| { type: 'color'; alpha?: boolean }
| { type: 'enum'; options: EnumOption[] };
/**
*
* Array property options
*
* @example
* ```typescript
* @Property({
* type: 'array',
* label: 'Particle Assets',
* itemType: { type: 'asset', extensions: ['.particle'] }
* })
* public particleAssets: string[] = [];
* ```
*/
interface ArrayPropertyOptions extends PropertyOptionsBase {
type: 'array';
/** 数组元素类型 | Array item type */
itemType: ArrayItemType;
/** 最小数组长度 | Minimum array length */
minLength?: number;
/** 最大数组长度 | Maximum array length */
maxLength?: number;
/** 是否允许重排序 | Allow reordering */
reorderable?: boolean;
}
/**
*
* Animation clips property options
@@ -160,6 +204,7 @@ export type PropertyOptions =
| VectorPropertyOptions
| EnumPropertyOptions
| AssetPropertyOptions
| ArrayPropertyOptions
| AnimationClipsPropertyOptions
| CollisionLayerPropertyOptions
| CollisionMaskPropertyOptions;
@@ -13,7 +13,9 @@ import type { EntitySystem } from '../Systems';
import { ComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry';
import {
COMPONENT_TYPE_NAME,
COMPONENT_DEPENDENCIES
COMPONENT_DEPENDENCIES,
COMPONENT_EDITOR_OPTIONS,
type ComponentEditorOptions
} from '../Core/ComponentStorage/ComponentTypeUtils';
/**
@@ -29,6 +31,12 @@ export const SYSTEM_TYPE_NAME = Symbol('SystemTypeName');
export interface ComponentOptions {
/** 依赖的其他组件名称列表 | List of required component names */
requires?: string[];
/**
*
* Editor-related options
*/
editor?: ComponentEditorOptions;
}
/**
@@ -74,6 +82,12 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) {
(target as any)[COMPONENT_DEPENDENCIES] = options.requires;
}
// 存储编辑器选项
// Store editor options
if (options?.editor) {
(target as any)[COMPONENT_EDITOR_OPTIONS] = options.editor;
}
// 自动注册到 ComponentRegistry,使组件可以通过名称查找
// Auto-register to ComponentRegistry, enabling lookup by name
ComponentRegistry.register(target);
+9 -2
View File
@@ -5,13 +5,18 @@
export {
COMPONENT_TYPE_NAME,
COMPONENT_DEPENDENCIES,
COMPONENT_EDITOR_OPTIONS,
getComponentTypeName,
getComponentInstanceTypeName,
getComponentDependencies,
getComponentEditorOptions,
getComponentInstanceEditorOptions,
isComponentHiddenInInspector,
isComponentInstanceHiddenInInspector,
hasECSComponentDecorator
} from '../Core/ComponentStorage/ComponentTypeUtils';
export type { ComponentType } from '../Core/ComponentStorage/ComponentTypeUtils';
export type { ComponentType, ComponentEditorOptions } from '../Core/ComponentStorage/ComponentTypeUtils';
// ============================================================================
// Type Decorators (ECSComponent, ECSSystem)
@@ -36,6 +41,8 @@ export {
EntityRef,
getEntityRefMetadata,
hasEntityRef,
isEntityRefProperty,
getEntityRefProperties,
ENTITY_REF_METADATA
} from './EntityRefDecorator';
@@ -57,6 +64,6 @@ export type {
PropertyType,
PropertyControl,
PropertyAction,
AssetType,
PropertyAssetType,
EnumOption
} from './PropertyDecorator';
+22 -4
View File
@@ -4,6 +4,7 @@ import { EEntityLifecyclePolicy } from './Core/EntityLifecyclePolicy';
import { BitMask64Utils, BitMask64Data } from './Utils/BigIntCompatibility';
import { createLogger } from '../Utils/Logger';
import { getComponentInstanceTypeName, getComponentTypeName } from './Decorators';
import { generateGUID } from '../Utils/GUID';
import type { IScene } from './IScene';
/**
@@ -75,10 +76,23 @@ export class Entity {
public name: string;
/**
*
* ID
*
* Runtime identifier for fast lookups.
*/
public readonly id: number;
/**
* GUID
*
* /
*
*
* Persistent identifier for serialization.
* Remains stable across save/load cycles.
*/
public readonly persistentId: string;
/**
*
*/
@@ -130,11 +144,13 @@ export class Entity {
*
*
* @param name -
* @param id -
* @param id - ID
* @param persistentId -
*/
constructor(name: string, id: number) {
constructor(name: string, id: number, persistentId?: string) {
this.name = name;
this.id = id;
this.persistentId = persistentId ?? generateGUID();
}
/**
@@ -779,7 +795,7 @@ export class Entity {
* @returns
*/
public toString(): string {
return `Entity[${this.name}:${this.id}]`;
return `Entity[${this.name}:${this.id}:${this.persistentId.slice(0, 8)}]`;
}
/**
@@ -790,6 +806,7 @@ export class Entity {
public getDebugInfo(): {
name: string;
id: number;
persistentId: string;
enabled: boolean;
active: boolean;
destroyed: boolean;
@@ -801,6 +818,7 @@ export class Entity {
return {
name: this.name,
id: this.id,
persistentId: this.persistentId,
enabled: this._enabled,
active: this._active,
destroyed: this._isDestroyed,
@@ -6,10 +6,12 @@
import { Component } from '../Component';
import { ComponentType } from '../Core/ComponentStorage';
import { getComponentTypeName } from '../Decorators';
import { getComponentTypeName, isEntityRefProperty } from '../Decorators';
import {
getSerializationMetadata
} from './SerializationDecorators';
import type { Entity } from '../Entity';
import type { SerializationContext, SerializedEntityRef } from './SerializationContext';
/**
*
@@ -24,7 +26,8 @@ export type SerializableValue =
| { [key: string]: SerializableValue }
| { __type: 'Date'; value: string }
| { __type: 'Map'; value: Array<[SerializableValue, SerializableValue]> }
| { __type: 'Set'; value: SerializableValue[] };
| { __type: 'Set'; value: SerializableValue[] }
| { __entityRef: SerializedEntityRef };
/**
*
@@ -71,17 +74,25 @@ export class ComponentSerializer {
// 序列化标记的字段
for (const [fieldName, options] of metadata.fields) {
const fieldKey = typeof fieldName === 'symbol' ? fieldName.toString() : fieldName;
const value = (component as unknown as Record<string | symbol, SerializableValue>)[fieldName];
const value = (component as unknown as Record<string | symbol, unknown>)[fieldName];
// 跳过忽略的字段
if (metadata.ignoredFields.has(fieldName)) {
continue;
}
// 使用自定义序列化器或默认序列化
const serializedValue = options.serializer
? options.serializer(value)
: this.serializeValue(value);
let serializedValue: SerializableValue;
// 检查是否为 EntityRef 属性
if (isEntityRefProperty(component, fieldKey)) {
serializedValue = this.serializeEntityRef(value as Entity | null);
} else if (options.serializer) {
// 使用自定义序列化器
serializedValue = options.serializer(value);
} else {
// 使用默认序列化
serializedValue = this.serializeValue(value as SerializableValue);
}
// 使用别名或原始字段名
const key = options.alias || fieldKey;
@@ -100,11 +111,13 @@ export class ComponentSerializer {
*
* @param serializedData
* @param componentRegistry ( -> )
* @param context EntityRef
* @returns null
*/
public static deserialize(
serializedData: SerializedComponent,
componentRegistry: Map<string, ComponentType>
componentRegistry: Map<string, ComponentType>,
context?: SerializationContext
): Component | null {
const componentClass = componentRegistry.get(serializedData.type);
@@ -133,6 +146,18 @@ export class ComponentSerializer {
continue; // 字段不存在于序列化数据中
}
// 检查是否为序列化的 EntityRef
if (this.isSerializedEntityRef(serializedValue)) {
// EntityRef 需要延迟解析
if (context) {
const ref = serializedValue.__entityRef;
context.registerPendingRef(component, fieldKey, ref.id, ref.guid);
}
// 暂时设为 null,后续由 context.resolveAllReferences() 填充
(component as unknown as Record<string | symbol, unknown>)[fieldName] = null;
continue;
}
// 使用自定义反序列化器或默认反序列化
const value = options.deserializer
? options.deserializer(serializedValue)
@@ -168,16 +193,18 @@ export class ComponentSerializer {
*
* @param serializedComponents
* @param componentRegistry
* @param context EntityRef
* @returns
*/
public static deserializeComponents(
serializedComponents: SerializedComponent[],
componentRegistry: Map<string, ComponentType>
componentRegistry: Map<string, ComponentType>,
context?: SerializationContext
): Component[] {
const result: Component[] = [];
for (const serialized of serializedComponents) {
const component = this.deserialize(serialized, componentRegistry);
const component = this.deserialize(serialized, componentRegistry, context);
if (component) {
result.push(component);
}
@@ -349,4 +376,41 @@ export class ComponentSerializer {
isSerializable: true
};
}
/**
* Entity
*
* Serialize an Entity reference to a portable format.
*
* @param entity Entity null
* @returns
*/
public static serializeEntityRef(entity: Entity | null): SerializableValue {
if (!entity) {
return null;
}
return {
__entityRef: {
id: entity.id,
guid: entity.persistentId
}
};
}
/**
* EntityRef
*
* Check if a value is a serialized EntityRef.
*
* @param value
* @returns EntityRef true
*/
public static isSerializedEntityRef(value: unknown): value is { __entityRef: SerializedEntityRef } {
return (
typeof value === 'object' &&
value !== null &&
'__entityRef' in value
);
}
}
@@ -10,16 +10,26 @@ import { ComponentSerializer, SerializedComponent } from './ComponentSerializer'
import { IScene } from '../IScene';
import { HierarchyComponent } from '../Components/HierarchyComponent';
import { HierarchySystem } from '../Systems/HierarchySystem';
import { SerializationContext } from './SerializationContext';
/**
*
*/
export interface SerializedEntity {
/**
* ID
* IDID
*
* Runtime ID.
*/
id: number;
/**
* GUID
*
* Persistent GUID for cross-session reference resolution.
*/
guid?: string;
/**
*
*/
@@ -84,6 +94,7 @@ export class EntitySerializer {
const serializedEntity: SerializedEntity = {
id: entity.id,
guid: entity.persistentId,
name: entity.name,
tag: entity.tag,
active: entity.active,
@@ -120,12 +131,16 @@ export class EntitySerializer {
/**
*
*
* Deserialize an entity from serialized data.
*
* @param serializedEntity
* @param componentRegistry
* @param idGenerator ID生成器ID或保持原ID
* @param preserveIds IDfalse
* @param scene entity.scene以支持添加组件
* @param hierarchySystem
* @param allEntities
* @param context EntityRef
* @returns
*/
public static deserialize(
@@ -135,15 +150,21 @@ export class EntitySerializer {
preserveIds: boolean = false,
scene?: IScene,
hierarchySystem?: HierarchySystem | null,
allEntities?: Map<number, Entity>
allEntities?: Map<number, Entity>,
context?: SerializationContext
): Entity {
// 创建实体(使用原始ID或新生成的ID)
// 创建实体(使用原始ID或新生成的ID,保留原始 GUID
const entityId = preserveIds ? serializedEntity.id : idGenerator();
const entity = new Entity(serializedEntity.name, entityId);
const entity = new Entity(serializedEntity.name, entityId, serializedEntity.guid);
// 将实体添加到收集 Map 中(用于后续添加到场景)
allEntities?.set(entity.id, entity);
// 注册实体到序列化上下文(用于后续解析 EntityRef)
if (context) {
context.registerEntity(entity, serializedEntity.id, serializedEntity.guid);
}
// 如果提供了scene,先设置entity.scene以支持添加组件
if (scene) {
entity.scene = scene;
@@ -155,10 +176,11 @@ export class EntitySerializer {
entity.enabled = serializedEntity.enabled;
entity.updateOrder = serializedEntity.updateOrder;
// 反序列化组件
// 反序列化组件(传入 context 以支持 EntityRef 解析)
const components = ComponentSerializer.deserializeComponents(
serializedEntity.components,
componentRegistry
componentRegistry,
context
);
for (const component of components) {
@@ -183,7 +205,8 @@ export class EntitySerializer {
preserveIds,
scene,
hierarchySystem,
allEntities
allEntities,
context
);
// 使用 HierarchySystem 建立层级关系
hierarchySystem?.setParent(childEntity, entity);
@@ -223,12 +246,15 @@ export class EntitySerializer {
/**
*
*
* Deserialize multiple entities from serialized data.
*
* @param serializedEntities
* @param componentRegistry
* @param idGenerator ID生成器
* @param preserveIds ID
* @param scene entity.scene以支持添加组件
* @param hierarchySystem
* @param context EntityRef
* @returns
*/
public static deserializeEntities(
@@ -237,7 +263,8 @@ export class EntitySerializer {
idGenerator: () => number,
preserveIds: boolean = false,
scene?: IScene,
hierarchySystem?: HierarchySystem | null
hierarchySystem?: HierarchySystem | null,
context?: SerializationContext
): { rootEntities: Entity[]; allEntities: Map<number, Entity> } {
const rootEntities: Entity[] = [];
const allEntities = new Map<number, Entity>();
@@ -250,7 +277,8 @@ export class EntitySerializer {
preserveIds,
scene,
hierarchySystem,
allEntities
allEntities,
context
);
rootEntities.push(entity);
}
@@ -0,0 +1,470 @@
/**
*
* Prefab serializer
*
*
* Provides prefab creation and instantiation functionality.
*/
import { Entity } from '../Entity';
import { IScene } from '../IScene';
import { ComponentType } from '../Core/ComponentStorage';
import { EntitySerializer, SerializedEntity } from './EntitySerializer';
import { HierarchySystem } from '../Systems/HierarchySystem';
import { PrefabInstanceComponent } from '../Components/PrefabInstanceComponent';
/**
* SerializedEntity
* Serialized prefab entity (extends SerializedEntity)
*/
export interface SerializedPrefabEntity extends SerializedEntity {
/**
*
* Whether this is the prefab root entity
*/
isPrefabRoot?: boolean;
/**
* GUID
* GUID of nested prefab
*/
nestedPrefabGuid?: string;
}
/**
*
* Prefab metadata
*/
export interface PrefabMetadata {
/** 预制体名称 | Prefab name */
name: string;
/** 资产 GUID | Asset GUID */
guid?: string;
/** 创建时间戳 | Creation timestamp */
createdAt: number;
/** 最后修改时间戳 | Last modification timestamp */
modifiedAt: number;
/** 使用的组件类型列表 | List of component types used */
componentTypes: string[];
/** 引用的资产 GUID 列表 | List of referenced asset GUIDs */
referencedAssets: string[];
/** 预制体描述 | Prefab description */
description?: string;
/** 预制体标签 | Prefab tags */
tags?: string[];
}
/**
*
* Component type registry entry
*/
export interface PrefabComponentTypeEntry {
/** 组件类型名称 | Component type name */
typeName: string;
/** 组件版本号 | Component version number */
version: number;
}
/**
*
* Prefab data format
*/
export interface PrefabData {
/** 预制体格式版本号 | Prefab format version number */
version: number;
/** 预制体元数据 | Prefab metadata */
metadata: PrefabMetadata;
/** 根实体数据 | Root entity data */
root: SerializedPrefabEntity;
/** 组件类型注册表 | Component type registry */
componentTypeRegistry: PrefabComponentTypeEntry[];
}
/**
*
* Prefab creation options
*/
export interface PrefabCreateOptions {
/** 预制体名称 | Prefab name */
name: string;
/** 预制体描述 | Prefab description */
description?: string;
/** 预制体标签 | Prefab tags */
tags?: string[];
/** 是否包含子实体 | Whether to include child entities */
includeChildren?: boolean;
}
/**
*
* Prefab instantiation options
*/
export interface PrefabInstantiateOptions {
/** 父实体 ID | Parent entity ID */
parentId?: number;
/** 位置覆盖 | Position override */
position?: { x: number; y: number };
/** 旋转覆盖(角度) | Rotation override (in degrees) */
rotation?: number;
/** 缩放覆盖 | Scale override */
scale?: { x: number; y: number };
/** 实体名称覆盖 | Entity name override */
name?: string;
/** 是否保留原始实体 ID | Whether to preserve original entity IDs */
preserveIds?: boolean;
/** 是否标记为预制体实例 | Whether to mark as prefab instance */
trackInstance?: boolean;
}
/**
*
* Prefab format version
*/
export const PREFAB_FORMAT_VERSION = 1;
/**
*
* Prefab serializer class
*
*
* Provides prefab creation, serialization, and instantiation functionality.
*/
export class PrefabSerializer {
/**
*
* Create prefab data from entity
*
* @param entity - | Source entity
* @param options - | Creation options
* @param hierarchySystem - | Hierarchy system
* @returns | Prefab data
*/
public static createPrefab(
entity: Entity,
options: PrefabCreateOptions,
hierarchySystem?: HierarchySystem
): PrefabData {
const includeChildren = options.includeChildren ?? true;
// 序列化实体 | Serialize entity
const serializedEntity = EntitySerializer.serialize(
entity,
includeChildren,
hierarchySystem
);
// 转换为预制体实体格式 | Convert to prefab entity format
const prefabEntity = this.toPrefabEntity(serializedEntity, true);
// 收集组件类型信息 | Collect component type information
const { componentTypes, componentTypeRegistry } = this.collectComponentTypes(prefabEntity);
// 收集引用的资产(TODO: 实现资产引用扫描)
// Collect referenced assets (TODO: implement asset reference scanning)
const referencedAssets: string[] = [];
const now = Date.now();
const metadata: PrefabMetadata = {
name: options.name,
createdAt: now,
modifiedAt: now,
componentTypes,
referencedAssets
};
// 只在有值时添加可选属性 | Only add optional properties when they have values
if (options.description) {
metadata.description = options.description;
}
if (options.tags) {
metadata.tags = options.tags;
}
return {
version: PREFAB_FORMAT_VERSION,
metadata,
root: prefabEntity,
componentTypeRegistry
};
}
/**
*
* Instantiate entity from prefab data
*
* @param prefabData - | Prefab data
* @param scene - | Target scene
* @param componentRegistry - | Component type registry
* @param options - | Instantiation options
* @returns | Created root entity
*/
public static instantiate(
prefabData: PrefabData,
scene: IScene,
componentRegistry: Map<string, ComponentType>,
options: PrefabInstantiateOptions = {}
): Entity {
const {
parentId,
name,
preserveIds = false,
trackInstance = true
} = options;
// 获取层级系统 | Get hierarchy system
const hierarchySystem = scene.getSystem(HierarchySystem) ?? null;
// ID 生成器 | ID generator
let nextId = 1;
const idGenerator = (): number => {
while (scene.findEntityById(nextId)) {
nextId++;
}
return nextId++;
};
// 反序列化实体 | Deserialize entity
const { rootEntities, allEntities } = EntitySerializer.deserializeEntities(
[prefabData.root],
componentRegistry,
idGenerator,
preserveIds,
scene,
hierarchySystem
);
const rootEntity = rootEntities[0];
if (!rootEntity) {
throw new Error('Failed to instantiate prefab: no root entity created');
}
// 覆盖名称 | Override name
if (name) {
rootEntity.name = name;
}
// 将所有实体添加到场景 | Add all entities to scene
for (const entity of allEntities.values()) {
scene.entities.add(entity);
}
// 设置父级 | Set parent
if (parentId !== undefined && hierarchySystem) {
const parent = scene.findEntityById(parentId);
if (parent) {
hierarchySystem.setParent(rootEntity, parent);
}
}
// 添加预制体实例组件 | Add prefab instance component
if (trackInstance) {
const prefabGuid = prefabData.metadata.guid || '';
this.addPrefabInstanceComponents(
rootEntity,
allEntities,
prefabGuid,
'',
hierarchySystem
);
}
// TODO: 应用位置、旋转、缩放覆盖(需要 TransformComponent
// TODO: Apply position, rotation, scale overrides (requires TransformComponent)
return rootEntity;
}
/**
*
* Convert serialized entity to prefab entity format
*/
private static toPrefabEntity(
entity: SerializedEntity,
isRoot: boolean
): SerializedPrefabEntity {
const prefabEntity: SerializedPrefabEntity = {
...entity,
isPrefabRoot: isRoot,
children: entity.children.map(child => this.toPrefabEntity(child, false))
};
return prefabEntity;
}
/**
* 使
* Collect component types used in prefab
*/
private static collectComponentTypes(
entity: SerializedPrefabEntity
): {
componentTypes: string[];
componentTypeRegistry: PrefabComponentTypeEntry[];
} {
const typeMap = new Map<string, number>();
const collectFromEntity = (e: SerializedPrefabEntity): void => {
for (const comp of e.components) {
if (!typeMap.has(comp.type)) {
typeMap.set(comp.type, comp.version);
}
}
for (const child of e.children as SerializedPrefabEntity[]) {
collectFromEntity(child);
}
};
collectFromEntity(entity);
const componentTypes = Array.from(typeMap.keys());
const componentTypeRegistry: PrefabComponentTypeEntry[] = Array.from(
typeMap.entries()
).map(([typeName, version]) => ({ typeName, version }));
return { componentTypes, componentTypeRegistry };
}
/**
*
* Add prefab instance components to instantiated entities
*/
private static addPrefabInstanceComponents(
rootEntity: Entity,
allEntities: Map<number, Entity>,
prefabGuid: string,
prefabPath: string,
_hierarchySystem: HierarchySystem | null
): void {
const rootId = rootEntity.id;
// 为根实体添加组件 | Add component to root entity
const rootComp = new PrefabInstanceComponent(prefabGuid, prefabPath, true);
rootComp.rootInstanceEntityId = rootId;
rootEntity.addComponent(rootComp);
// 为所有子实体添加组件 | Add component to all child entities
for (const entity of allEntities.values()) {
if (entity.id === rootId) continue;
const childComp = new PrefabInstanceComponent(prefabGuid, prefabPath, false);
childComp.rootInstanceEntityId = rootId;
entity.addComponent(childComp);
}
}
/**
*
* Check if entity is a prefab instance
*/
public static isPrefabInstance(entity: Entity): boolean {
return entity.hasComponent(PrefabInstanceComponent);
}
/**
* GUID
* Get source prefab GUID of a prefab instance
*/
public static getSourcePrefabGuid(entity: Entity): string | null {
const comp = entity.getComponent(PrefabInstanceComponent);
return comp?.sourcePrefabGuid || null;
}
/**
*
* Get root entity of a prefab instance
*/
public static getPrefabInstanceRoot(entity: Entity): Entity | null {
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp || !comp.rootInstanceEntityId) return null;
const scene = entity.scene;
if (!scene) return null;
return scene.findEntityById(comp.rootInstanceEntityId) || null;
}
/**
* JSON
* Serialize prefab data to JSON string
*/
public static serialize(prefabData: PrefabData, pretty: boolean = true): string {
return JSON.stringify(prefabData, null, pretty ? 2 : undefined);
}
/**
* JSON
* Parse prefab data from JSON string
*/
public static deserialize(json: string): PrefabData {
const data = JSON.parse(json) as PrefabData;
// 基本验证 | Basic validation
if (!data.version || !data.metadata || !data.root) {
throw new Error('Invalid prefab data format');
}
return data;
}
/**
*
* Validate prefab data format
*/
public static validate(prefabData: PrefabData): { valid: boolean; errors?: string[] } {
const errors: string[] = [];
if (typeof prefabData.version !== 'number') {
errors.push('Invalid or missing version');
}
if (!prefabData.metadata) {
errors.push('Missing metadata');
} else {
if (!prefabData.metadata.name) {
errors.push('Missing metadata.name');
}
if (!Array.isArray(prefabData.metadata.componentTypes)) {
errors.push('Invalid metadata.componentTypes');
}
}
if (!prefabData.root) {
errors.push('Missing root entity');
} else {
this.validateEntity(prefabData.root, errors, 'root');
}
if (!Array.isArray(prefabData.componentTypeRegistry)) {
errors.push('Invalid componentTypeRegistry');
}
if (errors.length > 0) {
return { valid: false, errors };
}
return { valid: true };
}
/**
*
* Validate entity data
*/
private static validateEntity(
entity: SerializedPrefabEntity,
errors: string[],
path: string
): void {
if (typeof entity.id !== 'number') {
errors.push(`${path}: Invalid or missing id`);
}
if (typeof entity.name !== 'string') {
errors.push(`${path}: Invalid or missing name`);
}
if (!Array.isArray(entity.components)) {
errors.push(`${path}: Invalid or missing components`);
}
if (!Array.isArray(entity.children)) {
errors.push(`${path}: Invalid or missing children`);
} else {
entity.children.forEach((child, index) => {
this.validateEntity(child as SerializedPrefabEntity, errors, `${path}.children[${index}]`);
});
}
}
}
@@ -13,6 +13,7 @@ import { getSerializationMetadata } from './SerializationDecorators';
import { BinarySerializer } from '../../Utils/BinarySerializer';
import { HierarchySystem } from '../Systems/HierarchySystem';
import { HierarchyComponent } from '../Components/HierarchyComponent';
import { SerializationContext } from './SerializationContext';
/**
*
@@ -216,6 +217,14 @@ export class SceneSerializer {
/**
*
*
* 使
* 1. EntityRef
* 2. EntityRef
*
* Deserialize scene using two-phase approach:
* 1. Create all entities and components, collect pending EntityRefs
* 2. Resolve all EntityRefs, establish correct object references
*
* @param scene
* @param saveData JSON字符串或二进制Uint8Array
* @param options
@@ -266,14 +275,20 @@ export class SceneSerializer {
// 获取层级系统
const hierarchySystem = scene.getSystem(HierarchySystem);
// 反序列化实体
// ========== 阶段 1:创建实体和组件,收集 EntityRef ==========
// Phase 1: Create entities and components, collect EntityRefs
const context = new SerializationContext();
context.setPreserveIds(opts.preserveIds || false);
// 反序列化实体(传入 context 收集 EntityRef
const { rootEntities, allEntities } = EntitySerializer.deserializeEntities(
serializedScene.entities,
componentRegistry,
idGenerator,
opts.preserveIds || false,
scene,
hierarchySystem
hierarchySystem,
context
);
// 将所有实体添加到场景(包括子实体)
@@ -287,6 +302,18 @@ export class SceneSerializer {
scene.querySystem.clearCache();
scene.clearSystemEntityCaches();
// ========== 阶段 2:解析所有 EntityRef ==========
// Phase 2: Resolve all EntityRefs
const resolvedCount = context.resolveAllReferences();
const unresolvedCount = context.getUnresolvedCount();
if (unresolvedCount > 0) {
console.warn(
`[SceneSerializer] ${unresolvedCount} EntityRef(s) could not be resolved. ` +
`Resolved: ${resolvedCount}, Total pending: ${context.getPendingCount()}`
);
}
// 反序列化场景自定义数据
if (serializedScene.sceneData) {
this.deserializeSceneData(serializedScene.sceneData, scene.sceneData);
@@ -0,0 +1,321 @@
import type { Entity } from '../Entity';
import type { Component } from '../Component';
/**
*
*
* Serialized entity reference format.
*/
export interface SerializedEntityRef {
/**
* ID
*
* Runtime ID (backward compatible).
*/
id?: number | undefined;
/**
* GUID
*
* Persistent GUID (new format).
*/
guid?: string | undefined;
}
/**
*
*
* Pending entity reference record.
*/
interface PendingEntityRef {
/**
*
*/
component: Component;
/**
*
*/
propertyKey: string;
/**
* ID
*/
originalId: number | undefined;
/**
* GUID
*/
originalGuid: string | undefined;
}
/**
*
*
* /
*
*
*
* Serialization context for managing two-phase serialization/deserialization.
* Phase 1: Create all entities and components, collect pending references.
* Phase 2: Resolve all entity references, establish correct object relationships.
*
* @example
* ```typescript
* const context = new SerializationContext();
*
* // 第一阶段:反序列化实体
* for (const entityData of entities) {
* const entity = scene.createEntity(entityData.name);
* context.registerEntity(entity, entityData.id, entityData.guid);
*
* // 反序列化组件时,遇到 EntityRef 注册为待解析
* context.registerPendingRef(component, 'target', entityData.targetId, entityData.targetGuid);
* }
*
* // 第二阶段:解析所有引用
* context.resolveAllReferences();
* ```
*/
export class SerializationContext {
/**
* ID ID -> Entity
*
* Runtime ID mapping: original ID -> Entity.
*/
private _idRemapping: Map<number, Entity> = new Map();
/**
* GUID persistentId -> Entity
*
* GUID mapping: persistentId -> Entity.
*/
private _guidLookup: Map<string, Entity> = new Map();
/**
*
*
* Pending entity references to resolve.
*/
private _pendingRefs: PendingEntityRef[] = [];
/**
* ID
*
* Whether to preserve original IDs.
*/
private _preserveIds: boolean = false;
/**
* ID
*
* Set whether to preserve original IDs.
*/
public setPreserveIds(value: boolean): void {
this._preserveIds = value;
}
/**
* ID
*
* Get whether to preserve original IDs.
*/
public get preserveIds(): boolean {
return this._preserveIds;
}
/**
*
*
* Register entity to context for later reference resolution.
*
* @param entity -
* @param originalId - ID ID
* @param originalGuid - GUID GUID 使 entity.persistentId
*/
public registerEntity(entity: Entity, originalId?: number, originalGuid?: string): void {
// 使用实体自身的 persistentId 或提供的 originalGuid
const guid = originalGuid ?? entity.persistentId;
this._guidLookup.set(guid, entity);
// 如果提供了原始 ID,建立 ID 映射
if (originalId !== undefined) {
this._idRemapping.set(originalId, entity);
}
}
/**
* ID
*
* Get entity by original runtime ID.
*
* @param originalId - ID
* @returns null
*/
public getEntityById(originalId: number): Entity | null {
return this._idRemapping.get(originalId) ?? null;
}
/**
* GUID
*
* Get entity by GUID.
*
* @param guid - GUID
* @returns null
*/
public getEntityByGuid(guid: string): Entity | null {
return this._guidLookup.get(guid) ?? null;
}
/**
*
*
* Resolve entity reference, preferring GUID over ID.
*
* @param ref -
* @returns null
*/
public resolveEntityRef(ref: SerializedEntityRef | null | undefined): Entity | null {
if (!ref) {
return null;
}
// 优先使用 GUID
if (ref.guid) {
const entity = this._guidLookup.get(ref.guid);
if (entity) {
return entity;
}
}
// 降级使用 ID
if (ref.id !== undefined) {
const entity = this._idRemapping.get(ref.id);
if (entity) {
return entity;
}
}
return null;
}
/**
*
*
* Register a pending entity reference to be resolved later.
*
* @param component -
* @param propertyKey -
* @param originalId - ID
* @param originalGuid - GUID
*/
public registerPendingRef(
component: Component,
propertyKey: string,
originalId?: number,
originalGuid?: string
): void {
this._pendingRefs.push({
component,
propertyKey,
originalId,
originalGuid
});
}
/**
*
*
* Resolve all pending entity references.
* Should be called after all entities have been created.
*
* @returns
*/
public resolveAllReferences(): number {
let resolvedCount = 0;
for (const pending of this._pendingRefs) {
const entity = this.resolveEntityRef({
id: pending.originalId,
guid: pending.originalGuid
});
if (entity) {
// 使用类型断言设置属性值
(pending.component as unknown as Record<string, unknown>)[pending.propertyKey] = entity;
resolvedCount++;
}
// 如果无法解析,保持为 null(已在反序列化时设置)
}
return resolvedCount;
}
/**
*
*
* Get count of unresolved references.
*/
public getUnresolvedCount(): number {
let count = 0;
for (const pending of this._pendingRefs) {
const entity = this.resolveEntityRef({
id: pending.originalId,
guid: pending.originalGuid
});
if (!entity) {
count++;
}
}
return count;
}
/**
*
*
* Get count of pending references.
*/
public getPendingCount(): number {
return this._pendingRefs.length;
}
/**
*
*
* Get count of registered entities.
*/
public getRegisteredEntityCount(): number {
return this._guidLookup.size;
}
/**
*
*
* Clear context state.
*/
public clear(): void {
this._idRemapping.clear();
this._guidLookup.clear();
this._pendingRefs = [];
}
/**
*
*
* Get debug information.
*/
public getDebugInfo(): {
registeredEntities: number;
pendingRefs: number;
unresolvedRefs: number;
preserveIds: boolean;
} {
return {
registeredEntities: this._guidLookup.size,
pendingRefs: this._pendingRefs.length,
unresolvedRefs: this.getUnresolvedCount(),
preserveIds: this._preserveIds
};
}
}
@@ -60,3 +60,18 @@ export type {
ComponentChange,
SceneDataChange
} from './IncrementalSerializer';
// 预制体序列化
export { PrefabSerializer, PREFAB_FORMAT_VERSION } from './PrefabSerializer';
export type {
SerializedPrefabEntity,
PrefabMetadata,
PrefabComponentTypeEntry,
PrefabData,
PrefabCreateOptions,
PrefabInstantiateOptions
} from './PrefabSerializer';
// 序列化上下文
export { SerializationContext } from './SerializationContext';
export type { SerializedEntityRef } from './SerializationContext';
+90
View File
@@ -0,0 +1,90 @@
/**
* GUID
*
* UUID v4
* 使 crypto.randomUUID()使 Math.random()
*
* GUID generation utility.
* Provides cross-platform UUID v4 generation for entity persistent identification.
* Uses crypto.randomUUID() when available, falls back to Math.random() implementation.
*/
/**
* UUID v4 GUID
*
* Generate a UUID v4 format GUID.
*
* @returns 36 UUID (: "550e8400-e29b-41d4-a716-446655440000")
*
* @example
* ```typescript
* const id = generateGUID();
* console.log(id); // "550e8400-e29b-41d4-a716-446655440000"
* ```
*/
export function generateGUID(): string {
// 优先使用原生 crypto API(浏览器和 Node.js 19+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// 降级方案:使用 crypto.getRandomValues 或 Math.random
return generateGUIDFallback();
}
/**
* GUID
*
* Fallback GUID generation using crypto.getRandomValues or Math.random.
*/
function generateGUIDFallback(): string {
// 尝试使用 crypto.getRandomValues
if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
// 设置版本号 (version 4)
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
// 设置变体 (variant 1)
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
return formatUUID(bytes);
}
// 最终降级:使用 Math.random(不推荐,但可用)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* 16 UUID
*
* Format 16-byte array to UUID string.
*/
function formatUUID(bytes: Uint8Array): string {
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* UUID
*
* Validate if a string is a valid UUID format.
*
* @param value -
* @returns UUID true
*/
export function isValidGUID(value: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(value);
}
/**
* GUID
*
* Empty GUID constant (all zeros).
*/
export const EMPTY_GUID = '00000000-0000-0000-0000-000000000000';
+1
View File
@@ -8,3 +8,4 @@ export * from './Debug';
export * from './Logger';
export * from './BinarySerializer';
export * from './Profiler';
export * from './GUID';
+9
View File
@@ -18,6 +18,15 @@ export { PluginManager } from './Core/PluginManager';
export { PluginState } from './Core/Plugin';
export type { IPlugin, IPluginMetadata } from './Core/Plugin';
// 运行时模式服务 | Runtime Mode Service
export {
RuntimeModeService,
RuntimeModeToken,
createEditorModeService,
createStandaloneModeService
} from './Core/RuntimeModeService';
export type { IRuntimeMode, RuntimeModeConfig } from './Core/RuntimeModeService';
// 内置插件
export * from './Plugins';
@@ -4,7 +4,7 @@
*/
import type { SpriteRenderData, TextureLoadRequest, EngineStats, CameraConfig } from '../types';
import type { IEngineBridge } from '@esengine/asset-system';
import type { ITextureEngineBridge } from '@esengine/asset-system';
import type { GameEngine } from '../wasm/es_engine';
/**
@@ -43,7 +43,7 @@ export interface EngineBridgeConfig {
* bridge.render();
* ```
*/
export class EngineBridge implements IEngineBridge {
export class EngineBridge implements ITextureEngineBridge {
private engine: GameEngine | null = null;
private config: Required<EngineBridgeConfig>;
private initialized = false;
@@ -468,6 +468,41 @@ export class EngineBridge implements IEngineBridge {
};
}
/**
* Convert screen coordinates to world coordinates.
*
*
* Screen coordinates: (0,0) at top-left of canvas, Y-down
* World coordinates: Y-up, camera position at center of view
*
* @param screenX - Screen X coordinate (relative to canvas left edge)
* @param screenY - Screen Y coordinate (relative to canvas top edge)
* @returns World coordinates { x, y }
*/
screenToWorld(screenX: number, screenY: number): { x: number; y: number } {
if (!this.initialized) {
return { x: screenX, y: screenY };
}
const result = this.getEngine().screenToWorld(screenX, screenY);
return { x: result[0], y: result[1] };
}
/**
* Convert world coordinates to screen coordinates.
*
*
* @param worldX - World X coordinate
* @param worldY - World Y coordinate
* @returns Screen coordinates { x, y } (relative to canvas)
*/
worldToScreen(worldX: number, worldY: number): { x: number; y: number } {
if (!this.initialized) {
return { x: worldX, y: worldY };
}
const result = this.getEngine().worldToScreen(worldX, worldY);
return { x: result[0], y: result[1] };
}
/**
* Set grid visibility.
*
@@ -817,6 +852,37 @@ export class EngineBridge implements IEngineBridge {
}
}
// ===== Texture Cache API =====
// ===== 纹理缓存 API =====
/**
* Clear the texture path cache.
*
*
* This should be called when restoring scene snapshots to ensure
* textures are reloaded with correct IDs.
* 使ID重新加载
*/
clearTexturePathCache(): void {
if (!this.initialized) return;
this.getEngine().clearTexturePathCache();
}
/**
* Clear all textures and reset state.
*
*
* This removes all loaded textures from GPU memory and resets
* the ID counter. Use with caution as all texture references
* will become invalid.
* GPU内存中移除所有已加载的纹理并重置ID计数器
* 使
*/
clearAllTextures(): void {
if (!this.initialized) return;
this.getEngine().clearAllTextures();
}
/**
* Dispose the bridge and release resources.
*
-1
View File
@@ -22,5 +22,4 @@ export { RenderBatcher } from './core/RenderBatcher';
export { SpriteRenderHelper } from './core/SpriteRenderHelper';
export type { ITransformComponent } from './core/SpriteRenderHelper';
export { EngineRenderSystem, type TransformComponentType, type IUIRenderDataProvider, type GizmoDataProviderFn, type HasGizmoProviderFn, type ProviderRenderData, type AssetPathResolverFn } from './systems/EngineRenderSystem';
export { CameraSystem } from './systems/CameraSystem';
export * from './types';
@@ -4,7 +4,7 @@
*/
import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component, Core } from '@esengine/ecs-framework';
import { TransformComponent } from '@esengine/engine-core';
import { TransformComponent, sortingLayerManager } from '@esengine/engine-core';
import { Color } from '@esengine/ecs-framework-math';
import { SpriteComponent } from '@esengine/sprite';
import { CameraComponent } from '@esengine/camera';
@@ -24,10 +24,29 @@ export interface ProviderRenderData {
uvs: Float32Array;
colors: Uint32Array;
tileCount: number;
/** Sorting order for render ordering | 渲染排序顺序 */
sortingOrder: number;
/** Texture path for loading (optional, used if textureId is 0) */
texturePath?: string;
/**
*
* Sorting layer name
*
* 'Default'
* Determines the major render order category. Defaults to 'Default'.
*/
sortingLayer: string;
/**
*
* Order within the sorting layer
*/
orderInLayer: number;
/** 纹理 GUID(如果 textureId 为 0 则使用)| Texture GUID (used if textureId is 0) */
textureGuid?: string;
/**
*
* Whether to render in screen space
*
* sortingLayer bScreenSpace
* Overrides sortingLayer's bScreenSpace setting, for particles that need dynamic render space.
*/
bScreenSpace?: boolean;
}
/**
@@ -244,32 +263,73 @@ export class EngineRenderSystem extends EntitySystem {
* Process all matched entities.
*
*
* Rendering is done in two passes:
* 1. World Pass: World sprites, tilemaps, gizmos (affected by world camera)
* 2. UI Pass: Screen space UI (independent orthographic projection, overlaid on world)
* Rendering pipeline:
* 线
*
*
* 1. SpriteGizmo
* 2. UI UI
* 1. World Space Pass: Background Default Foreground WorldOverlay
*
*
* 2. Screen Space Pass (Preview Mode Only): UI ScreenOverlay Modal
* UI
*
* @param entities - Entities to process |
*/
protected override process(entities: readonly Entity[]): void {
// Clear and reuse map for gizmo drawing
// 清空并重用映射用于绘制gizmo
// 清空并重用映射用于绘制 gizmo
this.entityRenderMap.clear();
// Collect all render items separated by render space
// 按渲染空间分离收集所有渲染项
const worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = [];
const screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }> = [];
// Collect sprites from entities (all in world space)
// 收集实体的 sprites(都在世界空间)
this.collectEntitySprites(entities, worldSpaceItems);
// Collect render data from providers (e.g., tilemap, particle)
// 收集渲染数据提供者的数据(如瓦片地图、粒子)
this.collectProviderRenderData(worldSpaceItems, screenSpaceItems);
// Collect UI render data
// 收集 UI 渲染数据
if (this.uiRenderDataProvider) {
const uiRenderData = this.uiRenderDataProvider.getRenderData();
for (const data of uiRenderData) {
const uiSprites = this.convertProviderDataToSprites(data);
if (uiSprites.length > 0) {
const sortKey = sortingLayerManager.getSortKey(data.sortingLayer, data.orderInLayer);
// UI always goes to screen space in preview mode, world space in editor mode
// UI 在预览模式下始终在屏幕空间,编辑器模式下在世界空间
if (this.previewMode) {
screenSpaceItems.push({ sortKey, sprites: uiSprites });
} else {
worldSpaceItems.push({ sortKey, sprites: uiSprites });
}
}
}
}
// ===== Pass 1: World Space Rendering =====
// ===== 阶段 1:世界空间渲染 =====
// This includes world sprites, tilemaps, and world space UI
// 包括世界 Sprite、瓦片地图和世界空间 UI
this.renderWorldSpacePass(worldSpaceItems);
// Collect all render items with sorting order
// 收集所有渲染项及其排序顺序
const renderItems: Array<{ sortingOrder: number; sprites: SpriteRenderData[] }> = [];
// ===== Pass 2: Screen Space Rendering (Preview Mode Only) =====
// ===== 阶段 2:屏幕空间渲染(仅预览模式)=====
if (this.previewMode && screenSpaceItems.length > 0) {
this.renderScreenSpacePass(screenSpaceItems);
}
}
// Collect sprites from entities
// 收集实体的 sprites
/**
* Collect sprites from matched entities.
* sprites
*/
private collectEntitySprites(
entities: readonly Entity[],
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
): void {
for (const entity of entities) {
const sprite = entity.getComponent(SpriteComponent);
const transform = entity.getComponent(this.transformType) as unknown as ITransformComponent | null;
@@ -278,7 +338,7 @@ export class EngineRenderSystem extends EntitySystem {
continue;
}
// Calculate UV with flip | 计算带翻转的UV
// Calculate UV with flip | 计算带翻转的 UV
const uv: [number, number, number, number] = [0, 0, 1, 1];
if (sprite.flipX || sprite.flipY) {
if (sprite.flipX) {
@@ -296,40 +356,30 @@ export class EngineRenderSystem extends EntitySystem {
? transform.worldRotation.z
: (typeof transform.rotation === 'number' ? transform.rotation : transform.rotation.z);
// Convert hex color string to packed RGBA | 将十六进制颜色字符串转换为打包的RGBA
// Convert hex color string to packed RGBA | 将十六进制颜色字符串转换为打包的 RGBA
const color = Color.packHexAlpha(sprite.color, sprite.alpha);
// Get texture ID from sprite component
// 从精灵组件获取纹理ID
// Use Rust engine's path-based texture loading for automatic caching
// 使用Rust引擎的基于路径的纹理加载实现自动缓存
// 从精灵组件获取纹理 ID
let textureId = 0;
const textureSource = sprite.getTextureSource();
if (textureSource) {
// Resolve GUID to path if resolver is available
// 如果有解析器,将 GUID 解析为路径
const texturePath = this.assetPathResolver
? this.assetPathResolver(textureSource)
: textureSource;
const texturePath = this.resolveAssetPath(textureSource);
textureId = this.bridge.getOrLoadTextureByPath(texturePath);
}
// Get material ID from GUID (0 = default if not found or no GUID specified)
// 从 GUID 获取材质 ID(0 = 默认,如果未找到或未指定 GUID)
// Get material ID from GUID
// 从 GUID 获取材质 ID
const materialGuidOrPath = sprite.materialGuid;
const materialPath = materialGuidOrPath && this.assetPathResolver
? this.assetPathResolver(materialGuidOrPath)
const materialPath = materialGuidOrPath
? this.resolveAssetPath(materialGuidOrPath)
: materialGuidOrPath;
const materialId = materialPath
? getMaterialManager().getMaterialIdByPath(materialPath)
: 0;
// Collect material overrides if any
// 收集材质覆盖(如果有)
const hasOverrides = sprite.hasOverrides();
// Pass actual display dimensions (sprite size * world transform scale)
// 传递实际显示尺寸(sprite尺寸 * 世界变换缩放)
const renderData: SpriteRenderData = {
x: pos.x,
y: pos.y,
@@ -342,27 +392,41 @@ export class EngineRenderSystem extends EntitySystem {
uv,
color,
materialId,
// Only include overrides if there are any
// 仅在有覆盖时包含
...(hasOverrides ? { materialOverrides: sprite.materialOverrides } : {})
};
renderItems.push({ sortingOrder: sprite.sortingOrder, sprites: [renderData] });
const sortKey = sortingLayerManager.getSortKey(sprite.sortingLayer, sprite.orderInLayer);
worldSpaceItems.push({ sortKey, sprites: [renderData] });
this.entityRenderMap.set(entity.id, renderData);
}
}
// Collect render data from providers (e.g., tilemap)
/**
* Collect render data from providers (tilemap, particle, etc.).
*
*/
private collectProviderRenderData(
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>,
screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
): void {
for (const provider of this.renderDataProviders) {
const renderDataList = provider.getRenderData();
for (const data of renderDataList) {
// Get texture ID - load from path if needed
// Determine render space: explicit flag > layer config
// 确定渲染空间:显式标志 > 层配置
const bScreenSpace = data.bScreenSpace ?? sortingLayerManager.isScreenSpace(data.sortingLayer);
// Get texture ID - load from GUID if needed
// 获取纹理 ID - 如果需要从 GUID 加载
let textureId = data.textureIds[0] || 0;
if (textureId === 0 && data.texturePath) {
textureId = this.bridge.getOrLoadTextureByPath(data.texturePath);
if (textureId === 0 && data.textureGuid) {
const resolvedPath = this.resolveAssetPath(data.textureGuid);
textureId = this.bridge.getOrLoadTextureByPath(resolvedPath);
}
// Convert tilemap render data to sprites
const tilemapSprites: SpriteRenderData[] = [];
// Convert render data to sprites
// 转换渲染数据为 sprites
const sprites: SpriteRenderData[] = [];
for (let i = 0; i < data.tileCount; i++) {
const tOffset = i * 7;
const uvOffset = i * 4;
@@ -380,34 +444,38 @@ export class EngineRenderSystem extends EntitySystem {
color: data.colors[i]
};
tilemapSprites.push(renderData);
sprites.push(renderData);
}
if (tilemapSprites.length > 0) {
renderItems.push({ sortingOrder: data.sortingOrder, sprites: tilemapSprites });
if (sprites.length > 0) {
const sortKey = sortingLayerManager.getSortKey(data.sortingLayer, data.orderInLayer);
// Route to appropriate render space
// 路由到适当的渲染空间
if (this.previewMode && bScreenSpace) {
screenSpaceItems.push({ sortKey, sprites });
} else {
worldSpaceItems.push({ sortKey, sprites });
}
}
}
}
}
// Collect UI render data if in editor mode (renders in world space)
// 如果在编辑器模式,收集 UI 渲染数据(在世界空间渲染)
if (!this.previewMode && this.uiRenderDataProvider) {
const uiRenderData = this.uiRenderDataProvider.getRenderData();
for (const data of uiRenderData) {
const uiSprites = this.convertProviderDataToSprites(data);
if (uiSprites.length > 0) {
renderItems.push({ sortingOrder: data.sortingOrder, sprites: uiSprites });
}
}
}
// Sort by sortingOrder (lower values render first, appear behind)
// 按 sortingOrder 排序(值越小越先渲染,显示在后面)
renderItems.sort((a, b) => a.sortingOrder - b.sortingOrder);
/**
* Render world space content.
*
*/
private renderWorldSpacePass(
worldSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
): void {
// Sort by sortKey (lower values render first, appear behind)
// 按 sortKey 排序(值越小越先渲染,显示在后面)
worldSpaceItems.sort((a, b) => a.sortKey - b.sortKey);
// Submit all sprites in sorted order
// 按排序顺序提交所有 sprites
for (const item of renderItems) {
for (const item of worldSpaceItems) {
for (const sprite of item.sprites) {
this.batcher.addSprite(sprite);
}
@@ -418,93 +486,53 @@ export class EngineRenderSystem extends EntitySystem {
this.bridge.submitSprites(sprites);
}
// Draw gizmos for all entities with IGizmoProvider components
// 为所有具有 IGizmoProvider 组件的实体绘制 Gizmo
// Draw gizmos
// 绘制 Gizmo
if (this.showGizmos) {
this.drawComponentGizmos();
}
// Draw gizmos for selected entities (always, even if no sprites)
// 为选中的实体绘制Gizmo(始终绘制,即使没有精灵)
if (this.showGizmos && this.selectedEntityIds.size > 0) {
this.drawSelectedEntityGizmos();
}
// Draw camera frustum gizmos
// 绘制相机视锥体 gizmo
if (this.showGizmos) {
this.drawCameraFrustums();
}
// Draw UI canvas boundary
// 绘制 UI 画布边界
if (this.showGizmos && this.showUICanvasBoundary && this.uiCanvasWidth > 0 && this.uiCanvasHeight > 0) {
this.drawUICanvasBoundary();
}
// ===== World Pass: Render world content =====
// ===== 世界阶段:渲染世界内容 =====
// Render world content
// 渲染世界内容
this.bridge.render();
// ===== Pass 2: Screen Space UI Rendering (Preview Mode Only) =====
// ===== 阶段 2:屏幕空间 UI 渲染(仅预览模式)=====
// UI is rendered on top of world content with independent projection
// UI 使用独立投影渲染在世界内容之上
// Only in preview mode - in editor mode, UI is rendered in world space above
// 仅在预览模式 - 在编辑器模式,UI 在上面的世界空间渲染
if (this.previewMode) {
this.renderScreenSpaceUI();
}
}
/**
* Render screen space UI with fixed orthographic projection.
* 使 UI
*
* Screen space UI is rendered with an independent orthographic projection
* based on the UI canvas size, not affected by the world camera.
* UI 使 UI
* Render screen space content (UI, ScreenOverlay, Modal).
* UI
*/
private renderScreenSpaceUI(): void {
if (!this.uiRenderDataProvider) {
return;
}
// Get all UI render data (now only screen space)
// 获取所有 UI 渲染数据(现在只有屏幕空间)
const uiRenderData = this.uiRenderDataProvider.getRenderData();
if (uiRenderData.length === 0) {
return;
}
private renderScreenSpacePass(
screenSpaceItems: Array<{ sortKey: number; sprites: SpriteRenderData[] }>
): void {
// Sort by sortKey
// 按 sortKey 排序
screenSpaceItems.sort((a, b) => a.sortKey - b.sortKey);
// Switch to screen space projection
// 切换到屏幕空间投影
// Use UI canvas size for the orthographic projection
// 使用 UI 画布尺寸进行正交投影
const canvasWidth = this.uiCanvasWidth > 0 ? this.uiCanvasWidth : 1920;
const canvasHeight = this.uiCanvasHeight > 0 ? this.uiCanvasHeight : 1080;
// Save current camera state and switch to screen space mode
// 保存当前相机状态并切换到屏幕空间模式
this.bridge.pushScreenSpaceMode(canvasWidth, canvasHeight);
// Clear batcher for screen space content
// 清空批处理器用于屏幕空间内容
this.batcher.clear();
// Collect screen space UI render items
const screenSpaceItems: Array<{ sortingOrder: number; sprites: SpriteRenderData[] }> = [];
for (const data of uiRenderData) {
const uiSprites = this.convertProviderDataToSprites(data);
if (uiSprites.length > 0) {
screenSpaceItems.push({ sortingOrder: data.sortingOrder, sprites: uiSprites });
}
}
// Sort by sortingOrder
screenSpaceItems.sort((a, b) => a.sortingOrder - b.sortingOrder);
// Submit screen space UI sprites
// Submit screen space sprites
// 提交屏幕空间 sprites
for (const item of screenSpaceItems) {
for (const sprite of item.sprites) {
this.batcher.addSprite(sprite);
@@ -529,10 +557,11 @@ export class EngineRenderSystem extends EntitySystem {
* Sprite
*/
private convertProviderDataToSprites(data: ProviderRenderData): SpriteRenderData[] {
// Get texture ID - load from path if needed
// Get texture ID - load from GUID if needed
// 获取纹理 ID - 如果需要从 GUID 加载
let textureId = data.textureIds[0] || 0;
if (textureId === 0 && data.texturePath) {
textureId = this.bridge.getOrLoadTextureByPath(data.texturePath);
if (textureId === 0 && data.textureGuid) {
textureId = this.bridge.getOrLoadTextureByPath(this.resolveAssetPath(data.textureGuid));
}
const sprites: SpriteRenderData[] = [];
@@ -1209,4 +1238,17 @@ export class EngineRenderSystem extends EntitySystem {
getAssetPathResolver(): AssetPathResolverFn | null {
return this.assetPathResolver;
}
/**
* Resolve asset GUID or path to actual file path.
* GUID
*
* @param guidOrPath - Asset GUID or path | GUID
* @returns Resolved path or original value |
*/
private resolveAssetPath(guidOrPath: string): string {
return this.assetPathResolver
? this.assetPathResolver(guidOrPath)
: guidOrPath;
}
}
+8 -96
View File
@@ -1,125 +1,37 @@
/**
* ecs-engine-bindgen
* ecs-engine-bindgen service tokens
*
*
* Token
*
* Defines service tokens and interfaces for render system and engine bridge.
* Who defines the interface, who exports the Token.
*
* @example
* ```typescript
* // 消费方导入 Token | Consumer imports Token
* import { RenderSystemToken, type IRenderSystem } from '@esengine/ecs-engine-bindgen';
*
* // 获取服务 | Get service
* const renderSystem = context.services.get(RenderSystemToken);
* if (renderSystem) {
* renderSystem.addRenderDataProvider(myProvider);
* }
* ```
*/
import { createServiceToken } from '@esengine/engine-core';
import type { EngineBridge } from './core/EngineBridge';
import { createServiceToken } from '@esengine/ecs-framework';
import { EngineBridgeToken as CoreEngineBridgeToken, type IEngineBridge as CoreIEngineBridge } from '@esengine/engine-core';
import type { IRenderDataProvider as InternalIRenderDataProvider } from './systems/EngineRenderSystem';
// ============================================================================
// 共享渲染接口 | Shared Render Interfaces
// ============================================================================
// 从 engine-core 重新导出 | Re-export from engine-core
export { CoreEngineBridgeToken as EngineBridgeToken };
export type { CoreIEngineBridge as IEngineBridge };
/**
*
* Render data provider interface
*
*
* Implemented by render systems of various modules, used to provide render data to main render system.
*/
export type IRenderDataProvider = InternalIRenderDataProvider;
/**
*
* Render system interface
*
*
* Cross-module shared render system contract.
*/
export interface IRenderSystem {
/**
*
* Register a render data provider
*
* @param provider | Render data provider
*/
addRenderDataProvider(provider: IRenderDataProvider): void;
/**
*
* Remove a render data provider
*
* @param provider | Render data provider
*/
removeRenderDataProvider(provider: IRenderDataProvider): void;
}
/**
*
* Engine bridge interface
*
* WASM
* WASM engine bridge contract.
*/
export interface IEngineBridge {
/**
*
* Load texture
*/
loadTexture(id: number, url: string): Promise<void>;
}
/**
*
* Engine integration interface
*
*
* Engine integration features like texture loading.
*/
export interface IEngineIntegration {
/**
*
* Load texture for component
*/
/** 通过相对路径加载纹理(用户脚本使用)| Load texture by relative path (for user scripts) */
loadTextureForComponent(texturePath: string): Promise<number>;
/** 通过 GUID 加载纹理(内部引用使用)| Load texture by GUID (for internal references) */
loadTextureByGuid(guid: string): Promise<number>;
}
// ============================================================================
// 服务令牌 | Service Tokens
// ============================================================================
/**
*
* Render system service token
*
*
* For getting render system instance.
*/
export const RenderSystemToken = createServiceToken<IRenderSystem>('renderSystem');
/**
*
* Engine bridge service token
*
* WASM
* For getting WASM engine bridge instance.
*/
export const EngineBridgeToken = createServiceToken<IEngineBridge>('engineBridge');
/**
*
* Engine integration service token
*
*
* For getting engine integration instance (texture loading, etc.).
*/
export const EngineIntegrationToken = createServiceToken<IEngineIntegration>('engineIntegration');
+50 -2
View File
@@ -153,6 +153,18 @@ export class GameEngine {
*
*/
resizeViewport(viewport_id: string, width: number, height: number): void;
/**
* Convert screen coordinates to world coordinates.
*
*
* # Arguments |
* * `screen_x` - Screen X coordinate (0 = left edge of canvas)
* * `screen_y` - Screen Y coordinate (0 = top edge of canvas)
*
* # Returns |
* Array of [world_x, world_y] | [world_x, world_y]
*/
screenToWorld(screen_x: number, screen_y: number): Float32Array;
/**
* Set clear color (background color).
*
@@ -175,6 +187,18 @@ export class GameEngine {
*
*/
setShowGizmos(show: boolean): void;
/**
* Convert world coordinates to screen coordinates.
*
*
* # Arguments |
* * `world_x` - World X coordinate
* * `world_y` - World Y coordinate
*
* # Returns |
* Array of [screen_x, screen_y] | [screen_x, screen_y]
*/
worldToScreen(world_x: number, world_y: number): Float32Array;
/**
* Add a circle gizmo outline.
* Gizmo边框
@@ -214,6 +238,17 @@ export class GameEngine {
* vec4 uniform
*/
setMaterialVec4(material_id: number, name: string, x: number, y: number, z: number, w: number): boolean;
/**
* Clear all textures and reset state.
*
*
* This removes all loaded textures from GPU memory and resets
* the ID counter. Use with caution as all texture references
* will become invalid.
* GPU内存中移除所有已加载的纹理并重置ID计数器
* 使
*/
clearAllTextures(): void;
/**
* Render to a specific viewport.
*
@@ -317,6 +352,15 @@ export class GameEngine {
* * `blend_mode` - 0=None, 1=Alpha, 2=Additive, 3=Multiply, 4=Screen, 5=PremultipliedAlpha
*/
setMaterialBlendMode(material_id: number, blend_mode: number): boolean;
/**
* Clear the texture path cache.
*
*
* This should be called when restoring scene snapshots to ensure
* textures are reloaded with correct IDs.
* 使ID重新加载
*/
clearTexturePathCache(): void;
/**
* Create a new game engine instance.
*
@@ -375,6 +419,8 @@ export interface InitOutput {
readonly gameengine_addGizmoLine: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
readonly gameengine_addGizmoRect: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number, i: number, j: number, k: number, l: number, m: number) => void;
readonly gameengine_clear: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_clearAllTextures: (a: number) => void;
readonly gameengine_clearTexturePathCache: (a: number) => void;
readonly gameengine_compileShader: (a: number, b: number, c: number, d: number, e: number) => [number, number, number];
readonly gameengine_compileShaderWithId: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number];
readonly gameengine_createMaterial: (a: number, b: number, c: number, d: number, e: number) => number;
@@ -401,6 +447,7 @@ export interface InitOutput {
readonly gameengine_renderToViewport: (a: number, b: number, c: number) => [number, number];
readonly gameengine_resize: (a: number, b: number, c: number) => void;
readonly gameengine_resizeViewport: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_screenToWorld: (a: number, b: number, c: number) => [number, number];
readonly gameengine_setActiveViewport: (a: number, b: number, c: number) => number;
readonly gameengine_setCamera: (a: number, b: number, c: number, d: number, e: number) => void;
readonly gameengine_setClearColor: (a: number, b: number, c: number, d: number, e: number) => void;
@@ -420,9 +467,10 @@ export interface InitOutput {
readonly gameengine_unregisterViewport: (a: number, b: number, c: number) => void;
readonly gameengine_updateInput: (a: number) => void;
readonly gameengine_width: (a: number) => number;
readonly gameengine_worldToScreen: (a: number, b: number, c: number) => [number, number];
readonly init: () => void;
readonly wasm_bindgen__convert__closures_____invoke__hdbeb4a641c76f980: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__h201da39d82f7cf6e: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__hc746ced83e8f2609: (a: number, b: number) => void;
readonly wasm_bindgen__closure__destroy__hebcd2828f83f27ed: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_exn_store: (a: number) => void;
+2
View File
@@ -35,8 +35,10 @@
"@esengine/particle": "workspace:*",
"@esengine/particle-editor": "workspace:*",
"@esengine/physics-rapier2d": "workspace:*",
"@esengine/platform-web": "workspace:*",
"@esengine/physics-rapier2d-editor": "workspace:*",
"@esengine/runtime-core": "workspace:*",
"@esengine/sdk": "workspace:*",
"@esengine/shader-editor": "workspace:*",
"@esengine/sprite": "workspace:*",
"@esengine/sprite-editor": "workspace:*",
@@ -106,6 +106,53 @@ pub struct FileChangeEvent {
pub paths: Vec<String>,
}
/// Install esbuild globally using npm.
/// 使用 npm 全局安装 esbuild。
///
/// # Returns | 返回
/// Progress messages as the installation proceeds.
/// 安装过程中的进度消息。
#[command]
pub async fn install_esbuild(app: AppHandle) -> Result<(), String> {
println!("[Environment] Starting esbuild installation...");
// Emit progress event | 发送进度事件
let _ = app.emit("esbuild-install:progress", "Checking npm...");
// Check if npm is available | 检查 npm 是否可用
let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" };
let npm_check = Command::new(npm_cmd)
.arg("--version")
.output()
.map_err(|_| "npm not found. Please install Node.js first. | 未找到 npm,请先安装 Node.js。".to_string())?;
if !npm_check.status.success() {
return Err("npm not working properly. | npm 无法正常工作。".to_string());
}
let _ = app.emit("esbuild-install:progress", "Installing esbuild globally...");
println!("[Environment] Running: npm install -g esbuild");
// Install esbuild globally | 全局安装 esbuild
let output = Command::new(npm_cmd)
.args(&["install", "-g", "esbuild"])
.output()
.map_err(|e| format!("Failed to run npm install | npm install 执行失败: {}", e))?;
if output.status.success() {
println!("[Environment] esbuild installed successfully");
let _ = app.emit("esbuild-install:progress", "Installation complete!");
let _ = app.emit("esbuild-install:success", true);
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let error_msg = format!("Failed to install esbuild | 安装 esbuild 失败: {}", stderr);
println!("[Environment] {}", error_msg);
let _ = app.emit("esbuild-install:error", &error_msg);
Err(error_msg)
}
}
/// Check development environment.
/// 检测开发环境。
///
@@ -123,27 +170,12 @@ pub async fn check_environment() -> Result<EnvironmentCheckResult, String> {
/// Check esbuild availability and get its status.
/// 检查 esbuild 可用性并获取其状态。
///
/// Only checks for globally installed esbuild (via npm -g).
/// 只检测通过 npm 全局安装的 esbuild。
fn check_esbuild_status() -> ToolStatus {
// Try bundled esbuild first | 首先尝试打包的 esbuild
if let Some(bundled_path) = find_bundled_esbuild() {
match get_esbuild_version(&bundled_path) {
Ok(version) => {
return ToolStatus {
available: true,
version: Some(version),
path: Some(bundled_path),
source: Some("bundled".to_string()),
error: None,
};
}
Err(e) => {
println!("[Environment] Bundled esbuild found but failed to get version: {}", e);
}
}
}
// Try global esbuild | 尝试全局 esbuild
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
match get_esbuild_version(global_esbuild) {
Ok(version) => {
ToolStatus {
@@ -160,7 +192,7 @@ fn check_esbuild_status() -> ToolStatus {
version: None,
path: None,
source: None,
error: Some("esbuild not found | 未找到 esbuild".to_string()),
error: Some("esbuild not installed globally. Please install: npm install -g esbuild | 未全局安装 esbuild,请安装: npm install -g esbuild".to_string()),
}
}
}
@@ -436,6 +468,204 @@ pub async fn watch_scripts(
Ok(())
}
/// Watch for file changes in asset directories.
/// 监视资产目录中的文件变更。
///
/// Watches multiple directories (assets, scenes, etc.) for all file types.
/// 监视多个目录(assets, scenes 等)中的所有文件类型。
///
/// Emits "user-code:file-changed" events when files change.
/// 当文件发生变更时触发 "user-code:file-changed" 事件。
#[command]
pub async fn watch_assets(
app: AppHandle,
watcher_state: State<'_, ScriptWatcherState>,
project_path: String,
directories: Vec<String>,
) -> Result<(), String> {
// Create a unique key for this watcher set | 为此监视器集创建唯一键
let watcher_key = format!("{}/assets", project_path);
// Check if already watching | 检查是否已在监视
{
let watchers = watcher_state.watchers.lock().await;
if watchers.contains_key(&watcher_key) {
println!("[AssetWatcher] Already watching: {}", watcher_key);
return Ok(());
}
}
// Validate directories exist | 验证目录是否存在
let mut watch_paths = Vec::new();
for dir in &directories {
let watch_path = Path::new(&project_path).join(dir);
if watch_path.exists() {
watch_paths.push((watch_path, dir.clone()));
} else {
println!("[AssetWatcher] Directory does not exist, skipping: {}", watch_path.display());
}
}
if watch_paths.is_empty() {
return Err("No valid directories to watch | 没有有效的目录可监视".to_string());
}
// Create a channel for shutdown signal | 创建关闭信号通道
let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>();
// Clone values for the spawned task | 克隆值以供任务使用
let project_path_clone = project_path.clone();
let app_clone = app.clone();
// Spawn file watcher task | 启动文件监视任务
tokio::spawn(async move {
// Create notify watcher | 创建 notify 监视器
let (tx, rx) = channel();
let mut watcher = match RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
},
Config::default().with_poll_interval(Duration::from_millis(500)),
) {
Ok(w) => w,
Err(e) => {
eprintln!("[AssetWatcher] Failed to create watcher: {}", e);
return;
}
};
// Start watching all directories | 开始监视所有目录
for (path, dir_name) in &watch_paths {
if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
eprintln!("[AssetWatcher] Failed to watch {}: {}", dir_name, e);
} else {
println!("[AssetWatcher] Started watching: {}", path.display());
}
}
// Asset file extensions to monitor | 要监视的资产文件扩展名
let asset_extensions: std::collections::HashSet<&str> = [
// Images
"png", "jpg", "jpeg", "webp", "gif", "bmp", "svg",
// Audio
"mp3", "ogg", "wav", "flac", "m4a",
// Data formats
"json", "xml", "yaml", "yml", "txt",
// Custom asset types
"prefab", "ecs", "btree", "particle", "tmx", "tsx",
// Scripts (also watch these in assets dir)
"ts", "tsx", "js", "jsx",
// Materials and shaders
"mat", "shader", "glsl", "vert", "frag",
// Fonts
"ttf", "otf", "woff", "woff2",
// 3D assets
"gltf", "glb", "obj", "fbx",
].into_iter().collect();
// Debounce state | 防抖状态
let mut pending_events: std::collections::HashMap<String, String> = std::collections::HashMap::new();
let mut last_event_time = std::time::Instant::now();
let debounce_duration = Duration::from_millis(300);
// Event loop | 事件循环
loop {
// Check for shutdown | 检查关闭信号
if shutdown_rx.try_recv().is_ok() {
println!("[AssetWatcher] Stopping watcher for: {}", project_path_clone);
break;
}
// Check for file events with timeout | 带超时检查文件事件
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(event) => {
// Filter for asset files | 过滤资产文件
let valid_paths: Vec<(String, String)> = event
.paths
.iter()
.filter(|p| {
// Skip .meta files | 跳过 .meta 文件
if p.to_string_lossy().ends_with(".meta") {
return false;
}
// Check extension | 检查扩展名
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("");
asset_extensions.contains(ext.to_lowercase().as_str())
})
.map(|p| {
let path_str = p.to_string_lossy().to_string();
let change_type = match event.kind {
EventKind::Create(_) => "create",
EventKind::Modify(_) => "modify",
EventKind::Remove(_) => "remove",
_ => "modify",
};
(path_str, change_type.to_string())
})
.collect();
if !valid_paths.is_empty() {
// Only handle create/modify/remove events | 只处理创建/修改/删除事件
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
for (path, change_type) in valid_paths {
pending_events.insert(path, change_type);
}
last_event_time = std::time::Instant::now();
}
_ => continue,
};
}
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// Check if we should emit pending events (debounce) | 检查是否应该发送待处理事件(防抖)
if !pending_events.is_empty() && last_event_time.elapsed() >= debounce_duration {
// Group by change type | 按变更类型分组
let mut by_type: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
for (path, change_type) in pending_events.drain() {
by_type.entry(change_type).or_default().push(path);
}
// Emit events for each type | 为每种类型发送事件
for (change_type, paths) in by_type {
let file_event = FileChangeEvent {
change_type,
paths,
};
println!("[AssetWatcher] File change detected (debounced): {:?}", file_event);
// Emit event to frontend | 向前端发送事件
if let Err(e) = app_clone.emit("user-code:file-changed", file_event) {
eprintln!("[AssetWatcher] Failed to emit event: {}", e);
}
}
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
println!("[AssetWatcher] Watcher channel disconnected");
break;
}
}
}
});
// Store watcher handle | 存储监视器句柄
{
let mut watchers = watcher_state.watchers.lock().await;
watchers.insert(
watcher_key.clone(),
crate::state::WatcherHandle { shutdown_tx },
);
}
println!("[AssetWatcher] Watch started for directories: {:?}", directories);
Ok(())
}
/// Stop watching for file changes.
/// 停止监视文件变更。
#[command]
@@ -468,32 +698,9 @@ pub async fn stop_watch_scripts(
/// Find esbuild executable path.
/// 查找 esbuild 可执行文件路径。
///
/// Search order | 搜索顺序:
/// 1. Bundled esbuild in app resources | 应用资源中打包的 esbuild
/// 2. Local node_modules | 本地 node_modules
/// 3. Global esbuild | 全局 esbuild
fn find_esbuild(project_root: &str) -> Result<String, String> {
let project_path = Path::new(project_root);
// Try bundled esbuild first (in app resources) | 首先尝试打包的 esbuild(在应用资源中)
if let Some(bundled) = find_bundled_esbuild() {
println!("[Compiler] Using bundled esbuild: {}", bundled);
return Ok(bundled);
}
// Try local node_modules | 尝试本地 node_modules
let local_esbuild = if cfg!(windows) {
project_path.join("node_modules/.bin/esbuild.cmd")
} else {
project_path.join("node_modules/.bin/esbuild")
};
if local_esbuild.exists() {
println!("[Compiler] Using local esbuild: {}", local_esbuild.display());
return Ok(local_esbuild.to_string_lossy().to_string());
}
// Try global esbuild | 尝试全局 esbuild
/// Only uses globally installed esbuild (npm -g).
/// 只使用全局安装的 esbuild (npm -g)。
fn find_esbuild(_project_root: &str) -> Result<String, String> {
let global_esbuild = if cfg!(windows) { "esbuild.cmd" } else { "esbuild" };
// Check if global esbuild exists | 检查全局 esbuild 是否存在
@@ -506,47 +713,10 @@ fn find_esbuild(project_root: &str) -> Result<String, String> {
println!("[Compiler] Using global esbuild");
Ok(global_esbuild.to_string())
},
_ => Err("esbuild not found. Please install esbuild: npm install -g esbuild | 未找到 esbuild,请安装: npm install -g esbuild".to_string())
_ => Err("esbuild not installed globally. Please install: npm install -g esbuild | 未全局安装 esbuild,请安装: npm install -g esbuild".to_string())
}
}
/// Find bundled esbuild in app resources.
/// 在应用资源中查找打包的 esbuild。
fn find_bundled_esbuild() -> Option<String> {
// Get the executable path | 获取可执行文件路径
let exe_path = std::env::current_exe().ok()?;
let exe_dir = exe_path.parent()?;
// In development, resources are in src-tauri directory | 开发模式下,资源在 src-tauri 目录
// In production, resources are next to the executable | 生产模式下,资源在可执行文件旁边
let esbuild_name = if cfg!(windows) { "esbuild.exe" } else { "esbuild" };
// Try production path (resources next to exe) | 尝试生产路径(资源在 exe 旁边)
let prod_path = exe_dir.join("bin").join(esbuild_name);
if prod_path.exists() {
return Some(prod_path.to_string_lossy().to_string());
}
// Try development path (in src-tauri/bin) | 尝试开发路径(在 src-tauri/bin 中)
// This handles running via `cargo tauri dev`
let dev_path = exe_dir
.ancestors()
.find_map(|p| {
let candidate = p.join("src-tauri").join("bin").join(esbuild_name);
if candidate.exists() {
Some(candidate)
} else {
None
}
});
if let Some(path) = dev_path {
return Some(path.to_string_lossy().to_string());
}
None
}
/// Parse esbuild error output.
/// 解析 esbuild 错误输出。
fn parse_esbuild_errors(stderr: &str) -> Vec<CompileError> {
@@ -65,11 +65,13 @@ pub async fn open_file_dialog(
}
/// Save file dialog (generic)
/// 通用保存文件对话框
#[tauri::command]
pub async fn save_file_dialog(
app: AppHandle,
title: Option<String>,
default_name: Option<String>,
default_path: Option<String>,
filters: Option<Vec<FileFilter>>,
) -> Result<Option<String>, String> {
let mut dialog = app.dialog().file();
@@ -80,6 +82,14 @@ pub async fn save_file_dialog(
dialog = dialog.set_title("Save File");
}
// Set default directory | 设置默认目录
if let Some(path) = default_path {
let path_buf = std::path::PathBuf::from(&path);
if path_buf.exists() {
dialog = dialog.set_directory(&path_buf);
}
}
if let Some(name) = default_name {
dialog = dialog.set_file_name(&name);
}
@@ -23,6 +23,31 @@ pub fn read_file_content(path: String) -> Result<String, String> {
.map_err(|e| format!("Failed to read file {}: {}", path, e))
}
/// Append text to log file (auto-creates parent directories)
/// 追加文本到日志文件(自动创建父目录)
#[tauri::command]
pub fn append_to_log(path: String, content: String) -> Result<(), String> {
use std::fs::OpenOptions;
use std::io::Write;
// Ensure parent directory exists
if let Some(parent) = Path::new(&path).parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
}
}
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| format!("Failed to open log file {}: {}", path, e))?;
writeln!(file, "{}", content)
.map_err(|e| format!("Failed to write to log file {}: {}", path, e))
}
/// Write text content to file (auto-creates parent directories)
#[tauri::command]
pub fn write_file_content(path: String, content: String) -> Result<(), String> {
@@ -72,6 +72,38 @@ pub fn open_file_with_default_app(file_path: String) -> Result<(), String> {
Ok(())
}
/// Open folder in system file explorer
/// 在系统文件管理器中打开文件夹
#[tauri::command]
pub fn open_folder(path: String) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
let normalized_path = path.replace('/', "\\");
Command::new("explorer")
.arg(&normalized_path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
#[cfg(target_os = "macos")]
{
Command::new("open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open folder: {}", e))?;
}
Ok(())
}
/// Show file in system file explorer
#[tauri::command]
pub fn show_in_folder(file_path: String) -> Result<(), String> {
@@ -344,7 +376,6 @@ pub fn get_current_dir() -> Result<String, String> {
/// 扫描 dist/engine/ 目录,为所有有 .d.ts 文件的模块添加路径。
#[tauri::command]
pub fn update_project_tsconfig(app: AppHandle, project_path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let project = Path::new(&project_path);
@@ -558,11 +589,18 @@ pub fn start_local_server(root_path: String, port: u16) -> Result<String, String
// Handle different request types
let file_path = if url.starts_with("/asset?path=") {
// Asset proxy - extract and decode path parameter
// 资产代理 - 提取并解码路径参数
let query = &url[7..]; // Skip "/asset?"
if let Some(path_value) = query.strip_prefix("path=") {
urlencoding::decode(path_value)
let decoded = urlencoding::decode(path_value)
.map(|s| s.to_string())
.unwrap_or_default()
.unwrap_or_default();
// Normalize path: remove ./ prefix and join with root
// 规范化路径:移除 ./ 前缀并与根目录连接
let normalized = decoded.trim_start_matches("./");
PathBuf::from(&root).join(normalized)
.to_string_lossy()
.to_string()
} else {
String::new()
}
@@ -54,6 +54,7 @@ fn main() {
commands::read_file_content,
commands::write_file_content,
commands::write_binary_file,
commands::append_to_log,
commands::path_exists,
commands::create_directory,
commands::create_file,
@@ -79,6 +80,7 @@ fn main() {
// System operations
commands::toggle_devtools,
commands::open_file_with_default_app,
commands::open_folder,
commands::show_in_folder,
commands::get_temp_dir,
commands::open_with_editor,
@@ -92,8 +94,10 @@ fn main() {
// User code compilation | 用户代码编译
commands::compile_typescript,
commands::watch_scripts,
commands::watch_assets,
commands::stop_watch_scripts,
commands::check_environment,
commands::install_esbuild,
// Build commands | 构建命令
commands::prepare_build_directory,
commands::copy_directory,
+374 -215
View File
@@ -31,9 +31,11 @@ import {
import type { IDialogExtended } from './services/TauriDialogService';
import { GlobalBlackboardService } from '@esengine/behavior-tree';
import { ServiceRegistry, PluginInstaller, useDialogStore } from './app/managers';
import { useEditorStore } from './stores';
import { StartupPage } from './components/StartupPage';
import { ProjectCreationWizard } from './components/ProjectCreationWizard';
import { SceneHierarchy } from './components/SceneHierarchy';
import { ContentBrowser } from './components/ContentBrowser';
import { Inspector } from './components/inspectors/Inspector';
import { AssetBrowser } from './components/AssetBrowser';
import { Viewport } from './components/Viewport';
@@ -49,7 +51,7 @@ import { ForumPanel } from './components/forum';
import { ToastProvider, useToast } from './components/Toast';
import { TitleBar } from './components/TitleBar';
import { MainToolbar } from './components/MainToolbar';
import { FlexLayoutDockContainer, FlexDockPanel } from './components/FlexLayoutDockContainer';
import { FlexLayoutDockContainer, FlexDockPanel, type FlexLayoutDockContainerHandle } from './components/FlexLayoutDockContainer';
import { StatusBar } from './components/StatusBar';
import { TauriAPI } from './api/tauri';
import { SettingsService } from './services/SettingsService';
@@ -58,6 +60,7 @@ import { EngineService } from './services/EngineService';
import { CompilerConfigDialog } from './components/CompilerConfigDialog';
import { checkForUpdatesOnStartup } from './utils/updater';
import { useLocale } from './hooks/useLocale';
import { useStoreSubscriptions } from './hooks/useStoreSubscriptions';
import { en, zh, es } from './locales';
import type { Locale } from '@esengine/editor-core';
import { Loader2 } from 'lucide-react';
@@ -83,41 +86,82 @@ const logger = createLogger('App');
function App() {
const initRef = useRef(false);
const layoutContainerRef = useRef<FlexLayoutDockContainerHandle>(null);
const [pluginLoader] = useState(() => new PluginLoader());
const { showToast, hideToast } = useToast();
// ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) =====
const [initialized, setInitialized] = useState(false);
const [projectLoaded, setProjectLoaded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadingMessage, setLoadingMessage] = useState('');
const [currentProjectPath, setCurrentProjectPath] = useState<string | null>(null);
const [availableScenes, setAvailableScenes] = useState<string[]>([]);
const [pluginManager, setPluginManager] = useState<PluginManager | null>(null);
const [entityStore, setEntityStore] = useState<EntityStoreService | null>(null);
const [messageHub, setMessageHub] = useState<MessageHub | null>(null);
const [inspectorRegistry, setInspectorRegistry] = useState<InspectorRegistry | null>(null);
const [logService, setLogService] = useState<LogService | null>(null);
const [uiRegistry, setUiRegistry] = useState<UIRegistry | null>(null);
const [settingsRegistry, setSettingsRegistry] = useState<SettingsRegistry | null>(null);
const [sceneManager, setSceneManager] = useState<SceneManagerService | null>(null);
const [notification, setNotification] = useState<INotification | null>(null);
const [dialog, setDialog] = useState<IDialogExtended | null>(null);
const [buildService, setBuildService] = useState<BuildService | null>(null);
const [projectServiceState, setProjectServiceState] = useState<ProjectService | null>(null);
// ===== 从 EditorStore 获取状态 | Get state from EditorStore =====
const {
projectLoaded, setProjectLoaded,
currentProjectPath, setCurrentProjectPath,
availableScenes, setAvailableScenes,
isLoading, setIsLoading,
loadingMessage,
panels, setPanels,
activeDynamicPanels, addDynamicPanel, removeDynamicPanel, clearDynamicPanels,
dynamicPanelTitles, setDynamicPanelTitle,
activePanelId, setActivePanelId,
pluginUpdateTrigger, triggerPluginUpdate,
isRemoteConnected, setIsRemoteConnected,
isContentBrowserDocked, setIsContentBrowserDocked,
isEditorFullscreen, setIsEditorFullscreen,
status, setStatus,
showProjectWizard, setShowProjectWizard,
settingsInitialCategory, setSettingsInitialCategory,
compilerDialog, openCompilerDialog, closeCompilerDialog,
} = useEditorStore();
// ===== 服务实例用 useRef(不触发重渲染)| Service instances use useRef (no re-renders) =====
const pluginManagerRef = useRef<PluginManager | null>(null);
const entityStoreRef = useRef<EntityStoreService | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const inspectorRegistryRef = useRef<InspectorRegistry | null>(null);
const logServiceRef = useRef<LogService | null>(null);
const uiRegistryRef = useRef<UIRegistry | null>(null);
const settingsRegistryRef = useRef<SettingsRegistry | null>(null);
const sceneManagerRef = useRef<SceneManagerService | null>(null);
const notificationRef = useRef<INotification | null>(null);
const dialogRef = useRef<IDialogExtended | null>(null);
const buildServiceRef = useRef<BuildService | null>(null);
const projectServiceRef = useRef<ProjectService | null>(null);
// 兼容层:提供 getter 访问服务 | Compatibility layer: provide getter access to services
const pluginManager = pluginManagerRef.current;
const entityStore = entityStoreRef.current;
const messageHub = messageHubRef.current;
const inspectorRegistry = inspectorRegistryRef.current;
const logService = logServiceRef.current;
const uiRegistry = uiRegistryRef.current;
const settingsRegistry = settingsRegistryRef.current;
const sceneManager = sceneManagerRef.current;
const notification = notificationRef.current;
const dialog = dialogRef.current;
const buildService = buildServiceRef.current;
const projectServiceState = projectServiceRef.current;
const [commandManager] = useState(() => new CommandManager());
const { t, locale, changeLocale } = useLocale();
// 初始化 Store 订阅(集中管理 MessageHub 订阅)
// Initialize store subscriptions (centrally manage MessageHub subscriptions)
useStoreSubscriptions({
messageHub: messageHubRef.current,
entityStore: entityStoreRef.current,
sceneManager: sceneManagerRef.current,
enabled: initialized,
});
// 同步 locale 到 TauriDialogService
useEffect(() => {
if (dialog) {
dialog.setLocale(locale);
if (dialogRef.current) {
dialogRef.current.setLocale(locale);
}
}, [locale, dialog]);
const [status, setStatus] = useState(t('header.status.initializing'));
const [panels, setPanels] = useState<FlexDockPanel[]>([]);
const [pluginUpdateTrigger, setPluginUpdateTrigger] = useState(0);
const [isRemoteConnected, setIsRemoteConnected] = useState(false);
const [showProjectWizard, setShowProjectWizard] = useState(false);
}, [locale]);
// ===== 从 DialogStore 获取对话框状态 | Get dialog state from DialogStore =====
const {
showProfiler, setShowProfiler,
showAdvancedProfiler, setShowAdvancedProfiler,
@@ -129,16 +173,6 @@ function App() {
errorDialog, setErrorDialog,
confirmDialog, setConfirmDialog
} = useDialogStore();
const [settingsInitialCategory, setSettingsInitialCategory] = useState<string | undefined>(undefined);
const [activeDynamicPanels, setActiveDynamicPanels] = useState<string[]>([]);
const [activePanelId, setActivePanelId] = useState<string | undefined>(undefined);
const [dynamicPanelTitles, setDynamicPanelTitles] = useState<Map<string, string>>(new Map());
const [isEditorFullscreen, setIsEditorFullscreen] = useState(false);
const [compilerDialog, setCompilerDialog] = useState<{
isOpen: boolean;
compilerId: string;
currentFileName?: string;
}>({ isOpen: false, compilerId: '' });
useEffect(() => {
// 禁用默认右键菜单
@@ -153,6 +187,35 @@ function App() {
};
}, []);
// Global keyboard shortcuts for undo/redo | 全局撤销/重做快捷键
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Skip if user is typing in an input | 如果用户正在输入则跳过
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Ctrl+Z: Undo | 撤销
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
e.preventDefault();
if (commandManager.canUndo()) {
commandManager.undo();
}
}
// Ctrl+Y or Ctrl+Shift+Z: Redo | 重做
else if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (commandManager.canRedo()) {
commandManager.redo();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [commandManager]);
// 快捷键监听
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
@@ -181,12 +244,23 @@ function App() {
e.preventDefault();
if (sceneManager) {
try {
await sceneManager.saveScene();
const sceneState = sceneManager.getSceneState();
showToast(t('scene.savedSuccess', { name: sceneState.sceneName }), 'success');
// 检查是否在预制体编辑模式 | Check if in prefab edit mode
if (sceneManager.isPrefabEditMode()) {
await sceneManager.savePrefab();
const prefabState = sceneManager.getPrefabEditModeState();
showToast(t('editMode.prefab.savedSuccess', { name: prefabState?.prefabName ?? 'Prefab' }), 'success');
} else {
await sceneManager.saveScene();
const sceneState = sceneManager.getSceneState();
showToast(t('scene.savedSuccess', { name: sceneState.sceneName }), 'success');
}
} catch (error) {
console.error('Failed to save scene:', error);
showToast(t('scene.saveFailed'), 'error');
console.error('Failed to save:', error);
if (sceneManager.isPrefabEditMode()) {
showToast(t('editMode.prefab.saveFailed'), 'error');
} else {
showToast(t('scene.saveFailed'), 'error');
}
}
}
break;
@@ -208,29 +282,31 @@ function App() {
showBuildSettings, showSettings, showAbout, showPluginGenerator,
showPortManager, showAdvancedProfiler, errorDialog, confirmDialog]);
// 插件和通知订阅 | Plugin and notification subscriptions
useEffect(() => {
if (messageHub) {
const unsubscribeEnabled = messageHub.subscribe('plugin:enabled', () => {
setPluginUpdateTrigger((prev) => prev + 1);
});
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribeDisabled = messageHub.subscribe('plugin:disabled', () => {
setPluginUpdateTrigger((prev) => prev + 1);
});
const unsubscribeEnabled = hub.subscribe('plugin:enabled', () => {
triggerPluginUpdate();
});
const unsubscribeNotification = messageHub.subscribe('notification:show', (notification: { message: string; type: 'success' | 'error' | 'warning' | 'info'; timestamp: number }) => {
if (notification && notification.message) {
showToast(notification.message, notification.type);
}
});
const unsubscribeDisabled = hub.subscribe('plugin:disabled', () => {
triggerPluginUpdate();
});
return () => {
unsubscribeEnabled();
unsubscribeDisabled();
unsubscribeNotification();
};
}
}, [messageHub, showToast]);
const unsubscribeNotification = hub.subscribe('notification:show', (notification: { message: string; type: 'success' | 'error' | 'warning' | 'info'; timestamp: number }) => {
if (notification && notification.message) {
showToast(notification.message, notification.type);
}
});
return () => {
unsubscribeEnabled();
unsubscribeDisabled();
unsubscribeNotification();
};
}, [initialized, triggerPluginUpdate, showToast]);
// 监听远程连接状态
// Monitor remote connection status
@@ -307,18 +383,21 @@ function App() {
}
});
// 设置服务引用(不触发重渲染)| Set service refs (no re-renders)
pluginManagerRef.current = services.pluginManager;
entityStoreRef.current = services.entityStore;
messageHubRef.current = services.messageHub;
inspectorRegistryRef.current = services.inspectorRegistry;
logServiceRef.current = services.logService;
uiRegistryRef.current = services.uiRegistry;
settingsRegistryRef.current = services.settingsRegistry;
sceneManagerRef.current = services.sceneManager;
notificationRef.current = services.notification;
dialogRef.current = services.dialog as IDialogExtended;
buildServiceRef.current = services.buildService;
// 设置初始化完成(触发一次重渲染)| Set initialized (triggers one re-render)
setInitialized(true);
setPluginManager(services.pluginManager);
setEntityStore(services.entityStore);
setMessageHub(services.messageHub);
setInspectorRegistry(services.inspectorRegistry);
setLogService(services.logService);
setUiRegistry(services.uiRegistry);
setSettingsRegistry(services.settingsRegistry);
setSceneManager(services.sceneManager);
setNotification(services.notification);
setDialog(services.dialog as IDialogExtended);
setBuildService(services.buildService);
setStatus(t('header.status.ready'));
// Check for updates on startup (after 3 seconds)
@@ -332,66 +411,81 @@ function App() {
initializeEditor();
}, []);
// 初始化后订阅消息 | Subscribe to messages after initialization
useEffect(() => {
if (!messageHub) return;
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = messageHub.subscribe('dynamic-panel:open', (data: any) => {
const unsubscribe = hub.subscribe('dynamic-panel:open', (data: any) => {
const { panelId, title } = data;
logger.info('Opening dynamic panel:', panelId, 'with title:', title);
setActiveDynamicPanels((prev) => {
const newPanels = prev.includes(panelId) ? prev : [...prev, panelId];
return newPanels;
});
addDynamicPanel(panelId, title);
setActivePanelId(panelId);
// 更新动态面板标题
if (title) {
setDynamicPanelTitles((prev) => {
const newTitles = new Map(prev);
newTitles.set(panelId, title);
return newTitles;
});
}
});
return () => unsubscribe?.();
}, [messageHub]);
}, [initialized, addDynamicPanel, setActivePanelId]);
useEffect(() => {
if (!messageHub) return;
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = messageHub.subscribe('editor:fullscreen', (data: any) => {
const unsubscribe = hub.subscribe('editor:fullscreen', (data: any) => {
const { fullscreen } = data;
logger.info('Editor fullscreen state changed:', fullscreen);
setIsEditorFullscreen(fullscreen);
});
return () => unsubscribe?.();
}, [messageHub]);
}, [initialized, setIsEditorFullscreen]);
useEffect(() => {
if (!messageHub) return;
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribe = messageHub.subscribe('compiler:open-dialog', (data: {
const unsubscribe = hub.subscribe('compiler:open-dialog', (data: {
compilerId: string;
currentFileName?: string;
projectPath?: string;
}) => {
logger.info('Opening compiler dialog:', data.compilerId);
setCompilerDialog({
isOpen: true,
compilerId: data.compilerId,
currentFileName: data.currentFileName
});
openCompilerDialog(data.compilerId, data.currentFileName);
});
return () => unsubscribe?.();
}, [messageHub]);
}, [initialized, openCompilerDialog]);
// 注册引擎快照请求处理器(用于预制体编辑模式)
// Register engine snapshot request handlers (for prefab edit mode)
useEffect(() => {
if (!initialized || !messageHubRef.current) return;
const hub = messageHubRef.current;
const unsubscribeSave = hub.onRequest<void, boolean>(
'engine:saveSceneSnapshot',
async () => {
const engineService = EngineService.getInstance();
return engineService.saveSceneSnapshot();
}
);
const unsubscribeRestore = hub.onRequest<void, boolean>(
'engine:restoreSceneSnapshot',
async () => {
const engineService = EngineService.getInstance();
return await engineService.restoreSceneSnapshot();
}
);
return () => {
unsubscribeSave?.();
unsubscribeRestore?.();
};
}, [initialized]);
const handleOpenRecentProject = async (projectPath: string) => {
try {
setIsLoading(true);
setLoadingMessage(t('loading.step1'));
setIsLoading(true, t('loading.step1'));
const projectService = Core.services.resolve(ProjectService);
@@ -401,7 +495,7 @@ function App() {
return;
}
setProjectServiceState(projectService);
projectServiceRef.current = projectService;
await projectService.openProject(projectPath);
// 注意:插件配置会在引擎初始化后加载和激活
@@ -438,7 +532,7 @@ function App() {
setProjectLoaded(true);
// 等待引擎初始化完成(Viewport 渲染后会触发引擎初始化)
setLoadingMessage(t('loading.step2'));
setIsLoading(true, t('loading.step2'));
const engineService = EngineService.getInstance();
// 等待引擎初始化(最多等待 30 秒,因为需要等待 Viewport 渲染)
@@ -449,12 +543,12 @@ function App() {
// 加载项目插件配置并激活插件(在引擎初始化后、模块系统初始化前)
// Load project plugin config and activate plugins (after engine init, before module system init)
if (pluginManager) {
if (pluginManagerRef.current) {
const pluginSettings = projectService.getPluginSettings();
console.log('[App] Plugin settings from project:', pluginSettings);
if (pluginSettings && pluginSettings.enabledPlugins.length > 0) {
console.log('[App] Loading plugin config:', pluginSettings.enabledPlugins);
await pluginManager.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
await pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins });
} else {
console.log('[App] No plugin settings found in project config');
}
@@ -470,16 +564,16 @@ function App() {
setStatus(t('header.status.projectOpened'));
setLoadingMessage(t('loading.step3'));
setIsLoading(true, t('loading.step3'));
const sceneManagerService = Core.services.resolve(SceneManagerService);
if (sceneManagerService) {
await sceneManagerService.newScene();
}
if (pluginManager) {
setLoadingMessage(t('loading.loadingPlugins'));
await pluginLoader.loadProjectPlugins(projectPath, pluginManager);
if (pluginManagerRef.current) {
setIsLoading(true, t('loading.loadingPlugins'));
await pluginLoader.loadProjectPlugins(projectPath, pluginManagerRef.current);
}
setIsLoading(false);
@@ -517,8 +611,7 @@ function App() {
const fullProjectPath = `${projectPath}${sep}${projectName}`;
try {
setIsLoading(true);
setLoadingMessage(t('project.creating'));
setIsLoading(true, t('project.creating'));
const projectService = Core.services.resolve(ProjectService);
if (!projectService) {
@@ -533,7 +626,7 @@ function App() {
await projectService.createProject(fullProjectPath);
setLoadingMessage(t('project.createdOpening'));
setIsLoading(true, t('project.createdOpening'));
await handleOpenRecentProject(fullProjectPath);
} catch (error) {
@@ -550,8 +643,7 @@ function App() {
cancelText: t('common.cancel'),
onConfirm: () => {
setConfirmDialog(null);
setIsLoading(true);
setLoadingMessage(t('project.opening'));
setIsLoading(true, t('project.opening'));
handleOpenRecentProject(fullProjectPath).catch((err) => {
console.error('Failed to open project:', err);
setIsLoading(false);
@@ -701,13 +793,13 @@ function App() {
const handleLocaleChange = (newLocale: Locale) => {
changeLocale(newLocale);
// 通知所有已加载的插件更新语言
if (pluginManager) {
pluginManager.setLocale(newLocale);
// 通知所有已加载的插件更新语言 | Notify all loaded plugins to update locale
if (pluginManagerRef.current) {
pluginManagerRef.current.setLocale(newLocale);
// 通过 MessageHub 通知需要重新获取节点模板
if (messageHub) {
messageHub.publish('locale:changed', { locale: newLocale });
// 通过 MessageHub 通知需要重新获取节点模板 | Notify via MessageHub to refetch node templates
if (messageHubRef.current) {
messageHubRef.current.publish('locale:changed', { locale: newLocale });
}
}
};
@@ -729,30 +821,30 @@ function App() {
};
const handleReloadPlugins = async () => {
if (currentProjectPath && pluginManager) {
if (currentProjectPath && pluginManagerRef.current) {
try {
// 1. 关闭所有动态面板
setActiveDynamicPanels([]);
// 1. 关闭所有动态面板 | Close all dynamic panels
clearDynamicPanels();
// 2. 清空当前面板列表(强制卸载插件面板组件)
// 2. 清空当前面板列表(强制卸载插件面板组件)| Clear panel list (force unmount plugin panels)
setPanels((prev) => prev.filter((p) =>
['scene-hierarchy', 'inspector', 'console', 'asset-browser'].includes(p.id)
));
// 3. 等待React完成卸载
// 3. 等待React完成卸载 | Wait for React to unmount
await new Promise((resolve) => setTimeout(resolve, 200));
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall
await pluginLoader.unloadProjectPlugins(pluginManager);
// 4. 卸载所有项目插件(清理UIRegistry、调用uninstall| Unload all project plugins
await pluginLoader.unloadProjectPlugins(pluginManagerRef.current);
// 5. 等待卸载完成
// 5. 等待卸载完成 | Wait for unload
await new Promise((resolve) => setTimeout(resolve, 100));
// 6. 重新加载插件
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManager);
// 6. 重新加载插件 | Reload plugins
await pluginLoader.loadProjectPlugins(currentProjectPath, pluginManagerRef.current);
// 7. 触发面板重新渲染
setPluginUpdateTrigger((prev) => prev + 1);
// 7. 触发面板重新渲染 | Trigger panel re-render
triggerPluginUpdate();
showToast(t('plugin.reloadedSuccess'), 'success');
} catch (error) {
@@ -762,93 +854,152 @@ function App() {
}
};
useEffect(() => {
if (projectLoaded && entityStore && messageHub && logService && uiRegistry && pluginManager) {
const corePanels: FlexDockPanel[] = [
{
id: 'scene-hierarchy',
title: t('panel.sceneHierarchy'),
content: <SceneHierarchy entityStore={entityStore} messageHub={messageHub} commandManager={commandManager} />,
closable: false
},
{
id: 'viewport',
title: t('panel.viewport'),
content: <Viewport locale={locale} messageHub={messageHub} />,
closable: false
},
{
id: 'inspector',
title: t('panel.inspector'),
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false
},
{
id: 'forum',
title: t('panel.forum'),
content: <ForumPanel />,
closable: true
}
];
// ===== 面板构建(拆分依赖减少重建)| Panel building (split deps to reduce rebuilds) =====
// 使用 ref 存储面板构建函数,避免频繁重建
// Use ref to store panel builder function to avoid frequent rebuilds
const buildPanelsRef = useRef<() => void>(() => {});
// 获取启用的插件面板
const pluginPanels: FlexDockPanel[] = uiRegistry.getAllPanels()
.filter((panelDesc) => {
if (!panelDesc.component) {
return false;
}
if (panelDesc.isDynamic) {
return false;
}
return true;
})
.map((panelDesc) => {
const Component = panelDesc.component;
// 使用 titleKey 翻译,回退到 title
// Use titleKey for translation, fallback to title
const title = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
return {
id: panelDesc.id,
title,
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
closable: panelDesc.closable ?? true
};
});
// 更新面板构建函数(不触发重渲染)| Update panel builder (no re-render)
buildPanelsRef.current = () => {
if (!projectLoaded || !initialized) return;
// 添加激活的动态面板
const dynamicPanels: FlexDockPanel[] = activeDynamicPanels
.filter((panelId) => {
const panelDesc = uiRegistry.getPanel(panelId);
return panelDesc && (panelDesc.component || panelDesc.render);
})
.map((panelId) => {
const panelDesc = uiRegistry.getPanel(panelId)!;
// 优先使用动态标题,否则使用默认标题
// Prefer dynamic title, fallback to default title
const customTitle = dynamicPanelTitles.get(panelId);
// 使用 titleKey 翻译,回退到 title
const defaultTitle = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
const hub = messageHubRef.current;
const store = entityStoreRef.current;
const registry = uiRegistryRef.current;
const inspReg = inspectorRegistryRef.current;
// 支持 component 或 render 两种方式
let content: React.ReactNode;
if (panelDesc.component) {
const Component = panelDesc.component;
content = <Component projectPath={currentProjectPath} locale={locale} />;
} else if (panelDesc.render) {
content = panelDesc.render();
}
if (!hub || !store || !registry) return;
return {
id: panelDesc.id,
title: customTitle || defaultTitle,
content,
closable: panelDesc.closable ?? true
};
});
const corePanels: FlexDockPanel[] = [
{
id: 'scene-hierarchy',
title: t('panel.sceneHierarchy'),
content: <SceneHierarchy entityStore={store} messageHub={hub} commandManager={commandManager} />,
closable: false,
layout: { position: 'right-top' }
},
{
id: 'viewport',
title: t('panel.viewport'),
content: <Viewport locale={locale} messageHub={hub} commandManager={commandManager} />,
closable: false,
layout: { position: 'center' }
},
{
id: 'inspector',
title: t('panel.inspector'),
content: <Inspector entityStore={store} messageHub={hub} inspectorRegistry={inspReg!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false,
layout: { position: 'right-bottom' }
},
{
id: 'forum',
title: t('panel.forum'),
content: <ForumPanel />,
closable: true,
layout: { position: 'center' }
}
];
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
// 如果内容管理器已停靠,添加到面板 | If content browser is docked, add to panels
if (isContentBrowserDocked) {
corePanels.push({
id: 'content-browser',
title: t('panel.contentBrowser'),
content: (
<ContentBrowser
projectPath={currentProjectPath}
locale={locale}
onOpenScene={handleOpenSceneByPath}
isDrawer={false}
onDockInLayout={() => setIsContentBrowserDocked(false)}
/>
),
closable: true,
layout: { position: 'bottom', weight: 20, requiresSeparateTabset: true }
});
}
}, [projectLoaded, entityStore, messageHub, logService, uiRegistry, pluginManager, locale, currentProjectPath, t, pluginUpdateTrigger, handleOpenSceneByPath, activeDynamicPanels, dynamicPanelTitles]);
// 获取启用的插件面板 | Get enabled plugin panels
const pluginPanels: FlexDockPanel[] = registry.getAllPanels()
.filter((panelDesc) => panelDesc.component && !panelDesc.isDynamic)
.map((panelDesc) => {
const Component = panelDesc.component!;
const title = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
return {
id: panelDesc.id,
title,
content: <Component key={`${panelDesc.id}-${pluginUpdateTrigger}`} projectPath={currentProjectPath} />,
closable: panelDesc.closable ?? true
};
});
// 添加激活的动态面板 | Add active dynamic panels
const dynamicPanels: FlexDockPanel[] = activeDynamicPanels
.filter((panelId) => {
const panelDesc = registry.getPanel(panelId);
return panelDesc && (panelDesc.component || panelDesc.render);
})
.map((panelId) => {
const panelDesc = registry.getPanel(panelId)!;
const customTitle = dynamicPanelTitles.get(panelId);
const defaultTitle = panelDesc.titleKey ? t(panelDesc.titleKey) : panelDesc.title;
let content: React.ReactNode;
if (panelDesc.component) {
const Component = panelDesc.component;
content = <Component projectPath={currentProjectPath} locale={locale} />;
} else if (panelDesc.render) {
content = panelDesc.render();
}
return {
id: panelDesc.id,
title: customTitle || defaultTitle,
content,
closable: panelDesc.closable ?? true
};
});
setPanels([...corePanels, ...pluginPanels, ...dynamicPanels]);
};
// Effect 1: 项目加载后首次构建面板 | Build panels after project loads
useEffect(() => {
if (projectLoaded && initialized) {
buildPanelsRef.current();
}
}, [projectLoaded, initialized]);
// Effect 2: 插件更新时重建 | Rebuild on plugin update
useEffect(() => {
if (projectLoaded && initialized && pluginUpdateTrigger > 0) {
buildPanelsRef.current();
}
}, [projectLoaded, initialized, pluginUpdateTrigger]);
// Effect 3: 动态面板变化时重建 | Rebuild on dynamic panel change
useEffect(() => {
if (projectLoaded && initialized) {
buildPanelsRef.current();
}
}, [projectLoaded, initialized, activeDynamicPanels, isContentBrowserDocked]);
// Effect 4: 语言变化时更新面板标题(不重建组件)| Update panel titles on locale change (don't rebuild components)
useEffect(() => {
if (projectLoaded && initialized) {
// 只更新标题,不重建组件 | Only update titles, don't rebuild components
setPanels((prev) => prev.map(panel => ({
...panel,
title: panel.id === 'scene-hierarchy' ? t('panel.sceneHierarchy') :
panel.id === 'viewport' ? t('panel.viewport') :
panel.id === 'inspector' ? t('panel.inspector') :
panel.id === 'forum' ? t('panel.forum') :
panel.id === 'content-browser' ? t('panel.contentBrowser') :
panel.title
})));
}
}, [locale, t, projectLoaded, initialized, setPanels]);
if (!initialized) {
@@ -985,7 +1136,7 @@ function App() {
compilerId={compilerDialog.compilerId}
projectPath={currentProjectPath}
currentFileName={compilerDialog.currentFileName}
onClose={() => setCompilerDialog({ isOpen: false, compilerId: '' })}
onClose={closeCompilerDialog}
onCompileComplete={(result) => {
if (result.success) {
showToast(result.message, 'success');
@@ -997,12 +1148,18 @@ function App() {
<div className="editor-content">
<FlexLayoutDockContainer
ref={layoutContainerRef}
panels={panels}
activePanelId={activePanelId}
messageHub={messageHub}
messageHub={messageHubRef.current}
onPanelClose={(panelId) => {
logger.info('Panel closed:', panelId);
setActiveDynamicPanels((prev) => prev.filter((id) => id !== panelId));
// 如果关闭的是内容管理器,重置停靠状态
// If closing content browser, reset dock state
if (panelId === 'content-browser') {
setIsContentBrowserDocked(false);
}
removeDynamicPanel(panelId);
}}
/>
</div>
@@ -1015,6 +1172,8 @@ function App() {
locale={locale}
projectPath={currentProjectPath}
onOpenScene={handleOpenSceneByPath}
onDockContentBrowser={() => setIsContentBrowserDocked(true)}
onResetLayout={() => layoutContainerRef.current?.resetLayout()}
/>
@@ -11,8 +11,8 @@ export class TauriFileAPI implements IFileAPI {
return await TauriAPI.openSceneDialog();
}
public async saveSceneDialog(defaultName?: string): Promise<string | null> {
return await TauriAPI.saveSceneDialog(defaultName);
public async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
return await TauriAPI.saveSceneDialog(defaultName, scenesDir);
}
public async readFileContent(path: string): Promise<string> {
+26 -7
View File
@@ -31,11 +31,13 @@ export class TauriAPI {
static async saveFileDialog(
title?: string,
defaultName?: string,
filters?: FileFilter[]
filters?: FileFilter[],
defaultPath?: string
): Promise<string | null> {
return await invoke<string | null>('save_file_dialog', {
title,
defaultName,
defaultPath,
filters
});
}
@@ -101,15 +103,19 @@ export class TauriAPI {
}
/**
*
* @param defaultName
* @returns null
*/
static async saveSceneDialog(defaultName?: string): Promise<string | null> {
*
* Open save scene dialog
*
* @param defaultName | Default file name (optional)
* @param scenesDir | Scenes directory path (optional)
* @returns null | Selected file path or null
*/
static async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise<string | null> {
return await this.saveFileDialog(
'Save ECS Scene',
defaultName,
[{ name: 'ECS Scene Files', extensions: ['ecs'] }]
[{ name: 'ECS Scene Files', extensions: ['ecs'] }],
scenesDir
);
}
@@ -370,6 +376,19 @@ export class TauriAPI {
static async checkEnvironment(): Promise<EnvironmentCheckResult> {
return await invoke<EnvironmentCheckResult>('check_environment');
}
/**
* esbuild
* Install esbuild globally via npm
*
* This command installs esbuild globally using `npm install -g esbuild`.
* 使 `npm install -g esbuild` esbuild
*
* @returns Promise that resolves when installation completes
*/
static async installEsbuild(): Promise<void> {
return await invoke<void>('install_esbuild');
}
}
/**
@@ -28,6 +28,9 @@ import { MaterialPlugin } from '@esengine/material-editor';
import { SpritePlugin } from '@esengine/sprite-editor';
import { ShaderEditorPlugin } from '@esengine/shader-editor';
// 纯运行时插件 | Runtime-only plugins
import { CameraPlugin } from '@esengine/camera';
export class PluginInstaller {
/**
*
@@ -57,6 +60,7 @@ export class PluginInstaller {
// 统一模块插件(runtime + editor
const modulePlugins = [
{ name: 'CameraPlugin', plugin: CameraPlugin },
{ name: 'SpritePlugin', plugin: SpritePlugin },
{ name: 'TilemapPlugin', plugin: TilemapPlugin },
{ name: 'UIPlugin', plugin: UIPlugin },
@@ -1,4 +1,5 @@
import { Core, ComponentRegistry as CoreComponentRegistry } from '@esengine/ecs-framework';
import { Core, ComponentRegistry as CoreComponentRegistry, PrefabSerializer, ComponentRegistry as ECSComponentRegistry } from '@esengine/ecs-framework';
import type { ComponentType } from '@esengine/ecs-framework';
import { invoke } from '@tauri-apps/api/core';
import {
UIRegistry,
@@ -175,6 +176,17 @@ export class ServiceRegistry {
Core.services.registerInstance(SceneManagerService, sceneManager);
Core.services.registerInstance(FileActionRegistry, fileActionRegistry);
Core.services.registerInstance(IFileActionRegistry, fileActionRegistry); // Symbol 注册用于跨包插件访问
// 注册预制体文件处理器 | Register prefab file handler
fileActionRegistry.registerActionHandler({
extensions: ['prefab'],
onDoubleClick: (filePath: string) => {
// 发布事件,由编辑器面板处理预制体选择/预览
// Publish event for editor panels to handle prefab selection/preview
messageHub.publish('prefab:selected', { path: filePath });
}
});
Core.services.registerInstance(EntityCreationRegistry, entityCreationRegistry);
Core.services.registerInstance(ComponentActionRegistry, componentActionRegistry);
Core.services.registerInstance(ComponentInspectorRegistry, componentInspectorRegistry);
@@ -165,6 +165,33 @@ export class CommandManager {
const batchCommand = new BatchCommand(commands);
this.execute(batchCommand);
}
/**
*
* Push command to undo stack without executing
*
*
* Used for operations that have already been performed (like drag transforms),
* only need to record to history
*/
pushWithoutExecute(command: ICommand): void {
if (this.config.autoMerge && this.undoStack.length > 0) {
const lastCommand = this.undoStack[this.undoStack.length - 1];
if (lastCommand && lastCommand.canMergeWith(command)) {
const mergedCommand = lastCommand.mergeWith(command);
this.undoStack[this.undoStack.length - 1] = mergedCommand;
this.redoStack = [];
return;
}
}
this.undoStack.push(command);
this.redoStack = [];
if (this.undoStack.length > this.config.maxHistorySize) {
this.undoStack.shift();
}
}
}
/**
@@ -1,12 +1,18 @@
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { Entity, Component, getComponentDependencies, getComponentTypeName } from '@esengine/ecs-framework';
import { MessageHub, ComponentRegistry } from '@esengine/editor-core';
import { Core } from '@esengine/ecs-framework';
import { BaseCommand } from '../BaseCommand';
/**
*
*
* @ECSComponent requires
* Automatically adds missing dependency components (declared via @ECSComponent requires option)
*/
export class AddComponentCommand extends BaseCommand {
private component: Component | null = null;
/** 自动添加的依赖组件(用于撤销时一并移除) | Auto-added dependencies (for undo removal) */
private autoAddedDependencies: Component[] = [];
constructor(
private messageHub: MessageHub,
@@ -18,9 +24,12 @@ export class AddComponentCommand extends BaseCommand {
}
execute(): void {
// 先添加缺失的依赖组件 | Add missing dependencies first
this.addMissingDependencies();
this.component = new this.ComponentClass();
// 应用初始数据
// 应用初始数据 | Apply initial data
if (this.initialData) {
for (const [key, value] of Object.entries(this.initialData)) {
(this.component as any)[key] = value;
@@ -35,20 +44,90 @@ export class AddComponentCommand extends BaseCommand {
});
}
/**
*
* Add missing dependency components
*/
private addMissingDependencies(): void {
const dependencies = getComponentDependencies(this.ComponentClass);
if (!dependencies || dependencies.length === 0) {
return;
}
const componentRegistry = Core.services.tryResolve(ComponentRegistry) as ComponentRegistry | null;
if (!componentRegistry) {
return;
}
for (const depName of dependencies) {
// 检查实体是否已有该依赖组件 | Check if entity already has this dependency
const depInfo = componentRegistry.getComponent(depName);
if (!depInfo?.type) {
console.warn(`Dependency component not found in registry: ${depName}`);
continue;
}
const DepClass = depInfo.type;
// 使用名称检查而非类引用,因为打包可能导致同一个类有多个副本
// Use name-based check instead of class reference, as bundling may create multiple copies of the same class
const foundByName = this.entity.components.find(c => c.constructor.name === DepClass.name);
if (foundByName) {
// 组件已存在(通过名称匹配),跳过添加
// Component already exists (matched by name), skip adding
continue;
}
// 自动添加依赖组件 | Auto-add dependency component
const depComponent = new DepClass();
this.entity.addComponent(depComponent);
this.autoAddedDependencies.push(depComponent);
this.messageHub.publish('component:added', {
entity: this.entity,
component: depComponent,
isAutoDependency: true
});
}
}
undo(): void {
if (!this.component) return;
// 先移除主组件 | Remove main component first
this.entity.removeComponent(this.component);
this.messageHub.publish('component:removed', {
entity: this.entity,
componentType: this.ComponentClass.name
componentType: getComponentTypeName(this.ComponentClass)
});
// 移除自动添加的依赖组件(逆序) | Remove auto-added dependencies (reverse order)
for (let i = this.autoAddedDependencies.length - 1; i >= 0; i--) {
const dep = this.autoAddedDependencies[i];
if (dep) {
this.entity.removeComponent(dep);
this.messageHub.publish('component:removed', {
entity: this.entity,
componentType: dep.constructor.name,
isAutoDependency: true
});
}
}
this.component = null;
this.autoAddedDependencies = [];
}
getDescription(): string {
return `添加组件: ${this.ComponentClass.name}`;
const mainName = getComponentTypeName(this.ComponentClass);
if (this.autoAddedDependencies.length > 0) {
const depNames = this.autoAddedDependencies.map(d => d.constructor.name).join(', ');
return `添加组件: ${mainName} (+ 依赖: ${depNames})`;
}
return `添加组件: ${mainName}`;
}
}
@@ -1,3 +1,4 @@
export type { ICommand } from './ICommand';
export { BaseCommand } from './BaseCommand';
export { CommandManager } from './CommandManager';
export { TransformCommand, type TransformState, type TransformOperationType } from './transform/TransformCommand';
@@ -0,0 +1,65 @@
/**
*
* Apply prefab command
*
*
* Applies modifications from a prefab instance to the source prefab file.
*/
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
*
* Apply prefab command
*/
export class ApplyPrefabCommand extends BaseCommand {
private previousModifiedProperties: string[] = [];
private previousOriginalValues: Record<string, unknown> = {};
private success: boolean = false;
constructor(
private prefabService: PrefabService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
}
async execute(): Promise<void> {
// 保存当前状态用于撤销 | Save current state for undo
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
this.previousModifiedProperties = [...comp.modifiedProperties];
this.previousOriginalValues = { ...comp.originalValues };
}
// 执行应用操作 | Execute apply operation
this.success = await this.prefabService.applyToPrefab(this.entity);
if (!this.success) {
throw new Error('Failed to apply changes to prefab');
}
}
undo(): void {
// 恢复修改状态 | Restore modification state
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
comp.modifiedProperties = this.previousModifiedProperties;
comp.originalValues = this.previousOriginalValues;
}
// 发布事件通知 UI 更新 | Publish event to notify UI update
this.messageHub.publish('component:property:changed', {
entityId: this.entity.id
});
}
getDescription(): string {
const comp = this.entity.getComponent(PrefabInstanceComponent);
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
return `应用修改到预制体: ${prefabName}`;
}
}
@@ -0,0 +1,128 @@
/**
*
* Break prefab link command
*
* 使
* Breaks the link between an entity and its source prefab, making it a regular entity.
*/
import { Entity, PrefabInstanceComponent, Core } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
*
* Saved prefab instance component state
*/
interface PrefabInstanceState {
entityId: number;
sourcePrefabGuid: string;
sourcePrefabPath: string;
isRoot: boolean;
rootInstanceEntityId: number | null;
modifiedProperties: string[];
originalValues: Record<string, unknown>;
instantiatedAt: number;
}
/**
*
* Break prefab link command
*/
export class BreakPrefabLinkCommand extends BaseCommand {
private removedStates: PrefabInstanceState[] = [];
constructor(
private prefabService: PrefabService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
}
execute(): void {
// 保存所有将被移除的组件状态 | Save all component states that will be removed
this.removedStates = [];
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (!comp) {
throw new Error('Entity is not a prefab instance');
}
// 保存根实体的状态 | Save root entity state
this.saveComponentState(this.entity);
// 如果是根节点,也保存所有子实体的状态
// If it's root, also save all children's state
if (comp.isRoot) {
const scene = Core.scene;
if (scene) {
scene.entities.forEach((e) => {
if (e.id === this.entity.id) return;
const childComp = e.getComponent(PrefabInstanceComponent);
if (childComp && childComp.rootInstanceEntityId === this.entity.id) {
this.saveComponentState(e);
}
});
}
}
// 执行断开链接操作 | Execute break link operation
this.prefabService.breakPrefabLink(this.entity);
}
undo(): void {
// 恢复所有被移除的组件 | Restore all removed components
const scene = Core.scene;
if (!scene) return;
for (const state of this.removedStates) {
const entity = scene.findEntityById(state.entityId);
if (!entity) continue;
// 创建并恢复组件 | Create and restore component
const comp = new PrefabInstanceComponent(
state.sourcePrefabGuid,
state.sourcePrefabPath,
state.isRoot
);
comp.rootInstanceEntityId = state.rootInstanceEntityId;
comp.modifiedProperties = state.modifiedProperties;
comp.originalValues = state.originalValues;
comp.instantiatedAt = state.instantiatedAt;
entity.addComponent(comp);
}
// 发布事件通知 UI 更新 | Publish event to notify UI update
this.messageHub.publish('prefab:link:restored', {
entityId: this.entity.id
});
}
getDescription(): string {
const state = this.removedStates.find(s => s.entityId === this.entity.id);
const prefabName = state?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
return `断开预制体链接: ${prefabName}`;
}
/**
*
* Save entity's prefab instance component state
*/
private saveComponentState(entity: Entity): void {
const comp = entity.getComponent(PrefabInstanceComponent);
if (!comp) return;
this.removedStates.push({
entityId: entity.id,
sourcePrefabGuid: comp.sourcePrefabGuid,
sourcePrefabPath: comp.sourcePrefabPath,
isRoot: comp.isRoot,
rootInstanceEntityId: comp.rootInstanceEntityId,
modifiedProperties: [...comp.modifiedProperties],
originalValues: { ...comp.originalValues },
instantiatedAt: comp.instantiatedAt
});
}
}
@@ -0,0 +1,150 @@
/**
*
* Create prefab command
*
*
* Creates a prefab asset from the selected entity and saves it to the file system.
*/
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
import type { PrefabData } from '@esengine/ecs-framework';
import type { MessageHub, IFileAPI, ProjectService, AssetRegistryService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
*
* Create prefab command options
*/
export interface CreatePrefabOptions {
/** 预制体名称 | Prefab name */
name: string;
/** 保存路径(不包含文件名) | Save path (without filename) */
savePath?: string;
/** 预制体描述 | Prefab description */
description?: string;
/** 预制体标签 | Prefab tags */
tags?: string[];
/** 是否包含子实体 | Whether to include child entities */
includeChildren?: boolean;
}
/**
*
* Create prefab command
*/
export class CreatePrefabCommand extends BaseCommand {
private savedFilePath: string | null = null;
private savedGuid: string | null = null;
constructor(
private messageHub: MessageHub,
private fileAPI: IFileAPI,
private projectService: ProjectService | undefined,
private assetRegistry: AssetRegistryService | null,
private sourceEntity: Entity,
private options: CreatePrefabOptions
) {
super();
}
async execute(): Promise<void> {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化 | Scene not initialized');
}
// 获取层级系统 | Get hierarchy system
const hierarchySystem = scene.getSystem(HierarchySystem);
// 创建预制体数据 | Create prefab data
const prefabData = PrefabSerializer.createPrefab(
this.sourceEntity,
{
name: this.options.name,
description: this.options.description,
tags: this.options.tags,
includeChildren: this.options.includeChildren ?? true
},
hierarchySystem ?? undefined
);
// 序列化为 JSON | Serialize to JSON
const prefabJson = PrefabSerializer.serialize(prefabData, true);
// 确定保存路径 | Determine save path
let savePath = this.options.savePath;
if (!savePath && this.projectService?.isProjectOpen()) {
// 默认保存到项目的 prefabs 目录 | Default save to project's prefabs directory
const currentProject = this.projectService.getCurrentProject();
if (currentProject) {
const projectRoot = currentProject.path;
const sep = projectRoot.includes('\\') ? '\\' : '/';
savePath = `${projectRoot}${sep}assets${sep}prefabs`;
// 确保目录存在 | Ensure directory exists
await this.fileAPI.createDirectory(savePath);
}
}
// 构建完整文件路径 | Build complete file path
let fullPath: string | null = null;
if (savePath) {
const sep = savePath.includes('\\') ? '\\' : '/';
fullPath = `${savePath}${sep}${this.options.name}.prefab`;
} else {
// 打开保存对话框 | Open save dialog
fullPath = await this.fileAPI.saveSceneDialog(`${this.options.name}.prefab`);
}
if (!fullPath) {
throw new Error('保存被取消 | Save cancelled');
}
// 确保扩展名正确 | Ensure correct extension
if (!fullPath.endsWith('.prefab')) {
fullPath += '.prefab';
}
// 保存文件 | Save file
await this.fileAPI.writeFileContent(fullPath, prefabJson);
this.savedFilePath = fullPath;
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
if (this.assetRegistry) {
const guid = await this.assetRegistry.registerAsset(fullPath);
this.savedGuid = guid;
console.log(`[CreatePrefabCommand] Registered prefab asset with GUID: ${guid}`);
}
// 发布事件 | Publish event
await this.messageHub.publish('prefab:created', {
path: fullPath,
guid: this.savedGuid,
name: this.options.name,
sourceEntityId: this.sourceEntity.id,
sourceEntityName: this.sourceEntity.name
});
}
undo(): void {
// 预制体创建是一个文件系统操作,撤销意味着删除文件
// Prefab creation is a file system operation, undo means deleting the file
// 但为了安全,我们不自动删除文件,只是清除引用
// But for safety, we don't auto-delete the file, just clear the reference
this.savedFilePath = null;
// TODO: 如果需要完整撤销,可以实现文件删除
// TODO: If full undo is needed, implement file deletion
}
getDescription(): string {
return `创建预制体: ${this.options.name}`;
}
/**
*
* Get saved file path
*/
getSavedFilePath(): string | null {
return this.savedFilePath;
}
}
@@ -0,0 +1,143 @@
/**
*
* Instantiate prefab command
*
*
* Creates an entity instance from a prefab asset.
*/
import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
import type { EntityStoreService, MessageHub } from '@esengine/editor-core';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { BaseCommand } from '../BaseCommand';
/**
*
* Instantiate prefab command options
*/
export interface InstantiatePrefabOptions {
/** 父实体 | Parent entity */
parent?: Entity;
/** 实例名称(可选,默认使用预制体名称) | Instance name (optional, defaults to prefab name) */
name?: string;
/** 位置覆盖 | Position override */
position?: { x: number; y: number };
/** 是否追踪为预制体实例 | Whether to track as prefab instance */
trackInstance?: boolean;
}
/**
*
* Instantiate prefab command
*/
export class InstantiatePrefabCommand extends BaseCommand {
private createdEntity: Entity | null = null;
private createdEntityIds: number[] = [];
constructor(
private entityStore: EntityStoreService,
private messageHub: MessageHub,
private prefabData: PrefabData,
private options: InstantiatePrefabOptions = {}
) {
super();
}
execute(): void {
const scene = Core.scene;
if (!scene) {
throw new Error('场景未初始化 | Scene not initialized');
}
// 获取组件注册表 | Get component registry
// ComponentRegistry.getAllComponentNames() returns Map<string, Function>
// We need to cast it to Map<string, ComponentType>
const componentRegistry = ComponentRegistry.getAllComponentNames() as Map<string, ComponentType>;
// 实例化预制体 | Instantiate prefab
this.createdEntity = PrefabSerializer.instantiate(
this.prefabData,
scene,
componentRegistry,
{
parentId: this.options.parent?.id,
name: this.options.name,
position: this.options.position,
trackInstance: this.options.trackInstance ?? true
}
);
// 收集所有创建的实体 ID(用于撤销) | Collect all created entity IDs (for undo)
this.collectEntityIds(this.createdEntity);
// 更新 EntityStore | Update EntityStore
this.entityStore.syncFromScene();
// 选中创建的实体 | Select created entity
this.entityStore.selectEntity(this.createdEntity);
// 发布事件 | Publish event
this.messageHub.publish('entity:added', { entity: this.createdEntity });
this.messageHub.publish('prefab:instantiated', {
entity: this.createdEntity,
prefabName: this.prefabData.metadata.name,
prefabGuid: this.prefabData.metadata.guid
});
}
undo(): void {
if (!this.createdEntity) return;
const scene = Core.scene;
if (!scene) return;
// 移除所有创建的实体 | Remove all created entities
for (const entityId of this.createdEntityIds) {
const entity = scene.findEntityById(entityId);
if (entity) {
scene.entities.remove(entity);
}
}
// 更新 EntityStore | Update EntityStore
this.entityStore.syncFromScene();
// 发布事件 | Publish event
this.messageHub.publish('entity:removed', { entityId: this.createdEntity.id });
this.createdEntity = null;
this.createdEntityIds = [];
}
getDescription(): string {
const name = this.options.name || this.prefabData.metadata.name;
return `实例化预制体: ${name}`;
}
/**
*
* Get created root entity
*/
getCreatedEntity(): Entity | null {
return this.createdEntity;
}
/**
* ID
* Recursively collect entity IDs
*/
private collectEntityIds(entity: Entity): void {
this.createdEntityIds.push(entity.id);
const scene = Core.scene;
if (!scene) return;
const hierarchySystem = scene.getSystem(HierarchySystem);
if (hierarchySystem) {
const children = hierarchySystem.getChildren(entity);
for (const child of children) {
this.collectEntityIds(child);
}
}
}
}
@@ -0,0 +1,155 @@
/**
*
* Revert prefab instance command
*
*
* Reverts a prefab instance to the state of the source prefab.
*/
import { Entity, PrefabInstanceComponent } from '@esengine/ecs-framework';
import type { MessageHub, PrefabService } from '@esengine/editor-core';
import { BaseCommand } from '../BaseCommand';
/**
*
* Component snapshot
*/
interface ComponentSnapshot {
typeName: string;
data: Record<string, unknown>;
}
/**
*
* Revert prefab instance command
*/
export class RevertPrefabCommand extends BaseCommand {
private previousSnapshots: ComponentSnapshot[] = [];
private previousModifiedProperties: string[] = [];
private previousOriginalValues: Record<string, unknown> = {};
private success: boolean = false;
constructor(
private prefabService: PrefabService,
private messageHub: MessageHub,
private entity: Entity
) {
super();
}
async execute(): Promise<void> {
// 保存当前状态用于撤销 | Save current state for undo
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
this.previousModifiedProperties = [...comp.modifiedProperties];
this.previousOriginalValues = { ...comp.originalValues };
// 保存所有修改的属性当前值 | Save current values of all modified properties
this.previousSnapshots = [];
for (const key of comp.modifiedProperties) {
const [componentType, ...pathParts] = key.split('.');
const propertyPath = pathParts.join('.');
for (const compInstance of this.entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
const value = this.getNestedValue(compInstance, propertyPath);
this.previousSnapshots.push({
typeName: key,
data: { value: this.deepClone(value) }
});
break;
}
}
}
}
// 执行还原操作 | Execute revert operation
this.success = await this.prefabService.revertInstance(this.entity);
if (!this.success) {
throw new Error('Failed to revert prefab instance');
}
}
undo(): void {
// 恢复修改的属性值 | Restore modified property values
for (const snapshot of this.previousSnapshots) {
const [componentType, ...pathParts] = snapshot.typeName.split('.');
const propertyPath = pathParts.join('.');
for (const compInstance of this.entity.components) {
const typeName = (compInstance.constructor as any).__componentTypeName || compInstance.constructor.name;
if (typeName === componentType) {
this.setNestedValue(compInstance, propertyPath, snapshot.data.value);
break;
}
}
}
// 恢复修改状态 | Restore modification state
const comp = this.entity.getComponent(PrefabInstanceComponent);
if (comp) {
comp.modifiedProperties = this.previousModifiedProperties;
comp.originalValues = this.previousOriginalValues;
}
// 发布事件通知 UI 更新 | Publish event to notify UI update
this.messageHub.publish('component:property:changed', {
entityId: this.entity.id
});
}
getDescription(): string {
const comp = this.entity.getComponent(PrefabInstanceComponent);
const prefabName = comp?.sourcePrefabPath?.split(/[/\\]/).pop()?.replace('.prefab', '') || 'Prefab';
return `还原预制体实例: ${prefabName}`;
}
/**
*
* Get nested property value
*/
private getNestedValue(obj: any, path: string): unknown {
const parts = path.split('.');
let current = obj;
for (const part of parts) {
if (current === null || current === undefined) return undefined;
current = current[part];
}
return current;
}
/**
*
* Set nested property value
*/
private setNestedValue(obj: any, path: string, value: unknown): void {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const key = parts[i]!;
if (current[key] === null || current[key] === undefined) {
current[key] = {};
}
current = current[key];
}
current[parts[parts.length - 1]!] = value;
}
/**
*
* Deep clone value
*/
private deepClone(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (typeof value === 'object') {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
return value;
}
}
@@ -0,0 +1,14 @@
/**
*
* Prefab commands export
*/
export { CreatePrefabCommand } from './CreatePrefabCommand';
export type { CreatePrefabOptions } from './CreatePrefabCommand';
export { InstantiatePrefabCommand } from './InstantiatePrefabCommand';
export type { InstantiatePrefabOptions } from './InstantiatePrefabCommand';
export { ApplyPrefabCommand } from './ApplyPrefabCommand';
export { RevertPrefabCommand } from './RevertPrefabCommand';
export { BreakPrefabLinkCommand } from './BreakPrefabLinkCommand';
@@ -0,0 +1,193 @@
import { Entity, Component } from '@esengine/ecs-framework';
import { MessageHub } from '@esengine/editor-core';
import { TransformComponent } from '@esengine/engine-core';
import { UITransformComponent } from '@esengine/ui';
import { BaseCommand } from '../BaseCommand';
import { ICommand } from '../ICommand';
/**
* Transform
* Transform state snapshot
*/
export interface TransformState {
// TransformComponent
positionX?: number;
positionY?: number;
positionZ?: number;
rotationX?: number;
rotationY?: number;
rotationZ?: number;
scaleX?: number;
scaleY?: number;
scaleZ?: number;
// UITransformComponent
x?: number;
y?: number;
width?: number;
height?: number;
rotation?: number;
uiScaleX?: number;
uiScaleY?: number;
}
/**
*
* Transform operation type
*/
export type TransformOperationType = 'move' | 'rotate' | 'scale';
/**
*
* Transform command for undo/redo support
*/
export class TransformCommand extends BaseCommand {
private readonly componentType: 'transform' | 'uiTransform';
private readonly timestamp: number;
constructor(
private readonly messageHub: MessageHub,
private readonly entity: Entity,
private readonly component: Component,
private readonly operationType: TransformOperationType,
private readonly oldState: TransformState,
private newState: TransformState
) {
super();
this.componentType = component instanceof TransformComponent ? 'transform' : 'uiTransform';
this.timestamp = Date.now();
}
execute(): void {
this.applyState(this.newState);
this.notifyChange();
}
undo(): void {
this.applyState(this.oldState);
this.notifyChange();
}
getDescription(): string {
const opNames: Record<TransformOperationType, string> = {
move: '移动',
rotate: '旋转',
scale: '缩放'
};
return `${opNames[this.operationType]} ${this.entity.name || 'Entity'}`;
}
/**
*
*
*/
canMergeWith(other: ICommand): boolean {
if (!(other instanceof TransformCommand)) return false;
// 相同实体、相同组件、相同操作类型
if (this.entity !== other.entity) return false;
if (this.component !== other.component) return false;
if (this.operationType !== other.operationType) return false;
// 时间间隔小于 500ms 才能合并(连续拖动)
const timeDiff = other.timestamp - this.timestamp;
return timeDiff < 500;
}
mergeWith(other: ICommand): ICommand {
if (!(other instanceof TransformCommand)) {
throw new Error('无法合并不同类型的命令');
}
// 保留原始 oldState,使用新命令的 newState
return new TransformCommand(
this.messageHub,
this.entity,
this.component,
this.operationType,
this.oldState,
other.newState
);
}
/**
*
* Apply transform state
*/
private applyState(state: TransformState): void {
if (this.componentType === 'transform') {
const transform = this.component as TransformComponent;
if (state.positionX !== undefined) transform.position.x = state.positionX;
if (state.positionY !== undefined) transform.position.y = state.positionY;
if (state.positionZ !== undefined) transform.position.z = state.positionZ;
if (state.rotationX !== undefined) transform.rotation.x = state.rotationX;
if (state.rotationY !== undefined) transform.rotation.y = state.rotationY;
if (state.rotationZ !== undefined) transform.rotation.z = state.rotationZ;
if (state.scaleX !== undefined) transform.scale.x = state.scaleX;
if (state.scaleY !== undefined) transform.scale.y = state.scaleY;
if (state.scaleZ !== undefined) transform.scale.z = state.scaleZ;
} else {
const uiTransform = this.component as UITransformComponent;
if (state.x !== undefined) uiTransform.x = state.x;
if (state.y !== undefined) uiTransform.y = state.y;
if (state.rotation !== undefined) uiTransform.rotation = state.rotation;
if (state.uiScaleX !== undefined) uiTransform.scaleX = state.uiScaleX;
if (state.uiScaleY !== undefined) uiTransform.scaleY = state.uiScaleY;
}
}
/**
*
* Notify property change
*/
private notifyChange(): void {
const propertyName = this.operationType === 'move'
? (this.componentType === 'transform' ? 'position' : 'x')
: this.operationType === 'rotate'
? 'rotation'
: (this.componentType === 'transform' ? 'scale' : 'scaleX');
this.messageHub.publish('component:property:changed', {
entity: this.entity,
component: this.component,
propertyName,
value: this.componentType === 'transform'
? (this.component as TransformComponent)[propertyName as keyof TransformComponent]
: (this.component as UITransformComponent)[propertyName as keyof UITransformComponent]
});
// 通知 Inspector 刷新 | Notify Inspector to refresh
this.messageHub.publish('entity:select', { entityId: this.entity.id });
}
/**
* TransformComponent
* Capture state from TransformComponent
*/
static captureTransformState(transform: TransformComponent): TransformState {
return {
positionX: transform.position.x,
positionY: transform.position.y,
positionZ: transform.position.z,
rotationX: transform.rotation.x,
rotationY: transform.rotation.y,
rotationZ: transform.rotation.z,
scaleX: transform.scale.x,
scaleY: transform.scale.y,
scaleZ: transform.scale.z
};
}
/**
* UITransformComponent
* Capture state from UITransformComponent
*/
static captureUITransformState(uiTransform: UITransformComponent): TransformState {
return {
x: uiTransform.x,
y: uiTransform.y,
rotation: uiTransform.rotation,
uiScaleX: uiTransform.scaleX,
uiScaleY: uiTransform.scaleY
};
}
}
@@ -5,44 +5,33 @@
* Provides build settings interface for managing platform builds,
* scenes, and player settings.
*
*
* 使 Zustand store useEffect
* Uses Zustand store for state management to avoid re-render issues from too many useEffects
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Monitor, Apple, Smartphone, Globe, Server, Gamepad2,
Plus, Minus, ChevronDown, ChevronRight, Settings,
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check
Package, Loader2, CheckCircle, XCircle, AlertTriangle, X, Copy, Check, FolderOpen
} from 'lucide-react';
import type { BuildService, BuildProgress, BuildConfig, WebBuildConfig, WeChatBuildConfig, SceneManagerService, ProjectService, BuildSettingsConfig } from '@esengine/editor-core';
import { BuildPlatform, BuildStatus } from '@esengine/editor-core';
import { invoke } from '@tauri-apps/api/core';
import type { BuildService, SceneManagerService, ProjectService } from '@esengine/editor-core';
import { BuildStatus } from '@esengine/editor-core';
import { useLocale } from '../hooks/useLocale';
import { useShallow } from 'zustand/react/shallow';
import {
useBuildSettingsStore,
type PlatformType,
type BuildProfile,
type BuildSettings,
} from '../stores/BuildSettingsStore';
import '../styles/BuildSettingsPanel.css';
// ==================== Types | 类型定义 ====================
/** Platform type | 平台类型 */
type PlatformType =
| 'windows'
| 'macos'
| 'linux'
| 'android'
| 'ios'
| 'web'
| 'wechat-minigame';
/** Build profile | 构建配置 */
interface BuildProfile {
id: string;
name: string;
platform: PlatformType;
isActive?: boolean;
}
/** Scene entry | 场景条目 */
interface SceneEntry {
path: string;
enabled: boolean;
}
// 类型定义已移至 BuildSettingsStore.ts
// Type definitions moved to BuildSettingsStore.ts
/** Platform configuration | 平台配置 */
interface PlatformConfig {
@@ -52,21 +41,6 @@ interface PlatformConfig {
available: boolean;
}
/** Build settings | 构建设置 */
interface BuildSettings {
scenes: SceneEntry[];
scriptingDefines: string[];
companyName: string;
productName: string;
version: string;
// Platform-specific | 平台特定
developmentBuild: boolean;
sourceMap: boolean;
compressionMethod: 'Default' | 'LZ4' | 'LZ4HC';
/** Web build mode | Web 构建模式 */
buildMode: 'split-bundles' | 'single-bundle' | 'single-file';
}
// ==================== Constants | 常量 ====================
const PLATFORMS: PlatformConfig[] = [
@@ -79,18 +53,6 @@ const PLATFORMS: PlatformConfig[] = [
{ platform: 'wechat-minigame', label: 'WeChat Mini Game', icon: <Gamepad2 size={16} />, available: true },
];
const DEFAULT_SETTINGS: BuildSettings = {
scenes: [],
scriptingDefines: [],
companyName: 'DefaultCompany',
productName: 'MyGame',
version: '0.1.0',
developmentBuild: false,
sourceMap: false,
compressionMethod: 'Default',
buildMode: 'split-bundles',
};
// ==================== Status Key Mapping | 状态键映射 ====================
/** Map BuildStatus to translation key | 将 BuildStatus 映射到翻译键 */
@@ -202,269 +164,81 @@ export function BuildSettingsPanel({
}: BuildSettingsPanelProps) {
const { t } = useLocale();
// State | 状态
const [profiles, setProfiles] = useState<BuildProfile[]>([
{ id: 'web-dev', name: 'Web - Development', platform: 'web', isActive: true },
{ id: 'web-prod', name: 'Web - Production', platform: 'web' },
{ id: 'wechat', name: 'WeChat Mini Game', platform: 'wechat-minigame' },
]);
const [selectedPlatform, setSelectedPlatform] = useState<PlatformType>('web');
const [selectedProfile, setSelectedProfile] = useState<BuildProfile | null>(profiles[0] || null);
const [settings, setSettings] = useState<BuildSettings>(DEFAULT_SETTINGS);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
sceneList: true,
scriptingDefines: true,
platformSettings: true,
playerSettings: true,
});
// 使用 Zustand store 替代本地状态(使用 useShallow 避免不必要的重渲染)
// Use Zustand store instead of local state (use useShallow to avoid unnecessary re-renders)
const {
profiles,
selectedPlatform,
selectedProfile,
settings,
expandedSections,
isBuilding,
buildProgress,
buildResult,
showBuildProgress,
} = useBuildSettingsStore(useShallow(state => ({
profiles: state.profiles,
selectedPlatform: state.selectedPlatform,
selectedProfile: state.selectedProfile,
settings: state.settings,
expandedSections: state.expandedSections,
isBuilding: state.isBuilding,
buildProgress: state.buildProgress,
buildResult: state.buildResult,
showBuildProgress: state.showBuildProgress,
})));
// Build state | 构建状态
const [isBuilding, setIsBuilding] = useState(false);
const [buildProgress, setBuildProgress] = useState<BuildProgress | null>(null);
const [buildResult, setBuildResult] = useState<{
success: boolean;
outputPath: string;
duration: number;
warnings: string[];
error?: string;
} | null>(null);
const [showBuildProgress, setShowBuildProgress] = useState(false);
const buildAbortRef = useRef<AbortController | null>(null);
// 获取 store actions(通过 getState 获取,这些不会触发重渲染)
// Get store actions via getState (these don't trigger re-renders)
const store = useBuildSettingsStore.getState();
const {
setSelectedPlatform: handlePlatformSelect,
setSelectedProfile: handleProfileSelect,
addProfile: handleAddProfile,
updateSettings,
setSceneEnabled,
addDefine,
removeDefine: handleRemoveDefine,
toggleSection,
cancelBuild: handleCancelBuild,
closeBuildProgress: handleCloseBuildProgress,
} = store;
// Handlers | 处理函数
const toggleSection = useCallback((section: string) => {
setExpandedSections(prev => ({
...prev,
[section]: !prev[section]
}));
}, []);
const handlePlatformSelect = useCallback((platform: PlatformType) => {
setSelectedPlatform(platform);
// Find first profile for this platform | 查找此平台的第一个配置
const profile = profiles.find(p => p.platform === platform);
setSelectedProfile(profile || null);
}, [profiles]);
const handleProfileSelect = useCallback((profile: BuildProfile) => {
setSelectedProfile(profile);
setSelectedPlatform(profile.platform);
}, []);
const handleAddProfile = useCallback(() => {
const newProfile: BuildProfile = {
id: `profile-${Date.now()}`,
name: `${selectedPlatform} - New Profile`,
platform: selectedPlatform,
};
setProfiles(prev => [...prev, newProfile]);
setSelectedProfile(newProfile);
}, [selectedPlatform]);
// Map platform type to BuildPlatform enum | 将平台类型映射到 BuildPlatform 枚举
const getPlatformEnum = useCallback((platformType: PlatformType): BuildPlatform => {
const platformMap: Record<PlatformType, BuildPlatform> = {
'web': BuildPlatform.Web,
'wechat-minigame': BuildPlatform.WeChatMiniGame,
'windows': BuildPlatform.Desktop,
'macos': BuildPlatform.Desktop,
'linux': BuildPlatform.Desktop,
'android': BuildPlatform.Android,
'ios': BuildPlatform.iOS
};
return platformMap[platformType];
}, []);
const handleBuild = useCallback(async () => {
if (!selectedProfile || !projectPath) {
return;
// 初始化 store(仅在 mount 时)
// Initialize store (only on mount)
useEffect(() => {
if (projectPath) {
useBuildSettingsStore.getState().initialize({
projectPath,
buildService,
projectService,
availableScenes,
});
}
return () => useBuildSettingsStore.getState().cleanup();
}, [projectPath]); // 只依赖 projectPath,避免频繁重初始化
// Call external handler if provided | 如果提供了外部处理程序则调用
// 当前平台的配置列表(使用 useMemo 避免每次重新过滤)
// Profiles for current platform (use useMemo to avoid re-filtering every time)
const platformProfiles = useMemo(
() => profiles.filter(p => p.platform === selectedPlatform),
[profiles, selectedPlatform]
);
// 构建处理 | Build handler
const handleBuild = useCallback(async () => {
if (!selectedProfile || !projectPath) return;
// Call external handler if provided
if (onBuild) {
onBuild(selectedProfile, settings);
}
// Use BuildService if available | 如果可用则使用 BuildService
if (buildService) {
setIsBuilding(true);
setBuildProgress(null);
setBuildResult(null);
setShowBuildProgress(true);
try {
const platform = getPlatformEnum(selectedProfile.platform);
const baseConfig = {
platform,
outputPath: `${projectPath}/build/${selectedProfile.platform}`,
isRelease: !settings.developmentBuild,
sourceMap: settings.sourceMap,
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path)
};
// Build platform-specific config | 构建平台特定配置
let buildConfig: BuildConfig;
if (platform === BuildPlatform.Web) {
const webConfig: WebBuildConfig = {
...baseConfig,
platform: BuildPlatform.Web,
buildMode: settings.buildMode,
generateHtml: true,
minify: !settings.developmentBuild,
generateAssetCatalog: true,
assetLoadingStrategy: 'on-demand'
};
buildConfig = webConfig;
} else if (platform === BuildPlatform.WeChatMiniGame) {
const wechatConfig: WeChatBuildConfig = {
...baseConfig,
platform: BuildPlatform.WeChatMiniGame,
appId: '',
useSubpackages: false,
mainPackageLimit: 4096,
usePlugins: false
};
buildConfig = wechatConfig;
} else {
buildConfig = baseConfig;
}
// Execute build with progress callback | 执行构建并传入进度回调
const result = await buildService.build(buildConfig, (progress) => {
setBuildProgress(progress);
});
// Set result | 设置结果
setBuildResult({
success: result.success,
outputPath: result.outputPath,
duration: result.duration,
warnings: result.warnings,
error: result.error
});
} catch (error) {
console.error('Build failed:', error);
setBuildResult({
success: false,
outputPath: '',
duration: 0,
warnings: [],
error: error instanceof Error ? error.message : String(error)
});
} finally {
setIsBuilding(false);
}
}
}, [selectedProfile, settings, projectPath, buildService, onBuild, getPlatformEnum]);
// Load saved build settings from project config
// 从项目配置加载已保存的构建设置
useEffect(() => {
if (!projectService) return;
const savedSettings = projectService.getBuildSettings();
if (savedSettings) {
setSettings(prev => ({
...prev,
scriptingDefines: savedSettings.scriptingDefines || [],
companyName: savedSettings.companyName || prev.companyName,
productName: savedSettings.productName || prev.productName,
version: savedSettings.version || prev.version,
developmentBuild: savedSettings.developmentBuild ?? prev.developmentBuild,
sourceMap: savedSettings.sourceMap ?? prev.sourceMap,
compressionMethod: savedSettings.compressionMethod || prev.compressionMethod,
buildMode: savedSettings.buildMode || prev.buildMode
}));
}
}, [projectService]);
// Initialize scenes from availableScenes prop and saved settings
// 从 availableScenes prop 和已保存设置初始化场景列表
useEffect(() => {
if (availableScenes && availableScenes.length > 0) {
const savedSettings = projectService?.getBuildSettings();
const savedScenes = savedSettings?.scenes || [];
setSettings(prev => ({
...prev,
scenes: availableScenes.map(path => ({
path,
enabled: savedScenes.length > 0 ? savedScenes.includes(path) : true
}))
}));
}
}, [availableScenes, projectService]);
// Auto-save build settings when changed
// 设置变化时自动保存
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
if (!projectService) return;
// Debounce save to avoid too many writes
// 防抖保存,避免频繁写入
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => {
const configToSave: BuildSettingsConfig = {
scenes: settings.scenes.filter(s => s.enabled).map(s => s.path),
scriptingDefines: settings.scriptingDefines,
companyName: settings.companyName,
productName: settings.productName,
version: settings.version,
developmentBuild: settings.developmentBuild,
sourceMap: settings.sourceMap,
compressionMethod: settings.compressionMethod,
buildMode: settings.buildMode
};
projectService.updateBuildSettings(configToSave);
}, 500);
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [settings, projectService]);
// Monitor build progress from service | 从服务监控构建进度
useEffect(() => {
if (!buildService || !isBuilding) {
return;
}
const interval = setInterval(() => {
const task = buildService.getCurrentTask();
if (task) {
setBuildProgress(task.progress);
}
}, 100);
return () => clearInterval(interval);
}, [buildService, isBuilding]);
const handleCancelBuild = useCallback(() => {
if (buildService) {
buildService.cancelBuild();
}
}, [buildService]);
const handleCloseBuildProgress = useCallback(() => {
if (!isBuilding) {
setShowBuildProgress(false);
setBuildProgress(null);
setBuildResult(null);
}
}, [isBuilding]);
// Get status message | 获取状态消息
const getStatusMessage = useCallback((status: BuildStatus): string => {
return t(buildStatusKeys[status]) || status;
}, [t]);
// 使用 store 的构建操作 | Use store's build action
await useBuildSettingsStore.getState().startBuild();
}, [selectedProfile, projectPath, onBuild, settings]);
// 添加当前场景 | Add current scene
const handleAddScene = useCallback(() => {
if (!sceneManager) {
console.warn('SceneManagerService not available');
@@ -479,36 +253,29 @@ export function BuildSettingsPanel({
return;
}
// Check if scene is already in the list | 检查场景是否已在列表中
// 检查场景是否已在列表中 | Check if scene is already in the list
const exists = settings.scenes.some(s => s.path === currentScenePath);
if (exists) {
console.log('Scene already in list:', currentScenePath);
return;
}
// Add current scene to the list | 将当前场景添加到列表中
setSettings(prev => ({
...prev,
scenes: [...prev.scenes, { path: currentScenePath, enabled: true }]
}));
// 使用 store 添加场景 | Use store to add scene
useBuildSettingsStore.getState().addScene(currentScenePath);
}, [sceneManager, settings.scenes]);
// 添加脚本定义(带 prompt| Add scripting define (with prompt)
const handleAddDefine = useCallback(() => {
const define = prompt('Enter scripting define:');
if (define) {
setSettings(prev => ({
...prev,
scriptingDefines: [...prev.scriptingDefines, define]
}));
addDefine(define);
}
}, []);
}, [addDefine]);
const handleRemoveDefine = useCallback((index: number) => {
setSettings(prev => ({
...prev,
scriptingDefines: prev.scriptingDefines.filter((_, i) => i !== index)
}));
}, []);
// 获取状态消息 | Get status message
const getStatusMessage = useCallback((status: BuildStatus): string => {
return t(buildStatusKeys[status]) || status;
}, [t]);
// Get platform config | 获取平台配置
const currentPlatformConfig = PLATFORMS.find(p => p.platform === selectedPlatform);
@@ -634,14 +401,7 @@ export function BuildSettingsPanel({
<input
type="checkbox"
checked={scene.enabled}
onChange={e => {
setSettings(prev => ({
...prev,
scenes: prev.scenes.map((s, i) =>
i === index ? { ...s, enabled: e.target.checked } : s
)
}));
}}
onChange={e => setSceneEnabled(index, e.target.checked)}
/>
<span>{scene.path}</span>
</div>
@@ -713,10 +473,7 @@ export function BuildSettingsPanel({
<input
type="checkbox"
checked={settings.developmentBuild}
onChange={e => setSettings(prev => ({
...prev,
developmentBuild: e.target.checked
}))}
onChange={e => updateSettings({ developmentBuild: e.target.checked })}
/>
</div>
<div className="build-settings-form-row">
@@ -724,20 +481,14 @@ export function BuildSettingsPanel({
<input
type="checkbox"
checked={settings.sourceMap}
onChange={e => setSettings(prev => ({
...prev,
sourceMap: e.target.checked
}))}
onChange={e => updateSettings({ sourceMap: e.target.checked })}
/>
</div>
<div className="build-settings-form-row">
<label>{t('buildSettings.compressionMethod')}</label>
<select
value={settings.compressionMethod}
onChange={e => setSettings(prev => ({
...prev,
compressionMethod: e.target.value as any
}))}
onChange={e => updateSettings({ compressionMethod: e.target.value as 'Default' | 'LZ4' | 'LZ4HC' })}
>
<option value="Default">Default</option>
<option value="LZ4">LZ4</option>
@@ -749,10 +500,7 @@ export function BuildSettingsPanel({
<div className="build-settings-toggle-group">
<select
value={settings.buildMode}
onChange={e => setSettings(prev => ({
...prev,
buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file'
}))}
onChange={e => updateSettings({ buildMode: e.target.value as 'split-bundles' | 'single-bundle' | 'single-file' })}
>
<option value="split-bundles">{t('buildSettings.splitBundles')}</option>
<option value="single-bundle">{t('buildSettings.singleBundle')}</option>
@@ -798,10 +546,7 @@ export function BuildSettingsPanel({
<input
type="text"
value={settings.companyName}
onChange={e => setSettings(prev => ({
...prev,
companyName: e.target.value
}))}
onChange={e => updateSettings({ companyName: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
@@ -809,10 +554,7 @@ export function BuildSettingsPanel({
<input
type="text"
value={settings.productName}
onChange={e => setSettings(prev => ({
...prev,
productName: e.target.value
}))}
onChange={e => updateSettings({ productName: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
@@ -820,10 +562,7 @@ export function BuildSettingsPanel({
<input
type="text"
value={settings.version}
onChange={e => setSettings(prev => ({
...prev,
version: e.target.value
}))}
onChange={e => updateSettings({ version: e.target.value })}
/>
</div>
<div className="build-settings-form-row">
@@ -867,11 +606,11 @@ export function BuildSettingsPanel({
{/* Status Icon | 状态图标 */}
<div className="build-progress-status-icon">
{isBuilding ? (
<Loader2 size={48} className="build-progress-spinner" />
<Loader2 size={36} className="build-progress-spinner" />
) : buildResult?.success ? (
<CheckCircle size={48} className="build-progress-success" />
<CheckCircle size={40} className="build-progress-success" />
) : (
<XCircle size={48} className="build-progress-error" />
<XCircle size={40} className="build-progress-error" />
)}
</div>
@@ -950,12 +689,29 @@ export function BuildSettingsPanel({
{t('buildSettings.cancel')}
</button>
) : (
<button
className="build-settings-btn primary"
onClick={handleCloseBuildProgress}
>
{t('buildSettings.close')}
</button>
<>
<button
className="build-settings-btn secondary"
onClick={handleCloseBuildProgress}
>
{t('buildSettings.close')}
</button>
{buildResult?.success && buildResult.outputPath && (
<button
className="build-settings-btn primary"
onClick={() => {
// 使用 Tauri 打开文件夹
// Use Tauri to open folder
invoke('open_folder', { path: buildResult.outputPath }).catch(e => {
console.error('Failed to open folder:', e);
});
}}
>
<FolderOpen size={14} />
{t('buildSettings.openFolder')}
</button>
)}
</>
)}
</div>
</div>
@@ -3,7 +3,7 @@
*
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import * as LucideIcons from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
import {
@@ -38,10 +38,13 @@ import {
RefreshCw,
Settings,
Database,
AlertTriangle
AlertTriangle,
X,
FolderPlus,
Inbox
} from 'lucide-react';
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate } from '@esengine/editor-core';
import { Core, Entity, HierarchySystem, PrefabSerializer } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, AssetRegistryService, MANAGED_ASSET_DIRECTORIES, type FileCreationTemplate, EntityStoreService, SceneManagerService } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { SettingsService } from '../services/SettingsService';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
@@ -126,6 +129,32 @@ function isRootManagedDirectory(folderPath: string, projectPath: string | null):
return false;
}
/**
*
* Highlight search text in a string
*/
function highlightSearchText(text: string, query: string): React.ReactNode {
if (!query.trim()) return text;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const index = lowerText.indexOf(lowerQuery);
if (index === -1) return text;
const before = text.substring(0, index);
const match = text.substring(index, index + query.length);
const after = text.substring(index + query.length);
return (
<>
{before}
<span className="search-highlight">{match}</span>
{after}
</>
);
}
// 获取资产类型显示名称
function getAssetTypeName(asset: AssetItem): string {
if (asset.type === 'folder') return 'Folder';
@@ -179,6 +208,10 @@ export function ContentBrowser({
const [loading, setLoading] = useState(false);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
// 隐藏的文件扩展名(默认隐藏 .meta| Hidden file extensions (hide .meta by default)
const [hiddenExtensions, setHiddenExtensions] = useState<Set<string>>(new Set(['meta']));
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
// Folder tree state
const [folderTree, setFolderTree] = useState<FolderNode | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
@@ -474,11 +507,33 @@ export class ${className} {
setDeleteConfirmDialog(asset);
}
}
// Ctrl+A - 全选 | Select all
if (e.key === 'a' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
// 计算当前过滤后的资产 | Calculate currently filtered assets
const currentFiltered = searchQuery.trim()
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
: assets;
const allPaths = new Set(currentFiltered.map(a => a.path));
setSelectedPaths(allPaths);
const lastItem = currentFiltered[currentFiltered.length - 1];
if (lastItem) {
setLastSelectedPath(lastItem.path);
}
}
// Escape - 取消选择 | Deselect all
if (e.key === 'Escape') {
e.preventDefault();
setSelectedPaths(new Set());
setLastSelectedPath(null);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedPaths, assets, renameDialog, deleteConfirmDialog, createFileDialog]);
}, [selectedPaths, assets, searchQuery, renameDialog, deleteConfirmDialog, createFileDialog]);
const getTemplateLabel = (label: string): string => {
// Map template labels to translation keys
@@ -582,6 +637,21 @@ export class ${className} {
}
}, [currentPath, projectPath, loadAssets, buildFolderTree]);
// 点击外部关闭过滤器下拉菜单 | Close filter dropdown when clicking outside
useEffect(() => {
if (!showFilterDropdown) return;
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest('.cb-filter-wrapper')) {
setShowFilterDropdown(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterDropdown]);
// Initialize on mount
useEffect(() => {
if (projectPath) {
@@ -618,6 +688,44 @@ export class ${className} {
}
}, [expandedFolders, projectPath, buildFolderTree]);
// Subscribe to asset change events to refresh content
// 订阅资产变化事件以刷新内容
useEffect(() => {
if (!messageHub) return;
const handleAssetChange = (data: { type: string; path: string; relativePath: string; guid: string }) => {
// Check if the changed file is in the current directory
// 检查变化的文件是否在当前目录中
if (!currentPath || !data.path) return;
const normalizedPath = data.path.replace(/\\/g, '/');
const normalizedCurrentPath = currentPath.replace(/\\/g, '/');
const parentDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
if (parentDir === normalizedCurrentPath) {
// Refresh current directory
// 刷新当前目录
loadAssets(currentPath);
}
};
const handleAssetsRefresh = () => {
// Refresh current directory when generic refresh is requested
// 当请求通用刷新时刷新当前目录
if (currentPath) {
loadAssets(currentPath);
}
};
const unsubChange = messageHub.subscribe('assets:changed', handleAssetChange);
const unsubRefresh = messageHub.subscribe('assets:refresh', handleAssetsRefresh);
return () => {
unsubChange();
unsubRefresh();
};
}, [messageHub, currentPath, loadAssets]);
// Handle reveal path - navigate to folder and select file
const prevRevealPath = useRef<string | null>(null);
useEffect(() => {
@@ -788,7 +896,13 @@ export class ${className} {
const handleFolderDragOver = useCallback((e: React.DragEvent, folderPath: string) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(folderPath);
// 支持资产拖放和实体拖放 | Support asset drag and entity drag
const hasAsset = e.dataTransfer.types.includes('asset-path');
const hasEntity = e.dataTransfer.types.includes('entity-id');
if (hasAsset || hasEntity) {
e.dataTransfer.dropEffect = hasEntity ? 'copy' : 'move';
setDragOverFolder(folderPath);
}
}, []);
const handleFolderDragLeave = useCallback((e: React.DragEvent) => {
@@ -802,11 +916,75 @@ export class ${className} {
e.stopPropagation();
setDragOverFolder(null);
// 检查是否是资产移动 | Check if it's asset move
const sourcePath = e.dataTransfer.getData('asset-path');
if (sourcePath) {
await handleMoveAsset(sourcePath, targetFolderPath);
return;
}
}, [handleMoveAsset]);
// 检查是否是实体拖放(创建预制体)| Check if it's entity drop (create prefab)
const entityIdStr = e.dataTransfer.getData('entity-id');
if (entityIdStr) {
const entityId = parseInt(entityIdStr, 10);
if (isNaN(entityId)) return;
const scene = Core.scene;
if (!scene) return;
const entity = scene.findEntityById(entityId);
if (!entity) return;
// 获取层级系统 | Get hierarchy system
const hierarchySystem = scene.getSystem(HierarchySystem);
// 创建预制体数据 | Create prefab data
const prefabData = PrefabSerializer.createPrefab(
entity,
{
name: entity.name,
includeChildren: true
},
hierarchySystem ?? undefined
);
// 序列化为 JSON | Serialize to JSON
const prefabJson = PrefabSerializer.serialize(prefabData, true);
// 保存到目标文件夹 | Save to target folder
const sep = targetFolderPath.includes('\\') ? '\\' : '/';
const filePath = `${targetFolderPath}${sep}${entity.name}.prefab`;
try {
await TauriAPI.writeFileContent(filePath, prefabJson);
console.log(`[ContentBrowser] Prefab created: ${filePath}`);
// 注册资产以生成 .meta 文件 | Register asset to generate .meta file
const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null;
let guid: string | null = null;
if (assetRegistry) {
guid = await assetRegistry.registerAsset(filePath);
console.log(`[ContentBrowser] Registered prefab asset with GUID: ${guid}`);
}
// 刷新目录 | Refresh directory
if (currentPath === targetFolderPath) {
await loadAssets(targetFolderPath);
}
// 发布事件 | Publish event
messageHub.publish('prefab:created', {
path: filePath,
guid,
name: entity.name,
sourceEntityId: entity.id,
sourceEntityName: entity.name
});
} catch (error) {
console.error('[ContentBrowser] Failed to create prefab:', error);
}
}
}, [handleMoveAsset, currentPath, loadAssets, messageHub]);
// Handle asset click
const handleAssetClick = useCallback((asset: AssetItem, e: React.MouseEvent) => {
@@ -859,6 +1037,22 @@ export class ${className} {
return;
}
// 预制体文件进入预制体编辑模式
// Open prefab file in prefab edit mode
if (ext === 'prefab') {
try {
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager) {
await sceneManager.enterPrefabEditMode(asset.path);
} else {
console.error('SceneManagerService not available');
}
} catch (error) {
console.error('Failed to open prefab:', error);
}
return;
}
// 脚本文件使用配置的编辑器打开
// Open script files with configured editor
if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') {
@@ -1092,9 +1286,10 @@ export class ${className} {
onClick: async () => {
if (currentPath) {
try {
console.log('[ContentBrowser] showInFolder (empty area) - currentPath:', currentPath);
await TauriAPI.showInFolder(currentPath);
} catch (error) {
console.error('Failed to show in folder:', error);
console.error('Failed to show in folder:', error, 'Path:', currentPath);
}
}
setContextMenu(null);
@@ -1301,8 +1496,17 @@ export class ${className} {
icon: <ExternalLink size={16} />,
onClick: async () => {
try {
console.log('[ContentBrowser] showInFolder path:', asset.path);
await TauriAPI.showInFolder(asset.path);
// Ensure we use absolute path
// 确保使用绝对路径
const absolutePath = asset.path.includes(':') || asset.path.startsWith('\\\\')
? asset.path
: (projectPath ? `${projectPath}/${asset.path}`.replace(/\//g, '\\') : asset.path);
console.log('[ContentBrowser] showInFolder - asset.path:', asset.path);
console.log('[ContentBrowser] showInFolder - projectPath:', projectPath);
console.log('[ContentBrowser] showInFolder - absolutePath:', absolutePath);
await TauriAPI.showInFolder(absolutePath);
} catch (error) {
console.error('Failed to show in folder:', error, 'Path:', asset.path);
}
@@ -1405,9 +1609,10 @@ export class ${className} {
icon: <ExternalLink size={16} />,
onClick: async () => {
try {
console.log('[ContentBrowser] showInFolder (folder tree) - node.path:', node.path);
await TauriAPI.showInFolder(node.path);
} catch (error) {
console.error('Failed to show in explorer:', error);
console.error('Failed to show in explorer:', error, 'Path:', node.path);
}
}
});
@@ -1466,10 +1671,51 @@ export class ${className} {
);
}, [currentPath, expandedFolders, handleFolderSelect, handleFolderTreeContextMenu, toggleFolderExpand, projectPath, t, dragOverFolder, handleFolderDragOver, handleFolderDragLeave, handleFolderDrop]);
// Filter assets by search
const filteredAssets = searchQuery.trim()
? assets.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()))
: assets;
// 收集当前目录所有唯一扩展名 | Collect all unique extensions in current directory
const allExtensions = useMemo(() => {
const exts = new Set<string>();
assets.forEach(a => {
if (a.extension) {
exts.add(a.extension.toLowerCase());
}
});
return Array.from(exts).sort();
}, [assets]);
// 切换扩展名隐藏状态 | Toggle extension hidden state
const toggleExtensionHidden = useCallback((ext: string) => {
setHiddenExtensions(prev => {
const newSet = new Set(prev);
if (newSet.has(ext)) {
newSet.delete(ext);
} else {
newSet.add(ext);
}
return newSet;
});
}, []);
// Filter assets by search and hidden extensions
// 按搜索词和隐藏扩展名过滤资产
const filteredAssets = useMemo(() => {
let result = assets;
// 过滤隐藏的扩展名 | Filter hidden extensions
if (hiddenExtensions.size > 0) {
result = result.filter(a => {
if (a.type === 'folder') return true;
const ext = a.extension?.toLowerCase();
return !ext || !hiddenExtensions.has(ext);
});
}
// 搜索过滤 | Search filter
if (searchQuery.trim()) {
result = result.filter(a => a.name.toLowerCase().includes(searchQuery.toLowerCase()));
}
return result;
}, [assets, hiddenExtensions, searchQuery]);
const breadcrumbs = getBreadcrumbs();
@@ -1601,10 +1847,55 @@ export class ${className} {
{/* Search Bar */}
<div className="cb-search-bar">
<button className="cb-filter-btn">
<SlidersHorizontal size={14} />
<ChevronDown size={10} />
</button>
<div className="cb-filter-wrapper">
<button
className={`cb-filter-btn ${hiddenExtensions.size > 0 ? 'has-filter' : ''}`}
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
title={hiddenExtensions.size > 0 ? `${hiddenExtensions.size} hidden` : 'Filter'}
>
<SlidersHorizontal size={14} />
<ChevronDown size={10} />
{hiddenExtensions.size > 0 && (
<span className="cb-filter-badge">{hiddenExtensions.size}</span>
)}
</button>
{showFilterDropdown && (
<div className="cb-filter-dropdown">
<div className="cb-filter-header">
<span>{t('contentBrowser.hiddenExtensions') || 'Hidden Extensions'}</span>
{hiddenExtensions.size > 0 && (
<button
className="cb-filter-clear"
onClick={() => setHiddenExtensions(new Set())}
>
{t('common.clearAll') || 'Clear All'}
</button>
)}
</div>
<div className="cb-filter-list">
{allExtensions.length === 0 ? (
<div className="cb-filter-empty">
{t('contentBrowser.noExtensions') || 'No file types'}
</div>
) : (
allExtensions.map(ext => (
<label key={ext} className="cb-filter-item">
<input
type="checkbox"
checked={hiddenExtensions.has(ext)}
onChange={() => toggleExtensionHidden(ext)}
/>
<span className="cb-filter-ext">.{ext}</span>
<span className="cb-filter-count">
({assets.filter(a => a.extension?.toLowerCase() === ext).length})
</span>
</label>
))
)}
</div>
</div>
)}
</div>
<div className="cb-search-input-wrapper">
<Search size={14} className="cb-search-icon" />
<input
@@ -1613,7 +1904,23 @@ export class ${className} {
placeholder={`${t('contentBrowser.search')} ${breadcrumbs[breadcrumbs.length - 1]?.name || ''}`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape' && searchQuery) {
e.preventDefault();
e.stopPropagation();
setSearchQuery('');
}
}}
/>
{searchQuery && (
<button
className="cb-search-clear"
onClick={() => setSearchQuery('')}
title={t('common.clear') || 'Clear'}
>
<X size={12} />
</button>
)}
</div>
<div className="cb-view-options">
<button
@@ -1635,11 +1942,52 @@ export class ${className} {
<div
className={`cb-asset-grid ${viewMode}`}
onContextMenu={(e) => handleContextMenu(e)}
onDragOver={(e) => {
// 允许实体拖放到当前目录 | Allow entity drop to current directory
if (e.dataTransfer.types.includes('entity-id') && currentPath) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}}
onDrop={(e) => {
// 在当前目录创建预制体 | Create prefab in current directory
if (currentPath && e.dataTransfer.types.includes('entity-id')) {
handleFolderDrop(e, currentPath);
}
}}
>
{loading ? (
<div className="cb-loading">Loading...</div>
<div className="cb-loading">
<div className="cb-loading-spinner" />
<span>{t('contentBrowser.loading') || 'Loading...'}</span>
</div>
) : filteredAssets.length === 0 ? (
<div className="cb-empty">{t('contentBrowser.empty')}</div>
<div className="cb-empty">
<Inbox size={48} className="cb-empty-icon" />
<span className="cb-empty-title">
{searchQuery.trim()
? t('contentBrowser.noSearchResults')
: t('contentBrowser.empty')}
</span>
<span className="cb-empty-hint">
{searchQuery.trim()
? t('contentBrowser.noSearchResultsHint')
: t('contentBrowser.emptyHint')}
</span>
{!searchQuery.trim() && (
<button
className="cb-empty-action"
onClick={() => setContextMenu({
position: { x: window.innerWidth / 2, y: window.innerHeight / 2 },
asset: null,
isBackground: true
})}
>
<Plus size={12} style={{ marginRight: 4 }} />
{t('contentBrowser.createNew') || 'Create New'}
</button>
)}
</div>
) : (
filteredAssets.map(asset => {
const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path;
@@ -1692,7 +2040,7 @@ export class ${className} {
</div>
<div className="cb-asset-info">
<div className="cb-asset-name" title={asset.name}>
{asset.name}
{highlightSearchText(asset.name, searchQuery)}
</div>
<div className="cb-asset-type">
{getAssetTypeName(asset)}
@@ -1706,7 +2054,23 @@ export class ${className} {
{/* Status Bar */}
<div className="cb-status-bar">
<span>{filteredAssets.length} {t('contentBrowser.items')}</span>
<span>
{searchQuery.trim() ? (
// 搜索模式:显示找到的结果数 | Search mode: show found results
t('contentBrowser.searchResults', {
found: filteredAssets.length,
total: assets.length
})
) : (
// 正常模式 | Normal mode
`${filteredAssets.length} ${t('contentBrowser.items')}`
)}
</span>
{selectedPaths.size > 1 && (
<span className="cb-status-selected">
{t('contentBrowser.selectedCount', { count: selectedPaths.size })}
</span>
)}
</div>
</div>
@@ -1730,8 +2094,8 @@ export class ${className} {
{/* Rename Dialog */}
{renameDialog && (
<div className="cb-dialog-overlay" onClick={() => setRenameDialog(null)}>
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
<div className="cb-dialog-overlay">
<div className="cb-dialog">
<div className="cb-dialog-header">
<h3>{t('contentBrowser.dialogs.renameTitle')}</h3>
</div>
@@ -1764,8 +2128,8 @@ export class ${className} {
{/* Delete Confirm Dialog */}
{deleteConfirmDialog && (
<div className="cb-dialog-overlay" onClick={() => setDeleteConfirmDialog(null)}>
<div className="cb-dialog" onClick={(e) => e.stopPropagation()}>
<div className="cb-dialog-overlay">
<div className="cb-dialog">
<div className="cb-dialog-header">
<h3>{t('contentBrowser.deleteConfirmTitle')}</h3>
</div>
@@ -8,9 +8,9 @@ export interface ContextMenuItem {
onClick: () => void;
disabled?: boolean;
separator?: boolean;
/** 快捷键提示文本 */
/** 快捷键提示文本 | Shortcut hint text */
shortcut?: string;
/** 子菜单项 */
/** 子菜单项 | Submenu items */
children?: ContextMenuItem[];
}
@@ -24,43 +24,94 @@ interface SubMenuProps {
items: ContextMenuItem[];
parentRect: DOMRect;
onClose: () => void;
level: number;
}
/**
*
* Calculate submenu position, handle screen boundaries
*/
function calculateSubmenuPosition(
parentRect: DOMRect,
menuWidth: number,
menuHeight: number
): { x: number; y: number; flipHorizontal: boolean } {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 10;
let x = parentRect.right;
let y = parentRect.top;
let flipHorizontal = false;
// 检查右侧空间是否足够 | Check if there's enough space on the right
if (x + menuWidth > viewportWidth - padding) {
// 尝试显示在左侧 | Try to show on the left side
const leftPosition = parentRect.left - menuWidth;
if (leftPosition >= padding) {
x = leftPosition;
flipHorizontal = true;
} else {
// 两侧都不够,选择空间更大的一侧 | Neither side has enough space, choose the larger one
if (parentRect.left > viewportWidth - parentRect.right) {
x = padding;
flipHorizontal = true;
} else {
x = viewportWidth - menuWidth - padding;
}
}
}
// 检查底部空间是否足够 | Check if there's enough space at the bottom
if (y + menuHeight > viewportHeight - padding) {
y = Math.max(padding, viewportHeight - menuHeight - padding);
}
// 确保不超出顶部 | Ensure it doesn't go above the top
if (y < padding) {
y = padding;
}
return { x, y, flipHorizontal };
}
/**
*
* SubMenu component
*/
function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
function SubMenu({ items, parentRect, onClose, level }: SubMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 计算位置 | Calculate position
useEffect(() => {
if (menuRef.current) {
const menu = menuRef.current;
const rect = menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// 默认在父菜单右侧显示
let x = parentRect.right;
let y = parentRect.top;
// 如果右侧空间不足,显示在左侧
if (x + rect.width > viewportWidth) {
x = parentRect.left - rect.width;
}
// 如果底部空间不足,向上调整
if (y + rect.height > viewportHeight) {
y = Math.max(0, viewportHeight - rect.height - 10);
}
const { x, y } = calculateSubmenuPosition(parentRect, rect.width, rect.height);
setPosition({ x, y });
}
}, [parentRect]);
// 清理定时器 | Cleanup timer
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
// 清除关闭定时器 | Clear close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -71,14 +122,38 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
}
}, []);
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
// 延迟关闭子菜单,给用户时间移动到子菜单
// Delay closing submenu to give user time to move to it
closeTimeoutRef.current = setTimeout(() => {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}, 150);
}
}, []);
const handleSubmenuMouseEnter = useCallback(() => {
// 鼠标进入子菜单区域,取消关闭定时器
// Mouse entered submenu area, cancel close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
// 初始位置在屏幕外,等待计算后显示
// Initial position off-screen, wait for calculation before showing
const style: React.CSSProperties = position
? { left: `${position.x}px`, top: `${position.y}px`, opacity: 1 }
: { left: '-9999px', top: '-9999px', opacity: 0 };
return (
<div
ref={menuRef}
className="context-menu submenu"
style={{
left: `${position.x}px`,
top: `${position.y}px`
}}
style={style}
onMouseEnter={handleSubmenuMouseEnter}
>
{items.map((item, index) => {
if (item.separator) {
@@ -90,19 +165,16 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
onClick={() => {
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => {
if (!item.children) {
setActiveSubmenuIndex(null);
}
}}
onMouseLeave={() => handleItemMouseLeave(item)}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
@@ -113,6 +185,7 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
items={item.children}
parentRect={submenuRect}
onClose={onClose}
level={level + 1}
/>
)}
</div>
@@ -124,10 +197,12 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) {
export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const [adjustedPosition, setAdjustedPosition] = useState(position);
const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null);
const [activeSubmenuIndex, setActiveSubmenuIndex] = useState<number | null>(null);
const [submenuRect, setSubmenuRect] = useState<DOMRect | null>(null);
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 计算调整后的位置 | Calculate adjusted position
useEffect(() => {
const adjustPosition = () => {
if (menuRef.current) {
@@ -138,24 +213,29 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
const STATUS_BAR_HEIGHT = 28;
const TITLE_BAR_HEIGHT = 32;
const padding = 10;
let x = position.x;
let y = position.y;
if (x + rect.width > viewportWidth - 10) {
x = Math.max(10, viewportWidth - rect.width - 10);
// 检查右边界 | Check right boundary
if (x + rect.width > viewportWidth - padding) {
x = Math.max(padding, viewportWidth - rect.width - padding);
}
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - 10) {
y = Math.max(TITLE_BAR_HEIGHT + 10, viewportHeight - STATUS_BAR_HEIGHT - rect.height - 10);
// 检查下边界 | Check bottom boundary
if (y + rect.height > viewportHeight - STATUS_BAR_HEIGHT - padding) {
y = Math.max(TITLE_BAR_HEIGHT + padding, viewportHeight - STATUS_BAR_HEIGHT - rect.height - padding);
}
if (x < 10) {
x = 10;
// 确保不超出左边界 | Ensure not beyond left boundary
if (x < padding) {
x = padding;
}
if (y < TITLE_BAR_HEIGHT + 10) {
y = TITLE_BAR_HEIGHT + 10;
// 确保不超出上边界 | Ensure not beyond top boundary
if (y < TITLE_BAR_HEIGHT + padding) {
y = TITLE_BAR_HEIGHT + padding;
}
setAdjustedPosition({ x, y });
@@ -168,6 +248,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
return () => cancelAnimationFrame(rafId);
}, [position]);
// 点击外部关闭 | Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
@@ -181,6 +262,8 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
}
};
// 使用 mousedown 而不是 click,以便更快响应
// Use mousedown instead of click for faster response
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
@@ -190,7 +273,22 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
};
}, [onClose]);
// 清理定时器 | Cleanup timer
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
}
};
}, []);
const handleItemMouseEnter = useCallback((index: number, item: ContextMenuItem, e: React.MouseEvent) => {
// 清除关闭定时器 | Clear close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
if (item.children && item.children.length > 0) {
setActiveSubmenuIndex(index);
const itemRect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -201,14 +299,38 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
}
}, []);
const handleItemMouseLeave = useCallback((item: ContextMenuItem) => {
if (item.children && item.children.length > 0) {
// 延迟关闭子菜单,给用户时间移动到子菜单
// Delay closing submenu to give user time to move to it
closeTimeoutRef.current = setTimeout(() => {
setActiveSubmenuIndex(null);
setSubmenuRect(null);
}, 150);
}
}, []);
const handleSubmenuMouseEnter = useCallback(() => {
// 鼠标进入子菜单区域,取消关闭定时器
// Mouse entered submenu area, cancel close timer
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
}, []);
// 初始位置在屏幕外,等待计算后显示
// Initial position off-screen, wait for calculation before showing
const style: React.CSSProperties = adjustedPosition
? { left: `${adjustedPosition.x}px`, top: `${adjustedPosition.y}px`, opacity: 1 }
: { left: '-9999px', top: '-9999px', opacity: 0 };
return (
<div
ref={menuRef}
className="context-menu"
style={{
left: `${adjustedPosition.x}px`,
top: `${adjustedPosition.y}px`
}}
style={style}
onMouseEnter={handleSubmenuMouseEnter}
>
{items.map((item, index) => {
if (item.separator) {
@@ -220,19 +342,16 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
return (
<div
key={index}
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''}`}
onClick={() => {
className={`context-menu-item ${item.disabled ? 'disabled' : ''} ${hasChildren ? 'has-submenu' : ''} ${activeSubmenuIndex === index ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!item.disabled && !hasChildren) {
item.onClick();
onClose();
}
}}
onMouseEnter={(e) => handleItemMouseEnter(index, item, e)}
onMouseLeave={() => {
if (!item.children) {
setActiveSubmenuIndex(null);
}
}}
onMouseLeave={() => handleItemMouseLeave(item)}
>
{item.icon && <span className="context-menu-icon">{item.icon}</span>}
<span className="context-menu-label">{item.label}</span>
@@ -243,6 +362,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) {
items={item.children}
parentRect={submenuRect}
onClose={onClose}
level={1}
/>
)}
</div>
@@ -3,7 +3,7 @@
* FlexLayoutDockContainer - FlexLayout
*/
import { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { useCallback, useRef, useEffect, useState, useMemo, useImperativeHandle, forwardRef } from 'react';
import { Layout, Model, TabNode, TabSetNode, IJsonModel, Actions, Action, DockLocation } from 'flexlayout-react';
import 'flexlayout-react/style/light.css';
import '../styles/FlexLayoutDock.css';
@@ -11,6 +11,81 @@ import { LayoutMerger, LayoutBuilder, FlexDockPanel } from '../shared/layout';
export type { FlexDockPanel };
/** LocalStorage key for persisting layout | 持久化布局的 localStorage 键 */
const LAYOUT_STORAGE_KEY = 'esengine-editor-layout';
/** Layout version for migration | 布局版本用于迁移 */
const LAYOUT_VERSION = 1;
/** Saved layout data structure | 保存的布局数据结构 */
interface SavedLayoutData {
version: number;
layout: IJsonModel;
timestamp: number;
}
/**
* Save layout to localStorage.
* localStorage
*/
function saveLayoutToStorage(layout: IJsonModel): void {
try {
const data: SavedLayoutData = {
version: LAYOUT_VERSION,
layout,
timestamp: Date.now()
};
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.warn('Failed to save layout to localStorage:', error);
}
}
/**
* Load layout from localStorage.
* localStorage
*/
function loadLayoutFromStorage(): IJsonModel | null {
try {
const saved = localStorage.getItem(LAYOUT_STORAGE_KEY);
if (!saved) return null;
const data: SavedLayoutData = JSON.parse(saved);
// Version check for future migrations
if (data.version !== LAYOUT_VERSION) {
console.info('Layout version mismatch, using default layout');
return null;
}
return data.layout;
} catch (error) {
console.warn('Failed to load layout from localStorage:', error);
return null;
}
}
/**
* Clear saved layout from localStorage.
* localStorage
*/
function clearLayoutStorage(): void {
try {
localStorage.removeItem(LAYOUT_STORAGE_KEY);
} catch (error) {
console.warn('Failed to clear layout from localStorage:', error);
}
}
/**
* Public handle for FlexLayoutDockContainer.
* FlexLayoutDockContainer
*/
export interface FlexLayoutDockContainerHandle {
/** Reset layout to default | 重置布局到默认状态 */
resetLayout: () => void;
}
/**
* Panel IDs that should persist in DOM when switching tabs.
* These panels contain WebGL canvas or other stateful content that cannot be unmounted.
@@ -94,11 +169,14 @@ interface FlexLayoutDockContainerProps {
messageHub?: { subscribe: (event: string, callback: (data: any) => void) => () => void } | null;
}
export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }: FlexLayoutDockContainerProps) {
export const FlexLayoutDockContainer = forwardRef<FlexLayoutDockContainerHandle, FlexLayoutDockContainerProps>(
function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }, ref) {
const layoutRef = useRef<Layout>(null);
const previousLayoutJsonRef = useRef<string | null>(null);
const previousPanelIdsRef = useRef<string>('');
const previousPanelTitlesRef = useRef<Map<string, string>>(new Map());
/** Skip saving on next model change (used when resetting layout) | 下次模型变化时跳过保存(重置布局时使用) */
const skipNextSaveRef = useRef(false);
// Persistent panel state | 持久化面板状态
const [persistentPanelRects, setPersistentPanelRects] = useState<Map<string, DOMRect>>(new Map());
@@ -116,14 +194,52 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
return LayoutBuilder.createDefaultLayout(panels, activePanelId);
}, [panels, activePanelId]);
/**
* Try to load saved layout and merge with current panels.
*
*/
const loadSavedLayoutOrDefault = useCallback((): IJsonModel => {
const savedLayout = loadLayoutFromStorage();
if (savedLayout) {
try {
// Merge saved layout with current panels (handle new/removed panels)
const defaultLayout = createDefaultLayout();
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
return mergedLayout;
} catch (error) {
console.warn('Failed to merge saved layout, using default:', error);
}
}
return createDefaultLayout();
}, [createDefaultLayout, panels]);
const [model, setModel] = useState<Model>(() => {
try {
return Model.fromJson(createDefaultLayout());
return Model.fromJson(loadSavedLayoutOrDefault());
} catch (error) {
throw new Error(`Failed to create layout model: ${error instanceof Error ? error.message : String(error)}`);
console.warn('Failed to load saved layout, using default:', error);
return Model.fromJson(createDefaultLayout());
}
});
/**
* Reset layout to default and clear saved layout.
*
*/
const resetLayout = useCallback(() => {
clearLayoutStorage();
skipNextSaveRef.current = true;
previousLayoutJsonRef.current = null;
previousPanelIdsRef.current = '';
const defaultLayout = createDefaultLayout();
setModel(Model.fromJson(defaultLayout));
}, [createDefaultLayout]);
// Expose resetLayout method via ref | 通过 ref 暴露 resetLayout 方法
useImperativeHandle(ref, () => ({
resetLayout
}), [resetLayout]);
useEffect(() => {
try {
// 检查面板ID列表是否真的变化了(而不只是标题等属性变化)
@@ -168,26 +284,34 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
previousPanelIdsRef.current = currentPanelIds;
// 如果已经有布局且只是添加新面板,使用Action动态添加
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds) {
// 检查新面板是否需要独立 tabset(如 bottom 位置的面板)
// Check if new panels require separate tabset (e.g., bottom position panels)
const newPanelsWithConfig = panels.filter((p) => newPanelIds.includes(p.id));
const hasSpecialLayoutPanels = newPanelsWithConfig.some((p) =>
p.layout?.requiresSeparateTabset || p.layout?.position === 'bottom'
);
if (model && newPanelIds.length > 0 && removedPanelIds.length === 0 && previousIds && !hasSpecialLayoutPanels) {
// 找到要添加的面板
const newPanels = panels.filter((p) => newPanelIds.includes(p.id));
// 找到中心区域的tabset ID
// 构建面板位置映射 | Build panel position map
const panelPositionMap = new Map(panels.map((p) => [p.id, p.layout?.position || 'center']));
// 找到中心区域的tabset ID | Find center tabset ID
let centerTabsetId: string | null = null;
model.visitNodes((node: any) => {
if (node.getType() === 'tabset') {
const tabset = node as any;
// 检查是否是中心tabset
// 检查是否是中心tabset(包含 center 位置的面板)
// Check if this is center tabset (contains center position panels)
const children = tabset.getChildren();
const hasNonSidePanel = children.some((child: any) => {
const hasCenterPanel = children.some((child: any) => {
const id = child.getId();
return !id.includes('hierarchy') &&
!id.includes('asset') &&
!id.includes('inspector') &&
!id.includes('console');
const position = panelPositionMap.get(id);
return position === 'center' || position === undefined;
});
if (hasNonSidePanel && !centerTabsetId) {
if (hasCenterPanel && !centerTabsetId) {
centerTabsetId = tabset.getId();
}
}
@@ -229,7 +353,9 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
const defaultLayout = createDefaultLayout();
// 如果有保存的布局,尝试合并
if (previousLayoutJsonRef.current && previousIds) {
// 注意:如果新面板需要特殊布局(独立 tabset),直接使用默认布局
// Note: If new panels need special layout (separate tabset), use default layout directly
if (previousLayoutJsonRef.current && previousIds && !hasSpecialLayoutPanels) {
try {
const savedLayout = JSON.parse(previousLayoutJsonRef.current);
const mergedLayout = LayoutMerger.merge(savedLayout, defaultLayout, panels);
@@ -340,6 +466,13 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
const layoutJson = newModel.toJson();
previousLayoutJsonRef.current = JSON.stringify(layoutJson);
// Save to localStorage (unless skipped) | 保存到 localStorage(除非跳过)
if (skipNextSaveRef.current) {
skipNextSaveRef.current = false;
} else {
saveLayoutToStorage(layoutJson);
}
// Check if any tabset is maximized
let hasMaximized = false;
newModel.visitNodes((node) => {
@@ -390,7 +523,7 @@ export function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, m
))}
</div>
);
}
});
/**
* Container for persistent panel content.
@@ -1,18 +1,19 @@
import { useState, useEffect, useRef } from 'react';
import { Component, Core, getComponentInstanceTypeName } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry } from '@esengine/editor-core';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Component, Core, getComponentInstanceTypeName, PrefabInstanceComponent, Entity } from '@esengine/ecs-framework';
import { PropertyMetadataService, PropertyMetadata, PropertyAction, MessageHub, FileActionRegistry, PrefabService } from '@esengine/editor-core';
import { ChevronRight, ChevronDown, Lock } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { AnimationClipsFieldEditor } from '../infrastructure/field-editors/AnimationClipsFieldEditor';
import { AssetField } from './inspectors/fields/AssetField';
import { CollisionLayerField } from './inspectors/fields/CollisionLayerField';
import { useLocale } from '../hooks/useLocale';
import '../styles/PropertyInspector.css';
const animationClipsEditor = new AnimationClipsFieldEditor();
interface PropertyInspectorProps {
component: Component;
entity?: any;
entity?: Entity;
version?: number;
onChange?: (propertyName: string, value: any) => void;
onAction?: (actionId: string, propertyName: string, component: Component) => void;
@@ -21,9 +22,47 @@ interface PropertyInspectorProps {
export function PropertyInspector({ component, entity, version, onChange, onAction }: PropertyInspectorProps) {
const [properties, setProperties] = useState<Record<string, PropertyMetadata>>({});
const [controlledFields, setControlledFields] = useState<Map<string, string>>(new Map());
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; propertyName: string } | null>(null);
// version is used implicitly - when it changes, React re-renders and getValue reads fresh values
void version;
// 获取预制体服务和组件名称 | Get prefab service and component name
const prefabService = useMemo(() => Core.services.tryResolve(PrefabService) as PrefabService | null, []);
const componentTypeName = useMemo(() => getComponentInstanceTypeName(component), [component]);
// 获取预制体实例组件 | Get prefab instance component
const prefabInstanceComp = useMemo(() => {
return entity?.getComponent(PrefabInstanceComponent) ?? null;
}, [entity, version]);
// 检查属性是否被覆盖 | Check if property is overridden
const isPropertyOverridden = useCallback((propertyName: string): boolean => {
if (!prefabInstanceComp) return false;
return prefabInstanceComp.isPropertyModified(componentTypeName, propertyName);
}, [prefabInstanceComp, componentTypeName]);
// 处理属性右键菜单 | Handle property context menu
const handlePropertyContextMenu = useCallback((e: React.MouseEvent, propertyName: string) => {
if (!isPropertyOverridden(propertyName)) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, propertyName });
}, [isPropertyOverridden]);
// 还原属性 | Revert property
const handleRevertProperty = useCallback(async () => {
if (!contextMenu || !prefabService || !entity) return;
await prefabService.revertProperty(entity, componentTypeName, contextMenu.propertyName);
setContextMenu(null);
}, [contextMenu, prefabService, entity, componentTypeName]);
// 关闭右键菜单 | Close context menu
useEffect(() => {
const handleClick = () => setContextMenu(null);
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, []);
// Scan entity for components that control this component's properties
useEffect(() => {
if (!entity) return;
@@ -236,7 +275,7 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
const canCreate = creationMapping !== null;
return (
<div key={propertyName} className="property-field">
<div key={propertyName} className="property-field property-field-asset">
<label className="property-label">
{label}
{controlledBy && (
@@ -300,6 +339,28 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
/>
);
case 'array': {
const arrayMeta = metadata as {
itemType?: { type: string; extensions?: string[]; assetType?: string };
minLength?: number;
maxLength?: number;
reorderable?: boolean;
};
return (
<ArrayField
key={propertyName}
label={label}
value={value ?? []}
itemType={arrayMeta.itemType}
minLength={arrayMeta.minLength}
maxLength={arrayMeta.maxLength}
reorderable={arrayMeta.reorderable}
readOnly={metadata.readOnly}
onChange={(newValue) => handleChange(propertyName, newValue)}
/>
);
}
default:
return null;
}
@@ -307,8 +368,36 @@ export function PropertyInspector({ component, entity, version, onChange, onActi
return (
<div className="property-inspector">
{Object.entries(properties).map(([propertyName, metadata]) =>
renderProperty(propertyName, metadata)
{Object.entries(properties).map(([propertyName, metadata]) => {
const overridden = isPropertyOverridden(propertyName);
return (
<div
key={propertyName}
className={`property-row ${overridden ? 'overridden' : ''}`}
onContextMenu={(e) => handlePropertyContextMenu(e, propertyName)}
>
{renderProperty(propertyName, metadata)}
{overridden && (
<span className="property-override-indicator" title="Modified from prefab" />
)}
</div>
);
})}
{/* 右键菜单 | Context Menu */}
{contextMenu && (
<div
className="property-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
<button
className="property-context-menu-item"
onClick={handleRevertProperty}
>
<span></span>
<span>Revert to Prefab</span>
</button>
</div>
)}
</div>
);
@@ -331,8 +420,17 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
const [isDragging, setIsDragging] = useState(false);
const [dragStartX, setDragStartX] = useState(0);
const [dragStartValue, setDragStartValue] = useState(0);
const [localValue, setLocalValue] = useState(String(value ?? 0));
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
// 同步外部值 | Sync external value
useEffect(() => {
if (!isFocused && !isDragging) {
setLocalValue(String(value ?? 0));
}
}, [value, isFocused, isDragging]);
const renderActionButton = (action: PropertyAction) => {
const IconComponent = action.icon ? (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[action.icon] : null;
return (
@@ -389,6 +487,33 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
};
}, [isDragging, dragStartX, dragStartValue, step, min, max, onChange]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setLocalValue(String(value ?? 0));
e.currentTarget.blur();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
e.target.select();
};
const handleBlur = () => {
setIsFocused(false);
let val = parseFloat(localValue) || 0;
if (min !== undefined) val = Math.max(min, val);
if (max !== undefined) val = Math.min(max, val);
if (isInteger) val = Math.round(val);
onChange(val);
};
return (
<div className="property-field">
<label
@@ -402,16 +527,15 @@ function NumberField({ label, value, min, max, step = 0.1, isInteger = false, re
ref={inputRef}
type="number"
className="property-input property-input-number"
value={value}
value={localValue}
min={min}
max={max}
step={step}
disabled={readOnly}
onChange={(e) => {
const val = parseFloat(e.target.value) || 0;
onChange(isInteger ? Math.round(val) : val);
}}
onFocus={(e) => e.target.select()}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
{actions && actions.length > 0 && (
<div className="property-actions">
@@ -430,16 +554,42 @@ interface StringFieldProps {
}
function StringField({ label, value, readOnly, onChange }: StringFieldProps) {
const [localValue, setLocalValue] = useState(value ?? '');
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (!isFocused) {
setLocalValue(value ?? '');
}
}, [value, isFocused]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setLocalValue(value ?? '');
e.currentTarget.blur();
}
};
return (
<div className="property-field">
<label className="property-label">{label}</label>
<input
type="text"
className="property-input property-input-text"
value={value}
value={localValue}
disabled={readOnly}
onChange={(e) => onChange(e.target.value)}
onFocus={(e) => e.target.select()}
onChange={(e) => setLocalValue(e.target.value)}
onFocus={(e) => {
setIsFocused(true);
e.target.select();
}}
onBlur={() => {
setIsFocused(false);
onChange(localValue);
}}
onKeyDown={handleKeyDown}
/>
</div>
);
@@ -695,7 +845,17 @@ interface DraggableAxisInputProps {
function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: DraggableAxisInputProps) {
const [isDragging, setIsDragging] = useState(false);
const [localValue, setLocalValue] = useState(String(value ?? 0));
const [isFocused, setIsFocused] = useState(false);
const dragStartRef = useRef({ x: 0, value: 0 });
const inputRef = useRef<HTMLInputElement>(null);
// 同步外部值(不在聚焦或拖动时)| Sync external value (not when focused or dragging)
useEffect(() => {
if (!isFocused && !isDragging) {
setLocalValue(String(value ?? 0));
}
}, [value, isFocused, isDragging]);
const handleMouseDown = (e: React.MouseEvent) => {
if (readOnly) return;
@@ -730,6 +890,37 @@ function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: Dragga
const axisClass = `property-vector-axis-${axis}`;
const inputClass = compact ? 'property-input property-input-number-compact' : 'property-input property-input-number';
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// 确认输入并失焦 | Confirm input and blur
e.currentTarget.blur();
} else if (e.key === 'Escape') {
// 取消输入,恢复原值 | Cancel input, restore original value
setLocalValue(String(value ?? 0));
e.currentTarget.blur();
}
// Tab 键使用浏览器默认行为 | Tab uses browser default behavior
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsFocused(true);
e.target.select();
};
const handleBlur = () => {
setIsFocused(false);
const parsed = parseFloat(localValue);
if (!isNaN(parsed)) {
onChange(Math.round(parsed * 1000) / 1000);
} else {
setLocalValue(String(value ?? 0));
}
};
return (
<div className={compact ? 'property-vector-axis-compact' : 'property-vector-axis'}>
<span
@@ -740,13 +931,16 @@ function DraggableAxisInput({ axis, value, readOnly, compact, onChange }: Dragga
{axis.toUpperCase()}
</span>
<input
ref={inputRef}
type="number"
className={inputClass}
value={value ?? 0}
value={localValue}
disabled={readOnly}
step={0.1}
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
onFocus={(e) => e.target.select()}
onChange={handleInputChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
</div>
);
@@ -954,3 +1148,158 @@ function EnumField({ label, value, options, readOnly, onChange }: EnumFieldProps
);
}
// ============= ArrayField 数组字段组件 =============
interface ArrayFieldProps {
label: string;
value: any[];
itemType?: { type: string; extensions?: string[]; assetType?: string };
minLength?: number;
maxLength?: number;
reorderable?: boolean;
readOnly?: boolean;
onChange: (value: any[]) => void;
}
function ArrayField({
label,
value,
itemType,
minLength = 0,
maxLength = 100,
reorderable = true,
readOnly,
onChange
}: ArrayFieldProps) {
const { t } = useLocale();
const [isExpanded, setIsExpanded] = useState(true);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const safeValue = Array.isArray(value) ? value : [];
const canAdd = !readOnly && safeValue.length < maxLength;
const canRemove = !readOnly && safeValue.length > minLength;
const handleAdd = () => {
if (!canAdd) return;
let defaultValue: any = '';
if (itemType?.type === 'number') defaultValue = 0;
if (itemType?.type === 'boolean') defaultValue = false;
onChange([...safeValue, defaultValue]);
};
const handleRemove = (index: number) => {
if (!canRemove) return;
const newValue = [...safeValue];
newValue.splice(index, 1);
onChange(newValue);
};
const handleItemChange = (index: number, newItemValue: any) => {
const newValue = [...safeValue];
newValue[index] = newItemValue;
onChange(newValue);
};
const handleDragStart = (index: number) => {
if (!reorderable || readOnly) return;
setDragIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (dragIndex === null || dragIndex === index) return;
const newValue = [...safeValue];
const [removed] = newValue.splice(dragIndex, 1);
newValue.splice(index, 0, removed);
onChange(newValue);
setDragIndex(index);
};
const handleDragEnd = () => {
setDragIndex(null);
};
// 渲染数组项 | Render array item
const renderItem = (item: any, index: number) => {
const isAsset = itemType?.type === 'asset';
return (
<div
key={index}
className={`array-field-item ${dragIndex === index ? 'dragging' : ''}`}
draggable={reorderable && !readOnly}
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDragEnd={handleDragEnd}
>
{reorderable && !readOnly && (
<span className="array-field-drag-handle" title={t('inspector.array.dragToReorder')}></span>
)}
<span className="array-field-index">[{index}]</span>
<div className="array-field-value">
{isAsset ? (
<AssetField
value={item ?? null}
onChange={(newValue) => handleItemChange(index, newValue || '')}
fileExtension={itemType?.extensions?.[0] || ''}
placeholder={t('inspector.array.dropAsset')}
readonly={readOnly}
/>
) : (
<input
type="text"
className="property-input property-input-text"
value={item ?? ''}
disabled={readOnly}
onChange={(e) => handleItemChange(index, e.target.value)}
/>
)}
</div>
{canRemove && (
<button
className="array-field-remove"
onClick={() => handleRemove(index)}
title={t('inspector.array.remove')}
>
×
</button>
)}
</div>
);
};
return (
<div className="property-field property-field-array">
<div className="array-field-header">
<button
className="property-expand-btn"
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<label className="property-label">{label}</label>
<span className="array-field-count">[{safeValue.length}]</span>
{canAdd && (
<button
className="array-field-add"
onClick={handleAdd}
title={t('inspector.array.add')}
>
+
</button>
)}
</div>
{isExpanded && (
<div className="array-field-items">
{safeValue.length === 0 ? (
<div className="array-field-empty">{t('inspector.array.empty')}</div>
) : (
safeValue.map((item, index) => renderItem(item, index))
)}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { getVersion } from '@tauri-apps/api/app';
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle } from 'lucide-react';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { Globe, ChevronDown, Download, X, Loader2, Trash2, CheckCircle, AlertCircle, Terminal } from 'lucide-react';
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
import { StartupLogo } from './StartupLogo';
import { TauriAPI, type EnvironmentCheckResult } from '../api/tauri';
@@ -35,6 +36,10 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
const [isInstalling, setIsInstalling] = useState(false);
const [envCheck, setEnvCheck] = useState<EnvironmentCheckResult | null>(null);
const [showEnvStatus, setShowEnvStatus] = useState(false);
const [showEsbuildInstall, setShowEsbuildInstall] = useState(false);
const [isInstallingEsbuild, setIsInstallingEsbuild] = useState(false);
const [installProgress, setInstallProgress] = useState('');
const [installError, setInstallError] = useState('');
const langMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -70,15 +75,74 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
console.log('[Environment] Ready ✓');
console.log(`[Environment] esbuild: ${result.esbuild.version} (${result.esbuild.source})`);
} else {
// 环境有问题,显示提示
setShowEnvStatus(true);
// esbuild 未安装,显示安装对话框
console.warn('[Environment] Not ready:', result.esbuild.error);
setShowEsbuildInstall(true);
}
}).catch((error) => {
console.error('[Environment] Check failed:', error);
});
}, []);
// 监听 esbuild 安装进度事件
useEffect(() => {
let unlisten: UnlistenFn | undefined;
const setupListeners = async () => {
// 监听安装进度
unlisten = await listen<string>('esbuild-install:progress', (event) => {
setInstallProgress(event.payload);
});
// 监听安装成功
const unlistenSuccess = await listen('esbuild-install:success', async () => {
// 重新检测环境
const result = await TauriAPI.checkEnvironment();
setEnvCheck(result);
if (result.ready) {
setShowEsbuildInstall(false);
setIsInstallingEsbuild(false);
setInstallProgress('');
setInstallError('');
}
});
// 监听安装错误
const unlistenError = await listen<string>('esbuild-install:error', (event) => {
setInstallError(event.payload);
setIsInstallingEsbuild(false);
});
return () => {
unlisten?.();
unlistenSuccess();
unlistenError();
};
};
setupListeners();
return () => {
unlisten?.();
};
}, []);
// 处理 esbuild 安装
const handleInstallEsbuild = async () => {
setIsInstallingEsbuild(true);
setInstallProgress(t('startup.installingEsbuild'));
setInstallError('');
try {
await TauriAPI.installEsbuild();
// 成功会通过事件处理
} catch (error) {
console.error('[Environment] Failed to install esbuild:', error);
setInstallError(String(error));
setIsInstallingEsbuild(false);
}
};
const handleInstallUpdate = async () => {
setIsInstalling(true);
const success = await installUpdate();
@@ -343,6 +407,57 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</div>
</div>
)}
{/* esbuild 安装对话框 | esbuild Installation Dialog */}
{showEsbuildInstall && (
<div className="startup-dialog-overlay">
<div className="startup-dialog">
<div className="startup-dialog-header">
<Terminal size={20} className="dialog-icon-info" />
<h3>{t('startup.esbuildNotInstalled')}</h3>
</div>
<div className="startup-dialog-body">
<p>{t('startup.esbuildRequired')}</p>
<p className="startup-dialog-info">{t('startup.esbuildInstallPrompt')}</p>
{/* 安装进度 | Installation Progress */}
{isInstallingEsbuild && (
<div className="startup-dialog-progress">
<Loader2 size={16} className="animate-spin" />
<span>{installProgress}</span>
</div>
)}
{/* 错误信息 | Error Message */}
{installError && (
<div className="startup-dialog-error">
<AlertCircle size={16} />
<span>{installError}</span>
</div>
)}
</div>
<div className="startup-dialog-footer">
<button
className="startup-dialog-btn primary"
onClick={handleInstallEsbuild}
disabled={isInstallingEsbuild}
>
{isInstallingEsbuild ? (
<>
<Loader2 size={14} className="animate-spin" />
{t('startup.installing')}
</>
) : (
<>
<Download size={14} />
{t('startup.installNow')}
</>
)}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X } from 'lucide-react';
import { FolderOpen, FileText, Terminal, ChevronDown, ChevronUp, Activity, Wifi, Save, GitBranch, X, LayoutGrid } from 'lucide-react';
import type { MessageHub, LogService } from '@esengine/editor-core';
import { ContentBrowser } from './ContentBrowser';
import { OutputLogPanel } from './OutputLogPanel';
@@ -14,6 +14,10 @@ interface StatusBarProps {
locale?: string;
projectPath?: string | null;
onOpenScene?: (scenePath: string) => void;
/** 停靠内容管理器到布局中的回调 | Callback to dock content browser in layout */
onDockContentBrowser?: () => void;
/** 重置布局回调 | Callback to reset layout */
onResetLayout?: () => void;
}
type ActiveTab = 'output' | 'cmd';
@@ -25,7 +29,9 @@ export function StatusBar({
logService,
locale = 'en',
projectPath,
onOpenScene
onOpenScene,
onDockContentBrowser,
onResetLayout
}: StatusBarProps) {
const { t } = useLocale();
const [consoleInput, setConsoleInput] = useState('');
@@ -224,6 +230,11 @@ export function StatusBar({
onOpenScene={onOpenScene}
isDrawer={true}
revealPath={revealPath}
onDockInLayout={() => {
// 关闭抽屉并停靠到布局 | Close drawer and dock to layout
setContentDrawerOpen(false);
onDockContentBrowser?.();
}}
/>
</div>
</div>
@@ -303,6 +314,13 @@ export function StatusBar({
<div className="status-bar-divider" />
<div className="status-bar-icon-group">
<button
className="status-bar-icon-btn"
title={t('statusBar.resetLayout')}
onClick={onResetLayout}
>
<LayoutGrid size={14} />
</button>
<button className="status-bar-icon-btn" title={t('statusBar.network')}>
<Wifi size={14} />
</button>
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { UIRegistry, MessageHub, PluginManager } from '@esengine/editor-core';
import { UIRegistry, MessageHub, PluginManager, CommandManager } from '@esengine/editor-core';
import type { MenuItem as PluginMenuItem } from '@esengine/editor-core';
import * as LucideIcons from 'lucide-react';
import { useLocale } from '../hooks/useLocale';
@@ -21,6 +21,7 @@ interface TitleBarProps {
uiRegistry?: UIRegistry;
messageHub?: MessageHub;
pluginManager?: PluginManager;
commandManager?: CommandManager;
onNewScene?: () => void;
onOpenScene?: () => void;
onSaveScene?: () => void;
@@ -44,6 +45,7 @@ export function TitleBar({
uiRegistry,
messageHub,
pluginManager,
commandManager,
onNewScene,
onOpenScene,
onSaveScene,
@@ -65,9 +67,42 @@ export function TitleBar({
const [openMenu, setOpenMenu] = useState<string | null>(null);
const [pluginMenuItems, setPluginMenuItems] = useState<PluginMenuItem[]>([]);
const [isMaximized, setIsMaximized] = useState(false);
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const appWindow = getCurrentWindow();
// Update undo/redo state | 更新撤销/重做状态
const updateUndoRedoState = useCallback(() => {
if (commandManager) {
setCanUndo(commandManager.canUndo());
setCanRedo(commandManager.canRedo());
}
}, [commandManager]);
// Handle undo | 处理撤销
const handleUndo = useCallback(() => {
if (commandManager && commandManager.canUndo()) {
commandManager.undo();
updateUndoRedoState();
}
}, [commandManager, updateUndoRedoState]);
// Handle redo | 处理重做
const handleRedo = useCallback(() => {
if (commandManager && commandManager.canRedo()) {
commandManager.redo();
updateUndoRedoState();
}
}, [commandManager, updateUndoRedoState]);
// Update undo/redo state periodically | 定期更新撤销/重做状态
useEffect(() => {
updateUndoRedoState();
const interval = setInterval(updateUndoRedoState, 100);
return () => clearInterval(interval);
}, [updateUndoRedoState]);
const updateMenuItems = () => {
if (uiRegistry) {
const items = uiRegistry.getChildMenus('window');
@@ -135,8 +170,8 @@ export function TitleBar({
{ label: t('menu.file.exit'), onClick: onExit }
],
edit: [
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: true },
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: true },
{ label: t('menu.edit.undo'), shortcut: 'Ctrl+Z', disabled: !canUndo, onClick: handleUndo },
{ label: t('menu.edit.redo'), shortcut: 'Ctrl+Y', disabled: !canRedo, onClick: handleRedo },
{ separator: true },
{ label: t('menu.edit.cut'), shortcut: 'Ctrl+X', disabled: true },
{ label: t('menu.edit.copy'), shortcut: 'Ctrl+C', disabled: true },
+389 -107
View File
@@ -2,14 +2,17 @@ import { useEffect, useRef, useState, useCallback } from 'react';
import {
RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity,
MousePointer2, Move, RotateCw, Scaling, Globe, QrCode, ChevronDown,
Magnet, ZoomIn
Magnet, ZoomIn, Save, X, PackageOpen
} from 'lucide-react';
import '../styles/Viewport.css';
import { useEngine } from '../hooks/useEngine';
import { useLocale } from '../hooks/useLocale';
import { EngineService } from '../services/EngineService';
import { Core, Entity, SceneSerializer } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService } from '@esengine/editor-core';
import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework';
import type { PrefabData, ComponentType } from '@esengine/ecs-framework';
import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core';
import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand';
import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands';
import { TransformComponent } from '@esengine/engine-core';
import { CameraComponent } from '@esengine/camera';
import { UITransformComponent } from '@esengine/ui';
@@ -17,6 +20,7 @@ import { TauriAPI } from '../api/tauri';
import { open } from '@tauri-apps/plugin-shell';
import { RuntimeResolver } from '../services/RuntimeResolver';
import { QRCodeDialog } from './QRCodeDialog';
import { collectAssetReferences } from '@esengine/asset-system';
import type { ModuleManifest } from '../services/RuntimeResolver';
@@ -52,39 +56,53 @@ function generateRuntimeHtml(importMap: Record<string, string>, modules: ModuleM
// Generate user runtime loading code
// 生成用户运行时加载代码
// Now we only load @esengine/sdk as a single global
// 现在只加载 @esengine/sdk 作为单一全局变量
const userRuntimeCode = hasUserRuntime ? `
updateLoading('Loading user scripts...');
try {
// Import ECS framework and set up global for user-runtime.js shim
// 导入 ECS 框架并为 user-runtime.js 设置全局变量
const ecsFramework = await import('@esengine/ecs-framework');
window.__ESENGINE__ = window.__ESENGINE__ || {};
window.__ESENGINE__.ecsFramework = ecsFramework;
// Load unified SDK and set global
// 加载统一 SDK 并设置全局变量
console.log('[Preview] Loading @esengine/sdk...');
const sdk = await import('@esengine/sdk');
window.__ESENGINE_SDK__ = sdk;
console.log('[Preview] SDK loaded successfully');
// Check SDK is valid
// 检查 SDK 是否有效
if (!sdk.Component || !sdk.ComponentRegistry) {
throw new Error('SDK missing critical exports (Component, ComponentRegistry)');
}
// Load user-runtime.js which contains compiled user components
// 加载 user-runtime.js,其中包含编译的用户组件
console.log('[Preview] Loading user-runtime.js...');
const userRuntimeScript = document.createElement('script');
userRuntimeScript.src = './user-runtime.js?_=' + Date.now();
await new Promise((resolve, reject) => {
userRuntimeScript.onload = resolve;
userRuntimeScript.onerror = reject;
userRuntimeScript.onerror = (e) => reject(new Error('Failed to load user-runtime.js: ' + e.message));
document.head.appendChild(userRuntimeScript);
});
console.log('[Preview] user-runtime.js loaded successfully');
// Register user components to ComponentRegistry
// 将用户组件注册到 ComponentRegistry
if (window.__USER_RUNTIME_EXPORTS__) {
const { ComponentRegistry, Component } = ecsFramework;
const { ComponentRegistry, Component } = window.__ESENGINE_SDK__;
const exports = window.__USER_RUNTIME_EXPORTS__;
for (const [name, exported] of Object.entries(exports)) {
if (typeof exported === 'function' && exported.prototype instanceof Component) {
ComponentRegistry.register(exported);
console.log('[Preview] Registered user component:', name);
if (ComponentRegistry && Component) {
for (const [name, exported] of Object.entries(exports)) {
if (typeof exported === 'function' && exported.prototype instanceof Component) {
ComponentRegistry.register(exported);
console.log('[Preview] Registered user component:', name);
}
}
}
}
} catch (e) {
console.warn('[Preview] Failed to load user scripts:', e.message);
console.error('[Preview] Failed to load user scripts:', e.message, e);
throw e; // Re-throw to show error in UI
}
` : '';
@@ -146,12 +164,13 @@ ${importMapScript}
const errorTitle = document.getElementById('error-title');
const errorMessage = document.getElementById('error-message');
function showError(title, msg) {
function showError(title, msg, error) {
loading.style.display = 'none';
errorTitle.textContent = title || 'Failed to start';
errorMessage.textContent = msg;
const stack = error?.stack || '';
errorMessage.textContent = msg + (stack ? '\\n\\nStack:\\n' + stack : '');
errorDiv.classList.add('show');
console.error('[Preview]', msg);
console.error('[Preview]', msg, error || '');
}
function updateLoading(msg) {
@@ -191,7 +210,7 @@ ${userRuntimeCode}
});
console.log('[Preview] Started successfully');
} catch (error) {
showError(null, error.message || String(error));
showError(null, error.message || String(error), error);
}
</script>
</body>
@@ -205,9 +224,10 @@ export type PlayState = 'stopped' | 'playing' | 'paused';
interface ViewportProps {
locale?: string;
messageHub?: MessageHub;
commandManager?: CommandManager;
}
export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
export function Viewport({ locale = 'en', messageHub, commandManager }: ViewportProps) {
const { t } = useLocale();
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -221,6 +241,13 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const [devicePreviewUrl, setDevicePreviewUrl] = useState('');
const runMenuRef = useRef<HTMLDivElement>(null);
// Prefab edit mode state | 预制体编辑模式状态
const [prefabEditMode, setPrefabEditMode] = useState<{
isActive: boolean;
prefabName: string;
prefabPath: string;
} | null>(null);
// Snap settings
const [snapEnabled, setSnapEnabled] = useState(true);
const [gridSnapValue, setGridSnapValue] = useState(10);
@@ -237,10 +264,15 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
const playStateRef = useRef<PlayState>('stopped');
// Keep ref in sync with state
useEffect(() => {
playStateRef.current = playState;
}, [playState]);
// Live transform display state | 实时变换显示状态
const [liveTransform, setLiveTransform] = useState<{
type: 'move' | 'rotate' | 'scale';
x: number;
y: number;
rotation?: number;
scaleX?: number;
scaleY?: number;
} | null>(null);
// Rust engine hook with multi-viewport support
const engine = useEngine({
@@ -261,40 +293,28 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
const lastMousePosRef = useRef({ x: 0, y: 0 });
const selectedEntityRef = useRef<Entity | null>(null);
const messageHubRef = useRef<MessageHub | null>(null);
const commandManagerRef = useRef<CommandManager | null>(null);
const transformModeRef = useRef<TransformMode>('select');
// Initial transform state for undo/redo | 用于撤销/重做的初始变换状态
const initialTransformStateRef = useRef<TransformState | null>(null);
const transformComponentRef = useRef<TransformComponent | UITransformComponent | null>(null);
const snapEnabledRef = useRef(true);
const gridSnapRef = useRef(10);
const rotationSnapRef = useRef(15);
const scaleSnapRef = useRef(0.25);
// Keep refs in sync with state
// Keep refs in sync with state for stable event handler closures
// 保持 refs 与 state 同步,以便事件处理器闭包稳定
useEffect(() => {
playStateRef.current = playState;
camera2DZoomRef.current = camera2DZoom;
}, [camera2DZoom]);
useEffect(() => {
camera2DOffsetRef.current = camera2DOffset;
}, [camera2DOffset]);
useEffect(() => {
transformModeRef.current = transformMode;
}, [transformMode]);
useEffect(() => {
snapEnabledRef.current = snapEnabled;
}, [snapEnabled]);
useEffect(() => {
gridSnapRef.current = gridSnapValue;
}, [gridSnapValue]);
useEffect(() => {
rotationSnapRef.current = rotationSnapValue;
}, [rotationSnapValue]);
useEffect(() => {
scaleSnapRef.current = scaleSnapValue;
}, [scaleSnapValue]);
}, [playState, camera2DZoom, camera2DOffset, transformMode, snapEnabled, gridSnapValue, rotationSnapValue, scaleSnapValue]);
// Snap helper functions
const snapToGrid = useCallback((value: number): number => {
@@ -351,6 +371,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}, []);
// Sync commandManager prop to ref | 同步 commandManager prop 到 ref
useEffect(() => {
commandManagerRef.current = commandManager ?? null;
}, [commandManager]);
// Canvas setup and input handling
useEffect(() => {
const canvas = canvasRef.current;
@@ -415,6 +440,21 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
// In transform mode, left click transforms entity
isDraggingTransformRef.current = true;
canvas.style.cursor = 'move';
// Capture initial transform state for undo/redo
// 捕获初始变换状态用于撤销/重做
const entity = selectedEntityRef.current;
if (entity) {
const transform = entity.getComponent(TransformComponent);
const uiTransform = entity.getComponent(UITransformComponent);
if (transform) {
initialTransformStateRef.current = TransformCommand.captureTransformState(transform);
transformComponentRef.current = transform;
} else if (uiTransform) {
initialTransformStateRef.current = TransformCommand.captureUITransformState(uiTransform);
transformComponentRef.current = uiTransform;
}
}
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
@@ -468,6 +508,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}
// Update live transform display | 更新实时变换显示
setLiveTransform({
type: mode as 'move' | 'rotate' | 'scale',
x: transform.position.x,
y: transform.position.y,
rotation: transform.rotation.z * 180 / Math.PI,
scaleX: transform.scale.x,
scaleY: transform.scale.y
});
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'position' : mode === 'rotate' ? 'rotation' : 'scale';
const value = propertyName === 'position' ? transform.position :
@@ -517,6 +567,16 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}
// Update live transform display for UI | 更新 UI 的实时变换显示
setLiveTransform({
type: mode as 'move' | 'rotate' | 'scale',
x: uiTransform.x,
y: uiTransform.y,
rotation: uiTransform.rotation * 180 / Math.PI,
scaleX: uiTransform.scaleX,
scaleY: uiTransform.scaleY
});
if (messageHubRef.current) {
const propertyName = mode === 'move' ? 'x' : mode === 'rotate' ? 'rotation' : 'scaleX';
messageHubRef.current.publish('component:property:changed', {
@@ -542,6 +602,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
if (isDraggingTransformRef.current) {
isDraggingTransformRef.current = false;
canvas.style.cursor = 'grab';
// Clear live transform display | 清除实时变换显示
setLiveTransform(null);
// Apply snap on mouse up
const entity = selectedEntityRef.current;
@@ -574,6 +636,36 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
}
// Create TransformCommand for undo/redo | 创建变换命令用于撤销/重做
const initialState = initialTransformStateRef.current;
const component = transformComponentRef.current;
const hub = messageHubRef.current;
const cmdManager = commandManagerRef.current;
if (entity && initialState && component && hub && cmdManager) {
const mode = transformModeRef.current as TransformOperationType;
let newState: TransformState;
if (component instanceof TransformComponent) {
newState = TransformCommand.captureTransformState(component);
} else {
newState = TransformCommand.captureUITransformState(component as UITransformComponent);
}
// Only create command if state actually changed | 只有状态实际改变时才创建命令
const hasChanged = JSON.stringify(initialState) !== JSON.stringify(newState);
if (hasChanged) {
const cmd = new TransformCommand(hub, entity, component, mode, initialState, newState);
// Push to undo stack without re-executing (already applied during drag)
// 推入撤销栈但不重新执行(拖动时已应用)
cmdManager.pushWithoutExecute(cmd);
}
}
// Clear refs | 清除引用
initialTransformStateRef.current = null;
transformComponentRef.current = null;
// Notify Inspector to refresh after transform change
if (messageHubRef.current && selectedEntityRef.current) {
messageHubRef.current.publish('entity:selected', {
@@ -839,8 +931,8 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
await TauriAPI.createDirectory(assetsDir);
}
// Collect all asset paths from scene
// 从场景中收集所有资产路径
// Collect all asset references from scene using generic collector
// 使用通用收集器从场景中收集所有资产引用
const sceneObj = JSON.parse(sceneData);
const assetPaths = new Set<string>();
// GUID 到路径的映射,用于需要通过 GUID 加载的资产
@@ -850,69 +942,65 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
// Get asset registry for resolving GUIDs
const assetRegistry = Core.services.tryResolve(AssetRegistryService);
// Scan all components for asset references
if (sceneObj.entities) {
for (const entity of sceneObj.entities) {
if (entity.components) {
for (const comp of entity.components) {
// Sprite textures
if (comp.type === 'Sprite' && comp.data?.texture) {
assetPaths.add(comp.data.texture);
}
// Behavior tree assets
if (comp.type === 'BehaviorTreeRuntime' && comp.data?.treeAssetId) {
assetPaths.add(comp.data.treeAssetId);
}
// Tilemap assets
if (comp.type === 'Tilemap' && comp.data?.tmxPath) {
assetPaths.add(comp.data.tmxPath);
}
// Audio assets
if (comp.type === 'AudioSource' && comp.data?.clip) {
assetPaths.add(comp.data.clip);
}
// Particle assets - resolve GUID to path
if (comp.type === 'ParticleSystem' && comp.data?.particleAssetGuid) {
const guid = comp.data.particleAssetGuid;
if (assetRegistry) {
const relativePath = assetRegistry.getPathByGuid(guid);
if (relativePath && projectPath) {
// Convert relative path to absolute path
// 将相对路径转换为绝对路径
const absolutePath = `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
assetPaths.add(absolutePath);
guidToPath.set(guid, absolutePath);
// Use generic asset collector to find all asset references
// 使用通用资产收集器找到所有资产引用
const assetReferences = collectAssetReferences(sceneObj);
// Also check for texture referenced in particle asset
// 同时检查粒子资产中引用的纹理
try {
const particleContent = await TauriAPI.readFileContent(absolutePath);
const particleData = JSON.parse(particleContent);
const textureRef = particleData.textureGuid || particleData.texturePath;
if (textureRef) {
// Check if it's a GUID or a path
if (textureRef.includes('-') && textureRef.length > 30) {
// Looks like a GUID
const textureRelPath = assetRegistry.getPathByGuid(textureRef);
if (textureRelPath && projectPath) {
const textureAbsPath = `${projectPath}\\${textureRelPath.replace(/\//g, '\\')}`;
assetPaths.add(textureAbsPath);
guidToPath.set(textureRef, textureAbsPath);
}
} else {
// It's a path
const textureAbsPath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
assetPaths.add(textureAbsPath);
}
}
} catch {
// Ignore parse errors
}
}
}
// Helper: check if value looks like a GUID
const isGuidLike = (value: string) =>
value.includes('-') && value.length >= 30 && value.length <= 40;
// Helper: resolve GUID to absolute path
const resolveGuidToPath = (guid: string): string | null => {
if (!assetRegistry || !projectPath) return null;
const relativePath = assetRegistry.getPathByGuid(guid);
if (!relativePath) return null;
return `${projectPath}\\${relativePath.replace(/\//g, '\\')}`;
};
// Helper: load particle asset and extract texture references
const loadParticleTextures = async (particlePath: string) => {
try {
const particleContent = await TauriAPI.readFileContent(particlePath);
const particleData = JSON.parse(particleContent);
const textureRef = particleData.textureGuid || particleData.texturePath;
if (textureRef) {
if (isGuidLike(textureRef)) {
const texturePath = resolveGuidToPath(textureRef);
if (texturePath) {
assetPaths.add(texturePath);
guidToPath.set(textureRef, texturePath);
}
} else if (projectPath) {
const texturePath = `${projectPath}\\${textureRef.replace(/\//g, '\\')}`;
assetPaths.add(texturePath);
}
}
} catch {
// Ignore parse errors
}
};
// Process collected asset references
// 处理收集的资产引用
for (const ref of assetReferences) {
const value = ref.guid;
// Check if it's a GUID that needs resolution
if (isGuidLike(value)) {
const absolutePath = resolveGuidToPath(value);
if (absolutePath) {
assetPaths.add(absolutePath);
guidToPath.set(value, absolutePath);
// If it's a particle asset, also load its texture references
if (absolutePath.endsWith('.particle') || absolutePath.endsWith('.particle.json')) {
await loadParticleTextures(absolutePath);
}
}
} else {
// It's a direct path
assetPaths.add(value);
}
}
@@ -931,9 +1019,11 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
}
// Get filename and determine relative path
// 路径格式:相对于 assets 目录,不包含 'assets/' 前缀
// Path format: relative to assets directory, without 'assets/' prefix
const filename = assetPath.split(/[/\\]/).pop() || '';
const destPath = `${assetsDir}\\${filename}`;
const relativePath = `assets/${filename}`;
const relativePath = filename;
// Copy file
await TauriAPI.copyFile(assetPath, destPath);
@@ -1200,6 +1290,68 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
};
}, [messageHub]);
// Subscribe to prefab edit mode changes | 监听预制体编辑模式变化
useEffect(() => {
if (!messageHub) return;
const unsubscribePrefabEditMode = messageHub.subscribe('prefab:editMode:changed', (data: {
isActive: boolean;
prefabPath?: string;
prefabName?: string;
}) => {
if (data.isActive && data.prefabName && data.prefabPath) {
setPrefabEditMode({
isActive: true,
prefabName: data.prefabName,
prefabPath: data.prefabPath
});
} else {
setPrefabEditMode(null);
}
});
// Check initial prefab edit mode state | 检查初始预制体编辑模式状态
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager) {
const prefabState = sceneManager.getPrefabEditModeState?.();
if (prefabState?.isActive) {
setPrefabEditMode({
isActive: true,
prefabName: prefabState.prefabName,
prefabPath: prefabState.prefabPath
});
}
}
return () => {
unsubscribePrefabEditMode();
};
}, [messageHub]);
// Handle prefab save | 处理预制体保存
const handleSavePrefab = useCallback(async () => {
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager?.isPrefabEditMode?.()) {
try {
await sceneManager.savePrefab();
} catch (error) {
console.error('Failed to save prefab:', error);
}
}
}, []);
// Handle exit prefab edit mode | 处理退出预制体编辑模式
const handleExitPrefabEditMode = useCallback(async (save: boolean = false) => {
const sceneManager = Core.services.tryResolve(SceneManagerService);
if (sceneManager?.isPrefabEditMode?.()) {
try {
await sceneManager.exitPrefabEditMode(save);
} catch (error) {
console.error('Failed to exit prefab edit mode:', error);
}
}
}, []);
const handleFullscreen = () => {
if (containerRef.current) {
if (document.fullscreenElement) {
@@ -1271,8 +1423,110 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
/**
*
* Handle viewport drag-drop (for prefab instantiation)
*/
const handleViewportDragOver = useCallback((e: React.DragEvent) => {
const hasAssetPath = e.dataTransfer.types.includes('asset-path');
if (hasAssetPath) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}
}, []);
const handleViewportDrop = useCallback(async (e: React.DragEvent) => {
const assetPath = e.dataTransfer.getData('asset-path');
if (!assetPath || !assetPath.toLowerCase().endsWith('.prefab')) {
return;
}
e.preventDefault();
try {
// 读取预制体文件 | Read prefab file
const prefabJson = await TauriAPI.readFileContent(assetPath);
const prefabData = PrefabSerializer.deserialize(prefabJson);
// 获取服务 | Get services
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
if (!entityStore || !messageHub || !commandManager) {
console.error('[Viewport] Required services not available');
return;
}
// 计算放置位置(将屏幕坐标转换为世界坐标)| Calculate drop position (convert screen to world)
const canvas = canvasRef.current;
let worldPos = { x: 0, y: 0 };
if (canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const screenX = e.clientX - rect.left;
const screenY = e.clientY - rect.top;
const canvasX = screenX * dpr;
const canvasY = screenY * dpr;
const centeredX = canvasX - canvas.width / 2;
const centeredY = canvas.height / 2 - canvasY;
worldPos = {
x: centeredX / camera2DZoomRef.current - camera2DOffsetRef.current.x,
y: centeredY / camera2DZoomRef.current - camera2DOffsetRef.current.y
};
}
// 创建实例化命令 | Create instantiate command
const command = new InstantiatePrefabCommand(
entityStore,
messageHub,
prefabData,
{
position: worldPos,
trackInstance: true
}
);
commandManager.execute(command);
console.log(`[Viewport] Prefab instantiated at (${worldPos.x.toFixed(0)}, ${worldPos.y.toFixed(0)}): ${prefabData.metadata.name}`);
} catch (error) {
console.error('[Viewport] Failed to instantiate prefab:', error);
}
}, [messageHub, commandManager]);
return (
<div className="viewport" ref={containerRef}>
<div
className={`viewport ${prefabEditMode?.isActive ? 'prefab-edit-mode' : ''}`}
ref={containerRef}
onDragOver={handleViewportDragOver}
onDrop={handleViewportDrop}
>
{/* Prefab Edit Mode Toolbar | 预制体编辑模式工具栏 */}
{prefabEditMode?.isActive && (
<div className="viewport-prefab-toolbar">
<div className="viewport-prefab-toolbar-left">
<PackageOpen size={14} />
<span className="prefab-name">{t('viewport.prefab.editing') || 'Editing'}: {prefabEditMode.prefabName}</span>
</div>
<div className="viewport-prefab-toolbar-right">
<button
className="viewport-prefab-btn save"
onClick={handleSavePrefab}
title={t('viewport.prefab.save') || 'Save Prefab'}
>
<Save size={14} />
<span>{t('viewport.prefab.save') || 'Save'}</span>
</button>
<button
className="viewport-prefab-btn exit"
onClick={() => handleExitPrefabEditMode(false)}
title={t('viewport.prefab.exit') || 'Exit Edit Mode'}
>
<X size={14} />
<span>{t('viewport.prefab.exit') || 'Exit'}</span>
</button>
</div>
</div>
)}
{/* Internal Overlay Toolbar */}
<div className="viewport-internal-toolbar">
<div className="viewport-internal-toolbar-left">
@@ -1505,6 +1759,34 @@ export function Viewport({ locale = 'en', messageHub }: ViewportProps) {
</div>
)}
{/* Live Transform Display | 实时变换显示 */}
{liveTransform && (
<div className="viewport-live-transform">
{liveTransform.type === 'move' && (
<>
<span className="live-transform-label">X:</span>
<span className="live-transform-value">{liveTransform.x.toFixed(1)}</span>
<span className="live-transform-label">Y:</span>
<span className="live-transform-value">{liveTransform.y.toFixed(1)}</span>
</>
)}
{liveTransform.type === 'rotate' && (
<>
<span className="live-transform-label">R:</span>
<span className="live-transform-value">{liveTransform.rotation?.toFixed(1)}°</span>
</>
)}
{liveTransform.type === 'scale' && (
<>
<span className="live-transform-label">SX:</span>
<span className="live-transform-value">{liveTransform.scaleX?.toFixed(2)}</span>
<span className="live-transform-label">SY:</span>
<span className="live-transform-value">{liveTransform.scaleY?.toFixed(2)}</span>
</>
)}
</div>
)}
<QRCodeDialog
url={devicePreviewUrl}
isOpen={showQRDialog}
@@ -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">&#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>
);
}
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More