1. 新增新手引导配置与相关工具方法,实现英雄、装备、技能、药品四个面板按钮的依次引导 2. 修复预制体文件中冗余的节点与组件配置,清理无效的ebars_node引用 3. 新增引导结束标记逻辑,确保仅首次进入战斗阶段时触发引导 4. 在组件销毁与任务结束时自动清理引导调度与动画,防止回调泄漏
2361 lines
94 KiB
TypeScript
2361 lines
94 KiB
TypeScript
/**
|
||
* @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, 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 } 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_chou: 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;
|
||
|
||
// ======================== 面板划出状态 ========================
|
||
|
||
/** 当前激活的面板索引: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;
|
||
|
||
// ======================== 新手按钮指引 ========================
|
||
|
||
/** 新手按钮指引 ID(写入 finish_guides,已占用 0~4,此处用 5) */
|
||
private static readonly BUTTON_GUIDE_ID: number = 5;
|
||
/** 新手按钮指引抖动最大角度(度) */
|
||
private static readonly BUTTON_GUIDE_SHAKE_ANGLE: number = 10;
|
||
/** 新手按钮指引每步时长(秒) */
|
||
private static readonly BUTTON_GUIDE_STEP_DURATION: number = 4.2;
|
||
/** 新手按钮指引气泡停留时长(秒) */
|
||
private static readonly BUTTON_GUIDE_TIP_STAY: number = 3.4;
|
||
/** 指引步骤配置:按钮节点取值器 + 气泡文案 */
|
||
private readonly guideSteps: Array<{ getBtn: () => Node | null, text: string }> = [
|
||
{ getBtn: () => this.showHeros, text: "这里召唤英雄" },
|
||
{ getBtn: () => this.showEquips, text: "这里购买装备" },
|
||
{ getBtn: () => this.showSkills, text: "这里购买技能卷轴" },
|
||
{ 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_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.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.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 个空槽位)
|
||
this.refreshHeroBoxSlots();
|
||
|
||
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_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.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_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.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();
|
||
|
||
// 第一次进入战斗阶段,关闭guide4
|
||
if (!smc.finish_guides.includes(4)) {
|
||
smc.finish_guides.push(4);
|
||
oops.gui.remove(UIID.Guide4);
|
||
}
|
||
|
||
// 首次进入战斗阶段:启动按钮逐个指引(仅提醒 1 次)
|
||
if (!smc.finish_guides.includes(MissionCardComp.BUTTON_GUIDE_ID)) {
|
||
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_chou;
|
||
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_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.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);
|
||
}
|
||
|
||
// 英雄登场后同步英雄信息盒
|
||
this.refreshHeroBoxSlots();
|
||
}
|
||
|
||
/** 英雄死亡事件回调:刷新面板列表并更新英雄数量 UI */
|
||
private onHeroDead() {
|
||
this.updateHeroNumUI(true, false);
|
||
this.refreshHeroBoxSlots();
|
||
}
|
||
|
||
/** 英雄被出售事件回调:更新英雄数量 UI 并清空对应英雄信息盒 */
|
||
private onHeroSell() {
|
||
this.updateHeroNumUI(true, false);
|
||
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 (actor.model.lv >= FightSet.HERO_MAX_LV) {
|
||
oops.gui.toast(`已达最高等级`);
|
||
return;
|
||
}
|
||
// 统一经济管理入口:按当前等级扣费
|
||
if (!MissionEconomy.executeUpgradeCost(actor.model.lv)) {
|
||
oops.gui.toast(`金币不足`);
|
||
return;
|
||
}
|
||
this.tryUpgradeHeroByEid(eid);
|
||
}
|
||
|
||
/** 英雄升级事件回调:刷新英雄信息盒显示 */
|
||
private onHeroLvUp() {
|
||
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);
|
||
// 抽 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_chou);
|
||
}
|
||
/** 抽卡按钮释放 → 执行抽卡逻辑 */
|
||
private onDrawTouchEnd() {
|
||
oops.audio.playEffect("music/button");
|
||
this.playButtonClickAnim(this.cards_chou, () => this.onClickDraw());
|
||
}
|
||
/** 抽卡按钮取消 → 恢复缩放 */
|
||
private onDrawTouchCancel() {
|
||
this.playButtonResetAnim(this.cards_chou);
|
||
}
|
||
|
||
// ======================== 新手按钮指引 ========================
|
||
|
||
/**
|
||
* 启动新手按钮指引:依次指引 英雄→装备→技能→药品 四个面板按钮。
|
||
* 每步播放 bg/icon 循环抖动 + smalltip 气泡文案,
|
||
* 全部播放完成后写入 smc.finish_guides,仅提醒 1 次。
|
||
*/
|
||
private playButtonGuide() {
|
||
// 确保无旧指引残留,再从头开始
|
||
this.stopButtonGuide();
|
||
this.guideStepIndex = 0;
|
||
this.playButtonGuideStep();
|
||
}
|
||
|
||
/** 播放当前步指引,并用 scheduleOnce 驱动进入下一步 */
|
||
private playButtonGuideStep() {
|
||
const step = this.guideSteps[this.guideStepIndex];
|
||
if (!step) return;
|
||
const btn = step.getBtn();
|
||
if (btn && btn.isValid) {
|
||
this.playGuideShake(btn, true);
|
||
this.showGuideTip(btn, step.text);
|
||
}
|
||
this.scheduleOnce(() => {
|
||
this.stopGuideShake(btn);
|
||
this.guideStepIndex++;
|
||
if (this.guideStepIndex < this.guideSteps.length) {
|
||
this.playButtonGuideStep();
|
||
} else {
|
||
this.finishButtonGuide();
|
||
}
|
||
}, MissionCardComp.BUTTON_GUIDE_STEP_DURATION);
|
||
}
|
||
|
||
/** 指引全部完成:还原气泡文案并记录引导完成标记 */
|
||
private finishButtonGuide() {
|
||
this.restoreGuideTipTexts();
|
||
this.guideStepIndex = -1;
|
||
if (!smc.finish_guides.includes(MissionCardComp.BUTTON_GUIDE_ID)) {
|
||
smc.finish_guides.push(MissionCardComp.BUTTON_GUIDE_ID);
|
||
}
|
||
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.stopGuideShake(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 子节点播放循环抖动动画。
|
||
* 使用 angle 往复 tween,不修改 position,避免与 Widget 布局冲突。
|
||
* @param btn 面板按钮节点(内部含 bg/icon 子节点)
|
||
* @param repeat 是否无限循环(true = 循环抖动)
|
||
*/
|
||
private playGuideShake(btn: Node, repeat: boolean) {
|
||
const angle = MissionCardComp.BUTTON_GUIDE_SHAKE_ANGLE;
|
||
for (const name of ["bg", "icon"]) {
|
||
const child = btn.getChildByName(name);
|
||
if (!child || !child.isValid) continue;
|
||
Tween.stopAllByTarget(child);
|
||
child.angle = 0;
|
||
let t = tween(child)
|
||
.to(0.07, { angle: angle })
|
||
.to(0.14, { angle: -angle })
|
||
.to(0.07, { angle: 0 })
|
||
.delay(0.12);
|
||
if (repeat) {
|
||
t = t.repeatForever();
|
||
}
|
||
t.start();
|
||
}
|
||
}
|
||
|
||
/** 停止按钮 bg/icon 抖动并复位角度 */
|
||
private stopGuideShake(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.angle = 0;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 在按钮节点上弹出 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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 英雄面板按钮回调:
|
||
* 英雄面板(herosPanNode)为常驻基准面板,固定不动;
|
||
* 点击按钮收起装备/技能/药品覆盖面板,并显示英雄抽卡面板(cardPanNode)。
|
||
*/
|
||
private onShowHerosClick() {
|
||
oops.audio.playEffect("music/button");
|
||
// 已展开则不重复开启(不重复扣费)
|
||
if (this.isCardPanelShown) return;
|
||
// 英雄槽位已满:气泡提示并中止,不再展开三选一,也不扣开启费用
|
||
if (this.getAliveHeroCount() >= this.getMissionHeroMaxNum()) {
|
||
this.showSmallTip("panel_hero_full", "英雄槽已满");
|
||
return;
|
||
}
|
||
// 开启英雄抽卡面板需支付金币,金币不足则提示并中止
|
||
if (!this.tryPayPanelOpenCost(0)) return;
|
||
// 收起装备/技能/药品覆盖面板
|
||
this.slideToPanel(0);
|
||
// 显示英雄抽卡面板(缩放 0→1,并点亮按钮 active 高亮)
|
||
this.showCardsPanel();
|
||
}
|
||
|
||
// ======================== 英雄信息盒(herosBoxNode) ========================
|
||
|
||
/**
|
||
* 同步英雄信息盒槽位与场上英雄:
|
||
* 1. 刷新 herosBoxNode 下 HeroBoxComp 槽位缓存(编辑器已预放 3 个)。
|
||
* 2. 查询当前存活英雄列表,按序绑定到槽位(bindHero 内部激活节点)。
|
||
* 3. 多余槽位清空数据并隐藏(默认全部隐藏,召唤一个激活一个)。
|
||
*
|
||
* Why: 英雄召唤 / 死亡 / 卖出 / 升级后调用,保证面板与场上一一对应。
|
||
*/
|
||
private refreshHeroBoxSlots() {
|
||
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 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;
|
||
}
|
||
// 刷新所有刷新按钮的费用显示(含刷新石/金币切换与置灰状态)
|
||
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. 过滤掉场上已召唤的英雄(已召唤的英雄不再刷出,仅通过升级卡升级)。
|
||
* 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({
|
||
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() {
|
||
// 走 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_chou, 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;
|
||
}
|
||
|
||
/**
|
||
* 获取场上所有存活英雄的 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 = 升级成功
|
||
*/
|
||
public 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 的技能等级映射规则。
|
||
*
|
||
* 与 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.min(FightSet.HERO_MAX_LV, Math.floor(targetLv)));
|
||
const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1;
|
||
model.lv = nextLv;
|
||
|
||
// 同步 base 快照(与 Hero.load 同公式),保证 UI/复活等下游读到当前 lv 的基础值
|
||
const base_ap = hero.ap * Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1);
|
||
const base_hp = hero.hp * Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1);
|
||
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 的加法区规则)
|
||
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.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.heroBoxComps = [];
|
||
this.skillCardComps = [];
|
||
|
||
if (this.node && this.node.isValid) {
|
||
this.node.destroy();
|
||
}
|
||
}
|
||
}
|