Files
pixelheros/assets/script/game/map/HeroBoxComp.ts
pan d9bccd7c93 refactor: 将代码中的card_lv统一替换为rarity
本次提交完成了全项目范围内术语的统一替换,将原本指代卡牌品质/等级的card_lv字段全部重命名为rarity,对齐了设计文档中的命名规范,避免了术语混淆:
1.  更新了所有配置文件、组件类、数据结构中的字段定义
2.  同步修改了注释、文档说明中的相关表述
3.  修正了英雄、技能、装备卡牌的品质显示逻辑
4.  调整了背景颜色切换、属性计算等相关业务逻辑
2026-09-02 16:42:54 +08:00

676 lines
28 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 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, calcGrowBonus } from "../common/config/heroSet";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { Hero } from "../hero/Hero";
import { FacSet, FightSet, getLvColor, NumberFormatter } from "../common/config/GameSet";
import { buildHeroSkillDesc } from "../common/HeroSkillText";
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));
// 无限升级:不再拦截满级
oops.audio.playEffect("music/button");
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(heroLv, nextLv);
// oops.gui 公共确认窗口(自定义 CommonPrompt:内容支持富文本高亮)
oops.gui.open(UIID.Window, {
title: `升级英雄`,
content: 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 });
}
/**
* 构建升级前后对比富文本(当前属性/能力 vs 升级后属性/能力)。
* 技能描述统一按局外等级口径渲染(buildHeroSkillDesc),与英雄信息弹窗同一套
* 文案规则;战斗内升级不改变局外等级,故技能段前后一致,仅属性段随等级变化。
*
* @param heroLv 当前英雄等级
* @param nextLv 目标等级
* @returns 多行富文本:当前属性→升级后属性 + 当前技能 vs 升级后技能
*/
private buildUpgradePreview(heroLv: number, nextLv: number): string {
const hero = HeroInfo[this.model?.hero_uuid ?? 0];
if (!hero) return "";
// ---- 属性成长(固定基础值 + 成长类型指数递增 + 一次性加成) ----
const attrLine = (lv: number): string => {
const grow = calcGrowBonus(hero, lv);
const ap = Math.floor(hero.ap + grow.ap + HeroBoxComp.sumBonus(hero.ap_bonus, lv));
const hp = Math.floor(hero.hp + grow.hp + HeroBoxComp.sumBonus(hero.hp_bonus, lv));
return `攻击 <color=#27AE60>${ap}</color> 生命 <color=#27AE60>${hp}</color>`;
};
// ---- 升级前后能力对比(局外等级口径,战斗内升级技能段不变) ----
const skillDesc = buildHeroSkillDesc(hero.uuid);
const curDesc = skillDesc;
const nextDesc = skillDesc;
return `<outline color=#000000 width=2>` + [
`<b>当前 Lv.${heroLv}:</b>`,
attrLine(heroLv),
curDesc,
``,
`<b>升级后 Lv.${nextLv}:</b>`,
attrLine(nextLv),
nextDesc,
].filter(s => s !== undefined).join("\n") + `</outline>`;
}
/** 累加等级额外加成(≤目标等级的所有档位) */
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();
}
/**
* 英雄盒按下:记录起点并启动长按判定。
* 出售/升级按钮上的触摸不触发长按(按钮有各自的 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];
// ---- 背景(按 rarity 切换 bg_node 颜色子节点) ----
this.updateBgNode(hero?.rarity ?? 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);
}
// ---- 出售价格(按等级计算) ----
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));
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 ----
// plus(附加值)= 最终值 - 当前等级基础值;基础值 = 1 级原始 base + 等级成长 + ap/hp_bonus 一次性奖励。
// Why: base_ap/base_hp 是不含任何等级增长的原始值,直接用 final-base 会把升级带来的
// 属性增长也计入增量。这里补齐等级增长作为基准,使 plus 仅反映 buff/光环等临时增益。
const lv = Math.max(1, Math.floor(this.model.lv ?? 1));
const grow = hero ? calcGrowBonus(hero, lv) : { ap: 0, hp: 0 };
const finalAp = Math.max(0, Math.floor(this.model.getFinalAp()));
const lvBaseAp = Math.max(0, Math.floor((this.model.base_ap ?? 0) + grow.ap + HeroBoxComp.sumBonus(hero?.ap_bonus, lv)));
this.setStatRow(this.ap_all_label, this.ap_plus_label, finalAp, finalAp - lvBaseAp);
const finalHp = Math.max(0, Math.floor(this.model.getFinalHpMax()));
const lvBaseHp = Math.max(0, Math.floor((this.model.base_hp ?? 0) + grow.hp + HeroBoxComp.sumBonus(hero?.hp_bonus, lv)));
this.setStatRow(this.hp_all_label, this.hp_plus_label, finalHp, finalHp - lvBaseHp);
// ---- 图标(UUID 变化时重新加载首帧动画) ----
if (hero && heroUuid !== this.iconHeroUuid) {
this.iconHeroUuid = heroUuid;
this.iconVisualToken += 1;
this.updateHeroIcon(hero.path, heroUuid, this.iconVisualToken);
}
}
// ======================== 内部工具 ========================
/**
* 根据 rarity 切换 bg_node 下的颜色子节点显示。
* 等级与颜色对应关系:
* rarity 1 → green
* rarity 2 → blue
* rarity 3 → purple
* rarity 4 → red
* rarity 5 → yellow
* @param rarity 英雄卡牌等级(HeroInfo.rarity,缺省按 1 处理)
*/
private updateBgNode(rarity: number) {
if (!this.bg_node || !this.bg_node.isValid) return;
const lvToColor: Record<number, string> = {
1: "green",
2: "blue",
3: "purple",
4: "red",
5: "yellow",
};
const targetColor = lvToColor[rarity] ?? 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;
}
});
}
}