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