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.
AdLike2048/assets/mx/module/ui/UIManager.ts

1043 lines
35 KiB

3 weeks ago
//*********************
// 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);
});
}
}