import { instantiate, Node } from 'cc'; import { BUNDLE_ID } from '@scripts/define/ResDefine'; import { getUIViewConfig, UIViewConfig, UIViewID } from '../../define/UIDefine'; import { ResourceManager } from '../resource/ResourceManager'; import { UICachePolicy, UICloseReason, UILayer, UIViewState } from './UILayer'; import { UIView } from './UIView'; interface UIViewRecord { config: UIViewConfig; state: UIViewState; view: UIView | null; ownerRoot: Node | null; generation: number; opening: Promise | null; closing: Promise | null; delayedDestroyTimer: ReturnType | null; } export class UIManager { private static readonly DELAYED_DESTROY_MS = 5000; private readonly records = new Map(); private readonly roots = new Map(); constructor(private readonly resources: ResourceManager) { } bindLayer(layer: UILayer, root: Node): void { this.roots.set(layer, root); } /** * 接管当前场景各 UI Layer 中预放置的 UIView。 * 注册后的 View 先进入缓存态,之后仍由 open/close 统一驱动生命周期。 */ async registerSceneViews(): Promise { for (const [layer, root] of this.roots) { if (!root.isValid) continue; const views = root.getComponentsInChildren(UIView); for (const view of views) { const viewId = view.getViewID(); if (!viewId) { console.warn(`[UIManager] Scene UIView has no viewId: ${view.node.name}`); continue; } const config = getUIViewConfig(viewId); if (!config) { console.warn(`[UIManager] Scene UIView is not configured: ${viewId}`); continue; } if (config.layer !== layer) { console.warn( `[UIManager] Scene UIView is under the wrong layer: ${viewId}, expected=${config.layer}, actual=${layer}`, ); continue; } const existing = this.records.get(viewId); if (existing?.view === view) continue; if (existing?.view?.node?.isValid) { console.warn(`[UIManager] Duplicate scene UIView ignored: ${viewId}`); continue; } if (existing) this.forgetRecord(existing); const record: UIViewRecord = { config, state: UIViewState.Creating, view, ownerRoot: root, generation: 1, opening: null, closing: null, delayedDestroyTimer: null, }; this.records.set(viewId, record); view.node.active = false; try { await view.internalCreate(); if (!view.node.isValid || this.records.get(viewId) !== record) { this.forgetRecord(record); continue; } record.state = UIViewState.Cached; } catch (error) { this.forgetRecord(record); throw error; } } } } clearSceneLayers(): void { const sceneRoots = new Set(this.roots.values()); this.roots.clear(); for (const record of [...this.records.values()]) { const node = record.view?.node; const belongsToSceneLayer = node?.isValid ? [...sceneRoots].some((root) => root.isValid && (node === root || node.isChildOf(root))) : record.ownerRoot !== null && sceneRoots.has(record.ownerRoot); if (!node?.isValid || belongsToSceneLayer) { this.forgetRecord(record); } } } get(viewId: UIViewID): TView | null { const record = this.records.get(viewId); if ( !record || !record.view?.node?.isValid || (record.state !== UIViewState.Opening && record.state !== UIViewState.Opened) ) { return null; } return record.view as TView | null; } isOpen(viewId: UIViewID): boolean { return this.get(viewId) !== null; } async open(viewId: UIViewID, params?: TParams): Promise { let record = this.records.get(viewId); if (record?.view && !record.view.node.isValid) { this.forgetRecord(record); record = undefined; } if (record?.state === UIViewState.Opened) return record.view as TView; if (record?.opening) return record.opening as Promise; if (record?.closing) await record.closing; if (record) this.cancelDelayedDestroy(record); if (record?.state === UIViewState.Cached && record.view?.node.isValid) { await this.closeMutexViews(record.config); record.state = UIViewState.Opening; record.view.node.active = true; await record.view.internalOpen(params); record.state = UIViewState.Opened; return record.view as TView; } const config = getUIViewConfig(viewId); const root = this.roots.get(config.layer); if (!root?.isValid) throw new Error(`[UIManager] UILayer is not bound: ${config.layer}`); record = record ?? { config, state: UIViewState.None, view: null, ownerRoot: root, generation: 0, opening: null, closing: null, delayedDestroyTimer: null, }; record.ownerRoot = root; this.records.set(viewId, record); const generation = ++record.generation; record.opening = this.performOpen(record, root, params, generation); try { return await record.opening as TView | null; } finally { if (record.generation === generation) record.opening = null; } } async close(viewIdOrInstance: UIViewID, reason = UICloseReason.User): Promise { const record = typeof viewIdOrInstance === 'string' ? this.records.get(viewIdOrInstance) : [...this.records.values()].find((item) => item.view === viewIdOrInstance); if (!record) return false; if (record.closing) return record.closing; if (record.state === UIViewState.Cached) return true; ++record.generation; record.closing = this.performClose(record, reason); try { return await record.closing; } finally { record.closing = null; } } async closeAll(reason = UICloseReason.SceneChanged): Promise { await Promise.all([...this.records.values()].map((record) => this.close(record.config.id, reason))); } private async performOpen( record: UIViewRecord, root: Node, params: unknown, generation: number, ): Promise { record.state = UIViewState.Loading; const bundleName = record.config.bundle || BUNDLE_ID.Lobby; const prefab = await this.resources.loadPrefab(bundleName, record.config.prefabPath); if (generation !== record.generation) return null; record.state = UIViewState.Creating; const node = instantiate(prefab); root.addChild(node); const view = node.getComponent(UIView); if (!view) { node.destroy(); throw new Error(`[UIManager] Prefab has no UIView component: ${record.config.id}`); } record.view = view; await view.internalCreate(); if (generation !== record.generation || !node.isValid) { node.destroy(); record.view = null; return null; } await this.closeMutexViews(record.config); record.state = UIViewState.Opening; node.active = true; await view.internalOpen(params); if (generation !== record.generation || !node.isValid) return null; record.state = UIViewState.Opened; return view; } private async performClose(record: UIViewRecord, reason: UICloseReason): Promise { if (!record.view?.node?.isValid) { this.cancelDelayedDestroy(record); record.state = UIViewState.Disposed; record.view = null; this.records.delete(record.config.id); return false; } const allow = reason === UICloseReason.ForceDispose ? true : await record.view.internalBeforeClose(reason); if (!allow) return false; record.state = UIViewState.Closing; await record.view.internalClose(reason); const uiCache = record.config.cache || UICachePolicy.None; if (reason === UICloseReason.ForceDispose || uiCache === UICachePolicy.None) { this.disposeRecord(record); return true; } record.view.node.active = false; record.state = UIViewState.Cached; if (uiCache === UICachePolicy.Delayed) { this.scheduleDelayedDestroy(record); } return true; } private scheduleDelayedDestroy(record: UIViewRecord): void { this.cancelDelayedDestroy(record); const generation = record.generation; record.delayedDestroyTimer = setTimeout(() => { record.delayedDestroyTimer = null; if ( record.generation === generation && record.state === UIViewState.Cached ) { this.disposeRecord(record); } }, UIManager.DELAYED_DESTROY_MS); } private cancelDelayedDestroy(record: UIViewRecord): void { if (record.delayedDestroyTimer === null) return; clearTimeout(record.delayedDestroyTimer); record.delayedDestroyTimer = null; } private disposeRecord(record: UIViewRecord): void { this.cancelDelayedDestroy(record); if (record.view?.node?.isValid) { record.view.internalDispose(); record.view.node.destroy(); } record.view = null; record.ownerRoot = null; record.state = UIViewState.Disposed; this.records.delete(record.config.id); } /** 从管理器中同步移除记录,不主动销毁正由场景释放的节点。 */ private forgetRecord(record: UIViewRecord): void { ++record.generation; this.cancelDelayedDestroy(record); if (record.view?.node?.isValid) { record.view.internalDispose(); } record.view = null; record.ownerRoot = null; record.opening = null; record.closing = null; record.state = UIViewState.Disposed; if (this.records.get(record.config.id) === record) { this.records.delete(record.config.id); } } private async closeMutexViews(config: UIViewConfig): Promise { const tasks: Promise[] = []; for (const record of this.records.values()) { if (!record.config.mutexGroup) continue; if (record.config.id !== config.id && record.config.mutexGroup === config.mutexGroup) { tasks.push(this.close(record.config.id, UICloseReason.Navigation)); } } await Promise.all(tasks); } loading(active: boolean) { if (active) { this.open(UIViewID.LoadingView); } else { this.close(UIViewID.LoadingView); } } toast() { } dialog() { } }