Files
pixelheros/assets/script/game/map/CardComp.ts
walkpan e880613f8f docs: 为游戏地图模块添加详细的代码注释
为游戏地图模块的脚本文件添加全面的注释,说明每个组件的职责、关键设计、依赖关系和使用方式。注释覆盖了英雄信息面板、技能卡槽位管理器、排行榜弹窗、卡牌控制器、背景滚动组件等核心功能模块,提高了代码的可读性和维护性。

同时修复了英雄预制体的激活状态和技能效果预制体的尺寸参数。
2026-04-07 19:00:30 +08:00

842 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @file CardComp.ts
* @description 单张卡牌槽位组件UI 视图层)
*
* 职责:
* 1. 管理单个卡牌槽位的 **显示** 和 **交互**(触摸拖拽 / 点击使用 / 锁定切换)。
* 2. 接收来自 MissionCardComp 分发的 CardConfig 数据,渲染对应卡面(英雄 / 技能 / 特殊卡)。
* 3. 在玩家触发"使用卡牌"操作时,执行 **费用扣除 → 动画表现 → 效果分发** 的完整流程。
*
* 关键设计:
* - 槽位可 **锁定**:锁定后刷新卡池不会覆盖旧卡,由 `isLocked` 控制。
* - 卡牌使用支持 **上划手势** 与 **点击** 两种触发方式。
* - 英雄卡图标使用 AnimationClip 动态加载 idle 动画;技能 / 特殊卡使用 SpriteAtlas 静态图标。
* - 通过 `iconVisualToken` 防止异步加载资源回调与当前显示不一致(竞态保护)。
*
* 依赖:
* - CardConfig / CardType / CKind 等卡牌数据结构CardSet
* - HeroInfoheroSet、SkillSetSkillSet—— 用于渲染卡面信息
* - 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();
}
}