diff --git a/packages/core/src/ECS/Utils/EntityList.ts b/packages/core/src/ECS/Utils/EntityList.ts index 24a75b0b..87b3304c 100644 --- a/packages/core/src/ECS/Utils/EntityList.ts +++ b/packages/core/src/ECS/Utils/EntityList.ts @@ -80,17 +80,30 @@ export class EntityList { /** * 移除所有实体 + * Remove all entities + * + * 包括 buffer 中的实体和待添加队列中的实体。 + * Includes entities in buffer and entities in pending add queue. */ public removeAllEntities(): void { - // 收集所有实体ID用于回收 const idsToRecycle: number[] = []; + // 销毁 buffer 中的实体 + // Destroy entities in buffer for (let i = this.buffer.length - 1; i >= 0; i--) { idsToRecycle.push(this.buffer[i]!.id); this.buffer[i]!.destroy(); } - // 批量回收ID + // 销毁待添加队列中的实体(这些实体已创建但尚未加入 buffer) + // Destroy entities in pending add queue (created but not yet in buffer) + for (const entity of this._entitiesToAdd) { + idsToRecycle.push(entity.id); + entity.destroy(); + } + + // 批量回收 ID + // Recycle IDs in batch if (this._scene && this._scene.identifierPool) { for (const id of idsToRecycle) { this._scene.identifierPool.checkIn(id); diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 17e2517e..352c4625 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -841,54 +841,51 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport // Switch to player camera syncPlayerCamera(); - // Register RuntimeSceneManager for scene switching in play mode - // 注册 RuntimeSceneManager 以支持 Play 模式下的场景切换 + // 设置 RuntimeSceneManager 用于 Play 模式场景切换 + // Setup RuntimeSceneManager for scene switching in play mode + // + // 生命周期设计: + // - 首次 Play:创建新实例 + // - 后续 Play:复用实例,只更新 sceneLoader + // - Stop:调用 reset() 清理会话状态,保留实例 + // + // Lifecycle design: + // - First Play: Create new instance + // - Subsequent Plays: Reuse instance, only update sceneLoader + // - Stop: Call reset() to clear session state, keep instance 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 读取场景文件的场景加载器函数 + // 创建场景加载函数 + // Create scene loader function 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(); + // 注意:不要在这里保存快照! + // 初始快照在 Play 开始时已保存,Stop 时应恢复到那个状态 + // 如果在这里保存,会覆盖初始快照,导致动态创建的实体残留 + // + // Note: Don't save snapshot here! + // Initial snapshot was saved at Play start, Stop should restore to that state + // Saving here would overwrite initial snapshot, causing dynamically created entities to remain - // 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) { @@ -896,28 +893,17 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport } } - // 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(); } @@ -929,22 +915,35 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport } }; - // 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'); + if (runtimeSceneManagerRef.current) { + // 复用已有实例:重置状态并更新 sceneLoader + // Reuse existing instance: reset state and update sceneLoader + runtimeSceneManagerRef.current.reset(); + runtimeSceneManagerRef.current.setSceneLoader(editorSceneLoader); + runtimeSceneManagerRef.current.setBaseUrl(`${projectPath}\\scenes`); + + // 确保已注册到服务容器 + // Ensure registered to service container + if (!Core.services.isRegistered(GlobalSceneManagerKey)) { + Core.services.registerInstance(GlobalSceneManagerKey, runtimeSceneManagerRef.current); + } + console.log('[Viewport] RuntimeSceneManager reused for play mode'); + } else { + // 首次创建实例 + // First time: create new instance + const sceneManager = new RuntimeSceneManager( + editorSceneLoader, + `${projectPath}\\scenes` + ); + runtimeSceneManagerRef.current = sceneManager; + + if (!Core.services.isRegistered(GlobalSceneManagerKey)) { + Core.services.registerInstance(GlobalSceneManagerKey, sceneManager); + } + console.log('[Viewport] RuntimeSceneManager created for play mode'); + } } // Register user code components and systems before starting engine @@ -984,16 +983,19 @@ export function Viewport({ locale = 'en', messageHub, commandManager }: Viewport setPlayState('stopped'); engine.stop(); - // Unregister RuntimeSceneManager - // 注销 RuntimeSceneManager + // 重置 RuntimeSceneManager 状态(但保留实例以便下次 Play 复用) + // Reset RuntimeSceneManager state (but keep instance for next Play reuse) 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'); + // 调用 reset() 而不是 dispose(),保留 sceneLoader 以便复用 + // Call reset() instead of dispose(), keep sceneLoader for reuse + runtimeSceneManagerRef.current.reset(); + // 注意:不设置 ref 为 null,保留实例 + // Note: Don't set ref to null, keep the instance + console.log('[Viewport] RuntimeSceneManager reset (instance kept for reuse)'); } // Restore scene snapshot diff --git a/packages/runtime-core/src/services/RuntimeSceneManager.ts b/packages/runtime-core/src/services/RuntimeSceneManager.ts index 46ac45eb..54299586 100644 --- a/packages/runtime-core/src/services/RuntimeSceneManager.ts +++ b/packages/runtime-core/src/services/RuntimeSceneManager.ts @@ -2,8 +2,12 @@ * 运行时场景管理器 * Runtime Scene Manager * - * 提供场景加载和切换 API,供用户脚本使用 - * Provides scene loading and transition API for user scripts + * 提供场景加载和切换 API,供用户脚本使用。 + * Provides scene loading and transition API for user scripts. + * + * 生命周期设计 | Lifecycle Design: + * - reset(): 清理会话状态,保留核心功能(用于 Play/Stop 切换) + * - dispose(): 完全销毁,释放所有资源(用于编辑器关闭) * * @example * ```typescript @@ -68,8 +72,9 @@ export type SceneLoader = (url: string) => Promise; * 运行时场景管理器接口 * Runtime Scene Manager Interface * - * 继承 IService 的 dispose 模式以兼容 ServiceContainer。 - * Follows IService dispose pattern for ServiceContainer compatibility. + * 生命周期方法 | Lifecycle Methods: + * - reset(): 重置会话状态(Play/Stop 切换时调用) + * - dispose(): 完全销毁(编辑器关闭时调用) */ export interface IRuntimeSceneManager { /** @@ -133,8 +138,41 @@ export interface IRuntimeSceneManager { onLoadError(callback: (error: Error, sceneName: string) => void): () => void; /** - * 释放资源(IService 兼容) - * Dispose resources (IService compatible) + * 设置场景加载器 + * Set scene loader + * + * 用于更新场景加载函数(如 Play 模式切换时)。 + * Used to update scene loader function (e.g., during Play mode transitions). + */ + setSceneLoader(loader: SceneLoader): void; + + /** + * 设置基础 URL + * Set base URL + */ + setBaseUrl(baseUrl: string): void; + + /** + * 重置会话状态 + * Reset session state + * + * 清理监听器和当前场景状态,但保留 sceneLoader。 + * 用于 Play/Stop 切换时调用。 + * + * Clears listeners and current scene state, but keeps sceneLoader. + * Called during Play/Stop transitions. + */ + reset(): void; + + /** + * 完全释放资源 + * Dispose all resources + * + * 销毁实例,清理所有资源。 + * 仅在编辑器关闭时调用。 + * + * Destroys the instance, cleans up all resources. + * Only called when editor closes. */ dispose(): void; } @@ -367,25 +405,45 @@ export class RuntimeSceneManager implements IRuntimeSceneManager { } } - // ==================== IService 实现 | IService Implementation ==================== + // ==================== 生命周期方法 | Lifecycle Methods ==================== /** - * 释放资源 - * Dispose resources + * 重置会话状态 + * Reset session state * - * 实现 IService 接口,清理所有监听器和状态。 - * Implements IService interface, cleans up all listeners and state. + * 清理监听器和当前场景状态,但保留 sceneLoader 和 baseUrl。 + * 用于 Play/Stop 切换时调用,允许实例复用。 + * + * Clears listeners and current scene state, but keeps sceneLoader and baseUrl. + * Called during Play/Stop transitions, allows instance reuse. */ - dispose(): void { - if (this._disposed) return; - + reset(): void { this._loadStartListeners.clear(); this._loadCompleteListeners.clear(); this._loadErrorListeners.clear(); this._scenes.clear(); - this._sceneLoader = null; this._currentSceneName = null; this._currentScenePath = null; + this._isLoading = false; + // 注意:保留 _sceneLoader 和 _baseUrl + // Note: Keep _sceneLoader and _baseUrl + } + + /** + * 完全释放资源 + * Dispose all resources + * + * 销毁实例,清理所有资源包括 sceneLoader。 + * 仅在编辑器完全关闭时调用。 + * + * Destroys the instance, cleans up all resources including sceneLoader. + * Only called when editor completely closes. + */ + dispose(): void { + if (this._disposed) return; + + this.reset(); + this._sceneLoader = null; this._disposed = true; } } diff --git a/packages/ui/src/systems/SceneLoadTriggerSystem.ts b/packages/ui/src/systems/SceneLoadTriggerSystem.ts index c712c77d..4466a84d 100644 --- a/packages/ui/src/systems/SceneLoadTriggerSystem.ts +++ b/packages/ui/src/systems/SceneLoadTriggerSystem.ts @@ -4,24 +4,24 @@ * * 处理 SceneLoadTriggerComponent,绑定 UIInteractable 点击事件到场景加载。 * Processes SceneLoadTriggerComponent, binds UIInteractable click to scene loading. + * + * 设计说明 | Design Notes: + * - 每次点击时动态从服务容器获取 RuntimeSceneManager + * - 不缓存服务引用,避免 Play/Stop 切换时引用失效 + * - Dynamically resolves RuntimeSceneManager from service container on each click + * - Avoids caching service references to handle Play/Stop lifecycle correctly */ 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. + * 包含 IService 所需方法以满足 tryResolve 类型约束。 + * Includes IService required methods to satisfy tryResolve type constraint. */ interface ISceneManager { loadScene(sceneName: string): Promise; @@ -32,8 +32,8 @@ interface ISceneManager { * 全局场景管理器服务键 * Global scene manager service key * - * 使用 Symbol.for 确保与 BrowserRuntime 中注册的键一致。 - * Uses Symbol.for to match the key registered in BrowserRuntime. + * 使用 Symbol.for 确保与 BrowserRuntime/Viewport 中注册的键一致。 + * Uses Symbol.for to match the key registered in BrowserRuntime/Viewport. */ const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager'); @@ -46,30 +46,11 @@ const GlobalSceneManagerKey = Symbol.for('@esengine/service:runtimeSceneManager' */ @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); @@ -77,84 +58,51 @@ export class SceneLoadTriggerSystem extends EntitySystem { if (!trigger || !interactable) continue; if (!trigger.enabled || !trigger.targetScene) continue; - // 只绑定一次回调 - // Only bind callback once + // 只绑定一次回调 | 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 + this._bindClickHandler(trigger, interactable); } } /** * 绑定点击处理器 * Bind click handler + * + * 关键设计:不缓存 sceneManager 引用,每次点击时动态获取。 + * Key design: Don't cache sceneManager reference, resolve dynamically on each click. */ 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(); + // 每次点击时动态获取场景管理器 + // Resolve scene manager dynamically on each click + const sceneManager = Core.services.tryResolve(GlobalSceneManagerKey); + if (!sceneManager?.loadScene) { + // 编辑器预览模式下可能未注册,静默处理 + // May not be registered in editor preview mode, handle silently + return; } - // 加载场景 - // 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 + sceneManager.loadScene(targetScene).catch((error) => { + console.error(`[SceneLoadTriggerSystem] Failed to load scene "${targetScene}":`, error); + if (trigger.disableOnClick) { + trigger.enabled = true; + } + }); }; trigger._callbackBound = true;