//********************* // create by 流云 // time: Sat Sep 05 2026 // desc: 战斗内点击五行数字 — 详情弹窗(数字皮 / 普攻技能 / 镶嵌宝石) //********************* import { _decorator, EventTouch, instantiate, Label, Layout, Node, Sprite, Tween, UITransform, UIOpacity, UIRenderer, Vec3, sp, } from 'cc'; import { type IPopAnimConfig, type IPopAnimSession } from '../../../mx/module/ui/popAnim'; import { ensureSpineSkeletonDataRuntime } from 'db://assets/mx/module/lang/spineRuntimePatch'; import { SpriteLoad } from 'db://assets/mx/components/SpriteLoad'; import { BATTLE_EVENT_ALL, BattleEventType, type BattleEvent } from 'db://assets/mx/module/battle'; import type { EntityId } from 'db://assets/mx/module/ecs'; import { GEvent } from '../../../mx/module/event/GEvent'; import { Auto_BattleWeaponDescHero } from './Auto_BattleWeaponDescHero'; import type { TileEntitySnapshot, TowerRuntimeSnapshot } from '../../battle/combat/TowerQueryTypes'; import { TowerQueryService } from '../../battle/combat/TowerQueryService'; import { deriveTowerSetupStats, TowerStatDisplay } from '../../battle/combat/TowerStatResolver'; import { heroNormalSkillId, isElementHero } from '../../battle/config/BattleHeroTable'; import { BATTLE_HERO_NUM_PATH } from '../../battle/res/BattleHeroResCollector'; import { glyphSpineHasSkin } from '../../battle/view/GlyphSpineSkin'; import { GBundle } from '../../game/ConfigRes'; import { TableNames } from '../../game/ConfigTableData'; const { ccclass } = _decorator; const LOG = '[UIBattleWeaponDescHero]'; /** 空镶嵌提示文案(与预制体 gtemplate/lb 默认一致) */ const GEM_EMPTY_TIP = '尚未镶嵌宝石'; const NUM_IDLE = '待机'; /** 五行数字详情弹窗入参 */ export interface BattleWeaponDescHeroPanelData { entity: TileEntitySnapshot; runtime?: TowerRuntimeSnapshot | null; /** 敌方阵营(敌方不读本机镶嵌) */ enemyCamp?: boolean; /** 已解析的展示武器/技能宝石 id(兼容旧入参) */ weaponId?: number; weaponWorldPos?: { x: number; y: number; z?: number }; gridStep?: number; trySwitchAtUiPos?: (uiPos: { x: number; y: number }) => boolean; /** 监视中的地图/背包格 */ watchTarget?: { kind: 'map' | 'bag'; index: number } | null; /** 已部署 ECS 实体 */ watchEntityId?: EntityId; /** 从棋盘重新读取 entity/runtime */ resolveLiveData?: () => Pick | null; } @ccclass('UIBattleWeaponDescHero') export class UIBattleWeaponDescHero extends Auto_BattleWeaponDescHero { private static readonly PANEL_GAP_GRIDS = 1.7; private _popupBody: Node | null = null; /** 递增时取消尚未完成的关窗销毁 */ private _panelLifeGen = 0; /** ui_node 下挂的数字预制体节点 */ private _numNode: Node | null = null; /** 宝石列表模板 */ private _gemTpl: Node | null = null; /** 已克隆的宝石行 */ private readonly _gemRows: Node[] = []; /** 静态区缓存键 */ private _staticViewKey = ''; /** 战斗事件监听是否已绑定 */ private _battleEventBound = false; /** GEvent 监听是否已绑定 */ private _geventBound = false; onLoad(): void { super.onLoad(); this._popupBody = this.node.getChildByName('弹窗主体'); this._cacheGemTemplate(); } onEnable(): void { super.onEnable(); this.ui_bgMask?.on(Node.EventType.TOUCH_END, this.click_ui_bgMask, this); this._bindLiveRefresh(); } onDisable(): void { if (this.ui_bgMask?.isValid) { this.ui_bgMask.off(Node.EventType.TOUCH_END, this.click_ui_bgMask, this); } this._unbindLiveRefresh(); this.unschedule(this._deferredRefreshDynamic); this._staticViewKey = ''; this._clearNumNode(); this._clearGemRows(); super.onDisable(); } /** 订阅击杀/棋盘变更,实时刷新数值区 */ private _bindLiveRefresh(): void { if (!this._geventBound) { GEvent.Ins.on(GEvent.UpdateBestPlace, this._onLiveDataDirty, this); this._geventBound = true; } const battle = gg.game.CurentBattle; if (battle && !this._battleEventBound) { battle.on(BATTLE_EVENT_ALL, this._onBattleEvent); this._battleEventBound = true; } } /** 取消实时刷新订阅 */ private _unbindLiveRefresh(): void { if (this._geventBound) { GEvent.Ins.off(GEvent.UpdateBestPlace, this._onLiveDataDirty, this); this._geventBound = false; } const battle = gg.game.CurentBattle; if (battle && this._battleEventBound) { battle.off(BATTLE_EVENT_ALL, this._onBattleEvent); this._battleEventBound = false; } } private _onBattleEvent = (event: BattleEvent): void => { if (event.type === BattleEventType.EntityKilled) { this._scheduleRefreshDynamicFromLive(); } }; private _onLiveDataDirty = (): void => { this._scheduleRefreshDynamicFromLive(); }; /** 帧末刷新,确保击杀经验/升级写入后再读 */ private _scheduleRefreshDynamicFromLive(): void { this.unschedule(this._deferredRefreshDynamic); this.scheduleOnce(this._deferredRefreshDynamic, 0); } private _deferredRefreshDynamic = (): void => { this._refreshDynamicFromLive(); }; onInit(): void { this._panelLifeGen++; this._ensurePanelVisible(); this._bindLiveRefresh(); this._cacheGemTemplate(); const ctx = this._resolveRefreshContext(); if (!ctx) { this._staticViewKey = ''; this._clearLabels(); this._clearNumNode(); this._clearGemRows(); return; } const { data, w1, level, levelLimit, stats, runtime, enemyCamp } = ctx; const element = Math.floor(Number(w1.herotype) || 0); const staticViewKey = this._buildStaticViewKey(w1.id, level, element, enemyCamp); const needStaticRefresh = staticViewKey !== this._staticViewKey || !this._numNode?.isValid; if (needStaticRefresh) { this._staticViewKey = staticViewKey; this._refreshNumber(w1); this._refreshStaticLabels(w1, level, levelLimit); this._refreshGemList(element, enemyCamp); } this._refreshDynamicStats(w1, level, stats); this._applyPanelPosition(data); } /** 合并 prema 与 resolveLiveData */ private _resolveRefreshContext(): { data: BattleWeaponDescHeroPanelData; w1: ITableHero; level: number; levelLimit: number; stats: TowerRuntimeSnapshot['stats'] | undefined; runtime: TowerRuntimeSnapshot | null; enemyCamp: boolean; } | null { const data = this.prema as BattleWeaponDescHeroPanelData | null; if (!data?.entity) return null; const live = data.resolveLiveData?.() ?? null; if (live) { data.entity = live.entity; data.runtime = live.runtime ?? null; if (live.weaponId != null && live.weaponId > 0) { data.weaponId = live.weaponId; } if (live.runtime?.entityId) { data.watchEntityId = live.runtime.entityId; } } const w1 = data.entity.tower?.config ?? null; if (!w1) return null; if (!isElementHero(w1)) { console.warn(LOG, '非五行数字,详情仅支持五行', w1.id, w1.herotype); } let runtime = data.runtime ?? null; const watchEntityId = data.watchEntityId ?? runtime?.entityId ?? 0; const battle = gg.game.CurentBattle; if (watchEntityId > 0 && battle?.session) { runtime = TowerQueryService.instance.getRuntimeSnapshot(battle.session, watchEntityId) ?? runtime; data.runtime = runtime; } const comp = data.entity.composition; const level = runtime?.level ?? comp?.level ?? data.entity.level; const levelLimit = data.entity.tower?.levelLimit ?? w1.level_limit ?? level; const liveStats = watchEntityId > 0 && battle?.session ? TowerQueryService.instance.readLiveStats(battle.session, watchEntityId) : null; const stats = liveStats ?? runtime?.stats ?? data.entity.tower?.stats; const enemyCamp = !!data.enemyCamp; return { data, w1, level, levelLimit, stats, runtime, enemyCamp }; } /** 击杀/升级后仅刷新数值区 */ private _refreshDynamicFromLive(): void { if (!this.node?.isValid || !this.node.active) return; const ctx = this._resolveRefreshContext(); if (!ctx) return; const { w1, level, levelLimit, stats, enemyCamp } = ctx; const element = Math.floor(Number(w1.herotype) || 0); const staticViewKey = this._buildStaticViewKey(w1.id, level, element, enemyCamp); if (staticViewKey !== this._staticViewKey) { this.onInit(); return; } this._refreshDynamicStats(w1, level, stats); if (this.lb_lvMax) this.lb_lvMax.string = `满级${levelLimit}级`; } destroyPanel(tweenType = 1) { return new Promise((resolve) => { const lifeGen = this._panelLifeGen; const finish = () => { if (lifeGen !== this._panelLifeGen) { resolve(true); return; } this.prepareDestroyForRender(); if (this.node.isValid) { this.node.destroy(); } resolve(true); this.notifyPanelClose(); }; if (tweenType === 2 || tweenType === 3) { gg.ui.popAnim.playClose(this.node, this._resolvePopSession(), tweenType, finish); return; } finish(); }); } click_ui_bgMask(event?: EventTouch): void { const uiPos = event?.getUILocation(); const trySwitch = (this.prema as BattleWeaponDescHeroPanelData | null)?.trySwitchAtUiPos; if (uiPos && trySwitch?.(uiPos)) { return; } this.close(2); } /** 静态区缓存键 */ private _buildStaticViewKey(heroId: number, level: number, element: number, enemyCamp: boolean): string { return `${Math.floor(Number(heroId) || 0)}|${Math.floor(Number(level) || 0)}|${element}|${enemyCamp ? 1 : 0}`; } /** * ui_node:按 Hero.number 加载数字预制体,并切五行皮肤。 * @param w1 当前数字对应 Hero */ private _refreshNumber(w1: ITableHero): void { if (!this.ui_node?.isValid) return; this._clearNumNode(); const num = Math.floor(Number(w1.number) || 0); if (!(num > 0)) { console.warn(LOG, 'Hero.number 无效', w1.id, w1.number); this.ui_node.active = false; return; } const skinName = this._elementSkinName(Math.floor(Number(w1.herotype) || 0)); if (!skinName) { console.warn(LOG, '五行皮肤未定义', w1.id, w1.herotype); this.ui_node.active = false; return; } let node: Node | null = null; try { node = gg.res.getNode(BATTLE_HERO_NUM_PATH, String(num), GBundle.BattleHero); } catch (e) { console.warn(LOG, '数字预制体加载失败', num, String(e)); this.ui_node.active = false; return; } if (!node?.isValid) { console.warn(LOG, '数字预制体缺失', num); this.ui_node.active = false; return; } node.parent = this.ui_node; node.layer = this.ui_node.layer; node.setPosition(0, 0, 0); node.setScale(1, 1, 1); node.active = true; this._numNode = node; this.ui_node.active = true; this._playElementSkin(node, skinName, w1.id); } /** Hero.herotype → 数字 Spine 皮肤名 */ private _elementSkinName(herotype: number): string { switch (herotype) { case 1: return '金'; case 2: return '木'; case 3: return '水'; case 4: return '火'; case 5: return '土'; default: return ''; } } /** * 数字节点切五行皮并播待机。 * @param node 数字预制体 * @param skinName 金木水火土 * @param heroId 日志用 */ private _playElementSkin(node: Node, skinName: string, heroId: number): void { const sk = node.getComponent(sp.Skeleton); if (!sk?.isValid) { console.warn(LOG, '数字节点缺少 Skeleton', node.name, heroId); return; } const sd = sk.skeletonData; if (!sd?.isValid) { sk.enabled = false; console.warn(LOG, '数字节点 skeletonData 无效', node.name, heroId); return; } sk.enabled = true; sk.setAnimationCacheMode(sp.SpineAnimationCacheMode.REALTIME); ensureSpineSkeletonDataRuntime(sd); if (!glyphSpineHasSkin(sk, skinName)) { console.warn(LOG, '数字皮不存在', skinName, node.name, heroId); return; } sk.setSkin(skinName); sk.setSlotsToSetupPose(); const runtime = sd.getRuntimeData?.(true); if (runtime?.findAnimation?.(NUM_IDLE)) { sk.setAnimation(0, NUM_IDLE, true); } } /** 归还数字预制体 */ private _clearNumNode(): void { const held = this._numNode; this._numNode = null; if (!held?.isValid) { if (this.ui_node?.isValid) this.ui_node.removeAllChildren(); return; } Tween.stopAllByTarget(held); if (held['resKey']) gg.res.putNode(held); else held.destroy(); if (this.ui_node?.isValid) this.ui_node.removeAllChildren(); } /** 名称 / 满级 / 普攻技能文案 */ private _refreshStaticLabels(w1: ITableHero, level: number, levelLimit: number): void { if (this.lb_name) { this.lb_name.string = gg.lang.get(w1.name); } if (this.lb_lvMax) { this.lb_lvMax.string = `满级${levelLimit}级`; } this._refreshSkillLabels(w1, level); } /** * lb_skillName / lb_skillDesc:读 Hero.atk_id 对应 Skill。 * Skill.name 配表常为空时,名称回退为 desc,避免「技能:」右侧空白。 * @param w1 Hero * @param level 合成等级 */ private _refreshSkillLabels(w1: ITableHero, level: number): void { const skillId = heroNormalSkillId(w1, level); const skill = skillId > 0 ? gg.data.table.getTableData(TableNames.Skill, skillId) : null; if (skillId > 0 && !skill) { console.warn(LOG, 'Skill 缺失', skillId, w1.id, level); } const nameKey = skill ? String(skill.name ?? '').trim() : ''; const descKey = skill ? String(skill.desc ?? '').trim() : ''; if (skill && !nameKey) { console.warn(LOG, 'Skill.name 为空,名称回退 desc', skillId, w1.id); } if (this.lb_skillName) { const nameText = nameKey ? gg.lang.get(nameKey) : (descKey ? gg.lang.get(descKey) : ''); this.lb_skillName.string = nameText; } if (this.lb_skillDesc) { this.lb_skillDesc.string = descKey ? gg.lang.get(descKey) : ''; } } /** 等级 / 攻击 / 攻速 */ private _refreshDynamicStats( w1: ITableHero, level: number, stats: TowerRuntimeSnapshot['stats'] | undefined, ): void { const setup = deriveTowerSetupStats(w1, level, 0); const attack = stats?.attack ?? setup.attack; const attackSpeed = stats?.attackSpeed ?? setup.attackSpeed; if (this.lb_level) { this.lb_level.string = `Lv.${level}`; } if (this.lb_attck) { this.lb_attck.string = TowerStatDisplay.oneDecimal(attack); } if (this.lb_attckSpeed) { this.lb_attckSpeed.string = TowerStatDisplay.attackSpeed(attackSpeed); } } /** 缓存 gtemplate */ private _cacheGemTemplate(): void { if (this._gemTpl?.isValid) return; const list = this.ui_gemList; if (!list?.isValid) return; const tpl = list.getChildByName('gtemplate'); if (!tpl?.isValid) { console.warn(LOG, 'ui_gemList/gtemplate 缺失'); return; } this._gemTpl = tpl; tpl.active = false; } /** * 固定刷新 4 个镶嵌槽:有宝石画完整行,空槽只显示 lb。 * @param element Hero.herotype 1~5 * @param enemyCamp 敌方不读本机镶嵌(四槽皆空) */ private _refreshGemList(element: number, enemyCamp: boolean): void { this._cacheGemTemplate(); this._clearGemRows(); const list = this.ui_gemList; const tpl = this._gemTpl; if (!list?.isValid || !tpl?.isValid) return; const slots = [0, 0, 0, 0]; if (!enemyCamp && element >= 1 && element <= 5) { try { const raw = gg.gem.getElementSlots(element); for (let i = 0; i < 4; i++) { slots[i] = Math.floor(Number(raw[i]) || 0); } } catch (err) { console.warn(LOG, 'getElementSlots 失败', element, err); } } for (let i = 0; i < 4; i++) { const row = instantiate(tpl); row.parent = list; row.active = true; const gemId = slots[i]; if (gemId > 0) { const conf = gg.gem.getGem(gemId); if (!conf) { console.warn(LOG, '镶嵌宝石配表缺失', gemId, 'element', element, 'slot', i); this._paintEmptyGemSlot(row); } else { this._paintGemRow(row, conf); } } else { this._paintEmptyGemSlot(row); } this._gemRows.push(row); } this._updateGemLayout(list); } /** 填充一颗已镶嵌宝石行 */ private _paintGemRow(row: Node, conf: ITableWeapon): void { const lbEmpty = row.getChildByName('lb'); if (lbEmpty?.isValid) lbEmpty.active = false; const quality = Math.floor(Number(conf.quality) || 0); const lblv = row.getChildByName('lblv')?.getComponent(Label); if (lblv) { lblv.node.active = true; lblv.string = `Lv.${quality}`; } const icon = row.getChildByName('icon'); if (icon?.isValid) { icon.active = true; const iconName = String(conf.icon || '').trim(); if (!iconName) console.warn(LOG, '宝石 icon 缺失', conf.id); this._setItemSprite(icon, iconName ? `weapon/${iconName}` : ''); } const lbName = row.getChildByName('lbName')?.getComponent(Label); if (lbName) { lbName.node.active = true; lbName.string = this._gemBaseName(conf); } const lbDesc = row.getChildByName('lbDesc')?.getComponent(Label); if (lbDesc) { lbDesc.node.active = true; lbDesc.string = gg.lang.get(conf.desc); } } /** * 宝石展示名:去掉配表名尾部「N级」,等级改由 lblv 的 Lv.N 展示。 * @param conf Weapon 行 */ private _gemBaseName(conf: ITableWeapon): string { const raw = gg.lang.get(conf.name); const stripped = String(raw).replace(/\d+级\s*$/, '').trim(); return stripped || raw; } /** * 空槽:只显示 lb,文案固定「尚未镶嵌宝石」。 * @param row 槽位行 */ private _paintEmptyGemSlot(row: Node): void { const lbEmpty = row.getChildByName('lb')?.getComponent(Label); if (lbEmpty) { lbEmpty.node.active = true; lbEmpty.string = GEM_EMPTY_TIP; } const lblv = row.getChildByName('lblv'); if (lblv?.isValid) lblv.active = false; const icon = row.getChildByName('icon'); if (icon?.isValid) { icon.active = false; this._setItemSprite(icon, ''); } const lbName = row.getChildByName('lbName'); if (lbName?.isValid) lbName.active = false; const lbDesc = row.getChildByName('lbDesc'); if (lbDesc?.isValid) lbDesc.active = false; } /** Items bundle 图集 */ private _setItemSprite(node: Node | null, path: string): void { if (!node?.isValid) return; if (!path) { node.getComponent(SpriteLoad)?.clear(); const spComp = node.getComponent(Sprite); if (spComp) spComp.spriteFrame = null; return; } if (!node.getComponent(Sprite)) node.addComponent(Sprite); node.getOrAddComponent(SpriteLoad).setSprite(path, GBundle.Items); } /** 清理宝石克隆行(保留模板) */ private _clearGemRows(): void { for (let i = 0; i < this._gemRows.length; i++) { const n = this._gemRows[i]; if (n?.isValid) n.destroy(); } this._gemRows.length = 0; if (this._gemTpl?.isValid) this._gemTpl.active = false; } /** 触发布局 */ private _updateGemLayout(list: Node): void { const lay = list.getComponent(Layout); if (lay) lay.updateLayout(); } private _clearLabels(): void { if (this.lb_name) this.lb_name.string = ''; if (this.lb_level) this.lb_level.string = ''; if (this.lb_lvMax) this.lb_lvMax.string = ''; if (this.lb_attck) this.lb_attck.string = ''; if (this.lb_attckSpeed) this.lb_attckSpeed.string = ''; if (this.lb_skillName) this.lb_skillName.string = ''; if (this.lb_skillDesc) this.lb_skillDesc.string = ''; } private _applyPanelPosition(data: BattleWeaponDescHeroPanelData | null): void { const weaponWorld = data?.weaponWorldPos; if (!weaponWorld || !this._popupBody?.isValid) return; const rootUt = this.node.getComponent(UITransform); const bodyUt = this._popupBody.getComponent(UITransform); if (!rootUt || !bodyUt) return; const step = data?.gridStep ?? 93; const halfW = bodyUt.contentSize.width * 0.5; const halfH = bodyUt.contentSize.height * 0.5; const gap = UIBattleWeaponDescHero.PANEL_GAP_GRIDS; const weaponLocal = rootUt.convertToNodeSpaceAR( new Vec3(weaponWorld.x, weaponWorld.y, weaponWorld.z ?? 0), ); const x = 0; const y = weaponLocal.y + step * (0.5 + gap) + halfH; const rootHalfW = rootUt.contentSize.width * 0.5; const rootHalfH = rootUt.contentSize.height * 0.5; const clampedX = Math.max(-rootHalfW + halfW, Math.min(rootHalfW - halfW, x)); const clampedY = Math.max(-rootHalfH + halfH, Math.min(rootHalfH - halfH, y)); this._popupBody.setPosition(clampedX, clampedY, 0); } private _ensurePanelVisible(): void { if (!this.node?.isValid) return; this.node.active = true; Tween.stopAllByTarget(this.node); const body = this._popupBody; if (body?.isValid) { body.active = true; const renderers = body.getComponentsInChildren(UIRenderer); for (let i = 0; i < renderers.length; i++) { const r = renderers[i]; if (r?.isValid) r.enabled = true; } } this._keepMaskTouchOnly(); } private _keepMaskTouchOnly(): void { if (!this.ui_bgMask?.isValid) return; Tween.stopAllByTarget(this.ui_bgMask); const maskSpr = this.ui_bgMask.getComponent(Sprite); if (maskSpr) maskSpr.enabled = false; const op = this.ui_bgMask.getComponent(UIOpacity); if (op) op.opacity = 0; } private _resolvePopSession(): IPopAnimSession { const cfg = this.uiInfo?.uiconf?.popAnim as IPopAnimConfig | undefined; return { mask: cfg?.mask ?? 'none', center: cfg?.center ?? 'none', }; } }