Files
pixelheros/assets/script/game/map/MissionCardComp.ts
panFD c27c81cfa6 refactor: 重构关卡战斗流程与AI逻辑,改为单场Boss战模式
1.  近战AI新增警戒线机制,无威胁时退回编队
2.  替换多回合波次为单场Boss战,简化刷怪与回合逻辑
3.  重构招募系统为开局免费三选一流程
4.  调整英雄与怪物基础属性平衡
5.  精简HUD与配置文件冗余代码
2026-08-13 00:21:54 +08:00

2371 lines
95 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 槽位。
* 抽卡规则:从基础英雄卡池按权重抽取,同一次抽卡内英雄不重复;
* 不过滤已召唤英雄,抽到同 uuid 可触发二合一合成升级。
* 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, Widget } 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 { HeroBoxComp } from "./HeroBoxComp";
import { SCardComp } from "./SCardComp";
import { MissEquipComp } from "./MissEquipComp";
import { MissSkillsComp } from "./MissSkillsComp";
import { oops } from "db://oops-framework/core/Oops";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { smc } from "../common/SingletonModuleComp";
import { HeroInfo, HType, resolveTriggerByLv, resolveFieldByLv, resolveReviveByLv, calcGrowBonus } 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)
cardPanNode: Node = null!
@property(Node)
cards_node: Node = null!
/** 卡牌槽位预制体(动态实例化 3 个) */
@property(Prefab)
cardPrefab: Prefab = 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(Node)
showHerosCost: Node = null!
/** 装备面板显示按钮 */
@property(Node)
showEquips: Node = null!
@property(Node)
equipsPanNode: Node = null!
@property(Node)
equipsBoxNode: Node = null!
@property(Prefab)
equipPrefab: Prefab = null!
/** 装备面板开启金币费用显示节点 */
@property(Node)
showEquipsCost: Node = null!
/** 技能面板显示按钮 */
@property(Node)
showSkills: Node = null!
@property(Node)
skillPanNode: Node = null!
@property(Node)
skillBoxNode: Node = null!
@property(Prefab)
sCardPrefab: Prefab = null!
/** 技能面板开启金币费用显示节点 */
@property(Node)
showSkillsCost: Node = null!
/** 药品面板显示按钮 */
@property(Node)
showShop: Node = null!
@property(Node)
shopPanNode: Node = null!
@property(Node)
shopBoxNode: Node = null!
@property(Prefab)
itemPrefab: Prefab = null!
/** 药品面板开启金币费用显示节点 */
@property(Node)
showShopCost: Node = null!
/** 技能刷新按钮节点 */
@property(Node)
skill_refresh: Node = null!
/** 装备刷新按钮节点 */
@property(Node)
equip_refresh: Node = null!;
/** 药品刷新按钮节点 */
@property(Node)
shop_refresh: Node = null!;
/** 刷新卡池按钮节点 */
@property(Node)
cards_refresh: Node = null!
/** 金币显示节点(含 icon + num 子节点) */
@property(Node)
coins_node: Node = null!
/** 英雄数量显示节点(含 icon + num 子节点) */
@property(Node)
hero_num_node: Node = null!
/** 刷新石数量显示节点(含 icon + num 子节点) */
@property(Node)
refresh_stone_num_node: Node = null!
// ======================== 运行时状态 ========================
/** 槽位水平坐标(屏幕 720 三等分,卡片 230列宽 240 */
private readonly slotPosX: number[] = [-240, 0, 240];
/** 三个槽位对应的 CHeroComp 控制器缓存(有序数组) */
private cardComps: CHeroComp[] = [];
/** 英雄面板 HeroBoxComp 控制器缓存(与场上英雄一一对应,最多 3 个) */
private heroBoxComps: HeroBoxComp[] = [];
/** 装备卡槽控制器缓存 */
private equipCardComps: EquipListComp[] = [];
/** 技能卡槽控制器缓存 */
private skillCardComps: SCardComp[] = [];
/** 药品卡槽控制器缓存 */
private itemCardComps: ItemListComp[] = [];
/** 是否已召唤过英雄(用于控制其他面板按钮是否可点击) */
private hasCalledHero: boolean = false;
/** 开局免费连续三选一的总次数 */
private static readonly OPENING_FREE_PICK_COUNT = 3;
/** 开局免费三选一剩余次数(>0 时招募不扣费,选完 1 张自动弹出下一组) */
private openingFreePicks: number = 0;
// ======================== 面板划出状态 ========================
/** 当前激活的面板索引0=英雄, 1=装备, 2=技能, 3=药品 */
private currentPanelIndex: number = 0;
/** 覆盖面板(卡牌/装备/技能/药品)显示位置:面板铺满屏幕,显示时固定位于 (0,0) */
private readonly panelShowPos: Vec3 = new Vec3(0, 0, 0);
/** 覆盖面板显示态缩放 (1,1) */
private readonly panelShowScale: Vec3 = new Vec3(1, 1, 1);
/** 覆盖面板隐藏态缩放 (0,0) */
private readonly panelHideScale: Vec3 = new Vec3(0, 0, 1);
/** 是否已初始化面板布局 */
private hasInitPanelLayout: boolean = false;
/** 英雄抽卡面板是否处于展开状态(驱动 showHeros 按钮 active 高亮) */
private isCardPanelShown: boolean = false;
/** 抽卡面板开启初始金币费用 */
private static readonly PANEL_OPEN_BASE_COST = 5;
/** 抽卡面板每抽 1 次的费用递增 */
private static readonly PANEL_OPEN_COST_STEP = 5;
/** 各抽卡面板当前开启费用 [英雄抽卡, 装备, 技能, 药品] */
private panelOpenCosts: number[] = [];
/** 是否已缓存卡牌面板基准缩放 */
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 readonly cardPanelAnimDuration: number = 0.25;
// ======================== 新手按钮指引 ========================
/** 新手按钮指引脉动放大倍率 */
private static readonly BUTTON_GUIDE_PULSE_SCALE: number = 1.15;
/** 新手按钮指引每步时长(秒) */
private static readonly BUTTON_GUIDE_STEP_DURATION: number = 4.2;
/** 新手按钮指引气泡停留时长(秒) */
private static readonly BUTTON_GUIDE_TIP_STAY: number = 3.4;
/** 指引步骤配置完成标记键smc.data.tip_done+ 按钮节点取值器 + 气泡文案 */
private readonly guideSteps: Array<{ key: "hero" | "equip" | "skill" | "shop", getBtn: () => Node | null, text: string }> = [
{ key: "hero", getBtn: () => this.showHeros, text: "这里召唤英雄" },
{ key: "equip", getBtn: () => this.showEquips, text: "这里购买装备" },
{ key: "skill", getBtn: () => this.showSkills, text: "这里购买技能卷轴" },
{ key: "shop", getBtn: () => this.showShop, text: "这里购买药品" },
];
/** 当前指引下标(-1 = 未在指引中) */
private guideStepIndex: number = -1;
/** 气泡原文缓存(指引结束后还原) */
private guideTipTextCache: Map<Node, string> = new Map();
// ======================== 生命周期 ========================
/**
* 组件加载:
* 1. 绑定生命周期事件和按钮交互事件。
* 2. 缓存 3 个 CHeroComp 子控制器引用。
* 3. 计算并设置槽位水平布局。
* 4. 初始化卡牌面板缩放参数。
* 5. 初始化面板轮播布局。
*/
onLoad() {
this.bindEvents();
this.cacheCardComps();
this.hideAllHeroBoxSlots();
this.initCardsPanelPos();
this.initPanelLayout();
mLogger.log(this.debugMode, "MissionCardComp", "onLoad init", {
slots: this.cardComps.length,
});
}
/** 组件销毁时解绑所有事件并清理英雄信息面板 */
onDestroy() {
super.onDestroy();
// 清理新手按钮指引的调度与动画,防止回调泄漏
this.stopButtonGuide();
this.unscheduleAllCallbacks();
if (this.cards_refresh && this.cards_refresh.isValid) {
this.cards_refresh.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_refresh.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_refresh.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
}
if (this.showHeros && this.showHeros.isValid) {
this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, 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_refresh);
this.resetPanelOpenCosts();
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.populateEquipments();
// 填充技能卡槽英雄三槽模式sCardPrefab 实例化 3 个槽位)
this.populateSkillCards();
// 填充商品商店(不限购)
this.populateShopItems();
// 重置英雄召唤状态(游戏刚开始时其他面板不可点击)
this.hasCalledHero = false;
this.updatePanelButtonsInteractable();
// 初始化英雄信息盒3 个空槽位)
// 暂不启用 herobox 同步显示英雄信息(后续可能恢复),保留代码备查
// this.refreshHeroBoxSlots();
// 重置开局免费三选一次数,随后立即弹出第 1 组三选一
this.openingFreePicks = MissionCardComp.OPENING_FREE_PICK_COUNT;
this.showCardsPanel();
mLogger.log(this.debugMode, "MissionCardComp", "mission start");
}
/** 任务结束:清空 3 槽 + 装备商店 + 技能卡槽 + 英雄面板并隐藏整个节点 */
onMissionEnd() {
this.clearAllCards();
this.clearEquipments();
this.clearSkillCards();
this.clearShopItems();
for (const comp of this.heroBoxComps) {
if (comp && comp.node && comp.node.isValid) {
comp.applyEmpty();
}
}
// 任务结束清理:停止新手按钮指引的调度与动画
this.stopButtonGuide();
this.unscheduleAllCallbacks();
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_refresh
*/
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.RefreshStone, this.onRefreshStone, 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.HeroLvUp, this.onHeroLvUp, this);
oops.message.on(GameEvent.UseHeroCard, this.onUseHeroCard, 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);
oops.message.on(GameEvent.HeroBoxEmptyClick, this.onHeroBoxEmptyClick, this);
oops.message.on(GameEvent.HeroUpgrade, this.onHeroUpgrade, this);
/** 按钮触控事件:抽卡 */
this.cards_refresh?.on(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_refresh?.on(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, 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 onRefreshStone(event: string, args: any) {
const payload = args ?? event;
const v = typeof payload === 'number' ? payload : (payload?.delta ?? payload?.value ?? 0);
this.updateDrawCostUI();
if (v !== 0) {
this.playRefreshStoneChangeAnim(v > 0);
}
}
/** 战斗开始:保留卡牌面板,允许玩家在战斗阶段继续抽卡和召唤英雄 */
private onFightStart() {
this.enterBattlePhase();
// 首次进入战斗阶段:启动按钮逐个指引(仅提醒 1 次,全部完成则自动跳过)
const tips = smc.data.tip_done;
if (!tips.hero || !tips.equip || !tips.skill || !tips.shop) {
this.playButtonGuide();
}
}
private onShowSmallTip(event: string, args: any) {
// 支持字符串(仅类型)或 { type, text }(类型 + 自定义文案)两种负载
const payload = typeof args === "string" ? { type: args } : (args ?? {});
this.showSmallTip(payload.type, payload.text);
}
/**
* 在目标节点上弹出 smalltip 气泡(缩放动画)。
* @param type 提示类型(决定挂载到哪个节点)
* - refresh_coin / buy_coin / hero_full挂在抽卡按钮 / 金币节点 / 英雄数量节点
* - panel_hero_full / panel_equip_full / panel_skill_full / panel_shop_full
* 挂在 showHeros / showEquips / showSkills / showShop 按钮节点(按钮下需预置 smalltip 子节点)
* @param text 可选:覆盖气泡文字(默认读 prefab 中配置的文本)
*/
public showSmallTip(type: "refresh_coin" | "buy_coin" | "hero_full" | "panel_hero_full" | "panel_equip_full" | "panel_skill_full" | "panel_shop_full", text?: string) {
let targetNode: Node | null = null;
switch (type) {
case "refresh_coin":
targetNode = this.cards_refresh;
break;
case "buy_coin":
targetNode = this.coins_node;
break;
case "hero_full":
targetNode = this.hero_num_node;
break;
case "panel_hero_full":
targetNode = this.showHeros;
break;
case "panel_equip_full":
targetNode = this.showEquips;
break;
case "panel_skill_full":
targetNode = this.showSkills;
break;
case "panel_shop_full":
targetNode = this.showShop;
break;
}
if (targetNode && targetNode.isValid) {
const tipNode = targetNode.getChildByName("smalltip");
if (tipNode) {
// 动态覆盖气泡文字box/Label用于同节点多文案场景如英雄满员 / 至少保留 1 人)
if (text) {
const label = tipNode.getComponentInChildren(Label);
if (label) label.string = text;
}
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");
if (this.isOverlayPanelShown(2)) return;
// 技能槽位已满:气泡提示并中止,不再展开三选一,也不扣开启费用
if (MissSkillsComp.isFull()) {
this.showSmallTip("panel_skill_full", "技能槽已满");
return;
}
// 开启技能面板需支付金币,金币不足则提示并中止
if (!this.tryPayPanelOpenCost(2)) return;
this.slideToPanel(2);
}
// ======================== 商店面板 ========================
/**
* 商店面板按钮回调:药品商店面板从底部划出(index=3)。
*/
private onShowShopClick() {
oops.audio.playEffect("music/button");
if (this.isOverlayPanelShown(3)) return;
// 开启药品面板需支付金币,金币不足则提示并中止
if (!this.tryPayPanelOpenCost(3)) return;
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]);
} else {
// 多余槽位清空
comp.applyCardData(null as unknown as CardConfig);
}
}
// 根据物品数量设置列表面板高度
this.resizeListBox(this.shopBoxNode, targetCount);
// 三等分水平布局
this.layoutSlotsHorizontal(this.itemCardComps);
mLogger.log(this.debugMode, "MissionCardComp", "populate shop items", {
count: items.length,
slotCount: this.itemCardComps.length
});
}
/**
* 确保药品槽位数量足够。
* 优先复用现有槽位,不足时从 itemPrefab 实例化补充。
* 实例化后立即按 slotPosX 定位,避免堆叠在 (0,0)。
*/
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);
}
this.layoutSlotsHorizontal(this.itemCardComps);
}
/** 清空商品列表(复用槽位,仅清空数据) */
private clearShopItems() {
for (const comp of this.itemCardComps) {
if (comp && comp.node && comp.node.isValid) {
comp.applyCardData(null as unknown as CardConfig);
}
}
}
// ======================== 装备/商品购买事件已移除限购逻辑 ========================
/** 解除按钮监听,避免节点销毁后回调泄漏 */
private unbindEvents() {
oops.message.off(GameEvent.CoinAdd, this.onCoinAdd, this);
oops.message.off(GameEvent.RefreshStone, this.onRefreshStone, 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.HeroLvUp, this.onHeroLvUp, this);
oops.message.off(GameEvent.UseHeroCard, this.onUseHeroCard, 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);
oops.message.off(GameEvent.HeroBoxEmptyClick, this.onHeroBoxEmptyClick, this);
oops.message.off(GameEvent.HeroUpgrade, this.onHeroUpgrade, this);
if (this.cards_refresh && this.cards_refresh.isValid) {
this.cards_refresh.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this);
this.cards_refresh.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this);
this.cards_refresh.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this);
}
if (this.showHeros && this.showHeros.isValid) {
this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, 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();
}
// 英雄登场后同步英雄信息盒
// 暂不启用 herobox 同步(后续可能恢复)
// this.refreshHeroBoxSlots();
}
/** 英雄死亡事件回调:刷新面板列表并更新英雄数量 UI */
private onHeroDead() {
this.updateHeroNumUI(true, false);
// 暂不启用 herobox 同步(后续可能恢复)
// this.refreshHeroBoxSlots();
}
/** 英雄被出售事件回调:更新英雄数量 UI 并清空对应英雄信息盒 */
private onHeroSell() {
this.updateHeroNumUI(true, false);
// 暂不启用 herobox 同步(后续可能恢复)
// this.refreshHeroBoxSlots();
}
/**
* 英雄面板升级请求回调(由 HeroBoxComp 确认弹窗后派发)。
* 扣费成功后按 eid 精确升级;金币不足或不可升级时 toast 提示。
*/
private onHeroUpgrade(event: string, args: any) {
const payload = args ?? event;
const eid: number = payload?.eid ?? 0;
if (!eid) return;
const actor = this.queryAliveHeroActors().find(item => item.eid === eid);
if (!actor) return;
// 无限升级:不再拦截满级,仅校验金币
// 统一经济管理入口:按当前等级扣费
if (!MissionEconomy.executeUpgradeCost(actor.model.lv)) {
oops.gui.toast(`金币不足`);
return;
}
this.tryUpgradeHeroByEid(eid);
}
/** 英雄升级事件回调:刷新英雄信息盒显示 */
private onHeroLvUp() {
// 暂不启用 herobox 同步(后续可能恢复)
// this.refreshHeroBoxSlots();
}
/** 英雄面板空槽位点击回调:展示英雄卡池选择界面 */
private onHeroBoxEmptyClick() {
this.onShowCardsClick();
}
/**
* 使用英雄卡的 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);
// 开局免费三选一次数未用完时保持面板展开,连续弹出下一组三选一
if (this.openingFreePicks > 0) {
this.openingFreePicks--;
if (this.openingFreePicks <= 0) {
// 最后一组选完,免费次数耗尽,收起面板并通知自动进入战斗
this.hideCardsPanel();
oops.message.dispatchEvent("OpeningPicksDone");
}
} else {
// 抽 1 张后关闭英雄卡池面板
this.hideCardsPanel();
}
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();
// 抽 1 张后关闭装备面板
this.hideOverlayPanel(1);
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();
// 抽 1 张后关闭技能面板
this.hideOverlayPanel(2);
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();
// 抽 1 张后关闭药品面板
this.hideOverlayPanel(3);
mLogger.log(this.debugMode, "MissionCardComp", "refresh item pool after buy", {
triggerSlot: itemIdx
});
return;
}
}
// ======================== 按钮触控回调 ========================
/** 抽卡按钮按下反馈 */
private onDrawTouchStart() {
this.playButtonPressAnim(this.cards_refresh);
}
/** 抽卡按钮释放 → 执行抽卡逻辑 */
private onDrawTouchEnd() {
oops.audio.playEffect("music/button");
this.playButtonClickAnim(this.cards_refresh, () => this.onClickDraw());
}
/** 抽卡按钮取消 → 恢复缩放 */
private onDrawTouchCancel() {
this.playButtonResetAnim(this.cards_refresh);
}
// ======================== 新手按钮指引 ========================
/**
* 启动新手按钮指引:依次指引 英雄→装备→技能→药品 四个面板按钮。
* 每步播放 bg/icon 循环缩放脉动 + smalltip 气泡文案,
* 已完成的步骤smc.data.tip_done 对应键为 true自动跳过仅提醒 1 次。
*/
private playButtonGuide() {
// 确保无旧指引残留,再从头开始
this.stopButtonGuide();
this.guideStepIndex = -1;
this.nextButtonGuideStep();
}
/** 推进到下一个未完成指引的步骤;全部完成则结束 */
private nextButtonGuideStep() {
this.guideStepIndex++;
// 跳过已完成的步骤
while (this.guideStepIndex < this.guideSteps.length
&& smc.data.tip_done[this.guideSteps[this.guideStepIndex].key]) {
this.guideStepIndex++;
}
if (this.guideStepIndex >= this.guideSteps.length) {
this.finishButtonGuide();
return;
}
this.playButtonGuideStep();
}
/** 播放当前步指引,并用 scheduleOnce 驱动进入下一步 */
private playButtonGuideStep() {
const step = this.guideSteps[this.guideStepIndex];
if (!step) return;
const btn = step.getBtn();
if (btn && btn.isValid) {
this.playGuidePulse(btn, true);
this.showGuideTip(btn, step.text);
}
this.scheduleOnce(() => {
this.stopGuidePulse(btn);
// 本步提醒完成:记录完成标记(即使中止也只标记已播完的步骤)
smc.data.tip_done[step.key] = true;
this.nextButtonGuideStep();
}, MissionCardComp.BUTTON_GUIDE_STEP_DURATION);
}
/** 指引全部完成:还原气泡文案 */
private finishButtonGuide() {
this.restoreGuideTipTexts();
this.guideStepIndex = -1;
mLogger.log(this.debugMode, "MissionCardComp", "button guide finished");
}
/**
* 中止指引(任务结束 / 组件销毁时调用):
* 停止所有缩放脉动动画、还原气泡文案并隐藏气泡。
*/
private stopButtonGuide() {
if (this.guideStepIndex < 0 && this.guideTipTextCache.size === 0) return;
for (const step of this.guideSteps) {
this.stopGuidePulse(step.getBtn());
}
this.restoreGuideTipTexts();
this.guideStepIndex = -1;
}
/** 还原指引覆盖过的气泡文案并隐藏气泡 */
private restoreGuideTipTexts() {
for (const [tipNode, text] of this.guideTipTextCache) {
if (tipNode && tipNode.isValid) {
Tween.stopAllByTarget(tipNode);
const label = tipNode.getComponentInChildren(Label);
if (label) label.string = text;
tipNode.active = false;
}
}
this.guideTipTextCache.clear();
}
/**
* 对按钮 bg/icon 子节点播放循环缩放脉动动画。
* 使用 scale 往复 tween不修改 position避免与 Widget 布局冲突。
* @param btn 面板按钮节点(内部含 bg/icon 子节点)
* @param repeat 是否无限循环true = 循环脉动)
*/
private playGuidePulse(btn: Node, repeat: boolean) {
const pulseScale = MissionCardComp.BUTTON_GUIDE_PULSE_SCALE;
for (const name of ["bg", "icon"]) {
const child = btn.getChildByName(name);
if (!child || !child.isValid) continue;
Tween.stopAllByTarget(child);
child.setScale(1, 1, 1);
// 单轮脉动约 0.6s,持续闪动与气泡停留节奏一致
const pulseTween = tween(child)
.to(0.3, { scale: new Vec3(pulseScale, pulseScale, 1) }, { easing: 'sineInOut' })
.to(0.3, { scale: new Vec3(1, 1, 1) }, { easing: 'sineInOut' });
if (repeat) {
tween(child).repeatForever(pulseTween).start();
} else {
pulseTween.start();
}
}
}
/** 停止按钮 bg/icon 缩放脉动并复位缩放 */
private stopGuidePulse(btn: Node | null) {
if (!btn || !btn.isValid) return;
for (const name of ["bg", "icon"]) {
const child = btn.getChildByName(name);
if (!child || !child.isValid) continue;
Tween.stopAllByTarget(child);
child.setScale(1, 1, 1);
}
}
/**
* 在按钮节点上弹出 smalltip 气泡并覆盖文案。
* 首次覆盖前缓存原文,供指引结束后还原。
* @param btn 面板按钮节点(下挂 smalltip 子节点)
* @param text 指引文案
*/
private showGuideTip(btn: Node, text: string) {
const tipNode = btn.getChildByName("smalltip");
if (!tipNode) return;
if (!this.guideTipTextCache.has(tipNode)) {
const label = tipNode.getComponentInChildren(Label);
this.guideTipTextCache.set(tipNode, label ? label.string : "");
}
const label = tipNode.getComponentInChildren(Label);
if (label) label.string = text;
tipNode.active = true;
Tween.stopAllByTarget(tipNode);
tipNode.setScale(0, 0, 1);
tween(tipNode)
.to(0.15, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' })
.to(0.05, { scale: new Vec3(1, 1, 1) })
.delay(MissionCardComp.BUTTON_GUIDE_TIP_STAY)
.to(0.15, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' })
.call(() => {
if (tipNode && tipNode.isValid) tipNode.active = false;
})
.start();
}
// ======================== 面板底部划出系统 ========================
/** 面板节点数组(顺序:英雄→装备→技能→药品) */
private getPanelNodes(): Node[] {
return [this.herosPanNode, this.equipsPanNode, this.skillPanNode, this.shopPanNode];
}
/** 面板显示按钮数组(同上排序) */
private getPanelShowButtons(): Node[] {
return [this.showHeros, this.showEquips, this.showSkills, this.showShop];
}
/**
* 判断覆盖面板index≥1当前是否处于展开状态避免重复开启导致重复扣费。
* @param index 面板下标1=装备 2=技能 3=药品)
*/
private isOverlayPanelShown(index: number): boolean {
if (this.currentPanelIndex !== index) return false;
const node = this.getPanelNodes()[index];
return !!node && node.isValid && node.active;
}
/**
* 初始化面板布局:
* 1. 英雄面板index=0为常驻基准面板始终显示。
* 2. 覆盖面板(卡牌/装备/技能/药品)铺满屏幕、位置固定 (0,0)
* 通过缩放 0↔1 控制显示/隐藏,初始缩放置 0 并停用。
*/
private initPanelLayout() {
if (this.hasInitPanelLayout) return;
const panels = this.getPanelNodes();
const heroNode = panels[0];
if (heroNode && heroNode.isValid) {
heroNode.active = true;
}
// 覆盖面板从 index 1 开始:位置由代码接管,禁用 Widget 避免其在 lateUpdate 对齐覆盖 position
for (let i = 1; i < panels.length; i++) {
this.initOverlayPanel(panels[i]);
}
// 卡牌面板与其他覆盖面板同级,同上处理
this.initOverlayPanel(this.cardPanNode);
this.hasInitPanelLayout = true;
this.updatePanelButtonStates();
}
/** 单个覆盖面板初始化:禁用 Widget、定位到显示位置、缩放置 0 并隐藏 */
private initOverlayPanel(node: Node | null) {
if (!node || !node.isValid) return;
const widget = node.getComponent(Widget);
if (widget) widget.enabled = false;
node.setPosition(this.panelShowPos);
node.setScale(this.panelHideScale);
node.active = false;
}
/**
* 显示覆盖面板:缩放从 0 动画放大到 1。
* @param node 覆盖面板节点(卡牌/装备/技能/药品)
*/
private showOverlayPanel(node: Node | null) {
if (!node || !node.isValid) return;
node.setPosition(this.panelShowPos);
node.setScale(this.panelHideScale);
node.active = true;
Tween.stopAllByTarget(node);
tween(node)
.to(this.cardPanelAnimDuration, { scale: this.panelShowScale }, { easing: 'quadOut' })
.start();
}
/**
* 隐藏覆盖面板:缩放从当前值动画缩小到 0结束后停用节点。
* @param node 覆盖面板节点(卡牌/装备/技能/药品)
*/
private hideOverlayPanelNode(node: Node | null) {
if (!node || !node.isValid) return;
Tween.stopAllByTarget(node);
tween(node)
.to(this.cardPanelAnimDuration, { scale: this.panelHideScale }, { easing: 'quadIn' })
.call(() => {
if (node.isValid) node.active = false;
})
.start();
}
/**
* 切换到指定面板:
* 英雄面板index=0为常驻基准面板
* 选中的覆盖面板缩放放大到 1 显示,其余覆盖面板缩放缩小到 0 隐藏。
* @param index 0=英雄, 1=装备, 2=技能, 3=药品
*/
private slideToPanel(index: number) {
const panels = this.getPanelNodes();
if (index < 0 || index >= panels.length) return;
this.currentPanelIndex = index;
// 覆盖面板从 index 1 开始index 0 英雄面板常驻不动)
for (let i = 1; i < panels.length; i++) {
const node = panels[i];
if (!node || !node.isValid) continue;
if (i === index) {
this.showOverlayPanel(node);
} else {
this.hideOverlayPanelNode(node);
}
}
this.updatePanelButtonStates();
}
/**
* 更新面板 tab 按钮的 active 子节点状态。
* 装备/技能/药品按钮按 currentPanelIndex 高亮;
* 英雄抽卡面板cardPanNode不在覆盖面板数组内
* showHeros 按钮按 isCardPanelShown 高亮:面板展开时显示 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) {
const shouldActive = i === 0 ? this.isCardPanelShown : (i === this.currentPanelIndex);
activeChild.active = shouldActive;
}
}
}
/**
* 更新面板按钮的可交互状态。
* 游戏刚开始时(未召唤英雄),只有英雄面板可打开;
* 召唤第一个英雄后,其他面板才允许点击。
* 通过 nock_node阻挡层控制按钮是否可点击。
*/
private updatePanelButtonsInteractable() {
const interactable = this.hasCalledHero;
if (this.nock_node && this.nock_node.isValid) {
this.nock_node.active = !interactable;
}
}
/**
* 英雄招募按钮:每次都展开三选一面板,玩家选 1
* 开局免费三选一次数未用完时不扣面板开启费。
* 满员HERO_MAX_NUM时气泡提示并拦截不扣费、不计数。
*/
private onShowHerosClick() {
oops.audio.playEffect("music/button");
// 满员拦截
if (this.getAliveHeroCount() >= this.getMissionHeroMaxNum()) {
this.showSmallTip("panel_hero_full", "英雄槽已满");
return;
}
// 开局免费次数内不扣面板开启费
if (this.openingFreePicks <= 0 && !this.tryPayPanelOpenCost(0)) return;
this.slideToPanel(0);
this.showCardsPanel();
}
// ======================== 英雄信息盒herosBoxNode ========================
/**
* 同步英雄信息盒槽位与场上英雄:
* 1. 刷新 herosBoxNode 下 HeroBoxComp 槽位缓存(编辑器已预放 3 个)。
* 2. 查询当前存活英雄列表按序绑定到槽位bindHero 内部激活节点)。
* 3. 多余槽位清空数据并隐藏(默认全部隐藏,召唤一个激活一个)。
*
* Why: 英雄召唤 / 死亡 / 卖出 / 升级后调用,保证面板与场上一一对应。
*
* 备注:当前版本暂不启用 herobox 同步显示英雄信息(后续可能恢复),
* 方法体已整体注释,所有调用点也已注释。恢复时取消方法体与调用点注释。
*/
private refreshHeroBoxSlots() {
/* 暂不启用 herobox 同步,保留实现备查
if (!this.herosBoxNode || !this.herosBoxNode.isValid) return;
this.ensureHeroBoxSlots();
// 查询场上存活英雄(含复活中的实体,按 eid 升序保证稳定顺序)
const actors: Array<{ eid: number, model: HeroAttrsComp }> = [];
ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => {
const model = entity.get(HeroAttrsComp);
if (!model) return;
if (model.fac !== FacSet.HERO) return;
if (model.is_dead) return;
actors.push({ eid: entity.eid, model });
});
actors.sort((a, b) => a.eid - b.eid);
for (let i = 0; i < this.heroBoxComps.length; i++) {
const comp = this.heroBoxComps[i];
if (!comp || !comp.node || !comp.node.isValid) continue;
const actor = actors[i];
if (actor) {
comp.bindHero(actor.eid, actor.model);
} else {
// 空槽位默认隐藏:召唤一个英雄才激活一个槽位
comp.applyEmpty();
comp.node.active = false;
}
}
// 列表高度只按可见槽位(存活英雄数)计算,空槽位已隐藏不再占位
this.resizeListBox(this.herosBoxNode, actors.length);
*/
}
/**
* 缓存英雄信息盒槽位组件引用。
* 槽位已在编辑器中预先放置herosBoxNode 下挂 HeroBoxComp
* 运行时仅刷新缓存,不再动态实例化。
*/
private ensureHeroBoxSlots() {
if (!this.herosBoxNode || !this.herosBoxNode.isValid) return;
this.heroBoxComps = this.herosBoxNode.children
.map(node => node.getComponent(HeroBoxComp))
.filter((comp): comp is HeroBoxComp => !!comp);
}
/** 英雄信息盒槽位默认全部隐藏,召唤一个英雄再激活一个 */
private hideAllHeroBoxSlots() {
this.ensureHeroBoxSlots();
for (const comp of this.heroBoxComps) {
if (comp && comp.node && comp.node.isValid) {
comp.node.active = false;
}
}
}
/**
* 支付抽卡面板开启费用:
* 金币足够则扣除当前费用,并将该面板费用递增 PANEL_OPEN_COST_STEP
* 不足则弹出"金币不足"小提示并返回 false。
* @param panelIndex 面板下标0=英雄抽卡 1=装备 2=技能 3=药品)
* @returns 是否支付成功
*/
private tryPayPanelOpenCost(panelIndex: number): boolean {
const cost = this.panelOpenCosts[panelIndex] ?? MissionCardComp.PANEL_OPEN_BASE_COST;
if (!MissionEconomy.spendCoin(cost)) {
oops.message.dispatchEvent(GameEvent.ShowSmallTip, "buy_coin");
return false;
}
this.panelOpenCosts[panelIndex] = cost + MissionCardComp.PANEL_OPEN_COST_STEP;
this.updatePanelOpenCostUI();
return true;
}
/** 重置 4 个抽卡面板开启费用为初始值(任务开始时调用) */
private resetPanelOpenCosts() {
this.panelOpenCosts = [
MissionCardComp.PANEL_OPEN_BASE_COST,
MissionCardComp.PANEL_OPEN_BASE_COST,
MissionCardComp.PANEL_OPEN_BASE_COST,
MissionCardComp.PANEL_OPEN_BASE_COST
];
this.updatePanelOpenCostUI();
}
/** 刷新 4 个显示按钮上的开启金币费用显示 */
private updatePanelOpenCostUI() {
const costNodes = this.getPanelCostNodes();
for (let i = 0; i < costNodes.length; i++) {
const node = costNodes[i];
if (!node || !node.isValid) continue;
const label = node.getComponent(Label);
if (label) {
label.string = `${this.panelOpenCosts[i] ?? MissionCardComp.PANEL_OPEN_BASE_COST}`;
}
}
}
/** 获取 4 个面板开启金币费用显示节点顺序0=英雄 1=装备 2=技能 3=药品) */
private getPanelCostNodes(): Node[] {
return [this.showHerosCost, this.showEquipsCost, this.showSkillsCost, this.showShopCost];
}
/**
* 显示卡牌面板:缩放从 0 动画放大到 1
*/
private onShowCardsClick() {
oops.audio.playEffect("music/button");
// 已展开则不重复开启(不重复扣费)
if (this.isCardPanelShown) return;
// 空槽位点开英雄抽卡面板同样需支付金币
if (!this.tryPayPanelOpenCost(0)) return;
this.showCardsPanel();
}
/**
* 隐藏卡牌面板:缩放动画缩小到 0
*/
private onCloseCardsClick() {
oops.audio.playEffect("music/button");
this.hideCardsPanel();
}
/**
* 显示卡牌面板:缩放 0→1并点亮 showHeros 按钮的 active 高亮。
* 统一开合入口,供按钮点击与系统逻辑复用。
*/
private showCardsPanel() {
this.isCardPanelShown = true;
this.showOverlayPanel(this.cardPanNode);
this.updatePanelButtonStates();
}
/** 隐藏卡牌面板:缩放缩小到 0同时隐藏 showHeros 按钮的 active 高亮 */
private hideCardsPanel() {
this.isCardPanelShown = false;
this.hideOverlayPanelNode(this.cardPanNode);
this.updatePanelButtonStates();
}
/**
* 隐藏指定覆盖面板(缩放缩小到 0 并停用):
* 用于抽 1 张卡成功后自动收起对应卡池面板,露出下层英雄面板。
* @param index 面板索引1=装备, 2=技能, 3=药品0 为英雄基准面板,不可隐藏)
*/
private hideOverlayPanel(index: number) {
const panels = this.getPanelNodes();
if (index < 1 || index >= panels.length) return;
const node = panels[index];
if (!node || !node.isValid) return;
this.hideOverlayPanelNode(node);
// 面板收起后 tab 高亮回归英雄面板
this.currentPanelIndex = 0;
this.updatePanelButtonStates();
}
// ======================== 装备商店面板 ========================
/**
* 装备面板按钮回调:装备商店面板从底部划出(index=1)。
*/
private onShowEquipsClick() {
oops.audio.playEffect("music/button");
if (this.isOverlayPanelShown(1)) return;
// 装备槽位已满:气泡提示并中止,不再展开三选一,也不扣开启费用
if (MissEquipComp.isFull()) {
this.showSmallTip("panel_equip_full", "装备槽已满");
return;
}
// 开启装备面板需支付金币,金币不足则提示并中止
if (!this.tryPayPanelOpenCost(1)) return;
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]);
} else {
// 多余槽位清空
comp.applyCardData(null as unknown as CardConfig);
}
}
// 根据物品数量设置列表面板高度
this.resizeListBox(this.equipsBoxNode, targetCount);
// 三等分水平布局
this.layoutSlotsHorizontal(this.equipCardComps);
mLogger.log(this.debugMode, "MissionCardComp", "populate equipments", {
count: equips.length,
slotCount: this.equipCardComps.length
});
}
/**
* 确保装备槽位数量足够。
* 优先复用现有槽位,不足时从 equipPrefab 实例化补充。
* 实例化后立即按 slotPosX 定位,避免堆叠在 (0,0)。
*/
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);
}
this.layoutSlotsHorizontal(this.equipCardComps);
}
/** 清空装备列表(复用槽位,仅清空数据) */
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);
// 三等分水平布局
this.layoutSlotsHorizontal(this.skillCardComps);
mLogger.log(this.debugMode, "MissionCardComp", "populate skill cards", {
count: skills.length,
slotCount: this.skillCardComps.length
});
}
/**
* 确保技能槽位数量足够。
* 优先复用现有槽位,不足时从 sCardPrefab 实例化补充。
* 实例化后立即按 slotPosX 定位,避免堆叠在 (0,0)。
*/
private ensureSkillSlots(targetCount: number) {
if (!this.skillBoxNode || !this.sCardPrefab) return;
// 刷新缓存
this.cacheSkillCardComps();
// 补充实例化不足的槽位
while (this.skillCardComps.length < targetCount) {
const node = instantiate(this.sCardPrefab);
// 先禁用根节点 Widget 再挂到父节点:
// 否则 addChild 激活当帧 lateUpdate 时 Widget 会对齐覆盖 position
const widget = node.getComponent(Widget);
if (widget) widget.enabled = false;
this.skillBoxNode.addChild(node);
const comp = node.getComponent(SCardComp) || node.addComponent(SCardComp);
this.skillCardComps.push(comp);
}
this.layoutSlotsHorizontal(this.skillCardComps);
}
/** 清空技能卡槽(复用槽位,仅清空数据) */
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);
// 先禁用根节点 Widget 再挂到父节点:
// 否则 addChild 激活当帧 lateUpdate 时 Widget 会对齐覆盖 position
const widget = node.getComponent(Widget);
if (widget) widget.enabled = false;
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
});
}
// 实例化后立即按 slotPosX 定位,避免堆叠在 (0,0)
this.layoutCardSlots();
} 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 refresh", {
cost,
leftCoin: MissionEconomy.getCoin()
});
this.layoutCardSlots();
const cards = this.buildDrawCards();
this.dispatchCardsToSlots(cards);
}
/**
* 构建随机召唤卡池:全英雄卡。
* @returns 卡配置数组
*/
private buildRandomSummonPool(): CardConfig[] {
return CardPoolList.filter(c => c.type === CardType.Hero);
}
// ======================== 阶段切换 ========================
/** 缓存卡牌面板的基准缩放值(从场景初始状态读取,仅缓存一次) */
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;
}
// 刷新所有刷新按钮的费用显示(含刷新石/金币切换与置灰状态)
this.updateDrawCostUI();
// 显示卡牌面板(默认进入准备阶段时展开,缩放放大到 1点亮按钮 active 高亮)
if (this.cardPanNode && this.cardPanNode.isValid) {
this.showCardsPanel();
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,
panelShowPos: this.panelShowPos
});
}
private enterBattlePhase() {
if (!this.cards_node || !this.cards_node.isValid) return;
this.initCardsPanelPos();
// 战斗阶段允许抽卡:刷新所有刷新按钮的费用显示(含刷新石/金币切换与置灰状态)
this.updateDrawCostUI();
}
/**
* 构建本次抽卡结果,返回 3 张英雄卡。
*
* 规则:
* 1. 从基础英雄卡池按权重抽取,同一次抽卡内英雄不重复。
* 2. 不过滤已召唤英雄:抽到同 uuid 可触发二合一合成升级。
* 3. 不足 3 张时循环补齐(允许重复)。
*
* 注:升级卡机制已移除,英雄卡池只出普通英雄卡。
*/
private buildDrawCards(): CardConfig[] {
const availableHeroCards = CardPoolList.filter(c => c.type === CardType.Hero);
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({
count: 3,
type: CardType.Hero,
heroType,
unique: true, // 保证一次刷新内的英雄卡不重复
});
// 不过滤已召唤英雄:抽到同 uuid 可触发二合一合成升级
if (cards.length <= 0) return false;
this.layoutCardSlots();
this.dispatchCardsToSlots(cards.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() {
// 走 CHeroComp.setSlotPosition同步更新其 restPosition
// 避免 clearBySystem 恢复位置时与布局结果不一致
for (let i = 0; i < this.cardComps.length && i < this.slotPosX.length; i++) {
const comp = this.cardComps[i];
if (comp && comp.node && comp.node.isValid) {
const widget = comp.node.getComponent(Widget);
if (widget) widget.enabled = false;
comp.setSlotPosition(this.slotPosX[i]);
}
}
}
/**
* 三等分水平布局:
* 按固定坐标数组 slotPosX 排列槽位,超出数量的槽位不处理。
*
* Why: 各卡池根节点的 Layout 组件已移除,槽位水平位置改由代码统一控制。
* 部分预制体根节点残留 Widget 组件(对齐父节点中心),
* 启用时会在 lateUpdate 覆盖代码设置的 position需先禁用。
*
* @param comps 槽位组件数组CHeroComp / EquipListComp / SCardComp / ItemListComp
*/
private layoutSlotsHorizontal(comps: Array<CHeroComp | EquipListComp | SCardComp | ItemListComp>) {
for (let i = 0; i < comps.length && i < this.slotPosX.length; i++) {
const node = comps[i]?.node;
if (!node || !node.isValid) continue;
// 禁用根节点 Widget防止其在对齐刷新时覆盖 position
const widget = node.getComponent(Widget);
if (widget) widget.enabled = false;
const pos = node.getPosition();
node.setPosition(this.slotPosX[i], pos.y, pos.z);
}
}
/**
* 延迟一帧执行布局:
* 首次实例化的嵌套预制体在首帧会先恢复到原始位置,
* 等下一帧预制体初始化完成后再定位,避免首帧堆叠在 (0,0)。
*/
private layoutSlotsHorizontalDeferred(comps: Array<CHeroComp | EquipListComp | SCardComp | ItemListComp>) {
this.scheduleOnce(() => {
this.layoutSlotsHorizontal(comps);
}, 0);
}
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.canRefresh(this.refreshCost);
}
private updateDrawCostUI() {
const stones = MissionEconomy.getRefreshStone();
const stoneCost = MissionEconomy.getRefreshStoneCost();
this.updateOneRefreshBtnUI(this.cards_refresh, stones, stoneCost);
this.updateOneRefreshBtnUI(this.equip_refresh, stones, stoneCost);
this.updateOneRefreshBtnUI(this.shop_refresh, stones, stoneCost);
this.updateOneRefreshBtnUI(this.skill_refresh, stones, stoneCost);
}
/**
* 更新单个刷新按钮的费用显示与可用状态。
*
* 统一 UI 结构(按钮节点下):
* - 背景 :按钮底图
* - nobg :置灰遮罩,不可刷新时激活
* - Node/ :费用容器,下含 Label + coin + stone
* - coin/ :金币费用(含 num 子节点 Label刷新石=0 时显示
* - stone/ :刷新石费用(含 num 子节点 Label刷新石>0 时显示
*
* @param btnNode 刷新按钮节点
* @param stones 当前刷新石数量(外部统一读取,避免 4 次重复访问)
* @param stoneCost 单次刷新消耗的刷新石数量
*/
private updateOneRefreshBtnUI(btnNode: Node | null, stones: number, stoneCost: number) {
if (!btnNode || !btnNode.isValid) return;
// 刷新石足够一次消耗时走刷新石通道,否则走金币通道
const useStone = stones >= stoneCost;
// 置灰遮罩:有刷新石必定可刷新,否则按金币判断
const nobg = btnNode.getChildByName("nobg");
if (nobg) {
nobg.active = useStone ? false : !this.canDrawCards();
}
// 费用容器:按钮下的 Node 子节点(统一结构:背景 + Node[Label/coin/stone]
const container = btnNode.getChildByName("Node");
if (!container) return;
// 金币费用节点
const coinNode = container.getChildByName("coin");
if (coinNode) {
coinNode.active = !useStone;
if (!useStone) {
const numLabel = coinNode.getChildByName("num")?.getComponent(Label);
if (numLabel) {
numLabel.string = `${MissionEconomy.getRefreshCost(this.refreshCost)}`;
}
}
}
// 刷新石费用节点(显示单次刷新消耗数量)
const stoneNode = container.getChildByName("stone");
if (stoneNode) {
stoneNode.active = useStone;
if (useStone) {
const numLabel = stoneNode.getChildByName("num")?.getComponent(Label);
if (numLabel) {
numLabel.string = `${stoneCost}`;
}
}
}
}
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);
}
/** 刷新石计数变化动画(与金币一致的 icon+num 弹跳) */
private playRefreshStoneChangeAnim(isIncrease: boolean) {
if (!this.refresh_stone_num_node || !this.refresh_stone_num_node.isValid) return;
const peak = isIncrease ? 1.2 : 1.2;
const icon = this.refresh_stone_num_node.getChildByName("icon");
if (icon && icon.isValid) this.playHeroNumNodePop(icon, peak);
const num = this.refresh_stone_num_node.getChildByName("num");
if (num && num.isValid) 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;
}
/**
* 按 eid 精确升级场上对应英雄实体。
*
* @param heroEid 要升级的英雄实体 eid
* @returns true = 升级成功
*/
public tryUpgradeHeroByEid(heroEid: number): boolean {
if (!heroEid) return false;
const actor = this.queryAliveHeroActors().find(item => item.eid === heroEid);
if (!actor) return false;
const nextLv = actor.model.lv + 1;
this.applyHeroLevel(actor.model, nextLv);
if (actor.view) {
actor.view.playBuff("buff_lvup");
}
return true;
}
/**
* 应用英雄等级变化(属性 = 固定基础值 + grow_type 指数递增重新计算)。
* 沿用旧版本 SkillLvUp 的技能等级映射规则(技能按 HERO_SKILL_MAX_LV 封顶)。
*
* 与 Hero.load 保持一致:
* 1. 同步刷新 base_ap/base_hp 快照,下游 UI/复活读到当前 lv 的基础值
* 2. 走 getRuntimeAp/getRuntimeHp 应用驻场光环乘区
* 3. 重新解析 runtime_* 触发缓存与光环/复活,避免升级后停在旧等级快照
* 4. 按 ≤nextLv 累加 hp_bonus/ap_bonus
*/
public applyHeroLevel(model: HeroAttrsComp, targetLv: number) {
const hero = HeroInfo[model.hero_uuid];
if (!hero) return;
const nextLv = Math.max(1, Math.floor(targetLv));
const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1;
model.lv = nextLv;
// 同步 base 快照(与 Hero.load 同公式:固定基础值,成长由 grow_type 指数递增提供)
const base_ap = hero.ap;
const base_hp = hero.hp;
model.base_ap = base_ap;
model.base_hp = base_hp;
// 走驻场光环乘区,与 Hero.load 行为一致;怪物保持基础值
if (model.fac === FacSet.HERO) {
model.ap = model.getRuntimeAp(base_ap);
model.hp_max = model.getRuntimeHp(base_hp);
} else {
model.ap = base_ap;
model.hp_max = base_hp;
}
// 按 ≤nextLv 累加:成长类型每级递增 + hp_bonus/ap_bonus 一次性奖励(沿用 Hero.load 的加法区规则)
const grow = calcGrowBonus(hero, nextLv);
model.hp_max += grow.hp;
model.ap += grow.ap;
if (model.hp_bonus) {
let hpExtra = 0;
for (const e of model.hp_bonus) { if (e.lv <= nextLv) hpExtra += e.value; }
model.hp_max += hpExtra;
}
if (model.ap_bonus) {
let apExtra = 0;
for (const e of model.ap_bonus) { if (e.lv <= nextLv) apExtra += e.value; }
model.ap += apExtra;
}
// 血量按当前血线比例映射到新上限,保留升级前战斗状态
model.hp = Math.max(1, Math.floor(model.hp_max * Math.max(0, Math.min(1, hpRate))));
// 重新解析触发技能/驻场光环/复活到运行时缓存(与 Hero.load 对齐)
model.runtime_call = resolveTriggerByLv(model.call, nextLv);
model.runtime_dead = resolveTriggerByLv(model.dead, nextLv);
model.runtime_fstart = resolveTriggerByLv(model.fstart, nextLv);
model.runtime_fend = resolveTriggerByLv(model.fend, nextLv);
model.runtime_atking = resolveTriggerByLv(model.atking, nextLv);
model.runtime_atked = resolveTriggerByLv(model.atked, nextLv);
model.runtime_field = resolveFieldByLv(model.field, nextLv);
model.runtime_revive = resolveReviveByLv(model.revive, nextLv);
model.skills = {};
for (const key in hero.skills) {
const skill = hero.skills[key];
if (!skill) continue;
model.skills[skill.uuid] = { ...skill, lv: Math.min(FightSet.HERO_SKILL_MAX_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_refresh);
// 关键:在 reset/销毁 时将 Map 置空,彻底切断引用
this.cardComps = [];
this.heroBoxComps = [];
this.skillCardComps = [];
if (this.node && this.node.isValid) {
this.node.destroy();
}
}
}