feat(map): add hero info box panel for mission scene
1. 新增HeroBoxComp组件用于展示单个已登场英雄信息 2. 实现MissionCardComp的英雄信息盒槽位管理逻辑 3. 新增英雄升级事件监听与面板同步逻辑 4. 适配英雄召唤、死亡、出售后的UI刷新
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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 的差异:
|
||||
* - 数据源为 HeroInfo(heroSet),而非 SkillCardList / SkillSet。
|
||||
* - 购买前需通过 GameEvent.UseHeroCard 做英雄数量上限校验。
|
||||
* - 英雄卡效果事件为 GameEvent.CallHero,而非 UseSkillCard。
|
||||
* 依赖:
|
||||
* - HeroAttrsComp —— 英雄属性数据模型
|
||||
* - HeroInfo(heroSet)—— 英雄静态配置
|
||||
*/
|
||||
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;
|
||||
|
||||
// ======================== 运行时状态 ========================
|
||||
|
||||
/** 绑定的英雄实体 ID(0 表示空槽位) */
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user