1. 新增base_pool_lv字段存储英雄初始池等级 2. 替换多处直接使用pool_lv的逻辑,优先使用base_pool_lv 3. 移除预制体中废弃的pool_lv_node节点
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
/**
|
||
* @file CardLiteComp.ts
|
||
* @description 英雄图鉴卡预制体组件(UI 视图层)
|
||
*
|
||
* 职责:
|
||
* 1. 管理英雄图鉴中单张卡牌的 **显示**(名称、图标动画、费用、卡池等级、等级)。
|
||
* 2. 接收 CardConfig 数据并渲染对应卡面(英雄 / 技能 / 特殊卡)。
|
||
* 3. 点击卡牌时通过事件通知父组件(HerosListComp)切换选中英雄详情。
|
||
*
|
||
* 关键设计:
|
||
* - 轻量版 CardComp,无拖拽使用、无锁定、无费用扣除等交互。
|
||
* - 英雄卡图标使用 AnimationClip 动态加载 idle 动画;非英雄卡使用静态图标。
|
||
* - 通过 iconVisualToken 防止异步加载竞态。
|
||
*
|
||
* 依赖:
|
||
* - CardConfig / CardType / CKind 等卡牌数据结构(CardSet)
|
||
* - HeroInfo(heroSet)—— 用于渲染英雄卡面信息
|
||
* - SkillSet(SkillSet)—— 用于渲染技能卡图标
|
||
* - smc —— 全局单例,访问图标图集缓存
|
||
*/
|
||
import { mLogger } from "../common/Logger";
|
||
import { _decorator, Animation, AnimationClip, EventTouch, Label, Node, NodeEventType, Sprite, SpriteAtlas, Tween, tween, UIOpacity, Vec3, resources, 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 { getLvColor } from "../common/config/GameSet";
|
||
import { smc } from "../common/SingletonModuleComp";
|
||
|
||
const { ccclass, property } = _decorator;
|
||
|
||
/**
|
||
* CardLiteComp —— 单张卡牌简单视图组件
|
||
*
|
||
* 挂载在英雄图鉴卡预制体上,由 HerosListComp 实例化并管理。
|
||
* 负责单卡的渲染与点击选择。
|
||
*/
|
||
@ccclass('CardLiteComp')
|
||
@ecs.register('CardLiteComp', false)
|
||
export class CardLiteComp extends CCComp {
|
||
private debugMode: boolean = false;
|
||
|
||
// ======================== 编辑器绑定节点 ========================
|
||
|
||
@property(Node)
|
||
name_node: Node = null!
|
||
@property(Node)
|
||
icon_node: Node = null!
|
||
@property(Node)
|
||
cost_node: Node = null!
|
||
@property(Node)
|
||
Ckind_node: Node = null!
|
||
@property(Node)
|
||
BG_node: Node = null!
|
||
@property(Label)
|
||
lvl_node: Label = null!
|
||
|
||
// ======================== 运行时状态 ========================
|
||
|
||
card_cost: number = 0
|
||
card_type: CardType = CardType.Hero
|
||
card_uuid: number = 0
|
||
private cardData: CardConfig | null = null;
|
||
private iconVisualToken: number = 0;
|
||
private opacityComp: UIOpacity | null = null;
|
||
|
||
/** 点击回调(由外部 HerosListComp 设置) */
|
||
onClickCallback: ((comp: CardLiteComp) => void) | null = null;
|
||
|
||
// ======================== 生命周期 ========================
|
||
|
||
onLoad() {
|
||
this.bindEvents();
|
||
this.opacityComp = this.node.getComponent(UIOpacity) || this.node.addComponent(UIOpacity);
|
||
this.opacityComp.opacity = 255;
|
||
if (!this.cardData) {
|
||
this.applyEmptyUI();
|
||
}
|
||
}
|
||
|
||
onDestroy() {
|
||
super.onDestroy();
|
||
this.unbindEvents();
|
||
}
|
||
|
||
start() {
|
||
this.node.active = true;
|
||
}
|
||
|
||
// ======================== 对外接口 ========================
|
||
|
||
/**
|
||
* 设置卡牌数据并渲染。
|
||
* @param data 卡牌配置数据
|
||
*/
|
||
setData(data: CardConfig) {
|
||
if (!data) return;
|
||
this.cardData = data;
|
||
this.card_uuid = data.uuid;
|
||
this.card_type = data.type;
|
||
this.card_cost = data.cost ?? 0;
|
||
this.node.active = true;
|
||
this.applyCardUI();
|
||
mLogger.log(this.debugMode, "CardLiteComp", "setData", {
|
||
uuid: this.card_uuid,
|
||
type: this.card_type,
|
||
cost: this.card_cost
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 便捷方法:通过英雄 UUID 和卡池等级直接设置英雄卡。
|
||
* 自动从 CardPoolList 查找匹配的卡牌配置。
|
||
* @param uuid 英雄 UUID
|
||
* @param poolLv 卡池等级(默认 1)
|
||
*/
|
||
setHeroByUuid(uuid: number, poolLv: number = 1) {
|
||
const card = CardPoolList.find(c => c.uuid === uuid && c.pool_lv === poolLv);
|
||
if (card) {
|
||
this.setData(card);
|
||
return;
|
||
}
|
||
const hero = HeroInfo[uuid];
|
||
if (hero) {
|
||
this.setData({
|
||
uuid: hero.uuid,
|
||
type: CardType.Hero,
|
||
cost: 5,
|
||
weight: 25,
|
||
pool_lv: poolLv as any,
|
||
kind: CKind.Hero,
|
||
hero_lv: hero.lv || 1
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 查询当前是否有卡牌数据 */
|
||
hasCard(): boolean {
|
||
return !!this.cardData;
|
||
}
|
||
|
||
/** 获取当前卡牌数据 */
|
||
getCardData(): CardConfig | null {
|
||
return this.cardData;
|
||
}
|
||
|
||
/**
|
||
* 清空卡牌数据并重置 UI。
|
||
*/
|
||
clear() {
|
||
this.cardData = null;
|
||
this.card_uuid = 0;
|
||
this.card_cost = 0;
|
||
this.card_type = CardType.Hero;
|
||
this.applyEmptyUI();
|
||
this.node.active = false;
|
||
}
|
||
|
||
// ======================== 事件绑定 ========================
|
||
|
||
private bindEvents() {
|
||
this.node.on(NodeEventType.TOUCH_END, this.onCardClick, this);
|
||
}
|
||
|
||
private unbindEvents() {
|
||
if (this.node && this.node.isValid) {
|
||
this.node.off(NodeEventType.TOUCH_END, this.onCardClick, this);
|
||
}
|
||
}
|
||
|
||
/** 点击卡牌:触发回调通知父组件 */
|
||
private onCardClick(event: EventTouch) {
|
||
if (!this.cardData) return;
|
||
event.propagationStopped = true;
|
||
if (this.onClickCallback) {
|
||
this.onClickCallback(this);
|
||
}
|
||
mLogger.log(this.debugMode, "CardLiteComp", "card clicked", {
|
||
uuid: this.card_uuid,
|
||
type: this.card_type
|
||
});
|
||
}
|
||
|
||
// ======================== UI 渲染 ========================
|
||
|
||
/**
|
||
* 根据当前 cardData 渲染卡面。
|
||
*
|
||
* 渲染逻辑根据卡牌类型分三路:
|
||
* 1. 英雄卡:显示英雄名、等级、idle 动画。
|
||
* 2. 技能卡:显示技能名、静态图标。
|
||
* 3. 特殊卡:显示卡名、静态图标。
|
||
*
|
||
* 同时根据 pool_lv 切换背景底框,根据 kind 切换种类标识。
|
||
*/
|
||
private applyCardUI() {
|
||
if (!this.cardData) {
|
||
this.applyEmptyUI();
|
||
return;
|
||
}
|
||
|
||
this.iconVisualToken += 1;
|
||
if (this.opacityComp) this.opacityComp.opacity = 255;
|
||
|
||
mLogger.log(this.debugMode, "CardLiteComp", "applyCardUI nodes", {
|
||
uuid: this.card_uuid,
|
||
hasName: !!this.name_node,
|
||
hasIcon: !!this.icon_node,
|
||
hasCost: !!this.cost_node,
|
||
hasBG: !!this.BG_node,
|
||
hasLvl: !!this.lvl_node,
|
||
nodeActive: this.node.active,
|
||
nodeScale: this.node.scale.toString(),
|
||
nodePos: this.node.position.toString(),
|
||
parentName: this.node.parent?.name || "none",
|
||
});
|
||
|
||
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.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;
|
||
}
|
||
} 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}`);
|
||
} 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}`);
|
||
}
|
||
|
||
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.icon_node) {
|
||
const iconNode = this.icon_node;
|
||
if (this.card_type === CardType.Hero) {
|
||
iconNode.setScale(new Vec3(-1.5, 1.5, 1));
|
||
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 applyEmptyUI() {
|
||
this.iconVisualToken += 1;
|
||
this.setLabel(this.name_node, "");
|
||
if (this.cost_node) {
|
||
const numNode = this.cost_node.getChildByName("num");
|
||
if (numNode) {
|
||
this.setLabel(numNode, "");
|
||
}
|
||
this.cost_node.active = false;
|
||
}
|
||
if (this.lvl_node) this.lvl_node.node.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.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;
|
||
}
|
||
}
|
||
|
||
// ======================== 动画 ========================
|
||
|
||
/**
|
||
* 入场动画:先缩小再弹大再回归正常比例。
|
||
*/
|
||
playRefreshAnim() {
|
||
Tween.stopAllByTarget(this.node);
|
||
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();
|
||
}
|
||
|
||
// ======================== 工具方法 ========================
|
||
|
||
/**
|
||
* 安全设置文本,兼容节点上或子节点上的 Label。
|
||
*/
|
||
private setLabel(node: Node | null, value: string) {
|
||
if (!node) return;
|
||
const label = node.getComponent(Label) || node.getComponentInChildren(Label);
|
||
if (label) label.string = value;
|
||
}
|
||
|
||
/**
|
||
* 更新图标:从全局缓存图集中获取对应帧。
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据卡牌类型和 UUID 解析出图标 ID(在 SpriteAtlas 中的帧名)。
|
||
*/
|
||
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 做竞态保护,确保异步回调时不会覆盖已更新的图标。
|
||
*/
|
||
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, "CardLiteComp", `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();
|
||
}
|
||
}
|