//********************* // create by 流云 // time: Sat Aug 01 2026 // desc: 战斗图鉴(牌库概率 + 神将图鉴) //********************* import { _decorator, assetManager, Color, Component, instantiate, Label, Layout, Node, Prefab, RichText, sp, Sprite, UIOpacity, UITransform } from 'cc'; import { Auto_PopIllustrated } from './Auto_PopIllustrated'; import { heroUltSkillId, textIconName } from '../../battle/config/BattleHeroTable'; import type { BattleBagSession } from '../../battle/bag/BattleBagSession'; import type { PlayerBagTextPoolState } from '../../battle/bag/PlayerBagTextPool'; import type { ProjectBattleHandle } from '../../battle/runtime/BattleRuntime'; import { applyGlyphSpineSkin } from '../../battle/view/GlyphSpineSkin'; import { TextGrid } from '../../battle/view/TextGrid'; import { GBundle, GPath } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { LangTxt } from 'db://assets/mx/module/lang/LangTxt'; import { MERGE_EVENT_ALL, MergeEventType, type MergeEvent } from 'db://assets/mx/module/merge'; const { ccclass } = _decorator; /** Text.type:基础字 */ const TEXT_TYPE_BASIC = 1; /** Text.type:武将组合字 */ const TEXT_TYPE_HERO = 2; const BASE_TEXT_CHAR_SCALE = 1; const LOG = '[PopIllustrated]'; /** Hero.quality → 技能名颜色 */ const HERO_QUALITY_NAME_HEX: Record = { 1: '#2ECC71', 2: '#3498DB', 3: '#9B59B6', 4: '#F39C12', 5: '#E53935', }; /** 字形展示预制体(与背包 TextGrid 同源) */ const GLYPH_GRID_PATH = GPath.Prefab; const GLYPH_GRID_NAME = 'textGrid'; /** 图鉴内隐藏 textGrid 子节点 */ const GLYPH_HIDE_CHILDREN = ['select', 'redSelect', 'canUpEff', 'trail1', 'trail2', 'trail3']; /** 页签:图鉴 */ const TAB_TUJIAN = 0; /** 页签:牌库 */ const TAB_PAIKU = 1; /** 牌库基础字槽位 */ const PAIKU_ZI_SLOTS = ['zi1', 'zi2', 'zi3', 'zi4'] as const; /** 牌库基础字概率标签 */ const PAIKU_NUM_SLOTS = ['num1', 'num2', 'num3', 'num4'] as const; /** 牌库武将字列表项:单字挂点 */ const HERO_LIST_GLYPH_SLOT = 'hero'; /** 字池条目快照 */ interface IPoolTextSnap { /** Text.id */ textId: number; /** 配置行 */ row: ITableText; /** 基础权重 */ weight: number; /** 剩余数量 */ remaining: number; /** 展示用上限(字池不限量时为 0) */ max: number; /** weight × remaining */ effectiveWeight: number; } @ccclass('UIPopIllustrated') export class UIPopIllustrated extends Auto_PopIllustrated { private _heroListTemplate: Node = null; private _haveListTemplate: Node = null; /** 当前页签 */ private _pageIndex = TAB_TUJIAN; /** 图鉴列表数据:Hero 表 herotype === 2 */ private _comboHeroList: ITableHero[] = []; /** 图鉴当前选中下标 */ private _selectIndex = 0; /** 图鉴列表项节点(与 _comboHeroList 下标对齐) */ private _haveListItems: Node[] = []; /** 异步填充代数,过期回调丢弃 */ private _fillToken = 0; /** 背包刷新监听 */ private _bagRefreshListener: ((e: MergeEvent) => void) | null = null; /** 已注册 BagRefreshed 的背包会话 */ private _bagRefreshBag: BattleBagSession | null = null; onLoad(): void { super.onLoad(); this._heroListTemplate = this._resolveHeroListTemplate(); this._haveListTemplate = this._findListTemplate(this.ui_haveList); if (this._heroListTemplate) this._heroListTemplate.active = false; if (this._haveListTemplate) this._haveListTemplate.active = false; } onInit(): void { let curWaveNum = gg.game.CurentBattle.CurentWaveNum gg.sdk.reportDY('inLevel', `${gg.game.getReportDYBattleCountLabel(curWaveNum)}-打开图鉴`); this._comboHeroList = []; this._selectIndex = 0; this._haveListItems = []; this.click_btnTuJian(); } onEnable(): void { super.onEnable(); this._bindBagRefreshListener(); if (this._pageIndex === TAB_PAIKU) { this.showPaiKu(); } } onDisable(): void { this._unbindBagRefreshListener(); super.onDisable(); } onDestroy(): void { this._fillToken++; this._clearHeroSlot(this.ui_select); super.onDestroy(); } updateUI(): void { this._applyTabButton(this.btnTuJian, this._pageIndex === TAB_TUJIAN); this._applyTabButton(this.btnPaiKu, this._pageIndex === TAB_PAIKU); this.ui_tab1.active = this._pageIndex === TAB_TUJIAN; this.ui_tab2.active = this._pageIndex === TAB_PAIKU; this.ui_tujian.active = this._pageIndex === TAB_TUJIAN; this.ui_paiku.active = this._pageIndex === TAB_PAIKU; } /** 页签按钮:未选中 font,选中 fontSelect */ private _applyTabButton(btn: Node, selected: boolean): void { const font = btn.getChildByName('font'); const fontSelect = btn.getChildByName('fontSelect'); if (font) font.active = !selected; if (fontSelect) fontSelect.active = selected; } click_btnClose(): void { this.close(2); } click_btnPaiKu(): void { this._pageIndex = TAB_PAIKU; this.updateUI(); this._bindBagRefreshListener(); this.showPaiKu(); } click_btnTuJian(): void { this._pageIndex = TAB_TUJIAN; this.updateUI(); this.showTuJian(); } showPaiKu(): void { const bag = this._getBattleBagSession(); const poolState = bag?.exportCounters().textPool ?? null; if (!bag) { console.log(LOG, '背包会话未就绪'); this._clearPaiKuView(); return; } if (!poolState?.ready) { console.log(LOG, '局内字池未就绪', `N=${poolState?.numberN ?? 0}`); this._clearPaiKuView(); return; } const entries = this._buildPoolEntries(poolState); const totalEffective = this._sumEffectiveWeight(entries); this._fillBasicTextSlots(entries, totalEffective); this._fillHeroTextList(entries, poolState.numberN); this._refreshHeroTextTotalProb(entries, totalEffective); } showTuJian(): void { this._fillComboHeroList(); } /** 牌库 zi1~4 / num1~4:Text.type===1,概率 = 有效权重 / 全池有效权重 */ private _fillBasicTextSlots(entries: readonly IPoolTextSnap[], totalEffective: number): void { const basicRows = this._getTextRowsByType(TEXT_TYPE_BASIC, true); for (let i = 0; i < PAIKU_ZI_SLOTS.length; i++) { const ziNode = this.ui_paiku.getChildByName(PAIKU_ZI_SLOTS[i]); const numLabel = this.ui_paiku.getChildByName(PAIKU_NUM_SLOTS[i])?.getComponent(Label); const row = basicRows[i]; if (!row) { if (ziNode?.isValid) ziNode.active = false; this._setDynamicLabel(numLabel, '0%'); continue; } const snap = this._findPoolEntry(entries, Number(row.id)); if (ziNode?.isValid) { ziNode.active = true; this._mountGlyphChar(ziNode, textIconName(row), BASE_TEXT_CHAR_SCALE); } this._setDynamicLabel(numLabel, this._formatPercent(snap?.effectiveWeight ?? 0, totalEffective)); } } /** 牌库 ui_heroList:Text.type===2 单字 + 剩余数量 */ private _fillHeroTextList(entries: readonly IPoolTextSnap[], numberN: number): void { if (!this._heroListTemplate?.isValid) return; this._clearListChildren(this.ui_heroList, this._heroListTemplate); const heroRows = this._getTextRowsByType(TEXT_TYPE_HERO, true); for (let i = 0; i < heroRows.length; i++) { const row = heroRows[i]; const snap = this._findPoolEntry(entries, Number(row.id)); const remaining = snap?.remaining ?? 0; const item = instantiate(this._heroListTemplate); item.parent = this.ui_heroList; item.active = true; const heroSlot = item.getChildByName(HERO_LIST_GLYPH_SLOT); if (!heroSlot?.isValid) { console.log(LOG, 'template hero 挂点缺失'); } else { this._mountGlyphChar(heroSlot, textIconName(row), BASE_TEXT_CHAR_SCALE); } const numNode = item.getChildByName('num'); const numLabel = numNode?.getComponent(Label); const depleted = remaining <= 0; this._applyHeroListDepletedVisual(item, depleted); this._setDynamicLabel(numLabel, this._formatRemainCount(remaining)); if (!snap && remaining <= 0) { console.log(LOG, '字池无该武将字', row.id, textIconName(row), `N=${numberN}`); } } this.ui_heroList.getComponent(Layout)?.updateLayout(); } /** lb_heroNum:武将组合字合计概率(仅百分比) */ private _refreshHeroTextTotalProb(entries: readonly IPoolTextSnap[], totalEffective: number): void { if (!this.lb_heroNum) return; let heroEffective = 0; for (let i = 0; i < entries.length; i++) { if (Number(entries[i].row.type) === TEXT_TYPE_HERO) { heroEffective += entries[i].effectiveWeight; } } this._setDynamicLabel(this.lb_heroNum, this._formatPercent(heroEffective, totalEffective)); } /** 字池未就绪时清空牌库展示 */ private _clearPaiKuView(): void { for (let i = 0; i < PAIKU_ZI_SLOTS.length; i++) { const ziNode = this.ui_paiku.getChildByName(PAIKU_ZI_SLOTS[i]); const numLabel = this.ui_paiku.getChildByName(PAIKU_NUM_SLOTS[i])?.getComponent(Label) ?? null; if (ziNode?.isValid) ziNode.active = false; this._setDynamicLabel(numLabel, '0%'); } if (this._heroListTemplate?.isValid) { this._clearListChildren(this.ui_heroList, this._heroListTemplate); } this._setDynamicLabel(this.lb_heroNum, '0%'); } /** 当前战斗背包会话(PopIllustrated 走 gg.game.CurentBattle,不依赖 SkillPresentationEnv) */ private _getBattleBagSession(): BattleBagSession | null { const battle = gg.game.CurentBattle as ProjectBattleHandle | null; return battle?.bagSession ?? null; } /** 字池快照 → 展示条目 */ private _buildPoolEntries(state: PlayerBagTextPoolState): IPoolTextSnap[] { const entries: IPoolTextSnap[] = []; for (let i = 0; i < state.textIds.length; i++) { const textId = Number(state.textIds[i]); const row = gg.data.table.getTableData(TableNames.Text, textId); if (!row) { console.log(LOG, 'Text 缺失', textId); continue; } const remaining = Number(state.remaining[i]) | 0; const weight = Number(state.weights[i]); const max = 0; entries.push({ textId, row, weight, remaining, max, effectiveWeight: remaining > 0 && weight > 0 ? weight * remaining : 0, }); } return entries; } /** Text 表按 type 筛选并 id 升序;excludeZeroWeight 时跳过 weight<=0 */ private _getTextRowsByType(type: number, excludeZeroWeight = false): ITableText[] { return (gg.data.table.getTableList(TableNames.Text) ?? []) .filter((row) => { if (Number(row.type) !== type) return false; if (excludeZeroWeight && !(Number(row.weight) > 0)) return false; return true; }) .sort((a, b) => a.id - b.id); } /** 按 Text.id 查字池条目 */ private _findPoolEntry(entries: readonly IPoolTextSnap[], textId: number): IPoolTextSnap | null { for (let i = 0; i < entries.length; i++) { if (entries[i].textId === textId) return entries[i]; } return null; } /** 有效权重总和 */ private _sumEffectiveWeight(entries: readonly IPoolTextSnap[]): number { let total = 0; for (let i = 0; i < entries.length; i++) { total += entries[i].effectiveWeight; } return total; } /** 剩余数量展示:x0 */ private _formatRemainCount(remaining: number): string { return `x${Math.max(0, remaining | 0)}`; } /** 动态文案 Label:移除 LangTxt 后再赋值,避免被语言系统覆盖 */ private _setDynamicLabel(label: Label | null, text: string): void { if (!label?.isValid) return; label.node.getComponent(LangTxt)?.destroy(); label.string = text; } private _bindBagRefreshListener(): void { this._unbindBagRefreshListener(); const bag = this._getBattleBagSession(); if (!bag) return; this._bagRefreshBag = bag; this._bagRefreshListener = (e: MergeEvent) => { if (this._pageIndex !== TAB_PAIKU) return; if (e.type === MergeEventType.BagRefreshed || e.type === MergeEventType.GlyphRewritten) { this.showPaiKu(); } }; bag.on(MERGE_EVENT_ALL, this._bagRefreshListener); } private _unbindBagRefreshListener(): void { if (this._bagRefreshBag && this._bagRefreshListener) { this._bagRefreshBag.off(MERGE_EVENT_ALL, this._bagRefreshListener); } this._bagRefreshBag = null; this._bagRefreshListener = null; } /** 图鉴列表:Hero 表 herotype === 2 */ private _fillComboHeroList(): void { if (!this._haveListTemplate?.isValid) return; this._fillToken++; this._clearHeroSlot(this.ui_select); this._clearListChildren(this.ui_haveList, this._haveListTemplate); this._haveListItems = []; this._comboHeroList = gg.equip.getNormalHeroList().slice().sort((a, b) => a.id - b.id); if (this._comboHeroList.length <= 0) { this._clearHeroDetailPanel(); return; } if (this._selectIndex < 0 || this._selectIndex >= this._comboHeroList.length) { this._selectIndex = 0; } const token = this._fillToken; this._comboHeroList.forEach((hero, index) => { const item = instantiate(this._haveListTemplate); item.parent = this.ui_haveList; item.active = true; this._haveListItems.push(item); this._applyHaveListSelectState(item, index === this._selectIndex); item.off(Node.EventType.TOUCH_END); item.on(Node.EventType.TOUCH_END, () => this._selectHeroByIndex(index), this); void this._fillHeroSlot(item.getChildByName('hero'), hero, token); }); this.ui_haveList.getComponent(Layout)?.updateLayout(); void this._showSelectedHero(this._selectIndex, token); } /** 列表项选中框 select */ private _applyHaveListSelectState(item: Node, selected: boolean): void { const selectNode = item.getChildByName('select'); if (selectNode) selectNode.active = selected; } private _refreshHaveListSelectState(): void { for (let i = 0; i < this._haveListItems.length; i++) { const item = this._haveListItems[i]; if (item?.isValid) this._applyHaveListSelectState(item, i === this._selectIndex); } } private _selectHeroByIndex(index: number): void { if (index < 0 || index >= this._comboHeroList.length) return; if (this._selectIndex === index) return; this._selectIndex = index; this._refreshHaveListSelectState(); void this._showSelectedHero(index, this._fillToken); } /** 大图区 + 技能文案 */ private async _showSelectedHero(index: number, token: number): Promise { const hero = this._comboHeroList[index]; if (!hero) { this._clearHeroDetailPanel(); return; } await this._fillHeroSlot(this.ui_select, hero, token); if (token !== this._fillToken || !this.isValid) return; this._applyHeroDetailPanel(hero); } private _applyHeroDetailPanel(hero: ITableHero): void { const skillId = heroUltSkillId(hero, 1); const skill = skillId > 0 ? gg.data.table.getTableData(TableNames.Skill, skillId) : null; if (this.lb_skillName) { this.lb_skillName.string = skill ? gg.lang.get(skill.name) : gg.lang.get(hero.name); const hex = HERO_QUALITY_NAME_HEX[Math.floor(Number(hero.quality) || 0)]; if (!hex) { console.log(LOG, '未知英雄品质色', hero.id, hero.quality); } else { this.lb_skillName.color = new Color().fromHEX(hex); } } if (this.lb_skillDesc) { const desc = skill ? gg.lang.get(skill.desc) : ''; this.lb_skillDesc.string = desc; } } private _clearHeroDetailPanel(): void { if (this.lb_skillName) { this.lb_skillName.string = ''; this.lb_skillName.color = Color.WHITE; } if (this.lb_skillDesc) this.lb_skillDesc.string = ''; } private _formatPercent(weight: number, total: number): string { const w = Number(weight); const t = Number(total); if (!Number.isFinite(w) || !Number.isFinite(t) || t <= 0) return '0%'; return `${(w / t * 100).toFixed(2)}%`; } /** 挂载字形展示(与背包 TextGrid 同源 prefab) */ private _mountGlyphChar(parent: Node, iconName: string, scale = BASE_TEXT_CHAR_SCALE, clearAll = true): void { const name = String(iconName || '').trim(); if (!name) { console.log(LOG, '字形名为空', parent.name); return; } if (clearAll) parent.removeAllChildren(); const attach = (gridNode: Node): void => { gridNode.parent = parent; gridNode.setPosition(0, 0, 0); gridNode.setScale(scale, scale, 1); this._applyGlyphGridPresentation(gridNode, name); }; const prefab = gg.res.getRes(GLYPH_GRID_NAME, GLYPH_GRID_PATH, GBundle.BattleGame, Prefab); if (prefab) { attach(instantiate(prefab)); return; } const bundle = assetManager.getBundle(GBundle.BattleGame); if (!bundle) { console.log(LOG, 'BattleGame bundle 未加载', name); return; } bundle.load(`${GLYPH_GRID_PATH}${GLYPH_GRID_NAME}`, Prefab, (err, loaded) => { if (err || !loaded || !parent.isValid) { if (err) console.log(LOG, 'textGrid 加载失败', name, String(err)); return; } attach(instantiate(loaded)); parent.parent?.getComponent(Layout)?.updateLayout(); }); } /** 图鉴用 textGrid:隐藏多余子节点,切 Hero.res 皮肤 */ private _applyGlyphGridPresentation(gridNode: Node, iconName: string): void { for (let i = 0; i < GLYPH_HIDE_CHILDREN.length; i++) { const child = gridNode.getChildByName(GLYPH_HIDE_CHILDREN[i]); if (child) child.active = false; } const skinNode = gridNode.getChildByName('skin'); if (skinNode) skinNode.active = true; const textGrid = gridNode.getComponent(TextGrid); if (textGrid) textGrid.enabled = false; const sk = skinNode?.getComponent(sp.Skeleton) ?? skinNode?.getComponentInChildren(sp.Skeleton); if (!sk?.isValid) { console.log(LOG, '字形 Spine 缺失', iconName); return; } sk.enabled = true; if (!applyGlyphSpineSkin(sk, iconName)) { console.log(LOG, '字形皮肤未能应用', iconName); } if (sk.skeletonData) ensureSpineSkeletonDataRuntime(sk.skeletonData); const runtime = sk.skeletonData?.getRuntimeData?.(true); if (runtime?.findAnimation?.('待机')) { sk.setAnimation(0, '待机', true); } } private _clearListChildren(listNode: Node, template: Node): void { for (const child of [...listNode.children]) { if (child === template) continue; child.destroy(); } } /** ui_heroList 模板节点 */ private _resolveHeroListTemplate(): Node | null { return this._findListTemplate(this.ui_heroList); } /** 列表 template 节点(优先 name=template) */ private _findListTemplate(listRoot: Node | null): Node | null { if (!listRoot?.isValid) return null; const named = listRoot.getChildByName('template'); if (named?.isValid) return named; if (listRoot.children.length > 0) return listRoot.children[0]; return null; } /** * 武将字列表项:剩余为 0 时隐藏 num、显示 complete。 * @param item 列表项 * @param depleted 是否已抽空 */ private _applyHeroListDepletedVisual(item: Node, depleted: boolean): void { const numNode = item.getChildByName('num'); const completeNode = item.getChildByName('complete'); if (numNode?.isValid) numNode.active = !depleted; if (!completeNode?.isValid) { if (depleted) console.log(LOG, 'template complete 节点缺失', item.name); return; } completeNode.active = depleted; if (depleted) { completeNode.setSiblingIndex(item.children.length - 1); } } /** 神将分包已下线:图鉴暂不挂展示体 */ private async _fillHeroSlot(slot: Node | null, hero: ITableHero, token: number): Promise { if (!slot?.isValid) { console.log(LOG, 'hero 挂点缺失'); return; } this._clearHeroSlot(slot); const resName = String(hero.res || hero.name || '').trim(); console.log(LOG, 'shenjiang 包已删除,跳过神将展示', hero.id, resName); } private _mountDisplay(slot: Node, child: Node): void { child.parent = slot; this._setLayerRecursive(child, slot.layer); child.setPosition(0, 0, 0); child.setScale(1, 1, 1); child.angle = 0; child.active = true; } private _clearHeroSlot(slot: Node | null): void { if (!slot?.isValid) return; const kids = slot.children; const copy: Node[] = []; for (let i = 0; i < kids.length; i++) copy.push(kids[i]); for (let i = 0; i < copy.length; i++) this._freeDisplay(copy[i]); } private _loadDisplayPrefab(dir: string, prefabName: string, bundleName: string): Promise { const name = (prefabName || '').trim(); if (!name) return Promise.resolve(null); const full = `${dir}${name}`; return new Promise((resolve) => { const existed = assetManager.getBundle(bundleName); if (existed) { this._instantiatePrefab(existed, full, resolve); return; } assetManager.loadBundle(bundleName, (err, bundle) => { if (err || !bundle) { console.error(LOG, 'bundle 加载失败', bundleName, String(err)); resolve(null); return; } this._instantiatePrefab(bundle, full, resolve); }); }); } private _instantiatePrefab(bundle: ReturnType, full: string, resolve: (n: Node | null) => void): void { if (!bundle) { resolve(null); return; } const cached = bundle.get(full, Prefab); if (cached) { resolve(instantiate(cached)); return; } bundle.load(full, Prefab, (err, p) => { if (err || !p) { console.error(LOG, '预制体加载失败', full, String(err)); resolve(null); return; } resolve(instantiate(p)); }); } private _freeDisplay(node: Node | null | undefined): void { if (!node?.isValid) return; node.removeFromParent(); node.destroy(); } private _disableLogicScripts(node: Node): void { const comps = node.getComponents(Component); for (let i = 0; i < comps.length; i++) { const c = comps[i]; if (c instanceof UITransform || c instanceof Sprite || c instanceof sp.Skeleton) continue; if (c instanceof UIOpacity) continue; c.enabled = false; } for (let i = 0; i < node.children.length; i++) { this._disableLogicScripts(node.children[i]); } } private _findSkeleton(node: Node | null | undefined): sp.Skeleton | null { if (!node?.isValid) return null; return node.getComponent(sp.Skeleton) ?? node.getComponentInChildren(sp.Skeleton) ?? null; } private _applySpine(node: Node, skin: string, anim: string): void { const skel = this._findSkeleton(node); if (!skel) return; if (skel.skeletonData) ensureSpineSkeletonDataRuntime(skel.skeletonData); if (skin) { try { skel.setSkin(skin); skel.setSlotsToSetupPose(); } catch (e) { console.log(LOG, `setSkin failed "${skin}"`, String(e)); } } const clip = anim || '待机'; if (!clip) return; try { skel.setAnimation(0, clip, true); } catch (e) { console.log(LOG, `setAnimation failed "${clip}"`, String(e)); } } private _setLayerRecursive(node: Node, layer: number): void { node.layer = layer; for (let i = 0; i < node.children.length; i++) { const child = node.children[i]; if (child?.isValid) this._setLayerRecursive(child, layer); } } }