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'; import { UIToast } from '@scripts/modules/lobby/ui/UIToast'; import { IDialogConfig, UIDialog } from '@scripts/modules/lobby/ui/UIDialog'; import { AppKernel } from '@scripts/app/AppKernel'; 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(); private loadingCount = 0; 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 { const config = getUIViewConfig(viewId); const currentRecord = this.records.get(viewId); const shouldShowLoading = config.ignoreLoading !== true && currentRecord?.state !== UIViewState.Opened && !currentRecord?.opening; if (!shouldShowLoading) { return this.openView(viewId, params); } await this.loading(true, 0.5); try { return await this.openView(viewId, params); } finally { await this.loading(false); } } private async openView( viewId: UIViewID, params?: TParams, ): Promise { let record = this.records.get(viewId); if (record?.closing) { await record.closing; 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) this.cancelDelayedDestroy(record); if (record?.state === UIViewState.Cached && record.view?.node.isValid) { const generation = ++record.generation; const opening = this.performCachedOpen(record, params, generation); record.opening = opening; try { return await opening as TView | null; } finally { if (record.opening === opening) record.opening = null; } } 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; const opening = this.performOpen(record, root, params, generation); record.opening = opening; try { return await opening as TView | null; } finally { if (record.opening === opening) 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) { if (record.opening) { ++record.generation; if ((record.config.cache || UICachePolicy.None) === UICachePolicy.Delayed) { this.scheduleDelayedDestroy(record); } } return true; } ++record.generation; record.closing = this.performClose(record, reason); try { return await record.closing; } finally { record.closing = null; } } /** * 关闭全部已登记界面;ignoreViewIds 中的界面会保持当前状态。 */ async closeAll(ignoreViewIds: readonly UIViewID[] = [], reason = UICloseReason.SceneChanged): Promise { const ignoredViews = new Set(ignoreViewIds); const handles: Promise[] = []; this.records.forEach(record => { if (ignoredViews.has(record.config.id)) return; handles.push(this.close(record.config.id, reason)); }) await Promise.all(handles); } 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; const prefabView = prefab.data?.getComponent?.(UIView) as UIView | null; if (!prefabView) { record.state = UIViewState.None; throw new Error(`[UIManager] Prefab has no UIView component: ${record.config.id}`); } let allowOpen: boolean; try { allowOpen = await prefabView.internalOpenBefore(params); } catch (error) { if (generation === record.generation) record.state = UIViewState.None; throw error; } if (generation !== record.generation) return null; if (!allowOpen) { record.state = UIViewState.None; 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 performCachedOpen( record: UIViewRecord, params: unknown, generation: number, ): Promise { const view = record.view; if (!view?.node?.isValid) return null; let allowOpen: boolean; try { allowOpen = await view.internalOpenBefore(params); } catch (error) { if (generation === record.generation) this.restoreCachedRecord(record); throw error; } if (generation !== record.generation || !view.node.isValid) return null; if (!allowOpen) { this.restoreCachedRecord(record); return null; } await this.closeMutexViews(record.config); if (generation !== record.generation || !view.node.isValid) return null; record.state = UIViewState.Opening; view.node.active = true; await view.internalOpen(params); if (generation !== record.generation || !view.node.isValid) return null; record.state = UIViewState.Opened; return view; } private restoreCachedRecord(record: UIViewRecord): void { if (!record.view?.node?.isValid || this.records.get(record.config.id) !== record) return; record.view.node.active = false; record.state = UIViewState.Cached; if ((record.config.cache || UICachePolicy.None) === UICachePolicy.Delayed) { this.scheduleDelayedDestroy(record); } } 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); } async loading(active: boolean, delay = 0): Promise { if (active) { ++this.loadingCount; try { await this.open(UIViewID.LoadingView, { delay }); if (this.loadingCount === 0) { await this.close(UIViewID.LoadingView); } } catch (error) { this.loadingCount = Math.max(0, this.loadingCount - 1); throw error; } return; } if (this.loadingCount > 0) --this.loadingCount; if (this.loadingCount > 0) return; await this.close(UIViewID.LoadingView); // close 等待期间可能又有新的加载任务,需重新显示 LoadingView。 if (this.loadingCount > 0) { await this.open(UIViewID.LoadingView); } } async toast(msg: string) { let uiToast = this.get(UIViewID.UIToast); if (!uiToast) { uiToast = await this.open(UIViewID.UIToast); } uiToast.show(msg); } async dialog(config: IDialogConfig) { await AppKernel.ui.open(UIViewID.UIDialog, config); } }