Files
pixelheros/assets/script/game/map/MissionCardComp.ts
pan 401b02be96 feat: 新增装备商店系统,调整英雄站位与抽卡逻辑
1.  重构抽卡函数,新增排除装备卡参数,装备卡改为商店直购
2.  添加装备商店面板,支持装备浏览与购买,跨波次保留购买记录
3.  调整英雄出场点位坐标,优化战场布局
4.  扩展技能卡槽位,新增两行技能槽位置
5.  重构EquipListComp组件,适配装备商店UI逻辑
6.  删除废弃的melist预制件与相关元文件
7.  调整hnode预制件布局参数
2026-07-22 10:44:01 +08:00

1454 lines
58 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 MissionCardComp.ts
* @description 卡牌系统核心控制器(战斗 UI 层 + 业务逻辑层)
*
* 职责:
* 1. **卡牌分发管理** —— 从卡池抽取 3 张卡,分发到 3 个 CardComp 槽位。
* 抽卡规则:场上已有英雄时按权重混合"对应英雄的升级卡" + "其他英雄卡" + "刷新卡"
* 场上无英雄时仅抽取英雄卡和刷新卡。
* 2. **金币费用管理** —— 抽卡费用refreshCost的扣除。
* 3. **英雄数量上限校验** —— 在 UseHeroCard 事件的 guard 阶段判断是否允许再召唤英雄。
* 4. **场上英雄信息面板HInfoComp 列表)同步** ——
* 英雄上场时实例化面板,死亡时移除,定时刷新属性显示。
* 5. **特殊卡执行** —— 处理英雄升级卡SpecialUpgrade按 UUID 精确升级)和
* 英雄刷新卡SpecialRefresh
* 6. **准备/战斗阶段切换** —— 控制卡牌面板的 展开/收起 动画。
*
* 关键设计:
* - 3 个 CardComp 通过 cacheCardComps() 映射为有序数组 cardComps[]
* 之后所有分发、清空操作均通过此数组进行。
* - buildDrawCards() 动态合并升级卡池和基础卡池后抽取 3 张,不足时循环补齐。
* - 英雄上限校验onUseHeroCard采用 guard/cancel 模式:
* CardComp 发出 UseHeroCard 事件并传入 guard 对象,
* 本组件可通过 guard.cancel=true 阻止使用。
*
* 历史:
* 旧版本曾包含"卡池等级poolLv"机制和"三合一合成腾位"判断,已全部移除:
* - 卡牌不再分级,所有英雄卡统一 lv1。
* - 英雄升级仅通过升级卡SpecialUpgrade触发按 UUID 精确升级场上对应英雄。
*
* 依赖:
* - CardComp —— 单卡槽位
* - HInfoComp —— 英雄信息面板
* - CardSet 模块 —— 卡池配置、抽卡规则、特殊卡数据
* - HeroAttrsComp —— 英雄属性(升级)
* - smc.vmdata.mission_data —— 局内数据coin / hero_num / hero_max_num
*/
import { mLogger } from "../common/Logger";
import { _decorator, instantiate, Label, Node, NodeEventType, Prefab, 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 { GameEvent } from "../common/config/GameEvent";
import { CardConfig, CardPoolList, CardType, drawCardsByRule, isEquipmentCard, SpecialRefreshCardList, SpecialRefreshHeroType, SpecialUpgradeCardList } from "../common/config/CardSet";
import { CardComp } from "./CardComp";
import { SCardComp } from "./SCardComp";
import { EquipListComp } from "./EquipListComp";
import { oops } from "db://oops-framework/core/Oops";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { smc } from "../common/SingletonModuleComp";
import { HeroInfo, HType } from "../common/config/heroSet";
import { HeroViewComp } from "../hero/HeroViewComp";
import { FacSet, FightSet } from "../common/config/GameSet";
import { MissionEconomy } from "./MissionEconomy";
import { UIID } from "../common/config/GameUIConfig";
const { ccclass, property } = _decorator;
/**
* MissionCardComp —— 卡牌系统核心控制器
*
* 管理 3 个卡牌槽位的抽卡分发、金币费用、
* 英雄上限校验、场上英雄信息面板同步以及特殊卡执行。
*/
@ccclass('MissionCardComp')
@ecs.register('MissionCard', false)
export class MissionCardComp extends CCComp {
/** 是否启用调试日志 */
private debugMode: boolean = false;
/** 卡牌槽位宽度(像素),用于水平等距布局 */
private readonly cardWidth: number = 175;
/** 按钮正常缩放 */
private readonly buttonNormalScale: number = 1;
/** 按钮按下缩放 */
private readonly buttonPressScale: number = 0.94;
/** 按钮弹起缩放(峰值) */
private readonly buttonClickScale: number = 1.06;
/** 抽卡(刷新)费用 */
refreshCost: number = FightSet.REFRESH_COST;
/** 卡牌面板展开/收起动画时长(秒) */
cardsPanelMoveDuration: number = 0.2;
// ======================== 编辑器绑定节点 ========================
/** 卡牌面板根节点(战斗阶段收起,准备阶段展开) */
@property(Node)
cards_node: Node = null!
/** 卡牌槽位 1 节点 */
@property(Node)
card1: Node = null!
/** 卡牌槽位 2 节点 */
@property(Node)
card2: Node = null!
/** 卡牌槽位 3 节点 */
@property(Node)
card3: Node = null!
/** 卡牌槽位 4 节点 */
@property(Node)
card4: Node = null!
/** 抽卡(刷新)按钮节点 */
@property(Node)
cards_chou: Node = null!
/** 英雄卡牌池cards_node显示隐藏 */
@property(Node)
showHeros: Node = null!
/** 关闭英雄卡池按钮(仅负责收起 cards_node */
@property(Node)
closeHeros: Node = null!
/** 卡池升级按钮节点(已废弃,保留节点引用防止 editor 报错) */
/** 英雄卡牌池cards_node显示隐藏 */
@property(Node)
showEquips: Node = null!
/** 关闭英雄卡池按钮(仅负责收起 cards_node */
@property(Node)
closeEquips: Node = null!
@property(Node)
equipsPanNode: Node = null!
@property(Node)
equipsBoxNode: Node = null!
@property(Prefab)
equipPrefab: Prefab = null!
/** 卡池升级按钮节点(已废弃,保留节点引用防止 editor 报错) */
/** 英雄卡牌池cards_node显示隐藏 */
@property(Node)
showShop: Node = null!
/** 关闭英雄卡池按钮(仅负责收起 cards_node */
@property(Node)
closeShop: Node = null!
/** 卡池升级按钮节点(已废弃,保留节点引用防止 editor 报错) */
@property(Node)
cards_up: Node = null!
/** 金币显示节点(含 icon + num 子节点) */
@property(Node)
coins_node: Node = null!
/** 卡池等级显示节点(已废弃,保留节点引用) */
@property(Node)
pool_lv_node: Node = null!
/** 英雄数量显示节点(含 icon + num 子节点) */
@property(Node)
hero_num_node: Node = null!
/** 技能卡牌三选一弹窗节点 */
@property(Node)
skill_card_node: Node = null!
/**技能卡槽1节点 */
@property(Node)
skill_card1: Node = null!
/**技能卡槽2节点 */
@property(Node)
skill_card2: Node = null!
/**技能卡槽3节点 */
@property(Node)
skill_card3: Node = null!
/**技能刷新按钮节点 */
@property(Node)
skill_refresh: Node = null!
/**技能广告刷新按钮节点 */
@property(Node)
skill_ad_refresh: Node = null!
/**可用刷新数显示节点 */
@property(Node)
skill_refresh_num_node: Node = null!
// ======================== 运行时状态 ========================
/** 三个槽位对应的 CardComp 控制器缓存(有序数组) */
private cardComps: CardComp[] = [];
/** 技能卡槽控制器缓存 */
private skillCardComps: SCardComp[] = [];
/** 已购买装备的 UUID 集合(跨波次保持,防止重复购买) */
private purchasedEquipUuids: Set<number> = new Set();
/** 是否已缓存卡牌面板基准缩放 */
private hasCachedCardsBaseScale: boolean = false;
/** 卡牌面板基准缩放(从场景读取) */
private cardsBaseScale: Vec3 = new Vec3(1, 1, 1);
/** 卡牌面板展开态缩放 */
private cardsShowScale: Vec3 = new Vec3(1, 1, 1);
/** 卡牌面板收起态缩放scale=0 隐藏) */
private cardsHideScale: Vec3 = new Vec3(0, 0, 1);
/** 卡牌原始定位点 */
private cardsPos = [-220, 0, 220]
/**
* 必出升级卡的轮询索引:每次刷新按场上可升级英雄 UUID 升序轮询选 1 张。
* 每次 buildDrawCards 取模更新,保证多次刷新时升级卡依次切换。
*/
private upgradeRotationIndex: number = 0;
// ======================== 生命周期 ========================
/**
* 组件加载:
* 1. 绑定生命周期事件和按钮交互事件。
* 2. 缓存 3 个 CardComp 子控制器引用。
* 3. 计算并设置槽位水平布局。
* 4. 初始化卡牌面板缩放参数。
*/
onLoad() {
this.bindEvents();
this.cacheCardComps();
this.layoutCardSlots();
this.initCardsPanelPos();
mLogger.log(this.debugMode, "MissionCardComp", "onLoad init", {
slots: this.cardComps.length,
});
}
/** 组件销毁时解绑所有事件并清理英雄信息面板 */
onDestroy() {
super.onDestroy();
if (this.cards_chou && this.cards_chou.isValid) {
this.cards_chou.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_chou.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_chou.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
}
if (this.showHeros && this.showHeros.isValid) {
this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, this);
}
if (this.closeHeros && this.closeHeros.isValid) {
this.closeHeros.off(NodeEventType.TOUCH_END, this.onCloseHerosClick, this);
}
if (this.showEquips && this.showEquips.isValid) {
this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this);
}
if (this.closeEquips && this.closeEquips.isValid) {
this.closeEquips.off(NodeEventType.TOUCH_END, this.onCloseEquipsClick, this);
}
this.unbindEvents();
}
/** 外部初始化入口(由 CardController 调用) */
init() {
this.onMissionStart();
}
/**
* 任务开始:
* 1. 进入准备阶段(展开卡牌面板)。
* 2. 初始化局内数据(金币、英雄数量上限)。
* 3. 清空旧英雄信息面板和卡牌槽位。
* 4. 重置按钮状态和 UI 显示。
* 5. 执行首次抽卡并分发到 3 个槽位。
*/
onMissionStart() {
this.enterPreparePhase();
const missionData = this.getMissionData();
if (missionData) {
missionData.coin = Math.max(0, Math.floor(missionData.coin ?? 0));
missionData.hero_num = 0;
missionData.hero_max_num = FightSet.HERO_MAX_NUM;
missionData.hero_extend_max_num = FightSet.HERO_MAX_NUM + 1;
}
// 确保卡牌组件列表已被正确缓存
if (!this.cardComps || this.cardComps.length === 0) {
this.cacheCardComps();
}
this.layoutCardSlots();
this.clearAllCards();
this.resetButtonScale(this.cards_chou);
this.updateCoinAndCostUI();
this.updateHeroNumUI(false, false);
if (this.node && this.node.isValid) {
this.node.active = true;
}
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
// 重置购买记录并填充装备商店
this.purchasedEquipUuids.clear();
this.populateEquipments();
mLogger.log(this.debugMode, "MissionCardComp", "mission start");
}
/** 任务结束:清空 3 槽 + 装备商店 + 英雄面板并隐藏整个节点 */
onMissionEnd() {
this.clearAllCards();
this.clearEquipments();
if (this.node && this.node.isValid) {
this.node.active = false;
}
}
start() {
}
/**
* 帧更新:每 0.15 秒刷新一次场上英雄信息面板(降频)。
* 检测已死亡 / 已失效的面板并移除,刷新存活面板属性。
*/
update(dt: number) {
}
/** 关闭面板(不销毁数据模型,仅隐藏) */
close() {
if (this.node && this.node.isValid) {
this.node.active = false;
}
}
// ======================== 事件绑定 ========================
/**
* 绑定所有事件监听:
* - 节点级事件MissionStart / MissionEnd / FightStart
* - 全局消息CoinAdd / MasterCalled / HeroDead / UseHeroCard / UseSpecialCard
* - 按钮触控抽卡cards_chou
*/
private bindEvents() {
/** 生命周期事件(节点级) */
this.on(GameEvent.MissionStart, this.onMissionStart, this);
this.on(GameEvent.MissionEnd, this.onMissionEnd, this);
this.on(GameEvent.FightStart, this.onFightStart, this);
this.on("PhasePrepareStart", this.onPhasePrepareStart, this);
oops.message.on(GameEvent.CoinAdd, this.onCoinAdd, this);
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.UseHeroCard, this.onUseHeroCard, this);
oops.message.on(GameEvent.UseSkillCard, this.onUseSkillCard, this);
oops.message.on(GameEvent.UseSpecialCard, this.onUseSpecialCard, this);
oops.message.on(GameEvent.ShowSmallTip, this.onShowSmallTip, this);
oops.message.on(GameEvent.CardUsed, this.onCardUsed, this);
/** 按钮触控事件:抽卡 */
this.cards_chou?.on(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_chou?.on(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_chou?.on(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
/** 英雄卡池显示/隐藏切换按钮 */
this.showHeros?.on(NodeEventType.TOUCH_END, this.onShowHerosClick, this);
/** 关闭英雄卡池按钮 */
this.closeHeros?.on(NodeEventType.TOUCH_END, this.onCloseHerosClick, this);
/** 装备商店显示/隐藏切换按钮 */
this.showEquips?.on(NodeEventType.TOUCH_END, this.onShowEquipsClick, this);
/** 关闭装备商店按钮 */
this.closeEquips?.on(NodeEventType.TOUCH_END, this.onCloseEquipsClick, this);
/** 技能卡刷新按钮 */
this.skill_refresh?.on(NodeEventType.TOUCH_START, this.onSkillDrawTouchStart, this);
this.skill_refresh?.on(NodeEventType.TOUCH_END, this.onSkillDrawTouchEnd, this);
this.skill_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onSkillDrawTouchCancel, this);
this.skill_ad_refresh?.on(NodeEventType.TOUCH_START, this.onSkillAdDrawTouchStart, this);
this.skill_ad_refresh?.on(NodeEventType.TOUCH_END, this.onSkillAdDrawTouchEnd, this);
this.skill_ad_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onSkillAdDrawTouchCancel, this);
}
// ======================== 事件回调 ========================
/**
* 金币变化事件回调:
* 仅负责 UI 更新和动画表现。数据更新已由 MissionEconomy 统一处理。
*/
private onCoinAdd(event: string, args: any) {
const payload = args ?? event;
const v = typeof payload === 'number' ? payload : (payload?.delta ?? payload?.value ?? 0);
this.updateCoinAndCostUI();
if (v !== 0) {
this.playCoinChangeAnim(v > 0);
}
}
/** 战斗开始:保留卡牌面板,允许玩家在战斗阶段继续抽卡和召唤英雄 */
private onFightStart() {
this.enterBattlePhase();
// 第一次进入战斗阶段关闭guide4
if (!smc.finish_guides.includes(4)) {
smc.finish_guides.push(4);
oops.gui.remove(UIID.Guide4);
}
}
private onShowSmallTip(event: string, args: any) {
const type = args as string;
this.showSmallTip(type as any);
}
public showSmallTip(type: "refresh_coin" | "buy_coin" | "hero_full") {
let targetNode: Node | null = null;
switch (type) {
case "refresh_coin":
targetNode = this.cards_chou;
break;
case "buy_coin":
targetNode = this.coins_node;
break;
case "hero_full":
targetNode = this.hero_num_node;
break;
}
if (targetNode && targetNode.isValid) {
const tipNode = targetNode.getChildByName("smalltip");
if (tipNode) {
tipNode.active = true;
Tween.stopAllByTarget(tipNode);
// 设置初始状态:缩放为 0
tipNode.setScale(new Vec3(0, 0, 1));
tween(tipNode)
// 1. 弹出动画(微放大再回弹)
.to(0.15, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' })
.to(0.05, { scale: new Vec3(1, 1, 1) })
// 2. 停留 1 秒
.delay(1)
// 3. 缩小消失动画
.to(0.15, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' })
.call(() => {
if (tipNode && tipNode.isValid) tipNode.active = false;
})
.start();
}
}
}
private onPhasePrepareStart() {
this.updateHeroNumUI(true, true);
}
/**
* 技能卡入场动画:每个卡槽从场景基准位置下方 80px 处升起,
* 终止位置 = 场景编辑器位置(保持初始不做位移的约束),
* 多张依次错开 staggerDelay 形成"依次飞入"效果。
*/
private readonly skillEnterOffsetY: number = -80;
private readonly skillEnterDuration: number = 0.25;
private readonly skillEnterStagger: number = 0.08;
private playSkillCardEnterAnim() {
if (!this.skillCardComps || this.skillCardComps.length === 0) return;
for (let i = 0; i < this.skillCardComps.length; i++) {
const comp = this.skillCardComps[i];
if (!comp) continue;
const node = comp.node;
if (!node || !node.isValid) continue;
// 缓存场景编辑器位置作为动画终止点(初始不做位移)
const basePos = node.getPosition();
// 起始位置 = 基准位置下移 80px
node.setPosition(basePos.x, basePos.y + this.skillEnterOffsetY, basePos.z);
Tween.stopAllByTarget(node);
tween(node)
.delay(i * this.skillEnterStagger)
.to(this.skillEnterDuration, { position: basePos }, { easing: 'quadOut' })
.start();
}
}
private showSkillCardPopup() {
if (!this.skill_card_node) return;
this.skill_card_node.active = true;
// 先分发卡牌数据(让卡面内容就绪),再做入场动画
const cards = this.buildSkillDrawCards();
this.dispatchCardsToSkillSlots(cards);
this.playSkillCardEnterAnim();
// 首次弹出技能三选一的时候弹出guide2
if (!smc.finish_guides.includes(2)) {
oops.gui.open(UIID.Guide2);
}
}
private dispatchCardsToSkillSlots(cards: CardConfig[]) {
if (!this.skillCardComps) return;
for (let i = 0; i < this.skillCardComps.length; i++) {
if (this.skillCardComps[i]) {
this.skillCardComps[i].applyDrawCard(cards[i] ?? null);
}
}
}
private onUseSkillCard(event: string, args: any) {
// 购买技能卡后不再关闭弹窗:技能卡池与英雄卡池保持一致,
// 由 onCardUsed 立即向被使用的槽位补充一张新技能卡。
// 弹窗由外部(按钮/阶段切换)控制显隐。
// 追踪装备购买记录EquipListComp 购买后也会派发此事件)
const usedCard = args as CardConfig;
if (usedCard && isEquipmentCard(usedCard)) {
this.purchasedEquipUuids.add(usedCard.uuid);
}
// 修复:独立判断 guide2 的关闭 和 guide3 的开启
if (!smc.finish_guides.includes(2)) {
smc.finish_guides.push(2);
oops.gui.remove(UIID.Guide2);
}
if (!smc.finish_guides.includes(3)) {
oops.gui.open(UIID.Guide3);
}
// 驻场技能可能影响刷新费用(如"刷新优惠"),延迟到下一帧刷新费用 UI
this.scheduleOnce(() => this.updateCoinAndCostUI(), 0);
}
/** 解除按钮监听,避免节点销毁后回调泄漏 */
private unbindEvents() {
oops.message.off(GameEvent.CoinAdd, this.onCoinAdd, this);
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.UseHeroCard, this.onUseHeroCard, this);
oops.message.off(GameEvent.UseSkillCard, this.onUseSkillCard, this);
oops.message.off(GameEvent.UseSpecialCard, this.onUseSpecialCard, this);
oops.message.off(GameEvent.ShowSmallTip, this.onShowSmallTip, this);
oops.message.off(GameEvent.CardUsed, this.onCardUsed, this);
if (this.cards_chou && this.cards_chou.isValid) {
this.cards_chou.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_chou.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_chou.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
}
if (this.showHeros && this.showHeros.isValid) {
this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, this);
}
if (this.closeHeros && this.closeHeros.isValid) {
this.closeHeros.off(NodeEventType.TOUCH_END, this.onCloseHerosClick, this);
}
if (this.showEquips && this.showEquips.isValid) {
this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this);
}
if (this.closeEquips && this.closeEquips.isValid) {
this.closeEquips.off(NodeEventType.TOUCH_END, this.onCloseEquipsClick, this);
}
if (this.skill_refresh && this.skill_refresh.isValid) {
this.skill_refresh.off(NodeEventType.TOUCH_START, this.onSkillDrawTouchStart, this);
this.skill_refresh.off(NodeEventType.TOUCH_END, this.onSkillDrawTouchEnd, this);
this.skill_refresh.off(NodeEventType.TOUCH_CANCEL, this.onSkillDrawTouchCancel, this);
}
if (this.skill_ad_refresh && this.skill_ad_refresh.isValid) {
this.skill_ad_refresh.off(NodeEventType.TOUCH_START, this.onSkillAdDrawTouchStart, this);
this.skill_ad_refresh.off(NodeEventType.TOUCH_END, this.onSkillAdDrawTouchEnd, this);
this.skill_ad_refresh.off(NodeEventType.TOUCH_CANCEL, this.onSkillAdDrawTouchCancel, this);
}
}
/**
* 英雄上场事件回调MasterCalled
* 为新上场英雄创建或更新信息面板,并刷新英雄数量 UI。
*/
private onMasterCalled(event: string, args: any) {
const payload = args ?? event;
const eid = Number(payload?.eid ?? 0);
const model = payload?.model as HeroAttrsComp | undefined;
mLogger.log(this.debugMode, "MissionCardComp", "onMasterCalled received payload:", { eid, hasModel: !!model });
if (!eid || !model) return;
const before = this.getAliveHeroCount();
const after = this.getAliveHeroCount();
this.updateHeroNumUI(true, after > before);
// 第一次召唤英雄后关闭guide3
// 注:首个英雄召唤后战斗自动开始(见 MissionComp不再需要 Guide4 引导点击开始按钮
if (!smc.finish_guides.includes(3)) {
smc.finish_guides.push(3);
oops.gui.remove(UIID.Guide3);
}
}
/** 英雄死亡事件回调:刷新面板列表并更新英雄数量 UI */
private onHeroDead() {
this.updateHeroNumUI(true, false);
}
/** 英雄被出售事件回调:更新英雄数量 UI */
private onHeroSell() {
this.updateHeroNumUI(true, false);
}
/**
* 使用英雄卡的 guard 校验(由 CardComp 通过 UseHeroCard 事件调用):
* - 当前英雄数 < 上限 → 允许使用。
* - 已满 → 阻止使用cancel=true弹 toast。
*
* 注意:英雄不再支持合成腾位,满员时一律阻止。
*/
private onUseHeroCard(event: string, args: any) {
const payload = args ?? event;
if (!payload) return;
const current = this.getAliveHeroCount();
this.syncMissionHeroData(current);
const heroMax = this.getMissionHeroMaxNum();
if (current >= heroMax) {
payload.cancel = true;
payload.reason = "hero_limit";
this.showSmallTip("hero_full");
this.playHeroNumDeniedAnim();
}
}
/**
* 使用特殊卡事件回调:
* - SpecialUpgrade按卡牌携带的 target_hero_eid 精确升级场上对应英雄实体。
* target_hero_eid 缺失或实体不存在时升级失败。
* - SpecialRefresh按英雄类型重新抽取英雄卡。
*/
private onUseSpecialCard(event: string, args: any) {
const payload = args ?? event;
const uuid = Number(payload?.uuid ?? 0);
const type = Number(payload?.type ?? 0) as CardType;
if (!uuid) return;
let success = false;
if (type === CardType.SpecialUpgrade) {
const template = SpecialUpgradeCardList[uuid];
if (!template) return;
const targetHeroEid = Number(payload?.target_hero_eid ?? 0);
success = this.tryUpgradeHeroByEid(targetHeroEid);
if (!success) {
oops.gui.toast(`场上没有可升级的英雄`);
}
} else if (type === CardType.SpecialRefresh) {
const card = SpecialRefreshCardList[uuid];
if (!card) return;
success = this.tryRefreshHeroCardsByEffect(card.refreshHeroType);
if (!success) oops.gui.toast("当前卡池无符合条件的英雄卡");
}
mLogger.log(this.debugMode, "MissionCardComp", "use special card", {
uuid,
type,
success
});
}
/**
* 单张卡牌被成功使用后的整池刷新回调:
* - payload 为该卡槽组件实例CardComp 或 SCardComp
* - 命中英雄卡槽 → 重新构建 3 张英雄池卡牌并分发到所有英雄槽。
* - 命中技能卡槽 → 重新构建 3 张技能卡并分发到所有技能槽。
* - 未命中任何槽位 → 忽略。
*
* Why: 购买一张卡后立即刷新整个卡池(不扣金币),让玩家持续从新池中挑选;
* 英雄卡池与技能卡池遵循同一逻辑。
* SpecialRefresh 自身效果onUseSpecialCard 中的 tryRefreshHeroCards
* 已是整池刷新,因此 CardComp 对其不派发 CardUsed避免双重刷新。
*/
private onCardUsed(event: string, args: any) {
const source = args;
if (!source) return;
const heroIdx = this.cardComps.findIndex(c => c === source);
if (heroIdx >= 0) {
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
mLogger.log(this.debugMode, "MissionCardComp", "refresh hero pool after buy", {
triggerSlot: heroIdx
});
return;
}
const skillIdx = this.skillCardComps.findIndex(c => c === source);
if (skillIdx >= 0) {
const cards = this.buildSkillDrawCards();
this.dispatchCardsToSkillSlots(cards);
mLogger.log(this.debugMode, "MissionCardComp", "refresh skill pool after buy", {
triggerSlot: skillIdx
});
return;
}
}
// ======================== 按钮触控回调 ========================
/** 抽卡按钮按下反馈 */
private onDrawTouchStart() {
this.playButtonPressAnim(this.cards_chou);
}
/** 抽卡按钮释放 → 执行抽卡逻辑 */
private onDrawTouchEnd() {
oops.audio.playEffect("music/button");
this.playButtonClickAnim(this.cards_chou, () => this.onClickDraw());
}
/** 抽卡按钮取消 → 恢复缩放 */
private onDrawTouchCancel() {
this.playButtonResetAnim(this.cards_chou);
}
/**
* 英雄卡池显隐切换按钮回调:
* - 切换 cards_node 的 active 状态。
* - 显示时激活 showHeros 下的 "active" 子节点,隐藏时关闭该子节点。
*/
private onShowHerosClick() {
if (!this.cards_node || !this.cards_node.isValid) return;
oops.audio.playEffect("music/button");
const visible = !this.cards_node.active;
this.cards_node.active = visible;
const activeChild = this.showHeros?.getChildByName("active");
if (activeChild) {
activeChild.active = visible;
}
}
/**
* 关闭英雄卡池按钮回调:
* - 仅负责收起 cards_node不切换展开态。
* - 同步关闭 showHeros 下的 "active" 子节点,保持与 toggle 状态一致。
* - 同步关闭已打开的英雄信息面板HInfo避免卡池收起后信息面板孤立悬浮。
*/
private onCloseHerosClick() {
if (!this.cards_node || !this.cards_node.isValid) return;
oops.audio.playEffect("music/button");
this.cards_node.active = false;
const activeChild = this.showHeros?.getChildByName("active");
if (activeChild) {
activeChild.active = false;
}
// 关闭英雄卡池时同步关闭已打开的英雄信息面板
oops.gui.remove(UIID.HInfo);
}
// ======================== 装备商店面板 ========================
/**
* 装备商店显隐切换按钮回调:
* 切换 equipsPanNode 的 active 状态。
*/
private onShowEquipsClick() {
if (!this.equipsPanNode || !this.equipsPanNode.isValid) return;
oops.audio.playEffect("music/button");
this.equipsPanNode.active = !this.equipsPanNode.active;
}
/** 关闭装备商店按钮回调 */
private onCloseEquipsClick() {
if (!this.equipsPanNode || !this.equipsPanNode.isValid) return;
oops.audio.playEffect("music/button");
this.equipsPanNode.active = false;
}
/**
* 填充装备列表:
* 从 CardPoolList 获取所有装备卡trigger_type=Field
* 实例化 equipPrefab 到 equipsBoxNode
* 每个装备项由 EquipListComp 渲染并处理购买。
*
* 已购买的装备会标记为已购状态(隐藏购买按钮)。
*/
private populateEquipments() {
if (!this.equipsBoxNode || !this.equipPrefab) return;
// 清空旧列表
this.equipsBoxNode.removeAllChildren();
// 获取所有装备卡
const equipments = CardPoolList.filter(c => isEquipmentCard(c));
for (const equip of equipments) {
const node = instantiate(this.equipPrefab);
this.equipsBoxNode.addChild(node);
const comp = node.getComponent(EquipListComp) || node.addComponent(EquipListComp);
comp.applyCardData(equip);
// 已购买的装备标记为已购
if (this.purchasedEquipUuids.has(equip.uuid)) {
comp.setPurchased();
}
}
// 默认显示装备面板
if (this.equipsPanNode) {
this.equipsPanNode.active = true;
}
mLogger.log(this.debugMode, "MissionCardComp", "populate equipments", {
count: equipments.length
});
}
/** 清空装备列表 */
private clearEquipments() {
if (this.equipsBoxNode && this.equipsBoxNode.isValid) {
this.equipsBoxNode.removeAllChildren();
}
}
// ======================== 技能抽卡按钮回调 ========================
private onSkillDrawTouchStart() {
this.playButtonPressAnim(this.skill_refresh);
}
private onSkillDrawTouchEnd() {
oops.audio.playEffect("music/button");
this.playButtonClickAnim(this.skill_refresh, () => this.onClickSkillRefresh());
}
private onSkillDrawTouchCancel() {
this.playButtonResetAnim(this.skill_refresh);
}
private onSkillAdDrawTouchStart() {
this.playButtonPressAnim(this.skill_ad_refresh);
}
private onSkillAdDrawTouchEnd() {
oops.audio.playEffect("music/button");
this.playButtonClickAnim(this.skill_ad_refresh, () => this.onClickSkillAdRefresh());
}
private onSkillAdDrawTouchCancel() {
this.playButtonResetAnim(this.skill_ad_refresh);
}
private onClickSkillRefresh() {
const cost = MissionEconomy.getRefreshCost(this.refreshCost);
const success = MissionEconomy.executeRefresh(this.refreshCost);
if (!success) {
this.showSmallTip("refresh_coin");
return;
}
const cards = this.buildSkillDrawCards();
this.dispatchCardsToSkillSlots(cards);
}
private onClickSkillAdRefresh() {
// TODO: 接入广告 SDK 逻辑,目前先直接刷新
const cards = this.buildSkillDrawCards();
this.dispatchCardsToSkillSlots(cards);
}
/** 将三个卡槽节点映射为 CardComp形成固定顺序控制数组 */
private cacheCardComps() {
if (this.card4) {
this.card4.active = false;
}
const nodes = [this.card1, this.card2, this.card3];
this.cardComps = nodes
.map(node => node?.getComponent(CardComp))
.filter((comp): comp is CardComp => !!comp);
const skillNodes = [this.skill_card1, this.skill_card2, this.skill_card3];
this.skillCardComps = skillNodes
.map(node => node?.getComponent(SCardComp))
.filter((comp): comp is SCardComp => !!comp);
}
// ======================== 核心业务:抽卡 ========================
/**
* 抽卡按钮核心逻辑:
* 1. 检查金币是否足够 → 不够则 toast 提示。
* 2. 扣除费用、播放金币动画。
* 3. 重新布局槽位 → 从卡池构建 3 张卡 → 分发到槽位。
*/
private onClickDraw() {
const cost = MissionEconomy.getRefreshCost(this.refreshCost);
const success = MissionEconomy.executeRefresh(this.refreshCost);
if (!success) {
this.showSmallTip("refresh_coin");
this.updateCoinAndCostUI();
mLogger.log(this.debugMode, "MissionCardComp", "draw coin not enough", {
currentCoin: MissionEconomy.getCoin(),
cost
});
return;
}
mLogger.log(this.debugMode, "MissionCardComp", "click draw", {
cost,
leftCoin: MissionEconomy.getCoin()
});
this.layoutCardSlots();
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
}
// ======================== 阶段切换 ========================
/** 缓存卡牌面板的基准缩放值(从场景初始状态读取,仅缓存一次) */
private initCardsPanelPos() {
if (!this.cards_node || !this.cards_node.isValid) return;
if (!this.hasCachedCardsBaseScale) {
const scale = this.cards_node.scale;
this.cardsBaseScale = new Vec3(scale.x, scale.y, scale.z);
this.hasCachedCardsBaseScale = true;
}
this.cardsShowScale = new Vec3(this.cardsBaseScale.x, this.cardsBaseScale.y, this.cardsBaseScale.z);
this.cardsHideScale = new Vec3(0, 0, this.cardsBaseScale.z);
}
/**
* 进入准备阶段:
* - 先将卡牌面板置为收起态,激活 showHeros 按钮可见。
* - 再触发一次 onShowHerosClick等同玩家点击 showHeros 按钮),
* 让卡池以"按钮点击"的方式展开,保持与玩家手动点击一致的交互表现。
*/
private enterPreparePhase() {
if (!this.cards_node || !this.cards_node.isValid) return;
this.initCardsPanelPos();
// 先收起面板,确保后续 onShowHerosClick 能正确切换为展开
Tween.stopAllByTarget(this.cards_node);
this.cards_node.setScale(this.cardsShowScale);
this.cards_node.active = false;
// 显式激活「显示英雄卡池」按钮本身,让玩家可见可点
if (this.showHeros && this.showHeros.isValid) {
this.showHeros.active = true;
const showActive = this.showHeros.getChildByName("active");
if (showActive) showActive.active = false;
}
if (this.cards_chou && this.cards_chou.isValid) {
const nobg = this.cards_chou.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
}
// 触发一次 showHeros 按钮点击效果,自动展开卡池
this.onShowHerosClick();
}
private enterBattlePhase() {
if (!this.cards_node || !this.cards_node.isValid) return;
this.initCardsPanelPos();
// 战斗阶段允许抽卡nobg 按"金币是否足够"判断
if (this.cards_chou && this.cards_chou.isValid) {
const nobg = this.cards_chou.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
}
}
/**
* 构建本次抽卡结果,保证最终可分发 3 条数据。
*
* 抽卡规则:
* 1. **必出规则**:当场上有可升级英雄时,每次刷新必出 1 张升级卡,并放在返回数组首位(对应卡池最左侧槽位)。
* 多次刷新时,按场上英雄 target_hero_eid 升序循环切换升级卡upgradeRotationIndex
* 2. 其余 2 张从混合池(剩余升级卡 + 基础英雄卡)按权重抽取。
*
* 特殊规则:当场存活英雄数已达 HERO_MAX_NUM 时,全部出升级卡(若全部已满级则降级回混合池)。
*/
private buildDrawCards(): CardConfig[] {
const upgradeCards = this.buildHeroUpgradeCards();
const aliveHeroCount = this.getAliveHeroCount();
const heroMax = this.getMissionHeroMaxNum();
// 英雄已满员且有可升级的英雄 → 只出升级卡
if (aliveHeroCount >= heroMax && upgradeCards.length > 0) {
const picked = this.pickMixedCards(upgradeCards, 3, upgradeCards);
if (picked.length >= 3) return picked.slice(0, 3);
/** 兜底:不足 3 张时循环补齐 */
const filled = [...picked];
while (filled.length < 3) {
filled.push(upgradeCards[filled.length % upgradeCards.length]);
}
return filled;
}
const heroCards = CardPoolList.filter(c => c.type === CardType.Hero);
// 过滤掉场上已召唤英雄的普通英雄卡:已召唤的英雄只能通过升级卡升级,不再刷出普通卡
const aliveHeroUuids = this.getAliveHeroUuids();
const availableHeroCards = heroCards.filter(c => !aliveHeroUuids.has(c.uuid));
// 英雄池只刷英雄卡(含动态升级卡 + 未召唤英雄的普通卡)
const mixedPool: CardConfig[] = [...upgradeCards, ...availableHeroCards];
if (mixedPool.length === 0) return [];
const result: CardConfig[] = [];
const usedUpgradeTargets = new Set<number>();
const usedHeroUuids = new Set<number>();
// 必出 1 张升级卡,按 UUID 升序轮询,放在返回数组首位(对应最左侧卡槽)
if (upgradeCards.length > 0) {
const idx = this.upgradeRotationIndex % upgradeCards.length;
this.upgradeRotationIndex = (this.upgradeRotationIndex + 1) % upgradeCards.length;
const mandatory = upgradeCards[idx];
result.push(mandatory);
usedUpgradeTargets.add(mandatory.target_hero_eid ?? 0);
}
// 剩余张数从混合池抽取,排除已选升级卡 target_hero_eid 和已选英雄卡 uuid
const remainingCount = 3 - result.length;
if (remainingCount > 0) {
const remainingPool = mixedPool.filter(c => {
if (c.type === CardType.SpecialUpgrade && c.target_hero_eid) {
return !usedUpgradeTargets.has(c.target_hero_eid);
}
if (c.type === CardType.Hero) {
return !usedHeroUuids.has(c.uuid);
}
return true;
});
const rest = this.pickMixedCards(remainingPool, remainingCount, upgradeCards);
rest.forEach(c => {
result.push(c);
if (c.type === CardType.SpecialUpgrade && c.target_hero_eid) {
usedUpgradeTargets.add(c.target_hero_eid);
}
if (c.type === CardType.Hero) {
usedHeroUuids.add(c.uuid);
}
});
}
// 兜底:不足 3 张时从未使用的英雄卡中补齐,避免出现重复英雄
while (result.length < 3) {
const fillCard = availableHeroCards.find(c => !usedHeroUuids.has(c.uuid));
if (!fillCard) break;
result.push(fillCard);
usedHeroUuids.add(fillCard.uuid);
}
return result.slice(0, 3);
}
/**
* 从混合池中按权重抽取 n 张卡。
* 升级卡之间强制 unique同一个 target_hero_eid 只能出现一次)。
* 英雄卡之间强制 unique同一个 uuid 只能出现一次),避免同一次发牌出现重复英雄。
*/
private pickMixedCards(pool: CardConfig[], count: number, upgradeCards: CardConfig[]): CardConfig[] {
if (pool.length === 0 || count <= 0) return [];
const selected: CardConfig[] = [];
const usedUpgradeTargets = new Set<number>();
const usedHeroUuids = new Set<number>();
while (selected.length < count) {
const available = pool.filter(c => {
if (c.type === CardType.SpecialUpgrade && c.target_hero_eid) {
return !usedUpgradeTargets.has(c.target_hero_eid);
}
if (c.type === CardType.Hero) {
return !usedHeroUuids.has(c.uuid);
}
return true;
});
if (available.length === 0) break;
const pick = this.weightedPick(available);
if (!pick) break;
selected.push(pick);
if (pick.type === CardType.SpecialUpgrade && pick.target_hero_eid) {
usedUpgradeTargets.add(pick.target_hero_eid);
}
if (pick.type === CardType.Hero) {
usedHeroUuids.add(pick.uuid);
}
}
return selected;
}
/** 单次按权重抽取一张卡 */
private weightedPick(cards: CardConfig[]): CardConfig | null {
if (cards.length === 0) return null;
const totalWeight = cards.reduce((total, card) => total + (card.weight ?? 0), 0);
let random = Math.random() * totalWeight;
for (const card of cards) {
random -= (card.weight ?? 0);
if (random <= 0) return card;
}
return cards[cards.length - 1];
}
/**
* 扫描场上存活英雄,为每个英雄实体生成一张升级卡(按 eid 绑定)。
* - 已达 HERO_MAX_LV 的英雄不出卡。
* - 同 eid 只生成一张eid 本就唯一,不会重复)。
* - cost = 模板 cost + (当前等级 - 1) * BASE_COST等级越高升级越贵。
*/
private buildHeroUpgradeCards(): CardConfig[] {
const template = SpecialUpgradeCardList[7001];
if (!template) return [];
const actors = this.queryAliveHeroActors();
if (actors.length === 0) return [];
const result: CardConfig[] = [];
for (const actor of actors) {
const eid = actor.eid;
const lv = actor.model.lv;
if (lv >= FightSet.HERO_MAX_LV) continue; // 已达上限不再出升级卡
const dynamicCost = template.cost + (lv - 1) * FightSet.BASE_COST;
result.push({
...template,
cost: dynamicCost,
weight: template.weight,
target_hero_eid: eid,
hero_lv: lv + 1,
});
}
// 按 target_hero_eid 升序排序,保证返回顺序稳定(便于 buildDrawCards 按顺序轮询)
result.sort((a, b) => (a.target_hero_eid ?? 0) - (b.target_hero_eid ?? 0));
return result;
}
private buildSkillDrawCards(): CardConfig[] {
const currentWave = this.getCurrentWave();
// 使用明确规则的 drawCardsByRule指定只要 3 张技能卡,并且过滤对应 wave
// 装备卡已改为商店直购,不再参与抽卡
const cards = drawCardsByRule(1, {
count: 3,
type: CardType.Skill,
wave: currentWave,
unique: true, // 保证技能牌不重复
excludeEquipment: true, // 排除装备卡
});
if (cards.length >= 3) return cards.slice(0, 3);
const filled = [...cards];
while (filled.length < 3) {
const fallback = drawCardsByRule(1, {
count: 3,
type: CardType.Skill,
wave: currentWave,
unique: true,
excludeEquipment: true,
});
if (fallback.length === 0) break;
// 如果池子数量不足,只能被迫允许重复,但尽量拿没被抽到的
const fPick = fallback.find(c => !filled.some(fc => fc.uuid === c.uuid));
if (fPick) {
filled.push(fPick);
} else {
filled.push(fallback[filled.length % fallback.length]);
}
}
return filled;
}
private tryRefreshHeroCards(heroType?: HType): boolean {
const cards = drawCardsByRule(1, {
count: 3,
type: CardType.Hero,
heroType,
unique: true, // 保证一次刷新内的英雄卡不重复
});
// 过滤掉场上已召唤英雄的普通英雄卡(已召唤的英雄不再重复刷出)
const aliveHeroUuids = this.getAliveHeroUuids();
const available = cards.filter(c => !aliveHeroUuids.has(c.uuid));
if (available.length <= 0) return false;
this.layoutCardSlots();
this.dispatchCardsToSlots(available.slice(0, 3));
return true;
}
private tryRefreshHeroCardsByEffect(refreshHeroType: SpecialRefreshHeroType): boolean {
const heroType = this.resolveRefreshHeroType(refreshHeroType);
return this.tryRefreshHeroCards(heroType);
}
private resolveRefreshHeroType(refreshHeroType: SpecialRefreshHeroType): HType | undefined {
if (refreshHeroType === SpecialRefreshHeroType.Melee) return HType.Melee;
if (refreshHeroType === SpecialRefreshHeroType.Ranged) return HType.Long;
return undefined;
}
/** 全量分发给 3 槽;每个槽位是否接收由 CardComp 自己判断(锁定可跳过) */
private dispatchCardsToSlots(cards: CardConfig[]) {
if (!this.cardComps) return;
for (let i = 0; i < this.cardComps.length; i++) {
if (this.cardComps[i]) {
const accepted = this.cardComps[i].applyDrawCard(cards[i] ?? null);
mLogger.log(this.debugMode, "MissionCardComp", "dispatch card", {
index: i,
card: cards[i]?.uuid ?? 0,
accepted
});
}
}
}
/** 系统清空 3 槽(用于任务切换) */
private clearAllCards() {
if (!this.cardComps) return;
this.cardComps.forEach(comp => {
if (comp) comp.clearBySystem();
});
if (this.skillCardComps) {
this.skillCardComps.forEach(comp => {
if (comp) comp.clearBySystem();
});
}
}
private layoutCardSlots() {
if (!this.cardComps) return;
const count = this.cardComps.length;
if (count === 0) return;
for (let i = 0; i < count; i++) {
if (this.cardComps[i]) {
this.cardComps[i].setSlotPosition(this.cardsPos[i]);
}
}
mLogger.log(this.debugMode, "MissionCardComp", "layout card slots", {
count,
cardWidth: this.cardWidth
});
}
private playButtonPressAnim(node: Node | null) {
this.playNodeScaleTo(node, this.buttonPressScale, 0.06);
}
private playButtonClickAnim(node: Node | null, onComplete: () => void) {
if (!node || !node.isValid) {
onComplete();
return;
}
this.playNodeScalePop(node, this.buttonClickScale, 0.05, 0.08, onComplete);
}
private playButtonResetAnim(node: Node | null) {
this.playNodeScaleTo(node, this.buttonNormalScale, 0.08);
}
private resetButtonScale(node: Node | null) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
node.setScale(this.buttonNormalScale, this.buttonNormalScale, 1);
}
private canDrawCards() {
return MissionEconomy.getCoin() >= MissionEconomy.getRefreshCost(this.refreshCost);
}
private updateDrawCostUI() {
// 战斗阶段也允许抽卡nobg 统一按"金币是否足够"判断
if (this.cards_chou) {
const nobg = this.cards_chou.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
const coinNode = this.cards_chou.getChildByName("coin");
const numLabel = coinNode?.getChildByName("num")?.getComponent(Label);
if (numLabel) {
numLabel.string = `${MissionEconomy.getRefreshCost(this.refreshCost)}`;
}
}
if (this.skill_refresh) {
const nobg = this.skill_refresh.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
const coinNode = this.skill_refresh.getChildByName("coin");
const numLabel = coinNode?.getChildByName("num")?.getComponent(Label);
if (numLabel) {
numLabel.string = `${MissionEconomy.getRefreshCost(this.refreshCost)}`;
}
}
}
private updateCoinAndCostUI() {
this.updateDrawCostUI();
}
private playCoinChangeAnim(isIncrease: boolean) {
if (!this.coins_node || !this.coins_node.isValid) return;
const icon = this.coins_node.getChildByName("icon");
if (!icon || !icon.isValid) return;
const peak = isIncrease ? 1.2 : 1.2;
this.playHeroNumNodePop(icon, peak);
const num = this.coins_node.getChildByName("num");
if (!num || !num.isValid) return;
this.playHeroNumNodePop(num, peak);
}
public setHeroMaxCount(max: number) {
const missionData = this.getMissionData();
if (!missionData) return;
const min = FightSet.HERO_MAX_NUM;
const limit = Math.max(min, missionData.hero_extend_max_num ?? (FightSet.HERO_MAX_NUM + 1));
const next = Math.max(min, Math.min(limit, Math.floor(max || min)));
if (next === missionData.hero_max_num) return;
missionData.hero_max_num = next;
this.updateHeroNumUI(true, false);
}
public tryExpandHeroMax(add: number = 1): boolean {
const missionData = this.getMissionData();
if (!missionData) return false;
const before = this.getMissionHeroMaxNum();
const next = before + Math.max(0, Math.floor(add));
this.setHeroMaxCount(next);
return this.getMissionHeroMaxNum() > before;
}
public canUseHeroCard(): boolean {
return this.getAliveHeroCount() < this.getMissionHeroMaxNum();
}
private getAliveHeroCount(): number {
let count = 0;
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
const model = entity.get(HeroAttrsComp);
if (model && model.fac === FacSet.HERO) {
count++;
}
});
return count;
}
private queryAliveHeroActors(): Array<{ eid: number, model: HeroAttrsComp, view: HeroViewComp | null }> {
const actors: Array<{ eid: number, model: HeroAttrsComp, view: HeroViewComp | null }> = [];
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;
const view = entity.get(HeroViewComp);
actors.push({ eid: entity.eid, model, view });
});
return actors;
}
/**
* 获取场上所有存活英雄的 hero_uuid 集合。
* 用于抽卡时过滤:已召唤的英雄不再从普通英雄卡池中刷出,只能通过升级卡升级。
*/
private getAliveHeroUuids(): Set<number> {
const uuids = new Set<number>();
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;
uuids.add(model.hero_uuid);
});
return uuids;
}
/**
* 按 eid 精确升级场上对应英雄实体。
*
* @param heroEid 要升级的英雄实体 eid
* @returns true = 升级成功
*/
private tryUpgradeHeroByEid(heroEid: number): boolean {
if (!heroEid) return false;
const actor = this.queryAliveHeroActors().find(item => item.eid === heroEid);
if (!actor) return false;
if (actor.model.lv >= FightSet.HERO_MAX_LV) return false;
const nextLv = Math.min(FightSet.HERO_MAX_LV, actor.model.lv + 1);
this.applyHeroLevel(actor.model, nextLv);
if (actor.view) {
actor.view.playBuff("buff_lvup");
}
return true;
}
/**
* 应用英雄等级变化(属性按 HERO_LV_MULTIPLIER 重新计算)。
* 沿用旧版本 SkillLvUp 的技能等级映射规则。
*/
private applyHeroLevel(model: HeroAttrsComp, targetLv: number) {
const hero = HeroInfo[model.hero_uuid];
if (!hero) return;
const nextLv = Math.max(1, Math.min(FightSet.HERO_MAX_LV, Math.floor(targetLv)));
const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1;
model.lv = nextLv;
model.ap = hero.ap * Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1);
model.hp_max = hero.hp * Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1);
model.hp = Math.max(1, Math.floor(model.hp_max * Math.max(0, Math.min(1, hpRate))));
model.skills = {};
for (const key in hero.skills) {
const skill = hero.skills[key];
if (!skill) continue;
model.skills[skill.uuid] = { ...skill, lv: Math.max(0, skill.lv + nextLv - 2), ccd: 0 };
}
model.updateSkillDistanceCache();
model.dirty_hp = true;
model.dirty_lv = true;
oops.message.dispatchEvent(GameEvent.HeroLvUp, {
uuid: model.hero_uuid,
lv: nextLv
});
}
private updateHeroNumUI(animate: boolean, isIncrease: boolean) {
this.syncMissionHeroData();
if (!animate || !isIncrease) return;
this.playHeroNumGainAnim();
}
private playHeroNumGainAnim() {
if (!this.hero_num_node || !this.hero_num_node.isValid) return;
const iconNode = this.hero_num_node.getChildByName("icon");
const numNode = this.hero_num_node.getChildByName("num");
this.playHeroNumNodePop(iconNode, 1.2);
this.playHeroNumNodePop(numNode, 1.2);
}
private playHeroNumDeniedAnim() {
if (!this.hero_num_node || !this.hero_num_node.isValid) return;
const iconNode = this.hero_num_node.getChildByName("icon");
const numNode = this.hero_num_node.getChildByName("num");
this.playHeroNumNodePop(iconNode, 1.2);
this.playHeroNumNodePop(numNode, 1.2);
}
private playHeroNumNodePop(node: Node | null, scalePeak: number) {
this.playNodeScalePop(node, scalePeak, 0.08, 0.1);
}
private playNodeScaleTo(node: Node | null, scale: number, duration: number) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
tween(node)
.to(duration, {
scale: new Vec3(scale, scale, 1)
})
.start();
}
private playNodeScalePop(node: Node | null, scalePeak: number, toPeakDuration: number, toNormalDuration: number, onPeak?: () => void) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
node.setScale(1, 1, 1);
const seq = tween(node)
.to(toPeakDuration, { scale: new Vec3(scalePeak, scalePeak, 1) });
if (onPeak) {
seq.call(onPeak);
}
seq.to(toNormalDuration, { scale: new Vec3(1, 1, 1) })
.start();
}
private getMissionData(): any {
return smc?.vmdata?.mission_data ?? null;
}
private getMissionHeroNum(): number {
const missionData = this.getMissionData();
return Math.max(0, Math.floor(missionData?.hero_num ?? 0));
}
private getCurrentWave(): number {
const missionData = this.getMissionData();
return Math.max(1, Math.floor(missionData?.level ?? 1));
}
private getMissionHeroMaxNum(): number {
return FightSet.HERO_MAX_NUM
}
private syncMissionHeroData(count?: number) {
const missionData = this.getMissionData();
if (!missionData) return;
const safeCount = Math.max(0, Math.floor(count ?? this.getAliveHeroCount()));
missionData.hero_num = safeCount;
}
/** 视图对象通过 ecs.Entity.remove(ModuleViewComp) 删除组件是触发组件处理自定义释放逻辑 */
reset() {
this.resetButtonScale(this.cards_chou);
// 关键:在 reset/销毁 时将 Map 置空,彻底切断引用
this.cardComps = [] as any;
this.skillCardComps = [] as any;
this.purchasedEquipUuids.clear();
if (this.node && this.node.isValid) {
this.node.destroy();
}
}
}