/** * @file MissionCardComp.ts * @description 卡牌系统核心控制器(战斗 UI 层 + 业务逻辑层) * * 职责: * 1. **卡牌分发管理** —— 从卡池抽取 3 张英雄卡,分发到 3 个 CHeroComp 槽位。 * 抽卡规则:从基础英雄卡池按权重抽取,同一次抽卡内英雄不重复; * 不过滤已召唤英雄,抽到同 uuid 可触发二合一合成升级。 * 2. **金币费用管理** —— 抽卡费用(refreshCost)的扣除。 * 3. **英雄数量上限校验** —— 在 UseHeroCard 事件的 guard 阶段判断是否允许再召唤英雄。 * 4. **场上英雄信息面板(HInfoComp 列表)同步** —— * 英雄上场时实例化面板,死亡时移除,定时刷新属性显示。 * 5. **特殊卡执行** —— 处理英雄升级卡(SpecialUpgrade,按 UUID 精确升级)和 * 英雄刷新卡(SpecialRefresh)。 * 6. **准备/战斗阶段切换** —— 控制卡牌面板的 展开/收起 动画。 * * 关键设计: * - 3 个 CHeroComp 由 cardPrefab 动态实例化生成,通过 cacheCardComps() 映射为有序数组 cardComps[], * 之后所有分发、清空操作均通过此数组进行。 * - buildDrawCards() 从基础英雄卡池按权重抽取 3 张,不足时循环补齐。 * - 英雄上限校验(onUseHeroCard)采用 guard/cancel 模式: * CHeroComp 发出 UseHeroCard 事件并传入 guard 对象, * 本组件可通过 guard.cancel=true 阻止使用。 * * 历史: * 旧版本曾包含"卡池等级(poolLv)"机制和"三合一合成腾位"判断,已全部移除: * - 卡牌不再分级,所有英雄卡统一 lv1。 * - 升级卡机制已移除,英雄卡池只出普通英雄卡;英雄升级仅通过升级卡(SpecialUpgrade)触发。 * * 依赖: * - CHeroComp —— 单卡槽位 * - HInfoComp —— 英雄信息面板 * - CardSet 模块 —— 卡池配置、抽卡规则、特殊卡数据 * - HeroAttrsComp —— 英雄属性(升级) * - smc.vmdata.mission_data —— 局内数据(coin / hero_num / hero_max_num) */ import { mLogger } from "../common/Logger"; import { _decorator, instantiate, Label, Node, NodeEventType, Prefab, Tween, tween, Vec3, UITransform, Widget } from "cc"; import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { GameEvent } from "../common/config/GameEvent"; import { CardConfig, CardPoolList, CardType, drawCardsByRule, SpecialRefreshCardList, SpecialRefreshHeroType } from "../common/config/CardSet"; import { drawEquipCards } from "../common/config/EquipSet"; import { drawItemCards } from "../common/config/ICardSet"; import { drawSkillCards } from "../common/config/SCardSet"; import { CHeroComp } from "./CHeroComp"; import { EquipListComp } from "./EquipListComp"; import { HeroBoxComp } from "./HeroBoxComp"; import { SCardComp } from "./SCardComp"; import { MissEquipComp } from "./MissEquipComp"; import { MissSkillsComp } from "./MissSkillsComp"; import { oops } from "db://oops-framework/core/Oops"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { smc } from "../common/SingletonModuleComp"; import { HeroInfo, HType, resolveTriggerByLv, resolveFieldByLv, resolveReviveByLv, calcGrowBonus } from "../common/config/heroSet"; import { HeroViewComp } from "../hero/HeroViewComp"; import { FacSet, FightSet } from "../common/config/GameSet"; import { MissionEconomy } from "./MissionEconomy"; import { UIID } from "../common/config/GameUIConfig"; import { ItemListComp } from "./ItemListComp"; const { ccclass, property } = _decorator; /** * MissionCardComp —— 卡牌系统核心控制器 * * 管理 3 个卡牌槽位的抽卡分发、金币费用、 * 英雄上限校验、场上英雄信息面板同步以及特殊卡执行。 */ @ccclass('MissionCardComp') @ecs.register('MissionCard', false) export class MissionCardComp extends CCComp { /** 是否启用调试日志 */ private debugMode: boolean = false; /** 按钮正常缩放 */ private readonly buttonNormalScale: number = 1; /** 按钮按下缩放 */ private readonly buttonPressScale: number = 0.94; /** 按钮弹起缩放(峰值) */ private readonly buttonClickScale: number = 1.06; /** 抽卡(刷新)费用 */ refreshCost: number = FightSet.REFRESH_COST; /** 卡牌面板展开/收起动画时长(秒) */ cardsPanelMoveDuration: number = 0.2; // ======================== 编辑器绑定节点 ======================== /** 英雄抽卡面板(战斗阶段收起,准备阶段展开) */ @property(Node) cardPanNode: Node = null! @property(Node) cards_node: Node = null! /** 卡牌槽位预制体(动态实例化 3 个) */ @property(Prefab) cardPrefab: Prefab = null! @property(Node) closeCards: Node = null! @property(Node) nock_node: Node = null! /** 英雄抽卡面板显示按钮 */ @property(Node) showHeros: Node = null! @property(Node) herosPanNode: Node = null! @property(Node) herosBoxNode: Node = null! /** 英雄抽卡面板开启金币费用显示节点 */ @property(Node) showHerosCost: Node = null! /** 装备面板显示按钮 */ @property(Node) showEquips: Node = null! @property(Node) equipsPanNode: Node = null! @property(Node) equipsBoxNode: Node = null! @property(Prefab) equipPrefab: Prefab = null! /** 装备面板开启金币费用显示节点 */ @property(Node) showEquipsCost: Node = null! /** 技能面板显示按钮 */ @property(Node) showSkills: Node = null! @property(Node) skillPanNode: Node = null! @property(Node) skillBoxNode: Node = null! @property(Prefab) sCardPrefab: Prefab = null! /** 技能面板开启金币费用显示节点 */ @property(Node) showSkillsCost: Node = null! /** 药品面板显示按钮 */ @property(Node) showShop: Node = null! @property(Node) shopPanNode: Node = null! @property(Node) shopBoxNode: Node = null! @property(Prefab) itemPrefab: Prefab = null! /** 药品面板开启金币费用显示节点 */ @property(Node) showShopCost: Node = null! /** 技能刷新按钮节点 */ @property(Node) skill_refresh: Node = null! /** 装备刷新按钮节点 */ @property(Node) equip_refresh: Node = null!; /** 药品刷新按钮节点 */ @property(Node) shop_refresh: Node = null!; /** 刷新卡池按钮节点 */ @property(Node) cards_refresh: Node = null! /** 金币显示节点(含 icon + num 子节点) */ @property(Node) coins_node: Node = null! /** 英雄数量显示节点(含 icon + num 子节点) */ @property(Node) hero_num_node: Node = null! /** 刷新石数量显示节点(含 icon + num 子节点) */ @property(Node) refresh_stone_num_node: Node = null! // ======================== 运行时状态 ======================== /** 槽位水平坐标(屏幕 720 三等分,卡片 230,列宽 240) */ private readonly slotPosX: number[] = [-240, 0, 240]; /** 三个槽位对应的 CHeroComp 控制器缓存(有序数组) */ private cardComps: CHeroComp[] = []; /** 英雄面板 HeroBoxComp 控制器缓存(与场上英雄一一对应,最多 3 个) */ private heroBoxComps: HeroBoxComp[] = []; /** 装备卡槽控制器缓存 */ private equipCardComps: EquipListComp[] = []; /** 技能卡槽控制器缓存 */ private skillCardComps: SCardComp[] = []; /** 药品卡槽控制器缓存 */ private itemCardComps: ItemListComp[] = []; /** 是否已召唤过英雄(用于控制其他面板按钮是否可点击) */ private hasCalledHero: boolean = false; /** * 招募计数:每次点击招募 +1。 * 节奏为「每 4 次一循环」:前 3 次直接随机召唤,第 4 次走三选一。 */ private recruitCount: number = 0; /** 全远程阵容时近战卡的权重放大倍数(近战保底) */ private static readonly MELEE_PITY_WEIGHT_MUL = 3; // ======================== 面板划出状态 ======================== /** 当前激活的面板索引:0=英雄, 1=装备, 2=技能, 3=药品 */ private currentPanelIndex: number = 0; /** 覆盖面板(卡牌/装备/技能/药品)显示位置:面板铺满屏幕,显示时固定位于 (0,0) */ private readonly panelShowPos: Vec3 = new Vec3(0, 0, 0); /** 覆盖面板显示态缩放 (1,1) */ private readonly panelShowScale: Vec3 = new Vec3(1, 1, 1); /** 覆盖面板隐藏态缩放 (0,0) */ private readonly panelHideScale: Vec3 = new Vec3(0, 0, 1); /** 是否已初始化面板布局 */ private hasInitPanelLayout: boolean = false; /** 英雄抽卡面板是否处于展开状态(驱动 showHeros 按钮 active 高亮) */ private isCardPanelShown: boolean = false; /** 抽卡面板开启初始金币费用 */ private static readonly PANEL_OPEN_BASE_COST = 5; /** 抽卡面板每抽 1 次的费用递增 */ private static readonly PANEL_OPEN_COST_STEP = 5; /** 各抽卡面板当前开启费用 [英雄抽卡, 装备, 技能, 药品] */ private panelOpenCosts: number[] = []; /** 是否已缓存卡牌面板基准缩放 */ private hasCachedCardsBaseScale: boolean = false; /** 卡牌面板基准缩放(从场景读取) */ private cardsBaseScale: Vec3 = new Vec3(1, 1, 1); /** 卡牌面板展开态缩放 */ private cardsShowScale: Vec3 = new Vec3(1, 1, 1); /** 卡牌面板收起态缩放(scale=0 隐藏) */ private cardsHideScale: Vec3 = new Vec3(0, 0, 1); /** 覆盖面板显示/隐藏动画时长 */ private readonly cardPanelAnimDuration: number = 0.25; // ======================== 新手按钮指引 ======================== /** 新手按钮指引脉动放大倍率 */ private static readonly BUTTON_GUIDE_PULSE_SCALE: number = 1.15; /** 新手按钮指引每步时长(秒) */ private static readonly BUTTON_GUIDE_STEP_DURATION: number = 4.2; /** 新手按钮指引气泡停留时长(秒) */ private static readonly BUTTON_GUIDE_TIP_STAY: number = 3.4; /** 指引步骤配置:完成标记键(smc.data.tip_done)+ 按钮节点取值器 + 气泡文案 */ private readonly guideSteps: Array<{ key: "hero" | "equip" | "skill" | "shop", getBtn: () => Node | null, text: string }> = [ { key: "hero", getBtn: () => this.showHeros, text: "这里召唤英雄" }, { key: "equip", getBtn: () => this.showEquips, text: "这里购买装备" }, { key: "skill", getBtn: () => this.showSkills, text: "这里购买技能卷轴" }, { key: "shop", getBtn: () => this.showShop, text: "这里购买药品" }, ]; /** 当前指引下标(-1 = 未在指引中) */ private guideStepIndex: number = -1; /** 气泡原文缓存(指引结束后还原) */ private guideTipTextCache: Map = new Map(); // ======================== 生命周期 ======================== /** * 组件加载: * 1. 绑定生命周期事件和按钮交互事件。 * 2. 缓存 3 个 CHeroComp 子控制器引用。 * 3. 计算并设置槽位水平布局。 * 4. 初始化卡牌面板缩放参数。 * 5. 初始化面板轮播布局。 */ onLoad() { this.bindEvents(); this.cacheCardComps(); this.hideAllHeroBoxSlots(); this.initCardsPanelPos(); this.initPanelLayout(); mLogger.log(this.debugMode, "MissionCardComp", "onLoad init", { slots: this.cardComps.length, }); } /** 组件销毁时解绑所有事件并清理英雄信息面板 */ onDestroy() { super.onDestroy(); // 清理新手按钮指引的调度与动画,防止回调泄漏 this.stopButtonGuide(); this.unscheduleAllCallbacks(); if (this.cards_refresh && this.cards_refresh.isValid) { this.cards_refresh.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this); this.cards_refresh.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this); this.cards_refresh.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this); } if (this.showHeros && this.showHeros.isValid) { this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, this); } if (this.closeCards && this.closeCards.isValid) { this.closeCards.off(NodeEventType.TOUCH_END, this.onCloseCardsClick, this); } if (this.showEquips && this.showEquips.isValid) { this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this); } if (this.showSkills && this.showSkills.isValid) { this.showSkills.off(NodeEventType.TOUCH_END, this.onShowSkillsClick, this); } if (this.showShop && this.showShop.isValid) { this.showShop.off(NodeEventType.TOUCH_END, this.onShowShopClick, this); } this.unbindEvents(); } /** 外部初始化入口(由 CardController 调用) */ init() { this.onMissionStart(); } /** * 任务开始: * 1. 进入准备阶段(展开卡牌面板)。 * 2. 初始化局内数据(金币、英雄数量上限)。 * 3. 清空旧英雄信息面板和卡牌槽位。 * 4. 重置按钮状态和 UI 显示。 * 5. 执行首次抽卡并分发到 3 个槽位。 */ onMissionStart() { this.enterPreparePhase(); const missionData = this.getMissionData(); if (missionData) { missionData.coin = Math.max(0, Math.floor(missionData.coin ?? 0)); missionData.hero_num = 0; missionData.hero_max_num = FightSet.HERO_MAX_NUM; missionData.hero_extend_max_num = FightSet.HERO_MAX_NUM + 1; } // 确保卡牌组件列表已被正确缓存 if (!this.cardComps || this.cardComps.length === 0) { this.cacheCardComps(); } this.layoutCardSlots(); this.clearAllCards(); this.resetButtonScale(this.cards_refresh); this.resetPanelOpenCosts(); this.updateCoinAndCostUI(); this.updateHeroNumUI(false, false); if (this.node && this.node.isValid) { this.node.active = true; } const cards = this.buildDrawCards(); mLogger.log(this.debugMode, "MissionCardComp", "onMissionStart buildDrawCards", { cardsLength: cards.length, cards: cards.map(c => ({ uuid: c.uuid, type: c.type, name: c.name })) }); this.dispatchCardsToSlots(cards); // 填充装备商店(不限购) this.populateEquipments(); // 填充技能卡槽(英雄三槽模式:sCardPrefab 实例化 3 个槽位) this.populateSkillCards(); // 填充商品商店(不限购) this.populateShopItems(); // 重置英雄召唤状态(游戏刚开始时其他面板不可点击) this.hasCalledHero = false; this.updatePanelButtonsInteractable(); // 初始化英雄信息盒(3 个空槽位) 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_refresh) */ private bindEvents() { /** 生命周期事件(节点级) */ this.on(GameEvent.MissionStart, this.onMissionStart, this); this.on(GameEvent.MissionEnd, this.onMissionEnd, this); this.on(GameEvent.FightStart, this.onFightStart, this); this.on("PhasePrepareStart", this.onPhasePrepareStart, this); oops.message.on(GameEvent.CoinAdd, this.onCoinAdd, this); oops.message.on(GameEvent.RefreshStone, this.onRefreshStone, this); oops.message.on(GameEvent.MasterCalled, this.onMasterCalled, this); oops.message.on(GameEvent.HeroDead, this.onHeroDead, this); oops.message.on(GameEvent.HeroSell, this.onHeroSell, this); oops.message.on(GameEvent.HeroLvUp, this.onHeroLvUp, this); oops.message.on(GameEvent.UseHeroCard, this.onUseHeroCard, this); oops.message.on(GameEvent.UseSpecialCard, this.onUseSpecialCard, this); oops.message.on(GameEvent.ShowSmallTip, this.onShowSmallTip, this); oops.message.on(GameEvent.CardUsed, this.onCardUsed, this); oops.message.on(GameEvent.HeroBoxEmptyClick, this.onHeroBoxEmptyClick, this); oops.message.on(GameEvent.HeroUpgrade, this.onHeroUpgrade, this); /** 按钮触控事件:抽卡 */ this.cards_refresh?.on(NodeEventType.TOUCH_START, this.onDrawTouchStart, this); this.cards_refresh?.on(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this); this.cards_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this); /** 隐藏卡牌面板按钮 */ this.closeCards?.on(NodeEventType.TOUCH_END, this.onCloseCardsClick, this); /** 英雄面板显示按钮 */ this.showHeros?.on(NodeEventType.TOUCH_END, this.onShowHerosClick, this); /** 装备面板显示按钮 */ this.showEquips?.on(NodeEventType.TOUCH_END, this.onShowEquipsClick, this); /** 技能面板显示按钮 */ this.showSkills?.on(NodeEventType.TOUCH_END, this.onShowSkillsClick, this); /** 药品面板显示按钮 */ this.showShop?.on(NodeEventType.TOUCH_END, this.onShowShopClick, this); /** 装备刷新按钮 */ this.equip_refresh?.on(NodeEventType.TOUCH_START, this.onEquipDrawTouchStart, this); this.equip_refresh?.on(NodeEventType.TOUCH_END, this.onEquipDrawTouchEnd, this); this.equip_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onEquipDrawTouchCancel, this); /** 技能刷新按钮 */ this.skill_refresh?.on(NodeEventType.TOUCH_START, this.onSkillDrawTouchStart, this); this.skill_refresh?.on(NodeEventType.TOUCH_END, this.onSkillDrawTouchEnd, this); this.skill_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onSkillDrawTouchCancel, this); /** 药品刷新按钮 */ this.shop_refresh?.on(NodeEventType.TOUCH_START, this.onShopDrawTouchStart, this); this.shop_refresh?.on(NodeEventType.TOUCH_END, this.onShopDrawTouchEnd, this); this.shop_refresh?.on(NodeEventType.TOUCH_CANCEL, this.onShopDrawTouchCancel, this); } // ======================== 事件回调 ======================== /** * 金币变化事件回调: * 仅负责 UI 更新和动画表现。数据更新已由 MissionEconomy 统一处理。 */ private onCoinAdd(event: string, args: any) { const payload = args ?? event; const v = typeof payload === 'number' ? payload : (payload?.delta ?? payload?.value ?? 0); this.updateCoinAndCostUI(); if (v !== 0) { this.playCoinChangeAnim(v > 0); } } /** 刷新石数量变更:刷新刷新按钮状态/费用显示,并播放数量变化动画 */ private onRefreshStone(event: string, args: any) { const payload = args ?? event; const v = typeof payload === 'number' ? payload : (payload?.delta ?? payload?.value ?? 0); this.updateDrawCostUI(); if (v !== 0) { this.playRefreshStoneChangeAnim(v > 0); } } /** 战斗开始:保留卡牌面板,允许玩家在战斗阶段继续抽卡和召唤英雄 */ private onFightStart() { this.enterBattlePhase(); // 首次进入战斗阶段:启动按钮逐个指引(仅提醒 1 次,全部完成则自动跳过) const tips = smc.data.tip_done; if (!tips.hero || !tips.equip || !tips.skill || !tips.shop) { this.playButtonGuide(); } } private onShowSmallTip(event: string, args: any) { // 支持字符串(仅类型)或 { type, text }(类型 + 自定义文案)两种负载 const payload = typeof args === "string" ? { type: args } : (args ?? {}); this.showSmallTip(payload.type, payload.text); } /** * 在目标节点上弹出 smalltip 气泡(缩放动画)。 * @param type 提示类型(决定挂载到哪个节点) * - refresh_coin / buy_coin / hero_full:挂在抽卡按钮 / 金币节点 / 英雄数量节点 * - panel_hero_full / panel_equip_full / panel_skill_full / panel_shop_full: * 挂在 showHeros / showEquips / showSkills / showShop 按钮节点(按钮下需预置 smalltip 子节点) * @param text 可选:覆盖气泡文字(默认读 prefab 中配置的文本) */ public showSmallTip(type: "refresh_coin" | "buy_coin" | "hero_full" | "panel_hero_full" | "panel_equip_full" | "panel_skill_full" | "panel_shop_full", text?: string) { let targetNode: Node | null = null; switch (type) { case "refresh_coin": targetNode = this.cards_refresh; break; case "buy_coin": targetNode = this.coins_node; break; case "hero_full": targetNode = this.hero_num_node; break; case "panel_hero_full": targetNode = this.showHeros; break; case "panel_equip_full": targetNode = this.showEquips; break; case "panel_skill_full": targetNode = this.showSkills; break; case "panel_shop_full": targetNode = this.showShop; break; } if (targetNode && targetNode.isValid) { const tipNode = targetNode.getChildByName("smalltip"); if (tipNode) { // 动态覆盖气泡文字(box/Label),用于同节点多文案场景(如英雄满员 / 至少保留 1 人) if (text) { const label = tipNode.getComponentInChildren(Label); if (label) label.string = text; } tipNode.active = true; Tween.stopAllByTarget(tipNode); // 设置初始状态:缩放为 0 tipNode.setScale(new Vec3(0, 0, 1)); tween(tipNode) // 1. 弹出动画(微放大再回弹) .to(0.15, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' }) .to(0.05, { scale: new Vec3(1, 1, 1) }) // 2. 停留 1 秒 .delay(1) // 3. 缩小消失动画 .to(0.15, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' }) .call(() => { if (tipNode && tipNode.isValid) tipNode.active = false; }) .start(); } } } private onPhasePrepareStart() { this.updateHeroNumUI(true, true); } /** * 技能面板按钮回调:技能面板从底部划出(index=2)。 */ private onShowSkillsClick() { oops.audio.playEffect("music/button"); if (this.isOverlayPanelShown(2)) return; // 技能槽位已满:气泡提示并中止,不再展开三选一,也不扣开启费用 if (MissSkillsComp.isFull()) { this.showSmallTip("panel_skill_full", "技能槽已满"); return; } // 开启技能面板需支付金币,金币不足则提示并中止 if (!this.tryPayPanelOpenCost(2)) return; this.slideToPanel(2); } // ======================== 商店面板 ======================== /** * 商店面板按钮回调:药品商店面板从底部划出(index=3)。 */ private onShowShopClick() { oops.audio.playEffect("music/button"); if (this.isOverlayPanelShown(3)) return; // 开启药品面板需支付金币,金币不足则提示并中止 if (!this.tryPayPanelOpenCost(3)) return; this.slideToPanel(3); } /** * 填充商品列表(槽位复用模式): * 从 ICardSet 按权重抽取 3 张商品卡, * 优先复用现有槽位(shopBoxNode 子节点),不足再实例化新槽位, * 每个商品项由 ItemListComp 渲染并处理购买。 * * 商品为一次性 buff 技能(Instant 触发,t_times=1), * 购买后立即生效,由 MissSkillsComp 统一处理技能逻辑。 */ private populateShopItems() { if (!this.shopBoxNode || !this.itemPrefab) return; // 从商品卡池按权重抽取 3 张 const items = drawItemCards(3); const targetCount = items.length; // 确保槽位数量足够(复用 + 补充实例化) this.ensureItemSlots(targetCount); // 分发数据到槽位 for (let i = 0; i < this.itemCardComps.length; i++) { const comp = this.itemCardComps[i]; if (!comp) continue; if (i < targetCount) { comp.applyCardData(items[i]); } else { // 多余槽位清空 comp.applyCardData(null as unknown as CardConfig); } } // 根据物品数量设置列表面板高度 this.resizeListBox(this.shopBoxNode, targetCount); // 三等分水平布局 this.layoutSlotsHorizontal(this.itemCardComps); mLogger.log(this.debugMode, "MissionCardComp", "populate shop items", { count: items.length, slotCount: this.itemCardComps.length }); } /** * 确保药品槽位数量足够。 * 优先复用现有槽位,不足时从 itemPrefab 实例化补充。 * 实例化后立即按 slotPosX 定位,避免堆叠在 (0,0)。 */ private ensureItemSlots(targetCount: number) { if (!this.shopBoxNode || !this.itemPrefab) return; // 刷新缓存 this.cacheItemCardComps(); // 补充实例化不足的槽位 while (this.itemCardComps.length < targetCount) { const node = instantiate(this.itemPrefab); this.shopBoxNode.addChild(node); const comp = node.getComponent(ItemListComp) || node.addComponent(ItemListComp); this.itemCardComps.push(comp); } this.layoutSlotsHorizontal(this.itemCardComps); } /** 清空商品列表(复用槽位,仅清空数据) */ private clearShopItems() { for (const comp of this.itemCardComps) { if (comp && comp.node && comp.node.isValid) { comp.applyCardData(null as unknown as CardConfig); } } } // ======================== 装备/商品购买事件已移除限购逻辑 ======================== /** 解除按钮监听,避免节点销毁后回调泄漏 */ private unbindEvents() { oops.message.off(GameEvent.CoinAdd, this.onCoinAdd, this); oops.message.off(GameEvent.RefreshStone, this.onRefreshStone, this); oops.message.off(GameEvent.MasterCalled, this.onMasterCalled, this); oops.message.off(GameEvent.HeroDead, this.onHeroDead, this); oops.message.off(GameEvent.HeroSell, this.onHeroSell, this); oops.message.off(GameEvent.HeroLvUp, this.onHeroLvUp, this); oops.message.off(GameEvent.UseHeroCard, this.onUseHeroCard, this); oops.message.off(GameEvent.UseSpecialCard, this.onUseSpecialCard, this); oops.message.off(GameEvent.ShowSmallTip, this.onShowSmallTip, this); oops.message.off(GameEvent.CardUsed, this.onCardUsed, this); oops.message.off(GameEvent.HeroBoxEmptyClick, this.onHeroBoxEmptyClick, this); oops.message.off(GameEvent.HeroUpgrade, this.onHeroUpgrade, this); if (this.cards_refresh && this.cards_refresh.isValid) { this.cards_refresh.off(NodeEventType.TOUCH_START, this.onDrawTouchStart, this); this.cards_refresh.off(NodeEventType.TOUCH_END, this.onDrawTouchEnd, this); this.cards_refresh.off(NodeEventType.TOUCH_CANCEL, this.onDrawTouchCancel, this); } if (this.showHeros && this.showHeros.isValid) { this.showHeros.off(NodeEventType.TOUCH_END, this.onShowHerosClick, this); } if (this.closeCards && this.closeCards.isValid) { this.closeCards.off(NodeEventType.TOUCH_END, this.onCloseCardsClick, this); } if (this.showEquips && this.showEquips.isValid) { this.showEquips.off(NodeEventType.TOUCH_END, this.onShowEquipsClick, this); } if (this.showShop && this.showShop.isValid) { this.showShop.off(NodeEventType.TOUCH_END, this.onShowShopClick, this); } if (this.equip_refresh && this.equip_refresh.isValid) { this.equip_refresh.off(NodeEventType.TOUCH_START, this.onEquipDrawTouchStart, this); this.equip_refresh.off(NodeEventType.TOUCH_END, this.onEquipDrawTouchEnd, this); this.equip_refresh.off(NodeEventType.TOUCH_CANCEL, this.onEquipDrawTouchCancel, this); } if (this.skill_refresh && this.skill_refresh.isValid) { this.skill_refresh.off(NodeEventType.TOUCH_START, this.onSkillDrawTouchStart, this); this.skill_refresh.off(NodeEventType.TOUCH_END, this.onSkillDrawTouchEnd, this); this.skill_refresh.off(NodeEventType.TOUCH_CANCEL, this.onSkillDrawTouchCancel, this); } if (this.shop_refresh && this.shop_refresh.isValid) { this.shop_refresh.off(NodeEventType.TOUCH_START, this.onShopDrawTouchStart, this); this.shop_refresh.off(NodeEventType.TOUCH_END, this.onShopDrawTouchEnd, this); this.shop_refresh.off(NodeEventType.TOUCH_CANCEL, this.onShopDrawTouchCancel, this); } } /** * 英雄上场事件回调(MasterCalled): * 为新上场英雄创建或更新信息面板,并刷新英雄数量 UI。 */ private onMasterCalled(event: string, args: any) { const payload = args ?? event; const eid = Number(payload?.eid ?? 0); const model = payload?.model as HeroAttrsComp | undefined; mLogger.log(this.debugMode, "MissionCardComp", "onMasterCalled received payload:", { eid, hasModel: !!model }); if (!eid || !model) return; const before = this.getAliveHeroCount(); const after = this.getAliveHeroCount(); this.updateHeroNumUI(true, after > before); // 第一次召唤英雄后,开放其他面板按钮 if (!this.hasCalledHero) { this.hasCalledHero = true; this.updatePanelButtonsInteractable(); } // 英雄登场后同步英雄信息盒 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 (!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_refresh); } /** 抽卡按钮释放 → 执行抽卡逻辑 */ private onDrawTouchEnd() { oops.audio.playEffect("music/button"); this.playButtonClickAnim(this.cards_refresh, () => this.onClickDraw()); } /** 抽卡按钮取消 → 恢复缩放 */ private onDrawTouchCancel() { this.playButtonResetAnim(this.cards_refresh); } // ======================== 新手按钮指引 ======================== /** * 启动新手按钮指引:依次指引 英雄→装备→技能→药品 四个面板按钮。 * 每步播放 bg/icon 循环缩放脉动 + smalltip 气泡文案, * 已完成的步骤(smc.data.tip_done 对应键为 true)自动跳过,仅提醒 1 次。 */ private playButtonGuide() { // 确保无旧指引残留,再从头开始 this.stopButtonGuide(); this.guideStepIndex = -1; this.nextButtonGuideStep(); } /** 推进到下一个未完成指引的步骤;全部完成则结束 */ private nextButtonGuideStep() { this.guideStepIndex++; // 跳过已完成的步骤 while (this.guideStepIndex < this.guideSteps.length && smc.data.tip_done[this.guideSteps[this.guideStepIndex].key]) { this.guideStepIndex++; } if (this.guideStepIndex >= this.guideSteps.length) { this.finishButtonGuide(); return; } this.playButtonGuideStep(); } /** 播放当前步指引,并用 scheduleOnce 驱动进入下一步 */ private playButtonGuideStep() { const step = this.guideSteps[this.guideStepIndex]; if (!step) return; const btn = step.getBtn(); if (btn && btn.isValid) { this.playGuidePulse(btn, true); this.showGuideTip(btn, step.text); } this.scheduleOnce(() => { this.stopGuidePulse(btn); // 本步提醒完成:记录完成标记(即使中止也只标记已播完的步骤) smc.data.tip_done[step.key] = true; this.nextButtonGuideStep(); }, MissionCardComp.BUTTON_GUIDE_STEP_DURATION); } /** 指引全部完成:还原气泡文案 */ private finishButtonGuide() { this.restoreGuideTipTexts(); this.guideStepIndex = -1; mLogger.log(this.debugMode, "MissionCardComp", "button guide finished"); } /** * 中止指引(任务结束 / 组件销毁时调用): * 停止所有缩放脉动动画、还原气泡文案并隐藏气泡。 */ private stopButtonGuide() { if (this.guideStepIndex < 0 && this.guideTipTextCache.size === 0) return; for (const step of this.guideSteps) { this.stopGuidePulse(step.getBtn()); } this.restoreGuideTipTexts(); this.guideStepIndex = -1; } /** 还原指引覆盖过的气泡文案并隐藏气泡 */ private restoreGuideTipTexts() { for (const [tipNode, text] of this.guideTipTextCache) { if (tipNode && tipNode.isValid) { Tween.stopAllByTarget(tipNode); const label = tipNode.getComponentInChildren(Label); if (label) label.string = text; tipNode.active = false; } } this.guideTipTextCache.clear(); } /** * 对按钮 bg/icon 子节点播放循环缩放脉动动画。 * 使用 scale 往复 tween,不修改 position,避免与 Widget 布局冲突。 * @param btn 面板按钮节点(内部含 bg/icon 子节点) * @param repeat 是否无限循环(true = 循环脉动) */ private playGuidePulse(btn: Node, repeat: boolean) { const pulseScale = MissionCardComp.BUTTON_GUIDE_PULSE_SCALE; for (const name of ["bg", "icon"]) { const child = btn.getChildByName(name); if (!child || !child.isValid) continue; Tween.stopAllByTarget(child); child.setScale(1, 1, 1); // 单轮脉动约 0.6s,持续闪动与气泡停留节奏一致 const pulseTween = tween(child) .to(0.3, { scale: new Vec3(pulseScale, pulseScale, 1) }, { easing: 'sineInOut' }) .to(0.3, { scale: new Vec3(1, 1, 1) }, { easing: 'sineInOut' }); if (repeat) { tween(child).repeatForever(pulseTween).start(); } else { pulseTween.start(); } } } /** 停止按钮 bg/icon 缩放脉动并复位缩放 */ private stopGuidePulse(btn: Node | null) { if (!btn || !btn.isValid) return; for (const name of ["bg", "icon"]) { const child = btn.getChildByName(name); if (!child || !child.isValid) continue; Tween.stopAllByTarget(child); child.setScale(1, 1, 1); } } /** * 在按钮节点上弹出 smalltip 气泡并覆盖文案。 * 首次覆盖前缓存原文,供指引结束后还原。 * @param btn 面板按钮节点(下挂 smalltip 子节点) * @param text 指引文案 */ private showGuideTip(btn: Node, text: string) { const tipNode = btn.getChildByName("smalltip"); if (!tipNode) return; if (!this.guideTipTextCache.has(tipNode)) { const label = tipNode.getComponentInChildren(Label); this.guideTipTextCache.set(tipNode, label ? label.string : ""); } const label = tipNode.getComponentInChildren(Label); if (label) label.string = text; tipNode.active = true; Tween.stopAllByTarget(tipNode); tipNode.setScale(0, 0, 1); tween(tipNode) .to(0.15, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' }) .to(0.05, { scale: new Vec3(1, 1, 1) }) .delay(MissionCardComp.BUTTON_GUIDE_TIP_STAY) .to(0.15, { scale: new Vec3(0, 0, 1) }, { easing: 'quadIn' }) .call(() => { if (tipNode && tipNode.isValid) tipNode.active = false; }) .start(); } // ======================== 面板底部划出系统 ======================== /** 面板节点数组(顺序:英雄→装备→技能→药品) */ private getPanelNodes(): Node[] { return [this.herosPanNode, this.equipsPanNode, this.skillPanNode, this.shopPanNode]; } /** 面板显示按钮数组(同上排序) */ private getPanelShowButtons(): Node[] { return [this.showHeros, this.showEquips, this.showSkills, this.showShop]; } /** * 判断覆盖面板(index≥1)当前是否处于展开状态,避免重复开启导致重复扣费。 * @param index 面板下标(1=装备 2=技能 3=药品) */ private isOverlayPanelShown(index: number): boolean { if (this.currentPanelIndex !== index) return false; const node = this.getPanelNodes()[index]; return !!node && node.isValid && node.active; } /** * 初始化面板布局: * 1. 英雄面板(index=0)为常驻基准面板,始终显示。 * 2. 覆盖面板(卡牌/装备/技能/药品)铺满屏幕、位置固定 (0,0), * 通过缩放 0↔1 控制显示/隐藏,初始缩放置 0 并停用。 */ private initPanelLayout() { if (this.hasInitPanelLayout) return; const panels = this.getPanelNodes(); const heroNode = panels[0]; if (heroNode && heroNode.isValid) { heroNode.active = true; } // 覆盖面板从 index 1 开始:位置由代码接管,禁用 Widget 避免其在 lateUpdate 对齐覆盖 position for (let i = 1; i < panels.length; i++) { this.initOverlayPanel(panels[i]); } // 卡牌面板与其他覆盖面板同级,同上处理 this.initOverlayPanel(this.cardPanNode); this.hasInitPanelLayout = true; this.updatePanelButtonStates(); } /** 单个覆盖面板初始化:禁用 Widget、定位到显示位置、缩放置 0 并隐藏 */ private initOverlayPanel(node: Node | null) { if (!node || !node.isValid) return; const widget = node.getComponent(Widget); if (widget) widget.enabled = false; node.setPosition(this.panelShowPos); node.setScale(this.panelHideScale); node.active = false; } /** * 显示覆盖面板:缩放从 0 动画放大到 1。 * @param node 覆盖面板节点(卡牌/装备/技能/药品) */ private showOverlayPanel(node: Node | null) { if (!node || !node.isValid) return; node.setPosition(this.panelShowPos); node.setScale(this.panelHideScale); node.active = true; Tween.stopAllByTarget(node); tween(node) .to(this.cardPanelAnimDuration, { scale: this.panelShowScale }, { easing: 'quadOut' }) .start(); } /** * 隐藏覆盖面板:缩放从当前值动画缩小到 0,结束后停用节点。 * @param node 覆盖面板节点(卡牌/装备/技能/药品) */ private hideOverlayPanelNode(node: Node | null) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); tween(node) .to(this.cardPanelAnimDuration, { scale: this.panelHideScale }, { easing: 'quadIn' }) .call(() => { if (node.isValid) node.active = false; }) .start(); } /** * 切换到指定面板: * 英雄面板(index=0)为常驻基准面板; * 选中的覆盖面板缩放放大到 1 显示,其余覆盖面板缩放缩小到 0 隐藏。 * @param index 0=英雄, 1=装备, 2=技能, 3=药品 */ private slideToPanel(index: number) { const panels = this.getPanelNodes(); if (index < 0 || index >= panels.length) return; this.currentPanelIndex = index; // 覆盖面板从 index 1 开始(index 0 英雄面板常驻不动) for (let i = 1; i < panels.length; i++) { const node = panels[i]; if (!node || !node.isValid) continue; if (i === index) { this.showOverlayPanel(node); } else { this.hideOverlayPanelNode(node); } } this.updatePanelButtonStates(); } /** * 更新面板 tab 按钮的 active 子节点状态。 * 装备/技能/药品按钮按 currentPanelIndex 高亮; * 英雄抽卡面板(cardPanNode)不在覆盖面板数组内, * showHeros 按钮按 isCardPanelShown 高亮:面板展开时显示 active,关闭时隐藏。 */ private updatePanelButtonStates() { const buttons = this.getPanelShowButtons(); for (let i = 0; i < buttons.length; i++) { const btn = buttons[i]; if (!btn || !btn.isValid) continue; const activeChild = btn.getChildByName("active"); if (activeChild) { const shouldActive = i === 0 ? this.isCardPanelShown : (i === this.currentPanelIndex); activeChild.active = shouldActive; } } } /** * 更新面板按钮的可交互状态。 * 游戏刚开始时(未召唤英雄),只有英雄面板可打开; * 召唤第一个英雄后,其他面板才允许点击。 * 通过 nock_node(阻挡层)控制按钮是否可点击。 */ private updatePanelButtonsInteractable() { const interactable = this.hasCalledHero; if (this.nock_node && this.nock_node.isValid) { this.nock_node.active = !interactable; } } /** * 英雄招募按钮(每 4 次一循环): * - 前 3 次:扣面板开启费后直接随机召唤 1 个英雄(不开面板,无需玩家选)。 * - 第 4 次:扣费后展开三选一面板(缩放 0→1),玩家选 1。 * 满员(HERO_MAX_NUM)时气泡提示并拦截,不扣费、不计数。 */ private onShowHerosClick() { oops.audio.playEffect("music/button"); // 满员拦截:随机招募与三选一都禁止 if (this.getAliveHeroCount() >= this.getMissionHeroMaxNum()) { this.showSmallTip("panel_hero_full", "英雄槽已满"); return; } // 随机招募与三选一都需支付面板开启费,金币不足则提示并中止 if (!this.tryPayPanelOpenCost(0)) return; // 先计数,再按节奏分支:count % 4 == 0 展开三选一,其余直接随机召唤 this.recruitCount++; const isPickOneOfThree = (this.recruitCount % 4) === 0; mLogger.log(this.debugMode, "MissionCardComp", "hero recruit", { recruitCount: this.recruitCount, mode: isPickOneOfThree ? "pick_one_of_three" : "random_summon" }); // 收起装备/技能/药品覆盖面板,回到英雄面板 this.slideToPanel(0); if (isPickOneOfThree) { // 第 4 次:展开英雄抽卡面板(缩放 0→1,点亮按钮 active 高亮) this.showCardsPanel(); } else { // 前 3 次:直接随机召唤,不开面板 this.randomSummonOne(); } } // ======================== 英雄信息盒(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 refresh", { cost, leftCoin: MissionEconomy.getCoin() }); this.layoutCardSlots(); const cards = this.buildDrawCards(); this.dispatchCardsToSlots(cards); } /** * 直接随机召唤 1 个英雄: * - 全英雄卡池按权重随机(不过滤已召唤英雄,配合二合一合成)。 * - 近战保底:场上已有英雄且全部为远程/中程(无近战)时,近战卡权重放大。 */ private randomSummonOne() { const pool = this.buildRandomSummonPool(); if (pool.length === 0) return; const pick = this.weightedPick(pool); if (!pick) return; mLogger.log(this.debugMode, "MissionCardComp", "random summon", { uuid: pick.uuid }); oops.message.dispatchEvent(GameEvent.CallHero, { uuid: pick.uuid, hero_lv: 1, card_lv: pick.card_lv ?? 1 }); } /** * 构建随机召唤卡池:全英雄卡,必要时按近战保底放大近战权重。 * @returns 带运行时权重的卡配置数组(weight 已按保底调整) */ private buildRandomSummonPool(): CardConfig[] { const heroCards = CardPoolList.filter(c => c.type === CardType.Hero); if (heroCards.length === 0) return []; // 全远程判定:有存活英雄但没有任何近战 → 触发放大近战权重 const meleeMul = this.isAllRangedFormation() ? MissionCardComp.MELEE_PITY_WEIGHT_MUL : 1; if (meleeMul === 1) return heroCards; return heroCards.map(c => { const hero = HeroInfo[c.uuid]; const isMelee = hero && hero.type === HType.Melee; // 复制一份避免污染卡池原始 weight return isMelee ? { ...c, weight: (c.weight ?? 0) * meleeMul } : c; }); } /** * 判断当前场上是否为「全远程阵容」: * 有至少 1 个存活英雄,且没有任何近战(Melee)英雄。 */ private isAllRangedFormation(): boolean { let heroCount = 0; let meleeCount = 0; ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => { const model = entity.get(HeroAttrsComp); if (!model || model.fac !== FacSet.HERO || model.is_dead) return; heroCount++; if (model.type === HType.Melee) meleeCount++; }); return heroCount > 0 && meleeCount === 0; } // ======================== 阶段切换 ======================== /** 缓存卡牌面板的基准缩放值(从场景初始状态读取,仅缓存一次) */ private initCardsPanelPos() { if (!this.cards_node || !this.cards_node.isValid) return; if (!this.hasCachedCardsBaseScale) { const scale = this.cards_node.scale; this.cardsBaseScale = new Vec3(scale.x, scale.y, scale.z); this.hasCachedCardsBaseScale = true; } this.cardsShowScale = new Vec3(this.cardsBaseScale.x, this.cardsBaseScale.y, this.cardsBaseScale.z); this.cardsHideScale = new Vec3(0, 0, this.cardsBaseScale.z); } /** * 进入准备阶段: * - 初始化面板布局(如尚未初始化)。 * - 收起覆盖面板回到英雄面板(index=0),作为默认展示。 * - 激活 showHeros 按钮可见性。 * - 显示卡牌面板(从底部滑入)。 */ private enterPreparePhase() { if (!this.cards_node || !this.cards_node.isValid) return; this.initCardsPanelPos(); // 确保面板布局已初始化 if (!this.hasInitPanelLayout) { this.initPanelLayout(); } // 显式激活「显示英雄卡池」按钮本身,让玩家可见可点 if (this.showHeros && this.showHeros.isValid) { this.showHeros.active = true; } // 刷新所有刷新按钮的费用显示(含刷新石/金币切换与置灰状态) this.updateDrawCostUI(); // 显示卡牌面板(默认进入准备阶段时展开,缩放放大到 1,点亮按钮 active 高亮) if (this.cardPanNode && this.cardPanNode.isValid) { this.showCardsPanel(); mLogger.log(this.debugMode, "MissionCardComp", "enterPreparePhase cardPanNode", { active: this.cardPanNode.active, position: this.cardPanNode.position, worldPos: this.cardPanNode.worldPosition, parentName: this.cardPanNode.parent?.name, parentActive: this.cardPanNode.parent?.active, parentWorldPos: this.cardPanNode.parent?.worldPosition, cards_nodeActive: this.cards_node?.active, cards_nodeWorldPos: this.cards_node?.worldPosition, cards_nodeChildren: this.cards_node?.children.length }); } // 收起覆盖面板,回到英雄面板 this.slideToPanel(0); mLogger.log(this.debugMode, "MissionCardComp", "enterPreparePhase herosPanNode", { herosPanNodeActive: this.herosPanNode?.active, herosPanNodePos: this.herosPanNode?.position, herosPanNodeWorldPos: this.herosPanNode?.worldPosition, currentPanelIndex: this.currentPanelIndex, panelShowPos: this.panelShowPos }); } private enterBattlePhase() { if (!this.cards_node || !this.cards_node.isValid) return; this.initCardsPanelPos(); // 战斗阶段允许抽卡:刷新所有刷新按钮的费用显示(含刷新石/金币切换与置灰状态) this.updateDrawCostUI(); } /** * 构建本次抽卡结果,返回 3 张英雄卡。 * * 规则: * 1. 从基础英雄卡池按权重抽取,同一次抽卡内英雄不重复。 * 2. 不过滤已召唤英雄:抽到同 uuid 可触发二合一合成升级。 * 3. 不足 3 张时循环补齐(允许重复)。 * * 注:升级卡机制已移除,英雄卡池只出普通英雄卡。 */ private buildDrawCards(): CardConfig[] { const availableHeroCards = CardPoolList.filter(c => c.type === CardType.Hero); if (availableHeroCards.length === 0) return []; const result: CardConfig[] = []; const usedUuids = new Set(); // 按权重抽取,同一次抽卡内英雄不重复 while (result.length < 3) { const pool = availableHeroCards.filter(c => !usedUuids.has(c.uuid)); if (pool.length === 0) break; const pick = this.weightedPick(pool); if (!pick) break; result.push(pick); usedUuids.add(pick.uuid); } // 兜底:不足 3 张时循环补齐(允许重复) while (result.length < 3) { result.push(availableHeroCards[result.length % availableHeroCards.length]); } return result.slice(0, 3); } /** 单次按权重抽取一张卡 */ private weightedPick(cards: CardConfig[]): CardConfig | null { if (cards.length === 0) return null; const totalWeight = cards.reduce((total, card) => total + (card.weight ?? 0), 0); let random = Math.random() * totalWeight; for (const card of cards) { random -= (card.weight ?? 0); if (random <= 0) return card; } return cards[cards.length - 1]; } private tryRefreshHeroCards(heroType?: HType): boolean { const cards = drawCardsByRule({ count: 3, type: CardType.Hero, heroType, unique: true, // 保证一次刷新内的英雄卡不重复 }); // 不过滤已召唤英雄:抽到同 uuid 可触发二合一合成升级 if (cards.length <= 0) return false; this.layoutCardSlots(); this.dispatchCardsToSlots(cards.slice(0, 3)); return true; } private tryRefreshHeroCardsByEffect(refreshHeroType: SpecialRefreshHeroType): boolean { const heroType = this.resolveRefreshHeroType(refreshHeroType); return this.tryRefreshHeroCards(heroType); } private resolveRefreshHeroType(refreshHeroType: SpecialRefreshHeroType): HType | undefined { if (refreshHeroType === SpecialRefreshHeroType.Melee) return HType.Melee; if (refreshHeroType === SpecialRefreshHeroType.Ranged) return HType.Long; return undefined; } /** 全量分发给 3 槽;每个槽位是否接收由 CHeroComp 自己判断(锁定可跳过) */ private dispatchCardsToSlots(cards: CardConfig[]) { if (!this.cardComps) return; for (let i = 0; i < this.cardComps.length; i++) { if (this.cardComps[i]) { const accepted = this.cardComps[i].applyDrawCard(cards[i] ?? null); const node = this.cardComps[i].node; mLogger.log(this.debugMode, "MissionCardComp", "dispatch card", { index: i, card: cards[i]?.uuid ?? 0, accepted, nodeActive: node?.active, nodeWorldPos: node?.worldPosition, nodeScale: node?.scale, parentName: node?.parent?.name, parentActive: node?.parent?.active, parentWorldPos: node?.parent?.worldPosition }); } } } /** 系统清空 3 槽(用于任务切换) */ private clearAllCards() { if (!this.cardComps) return; this.cardComps.forEach(comp => { if (comp) comp.clearBySystem(); }); } private layoutCardSlots() { // 走 CHeroComp.setSlotPosition,同步更新其 restPosition, // 避免 clearBySystem 恢复位置时与布局结果不一致 for (let i = 0; i < this.cardComps.length && i < this.slotPosX.length; i++) { const comp = this.cardComps[i]; if (comp && comp.node && comp.node.isValid) { const widget = comp.node.getComponent(Widget); if (widget) widget.enabled = false; comp.setSlotPosition(this.slotPosX[i]); } } } /** * 三等分水平布局: * 按固定坐标数组 slotPosX 排列槽位,超出数量的槽位不处理。 * * Why: 各卡池根节点的 Layout 组件已移除,槽位水平位置改由代码统一控制。 * 部分预制体根节点残留 Widget 组件(对齐父节点中心), * 启用时会在 lateUpdate 覆盖代码设置的 position,需先禁用。 * * @param comps 槽位组件数组(CHeroComp / EquipListComp / SCardComp / ItemListComp) */ private layoutSlotsHorizontal(comps: Array) { 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) { this.scheduleOnce(() => { this.layoutSlotsHorizontal(comps); }, 0); } private playButtonPressAnim(node: Node | null) { this.playNodeScaleTo(node, this.buttonPressScale, 0.06); } private playButtonClickAnim(node: Node | null, onComplete: () => void) { if (!node || !node.isValid) { onComplete(); return; } this.playNodeScalePop(node, this.buttonClickScale, 0.05, 0.08, onComplete); } private playButtonResetAnim(node: Node | null) { this.playNodeScaleTo(node, this.buttonNormalScale, 0.08); } private resetButtonScale(node: Node | null) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); node.setScale(this.buttonNormalScale, this.buttonNormalScale, 1); } private canDrawCards() { // 有刷新石时可直接刷新,否则校验金币是否足够 return MissionEconomy.canRefresh(this.refreshCost); } private updateDrawCostUI() { const stones = MissionEconomy.getRefreshStone(); const stoneCost = MissionEconomy.getRefreshStoneCost(); this.updateOneRefreshBtnUI(this.cards_refresh, stones, stoneCost); this.updateOneRefreshBtnUI(this.equip_refresh, stones, stoneCost); this.updateOneRefreshBtnUI(this.shop_refresh, stones, stoneCost); this.updateOneRefreshBtnUI(this.skill_refresh, stones, stoneCost); } /** * 更新单个刷新按钮的费用显示与可用状态。 * * 统一 UI 结构(按钮节点下): * - 背景 :按钮底图 * - nobg :置灰遮罩,不可刷新时激活 * - Node/ :费用容器,下含 Label + coin + stone * - coin/ :金币费用(含 num 子节点 Label),刷新石=0 时显示 * - stone/ :刷新石费用(含 num 子节点 Label),刷新石>0 时显示 * * @param btnNode 刷新按钮节点 * @param stones 当前刷新石数量(外部统一读取,避免 4 次重复访问) * @param stoneCost 单次刷新消耗的刷新石数量 */ private updateOneRefreshBtnUI(btnNode: Node | null, stones: number, stoneCost: number) { if (!btnNode || !btnNode.isValid) return; // 刷新石足够一次消耗时走刷新石通道,否则走金币通道 const useStone = stones >= stoneCost; // 置灰遮罩:有刷新石必定可刷新,否则按金币判断 const nobg = btnNode.getChildByName("nobg"); if (nobg) { nobg.active = useStone ? false : !this.canDrawCards(); } // 费用容器:按钮下的 Node 子节点(统一结构:背景 + Node[Label/coin/stone]) const container = btnNode.getChildByName("Node"); if (!container) return; // 金币费用节点 const coinNode = container.getChildByName("coin"); if (coinNode) { coinNode.active = !useStone; if (!useStone) { const numLabel = coinNode.getChildByName("num")?.getComponent(Label); if (numLabel) { numLabel.string = `${MissionEconomy.getRefreshCost(this.refreshCost)}`; } } } // 刷新石费用节点(显示单次刷新消耗数量) const stoneNode = container.getChildByName("stone"); if (stoneNode) { stoneNode.active = useStone; if (useStone) { const numLabel = stoneNode.getChildByName("num")?.getComponent(Label); if (numLabel) { numLabel.string = `${stoneCost}`; } } } } private updateCoinAndCostUI() { this.updateDrawCostUI(); } private playCoinChangeAnim(isIncrease: boolean) { if (!this.coins_node || !this.coins_node.isValid) return; const icon = this.coins_node.getChildByName("icon"); if (!icon || !icon.isValid) return; const peak = isIncrease ? 1.2 : 1.2; this.playHeroNumNodePop(icon, peak); const num = this.coins_node.getChildByName("num"); if (!num || !num.isValid) return; this.playHeroNumNodePop(num, peak); } /** 刷新石计数变化动画(与金币一致的 icon+num 弹跳) */ private playRefreshStoneChangeAnim(isIncrease: boolean) { if (!this.refresh_stone_num_node || !this.refresh_stone_num_node.isValid) return; const peak = isIncrease ? 1.2 : 1.2; const icon = this.refresh_stone_num_node.getChildByName("icon"); if (icon && icon.isValid) this.playHeroNumNodePop(icon, peak); const num = this.refresh_stone_num_node.getChildByName("num"); if (num && num.isValid) this.playHeroNumNodePop(num, peak); } public setHeroMaxCount(max: number) { const missionData = this.getMissionData(); if (!missionData) return; const min = FightSet.HERO_MAX_NUM; const limit = Math.max(min, missionData.hero_extend_max_num ?? (FightSet.HERO_MAX_NUM + 1)); const next = Math.max(min, Math.min(limit, Math.floor(max || min))); if (next === missionData.hero_max_num) return; missionData.hero_max_num = next; this.updateHeroNumUI(true, false); } public tryExpandHeroMax(add: number = 1): boolean { const missionData = this.getMissionData(); if (!missionData) return false; const before = this.getMissionHeroMaxNum(); const next = before + Math.max(0, Math.floor(add)); this.setHeroMaxCount(next); return this.getMissionHeroMaxNum() > before; } public canUseHeroCard(): boolean { return this.getAliveHeroCount() < this.getMissionHeroMaxNum(); } private getAliveHeroCount(): number { let count = 0; ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => { const model = entity.get(HeroAttrsComp); if (model && model.fac === FacSet.HERO) { count++; } }); return count; } private queryAliveHeroActors(): Array<{ eid: number, model: HeroAttrsComp, view: HeroViewComp | null }> { const actors: Array<{ eid: number, model: HeroAttrsComp, view: HeroViewComp | null }> = []; ecs.query(ecs.allOf(HeroAttrsComp)).forEach((entity: ecs.Entity) => { const model = entity.get(HeroAttrsComp); if (!model) return; if (model.fac !== FacSet.HERO) return; if (model.is_dead) return; const view = entity.get(HeroViewComp); actors.push({ eid: entity.eid, model, view }); }); return actors; } /** * 按 eid 精确升级场上对应英雄实体。 * * @param heroEid 要升级的英雄实体 eid * @returns true = 升级成功 */ public tryUpgradeHeroByEid(heroEid: number): boolean { if (!heroEid) return false; const actor = this.queryAliveHeroActors().find(item => item.eid === heroEid); if (!actor) return false; const nextLv = actor.model.lv + 1; this.applyHeroLevel(actor.model, nextLv); if (actor.view) { actor.view.playBuff("buff_lvup"); } return true; } /** * 应用英雄等级变化(属性 = 固定基础值 + grow_type 指数递增重新计算)。 * 沿用旧版本 SkillLvUp 的技能等级映射规则(技能按 HERO_SKILL_MAX_LV 封顶)。 * * 与 Hero.load 保持一致: * 1. 同步刷新 base_ap/base_hp 快照,下游 UI/复活读到当前 lv 的基础值 * 2. 走 getRuntimeAp/getRuntimeHp 应用驻场光环乘区 * 3. 重新解析 runtime_* 触发缓存与光环/复活,避免升级后停在旧等级快照 * 4. 按 ≤nextLv 累加 hp_bonus/ap_bonus */ public applyHeroLevel(model: HeroAttrsComp, targetLv: number) { const hero = HeroInfo[model.hero_uuid]; if (!hero) return; const nextLv = Math.max(1, Math.floor(targetLv)); const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1; model.lv = nextLv; // 同步 base 快照(与 Hero.load 同公式:固定基础值,成长由 grow_type 指数递增提供) const base_ap = hero.ap; const base_hp = hero.hp; model.base_ap = base_ap; model.base_hp = base_hp; // 走驻场光环乘区,与 Hero.load 行为一致;怪物保持基础值 if (model.fac === FacSet.HERO) { model.ap = model.getRuntimeAp(base_ap); model.hp_max = model.getRuntimeHp(base_hp); } else { model.ap = base_ap; model.hp_max = base_hp; } // 按 ≤nextLv 累加:成长类型每级递增 + hp_bonus/ap_bonus 一次性奖励(沿用 Hero.load 的加法区规则) const grow = calcGrowBonus(hero, nextLv); model.hp_max += grow.hp; model.ap += grow.ap; if (model.hp_bonus) { let hpExtra = 0; for (const e of model.hp_bonus) { if (e.lv <= nextLv) hpExtra += e.value; } model.hp_max += hpExtra; } if (model.ap_bonus) { let apExtra = 0; for (const e of model.ap_bonus) { if (e.lv <= nextLv) apExtra += e.value; } model.ap += apExtra; } // 血量按当前血线比例映射到新上限,保留升级前战斗状态 model.hp = Math.max(1, Math.floor(model.hp_max * Math.max(0, Math.min(1, hpRate)))); // 重新解析触发技能/驻场光环/复活到运行时缓存(与 Hero.load 对齐) model.runtime_call = resolveTriggerByLv(model.call, nextLv); model.runtime_dead = resolveTriggerByLv(model.dead, nextLv); model.runtime_fstart = resolveTriggerByLv(model.fstart, nextLv); model.runtime_fend = resolveTriggerByLv(model.fend, nextLv); model.runtime_atking = resolveTriggerByLv(model.atking, nextLv); model.runtime_atked = resolveTriggerByLv(model.atked, nextLv); model.runtime_field = resolveFieldByLv(model.field, nextLv); model.runtime_revive = resolveReviveByLv(model.revive, nextLv); model.skills = {}; for (const key in hero.skills) { const skill = hero.skills[key]; if (!skill) continue; model.skills[skill.uuid] = { ...skill, lv: Math.min(FightSet.HERO_SKILL_MAX_LV, Math.max(0, skill.lv + nextLv - 2)), ccd: 0 }; } model.updateSkillDistanceCache(); model.dirty_hp = true; model.dirty_lv = true; oops.message.dispatchEvent(GameEvent.HeroLvUp, { uuid: model.hero_uuid, lv: nextLv }); } private updateHeroNumUI(animate: boolean, isIncrease: boolean) { this.syncMissionHeroData(); if (!animate || !isIncrease) return; this.playHeroNumGainAnim(); } private playHeroNumGainAnim() { if (!this.hero_num_node || !this.hero_num_node.isValid) return; const iconNode = this.hero_num_node.getChildByName("icon"); const numNode = this.hero_num_node.getChildByName("num"); this.playHeroNumNodePop(iconNode, 1.2); this.playHeroNumNodePop(numNode, 1.2); } private playHeroNumDeniedAnim() { if (!this.hero_num_node || !this.hero_num_node.isValid) return; const iconNode = this.hero_num_node.getChildByName("icon"); const numNode = this.hero_num_node.getChildByName("num"); this.playHeroNumNodePop(iconNode, 1.2); this.playHeroNumNodePop(numNode, 1.2); } private playHeroNumNodePop(node: Node | null, scalePeak: number) { this.playNodeScalePop(node, scalePeak, 0.08, 0.1); } private playNodeScaleTo(node: Node | null, scale: number, duration: number) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); tween(node) .to(duration, { scale: new Vec3(scale, scale, 1) }) .start(); } private playNodeScalePop(node: Node | null, scalePeak: number, toPeakDuration: number, toNormalDuration: number, onPeak?: () => void) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); node.setScale(1, 1, 1); const seq = tween(node) .to(toPeakDuration, { scale: new Vec3(scalePeak, scalePeak, 1) }); if (onPeak) { seq.call(onPeak); } seq.to(toNormalDuration, { scale: new Vec3(1, 1, 1) }) .start(); } private getMissionData(): any { return smc?.vmdata?.mission_data ?? null; } private getMissionHeroNum(): number { const missionData = this.getMissionData(); return Math.max(0, Math.floor(missionData?.hero_num ?? 0)); } private getMissionHeroMaxNum(): number { return FightSet.HERO_MAX_NUM } private syncMissionHeroData(count?: number) { const missionData = this.getMissionData(); if (!missionData) return; const safeCount = Math.max(0, Math.floor(count ?? this.getAliveHeroCount())); missionData.hero_num = safeCount; } /** 视图对象通过 ecs.Entity.remove(ModuleViewComp) 删除组件是触发组件处理自定义释放逻辑 */ reset() { this.resetButtonScale(this.cards_refresh); // 关键:在 reset/销毁 时将 Map 置空,彻底切断引用 this.cardComps = []; this.heroBoxComps = []; this.skillCardComps = []; if (this.node && this.node.isValid) { this.node.destroy(); } } }