/** * @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, Input, Label, Node, NodeEventType, Sprite, SpriteAtlas, Tween, tween, UIOpacity, Vec3, input, resources, Light, 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 { CardConfig, CardType, SpecialRefreshCardList, CKind, CardPoolList } from "../common/config/CardSet"; import { CardBgComp } from "./CardBgComp"; 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"; import { HeroAttrsComp } from "../hero/HeroAttrsComp"; import { FieldSkillType } from "../common/config/SkillSet"; import { FieldSkillHelper } from "../hero/FieldSkillHelper"; import { MissionEconomy } from "./MissionEconomy"; 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! @property(Node) call_btn: Node = null! /** 英雄卡信息面板(显示 AP / HP) 废弃,改用直接绑定 */ // @property(Node) // info_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! /** 技能卡牌信息节点,显示技能信息*/ @property(Node) info_node = null! @property(Label) lvl_node: Label = null! //英雄本身的等级 @property(Node) up_icon_node: Node = null! //升级 icon @property(Node) ap_node = null! @property(Node) hp_node = null! /** 金币不足提示节点(useCard 费用不足时显示 2 秒) */ @property(Node) no_cion_node = null! // ======================== 运行时状态 ======================== /** 当前卡牌的金币费用 */ card_cost: number = 0 /** 当前卡牌类型(英雄 / 技能 / 特殊升级 / 特殊刷新) */ card_type: CardType = CardType.Hero /** 当前卡牌的唯一标识 UUID */ card_uuid: number = 0 /** 是否处于锁定状态(锁定且有卡时,抽卡分发会被跳过) */ private isLocked: boolean = false; /** 当前槽位承载的卡牌数据,null 表示空槽 */ private cardData: CardConfig | null = null; /** 当前是否正在触摸卡牌(用于阻止 setSlotPosition 等重置位置) */ 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(); // call_btn 默认隐藏,仅在点击本卡时显示 this.hideCallBtn(); // 金币不足提示默认隐藏 if (this.no_cion_node) this.no_cion_node.active = false; } /** 组件销毁时解绑所有事件,防止残留回调 */ onDestroy() { super.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 (smc.uiconsAtlas) { const frame = smc.uiconsAtlas.getSpriteFrame(iconId); sprite.spriteFrame = frame || 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; let baseCost = data.cost ?? 0; if (this.card_type === CardType.Hero) { // 驻场英雄带来的"购买优惠"折扣 const discount = FieldSkillHelper.getFieldSkillTotalValue(FieldSkillType.BuyDiscount); baseCost = Math.max(0, baseCost - discount); } this.card_cost = Math.floor(baseCost); 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 = this.card_cost; // 英雄卡特殊校验:通过 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.base_pool_lv ?? this.cardData.pool_lv ?? 1 }; oops.message.dispatchEvent(GameEvent.UseHeroCard, guard); if (guard.cancel) { this.playReboundAnim(); return null; } } // 使用统一经济管理入口消费金币 const success = MissionEconomy.spendCoin(cardCost); if (!success) { oops.message.dispatchEvent(GameEvent.ShowSmallTip, "buy_coin"); this.playReboundAnim(); // 显示"金币不足"提示节点 2 秒 this.showNoCoinNode(); mLogger.log(this.debugMode, "CardComp", "use card coin not enough", { uuid: this.cardData.uuid, type: this.cardData.type, cardCost, currentCoin: MissionEconomy.getCoin() }); return null; } // 【评分系统 - 效率分】记录刷新后的选中卡次数(命中率分子) smc.vmdata.scores.refresh_hit_count++; // 标记使用中,阻止并发操作 this.isUsing = true; const used = this.cardData; mLogger.log(this.debugMode, "CardComp", "use card", { uuid: used.uuid, type: used.type, cost: cardCost, leftCoin: MissionEconomy.getCoin() }); // 播放消失动画 → 动画结束后清槽并分发效果 this.playUseDisappearAnim(() => { this.clearAfterUse(); this.isUsing = false; this.executeCardEffectEntry(used); }); return used; } /** * 根据卡牌类型分发对应的游戏效果事件。 * - 英雄卡 → CallHero * - 特殊升级 / 特殊刷新 → UseSpecialCard * * 卡牌使用成功后会派发 CardUsed 事件(携带本卡槽组件), * 由 MissionCardComp 接收并立即补充一张新卡到该槽位。 * SpecialRefresh 例外:其效果本身会重填所有槽位,无需重复补卡。 * * @param payload 被使用的卡牌数据 */ private executeCardEffectEntry(payload: CardConfig) { switch (payload.type) { case CardType.Hero: oops.message.dispatchEvent(GameEvent.CallHero, payload); oops.message.dispatchEvent(GameEvent.CardUsed, this); break; case CardType.SpecialUpgrade: oops.message.dispatchEvent(GameEvent.UseSpecialCard, payload); oops.message.dispatchEvent(GameEvent.CardUsed, this); break; case CardType.SpecialRefresh: // 刷新卡效果(tryRefreshHeroCards)会重填全部 3 槽,此处不再补卡 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.hideCallBtn(); 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); // 召唤按钮点击 = 使用卡牌 this.call_btn?.on(NodeEventType.TOUCH_END, this.onCallBtnClick, this); // 监听跨卡联动:其他卡牌被点击时隐藏本卡 call_btn oops.message.on(GameEvent.CardSelected, this.onOtherCardSelected, this); } /** 解绑触控,防止节点销毁后残留回调 */ private unbindEvents() { if (this.node && this.node.isValid) { 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); } if (this.Lock && this.Lock.isValid) { this.Lock.off(NodeEventType.TOUCH_END, this.onToggleLock, this); } if (this.unLock && this.unLock.isValid) { this.unLock.off(NodeEventType.TOUCH_END, this.onToggleLock, this); } if (this.call_btn && this.call_btn.isValid) { this.call_btn.off(NodeEventType.TOUCH_END, this.onCallBtnClick, this); } oops.message.off(GameEvent.CardSelected, this.onOtherCardSelected, this); // 兜底注销全局触摸监听(showCallBtn 后组件销毁的场景) input.off(Input.EventType.TOUCH_END, this.onGlobalTouchEnd, this); } // ======================== 触摸交互 ======================== /** * 触摸开始:标记正在触摸。 * 注:已移除上划手势召唤,卡牌不跟随手指移动,仅通过点击显示 call_btn 召唤。 */ private onCardTouchStart(event: EventTouch) { if (!this.cardData || this.isUsing) return; this.isDragging = true; } /** * 触摸移动:不做任何处理。 * 卡牌不跟随手指位移,避免点击选中时产生位移。 */ private onCardTouchMove(event: EventTouch) { return; } /** * 触摸结束:视为点击,触发 onCardTap(显示召唤按钮 / 打开信息面板 / 通知其他卡牌联动)。 */ private onCardTouchEnd(event: EventTouch) { if (!this.isDragging || !this.cardData || this.isUsing) return; this.isDragging = false; this.onCardTap(); } /** 触摸取消:仅重置触摸状态 */ private onCardTouchCancel() { this.isDragging = false; } /** * 卡牌被点击(非拖拽): * 1. 显示本卡的召唤按钮 call_btn。 * 2. 派发 CardSelected 事件,其他卡牌收到后隐藏各自的 call_btn。 * 3. 英雄卡打开 HInfo 信息面板(点击查看,替代原长按)。 * 注:不重置位置。applyCardUI 中 widget.updateAlignment() 会让节点脱离 restPosition, * 这里若强制 setPosition(restPosition) 会导致可见的跳位。 */ private onCardTap() { this.showCallBtn(); oops.message.dispatchEvent(GameEvent.CardSelected, this); // 英雄卡 / 升级卡打开信息面板(点击替代长按) const isUpgradeCard = this.card_type === CardType.SpecialUpgrade && !!this.cardData?.target_hero_eid; if (this.card_type === CardType.Hero || isUpgradeCard) { // 升级卡通过 eid 反查实体得到 hero_uuid;若实体已不存在则降级为本卡 uuid const heroUuid = isUpgradeCard ? (this.queryHeroUuidByEid(this.cardData!.target_hero_eid as number) ?? this.card_uuid) : this.card_uuid; const heroLv = Math.max(1, this.cardData?.hero_lv ?? 1); const poolLv = Math.max(1, this.cardData?.base_pool_lv ?? this.cardData?.pool_lv ?? 1); oops.gui.remove(UIID.HInfo); oops.gui.open(UIID.HInfo, { heroUuid, heroLv, poolLv }); } } /** * 通过 eid 反查场上英雄实体的 hero_uuid。 * 用于动态升级卡渲染:升级卡绑定的是 eid,但显示需要 HeroInfo[uuid]。 * @returns 找到则返回 hero_uuid,找不到(实体已销毁等)返回 null */ private queryHeroUuidByEid(eid: number): number | null { if (!eid) return null; const entity = ecs.getEntityByEid(eid); if (!entity) return null; const model = entity.get(HeroAttrsComp); if (!model) return null; return model.hero_uuid ?? null; } /** * 其他卡牌被点击时联动隐藏本卡 call_btn。 * @param event 事件名(ListenerFunc 约定的第一个参数) * @param source 被点击的 CardComp 实例 */ private onOtherCardSelected(event: string, source: CardComp) { if (source === this) return; this.hideCallBtn(); } /** 显示召唤按钮 */ private showCallBtn() { if (this.call_btn && this.call_btn.isValid) { this.call_btn.active = true; // 按需监听全局触摸:点击本体以外区域时隐藏召唤按钮 input.on(Input.EventType.TOUCH_END, this.onGlobalTouchEnd, this); } } /** 隐藏召唤按钮 */ private hideCallBtn() { if (this.call_btn && this.call_btn.isValid) { this.call_btn.active = false; } // 注销全局监听(无论是否曾注册,off 均安全) input.off(Input.EventType.TOUCH_END, this.onGlobalTouchEnd, this); } /** * 全局触摸结束:点击落点不在召唤按钮 / 卡牌本体包围盒内时隐藏召唤按钮。 * Why: 复用 HInfoComp 的"点击外部关闭"交互模式,避免 call_btn 长期滞留。 * 排除卡牌本体是防止与 onCardTap 的"点击卡牌显示 call_btn"交互相互冲突。 * @param event 全局触摸事件 */ private onGlobalTouchEnd(event: EventTouch) { if (!this.call_btn || !this.call_btn.active) return; const uiPos = event.getUILocation(); // 命中召唤按钮本体 → 保留 const btnTrans = this.call_btn.getComponent(UITransform); if (btnTrans && btnTrans.getBoundingBoxToWorld().contains(uiPos)) return; // 命中卡牌本体 → 保留(由 onCardTap 自行管理显隐) const cardTrans = this.node.getComponent(UITransform); if (cardTrans && cardTrans.getBoundingBoxToWorld().contains(uiPos)) return; // 其余区域 → 隐藏 this.hideCallBtn(); } /** 召唤按钮点击回调:阻止冒泡后触发使用卡牌 */ private onCallBtnClick(event: EventTouch) { event.propagationStopped = true; this.useCard(); } /** * 点击锁控件:切换锁态(空槽不允许锁定)。 * 阻止事件冒泡,避免触发卡面的点击使用。 */ 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.x, this.restPosition.y, this.restPosition.z); // 渲染参数:动态升级卡(SpecialUpgrade + target_hero_eid)按英雄卡样式渲染, // 通过 target_hero_eid 反查实体得到 hero_uuid,再用 HeroInfo 渲染名称/AP/HP/动画。 const isUpgradeCard = this.card_type === CardType.SpecialUpgrade && !!this.cardData.target_hero_eid; const renderType: CardType = isUpgradeCard ? CardType.Hero : this.card_type; // 升级卡通过 eid 反查实体得到 hero_uuid;若实体已不存在则降级为本卡 uuid const renderUuid: number = isUpgradeCard ? (this.queryHeroUuidByEid(this.cardData.target_hero_eid as number) ?? this.card_uuid) : this.card_uuid; const renderKind: CKind = isUpgradeCard ? CKind.Hero : this.cardData.kind; // ---- 卡牌种类标识(近战 / 远程 / 辅助等) ---- const kindName = CKind[renderKind]; if (this.BG_node) { const bgLv = this.cardData.base_pool_lv ?? this.cardData.pool_lv; this.BG_node.children.forEach(child => { child.active = (child.name === kindName); const bg = child.getComponent(CardBgComp); if (bg) child.active ? bg.apply(bgLv) : bg.clear(); }); } if (this.Ckind_node) { this.Ckind_node.children.forEach(child => { child.active = (child.name === kindName); }); } // 升级标识独立由 up_icon_node 控制,不再挂在 Ckind_node 下 if (this.up_icon_node) { this.up_icon_node.active = isUpgradeCard; } const hbNodeUI = this.node.getChildByName("HB"); if (hbNodeUI) hbNodeUI.active = false; // ---- 按卡牌类型渲染具体内容 ---- const uiTrans = this.node.getComponent(UITransform); if (uiTrans) { // 卡牌尺寸由编辑器场景节点决定,不再硬编码 // uiTrans.setContentSize(170, 230); const widget = this.node.getComponent(Widget); if (widget) widget.updateAlignment(); } if (this.BG_node) { const bgTrans = this.BG_node.getComponent(UITransform); if (bgTrans) { // bgTrans.setContentSize(170, 230); const widget = this.BG_node.getComponent(Widget); if (widget) widget.updateAlignment(); } this.BG_node.children.forEach(child => { const childTrans = child.getComponent(UITransform); if (childTrans) { // childTrans.setContentSize(170, 230); const widget = child.getComponent(Widget); if (widget) widget.updateAlignment(); } }); } const hbNode = this.node.getChildByName("HB"); if (hbNode) { const hbTrans = hbNode.getComponent(UITransform); if (hbTrans) { // hbTrans.setContentSize(170, 230); const widget = hbNode.getComponent(Widget); if (widget) widget.updateAlignment(); } } // 触发布局刷新,确保其所有子节点(比如右上角的cost、名字等)依赖 Widget 的节点重新对齐 this.node.children.forEach(child => { const widget = child.getComponent(Widget); if (widget) widget.updateAlignment(); child.children.forEach(subChild => { const subWidget = subChild.getComponent(Widget); if (subWidget) subWidget.updateAlignment(); }); }); if (this.ap_node) { const widget = this.ap_node.getComponent(Widget); if (widget) widget.updateAlignment(); } if (this.hp_node) { const widget = this.hp_node.getComponent(Widget); if (widget) widget.updateAlignment(); } if (renderType === CardType.Hero) { const hero = HeroInfo[renderUuid]; const heroLv = Math.max(1, this.cardData.hero_lv ?? hero?.lv ?? 1); this.setLabel(this.name_node, `${hero?.name || ""}`); if (this.lvl_node) { // 升级卡:显示「当前→升级后」;普通英雄卡:显示当前等级。统一使用默认颜色 if (isUpgradeCard && heroLv > 1) { this.lvl_node.string = `Lv.${heroLv - 1} -> Lv.${heroLv}`; } else { this.lvl_node.string = `Lv.${heroLv}`; } this.lvl_node.node.active = true; } this.ap_node.getChildByName("val").getComponent(Label).string = `${(hero?.ap ?? 0) * heroLv}`; this.hp_node.getChildByName("val").getComponent(Label).string = `${(hero?.hp ?? 0) * heroLv}`; this.ap_node.active = true; this.hp_node.active = true; // 英雄卡:隐藏技能信息节点 if (this.info_node) this.info_node.active = false; } else { if (this.lvl_node) this.lvl_node.node.active = false; // 特殊卡(刷新):显示卡名 + 品质后缀 + 描述 const specialCard = 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}`); if (this.info_node) { this.info_node.active = true; this.setLabel(this.info_node, specialCard?.info || ""); } this.ap_node.active = false; this.hp_node.active = false; } // ---- 费用标签 ---- if (this.cost_node) { this.cost_node.active = true; const numNode = this.cost_node.getChildByName("num"); if (numNode) { this.setLabel(numNode, `${this.card_cost}`); } } // ---- 召唤按钮文案:英雄卡=招募,升级卡=升级 ---- if (this.call_btn) { this.setLabel(this.call_btn, isUpgradeCard ? "升级" : "招募"); } // ---- 图标 ---- const iconNode = this.icon_node as Node; if (renderType === CardType.Hero) { iconNode.setScale(new Vec3(-1.5, 1.5, 1)); // 英雄卡 / 升级卡使用 AnimationClip,加载 idle 动画 this.updateHeroAnimation(iconNode, renderUuid, this.iconVisualToken); return; } iconNode.setScale(new Vec3(1, 1, 1)); // 非英雄卡使用静态图标 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(); } /** * 回弹动画:仅恢复缩放到 1,不改变位置。 * Why: applyCardUI 中 widget.updateAlignment() 会让节点脱离 restPosition, * 若强行 setPosition(restPosition) 会产生肉眼可见的跳位(如金币不足时反馈)。 */ private playReboundAnim() { Tween.stopAllByTarget(this.node); tween(this.node) .to(0.12, { scale: new Vec3(1, 1, 1) }) .start(); } /** * 显示"金币不足"提示节点,2 秒后自动隐藏。 * Why: useCard 金币不足时给玩家明确的视觉反馈。 * 使用 scheduleOnce 与组件生命周期绑定,组件销毁时自动清理。 */ private showNoCoinNode() { if (!this.no_cion_node) return; // 清理上一次的隐藏回调,避免连续触发时被提前关闭 this.unschedule(this.hideNoCoinNode); this.no_cion_node.active = true; this.scheduleOnce(this.hideNoCoinNode, 2); } /** 隐藏"金币不足"提示节点 */ private hideNoCoinNode() { if (this.no_cion_node && this.no_cion_node.isValid) { this.no_cion_node.active = false; } } /** * 使用消失动画: * - 节点从当前位置向上移动 120px(X 轴保持当前值不变,不使用 restPosition.x) * - 同时 UIOpacity 渐隐至 0 * - 动画完成后调用 onComplete 回调 * * @param onComplete 动画完成后的回调 */ private playUseDisappearAnim(onComplete: () => void) { const current = this.node.position; const targetPos = new Vec3(current.x, current.y + 120, current.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 }) .call(onComplete) .start(); } /** * 渲染空槽状态: * 清空名称、费用、信息面板、种类标识、背景底框、边框、图标。 */ private applyEmptyUI() { const uiTrans = this.node.getComponent(UITransform); if (uiTrans) { // uiTrans.setContentSize(170, 230); const widget = this.node.getComponent(Widget); if (widget) widget.updateAlignment(); } if (this.BG_node) { const bgTrans = this.BG_node.getComponent(UITransform); if (bgTrans) { // bgTrans.setContentSize(170, 230); const widget = this.BG_node.getComponent(Widget); if (widget) widget.updateAlignment(); } this.BG_node.children.forEach(child => { const childTrans = child.getComponent(UITransform); if (childTrans) { // childTrans.setContentSize(170, 230); const widget = child.getComponent(Widget); if (widget) widget.updateAlignment(); } }); } const hbNode = this.node.getChildByName("HB"); if (hbNode) { const hbTrans = hbNode.getComponent(UITransform); if (hbTrans) { // hbTrans.setContentSize(170, 230); const widget = hbNode.getComponent(Widget); if (widget) widget.updateAlignment(); } } this.node.children.forEach(child => { const widget = child.getComponent(Widget); if (widget) widget.updateAlignment(); child.children.forEach(subChild => { const subWidget = subChild.getComponent(Widget); if (subWidget) subWidget.updateAlignment(); }); }); this.iconVisualToken += 1; this.setLabel(this.name_node, ""); if (this.cost_node) { const numNode = this.cost_node.getChildByName("num"); if (numNode) { this.setLabel(numNode, ""); } } if (this.ap_node) this.ap_node.active = false; if (this.hp_node) this.hp_node.active = false; if (this.lvl_node) this.lvl_node.node.active = false; if (this.up_icon_node) this.up_icon_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; const bg = child.getComponent(CardBgComp); if (bg) bg.clear(); }); } if (this.cost_node) this.cost_node.active = false; if (this.icon_node) (this.icon_node as Node).setScale(new Vec3(1, 1, 1)); 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.Hero) { return HeroInfo[uuid]?.icon || `${uuid}`; } return `${uuid}`; } /** * 为英雄卡图标加载并播放 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; } // 竞态保护:仅靠 token 判断。 // 升级卡的 card_uuid(7001) / card_type(SpecialUpgrade) 与实际渲染的 // hero uuid / Hero 类型不一致,不能用这些字段做校验,token 已足够。 if (token !== this.iconVisualToken) { return; } // 节点可能已在异步加载期间被销毁(如 cacheCardComps 重新实例化) if (!node || !node.isValid) { return; } // 重新获取 Animation 组件,防止原组件已被移除 const animComp = node.getComponent(Animation); if (!animComp) { return; } this.clearAnimationClips(animComp); animComp.addClip(clip); animComp.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)); } // ======================== 数据访问 ======================== /** ECS 组件移除时的释放钩子:销毁节点 */ reset() { this.node.destroy(); } }