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:
@@ -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
|
||||
|
||||
|
||||
@@ -48,6 +48,14 @@ logs/
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# 代码签名证书(敏感文件)
|
||||
certs/
|
||||
*.pfx
|
||||
*.p12
|
||||
*.cer
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# 测试覆盖率
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
// 优先使用 getOrLoadTextureByPath(Rust 分配 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
|
||||
// 优先使用 getOrLoadTextureByPath(Rust 分配 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"id": "audio",
|
||||
"name": "@esengine/audio",
|
||||
"globalKey": "audio",
|
||||
"displayName": "Audio",
|
||||
"description": "Audio playback and sound effects | 音频播放和音效",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
{
|
||||
"id": "camera",
|
||||
"name": "@esengine/camera",
|
||||
"globalKey": "camera",
|
||||
"displayName": "Camera",
|
||||
"description": "Camera and viewport management | 相机和视口管理",
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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
-4
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
* 实体ID(运行时ID)
|
||||
*
|
||||
* 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 是否保持原始ID(默认false)
|
||||
* @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';
|
||||
|
||||
@@ -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';
|
||||
@@ -8,3 +8,4 @@ export * from './Debug';
|
||||
export * from './Logger';
|
||||
export * from './BinarySerializer';
|
||||
export * from './Profiler';
|
||||
export * from './GUID';
|
||||
|
||||
@@ -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.
|
||||
* 销毁桥接并释放资源。
|
||||
|
||||
@@ -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. 世界阶段:世界 Sprite、瓦片地图、Gizmo(受世界相机影响)
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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">📦</span>
|
||||
<span className="prefab-label">{t('inspector.prefab.source')}:</span>
|
||||
<span className="prefab-name" title={prefabPath}>{prefabName}</span>
|
||||
{hasModifications && (
|
||||
<span className="prefab-modified-badge" title={t('inspector.prefab.modifications', { count: modificationCount })}>
|
||||
{modificationCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prefab-instance-actions">
|
||||
<button
|
||||
className="prefab-action-btn"
|
||||
onClick={handleOpen}
|
||||
title={t('inspector.prefab.open')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('inspector.prefab.open')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn"
|
||||
onClick={handleSelect}
|
||||
title={t('inspector.prefab.select')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{t('inspector.prefab.select')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-revert"
|
||||
onClick={handleRevert}
|
||||
title={t('inspector.prefab.revertAll')}
|
||||
disabled={isProcessing || !hasModifications}
|
||||
>
|
||||
{t('inspector.prefab.revert')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-apply"
|
||||
onClick={handleApply}
|
||||
title={t('inspector.prefab.applyAll')}
|
||||
disabled={isProcessing || !hasModifications}
|
||||
>
|
||||
{t('inspector.prefab.apply')}
|
||||
</button>
|
||||
<button
|
||||
className="prefab-action-btn prefab-action-unpack"
|
||||
onClick={handleUnpack}
|
||||
title={t('inspector.prefab.unpack')}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
⛓
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.asset-field__label {
|
||||
|
||||
@@ -119,18 +119,18 @@ export function AssetField({
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (readonly) return;
|
||||
if (readonly || !assetRegistry) return;
|
||||
|
||||
// Try to get GUID from drag data first
|
||||
const assetGuid = e.dataTransfer.getData('asset-guid');
|
||||
if (assetGuid && isGUID(assetGuid)) {
|
||||
// Validate extension if needed
|
||||
if (fileExtension && assetRegistry) {
|
||||
if (fileExtension) {
|
||||
const path = assetRegistry.getPathByGuid(assetGuid);
|
||||
if (path && !path.endsWith(fileExtension)) {
|
||||
return; // Extension mismatch
|
||||
@@ -140,50 +140,63 @@ export function AssetField({
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: handle asset-path and convert to GUID
|
||||
// Handle asset-path: convert to GUID or register
|
||||
const assetPath = e.dataTransfer.getData('asset-path');
|
||||
if (assetPath && (!fileExtension || assetPath.endsWith(fileExtension))) {
|
||||
// Try to get GUID from path
|
||||
if (assetRegistry) {
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = assetPath;
|
||||
if (assetPath.includes(':') || assetPath.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
|
||||
}
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = assetPath;
|
||||
if (assetPath.includes(':') || assetPath.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(assetPath) || assetPath;
|
||||
}
|
||||
|
||||
// 尝试多种路径格式 | Try multiple path formats
|
||||
const pathVariants = [relativePath, relativePath.replace(/\\/g, '/')];
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
onChange(guid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback to path if GUID not found (backward compatibility)
|
||||
onChange(assetPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle file drops
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const file = files.find((f) =>
|
||||
!fileExtension || f.name.endsWith(fileExtension)
|
||||
);
|
||||
|
||||
if (file) {
|
||||
// For file drops, we still use filename (need to register first)
|
||||
onChange(file.name);
|
||||
|
||||
// GUID 不存在,尝试注册 | GUID not found, try to register
|
||||
const absolutePath = assetPath.includes(':') ? assetPath : null;
|
||||
if (absolutePath) {
|
||||
try {
|
||||
const newGuid = await assetRegistry.registerAsset(absolutePath);
|
||||
if (newGuid) {
|
||||
console.log(`[AssetField] Registered dropped asset with GUID: ${newGuid}`);
|
||||
onChange(newGuid);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetField] Failed to register dropped asset:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.error(`[AssetField] Cannot use dropped asset without GUID: "${assetPath}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle text/plain drops (might be GUID or path)
|
||||
const text = e.dataTransfer.getData('text/plain');
|
||||
if (text && (!fileExtension || text.endsWith(fileExtension))) {
|
||||
// Try to convert to GUID if it's a path
|
||||
if (assetRegistry && !isGUID(text)) {
|
||||
const guid = assetRegistry.getGuidByPath(text);
|
||||
if (isGUID(text)) {
|
||||
onChange(text);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get GUID from path
|
||||
const pathVariants = [text, text.replace(/\\/g, '/')];
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
onChange(guid);
|
||||
return;
|
||||
}
|
||||
}
|
||||
onChange(text);
|
||||
|
||||
console.error(`[AssetField] Cannot use dropped text without GUID: "${text}"`);
|
||||
}
|
||||
}, [onChange, fileExtension, readonly, assetRegistry]);
|
||||
|
||||
@@ -192,23 +205,60 @@ export function AssetField({
|
||||
setShowPicker(true);
|
||||
}, [readonly]);
|
||||
|
||||
const handlePickerSelect = useCallback((path: string) => {
|
||||
// Convert path to GUID if possible
|
||||
if (assetRegistry) {
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = path;
|
||||
if (path.includes(':') || path.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(path) || path;
|
||||
}
|
||||
const guid = assetRegistry.getGuidByPath(relativePath);
|
||||
const handlePickerSelect = useCallback(async (path: string) => {
|
||||
// Convert path to GUID - 必须使用 GUID,不能使用路径!
|
||||
// Must use GUID, cannot use path!
|
||||
if (!assetRegistry) {
|
||||
console.error(`[AssetField] AssetRegistry not available, cannot select asset`);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Path might be absolute, convert to relative first
|
||||
let relativePath = path;
|
||||
if (path.includes(':') || path.startsWith('/')) {
|
||||
relativePath = assetRegistry.absoluteToRelative(path) || path;
|
||||
}
|
||||
|
||||
// 尝试多种路径格式 | Try multiple path formats
|
||||
const pathVariants = [
|
||||
relativePath,
|
||||
relativePath.replace(/\\/g, '/'), // 统一为正斜杠
|
||||
];
|
||||
|
||||
for (const variant of pathVariants) {
|
||||
const guid = assetRegistry.getGuidByPath(variant);
|
||||
if (guid) {
|
||||
console.log(`[AssetField] Found GUID for path "${path}": ${guid}`);
|
||||
onChange(guid);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Fallback to path if GUID not found
|
||||
onChange(path);
|
||||
|
||||
// GUID 不存在,尝试注册资产(创建 .meta 文件)
|
||||
// GUID not found, try to register asset (create .meta file)
|
||||
console.warn(`[AssetField] GUID not found for path "${path}", registering asset...`);
|
||||
|
||||
try {
|
||||
// 使用绝对路径注册 | Register using absolute path
|
||||
const absolutePath = path.includes(':') ? path : null;
|
||||
if (absolutePath) {
|
||||
const newGuid = await assetRegistry.registerAsset(absolutePath);
|
||||
if (newGuid) {
|
||||
console.log(`[AssetField] Registered new asset with GUID: ${newGuid}`);
|
||||
onChange(newGuid);
|
||||
setShowPicker(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[AssetField] Failed to register asset:`, error);
|
||||
}
|
||||
|
||||
// 注册失败,不能使用路径(会导致打包后找不到)
|
||||
// Registration failed, cannot use path (will fail after build)
|
||||
console.error(`[AssetField] Cannot use asset without GUID: "${path}". Please ensure the asset is in a managed directory (assets/, scripts/, scenes/).`);
|
||||
setShowPicker(false);
|
||||
}, [onChange, assetRegistry]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Setting
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { Core } from '@esengine/ecs-framework';
|
||||
import { AssetRegistryService } from '@esengine/editor-core';
|
||||
import { assetManager as globalAssetManager } from '@esengine/asset-system';
|
||||
import { EngineService } from '../../../services/EngineService';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
@@ -77,7 +77,8 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
|
||||
setDetectedType(meta.type);
|
||||
|
||||
// Get available loader types from assetManager
|
||||
const loaderFactory = globalAssetManager.getLoaderFactory();
|
||||
const assetManager = EngineService.getInstance().getAssetManager();
|
||||
const loaderFactory = assetManager?.getLoaderFactory();
|
||||
const registeredTypes = loaderFactory?.getRegisteredTypes() || [];
|
||||
|
||||
// Combine built-in types with registered types (deduplicated)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Settings, ChevronDown, ChevronRight, X, Plus, Box, Search, Lock, Unlock } from 'lucide-react';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry } from '@esengine/editor-core';
|
||||
import { Entity, Component, Core, getComponentDependencies, getComponentTypeName, getComponentInstanceTypeName, isComponentInstanceHiddenInInspector, PrefabInstanceComponent } from '@esengine/ecs-framework';
|
||||
import { MessageHub, CommandManager, ComponentRegistry, ComponentActionRegistry, ComponentInspectorRegistry, PrefabService } from '@esengine/editor-core';
|
||||
import { PropertyInspector } from '../../PropertyInspector';
|
||||
import { NotificationService } from '../../../services/NotificationService';
|
||||
import { RemoveComponentCommand, UpdateComponentCommand, AddComponentCommand } from '../../../application/commands/component';
|
||||
import { PrefabInstanceInfo } from '../common/PrefabInstanceInfo';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
@@ -35,19 +36,49 @@ interface EntityInspectorProps {
|
||||
messageHub: MessageHub;
|
||||
commandManager: CommandManager;
|
||||
componentVersion: number;
|
||||
/** 是否锁定检视器 | Whether inspector is locked */
|
||||
isLocked?: boolean;
|
||||
/** 锁定状态变化回调 | Lock state change callback */
|
||||
onLockChange?: (locked: boolean) => void;
|
||||
}
|
||||
|
||||
export function EntityInspector({ entity, messageHub, commandManager, componentVersion }: EntityInspectorProps) {
|
||||
const [expandedComponents, setExpandedComponents] = useState<Set<number>>(() => {
|
||||
// 默认展开所有组件
|
||||
return new Set(entity.components.map((_, index) => index));
|
||||
export function EntityInspector({
|
||||
entity,
|
||||
messageHub,
|
||||
commandManager,
|
||||
componentVersion,
|
||||
isLocked = false,
|
||||
onLockChange
|
||||
}: EntityInspectorProps) {
|
||||
// 使用组件类型名追踪折叠状态(持久化到 localStorage)
|
||||
// Use component type names to track collapsed state (persisted to localStorage)
|
||||
const [collapsedComponentTypes, setCollapsedComponentTypes] = useState<Set<string>>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('inspector-collapsed-components');
|
||||
return saved ? new Set(JSON.parse(saved)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// 保存折叠状态到 localStorage | Save collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
'inspector-collapsed-components',
|
||||
JSON.stringify([...collapsedComponentTypes])
|
||||
);
|
||||
} catch {
|
||||
// Ignore localStorage errors
|
||||
}
|
||||
}, [collapsedComponentTypes]);
|
||||
|
||||
const [showComponentMenu, setShowComponentMenu] = useState(false);
|
||||
const [localVersion, setLocalVersion] = useState(0);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; right: number } | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(new Set());
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
const [selectedComponentIndex, setSelectedComponentIndex] = useState(-1);
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const [propertySearchQuery, setPropertySearchQuery] = useState('');
|
||||
const addButtonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -56,29 +87,13 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
const componentRegistry = Core.services.resolve(ComponentRegistry);
|
||||
const componentActionRegistry = Core.services.resolve(ComponentActionRegistry);
|
||||
const componentInspectorRegistry = Core.services.resolve(ComponentInspectorRegistry);
|
||||
const prefabService = Core.services.tryResolve(PrefabService) as PrefabService | null;
|
||||
const availableComponents = (componentRegistry?.getAllComponents() || []) as ComponentInfo[];
|
||||
|
||||
// 当 entity 变化或组件数量变化时,更新展开状态(新组件默认展开)
|
||||
// 注意:不要依赖 componentVersion,否则每次属性变化都会重置展开状态
|
||||
useEffect(() => {
|
||||
setExpandedComponents((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
// 只添加新增组件的索引(保留已有的展开/收缩状态)
|
||||
entity.components.forEach((_, index) => {
|
||||
// 只有当索引不在集合中时才添加(即新组件)
|
||||
if (!prev.has(index) && index >= prev.size) {
|
||||
newSet.add(index);
|
||||
}
|
||||
});
|
||||
// 移除不存在的索引(组件被删除的情况)
|
||||
for (const idx of prev) {
|
||||
if (idx >= entity.components.length) {
|
||||
newSet.delete(idx);
|
||||
}
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, [entity, entity.components.length]);
|
||||
// 检查实体是否为预制体实例 | Check if entity is a prefab instance
|
||||
const isPrefabInstance = useMemo(() => {
|
||||
return entity.hasComponent(PrefabInstanceComponent);
|
||||
}, [entity, componentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showComponentMenu && addButtonRef.current) {
|
||||
@@ -121,6 +136,46 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
return grouped;
|
||||
}, [availableComponents, searchQuery]);
|
||||
|
||||
// 创建扁平化的可见组件列表(用于键盘导航)
|
||||
// Create flat list of visible components for keyboard navigation
|
||||
const flatVisibleComponents = useMemo(() => {
|
||||
const result: ComponentInfo[] = [];
|
||||
for (const [category, components] of filteredAndGroupedComponents.entries()) {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
if (!isCollapsed) {
|
||||
result.push(...components);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [filteredAndGroupedComponents, collapsedCategories, searchQuery]);
|
||||
|
||||
// 重置选中索引当搜索变化时 | Reset selected index when search changes
|
||||
useEffect(() => {
|
||||
setSelectedComponentIndex(searchQuery ? 0 : -1);
|
||||
}, [searchQuery]);
|
||||
|
||||
// 处理组件搜索的键盘导航 | Handle keyboard navigation for component search
|
||||
const handleComponentSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedComponentIndex(prev =>
|
||||
prev < flatVisibleComponents.length - 1 ? prev + 1 : prev
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedComponentIndex(prev => prev > 0 ? prev - 1 : 0);
|
||||
} else if (e.key === 'Enter' && selectedComponentIndex >= 0) {
|
||||
e.preventDefault();
|
||||
const selectedComponent = flatVisibleComponents[selectedComponentIndex];
|
||||
if (selectedComponent?.type) {
|
||||
handleAddComponent(selectedComponent.type);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowComponentMenu(false);
|
||||
}
|
||||
}, [flatVisibleComponents, selectedComponentIndex]);
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setCollapsedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
@@ -130,13 +185,15 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
});
|
||||
};
|
||||
|
||||
const toggleComponentExpanded = (index: number) => {
|
||||
setExpandedComponents((prev) => {
|
||||
const toggleComponentExpanded = (componentTypeName: string) => {
|
||||
setCollapsedComponentTypes((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
if (newSet.has(componentTypeName)) {
|
||||
// 已折叠,展开它 | Was collapsed, expand it
|
||||
newSet.delete(componentTypeName);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
// 已展开,折叠它 | Was expanded, collapse it
|
||||
newSet.add(componentTypeName);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -244,6 +301,12 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
|
||||
const filteredComponents = useMemo(() => {
|
||||
return entity.components.filter((component: Component) => {
|
||||
// 过滤掉标记为隐藏的组件(如 Hierarchy, PrefabInstance)
|
||||
// Filter out components marked as hidden (e.g., Hierarchy, PrefabInstance)
|
||||
if (isComponentInstanceHiddenInInspector(component)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
@@ -271,7 +334,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
<div className="inspector-header-left">
|
||||
<button
|
||||
className={`inspector-lock-btn ${isLocked ? 'locked' : ''}`}
|
||||
onClick={() => setIsLocked(!isLocked)}
|
||||
onClick={() => onLockChange?.(!isLocked)}
|
||||
title={isLocked ? '解锁检视器' : '锁定检视器'}
|
||||
>
|
||||
{isLocked ? <Lock size={14} /> : <Unlock size={14} />}
|
||||
@@ -282,6 +345,16 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
<span className="inspector-object-count">1 object</span>
|
||||
</div>
|
||||
|
||||
{/* Prefab Instance Info | 预制体实例信息 */}
|
||||
{isPrefabInstance && prefabService && (
|
||||
<PrefabInstanceInfo
|
||||
entity={entity}
|
||||
prefabService={prefabService}
|
||||
messageHub={messageHub}
|
||||
commandManager={commandManager}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Search Box */}
|
||||
<div className="inspector-search">
|
||||
<Search size={14} />
|
||||
@@ -290,7 +363,27 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
placeholder="Search..."
|
||||
value={propertySearchQuery}
|
||||
onChange={(e) => setPropertySearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' && propertySearchQuery) {
|
||||
e.preventDefault();
|
||||
setPropertySearchQuery('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{propertySearchQuery && (
|
||||
<button
|
||||
className="inspector-search-clear"
|
||||
onClick={() => setPropertySearchQuery('')}
|
||||
title="Clear"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
{propertySearchQuery && (
|
||||
<span className="inspector-search-count">
|
||||
{filteredComponents.length} / {entity.components.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
@@ -335,6 +428,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
placeholder="搜索组件..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleComponentSearchKeyDown}
|
||||
/>
|
||||
</div>
|
||||
{filteredAndGroupedComponents.size === 0 ? (
|
||||
@@ -343,35 +437,45 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-dropdown-list">
|
||||
{Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
const label = categoryLabels[category] || category;
|
||||
return (
|
||||
<div key={category} className="component-category-group">
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className="component-dropdown-item"
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
let globalIndex = 0;
|
||||
return Array.from(filteredAndGroupedComponents.entries()).map(([category, components]) => {
|
||||
const isCollapsed = collapsedCategories.has(category) && !searchQuery;
|
||||
const label = categoryLabels[category] || category;
|
||||
const startIndex = globalIndex;
|
||||
if (!isCollapsed) {
|
||||
globalIndex += components.length;
|
||||
}
|
||||
return (
|
||||
<div key={category} className="component-category-group">
|
||||
<button
|
||||
className="component-category-header"
|
||||
onClick={() => toggleCategory(category)}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
<span>{label}</span>
|
||||
<span className="component-category-count">{components.length}</span>
|
||||
</button>
|
||||
{!isCollapsed && components.map((info, idx) => {
|
||||
const IconComp = info.icon && (LucideIcons as any)[info.icon];
|
||||
const itemIndex = startIndex + idx;
|
||||
const isSelected = itemIndex === selectedComponentIndex;
|
||||
return (
|
||||
<button
|
||||
key={info.name}
|
||||
className={`component-dropdown-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => info.type && handleAddComponent(info.type)}
|
||||
onMouseEnter={() => setSelectedComponentIndex(itemIndex)}
|
||||
>
|
||||
{IconComp ? <IconComp size={14} /> : <Box size={14} />}
|
||||
<span className="component-dropdown-item-name">{info.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -386,8 +490,9 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
) : (
|
||||
filteredComponents.map((component: Component) => {
|
||||
const originalIndex = entity.components.indexOf(component);
|
||||
const isExpanded = expandedComponents.has(originalIndex);
|
||||
const componentName = getComponentInstanceTypeName(component);
|
||||
// 使用组件类型名判断展开状态(未在折叠集合中 = 展开)
|
||||
const isExpanded = !collapsedComponentTypes.has(componentName);
|
||||
const componentInfo = componentRegistry?.getComponent(componentName);
|
||||
const iconName = (componentInfo as { icon?: string } | undefined)?.icon;
|
||||
const IconComponent = iconName && (LucideIcons as unknown as Record<string, React.ComponentType<{ size?: number }>>)[iconName];
|
||||
@@ -399,7 +504,7 @@ export function EntityInspector({ entity, messageHub, commandManager, componentV
|
||||
>
|
||||
<div
|
||||
className="component-item-header"
|
||||
onClick={() => toggleComponentExpanded(originalIndex)}
|
||||
onClick={() => toggleComponentExpanded(componentName)}
|
||||
>
|
||||
<span className="component-expand-icon">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* 预制体检查器
|
||||
* Prefab Inspector
|
||||
*
|
||||
* 显示预制体文件的信息、实体层级预览和实例化功能。
|
||||
* Displays prefab file information, entity hierarchy preview, and instantiation features.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
PackageOpen, Box, Layers, Clock, HardDrive, Tag, Play, ChevronRight, ChevronDown
|
||||
} from 'lucide-react';
|
||||
import { Core, PrefabSerializer } from '@esengine/ecs-framework';
|
||||
import type { PrefabData, SerializedPrefabEntity } from '@esengine/ecs-framework';
|
||||
import { EntityStoreService, MessageHub, CommandManager } from '@esengine/editor-core';
|
||||
import { TauriAPI } from '../../../api/tauri';
|
||||
import { InstantiatePrefabCommand } from '../../../application/commands/prefab/InstantiatePrefabCommand';
|
||||
import { AssetFileInfo } from '../types';
|
||||
import '../../../styles/EntityInspector.css';
|
||||
|
||||
interface PrefabInspectorProps {
|
||||
fileInfo: AssetFileInfo;
|
||||
messageHub?: MessageHub;
|
||||
commandManager?: CommandManager;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatDate(timestamp?: number): string {
|
||||
if (!timestamp) return '未知';
|
||||
// 如果是毫秒级时间戳,不需要转换 | If millisecond timestamp, no conversion needed
|
||||
const ts = timestamp > 1e12 ? timestamp : timestamp * 1000;
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 实体层级节点组件
|
||||
* Entity hierarchy node component
|
||||
*/
|
||||
function EntityNode({ entity, depth = 0 }: { entity: SerializedPrefabEntity; depth?: number }) {
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
const hasChildren = entity.children && entity.children.length > 0;
|
||||
const componentCount = entity.components?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="prefab-entity-node">
|
||||
<div
|
||||
className="prefab-entity-row"
|
||||
style={{ paddingLeft: `${depth * 16 + 8}px` }}
|
||||
onClick={() => hasChildren && setExpanded(!expanded)}
|
||||
>
|
||||
<span className="prefab-entity-expand">
|
||||
{hasChildren ? (
|
||||
expanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
</span>
|
||||
<Box size={14} className="prefab-entity-icon" />
|
||||
<span className="prefab-entity-name">{entity.name}</span>
|
||||
<span className="prefab-entity-components">
|
||||
({componentCount} 组件)
|
||||
</span>
|
||||
</div>
|
||||
{expanded && hasChildren && (
|
||||
<div className="prefab-entity-children">
|
||||
{entity.children.map((child, index) => (
|
||||
<EntityNode
|
||||
key={child.id || index}
|
||||
entity={child as SerializedPrefabEntity}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrefabInspector({ fileInfo, messageHub, commandManager }: PrefabInspectorProps) {
|
||||
const [prefabData, setPrefabData] = useState<PrefabData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [instantiating, setInstantiating] = useState(false);
|
||||
|
||||
// 加载预制体数据 | Load prefab data
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadPrefab() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const content = await TauriAPI.readFileContent(fileInfo.path);
|
||||
const data = PrefabSerializer.deserialize(content);
|
||||
|
||||
// 验证预制体数据 | Validate prefab data
|
||||
const validation = PrefabSerializer.validate(data);
|
||||
if (!validation.valid) {
|
||||
throw new Error(`无效的预制体: ${validation.errors?.join(', ')}`);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setPrefabData(data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : '加载预制体失败');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadPrefab();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [fileInfo.path]);
|
||||
|
||||
// 实例化预制体 | Instantiate prefab
|
||||
const handleInstantiate = useCallback(async () => {
|
||||
if (!prefabData || instantiating) return;
|
||||
|
||||
setInstantiating(true);
|
||||
try {
|
||||
// 从 Core.services 获取服务,使用 tryResolve 避免类型问题
|
||||
// Get services from Core.services, use tryResolve to avoid type issues
|
||||
const entityStore = Core.services.tryResolve(EntityStoreService) as EntityStoreService | null;
|
||||
const hub = messageHub || Core.services.tryResolve(MessageHub) as MessageHub | null;
|
||||
const cmdManager = commandManager;
|
||||
|
||||
if (!entityStore || !hub || !cmdManager) {
|
||||
throw new Error('必要的服务未初始化 | Required services not initialized');
|
||||
}
|
||||
|
||||
const command = new InstantiatePrefabCommand(
|
||||
entityStore,
|
||||
hub,
|
||||
prefabData,
|
||||
{ trackInstance: true }
|
||||
);
|
||||
cmdManager.execute(command);
|
||||
|
||||
console.log(`[PrefabInspector] Prefab instantiated: ${prefabData.metadata.name}`);
|
||||
} catch (err) {
|
||||
console.error('[PrefabInspector] Failed to instantiate prefab:', err);
|
||||
} finally {
|
||||
setInstantiating(false);
|
||||
}
|
||||
}, [prefabData, instantiating, messageHub, commandManager]);
|
||||
|
||||
// 统计实体和组件数量 | Count entities and components
|
||||
const countEntities = useCallback((entity: SerializedPrefabEntity): { entities: number; components: number } => {
|
||||
let entities = 1;
|
||||
let components = entity.components?.length || 0;
|
||||
|
||||
if (entity.children) {
|
||||
for (const child of entity.children) {
|
||||
const childCounts = countEntities(child as SerializedPrefabEntity);
|
||||
entities += childCounts.entities;
|
||||
components += childCounts.components;
|
||||
}
|
||||
}
|
||||
|
||||
return { entities, components };
|
||||
}, []);
|
||||
|
||||
const counts = prefabData ? countEntities(prefabData.root) : { entities: 0, components: 0 };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#4ade80' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#666' }}>
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="entity-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#f87171' }} />
|
||||
<span className="entity-name">{fileInfo.name}</span>
|
||||
</div>
|
||||
<div className="inspector-content">
|
||||
<div className="inspector-section">
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: '#f87171' }}>
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="entity-inspector prefab-inspector">
|
||||
<div className="inspector-header">
|
||||
<PackageOpen size={16} style={{ color: '#4ade80' }} />
|
||||
<span className="entity-name">{prefabData?.metadata.name || fileInfo.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="inspector-content">
|
||||
{/* 预制体信息 | Prefab Information */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">预制体信息</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">版本</label>
|
||||
<span className="property-value-text">v{prefabData?.version}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Layers size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
实体数量
|
||||
</label>
|
||||
<span className="property-value-text">{counts.entities}</span>
|
||||
</div>
|
||||
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Box size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
组件总数
|
||||
</label>
|
||||
<span className="property-value-text">{counts.components}</span>
|
||||
</div>
|
||||
|
||||
{prefabData?.metadata.description && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">描述</label>
|
||||
<span className="property-value-text">{prefabData.metadata.description}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.tags && prefabData.metadata.tags.length > 0 && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Tag size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
标签
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{prefabData.metadata.tags.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文件信息 | File Information */}
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">文件信息</div>
|
||||
|
||||
{fileInfo.size !== undefined && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<HardDrive size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
大小
|
||||
</label>
|
||||
<span className="property-value-text">{formatFileSize(fileInfo.size)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.createdAt && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
创建时间
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{formatDate(prefabData.metadata.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{prefabData?.metadata.modifiedAt && (
|
||||
<div className="property-field">
|
||||
<label className="property-label">
|
||||
<Clock size={14} style={{ verticalAlign: 'middle', marginRight: '4px' }} />
|
||||
修改时间
|
||||
</label>
|
||||
<span className="property-value-text">
|
||||
{formatDate(prefabData.metadata.modifiedAt)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 组件类型 | Component Types */}
|
||||
{prefabData?.metadata.componentTypes && prefabData.metadata.componentTypes.length > 0 && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">组件类型</div>
|
||||
<div className="prefab-component-types">
|
||||
{prefabData.metadata.componentTypes.map((type) => (
|
||||
<span key={type} className="prefab-component-type-tag">
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 实体层级 | Entity Hierarchy */}
|
||||
{prefabData?.root && (
|
||||
<div className="inspector-section">
|
||||
<div className="section-title">实体层级</div>
|
||||
<div className="prefab-hierarchy">
|
||||
<EntityNode entity={prefabData.root} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 | Action Buttons */}
|
||||
<div className="inspector-section">
|
||||
<button
|
||||
className="prefab-instantiate-btn"
|
||||
onClick={handleInstantiate}
|
||||
disabled={instantiating}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
backgroundColor: '#4ade80',
|
||||
color: '#1a1a1a',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
cursor: instantiating ? 'wait' : 'pointer',
|
||||
opacity: instantiating ? 0.7 : 1
|
||||
}}
|
||||
>
|
||||
<Play size={14} />
|
||||
{instantiating ? '实例化中...' : '实例化到场景'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user