/**
* @file HeroBoxComp.ts
* @description 英雄面板单个英雄盒组件(UI 视图层)
*
* 职责:
* 1. 同步显示一个已登场英雄的基础信息(图标 / 名称 / 等级 / AP / HP)。
* 2. 长按英雄盒同步实例化信息面板(more.prefab),展示次级属性与技能描述;放开手指销毁。
* 3. 由 MissionCardComp 实例化到 herosBoxNode 下,最多 3 个,与场上英雄一一对应。
* 4. 英雄卖出 / 死亡后槽位保留,通过 applyEmpty() 显示为空英雄信息。
*
* 依赖:
* - HeroAttrsComp —— 英雄属性数据模型
* - HeroInfo(heroSet)—— 英雄静态配置
*/
import { mLogger } from "../common/Logger";
import { _decorator, Node, Sprite, Label, resources, AnimationClip, SpriteFrame, NodeEventType, EventTouch, Tween, tween, Vec3, Color, Prefab, instantiate, director, find } 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, NumberFormatter } from "../common/config/GameSet";
import { buildSkillDesc, ISkillDescSource } from "../common/config/HeroSkillDesc";
import { MissionEconomy } from "./MissionEconomy";
import { UIID } from "../common/config/GameUIConfig";
import { HeroMoreCom } from "./HeroMoreCom";
const { ccclass, property } = _decorator;
/**
* HeroBoxComp —— 英雄面板单个英雄信息盒
*
* 与场上英雄一一绑定(eid),英雄移除后显示空状态。
*/
@ccclass('HeroBoxComp')
@ecs.register('HeroBoxComp', false)
export class HeroBoxComp extends CCComp {
private debugMode: boolean = false;
/** 金币不足时升级费用标红色(暗红,区别于正常白色) */
private static readonly INSUFFICIENT_COLOR: Color = new Color(231, 76, 60);
// ======================== 编辑器绑定节点 ========================
/** 英雄图标节点 */
@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(Label)
private upgrade_price_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;
/** 英雄信息弹窗预制体(长按英雄盒时同步实例化展示,绕开 oops.gui 异步加载) */
@property(Prefab)
private morePrefab: Prefab = null;
// ======================== 运行时状态 ========================
/** 绑定的英雄实体 ID(0 表示空槽位) */
private eid: number = 0;
/** 绑定的英雄属性数据模型引用 */
private model: HeroAttrsComp | null = null;
/** 图标视觉令牌(异步加载竞态保护) */
private iconVisualToken: number = 0;
/** 当前显示的英雄 UUID(避免相同 UUID 重复加载动画) */
private iconHeroUuid: number = 0;
/** 当前已弹出的英雄信息面板节点(null 表示未弹出) */
private moreNode: Node | null = null;
/** 长按判定:当前按下触摸 ID(-1 表示无) */
private longPressTouchId: number = -1;
/** 长按判定:按下起点 UI 坐标 */
private longPressStartX: number = 0;
private longPressStartY: number = 0;
/** 长按触发时长(秒) */
private static readonly LONG_PRESS_DURATION = 0.5;
/** 长按最大位移容差(像素),超出则取消判定 */
private static readonly LONG_PRESS_SLOP = 30;
// ======================== 生命周期 ========================
onLoad() {
// 长按英雄盒任意区域实例化信息面板(more.prefab);出售/升级按钮上的触摸不触发
this.node.on(NodeEventType.TOUCH_START, this.onBoxTouchStart, this);
this.node.on(NodeEventType.TOUCH_MOVE, this.onBoxTouchMove, this);
this.node.on(NodeEventType.TOUCH_END, this.onBoxTouchEnd, this);
this.node.on(NodeEventType.TOUCH_CANCEL, this.onBoxTouchCancel, 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.node && this.node.isValid) {
this.node.off(NodeEventType.TOUCH_START, this.onBoxTouchStart, this);
this.node.off(NodeEventType.TOUCH_MOVE, this.onBoxTouchMove, this);
this.node.off(NodeEventType.TOUCH_END, this.onBoxTouchEnd, this);
this.node.off(NodeEventType.TOUCH_CANCEL, this.onBoxTouchCancel, 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);
}
// 销毁时一并清理已弹出的信息面板
this.closeMoreNode();
}
/**
* 升级按钮点击:构建升级后新能力预览,弹出公共确认框。
* 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);
// 金币不足:smalltip 气泡提示(挂载金币节点),不弹确认框
if (MissionEconomy.getCoin() < cost) {
oops.message.dispatchEvent(GameEvent.ShowSmallTip, { type: "buy_coin", text: "金币不足" });
return;
}
const preview = this.buildUpgradePreview(nextLv);
// oops.gui 公共确认窗口(自定义 CommonPrompt:内容支持富文本高亮)
oops.gui.open(UIID.Window, {
title: `升级英雄`,
content: `${heroName} Lv.${heroLv} → Lv.${nextLv}\n消耗 ${cost} 金币\n\n升级后获得:\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[] = [
`攻击 ${nextAp} 生命 ${nextHp}`
];
// ---- 新能力(触发技能 / 驻场光环 / 复活 / 额外加成,取目标等级生效档) ----
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: `确定出售 ${heroName}(Lv.${heroLv})吗?\n出售后获得 ${gold} 金币`,
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();
}
/**
* 英雄盒按下:记录起点并启动长按判定。
* 出售/升级按钮上的触摸不触发长按(按钮有各自的 TOUCH_END 行为)。
*/
private onBoxTouchStart(event: EventTouch) {
if (!this.hasHero) return;
const target = event.target as Node | null;
if (this.isNodeWithin(target, this.sell_btn) || this.isNodeWithin(target, this.upgrade_btn)) return;
this.longPressTouchId = event.touch?.getID() ?? -1;
const pos = event.getUILocation();
this.longPressStartX = pos.x;
this.longPressStartY = pos.y;
this.scheduleOnce(this.onLongPressFired, HeroBoxComp.LONG_PRESS_DURATION);
}
/** 英雄盒拖动:位移超出容差取消长按判定 */
private onBoxTouchMove(event: EventTouch) {
if (this.longPressTouchId < 0) return;
if ((event.touch?.getID() ?? -1) !== this.longPressTouchId) return;
const pos = event.getUILocation();
const dx = pos.x - this.longPressStartX;
const dy = pos.y - this.longPressStartY;
if (dx * dx + dy * dy > HeroBoxComp.LONG_PRESS_SLOP * HeroBoxComp.LONG_PRESS_SLOP) {
this.cancelLongPress();
}
}
/** 英雄盒抬起:取消未触发的长按判定;若长按已弹出信息面板则立即关闭 */
private onBoxTouchEnd(event: EventTouch) {
if (this.longPressTouchId < 0) return;
if ((event.touch?.getID() ?? -1) !== this.longPressTouchId) return;
this.cancelLongPress();
// 直接销毁已弹出的信息面板(同步 destroy,无竞态)
this.closeMoreNode();
}
/** 英雄盒触摸被系统打断:取消长按判定;若已弹窗则一并关闭 */
private onBoxTouchCancel(event: EventTouch) {
this.onBoxTouchEnd(event);
}
/** 取消长按判定(复位触摸 ID 并移除定时) */
private cancelLongPress() {
this.longPressTouchId = -1;
this.unschedule(this.onLongPressFired);
}
/** 长按触发:同步实例化英雄信息面板(more.prefab,展示次级属性与技能描述) */
private onLongPressFired() {
// 不重置 longPressTouchId:保留以便 onBoxTouchEnd 能识别同一触摸并销毁弹窗
if (!this.hasHero || !(this.model as any)?.ent) return;
if (!this.morePrefab) {
mLogger.warn(true, "HeroBoxComp", "onLongPressFired morePrefab 未绑定,请在编辑器拖入 more.prefab");
return;
}
oops.audio.playEffect("music/button");
// 同步实例化:避开 oops.gui 首次异步加载窗口期,确保放开时能立即 destroy
this.closeMoreNode();
this.moreNode = instantiate(this.morePrefab);
// more.prefab 是全屏遮罩弹窗,必须挂到 Canvas 根节点,避免被英雄盒父节点的 Mask 裁剪或坐标系错位
const canvas = find("Canvas") || director.getScene()?.getChildByName("Canvas");
(canvas ?? this.node.parent)?.addChild(this.moreNode);
// 整体上移 100 像素(弹窗相对英雄盒偏上展示)
this.moreNode.setPosition(this.moreNode.position.x, this.moreNode.position.y + 100, this.moreNode.position.z);
this.moreNode.getComponent(HeroMoreCom)?.init(this.eid);
}
/** 关闭并清理已弹出的英雄信息面板(同步 destroy) */
private closeMoreNode() {
if (this.moreNode && this.moreNode.isValid) {
this.moreNode.destroy();
}
this.moreNode = null;
}
/**
* 判断触摸目标节点是否位于指定祖先节点(含自身)之内。
* @param target 触摸命中的节点(可能为空)
* @param ancestor 祖先节点(可能为空,空时返回 false)
* @returns 命中节点是否为 ancestor 的后代或自身
*/
private isNodeWithin(target: Node | null, ancestor: Node | null): boolean {
if (!target || !ancestor || !ancestor.isValid) return false;
let cur: Node | null = target;
while (cur) {
if (cur === ancestor) return true;
if (cur === this.node) break;
cur = cur.parent;
}
return false;
}
/** 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.upgrade_price_label && this.upgrade_price_label.isValid) {
this.upgrade_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);
}
/**
* 刷新显示:同步英雄等级、名称、AP / HP 与图标(次级属性与技能描述已分离至 HeroMoreCom 弹窗)。
* 绑定的英雄实体已失效时自动切换为空状态。
*/
public refresh() {
if (!this.model || !(this.model as any).ent) {
this.applyEmpty();
return;
}
const heroUuid = this.model.hero_uuid ?? 0;
const hero = HeroInfo[heroUuid];
// ---- 背景(按 card_lv 切换 bg_node 颜色子节点) ----
this.updateBgNode(hero?.card_lv ?? 1);
// ---- 名称 ----
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)}`;
}
// ---- 升级费用(满级隐藏;金币不足时标红提示) ----
if (this.upgrade_price_label && this.upgrade_price_label.isValid) {
const lv = Math.max(1, Math.floor(this.model.lv ?? 1));
if (lv >= FightSet.HERO_MAX_LV) {
this.upgrade_price_label.string = "";
} else {
const cost = MissionEconomy.getUpgradeCost(lv);
this.upgrade_price_label.string = `${cost}`;
// 金币不足标红,足够恢复默认色,给玩家直观的可升级反馈
this.upgrade_price_label.color = MissionEconomy.getCoin() >= cost ? Color.WHITE : HeroBoxComp.INSUFFICIENT_COLOR;
}
}
// ---- 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);
// ---- 图标(UUID 变化时重新加载首帧动画) ----
if (hero && heroUuid !== this.iconHeroUuid) {
this.iconHeroUuid = heroUuid;
this.iconVisualToken += 1;
this.updateHeroIcon(hero.path, heroUuid, this.iconVisualToken);
}
}
// ======================== 内部工具 ========================
/**
* 根据 card_lv 切换 bg_node 下的颜色子节点显示。
* 等级与颜色对应关系:
* card_lv 1 → green
* card_lv 2 → blue
* card_lv 3 → purple
* card_lv 4 → red
* card_lv 5 → yellow
* @param cardLv 英雄卡牌等级(HeroInfo.card_lv,缺省按 1 处理)
*/
private updateBgNode(cardLv: number) {
if (!this.bg_node || !this.bg_node.isValid) return;
const lvToColor: Record = {
1: "green",
2: "blue",
3: "purple",
4: "red",
5: "yellow",
};
const targetColor = lvToColor[cardLv] ?? lvToColor[1];
this.bg_node.children.forEach(child => {
child.active = (child.name === targetColor);
});
}
/**
* 统计场上存活英雄数量(与 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) {
// 整数数值统一走 NumberFormatter 千分位缩写(如 12.3k),避免大数值溢出标签
const fmt = (v: number) => decimals > 0 ? v.toFixed(decimals) : NumberFormatter.formatNumber(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;
}
});
}
}