feat(map): add hero info box panel for mission scene

1. 新增HeroBoxComp组件用于展示单个已登场英雄信息
2. 实现MissionCardComp的英雄信息盒槽位管理逻辑
3. 新增英雄升级事件监听与面板同步逻辑
4. 适配英雄召唤、死亡、出售后的UI刷新
This commit is contained in:
pan
2026-07-28 14:47:08 +08:00
parent 647301501c
commit 2a5051d46b
6 changed files with 6262 additions and 2195 deletions

View File

@@ -1,47 +1,35 @@
/**
* @file HeroBoxComp.ts
* @description 英雄卡池单个卡槽组件UI 视图层 + 购买/召唤逻辑
* @description 英雄面板单个英雄盒组件UI 视图层)
*
* 职责:
* 1. 渲染单个英雄卡的显示信息(名称、等级、描述、图标、费用),
* 展示方式与 SCardComp 一致(图集静态图标 + ★等级 + 描述词条)
* 同时支持动态升级卡SpecialUpgrade + target_hero_eid按英雄卡样式渲染
* 2. 处理购买点击 → UseHeroCard 上限校验(guard) → 扣费 → 派发 CallHero 召唤英雄。
* 特殊卡(升级/刷新)则派发 UseSpecialCard。
* 3. 使用成功后派发 CardUsed 事件,通知 MissionCardComp 刷新英雄卡池。
* 1. 同步显示一个已登场英雄的详细信息(图标 / 名称 / 等级 / AP / HP / 描述)。
* 2. 由 MissionCardComp 实例化到 herosBoxNode 下,最多 3 个,与场上英雄一一对应
* 3. 英雄卖出 / 死亡后槽位保留,通过 applyEmpty() 显示为空英雄信息
*
* 与 SCardComp 的差异
* - 数据源为 HeroInfoheroSet而非 SkillCardList / SkillSet。
* - 购买前需通过 GameEvent.UseHeroCard 做英雄数量上限校验。
* - 英雄卡效果事件为 GameEvent.CallHero而非 UseSkillCard。
* 依赖
* - HeroAttrsComp —— 英雄属性数据模型
* - HeroInfoheroSet—— 英雄静态配置
*/
import { mLogger } from "../common/Logger";
import { _decorator, Node, Sprite, Label, NodeEventType, Vec3, Tween, tween, UIOpacity, SpriteFrame, resources, AnimationClip } from "cc";
import { _decorator, Node, Sprite, Label, resources, AnimationClip, SpriteFrame } 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 { CardConfig, CardType } from "../common/config/CardSet";
import { HeroInfo } from "../common/config/heroSet";
import { oops } from "db://oops-framework/core/Oops";
import { GameEvent } from "../common/config/GameEvent";
import { smc } from "../common/SingletonModuleComp";
import { UIID } from "../common/config/GameUIConfig";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { FieldSkillType } from "../common/config/SkillSet";
import { FieldSkillHelper } from "../hero/FieldSkillHelper";
import { MissionEconomy } from "./MissionEconomy";
import { getLvColor } from "../common/config/GameSet";
const { ccclass, property } = _decorator;
/**
* HeroBoxComp —— 英雄卡池单个英雄卡项
* HeroBoxComp —— 英雄面板单个英雄信息盒
*
* 由 MissionCardComp.cacheCardComps() 实例化
* 以图标 + 名称 + 描述词条 + 购买按钮的形式呈现。
* 与场上英雄一一绑定eid英雄移除后显示空状态
*/
@ccclass('HeroBoxComp')
@ecs.register('HeroBoxComp', false)
export class HeroBoxComp extends CCComp {
private debugMode: boolean = true;
private debugMode: boolean = false;
// ======================== 编辑器绑定节点 ========================
@@ -58,12 +46,20 @@ export class HeroBoxComp extends CCComp {
@property(Label)
private level_label: Label = null;
// ======================== 运行时状态 ========================
/** 绑定的英雄实体 ID0 表示空槽位) */
private eid: number = 0;
/** 绑定的英雄属性数据模型引用 */
private model: HeroAttrsComp | null = null;
/** 图标视觉令牌(异步加载竞态保护) */
private iconVisualToken: number = 0;
/** 当前显示的英雄 UUID避免相同 UUID 重复加载动画) */
private iconHeroUuid: number = 0;
// ======================== 生命周期 ========================
onLoad() {
}
/** ECS 组件移除时销毁节点 */
@@ -72,4 +68,140 @@ export class HeroBoxComp extends CCComp {
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;
this.refresh();
}
/** 清空槽位,显示空英雄信息 */
public applyEmpty() {
this.eid = 0;
this.model = null;
this.iconHeroUuid = 0;
this.iconVisualToken += 1;
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.setAttrLabels(0, 0);
}
/**
* 刷新显示同步英雄等级、名称、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 ----
this.setAttrLabels(
Math.max(0, Math.floor(this.model.getFinalAp())),
Math.max(0, Math.floor(this.model.getFinalHpMax()))
);
// ---- 图标UUID 变化时重新加载首帧动画) ----
if (hero && heroUuid !== this.iconHeroUuid) {
this.iconHeroUuid = heroUuid;
this.iconVisualToken += 1;
this.updateHeroIcon(hero.path, heroUuid, this.iconVisualToken);
}
}
// ======================== 内部工具 ========================
/**
* 写入 AP / HP 数值标签。
* 兼容 herobox.prefab 节点结构attr/ap|hp → val
*/
private setAttrLabels(ap: number, hp: number) {
const attrNode = this.node.getChildByName("attr");
const apNode = attrNode?.getChildByName("ap");
const hpNode = attrNode?.getChildByName("hp");
const apLabel = apNode?.getChildByName("val")?.getComponent(Label)
?? apNode?.getComponentInChildren(Label) ?? null;
const hpLabel = hpNode?.getChildByName("val")?.getComponent(Label)
?? hpNode?.getComponentInChildren(Label) ?? null;
if (apLabel) apLabel.string = ap > 0 ? `${ap}` : "";
if (hpLabel) hpLabel.string = hp > 0 ? `${hp}` : "";
}
/**
* 加载英雄 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;
}
});
}
}

View File

@@ -45,6 +45,7 @@ import { drawItemCards } from "../common/config/ICardSet";
import { drawSkillCards } from "../common/config/SCardSet";
import { CHeroComp } from "./CHeroComp";
import { EquipListComp } from "./EquipListComp";
import { HeroBoxComp } from "./HeroBoxComp";
import { SCardComp } from "./SCardComp";
import { oops } from "db://oops-framework/core/Oops";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
@@ -143,7 +144,7 @@ export class MissionCardComp extends CCComp {
@property(Prefab)
itemPrefab: Prefab = null!
/** 卡池升级按钮节点(已废弃,保留节点引用防止 editor 报错) */
@property(Node)
cards_up: Node = null!
/** 金币显示节点(含 icon + num 子节点) */
@@ -173,6 +174,8 @@ export class MissionCardComp extends CCComp {
/** 三个槽位对应的 CHeroComp 控制器缓存(有序数组) */
private cardComps: CHeroComp[] = [];
/** 英雄面板 HeroBoxComp 控制器缓存(与场上英雄一一对应,最多 3 个) */
private heroBoxComps: HeroBoxComp[] = [];
/** 装备卡槽控制器缓存 */
private equipCardComps: EquipListComp[] = [];
/** 技能卡槽控制器缓存 */
@@ -318,6 +321,9 @@ export class MissionCardComp extends CCComp {
this.hasCalledHero = false;
this.updatePanelButtonsInteractable();
// 初始化英雄信息盒3 个空槽位)
this.refreshHeroBoxSlots();
mLogger.log(this.debugMode, "MissionCardComp", "mission start");
}
@@ -327,6 +333,11 @@ export class MissionCardComp extends CCComp {
this.clearEquipments();
this.clearSkillCards();
this.clearShopItems();
for (const comp of this.heroBoxComps) {
if (comp && comp.node && comp.node.isValid) {
comp.applyEmpty();
}
}
if (this.node && this.node.isValid) {
this.node.active = false;
}
@@ -368,6 +379,7 @@ export class MissionCardComp extends CCComp {
oops.message.on(GameEvent.MasterCalled, this.onMasterCalled, this);
oops.message.on(GameEvent.HeroDead, this.onHeroDead, this);
oops.message.on(GameEvent.HeroSell, this.onHeroSell, this);
oops.message.on(GameEvent.HeroLvUp, this.onHeroLvUp, this);
oops.message.on(GameEvent.UseHeroCard, this.onUseHeroCard, this);
// 监听装备购买事件,追踪已购记录
oops.message.on(GameEvent.UseEquipCard, this.onUseEquipCard, this);
@@ -610,6 +622,7 @@ export class MissionCardComp extends CCComp {
oops.message.off(GameEvent.MasterCalled, this.onMasterCalled, this);
oops.message.off(GameEvent.HeroDead, this.onHeroDead, this);
oops.message.off(GameEvent.HeroSell, this.onHeroSell, this);
oops.message.off(GameEvent.HeroLvUp, this.onHeroLvUp, this);
oops.message.off(GameEvent.UseHeroCard, this.onUseHeroCard, this);
oops.message.off(GameEvent.UseEquipCard, this.onUseEquipCard, this);
oops.message.off(GameEvent.UseItemCard, this.onUseItemCard, this);
@@ -681,16 +694,26 @@ export class MissionCardComp extends CCComp {
smc.finish_guides.push(3);
oops.gui.remove(UIID.Guide3);
}
// 英雄登场后同步英雄信息盒
this.refreshHeroBoxSlots();
}
/** 英雄死亡事件回调:刷新面板列表并更新英雄数量 UI */
private onHeroDead() {
this.updateHeroNumUI(true, false);
this.refreshHeroBoxSlots();
}
/** 英雄被出售事件回调:更新英雄数量 UI */
/** 英雄被出售事件回调:更新英雄数量 UI 并清空对应英雄信息盒 */
private onHeroSell() {
this.updateHeroNumUI(true, false);
this.refreshHeroBoxSlots();
}
/** 英雄升级事件回调:刷新英雄信息盒显示 */
private onHeroLvUp() {
this.refreshHeroBoxSlots();
}
/**
@@ -930,6 +953,71 @@ export class MissionCardComp extends CCComp {
this.slideToPanel(0);
}
// ======================== 英雄信息盒herosBoxNode ========================
/**
* 同步英雄信息盒槽位与场上英雄:
* 1. 确保 herosBoxNode 下存在 3 个 HeroBoxComp 槽位(复用 + 实例化补充)。
* 2. 查询当前存活英雄列表,按序绑定到槽位。
* 3. 多余槽位显示空英雄信息。
*
* Why: 英雄召唤 / 死亡 / 卖出 / 升级后调用,保证面板与场上一一对应。
*/
private refreshHeroBoxSlots() {
if (!this.herosBoxNode || !this.herosBoxNode.isValid) return;
this.ensureHeroBoxSlots(3);
// 查询场上存活英雄(含复活中的实体,按 eid 升序保证稳定顺序)
const actors: Array<{ eid: number, model: HeroAttrsComp }> = [];
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
const model = entity.get(HeroAttrsComp);
if (!model) return;
if (model.fac !== FacSet.HERO) return;
if (model.is_dead) return;
actors.push({ eid: entity.eid, model });
});
actors.sort((a, b) => a.eid - b.eid);
for (let i = 0; i < this.heroBoxComps.length; i++) {
const comp = this.heroBoxComps[i];
if (!comp || !comp.node || !comp.node.isValid) continue;
const actor = actors[i];
if (actor) {
comp.bindHero(actor.eid, actor.model);
} else {
comp.applyEmpty();
}
}
this.resizeListBox(this.herosBoxNode, Math.max(actors.length, 1));
}
/**
* 确保英雄信息盒槽位数量足够。
* 优先复用 herosBoxNode 现有子节点,不足时从 heroPrefab 实例化补充。
*/
private ensureHeroBoxSlots(targetCount: number) {
if (!this.herosBoxNode || !this.herosBoxNode.isValid) return;
// 刷新缓存
this.heroBoxComps = this.herosBoxNode.children
.map(node => node.getComponent(HeroBoxComp))
.filter((comp): comp is HeroBoxComp => !!comp);
// 补充实例化不足的槽位
while (this.heroBoxComps.length < targetCount) {
if (!this.heroPrefab) break;
const node = instantiate(this.heroPrefab);
// 先禁用根节点 Widget 再挂到父节点,避免 lateUpdate 覆盖 position
const widget = node.getComponent(Widget);
if (widget) widget.enabled = false;
this.herosBoxNode.addChild(node);
const comp = node.getComponent(HeroBoxComp) || node.addComponent(HeroBoxComp);
this.heroBoxComps.push(comp);
}
}
/**
* 显示卡牌面板:从底部向上滑入到 Y=0
*/
@@ -1401,7 +1489,7 @@ export class MissionCardComp extends CCComp {
}
// 滑动到英雄面板
this.slideToPanel(0);
mLogger.log(this.debugMode, "MissionCardComp", "enterPreparePhase herosPanNode", {
herosPanNodeActive: this.herosPanNode?.active,
herosPanNodePos: this.herosPanNode?.position,
@@ -1853,6 +1941,7 @@ export class MissionCardComp extends CCComp {
// 关键:在 reset/销毁 时将 Map 置空,彻底切断引用
this.cardComps = [];
this.heroBoxComps = [];
this.skillCardComps = [];
this.purchasedEquipUuids.clear();
this.purchasedItemUuids.clear();