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, CardConfig, CardsUpSet, getCardsByLv } 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"; const { ccclass, property } = _decorator; /** 视图层对象 */ @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; /** 四个插卡槽位(固定顺序分发:1~4) */ @property(Node) cards_node:Node = null! @property({ tooltip: "战斗阶段卡牌面板下移隐藏距离" }) cardsBattleHideOffsetY: number = 1280; @property({ tooltip: "卡牌面板位移动画时长" }) cardsPanelMoveDuration: number = 0.2; @property(Node) card1:Node = null! @property(Node) card2:Node = null! @property(Node) card3:Node = null! @property(Node) card4:Node = null! @property(Node) cards_chou:Node = null! @property(Node) cards_up:Node = null! @property(Node) coins_node:Node = null! @property(Node) pool_lv_node:Node = null! @property(Node) hero_info_node:Node = null! //场上英雄信息面板所在节点 @property(Prefab) hero_info_prefab:Prefab=null! //场上英雄信息面板Prefab @property(Node) hero_num_node:Node=null! /** 预留图集缓存(后续接入按钮/卡面图标时复用) */ private uiconsAtlas: SpriteAtlas | null = null; /** 四个槽位对应的单卡控制器缓存 */ private cardComps: CardComp[] = []; /** 当前卡池等级(仅影响抽卡来源,不直接改卡槽现有内容) */ private poolLv: number = CARD_POOL_INIT_LEVEL; private readonly heroInfoItemGap: number = 86; private heroInfoSyncTimer: number = 0; private cardsShowPos: Vec3 = new Vec3(); private cardsHidePos: Vec3 = new Vec3(); private heroInfoItems: Map = new Map(); onLoad() { /** 绑定事件 -> 缓存子控制器 -> 初始化UI状态 */ 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(); } init(){ this.onMissionStart(); } /** 任务开始时:重置卡池等级、清空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.updatePoolLvUI(); 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() { } update(dt: number) { this.heroInfoSyncTimer += dt; if (this.heroInfoSyncTimer < 0.15) return; this.heroInfoSyncTimer = 0; this.refreshHeroInfoPanels(); } /** 关闭面板(不销毁数据模型,仅隐藏) */ close() { this.node.active = false; } /** 只处理UI层事件,不做卡牌效果分发 */ 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(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); /** 按钮事件:抽卡与卡池升级 */ 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); } private onCoinAdd(args:any){ if (args?.syncOnly) { this.updatePoolLvUI(); this.playCoinChangeAnim((args?.delta ?? 0) > 0); return; } const v = typeof args === 'number' ? args : (args?.delta ?? args?.value ?? 0); if (v === 0) return; this.setMissionCoin(this.getMissionCoin() + v); this.updatePoolLvUI(); this.playCoinChangeAnim(v > 0); } private onFightStart() { this.enterBattlePhase(); } /** 解除按钮监听,避免节点销毁后回调泄漏 */ private unbindEvents() { oops.message.off(GameEvent.MasterCalled, this.onMasterCalled, this); oops.message.off(GameEvent.HeroDead, this.onHeroDead, this); oops.message.off(GameEvent.UseHeroCard, this.onUseHeroCard, 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); } private onMasterCalled(event: string, args: any) { const payload = args ?? event; const eid = Number(payload?.eid ?? 0); const model = payload?.model as HeroAttrsComp | undefined; if (!eid || !model) return; const before = this.getAliveHeroCount(); this.ensureHeroInfoPanel(eid, model); const after = this.getAliveHeroCount(); this.updateHeroNumUI(true, after > before); } private onHeroDead() { this.refreshHeroInfoPanels(); this.updateHeroNumUI(true, false); } 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"; oops.gui.toast(`英雄已满 (${current}/${heroMax})`); this.playHeroNumDeniedAnim(); } } 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); } /** 抽卡按钮:每次固定抽4张,然后顺序分发给4个单卡脚本 */ private onClickDraw() { mLogger.log(this.debugMode, "MissionCardComp", "click draw", { poolLv: this.poolLv }); 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.updatePoolLvUI(); 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.updatePoolLvUI(); 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; const pos = this.cards_node.position; const parentScaleY = Math.max(0.001, Math.abs(this.cards_node.parent?.scale?.y ?? 1)); const localOffsetY = Math.abs(this.cardsBattleHideOffsetY) / parentScaleY; this.cardsShowPos = new Vec3(pos.x, pos.y, pos.z); this.cardsHidePos = new Vec3(pos.x, pos.y - localOffsetY, pos.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.setPosition(this.cardsShowPos); } private enterBattlePhase() { if (!this.cards_node || !this.cards_node.isValid) return; this.initCardsPanelPos(); this.cards_node.active = true; Tween.stopAllByTarget(this.cards_node); tween(this.cards_node) .to(this.cardsPanelMoveDuration, { position: this.cardsHidePos }) .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; } /** 全量分发给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) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); tween(node) .to(0.06, { scale: new Vec3(this.buttonPressScale, this.buttonPressScale, 1) }) .start(); } private playButtonClickAnim(node: Node | null, onComplete: () => void) { if (!node || !node.isValid) { onComplete(); return; } Tween.stopAllByTarget(node); tween(node) .to(0.05, { scale: new Vec3(this.buttonClickScale, this.buttonClickScale, 1) }) .call(onComplete) .to(0.08, { scale: new Vec3(this.buttonNormalScale, this.buttonNormalScale, 1) }) .start(); } private playButtonResetAnim(node: Node | null) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); tween(node) .to(0.08, { scale: new Vec3(this.buttonNormalScale, this.buttonNormalScale, 1) }) .start(); } 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 updatePoolLvUI() { if (!this.cards_up) return; const nobg = this.cards_up.getChildByName("nobg"); if (nobg) { nobg.active = !this.canUpPool(); } const label = this.cards_up.getChildByName("coin").getChildByName("num").getComponent(Label); if (!label) return; if (this.poolLv >= CARD_POOL_MAX_LEVEL) { label.string = `0`; } else { label.string = `${this.getUpgradeCost(this.poolLv)}`; } if (this.pool_lv_node) { for (let i = 1; i <= CARD_POOL_MAX_LEVEL; i++) { const n = this.pool_lv_node.getChildByName(`lv${i}`); if (n) n.active = i === this.poolLv; } } mLogger.log(this.debugMode, "MissionCardComp", "pool lv ui update", { poolLv: this.poolLv, cost: this.getUpgradeCost(this.poolLv) }); } private playCoinChangeAnim(isIncrease: boolean) { if (!this.coins_node || !this.coins_node.isValid) return; const target = this.coins_node.getChildByName("num") || this.coins_node; if (!target || !target.isValid) return; const peak = isIncrease ? 1.16 : 1.08; Tween.stopAllByTarget(target); target.setScale(1, 1, 1); tween(target) .to(0.08, { scale: new Vec3(peak, peak, 1) }) .to(0.1, { scale: new Vec3(1, 1, 1) }) .start(); } private getUpgradeCost(lv: number): number { return CardsUpSet[lv] ?? 0; } private ensureHeroInfoPanel(eid: number, model: HeroAttrsComp) { if (!this.hero_info_node || !this.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; } const node = instantiate(this.hero_info_prefab); node.parent = this.hero_info_node; node.active = true; const comp = node.getComponent(HInfoComp); if (!comp) { node.destroy(); return; } const item = { node, model, comp }; comp.bindData(eid, model); this.heroInfoItems.set(eid, item); this.relayoutHeroInfoPanels(); this.updateHeroInfoPanel(item); } 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]); } if (removeKeys.length > 0) { this.relayoutHeroInfoPanels(); } this.updateHeroNumUI(false, false); } private updateHeroInfoPanel(item: { node: Node, model: HeroAttrsComp, comp: HInfoComp }) { item.comp.refresh(); } private relayoutHeroInfoPanels() { let index = 0; this.heroInfoItems.forEach(item => { if (!item.node || !item.node.isValid) return; const pos = item.node.position; item.node.setPosition(pos.x, -index * this.heroInfoItemGap, pos.z); index += 1; }); } 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 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.16); this.playHeroNumNodePop(numNode, 1.16); } 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.1); this.playHeroNumNodePop(numNode, 1.1); } private playHeroNumNodePop(node: Node | null, scalePeak: number) { if (!node || !node.isValid) return; Tween.stopAllByTarget(node); node.setScale(1, 1, 1); tween(node) .to(0.08, { scale: new Vec3(scalePeak, scalePeak, 1) }) .to(0.1, { 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 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(); } }