Files
pixelheros/assets/script/game/map/HeroBoxComp.ts
panFD 096cae527b refactor(heroBox,skillDesc): 优化英雄面板技能描述显示
1. 新增showName参数控制技能描述是否显示技能名
2. 英雄盒面板隐藏技能名仅保留效果描述
3. 新增英雄强度评分公式设计文档
2026-08-01 19:55:14 +08:00

488 lines
20 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. 由 MissionCardComp 实例化到 herosBoxNode 下,最多 3 个,与场上英雄一一对应。
* 3. 英雄卖出 / 死亡后槽位保留,通过 applyEmpty() 显示为空英雄信息。
*
* 依赖:
* - HeroAttrsComp —— 英雄属性数据模型
* - HeroInfoheroSet—— 英雄静态配置
*/
import { mLogger } from "../common/Logger";
import { _decorator, Node, Sprite, Label, RichText, 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 { buildSkillDescRich } 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(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;
/** 击晕信息栏(默认隐藏) */
@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;
// ======================== 运行时状态 ========================
/** 绑定的英雄实体 ID0 表示空槽位) */
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 = "";
}
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);
}
// ---- 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) {
// 缓存未变则跳过重设,避免 refresh 降频内 RichText 重复重解析排版;隐藏「技能名」仅保留效果描述
const desc = buildSkillDescRich(this.model, false);
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);
}
}
// ======================== 内部工具 ========================
/**
* 写入一行战斗信息(总值 + 附加值)。
* 总值始终显示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;
}
});
}
}