Files
pixelheros/assets/script/game/map/HerosListComp.ts
pan a8aa876a74 feat: 新增技能图标组件与UI优化
1. 新增SSkillBoxComp技能小图标组件,支持技能展示与状态控制
2. 优化CardLiteComp选中态动画,改为缩放高亮效果
3. 为英雄列表、任务主页添加顶部栏显示控制逻辑
4. 调整部分预制件布局与节点配置
2026-08-25 10:50:28 +08:00

532 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 —— 轻量卡片组件
* - buildSkillDesc(HeroSkillDesc)—— 技能描述生成器
*/
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 } from "../common/config/heroSet";
import { buildSkillDesc } from "../common/config/HeroSkillDesc";
import { GameEvent } from "../common/config/GameEvent";
import { HERO_UPGRADE_NEED_FRAGMENTS, CARD_LV_BG_COLORS } from "../common/config/GameSet";
import { CardLiteComp } from "./CardLiteComp";
import { ChipComp } from "./ChipComp";
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,直接显示 lv.X */
@property(Label)
lv_label = null!
@property(Node)
type_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!
/** 升级特效动画节点(内含升级效果 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.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)
}
}
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 的子节点挂在下面
const tipNode = this.upgrade_btn?.getChildByName("smalltip")
if (!tipNode || !tipNode.isValid) {
mLogger.log(this.debugMode, "HerosListComp", `未在 upgrade_btn 下找到 smalltip 节点,无法提示:${text}`)
return
}
// 覆盖气泡文字
const label = tipNode.getComponentInChildren(Label)
if (label) label.string = text
tipNode.active = true
Tween.stopAllByTarget(tipNode)
tipNode.setScale(0, 0, 1)
tween(tipNode)
// 弹出动画(微放大再回弹)
.to(0.15, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' })
.to(0.05, { scale: new Vec3(1, 1, 1) })
// 停留 1 秒
.delay(1)
// 缩小消失
.to(0.15, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' })
.call(() => {
if (tipNode && tipNode.isValid) tipNode.active = false
})
.start()
}
// ======================== 卡片列表 ========================
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]?.card_lv ?? 1) - (HeroInfo[b]?.card_lv ?? 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
const heroLv = Math.max(1, Math.floor(hero.lv ?? 1))
const suffix = heroLv >= 2 ? "★".repeat(heroLv - 1) : ""
this.setLabelText(this.name_node, `${suffix}${hero.name || ""}${suffix}`)
this.setLabelText(this.ap_node, `${(hero.ap ?? 0) * heroLv}`)
this.setLabelText(this.def_node, `${(hero.def ?? 0) * heroLv}`)
this.setLabelText(this.hp_node, `${(hero.hp ?? 0) * heroLv}`)
this.updateCdDisplay(hero)
if (this.info_node) {
const desc = buildSkillDesc(hero)
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.card_lv ?? 1)
this.updateLvDisplay(uuid)
this.updateTypeDisplay(hero)
this.updateHeroAnimation(uuid)
this.updateGrowthPanel(uuid)
}
private updatePoolLvBg(card_lv: number) {
if (!this.BG_node) return
// BG_node 下直接放置颜色节点(green/blue/purple/yellow/red),按卡池等级激活对应颜色
const color = CARD_LV_BG_COLORS[Math.min(card_lv, 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 = `lv.${outerLv}`
}
private updateTypeDisplay(hero: typeof HeroInfo[number]) {
if (!this.type_node) return
this.type_node.active = true
const typeStr = `${hero.type ?? 0}`
this.type_node.children.forEach(child => {
child.active = (child.name === typeStr)
})
}
/**
* 更新局外成长面板:碎片数量与升级按钮状态
* @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 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()
}
}