You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1043 lines
35 KiB
1043 lines
35 KiB
//*********************
|
|
// create by 流云
|
|
// time: 2020.12.18
|
|
// desc: UI管理器
|
|
//*********************
|
|
|
|
import { Scene, Node, UITransform, Camera, director, Widget, Layout, Label, Overflow, HorizontalTextAlignment, Layers, Director, Asset, AssetManager, Prefab, assetManager, instantiate, Tween, tween, v3, RichText, game, view, EventTouch, isValid, ResolutionPolicy, screen, Mask } from "cc";
|
|
|
|
import { OpenParam, UIBase } from "./UIBase";
|
|
import type { IPopAnimConfig } from './popAnim';
|
|
import { PopAnimPlayer } from './popAnim/PopAnimPlayer';
|
|
import type { IPopAnimService } from './popAnim/IPopAnimService';
|
|
import { BundleLifecycle } from '../res/BundleLifecycle';
|
|
import { LoadRecoveryConfig } from '../res/LoadRecovery';
|
|
import { LoadScheduler } from '../res/LoadScheduler';
|
|
import {
|
|
createMembershipSet,
|
|
membershipAdd,
|
|
membershipDelete,
|
|
mapKeysToArray,
|
|
type MembershipSet,
|
|
} from '../../tools/MembershipSet';
|
|
|
|
/** 局内 pauseBattle 面板开关钩子(由业务层注册,避免 mx→script 静态依赖) */
|
|
export type BattlePausePanelHook = (uiId: number, open: boolean) => void;
|
|
|
|
/** Toast 出现基准高度(相对可见区域,约在角色名下方) */
|
|
const TOAST_BASE_Y_RATIO = 0.075;
|
|
const TOAST_STACK_GAP = 50;
|
|
|
|
/**UI信息 */
|
|
export class UIInfo {
|
|
/**UIID */
|
|
id: number = 0;
|
|
/**UI节点 */
|
|
uiNode: Node = null;
|
|
/**UI组件 */
|
|
uiBase: UIBase = null;
|
|
/**预制体资源 */
|
|
prefabRes: Prefab = null;
|
|
/**UI配置 */
|
|
uiconf: IUIConfig = null;
|
|
}
|
|
|
|
/**
|
|
* UI层级(新增层级在此添加枚举即可,层级按枚举顺序显示)
|
|
*/
|
|
export enum UILayer {
|
|
/**2D游戏UI */
|
|
Game2D = `Game2D`,
|
|
/**主UI层 */
|
|
MainUI = `MainUI`,
|
|
/**菜单层 */
|
|
Menu = `Menu`,
|
|
/**全屏面板层 */
|
|
Page = `Page`,
|
|
/**弹窗页面 */
|
|
Pop = `Pop`,
|
|
/**提示页面(模态对话框) */
|
|
Dalog = `Dalog`,
|
|
/**动画层 */
|
|
Anim = `Anim`,
|
|
/**引导页面 */
|
|
Guide = `Guide`,
|
|
/**加载页面 */
|
|
Loading = `Loading`,
|
|
/**调试层 */
|
|
DeBug = `DeBug`,
|
|
/**广告页面 */
|
|
Ad = `Ad`,
|
|
}
|
|
|
|
/** 面板成功打开后钩子(含已打开再置顶),由业务层注册,避免 mx→script 静态依赖 */
|
|
export type PanelOpenedHook = (uiId: number, layer: UILayer) => void;
|
|
|
|
/**
|
|
* UI配置
|
|
*/
|
|
export interface IUIConfig {
|
|
/**UI描述 */
|
|
desc: string;
|
|
/**UI层级 */
|
|
layer: UILayer;
|
|
/**资源包名 */
|
|
bundle: string;
|
|
/**资源路径 */
|
|
path: string;
|
|
/**资源名称 */
|
|
name: string;
|
|
/**
|
|
* 局内打开时暂停战斗(引用计数:关完所有 pauseBattle 面板才恢复)。
|
|
* 新增需暂停的弹窗:在 ConfigRes.UIConfigs 对应项加 pauseBattle: true。
|
|
*/
|
|
pauseBattle?: boolean;
|
|
/** 弹窗入场/退场预设,配好后无需在 onInit 手写 showOpen* */
|
|
popAnim?: IPopAnimConfig;
|
|
}
|
|
|
|
|
|
/**
|
|
* UI管理器
|
|
*/
|
|
export class UIManager {
|
|
|
|
private static _ins: UIManager = null;
|
|
static get Ins() {
|
|
if (!this._ins) this._ins = new UIManager();
|
|
return this._ins;
|
|
}
|
|
|
|
private readonly _popAnim: IPopAnimService = new PopAnimPlayer();
|
|
/** 弹窗入场/退场动效 */
|
|
get popAnim(): IPopAnimService { return this._popAnim; }
|
|
|
|
/**宽度 */
|
|
get width() {
|
|
return this._root ? this._root.getComponent(UITransform).width : view.getVisibleSize().width;
|
|
}
|
|
/**高度 */
|
|
get height() {
|
|
return this._root ? this._root.getComponent(UITransform).height : view.getVisibleSize().height;
|
|
}
|
|
|
|
/**场景唯一相机 */
|
|
UICamera: Camera = null;
|
|
|
|
/**是否动态适配屏幕(默认开启) */
|
|
isDynamicAdaptScreen: boolean = true;
|
|
|
|
private _curent_scene: Scene = null;
|
|
private _canvas: Node = null;
|
|
private _root: Node = null;
|
|
private _log: Node = null;
|
|
private _timer_flag = null;
|
|
|
|
|
|
private _layer_map: Map<UILayer, Node> = new Map<UILayer, Node>();
|
|
private _ui_map: Map<number, UIInfo> = new Map<number, UIInfo>();
|
|
/** 同 id 的 openPanel 异步加载未完成时,后续点击复用该 Promise,避免 uiBase 仍为 null 时写 prema */
|
|
private _panelOpenInflight: Map<number, Promise<void>> = new Map();
|
|
/** 与 _panelOpenInflight 配对,失败恢复时必须 resolve,否则后续 openPanel 会永久等待 */
|
|
private _panelOpenResolve: Record<number, (() => void) | undefined> = {};
|
|
/** 打开代数:abort 后作废仍在 await load 的过期回调 */
|
|
private _openEpoch: Record<number, number> = {};
|
|
/** 同 id 的 closePanel 进行中时复用 Promise,避免连点双 destroy / 双 decRef */
|
|
private _panelCloseInflight: Map<number, Promise<void>> = new Map();
|
|
private _wait_popIds: { id: number; param?: OpenParam }[] = [];
|
|
private _curent_pop: { id: number; param?: OpenParam } | null = null;
|
|
/** 同 id 连开时只保留最后一次参数 */
|
|
private _latestOpenParam: Record<number, OpenParam | undefined> = {};
|
|
/** 弹窗队列单工人 */
|
|
private _popRunning = false;
|
|
/** 本管理器已 acquire 的 pauseBattle 面板 */
|
|
private _pauseHeld: MembershipSet<number> = createMembershipSet();
|
|
|
|
private uiConfigs: { [key: number]: IUIConfig } = [];
|
|
private _battlePauseHook: BattlePausePanelHook | null = null;
|
|
private _panelOpenedHook: PanelOpenedHook | null = null;
|
|
|
|
/**
|
|
* 注册局内 pauseBattle 钩子(业务层在启动时调用一次)。
|
|
* open=true 打开面板;open=false 关闭面板。
|
|
*/
|
|
setBattlePauseHook(hook: BattlePausePanelHook | null): void {
|
|
this._battlePauseHook = hook;
|
|
}
|
|
|
|
/**
|
|
* 注册面板打开钩子(业务层在战斗 UI 存活期间注册)。
|
|
* 任意 openPanel 成功展示后回调(含已打开再置顶)。
|
|
*/
|
|
setPanelOpenedHook(hook: PanelOpenedHook | null): void {
|
|
this._panelOpenedHook = hook;
|
|
}
|
|
|
|
private _notifyBattlePause(id: number, open: boolean): void {
|
|
const conf = this.uiConfigs[id];
|
|
if (!conf?.pauseBattle) return;
|
|
this._battlePauseHook?.(id, open);
|
|
}
|
|
|
|
private _notifyPanelOpened(id: number): void {
|
|
const conf = this.uiConfigs[id];
|
|
if (!conf) return;
|
|
this._panelOpenedHook?.(id, conf.layer);
|
|
}
|
|
|
|
/**初始化(切换场景的时候调用) */
|
|
init(config: { [key: number]: IUIConfig }, camera?: Camera, isDynamicAdaptScreen: boolean = true) {
|
|
this.uiConfigs = config;
|
|
this.UICamera = camera;
|
|
this.isDynamicAdaptScreen = isDynamicAdaptScreen;
|
|
this._curent_scene = director.getScene();
|
|
this._canvas = this._curent_scene.getChildByName(`Canvas`);
|
|
|
|
if (this.isDynamicAdaptScreen) {
|
|
let winsize = screen.windowSize;
|
|
let ratio = winsize.width / winsize.height;
|
|
let drs = view.getDesignResolutionSize();
|
|
let drsRatio = drs.width / drs.height;
|
|
if (ratio > drsRatio) {
|
|
view.setResolutionPolicy(ResolutionPolicy.FIXED_HEIGHT);
|
|
} else {
|
|
view.setResolutionPolicy(ResolutionPolicy.FIXED_WIDTH);
|
|
}
|
|
}
|
|
|
|
//初始化UI根节点
|
|
this._root = this._canvas.getChildByName(`root`);
|
|
if (!this._root) this._root = this.addUILayer(this._canvas, `root`);
|
|
|
|
//初始化层级
|
|
for (let k in UILayer) {
|
|
let v = UILayer[k];
|
|
this._layer_map.set(v, this.addUILayer(this._root, v));
|
|
}
|
|
|
|
|
|
//初始化log层
|
|
this._log = this._root.getChildByName(`log`);
|
|
if (!this._log) {
|
|
this._log = new Node(`log`);
|
|
this._root.addChild(this._log);
|
|
let w = this._log.addComponent(Widget);
|
|
w.isAlignLeft = w.isAlignTop = w.isAlignRight = w.isAlignBottom = true;
|
|
w.left = w.top = w.right = w.bottom = 0;
|
|
w.updateAlignment();
|
|
|
|
let ly = this._log.addComponent(Layout);
|
|
ly.type = Layout.Type.VERTICAL;
|
|
let dc = new Node("dc");
|
|
dc.layer = Layers.Enum.UI_2D;
|
|
let lb = dc.addComponent(Label);
|
|
lb.string = "";
|
|
lb.fontSize = 50;
|
|
this._log.addChild(dc);
|
|
lb.isBold = true;
|
|
let l = new Node("log");
|
|
l.layer = Layers.Enum.UI_2D;
|
|
l.addComponent(UITransform).width = 700;
|
|
lb = l.addComponent(Label);
|
|
lb.horizontalAlign = HorizontalTextAlignment.LEFT;
|
|
lb.overflow = Overflow.RESIZE_HEIGHT;
|
|
lb.string = "";
|
|
lb.fontSize = 30;
|
|
this._log.addChild(l);
|
|
}
|
|
|
|
this._kickPopQueue();
|
|
// this._log.on(Node.EventType.TOUCH_START, (e: EventTouch) => {
|
|
// GEvent.Ins.emit(GEvent.GlobalTouchStart, e);
|
|
// })
|
|
// this._log.on(Node.EventType.TOUCH_MOVE, (e: EventTouch) => {
|
|
// GEvent.Ins.emit(GEvent.GlobalTouchMove, e);
|
|
// e.preventSwallow = true;
|
|
// e.propagationStopped = true;
|
|
// })
|
|
// this._log.on(Node.EventType.TOUCH_END, (e: EventTouch) => {
|
|
// GEvent.Ins.emit(GEvent.GlobalTouchEnd, e);
|
|
// e.preventSwallow = true;
|
|
// e.propagationStopped = true;
|
|
// })
|
|
// this._log.on(Node.EventType.TOUCH_CANCEL, (e: EventTouch) => {
|
|
// GEvent.Ins.emit(GEvent.GlobalTouchCancel, e);
|
|
// e.preventSwallow = true;
|
|
// e.propagationStopped = true;
|
|
// })
|
|
}
|
|
|
|
|
|
/**
|
|
* 设置底部距离
|
|
* @param value 游戏整个UI底边沿距离屏幕底边沿的设计分辨率像素距离
|
|
*/
|
|
setBottomWidget(value) {
|
|
let w = this._root.getComponent(Widget);
|
|
w.bottom = value;
|
|
}
|
|
|
|
/**
|
|
* 设置顶部距离
|
|
* @param value 游戏整个UI顶部边沿距离屏幕顶部边沿的设计分辨率像素距离
|
|
*/
|
|
setTopWidget(value) {
|
|
let w = this._root.getComponent(Widget);
|
|
w.top = value;
|
|
}
|
|
|
|
|
|
/**
|
|
* 显示一个节点到指定层级
|
|
* @param ui ui节点
|
|
* @param layer 层级
|
|
*/
|
|
showNode(ui, layer: UILayer, index = -2) {
|
|
this._layer_map.get(layer).addChild(ui);
|
|
//index设置为0,确保在最底层
|
|
//index设置为-1,确保在最上层
|
|
//index设置为-2,不设置z-index
|
|
if (index != -2)
|
|
ui.setSiblingIndex(index);
|
|
}
|
|
|
|
/**
|
|
* 打开单例页面
|
|
* @param id 页面ID
|
|
* @param param 参数
|
|
*/
|
|
async openPanel(id: number, param?: OpenParam): Promise<boolean> {
|
|
this._latestOpenParam[id] = param;
|
|
|
|
const closeWait = this._panelCloseInflight.get(id);
|
|
if (closeWait) {
|
|
await closeWait;
|
|
}
|
|
|
|
const pending = this._panelOpenInflight.get(id);
|
|
if (pending) {
|
|
await pending;
|
|
const latest = this._latestOpenParam[id];
|
|
if (this._ui_map.get(id)?.uiBase) {
|
|
return this._applyToOpenPanel(id, latest);
|
|
}
|
|
// 前次打开失败:inflight 已清,必须重试,否则这次点击直接 false、体感「再也打不开」
|
|
console.warn('[UIManager] openPanel 前次打开未成功,重试', id);
|
|
if (this._ui_map.has(id)) {
|
|
this._ui_map.delete(id);
|
|
}
|
|
return this._openPanelOnce(id, latest);
|
|
}
|
|
|
|
if (this._ui_map.get(id)?.uiBase) {
|
|
return this._applyToOpenPanel(id, param);
|
|
}
|
|
if (this._ui_map.has(id)) {
|
|
this._ui_map.delete(id);
|
|
}
|
|
|
|
return this._openPanelOnce(id, param);
|
|
}
|
|
|
|
/**
|
|
* 已打开面板套用最新参数并再走 onInit。
|
|
* @param id 面板 id
|
|
* @param param 打开参数
|
|
*/
|
|
private async _applyToOpenPanel(id: number, param?: OpenParam): Promise<boolean> {
|
|
const uiInfo = this._ui_map.get(id);
|
|
if (!uiInfo?.uiBase) {
|
|
console.error('[UIManager] applyToOpenPanel 面板未打开', id);
|
|
return false;
|
|
}
|
|
uiInfo.uiBase.applyOpenParam(param);
|
|
uiInfo.uiBase.beforePanelInit();
|
|
await Promise.resolve(uiInfo.uiBase.onInit());
|
|
if (!uiInfo.uiBase?.isValid) {
|
|
console.error('[UIManager] applyToOpenPanel 期间面板已销毁', id);
|
|
return false;
|
|
}
|
|
uiInfo.uiNode.setSiblingIndex(-1);
|
|
this._holdPause(id);
|
|
this._notifyPanelOpened(id);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 首次实例化打开。
|
|
* @param id 面板 id
|
|
* @param param 打开参数
|
|
*/
|
|
private async _openPanelOnce(id: number, param?: OpenParam): Promise<boolean> {
|
|
const conf = this.uiConfigs[id];
|
|
if (!conf) {
|
|
console.error('[UIManager] openPanel missing config', id);
|
|
return false;
|
|
}
|
|
|
|
const uiInfo = new UIInfo();
|
|
uiInfo.id = id;
|
|
uiInfo.uiconf = conf;
|
|
this._ui_map.set(id, uiInfo);
|
|
|
|
const epoch = (this._openEpoch[id] ?? 0) + 1;
|
|
this._openEpoch[id] = epoch;
|
|
let resolveInflight: () => void;
|
|
const inflightPromise = new Promise<void>((resolve) => {
|
|
resolveInflight = resolve;
|
|
});
|
|
this._panelOpenInflight.set(id, inflightPromise);
|
|
this._panelOpenResolve[id] = resolveInflight;
|
|
|
|
let ok = false;
|
|
let didShowLoading = false;
|
|
LoadScheduler.getInstance().beginUserLoad();
|
|
this._holdPause(id);
|
|
try {
|
|
const path = conf.path + conf.name;
|
|
const layer = this._layer_map.get(conf.layer);
|
|
if (!layer) {
|
|
console.error('[UIManager] openPanel missing layer', id, conf.layer);
|
|
return false;
|
|
}
|
|
|
|
if (param && param.showLoading && param.showLoading > 0) {
|
|
gg.game.showLoading(param.showLoading);
|
|
didShowLoading = true;
|
|
} else {
|
|
const bundle = assetManager.getBundle(conf.bundle);
|
|
const cached = bundle?.get(path, Prefab);
|
|
if (!cached?.isValid) {
|
|
gg.game.showLoading(2);
|
|
didShowLoading = true;
|
|
}
|
|
}
|
|
|
|
const p = await this.loadPrefab(conf.bundle, path, this._bundleOwner(id)) as Prefab;
|
|
if (this._openEpoch[id] !== epoch) {
|
|
return false;
|
|
}
|
|
if (!p) {
|
|
console.error('[UIManager] openPanel prefab load failed', id, conf.bundle, path);
|
|
return false;
|
|
}
|
|
const ui = instantiate(p);
|
|
uiInfo.prefabRes = p;
|
|
uiInfo.uiNode = ui;
|
|
uiInfo.uiBase = ui.getComponent(UIBase);
|
|
if (!uiInfo.uiBase) {
|
|
console.error('[UIManager] openPanel missing UIBase', id);
|
|
return false;
|
|
}
|
|
uiInfo.uiBase.uiInfo = uiInfo;
|
|
uiInfo.uiBase.preparePopOpenPose();
|
|
layer.addChild(ui);
|
|
// onAdded 可能 await 资源包/onInit;挂起会导致 inflight 永不结束
|
|
// 超时 timer 必须在成功后清除,否则 reject 会变成未处理 rejection
|
|
await new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
const timer = setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
reject(new Error('[UIManager] onAdded timeout'));
|
|
}, LoadRecoveryConfig.defaultTimeoutMs);
|
|
void Promise.resolve(uiInfo.uiBase.onAdded(param)).then(
|
|
() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
resolve();
|
|
},
|
|
(e) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
reject(e);
|
|
},
|
|
);
|
|
});
|
|
if (this._openEpoch[id] !== epoch) {
|
|
return false;
|
|
}
|
|
if (!uiInfo.uiBase.isValid) {
|
|
console.error('[UIManager] openPanel onAdded 后节点已销毁', id);
|
|
return false;
|
|
}
|
|
this._notifyPanelOpened(id);
|
|
this._ui_map.set(id, uiInfo);
|
|
ok = true;
|
|
return true;
|
|
} catch (err) {
|
|
console.error('[UIManager] openPanel failed', id, String(err));
|
|
return false;
|
|
} finally {
|
|
const stale = this._openEpoch[id] !== epoch;
|
|
if (!ok && !stale) {
|
|
this._cleanupFailedOpen(id, uiInfo);
|
|
this._releasePause(id);
|
|
}
|
|
if (didShowLoading) {
|
|
const pkg = ok && !stale ? uiInfo.uiBase?.getResPkg() : null;
|
|
const extraCount = pkg ? pkg.ress.length + (pkg.dirs?.length ?? 0) : 0;
|
|
if (extraCount <= 0) {
|
|
gg.game.hideLoading();
|
|
}
|
|
}
|
|
if (!stale) {
|
|
this._finishPanelOpenWait(id);
|
|
}
|
|
LoadScheduler.getInstance().endUserLoad();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 结束一次 openPanel 等待;恢复失败时必须调用,否则同 id 再也打不开。
|
|
* @param id 面板 id
|
|
*/
|
|
private _finishPanelOpenWait(id: number): void {
|
|
this._panelOpenInflight.delete(id);
|
|
const fin = this._panelOpenResolve[id];
|
|
if (fin) {
|
|
this._panelOpenResolve[id] = undefined;
|
|
fin();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 作废进行中的 openPanel(loading 失败恢复)。过期回调不再上屏。
|
|
* @param keepIds 保留的面板 id(如匹配页)
|
|
*/
|
|
abortInflightOpens(keepIds?: readonly number[]): void {
|
|
const ids = mapKeysToArray(this._panelOpenInflight);
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const id = ids[i];
|
|
if (keepIds && keepIds.indexOf(id) !== -1) continue;
|
|
this._abortOpen(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 作废指定 id 的打开中面板。
|
|
* @param id 面板 id
|
|
*/
|
|
private _abortOpen(id: number): void {
|
|
this._openEpoch[id] = (this._openEpoch[id] ?? 0) + 1;
|
|
const uiInfo = this._ui_map.get(id);
|
|
if (uiInfo) {
|
|
this._cleanupFailedOpen(id, uiInfo);
|
|
}
|
|
this._releasePause(id);
|
|
this._finishPanelOpenWait(id);
|
|
}
|
|
|
|
/** 打开失败或 abort:销毁节点、归还预制引用与 bundle owner */
|
|
private _cleanupFailedOpen(id: number, uiInfo: UIInfo | null): void {
|
|
if (uiInfo?.uiNode?.isValid) {
|
|
uiInfo.uiNode.destroy();
|
|
}
|
|
if (uiInfo?.prefabRes) {
|
|
uiInfo.prefabRes.decRef();
|
|
uiInfo.prefabRes = null;
|
|
}
|
|
if (uiInfo) {
|
|
uiInfo.uiNode = null;
|
|
uiInfo.uiBase = null;
|
|
this._releasePanelBundle(id, uiInfo.uiconf?.bundle);
|
|
}
|
|
this._ui_map.delete(id);
|
|
if (this._curent_pop?.id === id) {
|
|
this._curent_pop = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 关闭单例页面
|
|
* @param id 页面ID
|
|
* @param tweenType 1: 直接关闭 2: 弹窗缩回,同时背景渐变成透明 3: 不缩放,直接渐变到透明
|
|
*/
|
|
async closePanel(id: number, tweenType = 1) {
|
|
if (this._panelOpenInflight.has(id)) {
|
|
this._abortOpen(id);
|
|
this._kickPopQueue();
|
|
return;
|
|
}
|
|
const inflight = this._panelCloseInflight.get(id);
|
|
if (inflight) {
|
|
await inflight;
|
|
return;
|
|
}
|
|
if (!this._ui_map.has(id)) return;
|
|
const run = this._closePanelOnce(id, tweenType);
|
|
this._panelCloseInflight.set(id, run);
|
|
try {
|
|
await run;
|
|
} finally {
|
|
this._panelCloseInflight.delete(id);
|
|
this._kickPopQueue();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 关闭单例页面(已排除重入)。
|
|
* @param id 页面ID
|
|
* @param tweenType 动效类型
|
|
*/
|
|
private async _closePanelOnce(id: number, tweenType: number): Promise<void> {
|
|
if (!this._ui_map.has(id)) return;
|
|
const uiInfo = this._ui_map.get(id);
|
|
const bundleName = uiInfo.uiconf?.bundle;
|
|
if (uiInfo.uiNode && uiInfo.uiNode.isValid) {
|
|
await uiInfo.uiNode.getComponent(UIBase)?.destroyPanel(tweenType);
|
|
}
|
|
if (uiInfo.prefabRes) {
|
|
uiInfo.prefabRes.decRef();
|
|
uiInfo.prefabRes = null;
|
|
}
|
|
uiInfo.uiNode = null;
|
|
uiInfo.uiBase = null;
|
|
this._ui_map.delete(id);
|
|
if (this._curent_pop?.id === id) {
|
|
this._curent_pop = null;
|
|
}
|
|
this._releasePanelBundle(id, bundleName);
|
|
this._releasePause(id);
|
|
}
|
|
|
|
/**
|
|
* 先打开新页面,成功后再关闭旧页面。
|
|
* @param openId 新页面
|
|
* @param closeIds 打开成功后关闭的页面
|
|
* @param param 打开参数
|
|
* @param closeTween closePanel 动效类型
|
|
*/
|
|
async openThenClose(openId: number, closeIds: number[], param?: OpenParam, closeTween = 1): Promise<boolean> {
|
|
const ok = await this.openPanel(openId, param);
|
|
if (!ok) return false;
|
|
for (let i = 0; i < closeIds.length; i++) {
|
|
const cid = closeIds[i];
|
|
if (cid === openId) continue;
|
|
await this.closePanel(cid, closeTween);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 关闭所有页面
|
|
* @param ids 排除的页面ID数组(传入的id页面不会关闭)
|
|
*/
|
|
async closeAllPanel(ids: number[] = []) {
|
|
for (const entry of this._wait_popIds) {
|
|
if (ids.indexOf(entry.id) !== -1) continue;
|
|
this._releasePause(entry.id);
|
|
}
|
|
this._wait_popIds = this._wait_popIds.filter(entry => ids.indexOf(entry.id) !== -1);
|
|
if (this._curent_pop && ids.indexOf(this._curent_pop.id) === -1) {
|
|
this._curent_pop = null;
|
|
}
|
|
this.abortInflightOpens(ids);
|
|
const keys = mapKeysToArray(this._ui_map);
|
|
const layerRank = (layer: UILayer | undefined): number => {
|
|
switch (layer) {
|
|
case UILayer.Pop:
|
|
case UILayer.Dalog:
|
|
case UILayer.Guide:
|
|
case UILayer.Anim:
|
|
return 0;
|
|
case UILayer.Page:
|
|
return 1;
|
|
case UILayer.Menu:
|
|
case UILayer.MainUI:
|
|
return 2;
|
|
default:
|
|
return 3;
|
|
}
|
|
};
|
|
keys.sort((a, b) => {
|
|
const la = layerRank(this._ui_map.get(a)?.uiconf?.layer);
|
|
const lb = layerRank(this._ui_map.get(b)?.uiconf?.layer);
|
|
return la - lb;
|
|
});
|
|
for (const id of keys) {
|
|
if (ids.indexOf(id) !== -1) continue;
|
|
await this.closePanel(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理 Anim 层上的飞金币/飞道具节点(挂有 FlyCurrency / CoinFly 等)。
|
|
* 进战斗/回大厅会卸载资源时,若飞行动画仍在跑,Sprite 可能已失效,需在切场景前销毁。
|
|
*/
|
|
clearAnimFlyNodes() {
|
|
const anim = this._layer_map?.get(UILayer.Anim);
|
|
if (!anim?.isValid) {
|
|
return;
|
|
}
|
|
const list = anim.children.slice();
|
|
for (const n of list) {
|
|
if (!n?.isValid) continue;
|
|
// 飞桃/飞道具节点一律清掉,避免持已释放贴图继续合批
|
|
Tween.stopAllByTarget(n);
|
|
n.destroy();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 是否存在页面
|
|
* @param id 页面ID
|
|
* @returns
|
|
*/
|
|
hasPanel(id: number) {
|
|
return !!this._ui_map.get(id)?.uiBase;
|
|
}
|
|
|
|
getPanelBase(id: number) {
|
|
return this._ui_map.get(id)?.uiBase ?? null;
|
|
}
|
|
|
|
/** 弹窗是否正在显示或在 pop 队列中等待 */
|
|
hasPopPanelActiveOrQueued(id: number): boolean {
|
|
if (this.hasPanel(id)) return true;
|
|
if (this._curent_pop?.id === id) return true;
|
|
return this._wait_popIds.some(entry => entry.id === id);
|
|
}
|
|
|
|
/** 从 pop 等待队列移除指定弹窗(复活成功等场景清掉重复入队) */
|
|
removeQueuedPopPanels(id: number): void {
|
|
if (this._wait_popIds.length === 0) return;
|
|
const wasQueued = this._wait_popIds.some(entry => entry.id === id);
|
|
this._wait_popIds = this._wait_popIds.filter(entry => entry.id !== id);
|
|
if (wasQueued && !this.hasPanel(id) && !this._panelOpenInflight.has(id)) {
|
|
this._releasePause(id);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 判断是否有其他页面
|
|
* @param id 排除的页面id
|
|
* @returns
|
|
*/
|
|
hasOtherPanel(id: number, sameLayer = true) {
|
|
let conf = this.uiConfigs[id];
|
|
if (!conf) {
|
|
console.error('[UIManager] hasOtherPanel missing config', id);
|
|
return false;
|
|
}
|
|
let keys = mapKeysToArray(this._ui_map);
|
|
for (let key of keys) {
|
|
let info = this._ui_map.get(key);
|
|
if (!info?.uiBase || !info.uiconf) continue;
|
|
if (sameLayer && info.uiconf.layer != conf.layer) {
|
|
continue;
|
|
}
|
|
if (info.id != id) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 页面是否显示中
|
|
* @param id 页面ID
|
|
* @returns
|
|
*/
|
|
panelIsShow(id: number) {
|
|
if (!this._ui_map.has(id)) return false;
|
|
let uiInfo = this._ui_map.get(id);
|
|
if (!uiInfo.uiNode || !uiInfo.uiBase || !uiInfo.uiNode.isValid || !uiInfo.uiNode.active) return false;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 打开弹窗(队列式弹窗,连续调用不会一起弹出来,关闭上一个后才会自动弹下一个)
|
|
* @param id 弹窗id
|
|
* @param param 参数
|
|
*/
|
|
popPanel(id: number, param?: OpenParam) {
|
|
this._wait_popIds.push({ id: id, param: param });
|
|
this._holdPause(id);
|
|
this._kickPopQueue();
|
|
}
|
|
|
|
/** 推进弹窗队列 */
|
|
flushPopQueue(): void {
|
|
this._kickPopQueue();
|
|
}
|
|
|
|
|
|
getHaveWaitPopLayer() {
|
|
return this._wait_popIds.length > 0;
|
|
}
|
|
|
|
/**
|
|
* 显示加载提示
|
|
*/
|
|
async showWaitIcon() {
|
|
if (!this._waitIcon) {
|
|
let conf = null;
|
|
for (let k in this.uiConfigs) {
|
|
let v = this.uiConfigs[k];
|
|
if (v.name == "waitIcon") {
|
|
conf = v;
|
|
break;
|
|
}
|
|
}
|
|
if (conf == null)
|
|
return;
|
|
let p = await this.loadPrefab(conf.bundle, conf.path + conf.name, 'toast') as Prefab;
|
|
if (!p) {
|
|
console.error('[UIManager] waitIcon 预制加载失败');
|
|
return;
|
|
}
|
|
this._waitIcon = instantiate(p);
|
|
}
|
|
this.showNode(this._waitIcon, UILayer.Loading);
|
|
}
|
|
|
|
/**
|
|
* 关闭加载提示
|
|
*/
|
|
closeWaitIcon() {
|
|
if (this._waitIcon) {
|
|
this._waitIcon.removeFromParent();
|
|
}
|
|
}
|
|
private _waitIcon: Node = null;
|
|
//显示消息提示,不带自动翻译的
|
|
|
|
/**
|
|
* 显示消息提示,带自动翻译的
|
|
* @param msg 消息
|
|
* @param time 显示时间(秒)
|
|
*/
|
|
async showToast(msg: string, isAutoTranslate: boolean = true, time: number = 1) {
|
|
//翻译消息
|
|
if (isAutoTranslate) {
|
|
msg = gg.lang.get(msg);
|
|
}
|
|
|
|
if (!msg || msg == "") {
|
|
return;
|
|
}
|
|
|
|
let n: Node = null;
|
|
if (this._msgPool.length > 0) {
|
|
n = this._msgPool.pop();
|
|
} else {
|
|
let conf = null;
|
|
for (let k in this.uiConfigs) {
|
|
let v = this.uiConfigs[k];
|
|
if (v.name == "msg") {
|
|
conf = v;
|
|
break;
|
|
}
|
|
}
|
|
if (conf == null)
|
|
return;
|
|
let p = await this.loadPrefab(conf.bundle, conf.path + conf.name, 'toast') as Prefab;
|
|
if (!p) {
|
|
console.error('[UIManager] toast 预制加载失败');
|
|
return;
|
|
}
|
|
n = instantiate(p);
|
|
}
|
|
const layNode = n.getChildByName("lay");
|
|
const richTextNode = layNode.getChildByName("RichText");
|
|
richTextNode.getComponent(RichText)!.string = `<b>${msg}</b>`;
|
|
richTextNode.getComponent(Label)!.string = `${msg}`;
|
|
layNode.getComponent(Layout)?.updateLayout();
|
|
this.showNode(n, UILayer.Ad);
|
|
const toastBaseY = view.getVisibleSize().height * TOAST_BASE_Y_RATIO;
|
|
n.setPosition(0, toastBaseY, 0);
|
|
tween(n)
|
|
.set({ opacity: 255 })
|
|
.delay(time)
|
|
.by(0.5, { position: v3(0, 100, 0) })
|
|
.call(() => {
|
|
tween(n).to(0.5, { opacity: 0 }).start();
|
|
})
|
|
.by(0.25, { position: v3(0, 50, 0) })
|
|
.call(() => {
|
|
this._msgPool.push(n);
|
|
Tween.stopAllByTarget(n);
|
|
n.removeFromParent();
|
|
let ind = this._msgShowPool.indexOf(n);
|
|
if (ind >= 0)
|
|
this._msgShowPool.splice(ind, 1);
|
|
}).start();
|
|
this._msgShowPool.forEach(x => x.y += TOAST_STACK_GAP);
|
|
this._msgShowPool.push(n);
|
|
}
|
|
private _msgPool: Node[] = [];
|
|
private _msgShowPool: Node[] = [];
|
|
|
|
/**
|
|
* 显示动画(显示到UI管理的指定层级)
|
|
* @param n 动画节点
|
|
*/
|
|
showEff(n: Node) {
|
|
this.showNode(n, UILayer.Anim);
|
|
}
|
|
|
|
showLog(str = "", clear = false) {
|
|
if (this._log) {
|
|
this._log.active = true;
|
|
let log = this._log.getChildByName("log").getComponent(Label);
|
|
log.string = clear ? str : log.string + "\n" + str;
|
|
if (this._timer_flag)
|
|
clearInterval(this._timer_flag);
|
|
this._timer_flag = setInterval(this.updateDC.bind(this), 200);
|
|
}
|
|
}
|
|
|
|
closeLog() {
|
|
if (this._log) {
|
|
this._log.active = false;
|
|
}
|
|
}
|
|
|
|
updateDC() {
|
|
if (this._log && this._log.active) {
|
|
let dc = this._log.getChildByName("dc").getComponent(Label);
|
|
dc.string = "当前dc:" + director.root.device.numDrawCalls;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 启动弹窗队列工人(已在跑则忽略)。
|
|
*/
|
|
private _kickPopQueue(): void {
|
|
if (this._popRunning) return;
|
|
this._popRunning = true;
|
|
void this._runPopQueue().finally(() => {
|
|
this._popRunning = false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 串行弹出:当前窗还在展示则退出,等关闭再踢。
|
|
*/
|
|
private async _runPopQueue(): Promise<void> {
|
|
if (this._curent_pop != null) {
|
|
if (this._panelOpenInflight.has(this._curent_pop.id)) {
|
|
return;
|
|
}
|
|
const cur = this._ui_map.get(this._curent_pop.id);
|
|
if (cur?.uiBase) {
|
|
return;
|
|
}
|
|
if (cur) {
|
|
this._cleanupFailedOpen(this._curent_pop.id, cur);
|
|
}
|
|
this._releasePause(this._curent_pop.id);
|
|
this._curent_pop = null;
|
|
}
|
|
|
|
while (this._wait_popIds.length > 0) {
|
|
this._curent_pop = this._wait_popIds.shift();
|
|
const id = this._curent_pop.id;
|
|
const param = this._curent_pop.param;
|
|
const ok = await this.openPanel(id, param);
|
|
if (!ok) {
|
|
this._releasePause(id);
|
|
this._curent_pop = null;
|
|
continue;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
private addUILayer(parent: Node, name: string, layer: UILayer = null) {
|
|
let layerNode = new Node(name);
|
|
parent.addChild(layerNode);
|
|
//layerNode.addComponent(Mask);
|
|
let w = layerNode.addComponent(Widget);
|
|
w.isAlignLeft = w.isAlignTop = w.isAlignRight = w.isAlignBottom = true;
|
|
w.left = w.top = w.right = w.bottom = 0;
|
|
w.updateAlignment();
|
|
if (layer) {
|
|
this._layer_map.set(layer, layerNode);
|
|
}
|
|
return layerNode;
|
|
}
|
|
|
|
/**
|
|
* 面板持有的 bundle owner,按 id 独立,关闭时成对释放。
|
|
* @param panelId 面板 id
|
|
*/
|
|
private _bundleOwner(panelId: number): string {
|
|
return `ui:${panelId}`;
|
|
}
|
|
|
|
/**
|
|
* 归还本面板对分包的占用。
|
|
* @param panelId 面板 id
|
|
* @param bundleName 分包
|
|
*/
|
|
private _releasePanelBundle(panelId: number, bundleName: string | undefined): void {
|
|
if (!bundleName) return;
|
|
BundleLifecycle.getInstance().releaseBundleOwner(bundleName, this._bundleOwner(panelId));
|
|
}
|
|
|
|
/**
|
|
* 持有 pauseBattle(同 id 只 acquire 一次)。
|
|
* @param id 面板 id
|
|
*/
|
|
private _holdPause(id: number): void {
|
|
if (!this.uiConfigs[id]?.pauseBattle) return;
|
|
if (this._pauseHeld.has(id)) return;
|
|
membershipAdd(this._pauseHeld, id);
|
|
this._notifyBattlePause(id, true);
|
|
}
|
|
|
|
/**
|
|
* 释放 pauseBattle(未持有则忽略)。
|
|
* @param id 面板 id
|
|
*/
|
|
private _releasePause(id: number): void {
|
|
if (!membershipDelete(this._pauseHeld, id)) return;
|
|
this._notifyBattlePause(id, false);
|
|
}
|
|
|
|
private async loadPrefab(bundleName: string, assetPath: string, owner: string) {
|
|
let bundle = assetManager.getBundle(bundleName);
|
|
if (!bundle) bundle = await this.loadBundle(bundleName, owner);
|
|
else if (owner) BundleLifecycle.getInstance().acquire(bundleName, owner);
|
|
if (!bundle) {
|
|
console.error('[UIManager] loadPrefab bundle 失败', bundleName, assetPath);
|
|
return null;
|
|
}
|
|
return this.loadRes(bundle, assetPath);
|
|
}
|
|
|
|
private async loadBundle(bundleName: string, owner: string) {
|
|
const bundle = await BundleLifecycle.getInstance().ensureLoaded(bundleName);
|
|
if (bundle && owner) {
|
|
BundleLifecycle.getInstance().acquire(bundleName, owner);
|
|
}
|
|
if (!bundle) {
|
|
console.error('[UIManager] loadBundle 失败', bundleName);
|
|
}
|
|
return bundle;
|
|
}
|
|
|
|
/**
|
|
* 加载预制。缓存命中须 addRef;原生 load 由引擎持有本次引用。
|
|
* 经 ResourcesManager 同路径合并,避免与静默 preload/load 双飞后回调丢失。
|
|
*/
|
|
private loadRes(bundle: AssetManager.Bundle, assetPath: string): Promise<Asset | null> {
|
|
return new Promise((resolve) => {
|
|
gg.res.loadResPublic(bundle, assetPath, (asset: Asset | null, fromCache?: boolean) => {
|
|
if (!asset?.isValid) {
|
|
console.error('[UIManager] loadRes 预制无效', bundle.name, assetPath);
|
|
resolve(null);
|
|
return;
|
|
}
|
|
if (fromCache) {
|
|
asset.addRef();
|
|
}
|
|
resolve(asset);
|
|
}, Prefab);
|
|
});
|
|
}
|
|
} |