/** * @file MissionCardComp.ts * @description 卡牌系统核心控制器(战斗 UI 层 + 业务逻辑层) * * 职责: * 1. **卡牌分发管理** —— 从卡池抽取 4 张卡,分发到 4 个 CardComp 槽位。 * 2. **卡池升级** —— 消耗金币提升卡池等级(poolLv),解锁更高稀有度的卡牌。 * 3. **金币费用管理** —— 抽卡费用(refreshCost)、升级费用(CardsUpSet)、 * 波次折扣(CARD_POOL_UPGRADE_DISCOUNT_PER_WAVE)的计算与扣除。 * 4. **英雄数量上限校验** —— 在 UseHeroCard 事件的 guard 阶段判断是否允许 * 再召唤英雄(含合成后腾位的特殊判断 canUseHeroCardByMerge)。 * 5. **场上英雄信息面板(HInfoComp 列表)同步** —— * 英雄上场时实例化面板,死亡时移除,定时刷新属性显示。 * 6. **特殊卡执行** —— 处理英雄升级卡(SpecialUpgrade)和英雄刷新卡(SpecialRefresh)。 * 7. **准备/战斗阶段切换** —— 控制卡牌面板的 展开/收起 动画。 * * 关键设计: * - 4 个 CardComp 通过 cacheCardComps() 映射为有序数组 cardComps[], * 之后所有分发、清空操作均通过此数组进行。 * - buildDrawCards() 保证每次抽取 4 张,不足时循环补齐。 * - 英雄上限校验(onUseHeroCard)采用 guard/cancel 模式: * CardComp 发出 UseHeroCard 事件并传入 guard 对象, * 本组件可通过 guard.cancel=true 阻止使用。 * - ensureHeroInfoPanel() 建立 EID → HInfoComp 的 Map 映射, * 支持英雄合成升级后面板热更新。 * * 依赖: * - CardComp —— 单卡槽位 * - HInfoComp —— 英雄信息面板 * - CardSet 模块 —— 卡池配置、抽卡规则、特殊卡数据 * - HeroAttrsComp —— 英雄属性(合成校验 / 升级) * - MissionHeroCompComp —— 获取合成规则(needCount / maxLv) * - smc.vmdata.mission_data —— 局内数据(coin / hero_num / hero_max_num) */ import { mLogger } from "../common/Logger"; import { _decorator, instantiate, Label, Node, NodeEventType, Prefab, SpriteAtlas, 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 { CARD_POOL_INIT_LEVEL, CARD_POOL_MAX_LEVEL, CARD_POOL_UPGRADE_DISCOUNT_PER_WAVE, CardConfig, CardType, CardsUpSet, drawCardsByRule, getCardsByLv, SpecialRefreshCardList, SpecialRefreshHeroType, SpecialUpgradeCardList } from "../common/config/CardSet"; import { CardComp } from "./CardComp"; import { oops } from "db://oops-framework/core/Oops"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { HInfoComp } from "./HInfoComp"; import { smc } from "../common/SingletonModuleComp"; import { HeroInfo, HType } from "../common/config/heroSet"; import { HeroViewComp } from "../hero/HeroViewComp"; import { FacSet } from "../common/config/GameSet"; import { MoveComp } from "../hero/MoveComp"; import { MissionHeroCompComp } from "./MissionHeroComp"; const { ccclass, property } = _decorator; /** * MissionCardComp —— 卡牌系统核心控制器 * * 管理 4 个卡牌槽位的抽卡分发、卡池升级、金币费用、 * 英雄上限校验、场上英雄信息面板同步以及特殊卡执行。 */ @ccclass('MissionCardComp') @ecs.register('MissionCard', false) export class MissionCardComp extends CCComp { /** 是否启用调试日志 */ private debugMode: boolean = true; /** 卡牌槽位宽度(像素),用于水平等距布局 */ private readonly cardWidth: number = 175; /** 按钮正常缩放 */ private readonly buttonNormalScale: number = 1; /** 按钮按下缩放 */ private readonly buttonPressScale: number = 0.94; /** 按钮弹起缩放(峰值) */ private readonly buttonClickScale: number = 1.06; /** 抽卡(刷新)费用 */ refreshCost: number = 1; /** 卡牌面板展开/收起动画时长(秒) */ 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! /** 卡池升级按钮节点 */ @property(Node) cards_up:Node = null! /** 金币显示节点(含 icon + num 子节点) */ @property(Node) coins_node:Node = null! /** 卡池等级显示节点 */ @property(Node) pool_lv_node:Node = null! /** 场上英雄信息面板容器节点(HInfoComp 实例的父节点) */ @property(Node) hero_info_node:Node = null! /** 英雄信息面板预制体(每个英雄上场时实例化一份) */ @property(Prefab) hero_info_prefab:Prefab=null! /** 英雄数量显示节点(含 icon + num 子节点) */ @property(Node) hero_num_node:Node=null! // ======================== 运行时状态 ======================== /** 预留图集缓存(后续接入按钮/卡面图标时复用) */ private uiconsAtlas: SpriteAtlas | null = null; /** 四个槽位对应的 CardComp 控制器缓存(有序数组) */ private cardComps: CardComp[] = []; /** 当前卡池等级(仅影响抽卡来源,不直接改卡槽现有内容) */ private poolLv: number = CARD_POOL_INIT_LEVEL; /** 英雄信息面板项间距(像素) */ private readonly heroInfoItemGap: number = 130; /** 英雄信息面板项间额外间距(像素) */ private readonly heroInfoItemSpacing: number = 5; /** 英雄信息面板同步计时器(降频刷新用) */ private heroInfoSyncTimer: number = 0; /** 是否已缓存卡牌面板基准缩放 */ 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); /** * 英雄信息面板映射:EID → { node, model, comp } * 用于追踪每个出战英雄的面板实例和数据引用 */ private heroInfoItems: Map = new Map(); // ======================== 生命周期 ======================== /** * 组件加载: * 1. 绑定生命周期事件和按钮交互事件。 * 2. 缓存 4 个 CardComp 子控制器引用。 * 3. 计算并设置槽位水平布局。 * 4. 初始化卡牌面板缩放参数。 * 5. 触发首次任务开始流程。 */ onLoad() { this.bindEvents(); this.cacheCardComps(); this.layoutCardSlots(); this.initCardsPanelPos(); this.onMissionStart(); mLogger.log(this.debugMode, "MissionCardComp", "onLoad init", { slots: this.cardComps.length, poolLv: this.poolLv }); } /** 组件销毁时解绑所有事件并清理英雄信息面板 */ onDestroy() { this.unbindEvents(); this.clearHeroInfoPanels(); } /** 外部初始化入口(由 CardController 调用) */ init(){ this.onMissionStart(); } /** * 任务开始: * 1. 进入准备阶段(展开卡牌面板)。 * 2. 重置卡池等级为初始值。 * 3. 初始化局内数据(金币、英雄数量上限)。 * 4. 清空旧英雄信息面板和卡牌槽位。 * 5. 重置按钮状态和 UI 显示。 * 6. 执行首次抽卡并分发到 4 个槽位。 */ onMissionStart() { this.enterPreparePhase(); this.poolLv = CARD_POOL_INIT_LEVEL; const missionData = this.getMissionData(); if (missionData) { missionData.coin = Math.max(0, Math.floor(missionData.coin ?? 0)); missionData.hero_num = 0; missionData.hero_max_num = 5; missionData.hero_extend_max_num = 6; } this.clearHeroInfoPanels(); this.layoutCardSlots(); this.clearAllCards(); if (this.cards_up) { this.cards_up.active = true; } this.resetButtonScale(this.cards_chou); this.resetButtonScale(this.cards_up); this.updateCoinAndCostUI(); this.updateHeroNumUI(false, false); this.node.active = true; const cards = this.buildDrawCards(); this.dispatchCardsToSlots(cards); mLogger.log(this.debugMode, "MissionCardComp", "mission start", { poolLv: this.poolLv }); } /** 任务结束:清空 4 槽 + 英雄面板并隐藏整个节点 */ onMissionEnd() { this.clearAllCards(); this.clearHeroInfoPanels(); this.node.active = false; } start() { } /** * 帧更新:每 0.15 秒刷新一次场上英雄信息面板(降频)。 * 检测已死亡 / 已失效的面板并移除,刷新存活面板属性。 */ update(dt: number) { this.heroInfoSyncTimer += dt; if (this.heroInfoSyncTimer < 0.15) return; this.heroInfoSyncTimer = 0; this.refreshHeroInfoPanels(); } /** 关闭面板(不销毁数据模型,仅隐藏) */ close() { this.node.active = false; } // ======================== 事件绑定 ======================== /** * 绑定所有事件监听: * - 节点级事件:MissionStart / MissionEnd / NewWave / FightStart * - 全局消息:CoinAdd / MasterCalled / HeroDead / UseHeroCard / UseSpecialCard * - 按钮触控:抽卡(cards_chou)、升级(cards_up) */ private bindEvents() { /** 生命周期事件(节点级) */ this.on(GameEvent.MissionStart, this.onMissionStart, this); this.on(GameEvent.MissionEnd, this.onMissionEnd, this); this.on(GameEvent.NewWave, this.onNewWave, this); this.on(GameEvent.FightStart, this.onFightStart, 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.UseHeroCard, this.onUseHeroCard, this); oops.message.on(GameEvent.UseSpecialCard, this.onUseSpecialCard, 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.cards_up?.on(NodeEventType.TOUCH_START, this.onUpgradeTouchStart, this); this.cards_up?.on(NodeEventType.TOUCH_END, this.onUpgradeTouchEnd, this); this.cards_up?.on(NodeEventType.TOUCH_CANCEL, this.onUpgradeTouchCancel, this); } // ======================== 事件回调 ======================== /** * 金币变化事件回调: * - syncOnly=true:仅同步 UI 显示(金币已被外部修改过)。 * - 否则:累加 delta 到 mission_data.coin 后刷新 UI。 */ private onCoinAdd(event: string, args: any){ const payload = args ?? event; if (payload?.syncOnly) { this.updateCoinAndCostUI(); this.playCoinChangeAnim((payload?.delta ?? 0) > 0); return; } const v = typeof payload === 'number' ? payload : (payload?.delta ?? payload?.value ?? 0); if (v === 0) return; this.setMissionCoin(this.getMissionCoin() + v); this.updateCoinAndCostUI(); this.playCoinChangeAnim(v > 0); } /** 战斗开始:收起卡牌面板 */ private onFightStart() { this.enterBattlePhase(); } /** 新一波:展开面板 → 刷新费用 UI → 重新抽卡分发 */ private onNewWave() { this.enterPreparePhase(); this.updateCoinAndCostUI(); this.layoutCardSlots(); const cards = this.buildDrawCards(); this.dispatchCardsToSlots(cards); } /** 解除按钮监听,避免节点销毁后回调泄漏 */ 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.UseHeroCard, this.onUseHeroCard, this); oops.message.off(GameEvent.UseSpecialCard, this.onUseSpecialCard, this); 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); this.cards_up?.off(NodeEventType.TOUCH_START, this.onUpgradeTouchStart, this); this.cards_up?.off(NodeEventType.TOUCH_END, this.onUpgradeTouchEnd, this); this.cards_up?.off(NodeEventType.TOUCH_CANCEL, this.onUpgradeTouchCancel, 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(); this.ensureHeroInfoPanel(eid, model); const after = this.getAliveHeroCount(); this.updateHeroNumUI(true, after > before); } /** 英雄死亡事件回调:刷新面板列表并更新英雄数量 UI */ private onHeroDead() { this.refreshHeroInfoPanels(); 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) { const heroUuid = Number(payload?.uuid ?? 0); const heroLv = Math.max(1, Math.floor(Number(payload?.hero_lv ?? 1))); const cardLv = Math.max(1, Math.floor(Number(payload?.pool_lv ?? 1))); // 检查是否可以通过合成腾出位置 if (this.canUseHeroCardByMerge(heroUuid, heroLv)) { payload.cancel = false; payload.reason = ""; return; } payload.cancel = true; payload.reason = "hero_limit"; oops.gui.toast(`英雄已满 (${current}/${heroMax})`); this.playHeroNumDeniedAnim(); } } /** * 判断新召唤的英雄是否能通过合成腾位: * 场上同 UUID 同等级数量 + 1(新卡自身)>= 合成所需数量 → 可以合成。 */ private canUseHeroCardByMerge(heroUuid: number, heroLv: number): boolean { if (!heroUuid) return false; const mergeRule = this.getMergeRule(); if (heroLv >= mergeRule.maxLv) return false; const sameCount = this.countAliveHeroesByUuidAndLv(heroUuid, heroLv); return sameCount + 1 >= mergeRule.needCount; } /** 统计场上同 UUID 同等级的存活英雄数量 */ private countAliveHeroesByUuidAndLv(heroUuid: number, heroLv: number): number { let count = 0; const actors = this.queryAliveHeroActors(); for (let i = 0; i < actors.length; i++) { const model = actors[i].model; if (!model) continue; if (model.hero_uuid !== heroUuid) continue; if (model.lv !== heroLv) continue; count += 1; } return count; } /** * 从 MissionHeroCompComp 实时读取合成规则。 * 通过 ECS 查询获取,避免硬编码与 MissionHeroComp 不一致。 * @returns { needCount: 合成所需数量, maxLv: 最大合成等级 } */ private getMergeRule(): { needCount: number, maxLv: number } { let needCount = 3; let maxLv = 2; // 兜底值改为2,与 MissionHeroComp 保持一致 ecs.query(ecs.allOf(MissionHeroCompComp)).forEach((entity: ecs.Entity) => { const comp = entity.get(MissionHeroCompComp); if (!comp) return; needCount = comp.merge_need_count === 2 ? 2 : 3; maxLv = Math.max(1, Math.floor(comp.merge_max_lv ?? 2)); }); return { needCount, maxLv }; } /** * 使用特殊卡事件回调: * - SpecialUpgrade:随机选一个指定等级的英雄升级到目标等级。 * - 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 card = SpecialUpgradeCardList[uuid]; if (!card) return; success = this.tryUpgradeOneHero(card.currentLv, card.targetLv); if (!success) oops.gui.toast(`场上没有可从${card.currentLv}级升到${card.targetLv}级的英雄`); } else if (type === CardType.SpecialRefresh) { const card = SpecialRefreshCardList[uuid]; if (!card) return; success = this.tryRefreshHeroCardsByEffect(card.refreshHeroType, card.refreshLv); if (!success) oops.gui.toast("当前卡池无符合条件的英雄卡"); } mLogger.log(this.debugMode, "MissionCardComp", "use special card", { uuid, type, success }); } // ======================== 按钮触控回调 ======================== /** 抽卡按钮按下反馈 */ private onDrawTouchStart() { this.playButtonPressAnim(this.cards_chou); } /** 抽卡按钮释放 → 执行抽卡逻辑 */ private onDrawTouchEnd() { this.playButtonClickAnim(this.cards_chou, () => this.onClickDraw()); } /** 抽卡按钮取消 → 恢复缩放 */ private onDrawTouchCancel() { this.playButtonResetAnim(this.cards_chou); } /** 升级按钮按下反馈 */ private onUpgradeTouchStart() { this.playButtonPressAnim(this.cards_up); } /** 升级按钮释放 → 执行升级逻辑 */ private onUpgradeTouchEnd() { this.playButtonClickAnim(this.cards_up, () => this.onClickUpgrade()); } /** 升级按钮取消 → 恢复缩放 */ private onUpgradeTouchCancel() { this.playButtonResetAnim(this.cards_up); } /** 将四个卡槽节点映射为 CardComp,形成固定顺序控制数组 */ private cacheCardComps() { const nodes = [this.card1, this.card2, this.card3, this.card4]; this.cardComps = nodes .map(node => node?.getComponent(CardComp)) .filter((comp): comp is CardComp => !!comp); } // ======================== 核心业务:抽卡 & 升级 ======================== /** * 抽卡按钮核心逻辑: * 1. 检查金币是否足够 → 不够则 toast 提示。 * 2. 扣除费用、播放金币动画。 * 3. 重新布局槽位 → 从卡池构建 4 张卡 → 分发到槽位。 */ private onClickDraw() { const cost = this.getRefreshCost(); const currentCoin = this.getMissionCoin(); if (currentCoin < cost) { oops.gui.toast(`金币不足,刷新需要${cost}`); this.updateCoinAndCostUI(); mLogger.log(this.debugMode, "MissionCardComp", "draw coin not enough", { currentCoin, cost }); return; } this.setMissionCoin(currentCoin - cost); this.playCoinChangeAnim(false); this.updateCoinAndCostUI(); mLogger.log(this.debugMode, "MissionCardComp", "click draw", { poolLv: this.poolLv, cost, leftCoin: this.getMissionCoin() }); this.layoutCardSlots(); const cards = this.buildDrawCards(); this.dispatchCardsToSlots(cards); } /** 升级按钮:仅提升卡池等级,卡槽是否更新由下一次抽卡触发 */ private onClickUpgrade() { if (this.poolLv >= CARD_POOL_MAX_LEVEL) { mLogger.log(this.debugMode, "MissionCardComp", "pool already max", this.poolLv); return; } const cost = this.getUpgradeCost(this.poolLv); const currentCoin = this.getMissionCoin(); if (currentCoin < cost) { oops.gui.toast(`金币不足,升级需要${cost}`); this.updateCoinAndCostUI(); mLogger.log(this.debugMode, "MissionCardComp", "pool upgrade coin not enough", { poolLv: this.poolLv, currentCoin, cost }); return; } this.setMissionCoin(currentCoin - cost); this.poolLv += 1; this.playCoinChangeAnim(false); this.updateCoinAndCostUI(); mLogger.log(this.debugMode, "MissionCardComp", "pool level up", { poolLv: this.poolLv, cost, leftCoin: this.getMissionCoin() }); } // ======================== 阶段切换 ======================== /** 缓存卡牌面板的基准缩放值(从场景初始状态读取,仅缓存一次) */ private initCardsPanelPos() { if (!this.cards_node || !this.cards_node.isValid) return; if (!this.hasCachedCardsBaseScale) { const scale = this.cards_node.scale; this.cardsBaseScale = new Vec3(scale.x, scale.y, scale.z); this.hasCachedCardsBaseScale = true; } this.cardsShowScale = new Vec3(this.cardsBaseScale.x, this.cardsBaseScale.y, this.cardsBaseScale.z); this.cardsHideScale = new Vec3(0, 0, this.cardsBaseScale.z); } /** 进入准备阶段:展开卡牌面板(立即恢复缩放,无动画) */ private enterPreparePhase() { if (!this.cards_node || !this.cards_node.isValid) return; this.initCardsPanelPos(); this.cards_node.active = true; Tween.stopAllByTarget(this.cards_node); this.cards_node.setScale(this.cardsShowScale); } private enterBattlePhase() { if (!this.cards_node || !this.cards_node.isValid) return; this.initCardsPanelPos(); this.cards_node.active = true; Tween.stopAllByTarget(this.cards_node); this.cards_node.setScale(this.cardsShowScale); tween(this.cards_node) .to(this.cardsPanelMoveDuration, { scale: this.cardsHideScale }) .start(); } /** 构建本次抽卡结果,保证最终可分发4条数据 */ private buildDrawCards(): CardConfig[] { const cards = getCardsByLv(this.poolLv); /** 正常情况下直接取前4 */ if (cards.length >= 4) return cards.slice(0, 4); /** 兜底:当返回不足4张时循环补齐,保证分发不缺位 */ const filled = [...cards]; while (filled.length < 4) { const fallback = getCardsByLv(this.poolLv); if (fallback.length === 0) break; filled.push(fallback[filled.length % fallback.length]); } return filled; } private tryRefreshHeroCards(heroType?: HType, targetPoolLv?: number): boolean { const cards = drawCardsByRule(this.poolLv, { count: 4, type: CardType.Hero, heroType, targetPoolLv }); if (cards.length <= 0) return false; this.layoutCardSlots(); this.dispatchCardsToSlots(cards.slice(0, 4)); return true; } private tryRefreshHeroCardsByEffect(refreshHeroType: SpecialRefreshHeroType, refreshLv: number): boolean { const heroType = this.resolveRefreshHeroType(refreshHeroType); const targetPoolLv = refreshLv > 0 ? refreshLv : undefined; return this.tryRefreshHeroCards(heroType, targetPoolLv); } private resolveRefreshHeroType(refreshHeroType: SpecialRefreshHeroType): HType | undefined { if (refreshHeroType === SpecialRefreshHeroType.Melee) return HType.Melee; if (refreshHeroType === SpecialRefreshHeroType.Ranged) return HType.Long; return undefined; } /** 全量分发给4槽;每个槽位是否接收由 CardComp 自己判断(锁定可跳过) */ private dispatchCardsToSlots(cards: CardConfig[]) { for (let i = 0; i < this.cardComps.length; 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 }); } } /** 系统清空4槽(用于任务切换) */ private clearAllCards() { this.cardComps.forEach(comp => comp.clearBySystem()); } private layoutCardSlots() { const count = this.cardComps.length; if (count === 0) return; const startX = -((count - 1) * this.cardWidth) / 2; for (let i = 0; i < count; i++) { const x = startX + i * this.cardWidth; this.cardComps[i].setSlotPosition(x); } mLogger.log(this.debugMode, "MissionCardComp", "layout card slots", { count, cardWidth: this.cardWidth, startX }); } 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 canUpPool() { if (this.poolLv >= CARD_POOL_MAX_LEVEL) return false; const currentCoin = this.getMissionCoin(); return currentCoin >= this.getUpgradeCost(this.poolLv); } private canDrawCards() { return this.getMissionCoin() >= this.getRefreshCost(); } /** 更新升级按钮上的等级文案,反馈当前卡池层级 */ private updatePoolLvUI() { if (this.cards_up) { const nobg = this.cards_up.getChildByName("nobg"); if (nobg) { nobg.active = !this.canUpPool(); } const coinNode = this.cards_up.getChildByName("coin"); const label = coinNode?.getChildByName("num")?.getComponent(Label); if (this.poolLv >= CARD_POOL_MAX_LEVEL) { if (label) { label.string = `0`; } } else { if (label) { label.string = `${this.getUpgradeCost(this.poolLv)}`; } } } if (this.pool_lv_node) { this.pool_lv_node.active = true; const lv = Math.max(CARD_POOL_INIT_LEVEL, Math.min(CARD_POOL_MAX_LEVEL, Math.floor(this.poolLv))); this.pool_lv_node.getComponent(Label).string = `${lv}`; const peak = 1.2 this.playHeroNumNodePop( this.pool_lv_node, peak); } mLogger.log(this.debugMode, "MissionCardComp", "pool lv ui update", { poolLv: this.poolLv, cost: this.getUpgradeCost(this.poolLv) }); } private updateDrawCostUI() { if (!this.cards_chou) return; 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 = `${this.getRefreshCost()}`; } } private updateCoinAndCostUI() { this.updatePoolLvUI(); 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); } private getUpgradeCost(lv: number): number { const baseCost = Math.max(0, Math.floor(CardsUpSet[lv] ?? 0)); const completedWave = Math.max(0, this.getCurrentWave() - 1); const discount = Math.max(0, Math.floor(CARD_POOL_UPGRADE_DISCOUNT_PER_WAVE)) * completedWave; return Math.max(0, baseCost - discount); } private getRefreshCost(): number { return Math.max(0, Math.floor(this.refreshCost)); } private ensureHeroInfoPanel(eid: number, model: HeroAttrsComp) { if (!this.hero_info_node || !this.hero_info_prefab) { mLogger.error(this.debugMode, "MissionCardComp", "ensureHeroInfoPanel: missing hero_info_node or hero_info_prefab"); return; } this.hero_info_node.active = true; const current = this.heroInfoItems.get(eid); if (current) { current.model = model; current.comp.bindData(eid, model); this.updateHeroInfoPanel(current); return; } mLogger.log(this.debugMode, "MissionCardComp", "ensureHeroInfoPanel: creating new panel for eid", eid); const node = instantiate(this.hero_info_prefab); node.parent = this.hero_info_node; node.active = true; // 尝试两种方式获取组件,并输出日志 let comp = node.getComponent(HInfoComp) as any; if (!comp) { comp = node.getComponent("HInfoComp") as any; } if (!comp) { mLogger.error(this.debugMode, "MissionCardComp", "ensureHeroInfoPanel: Failed to get HInfoComp from prefab!"); node.destroy(); return; } const item = { node, model, comp }; comp.bindData(eid, model); this.heroInfoItems.set(eid, item); this.relayoutHeroInfoPanels(); this.updateHeroInfoPanel(item); mLogger.log(this.debugMode, "MissionCardComp", `ensureHeroInfoPanel: new panel created for eid ${eid}, final position:`, item.node.position); } private refreshHeroInfoPanels() { const removeKeys: number[] = []; this.heroInfoItems.forEach((item, eid) => { if (!item.node || !item.node.isValid) { removeKeys.push(eid); return; } if (item.model?.is_dead) { if (item.node.isValid) item.node.destroy(); removeKeys.push(eid); return; } if (!item.comp.isModelAlive()) { if (item.node.isValid) item.node.destroy(); removeKeys.push(eid); return; } this.updateHeroInfoPanel(item); }); for (let i = 0; i < removeKeys.length; i++) { this.heroInfoItems.delete(removeKeys[i]); } this.relayoutHeroInfoPanels(); this.updateHeroNumUI(false, false); } private updateHeroInfoPanel(item: { node: Node, model: HeroAttrsComp, comp: HInfoComp }) { item.comp.refresh(); } private relayoutHeroInfoPanels() { const sortedItems = [...this.heroInfoItems.values()].sort((a, b) => { const aEnt = (a.model as any)?.ent as ecs.Entity | undefined; const bEnt = (b.model as any)?.ent as ecs.Entity | undefined; const aView = aEnt?.get(HeroViewComp); const bView = bEnt?.get(HeroViewComp); const aMove = aEnt?.get(MoveComp); const bMove = bEnt?.get(MoveComp); // 排序逻辑: // 1. 如果有 x 坐标,按照 x 坐标从大到小(在前面)排序 // 2. 如果没有 x 坐标,默认退化到按照生成顺序或 eid 排序 const aFrontScore = aView?.node?.position?.x ?? -999999; const bFrontScore = bView?.node?.position?.x ?? -999999; if (aFrontScore !== bFrontScore) return bFrontScore - aFrontScore; const aSpawnOrder = aMove?.spawnOrder ?? 0; const bSpawnOrder = bMove?.spawnOrder ?? 0; if (aSpawnOrder !== bSpawnOrder) return aSpawnOrder - bSpawnOrder; const aEid = aEnt?.eid ?? 0; const bEid = bEnt?.eid ?? 0; return aEid - bEid; }); for (let index = 0; index < sortedItems.length; index++) { const item = sortedItems[index]; if (!item.node || !item.node.isValid) continue; // 使用 Widget 布局的话需要禁用它或者单纯调整位置 // 这里我们使用绝对坐标进行排列,假设是从左到右 const targetX = index * (this.heroInfoItemGap + this.heroInfoItemSpacing); const pos = item.node.position; item.node.setPosition(targetX, pos.y, pos.z); item.node.setSiblingIndex(index); } } private clearHeroInfoPanels() { this.heroInfoItems.forEach(item => { if (item.node && item.node.isValid) { item.node.destroy(); } }); this.heroInfoItems.clear(); if (this.hero_info_node && this.hero_info_node.isValid) { for (let i = this.hero_info_node.children.length - 1; i >= 0; i--) { const child = this.hero_info_node.children[i]; if (child && child.isValid) child.destroy(); } } this.heroInfoSyncTimer = 0; this.syncMissionHeroData(0); this.updateHeroNumUI(false, false); } public setHeroMaxCount(max: number) { const missionData = this.getMissionData(); if (!missionData) return; const min = 5; const limit = Math.max(min, missionData.hero_extend_max_num ?? 6); 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; this.heroInfoItems.forEach(item => { if (!item?.node || !item.node.isValid) return; if (!item.comp?.isModelAlive()) return; if (item.model?.is_dead) return; count += 1; }); return count; } private queryAliveHeroActors(): Array<{ model: HeroAttrsComp, view: HeroViewComp | null }> { const actors: Array<{ 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({ model, view }); }); return actors; } private tryUpgradeOneHero(currentLv: number, targetLv: number): boolean { const fromLv = Math.max(1, Math.floor(currentLv)); const toLv = Math.max(1, Math.floor(targetLv)); if (toLv <= fromLv) return false; const candidates = this.queryAliveHeroActors().filter(item => item.model.lv === fromLv); if (candidates.length === 0) return false; const target = candidates[Math.floor(Math.random() * candidates.length)]; this.applyHeroLevel(target.model, toLv); if (target.view) { target.view.playBuff("buff_lvup"); } return true; } private applyHeroLevel(model: HeroAttrsComp, targetLv: number) { const hero = HeroInfo[model.hero_uuid]; if (!hero) return; const nextLv = Math.max(1, Math.min(3, Math.floor(targetLv))); const hpRate = model.hp_max > 0 ? model.hp / model.hp_max : 1; model.lv = nextLv; model.ap = hero.ap * nextLv; model.hp_max = hero.hp * nextLv; model.hp = Math.max(1, Math.floor(model.hp_max * Math.max(0, Math.min(1, hpRate)))); model.skills = {}; for (const key in hero.skills) { const skill = hero.skills[key]; if (!skill) continue; model.skills[skill.uuid] = { ...skill, lv: Math.max(0, skill.lv + nextLv - 2), ccd: 0 }; } model.updateSkillDistanceCache(); model.dirty_hp = true; oops.message.dispatchEvent(GameEvent.HeroLvUp, { uuid: model.hero_uuid, lv: nextLv }); } private updateHeroNumUI(animate: boolean, isIncrease: boolean) { this.syncMissionHeroData(); if (!animate || !isIncrease) return; this.playHeroNumGainAnim(); } private playHeroNumGainAnim() { if (!this.hero_num_node || !this.hero_num_node.isValid) return; const iconNode = this.hero_num_node.getChildByName("icon"); const numNode = this.hero_num_node.getChildByName("num"); this.playHeroNumNodePop(iconNode, 1.2); this.playHeroNumNodePop(numNode, 1.2); } private playHeroNumDeniedAnim() { if (!this.hero_num_node || !this.hero_num_node.isValid) return; const iconNode = this.hero_num_node.getChildByName("icon"); const numNode = this.hero_num_node.getChildByName("num"); this.playHeroNumNodePop(iconNode, 1.2); this.playHeroNumNodePop(numNode, 1.2); } private playHeroNumNodePop(node: Node | null, scalePeak: number) { this.playNodeScalePop(node, scalePeak, 0.08, 0.1); } private playNodeScaleTo(node: Node | null, scale: number, duration: number) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); tween(node) .to(duration, { scale: new Vec3(scale, scale, 1) }) .start(); } private playNodeScalePop(node: Node | null, scalePeak: number, toPeakDuration: number, toNormalDuration: number, onPeak?: () => void) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); node.setScale(1, 1, 1); const seq = tween(node) .to(toPeakDuration, { scale: new Vec3(scalePeak, scalePeak, 1) }); if (onPeak) { seq.call(onPeak); } seq.to(toNormalDuration, { scale: new Vec3(1, 1, 1) }) .start(); } private getMissionData(): any { return smc?.vmdata?.mission_data ?? null; } private getMissionHeroNum(): number { const missionData = this.getMissionData(); return Math.max(0, Math.floor(missionData?.hero_num ?? 0)); } private getMissionCoin(): number { const missionData = this.getMissionData(); return Math.max(0, Math.floor(missionData?.coin ?? 0)); } private getCurrentWave(): number { const missionData = this.getMissionData(); return Math.max(1, Math.floor(missionData?.level ?? 1)); } private setMissionCoin(value: number) { const missionData = this.getMissionData(); if (!missionData) return; missionData.coin = Math.max(0, Math.floor(value)); } private getMissionHeroMaxNum(): number { const missionData = this.getMissionData(); return Math.max(5, Math.floor(missionData?.hero_max_num ?? 5)); } 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.clearHeroInfoPanels(); this.resetButtonScale(this.cards_chou); this.resetButtonScale(this.cards_up); this.node.destroy(); } }