Files
pixelheros/assets/script/game/map/HInfoComp.ts
panw 8026c2368e feat: 添加等级颜色显示,优化卡池和英雄等级UI
1.  新增getLvColor工具函数,根据等级返回对应颜色
2.  为英雄信息面板和卡牌添加等级文本颜色设置
3.  重构卡池等级节点命名和显示逻辑,修复prefab布局
4.  新增英雄自身等级显示组件到卡牌预制件
2026-05-25 09:49:34 +08:00

406 lines
14 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 HInfoComp.ts
* @description 场上英雄信息卡片组件UI 视图层)
*
* 职责:
* 1. 显示单个场上英雄的实时属性信息AP / HP / 边框品质 / idle 动画)。
* 2. 由 MissionCardComp.ensureHeroInfoPanel() 在英雄上场时实例化并绑定数据。
* 3. 支持点击打开英雄详情弹窗IBox、出售英雄等交互当前已注释
*
* 关键设计:
* - 通过 bindData(eid, model) 将组件与某个英雄 ECS 实体绑定。
* - refresh() 被 MissionCardComp 定时调用以同步实时属性变化。
* - iconVisualToken 机制与 CardComp 一致,用于异步动画加载的竞态保护。
* - isModelAlive() 检测绑定的英雄实体是否仍存活ECS ent 引用是否有效)。
*
* 依赖:
* - HeroAttrsComp —— 英雄属性数据模型
* - HeroInfoheroSet—— 英雄静态配置
* - Hero —— 英雄 ECS 实体类(用于出售删除)
* - UIID.IBox —— 英雄详情弹窗 ID
*/
import { _decorator, Animation, AnimationClip, Button, Event, Label, Node, NodeEventType, Sprite, resources, CCInteger } 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 { HeroInfo } from "../common/config/heroSet";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { smc } from "../common/SingletonModuleComp";
import { TalentType } from "../common/config/TalentSet";
import { Hero } from "../hero/Hero";
import { FieldSkillType } from "../common/config/SkillSet";
import { GameEvent } from "../common/config/GameEvent";
import { oops } from "db://oops-framework/core/Oops";
import { UIID } from "../common/config/GameUIConfig";
import { mLogger } from "../common/Logger";
import { MissionHeroComp } from "./MissionHeroComp";
import { MoveComp } from "../hero/MoveComp";
import { FacSet, getLvColor } from "../common/config/GameSet";
import { MissionEconomy } from "./MissionEconomy";
const {property, ccclass } = _decorator;
/**
* HInfoComp —— 场上英雄信息面板视图组件
*
* 每个实例对应一个出战英雄,在卡牌面板下方横向排列。
* 实时显示英雄的 AP / HP 和 idle 动画图标。
*/
@ccclass('HInfoComp')
@ecs.register('HInfoComp', false)
export class HInfoComp extends CCComp {
/** 英雄 idle 动画图标节点 */
@property(Node)
icon_node=null!
/** 出售按钮节点 */
@property(Node)
sell_node=null!
/** 关闭窗口按钮节点 */
@property(Node)
close_node=null!
/** 英雄名字节点 */
@property(Node)
Name_node=null!
/** 高品质边框 */
@property(Node)
info_node=null!
@property(Label)
lv_node: Label = null!
@property(Node)
pool_lvnode: Node = null!
@property(Node)
ap_node=null!
@property(Node)
hp_node=null!
/** 绑定的英雄 ECS 实体 ID */
private eid: number = 0;
/** 绑定的英雄属性数据模型引用 */
private model: HeroAttrsComp | null = null;
/** 英雄名字标签缓存引用 */
private nameLabel: Label | null = null;
/** AP 标签缓存引用 */
private apLabel: Label | null = null;
/** AP 加成标签缓存引用 */
private apPlusLabel: Label | null = null;
/** HP 标签缓存引用 */
private hpLabel: Label | null = null;
/** HP 加成标签缓存引用 */
private hpPlusLabel: Label | null = null;
/** 图标视觉令牌(异步加载竞态保护) */
private iconVisualToken: number = 0;
/** 当前显示的英雄 UUID避免相同 UUID 重复加载动画) */
private iconHeroUuid: number = 0;
/** 调试日志开关 */
private debugMode: boolean = true;
onLoad() {
this.cacheLabels();
this.bindEvents();
}
onAdded(args: { eid: number }) {
const eid = args?.eid ?? 0;
if (!eid) return;
let foundModel: HeroAttrsComp | null = null;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
if (entity.eid === eid) {
foundModel = entity.get(HeroAttrsComp);
}
});
if (foundModel) {
this.bindData(eid, foundModel);
} else {
this.isClosing = true;
oops.gui.remove(UIID.HInfo);
}
}
/** 是否正在关闭中,防止重复调用 remove */
private isClosing: boolean = false;
update(dt: number) {
if (this.isClosing) return;
if (!this.isModelAlive()) {
this.isClosing = true;
oops.gui.remove(UIID.HInfo);
return;
}
this.refresh();
}
onDestroy() {
super.onDestroy();
this.unbindEvents();
}
/**
* 绑定英雄数据:关联实体 ID 和属性模型,并立即刷新显示。
* @param eid 英雄 ECS 实体 ID
* @param model 英雄属性组件引用
*/
bindData(eid: number, model: HeroAttrsComp) {
this.eid = eid;
this.model = model;
this.cacheLabels();
this.refresh();
}
/**
* 刷新显示:
* 1. 根据英雄等级切换高级 / 普通边框。
* 2. 若英雄 UUID 发生变化,重新加载 idle 动画。
* 3. 更新 AP / HP 数值标签。
*/
refresh() {
if (!this.model) return;
// ---- 卡牌等级显示 ----
if (this.lv_node) {
const currentLv = this.model.lv ?? 1;
this.lv_node.string = `Lv.${currentLv}`;
this.lv_node.color = getLvColor(currentLv);
}
// ---- 卡池等级标识 ----
if (this.pool_lvnode) {
const poolLvStr = `lv${this.model.pool_lv ?? 1}`;
this.pool_lvnode.children.forEach(child => {
// if (child.name === "light") {
// child.active = false;
// } else if (child.name === "bg") {
// child.active = true;
// } else {
child.active = (child.name === poolLvStr);
// }
});
}
// ---- 图标动画(仅在 UUID 变化时重新加载) ----
const heroUuid = this.model.hero_uuid ?? 0;
if (heroUuid !== this.iconHeroUuid) {
this.iconHeroUuid = heroUuid;
this.iconVisualToken += 1;
this.updateHeroAnimation(this.icon_node, heroUuid, this.iconVisualToken);
}
// ---- 名字标签 ----
if (this.nameLabel) {
this.nameLabel.string = this.model.hero_name ?? "";
}
// ---- 数值标签 ----
if (this.apLabel) {
const currentAp = Math.max(0, Math.floor(this.model.ap ?? 0));
const baseAp = Math.max(0, Math.floor(this.model.base_ap ?? 0));
this.apLabel.string = `${currentAp}`;
if (this.apPlusLabel) {
const diff = currentAp - baseAp;
if (diff !== 0) {
this.apPlusLabel.string = diff > 0 ? `(+${diff})` : `(${diff})`;
this.apPlusLabel.node.active = true;
} else {
this.apPlusLabel.node.active = false;
}
}
}
if (this.hpLabel) {
const currentHp = Math.max(0, Math.floor(this.model.hp_max ?? 0));
const baseHp = Math.max(0, Math.floor(this.model.base_hp ?? 0));
this.hpLabel.string = `${currentHp}`;
if (this.hpPlusLabel) {
const diff = currentHp - baseHp;
if (diff !== 0) {
this.hpPlusLabel.string = diff > 0 ? `(+${diff})` : `(${diff})`;
this.hpPlusLabel.node.active = true;
} else {
this.hpPlusLabel.node.active = false;
}
}
}
}
/**
* 检测绑定的英雄实体是否仍存活。
* 通过检查 model 上的 ent 引用判断 ECS 实体是否已被回收。
*/
isModelAlive(): boolean {
return !!(this.model as any)?.ent;
}
// ======================== 内部工具方法 ========================
/** 缓存 AP / HP Label 引用,避免每次刷新都遍历节点树 */
private cacheLabels() {
if (!this.nameLabel && this.Name_node) {
this.nameLabel = this.Name_node.getComponent(Label) || this.Name_node.getComponentInChildren(Label);
}
if (!this.apLabel && this.ap_node) {
this.apLabel = this.ap_node.getChildByName("val")?.getComponent(Label) || null;
this.apPlusLabel = this.ap_node.getChildByName("plus")?.getComponent(Label) || null;
}
if (!this.hpLabel && this.hp_node) {
this.hpLabel = this.hp_node.getChildByName("val")?.getComponent(Label) || null;
this.hpPlusLabel = this.hp_node.getChildByName("plus")?.getComponent(Label) || null;
}
}
/**
* 按节点路径查找 Label 组件
* @param path 从当前节点开始的子节点名称路径数组
* @returns 找到的 Label 组件,或 null
*/
private findLabelByPath(path: string[]): Label | null {
let current: Node | null = this.node;
for (let i = 0; i < path.length; i++) {
current = current?.getChildByName(path[i]) ?? null;
if (!current) return null;
}
return current.getComponent(Label) || current.getComponentInChildren(Label);
}
// ======================== 英雄动画 ========================
/**
* 为英雄图标加载并播放 idle 动画(带竞态保护)。
* @param node 图标节点
* @param uuid 英雄 UUID
* @param token 视觉令牌
*/
private updateHeroAnimation(node: Node, uuid: number, token: number) {
if (!node) return;
const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite);
if (sprite) sprite.spriteFrame = null;
const hero = HeroInfo[uuid];
if (!hero) {
this.clearIconAnimation(node);
return;
}
const anim = node.getComponent(Animation) || node.addComponent(Animation);
this.clearAnimationClips(anim);
const path = `game/heros/hero/${hero.path}/idle`;
resources.load(path, AnimationClip, (err, clip) => {
if (err || !clip) return;
// 竞态保护
if (token !== this.iconVisualToken || !this.model || this.model.hero_uuid !== uuid) {
return;
}
this.clearAnimationClips(anim);
anim.addClip(clip);
anim.play("idle");
});
}
/** 停止并清除图标节点上的动画 */
private clearIconAnimation(node: Node) {
const anim = node?.getComponent(Animation);
if (!anim) return;
anim.stop();
this.clearAnimationClips(anim);
}
/** 移除 Animation 组件上的全部 AnimationClip */
private clearAnimationClips(anim: Animation) {
const clips = (anim as any).clips as AnimationClip[] | undefined;
if (!clips || clips.length === 0) return;
[...clips].forEach(clip => anim.removeClip(clip, true));
}
// ======================== 交互 ========================
private bindEvents() {
this.sell_node?.on(Button.EventType.CLICK, this.onSellHero, this);
this.close_node?.on(Button.EventType.CLICK, this.onClosePanel, this);
// this.node.on(NodeEventType.TOUCH_END, this.onOpenIBox, this);
}
private unbindEvents() {
if (this.sell_node && this.sell_node.isValid) {
this.sell_node.off(Button.EventType.CLICK, this.onSellHero, this);
}
if (this.close_node && this.close_node.isValid) {
this.close_node.off(Button.EventType.CLICK, this.onClosePanel, this);
}
// if (this.node && this.node.isValid) {
// this.node.off(NodeEventType.TOUCH_END, this.onOpenIBox, this);
// }
}
/**
* 点击关闭按钮时关闭英雄信息面板
*/
private onClosePanel() {
if (this.isClosing) return;
this.isClosing = true;
oops.gui.remove(UIID.HInfo);
}
/**
* 点击面板时打开英雄详情弹窗IBox
* 传入英雄 UUID、等级和技能列表。
*/
private onOpenIBox() {
// if (this.isClosing) return;
// if (!this.model) return;
// if (!this.isModelAlive()) return;
// const heroUuid = this.model.hero_uuid ?? 0;
// if (!heroUuid || !HeroInfo[heroUuid]) return;
// const heroLv = Math.max(1, Math.floor(this.model.lv ?? 1));
// this.isClosing = true;
// oops.gui.remove(UIID.HInfo); // 打开 IBox 前关闭自身
// oops.gui.remove(UIID.IBox);
// oops.gui.open(UIID.IBox, {
// heroUuid,
// heroLv,
// skills: this.model.skills
// });
}
/**
* 出售英雄:通过 Hero.removeByEid 移除 ECS 实体,
* 并关闭详情弹窗。
*/
private onSellHero(event?: Event) {
if (this.isClosing) return;
if (!this.eid) return;
const heroLv = Math.max(1, Math.floor(this.model?.lv ?? 1));
const removed = Hero.removeByEid(this.eid);
mLogger.log(this.debugMode, "HInfoComp", "onSellHero", {
eid: this.eid,
heroLv,
isAlive: this.isModelAlive(),
removed
});
if (!removed) return;
// 使用统一经济管理入口出售英雄(按等级计算卖价)
MissionEconomy.executeSellHero(heroLv);
// 派发英雄出售事件,通知 MissionCardComp 更新场上英雄数量
oops.message.dispatchEvent(GameEvent.HeroSell, { eid: this.eid });
this.isClosing = true;
oops.gui.remove(UIID.HInfo);
}
/** ECS 组件移除时的释放钩子:清理动画资源并销毁节点 */
reset() {
this.clearIconAnimation(this.icon_node);
this.iconVisualToken = 0;
this.iconHeroUuid = 0;
this.model = null;
this.eid = 0;
// 弹窗节点的生命周期由 oops.gui 统一管理,此处不再主动销毁节点
// if (this.node && this.node.isValid) {
// this.node.destroy();
// }
}
}