Files
pixelheros/assets/script/game/map/HInfoComp.ts
walkpan 083cd9f195 fix(map): 调整英雄站位坐标并新增CD标签显示
1. 修正MissionHeroComp中的6个英雄占位坐标
2. 在HInfoComp中新增CD标签缓存引用并实现技能CD显示逻辑
3. 更新hnode.prefab的UI布局尺寸与元素位置适配新需求
2026-05-25 23:02:47 +08:00

535 lines
19 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 { buildSkillDesc } from "../common/config/HeroSkillDesc";
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!
@property(Node)
cd_node=null!
/** 绑定的英雄 ECS 实体 ID */
private eid: number = 0;
/** 绑定的英雄属性数据模型引用 */
private model: HeroAttrsComp | null = null;
/** 是否为静态预览模式(卡牌点击查看,非场上英雄) */
private isPreview: boolean = false;
/** 静态预览数据 */
private previewUuid: number = 0;
private previewLv: number = 1;
private previewPoolLv: number = 1;
/** 英雄名字标签缓存引用 */
private nameLabel: Label | null = null;
/** 技能信息标签缓存引用 */
private infoLabel: 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;
/** CD 标签缓存引用 */
private cdLabel: 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; heroUuid?: number; heroLv?: number; poolLv?: number }) {
if (args?.heroUuid) {
this.bindPreviewData(args.heroUuid, args.heroLv ?? 1, args.poolLv ?? 1);
return;
}
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);
}
}
/**
* 静态预览模式刷新:从 HeroInfo 配置读取初始数据。
*/
private refreshPreview() {
const heroUuid = this.previewUuid;
const hero = HeroInfo[heroUuid];
if (!hero) return;
const heroLv = Math.max(1, this.previewLv);
if (this.lv_node) {
this.lv_node.string = `Lv.${heroLv}`;
this.lv_node.color = getLvColor(heroLv);
}
if (this.pool_lvnode) {
const poolLvStr = `lv${this.previewPoolLv}`;
this.pool_lvnode.children.forEach(child => {
child.active = (child.name === poolLvStr);
});
}
if (heroUuid !== this.iconHeroUuid) {
this.iconHeroUuid = heroUuid;
this.iconVisualToken += 1;
this.updateHeroAnimation(this.icon_node, heroUuid, this.iconVisualToken);
}
if (this.nameLabel) {
this.nameLabel.string = hero.name ?? "";
}
if (this.apLabel) {
const ap = Math.max(0, Math.floor((hero.ap ?? 0) * heroLv));
this.apLabel.string = `${ap}`;
if (this.apPlusLabel) this.apPlusLabel.node.active = false;
}
if (this.hpLabel) {
const hp = Math.max(0, Math.floor((hero.hp ?? 0) * heroLv));
this.hpLabel.string = `${hp}`;
if (this.hpPlusLabel) this.hpPlusLabel.node.active = false;
}
if (this.infoLabel) {
this.infoLabel.string = buildSkillDesc(hero);
}
if (this.cdLabel) {
const skillKeys = hero.skills ? Object.keys(hero.skills) : [];
const displaySkill = skillKeys.length > 1 ? hero.skills[skillKeys[1]] : (skillKeys.length > 0 ? hero.skills[skillKeys[0]] : null);
this.cdLabel.string = displaySkill?.cd ? `${displaySkill.cd.toFixed(1)}s` : "0s";
}
}
/** 是否正在关闭中,防止重复调用 remove */
private isClosing: boolean = false;
update(dt: number) {
if (this.isClosing) return;
if (this.isPreview) 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.isPreview = false;
if (this.sell_node) this.sell_node.active = true;
if (this.close_node) this.close_node.active = true;
this.cacheLabels();
this.refresh();
}
/**
* 绑定静态预览数据(卡牌点击查看)。
* 通过 HeroInfo 配置 × 等级计算初始值,不绑定 ECS 实体。
*/
bindPreviewData(heroUuid: number, heroLv: number, poolLv: number) {
this.isPreview = true;
this.previewUuid = heroUuid;
this.previewLv = heroLv;
this.previewPoolLv = poolLv;
if (this.sell_node) this.sell_node.active = false;
if (this.close_node) this.close_node.active = false;
this.cacheLabels();
this.refresh();
}
/**
* 刷新显示:
* 1. 根据英雄等级切换高级 / 普通边框。
* 2. 若英雄 UUID 发生变化,重新加载 idle 动画。
* 3. 更新 AP / HP 数值标签。
*/
refresh() {
if (this.isPreview) {
this.refreshPreview();
return;
}
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.infoLabel) {
const heroData = HeroInfo[heroUuid];
this.infoLabel.string = heroData ? buildSkillDesc(heroData) : "";
}
// ---- 数值标签 ----
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;
}
}
}
if (this.cdLabel) {
const skillIds = this.model.getSkillIds();
const displaySkillId = skillIds[1] ?? skillIds[0] ?? 0;
if (displaySkillId) {
const effectiveCd = this.model.getEffectiveSkillCd(displaySkillId);
this.cdLabel.string = effectiveCd > 0 ? `${effectiveCd.toFixed(1)}s` : "0s";
} else {
this.cdLabel.string = "0s";
}
}
}
/**
* 检测绑定的英雄实体是否仍存活。
* 通过检查 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.infoLabel && this.info_node) {
this.infoLabel = this.info_node.getComponentInChildren(Label);
if (!this.infoLabel) {
const child = this.info_node.children[0] || this.info_node;
this.infoLabel = child.getComponent(Label) || child.addComponent(Label);
this.infoLabel.fontSize = 20;
this.infoLabel.lineHeight = 28;
this.infoLabel.overflow = Label.Overflow.SHRINK;
this.infoLabel.horizontalAlign = Label.HorizontalAlign.LEFT;
this.infoLabel.verticalAlign = Label.VerticalAlign.TOP;
}
}
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;
}
if (!this.cdLabel && this.cd_node) {
this.cdLabel = this.cd_node.getChildByName("val")?.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) return;
if (this.isPreview) {
if (this.previewUuid !== uuid) return;
} else {
if (!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;
this.isPreview = false;
this.previewUuid = 0;
this.previewLv = 1;
this.previewPoolLv = 1;
}
}