/** * @file HeroBoxComp.ts * @description 英雄面板单个英雄盒组件(UI 视图层) * * 职责: * 1. 同步显示一个已登场英雄的详细信息(图标 / 名称 / 等级 / AP / HP / 描述)。 * 2. 由 MissionCardComp 实例化到 herosBoxNode 下,最多 3 个,与场上英雄一一对应。 * 3. 英雄卖出 / 死亡后槽位保留,通过 applyEmpty() 显示为空英雄信息。 * * 依赖: * - HeroAttrsComp —— 英雄属性数据模型 * - HeroInfo(heroSet)—— 英雄静态配置 */ import { mLogger } from "../common/Logger"; import { _decorator, Node, Sprite, Label, resources, AnimationClip, SpriteFrame, NodeEventType, UITransform, Tween, tween, Vec3 } 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 { GameEvent } from "../common/config/GameEvent"; import { HeroInfo } from "../common/config/heroSet"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { getLvColor } from "../common/config/GameSet"; import { buildSkillDesc } from "../common/config/HeroSkillDesc"; const { ccclass, property } = _decorator; /** * HeroBoxComp —— 英雄面板单个英雄信息盒 * * 与场上英雄一一绑定(eid),英雄移除后显示空状态。 */ @ccclass('HeroBoxComp') @ecs.register('HeroBoxComp', false) export class HeroBoxComp extends CCComp { private debugMode: boolean = false; // ======================== 编辑器绑定节点 ======================== /** 英雄图标节点 */ @property({ type: Node }) private icon_node: Node = null; /** 背景节点 */ @property({ type: Node }) private bg_node: Node = null; /** 英雄名称 */ @property(Label) private name_label: Label = null; /** 等级标签 */ @property(Label) private level_label: Label = null; /** 空槽位标识节点(未绑定英雄时激活,点击打开英雄卡池) */ @property(Node) private noHero: Node = null; /** 技能描述标签(多行,展示当前等级触发技能) */ @property(Label) private skill_label: Label = null; // ======================== 战斗信息标签(总值 + 附加值) ======================== /** 攻击总值 */ @property(Label) private ap_all_label: Label = null; /** 攻击附加值 */ @property(Label) private ap_plus_label: Label = null; /** 生命总值 */ @property(Label) private hp_all_label: Label = null; /** 生命附加值 */ @property(Label) private hp_plus_label: Label = null; /** 暴击率总值(百分点) */ @property(Label) private crt_all_label: Label = null; /** 暴击率附加值 */ @property(Label) private crt_plus_label: Label = null; /** 击退率总值(百分点) */ @property(Label) private knockback_all_label: Label = null; /** 击退率附加值 */ @property(Label) private knockback_plus_label: Label = null; /** 风怒率总值(百分点) */ @property(Label) private wfuny_all_label: Label = null; /** 风怒率附加值 */ @property(Label) private wfuny_plus_label: Label = null; /** 击晕率总值(百分点) */ @property(Label) private stun_all_label: Label = null; /** 击晕率附加值 */ @property(Label) private stun_plus_label: Label = null; /** 攻击速度总值(攻击间隔,秒;速度提升时间隔变短,附加值为负) */ @property(Label) private speed_all_label: Label = null; /** 攻击速度附加值 */ @property(Label) private speed_plus_label: Label = null; /** 冰冻率总值(百分点) */ @property(Label) private freeze_all_label: Label = null; /** 冰冻率附加值 */ @property(Label) private freeze_plus_label: Label = null; /** 穿透率总值(百分点) */ @property(Label) private puncture_all_label: Label = null; /** 穿透率附加值 */ @property(Label) private puncture_plus_label: Label = null; /** 暴击伤害总值(额外暴伤,百分点) */ @property(Label) private crit_dmg_all_label: Label = null; /** 暴击伤害附加值 */ @property(Label) private crit_dmg_plus_label: Label = null; // ======================== 更多属性折叠栏 ======================== /** 属性栏容器(展开/收起时动态调整高度) */ @property(Node) private attr_box: Node = null; /** 更多按钮(点击展开/收起次级属性栏) */ @property(Node) private more_btn: Node = null; /** 击晕信息栏(默认隐藏) */ @property(Node) private stun_row: Node = null; /** 冰冻信息栏(默认隐藏) */ @property(Node) private freeze_row: Node = null; /** 击退信息栏(默认隐藏) */ @property(Node) private knockback_row: Node = null; /** 穿透信息栏(默认隐藏) */ @property(Node) private puncture_row: Node = null; /** 风怒信息栏(默认隐藏) */ @property(Node) private wfuny_row: Node = null; /** 属性栏收起高度 */ private attrBoxHeightCollapsed: number = 100; /** 属性栏展开高度 */ private attrBoxHeightExpanded: number = 210; /** 次级属性栏是否展开 */ private moreExpanded: boolean = false; // ======================== 运行时状态 ======================== /** 绑定的英雄实体 ID(0 表示空槽位) */ private eid: number = 0; /** 绑定的英雄属性数据模型引用 */ private model: HeroAttrsComp | null = null; /** 图标视觉令牌(异步加载竞态保护) */ private iconVisualToken: number = 0; /** 当前显示的英雄 UUID(避免相同 UUID 重复加载动画) */ private iconHeroUuid: number = 0; // ======================== 生命周期 ======================== onLoad() { // 次级属性栏默认收起 this.setMoreRowsVisible(false); this.more_btn?.on(NodeEventType.TOUCH_END, this.onMoreBtnClick, this); // 空槽位点击 → 打开英雄卡池 this.noHero?.on(NodeEventType.TOUCH_END, this.onNoHeroClick, this); } onDestroy() { if (this.more_btn && this.more_btn.isValid) { this.more_btn.off(NodeEventType.TOUCH_END, this.onMoreBtnClick, this); } if (this.noHero && this.noHero.isValid) { this.noHero.off(NodeEventType.TOUCH_END, this.onNoHeroClick, this); } } /** * 空槽位点击:播放主节点点击动画,并派发事件通知 MissionCardComp 展示英雄卡池。 */ private onNoHeroClick() { oops.audio.playEffect("music/button"); this.playClickAnim(); oops.message.dispatchEvent(GameEvent.HeroBoxEmptyClick); } /** 主节点点击动画:缩放按下 → 回弹 */ private playClickAnim() { if (!this.node || !this.node.isValid) return; Tween.stopAllByTarget(this.node); this.node.setScale(1, 1, 1); tween(this.node) .to(0.06, { scale: new Vec3(0.94, 0.94, 1) }) .to(0.1, { scale: new Vec3(1, 1, 1) }) .start(); } /** 刷新计时累计(降频刷新实时属性) */ private refreshElapsed: number = 0; /** 实时刷新间隔(秒) */ private static readonly REFRESH_INTERVAL = 0.25; /** * 帧更新:绑定英雄后按固定间隔实时刷新面板信息。 * Why: AP/HP/暴击等属性受 buff/光环影响实时变化,需降频轮询同步; * 实体失效时 refresh 会自动切换为空状态。 */ update(dt: number) { if (!this.model) return; this.refreshElapsed += dt; if (this.refreshElapsed < HeroBoxComp.REFRESH_INTERVAL) return; this.refreshElapsed = 0; this.refresh(); } /** 更多按钮点击:切换次级属性栏显示/隐藏 */ private onMoreBtnClick() { oops.audio.playEffect("music/button"); this.moreExpanded = !this.moreExpanded; this.setMoreRowsVisible(this.moreExpanded); } /** 设置 5 个次级属性栏(击晕/冰冻/击退/穿透/风怒)的可见性,并同步调整属性栏高度 */ private setMoreRowsVisible(visible: boolean) { const rows = [this.stun_row, this.freeze_row, this.knockback_row, this.puncture_row, this.wfuny_row]; for (const row of rows) { if (row && row.isValid) { row.active = visible; } } // 展开/收起时切换属性栏高度(默认 125 ↔ 210,可在编辑器自定义) if (this.attr_box && this.attr_box.isValid) { const transform = this.attr_box.getComponent(UITransform) || this.attr_box.addComponent(UITransform); const height = visible ? this.attrBoxHeightExpanded : this.attrBoxHeightCollapsed; transform.setContentSize(transform.width, height); } // 切换更多按钮状态子节点:收起=n_* 激活,展开=a_* 激活 this.setMoreBtnState(visible); } /** * 切换更多按钮的状态子节点。 * 收起态激活 n_Bg / n_Arrow,展开态激活 a_Bg / a_Arrow。 */ private setMoreBtnState(expanded: boolean) { if (!this.more_btn || !this.more_btn.isValid) return; const nBg = this.more_btn.getChildByName("n_Bg"); const nArrow = this.more_btn.getChildByName("n_Arrow"); const aBg = this.more_btn.getChildByName("a_Bg"); const aArrow = this.more_btn.getChildByName("a_Arrow"); if (nBg) nBg.active = !expanded; if (nArrow) nArrow.active = !expanded; if (aBg) aBg.active = expanded; if (aArrow) aArrow.active = expanded; } /** ECS 组件移除时销毁节点 */ reset() { if (this.node && this.node.isValid) { this.node.destroy(); } } // ======================== 数据绑定 ======================== /** 当前槽位是否绑定了英雄 */ public get hasHero(): boolean { return this.eid > 0 && !!this.model; } /** 当前绑定的英雄实体 ID */ public get bindEid(): number { return this.eid; } /** * 绑定场上英雄并刷新显示。 * @param eid 英雄 ECS 实体 ID * @param model 英雄属性组件 */ public bindHero(eid: number, model: HeroAttrsComp) { this.eid = eid; this.model = model; this.node.active = true; if (this.noHero && this.noHero.isValid) { this.noHero.active = false; } this.refresh(); } /** 清空槽位,显示空英雄信息 */ public applyEmpty() { this.eid = 0; this.model = null; this.iconHeroUuid = 0; this.iconVisualToken += 1; if (this.noHero && this.noHero.isValid) { this.noHero.active = true; } if (this.name_label && this.name_label.isValid) { this.name_label.string = ""; } if (this.level_label && this.level_label.isValid) { this.level_label.string = ""; } if (this.icon_node && this.icon_node.isValid) { const sprite = this.icon_node.getComponent(Sprite) || this.icon_node.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; } this.clearStatRow(this.ap_all_label, this.ap_plus_label); this.clearStatRow(this.hp_all_label, this.hp_plus_label); this.clearStatRow(this.crt_all_label, this.crt_plus_label); this.clearStatRow(this.knockback_all_label, this.knockback_plus_label); this.clearStatRow(this.wfuny_all_label, this.wfuny_plus_label); this.clearStatRow(this.stun_all_label, this.stun_plus_label); this.clearStatRow(this.speed_all_label, this.speed_plus_label); this.clearStatRow(this.freeze_all_label, this.freeze_plus_label); this.clearStatRow(this.puncture_all_label, this.puncture_plus_label); this.clearStatRow(this.crit_dmg_all_label, this.crit_dmg_plus_label); if (this.skill_label && this.skill_label.isValid) { this.skill_label.string = ""; } } /** * 刷新显示:同步英雄等级、名称、AP / HP、描述与图标。 * 绑定的英雄实体已失效时自动切换为空状态。 */ public refresh() { if (!this.model || !(this.model as any).ent) { this.applyEmpty(); return; } const heroUuid = this.model.hero_uuid ?? 0; const hero = HeroInfo[heroUuid]; // ---- 名称 ---- if (this.name_label && this.name_label.isValid) { this.name_label.string = this.model.hero_name ?? ""; } // ---- 等级 ---- if (this.level_label && this.level_label.isValid) { const lv = this.model.lv ?? 1; this.level_label.string = `Lv.${lv}`; this.level_label.color = getLvColor(lv); } // ---- AP / HP ---- const finalAp = Math.max(0, Math.floor(this.model.getFinalAp())); const baseAp = Math.max(0, Math.floor(this.model.base_ap ?? 0)); this.setStatRow(this.ap_all_label, this.ap_plus_label, finalAp, finalAp - baseAp); const finalHp = Math.max(0, Math.floor(this.model.getFinalHpMax())); const baseHp = Math.max(0, Math.floor(this.model.base_hp ?? 0)); this.setStatRow(this.hp_all_label, this.hp_plus_label, finalHp, finalHp - baseHp); // ---- 暴击率 / 击退率 / 风怒率 / 击晕率(百分点,附加 = 最终 - 基础配置值) ---- const finalCrit = this.model.getFinalCritical(); this.setStatRow(this.crt_all_label, this.crt_plus_label, finalCrit, finalCrit - (this.model.critical ?? 0), "%"); const finalKnockback = this.model.getFinalKnockbackChance(); this.setStatRow(this.knockback_all_label, this.knockback_plus_label, finalKnockback, finalKnockback - (this.model.knockback_chance ?? 0), "%"); const finalWfuny = this.model.getFinalWindFury(); this.setStatRow(this.wfuny_all_label, this.wfuny_plus_label, finalWfuny, finalWfuny - (this.model.wfuny ?? 0), "%"); const finalStun = this.model.getFinalStunChance(); this.setStatRow(this.stun_all_label, this.stun_plus_label, finalStun, finalStun - (this.model.stun_chance ?? 0), "%"); // ---- 攻击速度(攻击间隔,秒;速度提升 → 间隔变短,附加值为负) ---- const skillIds = this.model.getSkillIds(); const displaySkillId = skillIds[1] ?? skillIds[0] ?? 0; const baseCd = displaySkillId ? (this.model.skills[displaySkillId]?.cd ?? 0) : 0; const finalCd = displaySkillId ? this.model.getEffectiveSkillCd(displaySkillId) : 0; this.setStatRow(this.speed_all_label, this.speed_plus_label, finalCd, finalCd - baseCd, "s", 1); // ---- 冰冻率 / 穿透率(百分点,附加 = 最终 - 基础配置值) ---- const finalFreeze = this.model.getFinalFreezeChance(); this.setStatRow(this.freeze_all_label, this.freeze_plus_label, finalFreeze, finalFreeze - (this.model.freeze_chance ?? 0), "%"); const finalPuncture = this.model.getFinalPunctureChance(); this.setStatRow(this.puncture_all_label, this.puncture_plus_label, finalPuncture, finalPuncture - (this.model.puncture_chance ?? 0), "%"); // ---- 暴击伤害(额外暴伤,百分点,附加 = 最终 - 基础配置值) ---- const finalCritDmg = this.model.getFinalCritDamage(); this.setStatRow(this.crit_dmg_all_label, this.crit_dmg_plus_label, finalCritDmg, finalCritDmg - (this.model.crit_damage ?? 0), "%"); // ---- 技能描述(当前等级触发技能,读运行时 model 最终配置) ---- if (this.skill_label && this.skill_label.isValid) { this.skill_label.string = buildSkillDesc(this.model); } // ---- 图标(UUID 变化时重新加载首帧动画) ---- if (hero && heroUuid !== this.iconHeroUuid) { this.iconHeroUuid = heroUuid; this.iconVisualToken += 1; this.updateHeroIcon(hero.path, heroUuid, this.iconVisualToken); } } // ======================== 内部工具 ======================== /** * 写入一行战斗信息(总值 + 附加值)。 * 总值始终显示(0 不隐藏);附加值为 0 时隐藏,正/负分别显示 (+x) / (x)。 * * @param allLabel 总值标签(可为空,空时跳过) * @param plusLabel 附加值标签(可为空) * @param total 总数值(最终生效值) * @param bonus 附加数值(最终值 - 基础值) * @param suffix 数值后缀(如 "%") * @param decimals 保留小数位数(默认 0,取整) */ private setStatRow(allLabel: Label | null, plusLabel: Label | null, total: number, bonus: number, suffix: string = "", decimals: number = 0) { const fmt = (v: number) => decimals > 0 ? v.toFixed(decimals) : `${Math.floor(v)}`; if (allLabel && allLabel.isValid) { allLabel.string = `${fmt(total)}${suffix}`; } if (plusLabel && plusLabel.isValid) { if (bonus !== 0) { plusLabel.string = bonus > 0 ? `(+${fmt(bonus)}${suffix})` : `(${fmt(bonus)}${suffix})`; } else { plusLabel.string = ""; } } } /** 清空一行战斗信息(空槽位时使用) */ private clearStatRow(allLabel: Label | null, plusLabel: Label | null) { if (allLabel && allLabel.isValid) allLabel.string = ""; if (plusLabel && plusLabel.isValid) plusLabel.string = ""; } /** * 加载英雄 idle 动画首帧作为静态图标。 * @param path 英雄美术资源名(HeroInfo.path) * @param uuid 英雄 UUID(竞态校验用) * @param token 视觉令牌(竞态保护) */ private updateHeroIcon(path: string, uuid: number, token: number) { if (!this.icon_node || !this.icon_node.isValid) return; const sprite = this.icon_node.getComponent(Sprite) || this.icon_node.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; resources.load(`game/heros/hero/${path}/idle`, AnimationClip, (err, clip) => { if (err || !clip) return; // 异步加载期间槽位可能已被复用 / 清空 if (token !== this.iconVisualToken) return; if (this.iconHeroUuid !== uuid) return; if (!this.icon_node || !this.icon_node.isValid) return; const target = this.icon_node.getComponent(Sprite) || this.icon_node.getComponentInChildren(Sprite); if (!target || !target.isValid) return; // 帧动画数据存于 spriteFrame 轨道的 _channel._curve._values(裸 SpriteFrame 数组) let firstFrame: SpriteFrame | undefined; for (const track of clip.tracks) { const curve = (track as unknown as { channel?: { curve?: { _values?: unknown[] } } }).channel?.curve; const frame = curve?._values?.[0]; if (frame instanceof SpriteFrame) { firstFrame = frame; break; } } if (firstFrame) { target.spriteFrame = firstFrame; } }); } }