From ed8f6e283bc037a05a0169a6312c71eaa5bb8562 Mon Sep 17 00:00:00 2001 From: YHH <359807859@qq.com> Date: Tue, 16 Dec 2025 12:46:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BA=B9=E7=90=86=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=20ID=20=E4=B8=8E=E6=9E=B6=E6=9E=84=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=20(#305)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(asset-system): 实现路径稳定 ID 生成器 使用 FNV-1a hash 算法为纹理生成稳定的运行时 ID: - 新增 _pathIdCache 静态缓存,跨 Play/Stop 循环保持稳定 - 新增 getStableIdForPath() 方法,相同路径永远返回相同 ID - 修改 loadTextureForComponent/loadTextureByGuid 使用稳定 ID - clearTextureMappings() 不再清除 _pathIdCache 这解决了 Play/Stop 后纹理 ID 失效的根本问题。 * fix(runtime-core): 移除 Play/Stop 循环中的 clearTextureMappings 调用 使用路径稳定 ID 后,不再需要在快照保存/恢复时清除纹理缓存: - saveSceneSnapshot() 移除 clearTextureMappings() 调用 - restoreSceneSnapshot() 移除 clearTextureMappings() 调用 - 组件保存的 textureId 在 Play/Stop 后仍然有效 * fix(editor-core): 修复场景切换时的资源泄漏 在 openScene() 加载新场景前先卸载旧场景资源: - 调用 sceneResourceManager.unloadSceneResources() 释放旧资源 - 使用引用计数机制,仅卸载不再被引用的资源 - 路径稳定 ID 缓存不受影响,保持 ID 稳定性 * fix(runtime-core): 修复 PluginManager 组件注册类型错误 将 ComponentRegistry 类改为 GlobalComponentRegistry 实例: - registerComponents() 期望 IComponentRegistry 接口实例 - GlobalComponentRegistry 是 ComponentRegistry 的全局实例 * refactor(core): 提取 IComponentRegistry 接口 将组件注册表抽象为接口,支持场景级组件注册: - 新增 IComponentRegistry 接口定义 - Scene 持有独立的 componentRegistry 实例 - 支持从 GlobalComponentRegistry 克隆 - 各系统支持传入自定义注册表 * refactor(engine-core): 改进插件服务注册机制 - 更新 IComponentRegistry 类型引用 - 优化 PluginServiceRegistry 服务管理 * refactor(modules): 适配新的组件注册接口 更新各模块 RuntimeModule 使用 IComponentRegistry 接口: - audio, behavior-tree, camera - sprite, tilemap, world-streaming * fix(physics-rapier2d): 修复物理插件组件注册 - PhysicsEditorPlugin 添加 runtimeModule 引用 - 适配 IComponentRegistry 接口 - 修复物理组件在场景加载时未注册的问题 * feat(editor-core): 添加 UserCodeService 就绪信号机制 - 新增 waitForReady()/signalReady() API - 支持等待用户脚本编译完成 - 解决场景加载时组件未注册的时序问题 * fix(editor-app): 在编译完成后调用 signalReady() 确保用户脚本编译完成后发出就绪信号: - 编译成功后调用 userCodeService.signalReady() - 编译失败也要发出信号,避免阻塞场景加载 * feat(editor-core): 改进编辑器核心服务 - EntityStoreService 添加调试日志 - AssetRegistryService 优化资产注册 - PluginManager 改进插件管理 - IFileAPI 添加 getFileMtime 接口 * feat(engine): 改进 Rust 纹理管理器 - 支持任意 ID 的纹理加载(非递增) - 添加纹理状态追踪 API - 优化纹理缓存清理机制 - 更新 TypeScript 绑定 * feat(ui): 添加场景切换和文本闪烁组件 新增组件: - SceneLoadTriggerComponent: 场景切换触发器 - TextBlinkComponent: 文本闪烁效果 新增系统: - SceneLoadTriggerSystem: 处理场景切换逻辑 - TextBlinkSystem: 处理文本闪烁动画 其他改进: - UIRuntimeModule 适配新组件注册接口 - UI 渲染系统优化 * feat(editor-app): 添加外部文件修改检测 - 新增 ExternalModificationDialog 组件 - TauriFileAPI 支持 getFileMtime - 场景文件被外部修改时提示用户 * feat(editor-app): 添加渲染调试面板 - 新增 RenderDebugService 和调试面板 UI - App/ContentBrowser 添加调试日志 - TitleBar/Viewport 优化 - DialogManager 改进 * refactor(editor-app): 编辑器服务和组件优化 - EngineService 改进引擎集成 - EditorEngineSync 同步优化 - AssetFileInspector 改进 - VectorFieldEditors 优化 - InstantiatePrefabCommand 改进 * feat(i18n): 更新国际化翻译 - 添加新功能相关翻译 - 更新中文、英文、西班牙文 * feat(tauri): 添加文件修改时间查询命令 - 新增 get_file_mtime 命令 - 支持检测文件外部修改 * refactor(particle): 粒子系统改进 - 适配新的组件注册接口 - ParticleSystem 优化 - 添加单元测试 * refactor(platform): 平台适配层优化 - BrowserRuntime 改进 - 新增 RuntimeSceneManager 服务 - 导出优化 * refactor(asset-system-editor): 资产元数据改进 - AssetMetaFile 优化 - 导出调整 * fix(asset-system): 移除未使用的 TextureLoader 导入 * fix(tests): 更新测试以使用 GlobalComponentRegistry 实例 修复多个测试文件以适配 ComponentRegistry 从静态类变为实例类的变更: - ComponentStorage.test.ts: 使用 GlobalComponentRegistry.reset() - EntitySerializer.test.ts: 使用 GlobalComponentRegistry 实例 - IncrementalSerialization.test.ts: 使用 GlobalComponentRegistry 实例 - SceneSerializer.test.ts: 使用 GlobalComponentRegistry 实例 - ComponentRegistry.extended.test.ts: 使用 GlobalComponentRegistry,同时注册到 scene.componentRegistry - SystemTypes.test.ts: 在 Scene 创建前注册组件 - QuerySystem.test.ts: mockScene 添加 componentRegistry --- packages/asset-system-editor/src/index.ts | 1 + .../src/meta/AssetMetaFile.ts | 48 + .../src/integration/EngineIntegration.ts | 223 ++-- packages/audio/src/AudioPlugin.ts | 4 +- .../src/BehaviorTreeRuntimeModule.ts | 5 +- packages/camera/src/CameraPlugin.ts | 4 +- packages/core/src/ECS/Core/ArchetypeSystem.ts | 4 +- packages/core/src/ECS/Core/CommandBuffer.ts | 8 +- .../core/src/ECS/Core/ComponentStorage.ts | 18 +- .../ComponentStorage/ComponentRegistry.ts | 363 +++--- .../ComponentStorage/IComponentRegistry.ts | 192 +++ packages/core/src/ECS/Core/QuerySystem.ts | 6 +- packages/core/src/ECS/Core/Storage/index.ts | 3 +- .../src/ECS/Decorators/PropertyDecorator.ts | 5 +- .../core/src/ECS/Decorators/TypeDecorators.ts | 8 +- packages/core/src/ECS/Entity.ts | 20 +- packages/core/src/ECS/IScene.ts | 22 + packages/core/src/ECS/Scene.ts | 37 +- .../src/ECS/Serialization/SceneSerializer.ts | 4 +- .../core/src/ECS/Utils/ComponentSparseSet.ts | 16 +- .../Core/ComponentRegistry.extended.test.ts | 112 +- .../tests/ECS/Core/ComponentStorage.test.ts | 112 +- .../core/tests/ECS/Core/QuerySystem.test.ts | 8 +- .../Serialization/EntitySerializer.test.ts | 14 +- .../IncrementalSerialization.test.ts | 14 +- .../ECS/Serialization/SceneSerializer.test.ts | 4 +- .../tests/ECS/Systems/SystemTypes.test.ts | 10 +- .../src/core/EngineBridge.ts | 127 ++ .../src/systems/EngineRenderSystem.ts | 33 +- .../src/wasm/es_engine.d.ts | 28 + .../src-tauri/src/commands/file_system.rs | 19 + packages/editor-app/src-tauri/src/main.rs | 14 +- packages/editor-app/src-tauri/tauri.conf.json | 6 +- packages/editor-app/src/App.tsx | 211 +++- .../editor-app/src/adapters/TauriFileAPI.ts | 4 + packages/editor-app/src/api/tauri.ts | 11 + .../src/app/managers/DialogManager.ts | 22 +- .../src/app/managers/ServiceRegistry.ts | 14 +- .../prefab/InstantiatePrefabCommand.ts | 6 +- .../src/components/ContentBrowser.tsx | 3 + .../components/ExternalModificationDialog.tsx | 58 + .../editor-app/src/components/TitleBar.tsx | 5 +- .../editor-app/src/components/Viewport.tsx | 163 ++- .../src/components/debug/RenderDebugPanel.css | 633 ++++++++++ .../src/components/debug/RenderDebugPanel.tsx | 1059 +++++++++++++++++ .../editor-app/src/components/debug/index.ts | 7 + .../inspectors/views/AssetFileInspector.tsx | 226 +++- .../field-editors/VectorFieldEditors.tsx | 30 +- packages/editor-app/src/locales/en.ts | 22 +- packages/editor-app/src/locales/es.ts | 3 +- packages/editor-app/src/locales/zh.ts | 22 +- .../src/services/EditorEngineSync.ts | 16 +- .../editor-app/src/services/EngineService.ts | 75 +- .../src/services/RenderDebugService.ts | 591 +++++++++ .../editor-app/src/styles/ConfirmDialog.css | 49 + .../editor-core/src/Plugin/PluginManager.ts | 12 +- .../src/Services/AssetRegistryService.ts | 20 +- .../src/Services/EntityStoreService.ts | 5 +- .../src/Services/SceneManagerService.ts | 205 +++- .../src/Services/UserCode/IUserCodeService.ts | 43 +- .../src/Services/UserCode/UserCodeService.ts | 210 ++-- .../src/Services/UserCode/index.ts | 15 +- packages/editor-core/src/Types/IFileAPI.ts | 9 + packages/engine-core/src/EnginePlugin.ts | 6 +- .../engine-core/src/PluginServiceRegistry.ts | 52 + packages/engine/src/core/engine.rs | 26 +- packages/engine/src/lib.rs | 36 + packages/engine/src/renderer/texture/mod.rs | 2 +- .../src/renderer/texture/texture_manager.rs | 111 +- .../particle/src/ParticleRuntimeModule.ts | 11 +- .../particle/src/ParticleSystemComponent.ts | 37 + .../src/__tests__/particle-e2e-test.html | 401 +++++++ .../src/__tests__/sprite-batch-test.html | 328 +++++ .../src/__tests__/uv-calculation.test.ts | 142 +++ .../particle/src/__tests__/webgl-uv-test.html | 278 +++++ .../rendering/ParticleRenderDataProvider.ts | 43 +- .../particle/src/systems/ClickFxSystem.ts | 45 +- .../particle/src/systems/ParticleSystem.ts | 180 ++- .../src/Physics2DComponentsModule.ts | 5 +- .../src/PhysicsEditorPlugin.ts | 20 +- .../src/PhysicsRuntimeModule.ts | 8 +- .../src/loaders/Rapier2DLoaderConfig.ts | 9 +- packages/platform-web/src/BrowserRuntime.ts | 94 +- packages/platform-web/src/index.ts | 10 + packages/runtime-core/src/GameRuntime.ts | 36 +- packages/runtime-core/src/PluginManager.ts | 4 +- packages/runtime-core/src/index.ts | 10 + .../src/services/RuntimeSceneManager.ts | 391 ++++++ packages/sprite/src/SpriteRuntimeModule.ts | 4 +- packages/tilemap/src/TilemapRuntimeModule.ts | 5 +- packages/ui/src/UIRuntimeModule.ts | 19 +- .../components/SceneLoadTriggerComponent.ts | 61 + .../ui/src/components/TextBlinkComponent.ts | 101 ++ .../ui/src/components/UIRenderComponent.ts | 27 + .../ui/src/components/UITransformComponent.ts | 9 + packages/ui/src/index.ts | 5 + .../ui/src/systems/SceneLoadTriggerSystem.ts | 162 +++ packages/ui/src/systems/TextBlinkSystem.ts | 37 + packages/ui/src/systems/UILayoutSystem.ts | 57 +- .../systems/render/UIButtonRenderSystem.ts | 4 +- .../render/UIProgressBarRenderSystem.ts | 4 +- .../src/systems/render/UIRectRenderSystem.ts | 76 +- .../src/systems/render/UIRenderCollector.ts | 132 +- .../render/UIScrollViewRenderSystem.ts | 4 +- .../systems/render/UISliderRenderSystem.ts | 4 +- .../src/systems/render/UITextRenderSystem.ts | 4 +- .../src/WorldStreamingModule.ts | 5 +- 107 files changed, 7399 insertions(+), 847 deletions(-) create mode 100644 packages/core/src/ECS/Core/ComponentStorage/IComponentRegistry.ts create mode 100644 packages/editor-app/src/components/ExternalModificationDialog.tsx create mode 100644 packages/editor-app/src/components/debug/RenderDebugPanel.css create mode 100644 packages/editor-app/src/components/debug/RenderDebugPanel.tsx create mode 100644 packages/editor-app/src/components/debug/index.ts create mode 100644 packages/editor-app/src/services/RenderDebugService.ts create mode 100644 packages/particle/src/__tests__/particle-e2e-test.html create mode 100644 packages/particle/src/__tests__/sprite-batch-test.html create mode 100644 packages/particle/src/__tests__/uv-calculation.test.ts create mode 100644 packages/particle/src/__tests__/webgl-uv-test.html create mode 100644 packages/runtime-core/src/services/RuntimeSceneManager.ts create mode 100644 packages/ui/src/components/SceneLoadTriggerComponent.ts create mode 100644 packages/ui/src/components/TextBlinkComponent.ts create mode 100644 packages/ui/src/systems/SceneLoadTriggerSystem.ts create mode 100644 packages/ui/src/systems/TextBlinkSystem.ts diff --git a/packages/asset-system-editor/src/index.ts b/packages/asset-system-editor/src/index.ts index 39898625..24d56846 100644 --- a/packages/asset-system-editor/src/index.ts +++ b/packages/asset-system-editor/src/index.ts @@ -18,6 +18,7 @@ export { AssetMetaManager, type IAssetMeta, type IImportSettings, + type ISpriteSettings, type IMetaFileSystem, getMetaFilePath, inferAssetType, diff --git a/packages/asset-system-editor/src/meta/AssetMetaFile.ts b/packages/asset-system-editor/src/meta/AssetMetaFile.ts index 5556ff96..4fc43b2a 100644 --- a/packages/asset-system-editor/src/meta/AssetMetaFile.ts +++ b/packages/asset-system-editor/src/meta/AssetMetaFile.ts @@ -49,6 +49,36 @@ export interface IAssetMeta { lastModified?: number; } +/** + * Sprite settings for textures + * 纹理的 Sprite 设置 + */ +export interface ISpriteSettings { + /** + * Nine-patch slice border [top, right, bottom, left] + * 九宫格切片边距 + * + * Defines the non-stretchable borders for nine-patch rendering. + * 定义九宫格渲染时不可拉伸的边框区域。 + */ + sliceBorder?: [number, number, number, number]; + + /** + * Sprite pivot point (0-1 normalized) + * Sprite 锚点(0-1 归一化) + * + * Default is [0.5, 0.5] (center) + * 默认为 [0.5, 0.5](中心) + */ + pivot?: [number, number]; + + /** + * Pixels per unit for world-space rendering + * 世界空间渲染的像素单位比 + */ + pixelsPerUnit?: number; +} + /** * Import settings for different asset types * 不同资产类型的导入设置 @@ -62,6 +92,9 @@ export interface IImportSettings { wrapMode?: 'clamp' | 'repeat' | 'mirror'; premultiplyAlpha?: boolean; + // Sprite settings | Sprite 设置 + spriteSettings?: ISpriteSettings; + // Audio settings | 音频设置 audioFormat?: 'mp3' | 'ogg' | 'wav'; sampleRate?: number; @@ -385,6 +418,21 @@ export class AssetMetaManager { } } + /** + * Invalidate cache for a specific asset path + * 使特定资产路径的缓存失效 + * + * Call this when a .meta file is modified externally. + * 当 .meta 文件被外部修改时调用此方法。 + */ + invalidateCache(assetPath: string): void { + const meta = this._cache.get(assetPath); + if (meta) { + this._guidToPath.delete(meta.guid); + this._cache.delete(assetPath); + } + } + /** * Clear cache * 清除缓存 diff --git a/packages/asset-system/src/integration/EngineIntegration.ts b/packages/asset-system/src/integration/EngineIntegration.ts index aa783af1..5309a97c 100644 --- a/packages/asset-system/src/integration/EngineIntegration.ts +++ b/packages/asset-system/src/integration/EngineIntegration.ts @@ -7,7 +7,6 @@ import { AssetManager } from '../core/AssetManager'; import { AssetGUID, AssetType } from '../types/AssetTypes'; import { ITextureAsset, IAudioAsset, IJsonAsset } from '../interfaces/IAssetLoader'; import { PathResolutionService, type IPathResolutionService } from '../services/PathResolutionService'; -import { TextureLoader } from '../loaders/TextureLoader'; /** * Texture engine bridge interface (for asset system) @@ -67,6 +66,49 @@ export interface ITextureEngineBridge { * 清除所有纹理并重置状态(可选)。 */ clearAllTextures?(): void; + + // ===== Texture State API ===== + // ===== 纹理状态 API ===== + + /** + * Get texture loading state. + * 获取纹理加载状态。 + * + * @param id Texture ID | 纹理 ID + * @returns State string: 'loading', 'ready', or 'failed:reason' | 状态字符串 + */ + getTextureState?(id: number): string; + + /** + * Check if texture is ready for rendering. + * 检查纹理是否已就绪可渲染。 + * + * @param id Texture ID | 纹理 ID + * @returns true if texture data is loaded | 纹理数据已加载则返回 true + */ + isTextureReady?(id: number): boolean; + + /** + * Get count of textures currently loading. + * 获取当前正在加载的纹理数量。 + * + * @returns Number of textures in 'loading' state | 处于加载状态的纹理数量 + */ + getTextureLoadingCount?(): number; + + /** + * Load texture asynchronously with Promise. + * 使用 Promise 异步加载纹理。 + * + * Unlike loadTexture which returns immediately, this method + * waits until the texture is actually loaded and ready. + * 与 loadTexture 立即返回不同,此方法会等待纹理实际加载完成。 + * + * @param id Texture ID | 纹理 ID + * @param url Image URL | 图片 URL + * @returns Promise that resolves when texture is ready | 纹理就绪时解析的 Promise + */ + loadTextureAsync?(id: number, url: string): Promise; } /** @@ -100,6 +142,10 @@ export class EngineIntegration { private _textureIdMap = new Map(); private _pathToTextureId = new Map(); + // 路径稳定 ID 缓存(跨 Play/Stop 循环保持稳定) + // Path-stable ID cache (persists across Play/Stop cycles) + private static _pathIdCache = new Map(); + // Audio resource mappings | 音频资源映射 private _audioIdMap = new Map(); private _pathToAudioId = new Map(); @@ -112,6 +158,39 @@ export class EngineIntegration { private _dataAssets = new Map(); private static _nextDataId = 1; + /** + * 根据路径生成稳定的 ID(使用 FNV-1a hash) + * Generate stable ID from path (using FNV-1a hash) + * + * 相同路径永远返回相同 ID,即使在 clearTextureMappings 后 + * Same path always returns same ID, even after clearTextureMappings + * + * @param path 资源路径 | Resource path + * @param type 资源类型 | Resource type + * @returns 稳定的运行时 ID | Stable runtime ID + */ + private static getStableIdForPath(path: string, type: 'texture' | 'audio'): number { + const cacheKey = `${type}:${path}`; + const cached = EngineIntegration._pathIdCache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + // FNV-1a hash 算法 | FNV-1a hash algorithm + let hash = 2166136261; // FNV offset basis + for (let i = 0; i < path.length; i++) { + hash ^= path.charCodeAt(i); + hash = Math.imul(hash, 16777619); // FNV prime + hash = hash >>> 0; // Keep as uint32 + } + + // 确保 ID > 0(0 保留给默认纹理) + // Ensure ID > 0 (0 is reserved for default texture) + const id = (hash % 0x7FFFFFFF) + 1; + EngineIntegration._pathIdCache.set(cacheKey, id); + return id; + } + constructor(assetManager: AssetManager, engineBridge?: ITextureEngineBridge, pathResolver?: IPathResolutionService) { this._assetManager = assetManager; this._engineBridge = engineBridge; @@ -138,63 +217,56 @@ export class EngineIntegration { * Load texture for component * 为组件加载纹理 * - * 使用 Rust 引擎作为纹理 ID 的唯一分配源。 - * Uses Rust engine as the single source of truth for texture ID allocation. + * 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。 + * 这样组件保存的 textureId 在恢复场景后仍然有效。 + * + * Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles. + * This ensures component's saved textureId remains valid after scene restore. * * AssetManager 内部会处理路径解析,这里只需传入原始路径。 * AssetManager handles path resolution internally, just pass the original path here. */ async loadTextureForComponent(texturePath: string): Promise { - // 检查缓存(使用原始路径作为键) - // Check cache (using original path as key) + // 生成路径稳定 ID(相同路径永远返回相同 ID) + // Generate path-stable ID (same path always returns same ID) + const stableId = EngineIntegration.getStableIdForPath(texturePath, 'texture'); + + // 检查是否已加载到 GPU + // Check if already loaded to GPU const existingId = this._pathToTextureId.get(texturePath); - if (existingId) { - return existingId; + if (existingId === stableId) { + return stableId; // 已加载,直接返回 | Already loaded, return directly } // 解析路径为引擎可用的 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; + // 使用稳定 ID 加载纹理到 GPU + // Load texture to GPU with stable ID + if (this._engineBridge) { + // 优先使用异步加载(支持加载状态追踪) + // Prefer async loading (supports loading state tracking) + if (this._engineBridge.loadTextureAsync) { + await this._engineBridge.loadTextureAsync(stableId, engineUrl); + } else { + await this._engineBridge.loadTexture(stableId, engineUrl); } } - // 回退:通过资产系统加载(兼容旧流程) - // Fallback: Load through asset system (for backward compatibility) - const result = await this._assetManager.loadAssetByPath(texturePath); - const textureAsset = result.asset; + // 缓存映射 + // Cache mapping + this._pathToTextureId.set(texturePath, stableId); - // 如果有引擎桥接,上传到GPU - // Upload to GPU if bridge exists - if (this._engineBridge && textureAsset.data) { - await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl); - } - - // 缓存映射(使用原始路径作为键,避免重复解析) - // Cache mapping (using original path as key to avoid re-resolving) - this._pathToTextureId.set(texturePath, textureAsset.textureId); - - return textureAsset.textureId; + return stableId; } /** * Load texture by GUID * 通过GUID加载纹理 * - * 使用 Rust 引擎作为纹理 ID 的唯一分配源。 - * Uses Rust engine as the single source of truth for texture ID allocation. + * 使用路径稳定 ID 确保相同路径在 Play/Stop 循环后返回相同 ID。 + * Uses path-stable ID to ensure same path returns same ID across Play/Stop cycles. */ async loadTextureByGuid(guid: AssetGUID): Promise { // 检查是否已有纹理ID / Check if texture ID exists @@ -206,31 +278,38 @@ export class EngineIntegration { // 通过资产系统加载获取元数据和路径 / Load through asset system to get metadata and path const result = await this._assetManager.loadAsset(guid); const metadata = result.metadata; - const engineUrl = this._pathResolver.catalogToRuntime(metadata.path); + const assetPath = metadata.path; - // 优先使用 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; + // 生成路径稳定 ID + // Generate path-stable ID + const stableId = EngineIntegration.getStableIdForPath(assetPath, 'texture'); + + // 检查是否已加载到 GPU + // Check if already loaded to GPU + if (this._pathToTextureId.get(assetPath) === stableId) { + this._textureIdMap.set(guid, stableId); + return stableId; + } + + // 解析路径为引擎可用的 URL + // Resolve path to engine-compatible URL + const engineUrl = this._pathResolver.catalogToRuntime(assetPath); + + // 使用稳定 ID 加载纹理到 GPU + // Load texture to GPU with stable ID + if (this._engineBridge) { + if (this._engineBridge.loadTextureAsync) { + await this._engineBridge.loadTextureAsync(stableId, engineUrl); + } else { + await this._engineBridge.loadTexture(stableId, engineUrl); } } - // 回退:使用 TextureLoader 分配的 ID(兼容旧流程) - // Fallback: Use TextureLoader allocated ID (for backward compatibility) - const textureAsset = result.asset; - if (this._engineBridge && textureAsset.data) { - await this._engineBridge.loadTexture(textureAsset.textureId, engineUrl); - } - // 缓存映射 / Cache mapping - this._textureIdMap.set(guid, textureAsset.textureId); + this._textureIdMap.set(guid, stableId); + this._pathToTextureId.set(assetPath, stableId); - return textureAsset.textureId; + return stableId; } /** @@ -561,40 +640,36 @@ export class EngineIntegration { } /** - * Clear all texture mappings - * 清空所有纹理映射 + * Clear all texture mappings (for scene switching) + * 清空所有纹理映射(用于场景切换) * - * This clears both local texture ID mappings and the AssetManager's - * texture cache to ensure textures are fully reloaded. - * 这会清除本地纹理 ID 映射和 AssetManager 的纹理缓存,确保纹理完全重新加载。 + * 注意:使用路径稳定 ID 后,不应在 Play/Stop 循环中调用此方法。 + * 此方法仅用于场景切换时释放旧场景的纹理资源。 * - * IMPORTANT: This also clears the Rust engine's texture cache to ensure - * both JS and Rust layers are in sync. - * 重要:这也会清除 Rust 引擎的纹理缓存,确保 JS 和 Rust 层同步。 + * NOTE: With path-stable IDs, this should NOT be called during Play/Stop cycle. + * This method is only for releasing old scene's texture resources during scene switching. + * + * _pathIdCache 不会被清除,确保相同路径始终返回相同 ID。 + * _pathIdCache is NOT cleared, ensuring same path always returns same ID. */ clearTextureMappings(): void { - // 1. 清除本地映射 - // Clear local mappings + // 1. 清除加载状态映射(不清除 _pathIdCache) + // Clear load state mappings (NOT clearing _pathIdCache) 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 + // 2. 清除 Rust 引擎的 GPU 纹理资源 + // Clear Rust engine's GPU texture resources 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(); + // 注意:不再重置 TextureLoader 的 ID 计数器,因为现在使用路径稳定 ID + // NOTE: No longer reset TextureLoader's ID counter as we now use path-stable IDs } /** diff --git a/packages/audio/src/AudioPlugin.ts b/packages/audio/src/AudioPlugin.ts index fbfd4d71..e74aea91 100644 --- a/packages/audio/src/AudioPlugin.ts +++ b/packages/audio/src/AudioPlugin.ts @@ -1,9 +1,9 @@ -import type { ComponentRegistry as ComponentRegistryType } from '@esengine/ecs-framework'; +import type { IComponentRegistry } from '@esengine/ecs-framework'; import type { IRuntimeModule, IRuntimePlugin, ModuleManifest } from '@esengine/engine-core'; import { AudioSourceComponent } from './AudioSourceComponent'; class AudioRuntimeModule implements IRuntimeModule { - registerComponents(registry: typeof ComponentRegistryType): void { + registerComponents(registry: IComponentRegistry): void { registry.register(AudioSourceComponent); } } diff --git a/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts b/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts index 2189ca4d..845561d2 100644 --- a/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts +++ b/packages/behavior-tree/src/BehaviorTreeRuntimeModule.ts @@ -1,5 +1,4 @@ -import type { IScene, ServiceContainer } from '@esengine/ecs-framework'; -import { ComponentRegistry } from '@esengine/ecs-framework'; +import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework'; import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; import { AssetManagerToken } from '@esengine/asset-system'; @@ -17,7 +16,7 @@ export { BehaviorTreeSystemToken } from './tokens'; class BehaviorTreeRuntimeModule implements IRuntimeModule { private _loaderRegistered = false; - registerComponents(registry: typeof ComponentRegistry): void { + registerComponents(registry: IComponentRegistry): void { registry.register(BehaviorTreeRuntimeComponent); } diff --git a/packages/camera/src/CameraPlugin.ts b/packages/camera/src/CameraPlugin.ts index 379c5a69..b4722c47 100644 --- a/packages/camera/src/CameraPlugin.ts +++ b/packages/camera/src/CameraPlugin.ts @@ -1,11 +1,11 @@ -import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework'; +import type { IComponentRegistry, 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 { + registerComponents(registry: IComponentRegistry): void { registry.register(CameraComponent); } diff --git a/packages/core/src/ECS/Core/ArchetypeSystem.ts b/packages/core/src/ECS/Core/ArchetypeSystem.ts index a2d5fdc4..f951385a 100644 --- a/packages/core/src/ECS/Core/ArchetypeSystem.ts +++ b/packages/core/src/ECS/Core/ArchetypeSystem.ts @@ -1,5 +1,5 @@ import { Entity } from '../Entity'; -import { ComponentType, ComponentRegistry } from './ComponentStorage'; +import { ComponentType, GlobalComponentRegistry } from './ComponentStorage'; import { BitMask64Data, BitMask64Utils } from '../Utils'; import { BitMaskHashMap } from '../Utils/BitMaskHashMap'; @@ -271,7 +271,7 @@ export class ArchetypeSystem { private generateArchetypeId(componentTypes: ComponentType[]): ArchetypeId { const mask = BitMask64Utils.clone(BitMask64Utils.ZERO); for (const type of componentTypes) { - const bitMask = ComponentRegistry.getBitMask(type); + const bitMask = GlobalComponentRegistry.getBitMask(type); BitMask64Utils.orInPlace(mask, bitMask); } return mask; diff --git a/packages/core/src/ECS/Core/CommandBuffer.ts b/packages/core/src/ECS/Core/CommandBuffer.ts index 35a3543f..a100a6c3 100644 --- a/packages/core/src/ECS/Core/CommandBuffer.ts +++ b/packages/core/src/ECS/Core/CommandBuffer.ts @@ -1,6 +1,6 @@ import { Entity } from '../Entity'; import { Component } from '../Component'; -import { ComponentType, ComponentRegistry } from './ComponentStorage'; +import { ComponentType, GlobalComponentRegistry } from './ComponentStorage'; import { IScene } from '../IScene'; import { createLogger } from '../../Utils/Logger'; @@ -198,10 +198,10 @@ export class CommandBuffer { private getTypeId(componentOrType: Component | ComponentType): number { if (typeof componentOrType === 'function') { // ComponentType - return ComponentRegistry.getBitIndex(componentOrType); + return GlobalComponentRegistry.getBitIndex(componentOrType); } else { // Component instance - return ComponentRegistry.getBitIndex(componentOrType.constructor as ComponentType); + return GlobalComponentRegistry.getBitIndex(componentOrType.constructor as ComponentType); } } @@ -413,7 +413,7 @@ export class CommandBuffer { if (ops.removes && ops.removes.size > 0) { for (const typeId of ops.removes) { try { - const componentType = ComponentRegistry.getTypeByBitIndex(typeId); + const componentType = GlobalComponentRegistry.getTypeByBitIndex(typeId); if (componentType) { entity.removeComponentByType(componentType); commandCount++; diff --git a/packages/core/src/ECS/Core/ComponentStorage.ts b/packages/core/src/ECS/Core/ComponentStorage.ts index 01c3d8b4..45d9f5ab 100644 --- a/packages/core/src/ECS/Core/ComponentStorage.ts +++ b/packages/core/src/ECS/Core/ComponentStorage.ts @@ -3,10 +3,13 @@ import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility'; import { SoAStorage, SupportedTypedArray } from './SoAStorage'; import { createLogger } from '../../Utils/Logger'; import { getComponentTypeName, ComponentType } from '../Decorators'; -import { ComponentRegistry } from './ComponentStorage/ComponentRegistry'; +import { ComponentRegistry, GlobalComponentRegistry } from './ComponentStorage/ComponentRegistry'; +import type { IComponentRegistry } from './ComponentStorage/IComponentRegistry'; // 导出核心类型 -export { ComponentRegistry }; +// Export core types +export { ComponentRegistry, GlobalComponentRegistry }; +export type { IComponentRegistry }; export type { ComponentType }; @@ -333,15 +336,18 @@ export class ComponentStorageManager { /** * 获取实体的组件位掩码 - * @param entityId 实体ID - * @returns 组件位掩码 + * Get component bitmask for entity + * + * @param entityId 实体ID | Entity ID + * @param registry 组件注册表(可选,默认使用全局注册表)| Component registry (optional, defaults to global) + * @returns 组件位掩码 | Component bitmask */ - public getComponentMask(entityId: number): BitMask64Data { + public getComponentMask(entityId: number, registry: IComponentRegistry = GlobalComponentRegistry): BitMask64Data { const mask = BitMask64Utils.clone(BitMask64Utils.ZERO); for (const [componentType, storage] of this.storages.entries()) { if (storage.hasComponent(entityId)) { - const componentMask = ComponentRegistry.getBitMask(componentType as ComponentType); + const componentMask = registry.getBitMask(componentType as ComponentType); BitMask64Utils.orInPlace(mask, componentMask); } } diff --git a/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts b/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts index faa88ab6..f71e8f5c 100644 --- a/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts +++ b/packages/core/src/ECS/Core/ComponentStorage/ComponentRegistry.ts @@ -1,3 +1,13 @@ +/** + * Component Registry Implementation. + * 组件注册表实现。 + * + * Manages component type bitmask allocation. + * Each Scene has its own registry instance for isolation. + * 管理组件类型的位掩码分配。 + * 每个 Scene 都有自己的注册表实例以实现隔离。 + */ + import { Component } from '../../Component'; import { BitMask64Utils, BitMask64Data } from '../../Utils/BigIntCompatibility'; import { createLogger } from '../../../Utils/Logger'; @@ -6,48 +16,43 @@ import { getComponentTypeName, hasECSComponentDecorator } from './ComponentTypeUtils'; +import type { IComponentRegistry } from './IComponentRegistry'; + +const logger = createLogger('ComponentRegistry'); /** - * 组件注册表 - * 管理组件类型的位掩码分配 + * Component Registry. + * 组件注册表。 + * + * Instance-based registry for component type management. + * Each Scene should have its own registry. + * 基于实例的组件类型管理注册表。 + * 每个 Scene 应有自己的注册表。 */ -export class ComponentRegistry { - protected static readonly _logger = createLogger('ComponentStorage'); - private static componentTypes = new Map(); - private static bitIndexToType = new Map(); - private static componentNameToType = new Map(); - private static componentNameToId = new Map(); - private static maskCache = new Map(); - private static nextBitIndex = 0; +export class ComponentRegistry implements IComponentRegistry { + private _componentTypes = new Map(); + private _bitIndexToType = new Map(); + private _componentNameToType = new Map(); + private _componentNameToId = new Map(); + private _maskCache = new Map(); + private _nextBitIndex = 0; + private _hotReloadEnabled = false; + private _warnedComponents = new Set(); /** - * 热更新模式标志,默认禁用 - * Hot reload mode flag, disabled by default - * 编辑器环境应启用此选项以支持脚本热更新 - * Editor environment should enable this to support script hot reload - */ - private static hotReloadEnabled = false; - - /** - * 已警告过的组件类型集合,避免重复警告 - * Set of warned component types to avoid duplicate warnings - */ - private static warnedComponents = new Set(); - - /** - * 注册组件类型并分配位掩码 - * Register component type and allocate bitmask + * Register component type and allocate bitmask. + * 注册组件类型并分配位掩码。 * - * @param componentType 组件类型 - * @returns 分配的位索引 + * @param componentType - Component constructor | 组件构造函数 + * @returns Allocated bit index | 分配的位索引 */ - public static register(componentType: ComponentType): number { + public register(componentType: ComponentType): number { const typeName = getComponentTypeName(componentType); - // 检查是否使用了 @ECSComponent 装饰器 // Check if @ECSComponent decorator is used - if (!hasECSComponentDecorator(componentType) && !this.warnedComponents.has(componentType)) { - this.warnedComponents.add(componentType); + // 检查是否使用了 @ECSComponent 装饰器 + if (!hasECSComponentDecorator(componentType) && !this._warnedComponents.has(componentType)) { + this._warnedComponents.add(componentType); console.warn( `[ComponentRegistry] Component "${typeName}" is missing @ECSComponent decorator. ` + `This may cause issues with serialization and code minification. ` + @@ -55,51 +60,43 @@ export class ComponentRegistry { ); } - if (this.componentTypes.has(componentType)) { - const existingIndex = this.componentTypes.get(componentType)!; - return existingIndex; + if (this._componentTypes.has(componentType)) { + return this._componentTypes.get(componentType)!; } - // 检查是否有同名但不同类的组件已注册(热更新场景) - // Check if a component with the same name but different class is registered (hot reload scenario) - if (this.hotReloadEnabled && this.componentNameToType.has(typeName)) { - const existingType = this.componentNameToType.get(typeName); + // Hot reload: check if same-named component exists + // 热更新:检查是否有同名组件 + if (this._hotReloadEnabled && this._componentNameToType.has(typeName)) { + const existingType = this._componentNameToType.get(typeName); if (existingType !== componentType) { - // 热更新:替换旧的类为新的类,复用相同的 bitIndex - // Hot reload: replace old class with new class, reuse the same bitIndex - const existingIndex = this.componentTypes.get(existingType!)!; + // Reuse old bitIndex, replace class mapping + // 复用旧的 bitIndex,替换类映射 + const existingIndex = this._componentTypes.get(existingType!)!; + this._componentTypes.delete(existingType!); + this._componentTypes.set(componentType, existingIndex); + this._bitIndexToType.set(existingIndex, componentType); + this._componentNameToType.set(typeName, componentType); - // 移除旧类的映射 - // Remove old class mapping - this.componentTypes.delete(existingType!); - - // 用新类更新映射 - // Update mappings with new class - this.componentTypes.set(componentType, existingIndex); - this.bitIndexToType.set(existingIndex, componentType); - this.componentNameToType.set(typeName, componentType); - - console.log(`[ComponentRegistry] Hot reload: replaced component "${typeName}"`); + logger.debug(`Hot reload: replaced component "${typeName}"`); return existingIndex; } } - const bitIndex = this.nextBitIndex++; - this.componentTypes.set(componentType, bitIndex); - this.bitIndexToType.set(bitIndex, componentType); - this.componentNameToType.set(typeName, componentType); - this.componentNameToId.set(typeName, bitIndex); + const bitIndex = this._nextBitIndex++; + this._componentTypes.set(componentType, bitIndex); + this._bitIndexToType.set(bitIndex, componentType); + this._componentNameToType.set(typeName, componentType); + this._componentNameToId.set(typeName, bitIndex); return bitIndex; } /** - * 获取组件类型的位掩码 - * @param componentType 组件类型 - * @returns 位掩码 + * Get component type's bitmask. + * 获取组件类型的位掩码。 */ - public static getBitMask(componentType: ComponentType): BitMask64Data { - const bitIndex = this.componentTypes.get(componentType); + public getBitMask(componentType: ComponentType): BitMask64Data { + const bitIndex = this._componentTypes.get(componentType); if (bitIndex === undefined) { const typeName = getComponentTypeName(componentType); throw new Error(`Component type ${typeName} is not registered`); @@ -108,12 +105,11 @@ export class ComponentRegistry { } /** - * 获取组件类型的位索引 - * @param componentType 组件类型 - * @returns 位索引 + * Get component type's bit index. + * 获取组件类型的位索引。 */ - public static getBitIndex(componentType: ComponentType): number { - const bitIndex = this.componentTypes.get(componentType); + public getBitIndex(componentType: ComponentType): number { + const bitIndex = this._componentTypes.get(componentType); if (bitIndex === undefined) { const typeName = getComponentTypeName(componentType); throw new Error(`Component type ${typeName} is not registered`); @@ -122,90 +118,84 @@ export class ComponentRegistry { } /** - * 检查组件类型是否已注册 - * @param componentType 组件类型 - * @returns 是否已注册 + * Check if component type is registered. + * 检查组件类型是否已注册。 */ - public static isRegistered(componentType: ComponentType): boolean { - return this.componentTypes.has(componentType); + public isRegistered(componentType: ComponentType): boolean { + return this._componentTypes.has(componentType); } /** - * 通过位索引获取组件类型 - * @param bitIndex 位索引 - * @returns 组件类型构造函数或null + * Get component type by bit index. + * 通过位索引获取组件类型。 */ - public static getTypeByBitIndex(bitIndex: number): ComponentType | null { - return (this.bitIndexToType.get(bitIndex) as ComponentType) || null; + public getTypeByBitIndex(bitIndex: number): ComponentType | null { + return (this._bitIndexToType.get(bitIndex) as ComponentType) || null; } /** - * 获取当前已注册的组件类型数量 - * @returns 已注册数量 + * Get registered component count. + * 获取已注册的组件数量。 */ - public static getRegisteredCount(): number { - return this.nextBitIndex; + public getRegisteredCount(): number { + return this._nextBitIndex; } /** - * 通过名称获取组件类型 - * @param componentName 组件名称 - * @returns 组件类型构造函数 + * Get component type by name. + * 通过名称获取组件类型。 */ - public static getComponentType(componentName: string): Function | null { - return this.componentNameToType.get(componentName) || null; + public getComponentType(componentName: string): Function | null { + return this._componentNameToType.get(componentName) || null; } /** - * 获取所有已注册的组件类型 - * @returns 组件类型映射 + * Get all registered component types. + * 获取所有已注册的组件类型。 */ - public static getAllRegisteredTypes(): Map { - return new Map(this.componentTypes); + public getAllRegisteredTypes(): Map { + return new Map(this._componentTypes); } /** - * 获取所有组件名称到类型的映射 - * @returns 名称到类型的映射 + * Get all component names. + * 获取所有组件名称。 */ - public static getAllComponentNames(): Map { - return new Map(this.componentNameToType); + public getAllComponentNames(): Map { + return new Map(this._componentNameToType); } /** - * 通过名称获取组件类型ID - * @param componentName 组件名称 - * @returns 组件类型ID + * Get component type ID by name. + * 通过名称获取组件类型 ID。 */ - public static getComponentId(componentName: string): number | undefined { - return this.componentNameToId.get(componentName); + public getComponentId(componentName: string): number | undefined { + return this._componentNameToId.get(componentName); } /** - * 注册组件类型(通过名称) - * @param componentName 组件名称 - * @returns 分配的组件ID + * Register component type by name. + * 通过名称注册组件类型。 */ - public static registerComponentByName(componentName: string): number { - if (this.componentNameToId.has(componentName)) { - return this.componentNameToId.get(componentName)!; + public registerComponentByName(componentName: string): number { + if (this._componentNameToId.has(componentName)) { + return this._componentNameToId.get(componentName)!; } - const bitIndex = this.nextBitIndex++; - this.componentNameToId.set(componentName, bitIndex); + const bitIndex = this._nextBitIndex++; + this._componentNameToId.set(componentName, bitIndex); return bitIndex; } /** - * 创建单个组件的掩码 - * @param componentName 组件名称 - * @returns 组件掩码 + * Create single component mask. + * 创建单个组件的掩码。 */ - public static createSingleComponentMask(componentName: string): BitMask64Data { + public createSingleComponentMask(componentName: string): BitMask64Data { const cacheKey = `single:${componentName}`; - if (this.maskCache.has(cacheKey)) { - return this.maskCache.get(cacheKey)!; + if (this._maskCache.has(cacheKey)) { + return this._maskCache.get(cacheKey)!; } const componentId = this.getComponentId(componentName); @@ -214,21 +204,20 @@ export class ComponentRegistry { } const mask = BitMask64Utils.create(componentId); - this.maskCache.set(cacheKey, mask); + this._maskCache.set(cacheKey, mask); return mask; } /** - * 创建多个组件的掩码 - * @param componentNames 组件名称数组 - * @returns 组合掩码 + * Create component mask for multiple components. + * 创建多个组件的掩码。 */ - public static createComponentMask(componentNames: string[]): BitMask64Data { + public createComponentMask(componentNames: string[]): BitMask64Data { const sortedNames = [...componentNames].sort(); const cacheKey = `multi:${sortedNames.join(',')}`; - if (this.maskCache.has(cacheKey)) { - return this.maskCache.get(cacheKey)!; + if (this._maskCache.has(cacheKey)) { + return this._maskCache.get(cacheKey)!; } const mask = BitMask64Utils.clone(BitMask64Utils.ZERO); @@ -240,90 +229,79 @@ export class ComponentRegistry { } } - this.maskCache.set(cacheKey, mask); + this._maskCache.set(cacheKey, mask); return mask; } /** - * 清除掩码缓存 + * Clear mask cache. + * 清除掩码缓存。 */ - public static clearMaskCache(): void { - this.maskCache.clear(); + public clearMaskCache(): void { + this._maskCache.clear(); } /** - * 启用热更新模式 - * Enable hot reload mode - * 在编辑器环境中调用以支持脚本热更新 - * Call in editor environment to support script hot reload + * Enable hot reload mode. + * 启用热更新模式。 */ - public static enableHotReload(): void { - this.hotReloadEnabled = true; + public enableHotReload(): void { + this._hotReloadEnabled = true; } /** - * 禁用热更新模式 - * Disable hot reload mode + * Disable hot reload mode. + * 禁用热更新模式。 */ - public static disableHotReload(): void { - this.hotReloadEnabled = false; + public disableHotReload(): void { + this._hotReloadEnabled = false; } /** - * 检查热更新模式是否启用 - * Check if hot reload mode is enabled + * Check if hot reload mode is enabled. + * 检查热更新模式是否启用。 */ - public static isHotReloadEnabled(): boolean { - return this.hotReloadEnabled; + public isHotReloadEnabled(): boolean { + return this._hotReloadEnabled; } /** - * 注销组件类型 - * Unregister component type - * - * 用于插件卸载时清理组件。 - * 注意:这不会释放 bitIndex,以避免索引冲突。 - * - * Used for cleanup during plugin unload. - * Note: This does not release bitIndex to avoid index conflicts. - * - * @param componentName 组件名称 | Component name + * Unregister component type. + * 注销组件类型。 */ - public static unregister(componentName: string): void { - const componentType = this.componentNameToType.get(componentName); + public unregister(componentName: string): void { + const componentType = this._componentNameToType.get(componentName); if (!componentType) { return; } - const bitIndex = this.componentTypes.get(componentType); + const bitIndex = this._componentTypes.get(componentType); - // 移除类型映射 // Remove type mappings - this.componentTypes.delete(componentType); + // 移除类型映射 + this._componentTypes.delete(componentType); if (bitIndex !== undefined) { - this.bitIndexToType.delete(bitIndex); + this._bitIndexToType.delete(bitIndex); } - this.componentNameToType.delete(componentName); - this.componentNameToId.delete(componentName); + this._componentNameToType.delete(componentName); + this._componentNameToId.delete(componentName); - // 清除相关的掩码缓存 // Clear related mask cache + // 清除相关的掩码缓存 this.clearMaskCache(); - this._logger.debug(`Component unregistered: ${componentName}`); + logger.debug(`Component unregistered: ${componentName}`); } /** - * 获取所有已注册的组件信息 - * Get all registered component info - * - * @returns 组件信息数组 | Array of component info + * Get all registered component info. + * 获取所有已注册的组件信息。 */ - public static getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }> { + public getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }> { const result: Array<{ name: string; type: Function; bitIndex: number }> = []; - for (const [name, type] of this.componentNameToType) { - const bitIndex = this.componentTypes.get(type); + for (const [name, type] of this._componentNameToType) { + const bitIndex = this._componentTypes.get(type); if (bitIndex !== undefined) { result.push({ name, type, bitIndex }); } @@ -333,17 +311,48 @@ export class ComponentRegistry { } /** - * 重置注册表(用于测试) - * Reset registry (for testing) + * Reset registry. + * 重置注册表。 */ - public static reset(): void { - this.componentTypes.clear(); - this.bitIndexToType.clear(); - this.componentNameToType.clear(); - this.componentNameToId.clear(); - this.maskCache.clear(); - this.warnedComponents.clear(); - this.nextBitIndex = 0; - this.hotReloadEnabled = false; + public reset(): void { + this._componentTypes.clear(); + this._bitIndexToType.clear(); + this._componentNameToType.clear(); + this._componentNameToId.clear(); + this._maskCache.clear(); + this._warnedComponents.clear(); + this._nextBitIndex = 0; + this._hotReloadEnabled = false; + } + + /** + * Clone component types from another registry. + * 从另一个注册表克隆组件类型。 + * + * Used to inherit framework components when creating a new Scene. + * 用于在创建新 Scene 时继承框架组件。 + */ + public cloneFrom(source: IComponentRegistry): void { + const types = source.getAllRegisteredTypes(); + for (const [type, index] of types) { + this._componentTypes.set(type, index); + this._bitIndexToType.set(index, type); + const typeName = getComponentTypeName(type as ComponentType); + this._componentNameToType.set(typeName, type); + this._componentNameToId.set(typeName, index); + } + this._nextBitIndex = source.getRegisteredCount(); + this._hotReloadEnabled = source.isHotReloadEnabled(); } } + +/** + * Global Component Registry. + * 全局组件注册表。 + * + * Used by framework components and decorators. + * Scene instances clone from this registry on creation. + * 用于框架组件和装饰器。 + * Scene 实例在创建时从此注册表克隆。 + */ +export const GlobalComponentRegistry = new ComponentRegistry(); diff --git a/packages/core/src/ECS/Core/ComponentStorage/IComponentRegistry.ts b/packages/core/src/ECS/Core/ComponentStorage/IComponentRegistry.ts new file mode 100644 index 00000000..87e90761 --- /dev/null +++ b/packages/core/src/ECS/Core/ComponentStorage/IComponentRegistry.ts @@ -0,0 +1,192 @@ +/** + * Component Registry Interface. + * 组件注册表接口。 + * + * Defines the contract for component type registration and lookup. + * Each Scene has its own ComponentRegistry instance for isolation. + * 定义组件类型注册和查找的契约。 + * 每个 Scene 都有自己的 ComponentRegistry 实例以实现隔离。 + */ + +import type { Component } from '../../Component'; +import type { BitMask64Data } from '../../Utils/BigIntCompatibility'; +import type { ComponentType } from './ComponentTypeUtils'; + +/** + * Component Registry Interface. + * 组件注册表接口。 + */ +export interface IComponentRegistry { + /** + * Register component type and allocate bitmask. + * 注册组件类型并分配位掩码。 + * + * @param componentType - Component constructor | 组件构造函数 + * @returns Allocated bit index | 分配的位索引 + */ + register(componentType: ComponentType): number; + + /** + * Get component type's bitmask. + * 获取组件类型的位掩码。 + * + * @param componentType - Component constructor | 组件构造函数 + * @returns Bitmask | 位掩码 + */ + getBitMask(componentType: ComponentType): BitMask64Data; + + /** + * Get component type's bit index. + * 获取组件类型的位索引。 + * + * @param componentType - Component constructor | 组件构造函数 + * @returns Bit index | 位索引 + */ + getBitIndex(componentType: ComponentType): number; + + /** + * Check if component type is registered. + * 检查组件类型是否已注册。 + * + * @param componentType - Component constructor | 组件构造函数 + * @returns Whether registered | 是否已注册 + */ + isRegistered(componentType: ComponentType): boolean; + + /** + * Get component type by bit index. + * 通过位索引获取组件类型。 + * + * @param bitIndex - Bit index | 位索引 + * @returns Component constructor or null | 组件构造函数或 null + */ + getTypeByBitIndex(bitIndex: number): ComponentType | null; + + /** + * Get component type by name. + * 通过名称获取组件类型。 + * + * @param componentName - Component name | 组件名称 + * @returns Component constructor or null | 组件构造函数或 null + */ + getComponentType(componentName: string): Function | null; + + /** + * Get component type ID by name. + * 通过名称获取组件类型 ID。 + * + * @param componentName - Component name | 组件名称 + * @returns Component type ID or undefined | 组件类型 ID 或 undefined + */ + getComponentId(componentName: string): number | undefined; + + /** + * Get all registered component types. + * 获取所有已注册的组件类型。 + * + * @returns Map of component type to bit index | 组件类型到位索引的映射 + */ + getAllRegisteredTypes(): Map; + + /** + * Get all component names. + * 获取所有组件名称。 + * + * @returns Map of name to component type | 名称到组件类型的映射 + */ + getAllComponentNames(): Map; + + /** + * Get registered component count. + * 获取已注册的组件数量。 + * + * @returns Count | 数量 + */ + getRegisteredCount(): number; + + /** + * Register component type by name. + * 通过名称注册组件类型。 + * + * @param componentName - Component name | 组件名称 + * @returns Allocated component ID | 分配的组件 ID + */ + registerComponentByName(componentName: string): number; + + /** + * Create single component mask. + * 创建单个组件的掩码。 + * + * @param componentName - Component name | 组件名称 + * @returns Component mask | 组件掩码 + */ + createSingleComponentMask(componentName: string): BitMask64Data; + + /** + * Create component mask for multiple components. + * 创建多个组件的掩码。 + * + * @param componentNames - Component names | 组件名称数组 + * @returns Combined mask | 组合掩码 + */ + createComponentMask(componentNames: string[]): BitMask64Data; + + /** + * Unregister component type. + * 注销组件类型。 + * + * @param componentName - Component name | 组件名称 + */ + unregister(componentName: string): void; + + /** + * Enable hot reload mode. + * 启用热更新模式。 + */ + enableHotReload(): void; + + /** + * Disable hot reload mode. + * 禁用热更新模式。 + */ + disableHotReload(): void; + + /** + * Check if hot reload mode is enabled. + * 检查热更新模式是否启用。 + * + * @returns Whether enabled | 是否启用 + */ + isHotReloadEnabled(): boolean; + + /** + * Clear mask cache. + * 清除掩码缓存。 + */ + clearMaskCache(): void; + + /** + * Reset registry. + * 重置注册表。 + */ + reset(): void; + + /** + * Get all registered component info. + * 获取所有已注册的组件信息。 + * + * @returns Array of component info | 组件信息数组 + */ + getRegisteredComponents(): Array<{ name: string; type: Function; bitIndex: number }>; + + /** + * Clone component types from another registry. + * 从另一个注册表克隆组件类型。 + * + * Used to inherit framework components when creating a new Scene. + * 用于在创建新 Scene 时继承框架组件。 + * + * @param source - Source registry | 源注册表 + */ + cloneFrom(source: IComponentRegistry): void; +} diff --git a/packages/core/src/ECS/Core/QuerySystem.ts b/packages/core/src/ECS/Core/QuerySystem.ts index bddbc79a..b8ba3050 100644 --- a/packages/core/src/ECS/Core/QuerySystem.ts +++ b/packages/core/src/ECS/Core/QuerySystem.ts @@ -1,6 +1,6 @@ import { Entity } from '../Entity'; import { Component } from '../Component'; -import { ComponentRegistry, ComponentType } from './ComponentStorage'; +import { GlobalComponentRegistry, ComponentType } from './ComponentStorage'; import { BitMask64Utils, BitMask64Data } from '../Utils/BigIntCompatibility'; import { createLogger } from '../../Utils/Logger'; import { getComponentTypeName } from '../Decorators'; @@ -932,7 +932,7 @@ export class QuerySystem { // 使用ComponentRegistry确保bitIndex一致 const mask = BitMask64Utils.clone(BitMask64Utils.ZERO); for (const type of componentTypes) { - const bitMask = ComponentRegistry.getBitMask(type); + const bitMask = GlobalComponentRegistry.getBitMask(type); BitMask64Utils.orInPlace(mask, bitMask); } @@ -1341,7 +1341,7 @@ export class QueryBuilder { const mask = BitMask64Utils.clone(BitMask64Utils.ZERO); for (const type of componentTypes) { try { - const bitMask = ComponentRegistry.getBitMask(type); + const bitMask = GlobalComponentRegistry.getBitMask(type); BitMask64Utils.orInPlace(mask, bitMask); } catch (error) { this._logger.warn(`组件类型 ${getComponentTypeName(type)} 未注册,跳过`); diff --git a/packages/core/src/ECS/Core/Storage/index.ts b/packages/core/src/ECS/Core/Storage/index.ts index 6dc45ec0..b1757fa7 100644 --- a/packages/core/src/ECS/Core/Storage/index.ts +++ b/packages/core/src/ECS/Core/Storage/index.ts @@ -1,3 +1,4 @@ export { ComponentPool, ComponentPoolManager } from '../ComponentPool'; -export { ComponentStorage, ComponentRegistry } from '../ComponentStorage'; +export { ComponentStorage, ComponentRegistry, GlobalComponentRegistry } from '../ComponentStorage'; +export type { IComponentRegistry } from '../ComponentStorage'; export { EnableSoA, Float64, Float32, Int32, SerializeMap, SoAStorage } from '../SoAStorage'; diff --git a/packages/core/src/ECS/Decorators/PropertyDecorator.ts b/packages/core/src/ECS/Decorators/PropertyDecorator.ts index 8c694211..1ca60948 100644 --- a/packages/core/src/ECS/Decorators/PropertyDecorator.ts +++ b/packages/core/src/ECS/Decorators/PropertyDecorator.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; -export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask'; +export type PropertyType = 'number' | 'integer' | 'string' | 'boolean' | 'color' | 'vector2' | 'vector3' | 'vector4' | 'enum' | 'asset' | 'array' | 'animationClips' | 'collisionLayer' | 'collisionMask'; /** * 属性资源类型 @@ -102,7 +102,7 @@ interface ColorPropertyOptions extends PropertyOptionsBase { * Vector property options */ interface VectorPropertyOptions extends PropertyOptionsBase { - type: 'vector2' | 'vector3'; + type: 'vector2' | 'vector3' | 'vector4'; } /** @@ -139,6 +139,7 @@ export type ArrayItemType = | { type: 'asset'; assetType?: PropertyAssetType; extensions?: string[] } | { type: 'vector2' } | { type: 'vector3' } + | { type: 'vector4' } | { type: 'color'; alpha?: boolean } | { type: 'enum'; options: EnumOption[] }; diff --git a/packages/core/src/ECS/Decorators/TypeDecorators.ts b/packages/core/src/ECS/Decorators/TypeDecorators.ts index 899a7abb..20c35deb 100644 --- a/packages/core/src/ECS/Decorators/TypeDecorators.ts +++ b/packages/core/src/ECS/Decorators/TypeDecorators.ts @@ -10,7 +10,7 @@ import type { Component } from '../Component'; import type { EntitySystem } from '../Systems'; -import { ComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry'; +import { GlobalComponentRegistry } from '../Core/ComponentStorage/ComponentRegistry'; import { COMPONENT_TYPE_NAME, COMPONENT_DEPENDENCIES, @@ -88,9 +88,9 @@ export function ECSComponent(typeName: string, options?: ComponentOptions) { (target as any)[COMPONENT_EDITOR_OPTIONS] = options.editor; } - // 自动注册到 ComponentRegistry,使组件可以通过名称查找 - // Auto-register to ComponentRegistry, enabling lookup by name - ComponentRegistry.register(target); + // 自动注册到全局 ComponentRegistry,使组件可以通过名称查找 + // Auto-register to GlobalComponentRegistry, enabling lookup by name + GlobalComponentRegistry.register(target); return target; }; diff --git a/packages/core/src/ECS/Entity.ts b/packages/core/src/ECS/Entity.ts index 330ca772..74b4ae91 100644 --- a/packages/core/src/ECS/Entity.ts +++ b/packages/core/src/ECS/Entity.ts @@ -1,5 +1,5 @@ import { Component } from './Component'; -import { ComponentRegistry, ComponentType } from './Core/ComponentStorage'; +import { ComponentType, GlobalComponentRegistry } from './Core/ComponentStorage'; import { EEntityLifecyclePolicy } from './Core/EntityLifecyclePolicy'; import { BitMask64Utils, BitMask64Data } from './Utils/BigIntCompatibility'; import { createLogger } from '../Utils/Logger'; @@ -293,11 +293,12 @@ export class Entity { } const mask = this._componentMask; - const maxBitIndex = ComponentRegistry.getRegisteredCount(); + const registry = this.scene.componentRegistry; + const maxBitIndex = registry.getRegisteredCount(); for (let bitIndex = 0; bitIndex < maxBitIndex; bitIndex++) { if (BitMask64Utils.getBit(mask, bitIndex)) { - const componentType = ComponentRegistry.getTypeByBitIndex(bitIndex); + const componentType = registry.getTypeByBitIndex(bitIndex); if (componentType) { const component = this.scene.componentStorageManager.getComponent(this.id, componentType); @@ -428,7 +429,8 @@ export class Entity { // 更新位掩码(组件已通过 @ECSComponent 装饰器自动注册) // Update bitmask (component already registered via @ECSComponent decorator) - const componentMask = ComponentRegistry.getBitMask(componentType); + const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry; + const componentMask = registry.getBitMask(componentType); BitMask64Utils.orInPlace(this._componentMask, componentMask); // 使缓存失效 @@ -565,11 +567,12 @@ export class Entity { * ``` */ public hasComponent(type: ComponentType): boolean { - if (!ComponentRegistry.isRegistered(type)) { + const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry; + if (!registry.isRegistered(type)) { return false; } - const mask = ComponentRegistry.getBitMask(type); + const mask = registry.getBitMask(type); return BitMask64Utils.hasAny(this._componentMask, mask); } @@ -641,12 +644,13 @@ export class Entity { */ public removeComponent(component: Component): void { const componentType = component.constructor as ComponentType; + const registry = this.scene?.componentRegistry ?? GlobalComponentRegistry; - if (!ComponentRegistry.isRegistered(componentType)) { + if (!registry.isRegistered(componentType)) { return; } - const bitIndex = ComponentRegistry.getBitIndex(componentType); + const bitIndex = registry.getBitIndex(componentType); // 更新位掩码 BitMask64Utils.clearBit(this._componentMask, bitIndex); diff --git a/packages/core/src/ECS/IScene.ts b/packages/core/src/ECS/IScene.ts index 566838e9..b5d5427a 100644 --- a/packages/core/src/ECS/IScene.ts +++ b/packages/core/src/ECS/IScene.ts @@ -3,6 +3,7 @@ import { EntityList } from './Utils/EntityList'; import { IdentifierPool } from './Utils/IdentifierPool'; import { EntitySystem } from './Systems/EntitySystem'; import { ComponentStorageManager, ComponentType } from './Core/ComponentStorage'; +import type { IComponentRegistry } from './Core/ComponentStorage'; import { QuerySystem } from './Core/QuerySystem'; import { TypeSafeEventSystem } from './Core/EventSystem'; import { EpochManager } from './Core/EpochManager'; @@ -57,6 +58,17 @@ export interface IScene { */ readonly componentStorageManager: ComponentStorageManager; + /** + * 组件注册表 + * Component Registry + * + * Each scene has its own registry for component type isolation. + * Clones from GlobalComponentRegistry on creation. + * 每个场景有自己的组件类型注册表以实现隔离。 + * 创建时从 GlobalComponentRegistry 克隆。 + */ + readonly componentRegistry: IComponentRegistry; + /** * 查询系统 */ @@ -359,10 +371,20 @@ export interface ISceneFactory { /** * 场景配置接口 + * Scene configuration interface */ export interface ISceneConfig { /** * 场景名称 + * Scene name */ name?: string; + + /** + * 是否从全局注册表继承组件类型 + * Whether to inherit component types from global registry + * + * @default true + */ + inheritGlobalRegistry?: boolean; } diff --git a/packages/core/src/ECS/Scene.ts b/packages/core/src/ECS/Scene.ts index 8de11b87..4ed97e72 100644 --- a/packages/core/src/ECS/Scene.ts +++ b/packages/core/src/ECS/Scene.ts @@ -2,7 +2,13 @@ import { Entity } from './Entity'; import { EntityList } from './Utils/EntityList'; import { IdentifierPool } from './Utils/IdentifierPool'; import { EntitySystem } from './Systems/EntitySystem'; -import { ComponentStorageManager, ComponentRegistry, ComponentType } from './Core/ComponentStorage'; +import { + ComponentStorageManager, + ComponentRegistry, + GlobalComponentRegistry, + ComponentType +} from './Core/ComponentStorage'; +import type { IComponentRegistry } from './Core/ComponentStorage'; import { QuerySystem } from './Core/QuerySystem'; import { TypeSafeEventSystem } from './Core/EventSystem'; import { ReferenceTracker } from './Core/ReferenceTracker'; @@ -75,6 +81,15 @@ export class Scene implements IScene { */ public readonly componentStorageManager: ComponentStorageManager; + /** + * 组件注册表 + * Component Registry + * + * Each scene has its own registry for component type isolation. + * 每个场景有自己的组件类型注册表以实现隔离。 + */ + public readonly componentRegistry: IComponentRegistry; + /** * 查询系统 * @@ -364,11 +379,23 @@ export class Scene implements IScene { /** * 创建场景实例 + * Create scene instance */ constructor(config?: ISceneConfig) { this.entities = new EntityList(this); this.identifierPool = new IdentifierPool(); this.componentStorageManager = new ComponentStorageManager(); + + // 创建场景级别的组件注册表 + // Create scene-level component registry + this.componentRegistry = new ComponentRegistry(); + + // 从全局注册表继承框架组件(默认启用) + // Inherit framework components from global registry (enabled by default) + if (config?.inheritGlobalRegistry !== false) { + this.componentRegistry.cloneFrom(GlobalComponentRegistry); + } + this.querySystem = new QuerySystem(); this.eventSystem = new TypeSafeEventSystem(); this.referenceTracker = new ReferenceTracker(); @@ -671,8 +698,8 @@ export class Scene implements IScene { const notifiedSystems = new Set(); // 如果提供了组件类型,使用索引优化 | If component type provided, use index optimization - if (changedComponentType && ComponentRegistry.isRegistered(changedComponentType)) { - const componentId = ComponentRegistry.getBitIndex(changedComponentType); + if (changedComponentType && this.componentRegistry.isRegistered(changedComponentType)) { + const componentId = this.componentRegistry.getBitIndex(changedComponentType); const interestedSystems = this._componentIdToSystems.get(componentId); if (interestedSystems) { @@ -760,7 +787,7 @@ export class Scene implements IScene { * @param system 系统 | System */ private addSystemToComponentIndex(componentType: ComponentType, system: EntitySystem): void { - const componentId = ComponentRegistry.getBitIndex(componentType); + const componentId = this.componentRegistry.getBitIndex(componentType); let systems = this._componentIdToSystems.get(componentId); if (!systems) { @@ -1506,7 +1533,7 @@ export class Scene implements IScene { ? IncrementalSerializer.deserializeIncremental(incremental as string | Uint8Array) : (incremental as IncrementalSnapshot); - const registry = componentRegistry || (ComponentRegistry.getAllComponentNames() as Map); + const registry = componentRegistry || (this.componentRegistry.getAllComponentNames() as Map); IncrementalSerializer.applyIncremental(this, snapshot, registry); } diff --git a/packages/core/src/ECS/Serialization/SceneSerializer.ts b/packages/core/src/ECS/Serialization/SceneSerializer.ts index 6be0cc28..b4ee866c 100644 --- a/packages/core/src/ECS/Serialization/SceneSerializer.ts +++ b/packages/core/src/ECS/Serialization/SceneSerializer.ts @@ -6,7 +6,7 @@ import type { IScene } from '../IScene'; import { Entity } from '../Entity'; -import { ComponentType, ComponentRegistry } from '../Core/ComponentStorage'; +import { ComponentType, GlobalComponentRegistry } from '../Core/ComponentStorage'; import { EntitySerializer, SerializedEntity } from './EntitySerializer'; import { getComponentTypeName } from '../Decorators'; import { getSerializationMetadata } from './SerializationDecorators'; @@ -565,7 +565,7 @@ export class SceneSerializer { * 从所有已注册的组件类型构建注册表 */ private static getGlobalComponentRegistry(): Map { - return ComponentRegistry.getAllComponentNames() as Map; + return GlobalComponentRegistry.getAllComponentNames() as Map; } /** diff --git a/packages/core/src/ECS/Utils/ComponentSparseSet.ts b/packages/core/src/ECS/Utils/ComponentSparseSet.ts index af5a2b6a..60f08912 100644 --- a/packages/core/src/ECS/Utils/ComponentSparseSet.ts +++ b/packages/core/src/ECS/Utils/ComponentSparseSet.ts @@ -1,5 +1,5 @@ import { Entity } from '../Entity'; -import { ComponentType, ComponentRegistry } from '../Core/ComponentStorage'; +import { ComponentType, GlobalComponentRegistry } from '../Core/ComponentStorage'; import { BitMask64Utils, BitMask64Data } from './BigIntCompatibility'; import { SparseSet } from './SparseSet'; import { Pool } from '../../Utils/Pool/Pool'; @@ -86,7 +86,7 @@ export class ComponentSparseSet { entityComponents.add(componentType); // 获取组件位掩码并合并 - const bitMask = ComponentRegistry.getBitMask(componentType); + const bitMask = GlobalComponentRegistry.getBitMask(componentType); BitMask64Utils.orInPlace(componentMask, bitMask); } @@ -166,10 +166,10 @@ export class ComponentSparseSet { // 构建目标位掩码 const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO); for (const componentType of componentTypes) { - if (!ComponentRegistry.isRegistered(componentType)) { + if (!GlobalComponentRegistry.isRegistered(componentType)) { return new Set(); // 未注册的组件类型,结果为空 } - const bitMask = ComponentRegistry.getBitMask(componentType); + const bitMask = GlobalComponentRegistry.getBitMask(componentType); BitMask64Utils.orInPlace(targetMask, bitMask); } @@ -206,8 +206,8 @@ export class ComponentSparseSet { // 构建目标位掩码 const targetMask = BitMask64Utils.clone(BitMask64Utils.ZERO); for (const componentType of componentTypes) { - if (ComponentRegistry.isRegistered(componentType)) { - const bitMask = ComponentRegistry.getBitMask(componentType); + if (GlobalComponentRegistry.isRegistered(componentType)) { + const bitMask = GlobalComponentRegistry.getBitMask(componentType); BitMask64Utils.orInPlace(targetMask, bitMask); } } @@ -242,12 +242,12 @@ export class ComponentSparseSet { return false; } - if (!ComponentRegistry.isRegistered(componentType)) { + if (!GlobalComponentRegistry.isRegistered(componentType)) { return false; } const entityMask = this._componentMasks[entityIndex]!; - const componentMask = ComponentRegistry.getBitMask(componentType); + const componentMask = GlobalComponentRegistry.getBitMask(componentType); return BitMask64Utils.hasAny(entityMask, componentMask); } diff --git a/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts b/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts index 8ff13820..9c9bb520 100644 --- a/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts +++ b/packages/core/tests/ECS/Core/ComponentRegistry.extended.test.ts @@ -1,19 +1,19 @@ import { Component } from '../../../src/ECS/Component'; -import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage/ComponentRegistry'; +import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage/ComponentRegistry'; import { Entity } from '../../../src/ECS/Entity'; import { Scene } from '../../../src/ECS/Scene'; describe('ComponentRegistry Extended - 64+ 组件支持', () => { - // 组件类缓存 + // 组件类缓存 | Component class cache const componentClassCache = new Map(); beforeEach(() => { - ComponentRegistry.reset(); + GlobalComponentRegistry.reset(); componentClassCache.clear(); }); afterEach(() => { - ComponentRegistry.reset(); + GlobalComponentRegistry.reset(); componentClassCache.clear(); }); @@ -39,11 +39,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { // 注册 100 个组件类型 for (let i = 0; i < 100; i++) { const ComponentClass = createTestComponent(i); - const bitIndex = ComponentRegistry.register(ComponentClass); + const bitIndex = GlobalComponentRegistry.register(ComponentClass); componentTypes.push(ComponentClass); expect(bitIndex).toBe(i); - expect(ComponentRegistry.isRegistered(ComponentClass)).toBe(true); + expect(GlobalComponentRegistry.isRegistered(ComponentClass)).toBe(true); } expect(componentTypes.length).toBe(100); @@ -53,14 +53,14 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { // 注册 80 个组件 for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); } // 验证第 70 个组件的位掩码 const Component70 = createTestComponent(70); - ComponentRegistry.register(Component70); + GlobalComponentRegistry.register(Component70); - const bitMask = ComponentRegistry.getBitMask(Component70); + const bitMask = GlobalComponentRegistry.getBitMask(Component70); expect(bitMask).toBeDefined(); expect(bitMask.segments).toBeDefined(); // 应该有扩展段 expect(bitMask.segments!.length).toBeGreaterThan(0); @@ -70,11 +70,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { // 注册 1500 个组件验证无限制 for (let i = 0; i < 1500; i++) { const ComponentClass = createTestComponent(i); - const bitIndex = ComponentRegistry.register(ComponentClass); + const bitIndex = GlobalComponentRegistry.register(ComponentClass); expect(bitIndex).toBe(i); } - expect(ComponentRegistry.getRegisteredCount()).toBe(1500); + expect(GlobalComponentRegistry.getRegisteredCount()).toBe(1500); }); }); @@ -92,10 +92,13 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { const componentTypes: any[] = []; const components: any[] = []; - // 添加 80 个组件 + // 添加 80 个组件 | Add 80 components + // 需要同时注册到 GlobalComponentRegistry(ArchetypeSystem 使用)和 Scene registry + // Need to register to both GlobalComponentRegistry (used by ArchetypeSystem) and Scene registry for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); componentTypes.push(ComponentClass); const component = new ComponentClass(); @@ -115,32 +118,35 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { }); it('应该能够正确检查超过 64 个组件的存在性', () => { - // 添加组件 0-79 + // 添加组件 0-79 | Add components 0-79 for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); entity.addComponent(new ComponentClass()); } - // 验证 hasComponent 对所有组件都工作 + // 验证 hasComponent 对所有组件都工作 | Verify hasComponent works for all for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); expect(entity.hasComponent(ComponentClass)).toBe(true); } - // 验证不存在的组件 + // 验证不存在的组件 | Verify non-existent component const NonExistentComponent = createTestComponent(999); - ComponentRegistry.register(NonExistentComponent); + GlobalComponentRegistry.register(NonExistentComponent); + scene.componentRegistry.register(NonExistentComponent); expect(entity.hasComponent(NonExistentComponent)).toBe(false); }); it('应该能够移除超过 64 索引的组件', () => { const componentTypes: any[] = []; - // 添加 80 个组件 + // 添加 80 个组件 | Add 80 components for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); componentTypes.push(ComponentClass); entity.addComponent(new ComponentClass()); } @@ -162,10 +168,11 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { }); it('应该能够正确遍历超过 64 个组件', () => { - // 添加 80 个组件 + // 添加 80 个组件 | Add 80 components for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); entity.addComponent(new ComponentClass()); } @@ -182,29 +189,29 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { describe('热更新模式', () => { it('默认应该禁用热更新模式', () => { - expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false); }); it('应该能够启用和禁用热更新模式', () => { - expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false); - ComponentRegistry.enableHotReload(); - expect(ComponentRegistry.isHotReloadEnabled()).toBe(true); + GlobalComponentRegistry.enableHotReload(); + expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(true); - ComponentRegistry.disableHotReload(); - expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + GlobalComponentRegistry.disableHotReload(); + expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false); }); it('reset 应该重置热更新模式为禁用', () => { - ComponentRegistry.enableHotReload(); - expect(ComponentRegistry.isHotReloadEnabled()).toBe(true); + GlobalComponentRegistry.enableHotReload(); + expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(true); - ComponentRegistry.reset(); - expect(ComponentRegistry.isHotReloadEnabled()).toBe(false); + GlobalComponentRegistry.reset(); + expect(GlobalComponentRegistry.isHotReloadEnabled()).toBe(false); }); it('启用热更新时应该替换同名组件类', () => { - ComponentRegistry.enableHotReload(); + GlobalComponentRegistry.enableHotReload(); // 模拟热更新场景:两个不同的类但有相同的 constructor.name // Simulate hot reload: two different classes with same constructor.name @@ -229,20 +236,20 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { expect(TestComponentV1.name).toBe(TestComponentV2.name); expect(TestComponentV1).not.toBe(TestComponentV2); - const index1 = ComponentRegistry.register(TestComponentV1); - const index2 = ComponentRegistry.register(TestComponentV2); + const index1 = GlobalComponentRegistry.register(TestComponentV1); + const index2 = GlobalComponentRegistry.register(TestComponentV2); // 应该复用相同的 bitIndex expect(index1).toBe(index2); // 新类应该替换旧类 - expect(ComponentRegistry.isRegistered(TestComponentV2)).toBe(true); - expect(ComponentRegistry.isRegistered(TestComponentV1)).toBe(false); + expect(GlobalComponentRegistry.isRegistered(TestComponentV2)).toBe(true); + expect(GlobalComponentRegistry.isRegistered(TestComponentV1)).toBe(false); }); it('禁用热更新时不应该替换同名组件类', () => { // 确保热更新被禁用 - ComponentRegistry.disableHotReload(); + GlobalComponentRegistry.disableHotReload(); // 创建两个同名组件 // Create two classes with same constructor.name @@ -265,15 +272,15 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { expect(TestCompA.name).toBe(TestCompB.name); expect(TestCompA).not.toBe(TestCompB); - const index1 = ComponentRegistry.register(TestCompA); - const index2 = ComponentRegistry.register(TestCompB); + const index1 = GlobalComponentRegistry.register(TestCompA); + const index2 = GlobalComponentRegistry.register(TestCompB); // 应该分配不同的 bitIndex(因为热更新被禁用) expect(index2).toBe(index1 + 1); // 两个类都应该被注册 - expect(ComponentRegistry.isRegistered(TestCompA)).toBe(true); - expect(ComponentRegistry.isRegistered(TestCompB)).toBe(true); + expect(GlobalComponentRegistry.isRegistered(TestCompA)).toBe(true); + expect(GlobalComponentRegistry.isRegistered(TestCompB)).toBe(true); }); }); @@ -282,14 +289,15 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { const scene = new Scene(); const entity = scene.createEntity('TestEntity'); - // 注册 65 个组件(跨越 64 位边界) + // 注册 65 个组件(跨越 64 位边界)| Register 65 components (crossing 64-bit boundary) for (let i = 0; i < 65; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); entity.addComponent(new ComponentClass()); } - // 验证第 63, 64, 65 个组件 + // 验证第 63, 64, 65 个组件 | Verify components 63, 64 const Component63 = createTestComponent(63); const Component64 = createTestComponent(64); @@ -301,25 +309,27 @@ describe('ComponentRegistry Extended - 64+ 组件支持', () => { const scene = new Scene(); const entity = scene.createEntity('TestEntity'); - // 添加 80 个组件 + // 添加 80 个组件 | Add 80 components for (let i = 0; i < 80; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); entity.addComponent(new ComponentClass()); } - // 强制重建缓存(通过访问 components) + // 强制重建缓存(通过访问 components)| Force cache rebuild const components1 = entity.components; expect(components1.length).toBe(80); - // 添加更多组件 + // 添加更多组件 | Add more components for (let i = 80; i < 90; i++) { const ComponentClass = createTestComponent(i); - ComponentRegistry.register(ComponentClass); + GlobalComponentRegistry.register(ComponentClass); + scene.componentRegistry.register(ComponentClass); entity.addComponent(new ComponentClass()); } - // 重新获取组件数组(应该重建缓存) + // 重新获取组件数组(应该重建缓存)| Re-get component array (should rebuild cache) const components2 = entity.components; expect(components2.length).toBe(90); }); diff --git a/packages/core/tests/ECS/Core/ComponentStorage.test.ts b/packages/core/tests/ECS/Core/ComponentStorage.test.ts index 77c95b04..4002fab6 100644 --- a/packages/core/tests/ECS/Core/ComponentStorage.test.ts +++ b/packages/core/tests/ECS/Core/ComponentStorage.test.ts @@ -1,7 +1,10 @@ -import { ComponentRegistry, ComponentStorage, ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage'; +import { ComponentRegistry, GlobalComponentRegistry, ComponentStorage, ComponentStorageManager } from '../../../src/ECS/Core/ComponentStorage'; import { Component } from '../../../src/ECS/Component'; import { BitMask64Utils } from '../../../src/ECS/Utils/BigIntCompatibility'; +// 为测试创建独立的注册表实例 | Create isolated registry instance for tests +let testRegistry: ComponentRegistry; + // 测试组件类(默认使用原始存储) class TestComponent extends Component { public value: number; @@ -51,89 +54,88 @@ class HealthComponent extends Component { describe('ComponentRegistry - 组件注册表测试', () => { beforeEach(() => { - // 重置注册表状态 - (ComponentRegistry as any).componentTypes = new Map(); - (ComponentRegistry as any).nextBitIndex = 0; + // 每个测试创建新的注册表实例 | Create new registry instance for each test + testRegistry = new ComponentRegistry(); }); describe('组件注册功能', () => { test('应该能够注册组件类型', () => { - const bitIndex = ComponentRegistry.register(TestComponent); - + const bitIndex = testRegistry.register(TestComponent); + expect(bitIndex).toBe(0); - expect(ComponentRegistry.isRegistered(TestComponent)).toBe(true); + expect(testRegistry.isRegistered(TestComponent)).toBe(true); }); test('重复注册相同组件应该返回相同的位索引', () => { - const bitIndex1 = ComponentRegistry.register(TestComponent); - const bitIndex2 = ComponentRegistry.register(TestComponent); - + const bitIndex1 = testRegistry.register(TestComponent); + const bitIndex2 = testRegistry.register(TestComponent); + expect(bitIndex1).toBe(bitIndex2); expect(bitIndex1).toBe(0); }); test('应该能够注册多个组件类型', () => { - const bitIndex1 = ComponentRegistry.register(TestComponent); - const bitIndex2 = ComponentRegistry.register(PositionComponent); - const bitIndex3 = ComponentRegistry.register(VelocityComponent); - + const bitIndex1 = testRegistry.register(TestComponent); + const bitIndex2 = testRegistry.register(PositionComponent); + const bitIndex3 = testRegistry.register(VelocityComponent); + expect(bitIndex1).toBe(0); expect(bitIndex2).toBe(1); expect(bitIndex3).toBe(2); }); test('应该能够检查组件是否已注册', () => { - expect(ComponentRegistry.isRegistered(TestComponent)).toBe(false); - - ComponentRegistry.register(TestComponent); - expect(ComponentRegistry.isRegistered(TestComponent)).toBe(true); + expect(testRegistry.isRegistered(TestComponent)).toBe(false); + + testRegistry.register(TestComponent); + expect(testRegistry.isRegistered(TestComponent)).toBe(true); }); }); describe('位掩码功能', () => { test('应该能够获取组件的位掩码', () => { - ComponentRegistry.register(TestComponent); - ComponentRegistry.register(PositionComponent); - - const mask1 = ComponentRegistry.getBitMask(TestComponent); - const mask2 = ComponentRegistry.getBitMask(PositionComponent); - + testRegistry.register(TestComponent); + testRegistry.register(PositionComponent); + + const mask1 = testRegistry.getBitMask(TestComponent); + const mask2 = testRegistry.getBitMask(PositionComponent); + expect(BitMask64Utils.getBit(mask1,0)).toBe(true); // 2^0 expect(BitMask64Utils.getBit(mask2,1)).toBe(true); // 2^1 }); test('应该能够获取组件的位索引', () => { - ComponentRegistry.register(TestComponent); - ComponentRegistry.register(PositionComponent); - - const index1 = ComponentRegistry.getBitIndex(TestComponent); - const index2 = ComponentRegistry.getBitIndex(PositionComponent); - + testRegistry.register(TestComponent); + testRegistry.register(PositionComponent); + + const index1 = testRegistry.getBitIndex(TestComponent); + const index2 = testRegistry.getBitIndex(PositionComponent); + expect(index1).toBe(0); expect(index2).toBe(1); }); test('获取未注册组件的位掩码应该抛出错误', () => { expect(() => { - ComponentRegistry.getBitMask(TestComponent); + testRegistry.getBitMask(TestComponent); }).toThrow('Component type TestComponent is not registered'); }); test('获取未注册组件的位索引应该抛出错误', () => { expect(() => { - ComponentRegistry.getBitIndex(TestComponent); + testRegistry.getBitIndex(TestComponent); }).toThrow('Component type TestComponent is not registered'); }); }); describe('注册表管理', () => { test('应该能够获取所有已注册的组件类型', () => { - ComponentRegistry.register(TestComponent); - ComponentRegistry.register(PositionComponent); - - const allTypes = ComponentRegistry.getAllRegisteredTypes(); - + testRegistry.register(TestComponent); + testRegistry.register(PositionComponent); + + const allTypes = testRegistry.getAllRegisteredTypes(); + expect(allTypes.size).toBe(2); expect(allTypes.has(TestComponent)).toBe(true); expect(allTypes.has(PositionComponent)).toBe(true); @@ -142,12 +144,12 @@ describe('ComponentRegistry - 组件注册表测试', () => { }); test('返回的注册表副本不应该影响原始数据', () => { - ComponentRegistry.register(TestComponent); - - const allTypes = ComponentRegistry.getAllRegisteredTypes(); + testRegistry.register(TestComponent); + + const allTypes = testRegistry.getAllRegisteredTypes(); allTypes.set(PositionComponent, 999); - - expect(ComponentRegistry.isRegistered(PositionComponent)).toBe(false); + + expect(testRegistry.isRegistered(PositionComponent)).toBe(false); }); }); }); @@ -156,10 +158,9 @@ describe('ComponentStorage - 组件存储器测试', () => { let storage: ComponentStorage; beforeEach(() => { - // 重置注册表 - (ComponentRegistry as any).componentTypes = new Map(); - (ComponentRegistry as any).nextBitIndex = 0; - + // 每个测试创建新的注册表实例 | Create new registry instance for each test + testRegistry = new ComponentRegistry(); + storage = new ComponentStorage(TestComponent); }); @@ -358,10 +359,9 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => { let manager: ComponentStorageManager; beforeEach(() => { - // 重置注册表 - (ComponentRegistry as any).componentTypes = new Map(); - (ComponentRegistry as any).nextBitIndex = 0; - + // 重置全局注册表 | Reset global registry + GlobalComponentRegistry.reset(); + manager = new ComponentStorageManager(); }); @@ -455,10 +455,10 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => { describe('位掩码功能', () => { test('应该能够获取实体的组件位掩码', () => { - // 确保组件已注册 - ComponentRegistry.register(TestComponent); - ComponentRegistry.register(PositionComponent); - ComponentRegistry.register(VelocityComponent); + // 确保组件已注册 | Ensure components are registered + GlobalComponentRegistry.register(TestComponent); + GlobalComponentRegistry.register(PositionComponent); + GlobalComponentRegistry.register(VelocityComponent); manager.addComponent(1, new TestComponent(100)); manager.addComponent(1, new PositionComponent(10, 20)); @@ -475,8 +475,8 @@ describe('ComponentStorageManager - 组件存储管理器测试', () => { }); test('添加和移除组件应该更新掩码', () => { - ComponentRegistry.register(TestComponent); - ComponentRegistry.register(PositionComponent); + GlobalComponentRegistry.register(TestComponent); + GlobalComponentRegistry.register(PositionComponent); manager.addComponent(1, new TestComponent(100)); let mask = manager.getComponentMask(1); diff --git a/packages/core/tests/ECS/Core/QuerySystem.test.ts b/packages/core/tests/ECS/Core/QuerySystem.test.ts index 0559d9e2..00b62f21 100644 --- a/packages/core/tests/ECS/Core/QuerySystem.test.ts +++ b/packages/core/tests/ECS/Core/QuerySystem.test.ts @@ -894,10 +894,12 @@ describe('QuerySystem - 查询系统测试', () => { const independentQuerySystem = new QuerySystem(); const testEntity = scene.createEntity('ArchetypeTestEntity'); - // 模拟Scene环境(保留componentStorageManager) + // 模拟Scene环境(保留componentStorageManager和componentRegistry) + // Mock Scene environment (keep componentStorageManager and componentRegistry) const mockScene = { querySystem: independentQuerySystem, componentStorageManager: scene.componentStorageManager, + componentRegistry: scene.componentRegistry, clearSystemEntityCaches: jest.fn() }; testEntity.scene = mockScene as any; @@ -938,10 +940,12 @@ describe('QuerySystem - 查询系统测试', () => { const independentQuerySystem = new QuerySystem(); const testEntity = scene.createEntity('RemoveAllTestEntity'); - // 模拟Scene环境(保留componentStorageManager) + // 模拟Scene环境(保留componentStorageManager和componentRegistry) + // Mock Scene environment (keep componentStorageManager and componentRegistry) const mockScene = { querySystem: independentQuerySystem, componentStorageManager: scene.componentStorageManager, + componentRegistry: scene.componentRegistry, clearSystemEntityCaches: jest.fn() }; testEntity.scene = mockScene as any; diff --git a/packages/core/tests/ECS/Serialization/EntitySerializer.test.ts b/packages/core/tests/ECS/Serialization/EntitySerializer.test.ts index 36809ab7..23d3ed43 100644 --- a/packages/core/tests/ECS/Serialization/EntitySerializer.test.ts +++ b/packages/core/tests/ECS/Serialization/EntitySerializer.test.ts @@ -5,7 +5,7 @@ import { Component } from '../../../src/ECS/Component'; import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem'; import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent'; import { ECSComponent } from '../../../src/ECS/Decorators'; -import { ComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage'; +import { GlobalComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage'; import { Serializable, Serialize } from '../../../src/ECS/Serialization'; @ECSComponent('EntitySerTest_Position') @@ -40,16 +40,18 @@ describe('EntitySerializer', () => { let componentRegistry: Map; beforeEach(() => { - ComponentRegistry.reset(); - ComponentRegistry.register(PositionComponent); - ComponentRegistry.register(VelocityComponent); - ComponentRegistry.register(HierarchyComponent); + // 重置全局注册表 | Reset global registry + GlobalComponentRegistry.reset(); + + GlobalComponentRegistry.register(PositionComponent); + GlobalComponentRegistry.register(VelocityComponent); + GlobalComponentRegistry.register(HierarchyComponent); scene = new Scene({ name: 'EntitySerializerTestScene' }); hierarchySystem = new HierarchySystem(); scene.addSystem(hierarchySystem); - componentRegistry = ComponentRegistry.getAllComponentNames() as Map; + componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map; }); afterEach(() => { diff --git a/packages/core/tests/ECS/Serialization/IncrementalSerialization.test.ts b/packages/core/tests/ECS/Serialization/IncrementalSerialization.test.ts index 3160d010..0d430164 100644 --- a/packages/core/tests/ECS/Serialization/IncrementalSerialization.test.ts +++ b/packages/core/tests/ECS/Serialization/IncrementalSerialization.test.ts @@ -12,7 +12,7 @@ import { ChangeOperation } from '../../../src/ECS/Serialization'; import { ECSComponent } from '../../../src/ECS/Decorators'; -import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage'; +import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage'; // 测试组件定义 @ECSComponent('IncTest_Position') @@ -56,12 +56,14 @@ describe('Incremental Serialization System', () => { beforeEach(() => { IncrementalSerializer.resetVersion(); - ComponentRegistry.reset(); - // 重新注册测试组件 - ComponentRegistry.register(PositionComponent); - ComponentRegistry.register(VelocityComponent); - ComponentRegistry.register(HealthComponent); + // 重置全局注册表 | Reset global registry + GlobalComponentRegistry.reset(); + + // 重新注册测试组件 | Re-register test components + GlobalComponentRegistry.register(PositionComponent); + GlobalComponentRegistry.register(VelocityComponent); + GlobalComponentRegistry.register(HealthComponent); scene = new Scene({ name: 'IncrementalTestScene' }); }); diff --git a/packages/core/tests/ECS/Serialization/SceneSerializer.test.ts b/packages/core/tests/ECS/Serialization/SceneSerializer.test.ts index b4e5bf7d..2c599f6a 100644 --- a/packages/core/tests/ECS/Serialization/SceneSerializer.test.ts +++ b/packages/core/tests/ECS/Serialization/SceneSerializer.test.ts @@ -4,7 +4,7 @@ import { Component } from '../../../src/ECS/Component'; import { HierarchySystem } from '../../../src/ECS/Systems/HierarchySystem'; import { HierarchyComponent } from '../../../src/ECS/Components/HierarchyComponent'; import { ECSComponent } from '../../../src/ECS/Decorators'; -import { ComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage'; +import { GlobalComponentRegistry, ComponentType } from '../../../src/ECS/Core/ComponentStorage'; import { Serializable, Serialize } from '../../../src/ECS/Serialization'; @ECSComponent('SceneSerTest_Position') @@ -40,7 +40,7 @@ describe('SceneSerializer', () => { beforeEach(() => { scene = new Scene({ name: 'SceneSerializerTestScene' }); - componentRegistry = ComponentRegistry.getAllComponentNames() as Map; + componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map; }); afterEach(() => { diff --git a/packages/core/tests/ECS/Systems/SystemTypes.test.ts b/packages/core/tests/ECS/Systems/SystemTypes.test.ts index 0a5bae73..8dfa4d8e 100644 --- a/packages/core/tests/ECS/Systems/SystemTypes.test.ts +++ b/packages/core/tests/ECS/Systems/SystemTypes.test.ts @@ -4,7 +4,7 @@ import { IntervalSystem } from '../../../src/ECS/Systems/IntervalSystem'; import { ProcessingSystem } from '../../../src/ECS/Systems/ProcessingSystem'; import { Entity } from '../../../src/ECS/Entity'; import { Component } from '../../../src/ECS/Component'; -import { ComponentRegistry } from '../../../src/ECS/Core/ComponentStorage'; +import { GlobalComponentRegistry } from '../../../src/ECS/Core/ComponentStorage'; import { Time } from '../../../src/Utils/Time'; import { Matcher } from '../../../src/ECS/Utils/Matcher'; import { Core } from '../../../src/Core'; @@ -85,13 +85,15 @@ describe('System Types - 系统类型测试', () => { beforeEach(() => { Core.create(); + // 注册测试组件类型 | Register test component types + // 必须在创建 Scene 之前注册,因为 Scene 会克隆 GlobalComponentRegistry + // Must register before Scene creation, as Scene clones GlobalComponentRegistry + GlobalComponentRegistry.register(TestComponent); + GlobalComponentRegistry.register(AnotherComponent); scene = new Scene(); entity = scene.createEntity('TestEntity'); // 重置时间系统 Time.update(0.016); - // 注册测试组件类型 - ComponentRegistry.register(TestComponent); - ComponentRegistry.register(AnotherComponent); }); describe('PassiveSystem - 被动系统', () => { diff --git a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts index 2095ccbb..4e22aad0 100644 --- a/packages/ecs-engine-bindgen/src/core/EngineBridge.ts +++ b/packages/ecs-engine-bindgen/src/core/EngineBridge.ts @@ -883,6 +883,133 @@ export class EngineBridge implements ITextureEngineBridge { this.getEngine().clearAllTextures(); } + // ===== Texture State API ===== + // ===== 纹理状态 API ===== + + /** + * Get texture loading state. + * 获取纹理加载状态。 + * + * @param id - Texture ID | 纹理ID + * @returns State string: 'loading', 'ready', or 'failed:reason' + * 状态字符串:'loading'、'ready' 或 'failed:reason' + */ + getTextureState(id: number): string { + if (!this.initialized) return 'loading'; + return this.getEngine().getTextureState(id); + } + + /** + * Check if texture is ready for rendering. + * 检查纹理是否已就绪可渲染。 + * + * @param id - Texture ID | 纹理ID + * @returns true if texture data is fully loaded | 纹理数据完全加载则返回true + */ + isTextureReady(id: number): boolean { + if (!this.initialized) return false; + return this.getEngine().isTextureReady(id); + } + + /** + * Get count of textures currently loading. + * 获取当前正在加载的纹理数量。 + * + * @returns Number of textures in 'loading' state | 处于加载状态的纹理数量 + */ + getTextureLoadingCount(): number { + if (!this.initialized) return 0; + return this.getEngine().getTextureLoadingCount(); + } + + /** + * Load texture asynchronously with Promise. + * 使用Promise异步加载纹理。 + * + * Unlike loadTexture which returns immediately with a placeholder, + * this method waits until the texture is actually loaded and ready. + * 与loadTexture立即返回占位符不同,此方法会等待纹理实际加载完成。 + * + * @param id - Texture ID | 纹理ID + * @param url - Image URL | 图片URL + * @returns Promise that resolves when texture is ready, rejects on failure + * 纹理就绪时解析的Promise,失败时拒绝 + */ + loadTextureAsync(id: number, url: string): Promise { + return new Promise((resolve, reject) => { + if (!this.initialized) { + reject(new Error('Engine not initialized')); + return; + } + + // Start loading the texture + // 开始加载纹理 + this.getEngine().loadTexture(id, url); + + // Poll for state changes + // 轮询状态变化 + const checkInterval = 16; // ~60fps + const maxWaitTime = 30000; // 30 seconds timeout + let elapsed = 0; + + const checkState = () => { + const state = this.getTextureState(id); + + if (state === 'ready') { + resolve(); + } else if (state.startsWith('failed:')) { + const reason = state.substring(7); + reject(new Error(`Texture load failed: ${reason}`)); + } else if (elapsed >= maxWaitTime) { + reject(new Error(`Texture load timeout after ${maxWaitTime}ms`)); + } else { + elapsed += checkInterval; + setTimeout(checkState, checkInterval); + } + }; + + // Start checking after a small delay to allow initial state setup + // 稍后开始检查,允许初始状态设置 + setTimeout(checkState, checkInterval); + }); + } + + /** + * Wait for all loading textures to complete. + * 等待所有加载中的纹理完成。 + * + * @param timeout - Maximum wait time in ms (default: 30000) + * 最大等待时间(毫秒,默认30000) + * @returns Promise that resolves when all textures are loaded + * 所有纹理加载完成时解析的Promise + */ + waitForAllTextures(timeout: number = 30000): Promise { + return new Promise((resolve, reject) => { + if (!this.initialized) { + reject(new Error('Engine not initialized')); + return; + } + + const checkInterval = 16; + let elapsed = 0; + + const checkLoading = () => { + const loadingCount = this.getTextureLoadingCount(); + + if (loadingCount === 0) { + resolve(); + } else if (elapsed >= timeout) { + reject(new Error(`Timeout waiting for ${loadingCount} textures to load`)); + } else { + elapsed += checkInterval; + setTimeout(checkLoading, checkInterval); + } + }; + + checkLoading(); + }); + } + /** * Dispose the bridge and release resources. * 销毁桥接并释放资源。 diff --git a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts index 76a8bb7f..cf288be1 100644 --- a/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts +++ b/packages/ecs-engine-bindgen/src/systems/EngineRenderSystem.ts @@ -3,16 +3,16 @@ * 用于ECS的引擎渲染系统。 */ -import { EntitySystem, Matcher, Entity, ComponentType, ECSSystem, Component, Core } from '@esengine/ecs-framework'; -import { TransformComponent, sortingLayerManager } from '@esengine/engine-core'; -import { Color } from '@esengine/ecs-framework-math'; -import { SpriteComponent } from '@esengine/sprite'; import { CameraComponent } from '@esengine/camera'; +import { Component, ComponentType, Core, ECSSystem, Entity, EntitySystem, Matcher } from '@esengine/ecs-framework'; +import { Color } from '@esengine/ecs-framework-math'; +import { TransformComponent, sortingLayerManager } from '@esengine/engine-core'; import { getMaterialManager } from '@esengine/material-system'; +import { SpriteComponent } from '@esengine/sprite'; import type { EngineBridge } from '../core/EngineBridge'; import { RenderBatcher } from '../core/RenderBatcher'; -import type { SpriteRenderData } from '../types'; import type { ITransformComponent } from '../core/SpriteRenderHelper'; +import type { SpriteRenderData } from '../types'; /** * Render data from a provider @@ -339,14 +339,12 @@ export class EngineRenderSystem extends EntitySystem { } // Calculate UV with flip | 计算带翻转的 UV - const uv: [number, number, number, number] = [0, 0, 1, 1]; - if (sprite.flipX || sprite.flipY) { - if (sprite.flipX) { - [uv[0], uv[2]] = [uv[2], uv[0]]; - } - if (sprite.flipY) { - [uv[1], uv[3]] = [uv[3], uv[1]]; - } + const uv: [number, number, number, number] = [...sprite.uv]; + if (sprite.flipX) { + [uv[0], uv[2]] = [uv[2], uv[0]]; + } + if (sprite.flipY) { + [uv[1], uv[3]] = [uv[3], uv[1]]; } // 使用世界变换(由 TransformSystem 计算,考虑父级变换),回退到本地变换 @@ -569,6 +567,13 @@ export class EngineRenderSystem extends EntitySystem { const tOffset = i * 7; const uvOffset = i * 4; + const uv: [number, number, number, number] = [ + data.uvs[uvOffset], + data.uvs[uvOffset + 1], + data.uvs[uvOffset + 2], + data.uvs[uvOffset + 3] + ]; + const renderData: SpriteRenderData = { x: data.transforms[tOffset], y: data.transforms[tOffset + 1], @@ -578,7 +583,7 @@ export class EngineRenderSystem extends EntitySystem { originX: data.transforms[tOffset + 5], originY: data.transforms[tOffset + 6], textureId, - uv: [data.uvs[uvOffset], data.uvs[uvOffset + 1], data.uvs[uvOffset + 2], data.uvs[uvOffset + 3]], + uv, color: data.colors[i] }; 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 bfe92d84..8896ac1c 100644 --- a/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts +++ b/packages/ecs-engine-bindgen/src/wasm/es_engine.d.ts @@ -209,11 +209,31 @@ export class GameEngine { * 获取所有已注册的视口ID。 */ getViewportIds(): string[]; + /** + * 检查纹理是否已就绪 + * Check if texture is ready to use + * + * # Arguments | 参数 + * * `id` - Texture ID | 纹理ID + */ + isTextureReady(id: number): boolean; /** * Add a capsule gizmo outline. * 添加胶囊Gizmo边框。 */ addGizmoCapsule(x: number, y: number, radius: number, half_height: number, rotation: number, r: number, g: number, b: number, a: number): void; + /** + * 获取纹理加载状态 + * Get texture loading state + * + * # Arguments | 参数 + * * `id` - Texture ID | 纹理ID + * + * # Returns | 返回 + * State string: "loading", "ready", or "failed:reason" + * 状态字符串:"loading"、"ready" 或 "failed:原因" + */ + getTextureState(id: number): string; /** * Register a new viewport. * 注册新视口。 @@ -361,6 +381,11 @@ export class GameEngine { * 在恢复场景快照时应调用此方法,以确保纹理使用正确的ID重新加载。 */ clearTexturePathCache(): void; + /** + * 获取正在加载中的纹理数量 + * Get the number of textures currently loading + */ + getTextureLoadingCount(): number; /** * Create a new game engine instance. * 创建新的游戏引擎实例。 @@ -429,6 +454,8 @@ export interface InitOutput { readonly gameengine_getCamera: (a: number) => [number, number]; readonly gameengine_getOrLoadTextureByPath: (a: number, b: number, c: number) => [number, number, number]; readonly gameengine_getTextureIdByPath: (a: number, b: number, c: number) => number; + readonly gameengine_getTextureLoadingCount: (a: number) => number; + readonly gameengine_getTextureState: (a: number, b: number) => [number, number]; readonly gameengine_getViewportCamera: (a: number, b: number, c: number) => [number, number]; readonly gameengine_getViewportIds: (a: number) => [number, number]; readonly gameengine_hasMaterial: (a: number, b: number) => number; @@ -436,6 +463,7 @@ export interface InitOutput { readonly gameengine_height: (a: number) => number; readonly gameengine_isEditorMode: (a: number) => number; readonly gameengine_isKeyDown: (a: number, b: number, c: number) => number; + readonly gameengine_isTextureReady: (a: number, b: number) => number; readonly gameengine_loadTexture: (a: number, b: number, c: number, d: number) => [number, number]; readonly gameengine_loadTextureByPath: (a: number, b: number, c: number) => [number, number, number]; readonly gameengine_new: (a: number, b: number) => [number, number, number]; 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 dac7738c..361c0a20 100644 --- a/packages/editor-app/src-tauri/src/commands/file_system.rs +++ b/packages/editor-app/src-tauri/src/commands/file_system.rs @@ -254,6 +254,25 @@ pub fn read_file_as_base64(file_path: String) -> Result { Ok(general_purpose::STANDARD.encode(&file_content)) } +/// Get file modification time (milliseconds since UNIX epoch) +/// 获取文件修改时间(Unix 纪元以来的毫秒数) +#[tauri::command] +pub fn get_file_mtime(path: String) -> Result { + let metadata = fs::metadata(&path) + .map_err(|e| format!("Failed to get metadata for {}: {}", path, e))?; + + let modified = metadata + .modified() + .map_err(|e| format!("Failed to get modified time for {}: {}", path, e))?; + + let millis = modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("Time error: {}", e))? + .as_millis() as u64; + + Ok(millis) +} + /// Copy file from source to destination #[tauri::command] pub fn copy_file(src: String, dst: String) -> Result<(), String> { diff --git a/packages/editor-app/src-tauri/src/main.rs b/packages/editor-app/src-tauri/src/main.rs index e1f01b5a..bf075541 100644 --- a/packages/editor-app/src-tauri/src/main.rs +++ b/packages/editor-app/src-tauri/src/main.rs @@ -65,6 +65,7 @@ fn main() { commands::scan_directory, commands::read_file_as_base64, commands::copy_file, + commands::get_file_mtime, // Dialog operations commands::open_folder_dialog, commands::open_file_dialog, @@ -183,18 +184,27 @@ fn handle_project_protocol( } /// Get MIME type based on file extension +/// 根据文件扩展名获取 MIME 类型 fn get_mime_type(file_path: &str) -> &'static str { if file_path.ends_with(".ts") || file_path.ends_with(".tsx") { "application/javascript" - } else if file_path.ends_with(".js") { + } else if file_path.ends_with(".js") || file_path.ends_with(".mjs") { "application/javascript" } else if file_path.ends_with(".json") { "application/json" + } else if file_path.ends_with(".wasm") { + "application/wasm" } else if file_path.ends_with(".css") { "text/css" } else if file_path.ends_with(".html") { "text/html" + } else if file_path.ends_with(".png") { + "image/png" + } else if file_path.ends_with(".jpg") || file_path.ends_with(".jpeg") { + "image/jpeg" + } else if file_path.ends_with(".svg") { + "image/svg+xml" } else { - "text/plain" + "application/octet-stream" } } diff --git a/packages/editor-app/src-tauri/tauri.conf.json b/packages/editor-app/src-tauri/tauri.conf.json index 6829671c..3872cb72 100644 --- a/packages/editor-app/src-tauri/tauri.conf.json +++ b/packages/editor-app/src-tauri/tauri.conf.json @@ -81,7 +81,8 @@ { "identifier": "main", "windows": [ - "main" + "main", + "frame-debugger" ], "permissions": [ "core:default", @@ -91,6 +92,9 @@ "core:window:allow-toggle-maximize", "core:window:allow-close", "core:window:allow-is-maximized", + "core:window:allow-create", + "core:webview:allow-create-webview", + "core:webview:allow-create-webview-window", "shell:default", "dialog:default", "updater:default", diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index 263a4e28..f1a45593 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -40,11 +40,15 @@ import { Inspector } from './components/inspectors/Inspector'; import { AssetBrowser } from './components/AssetBrowser'; import { Viewport } from './components/Viewport'; import { AdvancedProfilerWindow } from './components/AdvancedProfilerWindow'; +import { RenderDebugPanel } from './components/debug/RenderDebugPanel'; +import { emit, emitTo, listen } from '@tauri-apps/api/event'; +import { renderDebugService } from './services/RenderDebugService'; import { PortManager } from './components/PortManager'; import { SettingsWindow } from './components/SettingsWindow'; import { AboutDialog } from './components/AboutDialog'; import { ErrorDialog } from './components/ErrorDialog'; import { ConfirmDialog } from './components/ConfirmDialog'; +import { ExternalModificationDialog } from './components/ExternalModificationDialog'; import { PluginGeneratorWindow } from './components/PluginGeneratorWindow'; import { BuildSettingsWindow } from './components/BuildSettingsWindow'; import { ForumPanel } from './components/forum'; @@ -63,6 +67,7 @@ import { useLocale } from './hooks/useLocale'; import { useStoreSubscriptions } from './hooks/useStoreSubscriptions'; import { en, zh, es } from './locales'; import type { Locale } from '@esengine/editor-core'; +import { UserCodeService } from '@esengine/editor-core'; import { Loader2 } from 'lucide-react'; import './styles/App.css'; @@ -84,12 +89,24 @@ Core.services.registerInstance(ICompilerRegistry, compilerRegistryInstance); const logger = createLogger('App'); +// 检查是否为独立窗口模式 | Check if standalone window mode +const isFrameDebuggerMode = new URLSearchParams(window.location.search).get('mode') === 'frame-debugger'; + function App() { const initRef = useRef(false); const layoutContainerRef = useRef(null); const [pluginLoader] = useState(() => new PluginLoader()); const { showToast, hideToast } = useToast(); + // 如果是独立调试窗口模式,只渲染调试面板 | If standalone debugger mode, only render debug panel + if (isFrameDebuggerMode) { + return ( +
+ window.close()} standalone /> +
+ ); + } + // ===== 本地初始化状态(只用于初始化阶段)| Local init state (only for initialization) ===== const [initialized, setInitialized] = useState(false); @@ -170,10 +187,40 @@ function App() { showAbout, setShowAbout, showPluginGenerator, setShowPluginGenerator, showBuildSettings, setShowBuildSettings, + showRenderDebug, setShowRenderDebug, errorDialog, setErrorDialog, - confirmDialog, setConfirmDialog + confirmDialog, setConfirmDialog, + externalModificationDialog, setExternalModificationDialog } = useDialogStore(); + // 全局监听独立调试窗口的数据请求 | Global listener for standalone debug window requests + useEffect(() => { + let broadcastInterval: ReturnType | null = null; + + const unlistenPromise = listen('render-debug-request-data', () => { + // 开始定时广播数据 | Start broadcasting data periodically + if (!broadcastInterval) { + const broadcast = () => { + renderDebugService.setEnabled(true); + const snap = renderDebugService.collectSnapshot(); + if (snap) { + // 使用 emitTo 发送到独立窗口 | Use emitTo to send to standalone window + emitTo('frame-debugger', 'render-debug-snapshot', snap).catch(() => {}); + } + }; + broadcast(); // 立即广播一次 | Broadcast immediately + broadcastInterval = setInterval(broadcast, 500); + } + }); + + return () => { + unlistenPromise.then(unlisten => unlisten()); + if (broadcastInterval) { + clearInterval(broadcastInterval); + } + }; + }, []); + useEffect(() => { // 禁用默认右键菜单 const handleContextMenu = (e: MouseEvent) => { @@ -483,6 +530,113 @@ function App() { }; }, [initialized]); + // Handle external scene file changes + // 处理外部场景文件变更 + useEffect(() => { + if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return; + const hub = messageHubRef.current; + const sm = sceneManagerRef.current; + + const unsubscribe = hub.subscribe('scene:external-change', (data: { + path: string; + sceneName: string; + }) => { + logger.info('Scene externally modified:', data.path); + + // Show confirmation dialog to reload the scene + // 显示确认对话框以重新加载场景 + setConfirmDialog({ + title: t('scene.externalChange.title'), + message: t('scene.externalChange.message', { name: data.sceneName }), + confirmText: t('scene.externalChange.reload'), + cancelText: t('scene.externalChange.ignore'), + onConfirm: async () => { + setConfirmDialog(null); + try { + await sm.openScene(data.path); + showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success'); + } catch (error) { + console.error('Failed to reload scene:', error); + showToast(t('scene.reloadFailed'), 'error'); + } + }, + onCancel: () => { + // User chose to ignore, do nothing + // 用户选择忽略,不做任何操作 + } + }); + }); + + return () => unsubscribe?.(); + }, [initialized, t, showToast]); + + // Handle external modification when saving scene + // 处理保存场景时的外部修改检测 + useEffect(() => { + if (!initialized || !messageHubRef.current || !sceneManagerRef.current) return; + const hub = messageHubRef.current; + const sm = sceneManagerRef.current; + + const unsubscribe = hub.subscribe('scene:externalModification', (data: { + path: string; + sceneName: string; + }) => { + logger.info('Scene file externally modified during save:', data.path); + + // Show external modification dialog with three options + // 显示外部修改对话框,提供三个选项 + setExternalModificationDialog({ + sceneName: data.sceneName, + onReload: async () => { + setExternalModificationDialog(null); + try { + await sm.reloadScene(); + showToast(t('scene.reloadedSuccess', { name: data.sceneName }), 'success'); + } catch (error) { + console.error('Failed to reload scene:', error); + showToast(t('scene.reloadFailed'), 'error'); + } + }, + onOverwrite: async () => { + setExternalModificationDialog(null); + try { + await sm.saveScene(true); // Force save, overwriting external changes + showToast(t('scene.savedSuccess', { name: data.sceneName }), 'success'); + } catch (error) { + console.error('Failed to save scene:', error); + showToast(t('scene.saveFailed'), 'error'); + } + } + }); + }); + + return () => unsubscribe?.(); + }, [initialized, t, showToast, setExternalModificationDialog]); + + // Handle user code compilation results + // 处理用户代码编译结果 + useEffect(() => { + if (!initialized || !messageHubRef.current) return; + const hub = messageHubRef.current; + + const unsubscribe = hub.subscribe('usercode:compilation-result', (data: { + success: boolean; + exports: string[]; + errors: string[]; + }) => { + if (data.success) { + if (data.exports.length > 0) { + showToast(t('usercode.compileSuccess', { count: data.exports.length }), 'success'); + } + } else { + const errorMsg = data.errors[0] ?? t('usercode.compileError'); + showToast(errorMsg, 'error'); + } + }); + + return () => unsubscribe?.(); + }, [initialized, t, showToast]); + const handleOpenRecentProject = async (projectPath: string) => { try { setIsLoading(true, t('loading.step1')); @@ -523,7 +677,6 @@ function App() { const sceneFiles = await TauriAPI.scanDirectory(`${projectPath}/scenes`, '*.ecs'); const sceneNames = sceneFiles.map(f => `scenes/${f.split(/[\\/]/).pop()}`); setAvailableScenes(sceneNames); - console.log('[App] Found scenes:', sceneNames); } catch (e) { console.warn('[App] Failed to scan scenes:', e); } @@ -545,12 +698,8 @@ function App() { // Load project plugin config and activate plugins (after engine init, before module system init) 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 pluginManagerRef.current.loadConfig({ enabledPlugins: pluginSettings.enabledPlugins }); - } else { - console.log('[App] No plugin settings found in project config'); } } @@ -566,6 +715,13 @@ function App() { setIsLoading(true, t('loading.step3')); + // Wait for user code to be compiled and registered before loading scenes + // 等待用户代码编译和注册完成后再加载场景 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + await userCodeService.waitForReady(); + } + const sceneManagerService = Core.services.resolve(SceneManagerService); if (sceneManagerService) { await sceneManagerService.newScene(); @@ -696,6 +852,13 @@ function App() { } try { + // Wait for user code to be ready before loading scene + // 在加载场景前等待用户代码就绪 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + await userCodeService.waitForReady(); + } + await sceneManager.openScene(); const sceneState = sceneManager.getSceneState(); setStatus(t('scene.openedSuccess', { name: sceneState.sceneName })); @@ -706,13 +869,25 @@ function App() { }; const handleOpenSceneByPath = useCallback(async (scenePath: string) => { + console.log('[App] handleOpenSceneByPath called:', scenePath); if (!sceneManager) { console.error('SceneManagerService not available'); return; } try { + // Wait for user code to be ready before loading scene + // 在加载场景前等待用户代码就绪 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + console.log('[App] Waiting for user code service...'); + await userCodeService.waitForReady(); + console.log('[App] User code service ready'); + } + + console.log('[App] Calling sceneManager.openScene...'); await sceneManager.openScene(scenePath); + console.log('[App] Scene opened successfully'); const sceneState = sceneManager.getSceneState(); setStatus(t('scene.openedSuccess', { name: sceneState.sceneName })); } catch (error) { @@ -1087,6 +1262,14 @@ function App() { }} /> )} + {externalModificationDialog && ( + setExternalModificationDialog(null)} + /> + )} ); } @@ -1121,6 +1304,7 @@ function App() { onCreatePlugin={handleCreatePlugin} onReloadPlugins={handleReloadPlugins} onOpenBuildSettings={() => setShowBuildSettings(true)} + onOpenRenderDebug={() => setShowRenderDebug(true)} /> )} + {/* 渲染调试面板 | Render Debug Panel */} + setShowRenderDebug(false)} + /> + {errorDialog && ( )} + + {externalModificationDialog && ( + setExternalModificationDialog(null)} + /> + )} ); } diff --git a/packages/editor-app/src/adapters/TauriFileAPI.ts b/packages/editor-app/src/adapters/TauriFileAPI.ts index 7aaa800f..325da64e 100644 --- a/packages/editor-app/src/adapters/TauriFileAPI.ts +++ b/packages/editor-app/src/adapters/TauriFileAPI.ts @@ -38,4 +38,8 @@ export class TauriFileAPI implements IFileAPI { public async pathExists(path: string): Promise { return await TauriAPI.pathExists(path); } + + public async getFileMtime(path: string): Promise { + return await TauriAPI.getFileMtime(path); + } } diff --git a/packages/editor-app/src/api/tauri.ts b/packages/editor-app/src/api/tauri.ts index 626c678f..c1899c7d 100644 --- a/packages/editor-app/src/api/tauri.ts +++ b/packages/editor-app/src/api/tauri.ts @@ -267,6 +267,17 @@ export class TauriAPI { return await invoke('copy_file', { src, dst }); } + /** + * 获取文件修改时间 + * Get file modification time + * + * @param path 文件路径 | File path + * @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp) + */ + static async getFileMtime(path: string): Promise { + return await invoke('get_file_mtime', { path }); + } + /** * 写入二进制文件 * @param filePath 文件路径 diff --git a/packages/editor-app/src/app/managers/DialogManager.ts b/packages/editor-app/src/app/managers/DialogManager.ts index e07bc2d7..78f09aab 100644 --- a/packages/editor-app/src/app/managers/DialogManager.ts +++ b/packages/editor-app/src/app/managers/DialogManager.ts @@ -6,6 +6,16 @@ interface ErrorDialogData { message: string; } +/** + * 外部修改对话框数据 + * External modification dialog data + */ +export interface ExternalModificationDialogData { + sceneName: string; + onReload: () => void; + onOverwrite: () => void; +} + interface DialogState { showProfiler: boolean; showAdvancedProfiler: boolean; @@ -14,8 +24,10 @@ interface DialogState { showAbout: boolean; showPluginGenerator: boolean; showBuildSettings: boolean; + showRenderDebug: boolean; errorDialog: ErrorDialogData | null; confirmDialog: ConfirmDialogData | null; + externalModificationDialog: ExternalModificationDialogData | null; setShowProfiler: (show: boolean) => void; setShowAdvancedProfiler: (show: boolean) => void; @@ -24,8 +36,10 @@ interface DialogState { setShowAbout: (show: boolean) => void; setShowPluginGenerator: (show: boolean) => void; setShowBuildSettings: (show: boolean) => void; + setShowRenderDebug: (show: boolean) => void; setErrorDialog: (data: ErrorDialogData | null) => void; setConfirmDialog: (data: ConfirmDialogData | null) => void; + setExternalModificationDialog: (data: ExternalModificationDialogData | null) => void; closeAllDialogs: () => void; } @@ -37,8 +51,10 @@ export const useDialogStore = create((set) => ({ showAbout: false, showPluginGenerator: false, showBuildSettings: false, + showRenderDebug: false, errorDialog: null, confirmDialog: null, + externalModificationDialog: null, setShowProfiler: (show) => set({ showProfiler: show }), setShowAdvancedProfiler: (show) => set({ showAdvancedProfiler: show }), @@ -47,8 +63,10 @@ export const useDialogStore = create((set) => ({ setShowAbout: (show) => set({ showAbout: show }), setShowPluginGenerator: (show) => set({ showPluginGenerator: show }), setShowBuildSettings: (show) => set({ showBuildSettings: show }), + setShowRenderDebug: (show) => set({ showRenderDebug: show }), setErrorDialog: (data) => set({ errorDialog: data }), setConfirmDialog: (data) => set({ confirmDialog: data }), + setExternalModificationDialog: (data) => set({ externalModificationDialog: data }), closeAllDialogs: () => set({ showProfiler: false, @@ -58,7 +76,9 @@ export const useDialogStore = create((set) => ({ showAbout: false, showPluginGenerator: false, showBuildSettings: false, + showRenderDebug: false, errorDialog: null, - confirmDialog: null + confirmDialog: null, + externalModificationDialog: null }) })); diff --git a/packages/editor-app/src/app/managers/ServiceRegistry.ts b/packages/editor-app/src/app/managers/ServiceRegistry.ts index f3508015..58aab876 100644 --- a/packages/editor-app/src/app/managers/ServiceRegistry.ts +++ b/packages/editor-app/src/app/managers/ServiceRegistry.ts @@ -1,4 +1,4 @@ -import { Core, ComponentRegistry as CoreComponentRegistry, PrefabSerializer, ComponentRegistry as ECSComponentRegistry } from '@esengine/ecs-framework'; +import { Core, GlobalComponentRegistry, PrefabSerializer } from '@esengine/ecs-framework'; import type { ComponentType } from '@esengine/ecs-framework'; import { invoke } from '@tauri-apps/api/core'; import { @@ -136,8 +136,8 @@ export class ServiceRegistry { for (const comp of standardComponents) { // Register to editor registry for UI - // 组件已通过 @ECSComponent 装饰器自动注册到 CoreComponentRegistry - // Components are auto-registered to CoreComponentRegistry via @ECSComponent decorator + // 组件已通过 @ECSComponent 装饰器自动注册到 GlobalComponentRegistry + // Components are auto-registered to GlobalComponentRegistry via @ECSComponent decorator componentRegistry.register({ name: comp.editorName, type: comp.type, @@ -149,7 +149,7 @@ export class ServiceRegistry { // Enable hot reload for editor environment // 在编辑器环境中启用热更新 - CoreComponentRegistry.enableHotReload(); + GlobalComponentRegistry.enableHotReload(); const projectService = new ProjectService(messageHub, fileAPI); const componentDiscovery = new ComponentDiscoveryService(messageHub); @@ -340,8 +340,14 @@ export class ServiceRegistry { // 编辑器脚本编译错误只记录,不影响运行时 console.warn('[UserCodeService] Editor compilation errors:', editorResult.errors); } + + // 编译完成,发出就绪信号 | Compilation done, signal ready + userCodeService.signalReady(); } catch (error) { console.error('[UserCodeService] Failed to compile/load:', error); + // 即使编译失败也要发出就绪信号,避免阻塞场景加载 + // Signal ready even on failure to avoid blocking scene loading + userCodeService.signalReady(); } }; diff --git a/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts b/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts index 374b9739..2b3b8722 100644 --- a/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts +++ b/packages/editor-app/src/application/commands/prefab/InstantiatePrefabCommand.ts @@ -6,7 +6,7 @@ * Creates an entity instance from a prefab asset. */ -import { Core, Entity, HierarchySystem, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework'; +import { Core, Entity, HierarchySystem, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework'; import type { EntityStoreService, MessageHub } from '@esengine/editor-core'; import type { PrefabData, ComponentType } from '@esengine/ecs-framework'; import { BaseCommand } from '../BaseCommand'; @@ -50,9 +50,9 @@ export class InstantiatePrefabCommand extends BaseCommand { } // 获取组件注册表 | Get component registry - // ComponentRegistry.getAllComponentNames() returns Map + // GlobalComponentRegistry.getAllComponentNames() returns Map // We need to cast it to Map - const componentRegistry = ComponentRegistry.getAllComponentNames() as Map; + const componentRegistry = GlobalComponentRegistry.getAllComponentNames() as Map; // 实例化预制体 | Instantiate prefab this.createdEntity = PrefabSerializer.instantiate( diff --git a/packages/editor-app/src/components/ContentBrowser.tsx b/packages/editor-app/src/components/ContentBrowser.tsx index 982219f8..a1aabfcb 100644 --- a/packages/editor-app/src/components/ContentBrowser.tsx +++ b/packages/editor-app/src/components/ContentBrowser.tsx @@ -1026,13 +1026,16 @@ export class ${className} { // Handle asset double click const handleAssetDoubleClick = useCallback(async (asset: AssetItem) => { + console.log('[ContentBrowser] Double click:', asset.name, 'type:', asset.type, 'ext:', asset.extension); if (asset.type === 'folder') { setCurrentPath(asset.path); loadAssets(asset.path); setExpandedFolders(prev => new Set([...prev, asset.path])); } else { const ext = asset.extension?.toLowerCase(); + console.log('[ContentBrowser] File ext:', ext, 'onOpenScene:', !!onOpenScene); if (ext === 'ecs' && onOpenScene) { + console.log('[ContentBrowser] Opening scene:', asset.path); onOpenScene(asset.path); return; } diff --git a/packages/editor-app/src/components/ExternalModificationDialog.tsx b/packages/editor-app/src/components/ExternalModificationDialog.tsx new file mode 100644 index 00000000..304c884a --- /dev/null +++ b/packages/editor-app/src/components/ExternalModificationDialog.tsx @@ -0,0 +1,58 @@ +import { AlertTriangle, X, RefreshCw, Save } from 'lucide-react'; +import '../styles/ConfirmDialog.css'; + +interface ExternalModificationDialogProps { + sceneName: string; + onReload: () => void; + onOverwrite: () => void; + onCancel: () => void; +} + +/** + * 外部修改对话框 + * External Modification Dialog + * + * 当场景文件被外部修改时显示,让用户选择操作 + * Shown when scene file is modified externally, let user choose action + */ +export function ExternalModificationDialog({ + sceneName, + onReload, + onOverwrite, + onCancel +}: ExternalModificationDialogProps) { + return ( +
+
e.stopPropagation()}> +
+ +

文件已被外部修改

+ +
+
+

+ 场景 {sceneName} 已在编辑器外部被修改。 +

+

+ 请选择如何处理: +

+
+
+ + + +
+
+
+ ); +} diff --git a/packages/editor-app/src/components/TitleBar.tsx b/packages/editor-app/src/components/TitleBar.tsx index 40d22c7d..3c0fc33f 100644 --- a/packages/editor-app/src/components/TitleBar.tsx +++ b/packages/editor-app/src/components/TitleBar.tsx @@ -38,6 +38,7 @@ interface TitleBarProps { onCreatePlugin?: () => void; onReloadPlugins?: () => void; onOpenBuildSettings?: () => void; + onOpenRenderDebug?: () => void; } export function TitleBar({ @@ -61,7 +62,8 @@ export function TitleBar({ onOpenAbout, onCreatePlugin, onReloadPlugins, - onOpenBuildSettings + onOpenBuildSettings, + onOpenRenderDebug }: TitleBarProps) { const { t } = useLocale(); const [openMenu, setOpenMenu] = useState(null); @@ -197,6 +199,7 @@ export function TitleBar({ { label: t('menu.tools.reloadPlugins'), shortcut: 'Ctrl+R', onClick: onReloadPlugins }, { separator: true }, { label: t('menu.tools.portManager'), onClick: onOpenPortManager }, + { label: t('menu.tools.renderDebug'), onClick: onOpenRenderDebug }, { separator: true }, { label: t('menu.tools.settings'), onClick: onOpenSettings } ], diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index aa605c44..17e2517e 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -8,9 +8,9 @@ import '../styles/Viewport.css'; import { useEngine } from '../hooks/useEngine'; import { useLocale } from '../hooks/useLocale'; import { EngineService } from '../services/EngineService'; -import { Core, Entity, SceneSerializer, PrefabSerializer, ComponentRegistry } from '@esengine/ecs-framework'; +import { Core, Entity, SceneSerializer, PrefabSerializer, GlobalComponentRegistry } from '@esengine/ecs-framework'; import type { PrefabData, ComponentType } from '@esengine/ecs-framework'; -import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService } from '@esengine/editor-core'; +import { MessageHub, ProjectService, AssetRegistryService, EntityStoreService, CommandManager, SceneManagerService, UserCodeService, UserCodeTarget } from '@esengine/editor-core'; import { InstantiatePrefabCommand } from '../application/commands/prefab/InstantiatePrefabCommand'; import { TransformCommand, type TransformState, type TransformOperationType } from '../application/commands'; import { TransformComponent } from '@esengine/engine-core'; @@ -21,6 +21,8 @@ import { open } from '@tauri-apps/plugin-shell'; import { RuntimeResolver } from '../services/RuntimeResolver'; import { QRCodeDialog } from './QRCodeDialog'; import { collectAssetReferences } from '@esengine/asset-system'; +import { RuntimeSceneManager, type IRuntimeSceneManager } from '@esengine/runtime-core'; +import { ParticleSystemComponent } from '@esengine/particle'; import type { ModuleManifest } from '../services/RuntimeResolver'; @@ -264,6 +266,9 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const editorCameraRef = useRef({ x: 0, y: 0, zoom: 1 }); const playStateRef = useRef('stopped'); + // Runtime scene manager for play mode scene switching | Play 模式场景切换管理器 + const runtimeSceneManagerRef = useRef(null); + // Live transform display state | 实时变换显示状态 const [liveTransform, setLiveTransform] = useState<{ type: 'move' | 'rotate' | 'scale'; @@ -811,7 +816,22 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport return; } // Save scene snapshot before playing + // saveSceneSnapshot clears all textures, so we need to reset particle textureIds after + // saveSceneSnapshot 会清除所有纹理,所以之后需要重置粒子的 textureId EngineService.getInstance().saveSceneSnapshot(); + + // Reset particle component textureIds after snapshot (textures were cleared) + // 快照后重置粒子组件的 textureId(纹理已被清除) + const scene = Core.scene; + if (scene) { + for (const entity of scene.entities.buffer) { + const particleComponent = entity.getComponent(ParticleSystemComponent); + if (particleComponent) { + particleComponent.textureId = 0; + } + } + } + // Save editor camera state editorCameraRef.current = { x: camera2DOffset.x, y: camera2DOffset.y, zoom: camera2DZoom }; setPlayState('playing'); @@ -820,6 +840,132 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport EngineService.getInstance().setEditorMode(false); // Switch to player camera syncPlayerCamera(); + + // Register RuntimeSceneManager for scene switching in play mode + // 注册 RuntimeSceneManager 以支持 Play 模式下的场景切换 + const projectService = Core.services.tryResolve(ProjectService); + const projectPath = projectService?.getCurrentProject()?.path; + if (projectPath) { + // Create scene loader function that reads scene files using Tauri API + // 创建使用 Tauri API 读取场景文件的场景加载器函数 + const editorSceneLoader = async (scenePath: string): Promise => { + try { + // Normalize path: handle both relative and absolute paths + // 标准化路径:处理相对路径和绝对路径 + let fullPath = scenePath; + if (!scenePath.includes(':') && !scenePath.startsWith('/')) { + // Relative path - construct full path + // 相对路径 - 构建完整路径 + const normalizedPath = scenePath.replace(/^\.\//, '').replace(/\//g, '\\'); + fullPath = `${projectPath}\\${normalizedPath}`; + } else { + // Absolute path - normalize separators for Windows + // 绝对路径 - 为 Windows 规范化分隔符 + fullPath = scenePath.replace(/\//g, '\\'); + } + + // Read scene file content + // 读取场景文件内容 + const sceneJson = await TauriAPI.readFileContent(fullPath); + + // Validate scene data + // 验证场景数据 + const validation = SceneSerializer.validate(sceneJson); + if (!validation.valid) { + throw new Error(`Invalid scene: ${validation.errors?.join(', ')}`); + } + + // Save current scene snapshot (so we can go back) + // 保存当前场景快照(以便返回) + EngineService.getInstance().saveSceneSnapshot(); + + // Load new scene by deserializing into current scene + // 通过反序列化加载新场景到当前场景 + const scene = Core.scene; + if (scene) { + scene.deserialize(sceneJson, { strategy: 'replace' }); + + // Reset particle component textureIds after scene switch + // 场景切换后重置粒子组件的 textureId + // This ensures ParticleUpdateSystem will reload textures + // 这确保 ParticleUpdateSystem 会重新加载纹理 + for (const entity of scene.entities.buffer) { + const particleComponent = entity.getComponent(ParticleSystemComponent); + if (particleComponent) { + particleComponent.textureId = 0; + } + } + + // Re-register user code components and systems after scene switch + // 场景切换后重新注册用户代码组件和系统 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime); + if (runtimeModule) { + // Re-register components (ensures GlobalComponentRegistry has correct references) + // 重新注册组件(确保 GlobalComponentRegistry 有正确的引用) + userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry); + + // Re-register systems (recreates systems with correct component references) + // 重新注册系统(使用正确的组件引用重建系统) + userCodeService.registerSystems(runtimeModule, scene); + } + } + + // Load scene resources (textures, etc.) + // 加载场景资源(纹理等) + await EngineService.getInstance().loadSceneResources(); + + // Sync entity store + // 同步实体存储 + const entityStore = Core.services.tryResolve(EntityStoreService); + entityStore?.syncFromScene(); + } + + console.log(`[Viewport] Scene loaded in play mode: ${scenePath}`); + } catch (error) { + console.error(`[Viewport] Failed to load scene: ${scenePath}`, error); + throw error; + } + }; + + // Create and register RuntimeSceneManager + // 创建并注册 RuntimeSceneManager + const sceneManager = new RuntimeSceneManager( + editorSceneLoader, + `${projectPath}\\scenes` + ); + runtimeSceneManagerRef.current = sceneManager; + + // Register to Core.services with the global key + // 使用全局 key 注册到 Core.services + const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager'); + if (!Core.services.isRegistered(GlobalSceneManagerKey)) { + Core.services.registerInstance(GlobalSceneManagerKey, sceneManager); + } + + console.log('[Viewport] RuntimeSceneManager registered for play mode'); + } + + // Register user code components and systems before starting engine + // 在启动引擎前注册用户代码组件和系统 + const userCodeService = Core.services.tryResolve(UserCodeService); + if (userCodeService) { + const runtimeModule = userCodeService.getModule(UserCodeTarget.Runtime); + if (runtimeModule) { + // Register components first (ensures GlobalComponentRegistry has correct references) + // 先注册组件(确保 GlobalComponentRegistry 有正确的引用) + userCodeService.registerComponents(runtimeModule, GlobalComponentRegistry); + + // Then register systems (uses registered component references) + // 然后注册系统(使用已注册的组件引用) + const scene = Core.scene; + if (scene) { + userCodeService.registerSystems(runtimeModule, scene); + } + } + } + engine.start(); } else if (playState === 'paused') { setPlayState('playing'); @@ -837,6 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport const handleStop = async () => { setPlayState('stopped'); engine.stop(); + + // Unregister RuntimeSceneManager + // 注销 RuntimeSceneManager + if (runtimeSceneManagerRef.current) { + const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager'); + if (Core.services.isRegistered(GlobalSceneManagerKey)) { + Core.services.unregister(GlobalSceneManagerKey); + } + runtimeSceneManagerRef.current.dispose(); + runtimeSceneManagerRef.current = null; + console.log('[Viewport] RuntimeSceneManager unregistered'); + } + // Restore scene snapshot await EngineService.getInstance().restoreSceneSnapshot(); // Restore editor camera state diff --git a/packages/editor-app/src/components/debug/RenderDebugPanel.css b/packages/editor-app/src/components/debug/RenderDebugPanel.css new file mode 100644 index 00000000..ff23ada2 --- /dev/null +++ b/packages/editor-app/src/components/debug/RenderDebugPanel.css @@ -0,0 +1,633 @@ +/** + * 渲染调试面板样式 (浮动窗口) + * Render Debug Panel Styles (Floating Window) + */ + +/* ==================== Floating Window ==================== */ +.render-debug-window { + position: fixed; + display: flex; + flex-direction: column; + background: #1e1e1e; + border: 1px solid #3c3c3c; + border-radius: 6px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + z-index: 1000; + overflow: hidden; + font-family: 'Segoe UI', system-ui, sans-serif; + font-size: 11px; + color: #ccc; +} + +.render-debug-window.dragging { + cursor: move; + user-select: none; +} + +/* 独立窗口模式 | Standalone mode */ +.render-debug-window.standalone { + position: relative; + border: none; + border-radius: 0; + box-shadow: none; +} + +.render-debug-window.standalone .window-header { + cursor: default; +} + +/* ==================== Window Header ==================== */ +.render-debug-window .window-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #2d2d2d; + border-bottom: 1px solid #1a1a1a; + cursor: move; + flex-shrink: 0; +} + +.render-debug-window .window-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: #e0e0e0; +} + +.render-debug-window .window-title svg { + color: #4a9eff; +} + +.render-debug-window .paused-badge { + padding: 2px 6px; + background: #f59e0b; + color: #000; + font-size: 9px; + font-weight: 700; + border-radius: 3px; + letter-spacing: 0.5px; +} + +.render-debug-window .window-controls { + display: flex; + gap: 4px; +} + +.render-debug-window .window-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: 4px; + color: #888; + cursor: pointer; + transition: all 0.15s; +} + +.render-debug-window .window-btn:hover { + background: #3a3a3a; + color: #fff; +} + +/* ==================== Toolbar ==================== */ +.render-debug-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 12px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.render-debug-toolbar .toolbar-left, +.render-debug-toolbar .toolbar-right { + display: flex; + align-items: center; + gap: 6px; +} + +.render-debug-toolbar .toolbar-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: #3a3a3a; + border: 1px solid #4a4a4a; + border-radius: 3px; + color: #ccc; + font-size: 10px; + cursor: pointer; + transition: all 0.15s; +} + +.render-debug-toolbar .toolbar-btn:hover { + background: #4a4a4a; + color: #fff; +} + +.render-debug-toolbar .toolbar-btn.active { + background: #4a9eff; + border-color: #4a9eff; + color: #fff; +} + +.render-debug-toolbar .toolbar-btn.icon-only { + padding: 4px 6px; +} + +.render-debug-toolbar .toolbar-btn.recording { + background: rgba(239, 68, 68, 0.2); + border-color: #ef4444; +} + +.render-debug-toolbar .toolbar-btn .record-dot { + display: inline-block; + width: 10px; + height: 10px; + background: #ef4444; + border-radius: 50%; + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.render-debug-toolbar .history-badge { + padding: 2px 6px; + background: #8b5cf6; + color: #fff; + font-size: 9px; + font-weight: 700; + border-radius: 3px; + letter-spacing: 0.5px; + margin-left: 4px; +} + +.render-debug-toolbar .toolbar-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.render-debug-toolbar .toolbar-btn:disabled:hover { + background: #3a3a3a; + color: #ccc; +} + +/* ==================== Timeline ==================== */ +.render-debug-timeline { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + background: #222; + border-bottom: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.render-debug-timeline .timeline-slider { + width: 100%; + height: 6px; + -webkit-appearance: none; + appearance: none; + background: #333; + border-radius: 3px; + cursor: pointer; +} + +.render-debug-timeline .timeline-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: #4a9eff; + border-radius: 50%; + cursor: grab; + transition: transform 0.1s; +} + +.render-debug-timeline .timeline-slider::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.render-debug-timeline .timeline-slider::-webkit-slider-thumb:active { + cursor: grabbing; + transform: scale(1.1); +} + +.render-debug-timeline .timeline-slider::-moz-range-thumb { + width: 14px; + height: 14px; + background: #4a9eff; + border: none; + border-radius: 50%; + cursor: grab; +} + +.render-debug-timeline .timeline-info { + display: flex; + justify-content: space-between; + font-size: 9px; + color: #666; +} + +.render-debug-toolbar .toolbar-separator { + width: 1px; + height: 16px; + background: #3a3a3a; +} + +.render-debug-toolbar .frame-counter { + font-family: 'Consolas', monospace; + font-size: 10px; + color: #888; + padding: 0 6px; +} + +/* ==================== Main Layout ==================== */ +.render-debug-main { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* ==================== Left Panel (Event List) ==================== */ +.render-debug-left { + width: 260px; + min-width: 180px; + display: flex; + flex-direction: column; + background: #222; + border-right: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.event-list-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + font-size: 10px; + font-weight: 600; + color: #888; +} + +.event-list-header .event-count { + font-weight: 400; + color: #666; +} + +.event-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} + +.event-list::-webkit-scrollbar { + width: 5px; +} + +.event-list::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.event-list::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 2px; +} + +.event-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #555; + font-size: 10px; + text-align: center; + padding: 16px; + line-height: 1.5; +} + +/* Event Items */ +.event-item { + display: flex; + align-items: center; + padding: 3px 6px; + cursor: pointer; + user-select: none; + font-size: 10px; + color: #bbb; + border-bottom: 1px solid #1a1a1a; + gap: 3px; +} + +.event-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.event-item.selected { + background: rgba(74, 158, 255, 0.2); + border-left: 2px solid #4a9eff; + padding-left: 4px; +} + +.event-item .expand-icon { + display: flex; + align-items: center; + justify-content: center; + width: 12px; + height: 12px; + color: #666; + flex-shrink: 0; +} + +.event-item .expand-icon:not(.placeholder):hover { + color: #ccc; +} + +.event-item .expand-icon.placeholder { + visibility: hidden; +} + +.event-item .event-icon { + color: #666; + flex-shrink: 0; + margin-right: 3px; +} + +.event-item .event-icon.sprite { + color: #4fc3f7; +} + +.event-item .event-icon.particle { + color: #ffb74d; +} + +.event-item .event-icon.ui { + color: #81c784; +} + +.event-item .event-icon.batch { + color: #81c784; +} + +.event-item .event-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.event-item .event-draws { + font-family: 'Consolas', monospace; + font-size: 9px; + color: #666; + padding: 1px 3px; + background: #1a1a1a; + border-radius: 2px; + flex-shrink: 0; +} + +/* ==================== Right Panel ==================== */ +.render-debug-right { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +/* Preview Section */ +.render-debug-preview { + height: 40%; + min-height: 120px; + display: flex; + flex-direction: column; + border-bottom: 1px solid #1a1a1a; +} + +.preview-header { + padding: 6px 10px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + font-size: 10px; + font-weight: 600; + color: #888; +} + +.preview-canvas-container { + flex: 1; + background: #1a1a1a; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.preview-canvas-container canvas { + width: 100%; + height: 100%; +} + +/* Details Section */ +.render-debug-details { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.details-header { + padding: 6px 10px; + background: #262626; + border-bottom: 1px solid #1a1a1a; + font-size: 10px; + font-weight: 600; + color: #888; +} + +.details-content { + flex: 1; + overflow-y: auto; + padding: 10px; + background: #1e1e1e; +} + +.details-content::-webkit-scrollbar { + width: 5px; +} + +.details-content::-webkit-scrollbar-track { + background: #1a1a1a; +} + +.details-content::-webkit-scrollbar-thumb { + background: #3a3a3a; + border-radius: 2px; +} + +.details-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: #555; + font-size: 10px; +} + +/* Details Grid */ +.details-grid { + display: flex; + flex-direction: column; + gap: 1px; +} + +.details-section { + font-size: 9px; + font-weight: 600; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 8px 0 3px 0; + margin-top: 6px; + border-top: 1px solid #333; +} + +.details-section:first-child { + margin-top: 0; + border-top: none; + padding-top: 0; +} + +.detail-row { + display: flex; + align-items: flex-start; + padding: 3px 0; + font-size: 10px; +} + +.detail-row .detail-label { + width: 100px; + color: #888; + flex-shrink: 0; +} + +.detail-row .detail-value { + flex: 1; + color: #ccc; + font-family: 'Consolas', monospace; + word-break: break-all; +} + +.detail-row.highlight .detail-value { + color: #4fc3f7; + font-weight: 600; +} + +/* ==================== Stats Bar ==================== */ +.render-debug-stats { + display: flex; + align-items: center; + gap: 16px; + padding: 6px 12px; + background: #262626; + border-top: 1px solid #1a1a1a; + flex-shrink: 0; +} + +.render-debug-stats .stat-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: #888; +} + +.render-debug-stats .stat-item svg { + color: #4a9eff; +} + +/* ==================== Resize Handle ==================== */ +.resize-handle { + position: absolute; + right: 0; + bottom: 0; + width: 16px; + height: 16px; + cursor: se-resize; + background: linear-gradient(135deg, transparent 50%, #3a3a3a 50%); + border-radius: 0 0 6px 0; +} + +.resize-handle:hover { + background: linear-gradient(135deg, transparent 50%, #4a9eff 50%); +} + +/* ==================== TextureSheet Preview ==================== */ +.texture-sheet-preview { + margin-top: 8px; + border-radius: 4px; + overflow: hidden; + background: #1a1a1a; + border: 1px solid #333; +} + +.texture-sheet-preview canvas { + display: block; + width: 100%; + height: auto; +} + +/* ==================== Texture Preview ==================== */ +.texture-preview-row { + display: flex; + align-items: flex-start; + padding: 3px 0; + font-size: 10px; +} + +.texture-preview-row .detail-label { + width: 100px; + color: #888; + flex-shrink: 0; +} + +.texture-preview-content { + flex: 1; + min-width: 0; +} + +.texture-thumbnail-container { + display: flex; + flex-direction: column; + gap: 4px; +} + +.texture-thumbnail { + max-width: 100%; + max-height: 80px; + object-fit: contain; + border-radius: 3px; + border: 1px solid #333; + background: repeating-conic-gradient(#2a2a2a 0% 25%, #1a1a1a 0% 50%) 50% / 8px 8px; +} + +.texture-path { + font-family: 'Consolas', monospace; + font-size: 9px; + color: #666; + word-break: break-all; + line-height: 1.3; +} diff --git a/packages/editor-app/src/components/debug/RenderDebugPanel.tsx b/packages/editor-app/src/components/debug/RenderDebugPanel.tsx new file mode 100644 index 00000000..e10b37bb --- /dev/null +++ b/packages/editor-app/src/components/debug/RenderDebugPanel.tsx @@ -0,0 +1,1059 @@ +/** + * 渲染调试面板(Frame Debugger 风格) + * Render Debug Panel (Frame Debugger Style) + * + * 用于诊断渲染问题的可视化调试工具 + * Visual debugging tool for diagnosing rendering issues + */ + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { + X, + ExternalLink, + Monitor, + Play, + Pause, + SkipForward, + SkipBack, + ChevronRight, + ChevronDown, + ChevronFirst, + ChevronLast, + Layers, + Image, + Sparkles, + RefreshCw, + Download, + Radio, + Square, + Type +} from 'lucide-react'; +import { WebviewWindow, getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'; +import { emit, emitTo, listen, type UnlistenFn } from '@tauri-apps/api/event'; +import { renderDebugService, type RenderDebugSnapshot, type SpriteDebugInfo, type ParticleDebugInfo, type UIDebugInfo } from '../../services/RenderDebugService'; +import './RenderDebugPanel.css'; + +/** + * 渲染事件类型 + * Render event type + */ +type RenderEventType = 'clear' | 'sprite' | 'particle' | 'ui' | 'batch' | 'draw'; + +/** + * 渲染事件 + * Render event + */ +interface RenderEvent { + id: number; + type: RenderEventType; + name: string; + children?: RenderEvent[]; + expanded?: boolean; + data?: SpriteDebugInfo | ParticleDebugInfo | UIDebugInfo | any; + drawCalls?: number; + vertices?: number; +} + +interface RenderDebugPanelProps { + visible: boolean; + onClose: () => void; + /** 独立窗口模式(填满整个窗口)| Standalone mode (fill entire window) */ + standalone?: boolean; +} + +// 最大历史帧数 | Max history frames +const MAX_HISTORY_FRAMES = 120; + +export const RenderDebugPanel: React.FC = ({ visible, onClose, standalone = false }) => { + const [isPaused, setIsPaused] = useState(false); + const [snapshot, setSnapshot] = useState(null); + const [events, setEvents] = useState([]); + const [selectedEvent, setSelectedEvent] = useState(null); + + // 帧历史 | Frame history + const [frameHistory, setFrameHistory] = useState([]); + const [historyIndex, setHistoryIndex] = useState(-1); // -1 表示实时模式 | -1 means live mode + + // 窗口拖动状态 | Window drag state + const [position, setPosition] = useState({ x: 100, y: 60 }); + const [size, setSize] = useState({ width: 900, height: 600 }); + const [isDragging, setIsDragging] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); + + const canvasRef = useRef(null); + const windowRef = useRef(null); + + // 弹出为独立窗口 | Pop out to separate window + const handlePopOut = useCallback(async () => { + try { + // 检查窗口是否已存在 | Check if window already exists + const existingWindow = await WebviewWindow.getByLabel('frame-debugger'); + if (existingWindow) { + // 聚焦到现有窗口 | Focus existing window + await existingWindow.setFocus(); + onClose(); + return; + } + + const webview = new WebviewWindow('frame-debugger', { + url: window.location.href.split('?')[0] + '?mode=frame-debugger', + title: 'Frame Debugger', + width: 1000, + height: 700, + minWidth: 600, + minHeight: 400, + center: false, + x: 100, + y: 100, + resizable: true, + decorations: true, + alwaysOnTop: false, + focus: true + }); + + webview.once('tauri://created', () => { + console.log('[FrameDebugger] Separate window created'); + onClose(); // 关闭内嵌面板 | Close embedded panel + }); + + webview.once('tauri://error', (e) => { + console.error('[FrameDebugger] Failed to create window:', e); + }); + } catch (err) { + console.error('[FrameDebugger] Error creating window:', err); + } + }, [onClose]); + + // 从快照构建事件树 | Build event tree from snapshot + const buildEventsFromSnapshot = useCallback((snap: RenderDebugSnapshot): RenderEvent[] => { + const newEvents: RenderEvent[] = []; + let eventId = 0; + + newEvents.push({ + id: eventId++, + type: 'clear', + name: 'Clear (color)', + drawCalls: 1, + vertices: 0 + }); + + if (snap.sprites.length > 0) { + const spriteChildren: RenderEvent[] = snap.sprites.map((sprite) => ({ + id: eventId++, + type: 'sprite' as RenderEventType, + name: `Draw Sprite: ${sprite.entityName}`, + data: sprite, + drawCalls: 1, + vertices: 4 + })); + + newEvents.push({ + id: eventId++, + type: 'batch', + name: `SpriteBatch (${snap.sprites.length} sprites)`, + children: spriteChildren, + expanded: true, + drawCalls: snap.sprites.length, + vertices: snap.sprites.length * 4 + }); + } + + snap.particles.forEach(ps => { + const particleChildren: RenderEvent[] = ps.sampleParticles.map((p, idx) => ({ + id: eventId++, + type: 'particle' as RenderEventType, + name: `Particle ${idx}: frame=${p.frame}`, + data: { ...p, systemName: ps.systemName }, + drawCalls: 0, + vertices: 4 + })); + + newEvents.push({ + id: eventId++, + type: 'particle', + name: `ParticleSystem: ${ps.entityName} (${ps.activeCount} active)`, + children: particleChildren, + expanded: false, + data: ps, + drawCalls: 1, + vertices: ps.activeCount * 4 + }); + }); + + // UI 元素 | UI elements + if (snap.uiElements && snap.uiElements.length > 0) { + const uiChildren: RenderEvent[] = snap.uiElements.map((ui) => ({ + id: eventId++, + type: 'ui' as RenderEventType, + name: `UI ${ui.type}: ${ui.entityName}`, + data: ui, + drawCalls: 1, + vertices: 4 + })); + + newEvents.push({ + id: eventId++, + type: 'batch', + name: `UIBatch (${snap.uiElements.length} elements)`, + children: uiChildren, + expanded: true, + drawCalls: snap.uiElements.length, + vertices: snap.uiElements.length * 4 + }); + } + + newEvents.push({ + id: eventId++, + type: 'draw', + name: 'BlitFinalToBackBuffer', + drawCalls: 1, + vertices: 3 + }); + + return newEvents; + }, []); + + // 添加快照到历史 | Add snapshot to history + const addToHistory = useCallback((snap: RenderDebugSnapshot) => { + setFrameHistory(prev => { + const newHistory = [...prev, snap]; + if (newHistory.length > MAX_HISTORY_FRAMES) { + newHistory.shift(); + } + return newHistory; + }); + }, []); + + // 跳转到指定帧 | Go to specific frame + const goToFrame = useCallback((index: number) => { + if (index < 0 || index >= frameHistory.length) return; + + setHistoryIndex(index); + const snap = frameHistory[index]; + if (snap) { + setSnapshot(snap); + setEvents(buildEventsFromSnapshot(snap)); + setSelectedEvent(null); + } + }, [frameHistory, buildEventsFromSnapshot]); + + // 返回实时模式 | Return to live mode + const goLive = useCallback(() => { + setHistoryIndex(-1); + setIsPaused(false); + }, []); + + // 刷新数据 | Refresh data + const refreshData = useCallback(() => { + // 独立窗口模式下不直接收集,等待主窗口广播 | In standalone mode, wait for broadcast from main window + if (standalone) return; + // 如果在历史回放模式,不刷新 | Don't refresh if in history playback mode + if (historyIndex >= 0) return; + + renderDebugService.setEnabled(true); + const snap = renderDebugService.collectSnapshot(); + + if (snap) { + setSnapshot(snap); + addToHistory(snap); + setEvents(buildEventsFromSnapshot(snap)); + + // 广播给独立窗口 | Broadcast to standalone windows + emit('render-debug-snapshot', snap).catch(() => {}); + } + }, [standalone, historyIndex, addToHistory, buildEventsFromSnapshot]); + + // 处理接收到的快照数据 | Process received snapshot data + const processSnapshot = useCallback((snap: RenderDebugSnapshot) => { + // 如果在历史回放模式,不处理新数据 | Don't process new data if in history playback mode + if (historyIndex >= 0) return; + + setSnapshot(snap); + addToHistory(snap); + setEvents(buildEventsFromSnapshot(snap)); + }, [historyIndex, addToHistory, buildEventsFromSnapshot]); + + // 独立窗口模式:监听主窗口广播 | Standalone mode: listen to main window broadcast + useEffect(() => { + if (!standalone || !visible) return; + + console.log('[FrameDebugger-Standalone] Setting up listener for render-debug-snapshot'); + + let unlisten: UnlistenFn | null = null; + + listen('render-debug-snapshot', (event) => { + console.log('[FrameDebugger-Standalone] Received snapshot:', event.payload?.frameNumber); + if (!isPaused) { + processSnapshot(event.payload); + } + }).then(fn => { + unlisten = fn; + console.log('[FrameDebugger-Standalone] Listener registered successfully'); + }); + + // 通知主窗口开始收集 | Notify main window to start collecting + console.log('[FrameDebugger-Standalone] Sending render-debug-request-data to main window...'); + emitTo('main', 'render-debug-request-data', {}).then(() => { + console.log('[FrameDebugger-Standalone] Request sent to main window successfully'); + }).catch((err) => { + console.error('[FrameDebugger-Standalone] Failed to send request:', err); + }); + + return () => { + unlisten?.(); + }; + }, [standalone, visible, isPaused, processSnapshot]); + + // 自动刷新(仅主窗口模式且面板可见)| Auto refresh (main window mode only, when panel visible) + useEffect(() => { + if (visible && !isPaused && !standalone) { + refreshData(); + const interval = setInterval(refreshData, 500); + return () => clearInterval(interval); + } + }, [visible, isPaused, standalone, refreshData]); + + // 拖动处理 | Drag handling + const handleMouseDown = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('.window-header')) { + setIsDragging(true); + setDragOffset({ + x: e.clientX - position.x, + y: e.clientY - position.y + }); + } + }, [position]); + + const handleResizeMouseDown = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setIsResizing(true); + setDragOffset({ + x: e.clientX, + y: e.clientY + }); + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDragging) { + setPosition({ + x: Math.max(0, e.clientX - dragOffset.x), + y: Math.max(0, e.clientY - dragOffset.y) + }); + } else if (isResizing) { + const dx = e.clientX - dragOffset.x; + const dy = e.clientY - dragOffset.y; + setSize(prev => ({ + width: Math.max(400, prev.width + dx), + height: Math.max(300, prev.height + dy) + })); + setDragOffset({ x: e.clientX, y: e.clientY }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + setIsResizing(false); + }; + + if (isDragging || isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, isResizing, dragOffset]); + + // 绘制预览 | Draw preview + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * window.devicePixelRatio; + canvas.height = rect.height * window.devicePixelRatio; + ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + + // 背景 | Background + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, rect.width, rect.height); + + if (!selectedEvent) { + ctx.fillStyle = '#666'; + ctx.font = '12px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText('Select a render event to preview', rect.width / 2, rect.height / 2); + return; + } + + const data = selectedEvent.data; + const margin = 20; + const viewWidth = rect.width - margin * 2; + const viewHeight = rect.height - margin * 2; + + // ParticleSystem:显示粒子空间分布 | ParticleSystem: show particle spatial distribution + if (selectedEvent.type === 'particle' && data?.sampleParticles?.length > 0) { + const particles = data.sampleParticles; + + // 计算边界 | Calculate bounds + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + particles.forEach((p: any) => { + minX = Math.min(minX, p.x); + maxX = Math.max(maxX, p.x); + minY = Math.min(minY, p.y); + maxY = Math.max(maxY, p.y); + }); + + // 添加边距 | Add padding + const padding = 50; + const rangeX = Math.max(maxX - minX, 100) + padding * 2; + const rangeY = Math.max(maxY - minY, 100) + padding * 2; + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + + const scale = Math.min(viewWidth / rangeX, viewHeight / rangeY); + + // 绘制坐标轴 | Draw axes + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + const originX = margin + viewWidth / 2 - centerX * scale; + const originY = margin + viewHeight / 2 + centerY * scale; + + // X 轴 | X axis + ctx.beginPath(); + ctx.moveTo(margin, originY); + ctx.lineTo(margin + viewWidth, originY); + ctx.stroke(); + // Y 轴 | Y axis + ctx.beginPath(); + ctx.moveTo(originX, margin); + ctx.lineTo(originX, margin + viewHeight); + ctx.stroke(); + + // 绘制粒子 | Draw particles + const frameColors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'] as const; + particles.forEach((p: any, idx: number) => { + const px = margin + viewWidth / 2 + (p.x - centerX) * scale; + const py = margin + viewHeight / 2 - (p.y - centerY) * scale; + const size = Math.max(4, Math.min(20, (p.size ?? 10) * scale * 0.1)); + const color = frameColors[idx % frameColors.length] ?? '#4a9eff'; + const alpha = p.alpha ?? 1; + + ctx.globalAlpha = alpha; + ctx.fillStyle = color; + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fill(); + + // 标注帧号 | Label frame number + ctx.globalAlpha = 1; + ctx.fillStyle = '#fff'; + ctx.font = '9px Consolas'; + ctx.textAlign = 'center'; + ctx.fillText(`f${p.frame}`, px, py - size - 3); + }); + + ctx.globalAlpha = 1; + + // 显示信息 | Show info + ctx.fillStyle = '#666'; + ctx.font = '10px system-ui'; + ctx.textAlign = 'left'; + ctx.fillText(`${particles.length} particles sampled`, margin, rect.height - 6); + + } else if (data?.uv) { + // Sprite 或单个粒子:显示 UV 区域 | Sprite or single particle: show UV region + const uv = data.uv; + const previewSize = Math.min(viewWidth, viewHeight); + const offsetX = (rect.width - previewSize) / 2; + const offsetY = (rect.height - previewSize) / 2; + + // 绘制纹理边框 | Draw texture border + ctx.strokeStyle = '#333'; + ctx.lineWidth = 1; + ctx.strokeRect(offsetX, offsetY, previewSize, previewSize); + + // 如果是粒子帧,显示 TextureSheet 网格 | If particle frame, show TextureSheet grid + const tilesX = data._animTilesX ?? (data.systemName ? 1 : 1); + const tilesY = data._animTilesY ?? 1; + + if (tilesX > 1 || tilesY > 1) { + const cellWidth = previewSize / tilesX; + const cellHeight = previewSize / tilesY; + + // 绘制网格 | Draw grid + ctx.strokeStyle = '#2a2a2a'; + for (let i = 0; i <= tilesX; i++) { + ctx.beginPath(); + ctx.moveTo(offsetX + i * cellWidth, offsetY); + ctx.lineTo(offsetX + i * cellWidth, offsetY + previewSize); + ctx.stroke(); + } + for (let j = 0; j <= tilesY; j++) { + ctx.beginPath(); + ctx.moveTo(offsetX, offsetY + j * cellHeight); + ctx.lineTo(offsetX + previewSize, offsetY + j * cellHeight); + ctx.stroke(); + } + } + + // 高亮 UV 区域 | Highlight UV region + const x = offsetX + uv[0] * previewSize; + const y = offsetY + uv[1] * previewSize; + const w = (uv[2] - uv[0]) * previewSize; + const h = (uv[3] - uv[1]) * previewSize; + + ctx.fillStyle = 'rgba(74, 158, 255, 0.3)'; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = '#4a9eff'; + ctx.lineWidth = 2; + ctx.strokeRect(x, y, w, h); + + // 显示 UV 坐标 | Show UV coordinates + ctx.fillStyle = '#4a9eff'; + ctx.font = '10px Consolas, monospace'; + ctx.textAlign = 'left'; + ctx.fillText(`UV: [${uv.map((v: number) => v.toFixed(3)).join(', ')}]`, offsetX, offsetY + previewSize + 14); + + if (data.frame !== undefined) { + ctx.fillText(`Frame: ${data.frame}`, offsetX, offsetY + previewSize + 26); + } + } else { + // 其他事件类型 | Other event types + ctx.fillStyle = '#555'; + ctx.font = '11px system-ui'; + ctx.textAlign = 'center'; + ctx.fillText(selectedEvent.name, rect.width / 2, rect.height / 2 - 10); + ctx.fillStyle = '#444'; + ctx.font = '10px system-ui'; + ctx.fillText('No visual data available', rect.width / 2, rect.height / 2 + 10); + } + }, [selectedEvent]); + + // 切换展开/折叠 | Toggle expand/collapse + const toggleExpand = (event: RenderEvent) => { + setEvents(prev => prev.map(e => { + if (e.id === event.id) { + return { ...e, expanded: !e.expanded }; + } + return e; + })); + }; + + // 导出数据 | Export data + const handleExport = () => { + const json = renderDebugService.exportAsJSON(); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `render-debug-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + if (!visible) return null; + + // 独立窗口模式的样式 | Standalone mode styles + const windowStyle = standalone + ? { left: 0, top: 0, width: '100%', height: '100%', borderRadius: 0 } + : { left: position.x, top: position.y, width: size.width, height: size.height }; + + return ( +
+ {/* 头部(可拖动)| Header (draggable) */} +
+
+ + Frame Debugger + {isPaused && ( + PAUSED + )} +
+
+ {!standalone && ( + + )} + +
+
+ + {/* 工具栏 | Toolbar */} +
+
+ + {historyIndex >= 0 && ( + HISTORY + )} +
+ + + + {historyIndex >= 0 + ? `${historyIndex + 1} / ${frameHistory.length}` + : `Frame ${snapshot?.frameNumber ?? 0}`} + + + +
+
+ + +
+
+ + {/* 时间线 | Timeline */} + {frameHistory.length > 0 && ( +
+ = 0 ? historyIndex : frameHistory.length - 1} + onChange={(e) => { + const idx = parseInt(e.target.value); + setIsPaused(true); + goToFrame(idx); + }} + className="timeline-slider" + /> +
+ {frameHistory.length} frames captured + {historyIndex >= 0 && snapshot && ( + Frame #{snapshot.frameNumber} + )} +
+
+ )} + + {/* 主内容区 | Main content */} +
+ {/* 左侧事件列表 | Left: Event list */} +
+
+ Render Events + {events.reduce((sum, e) => sum + (e.drawCalls || 0), 0)} draw calls +
+
+ {events.length === 0 ? ( +
+ No render events captured. +
+ Start preview mode to see events. +
+ ) : ( + events.map(event => ( + + )) + )} +
+
+ + {/* 右侧内容 | Right: Content */} +
+ {/* 预览区 | Preview */} +
+
+ Output +
+
+ +
+
+ + {/* 详情区 | Details */} +
+
+ Details +
+
+ {selectedEvent ? ( + + ) : ( +
+ Select a render event to see details +
+ )} +
+
+
+
+ + {/* 统计栏 | Stats bar */} +
+
+ + Draw Calls: {events.reduce((sum, e) => sum + (e.drawCalls || 0), 0)} +
+
+ + Sprites: {snapshot?.sprites?.length ?? 0} +
+
+ + Particles: {snapshot?.particles?.reduce((sum, p) => sum + p.activeCount, 0) ?? 0} +
+
+ + UI: {snapshot?.uiElements?.length ?? 0} +
+
+ + Systems: {snapshot?.particles?.length ?? 0} +
+
+ + {/* 调整大小手柄(独立模式下隐藏)| Resize handle (hidden in standalone mode) */} + {!standalone &&
} +
+ ); +}; + +// ========== 子组件 | Sub-components ========== + +interface EventItemProps { + event: RenderEvent; + depth: number; + selected: boolean; + onSelect: (event: RenderEvent) => void; + onToggle: (event: RenderEvent) => void; +} + +const EventItem: React.FC = ({ event, depth, selected, onSelect, onToggle }) => { + const hasChildren = event.children && event.children.length > 0; + const iconSize = 12; + + const getTypeIcon = () => { + switch (event.type) { + case 'sprite': return ; + case 'particle': return ; + case 'ui': return ; + case 'batch': return ; + default: return ; + } + }; + + return ( + <> +
onSelect(event)} + > + {hasChildren ? ( + { e.stopPropagation(); onToggle(event); }}> + {event.expanded ? : } + + ) : ( + + )} + {getTypeIcon()} + {event.name} + {event.drawCalls !== undefined && ( + {event.drawCalls} + )} +
+ {hasChildren && event.expanded && event.children!.map(child => ( + + ))} + + ); +}; + +/** + * 纹理预览组件 + * Texture Preview Component + */ +const TexturePreview: React.FC<{ + textureUrl?: string; + texturePath?: string; + label?: string; +}> = ({ textureUrl, texturePath, label = 'Texture' }) => { + return ( +
+ {label} +
+ {textureUrl ? ( +
+ Texture + {texturePath || '-'} +
+ ) : ( + {texturePath || '-'} + )} +
+
+ ); +}; + +interface EventDetailsProps { + event: RenderEvent; +} + +const EventDetails: React.FC = ({ event }) => { + const data = event.data; + const canvasRef = useRef(null); + + // 绘制 TextureSheet 网格 | Draw TextureSheet grid + useEffect(() => { + if (event.type !== 'particle' || !data?.textureSheetAnimation) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const rect = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + ctx.scale(dpr, dpr); + + const tsAnim = data.textureSheetAnimation; + const tilesX = tsAnim.tilesX; + const tilesY = tsAnim.tilesY; + const totalFrames = tsAnim.totalFrames; + + const size = Math.min(rect.width, rect.height); + const offsetX = (rect.width - size) / 2; + const offsetY = (rect.height - size) / 2; + const cellWidth = size / tilesX; + const cellHeight = size / tilesY; + + // 背景 | Background + ctx.fillStyle = '#1a1a1a'; + ctx.fillRect(0, 0, rect.width, rect.height); + + // 绘制网格 | Draw grid + ctx.strokeStyle = '#3a3a3a'; + ctx.lineWidth = 1; + for (let i = 0; i <= tilesX; i++) { + ctx.beginPath(); + ctx.moveTo(offsetX + i * cellWidth, offsetY); + ctx.lineTo(offsetX + i * cellWidth, offsetY + size); + ctx.stroke(); + } + for (let j = 0; j <= tilesY; j++) { + ctx.beginPath(); + ctx.moveTo(offsetX, offsetY + j * cellHeight); + ctx.lineTo(offsetX + size, offsetY + j * cellHeight); + ctx.stroke(); + } + + // 绘制帧编号 | Draw frame numbers + ctx.fillStyle = '#555'; + ctx.font = `${Math.max(8, Math.min(12, cellWidth / 3))}px Consolas`; + ctx.textAlign = 'center'; + for (let frame = 0; frame < totalFrames; frame++) { + const col = frame % tilesX; + const row = Math.floor(frame / tilesX); + ctx.fillText(frame.toString(), offsetX + col * cellWidth + cellWidth / 2, offsetY + row * cellHeight + cellHeight / 2 + 4); + } + + // 高亮活跃帧 | Highlight active frames + const sampleParticles = data.sampleParticles ?? []; + const frameColors = ['#4a9eff', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'] as const; + const usedFrames = new Map(); + sampleParticles.forEach((p: any, idx: number) => { + if (!usedFrames.has(p.frame)) { + usedFrames.set(p.frame, frameColors[idx % frameColors.length] ?? '#4a9eff'); + } + }); + + usedFrames.forEach((color, frame) => { + const col = frame % tilesX; + const row = Math.floor(frame / tilesX); + const x = offsetX + col * cellWidth; + const y = offsetY + row * cellHeight; + + ctx.fillStyle = `${color}40`; + ctx.fillRect(x + 1, y + 1, cellWidth - 2, cellHeight - 2); + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.strokeRect(x + 1, y + 1, cellWidth - 2, cellHeight - 2); + }); + }, [event, data]); + + return ( +
+ + + + + + {data && ( + <> +
Properties
+ + {/* Sprite 数据 | Sprite data */} + {event.type === 'sprite' && data.entityName && ( + <> + + + + + v.toFixed(3)).join(', ')}]` : '-'} highlight /> + + + + + + )} + + {/* 粒子系统数据 | Particle system data */} + {event.type === 'particle' && data.activeCount !== undefined && ( + <> + {data.entityName && } + + + + {data.textureSheetAnimation && ( + <> +
Texture Sheet
+ + + {data.sampleParticles?.length > 0 && ( + (data.sampleParticles.map((p: any) => p.frame))).sort((a, b) => a - b).join(', ')} + highlight + /> + )} + {/* TextureSheet 网格预览 | TextureSheet grid preview */} +
+ +
+ + )} + + )} + + {/* 单个粒子数据 | Single particle data */} + {event.type === 'particle' && data.frame !== undefined && data.activeCount === undefined && ( + <> + {data.systemName && } + + v.toFixed(3)).join(', ')}]` : '-'} /> + + + + + + )} + + {/* UI 元素数据 | UI element data */} + {event.type === 'ui' && data.entityName && ( + <> + + + + + + + + + + + {data.backgroundColor && ( + + )} + {data.textureGuid && ( + + )} + {data.text && ( + <> +
Text
+ 30 ? data.text.slice(0, 30) + '...' : data.text} /> + {data.fontSize && } + + )} + + )} + + )} +
+ ); +}; + +const DetailRow: React.FC<{ label: string; value: string; highlight?: boolean }> = ({ label, value, highlight }) => ( +
+ {label} + {value} +
+); + +export default RenderDebugPanel; diff --git a/packages/editor-app/src/components/debug/index.ts b/packages/editor-app/src/components/debug/index.ts new file mode 100644 index 00000000..308fa792 --- /dev/null +++ b/packages/editor-app/src/components/debug/index.ts @@ -0,0 +1,7 @@ +/** + * 调试组件导出 + * Debug components export + */ + +export { RenderDebugPanel } from './RenderDebugPanel'; +export type { default as RenderDebugPanelProps } from './RenderDebugPanel'; diff --git a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx index 66bdbbb5..46f905f7 100644 --- a/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx +++ b/packages/editor-app/src/components/inspectors/views/AssetFileInspector.tsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useCallback } from 'react'; -import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2 } from 'lucide-react'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { Folder, File as FileIcon, Image as ImageIcon, Clock, HardDrive, Settings2, Grid3X3 } from 'lucide-react'; import { convertFileSrc } from '@tauri-apps/api/core'; import { Core } from '@esengine/ecs-framework'; import { AssetRegistryService } from '@esengine/editor-core'; +import type { ISpriteSettings } from '@esengine/asset-system-editor'; import { EngineService } from '../../../services/EngineService'; import { AssetFileInfo } from '../types'; import { ImagePreview, CodePreview, getLanguageFromExtension } from '../common'; @@ -50,6 +51,165 @@ function formatDate(timestamp?: number): string { }); } +/** + * Sprite Settings Editor Component + * 精灵设置编辑器组件 + * + * Allows editing nine-patch slice borders for texture assets. + * 允许编辑纹理资源的九宫格切片边框。 + */ +interface SpriteSettingsEditorProps { + filePath: string; + imageSrc: string; + initialSettings?: ISpriteSettings; + onSettingsChange: (settings: ISpriteSettings) => void; +} + +function SpriteSettingsEditor({ filePath, imageSrc, initialSettings, onSettingsChange }: SpriteSettingsEditorProps) { + const [sliceBorder, setSliceBorder] = useState<[number, number, number, number]>( + initialSettings?.sliceBorder || [0, 0, 0, 0] + ); + const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null); + const canvasRef = useRef(null); + + // Sync sliceBorder state when initialSettings changes (async load) + // 当 initialSettings 变化时同步 sliceBorder 状态(异步加载) + useEffect(() => { + if (initialSettings?.sliceBorder) { + setSliceBorder(initialSettings.sliceBorder); + } + }, [initialSettings?.sliceBorder]); + + // Load image to get dimensions + // 加载图像以获取尺寸 + useEffect(() => { + const img = new Image(); + img.onload = () => { + setImageSize({ width: img.width, height: img.height }); + }; + img.src = imageSrc; + }, [imageSrc]); + + // Draw slice preview + // 绘制切片预览 + useEffect(() => { + if (!canvasRef.current || !imageSize) return; + + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const img = new Image(); + img.onload = () => { + // Calculate scale to fit canvas + // 计算缩放以适应画布 + const maxSize = 200; + const scale = Math.min(maxSize / img.width, maxSize / img.height, 1); + const displayWidth = img.width * scale; + const displayHeight = img.height * scale; + + canvas.width = displayWidth; + canvas.height = displayHeight; + + // Draw image + // 绘制图像 + ctx.drawImage(img, 0, 0, displayWidth, displayHeight); + + // Draw slice lines + // 绘制切片线 + const [top, right, bottom, left] = sliceBorder; + + ctx.strokeStyle = '#00ff00'; + ctx.lineWidth = 1; + ctx.setLineDash([4, 4]); + + // Top line + if (top > 0) { + ctx.beginPath(); + ctx.moveTo(0, top * scale); + ctx.lineTo(displayWidth, top * scale); + ctx.stroke(); + } + + // Bottom line + if (bottom > 0) { + ctx.beginPath(); + ctx.moveTo(0, displayHeight - bottom * scale); + ctx.lineTo(displayWidth, displayHeight - bottom * scale); + ctx.stroke(); + } + + // Left line + if (left > 0) { + ctx.beginPath(); + ctx.moveTo(left * scale, 0); + ctx.lineTo(left * scale, displayHeight); + ctx.stroke(); + } + + // Right line + if (right > 0) { + ctx.beginPath(); + ctx.moveTo(displayWidth - right * scale, 0); + ctx.lineTo(displayWidth - right * scale, displayHeight); + ctx.stroke(); + } + }; + img.src = imageSrc; + }, [imageSrc, imageSize, sliceBorder]); + + const handleSliceChange = (index: number, value: number) => { + const newSlice = [...sliceBorder] as [number, number, number, number]; + newSlice[index] = Math.max(0, value); + setSliceBorder(newSlice); + onSettingsChange({ ...initialSettings, sliceBorder: newSlice }); + }; + + const labels = ['Top', 'Right', 'Bottom', 'Left']; + const labelsCN = ['上', '右', '下', '左']; + + return ( +
+ {/* Slice Preview Canvas */} +
+ + {imageSize && ( +
+ {imageSize.width} × {imageSize.height} px +
+ )} +
+ + {/* Slice Border Inputs */} +
+ {sliceBorder.map((value, index) => ( +
+ + handleSliceChange(index, parseInt(e.target.value) || 0)} + min={0} + max={imageSize ? (index % 2 === 0 ? imageSize.height : imageSize.width) : 9999} + className="property-input property-input-number" + style={{ width: '60px' }} + /> +
+ ))} +
+
+ ); +} + export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInspectorProps) { const IconComponent = fileInfo.isDirectory ? Folder : isImage ? ImageIcon : FileIcon; const iconColor = fileInfo.isDirectory ? '#dcb67a' : isImage ? '#a78bfa' : '#90caf9'; @@ -60,6 +220,10 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp const [detectedType, setDetectedType] = useState(null); const [isUpdating, setIsUpdating] = useState(false); + // State for sprite settings (nine-patch borders) + // 精灵设置状态(九宫格边框) + const [spriteSettings, setSpriteSettings] = useState(undefined); + // Load meta info and available loader types useEffect(() => { if (fileInfo.isDirectory) return; @@ -76,6 +240,14 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp setCurrentLoaderType(meta.loaderType || null); setDetectedType(meta.type); + // Get sprite settings from meta (for texture assets) + // 从 meta 获取精灵设置(用于纹理资源) + if (meta.importSettings?.spriteSettings) { + setSpriteSettings(meta.importSettings.spriteSettings as ISpriteSettings); + } else { + setSpriteSettings(undefined); + } + // Get available loader types from assetManager const assetManager = EngineService.getInstance().getAssetManager(); const loaderFactory = assetManager?.getLoaderFactory(); @@ -117,6 +289,39 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp } }, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]); + // Handle sprite settings change + // 处理精灵设置更改 + const handleSpriteSettingsChange = useCallback(async (newSettings: ISpriteSettings) => { + if (fileInfo.isDirectory || isUpdating) return; + + setIsUpdating(true); + try { + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + if (!assetRegistry?.isReady) return; + + const metaManager = assetRegistry.metaManager; + const meta = await metaManager.getOrCreateMeta(fileInfo.path); + + // Update meta with new sprite settings + // 使用新的精灵设置更新 meta + const updatedImportSettings = { + ...meta.importSettings, + spriteSettings: newSettings + }; + + await metaManager.updateMeta(fileInfo.path, { + importSettings: updatedImportSettings + }); + + setSpriteSettings(newSettings); + console.log(`[AssetFileInspector] Updated sprite settings for ${fileInfo.name}:`, newSettings); + } catch (error) { + console.error('Failed to update sprite settings:', error); + } finally { + setIsUpdating(false); + } + }, [fileInfo.path, fileInfo.name, fileInfo.isDirectory, isUpdating]); + return (
@@ -228,6 +433,23 @@ export function AssetFileInspector({ fileInfo, content, isImage }: AssetFileInsp
)} + {/* Sprite Settings Section - only for image files */} + {/* 精灵设置部分 - 仅用于图像文件 */} + {isImage && ( +
+
+ + 九宫格设置 (Nine-Patch) +
+ +
+ )} + {content && (
文件预览
diff --git a/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx b/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx index 05e229ec..357a791c 100644 --- a/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx +++ b/packages/editor-app/src/infrastructure/field-editors/VectorFieldEditors.tsx @@ -141,7 +141,27 @@ export class Vector4FieldEditor implements IFieldEditor { } render({ label, value, onChange, context }: FieldEditorProps): React.ReactElement { - const v = value || { x: 0, y: 0, z: 0, w: 0 }; + // Support both object {x,y,z,w} and array [0,1,2,3] formats + // 支持对象 {x,y,z,w} 和数组 [0,1,2,3] 两种格式 + let v: Vector4; + const isArray = Array.isArray(value); + + if (isArray) { + const arr = value as unknown as number[]; + v = { x: arr[0] ?? 0, y: arr[1] ?? 0, z: arr[2] ?? 0, w: arr[3] ?? 0 }; + } else { + v = value || { x: 0, y: 0, z: 0, w: 0 }; + } + + const handleChange = (newV: Vector4) => { + if (isArray) { + // Return as array if input was array + // 如果输入是数组,则返回数组 + onChange([newV.x, newV.y, newV.z, newV.w] as unknown as Vector4); + } else { + onChange(newV); + } + }; return (
@@ -150,28 +170,28 @@ export class Vector4FieldEditor implements IFieldEditor { onChange({ ...v, x })} + onChange={(x) => handleChange({ ...v, x })} readonly={context.readonly} axis="x" /> onChange({ ...v, y })} + onChange={(y) => handleChange({ ...v, y })} readonly={context.readonly} axis="y" /> onChange({ ...v, z })} + onChange={(z) => handleChange({ ...v, z })} readonly={context.readonly} axis="z" /> onChange({ ...v, w })} + onChange={(w) => handleChange({ ...v, w })} readonly={context.readonly} axis="w" /> diff --git a/packages/editor-app/src/locales/en.ts b/packages/editor-app/src/locales/en.ts index 9d968bcd..ced279ae 100644 --- a/packages/editor-app/src/locales/en.ts +++ b/packages/editor-app/src/locales/en.ts @@ -306,7 +306,15 @@ export const en: Translations = { openFailed: 'Failed to open scene', savedSuccess: 'Scene saved: {{name}}', saveFailed: 'Failed to save scene', - saveAsFailed: 'Failed to save scene as' + saveAsFailed: 'Failed to save scene as', + reloadedSuccess: 'Scene reloaded: {{name}}', + reloadFailed: 'Failed to reload scene', + externalChange: { + title: 'Scene Changed', + message: 'Scene "{{name}}" has been modified externally. Do you want to reload?', + reload: 'Reload', + ignore: 'Ignore' + } }, // ======================================== @@ -371,6 +379,15 @@ export const en: Translations = { dependencies: 'Dependencies' }, + // ======================================== + // User Code + // ======================================== + usercode: { + compileSuccess: 'Scripts compiled ({{count}} exports)', + compileError: 'Script compilation failed', + hotReloadSuccess: 'Scripts hot reloaded' + }, + // ======================================== // Loading // ======================================== @@ -432,7 +449,8 @@ export const en: Translations = { portManager: 'Port Manager', settings: 'Settings', devtools: 'Developer Tools', - build: 'Build Settings' + build: 'Build Settings', + renderDebug: 'Render Debug' }, help: { title: 'Help', diff --git a/packages/editor-app/src/locales/es.ts b/packages/editor-app/src/locales/es.ts index b8dd1694..1256f8ff 100644 --- a/packages/editor-app/src/locales/es.ts +++ b/packages/editor-app/src/locales/es.ts @@ -381,7 +381,8 @@ export const es: Translations = { portManager: 'Administrador de Puertos', settings: 'Configuración', devtools: 'Herramientas de Desarrollo', - build: 'Configuración de Compilación' + build: 'Configuración de Compilación', + renderDebug: 'Depuración de Renderizado' }, help: { title: 'Ayuda', diff --git a/packages/editor-app/src/locales/zh.ts b/packages/editor-app/src/locales/zh.ts index a6f5d822..10180d6f 100644 --- a/packages/editor-app/src/locales/zh.ts +++ b/packages/editor-app/src/locales/zh.ts @@ -306,7 +306,15 @@ export const zh: Translations = { openFailed: '打开场景失败', savedSuccess: '场景已保存: {{name}}', saveFailed: '保存场景失败', - saveAsFailed: '另存场景失败' + saveAsFailed: '另存场景失败', + reloadedSuccess: '场景已重新加载: {{name}}', + reloadFailed: '重新加载场景失败', + externalChange: { + title: '场景已更改', + message: '场景 "{{name}}" 已被外部修改。是否重新加载?', + reload: '重新加载', + ignore: '忽略' + } }, // ======================================== @@ -371,6 +379,15 @@ export const zh: Translations = { dependencies: '依赖' }, + // ======================================== + // User Code + // ======================================== + usercode: { + compileSuccess: '脚本编译成功 ({{count}} 个导出)', + compileError: '脚本编译失败', + hotReloadSuccess: '脚本热更新成功' + }, + // ======================================== // Loading // ======================================== @@ -432,7 +449,8 @@ export const zh: Translations = { portManager: '端口管理器', settings: '设置', devtools: '开发者工具', - build: '构建设置' + build: '构建设置', + renderDebug: '渲染调试' }, help: { title: '帮助', diff --git a/packages/editor-app/src/services/EditorEngineSync.ts b/packages/editor-app/src/services/EditorEngineSync.ts index 1028a323..a8861d23 100644 --- a/packages/editor-app/src/services/EditorEngineSync.ts +++ b/packages/editor-app/src/services/EditorEngineSync.ts @@ -278,12 +278,20 @@ export class EditorEngineSync { * Update sprite in engine entity. * 更新引擎实体的精灵。 * - * Note: Texture loading is now handled automatically by EngineRenderSystem. - * 注意:纹理加载现在由EngineRenderSystem自动处理。 + * Preloads textures when textureGuid changes to ensure they're available for rendering. + * 当 textureGuid 变更时预加载纹理以确保渲染时可用。 */ private updateSprite(entity: Entity, sprite: SpriteComponent, property: string, value: any): void { - // No manual texture loading needed - EngineRenderSystem handles it - // 不需要手动加载纹理 - EngineRenderSystem会处理 + // When textureGuid changes, trigger texture preload + // 当 textureGuid 变更时,触发纹理预加载 + if (property === 'textureGuid' && value) { + const bridge = this.engineService.getBridge(); + if (bridge) { + // Preload the texture so it's ready for the next render frame + // 预加载纹理以便下一渲染帧时可用 + bridge.getOrLoadTextureByPath(value); + } + } } /** diff --git a/packages/editor-app/src/services/EngineService.ts b/packages/editor-app/src/services/EngineService.ts index 2c13ef94..ed17b45d 100644 --- a/packages/editor-app/src/services/EngineService.ts +++ b/packages/editor-app/src/services/EngineService.ts @@ -11,6 +11,7 @@ import { Core, Scene, Entity, SceneSerializer, ProfilerSDK, createLogger, Plugin import { CameraConfig, EngineBridgeToken, RenderSystemToken, EngineIntegrationToken } from '@esengine/ecs-engine-bindgen'; import { TransformComponent, TransformTypeToken, CanvasElementToken } from '@esengine/engine-core'; import { SpriteComponent, SpriteAnimatorComponent, SpriteAnimatorSystemToken } from '@esengine/sprite'; +import { ParticleSystemComponent } from '@esengine/particle'; import { invalidateUIRenderCaches, UIRenderProviderToken, UIInputSystemToken } from '@esengine/ui'; import * as esEngine from '@esengine/engine'; import { @@ -462,6 +463,43 @@ export class EngineService { if (this._runtime?.bridge) { this._engineIntegration = new EngineIntegration(this._assetManager, this._runtime.bridge); + // 为 EngineIntegration 设置使用 Tauri URL 转换的 PathResolver + // Set PathResolver for EngineIntegration that uses Tauri URL conversion + this._engineIntegration.setPathResolver({ + catalogToRuntime: (catalogPath: string): string => { + // 空路径直接返回 + if (!catalogPath) return catalogPath; + + // 已经是 URL 则直接返回 + if (catalogPath.startsWith('http://') || + catalogPath.startsWith('https://') || + catalogPath.startsWith('data:') || + catalogPath.startsWith('asset://')) { + return catalogPath; + } + + // 使用 pathTransformerFn 转换路径为 Tauri URL + // 路径应该是相对于项目目录的,如 'assets/sparkle_yellow.png' + let fullPath = catalogPath; + // 如果路径不以 'assets/' 开头,添加前缀 + if (!catalogPath.startsWith('assets/') && !catalogPath.startsWith('assets\\')) { + fullPath = `assets/${catalogPath}`; + } + return pathTransformerFn(fullPath); + }, + editorToCatalog: (editorPath: string, projectRoot: string): string => { + return editorPath; // 不需要在此上下文中使用 + }, + setBaseUrl: () => {}, + getBaseUrl: () => '', + normalize: (path: string) => path.replace(/\\/g, '/'), + isAbsoluteUrl: (path: string) => + path.startsWith('http://') || + path.startsWith('https://') || + path.startsWith('data:') || + path.startsWith('asset://') + }); + this._sceneResourceManager = new SceneResourceManager(); this._sceneResourceManager.setResourceLoader(this._engineIntegration); @@ -712,10 +750,15 @@ export class EngineService { return convertFileSrc(absolutePath); } return relativePath; + } else { + // GUID not found in registry - this could be a timing issue where asset + // was just added but not yet registered. Log for debugging. + // GUID 在注册表中未找到 - 可能是资源刚添加但尚未注册的时序问题 + console.warn(`[AssetPathResolver] GUID not found in registry: ${guidOrPath}. Asset may not be registered yet.`); } } - // GUID not found, return original value - // 未找到 GUID,返回原值 + // GUID not found, return original value (will result in white block) + // 未找到 GUID,返回原值(会显示白块) return guidOrPath; } @@ -1029,6 +1072,19 @@ export class EngineService { // 清除 UI 渲染缓存 invalidateUIRenderCaches(); + // Reset particle component textureIds before loading resources + // 在加载资源前重置粒子组件的 textureId + // This ensures ParticleUpdateSystem will reload textures + // 这确保 ParticleUpdateSystem 会重新加载纹理 + if (this._runtime.scene) { + for (const entity of this._runtime.scene.entities.buffer) { + const particleComponent = entity.getComponent(ParticleSystemComponent); + if (particleComponent) { + particleComponent.textureId = 0; + } + } + } + // 加载场景资源 if (this._sceneResourceManager && this._runtime.scene) { await this._sceneResourceManager.loadSceneResources(this._runtime.scene); @@ -1057,6 +1113,21 @@ export class EngineService { return success; } + /** + * Load scene resources (textures, audio, etc.) + * 加载场景资源(纹理、音频等) + * + * Used by runtime scene switching in play mode. + * 用于 Play 模式下的运行时场景切换。 + */ + async loadSceneResources(): Promise { + const scene = this._runtime?.scene; + if (!this._sceneResourceManager || !scene) { + return; + } + await this._sceneResourceManager.loadSceneResources(scene); + } + /** * Check if a snapshot exists. */ diff --git a/packages/editor-app/src/services/RenderDebugService.ts b/packages/editor-app/src/services/RenderDebugService.ts new file mode 100644 index 00000000..04a95f30 --- /dev/null +++ b/packages/editor-app/src/services/RenderDebugService.ts @@ -0,0 +1,591 @@ +/** + * 渲染调试服务 + * Render Debug Service + * + * 从引擎收集渲染调试数据 + * Collects render debug data from the engine + */ + +import { Core, Entity } from '@esengine/ecs-framework'; +import { TransformComponent } from '@esengine/engine-core'; +import { SpriteComponent } from '@esengine/sprite'; +import { ParticleSystemComponent } from '@esengine/particle'; +import { UITransformComponent, UIRenderComponent, UITextComponent } from '@esengine/ui'; +import { AssetRegistryService, ProjectService } from '@esengine/editor-core'; +import { invoke } from '@tauri-apps/api/core'; + +/** + * 纹理调试信息 + * Texture debug info + */ +export interface TextureDebugInfo { + id: number; + path: string; + width: number; + height: number; + state: 'loading' | 'ready' | 'failed'; +} + +/** + * Sprite 调试信息 + * Sprite debug info + */ +export interface SpriteDebugInfo { + entityId: number; + entityName: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + textureId: number; + texturePath: string; + /** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */ + textureUrl?: string; + uv: [number, number, number, number]; + color: string; + alpha: number; + sortingLayer: string; + orderInLayer: number; +} + +/** + * 粒子调试信息 + * Particle debug info + */ +export interface ParticleDebugInfo { + entityId: number; + entityName: string; + systemName: string; + isPlaying: boolean; + activeCount: number; + maxParticles: number; + textureId: number; + texturePath: string; + /** 预解析的纹理 URL(可直接用于 img src)| Pre-resolved texture URL (can be used directly in img src) */ + textureUrl?: string; + textureSheetAnimation: { + enabled: boolean; + tilesX: number; + tilesY: number; + totalFrames: number; + } | null; + sampleParticles: Array<{ + index: number; + x: number; + y: number; + frame: number; + uv: [number, number, number, number]; + age: number; + lifetime: number; + size: number; + color: string; + alpha: number; + }>; +} + +/** + * UI 元素调试信息 + * UI element debug info + */ +export interface UIDebugInfo { + entityId: number; + entityName: string; + type: 'rect' | 'image' | 'text' | 'ninepatch' | 'circle' | 'rounded-rect' | 'unknown'; + x: number; + y: number; + width: number; + height: number; + worldX: number; + worldY: number; + rotation: number; + visible: boolean; + alpha: number; + sortingLayer: string; + orderInLayer: number; + textureGuid?: string; + textureUrl?: string; + backgroundColor?: string; + text?: string; + fontSize?: number; +} + +/** + * 渲染调试快照 + * Render debug snapshot + */ +export interface RenderDebugSnapshot { + timestamp: number; + frameNumber: number; + textures: TextureDebugInfo[]; + sprites: SpriteDebugInfo[]; + particles: ParticleDebugInfo[]; + uiElements: UIDebugInfo[]; + stats: { + totalSprites: number; + totalParticles: number; + totalUIElements: number; + totalTextures: number; + drawCalls: number; + }; +} + +/** + * 渲染调试服务 + * Render Debug Service + */ +export class RenderDebugService { + private static _instance: RenderDebugService | null = null; + private _frameNumber: number = 0; + private _enabled: boolean = false; + private _snapshots: RenderDebugSnapshot[] = []; + private _maxSnapshots: number = 60; + + // 引擎引用 | Engine reference + private _engineBridge: any = null; + + static getInstance(): RenderDebugService { + if (!RenderDebugService._instance) { + RenderDebugService._instance = new RenderDebugService(); + } + return RenderDebugService._instance; + } + + /** + * 设置引擎桥接 + * Set engine bridge + */ + setEngineBridge(bridge: any): void { + this._engineBridge = bridge; + } + + /** + * 启用/禁用调试 + * Enable/disable debugging + */ + setEnabled(enabled: boolean): void { + this._enabled = enabled; + if (!enabled) { + this._snapshots = []; + } + } + + get enabled(): boolean { + return this._enabled; + } + + // 纹理 base64 缓存 | Texture base64 cache + private _textureCache = new Map(); + private _texturePending = new Set(); + + /** + * 解析纹理 GUID 为 base64 data URL(从缓存获取) + * Resolve texture GUID to base64 data URL (from cache) + */ + private _resolveTextureUrl(textureGuid: string | null | undefined): string | undefined { + if (!textureGuid) return undefined; + + // 从缓存获取 | Get from cache + if (this._textureCache.has(textureGuid)) { + console.log('[RenderDebugService] Texture from cache:', textureGuid); + return this._textureCache.get(textureGuid); + } + + // 如果正在加载中,返回 undefined | If loading, return undefined + if (this._texturePending.has(textureGuid)) { + console.log('[RenderDebugService] Texture loading:', textureGuid); + return undefined; + } + + // 异步加载纹理 | Load texture asynchronously + console.log('[RenderDebugService] Starting texture load:', textureGuid); + this._loadTextureToCache(textureGuid); + return undefined; + } + + /** + * 异步加载纹理到缓存 + * Load texture to cache asynchronously + */ + private async _loadTextureToCache(textureGuid: string): Promise { + if (this._textureCache.has(textureGuid) || this._texturePending.has(textureGuid)) { + return; + } + + this._texturePending.add(textureGuid); + + try { + const assetRegistry = Core.services.tryResolve(AssetRegistryService) as AssetRegistryService | null; + const projectService = Core.services.tryResolve(ProjectService) as { getCurrentProject: () => { path: string } | null } | null; + + let resolvedPath: string | null = null; + + // 检查是否是 GUID 格式 | Check if GUID format + const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(textureGuid); + + if (isGuid && assetRegistry) { + resolvedPath = assetRegistry.getPathByGuid(textureGuid) || null; + } else { + resolvedPath = textureGuid; + } + + if (!resolvedPath) { + this._texturePending.delete(textureGuid); + return; + } + + // 检查是否是图片 | Check if image + const ext = resolvedPath.toLowerCase().split('.').pop() || ''; + const imageExts: Record = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'bmp': 'image/bmp' + }; + + const mimeType = imageExts[ext]; + if (!mimeType) { + this._texturePending.delete(textureGuid); + return; + } + + // 构建完整路径 | Build full path + const projectPath = projectService?.getCurrentProject()?.path; + const fullPath = resolvedPath.startsWith('/') || resolvedPath.includes(':') + ? resolvedPath + : projectPath + ? `${projectPath}/${resolvedPath}` + : resolvedPath; + + // 通过 Tauri command 读取文件并转为 base64 | Read file via Tauri command and convert to base64 + console.log('[RenderDebugService] Loading texture:', fullPath); + const base64 = await invoke('read_file_as_base64', { filePath: fullPath }); + const dataUrl = `data:${mimeType};base64,${base64}`; + + console.log('[RenderDebugService] Texture loaded, base64 length:', base64.length); + this._textureCache.set(textureGuid, dataUrl); + } catch (err) { + console.error('[RenderDebugService] Failed to load texture:', textureGuid, err); + } finally { + this._texturePending.delete(textureGuid); + } + } + + /** + * 收集当前帧的调试数据 + * Collect debug data for current frame + */ + collectSnapshot(): RenderDebugSnapshot | null { + if (!this._enabled) return null; + + const scene = Core.scene; + if (!scene) return null; + + this._frameNumber++; + + const snapshot: RenderDebugSnapshot = { + timestamp: Date.now(), + frameNumber: this._frameNumber, + textures: this._collectTextures(), + sprites: this._collectSprites(scene.entities.buffer), + particles: this._collectParticles(scene.entities.buffer), + uiElements: this._collectUI(scene.entities.buffer), + stats: { + totalSprites: 0, + totalParticles: 0, + totalUIElements: 0, + totalTextures: 0, + drawCalls: 0, + }, + }; + + // 计算统计 | Calculate stats + snapshot.stats.totalSprites = snapshot.sprites.length; + snapshot.stats.totalParticles = snapshot.particles.reduce((sum, p) => sum + p.activeCount, 0); + snapshot.stats.totalUIElements = snapshot.uiElements.length; + snapshot.stats.totalTextures = snapshot.textures.length; + + // 保存快照 | Save snapshot + this._snapshots.push(snapshot); + if (this._snapshots.length > this._maxSnapshots) { + this._snapshots.shift(); + } + + return snapshot; + } + + /** + * 获取最新快照 + * Get latest snapshot + */ + getLatestSnapshot(): RenderDebugSnapshot | null { + return this._snapshots.length > 0 ? this._snapshots[this._snapshots.length - 1] ?? null : null; + } + + /** + * 获取所有快照 + * Get all snapshots + */ + getSnapshots(): RenderDebugSnapshot[] { + return [...this._snapshots]; + } + + /** + * 清除快照 + * Clear snapshots + */ + clearSnapshots(): void { + this._snapshots = []; + } + + /** + * 收集纹理信息 + * Collect texture info + */ + private _collectTextures(): TextureDebugInfo[] { + const textures: TextureDebugInfo[] = []; + + // TODO: 从 EngineBridge 获取纹理管理器数据 + // TODO: Get texture manager data from EngineBridge + if (this._engineBridge) { + // const textureManager = this._engineBridge.getTextureManager(); + // for (const [id, tex] of textureManager.entries()) { + // textures.push({ ... }); + // } + } + + return textures; + } + + /** + * 收集 Sprite 信息 + * Collect sprite info + */ + private _collectSprites(entities: readonly Entity[]): SpriteDebugInfo[] { + const sprites: SpriteDebugInfo[] = []; + + for (const entity of entities) { + const sprite = entity.getComponent(SpriteComponent); + const transform = entity.getComponent(TransformComponent); + + if (!sprite || !transform) continue; + + const pos = transform.worldPosition ?? transform.position; + const rot = typeof transform.rotation === 'number' + ? transform.rotation + : transform.rotation.z; + + const textureGuid = sprite.textureGuid ?? ''; + sprites.push({ + entityId: entity.id, + entityName: entity.name, + x: pos.x, + y: pos.y, + width: sprite.width, + height: sprite.height, + rotation: rot, + textureId: (sprite as any).textureId ?? 0, + texturePath: textureGuid, + textureUrl: this._resolveTextureUrl(textureGuid), + uv: [...sprite.uv] as [number, number, number, number], + color: sprite.color, + alpha: sprite.alpha, + sortingLayer: sprite.sortingLayer, + orderInLayer: sprite.orderInLayer, + }); + } + + return sprites; + } + + /** + * 收集粒子系统信息 + * Collect particle system info + */ + private _collectParticles(entities: readonly Entity[]): ParticleDebugInfo[] { + const particleSystems: ParticleDebugInfo[] = []; + + for (const entity of entities) { + const ps = entity.getComponent(ParticleSystemComponent); + const transform = entity.getComponent(TransformComponent); + + if (!ps) continue; + + const pool = ps.pool; + + // 通过 getModule 获取 TextureSheetAnimation 模块 | Get TextureSheetAnimation module via getModule + const textureSheetAnim = ps.getModule?.('TextureSheetAnimation') as any; + + // 收集所有活跃粒子 | Collect all active particles + const sampleParticles: ParticleDebugInfo['sampleParticles'] = []; + if (pool) { + let count = 0; + pool.forEachActive((p: any) => { + const tilesX = p._animTilesX ?? 1; + const tilesY = p._animTilesY ?? 1; + const frame = p._animFrame ?? 0; + const col = frame % tilesX; + const row = Math.floor(frame / tilesX); + const uWidth = 1 / tilesX; + const vHeight = 1 / tilesY; + + sampleParticles.push({ + index: count, + x: p.x, + y: p.y, + frame, + uv: [ + col * uWidth, + row * vHeight, + (col + 1) * uWidth, + (row + 1) * vHeight, + ], + age: p.age, + lifetime: p.lifetime, + size: p.size ?? p.startSize ?? 1, + color: p.color ?? '#ffffff', + alpha: p.alpha ?? 1, + }); + count++; + }); + } + + // 获取模块的 tilesX/tilesY | Get tilesX/tilesY from module + const tilesX = textureSheetAnim?.tilesX ?? 1; + const tilesY = textureSheetAnim?.tilesY ?? 1; + const totalFrames = textureSheetAnim?.actualTotalFrames ?? (tilesX * tilesY); + + const textureGuid = ps.textureGuid ?? ''; + particleSystems.push({ + entityId: entity.id, + entityName: entity.name, + systemName: `ParticleSystem_${entity.id}`, + isPlaying: ps.isPlaying, + activeCount: pool?.activeCount ?? 0, + maxParticles: ps.maxParticles, + textureId: ps.textureId ?? 0, + texturePath: textureGuid, + textureUrl: this._resolveTextureUrl(textureGuid), + textureSheetAnimation: textureSheetAnim?.enabled ? { + enabled: true, + tilesX, + tilesY, + totalFrames, + } : null, + sampleParticles, + }); + } + + return particleSystems; + } + + /** + * 收集 UI 元素信息 + * Collect UI element info + */ + private _collectUI(entities: readonly Entity[]): UIDebugInfo[] { + const uiElements: UIDebugInfo[] = []; + + for (const entity of entities) { + const uiTransform = entity.getComponent(UITransformComponent); + + if (!uiTransform) continue; + + const uiRender = entity.getComponent(UIRenderComponent); + const uiText = entity.getComponent(UITextComponent); + + // 确定类型 | Determine type + let type: UIDebugInfo['type'] = 'unknown'; + if (uiText) { + type = 'text'; + } else if (uiRender) { + switch (uiRender.type) { + case 'rect': type = 'rect'; break; + case 'image': type = 'image'; break; + case 'ninepatch': type = 'ninepatch'; break; + case 'circle': type = 'circle'; break; + case 'rounded-rect': type = 'rounded-rect'; break; + default: type = 'rect'; + } + } + + // 获取纹理 GUID | Get texture GUID + const textureGuid = uiRender?.textureGuid?.toString() ?? ''; + + // 转换颜色为十六进制字符串 | Convert color to hex string + const backgroundColor = uiRender?.backgroundColor !== undefined + ? `#${uiRender.backgroundColor.toString(16).padStart(6, '0')}` + : undefined; + + uiElements.push({ + entityId: entity.id, + entityName: entity.name, + type, + x: uiTransform.x, + y: uiTransform.y, + width: uiTransform.width, + height: uiTransform.height, + worldX: uiTransform.worldX, + worldY: uiTransform.worldY, + rotation: uiTransform.rotation, + visible: uiTransform.visible && uiTransform.worldVisible, + alpha: uiTransform.worldAlpha, + sortingLayer: uiTransform.sortingLayer, + orderInLayer: uiTransform.orderInLayer, + textureGuid: textureGuid || undefined, + textureUrl: textureGuid ? this._resolveTextureUrl(textureGuid) : undefined, + backgroundColor, + text: uiText?.text, + fontSize: uiText?.fontSize, + }); + } + + return uiElements; + } + + /** + * 导出调试数据为 JSON + * Export debug data as JSON + */ + exportAsJSON(): string { + return JSON.stringify({ + exportTime: new Date().toISOString(), + snapshots: this._snapshots, + }, null, 2); + } + + /** + * 打印当前粒子 UV 到控制台 + * Print current particle UVs to console + */ + logParticleUVs(): void { + const snapshot = this.collectSnapshot(); + if (!snapshot) { + console.log('[RenderDebugService] No scene available'); + return; + } + + console.group('[RenderDebugService] Particle UV Debug'); + for (const ps of snapshot.particles) { + console.group(`${ps.entityName} (${ps.activeCount} active)`); + if (ps.textureSheetAnimation) { + console.log(`TextureSheetAnimation: ${ps.textureSheetAnimation.tilesX}x${ps.textureSheetAnimation.tilesY}`); + } + for (const p of ps.sampleParticles) { + console.log(` Particle ${p.index}: frame=${p.frame}, UV=[${p.uv.map(v => v.toFixed(3)).join(', ')}]`); + } + console.groupEnd(); + } + console.groupEnd(); + } +} + +// 全局实例 | Global instance +export const renderDebugService = RenderDebugService.getInstance(); + +// 导出到全局以便控制台使用 | Export to global for console usage +if (typeof window !== 'undefined') { + (window as any).renderDebugService = renderDebugService; +} diff --git a/packages/editor-app/src/styles/ConfirmDialog.css b/packages/editor-app/src/styles/ConfirmDialog.css index 85d022dd..2470f184 100644 --- a/packages/editor-app/src/styles/ConfirmDialog.css +++ b/packages/editor-app/src/styles/ConfirmDialog.css @@ -126,3 +126,52 @@ .confirm-dialog-btn:active { transform: scale(0.98); } + +/* External Modification Dialog | 外部修改对话框 */ +.external-modification-dialog .warning-icon { + color: #f0ad4e; + margin-right: 8px; + flex-shrink: 0; +} + +.external-modification-dialog .confirm-dialog-header { + gap: 0; +} + +.external-modification-dialog .confirm-dialog-header h2 { + flex: 1; +} + +.external-modification-dialog .hint-text { + margin-top: 12px; + color: var(--text-secondary, #999); + font-size: 13px; +} + +.external-modification-footer { + flex-wrap: wrap; +} + +.confirm-dialog-btn.reload { + background: #5bc0de; + color: white; + display: flex; + align-items: center; + gap: 6px; +} + +.confirm-dialog-btn.reload:hover { + background: #7cd0e8; +} + +.confirm-dialog-btn.overwrite { + background: #f0ad4e; + color: white; + display: flex; + align-items: center; + gap: 6px; +} + +.confirm-dialog-btn.overwrite:hover { + background: #f4be6e; +} diff --git a/packages/editor-core/src/Plugin/PluginManager.ts b/packages/editor-core/src/Plugin/PluginManager.ts index 1ceaf520..0e64d85a 100644 --- a/packages/editor-core/src/Plugin/PluginManager.ts +++ b/packages/editor-core/src/Plugin/PluginManager.ts @@ -3,7 +3,7 @@ * Unified Plugin Manager */ -import { createLogger, ComponentRegistry } from '@esengine/ecs-framework'; +import { createLogger, GlobalComponentRegistry } from '@esengine/ecs-framework'; import type { IScene, ServiceContainer, IService } from '@esengine/ecs-framework'; import type { ModuleManifest, @@ -670,9 +670,9 @@ export class PluginManager implements IService { // 注册组件(使用包装的 Registry 来跟踪) // Register components (use wrapped registry to track) if (runtimeModule.registerComponents) { - const componentsBefore = new Set(ComponentRegistry.getRegisteredComponents().map(c => c.name)); - runtimeModule.registerComponents(ComponentRegistry); - const componentsAfter = ComponentRegistry.getRegisteredComponents(); + const componentsBefore = new Set(GlobalComponentRegistry.getRegisteredComponents().map(c => c.name)); + runtimeModule.registerComponents(GlobalComponentRegistry); + const componentsAfter = GlobalComponentRegistry.getRegisteredComponents(); // 跟踪新注册的组件 // Track newly registered components @@ -779,7 +779,7 @@ export class PluginManager implements IService { if (resources.componentTypeNames.length > 0) { for (const componentName of resources.componentTypeNames) { try { - ComponentRegistry.unregister(componentName); + GlobalComponentRegistry.unregister(componentName); logger.debug(`Component unregistered: ${componentName}`); } catch (e) { logger.error(`Failed to unregister component ${componentName}:`, e); @@ -900,7 +900,7 @@ export class PluginManager implements IService { const runtimeModule = plugin.plugin.runtimeModule; if (runtimeModule?.registerComponents) { try { - runtimeModule.registerComponents(ComponentRegistry); + runtimeModule.registerComponents(GlobalComponentRegistry); logger.debug(`Components registered for: ${pluginId}`); } catch (e) { logger.error(`Failed to register components for ${pluginId}:`, e); diff --git a/packages/editor-core/src/Services/AssetRegistryService.ts b/packages/editor-core/src/Services/AssetRegistryService.ts index e2a08e09..9af15f76 100644 --- a/packages/editor-core/src/Services/AssetRegistryService.ts +++ b/packages/editor-core/src/Services/AssetRegistryService.ts @@ -394,8 +394,14 @@ export class AssetRegistryService implements IService { // 处理文件创建 - 注册新资产并生成 .meta if (changeType === 'create' || changeType === 'modify') { for (const absolutePath of paths) { - // Skip .meta files - if (absolutePath.endsWith('.meta')) continue; + // Handle .meta file changes - invalidate cache + // 处理 .meta 文件变化 - 使缓存失效 + if (absolutePath.endsWith('.meta')) { + const assetPath = absolutePath.slice(0, -5); // Remove '.meta' suffix + this._metaManager.invalidateCache(assetPath); + logger.debug(`Meta file changed, invalidated cache for: ${assetPath}`); + continue; + } // Only process files in managed directories // 只处理托管目录中的文件 @@ -406,8 +412,14 @@ export class AssetRegistryService implements IService { } } else if (changeType === 'remove') { for (const absolutePath of paths) { - // Skip .meta files - if (absolutePath.endsWith('.meta')) continue; + // Handle .meta file deletion - invalidate cache + // 处理 .meta 文件删除 - 使缓存失效 + if (absolutePath.endsWith('.meta')) { + const assetPath = absolutePath.slice(0, -5); + this._metaManager.invalidateCache(assetPath); + logger.debug(`Meta file removed, invalidated cache for: ${assetPath}`); + continue; + } // Only process files in managed directories // 只处理托管目录中的文件 diff --git a/packages/editor-core/src/Services/EntityStoreService.ts b/packages/editor-core/src/Services/EntityStoreService.ts index dd1306b4..6cae6da4 100644 --- a/packages/editor-core/src/Services/EntityStoreService.ts +++ b/packages/editor-core/src/Services/EntityStoreService.ts @@ -95,6 +95,9 @@ export class EntityStoreService implements IService { this.entities.clear(); this.rootEntityIds = []; + // 调试:打印场景实体信息 | Debug: print scene entity info + logger.info(`[syncFromScene] Scene name: ${scene.name}, entities.count: ${scene.entities.count}`); + let entityCount = 0; scene.entities.forEach((entity) => { entityCount++; @@ -106,7 +109,7 @@ export class EntityStoreService implements IService { } }); - logger.debug(`syncFromScene: synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`); + logger.info(`[syncFromScene] Synced ${entityCount} entities, ${this.rootEntityIds.length} root entities`); if (this.rootEntityIds.length > 0) { const rootNames = this.rootEntityIds .map(id => this.entities.get(id)?.name) diff --git a/packages/editor-core/src/Services/SceneManagerService.ts b/packages/editor-core/src/Services/SceneManagerService.ts index 225e77ce..293c68d2 100644 --- a/packages/editor-core/src/Services/SceneManagerService.ts +++ b/packages/editor-core/src/Services/SceneManagerService.ts @@ -7,7 +7,7 @@ import { Scene, PrefabSerializer, HierarchySystem, - ComponentRegistry + GlobalComponentRegistry } from '@esengine/ecs-framework'; import type { ComponentType } from '@esengine/ecs-framework'; import type { SceneResourceManager } from '@esengine/asset-system'; @@ -24,6 +24,10 @@ export interface SceneState { sceneName: string; isModified: boolean; isSaved: boolean; + /** 文件最后已知的修改时间(毫秒)| Last known file modification time (ms) */ + lastKnownMtime: number | null; + /** 文件是否被外部修改 | Whether file was modified externally */ + externallyModified: boolean; } /** @@ -55,7 +59,9 @@ export class SceneManagerService implements IService { currentScenePath: null, sceneName: 'Untitled', isModified: false, - isSaved: false + isSaved: false, + lastKnownMtime: null, + externallyModified: false }; /** 预制体编辑模式状态 | Prefab edit mode state */ @@ -118,7 +124,9 @@ export class SceneManagerService implements IService { currentScenePath: null, sceneName: 'Untitled', isModified: false, - isSaved: false + isSaved: false, + lastKnownMtime: null, + externallyModified: false }; // 同步到 EntityStore @@ -148,6 +156,18 @@ export class SceneManagerService implements IService { } } + // 在加载新场景前,清理旧场景的纹理映射(释放 GPU 资源) + // Before loading new scene, clear old scene's texture mappings (release GPU resources) + // 注意:路径稳定 ID 缓存 (_pathIdCache) 不会被清除 + // Note: Path-stable ID cache (_pathIdCache) is NOT cleared + if (this.sceneResourceManager) { + const oldScene = Core.scene as Scene | null; + if (oldScene && this.sceneState.currentScenePath) { + logger.info(`[openScene] Unloading old scene resources from: ${this.sceneState.currentScenePath}`); + await this.sceneResourceManager.unloadSceneResources(oldScene); + } + } + try { const jsonData = await this.fileAPI.readFileContent(path); @@ -165,10 +185,42 @@ export class SceneManagerService implements IService { // Ensure isEditorMode is set in editor to defer component lifecycle callbacks scene.isEditorMode = true; + // 调试:检查缺失的组件类型 | Debug: check missing component types + const registeredComponents = GlobalComponentRegistry.getAllComponentNames(); + try { + const sceneData = JSON.parse(jsonData); + const requiredTypes = new Set(); + for (const entity of sceneData.entities || []) { + for (const comp of entity.components || []) { + requiredTypes.add(comp.type); + } + } + + // 检查缺失的组件类型 | Check missing component types + const missingTypes = Array.from(requiredTypes).filter(t => !registeredComponents.has(t)); + if (missingTypes.length > 0) { + logger.warn(`[SceneManagerService.openScene] Missing component types (scene will load without these):`, missingTypes); + logger.debug(`Registered components (${registeredComponents.size}):`, Array.from(registeredComponents.keys())); + } + } catch (e) { + // JSON parsing should not fail at this point since we validated earlier + } + + // 调试:反序列化前场景状态 | Debug: scene state before deserialize + logger.info(`[openScene] Before deserialize: entities.count = ${scene.entities.count}`); + scene.deserialize(jsonData, { strategy: 'replace' }); + // 调试:反序列化后场景状态 | Debug: scene state after deserialize + logger.info(`[openScene] After deserialize: entities.count = ${scene.entities.count}`); + if (scene.entities.count > 0) { + const entityNames: string[] = []; + scene.entities.forEach(e => entityNames.push(e.name)); + logger.info(`[openScene] Entity names: ${entityNames.join(', ')}`); + } + // 加载场景资源 / Load scene resources if (this.sceneResourceManager) { await this.sceneResourceManager.loadSceneResources(scene); @@ -179,11 +231,23 @@ export class SceneManagerService implements IService { const fileName = path.split(/[/\\]/).pop() || 'Untitled'; const sceneName = fileName.replace('.ecs', ''); + // 获取文件修改时间 | Get file modification time + let mtime: number | null = null; + if (this.fileAPI.getFileMtime) { + try { + mtime = await this.fileAPI.getFileMtime(path); + } catch (e) { + logger.warn('Failed to get file mtime:', e); + } + } + this.sceneState = { currentScenePath: path, sceneName, isModified: false, - isSaved: true + isSaved: true, + lastKnownMtime: mtime, + externallyModified: false }; this.entityStore?.syncFromScene(); @@ -200,12 +264,22 @@ export class SceneManagerService implements IService { } } - public async saveScene(): Promise { + public async saveScene(force: boolean = false): Promise { if (!this.sceneState.currentScenePath) { await this.saveSceneAs(); return; } + // 检查文件是否被外部修改 | Check if file was modified externally + if (!force && await this.checkExternalModification()) { + // 发布事件让 UI 显示确认对话框 | Publish event for UI to show confirmation dialog + await this.messageHub.publish('scene:externalModification', { + path: this.sceneState.currentScenePath, + sceneName: this.sceneState.sceneName + }); + return; // 等待用户确认 | Wait for user confirmation + } + try { const scene = Core.scene as Scene | null; if (!scene) { @@ -219,8 +293,18 @@ export class SceneManagerService implements IService { await this.fileAPI.saveProject(this.sceneState.currentScenePath, jsonData); + // 更新 mtime | Update mtime + if (this.fileAPI.getFileMtime) { + try { + this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(this.sceneState.currentScenePath); + } catch (e) { + logger.warn('Failed to update file mtime after save:', e); + } + } + this.sceneState.isModified = false; this.sceneState.isSaved = true; + this.sceneState.externallyModified = false; await this.messageHub.publish('scene:saved', { path: this.sceneState.currentScenePath @@ -232,6 +316,89 @@ export class SceneManagerService implements IService { } } + /** + * 检查场景文件是否被外部修改 + * Check if scene file was modified externally + * + * @returns true 如果文件被外部修改 | true if file was modified externally + */ + public async checkExternalModification(): Promise { + const path = this.sceneState.currentScenePath; + const lastMtime = this.sceneState.lastKnownMtime; + + if (!path || lastMtime === null || !this.fileAPI.getFileMtime) { + return false; + } + + try { + const currentMtime = await this.fileAPI.getFileMtime(path); + const isModified = currentMtime > lastMtime; + + if (isModified) { + this.sceneState.externallyModified = true; + logger.warn(`Scene file externally modified: ${path} (${lastMtime} -> ${currentMtime})`); + } + + return isModified; + } catch (e) { + logger.warn('Failed to check file mtime:', e); + return false; + } + } + + /** + * 重新加载当前场景(放弃本地更改) + * Reload current scene (discard local changes) + */ + public async reloadScene(): Promise { + const path = this.sceneState.currentScenePath; + if (!path) { + logger.warn('No scene to reload'); + return; + } + + // 强制打开场景,绕过修改检查 | Force open scene, bypass modification check + const scene = Core.scene as Scene | null; + if (!scene) { + throw new Error('No active scene'); + } + + try { + const jsonData = await this.fileAPI.readFileContent(path); + const validation = SceneSerializer.validate(jsonData); + if (!validation.valid) { + throw new Error(`场景文件损坏: ${validation.errors?.join(', ')}`); + } + + scene.isEditorMode = true; + scene.deserialize(jsonData, { strategy: 'replace' }); + + if (this.sceneResourceManager) { + await this.sceneResourceManager.loadSceneResources(scene); + } + + // 更新 mtime | Update mtime + if (this.fileAPI.getFileMtime) { + try { + this.sceneState.lastKnownMtime = await this.fileAPI.getFileMtime(path); + } catch (e) { + logger.warn('Failed to update file mtime after reload:', e); + } + } + + this.sceneState.isModified = false; + this.sceneState.isSaved = true; + this.sceneState.externallyModified = false; + + this.entityStore?.syncFromScene(); + await this.messageHub.publish('scene:reloaded', { path }); + logger.info(`Scene reloaded: ${path}`); + } catch (error) { + logger.error('Failed to reload scene:', error); + throw error; + } + } + public async saveSceneAs(filePath?: string): Promise { let path: string | null | undefined = filePath; if (!path) { @@ -269,11 +436,23 @@ export class SceneManagerService implements IService { const fileName = path.split(/[/\\]/).pop() || 'Untitled'; const sceneName = fileName.replace('.ecs', ''); + // 获取文件修改时间 | Get file modification time + let mtime: number | null = null; + if (this.fileAPI.getFileMtime) { + try { + mtime = await this.fileAPI.getFileMtime(path); + } catch (e) { + logger.warn('Failed to get file mtime after save:', e); + } + } + this.sceneState = { currentScenePath: path, sceneName, isModified: false, - isSaved: true + isSaved: true, + lastKnownMtime: mtime, + externallyModified: false }; await this.messageHub.publish('scene:saved', { path }); @@ -405,11 +584,11 @@ export class SceneManagerService implements IService { } // 6. 获取组件注册表 | Get component registry - // ComponentRegistry.getAllComponentNames() 返回 Map + // GlobalComponentRegistry.getAllComponentNames() 返回 Map // 需要转换为 Map - const nameToType = ComponentRegistry.getAllComponentNames(); + const nameToType = GlobalComponentRegistry.getAllComponentNames(); const componentRegistry = new Map(); - nameToType.forEach((type, name) => { + nameToType.forEach((type: Function, name: string) => { componentRegistry.set(name, type as ComponentType); }); @@ -471,7 +650,9 @@ export class SceneManagerService implements IService { currentScenePath: null, sceneName: `Prefab: ${prefabName}`, isModified: false, - isSaved: true + isSaved: true, + lastKnownMtime: null, + externallyModified: false }; // 11. 同步到 EntityStore | Sync to EntityStore @@ -537,7 +718,9 @@ export class SceneManagerService implements IService { currentScenePath: originalState.originalScenePath, sceneName: originalState.originalSceneName, isModified: originalState.originalSceneModified, - isSaved: !originalState.originalSceneModified + isSaved: !originalState.originalSceneModified, + lastKnownMtime: null, + externallyModified: false }; // 5. 清除预制体编辑模式状态 | Clear prefab edit mode state diff --git a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts index 9f60c181..f5790bf4 100644 --- a/packages/editor-core/src/Services/UserCode/IUserCodeService.ts +++ b/packages/editor-core/src/Services/UserCode/IUserCodeService.ts @@ -71,16 +71,14 @@ export interface UserCodeCompileOptions { sourceMap?: boolean; /** Whether to minify output | 是否压缩输出 */ minify?: boolean; - /** Output format | 输出格式 */ + /** Output format (default: 'esm') | 输出格式(默认:'esm')*/ format?: 'esm' | 'iife'; /** - * SDK modules for shim generation. - * 用于生成 shim 的 SDK 模块列表。 + * SDK modules information (reserved for future use). + * SDK 模块信息(保留供将来使用)。 * - * If provided, shims will be created for these modules. - * Typically obtained from RuntimeResolver.getAvailableModules(). - * 如果提供,将为这些模块创建 shim。 - * 通常从 RuntimeResolver.getAvailableModules() 获取。 + * Currently SDK is handled via external dependencies and global variable. + * 当前 SDK 通过外部依赖和全局变量处理。 */ sdkModules?: SDKModuleInfo[]; } @@ -382,6 +380,37 @@ export interface IUserCodeService { * 检查是否正在监视。 */ isWatching(): boolean; + + /** + * Wait for user code to be ready (compiled and loaded). + * 等待用户代码准备就绪(已编译并加载)。 + * + * This method is used to synchronize scene loading with user code compilation. + * Call this before loading a scene to ensure user components are registered. + * 此方法用于同步场景加载与用户代码编译。 + * 在加载场景之前调用此方法以确保用户组件已注册。 + * + * @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise + */ + waitForReady(): Promise; + + /** + * Signal that user code is ready. + * 发出用户代码就绪信号。 + * + * Called after user code compilation and registration is complete. + * 在用户代码编译和注册完成后调用。 + */ + signalReady(): void; + + /** + * Reset the ready state (for project switching). + * 重置就绪状态(用于项目切换)。 + * + * Called when opening a new project to reset the ready promise. + * 打开新项目时调用以重置就绪 Promise。 + */ + resetReady(): void; } import { EditorConfig } from '../../Config'; diff --git a/packages/editor-core/src/Services/UserCode/UserCodeService.ts b/packages/editor-core/src/Services/UserCode/UserCodeService.ts index 3f3d59a0..09ad8b2d 100644 --- a/packages/editor-core/src/Services/UserCode/UserCodeService.ts +++ b/packages/editor-core/src/Services/UserCode/UserCodeService.ts @@ -11,7 +11,7 @@ import { Injectable, createLogger, PlatformDetector, - ComponentRegistry as CoreComponentRegistry, + GlobalComponentRegistry as CoreComponentRegistry, COMPONENT_TYPE_NAME, SYSTEM_TYPE_NAME } from '@esengine/ecs-framework'; @@ -82,9 +82,27 @@ export class UserCodeService implements IService, IUserCodeService { */ private _hotReloadCoordinator: HotReloadCoordinator; + /** + * 就绪状态 Promise + * Ready state promise + */ + private _readyPromise: Promise; + private _readyResolve: (() => void) | undefined; + constructor(fileSystem: IFileSystem) { this._fileSystem = fileSystem; this._hotReloadCoordinator = new HotReloadCoordinator(); + this._readyPromise = this._createReadyPromise(); + } + + /** + * Create a new ready promise. + * 创建新的就绪 Promise。 + */ + private _createReadyPromise(): Promise { + return new Promise(resolve => { + this._readyResolve = resolve; + }); } /** @@ -190,28 +208,20 @@ export class UserCodeService implements IService, IUserCodeService { const entryPath = `${outputDir}${sep}_entry_${options.target}.ts`; await this._fileSystem.writeFile(entryPath, entryContent); - // Create shim files for framework dependencies | 创建框架依赖的 shim 文件 - // Returns mapping from package name to shim path - // 返回包名到 shim 路径的映射 - const alias = await this._createDependencyShims(outputDir, options.sdkModules); - - // Determine global name for IIFE output | 确定 IIFE 输出的全局名称 - const globalName = options.target === UserCodeTarget.Runtime - ? EditorConfig.globals.userRuntimeExports - : EditorConfig.globals.userEditorExports; + // Get external dependencies | 获取外部依赖 + // SDK marked as external, resolved from global variable at runtime + // SDK 标记为外部依赖,运行时从全局变量解析 + const external = this._getExternalDependencies(options.target, options.sdkModules); // Compile using esbuild (via Tauri command or direct) | 使用 esbuild 编译 - // Use IIFE format to avoid ES module import issues in Tauri - // 使用 IIFE 格式以避免 Tauri 中的 ES 模块导入问题 + // Use ESM format for dynamic import() loading | 使用 ESM 格式以支持动态 import() 加载 const compileResult = await this._runEsbuild({ entryPath, outputPath, - format: 'iife', // Always use IIFE for Tauri compatibility | 始终使用 IIFE 以兼容 Tauri - globalName, + format: 'esm', // ESM for standard dynamic import() | ESM 用于标准动态 import() sourceMap: options.sourceMap ?? true, minify: options.minify ?? false, - external: [], // Don't use external, use alias instead | 不使用 external,使用 alias - alias, + external, projectRoot: options.projectPath }); @@ -259,6 +269,14 @@ export class UserCodeService implements IService, IUserCodeService { * Load compiled user code module. * 加载编译后的用户代码模块。 * + * Uses Blob URL for ESM dynamic import in Tauri environment. + * 在 Tauri 环境中使用 Blob URL 进行 ESM 动态导入。 + * + * Note: Browser's import() only supports http://, https://, and blob:// protocols. + * Custom protocols like project:// are not supported for ESM imports. + * 注意:浏览器的 import() 只支持 http://、https:// 和 blob:// 协议。 + * 自定义协议如 project:// 不支持 ESM 导入。 + * * @param modulePath - Path to compiled JS file | 编译后的 JS 文件路径 * @param target - Target environment | 目标环境 * @returns Loaded module | 加载的模块 @@ -268,20 +286,23 @@ export class UserCodeService implements IService, IUserCodeService { let moduleExports: Record; if (PlatformDetector.isTauriEnvironment()) { - // In Tauri, read file content and execute via script tag - // 在 Tauri 中,读取文件内容并通过 script 标签执行 - // This avoids CORS and module resolution issues - // 这避免了 CORS 和模块解析问题 + // Read file content via Tauri and load via Blob URL + // 通过 Tauri 读取文件内容并通过 Blob URL 加载 + // Browser's import() doesn't support custom protocols like project:// + // 浏览器的 import() 不支持自定义协议如 project:// const { invoke } = await import('@tauri-apps/api/core'); const content = await invoke('read_file_content', { path: modulePath }); - logger.debug(`Loading module via script injection`, { originalPath: modulePath }); + logger.debug(`Loading ESM module via Blob URL`, { + path: modulePath, + contentLength: content.length + }); - // Execute module code and capture exports | 执行模块代码并捕获导出 - moduleExports = await this._executeModuleCode(content, target); + // Load ESM via Blob URL | 通过 Blob URL 加载 ESM + moduleExports = await this._loadESMFromContent(content); } else { // Fallback to file:// for non-Tauri environments // 非 Tauri 环境使用 file:// @@ -924,6 +945,35 @@ export class UserCodeService implements IService, IUserCodeService { return this._watching; } + /** + * Wait for user code to be ready (compiled and loaded). + * 等待用户代码准备就绪(已编译并加载)。 + * + * @returns Promise that resolves when user code is ready | 当用户代码就绪时解决的 Promise + */ + waitForReady(): Promise { + return this._readyPromise; + } + + /** + * Signal that user code is ready. + * 发出用户代码就绪信号。 + */ + signalReady(): void { + if (this._readyResolve) { + this._readyResolve(); + this._readyResolve = undefined; + } + } + + /** + * Reset the ready state (for project switching). + * 重置就绪状态(用于项目切换)。 + */ + resetReady(): void { + this._readyPromise = this._createReadyPromise(); + } + /** * Dispose service resources. * 释放服务资源。 @@ -1058,44 +1108,6 @@ export class UserCodeService implements IService, IUserCodeService { return lines.join('\n'); } - /** - * Create shim file that maps SDK global variable to module import. - * 创建将 SDK 全局变量映射到模块导入的 shim 文件。 - * - * This is used for IIFE format to resolve external dependencies. - * Creates a single shim for @esengine/sdk. - * 这用于 IIFE 格式解析外部依赖。 - * 只创建一个 @esengine/sdk 的 shim。 - * - * @param outputDir - Output directory | 输出目录 - * @param _sdkModules - Deprecated, not used | 已废弃,不再使用 - * @returns Mapping from package name to shim path | 包名到 shim 路径的映射 - */ - private async _createDependencyShims( - outputDir: string, - _sdkModules?: SDKModuleInfo[] - ): Promise> { - const sep = outputDir.includes('\\') ? '\\' : '/'; - const sdkGlobalName = EditorConfig.globals.sdk; - - // Create single SDK shim - // 创建单一 SDK shim - const shimPath = `${outputDir}${sep}_shim_sdk.js`; - const shimContent = `// Shim for @esengine/sdk -// Maps to window.${sdkGlobalName} -// User code imports from '@esengine/sdk' will use this shim -module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || {}; -`; - await this._fileSystem.writeFile(shimPath, shimContent); - const normalizedPath = shimPath.replace(/\\/g, '/'); - - logger.info('Created SDK shim', { path: normalizedPath }); - - return { - '@esengine/sdk': normalizedPath - }; - } - /** * Get external dependencies that should not be bundled. * 获取不应打包的外部依赖。 @@ -1122,16 +1134,24 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { * * Uses Tauri command to invoke esbuild CLI. * 使用 Tauri 命令调用 esbuild CLI。 + * + * @param options - Compilation options | 编译选项 + * @returns Compilation result | 编译结果 */ private async _runEsbuild(options: { + /** Entry file path | 入口文件路径 */ entryPath: string; + /** Output file path | 输出文件路径 */ outputPath: string; + /** Output format (ESM for dynamic import) | 输出格式(ESM 用于动态导入)*/ format: 'esm' | 'iife'; - globalName?: string; + /** Generate source maps | 生成源码映射 */ sourceMap: boolean; + /** Minify output | 压缩输出 */ minify: boolean; + /** External dependencies (not bundled) | 外部依赖(不打包)*/ external: string[]; - alias?: Record; + /** Project root for resolving paths | 项目根路径用于解析路径 */ projectRoot: string; }): Promise<{ success: boolean; errors: CompileError[] }> { try { @@ -1143,13 +1163,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { entry: options.entryPath, output: options.outputPath, format: options.format, - aliasCount: options.alias ? Object.keys(options.alias).length : 0 + external: options.external }); - if (options.alias) { - logger.debug('esbuild alias mappings:', options.alias); - } - // Use Tauri command | 使用 Tauri 命令 const { invoke } = await import('@tauri-apps/api/core'); @@ -1167,11 +1183,9 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { entryPath: options.entryPath, outputPath: options.outputPath, format: options.format, - globalName: options.globalName, sourceMap: options.sourceMap, minify: options.minify, external: options.external, - alias: options.alias, projectRoot: options.projectRoot } }); @@ -1206,52 +1220,30 @@ module.exports = (typeof window !== 'undefined' && window.${sdkGlobalName}) || { } /** - * Execute compiled module code and return exports. - * 执行编译后的模块代码并返回导出。 + * Load ESM module from JavaScript content string. + * 从 JavaScript 内容字符串加载 ESM 模块。 * - * The code should be in IIFE format that sets a global variable. - * 代码应该是设置全局变量的 IIFE 格式。 + * Uses Blob URL to enable dynamic import() of ESM content. + * 使用 Blob URL 实现 ESM 内容的动态 import()。 * - * @param code - Compiled JavaScript code | 编译后的 JavaScript 代码 - * @param target - Target environment | 目标环境 + * @param content - JavaScript module content (ESM format) | JavaScript 模块内容(ESM 格式) * @returns Module exports | 模块导出 */ - private async _executeModuleCode( - code: string, - target: UserCodeTarget - ): Promise> { - // Determine global name based on target | 根据目标确定全局名称 - const globalName = target === UserCodeTarget.Runtime - ? EditorConfig.globals.userRuntimeExports - : EditorConfig.globals.userEditorExports; - - // Clear any previous exports | 清除之前的导出 - (window as any)[globalName] = undefined; + private async _loadESMFromContent(content: string): Promise> { + // Create Blob URL for ESM module | 为 ESM 模块创建 Blob URL + const blob = new Blob([content], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); try { - // esbuild generates: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); - // When executed via new Function(), var declarations stay in function scope - // We need to replace "var globalName" with "window.globalName" to expose it - // esbuild 生成: var __USER_RUNTIME_EXPORTS__ = (() => {...})(); - // 通过 new Function() 执行时,var 声明在函数作用域内 - // 需要替换 "var globalName" 为 "window.globalName" 以暴露到全局 - const modifiedCode = code.replace( - new RegExp(`^"use strict";\\s*var ${globalName}`, 'm'), - `"use strict";\nwindow.${globalName}` - ); + // Dynamic import the ESM module | 动态导入 ESM 模块 + const moduleExports = await import(/* @vite-ignore */ blobUrl); - // Execute the IIFE code | 执行 IIFE 代码 - // eslint-disable-next-line no-new-func - const executeScript = new Function(modifiedCode); - executeScript(); - - // Get exports from global | 从全局获取导出 - const exports = (window as any)[globalName] || {}; - - return exports; - } catch (error) { - logger.error('Failed to execute user code | 执行用户代码失败:', error); - throw error; + // Return all exports | 返回所有导出 + return { ...moduleExports }; + } finally { + // Always revoke Blob URL to prevent memory leaks + // 始终撤销 Blob URL 以防止内存泄漏 + URL.revokeObjectURL(blobUrl); } } diff --git a/packages/editor-core/src/Services/UserCode/index.ts b/packages/editor-core/src/Services/UserCode/index.ts index 183c4ec3..bc0b0a0a 100644 --- a/packages/editor-core/src/Services/UserCode/index.ts +++ b/packages/editor-core/src/Services/UserCode/index.ts @@ -43,9 +43,10 @@ * ↓ * [UserCodeService.scan()] - Discovers all scripts * ↓ - * [UserCodeService.compile()] - Compiles to JS using esbuild + * [UserCodeService.compile()] - Compiles to ESM using esbuild + * (@esengine/sdk marked as external) * ↓ - * [UserCodeService.load()] - Loads compiled module + * [UserCodeService.load()] - Loads via project:// protocol + import() * ↓ * [registerComponents()] - Registers with ECS runtime * [registerEditorExtensions()] - Registers inspectors/gizmos @@ -53,6 +54,16 @@ * [UserCodeService.watch()] - Hot reload on file changes * ``` * + * # Architecture | 架构 + * + * - **Compilation**: ESM format with `external: ['@esengine/sdk']` + * - **Loading**: Reads file via Tauri, loads via Blob URL + import() + * - **Runtime**: SDK accessed via `window.__ESENGINE_SDK__` global + * - **Hot Reload**: File watching via Rust backend + Tauri events + * + * Note: Browser's import() only supports http/https/blob protocols. + * Custom protocols like project:// are not supported for ESM imports. + * * # Example User Component | 用户组件示例 * * ```typescript diff --git a/packages/editor-core/src/Types/IFileAPI.ts b/packages/editor-core/src/Types/IFileAPI.ts index 109a4fa3..137d5d93 100644 --- a/packages/editor-core/src/Types/IFileAPI.ts +++ b/packages/editor-core/src/Types/IFileAPI.ts @@ -61,4 +61,13 @@ export interface IFileAPI { * @returns 路径是否存在 */ pathExists(path: string): Promise; + + /** + * 获取文件修改时间 + * Get file modification time + * + * @param path 文件路径 | File path + * @returns 文件修改时间(毫秒时间戳)| File modification time (milliseconds timestamp) + */ + getFileMtime?(path: string): Promise; } diff --git a/packages/engine-core/src/EnginePlugin.ts b/packages/engine-core/src/EnginePlugin.ts index 9e7e67cc..89aecdd2 100644 --- a/packages/engine-core/src/EnginePlugin.ts +++ b/packages/engine-core/src/EnginePlugin.ts @@ -11,7 +11,7 @@ * @see docs/architecture/plugin-system-design.md */ -import type { ComponentRegistry as ComponentRegistryType, IScene, ServiceContainer } from '@esengine/ecs-framework'; +import type { IComponentRegistry, IScene, ServiceContainer } from '@esengine/ecs-framework'; import { PluginServiceRegistry } from '@esengine/ecs-framework'; import { TransformComponent } from './TransformComponent'; import type { ModuleManifest } from './ModuleManifest'; @@ -105,7 +105,7 @@ export interface IRuntimeModule { * 注册组件到 ComponentRegistry * Register components to ComponentRegistry */ - registerComponents?(registry: typeof ComponentRegistryType): void; + registerComponents?(registry: IComponentRegistry): void; /** * 注册服务到 ServiceContainer @@ -192,7 +192,7 @@ export type IPlugin = IRuntimePlugin; // ============================================================================ class EngineRuntimeModule implements IRuntimeModule { - registerComponents(registry: typeof ComponentRegistryType): void { + registerComponents(registry: IComponentRegistry): void { registry.register(TransformComponent); } } diff --git a/packages/engine-core/src/PluginServiceRegistry.ts b/packages/engine-core/src/PluginServiceRegistry.ts index 38be3310..c1423378 100644 --- a/packages/engine-core/src/PluginServiceRegistry.ts +++ b/packages/engine-core/src/PluginServiceRegistry.ts @@ -65,6 +65,58 @@ export interface IEngineBridge { * Set clear color */ setClearColor(r: number, g: number, b: number, a: number): void; + + // ===== Texture State API (Optional) ===== + // ===== 纹理状态 API(可选)===== + + /** + * 获取纹理加载状态 + * Get texture loading state + * + * @param id 纹理 ID | Texture ID + * @returns 状态字符串: 'loading', 'ready', 或 'failed:reason' + * State string: 'loading', 'ready', or 'failed:reason' + */ + getTextureState?(id: number): string; + + /** + * 检查纹理是否就绪 + * Check if texture is ready for rendering + * + * @param id 纹理 ID | Texture ID + * @returns 纹理数据已加载则返回 true | true if texture data is loaded + */ + isTextureReady?(id: number): boolean; + + /** + * 获取正在加载的纹理数量 + * Get count of textures currently loading + * + * @returns 处于加载状态的纹理数量 | Number of textures in loading state + */ + getTextureLoadingCount?(): number; + + /** + * 异步加载纹理(等待完成) + * Load texture asynchronously (wait for completion) + * + * 与 loadTexture 不同,此方法会等待纹理实际加载完成。 + * Unlike loadTexture, this method waits until texture is actually loaded. + * + * @param id 纹理 ID | Texture ID + * @param url 图片 URL | Image URL + * @returns 纹理就绪时解析的 Promise | Promise that resolves when texture is ready + */ + loadTextureAsync?(id: number, url: string): Promise; + + /** + * 等待所有加载中的纹理完成 + * Wait for all loading textures to complete + * + * @param timeout 最大等待时间(毫秒,默认30000)| Max wait time in ms (default 30000) + * @returns 所有纹理加载完成时解析 | Resolves when all textures are loaded + */ + waitForAllTextures?(timeout?: number): Promise; } /** diff --git a/packages/engine/src/core/engine.rs b/packages/engine/src/core/engine.rs index ae83c4cd..2eedff63 100644 --- a/packages/engine/src/core/engine.rs +++ b/packages/engine/src/core/engine.rs @@ -197,14 +197,6 @@ impl Engine { colors: &[u32], material_ids: &[u32], ) -> Result<()> { - // Debug: log once - use std::sync::atomic::{AtomicBool, Ordering}; - static LOGGED: AtomicBool = AtomicBool::new(false); - if !LOGGED.swap(true, Ordering::Relaxed) { - let sprite_count = texture_ids.len(); - log::info!("Engine submit_sprite_batch: {} sprites, texture_ids: {:?}", sprite_count, texture_ids); - } - self.renderer.submit_batch( transforms, texture_ids, @@ -382,6 +374,24 @@ impl Engine { self.texture_manager.clear_all(); } + /// 获取纹理加载状态 + /// Get texture loading state + pub fn get_texture_state(&self, id: u32) -> crate::renderer::texture::TextureState { + self.texture_manager.get_texture_state(id) + } + + /// 检查纹理是否已就绪 + /// Check if texture is ready to use + pub fn is_texture_ready(&self, id: u32) -> bool { + self.texture_manager.is_texture_ready(id) + } + + /// 获取正在加载中的纹理数量 + /// Get the number of textures currently loading + pub fn get_texture_loading_count(&self) -> u32 { + self.texture_manager.get_loading_count() + } + /// Check if a key is currently pressed. /// 检查某个键是否当前被按下。 pub fn is_key_down(&self, key_code: &str) -> bool { diff --git a/packages/engine/src/lib.rs b/packages/engine/src/lib.rs index b59e9d6b..ea50d589 100644 --- a/packages/engine/src/lib.rs +++ b/packages/engine/src/lib.rs @@ -224,6 +224,42 @@ impl GameEngine { .map_err(|e| JsValue::from_str(&e.to_string())) } + /// 获取纹理加载状态 + /// Get texture loading state + /// + /// # Arguments | 参数 + /// * `id` - Texture ID | 纹理ID + /// + /// # Returns | 返回 + /// State string: "loading", "ready", or "failed:reason" + /// 状态字符串:"loading"、"ready" 或 "failed:原因" + #[wasm_bindgen(js_name = getTextureState)] + pub fn get_texture_state(&self, id: u32) -> String { + use crate::renderer::texture::TextureState; + match self.engine.get_texture_state(id) { + TextureState::Loading => "loading".to_string(), + TextureState::Ready => "ready".to_string(), + TextureState::Failed(reason) => format!("failed:{}", reason), + } + } + + /// 检查纹理是否已就绪 + /// Check if texture is ready to use + /// + /// # Arguments | 参数 + /// * `id` - Texture ID | 纹理ID + #[wasm_bindgen(js_name = isTextureReady)] + pub fn is_texture_ready(&self, id: u32) -> bool { + self.engine.is_texture_ready(id) + } + + /// 获取正在加载中的纹理数量 + /// Get the number of textures currently loading + #[wasm_bindgen(js_name = getTextureLoadingCount)] + pub fn get_texture_loading_count(&self) -> u32 { + self.engine.get_texture_loading_count() + } + /// Check if a key is currently pressed. /// 检查某个键是否当前被按下。 /// diff --git a/packages/engine/src/renderer/texture/mod.rs b/packages/engine/src/renderer/texture/mod.rs index e3be5892..9298c5cb 100644 --- a/packages/engine/src/renderer/texture/mod.rs +++ b/packages/engine/src/renderer/texture/mod.rs @@ -5,4 +5,4 @@ mod texture; mod texture_manager; pub use texture::Texture; -pub use texture_manager::TextureManager; +pub use texture_manager::{TextureManager, TextureState}; diff --git a/packages/engine/src/renderer/texture/texture_manager.rs b/packages/engine/src/renderer/texture/texture_manager.rs index d41846fc..60524b49 100644 --- a/packages/engine/src/renderer/texture/texture_manager.rs +++ b/packages/engine/src/renderer/texture/texture_manager.rs @@ -1,7 +1,9 @@ //! Texture loading and management. //! 纹理加载和管理。 +use std::cell::RefCell; use std::collections::HashMap; +use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture}; @@ -9,6 +11,21 @@ use web_sys::{HtmlImageElement, WebGl2RenderingContext, WebGlTexture}; use crate::core::error::{EngineError, Result}; use super::Texture; +/// 纹理加载状态 +/// Texture loading state +#[derive(Debug, Clone, PartialEq)] +pub enum TextureState { + /// 正在加载中 + /// Loading in progress + Loading, + /// 加载完成,可以使用 + /// Loaded and ready to use + Ready, + /// 加载失败 + /// Load failed + Failed(String), +} + /// Texture manager for loading and caching textures. /// 用于加载和缓存纹理的纹理管理器。 pub struct TextureManager { @@ -31,6 +48,10 @@ pub struct TextureManager { /// Default white texture for untextured rendering. /// 用于无纹理渲染的默认白色纹理。 default_texture: Option, + + /// 纹理加载状态(使用 Rc> 以便闭包可以修改) + /// Texture loading states (using Rc> so closures can modify) + texture_states: Rc>>, } impl TextureManager { @@ -43,6 +64,7 @@ impl TextureManager { path_to_id: HashMap::new(), next_id: 1, // Start from 1, 0 is reserved for default default_texture: None, + texture_states: Rc::new(RefCell::new(HashMap::new())), }; // Create default white texture | 创建默认白色纹理 @@ -90,17 +112,22 @@ impl TextureManager { /// 从URL加载纹理。 /// /// Note: This is an async operation. The texture will be available - /// after the image loads. - /// 注意:这是一个异步操作。纹理在图片加载后可用。 + /// after the image loads. Use `get_texture_state` to check loading status. + /// 注意:这是一个异步操作。纹理在图片加载后可用。使用 `get_texture_state` 检查加载状态。 pub fn load_texture(&mut self, id: u32, url: &str) -> Result<()> { + // 设置初始状态为 Loading | Set initial state to Loading + self.texture_states.borrow_mut().insert(id, TextureState::Loading); + // Create placeholder texture | 创建占位纹理 let texture = self.gl .create_texture() .ok_or_else(|| EngineError::TextureLoadFailed("Failed to create texture".into()))?; - // Set up temporary 1x1 texture | 设置临时1x1纹理 + // Set up temporary 1x1 transparent texture | 设置临时1x1透明纹理 + // 使用透明而非灰色,这样未加载完成时不会显示奇怪的颜色 + // Use transparent instead of gray, so incomplete textures don't show strange colors self.gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture)); - let placeholder: [u8; 4] = [128, 128, 128, 255]; + let placeholder: [u8; 4] = [0, 0, 0, 0]; let _ = self.gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( WebGl2RenderingContext::TEXTURE_2D, 0, @@ -119,6 +146,10 @@ impl TextureManager { // Store texture with placeholder size | 存储带占位符尺寸的纹理 self.textures.insert(id, Texture::new(texture, 1, 1)); + // Clone state map for closures | 克隆状态映射用于闭包 + let states_for_onload = Rc::clone(&self.texture_states); + let states_for_onerror = Rc::clone(&self.texture_states); + // Load actual image asynchronously | 异步加载实际图片 let gl = self.gl.clone(); @@ -130,6 +161,7 @@ impl TextureManager { // Clone image for use in closure | 克隆图片用于闭包 let image_clone = image.clone(); + let texture_id = id; // Set up load callback | 设置加载回调 let onload = Closure::wrap(Box::new(move || { @@ -146,7 +178,9 @@ impl TextureManager { ); if let Err(e) = result { - log::error!("Failed to upload texture: {:?} | 纹理上传失败: {:?}", e, e); + log::error!("Failed to upload texture {}: {:?} | 纹理 {} 上传失败: {:?}", texture_id, e, texture_id, e); + states_for_onload.borrow_mut().insert(texture_id, TextureState::Failed(format!("{:?}", e))); + return; } // Set texture parameters | 设置纹理参数 @@ -171,10 +205,22 @@ impl TextureManager { WebGl2RenderingContext::LINEAR as i32, ); + // 标记为就绪 | Mark as ready + states_for_onload.borrow_mut().insert(texture_id, TextureState::Ready); + + }) as Box); + + // Set up error callback | 设置错误回调 + let url_for_error = url.to_string(); + let onerror = Closure::wrap(Box::new(move || { + let error_msg = format!("Failed to load image: {}", url_for_error); + states_for_onerror.borrow_mut().insert(texture_id, TextureState::Failed(error_msg)); }) as Box); image.set_onload(Some(onload.as_ref().unchecked_ref())); + image.set_onerror(Some(onerror.as_ref().unchecked_ref())); onload.forget(); // Prevent closure from being dropped | 防止闭包被销毁 + onerror.forget(); image.set_src(url); @@ -223,6 +269,56 @@ impl TextureManager { self.textures.contains_key(&id) } + /// 获取纹理加载状态 + /// Get texture loading state + /// + /// 返回纹理的当前加载状态:Loading、Ready 或 Failed。 + /// Returns the current loading state of the texture: Loading, Ready, or Failed. + #[inline] + pub fn get_texture_state(&self, id: u32) -> TextureState { + // ID 0 是默认纹理,始终就绪 + // ID 0 is default texture, always ready + if id == 0 { + return TextureState::Ready; + } + + self.texture_states + .borrow() + .get(&id) + .cloned() + .unwrap_or(TextureState::Failed("Texture not found".to_string())) + } + + /// 检查纹理是否已就绪可用 + /// Check if texture is ready to use + /// + /// 这是 `get_texture_state() == TextureState::Ready` 的便捷方法。 + /// This is a convenience method for `get_texture_state() == TextureState::Ready`. + #[inline] + pub fn is_texture_ready(&self, id: u32) -> bool { + // ID 0 是默认纹理,始终就绪 + // ID 0 is default texture, always ready + if id == 0 { + return true; + } + + matches!( + self.texture_states.borrow().get(&id), + Some(TextureState::Ready) + ) + } + + /// 获取正在加载中的纹理数量 + /// Get the number of textures currently loading + #[inline] + pub fn get_loading_count(&self) -> u32 { + self.texture_states + .borrow() + .values() + .filter(|s| matches!(s, TextureState::Loading)) + .count() as u32 + } + /// Remove texture. /// 移除纹理。 pub fn remove_texture(&mut self, id: u32) { @@ -231,6 +327,8 @@ impl TextureManager { } // Also remove from path mapping | 同时从路径映射中移除 self.path_to_id.retain(|_, &mut v| v != id); + // Remove state | 移除状态 + self.texture_states.borrow_mut().remove(&id); } /// Load texture by path, returning texture ID. @@ -308,6 +406,9 @@ impl TextureManager { // Clear path mapping | 清除路径映射 self.path_to_id.clear(); + // Clear texture states | 清除纹理状态 + self.texture_states.borrow_mut().clear(); + // Reset ID counter (1 is reserved for first texture, 0 for default) // 重置ID计数器(1保留给第一个纹理,0给默认纹理) self.next_id = 1; diff --git a/packages/particle/src/ParticleRuntimeModule.ts b/packages/particle/src/ParticleRuntimeModule.ts index 1f493667..17995b45 100644 --- a/packages/particle/src/ParticleRuntimeModule.ts +++ b/packages/particle/src/ParticleRuntimeModule.ts @@ -1,4 +1,4 @@ -import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework'; +import type { IComponentRegistry, IScene } from '@esengine/ecs-framework'; import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core'; import { TransformTypeToken, CanvasElementToken } from '@esengine/engine-core'; import { AssetManagerToken } from '@esengine/asset-system'; @@ -20,7 +20,7 @@ class ParticleRuntimeModule implements IRuntimeModule { private _updateSystem: ParticleUpdateSystem | null = null; private _loaderRegistered = false; - registerComponents(registry: typeof ComponentRegistryType): void { + registerComponents(registry: IComponentRegistry): void { registry.register(ParticleSystemComponent); registry.register(ClickFxComponent); } @@ -73,13 +73,10 @@ class ParticleRuntimeModule implements IRuntimeModule { scene.addSystem(this._updateSystem); // 添加点击特效系统 | Add click FX system + // ClickFxSystem 不再需要 AssetManager,资产由 ParticleUpdateSystem 统一加载 + // ClickFxSystem no longer needs AssetManager, assets are loaded by ParticleUpdateSystem const clickFxSystem = new ClickFxSystem(); - // 设置资产管理器 | Set asset manager - if (assetManager) { - clickFxSystem.setAssetManager(assetManager); - } - // 设置 EngineBridge(用于屏幕坐标转世界坐标) // Set EngineBridge (for screen to world coordinate conversion) if (engineBridge) { diff --git a/packages/particle/src/ParticleSystemComponent.ts b/packages/particle/src/ParticleSystemComponent.ts index 77f87bb6..8e6f3f52 100644 --- a/packages/particle/src/ParticleSystemComponent.ts +++ b/packages/particle/src/ParticleSystemComponent.ts @@ -9,6 +9,7 @@ import { SizeOverLifetimeModule } from './modules/SizeOverLifetimeModule'; import { CollisionModule, BoundaryType, CollisionBehavior } from './modules/CollisionModule'; import { ForceFieldModule, ForceFieldType, type ForceField } from './modules/ForceFieldModule'; import { Physics2DCollisionModule } from './modules/Physics2DCollisionModule'; +import { TextureSheetAnimationModule, AnimationPlayMode, AnimationLoopMode } from './modules/TextureSheetAnimationModule'; import type { IParticleAsset, IBurstConfig } from './loaders/ParticleLoader'; // Re-export for backward compatibility @@ -828,6 +829,42 @@ export class ParticleSystemComponent extends Component implements ISortable { this._modules.push(forceModule); break; } + case 'TextureSheetAnimation': { + // 纹理图集动画模块 | Texture sheet animation module + const textureModule = new TextureSheetAnimationModule(); + // moduleConfig 直接包含属性(非 params 嵌套) + // moduleConfig contains properties directly (not nested in params) + const cfg = moduleConfig as unknown as Record; + textureModule.enabled = true; + if (cfg.tilesX !== undefined) textureModule.tilesX = cfg.tilesX as number; + if (cfg.tilesY !== undefined) textureModule.tilesY = cfg.tilesY as number; + if (cfg.totalFrames !== undefined) textureModule.totalFrames = cfg.totalFrames as number; + if (cfg.startFrame !== undefined) textureModule.startFrame = cfg.startFrame as number; + if (cfg.frameRate !== undefined) textureModule.frameRate = cfg.frameRate as number; + if (cfg.speedMultiplier !== undefined) textureModule.speedMultiplier = cfg.speedMultiplier as number; + if (cfg.cycleCount !== undefined) textureModule.cycleCount = cfg.cycleCount as number; + // 播放模式 | Play mode + if (cfg.playMode !== undefined) { + const playModeMap: Record = { + 'lifetimeLoop': AnimationPlayMode.LifetimeLoop, + 'fixedFps': AnimationPlayMode.FixedFPS, + 'random': AnimationPlayMode.Random, + 'speedBased': AnimationPlayMode.SpeedBased, + }; + textureModule.playMode = playModeMap[cfg.playMode as string] ?? AnimationPlayMode.LifetimeLoop; + } + // 循环模式 | Loop mode + if (cfg.loopMode !== undefined) { + const loopModeMap: Record = { + 'once': AnimationLoopMode.Once, + 'loop': AnimationLoopMode.Loop, + 'pingPong': AnimationLoopMode.PingPong, + }; + textureModule.loopMode = loopModeMap[cfg.loopMode as string] ?? AnimationLoopMode.Once; + } + this._modules.push(textureModule); + break; + } // 可扩展其他模块类型 | Extensible for other module types default: console.warn(`[ParticleSystem] Unknown module type: ${moduleConfig.type}`); diff --git a/packages/particle/src/__tests__/particle-e2e-test.html b/packages/particle/src/__tests__/particle-e2e-test.html new file mode 100644 index 00000000..f2da57a2 --- /dev/null +++ b/packages/particle/src/__tests__/particle-e2e-test.html @@ -0,0 +1,401 @@ + + + + Particle System End-to-End Test + + + +

Particle System End-to-End Test

+

This test simulates the COMPLETE particle rendering pipeline.

+ +
+

Step 1: Test Texture

+
+2x2 Spritesheet (128x128 pixels):
+┌───────────┬───────────┐
+│  RED (0)  │ GREEN (1) │  row=0, v: 0.0 - 0.5
+├───────────┼───────────┤
+│  BLUE (2) │ YELLOW(3) │  row=1, v: 0.5 - 1.0
+└───────────┴───────────┘
+        
+ +
+ +
+

Step 2: TextureSheetAnimationModule._setParticleUV()

+

+    
+ +
+

Step 3: ParticleRenderDataProvider._updateRenderData()

+

+    
+ +
+

Step 4: EngineRenderSystem.convertProviderDataToSprites()

+

+    
+ +
+

Step 5: sprite_batch.rs add_sprite_vertices_to_batch()

+

+    
+ +
+

Step 6: Final Rendering Result

+ +
+
+ +
+

Test Results

+ + + +
FrameExpectedGotStatus
+
+ +
+

Conclusion

+

+    
+ + + + diff --git a/packages/particle/src/__tests__/sprite-batch-test.html b/packages/particle/src/__tests__/sprite-batch-test.html new file mode 100644 index 00000000..4c9c541f --- /dev/null +++ b/packages/particle/src/__tests__/sprite-batch-test.html @@ -0,0 +1,328 @@ + + + + Sprite Batch Rendering Test (模拟 sprite_batch.rs) + + + +

Sprite Batch Rendering Test

+

This test simulates exactly how sprite_batch.rs renders sprites with UV coordinates.

+ +

Test Texture (2x2 spritesheet)

+
+┌─────────┬─────────┐
+│ RED (0) │ GREEN(1)│  v: 0.0 - 0.5
+├─────────┼─────────┤
+│ BLUE(2) │ YELLOW(3)│  v: 0.5 - 1.0
+└─────────┴─────────┘
+    
+ +

Rendering Test (same as sprite_batch.rs)

+
+
+ +
Main rendering canvas
+
+
+

sprite_batch.rs vertex mapping:

+
+corners = [
+  (-ox, height-oy),  // 0: Top-left (high Y)
+  (width-ox, height-oy), // 1: Top-right
+  (width-ox, -oy),   // 2: Bottom-right (low Y)
+  (-ox, -oy),        // 3: Bottom-left
+];
+
+tex_coords = [
+  [u0, v0], // 0: Top-left
+  [u1, v0], // 1: Top-right
+  [u1, v1], // 2: Bottom-right
+  [u0, v1], // 3: Bottom-left
+];
+
+indices = [0, 1, 2, 2, 3, 0];
+            
+
+
+ +

Frame-by-Frame Test Results

+
+ +

Conclusion

+

+
+    
+
+
diff --git a/packages/particle/src/__tests__/uv-calculation.test.ts b/packages/particle/src/__tests__/uv-calculation.test.ts
new file mode 100644
index 00000000..cb7d075b
--- /dev/null
+++ b/packages/particle/src/__tests__/uv-calculation.test.ts
@@ -0,0 +1,142 @@
+/**
+ * UV 计算测试
+ * UV Calculation Test
+ *
+ * 用于验证 TextureSheetAnimation 的 UV 坐标计算是否正确
+ * Used to verify TextureSheetAnimation UV coordinate calculation
+ */
+
+/**
+ * 模拟 ParticleRenderDataProvider 中的 UV 计算
+ * Simulate UV calculation from ParticleRenderDataProvider
+ */
+function calculateUV(frame: number, tilesX: number, tilesY: number) {
+    const col = frame % tilesX;
+    const row = Math.floor(frame / tilesX);
+    const uWidth = 1 / tilesX;
+    const vHeight = 1 / tilesY;
+
+    const u0 = col * uWidth;
+    const u1 = (col + 1) * uWidth;
+    const v0 = row * vHeight;
+    const v1 = (row + 1) * vHeight;
+
+    return { u0, v0, u1, v1, col, row };
+}
+
+/**
+ * 测试 4x4 spritesheet (16帧)
+ *
+ * 预期布局(标准 spritesheet,从左上角开始):
+ * ┌────┬────┬────┬────┐
+ * │ 0  │ 1  │ 2  │ 3  │  row=0, v: 0.00 - 0.25
+ * ├────┼────┼────┼────┤
+ * │ 4  │ 5  │ 6  │ 7  │  row=1, v: 0.25 - 0.50
+ * ├────┼────┼────┼────┤
+ * │ 8  │ 9  │ 10 │ 11 │  row=2, v: 0.50 - 0.75
+ * ├────┼────┼────┼────┤
+ * │ 12 │ 13 │ 14 │ 15 │  row=3, v: 0.75 - 1.00
+ * └────┴────┴────┴────┘
+ */
+function test4x4Spritesheet() {
+    console.log('=== 4x4 Spritesheet UV Test ===\n');
+
+    const tilesX = 4;
+    const tilesY = 4;
+
+    console.log('Expected layout (standard spritesheet, top-left origin):');
+    console.log('Frame 0 should be at TOP-LEFT (v: 0.00-0.25)');
+    console.log('Frame 12 should be at BOTTOM-LEFT (v: 0.75-1.00)\n');
+
+    // 测试关键帧
+    const testFrames = [0, 1, 4, 5, 12, 15];
+
+    for (const frame of testFrames) {
+        const uv = calculateUV(frame, tilesX, tilesY);
+        console.log(`Frame ${frame.toString().padStart(2)}: col=${uv.col}, row=${uv.row}`);
+        console.log(`         UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
+        console.log('');
+    }
+}
+
+/**
+ * 测试 2x2 spritesheet (4帧) - 最简单的情况
+ */
+function test2x2Spritesheet() {
+    console.log('=== 2x2 Spritesheet UV Test ===\n');
+
+    const tilesX = 2;
+    const tilesY = 2;
+
+    console.log('Layout:');
+    console.log('┌─────┬─────┐');
+    console.log('│  0  │  1  │  v: 0.0 - 0.5');
+    console.log('├─────┼─────┤');
+    console.log('│  2  │  3  │  v: 0.5 - 1.0');
+    console.log('└─────┴─────┘\n');
+
+    for (let frame = 0; frame < 4; frame++) {
+        const uv = calculateUV(frame, tilesX, tilesY);
+        console.log(`Frame ${frame}: col=${uv.col}, row=${uv.row}`);
+        console.log(`       UV: [${uv.u0.toFixed(2)}, ${uv.v0.toFixed(2)}, ${uv.u1.toFixed(2)}, ${uv.v1.toFixed(2)}]`);
+    }
+    console.log('');
+}
+
+/**
+ * WebGL 纹理坐标系说明
+ */
+function explainWebGLTextureCoords() {
+    console.log('=== WebGL Texture Coordinate System ===\n');
+
+    console.log('Without UNPACK_FLIP_Y_WEBGL:');
+    console.log('- Image row 0 (top of image file) -> stored at texture row 0');
+    console.log('- Texture coordinate V=0 samples texture row 0');
+    console.log('- Therefore: V=0 = image top, V=1 = image bottom');
+    console.log('');
+
+    console.log('sprite_batch.rs vertex mapping:');
+    console.log('- Vertex 0 (top-left on screen, high Y) uses tex_coords[0] = [u0, v0]');
+    console.log('- Vertex 2 (bottom-right on screen, low Y) uses tex_coords[2] = [u1, v1]');
+    console.log('');
+
+    console.log('Expected behavior:');
+    console.log('- Frame 0 UV [0, 0, 0.25, 0.25] should show TOP-LEFT quarter of spritesheet');
+    console.log('- If frame 0 shows BOTTOM-LEFT, the image is being rendered upside down');
+    console.log('');
+}
+
+/**
+ * 诊断当前问题
+ */
+function diagnoseIssue() {
+    console.log('=== Diagnosis ===\n');
+
+    console.log('If TextureSheetAnimation shows wrong frames, check:');
+    console.log('');
+    console.log('1. Is frame 0 showing the TOP-LEFT of the spritesheet?');
+    console.log('   - YES: UV calculation is correct');
+    console.log('   - NO (shows bottom-left): Image is flipped vertically in WebGL');
+    console.log('');
+    console.log('2. Are frames playing in wrong ORDER (e.g., 3,2,1,0 instead of 0,1,2,3)?');
+    console.log('   - Check animation frame index calculation');
+    console.log('');
+    console.log('3. Is the spritesheet itself laid out correctly?');
+    console.log('   - Frame 0 should be at TOP-LEFT of the image file');
+    console.log('');
+}
+
+// 运行所有测试
+export function runUVTests() {
+    explainWebGLTextureCoords();
+    test2x2Spritesheet();
+    test4x4Spritesheet();
+    diagnoseIssue();
+}
+
+// 如果直接运行此文件
+if (typeof window !== 'undefined') {
+    runUVTests();
+}
+
+export { calculateUV, test2x2Spritesheet, test4x4Spritesheet };
diff --git a/packages/particle/src/__tests__/webgl-uv-test.html b/packages/particle/src/__tests__/webgl-uv-test.html
new file mode 100644
index 00000000..21ddd587
--- /dev/null
+++ b/packages/particle/src/__tests__/webgl-uv-test.html
@@ -0,0 +1,278 @@
+
+
+
+    WebGL UV Coordinate Test
+    
+
+
+    

WebGL UV Coordinate System Test

+ +

1. Test Texture (2x2 grid)

+
+Image file layout (how it looks in image editor):
+┌─────────┬─────────┐
+│ RED (0) │ GREEN(1)│  row 0 (top of image file)
+├─────────┼─────────┤
+│ BLUE(2) │ YELLOW(3)│  row 1 (bottom of image file)
+└─────────┴─────────┘
+    
+ + ← This is the source texture + +

2. UV Sampling Test

+

Each square below samples a different UV region. We test what color appears.

+ +
+
UV [0, 0, 0.5, 0.5] (Frame 0):
+ +
+
+ +
+
UV [0.5, 0, 1, 0.5] (Frame 1):
+ +
+
+ +
+
UV [0, 0.5, 0.5, 1] (Frame 2):
+ +
+
+ +
+
UV [0.5, 0.5, 1, 1] (Frame 3):
+ +
+
+ +

3. Conclusion

+

+
+    
+
+
diff --git a/packages/particle/src/rendering/ParticleRenderDataProvider.ts b/packages/particle/src/rendering/ParticleRenderDataProvider.ts
index ba997fc1..6ae9c8a0 100644
--- a/packages/particle/src/rendering/ParticleRenderDataProvider.ts
+++ b/packages/particle/src/rendering/ParticleRenderDataProvider.ts
@@ -194,17 +194,40 @@ export class ParticleRenderDataProvider implements IRenderDataProvider {
                     this._transforms[tOffset + 5] = 0.5; // originX
                     this._transforms[tOffset + 6] = 0.5; // originY
 
-                    // Texture ID: 设置为 0,让 EngineRenderSystem 通过 textureGuid 解析
-                    // Set to 0, let EngineRenderSystem resolve via textureGuid
-                    // 这样可以避免场景恢复后 textureId 过期导致的纹理混乱问题
-                    // This avoids texture confusion when textureId becomes stale after scene restore
-                    this._textureIds[particleIndex] = 0;
+                    // Texture ID: 优先使用组件上预加载的 textureId,否则让 EngineRenderSystem 通过 textureGuid 解析
+                    // Prefer using pre-loaded textureId from component, otherwise let EngineRenderSystem resolve via textureGuid
+                    this._textureIds[particleIndex] = component.textureId;
 
-                    // UV (full texture)
-                    this._uvs[uvOffset] = 0;
-                    this._uvs[uvOffset + 1] = 0;
-                    this._uvs[uvOffset + 2] = 1;
-                    this._uvs[uvOffset + 3] = 1;
+                    // UV - 支持精灵图帧动画 | Support spritesheet animation
+                    if (p._animTilesX !== undefined && p._animTilesY !== undefined && p._animFrame !== undefined) {
+                        // 计算帧的 UV 坐标 | Calculate frame UV coordinates
+                        // WebGL 纹理坐标:V=0 采样纹理行0(即图像顶部)
+                        // WebGL texture coords: V=0 samples texture row 0 (image top)
+                        const tilesX = p._animTilesX;
+                        const tilesY = p._animTilesY;
+                        const frame = p._animFrame;
+                        const col = frame % tilesX;
+                        const row = Math.floor(frame / tilesX);
+                        const uWidth = 1 / tilesX;
+                        const vHeight = 1 / tilesY;
+
+                        // UV: u0, v0, u1, v1
+                        const u0 = col * uWidth;
+                        const u1 = (col + 1) * uWidth;
+                        const v0 = row * vHeight;
+                        const v1 = (row + 1) * vHeight;
+
+                        this._uvs[uvOffset] = u0;
+                        this._uvs[uvOffset + 1] = v0;
+                        this._uvs[uvOffset + 2] = u1;
+                        this._uvs[uvOffset + 3] = v1;
+                    } else {
+                        // 默认:使用完整纹理 | Default: use full texture
+                        this._uvs[uvOffset] = 0;
+                        this._uvs[uvOffset + 1] = 0;
+                        this._uvs[uvOffset + 2] = 1;
+                        this._uvs[uvOffset + 3] = 1;
+                    }
 
                     // Color (packed ABGR for WebGL)
                     this._colors[particleIndex] = Color.packABGR(
diff --git a/packages/particle/src/systems/ClickFxSystem.ts b/packages/particle/src/systems/ClickFxSystem.ts
index 38b0ab98..795f9bc0 100644
--- a/packages/particle/src/systems/ClickFxSystem.ts
+++ b/packages/particle/src/systems/ClickFxSystem.ts
@@ -8,10 +8,8 @@
 
 import { EntitySystem, Matcher, Entity, ECSSystem, PluginServiceRegistry, createServiceToken } from '@esengine/ecs-framework';
 import { Input, MouseButton, TransformComponent, SortingLayers } from '@esengine/engine-core';
-import type { IAssetManager } from '@esengine/asset-system';
 import { ClickFxComponent, ClickFxTriggerMode } from '../ClickFxComponent';
 import { ParticleSystemComponent, RenderSpace } from '../ParticleSystemComponent';
-import type { IParticleAsset } from '../loaders/ParticleLoader';
 
 // ============================================================================
 // 本地服务令牌定义 | Local Service Token Definitions
@@ -66,7 +64,6 @@ const RenderSystemToken = createServiceToken('renderSystem'
 export class ClickFxSystem extends EntitySystem {
     private _engineBridge: IEngineBridge | null = null;
     private _renderSystem: IEngineRenderSystem | null = null;
-    private _assetManager: IAssetManager | null = null;
     private _entitiesToDestroy: Entity[] = [];
     private _canvas: HTMLCanvasElement | null = null;
 
@@ -74,14 +71,6 @@ export class ClickFxSystem extends EntitySystem {
         super(Matcher.empty().all(ClickFxComponent));
     }
 
-    /**
-     * 设置资产管理器
-     * Set asset manager
-     */
-    setAssetManager(assetManager: IAssetManager | null): void {
-        this._assetManager = assetManager;
-    }
-
     /**
      * 设置服务注册表(用于获取 EngineBridge 和 RenderSystem)
      * Set service registry (for getting EngineBridge and RenderSystem)
@@ -339,8 +328,11 @@ export class ClickFxSystem extends EntitySystem {
         const transform = effectEntity.addComponent(new TransformComponent(screenSpaceX, screenSpaceY));
         transform.setScale(clickFx.scale, clickFx.scale, 1);
 
-        // 添加 ParticleSystem | Add ParticleSystem
-        const particleSystem = effectEntity.addComponent(new ParticleSystemComponent());
+        // 创建 ParticleSystemComponent 并预先设置 GUID(在添加到实体前)
+        // Create ParticleSystemComponent and set GUID before adding to entity
+        // 这样 ParticleUpdateSystem.onAdded 触发时已经有 GUID 了
+        // So ParticleUpdateSystem.onAdded has the GUID when triggered
+        const particleSystem = new ParticleSystemComponent();
         particleSystem.particleAssetGuid = particleGuid;
         particleSystem.autoPlay = true;
         // 使用 ScreenOverlay 层和屏幕空间渲染
@@ -349,31 +341,12 @@ export class ClickFxSystem extends EntitySystem {
         particleSystem.orderInLayer = 0;
         particleSystem.renderSpace = RenderSpace.Screen;
 
+        // 添加组件到实体(触发 ParticleUpdateSystem 的初始化和资产加载)
+        // Add component to entity (triggers ParticleUpdateSystem initialization and asset loading)
+        effectEntity.addComponent(particleSystem);
+
         // 记录活跃特效 | Record active effect
         clickFx.addActiveEffect(effectEntity.id);
-
-        // 异步加载并播放 | Async load and play
-        if (this._assetManager) {
-            this._assetManager.loadAsset(particleGuid).then(result => {
-                if (result?.asset) {
-                    particleSystem.setAssetData(result.asset);
-                    // 应用资产的排序属性 | Apply sorting properties from asset
-                    if (result.asset.sortingLayer) {
-                        particleSystem.sortingLayer = result.asset.sortingLayer;
-                    }
-                    if (result.asset.orderInLayer !== undefined) {
-                        particleSystem.orderInLayer = result.asset.orderInLayer;
-                    }
-                    particleSystem.play();
-                } else {
-                    console.warn(`[ClickFxSystem] Failed to load particle asset: ${particleGuid}`);
-                }
-            }).catch(error => {
-                console.error(`[ClickFxSystem] Error loading particle asset ${particleGuid}:`, error);
-            });
-        } else {
-            console.warn('[ClickFxSystem] AssetManager not set, cannot load particle asset');
-        }
     }
 
     /**
diff --git a/packages/particle/src/systems/ParticleSystem.ts b/packages/particle/src/systems/ParticleSystem.ts
index df23a125..0cf2aa97 100644
--- a/packages/particle/src/systems/ParticleSystem.ts
+++ b/packages/particle/src/systems/ParticleSystem.ts
@@ -185,6 +185,11 @@ export class ParticleUpdateSystem extends EntitySystem {
                 }
             }
 
+            // 如果正在初始化中,跳过处理 | Skip processing if initializing
+            if (this._loadingComponents.has(particle)) {
+                continue;
+            }
+
             // 检测资产 GUID 变化并重新加载 | Detect asset GUID change and reload
             // 这使得编辑器中选择新的粒子资产时能够立即切换
             // This allows immediate switching when selecting a new particle asset in the editor
@@ -205,8 +210,9 @@ export class ParticleUpdateSystem extends EntitySystem {
                 particle.update(deltaTime, worldX, worldY, worldRotation, worldScaleX, worldScaleY);
             }
 
-            // 尝试加载纹理(如果还没有加载)| Try to load texture if not loaded yet
-            if (particle.textureId === 0) {
+            // 尝试加载纹理(如果还没有加载且不在初始化中)
+            // Try to load texture if not loaded yet and not initializing
+            if (particle.textureId === 0 && !this._loadingComponents.has(particle)) {
                 this.loadParticleTexture(particle);
             }
 
@@ -262,56 +268,65 @@ export class ParticleUpdateSystem extends EntitySystem {
      * Async initialize particle system
      */
     private async _initializeParticle(entity: Entity, particle: ParticleSystemComponent): Promise {
-        // 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
-        if (particle.particleAssetGuid) {
-            const asset = await this._loadParticleAsset(particle.particleAssetGuid);
-            if (asset) {
-                particle.setAssetData(asset);
-                // 应用资产的排序属性 | Apply sorting properties from asset
-                if (asset.sortingLayer) {
-                    particle.sortingLayer = asset.sortingLayer;
-                }
-                if (asset.orderInLayer !== undefined) {
-                    particle.orderInLayer = asset.orderInLayer;
+        // 标记为正在初始化,防止 process 中重复调用 loadParticleTexture
+        // Mark as initializing to prevent duplicate loadParticleTexture calls in process
+        this._loadingComponents.add(particle);
+
+        try {
+            // 如果有资产 GUID,先加载资产 | Load asset first if GUID is set
+            if (particle.particleAssetGuid) {
+                const asset = await this._loadParticleAsset(particle.particleAssetGuid);
+                if (asset) {
+                    particle.setAssetData(asset);
+                    // 应用资产的排序属性 | Apply sorting properties from asset
+                    if (asset.sortingLayer) {
+                        particle.sortingLayer = asset.sortingLayer;
+                    }
+                    if (asset.orderInLayer !== undefined) {
+                        particle.orderInLayer = asset.orderInLayer;
+                    }
                 }
             }
-        }
 
-        // 初始化粒子系统(不自动播放,由下面的逻辑控制)
-        // Initialize particle system (don't auto play, controlled by logic below)
-        particle.ensureBuilt();
+            // 初始化粒子系统(不自动播放,由下面的逻辑控制)
+            // Initialize particle system (don't auto play, controlled by logic below)
+            particle.ensureBuilt();
 
-        // 加载纹理 | Load texture
-        await this.loadParticleTexture(particle);
+            // 加载纹理 | Load texture
+            await this.loadParticleTexture(particle);
 
-        // 注册到渲染数据提供者 | Register to render data provider
-        // 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
-        let transform: ITransformComponent | null = null;
-        if (this._transformType) {
-            transform = entity.getComponent(this._transformType);
-        }
-        // 即使没有 Transform,也要注册粒子系统(使用原点位置) | Register particle system even without Transform (use origin position)
-        if (transform) {
-            this._renderDataProvider.register(particle, transform);
-        } else {
-            this._renderDataProvider.register(particle, { position: { x: 0, y: 0 } });
-        }
-
-        // 记录已加载的资产 GUID | Record loaded asset GUID
-        this._lastLoadedGuids.set(particle, particle.particleAssetGuid);
-
-        // 决定是否自动播放 | Decide whether to auto play
-        // 编辑器模式:有资产时自动播放预览 | Editor mode: auto play preview if has asset
-        // 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay setting
-        const isEditorMode = this.scene?.isEditorMode ?? false;
-        if (particle.particleAssetGuid && particle.loadedAsset) {
-            if (isEditorMode) {
-                // 编辑器模式:始终播放预览 | Editor mode: always play preview
-                particle.play();
-            } else if (particle.autoPlay) {
-                // 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay
-                particle.play();
+            // 注册到渲染数据提供者 | Register to render data provider
+            // 尝试获取 Transform,如果没有则使用默认位置 | Try to get Transform, use default position if not available
+            let transform: ITransformComponent | null = null;
+            if (this._transformType) {
+                transform = entity.getComponent(this._transformType);
             }
+            // 即使没有 Transform,也要注册粒子系统(使用原点位置) | Register particle system even without Transform (use origin position)
+            if (transform) {
+                this._renderDataProvider.register(particle, transform);
+            } else {
+                this._renderDataProvider.register(particle, { position: { x: 0, y: 0 } });
+            }
+
+            // 记录已加载的资产 GUID | Record loaded asset GUID
+            this._lastLoadedGuids.set(particle, particle.particleAssetGuid);
+
+            // 决定是否自动播放 | Decide whether to auto play
+            // 编辑器模式:有资产时自动播放预览 | Editor mode: auto play preview if has asset
+            // 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay setting
+            const isEditorMode = this.scene?.isEditorMode ?? false;
+            if (particle.particleAssetGuid && particle.loadedAsset) {
+                if (isEditorMode) {
+                    // 编辑器模式:始终播放预览 | Editor mode: always play preview
+                    particle.play();
+                } else if (particle.autoPlay) {
+                    // 运行时模式:根据 autoPlay 设置 | Runtime mode: based on autoPlay
+                    particle.play();
+                }
+            }
+        } finally {
+            // 初始化完成,移除加载标记 | Initialization complete, remove loading mark
+            this._loadingComponents.delete(particle);
         }
     }
 
@@ -329,9 +344,25 @@ export class ParticleUpdateSystem extends EntitySystem {
         const currentGuid = particle.particleAssetGuid;
         const lastGuid = this._lastLoadedGuids.get(particle);
 
-        // 如果 GUID 没有变化,或者正在加载中,跳过
-        // Skip if GUID hasn't changed or already loading
-        if (currentGuid === lastGuid || this._loadingComponents.has(particle)) {
+        // 如果正在加载中,跳过
+        // Skip if already loading
+        if (this._loadingComponents.has(particle)) {
+            return;
+        }
+
+        // 检查是否需要重新加载:
+        // 1. GUID 变化了
+        // 2. 或者 GUID 相同但资产数据丢失(场景恢复后)
+        // 3. 或者 GUID 相同但纹理 ID 无效(纹理被清除后)
+        // Check if reload is needed:
+        // 1. GUID changed
+        // 2. Or GUID is same but asset data is lost (after scene restore)
+        // 3. Or GUID is same but texture ID is invalid (after texture clear)
+        const needsReload = currentGuid !== lastGuid ||
+            (currentGuid && !particle.loadedAsset) ||
+            (currentGuid && particle.textureId === 0);
+
+        if (!needsReload) {
             return;
         }
 
@@ -410,35 +441,70 @@ export class ParticleUpdateSystem extends EntitySystem {
             } catch (error) {
                 console.error('[ParticleUpdateSystem] Failed to load texture by GUID:', textureGuid, error);
                 // 加载失败时使用默认纹理 | Use default texture on load failure
-                await this._ensureDefaultTexture();
-                particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+                const loaded = await this._ensureDefaultTexture();
+                if (loaded) {
+                    particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+                }
             }
         } else {
             // 没有纹理 GUID 时使用默认粒子纹理 | Use default particle texture when no GUID
-            await this._ensureDefaultTexture();
-            particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+            const loaded = await this._ensureDefaultTexture();
+            if (loaded) {
+                particle.textureId = DEFAULT_PARTICLE_TEXTURE_ID;
+            }
         }
     }
 
     /**
      * 确保默认粒子纹理已加载
      * Ensure default particle texture is loaded
+     *
+     * 使用 loadTextureAsync API 等待纹理实际加载完成,
+     * 避免显示灰色占位符的问题。
+     * Uses loadTextureAsync API to wait for actual texture completion,
+     * avoiding the gray placeholder issue.
+     *
+     * @returns 是否成功加载 | Whether successfully loaded
      */
-    private async _ensureDefaultTexture(): Promise {
-        if (this._defaultTextureLoaded || this._defaultTextureLoading) return;
-        if (!this._engineBridge) return;
+    private async _ensureDefaultTexture(): Promise {
+        // 已加载过 | Already loaded
+        if (this._defaultTextureLoaded) return true;
+
+        // 正在加载中,等待完成 | Loading in progress, wait for completion
+        if (this._defaultTextureLoading) {
+            // 轮询等待加载完成 | Poll until loading completes
+            while (this._defaultTextureLoading) {
+                await new Promise(resolve => setTimeout(resolve, 10));
+            }
+            return this._defaultTextureLoaded;
+        }
+
+        // 没有引擎桥接,无法加载 | No engine bridge, cannot load
+        if (!this._engineBridge) {
+            console.warn('[ParticleUpdateSystem] EngineBridge not set, cannot load default texture');
+            return false;
+        }
 
         this._defaultTextureLoading = true;
         try {
             const dataUrl = generateDefaultParticleTextureDataURL();
             if (dataUrl) {
-                await this._engineBridge.loadTexture(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
+                // 优先使用 loadTextureAsync(等待纹理就绪)
+                // Prefer loadTextureAsync (waits for texture ready)
+                if (this._engineBridge.loadTextureAsync) {
+                    await this._engineBridge.loadTextureAsync(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
+                } else {
+                    // 回退到旧 API(可能显示灰色占位符)
+                    // Fallback to old API (may show gray placeholder)
+                    await this._engineBridge.loadTexture(DEFAULT_PARTICLE_TEXTURE_ID, dataUrl);
+                }
                 this._defaultTextureLoaded = true;
             }
         } catch (error) {
             console.error('[ParticleUpdateSystem] Failed to create default particle texture:', error);
         }
         this._defaultTextureLoading = false;
+        return this._defaultTextureLoaded;
     }
 
     protected override onRemoved(entity: Entity): void {
diff --git a/packages/physics-rapier2d/src/Physics2DComponentsModule.ts b/packages/physics-rapier2d/src/Physics2DComponentsModule.ts
index 5650c9b6..04f5dbf1 100644
--- a/packages/physics-rapier2d/src/Physics2DComponentsModule.ts
+++ b/packages/physics-rapier2d/src/Physics2DComponentsModule.ts
@@ -6,7 +6,7 @@
  * 用于编辑器中的组件序列化/反序列化
  */
 
-import { ComponentRegistry } from '@esengine/ecs-framework';
+import type { IComponentRegistry } from '@esengine/ecs-framework';
 import type { IRuntimeModule } from '@esengine/engine-core';
 
 // Components (no WASM dependency)
@@ -26,8 +26,9 @@ import { PolygonCollider2DComponent } from './components/PolygonCollider2DCompon
 export class Physics2DComponentsModule implements IRuntimeModule {
     /**
      * 注册组件到 ComponentRegistry
+     * Register components to ComponentRegistry
      */
-    registerComponents(registry: typeof ComponentRegistry): void {
+    registerComponents(registry: IComponentRegistry): void {
         registry.register(Rigidbody2DComponent);
         registry.register(BoxCollider2DComponent);
         registry.register(CircleCollider2DComponent);
diff --git a/packages/physics-rapier2d/src/PhysicsEditorPlugin.ts b/packages/physics-rapier2d/src/PhysicsEditorPlugin.ts
index 403e9ec6..695d472f 100644
--- a/packages/physics-rapier2d/src/PhysicsEditorPlugin.ts
+++ b/packages/physics-rapier2d/src/PhysicsEditorPlugin.ts
@@ -4,11 +4,14 @@
  * 编辑器版本的物理插件,不包含 WASM 依赖。
  * Editor version of physics plugin, without WASM dependencies.
  *
- * 用于编辑器中注册插件清单,但不创建运行时模块。
- * 运行时使用 PhysicsPlugin from '@esengine/physics-rapier2d/runtime'
+ * 使用轻量级 Physics2DComponentsModule 注册组件,
+ * 使场景中的物理组件可以正确序列化/反序列化。
+ * Uses lightweight Physics2DComponentsModule to register components,
+ * enabling proper serialization/deserialization of physics components in scenes.
  */
 
 import type { IRuntimePlugin, ModuleManifest } from '@esengine/engine-core';
+import { Physics2DComponentsModule } from './Physics2DComponentsModule';
 
 const manifest: ModuleManifest = {
     id: '@esengine/physics-rapier2d',
@@ -30,12 +33,15 @@ const manifest: ModuleManifest = {
 };
 
 /**
- * 编辑器物理插件(无运行时模块)
- * Editor physics plugin (no runtime module)
+ * 编辑器物理插件(轻量级运行时模块)
+ * Editor physics plugin (lightweight runtime module)
  *
- * 编辑器使用此版本注册插件,运行时使用带 WASM 的完整版本。
+ * 使用 Physics2DComponentsModule 注册组件,用于场景反序列化。
+ * 不包含 WASM 依赖,不创建物理系统。
+ * Uses Physics2DComponentsModule for component registration (scene deserialization).
+ * No WASM dependency, no physics system creation.
  */
 export const Physics2DPlugin: IRuntimePlugin = {
-    manifest
-    // No runtime module - editor doesn't need physics simulation
+    manifest,
+    runtimeModule: new Physics2DComponentsModule()
 };
diff --git a/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts b/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts
index 69b3dd74..2550a845 100644
--- a/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts
+++ b/packages/physics-rapier2d/src/PhysicsRuntimeModule.ts
@@ -4,8 +4,7 @@
  * 提供 Rapier2D 物理引擎的 ECS 集成
  */
 
-import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
-import { ComponentRegistry } from '@esengine/ecs-framework';
+import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
 import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
 import { WasmLibraryLoaderFactory } from '@esengine/platform-common';
 import type * as RAPIER from '@esengine/rapier2d';
@@ -101,10 +100,11 @@ class PhysicsRuntimeModule implements IRuntimeModule {
 
     /**
      * 注册物理组件
+     * Register physics components
      *
-     * @param registry - 组件注册表
+     * @param registry - 组件注册表 | Component registry
      */
-    registerComponents(registry: typeof ComponentRegistry): void {
+    registerComponents(registry: IComponentRegistry): void {
         registry.register(Rigidbody2DComponent);
         registry.register(BoxCollider2DComponent);
         registry.register(CircleCollider2DComponent);
diff --git a/packages/physics-rapier2d/src/loaders/Rapier2DLoaderConfig.ts b/packages/physics-rapier2d/src/loaders/Rapier2DLoaderConfig.ts
index b3195b90..cacb8395 100644
--- a/packages/physics-rapier2d/src/loaders/Rapier2DLoaderConfig.ts
+++ b/packages/physics-rapier2d/src/loaders/Rapier2DLoaderConfig.ts
@@ -9,11 +9,16 @@ import { isEditorEnvironment } from '@esengine/platform-common';
 /**
  * 获取 WASM 路径
  * Get WASM path based on environment
+ *
+ * Editor: engine/rapier2d/pkg/rapier_wasm2d_bg.wasm (deployed by vite build plugin)
+ * Runtime: wasm/rapier_wasm2d_bg.wasm (deployed by game build)
  */
 function getWasmPath(): string {
     const isEditor = isEditorEnvironment();
+    // Editor uses dist/engine/rapier2d/pkg/ structure (from vite copy-engine-modules plugin)
+    // 编辑器使用 dist/engine/rapier2d/pkg/ 结构(来自 vite copy-engine-modules 插件)
     const path = isEditor
-        ? 'engine/physics-rapier2d/rapier_wasm2d_bg.wasm'
+        ? 'engine/rapier2d/pkg/rapier_wasm2d_bg.wasm'
         : 'wasm/rapier_wasm2d_bg.wasm';
 
     console.log(`[Rapier2D] isEditor=${isEditor}, wasmPath=${path}`);
@@ -32,7 +37,7 @@ export const Rapier2DLoaderConfig: WasmLibraryConfig = {
     web: {
         /**
          * WASM 文件路径
-         * 编辑器: engine/physics-rapier2d/rapier_wasm2d_bg.wasm
+         * 编辑器: engine/rapier2d/pkg/rapier_wasm2d_bg.wasm
          * 运行时: wasm/rapier_wasm2d_bg.wasm
          */
         get wasmPath(): string {
diff --git a/packages/platform-web/src/BrowserRuntime.ts b/packages/platform-web/src/BrowserRuntime.ts
index 2715f6c9..b6f8540e 100644
--- a/packages/platform-web/src/BrowserRuntime.ts
+++ b/packages/platform-web/src/BrowserRuntime.ts
@@ -16,7 +16,10 @@ import {
     BrowserPlatformAdapter,
     runtimePluginManager,
     BrowserFileSystemService,
-    type IPlugin
+    RuntimeSceneManager,
+    RuntimeSceneManagerToken,
+    type IPlugin,
+    type IRuntimeSceneManager
 } from '@esengine/runtime-core';
 import { isValidGUID, type IAssetManager } from '@esengine/asset-system';
 import { BrowserAssetReader } from './BrowserAssetReader';
@@ -55,6 +58,7 @@ export class BrowserRuntime {
     private _assetBaseUrl: string;
     private _fileSystem: BrowserFileSystemService | null = null;
     private _assetReader: BrowserAssetReader | null = null;
+    private _sceneManager: RuntimeSceneManager | null = null;
     private _initialized = false;
 
     constructor(config: RuntimeConfig) {
@@ -164,10 +168,60 @@ export class BrowserRuntime {
         // 为渲染系统设置资产路径解析器
         this._setupAssetPathResolver();
 
+        // Initialize scene manager
+        // 初始化场景管理器
+        this._initializeSceneManager();
+
         this._initialized = true;
         console.log('[Runtime] Initialized');
     }
 
+    /**
+     * Initialize the runtime scene manager
+     * 初始化运行时场景管理器
+     */
+    private _initializeSceneManager(): void {
+        if (!this._runtime) return;
+
+        // Create scene manager with scene loader
+        // 使用场景加载器创建场景管理器
+        this._sceneManager = new RuntimeSceneManager(
+            (url: string) => this._runtime!.loadSceneFromUrl(url),
+            './scenes'
+        );
+
+        // Auto-discover scenes from catalog
+        // 从目录自动发现场景
+        // scenes 是运行时扩展字段,不在 IAssetCatalog 接口中
+        // scenes is a runtime extension field, not in IAssetCatalog interface
+        const catalog = this._fileSystem?.catalog as { scenes?: Array<{ name: string; path: string }> } | null;
+        if (catalog?.scenes) {
+            const scenes = catalog.scenes.map((scene) => ({
+                name: scene.name,
+                path: `./scenes/${scene.name}.ecs`
+            }));
+            this._sceneManager.registerScenes(scenes);
+        }
+
+        // Register scene manager as a service
+        // 注册场景管理器为服务
+        const serviceRegistry = this._runtime.getServiceRegistry();
+        if (serviceRegistry) {
+            serviceRegistry.register(RuntimeSceneManagerToken, this._sceneManager);
+        }
+
+        // Also register in Core.services for global access (systems can access it)
+        // 同时注册到 Core.services 供全局访问(系统可以访问)
+        // RuntimeSceneManager 实现了 IService 接口(有 dispose 方法)
+        // RuntimeSceneManager implements IService interface (has dispose method)
+        const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
+        if (!Core.services.isRegistered(GlobalSceneManagerKey)) {
+            Core.services.registerInstance(GlobalSceneManagerKey, this._sceneManager);
+        }
+
+        console.log('[Runtime] Scene manager initialized');
+    }
+
     /**
      * Set up asset path resolver for the render system
      * 为渲染系统设置资产路径解析器
@@ -226,12 +280,21 @@ export class BrowserRuntime {
     /**
      * Load a scene from URL
      * 从 URL 加载场景
+     *
+     * @param sceneUrl 场景 URL 或名称 | Scene URL or name
      */
     async loadScene(sceneUrl: string): Promise {
         if (!this._runtime) {
             throw new Error('Runtime not initialized. Call initialize() first.');
         }
-        await this._runtime.loadSceneFromUrl(sceneUrl);
+
+        // Use scene manager if available for proper tracking
+        // 如果可用,使用场景管理器进行正确跟踪
+        if (this._sceneManager) {
+            await this._sceneManager.loadSceneByPath(sceneUrl);
+        } else {
+            await this._runtime.loadSceneFromUrl(sceneUrl);
+        }
     }
 
     /**
@@ -287,6 +350,33 @@ export class BrowserRuntime {
         return this._runtime?.assetManager ?? null;
     }
 
+    /**
+     * Get the scene manager
+     * 获取场景管理器
+     *
+     * Use this to load scenes, check available scenes, and listen to scene events.
+     * 使用它来加载场景、检查可用场景和监听场景事件。
+     *
+     * @example
+     * ```typescript
+     * // Load a scene by name
+     * await runtime.sceneManager?.loadScene('Level1');
+     *
+     * // Get list of available scenes
+     * const scenes = runtime.sceneManager?.availableScenes;
+     *
+     * // Listen to scene load events
+     * runtime.sceneManager?.onLoadComplete((sceneName) => {
+     *     console.log(`Scene loaded: ${sceneName}`);
+     * });
+     * ```
+     *
+     * @returns The scene manager instance, or null if not initialized
+     */
+    get sceneManager(): IRuntimeSceneManager | null {
+        return this._sceneManager;
+    }
+
     /**
      * Check if runtime is initialized
      * 检查运行时是否已初始化
diff --git a/packages/platform-web/src/index.ts b/packages/platform-web/src/index.ts
index cc200553..ac254613 100644
--- a/packages/platform-web/src/index.ts
+++ b/packages/platform-web/src/index.ts
@@ -27,6 +27,16 @@ export { default } from './BrowserRuntime';
 // Asset reader
 export { BrowserAssetReader } from './BrowserAssetReader';
 
+// Re-export scene manager for convenience
+// 重新导出场景管理器以方便使用
+export {
+    RuntimeSceneManager,
+    RuntimeSceneManagerToken,
+    type IRuntimeSceneManager,
+    type SceneInfo,
+    type SceneLoadOptions
+} from '@esengine/runtime-core';
+
 // ============================================
 // Web Platform Subsystems
 // ============================================
diff --git a/packages/runtime-core/src/GameRuntime.ts b/packages/runtime-core/src/GameRuntime.ts
index 00aa57e5..b67cb525 100644
--- a/packages/runtime-core/src/GameRuntime.ts
+++ b/packages/runtime-core/src/GameRuntime.ts
@@ -879,11 +879,10 @@ export class GameRuntime {
      * Save scene snapshot
      *
      * 使用二进制格式提升序列化性能,并支持 EntityRef 的正确序列化。
-     * 在保存前清除纹理缓存,确保恢复时能够从干净状态重新加载纹理。
+     * 使用路径稳定 ID 后,不再需要清除纹理缓存。
      *
      * Uses binary format for better serialization performance and supports proper
-     * EntityRef serialization. Clears texture cache before saving to ensure
-     * clean reload on restore.
+     * EntityRef serialization. With path-stable IDs, no need to clear texture cache.
      *
      * @param options 可选配置
      * @param options.useJson 是否使用 JSON 格式(用于调试),默认 false 使用二进制
@@ -895,13 +894,10 @@ export class GameRuntime {
         }
 
         try {
-            // 清除所有纹理缓存(确保恢复时重新加载)
-            // Clear all texture caches (ensures reload on restore)
-            // clearTextureMappings() 内部会同时清除 Rust 层和 JS 层的纹理缓存
-            // clearTextureMappings() internally clears both Rust and JS layer texture caches
-            if (this._engineIntegration) {
-                this._engineIntegration.clearTextureMappings();
-            }
+            // 使用路径稳定 ID 后,不再清除纹理缓存
+            // 组件保存的 textureId 在 Play/Stop 后仍然有效
+            // With path-stable IDs, no longer clear texture cache
+            // Component's saved textureId remains valid after Play/Stop
 
             // 使用二进制格式提升性能(默认)或 JSON 用于调试
             // Use binary format for performance (default) or JSON for debugging
@@ -927,9 +923,15 @@ export class GameRuntime {
      * 1. 创建所有实体和组件
      * 2. 解析所有 EntityRef 引用
      *
+     * 使用路径稳定 ID 后,不再需要清除纹理缓存。
+     * 组件保存的 textureId 在恢复后仍然有效。
+     *
      * Uses two-phase deserialization to ensure EntityRef references are properly restored:
      * 1. Create all entities and components
      * 2. Resolve all EntityRef references
+     *
+     * With path-stable IDs, no need to clear texture cache.
+     * Component's saved textureId remains valid after restore.
      */
     async restoreSceneSnapshot(): Promise {
         if (!this._scene || !this._sceneSnapshot) {
@@ -938,19 +940,17 @@ export class GameRuntime {
         }
 
         try {
-            // 清除缓存
+            // 清除 Tilemap 缓存(Tilemap 使用独立的缓存机制)
+            // Clear Tilemap cache (Tilemap uses its own cache mechanism)
             const tilemapSystem = this._systemContext?.services.get(TilemapSystemToken);
             if (tilemapSystem) {
                 tilemapSystem.clearCache?.();
             }
 
-            // 清除所有纹理并重置状态(修复 Play/Stop 后纹理 ID 混乱的问题)
-            // Clear all textures and reset state (fixes texture ID confusion after Play/Stop)
-            // clearTextureMappings() 内部会同时清除 Rust 层和 JS 层的纹理缓存
-            // clearTextureMappings() internally clears both Rust and JS layer texture caches
-            if (this._engineIntegration) {
-                this._engineIntegration.clearTextureMappings();
-            }
+            // 使用路径稳定 ID 后,不再清除纹理缓存
+            // 组件保存的 textureId 在 Play/Stop 后仍然有效
+            // With path-stable IDs, no longer clear texture cache
+            // Component's saved textureId remains valid after Play/Stop
 
             // 反序列化场景(SceneSerializer 内部使用 SerializationContext 处理 EntityRef)
             // Deserialize scene (SceneSerializer internally uses SerializationContext for EntityRef)
diff --git a/packages/runtime-core/src/PluginManager.ts b/packages/runtime-core/src/PluginManager.ts
index ea1b1117..b7b39b95 100644
--- a/packages/runtime-core/src/PluginManager.ts
+++ b/packages/runtime-core/src/PluginManager.ts
@@ -3,7 +3,7 @@
  * 运行时插件管理器
  */
 
-import { ComponentRegistry, ServiceContainer } from '@esengine/ecs-framework';
+import { GlobalComponentRegistry, ServiceContainer } from '@esengine/ecs-framework';
 import type { IScene } from '@esengine/ecs-framework';
 import type { IRuntimePlugin, IRuntimeModule, SystemContext, ModuleManifest } from '@esengine/engine-core';
 
@@ -60,7 +60,7 @@ export class RuntimePluginManager {
             const mod = plugin.runtimeModule;
             if (mod?.registerComponents) {
                 try {
-                    mod.registerComponents(ComponentRegistry);
+                    mod.registerComponents(GlobalComponentRegistry);
                 } catch (e) {
                     console.error(`[PluginManager] Failed to register components for ${id}:`, e);
                 }
diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts
index 94ba356e..4c556a40 100644
--- a/packages/runtime-core/src/index.ts
+++ b/packages/runtime-core/src/index.ts
@@ -73,6 +73,16 @@ export {
     type BrowserFileSystemOptions
 } from './services/BrowserFileSystemService';
 
+// Runtime Scene Manager
+export {
+    RuntimeSceneManager,
+    RuntimeSceneManagerToken,
+    type IRuntimeSceneManager,
+    type SceneInfo,
+    type SceneLoadOptions,
+    type SceneLoader
+} from './services/RuntimeSceneManager';
+
 // Re-export catalog types from asset-system (canonical source)
 // 从 asset-system 重新导出目录类型(规范来源)
 export type {
diff --git a/packages/runtime-core/src/services/RuntimeSceneManager.ts b/packages/runtime-core/src/services/RuntimeSceneManager.ts
new file mode 100644
index 00000000..46ac45eb
--- /dev/null
+++ b/packages/runtime-core/src/services/RuntimeSceneManager.ts
@@ -0,0 +1,391 @@
+/**
+ * 运行时场景管理器
+ * Runtime Scene Manager
+ *
+ * 提供场景加载和切换 API,供用户脚本使用
+ * Provides scene loading and transition API for user scripts
+ *
+ * @example
+ * ```typescript
+ * // 在用户脚本中获取场景管理器
+ * // Get scene manager in user script
+ * const sceneManager = services.get(RuntimeSceneManagerToken);
+ *
+ * // 加载场景(按名称)
+ * // Load scene by name
+ * await sceneManager.loadScene('GameScene');
+ *
+ * // 加载场景(按路径)
+ * // Load scene by path
+ * await sceneManager.loadSceneByPath('./scenes/Level1.ecs');
+ * ```
+ */
+
+import { createServiceToken } from '@esengine/ecs-framework';
+
+/**
+ * 场景信息
+ * Scene info
+ */
+export interface SceneInfo {
+    /** 场景名称 | Scene name */
+    name: string;
+    /** 场景路径(相对于构建输出目录)| Scene path (relative to build output) */
+    path: string;
+}
+
+/**
+ * 场景加载选项
+ * Scene load options
+ */
+export interface SceneLoadOptions {
+    /**
+     * 是否显示加载界面
+     * Whether to show loading screen
+     */
+    showLoading?: boolean;
+
+    /**
+     * 过渡效果类型
+     * Transition effect type
+     */
+    transition?: 'none' | 'fade' | 'slide';
+
+    /**
+     * 过渡持续时间(毫秒)
+     * Transition duration in milliseconds
+     */
+    transitionDuration?: number;
+}
+
+/**
+ * 场景加载器函数类型
+ * Scene loader function type
+ */
+export type SceneLoader = (url: string) => Promise;
+
+/**
+ * 运行时场景管理器接口
+ * Runtime Scene Manager Interface
+ *
+ * 继承 IService 的 dispose 模式以兼容 ServiceContainer。
+ * Follows IService dispose pattern for ServiceContainer compatibility.
+ */
+export interface IRuntimeSceneManager {
+    /**
+     * 获取当前场景名称
+     * Get current scene name
+     */
+    readonly currentSceneName: string | null;
+
+    /**
+     * 获取可用场景列表
+     * Get available scene list
+     */
+    readonly availableScenes: readonly SceneInfo[];
+
+    /**
+     * 是否正在加载场景
+     * Whether a scene is currently loading
+     */
+    readonly isLoading: boolean;
+
+    /**
+     * 注册可用场景
+     * Register available scenes
+     */
+    registerScenes(scenes: SceneInfo[]): void;
+
+    /**
+     * 按名称加载场景
+     * Load scene by name
+     */
+    loadScene(sceneName: string, options?: SceneLoadOptions): Promise;
+
+    /**
+     * 按路径加载场景
+     * Load scene by path
+     */
+    loadSceneByPath(path: string, options?: SceneLoadOptions): Promise;
+
+    /**
+     * 重新加载当前场景
+     * Reload current scene
+     */
+    reloadCurrentScene(options?: SceneLoadOptions): Promise;
+
+    /**
+     * 添加场景加载开始监听器
+     * Add scene load start listener
+     */
+    onLoadStart(callback: (sceneName: string) => void): () => void;
+
+    /**
+     * 添加场景加载完成监听器
+     * Add scene load complete listener
+     */
+    onLoadComplete(callback: (sceneName: string) => void): () => void;
+
+    /**
+     * 添加场景加载错误监听器
+     * Add scene load error listener
+     */
+    onLoadError(callback: (error: Error, sceneName: string) => void): () => void;
+
+    /**
+     * 释放资源(IService 兼容)
+     * Dispose resources (IService compatible)
+     */
+    dispose(): void;
+}
+
+/**
+ * 运行时场景管理器服务令牌
+ * Runtime Scene Manager Service Token
+ */
+export const RuntimeSceneManagerToken = createServiceToken('runtimeSceneManager');
+
+/**
+ * 运行时场景管理器实现
+ * Runtime Scene Manager Implementation
+ *
+ * 实现 IService 接口以兼容 ServiceContainer。
+ * Implements IService for ServiceContainer compatibility.
+ */
+export class RuntimeSceneManager implements IRuntimeSceneManager {
+    private _scenes = new Map();
+    private _currentSceneName: string | null = null;
+    private _currentScenePath: string | null = null;
+    private _isLoading = false;
+    private _sceneLoader: SceneLoader | null = null;
+    private _baseUrl: string;
+    private _disposed = false;
+
+    // 事件监听器 | Event listeners
+    private _loadStartListeners = new Set<(sceneName: string) => void>();
+    private _loadCompleteListeners = new Set<(sceneName: string) => void>();
+    private _loadErrorListeners = new Set<(error: Error, sceneName: string) => void>();
+
+    /**
+     * 创建运行时场景管理器
+     * Create runtime scene manager
+     *
+     * @param sceneLoader 场景加载函数 | Scene loader function
+     * @param baseUrl 场景文件基础 URL | Scene files base URL
+     */
+    constructor(sceneLoader: SceneLoader, baseUrl: string = './scenes') {
+        this._sceneLoader = sceneLoader;
+        this._baseUrl = baseUrl;
+    }
+
+    get currentSceneName(): string | null {
+        return this._currentSceneName;
+    }
+
+    get availableScenes(): readonly SceneInfo[] {
+        return Array.from(this._scenes.values());
+    }
+
+    get isLoading(): boolean {
+        return this._isLoading;
+    }
+
+    /**
+     * 设置场景加载器
+     * Set scene loader
+     */
+    setSceneLoader(loader: SceneLoader): void {
+        this._sceneLoader = loader;
+    }
+
+    /**
+     * 设置基础 URL
+     * Set base URL
+     */
+    setBaseUrl(baseUrl: string): void {
+        this._baseUrl = baseUrl;
+    }
+
+    registerScenes(scenes: SceneInfo[]): void {
+        for (const scene of scenes) {
+            this._scenes.set(scene.name, scene);
+        }
+    }
+
+    /**
+     * 从目录或配置自动发现场景
+     * Auto-discover scenes from catalog or config
+     */
+    registerScenesFromCatalog(
+        catalog: { scenes?: Array<{ name: string; path: string }> }
+    ): void {
+        if (catalog.scenes) {
+            this.registerScenes(catalog.scenes);
+        }
+    }
+
+    async loadScene(sceneName: string, options?: SceneLoadOptions): Promise {
+        const sceneInfo = this._scenes.get(sceneName);
+        if (!sceneInfo) {
+            // 尝试使用场景名作为路径
+            // Try using scene name as path
+            const guessedPath = `${this._baseUrl}/${sceneName}.ecs`;
+            return this.loadSceneByPath(guessedPath, options);
+        }
+
+        return this.loadSceneByPath(sceneInfo.path, options);
+    }
+
+    async loadSceneByPath(path: string, options?: SceneLoadOptions): Promise {
+        if (!this._sceneLoader) {
+            throw new Error('[RuntimeSceneManager] Scene loader not set');
+        }
+
+        if (this._isLoading) {
+            console.warn('[RuntimeSceneManager] Scene is already loading, ignoring request');
+            return;
+        }
+
+        // 构建完整 URL | Build full URL
+        // Check if path is already absolute (http, relative ./, Unix /, or Windows drive letter)
+        // 检查路径是否已经是绝对路径(http、相对 ./、Unix /、或 Windows 驱动器号)
+        let fullPath = path;
+        const isAbsolutePath = path.startsWith('http') ||
+            path.startsWith('./') ||
+            path.startsWith('/') ||
+            (path.length > 1 && path[1] === ':'); // Windows absolute path like C:\ or F:\
+
+        if (!isAbsolutePath) {
+            fullPath = `${this._baseUrl}/${path}`;
+        }
+
+        // 提取场景名称 | Extract scene name
+        const sceneName = this._extractSceneName(path);
+
+        this._isLoading = true;
+        this._notifyLoadStart(sceneName);
+
+        try {
+            // TODO: 实现过渡效果 | TODO: Implement transition effects
+            // if (options?.transition && options.transition !== 'none') {
+            //     await this._startTransition(options.transition, options.transitionDuration);
+            // }
+
+            await this._sceneLoader(fullPath);
+
+            this._currentSceneName = sceneName;
+            this._currentScenePath = fullPath;
+            this._isLoading = false;
+
+            this._notifyLoadComplete(sceneName);
+
+            console.log(`[RuntimeSceneManager] Scene loaded: ${sceneName}`);
+        } catch (error) {
+            this._isLoading = false;
+            const err = error instanceof Error ? error : new Error(String(error));
+            this._notifyLoadError(err, sceneName);
+            throw err;
+        }
+    }
+
+    async reloadCurrentScene(options?: SceneLoadOptions): Promise {
+        if (!this._currentScenePath) {
+            throw new Error('[RuntimeSceneManager] No current scene to reload');
+        }
+
+        return this.loadSceneByPath(this._currentScenePath, options);
+    }
+
+    onLoadStart(callback: (sceneName: string) => void): () => void {
+        this._loadStartListeners.add(callback);
+        return () => this._loadStartListeners.delete(callback);
+    }
+
+    onLoadComplete(callback: (sceneName: string) => void): () => void {
+        this._loadCompleteListeners.add(callback);
+        return () => this._loadCompleteListeners.delete(callback);
+    }
+
+    onLoadError(callback: (error: Error, sceneName: string) => void): () => void {
+        this._loadErrorListeners.add(callback);
+        return () => this._loadErrorListeners.delete(callback);
+    }
+
+    /**
+     * 检查场景是否已注册
+     * Check if scene is registered
+     */
+    hasScene(sceneName: string): boolean {
+        return this._scenes.has(sceneName);
+    }
+
+    /**
+     * 获取场景路径
+     * Get scene path
+     */
+    getScenePath(sceneName: string): string | null {
+        return this._scenes.get(sceneName)?.path ?? null;
+    }
+
+    // ==================== 私有方法 | Private Methods ====================
+
+    private _extractSceneName(path: string): string {
+        // 从路径中提取场景名称 | Extract scene name from path
+        // ./scenes/Level1.ecs -> Level1
+        // scenes/GameScene.ecs -> GameScene
+        const fileName = path.split('/').pop() || path;
+        return fileName.replace(/\.ecs$/, '');
+    }
+
+    private _notifyLoadStart(sceneName: string): void {
+        for (const listener of this._loadStartListeners) {
+            try {
+                listener(sceneName);
+            } catch (e) {
+                console.error('[RuntimeSceneManager] Error in load start listener:', e);
+            }
+        }
+    }
+
+    private _notifyLoadComplete(sceneName: string): void {
+        for (const listener of this._loadCompleteListeners) {
+            try {
+                listener(sceneName);
+            } catch (e) {
+                console.error('[RuntimeSceneManager] Error in load complete listener:', e);
+            }
+        }
+    }
+
+    private _notifyLoadError(error: Error, sceneName: string): void {
+        for (const listener of this._loadErrorListeners) {
+            try {
+                listener(error, sceneName);
+            } catch (e) {
+                console.error('[RuntimeSceneManager] Error in load error listener:', e);
+            }
+        }
+    }
+
+    // ==================== IService 实现 | IService Implementation ====================
+
+    /**
+     * 释放资源
+     * Dispose resources
+     *
+     * 实现 IService 接口,清理所有监听器和状态。
+     * Implements IService interface, cleans up all listeners and state.
+     */
+    dispose(): void {
+        if (this._disposed) return;
+
+        this._loadStartListeners.clear();
+        this._loadCompleteListeners.clear();
+        this._loadErrorListeners.clear();
+        this._scenes.clear();
+        this._sceneLoader = null;
+        this._currentSceneName = null;
+        this._currentScenePath = null;
+        this._disposed = true;
+    }
+}
diff --git a/packages/sprite/src/SpriteRuntimeModule.ts b/packages/sprite/src/SpriteRuntimeModule.ts
index de020c9f..f59274bd 100644
--- a/packages/sprite/src/SpriteRuntimeModule.ts
+++ b/packages/sprite/src/SpriteRuntimeModule.ts
@@ -1,4 +1,4 @@
-import type { ComponentRegistry as ComponentRegistryType, IScene } from '@esengine/ecs-framework';
+import type { IComponentRegistry, IScene } from '@esengine/ecs-framework';
 import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
 import { SpriteComponent } from './SpriteComponent';
 import { SpriteAnimatorComponent } from './SpriteAnimatorComponent';
@@ -11,7 +11,7 @@ export type { SystemContext, ModuleManifest, IRuntimeModule, IRuntimePlugin };
 export { SpriteAnimatorSystemToken } from './tokens';
 
 class SpriteRuntimeModule implements IRuntimeModule {
-    registerComponents(registry: typeof ComponentRegistryType): void {
+    registerComponents(registry: IComponentRegistry): void {
         registry.register(SpriteComponent);
         registry.register(SpriteAnimatorComponent);
     }
diff --git a/packages/tilemap/src/TilemapRuntimeModule.ts b/packages/tilemap/src/TilemapRuntimeModule.ts
index e3433266..5e30ebbb 100644
--- a/packages/tilemap/src/TilemapRuntimeModule.ts
+++ b/packages/tilemap/src/TilemapRuntimeModule.ts
@@ -1,5 +1,4 @@
-import type { IScene } from '@esengine/ecs-framework';
-import { ComponentRegistry } from '@esengine/ecs-framework';
+import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
 import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
 import { AssetManagerToken } from '@esengine/asset-system';
 import { RenderSystemToken } from '@esengine/ecs-engine-bindgen';
@@ -26,7 +25,7 @@ class TilemapRuntimeModule implements IRuntimeModule {
     private _tilemapPhysicsSystem: TilemapPhysicsSystem | null = null;
     private _loaderRegistered = false;
 
-    registerComponents(registry: typeof ComponentRegistry): void {
+    registerComponents(registry: IComponentRegistry): void {
         registry.register(TilemapComponent);
         registry.register(TilemapCollider2DComponent);
     }
diff --git a/packages/ui/src/UIRuntimeModule.ts b/packages/ui/src/UIRuntimeModule.ts
index 9297b0d3..17fd4b9f 100644
--- a/packages/ui/src/UIRuntimeModule.ts
+++ b/packages/ui/src/UIRuntimeModule.ts
@@ -1,5 +1,4 @@
-import type { IScene } from '@esengine/ecs-framework';
-import { ComponentRegistry } from '@esengine/ecs-framework';
+import type { IScene, IComponentRegistry } from '@esengine/ecs-framework';
 import type { IRuntimeModule, IRuntimePlugin, ModuleManifest, SystemContext } from '@esengine/engine-core';
 import { EngineBridgeToken } from '@esengine/ecs-engine-bindgen';
 
@@ -14,10 +13,14 @@ import {
     UISliderComponent,
     UIScrollViewComponent
 } from './components';
+import { TextBlinkComponent } from './components/TextBlinkComponent';
+import { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
 import { UILayoutSystem } from './systems/UILayoutSystem';
 import { UIInputSystem } from './systems/UIInputSystem';
 import { UIAnimationSystem } from './systems/UIAnimationSystem';
 import { UIRenderDataProvider } from './systems/UIRenderDataProvider';
+import { TextBlinkSystem } from './systems/TextBlinkSystem';
+import { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
 import {
     UIRenderBeginSystem,
     UIRectRenderSystem,
@@ -43,7 +46,7 @@ export {
 } from './tokens';
 
 class UIRuntimeModule implements IRuntimeModule {
-    registerComponents(registry: typeof ComponentRegistry): void {
+    registerComponents(registry: IComponentRegistry): void {
         registry.register(UITransformComponent);
         registry.register(UIRenderComponent);
         registry.register(UIInteractableComponent);
@@ -53,6 +56,8 @@ class UIRuntimeModule implements IRuntimeModule {
         registry.register(UIProgressBarComponent);
         registry.register(UISliderComponent);
         registry.register(UIScrollViewComponent);
+        registry.register(TextBlinkComponent);
+        registry.register(SceneLoadTriggerComponent);
     }
 
     createSystems(scene: IScene, context: SystemContext): void {
@@ -65,6 +70,14 @@ class UIRuntimeModule implements IRuntimeModule {
         const animationSystem = new UIAnimationSystem();
         scene.addSystem(animationSystem);
 
+        // 文本闪烁系统 | Text blink system
+        const textBlinkSystem = new TextBlinkSystem();
+        scene.addSystem(textBlinkSystem);
+
+        // 场景加载触发系统 | Scene load trigger system
+        const sceneLoadTriggerSystem = new SceneLoadTriggerSystem();
+        scene.addSystem(sceneLoadTriggerSystem);
+
         const renderBeginSystem = new UIRenderBeginSystem();
         scene.addSystem(renderBeginSystem);
 
diff --git a/packages/ui/src/components/SceneLoadTriggerComponent.ts b/packages/ui/src/components/SceneLoadTriggerComponent.ts
new file mode 100644
index 00000000..309a798e
--- /dev/null
+++ b/packages/ui/src/components/SceneLoadTriggerComponent.ts
@@ -0,0 +1,61 @@
+/**
+ * 场景加载触发组件
+ * Scene Load Trigger Component
+ *
+ * 配合 UIInteractable 使用,点击时自动加载指定场景。
+ * Works with UIInteractable to automatically load scene on click.
+ */
+
+import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
+
+/**
+ * 场景加载触发组件
+ * Scene Load Trigger Component
+ *
+ * 添加到带有 UIInteractable 的实体上,点击时会加载 targetScene 指定的场景。
+ * Add to entity with UIInteractable, loads targetScene on click.
+ *
+ * @example
+ * ```json
+ * {
+ *   "type": "SceneLoadTrigger",
+ *   "data": {
+ *     "targetScene": "GameScene",
+ *     "enabled": true
+ *   }
+ * }
+ * ```
+ */
+@ECSComponent('SceneLoadTrigger')
+@Serializable({ version: 1, typeId: 'SceneLoadTrigger' })
+export class SceneLoadTriggerComponent extends Component {
+    /**
+     * 目标场景名称
+     * Target scene name to load on click
+     */
+    @Serialize()
+    @Property({ type: 'string', label: 'Target Scene' })
+    public targetScene: string = '';
+
+    /**
+     * 是否启用
+     * Whether the trigger is enabled
+     */
+    @Serialize()
+    @Property({ type: 'boolean', label: 'Enabled' })
+    public enabled: boolean = true;
+
+    /**
+     * 点击后是否禁用(防止重复点击)
+     * Disable after click (prevent double clicks)
+     */
+    @Serialize()
+    @Property({ type: 'boolean', label: 'Disable On Click' })
+    public disableOnClick: boolean = true;
+
+    /**
+     * 内部标记:回调是否已绑定
+     * Internal flag: whether callback is bound
+     */
+    public _callbackBound: boolean = false;
+}
diff --git a/packages/ui/src/components/TextBlinkComponent.ts b/packages/ui/src/components/TextBlinkComponent.ts
new file mode 100644
index 00000000..bc8179ef
--- /dev/null
+++ b/packages/ui/src/components/TextBlinkComponent.ts
@@ -0,0 +1,101 @@
+/**
+ * 文本闪烁组件
+ * Text Blink Component
+ *
+ * 让文本产生闪烁效果,类似 Unity 的 Animation 实现
+ * Creates a blinking effect for text, similar to Unity's Animation implementation
+ */
+
+import { Component, ECSComponent, Property, Serializable, Serialize } from '@esengine/ecs-framework';
+
+/**
+ * 文本闪烁组件
+ * Text Blink Component
+ */
+@ECSComponent('TextBlink')
+@Serializable({ version: 1, typeId: 'TextBlink' })
+export class TextBlinkComponent extends Component {
+    /**
+     * 闪烁速度(周期/秒)
+     * Blink speed (cycles per second)
+     */
+    @Serialize()
+    @Property({ type: 'number', label: 'Speed', min: 0.1, max: 10, step: 0.1 })
+    public speed: number = 1.5;
+
+    /**
+     * 最小透明度
+     * Minimum alpha
+     */
+    @Serialize()
+    @Property({ type: 'number', label: 'Min Alpha', min: 0, max: 1, step: 0.05 })
+    public minAlpha: number = 0.3;
+
+    /**
+     * 最大透明度
+     * Maximum alpha
+     */
+    @Serialize()
+    @Property({ type: 'number', label: 'Max Alpha', min: 0, max: 1, step: 0.05 })
+    public maxAlpha: number = 1.0;
+
+    /**
+     * 是否启用闪烁
+     * Whether blinking is enabled
+     */
+    @Serialize()
+    @Property({ type: 'boolean', label: 'Enabled' })
+    public blinkEnabled: boolean = true;
+
+    // ============= 运行时状态(不序列化)| Runtime state (not serialized) =============
+
+    /** 当前时间 | Current time */
+    private _time: number = 0;
+
+    /**
+     * 获取当前时间
+     * Get current time
+     */
+    public get time(): number {
+        return this._time;
+    }
+
+    /**
+     * 更新时间
+     * Update time
+     */
+    public addTime(deltaTime: number): void {
+        this._time += deltaTime;
+    }
+
+    /**
+     * 计算当前 alpha 值
+     * Calculate current alpha value
+     *
+     * 使用正弦波实现平滑的闪烁效果
+     * Uses sine wave for smooth blinking effect
+     */
+    public calculateAlpha(): number {
+        if (!this.blinkEnabled) {
+            return this.maxAlpha;
+        }
+
+        // 使用正弦波:sin 从 -1 到 1,映射到 minAlpha 到 maxAlpha
+        // Using sine wave: sin from -1 to 1, mapped to minAlpha to maxAlpha
+        const t = Math.sin(this._time * this.speed * Math.PI * 2);
+        const normalized = (t + 1) / 2; // 0 到 1
+        return this.minAlpha + normalized * (this.maxAlpha - this.minAlpha);
+    }
+
+    /**
+     * 重置状态
+     * Reset state
+     */
+    public reset(): void {
+        this._time = 0;
+    }
+
+    override onRemovedFromEntity(): void {
+        this.reset();
+    }
+}
diff --git a/packages/ui/src/components/UIRenderComponent.ts b/packages/ui/src/components/UIRenderComponent.ts
index e9273c7c..3f3e1a2c 100644
--- a/packages/ui/src/components/UIRenderComponent.ts
+++ b/packages/ui/src/components/UIRenderComponent.ts
@@ -120,9 +120,36 @@ export class UIRenderComponent extends Component {
     /**
      * 九宫格边距 [top, right, bottom, left]
      * Nine-patch margins
+     *
+     * Defines the non-stretchable borders for nine-patch rendering.
+     * 定义九宫格渲染时不可拉伸的边框区域。
      */
+    @Serialize()
+    @Property({ type: 'vector4', label: 'Nine-Patch Margins' })
     public ninePatchMargins: [number, number, number, number] = [0, 0, 0, 0];
 
+    /**
+     * 源纹理宽度(像素)
+     * Source texture width in pixels
+     *
+     * Required for nine-patch UV calculations.
+     * 九宫格 UV 计算所需。
+     */
+    @Serialize()
+    @Property({ type: 'number', label: 'Texture Width', min: 1 })
+    public textureWidth: number = 0;
+
+    /**
+     * 源纹理高度(像素)
+     * Source texture height in pixels
+     *
+     * Required for nine-patch UV calculations.
+     * 九宫格 UV 计算所需。
+     */
+    @Serialize()
+    @Property({ type: 'number', label: 'Texture Height', min: 1 })
+    public textureHeight: number = 0;
+
     // ===== 边框 Border =====
 
     /**
diff --git a/packages/ui/src/components/UITransformComponent.ts b/packages/ui/src/components/UITransformComponent.ts
index 78d719dc..31d30fd3 100644
--- a/packages/ui/src/components/UITransformComponent.ts
+++ b/packages/ui/src/components/UITransformComponent.ts
@@ -275,6 +275,15 @@ export class UITransformComponent extends Component implements ISortable {
      */
     public worldScaleY: number = 1;
 
+    /**
+     * 计算后的世界层内顺序(考虑父元素和层级深度)
+     * Computed world order in layer (considering parent and hierarchy depth)
+     *
+     * 子元素总是渲染在父元素之上:worldOrderInLayer = parentWorldOrder + depth * 1000 + localOrder
+     * Children always render on top of parents: worldOrderInLayer = parentWorldOrder + depth * 1000 + localOrder
+     */
+    public worldOrderInLayer: number = 0;
+
     /**
      * 本地到世界的 2D 变换矩阵(只读,由 UILayoutSystem 计算)
      * Local to world 2D transformation matrix (readonly, computed by UILayoutSystem)
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 48cdcd5a..54cbb4c5 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -88,6 +88,9 @@ export {
     type UIFontWeight
 } from './components/UITextComponent';
 
+export { TextBlinkComponent } from './components/TextBlinkComponent';
+export { SceneLoadTriggerComponent } from './components/SceneLoadTriggerComponent';
+
 export {
     UILayoutComponent,
     UILayoutType,
@@ -124,6 +127,8 @@ export { UILayoutSystem } from './systems/UILayoutSystem';
 export { UIInputSystem, type UIInputEvent } from './systems/UIInputSystem';
 export { UIAnimationSystem, UIEasing, type EasingFunction, type EasingName } from './systems/UIAnimationSystem';
 export { UIRenderDataProvider, type IUIRenderDataProvider } from './systems/UIRenderDataProvider';
+export { TextBlinkSystem } from './systems/TextBlinkSystem';
+export { SceneLoadTriggerSystem } from './systems/SceneLoadTriggerSystem';
 
 // Systems - Render (ECS-compliant render systems)
 export {
diff --git a/packages/ui/src/systems/SceneLoadTriggerSystem.ts b/packages/ui/src/systems/SceneLoadTriggerSystem.ts
new file mode 100644
index 00000000..c712c77d
--- /dev/null
+++ b/packages/ui/src/systems/SceneLoadTriggerSystem.ts
@@ -0,0 +1,162 @@
+/**
+ * 场景加载触发系统
+ * Scene Load Trigger System
+ *
+ * 处理 SceneLoadTriggerComponent,绑定 UIInteractable 点击事件到场景加载。
+ * Processes SceneLoadTriggerComponent, binds UIInteractable click to scene loading.
+ */
+
+import { Entity, EntitySystem, Matcher, ECSSystem, Core } from '@esengine/ecs-framework';
+import { SceneLoadTriggerComponent } from '../components/SceneLoadTriggerComponent';
+import { UIInteractableComponent } from '../components/UIInteractableComponent';
+
+/**
+ * 场景加载函数类型(与 RuntimeSceneManager.loadScene 兼容)
+ * Scene load function type (compatible with RuntimeSceneManager.loadScene)
+ */
+type SceneLoadFunction = (sceneName: string) => Promise;
+
+/**
+ * 场景管理器接口(最小化,避免循环依赖)
+ * Scene manager interface (minimal, avoids circular dependency)
+ *
+ * 包含 IService 的 dispose 方法以兼容 ServiceContainer。
+ * Includes IService's dispose method for ServiceContainer compatibility.
+ */
+interface ISceneManager {
+    loadScene(sceneName: string): Promise;
+    dispose(): void;
+}
+
+/**
+ * 全局场景管理器服务键
+ * Global scene manager service key
+ *
+ * 使用 Symbol.for 确保与 BrowserRuntime 中注册的键一致。
+ * Uses Symbol.for to match the key registered in BrowserRuntime.
+ */
+const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager');
+
+/**
+ * 场景加载触发系统
+ * Scene Load Trigger System
+ *
+ * 自动将 SceneLoadTriggerComponent 的配置连接到 UIInteractable 的点击事件。
+ * Automatically connects SceneLoadTriggerComponent config to UIInteractable click events.
+ */
+@ECSSystem('SceneLoadTrigger')
+export class SceneLoadTriggerSystem extends EntitySystem {
+    private _sceneLoader: SceneLoadFunction | null = null;
+
+    constructor() {
+        super(Matcher.empty().all(SceneLoadTriggerComponent, UIInteractableComponent));
+    }
+
+    /**
+     * 设置场景加载函数
+     * Set scene load function
+     *
+     * 可以直接设置函数,或者系统会尝试从服务注册表获取 RuntimeSceneManager。
+     * Can set function directly, or system will try to get RuntimeSceneManager from service registry.
+     */
+    public setSceneLoader(loader: SceneLoadFunction): void {
+        this._sceneLoader = loader;
+    }
+
+    protected override process(entities: readonly Entity[]): void {
+        // 如果没有设置场景加载器,尝试从服务注册表获取
+        // If no scene loader set, try to get from service registry
+        if (!this._sceneLoader) {
+            this._tryGetSceneManager();
+        }
+
+        for (const entity of entities) {
+            const trigger = entity.getComponent(SceneLoadTriggerComponent);
+            const interactable = entity.getComponent(UIInteractableComponent);
+
+            if (!trigger || !interactable) continue;
+            if (!trigger.enabled || !trigger.targetScene) continue;
+
+            // 只绑定一次回调
+            // Only bind callback once
+            if (trigger._callbackBound) continue;
+
+            this._bindClickHandler(entity, trigger, interactable);
+        }
+    }
+
+    /**
+     * 尝试从全局服务获取场景管理器
+     * Try to get scene manager from global services
+     */
+    private _tryGetSceneManager(): void {
+        try {
+            // 从 Core.services 获取场景管理器
+            // Get scene manager from Core.services
+            // RuntimeSceneManager 实现了 IService 接口
+            // RuntimeSceneManager implements IService interface
+            const sceneManager = Core.services.tryResolve(GlobalSceneManagerKey);
+            if (sceneManager?.loadScene) {
+                this._sceneLoader = (sceneName: string) => sceneManager.loadScene(sceneName);
+            }
+        } catch (e) {
+            // 忽略错误,保持 _sceneLoader 为 null
+            // Ignore error, keep _sceneLoader as null
+        }
+    }
+
+    /**
+     * 绑定点击处理器
+     * Bind click handler
+     */
+    private _bindClickHandler(
+        entity: Entity,
+        trigger: SceneLoadTriggerComponent,
+        interactable: UIInteractableComponent
+    ): void {
+        const targetScene = trigger.targetScene;
+
+        // 保存原有的 onClick(如果有)
+        // Save original onClick (if any)
+        const originalOnClick = interactable.onClick;
+
+        interactable.onClick = () => {
+            // 调用原有回调
+            // Call original callback
+            originalOnClick?.();
+
+            // 检查是否启用
+            // Check if enabled
+            if (!trigger.enabled) return;
+
+            // 禁用(防止重复点击)
+            // Disable (prevent double clicks)
+            if (trigger.disableOnClick) {
+                trigger.enabled = false;
+            }
+
+            // 尝试获取场景加载器(可能在回调绑定后才注册)
+            // Try to get scene loader (may be registered after callback binding)
+            if (!this._sceneLoader) {
+                this._tryGetSceneManager();
+            }
+
+            // 加载场景
+            // Load scene
+            if (this._sceneLoader) {
+                this._sceneLoader(targetScene).catch((error) => {
+                    console.error(`[SceneLoadTriggerSystem] Failed to load scene "${targetScene}":`, error);
+                    // 恢复启用状态
+                    // Restore enabled state
+                    if (trigger.disableOnClick) {
+                        trigger.enabled = true;
+                    }
+                });
+            }
+            // 静默处理:编辑器预览模式下场景切换不可用
+            // Silent handling: scene switching not available in editor preview mode
+        };
+
+        trigger._callbackBound = true;
+    }
+}
diff --git a/packages/ui/src/systems/TextBlinkSystem.ts b/packages/ui/src/systems/TextBlinkSystem.ts
new file mode 100644
index 00000000..e3316f5c
--- /dev/null
+++ b/packages/ui/src/systems/TextBlinkSystem.ts
@@ -0,0 +1,37 @@
+/**
+ * 文本闪烁系统 - 实现 UI 元素的透明度脉冲动画
+ *
+ * Text Blink System - Implements alpha pulse animation for UI elements
+ */
+
+import { Entity, EntitySystem, Matcher, Time } from '@esengine/ecs-framework';
+import { TextBlinkComponent } from '../components/TextBlinkComponent';
+import { UITransformComponent } from '../components/UITransformComponent';
+
+/**
+ * 处理 TextBlinkComponent,驱动 UI 元素的透明度动画。
+ * 常用于 "TAP TO START" 等需要吸引注意力的文本效果。
+ *
+ * Processes TextBlinkComponent to drive UI element alpha animation.
+ * Commonly used for attention-grabbing text effects like "TAP TO START".
+ */
+export class TextBlinkSystem extends EntitySystem {
+    constructor() {
+        super(Matcher.empty().all(TextBlinkComponent, UITransformComponent));
+    }
+
+    protected override process(entities: readonly Entity[]): void {
+        const deltaTime = Time.deltaTime;
+
+        for (const entity of entities) {
+            if (!entity.enabled) continue;
+
+            const blink = entity.getComponent(TextBlinkComponent);
+            const uiTransform = entity.getComponent(UITransformComponent);
+            if (!blink || !uiTransform) continue;
+
+            blink.addTime(deltaTime);
+            uiTransform.alpha = blink.calculateAlpha();
+        }
+    }
+}
diff --git a/packages/ui/src/systems/UILayoutSystem.ts b/packages/ui/src/systems/UILayoutSystem.ts
index 3b94cb39..6af85862 100644
--- a/packages/ui/src/systems/UILayoutSystem.ts
+++ b/packages/ui/src/systems/UILayoutSystem.ts
@@ -96,7 +96,7 @@ export class UILayoutSystem extends EntitySystem {
         const identityMatrix: Matrix2D = { a: 1, b: 0, c: 0, d: 1, tx: 0, ty: 0 };
 
         for (const entity of rootEntities) {
-            this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1, identityMatrix);
+            this.layoutEntity(entity, parentX, parentY, this.canvasWidth, this.canvasHeight, 1, identityMatrix, true, 0);
         }
     }
 
@@ -112,7 +112,8 @@ export class UILayoutSystem extends EntitySystem {
         parentHeight: number,
         parentAlpha: number,
         parentMatrix: Matrix2D,
-        parentVisible: boolean = true
+        parentVisible: boolean = true,
+        depth: number = 0
     ): void {
         const transform = entity.getComponent(UITransformComponent);
         if (!transform) return;
@@ -199,6 +200,12 @@ export class UILayoutSystem extends EntitySystem {
         // Calculate world visibility (if parent is invisible, children are also invisible)
         transform.worldVisible = parentVisible && transform.visible;
 
+        // 计算世界层内顺序(子元素总是渲染在父元素之上)
+        // Calculate world order in layer (children always render on top of parents)
+        // 公式:depth * 1000 + localOrderInLayer
+        // Formula: depth * 1000 + localOrderInLayer
+        transform.worldOrderInLayer = depth * 1000 + transform.orderInLayer;
+
         // 使用矩阵乘法计算世界变换
         this.updateWorldMatrix(transform, parentMatrix);
 
@@ -215,7 +222,7 @@ export class UILayoutSystem extends EntitySystem {
         // 检查是否有布局组件
         const layout = entity.getComponent(UILayoutComponent);
         if (layout && layout.type !== UILayoutType.None) {
-            this.layoutChildren(layout, transform, children);
+            this.layoutChildren(layout, transform, children, depth + 1);
         } else {
             // 无布局组件,直接递归处理子元素
             for (const child of children) {
@@ -227,7 +234,8 @@ export class UILayoutSystem extends EntitySystem {
                     height,
                     transform.worldAlpha,
                     transform.localToWorldMatrix,
-                    transform.worldVisible
+                    transform.worldVisible,
+                    depth + 1
                 );
             }
         }
@@ -240,7 +248,8 @@ export class UILayoutSystem extends EntitySystem {
     private layoutChildren(
         layout: UILayoutComponent,
         parentTransform: UITransformComponent,
-        children: Entity[]
+        children: Entity[],
+        depth: number
     ): void {
         const contentStartX = parentTransform.worldX + layout.paddingLeft;
         // Y-up 系统:worldY 是底部,顶部 = worldY + height
@@ -252,13 +261,13 @@ export class UILayoutSystem extends EntitySystem {
 
         switch (layout.type) {
             case UILayoutType.Horizontal:
-                this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
+                this.layoutHorizontal(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
                 break;
             case UILayoutType.Vertical:
-                this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
+                this.layoutVertical(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
                 break;
             case UILayoutType.Grid:
-                this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight);
+                this.layoutGrid(layout, parentTransform, children, contentStartX, contentStartY, contentWidth, contentHeight, depth);
                 break;
             default:
                 // 默认按正常方式递归(传递顶部 Y)
@@ -270,7 +279,9 @@ export class UILayoutSystem extends EntitySystem {
                         parentTransform.computedWidth,
                         parentTransform.computedHeight,
                         parentTransform.worldAlpha,
-                        parentTransform.localToWorldMatrix
+                        parentTransform.localToWorldMatrix,
+                        parentTransform.worldVisible,
+                        depth
                     );
                 }
         }
@@ -287,7 +298,8 @@ export class UILayoutSystem extends EntitySystem {
         startX: number,
         startY: number,
         contentWidth: number,
-        contentHeight: number
+        contentHeight: number,
+        depth: number
     ): void {
         // 计算总子元素宽度
         const childSizes = children.map(child => {
@@ -366,12 +378,14 @@ export class UILayoutSystem extends EntitySystem {
             childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
             // 传播世界可见性 | Propagate world visibility
             childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
+            // 计算世界层内顺序 | Calculate world order in layer
+            childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
             // 使用矩阵乘法计算世界旋转和缩放
             this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
             childTransform.layoutDirty = false;
 
             // 递归处理子元素的子元素
-            this.processChildrenRecursive(child, childTransform);
+            this.processChildrenRecursive(child, childTransform, depth);
 
             offsetX += size.width + gap;
         }
@@ -389,7 +403,8 @@ export class UILayoutSystem extends EntitySystem {
         startX: number,
         startY: number,
         contentWidth: number,
-        contentHeight: number
+        contentHeight: number,
+        depth: number
     ): void {
         // 计算总子元素高度
         const childSizes = children.map(child => {
@@ -466,11 +481,13 @@ export class UILayoutSystem extends EntitySystem {
             childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
             // 传播世界可见性 | Propagate world visibility
             childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
+            // 计算世界层内顺序 | Calculate world order in layer
+            childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
             // 使用矩阵乘法计算世界旋转和缩放
             this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
             childTransform.layoutDirty = false;
 
-            this.processChildrenRecursive(child, childTransform);
+            this.processChildrenRecursive(child, childTransform, depth);
 
             // 移动到下一个元素的顶部位置(向下 = Y 减小)
             currentTopY -= size.height + gap;
@@ -489,7 +506,8 @@ export class UILayoutSystem extends EntitySystem {
         startX: number,
         startY: number,
         contentWidth: number,
-        _contentHeight: number
+        _contentHeight: number,
+        depth: number
     ): void {
         const columns = layout.columns;
         const gapX = layout.getHorizontalGap();
@@ -524,11 +542,13 @@ export class UILayoutSystem extends EntitySystem {
             childTransform.worldAlpha = parentTransform.worldAlpha * childTransform.alpha;
             // 传播世界可见性 | Propagate world visibility
             childTransform.worldVisible = parentTransform.worldVisible && childTransform.visible;
+            // 计算世界层内顺序 | Calculate world order in layer
+            childTransform.worldOrderInLayer = depth * 1000 + childTransform.orderInLayer;
             // 使用矩阵乘法计算世界旋转和缩放
             this.updateWorldMatrix(childTransform, parentTransform.localToWorldMatrix);
             childTransform.layoutDirty = false;
 
-            this.processChildrenRecursive(child, childTransform);
+            this.processChildrenRecursive(child, childTransform, depth);
         }
     }
 
@@ -565,7 +585,7 @@ export class UILayoutSystem extends EntitySystem {
      * 递归处理子元素
      * Recursively process children
      */
-    private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent): void {
+    private processChildrenRecursive(entity: Entity, parentTransform: UITransformComponent, depth: number): void {
         const children = this.getUIChildren(entity);
         if (children.length === 0) return;
 
@@ -574,7 +594,7 @@ export class UILayoutSystem extends EntitySystem {
 
         const layout = entity.getComponent(UILayoutComponent);
         if (layout && layout.type !== UILayoutType.None) {
-            this.layoutChildren(layout, parentTransform, children);
+            this.layoutChildren(layout, parentTransform, children, depth + 1);
         } else {
             for (const child of children) {
                 this.layoutEntity(
@@ -585,7 +605,8 @@ export class UILayoutSystem extends EntitySystem {
                     parentTransform.computedHeight,
                     parentTransform.worldAlpha,
                     parentTransform.localToWorldMatrix,
-                    parentTransform.worldVisible
+                    parentTransform.worldVisible,
+                    depth + 1
                 );
             }
         }
diff --git a/packages/ui/src/systems/render/UIButtonRenderSystem.ts b/packages/ui/src/systems/render/UIButtonRenderSystem.ts
index ce85e2b5..05b511f9 100644
--- a/packages/ui/src/systems/render/UIButtonRenderSystem.ts
+++ b/packages/ui/src/systems/render/UIButtonRenderSystem.ts
@@ -58,9 +58,9 @@ export class UIButtonRenderSystem extends EntitySystem {
             const width = (transform.computedWidth ?? transform.width) * scaleX;
             const height = (transform.computedHeight ?? transform.height) * scaleY;
             const alpha = transform.worldAlpha ?? transform.alpha;
-            // 使用排序层和层内顺序 | Use sorting layer and order in layer
+            // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer
             const sortingLayer = transform.sortingLayer;
-            const orderInLayer = transform.orderInLayer;
+            const orderInLayer = transform.worldOrderInLayer;
             // 使用 transform 的 pivot 作为旋转/缩放中心
             const pivotX = transform.pivotX;
             const pivotY = transform.pivotY;
diff --git a/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts b/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
index 0dbad655..45ee5b2f 100644
--- a/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
+++ b/packages/ui/src/systems/render/UIProgressBarRenderSystem.ts
@@ -55,9 +55,9 @@ export class UIProgressBarRenderSystem extends EntitySystem {
             const width = (transform.computedWidth ?? transform.width) * scaleX;
             const height = (transform.computedHeight ?? transform.height) * scaleY;
             const alpha = transform.worldAlpha ?? transform.alpha;
-            // 使用排序层和层内顺序 | Use sorting layer and order in layer
+            // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer
             const sortingLayer = transform.sortingLayer;
-            const orderInLayer = transform.orderInLayer;
+            const orderInLayer = transform.worldOrderInLayer;
             // 使用 transform 的 pivot 作为旋转/缩放中心
             const pivotX = transform.pivotX;
             const pivotY = transform.pivotY;
diff --git a/packages/ui/src/systems/render/UIRectRenderSystem.ts b/packages/ui/src/systems/render/UIRectRenderSystem.ts
index 7f2868e3..e52c30c6 100644
--- a/packages/ui/src/systems/render/UIRectRenderSystem.ts
+++ b/packages/ui/src/systems/render/UIRectRenderSystem.ts
@@ -10,7 +10,7 @@
 
 import { EntitySystem, Matcher, Entity, ECSSystem } from '@esengine/ecs-framework';
 import { UITransformComponent } from '../../components/UITransformComponent';
-import { UIRenderComponent } from '../../components/UIRenderComponent';
+import { UIRenderComponent, UIRenderType } from '../../components/UIRenderComponent';
 import { UIButtonComponent } from '../../components/widgets/UIButtonComponent';
 import { UIProgressBarComponent } from '../../components/widgets/UIProgressBarComponent';
 import { UISliderComponent } from '../../components/widgets/UISliderComponent';
@@ -68,9 +68,11 @@ export class UIRectRenderSystem extends EntitySystem {
             const alpha = transform.worldAlpha ?? transform.alpha;
             // 使用世界旋转(考虑父级旋转)
             const rotation = transform.worldRotation ?? transform.rotation;
-            // 使用排序层和层内顺序 | Use sorting layer and order in layer
+            // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer
             const sortingLayer = transform.sortingLayer;
-            const orderInLayer = transform.orderInLayer;
+            // worldOrderInLayer 考虑了父子层级关系,确保子元素渲染在父元素之上
+            // worldOrderInLayer considers parent-child hierarchy, ensuring children render on top of parents
+            const orderInLayer = transform.worldOrderInLayer;
             // 使用 transform 的 pivot 作为旋转/缩放中心
             const pivotX = transform.pivotX;
             const pivotY = transform.pivotY;
@@ -107,24 +109,56 @@ export class UIRectRenderSystem extends EntitySystem {
                 const textureGuid = typeof render.textureGuid === 'string' ? render.textureGuid : undefined;
                 const textureId = typeof render.textureGuid === 'number' ? render.textureGuid : undefined;
 
-                collector.addRect(
-                    renderX, renderY,
-                    width, height,
-                    render.textureTint,
-                    alpha,
-                    sortingLayer,
-                    orderInLayer,
-                    {
-                        rotation,
-                        pivotX,
-                        pivotY,
-                        textureId,
-                        textureGuid,
-                        uv: render.textureUV
-                            ? [render.textureUV.u0, render.textureUV.v0, render.textureUV.u1, render.textureUV.v1]
-                            : undefined
-                    }
-                );
+
+                // Handle nine-patch rendering
+                // 处理九宫格渲染
+                if (render.type === UIRenderType.NinePatch &&
+                    render.textureWidth > 0 &&
+                    render.textureHeight > 0) {
+                    // addNinePatch expects top-left corner coordinates
+                    // Y-up coordinate system: top = bottom + height
+                    // addNinePatch 期望左上角坐标
+                    // Y轴向上坐标系:顶部 = 底部 + 高度
+                    const topLeftX = x;
+                    const topLeftY = y + height;
+                    collector.addNinePatch(
+                        topLeftX, topLeftY,
+                        width, height,
+                        render.ninePatchMargins,
+                        render.textureWidth,
+                        render.textureHeight,
+                        render.textureTint,
+                        alpha,
+                        sortingLayer,
+                        orderInLayer,
+                        {
+                            rotation,
+                            textureId,
+                            textureGuid
+                        }
+                    );
+                } else {
+                    // Standard image rendering
+                    // 标准图像渲染
+                    collector.addRect(
+                        renderX, renderY,
+                        width, height,
+                        render.textureTint,
+                        alpha,
+                        sortingLayer,
+                        orderInLayer,
+                        {
+                            rotation,
+                            pivotX,
+                            pivotY,
+                            textureId,
+                            textureGuid,
+                            uv: render.textureUV
+                                ? [render.textureUV.u0, render.textureUV.v0, render.textureUV.u1, render.textureUV.v1]
+                                : undefined
+                        }
+                    );
+                }
             }
             // Render background color if fill is enabled
             // 如果启用填充,渲染背景颜色
diff --git a/packages/ui/src/systems/render/UIRenderCollector.ts b/packages/ui/src/systems/render/UIRenderCollector.ts
index 9183aa8b..bad23ce5 100644
--- a/packages/ui/src/systems/render/UIRenderCollector.ts
+++ b/packages/ui/src/systems/render/UIRenderCollector.ts
@@ -56,7 +56,7 @@ export interface UIRenderPrimitive {
     rotation: number;
     /** Pivot/Origin X (0-1, 0=left, 0.5=center, 1=right) | 锚点 X (0-1, 0=左, 0.5=中心, 1=右) */
     pivotX: number;
-    /** Pivot/Origin Y (0-1, 0=top, 0.5=center, 1=bottom) | 锚点 Y (0-1, 0=上, 0.5=中心, 1=下) */
+    /** Pivot/Origin Y (0-1, 0=bottom, 0.5=center, 1=top) in Y-up system | 锚点 Y (0-1, 0=下, 0.5=中心, 1=上) Y轴向上坐标系 */
     pivotY: number;
     /** Packed color (0xAABBGGRR) | 打包颜色 */
     color: number;
@@ -171,6 +171,136 @@ export class UIRenderCollector {
         this.cache = null;
     }
 
+    /**
+     * Add a nine-patch (9-slice) primitive
+     * 添加九宫格原语
+     *
+     * Nine-patch divides the texture into 9 regions:
+     * - Corners: Keep original size
+     * - Edges: Stretch in one direction
+     * - Center: Stretches in both directions
+     *
+     * 九宫格将纹理分为 9 个区域:
+     * - 角落:保持原始尺寸
+     * - 边缘:单向拉伸
+     * - 中心:双向拉伸
+     *
+     * @param x - X position | X 坐标
+     * @param y - Y position | Y 坐标
+     * @param width - Target width | 目标宽度
+     * @param height - Target height | 目标高度
+     * @param margins - Nine-patch margins [top, right, bottom, left] | 九宫格边距
+     * @param textureWidth - Source texture width | 源纹理宽度
+     * @param textureHeight - Source texture height | 源纹理高度
+     * @param color - Tint color | 着色颜色
+     * @param alpha - Alpha value | 透明度
+     * @param sortingLayer - Sorting layer | 排序层
+     * @param orderInLayer - Order in layer | 层内顺序
+     * @param options - Additional options | 额外选项
+     */
+    addNinePatch(
+        x: number,
+        y: number,
+        width: number,
+        height: number,
+        margins: [number, number, number, number],
+        textureWidth: number,
+        textureHeight: number,
+        color: number,
+        alpha: number,
+        sortingLayer: string,
+        orderInLayer: number,
+        options?: {
+            rotation?: number;
+            textureId?: number;
+            textureGuid?: string;
+        }
+    ): void {
+        const [marginTop, marginRight, marginBottom, marginLeft] = margins;
+
+        // Ensure minimum size to avoid negative dimensions
+        // 确保最小尺寸以避免负尺寸
+        const minWidth = marginLeft + marginRight;
+        const minHeight = marginTop + marginBottom;
+        const targetWidth = Math.max(width, minWidth);
+        const targetHeight = Math.max(height, minHeight);
+
+        // Calculate center dimensions
+        // 计算中心区域尺寸
+        const centerWidth = targetWidth - marginLeft - marginRight;
+        const centerHeight = targetHeight - marginTop - marginBottom;
+
+        // Source texture UV boundaries (normalized)
+        // 源纹理 UV 边界(归一化)
+        const uvLeft = marginLeft / textureWidth;
+        const uvRight = (textureWidth - marginRight) / textureWidth;
+        const uvTop = marginTop / textureHeight;
+        const uvBottom = (textureHeight - marginBottom) / textureHeight;
+
+        // Common options for all patches
+        // 所有 patch 的公共选项
+        // Note: pivotY=1 means position is top-left corner (Y-up coordinate system)
+        // 注意:pivotY=1 表示位置是左上角(Y轴向上坐标系)
+        const baseOptions = {
+            rotation: options?.rotation ?? 0,
+            pivotX: 0,
+            pivotY: 1,
+            textureId: options?.textureId,
+            textureGuid: options?.textureGuid
+        };
+
+        // Helper to add a patch with specific UVs
+        // 辅助函数:添加具有特定 UV 的 patch
+        const addPatch = (
+            px: number,
+            py: number,
+            pw: number,
+            ph: number,
+            u0: number,
+            v0: number,
+            u1: number,
+            v1: number
+        ) => {
+            if (pw <= 0 || ph <= 0) return;
+            this.addRect(px, py, pw, ph, color, alpha, sortingLayer, orderInLayer, {
+                ...baseOptions,
+                uv: [u0, v0, u1, v1]
+            });
+        };
+
+        // Y-up coordinate system: y decreases as we go down
+        // Y轴向上坐标系:向下移动时 y 减小
+        // (x, y) is top-left corner, patches extend downward (negative y direction)
+        // (x, y) 是左上角,patch 向下延伸(y 减小方向)
+
+        // Top-left corner | 左上角
+        addPatch(x, y, marginLeft, marginTop, 0, 0, uvLeft, uvTop);
+
+        // Top edge | 顶边
+        addPatch(x + marginLeft, y, centerWidth, marginTop, uvLeft, 0, uvRight, uvTop);
+
+        // Top-right corner | 右上角
+        addPatch(x + marginLeft + centerWidth, y, marginRight, marginTop, uvRight, 0, 1, uvTop);
+
+        // Left edge | 左边 (move down = subtract y)
+        addPatch(x, y - marginTop, marginLeft, centerHeight, 0, uvTop, uvLeft, uvBottom);
+
+        // Center | 中心
+        addPatch(x + marginLeft, y - marginTop, centerWidth, centerHeight, uvLeft, uvTop, uvRight, uvBottom);
+
+        // Right edge | 右边
+        addPatch(x + marginLeft + centerWidth, y - marginTop, marginRight, centerHeight, uvRight, uvTop, 1, uvBottom);
+
+        // Bottom-left corner | 左下角
+        addPatch(x, y - marginTop - centerHeight, marginLeft, marginBottom, 0, uvBottom, uvLeft, 1);
+
+        // Bottom edge | 底边
+        addPatch(x + marginLeft, y - marginTop - centerHeight, centerWidth, marginBottom, uvLeft, uvBottom, uvRight, 1);
+
+        // Bottom-right corner | 右下角
+        addPatch(x + marginLeft + centerWidth, y - marginTop - centerHeight, marginRight, marginBottom, uvRight, uvBottom, 1, 1);
+    }
+
     /**
      * Get render data
      * 获取渲染数据
diff --git a/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts b/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
index db26c170..b28085dc 100644
--- a/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
+++ b/packages/ui/src/systems/render/UIScrollViewRenderSystem.ts
@@ -56,9 +56,9 @@ export class UIScrollViewRenderSystem extends EntitySystem {
             const width = (transform.computedWidth ?? transform.width) * scaleX;
             const height = (transform.computedHeight ?? transform.height) * scaleY;
             const alpha = transform.worldAlpha ?? transform.alpha;
-            // 使用排序层和层内顺序 | Use sorting layer and order in layer
+            // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer
             const sortingLayer = transform.sortingLayer;
-            const orderInLayer = transform.orderInLayer;
+            const orderInLayer = transform.worldOrderInLayer;
             // 使用 transform 的 pivot 计算位置
             const pivotX = transform.pivotX;
             const pivotY = transform.pivotY;
diff --git a/packages/ui/src/systems/render/UISliderRenderSystem.ts b/packages/ui/src/systems/render/UISliderRenderSystem.ts
index 09f93ed7..8dd2704d 100644
--- a/packages/ui/src/systems/render/UISliderRenderSystem.ts
+++ b/packages/ui/src/systems/render/UISliderRenderSystem.ts
@@ -55,9 +55,9 @@ export class UISliderRenderSystem extends EntitySystem {
             const width = (transform.computedWidth ?? transform.width) * scaleX;
             const height = (transform.computedHeight ?? transform.height) * scaleY;
             const alpha = transform.worldAlpha ?? transform.alpha;
-            // 使用排序层和层内顺序 | Use sorting layer and order in layer
+            // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer
             const sortingLayer = transform.sortingLayer;
-            const orderInLayer = transform.orderInLayer;
+            const orderInLayer = transform.worldOrderInLayer;
             // 使用 transform 的 pivot 计算中心位置
             const pivotX = transform.pivotX;
             const pivotY = transform.pivotY;
diff --git a/packages/ui/src/systems/render/UITextRenderSystem.ts b/packages/ui/src/systems/render/UITextRenderSystem.ts
index ce4fe8f5..77171b31 100644
--- a/packages/ui/src/systems/render/UITextRenderSystem.ts
+++ b/packages/ui/src/systems/render/UITextRenderSystem.ts
@@ -112,9 +112,9 @@ export class UITextRenderSystem extends EntitySystem {
             const width = (transform.computedWidth ?? transform.width) * scaleX;
             const height = (transform.computedHeight ?? transform.height) * scaleY;
             const alpha = transform.worldAlpha ?? transform.alpha;
-            // 使用排序层和层内顺序 | Use sorting layer and order in layer
+            // 使用排序层和世界层内顺序 | Use sorting layer and world order in layer
             const sortingLayer = transform.sortingLayer;
-            const orderInLayer = transform.orderInLayer;
+            const orderInLayer = transform.worldOrderInLayer;
             // 使用 transform 的 pivot 作为旋转/缩放中心
             const pivotX = transform.pivotX;
             const pivotY = transform.pivotY;
diff --git a/packages/world-streaming/src/WorldStreamingModule.ts b/packages/world-streaming/src/WorldStreamingModule.ts
index d176590c..d505fe58 100644
--- a/packages/world-streaming/src/WorldStreamingModule.ts
+++ b/packages/world-streaming/src/WorldStreamingModule.ts
@@ -1,5 +1,4 @@
-import type { IScene, ServiceContainer } from '@esengine/ecs-framework';
-import { ComponentRegistry } from '@esengine/ecs-framework';
+import type { IScene, ServiceContainer, IComponentRegistry } from '@esengine/ecs-framework';
 import type { IRuntimeModule, SystemContext } from '@esengine/engine-core';
 import { ChunkComponent } from './components/ChunkComponent';
 import { StreamingAnchorComponent } from './components/StreamingAnchorComponent';
@@ -22,7 +21,7 @@ export class WorldStreamingModule implements IRuntimeModule {
         return this._chunkManager;
     }
 
-    registerComponents(registry: typeof ComponentRegistry): void {
+    registerComponents(registry: IComponentRegistry): void {
         registry.register(ChunkComponent);
         registry.register(StreamingAnchorComponent);
         registry.register(ChunkLoaderComponent);