/** * @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() } }