Files
pixelheros/assets/script/game/map/MissionCardComp.ts
panFD 4ef2e13798 refactor(map): 重构任务卡牌面板,改用动态实例化卡槽
1. 移除静态绑定的卡槽节点,改用cardPrefab动态生成3个卡槽
2. 删除所有关闭面板按钮的事件绑定与回调函数
3. 简化面板显示隐藏逻辑,移除panelsHidden状态变量
4. 重构注释与变量命名,提升代码可读性
2026-07-25 22:25:57 +08:00

1758 lines
68 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 由 cardPrefab 动态实例化生成,通过 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, UITransform } 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, SpecialRefreshCardList, SpecialRefreshHeroType, SpecialUpgradeCardList } from "../common/config/CardSet";
import { drawSkillCards } from "../common/config/SCardSet";
import { drawEquipCards } from "../common/config/EquipSet";
import { drawItemCards } from "../common/config/ICardSet";
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";
import { ItemListComp } from "./ItemListComp";
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)
showCards: Node = null!
@property(Node)
cardPanNode: Node = null!
@property(Node)
cards_node: Node = null!
/** 卡牌槽位预制体(动态实例化 3 个) */
@property(Prefab)
cardPrefab: Prefab = null!
/** 抽卡(刷新)按钮节点 */
@property(Node)
cards_chou: Node = null!
@property(Node)
closeCards: Node = null!
@property(Node)
nock_node: Node = null!
/** 英雄面板显示按钮 */
@property(Node)
showHeros: Node = null!
@property(Node)
herosPanNode: Node = null!
@property(Node)
herosBoxNode: Node = null!
@property(Prefab)
heroPrefab: Prefab = null!
/** 装备面板显示按钮 */
@property(Node)
showEquips: Node = null!
@property(Node)
equipsPanNode: Node = null!
@property(Node)
equipsBoxNode: Node = null!
@property(Prefab)
equipPrefab: Prefab = null!
/** 技能面板显示按钮 */
@property(Node)
showSkills: Node = null!
@property(Node)
skillPanNode: Node = null!
@property(Node)
skillBoxNode: Node = null!
@property(Prefab)
skillPrefab: Prefab = null!
/** 药品面板显示按钮 */
@property(Node)
showShop: Node = null!
@property(Node)
shopPanNode: Node = null!
@property(Node)
shopBoxNode: Node = null!
@property(Prefab)
itemPrefab: Prefab = 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!
/** 装备刷新按钮节点 */
@property(Node)
equip_refresh: Node = null!;
/** 药品刷新按钮节点 */
@property(Node)
shop_refresh: Node = null!;
// ======================== 运行时状态 ========================
/** 三个槽位对应的 CardComp 控制器缓存(有序数组) */
private cardComps: CardComp[] = [];
/** 技能卡槽控制器缓存 */
private skillCardComps: SCardComp[] = [];
/** 是否已召唤过英雄(用于控制其他面板按钮是否可点击) */
private hasCalledHero: boolean = false;
/** 已购买装备的 UUID 集合(跨波次保持,防止重复购买) */
private purchasedEquipUuids: Set<number> = new Set();
/** 已购买商品的 UUID 集合(跨波次保持,防止重复购买) */
private purchasedItemUuids: Set<number> = new Set();
// ======================== 面板轮播状态 ========================
/** 面板滑动间距(单面板宽度,运行时由 initPanelLayout 推导) */
private panelSlideWidth: number = 720;
/** 当前激活的面板索引0=英雄, 1=装备, 2=技能, 3=药品 */
private currentPanelIndex: number = 0;
/** 各面板基准 Y 坐标缓存 */
private panelBaseYs: number[] = [];
/** 是否已初始化面板布局 */
private hasInitPanelLayout: boolean = false;
/** 是否已缓存卡牌面板基准缩放 */
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. 初始化卡牌面板缩放参数。
* 5. 初始化面板轮播布局。
*/
onLoad() {
this.bindEvents();
this.cacheCardComps();
this.layoutCardSlots();
this.initCardsPanelPos();
this.initPanelLayout();
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.showEquips && this.showEquips.isValid) {
this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this);
}
if (this.showSkills && this.showSkills.isValid) {
this.showSkills.off(NodeEventType.TOUCH_END, this.onShowSkillsClick, this);
}
if (this.showShop && this.showShop.isValid) {
this.showShop.off(NodeEventType.TOUCH_END, this.onShowShopClick, 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();
// 重置商品购买记录并填充商品商店
this.purchasedItemUuids.clear();
this.populateShopItems();
// 重置英雄召唤状态(游戏刚开始时其他面板不可点击)
this.hasCalledHero = false;
this.updatePanelButtonsInteractable();
// 首次进入准备阶段自动抽取一次技能卡,后续刷新只能通过技能刷新按钮触发
this.initSkillCardsOnce();
mLogger.log(this.debugMode, "MissionCardComp", "mission start");
}
/** 任务结束:清空 3 槽 + 装备商店 + 英雄面板并隐藏整个节点 */
onMissionEnd() {
this.clearAllCards();
this.clearEquipments();
this.clearShopItems();
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.UseEquipCard, this.onUseEquipCard, this);
// 监听商品购买事件,追踪已购记录
oops.message.on(GameEvent.UseItemCard, this.onUseItemCard, 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.showEquips?.on(NodeEventType.TOUCH_END, this.onShowEquipsClick, this);
/** 技能面板显示按钮 */
this.showSkills?.on(NodeEventType.TOUCH_END, this.onShowSkillsClick, this);
/** 药品面板显示按钮 */
this.showShop?.on(NodeEventType.TOUCH_END, this.onShowShopClick, 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);
/** 装备刷新按钮 */
this.equip_refresh?.on(NodeEventType.TOUCH_START, this.onEquipDrawTouchStart, this);
this.equip_refresh?.on(NodeEventType.TOUCH_END, this.onEquipDrawTouchEnd, this);
this.equip_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onEquipDrawTouchCancel, this);
/** 药品刷新按钮 */
this.shop_refresh?.on(NodeEventType.TOUCH_START, this.onShopDrawTouchStart, this);
this.shop_refresh?.on(NodeEventType.TOUCH_END, this.onShopDrawTouchEnd, this);
this.shop_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onShopDrawTouchCancel, 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();
}
}
/**
* 首次进入准备阶段时自动抽取一次技能卡:
* - 仅在任务开始时调用一次。
* - 后续抽卡只能通过技能刷新按钮触发。
* - 面板轮播模式下技能面板始终 active仅通过滑动切换可见性。
*/
private initSkillCardsOnce() {
if (this.skillCardComps.length === 0) {
this.cacheCardComps();
}
const cards = this.buildSkillDrawCards();
this.dispatchCardsToSkillSlots(cards);
this.playSkillCardEnterAnim();
// 首次弹出技能三选一的时候弹出guide2
if (!smc.finish_guides.includes(2)) {
oops.gui.open(UIID.Guide2);
}
}
/**
* 技能面板按钮回调:滑动到技能面板(index=2)。
*/
private onShowSkillsClick() {
oops.audio.playEffect("music/button");
this.slideToPanel(2);
}
// ======================== 商店面板 ========================
/**
* 商店面板按钮回调:滑动到药品商店面板(index=3)。
*/
private onShowShopClick() {
oops.audio.playEffect("music/button");
this.slideToPanel(3);
}
/**
* 填充商品列表:
* 从 ICardSet 按权重抽取 3 张商品卡,
* 实例化 itemPrefab 到 shopBoxNode
* 每个商品项由 ItemListComp 渲染并处理购买。
*
* 商品为一次性 buff 技能Instant 触发t_times=1
* 购买后立即生效,由 MissSkillsComp 统一处理技能逻辑。
*/
private populateShopItems() {
if (!this.shopBoxNode || !this.itemPrefab) return;
// 清空旧列表
this.shopBoxNode.removeAllChildren();
// 从商品卡池按权重抽取 3 张
const items = drawItemCards(3);
for (const item of items) {
const node = instantiate(this.itemPrefab);
this.shopBoxNode.addChild(node);
const comp = node.getComponent(ItemListComp) || node.addComponent(ItemListComp);
comp.applyCardData(item);
// 已购买的商品标记为已购
if (this.purchasedItemUuids.has(item.uuid)) {
comp.setPurchased();
}
}
// 根据物品数量设置列表面板高度
this.resizeListBox(this.shopBoxNode, items.length);
// 默认显示商店面板
if (this.shopPanNode) {
this.shopPanNode.active = true;
}
mLogger.log(this.debugMode, "MissionCardComp", "populate shop items", {
count: items.length
});
}
/** 清空商品列表 */
private clearShopItems() {
if (this.shopBoxNode && this.shopBoxNode.isValid) {
this.shopBoxNode.removeAllChildren();
}
}
private dispatchCardsToSkillSlots(cards: CardConfig[]) {
if (!this.skillCardComps) return;
for (let i = 0; i < this.skillCardComps.length; i++) {
if (this.skillCardComps[i]) {
this.skillCardComps[i].applyCardData(cards[i] ?? null);
}
}
}
private onUseSkillCard(event: string, args: any) {
// 购买技能卡后不再关闭弹窗:技能卡池与英雄卡池保持一致,
// 由 onCardUsed 立即向被使用的槽位补充一张新技能卡。
// 弹窗由外部(按钮/阶段切换)控制显隐。
// 修复:独立判断 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);
}
/** 装备购买事件回调:记录已购 UUID防止重复购买 */
private onUseEquipCard(event: string, args: any) {
const usedCard = args as CardConfig;
if (usedCard) {
this.purchasedEquipUuids.add(usedCard.uuid);
}
}
/** 商品购买事件回调:记录已购 UUID防止重复购买 */
private onUseItemCard(event: string, args: any) {
const usedCard = args as CardConfig;
if (usedCard) {
this.purchasedItemUuids.add(usedCard.uuid);
}
}
/** 解除按钮监听,避免节点销毁后回调泄漏 */
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.UseEquipCard, this.onUseEquipCard, this);
oops.message.off(GameEvent.UseItemCard, this.onUseItemCard, 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.showEquips && this.showEquips.isValid) {
this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, 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);
}
if (this.showShop && this.showShop.isValid) {
this.showShop.off(NodeEventType.TOUCH_END, this.onShowShopClick, this);
}
if (this.equip_refresh && this.equip_refresh.isValid) {
this.equip_refresh.off(NodeEventType.TOUCH_START, this.onEquipDrawTouchStart, this);
this.equip_refresh.off(NodeEventType.TOUCH_END, this.onEquipDrawTouchEnd, this);
this.equip_refresh.off(NodeEventType.TOUCH_CANCEL, this.onEquipDrawTouchCancel, this);
}
if (this.shop_refresh && this.shop_refresh.isValid) {
this.shop_refresh.off(NodeEventType.TOUCH_START, this.onShopDrawTouchStart, this);
this.shop_refresh.off(NodeEventType.TOUCH_END, this.onShopDrawTouchEnd, this);
this.shop_refresh.off(NodeEventType.TOUCH_CANCEL, this.onShopDrawTouchCancel, 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);
// 第一次召唤英雄后,开放其他面板按钮
if (!this.hasCalledHero) {
this.hasCalledHero = true;
this.updatePanelButtonsInteractable();
}
// 第一次召唤英雄后关闭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);
}
// ======================== 面板轮播系统 ========================
/** 面板节点数组(顺序:英雄→装备→技能→药品) */
private getPanelNodes(): Node[] {
return [this.cards_node, this.equipsPanNode, this.skill_card_node, this.shopPanNode];
}
/** 面板显示按钮数组(同上排序) */
private getPanelShowButtons(): Node[] {
return [this.showHeros, this.showEquips, this.showSkills, this.showShop];
}
/**
* 初始化面板轮播布局:
* 1. 缓存各面板基准 Y 坐标。
* 2. 推导面板滑动间距(取场景设计分辨率宽度或默认 720
* 3. 将所有面板排列为水平队列初始显示英雄面板index=0
*/
private initPanelLayout() {
if (this.hasInitPanelLayout) return;
const panels = this.getPanelNodes();
this.panelBaseYs = [];
// 以第一个有效面板的位置为基准,推导滑动间距
let refX = 0;
for (const p of panels) {
if (p && p.isValid) {
refX = p.getPosition().x;
break;
}
}
this.panelSlideWidth = Math.abs(refX) * 2 || 720;
if (this.panelSlideWidth < 400) this.panelSlideWidth = 720;
for (let i = 0; i < panels.length; i++) {
const node = panels[i];
if (!node || !node.isValid) {
this.panelBaseYs.push(0);
continue;
}
node.active = true;
const pos = node.getPosition();
this.panelBaseYs.push(pos.y);
// 初始位置:当前面板(英雄)居中,其余按序排列
const targetX = (i - this.currentPanelIndex) * this.panelSlideWidth;
node.setPosition(targetX, pos.y, pos.z);
}
this.hasInitPanelLayout = true;
this.updatePanelButtonStates();
}
/**
* 滑动到指定面板。
* @param index 0=英雄, 1=装备, 2=技能, 3=药品
*/
private slideToPanel(index: number) {
const panels = this.getPanelNodes();
if (index < 0 || index >= panels.length) return;
this.currentPanelIndex = index;
const duration = 0.25;
for (let i = 0; i < panels.length; i++) {
const node = panels[i];
if (!node || !node.isValid) continue;
const targetX = (i - index) * this.panelSlideWidth;
const baseY = this.panelBaseYs[i] ?? node.getPosition().y;
Tween.stopAllByTarget(node);
tween(node)
.to(duration, { position: new Vec3(targetX, baseY, 0) }, { easing: 'quadOut' })
.start();
}
this.updatePanelButtonStates();
}
/**
* 更新面板 tab 按钮的 active 子节点状态。
* 当前激活面板对应的按钮 active 高亮,其余关闭。
*/
private updatePanelButtonStates() {
const buttons = this.getPanelShowButtons();
for (let i = 0; i < buttons.length; i++) {
const btn = buttons[i];
if (!btn || !btn.isValid) continue;
const activeChild = btn.getChildByName("active");
if (activeChild) {
activeChild.active = (i === this.currentPanelIndex);
}
}
}
/**
* 更新面板按钮的可交互状态。
* 游戏刚开始时(未召唤英雄),只有英雄面板可打开;
* 召唤第一个英雄后,其他面板才允许点击。
* 通过 nock_node阻挡层控制按钮是否可点击。
*/
private updatePanelButtonsInteractable() {
const interactable = this.hasCalledHero;
if (this.nock_node && this.nock_node.isValid) {
this.nock_node.active = !interactable;
}
}
/**
* 英雄面板按钮回调:滑动到英雄面板(index=0)。
*/
private onShowHerosClick() {
oops.audio.playEffect("music/button");
this.slideToPanel(0);
}
// ======================== 装备商店面板 ========================
/**
* 装备面板按钮回调:滑动到装备商店面板(index=1)。
*/
private onShowEquipsClick() {
oops.audio.playEffect("music/button");
this.slideToPanel(1);
}
/**
* 根据子项数量设置容器高度。
* 单项高度 100间隔 5上下各加 25 冗余(共 50
*/
private resizeListBox(boxNode: Node, count: number) {
if (!boxNode || !boxNode.isValid) return;
const itemHeight = 100;
const spacing = 5;
const padding = 50;
const height = Math.max(0, count) * (itemHeight + spacing) - spacing + padding;
const uiTransform = boxNode.getComponent(UITransform) || boxNode.addComponent(UITransform);
uiTransform.setContentSize(uiTransform.width, height);
}
/**
* 填充装备列表:
* 从 EquipPoolList 按权重抽取 3 张装备卡,
* 实例化 equipPrefab 到 equipsBoxNode
* 每个装备项由 EquipListComp 渲染并处理购买。
*
* 已购买的装备会标记为已购状态(隐藏购买按钮)。
*/
private populateEquipments() {
if (!this.equipsBoxNode || !this.equipPrefab) return;
// 清空旧列表
this.equipsBoxNode.removeAllChildren();
// 从装备卡池按权重抽取 3 张
const equips = drawEquipCards(3);
for (const equip of equips) {
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();
}
}
// 根据物品数量设置列表面板高度
this.resizeListBox(this.equipsBoxNode, equips.length);
// 默认显示装备面板
if (this.equipsPanNode) {
this.equipsPanNode.active = true;
}
mLogger.log(this.debugMode, "MissionCardComp", "populate equipments", {
count: equips.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);
}
// ======================== 装备/药品刷新按钮回调 ========================
private onEquipDrawTouchStart() {
this.playButtonPressAnim(this.equip_refresh);
}
private onEquipDrawTouchEnd() {
oops.audio.playEffect("music/button");
this.playButtonClickAnim(this.equip_refresh, () => this.onClickEquipRefresh());
}
private onEquipDrawTouchCancel() {
this.playButtonResetAnim(this.equip_refresh);
}
private onShopDrawTouchStart() {
this.playButtonPressAnim(this.shop_refresh);
}
private onShopDrawTouchEnd() {
oops.audio.playEffect("music/button");
this.playButtonClickAnim(this.shop_refresh, () => this.onClickShopRefresh());
}
private onShopDrawTouchCancel() {
this.playButtonResetAnim(this.shop_refresh);
}
/** 装备刷新:扣费后重新抽取 3 张装备展示 */
private onClickEquipRefresh() {
const cost = MissionEconomy.getRefreshCost(this.refreshCost);
const success = MissionEconomy.executeRefresh(this.refreshCost);
if (!success) {
this.showSmallTip("refresh_coin");
return;
}
this.populateEquipments();
}
/** 药品刷新:扣费后重新抽取 3 张商品展示 */
private onClickShopRefresh() {
const cost = MissionEconomy.getRefreshCost(this.refreshCost);
const success = MissionEconomy.executeRefresh(this.refreshCost);
if (!success) {
this.showSmallTip("refresh_coin");
return;
}
this.populateShopItems();
}
/** 将 cardPrefab 实例化为 3 个卡槽并映射为 CardComp形成固定顺序控制数组 */
private cacheCardComps() {
// 清理旧卡槽(如果存在)
for (const comp of this.cardComps) {
if (comp && comp.node && comp.node.isValid) {
comp.node.destroy();
}
}
this.cardComps = [];
// 从预制体实例化 3 个卡槽
if (this.cardPrefab && this.cards_node && this.cards_node.isValid) {
for (let i = 0; i < 3; i++) {
const node = instantiate(this.cardPrefab);
this.cards_node.addChild(node);
const comp = node.getComponent(CardComp) || node.addComponent(CardComp);
this.cardComps.push(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);
}
/**
* 进入准备阶段:
* - 初始化面板轮播布局(如尚未初始化)。
* - 滑动到英雄面板index=0作为默认展示。
* - 激活 showHeros 按钮可见性。
*/
private enterPreparePhase() {
if (!this.cards_node || !this.cards_node.isValid) return;
this.initCardsPanelPos();
// 确保面板布局已初始化
if (!this.hasInitPanelLayout) {
this.initPanelLayout();
}
// 显式激活「显示英雄卡池」按钮本身,让玩家可见可点
if (this.showHeros && this.showHeros.isValid) {
this.showHeros.active = true;
}
if (this.cards_chou && this.cards_chou.isValid) {
const nobg = this.cards_chou.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
}
// 滑动到英雄面板
this.slideToPanel(0);
}
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;
}
/**
* 构建技能卡抽卡结果,返回 3 张。
*
* 技能卡池长期存在,不再按波次过滤,
* 直接从 SCardSet 的 drawSkillCards 按权重抽取。
*/
private buildSkillDrawCards(): CardConfig[] {
return drawSkillCards(3);
}
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.applyCardData(null);
});
}
}
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)}`;
}
}
if (this.equip_refresh) {
const nobg = this.equip_refresh.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
const coinNode = this.equip_refresh.getChildByName("coin");
const numLabel = coinNode?.getChildByName("num")?.getComponent(Label);
if (numLabel) {
numLabel.string = `${MissionEconomy.getRefreshCost(this.refreshCost)}`;
}
}
if (this.shop_refresh) {
const nobg = this.shop_refresh.getChildByName("nobg");
if (nobg) {
nobg.active = !this.canDrawCards();
}
const coinNode = this.shop_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 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();
this.purchasedItemUuids.clear();
if (this.node && this.node.isValid) {
this.node.destroy();
}
}
}