Files
pixelheros/assets/script/game/map/MissionCardComp.ts
panFD 1593c7ffbf refactor(map): 统一卡牌购买后的全池刷新逻辑
1.  为装备、物品、技能卡牌组件统一添加CardUsed事件派发
2.  重构MissionCardComp的卡槽管理,改为槽位复用模式
3.  拆分并统一装备、物品、技能卡池的刷新逻辑
4.  移除冗余的UseSkillCard事件监听与处理
5.  整理并缓存各类卡槽组件引用
2026-07-26 19:19:30 +08:00

1796 lines
69 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 个 CHeroComp 槽位。
* 抽卡规则:从基础英雄卡池按权重抽取,同一次抽卡内英雄不重复;
* 场上已召唤的英雄不再刷出。
* 2. **金币费用管理** —— 抽卡费用refreshCost的扣除。
* 3. **英雄数量上限校验** —— 在 UseHeroCard 事件的 guard 阶段判断是否允许再召唤英雄。
* 4. **场上英雄信息面板HInfoComp 列表)同步** ——
* 英雄上场时实例化面板,死亡时移除,定时刷新属性显示。
* 5. **特殊卡执行** —— 处理英雄升级卡SpecialUpgrade按 UUID 精确升级)和
* 英雄刷新卡SpecialRefresh
* 6. **准备/战斗阶段切换** —— 控制卡牌面板的 展开/收起 动画。
*
* 关键设计:
* - 3 个 CHeroComp 由 cardPrefab 动态实例化生成,通过 cacheCardComps() 映射为有序数组 cardComps[]
* 之后所有分发、清空操作均通过此数组进行。
* - buildDrawCards() 从基础英雄卡池按权重抽取 3 张,不足时循环补齐。
* - 英雄上限校验onUseHeroCard采用 guard/cancel 模式:
* CHeroComp 发出 UseHeroCard 事件并传入 guard 对象,
* 本组件可通过 guard.cancel=true 阻止使用。
*
* 历史:
* 旧版本曾包含"卡池等级poolLv"机制和"三合一合成腾位"判断,已全部移除:
* - 卡牌不再分级,所有英雄卡统一 lv1。
* - 升级卡机制已移除英雄卡池只出普通英雄卡英雄升级仅通过升级卡SpecialUpgrade触发。
*
* 依赖:
* - CHeroComp —— 单卡槽位
* - 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 } from "../common/config/CardSet";
import { drawEquipCards } from "../common/config/EquipSet";
import { drawItemCards } from "../common/config/ICardSet";
import { drawSkillCards } from "../common/config/SCardSet";
import { CHeroComp } from "./CHeroComp";
import { EquipListComp } from "./EquipListComp";
import { SCardComp } from "./SCardComp";
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 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)
sCardPrefab: 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_refresh: Node = null!
/** 装备刷新按钮节点 */
@property(Node)
equip_refresh: Node = null!;
/** 药品刷新按钮节点 */
@property(Node)
shop_refresh: Node = null!;
// ======================== 运行时状态 ========================
/** 三个槽位对应的 CHeroComp 控制器缓存(有序数组) */
private cardComps: CHeroComp[] = [];
/** 装备卡槽控制器缓存 */
private equipCardComps: EquipListComp[] = [];
/** 技能卡槽控制器缓存 */
private skillCardComps: SCardComp[] = [];
/** 药品卡槽控制器缓存 */
private itemCardComps: ItemListComp[] = [];
/** 是否已召唤过英雄(用于控制其他面板按钮是否可点击) */
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);
/** 卡牌面板显示位置 Y 坐标 */
private readonly cardPanelShowY: number = 0;
/** 卡牌面板隐藏位置 Y 坐标(向下 700 */
private readonly cardPanelHideY: number = -700;
/** 卡牌面板显示/隐藏动画时长 */
private readonly cardPanelAnimDuration: number = 0.25;
// ======================== 生命周期 ========================
/**
* 组件加载:
* 1. 绑定生命周期事件和按钮交互事件。
* 2. 缓存 3 个 CHeroComp 子控制器引用。
* 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.showCards && this.showCards.isValid) {
this.showCards.off(NodeEventType.TOUCH_END, this.onShowCardsClick, this);
}
if (this.closeCards && this.closeCards.isValid) {
this.closeCards.off(NodeEventType.TOUCH_END, this.onCloseCardsClick, 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();
mLogger.log(this.debugMode, "MissionCardComp", "onMissionStart buildDrawCards", {
cardsLength: cards.length,
cards: cards.map(c => ({ uuid: c.uuid, type: c.type, name: c.name }))
});
this.dispatchCardsToSlots(cards);
// 重置购买记录并填充装备商店
this.purchasedEquipUuids.clear();
this.populateEquipments();
// 填充技能卡槽英雄三槽模式sCardPrefab 实例化 3 个槽位)
this.populateSkillCards();
// 重置商品购买记录并填充商品商店
this.purchasedItemUuids.clear();
this.populateShopItems();
// 重置英雄召唤状态(游戏刚开始时其他面板不可点击)
this.hasCalledHero = false;
this.updatePanelButtonsInteractable();
mLogger.log(this.debugMode, "MissionCardComp", "mission start");
}
/** 任务结束:清空 3 槽 + 装备商店 + 技能卡槽 + 英雄面板并隐藏整个节点 */
onMissionEnd() {
this.clearAllCards();
this.clearEquipments();
this.clearSkillCards();
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.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.showCards?.on(NodeEventType.TOUCH_END, this.onShowCardsClick, this);
/** 隐藏卡牌面板按钮 */
this.closeCards?.on(NodeEventType.TOUCH_END, this.onCloseCardsClick, 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.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.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.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);
}
/**
* 技能面板按钮回调:滑动到技能面板(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 张商品卡,
* 优先复用现有槽位shopBoxNode 子节点),不足再实例化新槽位,
* 每个商品项由 ItemListComp 渲染并处理购买。
*
* 商品为一次性 buff 技能Instant 触发t_times=1
* 购买后立即生效,由 MissSkillsComp 统一处理技能逻辑。
*/
private populateShopItems() {
if (!this.shopBoxNode || !this.itemPrefab) return;
// 从商品卡池按权重抽取 3 张
const items = drawItemCards(3);
const targetCount = items.length;
// 确保槽位数量足够(复用 + 补充实例化)
this.ensureItemSlots(targetCount);
// 分发数据到槽位
for (let i = 0; i < this.itemCardComps.length; i++) {
const comp = this.itemCardComps[i];
if (!comp) continue;
if (i < targetCount) {
comp.applyCardData(items[i]);
// 已购买的商品标记为已购
if (this.purchasedItemUuids.has(items[i].uuid)) {
comp.setPurchased();
}
} else {
// 多余槽位清空
comp.applyCardData(null as unknown as CardConfig);
}
}
// 根据物品数量设置列表面板高度
this.resizeListBox(this.shopBoxNode, targetCount);
// 默认显示商店面板
if (this.shopPanNode) {
this.shopPanNode.active = true;
}
mLogger.log(this.debugMode, "MissionCardComp", "populate shop items", {
count: items.length,
slotCount: this.itemCardComps.length
});
}
/**
* 确保药品槽位数量足够。
* 优先复用现有槽位,不足时从 itemPrefab 实例化补充。
*/
private ensureItemSlots(targetCount: number) {
if (!this.shopBoxNode || !this.itemPrefab) return;
// 刷新缓存
this.cacheItemCardComps();
// 补充实例化不足的槽位
while (this.itemCardComps.length < targetCount) {
const node = instantiate(this.itemPrefab);
this.shopBoxNode.addChild(node);
const comp = node.getComponent(ItemListComp) || node.addComponent(ItemListComp);
this.itemCardComps.push(comp);
}
}
/** 清空商品列表(复用槽位,仅清空数据) */
private clearShopItems() {
for (const comp of this.itemCardComps) {
if (comp && comp.node && comp.node.isValid) {
comp.applyCardData(null as unknown as CardConfig);
}
}
}
/** 装备购买事件回调:记录已购 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.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.showCards && this.showCards.isValid) {
this.showCards.off(NodeEventType.TOUCH_END, this.onShowCardsClick, this);
}
if (this.closeCards && this.closeCards.isValid) {
this.closeCards.off(NodeEventType.TOUCH_END, this.onCloseCardsClick, this);
}
if (this.showEquips && this.showEquips.isValid) {
this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, 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.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.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 校验(由 CHeroComp 通过 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 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 为该卡槽组件实例CHeroComp / EquipListComp / ItemListComp
* - 命中英雄卡槽 → 重新构建 3 张英雄池卡牌并分发到所有英雄槽。
* - 命中装备卡槽 → 重新抽取装备并分发到所有装备槽。
* - 命中药品卡槽 → 重新抽取药品并分发到所有药品槽。
* - 未命中任何槽位 → 忽略。
*
* Why: 购买一张卡后立即刷新整个卡池(不扣金币),让玩家持续从新池中挑选;
* 所有卡池遵循同一逻辑。
* SpecialRefresh 自身效果onUseSpecialCard 中的 tryRefreshHeroCards
* 已是整池刷新,因此 CHeroComp 对其不派发 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 equipIdx = this.equipCardComps.findIndex(c => c === source);
if (equipIdx >= 0) {
this.populateEquipments();
mLogger.log(this.debugMode, "MissionCardComp", "refresh equip pool after buy", {
triggerSlot: equipIdx
});
return;
}
const skillIdx = this.skillCardComps.findIndex(c => c === source);
if (skillIdx >= 0) {
this.populateSkillCards();
mLogger.log(this.debugMode, "MissionCardComp", "refresh skill pool after buy", {
triggerSlot: skillIdx
});
return;
}
const itemIdx = this.itemCardComps.findIndex(c => c === source);
if (itemIdx >= 0) {
this.populateShopItems();
mLogger.log(this.debugMode, "MissionCardComp", "refresh item pool after buy", {
triggerSlot: itemIdx
});
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.herosPanNode, this.equipsPanNode, this.skillPanNode, 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);
}
/**
* 显示卡牌面板:从底部向上滑入到 Y=0
*/
private onShowCardsClick() {
oops.audio.playEffect("music/button");
if (!this.cardPanNode || !this.cardPanNode.isValid) return;
this.cardPanNode.active = true;
Tween.stopAllByTarget(this.cardPanNode);
tween(this.cardPanNode)
.to(this.cardPanelAnimDuration, { position: new Vec3(0, this.cardPanelShowY, 0) }, { easing: 'quadOut' })
.start();
}
/**
* 隐藏卡牌面板:向下滑出到 Y=-700
*/
private onCloseCardsClick() {
oops.audio.playEffect("music/button");
if (!this.cardPanNode || !this.cardPanNode.isValid) return;
Tween.stopAllByTarget(this.cardPanNode);
tween(this.cardPanNode)
.to(this.cardPanelAnimDuration, { position: new Vec3(0, this.cardPanelHideY, 0) }, { easing: 'quadIn' })
.call(() => {
if (this.cardPanNode && this.cardPanNode.isValid) {
this.cardPanNode.active = false;
}
})
.start();
}
// ======================== 装备商店面板 ========================
/**
* 装备面板按钮回调:滑动到装备商店面板(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 张装备卡,
* 优先复用现有槽位EquipsBoxNode 子节点),不足再实例化新槽位,
* 每个装备项由 EquipListComp 渲染并处理购买。
*
* 已购买的装备会标记为已购状态(隐藏购买按钮)。
*/
private populateEquipments() {
if (!this.equipsBoxNode || !this.equipPrefab) return;
// 从装备卡池按权重抽取 3 张
const equips = drawEquipCards(3);
const targetCount = equips.length;
// 确保槽位数量足够(复用 + 补充实例化)
this.ensureEquipSlots(targetCount);
// 分发数据到槽位
for (let i = 0; i < this.equipCardComps.length; i++) {
const comp = this.equipCardComps[i];
if (!comp) continue;
if (i < targetCount) {
comp.applyCardData(equips[i]);
// 已购买的装备标记为已购
if (this.purchasedEquipUuids.has(equips[i].uuid)) {
comp.setPurchased();
}
} else {
// 多余槽位清空
comp.applyCardData(null as unknown as CardConfig);
}
}
// 根据物品数量设置列表面板高度
this.resizeListBox(this.equipsBoxNode, targetCount);
// 默认显示装备面板
if (this.equipsPanNode) {
this.equipsPanNode.active = true;
}
mLogger.log(this.debugMode, "MissionCardComp", "populate equipments", {
count: equips.length,
slotCount: this.equipCardComps.length
});
}
/**
* 确保装备槽位数量足够。
* 优先复用现有槽位,不足时从 equipPrefab 实例化补充。
*/
private ensureEquipSlots(targetCount: number) {
if (!this.equipsBoxNode || !this.equipPrefab) return;
// 刷新缓存
this.cacheEquipCardComps();
// 补充实例化不足的槽位
while (this.equipCardComps.length < targetCount) {
const node = instantiate(this.equipPrefab);
this.equipsBoxNode.addChild(node);
const comp = node.getComponent(EquipListComp) || node.addComponent(EquipListComp);
this.equipCardComps.push(comp);
}
}
/** 清空装备列表(复用槽位,仅清空数据) */
private clearEquipments() {
for (const comp of this.equipCardComps) {
if (comp && comp.node && comp.node.isValid) {
comp.applyCardData(null as unknown as CardConfig);
}
}
}
// ======================== 技能抽卡(英雄三槽模式) ========================
/**
* 填充技能卡槽:
* 从 SkillCardList 按权重抽取 3 张技能卡,
* 优先复用现有槽位skillBoxNode 子节点),不足再实例化新槽位,
* 每个技能卡项由 SCardComp 渲染并处理购买。
*/
private populateSkillCards() {
if (!this.skillBoxNode || !this.sCardPrefab) return;
// 从技能卡池按权重抽取 3 张
const skills = drawSkillCards(3);
const targetCount = skills.length;
// 确保槽位数量足够(复用 + 补充实例化)
this.ensureSkillSlots(targetCount);
// 分发数据到槽位
for (let i = 0; i < this.skillCardComps.length; i++) {
const comp = this.skillCardComps[i];
if (!comp) continue;
if (i < targetCount) {
comp.applyDrawCard(skills[i]);
} else {
// 多余槽位清空
comp.applyDrawCard(null);
}
}
// 根据技能数量设置列表面板高度
this.resizeListBox(this.skillBoxNode, targetCount);
// 默认显示技能面板
if (this.skillPanNode) {
this.skillPanNode.active = true;
}
mLogger.log(this.debugMode, "MissionCardComp", "populate skill cards", {
count: skills.length,
slotCount: this.skillCardComps.length
});
}
/**
* 确保技能槽位数量足够。
* 优先复用现有槽位,不足时从 sCardPrefab 实例化补充。
*/
private ensureSkillSlots(targetCount: number) {
if (!this.skillBoxNode || !this.sCardPrefab) return;
// 刷新缓存
this.cacheSkillCardComps();
// 补充实例化不足的槽位
while (this.skillCardComps.length < targetCount) {
const node = instantiate(this.sCardPrefab);
this.skillBoxNode.addChild(node);
const comp = node.getComponent(SCardComp) || node.addComponent(SCardComp);
this.skillCardComps.push(comp);
}
}
/** 清空技能卡槽(复用槽位,仅清空数据) */
private clearSkillCards() {
for (const comp of this.skillCardComps) {
if (comp && comp.node && comp.node.isValid) {
comp.clearBySystem();
}
}
}
/**
* 缓存技能卡槽组件引用。
* 技能卡槽在编辑器中通过 sCardPrefab 实例化为 skillBoxNode 的子节点,
* 每个子节点根节点挂载 SCardComp。
*/
private cacheSkillCardComps() {
if (!this.skillBoxNode || !this.skillBoxNode.isValid) {
this.skillCardComps = [];
return;
}
this.skillCardComps = this.skillBoxNode.children
.map(node => node.getComponent(SCardComp))
.filter((comp): comp is SCardComp => !!comp);
}
// ======================== 装备/药品刷新按钮回调 ========================
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);
}
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);
}
/** 装备刷新:扣费后重新抽取 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();
}
/** 技能刷新:扣费后重新抽取 3 张技能卡展示 */
private onClickSkillRefresh() {
const success = MissionEconomy.executeRefresh(this.refreshCost);
if (!success) {
this.showSmallTip("refresh_coin");
return;
}
this.populateSkillCards();
}
/** 将 cardPrefab 实例化为 3 个卡槽并映射为 CHeroComp形成固定顺序控制数组幂等已缓存则不重复实例化 */
private cacheCardComps() {
// 幂等保护:卡槽已存在且节点有效时,仅刷新其他缓存,不重复实例化英雄卡槽
const hasValidHeroComps = this.cardComps.length === 3
&& this.cardComps.every(c => c && c.node && c.node.isValid);
if (hasValidHeroComps) {
this.cacheEquipCardComps();
this.cacheItemCardComps();
return;
}
// 清理旧卡槽(如果存在)
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(CHeroComp) || node.addComponent(CHeroComp);
this.cardComps.push(comp);
mLogger.log(this.debugMode, "MissionCardComp", "cacheCardComps instantiate", {
index: i,
nodeName: node.name,
nodeActive: node.active,
nodeWorldPos: node.worldPosition,
parentName: this.cards_node.name,
parentActive: this.cards_node.active,
parentWorldPos: this.cards_node.worldPosition,
cardPrefabValid: this.cardPrefab.isValid
});
}
} else {
mLogger.log(this.debugMode, "MissionCardComp", "cacheCardComps failed", {
cardPrefabValid: this.cardPrefab?.isValid,
cards_nodeValid: this.cards_node?.isValid,
cards_nodeActive: this.cards_node?.active
});
}
this.cacheEquipCardComps();
this.cacheItemCardComps();
}
/**
* 缓存装备卡槽组件引用。
* 装备卡槽在编辑器中通过 equipPrefab 实例化为 equipsBoxNode 的子节点,
* 每个子节点根节点挂载 EquipListComp。
*/
private cacheEquipCardComps() {
if (!this.equipsBoxNode || !this.equipsBoxNode.isValid) {
this.equipCardComps = [];
return;
}
this.equipCardComps = this.equipsBoxNode.children
.map(node => node.getComponent(EquipListComp))
.filter((comp): comp is EquipListComp => !!comp);
}
/**
* 缓存药品卡槽组件引用。
* 药品卡槽在编辑器中通过 itemPrefab 实例化为 shopBoxNode 的子节点,
* 每个子节点根节点挂载 ItemListComp。
*/
private cacheItemCardComps() {
if (!this.shopBoxNode || !this.shopBoxNode.isValid) {
this.itemCardComps = [];
return;
}
this.itemCardComps = this.shopBoxNode.children
.map(node => node.getComponent(ItemListComp))
.filter((comp): comp is ItemListComp => !!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();
}
}
// 显示卡牌面板(默认进入准备阶段时展开)
if (this.cardPanNode && this.cardPanNode.isValid) {
this.cardPanNode.active = true;
this.cardPanNode.setPosition(0, this.cardPanelShowY, 0);
mLogger.log(this.debugMode, "MissionCardComp", "enterPreparePhase cardPanNode", {
active: this.cardPanNode.active,
position: this.cardPanNode.position,
worldPos: this.cardPanNode.worldPosition,
parentName: this.cardPanNode.parent?.name,
parentActive: this.cardPanNode.parent?.active,
parentWorldPos: this.cardPanNode.parent?.worldPosition,
cards_nodeActive: this.cards_node?.active,
cards_nodeWorldPos: this.cards_node?.worldPosition,
cards_nodeChildren: this.cards_node?.children.length
});
}
// 滑动到英雄面板
this.slideToPanel(0);
mLogger.log(this.debugMode, "MissionCardComp", "enterPreparePhase herosPanNode", {
herosPanNodeActive: this.herosPanNode?.active,
herosPanNodePos: this.herosPanNode?.position,
herosPanNodeWorldPos: this.herosPanNode?.worldPosition,
currentPanelIndex: this.currentPanelIndex,
panelSlideWidth: this.panelSlideWidth
});
}
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. 从基础英雄卡池按权重抽取,同一次抽卡内英雄不重复。
* 2. 过滤掉场上已召唤的英雄(已召唤的英雄不再刷出,仅通过升级卡升级)。
* 3. 不足 3 张时循环补齐(允许重复)。
*
* 注:升级卡机制已移除,英雄卡池只出普通英雄卡。
*/
private buildDrawCards(): CardConfig[] {
const heroCards = CardPoolList.filter(c => c.type === CardType.Hero);
const aliveHeroUuids = this.getAliveHeroUuids();
const availableHeroCards = heroCards.filter(c => !aliveHeroUuids.has(c.uuid));
if (availableHeroCards.length === 0) return [];
const result: CardConfig[] = [];
const usedUuids = new Set<number>();
// 按权重抽取,同一次抽卡内英雄不重复
while (result.length < 3) {
const pool = availableHeroCards.filter(c => !usedUuids.has(c.uuid));
if (pool.length === 0) break;
const pick = this.weightedPick(pool);
if (!pick) break;
result.push(pick);
usedUuids.add(pick.uuid);
}
// 兜底:不足 3 张时循环补齐(允许重复)
while (result.length < 3) {
result.push(availableHeroCards[result.length % availableHeroCards.length]);
}
return result.slice(0, 3);
}
/** 单次按权重抽取一张卡 */
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];
}
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 槽;每个槽位是否接收由 CHeroComp 自己判断(锁定可跳过) */
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);
const node = this.cardComps[i].node;
mLogger.log(this.debugMode, "MissionCardComp", "dispatch card", {
index: i,
card: cards[i]?.uuid ?? 0,
accepted,
nodeActive: node?.active,
nodeWorldPos: node?.worldPosition,
nodeScale: node?.scale,
parentName: node?.parent?.name,
parentActive: node?.parent?.active,
parentWorldPos: node?.parent?.worldPosition
});
}
}
}
/** 系统清空 3 槽(用于任务切换) */
private clearAllCards() {
if (!this.cardComps) return;
this.cardComps.forEach(comp => {
if (comp) comp.clearBySystem();
});
}
private layoutCardSlots() {
// 布局由 cHero.prefab 根节点 Widget 约束控制,代码不再覆盖位置
}
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.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)}`;
}
}
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 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 = [];
this.skillCardComps = [];
this.purchasedEquipUuids.clear();
this.purchasedItemUuids.clear();
if (this.node && this.node.isValid) {
this.node.destroy();
}
}
}