/** * @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, instantiate, Label, Node, NodeEventType, Prefab, 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 { drawSkillCards } from "../common/config/SCardSet"; import { EquipPoolList } from "../common/config/EquipSet"; import { CardComp } from "./CardComp"; import { SCardComp } from "./SCardComp"; import { EquipListComp } from "./EquipListComp"; import { oops } from "db://oops-framework/core/Oops"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { smc } from "../common/SingletonModuleComp"; import { HeroInfo, HType } from "../common/config/heroSet"; import { HeroViewComp } from "../hero/HeroViewComp"; import { FacSet, FightSet } from "../common/config/GameSet"; import { MissionEconomy } from "./MissionEconomy"; import { UIID } from "../common/config/GameUIConfig"; 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! @property(Node) closeHeros: Node = null! @property(Node) showEquips: Node = null! @property(Node) closeEquips: Node = null! @property(Node) showSkills: Node = null! @property(Node) closeSkills: Node = null! @property(Node) equipsPanNode: Node = null! @property(Node) equipsBoxNode: Node = null! @property(Prefab) equipPrefab: Prefab = null! @property(Node) showShop: Node = null! @property(Node) closeShop: Node = null! @property(Node) shopPanNode: Node = null! @property(Node) shopBoxNode: Node = null! @property(Prefab) itemPrefab: Prefab = null! /** 卡池升级按钮节点(已废弃,保留节点引用防止 editor 报错) */ @property(Node) cards_up: Node = null! /** 金币显示节点(含 icon + num 子节点) */ @property(Node) coins_node: Node = null! /** 卡池等级显示节点(已废弃,保留节点引用) */ @property(Node) pool_lv_node: Node = null! /** 英雄数量显示节点(含 icon + num 子节点) */ @property(Node) hero_num_node: Node = null! /** 技能卡牌三选一弹窗节点 */ @property(Node) skill_card_node: Node = null! /**技能卡槽1节点 */ @property(Node) skill_card1: Node = null! /**技能卡槽2节点 */ @property(Node) skill_card2: Node = null! /**技能卡槽3节点 */ @property(Node) skill_card3: Node = null! /**技能刷新按钮节点 */ @property(Node) skill_refresh: Node = null! /**技能广告刷新按钮节点 */ @property(Node) skill_ad_refresh: Node = null! /**可用刷新数显示节点 */ @property(Node) skill_refresh_num_node: Node = null! // ======================== 运行时状态 ======================== /** 三个槽位对应的 CardComp 控制器缓存(有序数组) */ private cardComps: CardComp[] = []; /** 技能卡槽控制器缓存 */ private skillCardComps: SCardComp[] = []; /** 已购买装备的 UUID 集合(跨波次保持,防止重复购买) */ private purchasedEquipUuids: Set = new Set(); /** 是否已缓存卡牌面板基准缩放 */ 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); } if (this.closeHeros && this.closeHeros.isValid) { this.closeHeros.off(NodeEventType.TOUCH_END, this.onCloseHerosClick, this); } if (this.showEquips && this.showEquips.isValid) { this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this); } if (this.closeEquips && this.closeEquips.isValid) { this.closeEquips.off(NodeEventType.TOUCH_END, this.onCloseEquipsClick, this); } if (this.showSkills && this.showSkills.isValid) { this.showSkills.off(NodeEventType.TOUCH_END, this.onShowSkillsClick, this); } if (this.closeSkills && this.closeSkills.isValid) { this.closeSkills.off(NodeEventType.TOUCH_END, this.onCloseSkillsClick, this); } this.unbindEvents(); } /** 外部初始化入口(由 CardController 调用) */ init() { this.onMissionStart(); } /** * 任务开始: * 1. 进入准备阶段(展开卡牌面板)。 * 2. 初始化局内数据(金币、英雄数量上限)。 * 3. 清空旧英雄信息面板和卡牌槽位。 * 4. 重置按钮状态和 UI 显示。 * 5. 执行首次抽卡并分发到 3 个槽位。 */ onMissionStart() { this.enterPreparePhase(); const missionData = this.getMissionData(); if (missionData) { missionData.coin = Math.max(0, Math.floor(missionData.coin ?? 0)); missionData.hero_num = 0; missionData.hero_max_num = FightSet.HERO_MAX_NUM; missionData.hero_extend_max_num = FightSet.HERO_MAX_NUM + 1; } // 确保卡牌组件列表已被正确缓存 if (!this.cardComps || this.cardComps.length === 0) { this.cacheCardComps(); } this.layoutCardSlots(); this.clearAllCards(); this.resetButtonScale(this.cards_chou); this.updateCoinAndCostUI(); this.updateHeroNumUI(false, false); if (this.node && this.node.isValid) { this.node.active = true; } const cards = this.buildDrawCards(); this.dispatchCardsToSlots(cards); // 重置购买记录并填充装备商店 this.purchasedEquipUuids.clear(); this.populateEquipments(); // 首次进入准备阶段自动抽取一次技能卡,后续刷新只能通过技能刷新按钮触发 this.initSkillCardsOnce(); mLogger.log(this.debugMode, "MissionCardComp", "mission start"); } /** 任务结束:清空 3 槽 + 装备商店 + 英雄面板并隐藏整个节点 */ onMissionEnd() { this.clearAllCards(); this.clearEquipments(); if (this.node && this.node.isValid) { this.node.active = false; } } start() { } /** * 帧更新:每 0.15 秒刷新一次场上英雄信息面板(降频)。 * 检测已死亡 / 已失效的面板并移除,刷新存活面板属性。 */ update(dt: number) { } /** 关闭面板(不销毁数据模型,仅隐藏) */ close() { if (this.node && this.node.isValid) { this.node.active = false; } } // ======================== 事件绑定 ======================== /** * 绑定所有事件监听: * - 节点级事件:MissionStart / MissionEnd / FightStart * - 全局消息:CoinAdd / MasterCalled / HeroDead / UseHeroCard / UseSpecialCard * - 按钮触控:抽卡(cards_chou) */ private bindEvents() { /** 生命周期事件(节点级) */ this.on(GameEvent.MissionStart, this.onMissionStart, this); this.on(GameEvent.MissionEnd, this.onMissionEnd, this); this.on(GameEvent.FightStart, this.onFightStart, this); this.on("PhasePrepareStart", this.onPhasePrepareStart, this); oops.message.on(GameEvent.CoinAdd, this.onCoinAdd, this); oops.message.on(GameEvent.MasterCalled, this.onMasterCalled, this); oops.message.on(GameEvent.HeroDead, this.onHeroDead, this); oops.message.on(GameEvent.HeroSell, this.onHeroSell, this); oops.message.on(GameEvent.UseHeroCard, this.onUseHeroCard, this); oops.message.on(GameEvent.UseSkillCard, this.onUseSkillCard, this); // 监听装备购买事件,追踪已购记录 oops.message.on(GameEvent.UseEquipCard, this.onUseEquipCard, this); oops.message.on(GameEvent.UseSpecialCard, this.onUseSpecialCard, this); oops.message.on(GameEvent.ShowSmallTip, this.onShowSmallTip, this); oops.message.on(GameEvent.CardUsed, this.onCardUsed, this); /** 按钮触控事件:抽卡 */ this.cards_chou?.on(NodeEventType.TOUCH_START, this.onDrawTouchStart, this); this.cards_chou?.on(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this); this.cards_chou?.on(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this); /** 英雄卡池显示/隐藏切换按钮 */ this.showHeros?.on(NodeEventType.TOUCH_END, this.onShowHerosClick, this); /** 关闭英雄卡池按钮 */ this.closeHeros?.on(NodeEventType.TOUCH_END, this.onCloseHerosClick, this); /** 装备商店显示/隐藏切换按钮 */ this.showEquips?.on(NodeEventType.TOUCH_END, this.onShowEquipsClick, this); /** 关闭装备商店按钮 */ this.closeEquips?.on(NodeEventType.TOUCH_END, this.onCloseEquipsClick, this); /** 技能卡池显示按钮 */ this.showSkills?.on(NodeEventType.TOUCH_END, this.onShowSkillsClick, this); /** 关闭技能卡池按钮 */ this.closeSkills?.on(NodeEventType.TOUCH_END, this.onCloseSkillsClick, 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 initSkillCardsOnce() { if (this.skillCardComps.length === 0) { this.cacheCardComps(); } // 显示技能卡牌面板 if (this.skill_card_node) { 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); } } /** * 技能卡池显隐切换按钮回调: * - 仅切换 skill_card_node 的 active 状态,不执行抽卡。 * - 后续刷新需通过技能刷新按钮(skill_refresh)触发。 */ private onShowSkillsClick() { if (!this.skill_card_node || !this.skill_card_node.isValid) return; oops.audio.playEffect("music/button"); const visible = !this.skill_card_node.active; this.skill_card_node.active = visible; } /** 关闭技能卡池按钮回调 */ private onCloseSkillsClick() { if (!this.skill_card_node || !this.skill_card_node.isValid) return; oops.audio.playEffect("music/button"); this.skill_card_node.active = false; } 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) { // 购买技能卡后不再关闭弹窗:技能卡池与英雄卡池保持一致, // 由 onCardUsed 立即向被使用的槽位补充一张新技能卡。 // 弹窗由外部(按钮/阶段切换)控制显隐。 // 修复:独立判断 guide2 的关闭 和 guide3 的开启 if (!smc.finish_guides.includes(2)) { smc.finish_guides.push(2); oops.gui.remove(UIID.Guide2); } if (!smc.finish_guides.includes(3)) { oops.gui.open(UIID.Guide3); } // 驻场技能可能影响刷新费用(如"刷新优惠"),延迟到下一帧刷新费用 UI this.scheduleOnce(() => this.updateCoinAndCostUI(), 0); } /** 装备购买事件回调:记录已购 UUID,防止重复购买 */ private onUseEquipCard(event: string, args: any) { const usedCard = args as CardConfig; if (usedCard) { this.purchasedEquipUuids.add(usedCard.uuid); } } /** 解除按钮监听,避免节点销毁后回调泄漏 */ private unbindEvents() { oops.message.off(GameEvent.CoinAdd, this.onCoinAdd, this); oops.message.off(GameEvent.MasterCalled, this.onMasterCalled, this); oops.message.off(GameEvent.HeroDead, this.onHeroDead, this); oops.message.off(GameEvent.HeroSell, this.onHeroSell, this); oops.message.off(GameEvent.UseHeroCard, this.onUseHeroCard, this); oops.message.off(GameEvent.UseSkillCard, this.onUseSkillCard, this); oops.message.off(GameEvent.UseEquipCard, this.onUseEquipCard, this); oops.message.off(GameEvent.UseSpecialCard, this.onUseSpecialCard, this); oops.message.off(GameEvent.ShowSmallTip, this.onShowSmallTip, this); oops.message.off(GameEvent.CardUsed, this.onCardUsed, this); if (this.cards_chou && this.cards_chou.isValid) { this.cards_chou.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this); this.cards_chou.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this); this.cards_chou.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this); } if (this.showHeros && this.showHeros.isValid) { this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, this); } if (this.closeHeros && this.closeHeros.isValid) { this.closeHeros.off(NodeEventType.TOUCH_END, this.onCloseHerosClick, this); } if (this.showEquips && this.showEquips.isValid) { this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this); } if (this.closeEquips && this.closeEquips.isValid) { this.closeEquips.off(NodeEventType.TOUCH_END, this.onCloseEquipsClick, 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 }); } /** * 单张卡牌被成功使用后的整池刷新回调: * - payload 为该卡槽组件实例(CardComp 或 SCardComp)。 * - 命中英雄卡槽 → 重新构建 3 张英雄池卡牌并分发到所有英雄槽。 * - 命中技能卡槽 → 重新构建 3 张技能卡并分发到所有技能槽。 * - 未命中任何槽位 → 忽略。 * * Why: 购买一张卡后立即刷新整个卡池(不扣金币),让玩家持续从新池中挑选; * 英雄卡池与技能卡池遵循同一逻辑。 * SpecialRefresh 自身效果(onUseSpecialCard 中的 tryRefreshHeroCards) * 已是整池刷新,因此 CardComp 对其不派发 CardUsed,避免双重刷新。 */ private onCardUsed(event: string, args: any) { const source = args; if (!source) return; const heroIdx = this.cardComps.findIndex(c => c === source); if (heroIdx >= 0) { const cards = this.buildDrawCards(); this.dispatchCardsToSlots(cards); mLogger.log(this.debugMode, "MissionCardComp", "refresh hero pool after buy", { triggerSlot: heroIdx }); return; } const skillIdx = this.skillCardComps.findIndex(c => c === source); if (skillIdx >= 0) { const cards = this.buildSkillDrawCards(); this.dispatchCardsToSkillSlots(cards); mLogger.log(this.debugMode, "MissionCardComp", "refresh skill pool after buy", { triggerSlot: skillIdx }); return; } } // ======================== 按钮触控回调 ======================== /** 抽卡按钮按下反馈 */ private onDrawTouchStart() { this.playButtonPressAnim(this.cards_chou); } /** 抽卡按钮释放 → 执行抽卡逻辑 */ private onDrawTouchEnd() { oops.audio.playEffect("music/button"); this.playButtonClickAnim(this.cards_chou, () => this.onClickDraw()); } /** 抽卡按钮取消 → 恢复缩放 */ private onDrawTouchCancel() { this.playButtonResetAnim(this.cards_chou); } /** * 英雄卡池显隐切换按钮回调: * - 切换 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; } } /** * 关闭英雄卡池按钮回调: * - 仅负责收起 cards_node,不切换展开态。 * - 同步关闭 showHeros 下的 "active" 子节点,保持与 toggle 状态一致。 * - 同步关闭已打开的英雄信息面板(HInfo),避免卡池收起后信息面板孤立悬浮。 */ private onCloseHerosClick() { if (!this.cards_node || !this.cards_node.isValid) return; oops.audio.playEffect("music/button"); this.cards_node.active = false; const activeChild = this.showHeros?.getChildByName("active"); if (activeChild) { activeChild.active = false; } // 关闭英雄卡池时同步关闭已打开的英雄信息面板 oops.gui.remove(UIID.HInfo); } // ======================== 装备商店面板 ======================== /** * 装备商店显隐切换按钮回调: * 切换 equipsPanNode 的 active 状态。 */ private onShowEquipsClick() { if (!this.equipsPanNode || !this.equipsPanNode.isValid) return; oops.audio.playEffect("music/button"); this.equipsPanNode.active = !this.equipsPanNode.active; } /** 关闭装备商店按钮回调 */ private onCloseEquipsClick() { if (!this.equipsPanNode || !this.equipsPanNode.isValid) return; oops.audio.playEffect("music/button"); this.equipsPanNode.active = false; } /** * 填充装备列表: * 从 CardPoolList 获取所有装备卡(trigger_type=Field), * 实例化 equipPrefab 到 equipsBoxNode, * 每个装备项由 EquipListComp 渲染并处理购买。 * * 已购买的装备会标记为已购状态(隐藏购买按钮)。 */ private populateEquipments() { if (!this.equipsBoxNode || !this.equipPrefab) return; // 清空旧列表 this.equipsBoxNode.removeAllChildren(); // 从独立装备卡池获取所有装备 for (const equip of EquipPoolList) { const node = instantiate(this.equipPrefab); this.equipsBoxNode.addChild(node); const comp = node.getComponent(EquipListComp) || node.addComponent(EquipListComp); comp.applyCardData(equip); // 已购买的装备标记为已购 if (this.purchasedEquipUuids.has(equip.uuid)) { comp.setPurchased(); } } // 默认显示装备面板 if (this.equipsPanNode) { this.equipsPanNode.active = true; } mLogger.log(this.debugMode, "MissionCardComp", "populate equipments", { count: EquipPoolList.length }); } /** 清空装备列表 */ private clearEquipments() { if (this.equipsBoxNode && this.equipsBoxNode.isValid) { this.equipsBoxNode.removeAllChildren(); } } // ======================== 技能抽卡按钮回调 ======================== private onSkillDrawTouchStart() { this.playButtonPressAnim(this.skill_refresh); } private onSkillDrawTouchEnd() { oops.audio.playEffect("music/button"); this.playButtonClickAnim(this.skill_refresh, () => this.onClickSkillRefresh()); } private onSkillDrawTouchCancel() { this.playButtonResetAnim(this.skill_refresh); } private onSkillAdDrawTouchStart() { this.playButtonPressAnim(this.skill_ad_refresh); } private onSkillAdDrawTouchEnd() { oops.audio.playEffect("music/button"); this.playButtonClickAnim(this.skill_ad_refresh, () => this.onClickSkillAdRefresh()); } private onSkillAdDrawTouchCancel() { this.playButtonResetAnim(this.skill_ad_refresh); } private onClickSkillRefresh() { const cost = MissionEconomy.getRefreshCost(this.refreshCost); const success = MissionEconomy.executeRefresh(this.refreshCost); if (!success) { this.showSmallTip("refresh_coin"); return; } const cards = this.buildSkillDrawCards(); this.dispatchCardsToSkillSlots(cards); } private onClickSkillAdRefresh() { // TODO: 接入广告 SDK 逻辑,目前先直接刷新 const cards = this.buildSkillDrawCards(); this.dispatchCardsToSkillSlots(cards); } /** 将三个卡槽节点映射为 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); } /** * 进入准备阶段: * - 先将卡牌面板置为收起态,激活 showHeros 按钮可见。 * - 再触发一次 onShowHerosClick(等同玩家点击 showHeros 按钮), * 让卡池以"按钮点击"的方式展开,保持与玩家手动点击一致的交互表现。 */ private enterPreparePhase() { if (!this.cards_node || !this.cards_node.isValid) return; this.initCardsPanelPos(); // 先收起面板,确保后续 onShowHerosClick 能正确切换为展开 Tween.stopAllByTarget(this.cards_node); this.cards_node.setScale(this.cardsShowScale); this.cards_node.active = false; // 显式激活「显示英雄卡池」按钮本身,让玩家可见可点 if (this.showHeros && this.showHeros.isValid) { this.showHeros.active = true; const showActive = this.showHeros.getChildByName("active"); if (showActive) showActive.active = false; } if (this.cards_chou && this.cards_chou.isValid) { const nobg = this.cards_chou.getChildByName("nobg"); if (nobg) { nobg.active = !this.canDrawCards(); } } // 触发一次 showHeros 按钮点击效果,自动展开卡池 this.onShowHerosClick(); } 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(); const usedHeroUuids = new Set(); // 必出 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(); const usedHeroUuids = new Set(); while (selected.length < count) { const available = pool.filter(c => { if (c.type === CardType.SpecialUpgrade && c.target_hero_eid) { return !usedUpgradeTargets.has(c.target_hero_eid); } if (c.type === CardType.Hero) { return !usedHeroUuids.has(c.uuid); } return true; }); if (available.length === 0) break; const pick = this.weightedPick(available); if (!pick) break; selected.push(pick); if (pick.type === CardType.SpecialUpgrade && pick.target_hero_eid) { usedUpgradeTargets.add(pick.target_hero_eid); } if (pick.type === CardType.Hero) { usedHeroUuids.add(pick.uuid); } } return selected; } /** 单次按权重抽取一张卡 */ private weightedPick(cards: CardConfig[]): CardConfig | null { if (cards.length === 0) return null; const totalWeight = cards.reduce((total, card) => total + (card.weight ?? 0), 0); let random = Math.random() * totalWeight; for (const card of cards) { random -= (card.weight ?? 0); if (random <= 0) return card; } return cards[cards.length - 1]; } /** * 扫描场上存活英雄,为每个英雄实体生成一张升级卡(按 eid 绑定)。 * - 已达 HERO_MAX_LV 的英雄不出卡。 * - 同 eid 只生成一张(eid 本就唯一,不会重复)。 * - cost = 模板 cost + (当前等级 - 1) * BASE_COST,等级越高升级越贵。 */ private buildHeroUpgradeCards(): CardConfig[] { const template = SpecialUpgradeCardList[7001]; if (!template) return []; const actors = this.queryAliveHeroActors(); if (actors.length === 0) return []; const result: CardConfig[] = []; for (const actor of actors) { const eid = actor.eid; const lv = actor.model.lv; if (lv >= FightSet.HERO_MAX_LV) continue; // 已达上限不再出升级卡 const dynamicCost = template.cost + (lv - 1) * FightSet.BASE_COST; result.push({ ...template, cost: dynamicCost, weight: template.weight, target_hero_eid: eid, hero_lv: lv + 1, }); } // 按 target_hero_eid 升序排序,保证返回顺序稳定(便于 buildDrawCards 按顺序轮询) result.sort((a, b) => (a.target_hero_eid ?? 0) - (b.target_hero_eid ?? 0)); return result; } /** * 构建技能卡抽卡结果,返回 3 张。 * * 技能卡池长期存在,不再按波次过滤, * 直接从 SCardSet 的 drawSkillCards 按权重抽取。 */ private buildSkillDrawCards(): CardConfig[] { return drawSkillCards(3); } private tryRefreshHeroCards(heroType?: HType): boolean { const cards = drawCardsByRule(1, { count: 3, type: CardType.Hero, heroType, unique: true, // 保证一次刷新内的英雄卡不重复 }); // 过滤掉场上已召唤英雄的普通英雄卡(已召唤的英雄不再重复刷出) const aliveHeroUuids = this.getAliveHeroUuids(); const available = cards.filter(c => !aliveHeroUuids.has(c.uuid)); if (available.length <= 0) return false; this.layoutCardSlots(); this.dispatchCardsToSlots(available.slice(0, 3)); return true; } private tryRefreshHeroCardsByEffect(refreshHeroType: SpecialRefreshHeroType): boolean { const heroType = this.resolveRefreshHeroType(refreshHeroType); return this.tryRefreshHeroCards(heroType); } private resolveRefreshHeroType(refreshHeroType: SpecialRefreshHeroType): HType | undefined { if (refreshHeroType === SpecialRefreshHeroType.Melee) return HType.Melee; if (refreshHeroType === SpecialRefreshHeroType.Ranged) return HType.Long; return undefined; } /** 全量分发给 3 槽;每个槽位是否接收由 CardComp 自己判断(锁定可跳过) */ private dispatchCardsToSlots(cards: CardConfig[]) { if (!this.cardComps) return; for (let i = 0; i < this.cardComps.length; i++) { if (this.cardComps[i]) { const accepted = this.cardComps[i].applyDrawCard(cards[i] ?? null); mLogger.log(this.debugMode, "MissionCardComp", "dispatch card", { index: i, card: cards[i]?.uuid ?? 0, accepted }); } } } /** 系统清空 3 槽(用于任务切换) */ private clearAllCards() { if (!this.cardComps) return; this.cardComps.forEach(comp => { if (comp) comp.clearBySystem(); }); if (this.skillCardComps) { this.skillCardComps.forEach(comp => { if (comp) comp.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 { const uuids = new Set(); ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => { const model = entity.get(HeroAttrsComp); if (!model) return; if (model.fac !== FacSet.HERO) return; if (model.is_dead) return; uuids.add(model.hero_uuid); }); return uuids; } /** * 按 eid 精确升级场上对应英雄实体。 * * @param heroEid 要升级的英雄实体 eid * @returns true = 升级成功 */ private tryUpgradeHeroByEid(heroEid: number): boolean { if (!heroEid) return false; const actor = this.queryAliveHeroActors().find(item => item.eid === heroEid); if (!actor) return false; if (actor.model.lv >= FightSet.HERO_MAX_LV) return false; const nextLv = Math.min(FightSet.HERO_MAX_LV, actor.model.lv + 1); this.applyHeroLevel(actor.model, nextLv); if (actor.view) { actor.view.playBuff("buff_lvup"); } return true; } /** * 应用英雄等级变化(属性按 HERO_LV_MULTIPLIER 重新计算)。 * 沿用旧版本 SkillLvUp 的技能等级映射规则。 */ private applyHeroLevel(model: HeroAttrsComp, targetLv: number) { const hero = HeroInfo[model.hero_uuid]; if (!hero) return; const nextLv = Math.max(1, Math.min(FightSet.HERO_MAX_LV, Math.floor(targetLv))); const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1; model.lv = nextLv; model.ap = hero.ap * Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1); model.hp_max = hero.hp * Math.pow(FightSet.HERO_LV_MULTIPLIER, nextLv - 1); model.hp = Math.max(1, Math.floor(model.hp_max * Math.max(0, Math.min(1, hpRate)))); model.skills = {}; for (const key in hero.skills) { const skill = hero.skills[key]; if (!skill) continue; model.skills[skill.uuid] = { ...skill, lv: Math.max(0, skill.lv + nextLv - 2), ccd: 0 }; } model.updateSkillDistanceCache(); model.dirty_hp = true; model.dirty_lv = true; oops.message.dispatchEvent(GameEvent.HeroLvUp, { uuid: model.hero_uuid, lv: nextLv }); } private updateHeroNumUI(animate: boolean, isIncrease: boolean) { this.syncMissionHeroData(); if (!animate || !isIncrease) return; this.playHeroNumGainAnim(); } private playHeroNumGainAnim() { if (!this.hero_num_node || !this.hero_num_node.isValid) return; const iconNode = this.hero_num_node.getChildByName("icon"); const numNode = this.hero_num_node.getChildByName("num"); this.playHeroNumNodePop(iconNode, 1.2); this.playHeroNumNodePop(numNode, 1.2); } private playHeroNumDeniedAnim() { if (!this.hero_num_node || !this.hero_num_node.isValid) return; const iconNode = this.hero_num_node.getChildByName("icon"); const numNode = this.hero_num_node.getChildByName("num"); this.playHeroNumNodePop(iconNode, 1.2); this.playHeroNumNodePop(numNode, 1.2); } private playHeroNumNodePop(node: Node | null, scalePeak: number) { this.playNodeScalePop(node, scalePeak, 0.08, 0.1); } private playNodeScaleTo(node: Node | null, scale: number, duration: number) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); tween(node) .to(duration, { scale: new Vec3(scale, scale, 1) }) .start(); } private playNodeScalePop(node: Node | null, scalePeak: number, toPeakDuration: number, toNormalDuration: number, onPeak?: () => void) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); node.setScale(1, 1, 1); const seq = tween(node) .to(toPeakDuration, { scale: new Vec3(scalePeak, scalePeak, 1) }); if (onPeak) { seq.call(onPeak); } seq.to(toNormalDuration, { scale: new Vec3(1, 1, 1) }) .start(); } private getMissionData(): any { return smc?.vmdata?.mission_data ?? null; } private getMissionHeroNum(): number { const missionData = this.getMissionData(); return Math.max(0, Math.floor(missionData?.hero_num ?? 0)); } private getMissionHeroMaxNum(): number { return FightSet.HERO_MAX_NUM } private syncMissionHeroData(count?: number) { const missionData = this.getMissionData(); if (!missionData) return; const safeCount = Math.max(0, Math.floor(count ?? this.getAliveHeroCount())); missionData.hero_num = safeCount; } /** 视图对象通过 ecs.Entity.remove(ModuleViewComp) 删除组件是触发组件处理自定义释放逻辑 */ reset() { this.resetButtonScale(this.cards_chou); // 关键:在 reset/销毁 时将 Map 置空,彻底切断引用 this.cardComps = [] as any; this.skillCardComps = [] as any; this.purchasedEquipUuids.clear(); if (this.node && this.node.isValid) { this.node.destroy(); } } }