/** * @file CardComp.ts * @description 单张卡牌槽位组件(UI 视图层) * * 职责: * 1. 管理单个卡牌槽位的 **显示** 和 **交互**(触摸拖拽 / 点击使用 / 锁定切换)。 * 2. 接收来自 MissionCardComp 分发的 CardConfig 数据,渲染对应卡面(英雄 / 技能 / 特殊卡)。 * 3. 在玩家触发"使用卡牌"操作时,执行 **费用扣除 → 动画表现 → 效果分发** 的完整流程。 * * 关键设计: * - 槽位可 **锁定**:锁定后刷新卡池不会覆盖旧卡,由 `isLocked` 控制。 * - 卡牌使用支持 **上划手势** 与 **点击** 两种触发方式。 * - 英雄卡图标使用 AnimationClip 动态加载 idle 动画;技能 / 特殊卡使用 SpriteAtlas 静态图标。 * - 通过 `iconVisualToken` 防止异步加载资源回调与当前显示不一致(竞态保护)。 * * 依赖: * - CardConfig / CardType / CKind 等卡牌数据结构(CardSet) * - HeroInfo(heroSet)、SkillSet(SkillSet)—— 用于渲染卡面信息 * - GameEvent 事件总线 —— 分发 CallHero / UseSkillCard / UseSpecialCard 等效果 * - smc.vmdata.mission_data —— 读写局内金币 */ import { mLogger } from "../common/Logger"; import { _decorator, Animation, AnimationClip, EventTouch, Label, Node, NodeEventType, Sprite, SpriteAtlas, Tween, tween, UIOpacity, Vec3, resources, Light } 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 { CardConfig, CardType, SpecialRefreshCardList, SpecialUpgradeCardList, CKind, CardPoolList } from "../common/config/CardSet"; import { HeroInfo } from "../common/config/heroSet"; import { SkillSet } from "../common/config/SkillSet"; import { GameEvent } from "../common/config/GameEvent"; import { oops } from "db://oops-framework/core/Oops"; import { smc } from "../common/SingletonModuleComp"; import { UIID } from "../common/config/GameUIConfig"; const { ccclass, property } = _decorator; /** * CardComp —— 单张卡牌槽位视图组件 * * 挂载在每个卡牌槽位节点上,由 MissionCardComp 统一管理 4 个实例。 * 负责单卡的渲染、交互、动画以及使用逻辑。 */ @ccclass('CardComp') @ecs.register('CardComp', false) export class CardComp extends CCComp { /** 是否开启调试日志 */ private debugMode: boolean = true; // ======================== 编辑器绑定节点 ======================== /** 锁定态图标节点(显示时表示本槽位锁定) */ @property(Node) Lock: Node = null! /** 解锁态图标节点(显示时表示本槽位未锁定,可点击上锁) */ @property(Node) unLock: Node = null! /** 英雄卡信息面板(显示 AP / HP) */ @property(Node) info_node=null! /** 非英雄卡信息面板(显示技能 / 特殊卡描述文本) */ @property(Node) oinfo_node=null! /** 卡牌名称标签节点 */ @property(Node) name_node=null! /** 卡牌图标节点(英雄动画 / 技能图标) */ @property(Node) icon_node=null! /** 费用显示节点 */ @property(Node) cost_node=null! /** 卡牌种类标识节点(如近战 / 远程 / 辅助等分类子节点的容器) */ @property(Node) Ckind_node=null! /** 卡牌背景底框节点(按卡池等级切换子节点显示) */ @property(Node) BG_node=null! /** 普通品质边框节点(hero_lv / card_lv <= 1 时使用) */ @property(Node) NF_node=null! /** 高品质边框节点(hero_lv / card_lv > 1 时使用) */ @property(Node) HF_node=null! // ======================== 运行时状态 ======================== /** 当前卡牌的金币费用 */ card_cost:number=0 /** 当前卡牌类型(英雄 / 技能 / 特殊升级 / 特殊刷新) */ card_type:CardType=CardType.Hero /** 当前卡牌的唯一标识 UUID */ card_uuid:number=0 /** 是否处于锁定状态(锁定且有卡时,抽卡分发会被跳过) */ private isLocked: boolean = false; /** 图标图集缓存(首次加载后复用,避免重复 IO) */ private uiconsAtlas: SpriteAtlas | null = null; /** 当前槽位承载的卡牌数据,null 表示空槽 */ private cardData: CardConfig | null = null; /** 上划使用阈值(像素):拖拽距离 >= 此值视为"使用卡牌" */ private readonly dragUseThreshold: number = 70; /** 触摸起始 Y 坐标,用于计算拖拽距离 */ private touchStartY: number = 0; /** 当前是否正在拖拽 */ private isDragging: boolean = false; /** 当前是否正在执行"使用"流程(防止重复触发) */ private isUsing: boolean = false; /** 卡牌的静止位置(未拖拽时应在此位置) */ private restPosition: Vec3 = new Vec3(); /** 是否已缓存基准 Y/Z 坐标(首次 setSlotPosition 时确定) */ private hasFixedBasePosition: boolean = false; /** 基准 Y 坐标(由场景布局决定) */ private fixedBaseY: number = 0; /** 基准 Z 坐标 */ private fixedBaseZ: number = 0; /** UIOpacity 组件引用,用于使用消失动画中的渐隐 */ private opacityComp: UIOpacity | null = null; /** * 图标视觉令牌:每次更新图标时 +1, * 异步加载回调通过比对 token 判断是否仍为当前可见内容, * 防止快速切卡时旧回调错误覆盖新图标。 */ private iconVisualToken: number = 0; // ======================== 生命周期 ======================== /** * 组件加载时:绑定交互事件,初始化基础 UI 状态。 * 此阶段不触发业务逻辑。 */ onLoad() { /** 初始阶段只做UI状态准备,不触发业务逻辑 */ this.bindEvents(); this.restPosition = this.node.position.clone(); this.opacityComp = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity); this.opacityComp.opacity = 255; this.updateLockUI(); this.applyEmptyUI(); } /** 组件销毁时解绑所有事件,防止残留回调 */ onDestroy() { this.unbindEvents(); } /** 外部初始化入口(由 MissionCardComp 调用) */ init(){ this.onMissionStart(); } /** 游戏开始初始化(预留扩展) */ onMissionStart() { } /** 游戏结束清理(预留扩展) */ onMissionEnd() { } /** 节点启动时确保可见 */ start() { /** 单卡节点常驻,由数据控制显示内容 */ this.node.active = true; } // ======================== 对外接口 ======================== /** * 兼容旧接口:外部通过该入口更新卡牌 * @param card 卡牌节点引用(历史遗留参数,当前未使用) * @param data 卡牌配置数据 */ updateCardInfo(card:Node, data: CardConfig){ this.applyDrawCard(data); } /** * 更新卡牌图标:先尝试从缓存图集获取,未缓存则异步加载图集后获取。 * @param node 图标所在节点 * @param iconId 图标在 SpriteAtlas 中的帧名称 */ private updateIcon(node: Node, iconId: string) { if (!node || !iconId) return; const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); if (!sprite) return; // 已缓存图集 → 直接获取帧 if (this.uiconsAtlas) { const frame = this.uiconsAtlas.getSpriteFrame(iconId); if (frame) { sprite.spriteFrame = frame; } else { sprite.spriteFrame = null; } return; } // 首次加载图集 resources.load("gui/uicons", SpriteAtlas, (err, atlas) => { if (err || !atlas) { mLogger.log(this.debugMode, "CardComp", "load uicons atlas failed", err); return; } this.uiconsAtlas = atlas; const frame = atlas.getSpriteFrame(iconId); if (frame) { sprite.spriteFrame = frame; } else { sprite.spriteFrame = null; } }); } /** * 兼容旧接口:按索引更新卡牌(当前由 MissionCardComp 顺序分发) * @param index 槽位索引(0~3) * @param data 卡牌配置数据 */ updateCardData(index: number, data: CardConfig) { this.applyDrawCard(data); } /** * 兼容按钮回调入口:触发单卡使用 * @param e 事件对象 * @param index 索引字符串(历史遗留参数) */ selectCard(e: any, index: string) { this.useCard(); } /** 关闭界面(预留) */ close() { } // ======================== 核心业务方法 ======================== /** * 抽卡分发入口:由 MissionCardComp 调用以向本槽位放入新卡。 * * 流程: * 1. 若本槽位已锁定且已有卡 → 跳过,返回 false。 * 2. 更新 cardData 及派生字段 → 刷新 UI → 播放入场动画。 * * @param data 要放入的卡牌数据,null 表示无卡可放 * @returns true = 成功接收;false = 跳过(锁定 / 无数据) */ applyDrawCard(data: CardConfig | null): boolean { if (!data) return false; /** 锁定且已有旧卡时,跳过本次刷新,保持老卡 */ if (this.isLocked && this.cardData) { mLogger.log(this.debugMode, "CardComp", "slot locked, skip update", this.card_uuid); return false; } this.cardData = data; this.card_uuid = data.uuid; this.card_type = data.type; this.card_cost = data.cost; this.node.active = true; this.applyCardUI(); this.playRefreshAnim(); mLogger.log(this.debugMode, "CardComp", "card updated", { uuid: this.card_uuid, type: this.card_type, cost: this.card_cost }); return true; } /** * 使用当前卡牌的核心逻辑。 * * 完整流程: * 1. 前置校验:是否有卡、是否正在使用中。 * 2. 金币检查:不够则 toast 提示 + 回弹动画。 * 3. 英雄卡额外校验:通过 GameEvent.UseHeroCard 事件向 MissionCardComp 询问 * 英雄上限是否允许(guard 模式:外部可设 cancel=true 阻止使用)。 * 4. 扣除金币,同步 CoinAdd 事件。 * 5. 播放消失动画 → 动画结束后清空槽位并分发卡牌效果。 * * @returns 被使用的卡牌数据,若未成功使用则返回 null */ useCard(): CardConfig | null { if (!this.cardData || this.isUsing) return null; const cardCost = Math.max(0, Math.floor(this.cardData.cost ?? 0)); const currentCoin = this.getMissionCoin(); // 金币不足 → 提示并回弹 if (currentCoin < cardCost) { oops.gui.toast(`金币不足,召唤需要${cardCost}`); this.playReboundAnim(); mLogger.log(this.debugMode, "CardComp", "use card coin not enough", { uuid: this.cardData.uuid, type: this.cardData.type, cardCost, currentCoin }); return null; } // 英雄卡特殊校验:通过 guard 对象实现"可取消"模式 if (this.cardData.type === CardType.Hero) { const guard = { cancel: false, reason: "", uuid: this.cardData.uuid, hero_lv: this.cardData.hero_lv ?? 1, card_lv: this.cardData.pool_lv ?? 1 }; oops.message.dispatchEvent(GameEvent.UseHeroCard, guard); if (guard.cancel) { this.playReboundAnim(); return null; } } // 扣除金币 this.setMissionCoin(currentCoin - cardCost); oops.message.dispatchEvent(GameEvent.CoinAdd, { syncOnly: true, delta: -cardCost }); // 标记使用中,阻止并发操作 this.isUsing = true; const used = this.cardData; mLogger.log(this.debugMode, "CardComp", "use card", { uuid: used.uuid, type: used.type, cost: cardCost, leftCoin: this.getMissionCoin() }); // 播放消失动画 → 动画结束后清槽并分发效果 this.playUseDisappearAnim(() => { this.clearAfterUse(); this.isUsing = false; this.executeCardEffectEntry(used); }); return used; } /** * 根据卡牌类型分发对应的游戏效果事件。 * - 英雄卡 → CallHero * - 技能卡 → UseSkillCard * - 特殊升级 / 特殊刷新 → UseSpecialCard * * @param payload 被使用的卡牌数据 */ private executeCardEffectEntry(payload: CardConfig) { switch (payload.type) { case CardType.Hero: oops.message.dispatchEvent(GameEvent.CallHero, payload); break; case CardType.Skill: oops.message.dispatchEvent(GameEvent.UseSkillCard, payload); break; case CardType.SpecialUpgrade: case CardType.SpecialRefresh: oops.message.dispatchEvent(GameEvent.UseSpecialCard, payload); break; } } /** 查询槽位是否有卡 */ hasCard(): boolean { return !!this.cardData; } /** * 外部设置锁定态 * @param value true=锁定(刷新时保留旧卡),false=解锁 */ setLocked(value: boolean) { this.isLocked = value; this.updateLockUI(); } /** 外部读取当前锁定态 */ isSlotLocked(): boolean { return this.isLocked; } /** * 设置槽位的水平位置(由 MissionCardComp 根据槽位数量计算布局后调用)。 * 首次调用时会记录基准 Y/Z,后续只更新 X。 * @param x 目标水平坐标 */ setSlotPosition(x: number) { const current = this.node.position; if (!this.hasFixedBasePosition) { this.fixedBaseY = current.y; this.fixedBaseZ = current.z; this.hasFixedBasePosition = true; } this.restPosition = new Vec3(x, this.fixedBaseY, this.fixedBaseZ); // 拖拽/使用中不立即移动,等状态结束后归位 if (!this.isDragging && !this.isUsing) { this.node.setPosition(this.restPosition); } } /** * 系统清槽:用于任务开始/结束等强制重置场景。 * 停止所有动画 → 重置全部状态 → 清空显示 → 隐藏节点。 */ clearBySystem() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); this.opacityComp.opacity = 255; } this.cardData = null; this.card_uuid = 0; this.card_cost = 0; this.card_type = CardType.Hero; this.isLocked = false; this.isDragging = false; this.isUsing = false; this.node.setPosition(this.restPosition); this.node.setScale(new Vec3(1, 1, 1)); this.updateLockUI(); this.applyEmptyUI(); this.node.active = false; } // ======================== 内部清理 ======================== /** * 卡牌被玩家使用后的清槽行为。 * 与 clearBySystem 类似,但不重置锁定态。 */ private clearAfterUse() { Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); this.opacityComp.opacity = 255; } this.cardData = null; this.card_uuid = 0; this.card_cost = 0; this.card_type = CardType.Hero; this.isLocked = false; this.isDragging = false; this.node.setPosition(this.restPosition); this.node.setScale(new Vec3(1, 1, 1)); this.updateLockUI(); this.applyEmptyUI(); this.node.active = false; } // ======================== 事件绑定 ======================== /** 绑定触控事件:卡面点击/ 拖拽使用,锁按钮点击切换锁定 */ private bindEvents() { this.node.on(NodeEventType.TOUCH_START, this.onCardTouchStart, this); this.node.on(NodeEventType.TOUCH_MOVE, this.onCardTouchMove, this); this.node.on(NodeEventType.TOUCH_END, this.onCardTouchEnd, this); this.node.on(NodeEventType.TOUCH_CANCEL, this.onCardTouchCancel, this); this.Lock?.on(NodeEventType.TOUCH_END, this.onToggleLock, this); this.unLock?.on(NodeEventType.TOUCH_END, this.onToggleLock, this); } /** 解绑触控,防止节点销毁后残留回调 */ private unbindEvents() { this.node.off(NodeEventType.TOUCH_START, this.onCardTouchStart, this); this.node.off(NodeEventType.TOUCH_MOVE, this.onCardTouchMove, this); this.node.off(NodeEventType.TOUCH_END, this.onCardTouchEnd, this); this.node.off(NodeEventType.TOUCH_CANCEL, this.onCardTouchCancel, this); this.Lock?.off(NodeEventType.TOUCH_END, this.onToggleLock, this); this.unLock?.off(NodeEventType.TOUCH_END, this.onToggleLock, this); } // ======================== 触摸交互 ======================== /** 触摸开始:记录起点 Y,进入拖拽状态 */ private onCardTouchStart(event: EventTouch) { if (!this.cardData || this.isUsing) return; this.touchStartY = event.getUILocation().y; this.isDragging = true; } /** * 触摸移动:跟随手指向上偏移卡牌(仅允许向上拖拽,deltaY < 0 被 clamp 为 0) */ private onCardTouchMove(event: EventTouch) { if (!this.isDragging || !this.cardData || this.isUsing) return; const currentY = event.getUILocation().y; const deltaY = Math.max(0, currentY - this.touchStartY); this.node.setPosition(this.restPosition.x, this.restPosition.y + deltaY, this.restPosition.z); } /** * 触摸结束: * - 上拉距离 >= dragUseThreshold → 视为"使用卡牌" * - 否则视为"点击",打开英雄信息弹窗(仅英雄卡)并回弹 */ private onCardTouchEnd(event: EventTouch) { if (!this.isDragging || !this.cardData || this.isUsing) return; const endY = event.getUILocation().y; const deltaY = endY - this.touchStartY; this.isDragging = false; if (deltaY >= this.dragUseThreshold) { this.useCard(); return; } this.openHeroInfoIBox(); this.playReboundAnim(); } /** 触摸取消:回弹至原位 */ private onCardTouchCancel() { if (!this.isDragging || this.isUsing) return; this.isDragging = false; this.playReboundAnim(); } /** * 点击锁控件:切换锁态(空槽不允许锁定)。 * 阻止事件冒泡,避免触发卡面的点击使用。 */ private onToggleLock(event?: EventTouch) { if (!this.cardData) return; this.isLocked = !this.isLocked; this.updateLockUI(); mLogger.log(this.debugMode, "CardComp", "toggle lock", { uuid: this.card_uuid, locked: this.isLocked }); const stopPropagation = (event as any)?.stopPropagation; if (typeof stopPropagation === "function") { stopPropagation.call(event); } } // ======================== UI 渲染 ======================== /** * 根据锁态刷新 Lock / unLock 节点显示。 * 当前功能已注释(锁定 UI 暂未启用),保留接口以备后续启用。 */ private updateLockUI() { // if (this.Lock) this.Lock.active = !this.isLocked; // if (this.unLock) this.unLock.active = this.isLocked; } /** * 根据当前 cardData 渲染卡面文字与图标。 * * 渲染逻辑根据卡牌类型分三路: * 1. 英雄卡:显示英雄名(带星级后缀)、AP / HP 数值、idle 动画。 * 2. 技能卡:显示技能名(带品质后缀)、描述文本、静态图标。 * 3. 特殊卡:显示卡名(带品质后缀)、描述文本、静态图标。 * * 同时根据 pool_lv 切换背景底框,根据 hero_lv / card_lv 切换普通/高级边框。 */ private applyCardUI() { if (!this.cardData) { this.applyEmptyUI(); return; } // 递增视觉令牌,用于异步加载竞态保护 this.iconVisualToken += 1; if (this.opacityComp) this.opacityComp.opacity = 255; this.node.setPosition(this.restPosition); // ---- 卡牌种类标识(近战 / 远程 / 辅助等) ---- if (this.Ckind_node) { const kindName = CKind[this.cardData.kind]; this.Ckind_node.children.forEach(child => { child.active = (child.name === kindName); }); } // ---- 背景底框(按卡池等级显示对应子节点) ---- const cardLvStr = `lv${this.cardData.pool_lv}`; if (this.BG_node) { this.BG_node.children.forEach(child => { child.active = (child.name === cardLvStr); }); } // ---- 品质边框(高级 vs 普通) ---- const card_lv_val = this.cardData.card_lv ?? 1; const isHighLevel = (this.cardData.hero_lv ?? 0) > 1 || card_lv_val > 1; if (this.HF_node) this.HF_node.active = isHighLevel; if (this.NF_node) this.NF_node.active = !isHighLevel; this.node.getChildByName("HB").active = isHighLevel; const activeFrameNode = isHighLevel ? this.HF_node : this.NF_node; if (activeFrameNode) { activeFrameNode.children.forEach(child => { child.active = (child.name === cardLvStr); }); if(isHighLevel){activeFrameNode.getChildByName("light").active=true} } // ---- 按卡牌类型渲染具体内容 ---- if(this.card_type===CardType.Hero){ // 英雄卡:显示英雄名 + 星级 + AP/HP const hero = HeroInfo[this.card_uuid]; const heroLv = Math.max(1, Math.floor(this.cardData.hero_lv ?? hero?.lv ?? 1)); const suffix = heroLv >= 2 ? "★".repeat(heroLv - 1) : ""; this.setLabel(this.name_node, `${suffix}${hero?.name || ""}${suffix}`); this.info_node.active = true; this.oinfo_node.active = false; this.info_node.getChildByName("ap").getChildByName("val").getComponent(Label).string = `${(hero?.ap ?? 0) * heroLv}`; this.info_node.getChildByName("hp").getChildByName("val").getComponent(Label).string = `${(hero?.hp ?? 0) * heroLv}`; }else if(this.card_type===CardType.Skill){ // 技能卡:显示技能名 + 品质后缀 + 描述 const skill = SkillSet[this.card_uuid]; const skillCard = CardPoolList.find(c => c.uuid === this.card_uuid); const card_lv = Math.max(1, Math.floor(this.cardData.card_lv ?? 1)); const spSuffix = card_lv >= 2 ? "★".repeat(card_lv - 1) : ""; this.setLabel(this.name_node, `${spSuffix}${skillCard?.name || skill?.name || ""}${spSuffix}`); this.info_node.active = false; this.oinfo_node.active = true; this.oinfo_node.getChildByName("info").getComponent(Label).string = `${skillCard?.info || skill?.info || ""}`; }else{ // 特殊卡(升级 / 刷新):显示卡名 + 品质后缀 + 描述 const specialCard = this.card_type === CardType.SpecialUpgrade ? SpecialUpgradeCardList[this.card_uuid] : SpecialRefreshCardList[this.card_uuid]; const card_lv = Math.max(1, Math.floor(this.cardData.card_lv ?? 1)); const spSuffix = card_lv >= 2 ? "★".repeat(card_lv - 1) : ""; this.setLabel(this.name_node, `${spSuffix}${specialCard?.name || ""}${spSuffix}`); this.info_node.active = false; this.oinfo_node.active = true; this.oinfo_node.getChildByName("info").getComponent(Label).string = `${specialCard?.info || ""}`; } // ---- 费用标签 ---- this.setLabel(this.cost_node, `${this.card_cost}`); // ---- 图标 ---- const iconNode = this.icon_node as Node; if (this.card_type === CardType.Hero) { // 英雄卡使用 AnimationClip,加载 idle 动画 this.updateHeroAnimation(iconNode, this.card_uuid, this.iconVisualToken); return; } // 非英雄卡使用静态图标 this.clearIconAnimation(iconNode); const iconId = this.resolveCardIconId(this.card_type, this.card_uuid); if (iconId) { this.updateIcon(iconNode, iconId); } else { const sprite = iconNode?.getComponent(Sprite) || iconNode?.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; } } // ======================== 动画表现 ======================== /** 卡牌入场动画:先缩小再弹大再回归正常比例 */ private playRefreshAnim() { Tween.stopAllByTarget(this.node); this.node.setPosition(this.restPosition); this.node.setScale(new Vec3(0.92, 0.92, 1)); tween(this.node) .to(0.08, { scale: new Vec3(1.06, 1.06, 1) }) .to(0.1, { scale: new Vec3(1, 1, 1) }) .start(); } /** 回弹动画:从当前位置平滑回到静止位并恢复缩放 */ private playReboundAnim() { Tween.stopAllByTarget(this.node); tween(this.node) .to(0.12, { position: this.restPosition, scale: new Vec3(1, 1, 1) }) .start(); } /** * 使用消失动画: * - 节点向上移动 120px 并缩小至 0.8 * - 同时 UIOpacity 渐隐至 0 * - 动画完成后调用 onComplete 回调 * * @param onComplete 动画完成后的回调 */ private playUseDisappearAnim(onComplete: () => void) { const targetPos = new Vec3(this.restPosition.x, this.restPosition.y + 120, this.restPosition.z); Tween.stopAllByTarget(this.node); if (this.opacityComp) { Tween.stopAllByTarget(this.opacityComp); this.opacityComp.opacity = 255; tween(this.opacityComp) .to(0.18, { opacity: 0 }) .start(); } tween(this.node) .to(0.18, { position: targetPos, scale: new Vec3(0.8, 0.8, 1) }) .call(onComplete) .start(); } /** * 渲染空槽状态: * 清空名称、费用、信息面板、种类标识、背景底框、边框、图标。 */ private applyEmptyUI() { this.iconVisualToken += 1; this.setLabel(this.name_node, ""); this.setLabel(this.cost_node, ""); if (this.info_node) this.info_node.active = false; if (this.oinfo_node) this.oinfo_node.active = false; if (this.Ckind_node) { this.Ckind_node.children.forEach(child => { child.active = false; }); } if (this.BG_node) { this.BG_node.children.forEach(child => child.active = false); } if (this.HF_node) this.HF_node.active = false; if (this.NF_node) this.NF_node.active = false; this.clearIconAnimation(this.icon_node as Node); const sprite = this.icon_node?.getComponent(Sprite) || this.icon_node?.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; } // ======================== 工具方法 ======================== /** * 安全设置文本,兼容节点上或子节点上的 Label * @param node 标签所在节点 * @param value 要设置的文本 */ private setLabel(node: Node | null, value: string) { if (!node) return; const label = node.getComponent(Label) || node.getComponentInChildren(Label); if (label) label.string = value; } /** * 根据卡牌类型和 UUID 解析出图标 ID(在 SpriteAtlas 中的帧名)。 * @param type 卡牌类型 * @param uuid 卡牌 UUID * @returns 图标帧名称 */ private resolveCardIconId(type: CardType, uuid: number): string { if (type === CardType.Skill) { return SkillSet[uuid]?.icon || `${uuid}`; } if (type === CardType.Hero) { return HeroInfo[uuid]?.icon || `${uuid}`; } return `${uuid}`; } /** * 打开英雄信息弹窗(IBox)。 * 仅当当前卡为英雄卡且 HeroInfo 有效时生效。 */ private openHeroInfoIBox() { if (!this.cardData) return; if (this.cardData.type !== CardType.Hero) return; const hero = HeroInfo[this.cardData.uuid]; if (!hero) return; const heroLv = Math.max(1, Math.floor(this.cardData.hero_lv ?? hero.lv ?? 1)); oops.gui.remove(UIID.IBox); oops.gui.open(UIID.IBox, { heroUuid: this.cardData.uuid, heroLv }); } /** * 为英雄卡图标加载并播放 idle 动画。 * 使用 token 做竞态保护,确保异步回调时不会覆盖已更新的图标。 * * @param node 图标节点 * @param uuid 英雄 UUID * @param token 当前视觉令牌 */ private updateHeroAnimation(node: Node, uuid: number, token: number) { const sprite = node?.getComponent(Sprite) || node?.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; const hero = HeroInfo[uuid]; if (!hero) return; const anim = node.getComponent(Animation) || node.addComponent(Animation); this.clearAnimationClips(anim); const path = `game/heros/hero/${hero.path}/idle`; resources.load(path, AnimationClip, (err, clip) => { if (err || !clip) { mLogger.log(this.debugMode, "CardComp", `load hero animation failed ${uuid}`, err); return; } // 竞态保护:若加载期间卡面已变更则丢弃 if (token !== this.iconVisualToken || !this.cardData || this.card_type !== CardType.Hero || this.card_uuid !== uuid) { return; } this.clearAnimationClips(anim); anim.addClip(clip); anim.play("idle"); }); } /** 清除图标节点上的动画(停止播放并移除所有 clip) */ private clearIconAnimation(node: Node) { const anim = node?.getComponent(Animation); if (!anim) return; anim.stop(); this.clearAnimationClips(anim); } /** * 移除 Animation 组件上的全部 AnimationClip。 * 通过浅拷贝数组避免遍历时修改集合。 */ private clearAnimationClips(anim: Animation) { const clips = (anim as any).clips as AnimationClip[] | undefined; if (!clips || clips.length === 0) return; [...clips].forEach(clip => anim.removeClip(clip, true)); } // ======================== 数据访问 ======================== /** 从全局单例获取当前局内金币数量 */ private getMissionCoin(): number { const missionData = smc?.vmdata?.mission_data; return Math.max(0, Math.floor(missionData?.coin ?? 0)); } /** 设置当前局内金币数量(自动向下取整并 clamp 至 >= 0) */ private setMissionCoin(value: number) { const missionData = smc?.vmdata?.mission_data; if (!missionData) return; missionData.coin = Math.max(0, Math.floor(value)); } /** ECS 组件移除时的释放钩子:销毁节点 */ reset() { this.node.destroy(); } }