1. 新增SkillBox UI界面配置到GameUIConfig 2. 为CardComp组件添加技能描述文本渲染功能 3. 实现卡牌节点标签缓存与统一UI样式配置 4. 修复不同类型卡牌切换时的文本残留问题
1024 lines
38 KiB
TypeScript
1024 lines
38 KiB
TypeScript
/**
|
||
* @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, 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, SpecialUpgradeCardList, 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 { getLvColor } from "../common/config/GameSet";
|
||
import { MissionEconomy } from "./MissionEconomy";
|
||
import { buildSkillDesc } from "../common/config/HeroSkillDesc";
|
||
|
||
|
||
|
||
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)
|
||
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)
|
||
ap_node = null!
|
||
@property(Node)
|
||
hp_node = null!
|
||
|
||
/** 技能信息标签缓存引用(由 cacheLabels 在 onLoad 时初始化) */
|
||
private infoLabel: Label | null = 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;
|
||
/** 上划使用阈值(像素):拖拽距离 >= 此值视为"使用卡牌" */
|
||
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;
|
||
/** 是否触发了长按 */
|
||
private isLongPressed: boolean = false;
|
||
/** 长按触发时间(秒) */
|
||
private readonly LONG_PRESS_DURATION: number = 0.5;
|
||
|
||
// ======================== 生命周期 ========================
|
||
|
||
/**
|
||
* 组件加载时:绑定交互事件,初始化基础 UI 状态。
|
||
* 此阶段不触发业务逻辑。
|
||
*/
|
||
onLoad() {
|
||
/** 初始阶段只做UI状态准备,不触发业务逻辑 */
|
||
this.bindEvents();
|
||
this.cacheLabels();
|
||
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() {
|
||
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.pool_lv ?? 1
|
||
};
|
||
oops.message.dispatchEvent(GameEvent.UseHeroCard, guard);
|
||
if (guard.cancel) {
|
||
this.playReboundAnim();
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 使用统一经济管理入口消费金币
|
||
const success = MissionEconomy.spendCoin(cardCost);
|
||
if (!success) {
|
||
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: 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
|
||
* - 技能卡 → 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() {
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ======================== 触摸交互 ========================
|
||
|
||
/** 触摸开始:记录起点 Y,进入拖拽状态 */
|
||
private onCardTouchStart(event: EventTouch) {
|
||
if (!this.cardData || this.isUsing) return;
|
||
this.touchStartY = event.getUILocation().y;
|
||
this.isDragging = true;
|
||
this.isLongPressed = false;
|
||
this.unschedule(this.onLongPress);
|
||
this.scheduleOnce(this.onLongPress, this.LONG_PRESS_DURATION);
|
||
}
|
||
|
||
/**
|
||
* 触摸移动:跟随手指向上偏移卡牌(仅允许向上拖拽,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);
|
||
if (deltaY > 10) {
|
||
this.unschedule(this.onLongPress);
|
||
if (this.isLongPressed) {
|
||
this.isLongPressed = false;
|
||
oops.gui.remove(UIID.HInfo);
|
||
}
|
||
}
|
||
this.node.setPosition(this.restPosition.x, this.restPosition.y + deltaY, this.restPosition.z);
|
||
}
|
||
|
||
/**
|
||
* 触摸结束:
|
||
* - 上拉距离 >= dragUseThreshold → 视为"使用卡牌"
|
||
* - 否则视为"点击"或者"长按结束"
|
||
*/
|
||
private onCardTouchEnd(event: EventTouch) {
|
||
this.unschedule(this.onLongPress);
|
||
if (!this.isDragging || !this.cardData || this.isUsing) return;
|
||
const endY = event.getUILocation().y;
|
||
const deltaY = endY - this.touchStartY;
|
||
this.isDragging = false;
|
||
|
||
if (this.isLongPressed) {
|
||
this.isLongPressed = false;
|
||
oops.gui.remove(UIID.HInfo);
|
||
}
|
||
|
||
if (deltaY >= this.dragUseThreshold) {
|
||
const used = this.useCard();
|
||
if (!used) {
|
||
this.playReboundAnim();
|
||
}
|
||
return;
|
||
}
|
||
|
||
this.playReboundAnim();
|
||
}
|
||
|
||
/** 触摸取消:回弹至原位 */
|
||
private onCardTouchCancel() {
|
||
this.unschedule(this.onLongPress);
|
||
if (!this.isDragging || this.isUsing) return;
|
||
this.isDragging = false;
|
||
if (this.isLongPressed) {
|
||
this.isLongPressed = false;
|
||
oops.gui.remove(UIID.HInfo);
|
||
}
|
||
this.playReboundAnim();
|
||
}
|
||
|
||
/** 长按触发:英雄卡打开英雄信息预览面板 */
|
||
private onLongPress() {
|
||
if (!this.isDragging || this.isUsing) return;
|
||
if (!this.cardData || this.card_type !== CardType.Hero) return;
|
||
this.isLongPressed = true;
|
||
const heroUuid = 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 });
|
||
}
|
||
|
||
/**
|
||
* 点击锁控件:切换锁态(空槽不允许锁定)。
|
||
* 阻止事件冒泡,避免触发卡面的点击使用。
|
||
*/
|
||
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);
|
||
|
||
// ---- 卡牌种类标识(近战 / 远程 / 辅助等) ----
|
||
const kindName = CKind[this.cardData.kind];
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
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 (this.card_type === CardType.Hero) {
|
||
const hero = HeroInfo[this.card_uuid];
|
||
const heroLv = Math.max(1, this.cardData.hero_lv ?? hero?.lv ?? 1);
|
||
this.setLabel(this.name_node, `${hero?.name || ""}`);
|
||
if (this.lvl_node) {
|
||
this.lvl_node.string = `Lv.${heroLv}`;
|
||
this.lvl_node.color = getLvColor(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;
|
||
// 英雄卡:清空技能描述文本,避免切回时残留上一次的技能信息
|
||
if (this.infoLabel) this.infoLabel.string = "";
|
||
} else if (this.card_type === CardType.Skill) {
|
||
if (this.lvl_node) this.lvl_node.node.active = false;
|
||
// 技能卡:显示技能名 + 品质后缀 + 描述
|
||
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.ap_node.active = false;
|
||
this.hp_node.active = false;
|
||
// 技能卡:显示技能信息节点
|
||
if (this.info_node) this.info_node.active = true;
|
||
// 技能卡:填充技能描述文本(沿用 HInfoComp.buildSkillDesc 模式)
|
||
// 注意:技能卡 uuid 来自 SkillSet 而非 HeroInfo,故以 SkillConfig 作为数据源;
|
||
// 用 buildSkillDesc 兼容调用,结果为空时回退到 SkillConfig.info 字段
|
||
if (this.infoLabel) {
|
||
const skill = SkillSet[this.card_uuid];
|
||
if (skill) {
|
||
const desc = buildSkillDesc(skill as any) || skill.info || "";
|
||
this.infoLabel.string = desc;
|
||
} else {
|
||
this.infoLabel.string = "";
|
||
}
|
||
}
|
||
} else {
|
||
if (this.lvl_node) this.lvl_node.node.active = false;
|
||
// 特殊卡(升级 / 刷新):显示卡名 + 品质后缀 + 描述
|
||
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.ap_node.active = false;
|
||
this.hp_node.active = false;
|
||
// 特殊卡:清空技能描述文本,避免切到特殊卡时残留上一次的技能信息
|
||
if (this.infoLabel) this.infoLabel.string = "";
|
||
}
|
||
|
||
// ---- 费用标签 ----
|
||
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.name_node) {
|
||
const currentPos = this.name_node.position;
|
||
this.name_node.setPosition(currentPos.x, -70, currentPos.z);
|
||
}
|
||
|
||
// ---- 图标 ----
|
||
const iconNode = this.icon_node as Node;
|
||
if (this.card_type === CardType.Hero) {
|
||
iconNode.setScale(new Vec3(-1.5, 1.5, 1));
|
||
// 英雄卡使用 AnimationClip,加载 idle 动画
|
||
this.updateHeroAnimation(iconNode, this.card_uuid, 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();
|
||
}
|
||
|
||
/** 回弹动画:从当前位置平滑回到静止位并恢复缩放 */
|
||
private playReboundAnim() {
|
||
Tween.stopAllByTarget(this.node);
|
||
tween(this.node)
|
||
.to(0.12, {
|
||
position: new Vec3(this.restPosition.x, this.restPosition.y, this.restPosition.z),
|
||
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() {
|
||
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();
|
||
});
|
||
});
|
||
|
||
if (this.name_node) {
|
||
const currentPos = this.name_node.position;
|
||
this.name_node.setPosition(currentPos.x, -70, currentPos.z);
|
||
}
|
||
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.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;
|
||
}
|
||
|
||
/**
|
||
* 缓存 info_node 子树中的 Label 引用。
|
||
* 与 HInfoComp.cacheLabels() 同源实现:若 info_node 下没有 Label 则动态创建,
|
||
* 并设置与 HInfoComp 一致的字号 / 行高 / 对齐 / 溢出策略。
|
||
*/
|
||
private cacheLabels() {
|
||
if (this.infoLabel || !this.info_node) return;
|
||
this.infoLabel = this.info_node.getComponentInChildren(Label);
|
||
if (!this.infoLabel) {
|
||
const child = this.info_node.children[0] || this.info_node;
|
||
this.infoLabel = child.getComponent(Label) || child.addComponent(Label);
|
||
this.infoLabel.fontSize = 20;
|
||
this.infoLabel.lineHeight = 28;
|
||
this.infoLabel.overflow = Label.Overflow.SHRINK;
|
||
this.infoLabel.horizontalAlign = Label.HorizontalAlign.LEFT;
|
||
this.infoLabel.verticalAlign = Label.VerticalAlign.TOP;
|
||
}
|
||
}
|
||
|
||
|
||
/**
|
||
* 根据卡牌类型和 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}`;
|
||
}
|
||
|
||
/**
|
||
* 为英雄卡图标加载并播放 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));
|
||
}
|
||
|
||
// ======================== 数据访问 ========================
|
||
|
||
|
||
|
||
/** ECS 组件移除时的释放钩子:销毁节点 */
|
||
reset() {
|
||
this.node.destroy();
|
||
}
|
||
}
|