消消方块阵换皮表情
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.
 
 
 

1224 lines
44 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 { FlyCurrency } from "../../components/tween/FlyCurrency";
import { PfDiagnostics } from "db://assets/script/diagnostics/PfDiagnostics";
/** 与 ConfigRes.UIID.SkillSelect 一致;不可 import ConfigRes,否则会与 ConfigRes→UIManager 循环引用 */
const SKILL_SELECT_PANEL_ID = 651;
/** 与 ConfigRes.UIID 一致 */
const BATTLE_SUCCESS_PANEL_ID = 605;
const BATTLE_FAIL_PANEL_ID = 606;
const BATTLE_REVIVE_PANEL_ID = 607;
/** SkillSelect / 结算分包真重载冷却(避免连续失败时反复 removeBundle) */
const SKILL_SELECT_BUNDLE_RELOAD_COOLDOWN_MS = 30_000;
const SETTLEMENT_BUNDLE_RELOAD_COOLDOWN_MS = 30_000;
/** loadPrefab 结果:失败时带 stage(bundle/asset)与引擎 err 摘要 */
type LoadPrefabResult = {
prefab: Prefab | null;
stage?: 'bundle' | 'asset';
err?: string;
};
/**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`,
}
/**
* UI配置
*/
export interface IUIConfig {
/**UI描述 */
desc: string;
/**UI层级 */
layer: UILayer;
/**资源包名 */
bundle: string;
/**资源路径 */
path: string;
/**资源名称 */
name: string;
}
/**
* UI管理器
*/
export class UIManager {
private static _ins: UIManager = null;
static get Ins() {
if (!this._ins) this._ins = new UIManager();
return this._ins;
}
/**宽度 */
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;
}
/**UI相机 */
UICamera: Camera = null;
/**游戏相机 */
GameCamera: 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();
private _wait_popIds: any[] = [];
private _curent_pop = null;
/** 上次结算分包真重载时间戳(ms) */
private _settlementBundleReloadMs = 0;
/** 上次 SkillSelect 分包真重载时间戳(ms) */
private _skillSelectBundleReloadMs = 0;
private uiConfigs: { [key: number]: IUIConfig } = [];
/**初始化(切换场景的时候调用) */
init(config: { [key: number]: IUIConfig }, UICamera?: Camera, GameCamera?: Camera, isDynamicAdaptScreen: boolean = true) {
this.uiConfigs = config;
this.UICamera = UICamera;
this.GameCamera = GameCamera;
this.isDynamicAdaptScreen = isDynamicAdaptScreen;
//this.GameCamera.orthoHeight = view.getVisibleSize().height / 2;
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._root.uiTransform.schedule(() => {
this.showPopToLayer();
}, 0.5);
// 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) {
const pending = this._panelOpenInflight.get(id);
if (pending) {
await pending;
}
let uiInfo: UIInfo = null;
const traceSkillSelect = id === SKILL_SELECT_PANEL_ID && this.isSkillSelectTreasureOpen(param);
if (this._ui_map.has(id)) {
uiInfo = this._ui_map.get(id);
if (uiInfo.uiBase && uiInfo.uiNode?.isValid) {
if (this.shouldAbortSkillSelectOpen(id)) {
this.abortSkillSelectOpen(id, param, traceSkillSelect);
return;
}
if (traceSkillSelect) {
this._logSkillSelectTrace('load_start', param, { reuse: true });
}
uiInfo.uiNode.active = true;
uiInfo.uiNode.setSiblingIndex(-1);
uiInfo.uiBase.onAdded(param);
//console.log("openPanel已打开", id, param);
if (traceSkillSelect) {
this.finishSkillSelectTreasureOpen(param);
}
return;
}
// 节点已销毁或上次 prefab 加载失败会留下空占位,清除后允许重试
this._ui_map.delete(id);
}
uiInfo = new UIInfo();
uiInfo.id = id;
this._ui_map.set(id, uiInfo);
let resolveInflight: () => void;
const inflightPromise = new Promise<void>((resolve) => {
resolveInflight = resolve;
});
this._panelOpenInflight.set(id, inflightPromise);
try {
if (param && param.showLoading && param.showLoading > 0) {
gg.game.showLoading(param.showLoading);
}
let conf = this.uiConfigs[id];
let path = conf.path + conf.name;
let ui = null;
let loadResult = await this.loadPrefab(conf.bundle, path);
let p = loadResult.prefab;
let loadErrDetail = this._formatPrefabLoadDetail(loadResult);
if (!p && traceSkillSelect) {
this._logSkillSelectTrace('load_retry', param);
loadResult = await this.loadPrefab(conf.bundle, path);
p = loadResult.prefab;
loadErrDetail = this._formatPrefabLoadDetail(loadResult, true);
}
if (!p && this.isSkillSelectPanel(id)) {
const reloadAgoMs = Date.now() - this._skillSelectBundleReloadMs;
const reloadOnCooldown = this._skillSelectBundleReloadMs > 0
&& reloadAgoMs < SKILL_SELECT_BUNDLE_RELOAD_COOLDOWN_MS;
if (reloadOnCooldown) {
const remainSec = Math.ceil((SKILL_SELECT_BUNDLE_RELOAD_COOLDOWN_MS - reloadAgoMs) / 1000);
this._logSkillSelectTrace('bundle_reload_skip', param, { remainSec });
loadErrDetail = `${loadErrDetail}|reload_skip=cooldown|reload_ago=${Math.floor(reloadAgoMs / 1000)}s|reload_remain=${remainSec}s`;
} else {
this._logSkillSelectTrace('bundle_reload', param);
this._cleanupPanelForBundleReload(id);
uiInfo = this._ui_map.get(id);
this._skillSelectBundleReloadMs = Date.now();
loadResult = await this._reloadBundleAndLoadPrefab(conf.bundle, path);
p = loadResult.prefab;
loadErrDetail = `${loadErrDetail}|${this._formatPrefabLoadDetail(loadResult, false, true)}`;
this._reportSkillSelectBundleReload(!!p, param, conf.bundle, path, loadResult);
}
}
uiInfo.uiconf = conf;
if (traceSkillSelect) {
this._logSkillSelectTrace('load_start', param, { reuse: false });
}
if (p) {
if (this.shouldAbortSkillSelectOpen(id)) {
this.abortSkillSelectOpen(id, param, traceSkillSelect);
return;
}
ui = instantiate(p);
uiInfo.prefabRes = p;
uiInfo.uiNode = ui;
this._layer_map.get(conf.layer).addChild(ui);
uiInfo.uiBase = ui.getComponent(UIBase);
if (uiInfo.uiBase) {
uiInfo.uiBase.uiInfo = uiInfo;
uiInfo.uiBase.onAdded(param);
if (param && param.showLoading && param.showLoading > 0) {
gg.game.hideLoading();
}
if (traceSkillSelect) {
this.finishSkillSelectTreasureOpen(param);
}
} else if (this.isSkillSelectPanel(id)) {
console.error('[UIManager] SkillSelect missing UIBase');
if (ui?.isValid) {
ui.destroy();
}
this._ui_map.delete(id);
this._reportSkillSelectOpenFail('missing_uibase', param, `id=${id}`);
}
} else {
const failDetail = `bundle=${conf?.bundle}|path=${path}|${loadErrDetail}`;
console.error(
`[UIManager] openPanel prefab load failed id=${id} ${failDetail}`,
);
gg.sdk?.pfLogKey?.('UI:OPEN_FAIL', `id=${id}|${failDetail}`);
this._ui_map.delete(id);
if (this.isSkillSelectPanel(id)) {
this._reportSkillSelectOpenFail('prefab_load_failed', param, failDetail);
} else if (traceSkillSelect) {
this.notifySkillSelectOpenResult(param, false, 'prefab_load_failed', failDetail);
}
}
} finally {
this._panelOpenInflight.delete(id);
resolveInflight();
}
}
/**
* 关闭单例页面
* @param id 页面ID
* @param tweenType 1: 直接关闭 2: 弹窗缩回,同时背景渐变成透明 3: 不缩放,直接渐变到透明
*/
async closePanel(id: number, tweenType = 1) {
if (!this._ui_map.has(id)) return;
let uiInfo = this._ui_map.get(id);
if (uiInfo.uiNode && uiInfo.uiNode.isValid) {
await uiInfo.uiNode.getComponent(UIBase)?.destroyPanel(tweenType);
uiInfo.prefabRes?.decRef();
uiInfo.uiNode = null;
uiInfo.uiBase = null;
this._ui_map.delete(id);
}
}
/**
* 关闭所有页面
* @param ids 排除的页面ID数组(传入的id页面不会关闭)
*/
closeAllPanel(ids: number[] = []) {
let keys = Array.from(this._ui_map.keys());
for (let id of keys) {
if (ids.indexOf(id) == -1) {
this.closePanel(id);
}
}
}
/** 等待所有面板关闭完成后再做资源释放(避免节点仍引用贴图导致内存不降) */
async closeAllPanelAsync(ids: number[] = []): Promise<void> {
const keys = Array.from(this._ui_map.keys());
const tasks: Promise<unknown>[] = [];
for (const id of keys) {
if (ids.indexOf(id) === -1) {
tasks.push(this.closePanel(id));
}
}
await Promise.all(tasks);
}
/**
* 清理 Anim 层上的飞金币/飞道具节点(挂有 FlyCurrency)。
* 进战斗等会卸载 UI 资源时,若飞行动画仍在跑,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.getComponent(FlyCurrency)) {
Tween.stopAllByTarget(n);
n.destroy();
}
}
}
/**
* 是否存在页面
* @param id 页面ID
* @returns
*/
hasPanel(id: number) {
if (!this._ui_map.has(id)) return false;
return true;
}
/**
* 判断是否有其他页面
* @param id 排除的页面id
* @returns
*/
hasOtherPanel(id: number, sameLayer = true) {
let conf = this.uiConfigs[id];
let keys = Array.from(this._ui_map.keys());
for (let key of keys) {
let info = this._ui_map.get(key);
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;
}
/** 页面是否正在异步加载中 */
panelIsOpening(id: number) {
return this._panelOpenInflight.has(id);
}
/** 从弹窗队列中移除指定 id(避免连点堆积重复弹窗) */
removeWaitPop(id: number) {
if (!this._wait_popIds.length) {
return;
}
this._wait_popIds = this._wait_popIds.filter((x) => x.id !== id);
if (this._curent_pop?.id === id && !this.panelIsShow(id) && !this._panelOpenInflight.has(id)) {
this._curent_pop = null;
}
}
/**
* 打开弹窗(队列式弹窗,连续调用不会一起弹出来,关闭上一个后才会自动弹下一个)
* @param id 弹窗id
* @param param 参数
*/
popPanel(id: number, param?: OpenParam) {
this._wait_popIds.push({ id: id, param: param });
}
/**
* 底部波次宝箱专用:SkillSelect 入队,并在 load/onInit 结果后触发回调。
*/
popSkillSelectFromTreasure(param?: OpenParam) {
this._logSkillSelectTrace('click_enqueue', param);
this.popPanel(SKILL_SELECT_PANEL_ID, param);
this.showPopToLayer();
}
private isSkillSelectTreasureOpen(param?: OpenParam): boolean {
return !!(param?.onSkillSelectOpenSuccess || param?.onSkillSelectOpenFail);
}
/** UISkillSelect.onInit 抛错时调用,直接上报 init_error,避免被误判为 panel_not_visible_after_init */
reportSkillSelectInitFailed(param: OpenParam | undefined, err: unknown, context?: Record<string, unknown>) {
const msg = err instanceof Error ? `${err.name}:${err.message}` : String(err);
this._reportSkillSelectOpenFail('init_error', param, msg.slice(0, 120), context);
}
private _skillSelectBattleOnlyContext(): Record<string, unknown> {
const battle = gg.game?.CurentBattle;
return {
panelType: 'unknown',
pickSkillIds: [] as number[],
pickWeaponIds: [] as number[],
curWeaponIds: battle?.CurUseWeaponArr?.map(w => w?.weaponid).filter(id => id != null) ?? [],
curSkillIds: battle?.CurUseSkillArr?.map(s => s?.id).filter(id => id != null) ?? [],
};
}
private _resolveSkillSelectFailContext(explicit?: Record<string, unknown>): Record<string, unknown> {
if (explicit) {
return explicit;
}
try {
const uiInfo = this._ui_map.get(SKILL_SELECT_PANEL_ID);
const panel = uiInfo?.uiBase as { getOpenFailContext?: () => Record<string, unknown> } | null;
if (panel?.getOpenFailContext) {
return panel.getOpenFailContext();
}
} catch (e) {
console.error('[UIManager] getOpenFailContext error', e);
}
return this._skillSelectBattleOnlyContext();
}
private _compactIdList(ids: unknown, maxLen = 48): string {
if (!Array.isArray(ids) || ids.length === 0) {
return '';
}
const s = ids.join(',');
return s.length <= maxLen ? s : s.slice(0, maxLen);
}
private _formatSkillSelectIdContext(ctx: Record<string, unknown>): { pf: string; dy: string } {
const panelType = String(ctx.panelType ?? 'unknown');
const curW = this._compactIdList(ctx.curWeaponIds, 40);
const curS = this._compactIdList(ctx.curSkillIds, 48);
const pickS = this._compactIdList(ctx.pickSkillIds, 32);
const pickW = this._compactIdList(ctx.pickWeaponIds, 32);
const pfParts = [`ptype=${panelType}`];
if (curW) pfParts.push(`curW=${curW}`);
if (curS) pfParts.push(`curS=${curS}`);
if (pickS) pfParts.push(`pickS=${pickS}`);
if (pickW) pfParts.push(`pickW=${pickW}`);
const pf = pfParts.join('|');
const dyParts = [`ptype${panelType}`];
if (curW) dyParts.push(`curW${curW.replace(/,/g, '_')}`);
if (curS) dyParts.push(`curS${curS.replace(/,/g, '_')}`);
if (pickS) dyParts.push(`pickS${pickS.replace(/,/g, '_')}`);
if (pickW) dyParts.push(`pickW${pickW.replace(/,/g, '_')}`);
return { pf, dy: dyParts.join('-') };
}
private _skillSelectFailDetail(reason: string, detail?: string, idContext?: Record<string, unknown>, extra: Record<string, unknown> = {}) {
const battle = gg.game?.CurentBattle;
const conf = this.uiConfigs[SKILL_SELECT_PANEL_ID];
const ctx = idContext ?? this._skillSelectBattleOnlyContext();
return {
reason,
detail: detail ?? '',
wave: battle?.CurentWaveNum ?? 0,
chapter: battle?.ChapterId ?? gg.data?.doc?.chapter ?? 0,
openTimes: battle?.IsOpenSkillSelectTimes ?? 0,
saveBoxNum: battle?.tetriSaveBoxNum ?? 0,
queueLen: this._wait_popIds.length,
panelShowing: this.panelIsShow(SKILL_SELECT_PANEL_ID),
bundleLoaded: conf?.bundle && assetManager.getBundle(conf.bundle) ? 1 : 0,
bundle: conf?.bundle ?? '',
prefabPath: conf ? `${conf.path}${conf.name}` : '',
panelType: ctx.panelType ?? 'unknown',
pickSkillIds: ctx.pickSkillIds ?? [],
pickWeaponIds: ctx.pickWeaponIds ?? [],
curWeaponIds: ctx.curWeaponIds ?? [],
curSkillIds: ctx.curSkillIds ?? [],
...this._memWarnFailFields(),
...extra,
};
}
private _sanitizeSkillSelectReportSegment(text: string, maxLen: number): string {
return text.slice(0, maxLen).replace(/[^a-zA-Z0-9\u4e00-\u9fa5_|:-]/g, '_');
}
private _memWarnFailPf(): string {
return gg.sdk?.formatMemWarnForPf?.() ?? 'memLv=na';
}
private _memWarnFailDy(): string {
return gg.sdk?.formatMemWarnForDy?.() ?? 'memna';
}
private _memWarnFailFields(): Record<string, unknown> {
const s = gg.sdk?.getMemWarnState?.() ?? { level: null, agoSec: null };
return { memLv: s.level, memAgoSec: s.agoSec };
}
/** 宝箱 SkillSelect 流程本地日志(不上传 Pf / ZYZY,避免与 SkillSelectFail 混淆) */
private _logSkillSelectTrace(step: string, param?: OpenParam, extra: Record<string, unknown> = {}) {
if (param && !this.isSkillSelectTreasureOpen(param)) {
return;
}
const battle = gg.game?.CurentBattle;
console.log('[SkillSelectTrace]', JSON.stringify({
step,
wave: battle?.CurentWaveNum,
chapter: battle?.ChapterId ?? gg.data?.doc?.chapter,
openTimes: battle?.IsOpenSkillSelectTimes,
saveBoxNum: battle?.tetriSaveBoxNum,
queueLen: this._wait_popIds.length,
panelShowing: this.panelIsShow(SKILL_SELECT_PANEL_ID),
...extra,
}));
}
private notifySkillSelectOpenResult(param: OpenParam | undefined, success: boolean, reason?: string, detail?: string) {
if (!param || !this.isSkillSelectTreasureOpen(param)) {
return;
}
if (param._skillSelectResultReported) {
return;
}
if (success) {
param._skillSelectResultReported = true;
this._logSkillSelectTrace('open_success', param);
try {
param.onSkillSelectOpenSuccess?.();
} catch (e) {
console.error('[SkillSelectTrace] onSkillSelectOpenSuccess error', e);
}
return;
}
this._reportSkillSelectOpenFail(reason ?? 'unknown', param, detail);
}
private finishSkillSelectTreasureOpen(param?: OpenParam, attempt = 0) {
if (!param || !this.isSkillSelectTreasureOpen(param)) {
return;
}
const tryFinish = () => {
if (param._skillSelectResultReported) {
return;
}
if (this.panelIsShow(SKILL_SELECT_PANEL_ID)) {
this.notifySkillSelectOpenResult(param, true);
return;
}
if (attempt < 2) {
setTimeout(() => this.finishSkillSelectTreasureOpen(param, attempt + 1), attempt === 0 ? 0 : 80);
return;
}
this._reportSkillSelectOpenFail(
'panel_not_visible_after_init',
param,
`attempts=3|inflight=${this._panelOpenInflight.has(SKILL_SELECT_PANEL_ID) ? 1 : 0}`,
);
};
tryFinish();
}
private isSkillSelectPanel(id: number): boolean {
return id === SKILL_SELECT_PANEL_ID;
}
private isSettlementPanel(id: number): boolean {
return id === BATTLE_SUCCESS_PANEL_ID
|| id === BATTLE_FAIL_PANEL_ID
|| id === BATTLE_REVIVE_PANEL_ID;
}
/** 战斗已结束或正在回大厅时,不再打开 SkillSelect(避免异步 load 完成后访问 null 的 CurentBattle) */
private shouldAbortSkillSelectOpen(id: number): boolean {
return this.isSkillSelectPanel(id) && !gg.game?.isBattleContextActive?.();
}
private abortSkillSelectOpen(id: number, param?: OpenParam, traceSkillSelect = false, reason = 'battle_left'): void {
this._ui_map.delete(id);
if (traceSkillSelect) {
this.notifySkillSelectOpenResult(param, false, reason, reason);
}
}
/** 技能箱打开失败:PfDiagnostics + 宝箱回调 + ZYZY 打点(仅失败路径;防重复) */
private _reportSkillSelectOpenFail(
reason: string,
param?: OpenParam,
detail?: string,
explicitContext?: Record<string, unknown>,
): void {
if (param && this.isSkillSelectTreasureOpen(param) && param._skillSelectResultReported) {
return;
}
try {
const idContext = this._resolveSkillSelectFailContext(explicitContext);
const payload = this._skillSelectFailDetail(reason, detail, idContext);
const idFmt = this._formatSkillSelectIdContext(idContext);
const pfParts = [
`reason=${reason}`,
`w=${payload.wave}`,
`ch=${payload.chapter}`,
`t=${payload.openTimes}`,
`box=${payload.saveBoxNum}`,
`bundle=${payload.bundleLoaded}`,
idFmt.pf,
this._memWarnFailPf(),
];
if (detail) {
pfParts.push(`msg=${detail.slice(0, 80)}`);
}
PfDiagnostics.write(
'SKILL',
'OPEN_FAIL',
pfParts.join('|'),
reason === 'init_error' ? 120000 : 600000,
'error',
);
console.error('[SkillSelectFail]', JSON.stringify(payload));
if (param && this.isSkillSelectTreasureOpen(param)) {
param._skillSelectResultReported = true;
// 本地 Trace,不上传平台
this._logSkillSelectTrace('open_fail', param, { reason, detail });
const battle = gg.game?.CurentBattle;
if (battle) {
let dy = `SkillSelectFail-${reason}-w${payload.wave}-ch${payload.chapter}-t${payload.openTimes}-box${payload.saveBoxNum}-${idFmt.dy}-${this._memWarnFailDy()}`;
if (detail) {
dy += `-d-${this._sanitizeSkillSelectReportSegment(detail, 40)}`;
}
gg.sdk.reportDY("inLevel", dy);
}
try {
param.onSkillSelectOpenFail?.(reason, detail, idContext);
} catch (e) {
console.error('[SkillSelectTrace] onSkillSelectOpenFail error', e);
}
}
} catch (e) {
console.error('[UIManager] _reportSkillSelectOpenFail error', e);
}
}
/** SkillSelect 分包真重载:无论成功/失败均上报(与 OPEN_FAIL 独立) */
private _reportSkillSelectBundleReload(
success: boolean,
param: OpenParam | undefined,
bundleName: string,
assetPath: string,
loadResult: LoadPrefabResult,
): void {
try {
const battle = gg.game?.CurentBattle;
const wave = battle?.CurentWaveNum ?? 0;
const chapter = battle?.ChapterId ?? gg.data?.doc?.chapter ?? 0;
const openTimes = battle?.IsOpenSkillSelectTimes ?? 0;
const saveBoxNum = battle?.tetriSaveBoxNum ?? 0;
const result = success ? 'ok' : 'fail';
const pfParts = [
`result=${result}`,
`w=${wave}`,
`ch=${chapter}`,
`t=${openTimes}`,
`box=${saveBoxNum}`,
`bundle=${bundleName}`,
`path=${assetPath}`,
`stage=${loadResult.stage ?? 'unknown'}`,
this._memWarnFailPf(),
];
if (loadResult.err) {
pfParts.push(`err=${String(loadResult.err).slice(0, 60)}`);
}
PfDiagnostics.write(
'SKILL',
'OPEN_RELOAD',
pfParts.join('|'),
0,
success ? 'info' : 'warn',
);
let dy = `SkillSelectReload-${result}-w${wave}-ch${chapter}-t${openTimes}-box${saveBoxNum}-${this._memWarnFailDy()}-stage${loadResult.stage ?? 'unknown'}`;
if (loadResult.err) {
dy += `-err${this._sanitizeSkillSelectReportSegment(String(loadResult.err), 32)}`;
}
gg.sdk.reportDY('inLevel', dy);
console.log('[SkillSelectReload]', JSON.stringify({
result,
bundle: bundleName,
path: assetPath,
stage: loadResult.stage,
err: loadResult.err ?? '',
wave,
chapter,
openTimes,
saveBoxNum,
treasure: !!(param && this.isSkillSelectTreasureOpen(param)),
}));
} catch (e) {
console.error('[UIManager] _reportSkillSelectBundleReload error', e);
}
}
/**
* 战斗结算/复活/胜利:不走队列,避免被 SkillSelect、转盘等挡住。
* @returns 面板是否已成功显示
*/
async openBattleSettlementPanel(id: number, param?: OpenParam): Promise<boolean> {
this._curent_pop = null;
this._wait_popIds = this._wait_popIds.filter((x) => x.id !== id);
await this.openPanel(id, param);
if (this.panelIsShow(id)) {
return true;
}
const conf = this.uiConfigs[id];
if (conf?.bundle && this.isSettlementPanel(id)) {
const reloadAgoMs = Date.now() - this._settlementBundleReloadMs;
const reloadOnCooldown = this._settlementBundleReloadMs > 0
&& reloadAgoMs < SETTLEMENT_BUNDLE_RELOAD_COOLDOWN_MS;
if (!reloadOnCooldown) {
console.warn(`[UIManager] openBattleSettlementPanel bundle reload id=${id}`);
if (this._ui_map.has(id)) {
this._ui_map.delete(id);
}
this._settlementBundleReloadMs = Date.now();
const path = conf.path + conf.name;
const loadResult = await this._reloadBundleAndLoadPrefab(conf.bundle, path);
await this.openPanel(id, param);
if (this.panelIsShow(id)) {
return true;
}
console.error(
`[UIManager] openBattleSettlementPanel reload failed id=${id} stage=${loadResult.stage ?? 'unknown'}`,
);
gg.sdk?.pfLogKey?.('UI:SETTLE_RELOAD_FAIL', `id=${id}|stage=${loadResult.stage ?? 'unknown'}`);
}
}
console.error(`[UIManager] openBattleSettlementPanel retry id=${id}`);
if (this._ui_map.has(id)) {
this._ui_map.delete(id);
}
await this.openPanel(id, param);
if (this.panelIsShow(id)) {
return true;
}
console.error(`[UIManager] openBattleSettlementPanel failed id=${id}`);
gg.sdk?.pfLogKey?.('UI:SETTLE_OPEN_FAIL', `id=${id}`);
const failConf = conf ?? this.uiConfigs[id];
const panel = failConf?.name ?? String(id);
const bundleLoaded = failConf?.bundle && assetManager.getBundle(failConf.bundle) ? 1 : 0;
const battle = gg.game?.CurentBattle;
const memPf = this._memWarnFailPf();
PfDiagnostics.write(
'SETTLE',
'OPEN_FAIL',
`panel=${panel}|id=${id}|bundle=${bundleLoaded}|w=${battle?.CurentWaveNum ?? 0}|ch=${battle?.ChapterId ?? 0}|${memPf}`,
600000,
'error',
);
console.error('[SettleOpenFail]', JSON.stringify({
panel,
id,
bundleLoaded,
wave: battle?.CurentWaveNum,
chapter: battle?.ChapterId,
...this._memWarnFailFields(),
}));
if (battle) {
gg.sdk.reportDY(
'inLevel',
`SettleOpenFail-${panel}-id${id}-w${battle.CurentWaveNum}-ch${battle.ChapterId}-bundle${bundleLoaded}-${this._memWarnFailDy()}`,
);
}
return false;
}
/**
* 清理队列式弹窗队列(切场景/回大厅时避免残留弹窗重复弹出)
*/
clearWaitPopQueue() {
this._wait_popIds.length = 0;
this._curent_pop = null;
}
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)).prefab;
if (!p) {
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,addPosy: number = 0) {
//翻译消息
if (isAutoTranslate) {
msg = gg.lang.get(msg);
}
if (!msg || msg == "") {
console.log("showToast msg is empty");
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)).prefab;
if (!p) {
return;
}
n = instantiate(p);
}
n.getComponentInChildren(RichText)!.string = `<b>${msg}</b>`;
n.getComponentInChildren(Label)!.string = `${msg}`;
this.showNode(n, UILayer.Ad);
n.setPosition(0, 0, 0);
if(addPosy>0){
n.y += addPosy;
}
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 += 50);
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 async showPopToLayer() {
if (this._curent_pop != null) {
const popId = this._curent_pop.id;
// 弹窗仍在显示或正在 openPanel 异步加载时阻塞队列,避免加载中被误判为已结束
if (this.panelIsShow(popId) || this._panelOpenInflight.has(popId)) {
return;
}
this._curent_pop = null;
}
if (this._wait_popIds.length > 0) {
this._curent_pop = this._wait_popIds.shift();
let id = this._curent_pop.id;
let param = this._curent_pop.param;
if (id === SKILL_SELECT_PANEL_ID && this.isSkillSelectTreasureOpen(param)) {
this._logSkillSelectTrace('dequeue_start_load', param);
}
this.openPanel(id, param);
}
}
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;
}
private _extractLoadErr(err: unknown): string {
if (!err) {
return 'unknown';
}
if (typeof err === 'string') {
return err.slice(0, 120);
}
if (err instanceof Error) {
return err.message.slice(0, 120);
}
const anyErr = err as { message?: string };
if (anyErr.message) {
return String(anyErr.message).slice(0, 120);
}
try {
return String(err).slice(0, 120);
} catch {
return 'unknown';
}
}
private _formatPrefabLoadDetail(result: LoadPrefabResult, retry = false, bundleReload = false): string {
const parts: string[] = [];
if (retry) {
parts.push('retry=1');
}
if (bundleReload) {
parts.push('reload=1');
}
parts.push(`stage=${result.stage ?? 'unknown'}`);
parts.push(`err=${this._sanitizeSkillSelectReportSegment(result.err ?? 'unknown', 80)}`);
return parts.join('|');
}
/** SkillSelect 加载失败:卸分包后重新 loadBundle + load(仅失败路径调用) */
private async _reloadBundleAndLoadPrefab(bundleName: string, assetPath: string): Promise<LoadPrefabResult> {
try {
const bundle = assetManager.getBundle(bundleName);
if (bundle) {
bundle.releaseAll();
assetManager.removeBundle(bundle);
}
} catch (e) {
console.warn('[UIManager] _reloadBundleAndLoadPrefab removeBundle failed', bundleName, e);
}
return this.loadPrefab(bundleName, assetPath);
}
private _cleanupPanelForBundleReload(id: number): void {
if (!this._ui_map.has(id)) {
return;
}
const uiInfo = this._ui_map.get(id);
try {
if (uiInfo.uiNode?.isValid) {
Tween.stopAllByTarget(uiInfo.uiNode);
uiInfo.prefabRes?.decRef();
uiInfo.uiNode.destroy();
}
} catch (e) {
console.warn('[UIManager] _cleanupPanelForBundleReload destroy failed', id, e);
}
uiInfo.uiNode = null;
uiInfo.uiBase = null;
uiInfo.prefabRes = null;
this._ui_map.delete(id);
const fresh = new UIInfo();
fresh.id = id;
this._ui_map.set(id, fresh);
}
private async loadPrefab(bundleName: string, assetPath: string): Promise<LoadPrefabResult> {
let bundle = assetManager.getBundle(bundleName);
let bundleErr: string | undefined;
if (!bundle) {
const bundleResult = await this.loadBundle(bundleName);
bundle = bundleResult.bundle;
bundleErr = bundleResult.err;
}
if (!bundle) {
return {
prefab: null,
stage: 'bundle',
err: bundleErr ?? 'bundle_missing',
};
}
const assetResult = await this.loadRes(bundle, assetPath);
if (!assetResult.asset) {
return {
prefab: null,
stage: 'asset',
err: assetResult.err ?? 'asset_missing',
};
}
return { prefab: assetResult.asset as Prefab };
}
private async loadBundle(bundleName: string): Promise<{ bundle: AssetManager.Bundle | null; err?: string }> {
return new Promise((resolve) => {
assetManager.loadBundle(bundleName, (err, bundle) => {
if (err) {
console.log(`加载Bundle:${bundleName}错误`, err);
resolve({ bundle: null, err: this._extractLoadErr(err) });
} else {
resolve({ bundle });
}
});
});
}
private async loadRes(bundle: AssetManager.Bundle, assetPath: string): Promise<{ asset: Asset | null; err?: string }> {
return new Promise((resolve) => {
const cached = bundle.get(assetPath, Prefab);
if (cached) {
resolve({ asset: cached });
return;
}
bundle.load(assetPath, Prefab, (err, asset) => {
if (err) {
console.log(`加载Bundle:${bundle.name},path:${assetPath}资源错误`, err);
resolve({ asset: null, err: this._extractLoadErr(err) });
} else if (!asset) {
resolve({ asset: null, err: 'asset_null' });
} else {
resolve({ asset });
}
});
});
}
}