//********************* // 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 = new Map(); private _ui_map: Map = new Map(); /** 同 id 的 openPanel 异步加载未完成时,后续点击复用该 Promise,避免 uiBase 仍为 null 时写 prema */ private _panelOpenInflight: Map> = 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((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 { const keys = Array.from(this._ui_map.keys()); const tasks: Promise[] = []; 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) { const msg = err instanceof Error ? `${err.name}:${err.message}` : String(err); this._reportSkillSelectOpenFail('init_error', param, msg.slice(0, 120), context); } private _skillSelectBattleOnlyContext(): Record { 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): Record { if (explicit) { return explicit; } try { const uiInfo = this._ui_map.get(SKILL_SELECT_PANEL_ID); const panel = uiInfo?.uiBase as { getOpenFailContext?: () => Record } | 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): { 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, extra: Record = {}) { 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 { 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 = {}) { 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, ): 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 { 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 = `${msg}`; 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 { 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 { 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 }); } }); }); } }