本次变更完成了英雄bonus属性加成系统的图鉴可视化改造: 1. 扩展LvBonusAttrsEntry类型,新增name/icon/info配置字段 2. 服务端渲染兼容新增字段,仅在配置存在时输出 3. 更新英雄配置文档,补充bonus字段的新参数说明 4. 新增属性加成编辑器表单,支持配置名称、图标和自定义描述 5. 实现英雄图鉴的bonus档位图标渲染逻辑,支持锁定状态和点击弹窗 6. 新增bonus效果文案生成工具,支持自定义模板和属性自动生成 7. 为技能弹窗新增bonus专属渲染逻辑,适配属性加成档位的展示 8. 为部分测试英雄配置了bonus档位的名称属性
697 lines
28 KiB
TypeScript
697 lines
28 KiB
TypeScript
/**
|
||
* @file HerosListComp.ts
|
||
* @description 英雄图鉴弹出页面(UI 视图层)
|
||
*
|
||
* 职责:
|
||
* 1. 以卡片列表形式展示所有可用英雄(使用 CardLiteComp 预制体)。
|
||
* 2. 点击卡片选中英雄,右侧详情面板显示 idle 动画、名称、AP、HP、CD、技能描述。
|
||
* 3. 支持卡池等级筛选(全部 / Lv1 / Lv2 / Lv3)。
|
||
*
|
||
* 关键设计:
|
||
* - cards_node 为卡片容器,通过 instantiate(card_lite_prefab) 动态生成卡片。
|
||
* - 选中状态通过 selectNode 高亮管理,同一时间只有一张卡片高亮。
|
||
* - hero_icon 使用 Animation + iconVisualToken 机制防止异步加载竞态。
|
||
*
|
||
* 依赖:
|
||
* - HeroInfo / HeroList(heroSet)—— 英雄静态配置与全量 UUID 列表
|
||
* - CardLiteComp —— 轻量卡片组件
|
||
* - buildHeroSkillDesc(HeroSkillText)—— 技能描述生成器(局外等级口径)
|
||
*/
|
||
import { _decorator, Animation, AnimationClip, Button, Label, Node, Prefab, Sprite, UITransform, Vec3, Widget, instantiate, resources, tween, Tween } from "cc";
|
||
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
|
||
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
|
||
import { oops } from "db://oops-framework/core/Oops";
|
||
import { mLogger } from "../common/Logger";
|
||
import { HeroInfo, HeroList, SkillTriggerType, LvFieldEntry, LvTriggerEntry, TriggerGrouped, HSkillInfo, getHeroDisplayStats, LvBonusAttrsEntry } from "../common/config/heroSet";
|
||
import { buildHeroSkillDesc } from "../common/HeroSkillText";
|
||
import { GameEvent } from "../common/config/GameEvent";
|
||
import { HERO_UPGRADE_NEED_FRAGMENTS, RARITY_BG_COLORS } from "../common/config/GameSet";
|
||
import { CardLiteComp } from "./CardLiteComp";
|
||
import { ChipComp } from "./ChipComp";
|
||
import { SmallTipComp } from "./SmallTipComp";
|
||
import { SSkillBoxComp } from "./SSkillBoxComp";
|
||
import { smc } from "../common/SingletonModuleComp";
|
||
|
||
const { property, ccclass } = _decorator;
|
||
|
||
@ccclass('HerosListComp')
|
||
@ecs.register('HerosListComp', false)
|
||
export class HerosListComp extends CCComp {
|
||
// ======================== 编辑器绑定节点 ========================
|
||
|
||
@property(Node)
|
||
hero_icon = null!
|
||
|
||
@property(Node)
|
||
ap_node = null!
|
||
|
||
@property(Node)
|
||
def_node = null!
|
||
|
||
@property(Node)
|
||
hp_node = null!
|
||
|
||
@property(Node)
|
||
cd_node = null!
|
||
|
||
/** 卡牌背景底框节点(按卡池等级切换子节点显示) */
|
||
@property(Node)
|
||
BG_node = null!
|
||
|
||
@property(Node)
|
||
info_node = null!
|
||
|
||
@property(Node)
|
||
name_node = null!
|
||
|
||
@property(Node)
|
||
cards_node = null!
|
||
|
||
@property(Prefab)
|
||
card_lite_prefab = null!
|
||
|
||
/** 卡池等级 Label,直接显示 X */
|
||
@property(Label)
|
||
lv_label = null!
|
||
|
||
/** 职业展示节点(子节点按 HRole 枚举值命名 0~5:0=坦克 1=战士 2=刺客 3=射手 4=法师 5=辅助) */
|
||
@property(Node)
|
||
role_node = null!
|
||
|
||
/** 升级按钮节点(smalltip 气泡已作为其子节点挂在下面) */
|
||
@property(Node)
|
||
upgrade_btn = null!
|
||
|
||
/** 碎片数量显示节点(Label) */
|
||
@property(Node)
|
||
fragment_node = null!
|
||
|
||
/** 碎片图标节点(挂载 ChipComp 用于渲染碎片图标) */
|
||
@property(Node)
|
||
chip_node = null!
|
||
|
||
/** 技能描述节点() */
|
||
@property(Node)
|
||
infos_node = null!
|
||
|
||
/** 技能图标容器节点(动态挂载 SSkillBoxComp 实例,展示当前英雄技能) */
|
||
@property(Node)
|
||
skills_node = null!
|
||
|
||
/** 技能小图标预制体(根节点挂 SSkillBoxComp) */
|
||
@property(Prefab)
|
||
skill_icon_prefab = null!
|
||
/** 升级特效动画节点(内含升级效果 Animation,点击升级时从头播放) */
|
||
@property(Node)
|
||
upgrade_anim_node = null!
|
||
|
||
// ======================== 运行时状态 ========================
|
||
|
||
huuid: number = 0
|
||
private iconVisualToken: number = 0
|
||
private selectNode: Node | null = null
|
||
debugMode: boolean = false
|
||
|
||
start() {
|
||
this.bindUpgradeBtn()
|
||
this.initCardList()
|
||
// 升级特效动画初始隐藏,点击升级时才显示播放
|
||
if (this.upgrade_anim_node) this.upgrade_anim_node.active = false
|
||
if (HeroList.length > 0) {
|
||
this.onCardSelect(HeroList[0])
|
||
}
|
||
}
|
||
|
||
protected onLoad(): void {
|
||
// onLoad 阶段属性可能尚未绑定,延迟到 start 再处理
|
||
}
|
||
|
||
/**
|
||
* 绑定升级按钮点击事件
|
||
* 在 start 中调用,确保 @property 属性已正确赋值
|
||
*/
|
||
private bindUpgradeBtn() {
|
||
mLogger.log(this.debugMode, "HerosListComp", "bindUpgradeBtn", {
|
||
hasUpgradeBtn: !!this.upgrade_btn,
|
||
upgradeBtnName: this.upgrade_btn?.name,
|
||
upgradeBtnActive: this.upgrade_btn?.active,
|
||
upgradeBtnValid: this.upgrade_btn?.isValid,
|
||
})
|
||
|
||
if (!this.upgrade_btn) {
|
||
mLogger.error(this.debugMode, "HerosListComp", "upgrade_btn 未绑定,请检查编辑器属性面板")
|
||
return
|
||
}
|
||
|
||
// 确保 smalltip 气泡初始隐藏,避免遮挡按钮触摸(组件缺失时兜底补挂)
|
||
const tipNode = this.upgrade_btn.getChildByName("smalltip")
|
||
if (tipNode) {
|
||
tipNode.getComponent(SmallTipComp) ?? tipNode.addComponent(SmallTipComp)
|
||
tipNode.active = false
|
||
}
|
||
|
||
let btn = this.upgrade_btn.getComponent(Button)
|
||
if (!btn) btn = this.upgrade_btn.addComponent(Button)
|
||
btn.interactable = true
|
||
this.upgrade_btn.on(Button.EventType.CLICK, this.onUpgradeClick, this)
|
||
mLogger.log(this.debugMode, "HerosListComp", "升级按钮点击事件已绑定")
|
||
}
|
||
|
||
protected onEnable(): void {
|
||
oops.message.on(GameEvent.HERO_GROWTH_UPDATE, this.onGrowthUpdate, this)
|
||
if (this.cards_node && this.cards_node.children.length > 0) {
|
||
this.onCardSelect(this.huuid || HeroList[0])
|
||
}
|
||
}
|
||
|
||
protected onDisable(): void {
|
||
oops.message.off(GameEvent.HERO_GROWTH_UPDATE, this.onGrowthUpdate, this)
|
||
}
|
||
|
||
private onGrowthUpdate() {
|
||
if (this.huuid) {
|
||
this.updateHeroDetail(this.huuid)
|
||
}
|
||
this.refreshAllCardsCanUp()
|
||
}
|
||
|
||
/** 升级/碎片变动后统一刷新所有卡片的可升级标识与等级数字(升级入口仅在本面板,无需卡片各自监听事件) */
|
||
private refreshAllCardsCanUp() {
|
||
if (!this.cards_node) return
|
||
this.cards_node.children.forEach(cardNode => {
|
||
const comp = cardNode.getComponent(CardLiteComp)
|
||
comp?.refreshCanUp()
|
||
comp?.refreshLv()
|
||
})
|
||
}
|
||
|
||
closeHeros() {
|
||
this.node.active = false
|
||
}
|
||
|
||
/**
|
||
* 升级按钮点击回调
|
||
* 消耗碎片提升英雄局外等级,成功后刷新详情面板
|
||
*/
|
||
onUpgradeClick() {
|
||
mLogger.log(this.debugMode, "HerosListComp", "onUpgradeClick 被触发", { huuid: this.huuid })
|
||
|
||
const uuid = this.huuid
|
||
if (!uuid) return
|
||
|
||
// 点击动画:按压回弹
|
||
this.playUpgradeBtnAnim()
|
||
|
||
const success = smc.upgradeHero(uuid)
|
||
if (success) {
|
||
mLogger.log(this.debugMode, "HerosListComp", `英雄 ${uuid} 升级成功`)
|
||
this.playUpgradeAnim()
|
||
this.updateHeroDetail(uuid)
|
||
} else {
|
||
const growth = smc.getHeroGrowth(uuid)
|
||
const need = HERO_UPGRADE_NEED_FRAGMENTS[growth.lv + 1]
|
||
mLogger.log(this.debugMode, "HerosListComp", `英雄 ${uuid} 升级失败`, {
|
||
currentLv: growth.lv,
|
||
fragments: growth.fragments,
|
||
needFragments: need ?? "MAX",
|
||
})
|
||
// 碎片不足 / 已满级:气泡提示
|
||
const tipText = need === undefined ? "已满级" : "碎片不足"
|
||
this.showSmallTip(tipText)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 播放升级按钮按压回弹动画
|
||
*/
|
||
private playUpgradeBtnAnim() {
|
||
if (!this.upgrade_btn) {
|
||
mLogger.log(this.debugMode, "HerosListComp", "playUpgradeBtnAnim: upgrade_btn 为空")
|
||
return
|
||
}
|
||
mLogger.log(this.debugMode, "HerosListComp", "播放按钮动画")
|
||
Tween.stopAllByTarget(this.upgrade_btn)
|
||
this.upgrade_btn.setScale(1, 1, 1)
|
||
tween(this.upgrade_btn)
|
||
.to(0.08, { scale: new Vec3(0.9, 0.9, 1) }, { easing: 'quadOut' })
|
||
.to(0.12, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' })
|
||
.to(0.08, { scale: new Vec3(1, 1, 1) }, { easing: 'quadIn' })
|
||
.start()
|
||
}
|
||
|
||
/**
|
||
* 播放升级特效动画
|
||
* 每次升级成功均从头播放;动画未播完时再次升级,立即重新开始
|
||
*/
|
||
private playUpgradeAnim() {
|
||
if (!this.upgrade_anim_node) {
|
||
mLogger.log(this.debugMode, "HerosListComp", "playUpgradeAnim: upgrade_anim_node 未绑定")
|
||
return
|
||
}
|
||
const anim = this.upgrade_anim_node.getComponent(Animation)
|
||
const clip = anim?.clips?.[0]
|
||
if (!anim || !clip) {
|
||
mLogger.log(this.debugMode, "HerosListComp", "playUpgradeAnim: 缺少 Animation 组件或动画剪辑")
|
||
return
|
||
}
|
||
|
||
this.upgrade_anim_node.active = true
|
||
// 停止当前播放(如有),保证从头开始
|
||
anim.stop()
|
||
|
||
const animState = anim.getState(clip.name)
|
||
if (animState) {
|
||
// 防止重复注册,播放结束后自动隐藏特效节点
|
||
animState.off('finished', this.onUpgradeAnimFinished, this)
|
||
animState.once('finished', this.onUpgradeAnimFinished, this)
|
||
}
|
||
anim.play(clip.name)
|
||
}
|
||
|
||
/**
|
||
* 升级特效动画播放结束回调:隐藏特效节点
|
||
*/
|
||
private onUpgradeAnimFinished() {
|
||
if (this.upgrade_anim_node && this.upgrade_anim_node.isValid) {
|
||
this.upgrade_anim_node.active = false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在升级按钮附近弹出 smalltip 气泡提示
|
||
* @param text 提示文案
|
||
*/
|
||
private showSmallTip(text: string) {
|
||
// smalltip 作为 upgrade_btn 的子节点挂在下面,统一走 SmallTipComp 播动画
|
||
const tipNode = this.upgrade_btn?.getChildByName("smalltip")
|
||
if (!tipNode || !tipNode.isValid) {
|
||
mLogger.log(this.debugMode, "HerosListComp", `未在 upgrade_btn 下找到 smalltip 节点,无法提示:${text}`)
|
||
return
|
||
}
|
||
const tip = tipNode.getComponent(SmallTipComp) ?? tipNode.addComponent(SmallTipComp)
|
||
tip.show(text)
|
||
}
|
||
|
||
// ======================== 卡片列表 ========================
|
||
|
||
private initCardList() {
|
||
mLogger.log(this.debugMode, "HerosListComp", "initCardList start", {
|
||
hasCardsNode: !!this.cards_node,
|
||
hasPrefab: !!this.card_lite_prefab,
|
||
heroListLen: HeroList.length,
|
||
})
|
||
|
||
if (!this.cards_node || !this.card_lite_prefab) return
|
||
|
||
mLogger.log(this.debugMode, "HerosListComp", "cards_node info", {
|
||
name: this.cards_node.name,
|
||
active: this.cards_node.active,
|
||
childrenCount: this.cards_node.children.length,
|
||
pos: this.cards_node.position.toString(),
|
||
scale: this.cards_node.scale.toString(),
|
||
parentName: this.cards_node.parent?.name || "none",
|
||
})
|
||
|
||
this.cards_node.removeAllChildren()
|
||
|
||
const sorted = [...HeroList].sort((a, b) => {
|
||
return (HeroInfo[a]?.rarity ?? 1) - (HeroInfo[b]?.rarity ?? 1);
|
||
});
|
||
|
||
for (const uuid of sorted) {
|
||
const hero = HeroInfo[uuid]
|
||
if (!hero) continue
|
||
|
||
const cardNode = instantiate(this.card_lite_prefab)
|
||
cardNode.name = `card_${uuid}`
|
||
|
||
mLogger.log(this.debugMode, "HerosListComp", `card instantiated ${uuid}`, {
|
||
cardActive: cardNode.active,
|
||
cardPos: cardNode.position.toString(),
|
||
cardScale: cardNode.scale.toString(),
|
||
cardChildren: cardNode.children.map(c => c.name),
|
||
cardWidth: cardNode.getComponent("cc.UITransform")?.width,
|
||
cardHeight: cardNode.getComponent("cc.UITransform")?.height,
|
||
})
|
||
|
||
const comp = cardNode.getComponent(CardLiteComp) || cardNode.addComponent(CardLiteComp)
|
||
comp.setHeroByUuid(uuid)
|
||
comp.onClickCallback = (cardComp: CardLiteComp) => {
|
||
this.onCardSelect(uuid)
|
||
this.highlightCard(cardNode)
|
||
}
|
||
|
||
this.cards_node.addChild(cardNode)
|
||
}
|
||
|
||
mLogger.log(this.debugMode, "HerosListComp", "initCardList done", {
|
||
totalChildren: this.cards_node.children.length,
|
||
})
|
||
|
||
this.updateContentSize()
|
||
}
|
||
|
||
private updateContentSize() {
|
||
const total = this.cards_node.children.length
|
||
if (total === 0) return
|
||
|
||
const cols = 4
|
||
const rows = Math.ceil(total / cols)
|
||
const cardH = 200
|
||
const spacingY = 10
|
||
const contentH = Math.max(1000, rows * (cardH + spacingY) + 50)
|
||
|
||
const uiTrans = this.cards_node.getComponent(UITransform)
|
||
if (uiTrans) {
|
||
uiTrans.setContentSize(uiTrans.width, contentH)
|
||
}
|
||
|
||
mLogger.log(this.debugMode, "HerosListComp", "updateContentSize", {
|
||
total, cols, rows, contentH,
|
||
})
|
||
}
|
||
|
||
private highlightCard(cardNode: Node) {
|
||
// 取消旧选中:关闭其 active 标识节点
|
||
if (this.selectNode) {
|
||
this.selectNode.getComponent(CardLiteComp)?.setSelected(false)
|
||
}
|
||
this.selectNode = cardNode
|
||
// 新选中:激活根节点下的 active 标识节点(替代原缩放高亮)
|
||
cardNode.getComponent(CardLiteComp)?.setSelected(true)
|
||
}
|
||
|
||
private onCardSelect(uuid: number) {
|
||
this.huuid = uuid
|
||
this.updateHeroDetail(uuid)
|
||
}
|
||
|
||
// ======================== 详情面板 ========================
|
||
|
||
private updateHeroDetail(uuid: number) {
|
||
const hero = HeroInfo[uuid]
|
||
if (!hero) return
|
||
|
||
// 属性按当前局外等级计算(与技能面板 updateSkillIcons 同源,均为 getHeroOuterLv)
|
||
const heroLv = Math.max(1, smc.getHeroOuterLv(uuid))
|
||
this.setLabelText(this.name_node, hero.name || "")
|
||
// 属性面板与运行时口径一致:基础值 × 品质系数 + 职业成长 + bonus + lv12 专项
|
||
const stats = getHeroDisplayStats(hero, heroLv)
|
||
this.setLabelText(this.ap_node, `${stats.ap}`)
|
||
this.setLabelText(this.def_node, `${stats.def}`)
|
||
this.setLabelText(this.hp_node, `${stats.hp}`)
|
||
|
||
this.updateCdDisplay(hero)
|
||
|
||
if (this.info_node) {
|
||
// Label 承载:HeroSkillText 输出富文本,剥离 BBCode 标签转纯文本
|
||
const desc = buildHeroSkillDesc(hero.uuid, true, true).replace(/<[^>]+>/g, "")
|
||
const infoLabel = this.info_node.getChildByName("info")?.getComponent(Label)
|
||
|| this.info_node.getComponent(Label)
|
||
|| this.info_node.getComponentInChildren(Label)
|
||
if (infoLabel) infoLabel.string = desc || hero.info || ""
|
||
}
|
||
|
||
this.updatePoolLvBg(hero.rarity ?? 1)
|
||
this.updateLvDisplay(uuid)
|
||
this.updateRoleDisplay(hero)
|
||
this.updateHeroAnimation(uuid)
|
||
this.updateGrowthPanel(uuid)
|
||
this.updateSkillIcons(hero)
|
||
}
|
||
|
||
private updatePoolLvBg(rarity: number) {
|
||
if (!this.BG_node) return
|
||
// BG_node 下直接放置颜色节点(green/blue/purple/yellow/red),按卡池等级激活对应颜色
|
||
const color = RARITY_BG_COLORS[Math.min(rarity, 5) - 1]
|
||
this.BG_node.children.forEach(child => {
|
||
child.active = (child.name === color)
|
||
})
|
||
}
|
||
|
||
private updateCdDisplay(hero: typeof HeroInfo[number]) {
|
||
if (!this.cd_node) return
|
||
const skillKeys = Object.keys(hero.skills)
|
||
if (skillKeys.length === 0) return
|
||
const firstSkill = hero.skills[Number(skillKeys[0])]
|
||
if (firstSkill) {
|
||
this.setLabelText(this.cd_node, `${firstSkill.cd}s`)
|
||
}
|
||
}
|
||
|
||
private updateLvDisplay(uuid: number) {
|
||
if (!this.lv_label) return
|
||
const outerLv = smc.getHeroOuterLv(uuid)
|
||
this.lv_label.string = `${outerLv}`
|
||
}
|
||
|
||
private updateRoleDisplay(hero: typeof HeroInfo[number]) {
|
||
if (!this.role_node) return
|
||
this.role_node.active = true
|
||
const roleStr = `${hero.role ?? 0}`
|
||
this.role_node.children.forEach(child => {
|
||
child.active = (child.name === roleStr)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 更新局外成长面板:碎片数量与升级按钮状态
|
||
* @param uuid 英雄模板 uuid
|
||
*/
|
||
private updateGrowthPanel(uuid: number) {
|
||
const growth = smc.getHeroGrowth(uuid)
|
||
const needFragments = HERO_UPGRADE_NEED_FRAGMENTS[growth.lv + 1]
|
||
const isMaxLv = needFragments === undefined
|
||
|
||
// 碎片数量显示:当前拥有 / 下一级所需(满级时显示 MAX)
|
||
if (this.fragment_node) {
|
||
const fragmentText = isMaxLv
|
||
? `${growth.fragments} / MAX`
|
||
: `${growth.fragments} / ${needFragments}`
|
||
this.setLabelText(this.fragment_node, fragmentText)
|
||
}
|
||
|
||
// 碎片图标:使用 ChipComp 渲染英雄图标(自动读取 HeroInfo 配置)
|
||
if (this.chip_node) {
|
||
const chipComp = this.chip_node.getComponent(ChipComp) || this.chip_node.addComponent(ChipComp)
|
||
chipComp.setHeroByUuid(uuid)
|
||
}
|
||
|
||
// 升级按钮状态:始终可点击,点击后由 onUpgradeClick 判断碎片是否足够
|
||
// 碎片不足时按钮保持可点,通过气泡提示用户(而非禁用按钮)
|
||
if (this.upgrade_btn) {
|
||
this.upgrade_btn.active = true
|
||
const btnComp = this.upgrade_btn.getComponent("cc.Button") as import("cc").Button | null
|
||
if (btnComp) btnComp.interactable = true
|
||
}
|
||
}
|
||
|
||
// ======================== 技能图标列表 ========================
|
||
|
||
/** 图鉴面板展示的触发技能槽位(按触发时机排序:召唤→战斗开始→攻击后→受击后→死亡→常驻光环) */
|
||
private static readonly PANEL_TRIGGER_TYPES: SkillTriggerType[] = [
|
||
SkillTriggerType.Call,
|
||
SkillTriggerType.FStart,
|
||
SkillTriggerType.Atking,
|
||
SkillTriggerType.Atked,
|
||
SkillTriggerType.Dead,
|
||
SkillTriggerType.Field,
|
||
]
|
||
|
||
/**
|
||
* 刷新当前英雄的技能图标列表:
|
||
* 1. 大招:skills[1] 及以后(首个为普攻,跳过)。
|
||
* 2. 触发技能:每个触发槽位(Call/FStart/Atking/Atked/Dead/Field)整组视为一个技能,
|
||
* 只出一个图标,组内多技能/词条合并展示(复活已纳入 dead)。
|
||
* 以局外等级(getHeroOuterLv)为当前等级,未达解锁档的显示 locked,
|
||
* 已激活的显示当前等级对应的技能等级。
|
||
*/
|
||
private updateSkillIcons(hero: typeof HeroInfo[number]) {
|
||
if (!this.skills_node || !this.skill_icon_prefab) return
|
||
|
||
// 清空旧图标(显式销毁,避免节点泄漏)
|
||
for (let i = this.skills_node.children.length - 1; i >= 0; i--) {
|
||
this.skills_node.children[i].destroy()
|
||
}
|
||
|
||
// huuid 已在 onCardSelect 赋值,addSkillIcon 用它携带英雄语境
|
||
const lv = Math.max(1, smc.getHeroOuterLv(hero.uuid))
|
||
|
||
// ---- 大招:skills[1] 及以后(英雄出场即激活,与能量条同语义,恒不锁定) ----
|
||
Object.values(hero.skills).slice(1).forEach(hSkill => {
|
||
// 技能等级展示与 HeroSkillText 取档同规则:lv_entries 取 ≤lv 最大档的 s_lv,无生效档回退配置 lv
|
||
const sLv = hSkill.lv_entries?.length
|
||
? (hSkill.lv_entries.filter(e => e.lv <= lv).pop()?.s_lv ?? hSkill.lv)
|
||
: hSkill.lv
|
||
this.addSkillIcon(hSkill.uuid, sLv, false, false)
|
||
})
|
||
|
||
// ---- 普攻 lv2 强化档:作为独立技能图标(名称/图标取 lv1,弹窗只展示 lv2 说明) ----
|
||
this.addNormalLv2Icon(Object.values(hero.skills)[0], lv)
|
||
|
||
// ---- 触发技能:每个触发槽位整组一个图标,按 PANEL_TRIGGER_TYPES 顺序追加 ----
|
||
for (const type of HerosListComp.PANEL_TRIGGER_TYPES) {
|
||
if (type === SkillTriggerType.Field) {
|
||
this.addFieldGroupIcon(hero[SkillTriggerType.Field], lv)
|
||
} else {
|
||
this.addTriggerGroupIcon(hero[type] as TriggerGrouped | undefined, lv, type)
|
||
}
|
||
}
|
||
|
||
// ---- bonus 档位:配置了 icon 的档位在技能列表最末各出一个图标 ----
|
||
this.addBonusIcons(hero.bonus, lv)
|
||
}
|
||
|
||
/**
|
||
* bonus 档位图标:配置了 icon 的档位各出一个图标(追加在技能列表最末)。
|
||
* 激活判定:英雄局外等级 ≥ 档位 lv;未激活显示锁定遮罩。
|
||
* 点击弹窗走 SinfoComp bonus 专属渲染(名称/图标取档位配置,主说明为 attrs 效果)。
|
||
*/
|
||
private addBonusIcons(bonus: LvBonusAttrsEntry[] | undefined, lv: number) {
|
||
if (!bonus?.length) return
|
||
for (const entry of bonus) {
|
||
if (!entry.icon) continue
|
||
const node = instantiate(this.skill_icon_prefab)
|
||
node.name = `bonus_lv${entry.lv}`
|
||
const comp = node.getComponent(SSkillBoxComp) || node.addComponent(SSkillBoxComp)
|
||
comp.initBonus(this.huuid, entry, lv < entry.lv)
|
||
this.skills_node.addChild(node)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 实例化技能小图标并初始化
|
||
* @param s_uuid 技能 UUID(SkillSet 或 FieldSkillSet;整组模式下取组内首个,仅用于图标)
|
||
* @param s_lv 当前等级对应的技能等级(locked 时为解锁预览档)
|
||
* @param locked 当前等级未激活 → 显示锁定遮罩
|
||
* @param isField true 表示驻场光环词条(图标取 FieldSkillSet)
|
||
* @param slot 技能所属槽位(普攻/大招传 undefined;触发/光环传对应类型,
|
||
* 同一底座不同槽位视为不同技能,弹窗按槽位取档;复活已纳入 dead)
|
||
* @param isGroup 整组模式:该图标代表整个触发槽位,组名取组内首个自定义名,
|
||
* 弹窗按整组合并渲染(组内多技能/词条统一说明)
|
||
* @param normalLv2 普攻 lv2 专属模式:该图标代表普攻 lv2 强化档,名称/图标取 lv1,
|
||
* 弹窗只展示 lv2 档位说明
|
||
*/
|
||
private addSkillIcon(s_uuid: number, s_lv: number, locked: boolean, isField: boolean, slot?: SkillTriggerType, isGroup: boolean = false, normalLv2: boolean = false) {
|
||
const node = instantiate(this.skill_icon_prefab)
|
||
node.name = `skill_${s_uuid}`
|
||
const comp = node.getComponent(SSkillBoxComp) || node.addComponent(SSkillBoxComp)
|
||
// 携带所属英雄与槽位,点击弹窗按英雄技能分档描述渲染
|
||
comp.initHeroSkill(s_uuid, s_lv, locked, isField, this.huuid, slot, isGroup, normalLv2)
|
||
this.skills_node.addChild(node)
|
||
}
|
||
|
||
/**
|
||
* 触发技能槽位(call/fstart/atking/atked/dead)整组一个图标:
|
||
* 组内多 s_uuid 合并展示,图标/等级取组内首个 s_uuid 的生效档(未激活预览最低档);
|
||
* 整组激活判定:组内任一 s_uuid 存在 ≤lv 生效档即视为已激活(与 resolveTriggerByLv 同语义)
|
||
* @param type 触发槽位(随图标透传,弹窗按整组取档)
|
||
*/
|
||
private addTriggerGroupIcon(grouped: TriggerGrouped | undefined, lv: number, type: SkillTriggerType) {
|
||
if (!grouped) return
|
||
const keys = Object.keys(grouped).map(Number).filter(k => grouped[k]?.length)
|
||
if (keys.length === 0) return
|
||
// 图标/等级取组内首个 s_uuid 的生效档
|
||
const sorted = [...grouped[keys[0]]].sort((a, b) => a.lv - b.lv)
|
||
let active: LvTriggerEntry | undefined
|
||
for (const e of sorted) { if (e.lv <= lv) active = e }
|
||
// 整组激活判定:组内任一 s_uuid 存在生效档即激活
|
||
let anyActive = !!active
|
||
for (const key of keys) {
|
||
if (anyActive) break
|
||
for (const e of grouped[key] ?? []) {
|
||
if (e.lv <= lv) { anyActive = true; break }
|
||
}
|
||
}
|
||
const pick = active ?? sorted[0]
|
||
this.addSkillIcon(keys[0], pick.s_lv ?? 1, !anyActive, false, type, true)
|
||
}
|
||
|
||
/**
|
||
* 驻场光环槽位整组一个图标:组内多词条合并展示,
|
||
* 图标取生效档(未激活预览最低档)的首个词条
|
||
*/
|
||
private addFieldGroupIcon(field: LvFieldEntry[] | undefined, lv: number) {
|
||
if (!field?.length) return
|
||
const sorted = [...field].sort((a, b) => a.lv - b.lv)
|
||
let active: LvFieldEntry | undefined
|
||
for (const e of sorted) { if (e.lv <= lv) active = e }
|
||
const pick = active ?? sorted[0]
|
||
const firstUuid = pick.uuids[0]?.uuid
|
||
if (!firstUuid) return
|
||
// 词条档位无 s_lv 概念,技能等级恒为 1(等级标签展示技能等级而非解锁的英雄等级)
|
||
this.addSkillIcon(firstUuid, 1, !active, true, SkillTriggerType.Field, true)
|
||
}
|
||
|
||
/** 复活技能已纳入 dead 触发组,通过 dead 配置展示(revive_hp / revive_count),无需单独处理 */
|
||
|
||
/**
|
||
* 普攻 lv2+ 强化档作为独立技能图标:
|
||
* 普攻(skills[0])lv_entries 存在档时单独出图标,名称/图标取 lv1(s_uuid 相同),
|
||
* 等级显示偏移一档(配置 Lv2 → Lv1,SSkillBoxComp 统一处理),弹窗按 hero_normal_lv2
|
||
* 模式只展示 lv2+ 档位说明;激活判定按档位的英雄等级(lv 阈值)。
|
||
* Why: 普攻 lv2 档是独立成长机制(如附加暴击/冰冻),需让玩家明确感知其存在与解锁条件。
|
||
*/
|
||
private addNormalLv2Icon(normal: HSkillInfo | undefined, lv: number) {
|
||
const entry = normal?.lv_entries?.[0]
|
||
if (!normal || !entry) return
|
||
// 节点命名区分 lv2 档(普攻与触发槽位可能同底座,uuid 重名会互相干扰)
|
||
this.addSkillIcon(normal.uuid, entry.s_lv ?? 2, lv < entry.lv, false, undefined, false, true)
|
||
const icon = this.skills_node.children[this.skills_node.children.length - 1]
|
||
if (icon) icon.name = `skill_${normal.uuid}_lv2`
|
||
}
|
||
|
||
// ======================== 英雄动画 ========================
|
||
|
||
private updateHeroAnimation(uuid: number) {
|
||
if (!this.hero_icon) return
|
||
const hero = HeroInfo[uuid]
|
||
if (!hero) return
|
||
|
||
const sprite = this.hero_icon.getComponent(Sprite) || this.hero_icon.getComponentInChildren(Sprite)
|
||
if (sprite) sprite.spriteFrame = null
|
||
|
||
const anim = this.hero_icon.getComponent(Animation) || this.hero_icon.addComponent(Animation)
|
||
this.clearAnimationClips(anim)
|
||
|
||
this.iconVisualToken += 1
|
||
const token = this.iconVisualToken
|
||
const path = `game/heros/hero/${hero.path}/idle`
|
||
|
||
resources.load(path, AnimationClip, (err, clip) => {
|
||
if (err || !clip) {
|
||
mLogger.log(this.debugMode, "HerosListComp", `load hero animation failed ${uuid}`, err)
|
||
return
|
||
}
|
||
if (token !== this.iconVisualToken) return
|
||
this.clearAnimationClips(anim)
|
||
anim.addClip(clip)
|
||
anim.play("idle")
|
||
})
|
||
}
|
||
|
||
private clearAnimationClips(anim: Animation) {
|
||
const clips = anim.clips
|
||
if (clips && clips.length > 0) {
|
||
for (let i = clips.length - 1; i >= 0; i--) {
|
||
const clip = clips[i]
|
||
if (clip) anim.removeClip(clip, true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ======================== UI 工具 ========================
|
||
|
||
private setLabelText(node: Node, text: string) {
|
||
if (!node) return
|
||
const label = node.getComponent(Label) || node.getComponentInChildren(Label)
|
||
if (label) {
|
||
label.string = text
|
||
}
|
||
}
|
||
|
||
reset() {
|
||
this.node.destroy()
|
||
}
|
||
}
|