From beaa1d09dee51c7dff3f5e52af9c91e3e2d4c290 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Sat, 13 Dec 2025 19:44:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=A2=84=E5=88=B6=E4=BD=93=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=B8=8E=E6=9E=B6=E6=9E=84=E6=94=B9=E8=BF=9B=20(#303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 导入 --- .github/workflows/release-editor.yml | 31 +- .gitignore | 8 + packages/asset-system/module.json | 32 +- .../asset-system/src/core/AssetManager.ts | 36 + .../src/core/AssetPathResolver.ts | 6 - packages/asset-system/src/index.ts | 46 +- .../src/integration/EngineIntegration.ts | 141 ++- .../src/interfaces/IAssetLoader.ts | 30 +- .../src/interfaces/IPrefabAsset.ts | 405 +++++++ .../src/loaders/AssetLoaderFactory.ts | 43 + .../asset-system/src/loaders/PrefabLoader.ts | 156 +++ .../asset-system/src/loaders/TextureLoader.ts | 12 + .../src/services/PathResolutionService.ts | 239 ++++ packages/asset-system/src/tokens.ts | 24 +- .../asset-system/src/utils/AssetCollector.ts | 239 ++++ packages/asset-system/src/utils/AssetUtils.ts | 50 +- packages/audio/module.json | 1 + packages/audio/src/AudioPlugin.ts | 4 +- packages/audio/src/tokens.ts | 2 +- packages/behavior-tree-editor/src/tokens.ts | 2 +- packages/behavior-tree-editor/tsconfig.json | 10 +- packages/behavior-tree/module.json | 7 + .../src/BehaviorTreeRuntimeModule.ts | 4 +- .../BehaviorTreeAssetSerializer.ts | 13 +- .../src/Serialization/NodeTemplates.ts | 26 +- packages/behavior-tree/src/constants.ts | 4 +- .../execution/BehaviorTreeExecutionSystem.ts | 22 +- packages/behavior-tree/src/tokens.ts | 2 +- packages/blueprint/src/BlueprintPlugin.ts | 4 +- packages/camera/module.json | 1 + packages/camera/package.json | 1 + packages/camera/src/CameraManager.ts | 320 ++++++ packages/camera/src/CameraPlugin.ts | 21 +- .../systems => camera/src}/CameraSystem.ts | 8 +- packages/camera/src/index.ts | 8 +- packages/camera/src/tokens.ts | 31 +- packages/core/module.json | 1 + .../core/src/Core/PluginServiceRegistry.ts | 15 +- packages/core/src/Core/RuntimeModeService.ts | 284 +++++ .../src/ECS/Components/HierarchyComponent.ts | 2 +- .../ECS/Components/PrefabInstanceComponent.ts | 211 ++++ packages/core/src/ECS/Components/index.ts | 1 + .../ComponentStorage/ComponentTypeUtils.ts | 77 ++ .../src/ECS/Decorators/EntityRefDecorator.ts | 33 + .../src/ECS/Decorators/PropertyDecorator.ts | 55 +- .../core/src/ECS/Decorators/TypeDecorators.ts | 16 +- packages/core/src/ECS/Decorators/index.ts | 11 +- packages/core/src/ECS/Entity.ts | 26 +- .../ECS/Serialization/ComponentSerializer.ts | 84 +- .../src/ECS/Serialization/EntitySerializer.ts | 46 +- .../src/ECS/Serialization/PrefabSerializer.ts | 470 ++++++++ .../src/ECS/Serialization/SceneSerializer.ts | 31 +- .../ECS/Serialization/SerializationContext.ts | 321 ++++++ packages/core/src/ECS/Serialization/index.ts | 15 + packages/core/src/Utils/GUID.ts | 90 ++ packages/core/src/Utils/index.ts | 1 + packages/core/src/index.ts | 9 + .../src/core/EngineBridge.ts | 70 +- packages/ecs-engine-bindgen/src/index.ts | 1 - .../src/systems/EngineRenderSystem.ts | 288 +++-- packages/ecs-engine-bindgen/src/tokens.ts | 104 +- .../src/wasm/es_engine.d.ts | 52 +- packages/editor-app/package.json | 2 + .../src-tauri/src/commands/compiler.rs | 338 ++++-- .../src-tauri/src/commands/dialog.rs | 10 + .../src-tauri/src/commands/file_system.rs | 25 + .../src-tauri/src/commands/system.rs | 44 +- packages/editor-app/src-tauri/src/main.rs | 4 + packages/editor-app/src/App.tsx | 589 ++++++---- .../editor-app/src/adapters/TauriFileAPI.ts | 4 +- packages/editor-app/src/api/tauri.ts | 33 +- .../src/app/managers/PluginInstaller.ts | 4 + .../src/app/managers/ServiceRegistry.ts | 14 +- .../application/commands/CommandManager.ts | 27 + .../commands/component/AddComponentCommand.ts | 89 +- .../src/application/commands/index.ts | 1 + .../commands/prefab/ApplyPrefabCommand.ts | 65 ++ .../commands/prefab/BreakPrefabLinkCommand.ts | 128 +++ .../commands/prefab/CreatePrefabCommand.ts | 150 +++ .../prefab/InstantiatePrefabCommand.ts | 143 +++ .../commands/prefab/RevertPrefabCommand.ts | 155 +++ .../src/application/commands/prefab/index.ts | 14 + .../commands/transform/TransformCommand.ts | 193 ++++ .../src/components/BuildSettingsPanel.tsx | 498 +++----- .../src/components/ContentBrowser.tsx | 418 ++++++- .../editor-app/src/components/ContextMenu.tsx | 224 +++- .../components/FlexLayoutDockContainer.tsx | 163 ++- .../src/components/PropertyInspector.tsx | 387 ++++++- .../src/components/SceneHierarchy.tsx | 1002 ++++++++++++++--- .../editor-app/src/components/StartupPage.tsx | 121 +- .../editor-app/src/components/StatusBar.tsx | 22 +- .../editor-app/src/components/TitleBar.tsx | 43 +- .../editor-app/src/components/Viewport.tsx | 496 ++++++-- .../src/components/inspectors/Inspector.tsx | 198 +--- .../inspectors/common/PrefabInstanceInfo.tsx | 180 +++ .../inspectors/fields/AssetField.css | 1 + .../inspectors/fields/AssetField.tsx | 132 ++- .../inspectors/views/AssetFileInspector.tsx | 5 +- .../inspectors/views/EntityInspector.tsx | 235 ++-- .../inspectors/views/PrefabInspector.tsx | 374 ++++++ .../src/components/inspectors/views/index.ts | 1 + .../editor-app/src/hooks/useAssetSystem.ts | 20 +- .../src/hooks/useStoreSubscriptions.ts | 351 ++++++ packages/editor-app/src/i18n/locales/en.json | 6 + packages/editor-app/src/i18n/locales/zh.json | 6 + packages/editor-app/src/locales/en.ts | 76 +- packages/editor-app/src/locales/zh.ts | 76 +- packages/editor-app/src/main.tsx | 44 + .../editor-app/src/services/EngineService.ts | 189 +++- .../src/services/PluginSDKRegistry.ts | 145 +-- .../src/services/RuntimeResolver.ts | 12 + .../editor-app/src/services/getService.ts | 2 +- packages/editor-app/src/services/tokens.ts | 2 +- .../src/shared/layout/LayoutBuilder.ts | 110 +- .../src/shared/layout/LayoutMerger.ts | 36 +- .../editor-app/src/shared/layout/types.ts | 76 +- .../src/stores/BuildSettingsStore.ts | 514 +++++++++ .../src/stores/ContentBrowserStore.ts | 401 +++++++ packages/editor-app/src/stores/EditorStore.ts | 287 +++++ .../editor-app/src/stores/HierarchyStore.ts | 209 ++++ .../editor-app/src/stores/InspectorStore.ts | 162 +++ packages/editor-app/src/stores/index.ts | 31 + .../src/styles/BuildSettingsPanel.css | 81 +- .../editor-app/src/styles/ContentBrowser.css | 202 +++- .../editor-app/src/styles/ContextMenu.css | 4 +- .../editor-app/src/styles/EntityInspector.css | 243 +++- .../src/styles/PrefabInstanceInfo.css | 145 +++ .../src/styles/PropertyInspector.css | 191 +++- .../editor-app/src/styles/SceneHierarchy.css | 293 ++++- .../editor-app/src/styles/StartupPage.css | 58 + packages/editor-app/src/styles/Viewport.css | 124 ++ packages/editor-app/src/styles/global.css | 10 - .../editor-core/src/Config/EditorConfig.ts | 56 +- .../src/Plugin/PluginDescriptor.ts | 9 +- .../src/Services/AssetRegistryService.ts | 95 +- .../src/Services/Build/IBuildPipeline.ts | 30 + .../Build/pipelines/WebBuildPipeline.ts | 304 ++++- .../src/Services/CommandManager.ts | 27 + .../src/Services/EntityStoreService.ts | 19 +- .../editor-core/src/Services/PrefabService.ts | 476 ++++++++ .../src/Services/ProjectService.ts | 10 +- .../src/Services/PropertyMetadata.ts | 24 +- .../src/Services/SceneManagerService.ts | 405 ++++++- .../src/Services/UserCode/IUserCodeService.ts | 81 ++ .../src/Services/UserCode/UserCodeService.ts | 151 ++- packages/editor-core/src/Types/IFileAPI.ts | 9 +- packages/editor-core/src/index.ts | 1 + packages/editor-core/src/tokens.ts | 56 +- packages/editor-runtime/package.json | 1 + packages/editor-runtime/src/PluginAPI.ts | 17 +- packages/editor-runtime/src/index.ts | 31 +- packages/editor-runtime/vite.config.ts | 4 + packages/engine-core/module.json | 2 + packages/engine-core/package.json | 1 + packages/engine-core/src/EnginePlugin.ts | 24 +- .../engine-core/src/Input/InputManager.ts | 21 +- packages/engine-core/src/Input/index.ts | 2 +- packages/engine-core/src/ModuleManifest.ts | 23 + .../engine-core/src/PluginServiceRegistry.ts | 183 +-- packages/engine-core/src/SortingLayer.ts | 347 ++++++ .../engine-core/src/TransformComponent.ts | 22 +- packages/engine-core/src/index.ts | 29 +- packages/engine/Cargo.toml | 3 + packages/engine/src/core/engine.rs | 49 + packages/engine/src/lib.rs | 57 + .../engine/src/renderer/batch/sprite_batch.rs | 20 +- .../src/renderer/texture/texture_manager.rs | 30 + .../src/MaterialSystemPlugin.ts | 4 +- packages/material-system/src/tokens.ts | 2 +- packages/math/module.json | 1 + packages/math/src/Vector2.ts | 13 +- packages/math/src/Vector3.ts | 14 +- packages/math/src/index.ts | 4 +- packages/particle-editor/package.json | 1 + .../src/panels/ParticleEditorPanel.tsx | 306 +++-- .../src/stores/ParticleEditorStore.ts | 224 ++-- packages/particle-editor/tsconfig.json | 10 +- packages/particle/module.json | 6 + packages/particle/package.json | 1 + packages/particle/src/ClickFxComponent.ts | 213 ++++ .../particle/src/ParticleRuntimeModule.ts | 61 +- .../particle/src/ParticleSystemComponent.ts | 216 +++- packages/particle/src/index.ts | 7 +- .../particle/src/loaders/ParticleLoader.ts | 18 +- .../rendering/ParticleRenderDataProvider.ts | 86 +- .../particle/src/systems/ClickFxSystem.ts | 443 ++++++++ .../particle/src/systems/ParticleSystem.ts | 77 +- packages/particle/src/tokens.ts | 2 +- .../physics-rapier2d-editor/tsconfig.json | 13 +- packages/physics-rapier2d/module.json | 1 + packages/physics-rapier2d/package.json | 2 +- .../src/PhysicsEditorPlugin.ts | 4 +- .../src/PhysicsRuntimeModule.ts | 4 +- .../src/components/BoxCollider2DComponent.ts | 4 +- .../components/CapsuleCollider2DComponent.ts | 4 +- .../components/CircleCollider2DComponent.ts | 4 +- .../src/components/Collider2DBase.ts | 6 +- .../components/PolygonCollider2DComponent.ts | 10 +- .../src/components/Rigidbody2DComponent.ts | 18 +- packages/physics-rapier2d/src/runtime.ts | 4 +- .../src/services/Physics2DService.ts | 33 +- .../src/systems/Physics2DSystem.ts | 25 +- packages/physics-rapier2d/src/tokens.ts | 2 +- .../src/types/Physics2DEvents.ts | 8 +- .../src/types/Physics2DTypes.ts | 15 +- packages/physics-rapier2d/src/types/index.ts | 4 +- .../src/world/Physics2DWorld.ts | 44 +- packages/platform-web/src/BrowserRuntime.ts | 97 +- packages/runtime-core/module.json | 1 + packages/runtime-core/package.json | 1 + packages/runtime-core/src/GameRuntime.ts | 109 +- packages/runtime-core/src/PluginLoader.ts | 12 +- packages/runtime-core/src/PluginManager.ts | 12 +- packages/runtime-core/src/RuntimeBootstrap.ts | 6 +- .../src/adapters/BrowserPlatformAdapter.ts | 82 +- .../src/adapters/EditorPlatformAdapter.ts | 31 + packages/runtime-core/src/adapters/index.ts | 7 +- packages/runtime-core/src/index.ts | 18 +- .../src/services/BrowserFileSystemService.ts | 14 +- packages/sdk/module.json | 38 + packages/sdk/package.json | 53 + packages/sdk/src/index.ts | 87 ++ packages/sdk/tsconfig.build.json | 10 + packages/sdk/tsconfig.json | 24 + packages/sdk/tsup.config.ts | 7 + packages/sprite/module.json | 1 + packages/sprite/src/SpriteComponent.ts | 36 +- packages/sprite/src/SpriteRuntimeModule.ts | 6 +- packages/sprite/src/tokens.ts | 2 +- packages/tilemap-editor/tsconfig.json | 10 +- packages/tilemap/module.json | 5 + packages/tilemap/src/TilemapComponent.ts | 31 +- packages/tilemap/src/TilemapRuntimeModule.ts | 4 +- .../src/systems/TilemapRenderingSystem.ts | 24 +- packages/tilemap/src/tokens.ts | 2 +- .../src/inspectors/UITransformInspector.tsx | 6 +- packages/ui/module.json | 5 +- packages/ui/package.json | 3 + packages/ui/src/UIBuilder.ts | 5 +- packages/ui/src/UIRuntimeModule.ts | 4 +- .../src/components/UIInteractableComponent.ts | 8 +- .../ui/src/components/UITransformComponent.ts | 37 +- packages/ui/src/index.ts | 6 +- packages/ui/src/systems/UIAnimationSystem.ts | 7 +- packages/ui/src/systems/UIInputSystem.ts | 25 +- packages/ui/src/systems/UILayoutSystem.ts | 22 +- .../systems/render/UIButtonRenderSystem.ts | 35 +- .../render/UIProgressBarRenderSystem.ts | 46 +- .../src/systems/render/UIRectRenderSystem.ts | 41 +- .../src/systems/render/UIRenderCollector.ts | 77 +- .../render/UIScrollViewRenderSystem.ts | 35 +- .../systems/render/UISliderRenderSystem.ts | 53 +- .../src/systems/render/UITextRenderSystem.ts | 17 +- packages/ui/src/tokens.ts | 2 +- packages/ui/tsconfig.build.json | 20 +- packages/world-streaming-editor/tsconfig.json | 9 +- .../world-streaming/src/types/ChunkState.ts | 4 +- pnpm-lock.yaml | 84 +- 258 files changed, 17725 insertions(+), 3030 deletions(-) create mode 100644 packages/asset-system/src/interfaces/IPrefabAsset.ts create mode 100644 packages/asset-system/src/loaders/PrefabLoader.ts create mode 100644 packages/asset-system/src/services/PathResolutionService.ts create mode 100644 packages/asset-system/src/utils/AssetCollector.ts create mode 100644 packages/camera/src/CameraManager.ts rename packages/{ecs-engine-bindgen/src/systems => camera/src}/CameraSystem.ts (88%) create mode 100644 packages/core/src/Core/RuntimeModeService.ts create mode 100644 packages/core/src/ECS/Components/PrefabInstanceComponent.ts create mode 100644 packages/core/src/ECS/Serialization/PrefabSerializer.ts create mode 100644 packages/core/src/ECS/Serialization/SerializationContext.ts create mode 100644 packages/core/src/Utils/GUID.ts create mode 100644 packages/editor-app/src/application/commands/prefab/ApplyPrefabCommand.ts create mode 100644 packages/editor-app/src/application/commands/prefab/BreakPrefabLinkCommand.ts create mode 100644 packages/editor-app/src/application/commands/prefab/CreatePrefabCommand.ts create mode 100644 packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts create mode 100644 packages/editor-app/src/application/commands/prefab/RevertPrefabCommand.ts create mode 100644 packages/editor-app/src/application/commands/prefab/index.ts create mode 100644 packages/editor-app/src/application/commands/transform/TransformCommand.ts create mode 100644 packages/editor-app/src/components/inspectors/common/PrefabInstanceInfo.tsx create mode 100644 packages/editor-app/src/components/inspectors/views/PrefabInspector.tsx create mode 100644 packages/editor-app/src/hooks/useStoreSubscriptions.ts create mode 100644 packages/editor-app/src/stores/BuildSettingsStore.ts create mode 100644 packages/editor-app/src/stores/ContentBrowserStore.ts create mode 100644 packages/editor-app/src/stores/EditorStore.ts create mode 100644 packages/editor-app/src/stores/HierarchyStore.ts create mode 100644 packages/editor-app/src/stores/InspectorStore.ts create mode 100644 packages/editor-app/src/stores/index.ts create mode 100644 packages/editor-app/src/styles/PrefabInstanceInfo.css create mode 100644 packages/editor-core/src/Services/PrefabService.ts create mode 100644 packages/engine-core/src/SortingLayer.ts create mode 100644 packages/particle/src/ClickFxComponent.ts create mode 100644 packages/particle/src/systems/ClickFxSystem.ts create mode 100644 packages/sdk/module.json create mode 100644 packages/sdk/package.json create mode 100644 packages/sdk/src/index.ts create mode 100644 packages/sdk/tsconfig.build.json create mode 100644 packages/sdk/tsconfig.json create mode 100644 packages/sdk/tsup.config.ts diff --git a/.github/workflows/release-editor.yml b/.github/workflows/release-editor.yml index 22f09624..763ecd68 100644 --- a/.github/workflows/release-editor.yml +++ b/.github/workflows/release-editor.yml @@ -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 diff --git a/.gitignore b/.gitignore index bcc91689..99df7b23 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,14 @@ logs/ .env.test.local .env.production.local +# 代码签名证书(敏感文件) +certs/ +*.pfx +*.p12 +*.cer +*.pem +*.key + # 测试覆盖率 coverage/ *.lcov diff --git a/packages/asset-system/module.json b/packages/asset-system/module.json index e8d9ea1d..75679787 100644 --- a/packages/asset-system/module.json +++ b/packages/asset-system/module.json @@ -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" } diff --git a/packages/asset-system/src/core/AssetManager.ts b/packages/asset-system/src/core/AssetManager.ts index 44a1b864..f7c7a7bb 100644 --- a/packages/asset-system/src/core/AssetManager.ts +++ b/packages/asset-system/src/core/AssetManager.ts @@ -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 * 增加资产引用 diff --git a/packages/asset-system/src/core/AssetPathResolver.ts b/packages/asset-system/src/core/AssetPathResolver.ts index a96019fe..4a61b01b 100644 --- a/packages/asset-system/src/core/AssetPathResolver.ts +++ b/packages/asset-system/src/core/AssetPathResolver.ts @@ -233,9 +233,3 @@ export class AssetPathResolver { return path.replace(/\\/g, '/').replace(/\/+/g, '/'); } } - -/** - * Global asset path resolver instance - * 全局资产路径解析器实例 - */ -export const globalPathResolver = new AssetPathResolver(); diff --git a/packages/asset-system/src/index.ts b/packages/asset-system/src/index.ts index 768c69ab..57c3d093 100644 --- a/packages/asset-system/src/index.ts +++ b/packages/asset-system/src/index.ts @@ -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'; diff --git a/packages/asset-system/src/integration/EngineIntegration.ts b/packages/asset-system/src/integration/EngineIntegration.ts index c521008f..aa783af1 100644 --- a/packages/asset-system/src/integration/EngineIntegration.ts +++ b/packages/asset-system/src/integration/EngineIntegration.ts @@ -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(); private _pathToTextureId = new Map(); @@ -80,16 +112,25 @@ export class EngineIntegration { private _dataAssets = new Map(); 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(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 { // 检查是否已有纹理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(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(); } /** diff --git a/packages/asset-system/src/interfaces/IAssetLoader.ts b/packages/asset-system/src/interfaces/IAssetLoader.ts index b88979ae..b9beabb9 100644 --- a/packages/asset-system/src/interfaces/IAssetLoader.ts +++ b/packages/asset-system/src/interfaces/IAssetLoader.ts @@ -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; } /** @@ -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 diff --git a/packages/asset-system/src/interfaces/IPrefabAsset.ts b/packages/asset-system/src/interfaces/IPrefabAsset.ts new file mode 100644 index 00000000..2a4ff49e --- /dev/null +++ b/packages/asset-system/src/interfaces/IPrefabAsset.ts @@ -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; + + /** + * 检查实体是否为预制体实例 + * 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; + + /** + * 将实例还原为源预制体的状态 + * Revert instance to source prefab state + * + * @param instance - 预制体实例 | Prefab instance + * @returns 是否成功还原 | Whether revert was successful + */ + revertToPrefab?(instance: unknown): Promise; + + /** + * 获取实例相对于源预制体的属性覆盖 + * 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'; diff --git a/packages/asset-system/src/loaders/AssetLoaderFactory.ts b/packages/asset-system/src/loaders/AssetLoaderFactory.ts index 30549135..855fbf89 100644 --- a/packages/asset-system/src/loaders/AssetLoaderFactory.ts +++ b/packages/asset-system/src/loaders/AssetLoaderFactory.ts @@ -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(); + + 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 { + const map: Record = {}; + + 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; + } } diff --git a/packages/asset-system/src/loaders/PrefabLoader.ts b/packages/asset-system/src/loaders/PrefabLoader.ts new file mode 100644 index 00000000..ee955578 --- /dev/null +++ b/packages/asset-system/src/loaders/PrefabLoader.ts @@ -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 { + readonly supportedType = AssetType.Prefab; + readonly supportedExtensions = ['.prefab']; + readonly contentType: AssetContentType = 'text'; + + /** + * 从文本内容解析预制体 + * Parse prefab from text content + */ + async parse(content: IAssetContent, context: IAssetParseContext): Promise { + 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; + + // 验证版本号 | 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; + + 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); + } + } +} diff --git a/packages/asset-system/src/loaders/TextureLoader.ts b/packages/asset-system/src/loaders/TextureLoader.ts index d83a9cf8..2e9f4e23 100644 --- a/packages/asset-system/src/loaders/TextureLoader.ts +++ b/packages/asset-system/src/loaders/TextureLoader.ts @@ -38,6 +38,18 @@ export class TextureLoader implements IAssetLoader { 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. * 从图片内容解析纹理。 diff --git a/packages/asset-system/src/services/PathResolutionService.ts b/packages/asset-system/src/services/PathResolutionService.ts new file mode 100644 index 00000000..f8220421 --- /dev/null +++ b/packages/asset-system/src/services/PathResolutionService.ts @@ -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('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); + } +} diff --git a/packages/asset-system/src/tokens.ts b/packages/asset-system/src/tokens.ts index fd510a16..46d4d0ff 100644 --- a/packages/asset-system/src/tokens.ts +++ b/packages/asset-system/src/tokens.ts @@ -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('assetManager'); + +/** + * 预制体服务令牌 + * Prefab service token + * + * 用于注册和获取预制体服务。 + * For registering and getting prefab service. + */ +export const PrefabServiceToken = createServiceToken('prefabService'); + +/** + * 路径解析服务令牌 + * Path resolution service token + * + * 用于注册和获取路径解析服务。 + * For registering and getting path resolution service. + */ +export const PathResolutionServiceToken = createServiceToken('pathResolutionService'); diff --git a/packages/asset-system/src/utils/AssetCollector.ts b/packages/asset-system/src/utils/AssetCollector.ts new file mode 100644 index 00000000..bfa589e9 --- /dev/null +++ b/packages/asset-system/src/utils/AssetCollector.ts @@ -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, + 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 }>; + 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 { + return new Set(references.map(ref => ref.guid)); +} + +/** + * 按组件类型分组资产引用 + * Group asset references by component type + */ +export function groupByComponentType(references: SceneAssetRef[]): Map { + const groups = new Map(); + + for (const ref of references) { + const existing = groups.get(ref.componentType) || []; + existing.push(ref); + groups.set(ref.componentType, existing); + } + + return groups; +} diff --git a/packages/asset-system/src/utils/AssetUtils.ts b/packages/asset-system/src/utils/AssetUtils.ts index 3a10cdf1..07f45329 100644 --- a/packages/asset-system/src/utils/AssetUtils.ts +++ b/packages/asset-system/src/utils/AssetUtils.ts @@ -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 diff --git a/packages/audio/module.json b/packages/audio/module.json index fcba94ab..222c3a36 100644 --- a/packages/audio/module.json +++ b/packages/audio/module.json @@ -1,6 +1,7 @@ { "id": "audio", "name": "@esengine/audio", + "globalKey": "audio", "displayName": "Audio", "description": "Audio playback and sound effects | 音频播放和音效", "version": "1.0.0", diff --git a/packages/audio/src/AudioPlugin.ts b/packages/audio/src/AudioPlugin.ts index ca897d83..fbfd4d71 100644 --- a/packages/audio/src/AudioPlugin.ts +++ b/packages/audio/src/AudioPlugin.ts @@ -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() }; diff --git a/packages/audio/src/tokens.ts b/packages/audio/src/tokens.ts index 3a87216a..9abda796 100644 --- a/packages/audio/src/tokens.ts +++ b/packages/audio/src/tokens.ts @@ -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 diff --git a/packages/behavior-tree-editor/src/tokens.ts b/packages/behavior-tree-editor/src/tokens.ts index febf0e36..b78b4f30 100644 --- a/packages/behavior-tree-editor/src/tokens.ts +++ b/packages/behavior-tree-editor/src/tokens.ts @@ -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'; diff --git a/packages/behavior-tree-editor/tsconfig.json b/packages/behavior-tree-editor/tsconfig.json index d099ddd8..fa0eb685 100644 --- a/packages/behavior-tree-editor/tsconfig.json +++ b/packages/behavior-tree-editor/tsconfig.json @@ -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" } + ] } diff --git a/packages/behavior-tree/module.json b/packages/behavior-tree/module.json index 528a7317..61115169 100644 --- a/packages/behavior-tree/module.json +++ b/packages/behavior-tree/module.json @@ -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", diff --git a/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts b/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts index cb6bc86f..2189ca4d 100644 --- a/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts +++ b/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts @@ -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() }; diff --git a/packages/behavior-tree/src/Serialization/BehaviorTreeAssetSerializer.ts b/packages/behavior-tree/src/Serialization/BehaviorTreeAssetSerializer.ts index 4a999e7b..eb779306 100644 --- a/packages/behavior-tree/src/Serialization/BehaviorTreeAssetSerializer.ts +++ b/packages/behavior-tree/src/Serialization/BehaviorTreeAssetSerializer.ts @@ -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 }); diff --git a/packages/behavior-tree/src/Serialization/NodeTemplates.ts b/packages/behavior-tree/src/Serialization/NodeTemplates.ts index 5e43389e..ef319a30 100644 --- a/packages/behavior-tree/src/Serialization/NodeTemplates.ts +++ b/packages/behavior-tree/src/Serialization/NodeTemplates.ts @@ -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; } } diff --git a/packages/behavior-tree/src/constants.ts b/packages/behavior-tree/src/constants.ts index 5ea246f7..736f764a 100644 --- a/packages/behavior-tree/src/constants.ts +++ b/packages/behavior-tree/src/constants.ts @@ -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; diff --git a/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts b/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts index ce5743ad..cea732c8 100644 --- a/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts +++ b/packages/behavior-tree/src/execution/BehaviorTreeExecutionSystem.ts @@ -98,28 +98,30 @@ export class BehaviorTreeExecutionSystem extends EntitySystem { * 确保行为树资产已加载 * Ensure behavior tree asset is loaded */ - private async ensureAssetLoaded(assetIdOrPath: string): Promise { + private async ensureAssetLoaded(assetGuid: string): Promise { 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(assetIdOrPath); + // 使用 getAsset 通过 GUID 获取,而不是 getAssetByPath + // Use getAsset with GUID instead of getAssetByPath + const cachedAsset = this._assetManager.getAsset(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); } /** diff --git a/packages/behavior-tree/src/tokens.ts b/packages/behavior-tree/src/tokens.ts index 51c472c8..eb2b7b93 100644 --- a/packages/behavior-tree/src/tokens.ts +++ b/packages/behavior-tree/src/tokens.ts @@ -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'; // ============================================================================ diff --git a/packages/blueprint/src/BlueprintPlugin.ts b/packages/blueprint/src/BlueprintPlugin.ts index e7566c3b..b9f8d520 100644 --- a/packages/blueprint/src/BlueprintPlugin.ts +++ b/packages/blueprint/src/BlueprintPlugin.ts @@ -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() }; diff --git a/packages/camera/module.json b/packages/camera/module.json index 11a9c217..93bc1fce 100644 --- a/packages/camera/module.json +++ b/packages/camera/module.json @@ -1,6 +1,7 @@ { "id": "camera", "name": "@esengine/camera", + "globalKey": "camera", "displayName": "Camera", "description": "Camera and viewport management | 相机和视口管理", "version": "1.0.0", diff --git a/packages/camera/package.json b/packages/camera/package.json index 916248fd..0ae28e9d 100644 --- a/packages/camera/package.json +++ b/packages/camera/package.json @@ -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", diff --git a/packages/camera/src/CameraManager.ts b/packages/camera/src/CameraManager.ts new file mode 100644 index 00000000..5ec6a11e --- /dev/null +++ b/packages/camera/src/CameraManager.ts @@ -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(); diff --git a/packages/camera/src/CameraPlugin.ts b/packages/camera/src/CameraPlugin.ts index d129c42f..379c5a69 100644 --- a/packages/camera/src/CameraPlugin.ts +++ b/packages/camera/src/CameraPlugin.ts @@ -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() }; diff --git a/packages/ecs-engine-bindgen/src/systems/CameraSystem.ts b/packages/camera/src/CameraSystem.ts similarity index 88% rename from packages/ecs-engine-bindgen/src/systems/CameraSystem.ts rename to packages/camera/src/CameraSystem.ts index a2663505..13017e54 100644 --- a/packages/ecs-engine-bindgen/src/systems/CameraSystem.ts +++ b/packages/camera/src/CameraSystem.ts @@ -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; diff --git a/packages/camera/src/index.ts b/packages/camera/src/index.ts index 382385b0..45427abc 100644 --- a/packages/camera/src/index.ts +++ b/packages/camera/src/index.ts @@ -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'; diff --git a/packages/camera/src/tokens.ts b/packages/camera/src/tokens.ts index dcf11feb..246be61f 100644 --- a/packages/camera/src/tokens.ts +++ b/packages/camera/src/tokens.ts @@ -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('cameraManager'); +/** + * 相机管理器服务令牌 + * Camera manager service token + */ +export const CameraManagerToken = createServiceToken('cameraManager'); diff --git a/packages/core/module.json b/packages/core/module.json index f3dcc57b..4507b7cf 100644 --- a/packages/core/module.json +++ b/packages/core/module.json @@ -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 框架", diff --git a/packages/core/src/Core/PluginServiceRegistry.ts b/packages/core/src/Core/PluginServiceRegistry.ts index 7856d654..dd903f41 100644 --- a/packages/core/src/Core/PluginServiceRegistry.ts +++ b/packages/core/src/Core/PluginServiceRegistry.ts @@ -42,14 +42,23 @@ export interface ServiceToken { * 创建服务令牌 * 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(name: string): ServiceToken { - // __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; } diff --git a/packages/core/src/Core/RuntimeModeService.ts b/packages/core/src/Core/RuntimeModeService.ts new file mode 100644 index 00000000..ce00f50e --- /dev/null +++ b/packages/core/src/Core/RuntimeModeService.ts @@ -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('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 = 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 + }); +} diff --git a/packages/core/src/ECS/Components/HierarchyComponent.ts b/packages/core/src/ECS/Components/HierarchyComponent.ts index 1895a9cc..ad3ba539 100644 --- a/packages/core/src/ECS/Components/HierarchyComponent.ts +++ b/packages/core/src/ECS/Components/HierarchyComponent.ts @@ -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 { /** diff --git a/packages/core/src/ECS/Components/PrefabInstanceComponent.ts b/packages/core/src/ECS/Components/PrefabInstanceComponent.ts new file mode 100644 index 00000000..7a7f368b --- /dev/null +++ b/packages/core/src/ECS/Components/PrefabInstanceComponent.ts @@ -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 = {}; + + 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; + } +} diff --git a/packages/core/src/ECS/Components/index.ts b/packages/core/src/ECS/Components/index.ts index 826cbc8a..35e683f4 100644 --- a/packages/core/src/ECS/Components/index.ts +++ b/packages/core/src/ECS/Components/index.ts @@ -1 +1,2 @@ export { HierarchyComponent } from './HierarchyComponent'; +export { PrefabInstanceComponent } from './PrefabInstanceComponent'; diff --git a/packages/core/src/ECS/Core/ComponentStorage/ComponentTypeUtils.ts b/packages/core/src/ECS/Core/ComponentStorage/ComponentTypeUtils.ts index a80f05d6..d5094308 100644 --- a/packages/core/src/ECS/Core/ComponentStorage/ComponentTypeUtils.ts +++ b/packages/core/src/ECS/Core/ComponentStorage/ComponentTypeUtils.ts @@ -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); +} diff --git a/packages/core/src/ECS/Decorators/EntityRefDecorator.ts b/packages/core/src/ECS/Decorators/EntityRefDecorator.ts index 33bde380..b168de9b 100644 --- a/packages/core/src/ECS/Decorators/EntityRefDecorator.ts +++ b/packages/core/src/ECS/Decorators/EntityRefDecorator.ts @@ -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); +} diff --git a/packages/core/src/ECS/Decorators/PropertyDecorator.ts b/packages/core/src/ECS/Decorators/PropertyDecorator.ts index fa5b24a6..8c694211 100644 --- a/packages/core/src/ECS/Decorators/PropertyDecorator.ts +++ b/packages/core/src/ECS/Decorators/PropertyDecorator.ts @@ -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; diff --git a/packages/core/src/ECS/Decorators/TypeDecorators.ts b/packages/core/src/ECS/Decorators/TypeDecorators.ts index d6f7524c..899a7abb 100644 --- a/packages/core/src/ECS/Decorators/TypeDecorators.ts +++ b/packages/core/src/ECS/Decorators/TypeDecorators.ts @@ -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); diff --git a/packages/core/src/ECS/Decorators/index.ts b/packages/core/src/ECS/Decorators/index.ts index 42104586..a4cbe588 100644 --- a/packages/core/src/ECS/Decorators/index.ts +++ b/packages/core/src/ECS/Decorators/index.ts @@ -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'; diff --git a/packages/core/src/ECS/Entity.ts b/packages/core/src/ECS/Entity.ts index 97602d32..6bc0decd 100644 --- a/packages/core/src/ECS/Entity.ts +++ b/packages/core/src/ECS/Entity.ts @@ -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, diff --git a/packages/core/src/ECS/Serialization/ComponentSerializer.ts b/packages/core/src/ECS/Serialization/ComponentSerializer.ts index 6b38a88d..9bb0954b 100644 --- a/packages/core/src/ECS/Serialization/ComponentSerializer.ts +++ b/packages/core/src/ECS/Serialization/ComponentSerializer.ts @@ -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)[fieldName]; + const value = (component as unknown as Record)[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 + componentRegistry: Map, + 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)[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 + componentRegistry: Map, + 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 + ); + } } diff --git a/packages/core/src/ECS/Serialization/EntitySerializer.ts b/packages/core/src/ECS/Serialization/EntitySerializer.ts index 782993fd..09f88fa9 100644 --- a/packages/core/src/ECS/Serialization/EntitySerializer.ts +++ b/packages/core/src/ECS/Serialization/EntitySerializer.ts @@ -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 + allEntities?: Map, + 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 } { const rootEntities: Entity[] = []; const allEntities = new Map(); @@ -250,7 +277,8 @@ export class EntitySerializer { preserveIds, scene, hierarchySystem, - allEntities + allEntities, + context ); rootEntities.push(entity); } diff --git a/packages/core/src/ECS/Serialization/PrefabSerializer.ts b/packages/core/src/ECS/Serialization/PrefabSerializer.ts new file mode 100644 index 00000000..ab1f6dd8 --- /dev/null +++ b/packages/core/src/ECS/Serialization/PrefabSerializer.ts @@ -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, + 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(); + + 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, + 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}]`); + }); + } + } +} diff --git a/packages/core/src/ECS/Serialization/SceneSerializer.ts b/packages/core/src/ECS/Serialization/SceneSerializer.ts index 2991ab5f..6be0cc28 100644 --- a/packages/core/src/ECS/Serialization/SceneSerializer.ts +++ b/packages/core/src/ECS/Serialization/SceneSerializer.ts @@ -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); diff --git a/packages/core/src/ECS/Serialization/SerializationContext.ts b/packages/core/src/ECS/Serialization/SerializationContext.ts new file mode 100644 index 00000000..c39e5018 --- /dev/null +++ b/packages/core/src/ECS/Serialization/SerializationContext.ts @@ -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 = new Map(); + + /** + * GUID 映射:persistentId -> Entity + * + * GUID mapping: persistentId -> Entity. + */ + private _guidLookup: Map = 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)[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 + }; + } +} diff --git a/packages/core/src/ECS/Serialization/index.ts b/packages/core/src/ECS/Serialization/index.ts index 154c9df1..1db3e54b 100644 --- a/packages/core/src/ECS/Serialization/index.ts +++ b/packages/core/src/ECS/Serialization/index.ts @@ -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'; diff --git a/packages/core/src/Utils/GUID.ts b/packages/core/src/Utils/GUID.ts new file mode 100644 index 00000000..048b911a --- /dev/null +++ b/packages/core/src/Utils/GUID.ts @@ -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'; diff --git a/packages/core/src/Utils/index.ts b/packages/core/src/Utils/index.ts index 7d15a043..7c198247 100644 --- a/packages/core/src/Utils/index.ts +++ b/packages/core/src/Utils/index.ts @@ -8,3 +8,4 @@ export * from './Debug'; export * from './Logger'; export * from './BinarySerializer'; export * from './Profiler'; +export * from './GUID'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ac0c90c3..c009c463 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts index 2affdeb1..2095ccbb 100644 --- a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -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; 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. * 销毁桥接并释放资源。 diff --git a/packages/ecs-engine-bindgen/src/index.ts b/packages/ecs-engine-bindgen/src/index.ts index b5ee4a75..89c1ccb1 100644 --- a/packages/ecs-engine-bindgen/src/index.ts +++ b/packages/ecs-engine-bindgen/src/index.ts @@ -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'; diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts index 61febc0b..76a8bb7f 100644 --- a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -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; + } } diff --git a/packages/ecs-engine-bindgen/src/tokens.ts b/packages/ecs-engine-bindgen/src/tokens.ts index 1f3621b4..9440ee98 100644 --- a/packages/ecs-engine-bindgen/src/tokens.ts +++ b/packages/ecs-engine-bindgen/src/tokens.ts @@ -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; -} - /** * 引擎集成接口 * 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; + /** 通过 GUID 加载纹理(内部引用使用)| Load texture by GUID (for internal references) */ + loadTextureByGuid(guid: string): Promise; } -// ============================================================================ -// 服务令牌 | Service Tokens -// ============================================================================ - -/** - * 渲染系统服务令牌 - * Render system service token - * - * 用于获取渲染系统实例。 - * For getting render system instance. - */ export const RenderSystemToken = createServiceToken('renderSystem'); - -/** - * 引擎桥接服务令牌 - * Engine bridge service token - * - * 用于获取 WASM 引擎桥接实例。 - * For getting WASM engine bridge instance. - */ -export const EngineBridgeToken = createServiceToken('engineBridge'); - -/** - * 引擎集成服务令牌 - * Engine integration service token - * - * 用于获取引擎集成实例(纹理加载等)。 - * For getting engine integration instance (texture loading, etc.). - */ export const EngineIntegrationToken = createServiceToken('engineIntegration'); diff --git a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts index 2ba5c5f4..bfe92d84 100644 --- a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts +++ b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts @@ -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; diff --git a/packages/editor-app/package.json b/packages/editor-app/package.json index b9d765a6..01f5d05e 100644 --- a/packages/editor-app/package.json +++ b/packages/editor-app/package.json @@ -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:*", diff --git a/packages/editor-app/src-tauri/src/commands/compiler.rs b/packages/editor-app/src-tauri/src/commands/compiler.rs index 675c29e7..20a68a8a 100644 --- a/packages/editor-app/src-tauri/src/commands/compiler.rs +++ b/packages/editor-app/src-tauri/src/commands/compiler.rs @@ -106,6 +106,53 @@ pub struct FileChangeEvent { pub paths: Vec, } +/// 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 { /// 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, +) -> 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| { + 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 = 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> = 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 { - 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 { 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 { 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 { - // 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 { diff --git a/packages/editor-app/src-tauri/src/commands/dialog.rs b/packages/editor-app/src-tauri/src/commands/dialog.rs index fa13b48f..3a30f859 100644 --- a/packages/editor-app/src-tauri/src/commands/dialog.rs +++ b/packages/editor-app/src-tauri/src/commands/dialog.rs @@ -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, default_name: Option, + default_path: Option, filters: Option>, ) -> Result, 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); } diff --git a/packages/editor-app/src-tauri/src/commands/file_system.rs b/packages/editor-app/src-tauri/src/commands/file_system.rs index f1eabba6..dac7738c 100644 --- a/packages/editor-app/src-tauri/src/commands/file_system.rs +++ b/packages/editor-app/src-tauri/src/commands/file_system.rs @@ -23,6 +23,31 @@ pub fn read_file_content(path: String) -> Result { .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> { diff --git a/packages/editor-app/src-tauri/src/commands/system.rs b/packages/editor-app/src-tauri/src/commands/system.rs index 40f382ac..92c492b7 100644 --- a/packages/editor-app/src-tauri/src/commands/system.rs +++ b/packages/editor-app/src-tauri/src/commands/system.rs @@ -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 { /// 扫描 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(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(null); - const [availableScenes, setAvailableScenes] = useState([]); - const [pluginManager, setPluginManager] = useState(null); - const [entityStore, setEntityStore] = useState(null); - const [messageHub, setMessageHub] = useState(null); - const [inspectorRegistry, setInspectorRegistry] = useState(null); - const [logService, setLogService] = useState(null); - const [uiRegistry, setUiRegistry] = useState(null); - const [settingsRegistry, setSettingsRegistry] = useState(null); - const [sceneManager, setSceneManager] = useState(null); - const [notification, setNotification] = useState(null); - const [dialog, setDialog] = useState(null); - const [buildService, setBuildService] = useState(null); - const [projectServiceState, setProjectServiceState] = useState(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(null); + const entityStoreRef = useRef(null); + const messageHubRef = useRef(null); + const inspectorRegistryRef = useRef(null); + const logServiceRef = useRef(null); + const uiRegistryRef = useRef(null); + const settingsRegistryRef = useRef(null); + const sceneManagerRef = useRef(null); + const notificationRef = useRef(null); + const dialogRef = useRef(null); + const buildServiceRef = useRef(null); + const projectServiceRef = useRef(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([]); - 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(undefined); - const [activeDynamicPanels, setActiveDynamicPanels] = useState([]); - const [activePanelId, setActivePanelId] = useState(undefined); - const [dynamicPanelTitles, setDynamicPanelTitles] = useState>(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( + 'engine:saveSceneSnapshot', + async () => { + const engineService = EngineService.getInstance(); + return engineService.saveSceneSnapshot(); + } + ); + + const unsubscribeRestore = hub.onRequest( + '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: , - closable: false - }, - { - id: 'viewport', - title: t('panel.viewport'), - content: , - closable: false - }, - { - id: 'inspector', - title: t('panel.inspector'), - content: , - closable: false - }, - { - id: 'forum', - title: t('panel.forum'), - content: , - 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: , - 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 = ; - } 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: , + closable: false, + layout: { position: 'right-top' } + }, + { + id: 'viewport', + title: t('panel.viewport'), + content: , + closable: false, + layout: { position: 'center' } + }, + { + id: 'inspector', + title: t('panel.inspector'), + content: , + closable: false, + layout: { position: 'right-bottom' } + }, + { + id: 'forum', + title: t('panel.forum'), + content: , + 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: ( + 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: , + 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 = ; + } 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() {
{ 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); }} />
@@ -1015,6 +1172,8 @@ function App() { locale={locale} projectPath={currentProjectPath} onOpenScene={handleOpenSceneByPath} + onDockContentBrowser={() => setIsContentBrowserDocked(true)} + onResetLayout={() => layoutContainerRef.current?.resetLayout()} /> diff --git a/packages/editor-app/src/adapters/TauriFileAPI.ts b/packages/editor-app/src/adapters/TauriFileAPI.ts index 29083604..7aaa800f 100644 --- a/packages/editor-app/src/adapters/TauriFileAPI.ts +++ b/packages/editor-app/src/adapters/TauriFileAPI.ts @@ -11,8 +11,8 @@ export class TauriFileAPI implements IFileAPI { return await TauriAPI.openSceneDialog(); } - public async saveSceneDialog(defaultName?: string): Promise { - return await TauriAPI.saveSceneDialog(defaultName); + public async saveSceneDialog(defaultName?: string, scenesDir?: string): Promise { + return await TauriAPI.saveSceneDialog(defaultName, scenesDir); } public async readFileContent(path: string): Promise { diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index c6717557..626c678f 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -31,11 +31,13 @@ export class TauriAPI { static async saveFileDialog( title?: string, defaultName?: string, - filters?: FileFilter[] + filters?: FileFilter[], + defaultPath?: string ): Promise { return await invoke('save_file_dialog', { title, defaultName, + defaultPath, filters }); } @@ -101,15 +103,19 @@ export class TauriAPI { } /** - * 打开保存场景对话框 - * @param defaultName 默认文件名(可选) - * @returns 用户选择的文件路径,取消则返回 null - */ - static async saveSceneDialog(defaultName?: string): Promise { + * 打开保存场景对话框 + * 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 { 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 { return await invoke('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 { + return await invoke('install_esbuild'); + } } /** diff --git a/packages/editor-app/src/app/managers/PluginInstaller.ts b/packages/editor-app/src/app/managers/PluginInstaller.ts index 92a50db0..1a835c64 100644 --- a/packages/editor-app/src/app/managers/PluginInstaller.ts +++ b/packages/editor-app/src/app/managers/PluginInstaller.ts @@ -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 }, diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index a6a414bd..f3508015 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -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); diff --git a/packages/editor-app/src/application/commands/CommandManager.ts b/packages/editor-app/src/application/commands/CommandManager.ts index 0d20f900..b3784385 100644 --- a/packages/editor-app/src/application/commands/CommandManager.ts +++ b/packages/editor-app/src/application/commands/CommandManager.ts @@ -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(); + } + } } /** diff --git a/packages/editor-app/src/application/commands/component/AddComponentCommand.ts b/packages/editor-app/src/application/commands/component/AddComponentCommand.ts index 9dc228d2..c6c420ac 100644 --- a/packages/editor-app/src/application/commands/component/AddComponentCommand.ts +++ b/packages/editor-app/src/application/commands/component/AddComponentCommand.ts @@ -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}`; } } diff --git a/packages/editor-app/src/application/commands/index.ts b/packages/editor-app/src/application/commands/index.ts index d4a2c3a1..682cdd87 100644 --- a/packages/editor-app/src/application/commands/index.ts +++ b/packages/editor-app/src/application/commands/index.ts @@ -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'; diff --git a/packages/editor-app/src/application/commands/prefab/ApplyPrefabCommand.ts b/packages/editor-app/src/application/commands/prefab/ApplyPrefabCommand.ts new file mode 100644 index 00000000..2b547594 --- /dev/null +++ b/packages/editor-app/src/application/commands/prefab/ApplyPrefabCommand.ts @@ -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 = {}; + private success: boolean = false; + + constructor( + private prefabService: PrefabService, + private messageHub: MessageHub, + private entity: Entity + ) { + super(); + } + + async execute(): Promise { + // 保存当前状态用于撤销 | 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}`; + } +} diff --git a/packages/editor-app/src/application/commands/prefab/BreakPrefabLinkCommand.ts b/packages/editor-app/src/application/commands/prefab/BreakPrefabLinkCommand.ts new file mode 100644 index 00000000..13d565b5 --- /dev/null +++ b/packages/editor-app/src/application/commands/prefab/BreakPrefabLinkCommand.ts @@ -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; + 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 + }); + } +} diff --git a/packages/editor-app/src/application/commands/prefab/CreatePrefabCommand.ts b/packages/editor-app/src/application/commands/prefab/CreatePrefabCommand.ts new file mode 100644 index 00000000..7a3227e5 --- /dev/null +++ b/packages/editor-app/src/application/commands/prefab/CreatePrefabCommand.ts @@ -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 { + 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; + } +} diff --git a/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts b/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts new file mode 100644 index 00000000..374b9739 --- /dev/null +++ b/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts @@ -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 + // We need to cast it to Map + const componentRegistry = ComponentRegistry.getAllComponentNames() as Map; + + // 实例化预制体 | 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); + } + } + } +} diff --git a/packages/editor-app/src/application/commands/prefab/RevertPrefabCommand.ts b/packages/editor-app/src/application/commands/prefab/RevertPrefabCommand.ts new file mode 100644 index 00000000..a1128800 --- /dev/null +++ b/packages/editor-app/src/application/commands/prefab/RevertPrefabCommand.ts @@ -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; +} + +/** + * 还原预制体实例命令 + * Revert prefab instance command + */ +export class RevertPrefabCommand extends BaseCommand { + private previousSnapshots: ComponentSnapshot[] = []; + private previousModifiedProperties: string[] = []; + private previousOriginalValues: Record = {}; + private success: boolean = false; + + constructor( + private prefabService: PrefabService, + private messageHub: MessageHub, + private entity: Entity + ) { + super(); + } + + async execute(): Promise { + // 保存当前状态用于撤销 | 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; + } +} diff --git a/packages/editor-app/src/application/commands/prefab/index.ts b/packages/editor-app/src/application/commands/prefab/index.ts new file mode 100644 index 00000000..71540327 --- /dev/null +++ b/packages/editor-app/src/application/commands/prefab/index.ts @@ -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'; diff --git a/packages/editor-app/src/application/commands/transform/TransformCommand.ts b/packages/editor-app/src/application/commands/transform/TransformCommand.ts new file mode 100644 index 00000000..d8161d62 --- /dev/null +++ b/packages/editor-app/src/application/commands/transform/TransformCommand.ts @@ -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 = { + 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 + }; + } +} diff --git a/packages/editor-app/src/components/BuildSettingsPanel.tsx b/packages/editor-app/src/components/BuildSettingsPanel.tsx index fa9f7a5b..bc58bb86 100644 --- a/packages/editor-app/src/components/BuildSettingsPanel.tsx +++ b/packages/editor-app/src/components/BuildSettingsPanel.tsx @@ -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: , 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([ - { 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('web'); - const [selectedProfile, setSelectedProfile] = useState(profiles[0] || null); - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [expandedSections, setExpandedSections] = useState>({ - 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(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(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 = { - '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(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({ { - setSettings(prev => ({ - ...prev, - scenes: prev.scenes.map((s, i) => - i === index ? { ...s, enabled: e.target.checked } : s - ) - })); - }} + onChange={e => setSceneEnabled(index, e.target.checked)} /> {scene.path} @@ -713,10 +473,7 @@ export function BuildSettingsPanel({ setSettings(prev => ({ - ...prev, - developmentBuild: e.target.checked - }))} + onChange={e => updateSettings({ developmentBuild: e.target.checked })} />
@@ -724,20 +481,14 @@ export function BuildSettingsPanel({ setSettings(prev => ({ - ...prev, - sourceMap: e.target.checked - }))} + onChange={e => updateSettings({ sourceMap: e.target.checked })} />
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' })} > @@ -798,10 +546,7 @@ export function BuildSettingsPanel({ setSettings(prev => ({ - ...prev, - companyName: e.target.value - }))} + onChange={e => updateSettings({ companyName: e.target.value })} />
@@ -809,10 +554,7 @@ export function BuildSettingsPanel({ setSettings(prev => ({ - ...prev, - productName: e.target.value - }))} + onChange={e => updateSettings({ productName: e.target.value })} />
@@ -820,10 +562,7 @@ export function BuildSettingsPanel({ setSettings(prev => ({ - ...prev, - version: e.target.value - }))} + onChange={e => updateSettings({ version: e.target.value })} />
@@ -867,11 +606,11 @@ export function BuildSettingsPanel({ {/* Status Icon | 状态图标 */}
{isBuilding ? ( - + ) : buildResult?.success ? ( - + ) : ( - + )}
@@ -950,12 +689,29 @@ export function BuildSettingsPanel({ {t('buildSettings.cancel')} ) : ( - + <> + + {buildResult?.success && buildResult.outputPath && ( + + )} + )}
diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index 872e5dbf..982219f8 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -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} + {match} + {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>(new Set(['meta'])); + const [showFilterDropdown, setShowFilterDropdown] = useState(false); + // Folder tree state const [folderTree, setFolderTree] = useState(null); const [expandedFolders, setExpandedFolders] = useState>(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(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: , 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: , 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(); + 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 */}
- +
+ + {showFilterDropdown && ( +
+
+ {t('contentBrowser.hiddenExtensions') || 'Hidden Extensions'} + {hiddenExtensions.size > 0 && ( + + )} +
+
+ {allExtensions.length === 0 ? ( +
+ {t('contentBrowser.noExtensions') || 'No file types'} +
+ ) : ( + allExtensions.map(ext => ( + + )) + )} +
+
+ )} +
setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Escape' && searchQuery) { + e.preventDefault(); + e.stopPropagation(); + setSearchQuery(''); + } + }} /> + {searchQuery && ( + + )}
+ )} +
) : ( filteredAssets.map(asset => { const isDragOverAsset = asset.type === 'folder' && dragOverFolder === asset.path; @@ -1692,7 +2040,7 @@ export class ${className} {
- {asset.name} + {highlightSearchText(asset.name, searchQuery)}
{getAssetTypeName(asset)} @@ -1706,7 +2054,23 @@ export class ${className} { {/* Status Bar */}
- {filteredAssets.length} {t('contentBrowser.items')} + + {searchQuery.trim() ? ( + // 搜索模式:显示找到的结果数 | Search mode: show found results + t('contentBrowser.searchResults', { + found: filteredAssets.length, + total: assets.length + }) + ) : ( + // 正常模式 | Normal mode + `${filteredAssets.length} ${t('contentBrowser.items')}` + )} + + {selectedPaths.size > 1 && ( + + {t('contentBrowser.selectedCount', { count: selectedPaths.size })} + + )}
@@ -1730,8 +2094,8 @@ export class ${className} { {/* Rename Dialog */} {renameDialog && ( -
setRenameDialog(null)}> -
e.stopPropagation()}> +
+

{t('contentBrowser.dialogs.renameTitle')}

@@ -1764,8 +2128,8 @@ export class ${className} { {/* Delete Confirm Dialog */} {deleteConfirmDialog && ( -
setDeleteConfirmDialog(null)}> -
e.stopPropagation()}> +
+

{t('contentBrowser.deleteConfirmTitle')}

diff --git a/packages/editor-app/src/components/ContextMenu.tsx b/packages/editor-app/src/components/ContextMenu.tsx index 1e40f832..89fd030f 100644 --- a/packages/editor-app/src/components/ContextMenu.tsx +++ b/packages/editor-app/src/components/ContextMenu.tsx @@ -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(null); - const [position, setPosition] = useState({ x: 0, y: 0 }); + const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const [activeSubmenuIndex, setActiveSubmenuIndex] = useState(null); const [submenuRect, setSubmenuRect] = useState(null); + const closeTimeoutRef = useRef | 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 (
{items.map((item, index) => { if (item.separator) { @@ -90,19 +165,16 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) { return (
{ + 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 && {item.icon}} {item.label} @@ -113,6 +185,7 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) { items={item.children} parentRect={submenuRect} onClose={onClose} + level={level + 1} /> )}
@@ -124,10 +197,12 @@ function SubMenu({ items, parentRect, onClose }: SubMenuProps) { export function ContextMenu({ items, position, onClose }: ContextMenuProps) { const menuRef = useRef(null); - const [adjustedPosition, setAdjustedPosition] = useState(position); + const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null); const [activeSubmenuIndex, setActiveSubmenuIndex] = useState(null); const [submenuRect, setSubmenuRect] = useState(null); + const closeTimeoutRef = useRef | 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 (
{items.map((item, index) => { if (item.separator) { @@ -220,19 +342,16 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) { return (
{ + 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 && {item.icon}} {item.label} @@ -243,6 +362,7 @@ export function ContextMenu({ items, position, onClose }: ContextMenuProps) { items={item.children} parentRect={submenuRect} onClose={onClose} + level={1} /> )}
diff --git a/packages/editor-app/src/components/FlexLayoutDockContainer.tsx b/packages/editor-app/src/components/FlexLayoutDockContainer.tsx index 83ef2b78..e140ccb6 100644 --- a/packages/editor-app/src/components/FlexLayoutDockContainer.tsx +++ b/packages/editor-app/src/components/FlexLayoutDockContainer.tsx @@ -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( + function FlexLayoutDockContainer({ panels, onPanelClose, activePanelId, messageHub }, ref) { const layoutRef = useRef(null); const previousLayoutJsonRef = useRef(null); const previousPanelIdsRef = useRef(''); const previousPanelTitlesRef = useRef>(new Map()); + /** Skip saving on next model change (used when resetting layout) | 下次模型变化时跳过保存(重置布局时使用) */ + const skipNextSaveRef = useRef(false); // Persistent panel state | 持久化面板状态 const [persistentPanelRects, setPersistentPanelRects] = useState>(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(() => { 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 ))}
); -} +}); /** * Container for persistent panel content. diff --git a/packages/editor-app/src/components/PropertyInspector.tsx b/packages/editor-app/src/components/PropertyInspector.tsx index 1b28d2b7..9b3969fa 100644 --- a/packages/editor-app/src/components/PropertyInspector.tsx +++ b/packages/editor-app/src/components/PropertyInspector.tsx @@ -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>({}); const [controlledFields, setControlledFields] = useState>(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 ( -
+