1. 新增英雄升级相关UI控件与事件流程 2. 实现升级消耗计算与统一扣费逻辑 3. 添加升级预览确认弹窗与属性成长展示 4. 开放tryUpgradeHeroByEid与applyHeroLevel为公共方法
644 lines
27 KiB
TypeScript
644 lines
27 KiB
TypeScript
/**
|
||
* @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, RichText, resources, AnimationClip, SpriteFrame, NodeEventType, 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 { Hero } from "../hero/Hero";
|
||
import { FacSet, FightSet, getLvColor } from "../common/config/GameSet";
|
||
import { buildSkillDesc, buildSkillDescRich, ISkillDescSource } from "../common/config/HeroSkillDesc";
|
||
import { MissionEconomy } from "./MissionEconomy";
|
||
import { UIID } from "../common/config/GameUIConfig";
|
||
|
||
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(Node)
|
||
private sell_btn: Node = null;
|
||
|
||
/** 升级按钮(绑定英雄时激活,点击弹出升级预览确认框) */
|
||
@property(Node)
|
||
private upgrade_btn: Node = null;
|
||
|
||
/** 出售价格标签(按英雄等级显示当前卖价,含驻场加成) */
|
||
@property(Label)
|
||
private sell_price_label: Label = null;
|
||
|
||
/** 技能描述富文本(多行,展示当前等级触发技能,数值高亮) */
|
||
@property(RichText)
|
||
private skill_label: RichText = null;
|
||
/** 技能描述富文本内容缓存(避免 refresh 降频内重复重解析排版) */
|
||
private skillDescCache: string = "";
|
||
|
||
// ======================== 战斗信息标签(总值 + 附加值) ========================
|
||
|
||
/** 攻击总值 */
|
||
@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;
|
||
|
||
// ======================== 运行时状态 ========================
|
||
|
||
/** 绑定的英雄实体 ID(0 表示空槽位) */
|
||
private eid: number = 0;
|
||
/** 绑定的英雄属性数据模型引用 */
|
||
private model: HeroAttrsComp | null = null;
|
||
/** 图标视觉令牌(异步加载竞态保护) */
|
||
private iconVisualToken: number = 0;
|
||
/** 当前显示的英雄 UUID(避免相同 UUID 重复加载动画) */
|
||
private iconHeroUuid: number = 0;
|
||
|
||
// ======================== 生命周期 ========================
|
||
|
||
onLoad() {
|
||
// 次级属性栏默认隐藏,长按更多按钮时显示,放开隐藏
|
||
if (this.attr_box && this.attr_box.isValid) {
|
||
this.attr_box.active = false;
|
||
}
|
||
this.more_btn?.on(NodeEventType.TOUCH_START, this.onMoreBtnTouchStart, this);
|
||
this.more_btn?.on(NodeEventType.TOUCH_END, this.onMoreBtnTouchEnd, this);
|
||
this.more_btn?.on(NodeEventType.TOUCH_CANCEL, this.onMoreBtnTouchEnd, this);
|
||
// 空槽位点击 → 打开英雄卡池
|
||
this.noHero?.on(NodeEventType.TOUCH_END, this.onNoHeroClick, this);
|
||
// 出售按钮点击 → 出售当前绑定英雄
|
||
this.sell_btn?.on(NodeEventType.TOUCH_END, this.onSellHeroClick, this);
|
||
// 升级按钮点击 → 弹出升级预览确认框
|
||
this.upgrade_btn?.on(NodeEventType.TOUCH_END, this.onUpgradeHeroClick, this);
|
||
}
|
||
|
||
onDestroy() {
|
||
if (this.more_btn && this.more_btn.isValid) {
|
||
this.more_btn.off(NodeEventType.TOUCH_START, this.onMoreBtnTouchStart, this);
|
||
this.more_btn.off(NodeEventType.TOUCH_END, this.onMoreBtnTouchEnd, this);
|
||
this.more_btn.off(NodeEventType.TOUCH_CANCEL, this.onMoreBtnTouchEnd, this);
|
||
}
|
||
if (this.noHero && this.noHero.isValid) {
|
||
this.noHero.off(NodeEventType.TOUCH_END, this.onNoHeroClick, this);
|
||
}
|
||
if (this.sell_btn && this.sell_btn.isValid) {
|
||
this.sell_btn.off(NodeEventType.TOUCH_END, this.onSellHeroClick, this);
|
||
}
|
||
if (this.upgrade_btn && this.upgrade_btn.isValid) {
|
||
this.upgrade_btn.off(NodeEventType.TOUCH_END, this.onUpgradeHeroClick, this);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 升级按钮点击:构建升级后新能力预览,弹出公共确认框。
|
||
* Why: 升级消耗金币不可逆,需展示收益并二次确认。
|
||
*/
|
||
private onUpgradeHeroClick() {
|
||
if (!this.eid || !this.model) return;
|
||
const heroLv = Math.max(1, Math.floor(this.model.lv ?? 1));
|
||
// 已满级:气泡提示,复用英雄栏 smalltip 挂载点
|
||
if (heroLv >= FightSet.HERO_MAX_LV) {
|
||
oops.message.dispatchEvent(GameEvent.ShowSmallTip, { type: "hero_full", text: "已达最高等级" });
|
||
return;
|
||
}
|
||
oops.audio.playEffect("music/button");
|
||
|
||
const heroName = this.model.hero_name ?? "";
|
||
const nextLv = heroLv + 1;
|
||
const cost = MissionEconomy.getUpgradeCost(heroLv);
|
||
const preview = this.buildUpgradePreview(nextLv);
|
||
|
||
// oops.gui 公共确认窗口(自定义 CommonPrompt:内容支持富文本高亮)
|
||
oops.gui.open(UIID.Window, {
|
||
title: `升级英雄`,
|
||
content: `<color=#C0392B>${heroName}</color> Lv.${heroLv} → <color=#1E8449>Lv.${nextLv}</color>\n消耗 <color=#E67E22>${cost}</color> 金币\n\n<b>升级后获得:</b>\n${preview}`,
|
||
okWord: "升级",
|
||
cancelWord: "取消",
|
||
needCancel: true,
|
||
okFunc: () => this.executeUpgradeHero()
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 执行升级:校验状态后派发 HeroUpgrade 事件,由 MissionCardComp 扣费并应用等级。
|
||
* Why: 扣费/属性重算统一走 MissionCardComp(与升级卡共用 tryUpgradeHeroByEid),
|
||
* 本组件仅负责 UI 交互与预览,保证单一数据源。
|
||
*/
|
||
private executeUpgradeHero() {
|
||
// 确认窗口打开期间英雄可能已死亡/被移除,需二次校验
|
||
if (!this.eid || !this.model) return;
|
||
oops.message.dispatchEvent(GameEvent.HeroUpgrade, { eid: this.eid });
|
||
}
|
||
|
||
/**
|
||
* 构建升级后新能力预览富文本。
|
||
* 数据源取 HeroInfo 静态配置(lv 置为目标等级),由描述标准层统一渲染,
|
||
* 与面板技能描述同一套文案规则,杜绝数值漂移。
|
||
*
|
||
* @param nextLv 目标等级
|
||
* @returns 多行富文本:属性成长 + 下一档触发技能/光环/复活/加成
|
||
*/
|
||
private buildUpgradePreview(nextLv: number): string {
|
||
const hero = HeroInfo[this.model?.hero_uuid ?? 0];
|
||
if (!hero) return "";
|
||
|
||
// ---- 属性成长(按 HERO_LV_MULTIPLIER 幂成长 + 等级额外加成) ----
|
||
const mult = Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1);
|
||
const nextAp = Math.floor(hero.ap * mult + HeroBoxComp.sumBonus(hero.ap_bonus, nextLv));
|
||
const nextHp = Math.floor(hero.hp * mult + HeroBoxComp.sumBonus(hero.hp_bonus, nextLv));
|
||
const lines: string[] = [
|
||
`攻击 <color=#27AE60>${nextAp}</color> 生命 <color=#27AE60>${nextHp}</color>`
|
||
];
|
||
|
||
// ---- 新能力(触发技能 / 驻场光环 / 复活 / 额外加成,取目标等级生效档) ----
|
||
const source: ISkillDescSource = {
|
||
lv: nextLv,
|
||
call: hero.call,
|
||
dead: hero.dead,
|
||
fstart: hero.fstart,
|
||
fend: hero.fend,
|
||
atking: hero.atking,
|
||
atked: hero.atked,
|
||
field: hero.field,
|
||
revive: hero.revive,
|
||
// 预览仅展示增量,额外加成由上方属性成长体现,避免重复
|
||
};
|
||
const skillDesc = buildSkillDesc(source);
|
||
if (skillDesc) lines.push(skillDesc);
|
||
return lines.join("\n");
|
||
}
|
||
|
||
/** 累加等级额外加成(≤目标等级的所有档位) */
|
||
private static sumBonus(entries: { lv: number; value: number }[] | undefined, lv: number): number {
|
||
if (!entries) return 0;
|
||
let total = 0;
|
||
for (const e of entries) { if (e.lv <= lv) total += e.value; }
|
||
return total;
|
||
}
|
||
|
||
/**
|
||
* 出售按钮点击:弹出公共确认窗口,确认后再执行出售。
|
||
* Why: 出售不可逆,需二次确认防止误触。
|
||
*/
|
||
private onSellHeroClick() {
|
||
if (!this.eid || !this.model) return;
|
||
// 场上仅剩 1 个英雄时不允许出售:复用英雄栏 smalltip 气泡提示(与满员同一挂载点)
|
||
if (HeroBoxComp.getAliveHeroCount() <= 1) {
|
||
oops.message.dispatchEvent(GameEvent.ShowSmallTip, { type: "hero_full", text: "至少保留 1 个英雄" });
|
||
return;
|
||
}
|
||
oops.audio.playEffect("music/button");
|
||
|
||
const heroName = this.model.hero_name ?? "";
|
||
const heroLv = Math.max(1, Math.floor(this.model.lv ?? 1));
|
||
const gold = MissionEconomy.getSellGold(heroLv);
|
||
|
||
// oops.gui 公共确认窗口(自定义 CommonPrompt:内容支持富文本高亮)
|
||
oops.gui.open(UIID.Window, {
|
||
title: "出售英雄",
|
||
content: `确定出售 <color=#C0392B>${heroName}</color>(Lv.${heroLv})吗?\n出售后获得 <color=#1E8449>${gold}</color> 金币`,
|
||
okWord: "出售",
|
||
cancelWord: "取消",
|
||
needCancel: true,
|
||
okFunc: () => this.executeSellHero()
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 执行出售:移除英雄实体、按等级结算金币并派发 HeroSell 事件。
|
||
* MissionCardComp 监听该事件后会刷新槽位,本盒自动切为空状态。
|
||
*/
|
||
private executeSellHero() {
|
||
// 确认窗口打开期间英雄可能已死亡/被移除,需二次校验
|
||
if (!this.eid || !this.model) return;
|
||
const heroLv = Math.max(1, Math.floor(this.model.lv ?? 1));
|
||
// 移除 ECS 实体失败(实体已失效)则不做后续结算
|
||
if (!Hero.removeByEid(this.eid)) return;
|
||
|
||
// 统一经济管理入口:按等级计算卖价并加金币
|
||
const gold = MissionEconomy.executeSellHero(heroLv);
|
||
mLogger.log(this.debugMode, "HeroBoxComp", "executeSellHero", { eid: this.eid, heroLv, gold });
|
||
|
||
// 通知 MissionCardComp 更新场上英雄数量并刷新英雄盒槽位
|
||
oops.message.dispatchEvent(GameEvent.HeroSell, { eid: this.eid });
|
||
}
|
||
|
||
/**
|
||
* 空槽位点击:播放主节点点击动画,并派发事件通知 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 onMoreBtnTouchStart() {
|
||
oops.audio.playEffect("music/button");
|
||
this.setAttrBoxVisible(true);
|
||
}
|
||
|
||
/** 更多按钮放开/取消:隐藏次级属性栏 */
|
||
private onMoreBtnTouchEnd() {
|
||
this.setAttrBoxVisible(false);
|
||
}
|
||
|
||
/** 切换次级属性栏容器可见性 */
|
||
private setAttrBoxVisible(visible: boolean) {
|
||
if (this.attr_box && this.attr_box.isValid) {
|
||
this.attr_box.active = visible;
|
||
}
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
if (this.sell_btn && this.sell_btn.isValid) {
|
||
this.sell_btn.active = true;
|
||
}
|
||
if (this.upgrade_btn && this.upgrade_btn.isValid) {
|
||
this.upgrade_btn.active = true;
|
||
}
|
||
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.sell_btn && this.sell_btn.isValid) {
|
||
this.sell_btn.active = false;
|
||
}
|
||
if (this.upgrade_btn && this.upgrade_btn.isValid) {
|
||
this.upgrade_btn.active = false;
|
||
}
|
||
if (this.sell_price_label && this.sell_price_label.isValid) {
|
||
this.sell_price_label.string = "";
|
||
}
|
||
|
||
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 = "";
|
||
}
|
||
this.skillDescCache = "";
|
||
}
|
||
|
||
/**
|
||
* 刷新显示:同步英雄等级、名称、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);
|
||
}
|
||
|
||
// ---- 出售价格(按等级计算,含驻场 SellGold 加成) ----
|
||
if (this.sell_price_label && this.sell_price_label.isValid) {
|
||
const lv = Math.max(1, Math.floor(this.model.lv ?? 1));
|
||
this.sell_price_label.string = `${MissionEconomy.getSellGold(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), "%");
|
||
|
||
// ---- 技能描述(全档位合并:每技能一行,等级置前 + 已/未激活状态,数值富文本高亮) ----
|
||
if (this.skill_label && this.skill_label.isValid) {
|
||
// 缓存未变则跳过重设,避免 refresh 降频内 RichText 重复重解析排版
|
||
// showName=false 不显示技能名仅保留效果;showLocked=true 合并全档位(未来档数值灰色拼接,未激活技能整行灰色)
|
||
const desc = buildSkillDescRich(this.model, false, true);
|
||
if (desc !== this.skillDescCache) {
|
||
this.skillDescCache = desc;
|
||
this.skill_label.string = desc;
|
||
}
|
||
}
|
||
|
||
// ---- 图标(UUID 变化时重新加载首帧动画) ----
|
||
if (hero && heroUuid !== this.iconHeroUuid) {
|
||
this.iconHeroUuid = heroUuid;
|
||
this.iconVisualToken += 1;
|
||
this.updateHeroIcon(hero.path, heroUuid, this.iconVisualToken);
|
||
}
|
||
}
|
||
|
||
// ======================== 内部工具 ========================
|
||
|
||
/**
|
||
* 统计场上存活英雄数量(与 MissionCardComp.getAliveHeroCount 口径一致)。
|
||
*/
|
||
private static getAliveHeroCount(): number {
|
||
let count = 0;
|
||
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
|
||
const model = entity.get(HeroAttrsComp);
|
||
if (model && model.fac === FacSet.HERO) {
|
||
count++;
|
||
}
|
||
});
|
||
return count;
|
||
}
|
||
|
||
/**
|
||
* 写入一行战斗信息(总值 + 附加值)。
|
||
* 总值始终显示(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;
|
||
}
|
||
});
|
||
}
|
||
}
|