/** * @file CardLiteComp.ts * @description 英雄图鉴卡预制体组件(UI 视图层) * * 职责: * 1. 管理英雄图鉴中单张英雄卡牌的 **显示**(名称、图标动画、卡池等级、英雄等级)。 * 2. 接收英雄 UUID 并渲染对应卡面。 * 3. 点击卡牌时通过事件通知父组件(HerosListComp)切换选中英雄详情。 * * 关键设计: * - 轻量版 CardComp,仅用于英雄展示,无拖拽使用、无锁定、无费用扣除等交互。 * - 英雄卡图标加载 idle 动画剪辑并提取首帧静态展示。 * - 通过 iconVisualToken 防止异步加载竞态。 * * 依赖: * - HeroInfo(heroSet)—— 用于渲染英雄卡面信息 * - smc —— 全局单例,访问图标图集缓存与英雄局外等级 */ import { mLogger } from "../common/Logger"; import { _decorator, AnimationClip, EventTouch, Label, Node, NodeEventType, Sprite, SpriteFrame, Tween, tween, UIOpacity, Vec3, resources } 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 { HeroInfo } from "../common/config/heroSet"; import { getLvColor, CARD_LV_BG_COLORS } 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_uuid: number = 0 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; } onDestroy() { super.onDestroy(); this.unbindEvents(); } start() { this.node.active = true; } // ======================== 对外接口 ======================== /** * 通过英雄 UUID 渲染英雄卡(纯展示场景)。 * 从 HeroInfo 读取静态配置,从 smc 读取局外等级。 * @param uuid 英雄 UUID */ setHeroByUuid(uuid: number) { const hero = HeroInfo[uuid]; if (!hero) return; this.card_uuid = uuid; this.node.active = true; this.applyHeroUI(hero); mLogger.log(this.debugMode, "CardLiteComp", "setHeroByUuid", { uuid }); } /** 查询当前是否有英雄数据 */ hasCard(): boolean { return this.card_uuid > 0; } /** * 清空英雄数据并重置 UI。 */ clear() { this.card_uuid = 0; this.applyEmptyUI(); this.node.active = false; } /** * 设置选中态:切换根节点下 `active` 子节点的显隐(不再通过缩放表达选中)。 * @param selected 是否选中 */ setSelected(selected: boolean) { const mark = this.node.getChildByName("active"); if (mark) mark.active = selected; } // ======================== 事件绑定 ======================== 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.hasCard()) return; event.propagationStopped = true; if (this.onClickCallback) { this.onClickCallback(this); } mLogger.log(this.debugMode, "CardLiteComp", "card clicked", { uuid: this.card_uuid }); } // ======================== UI 渲染 ======================== /** * 渲染英雄卡面。 * * 1. 背景:按 card_lv 切换颜色底框(green/blue/purple/yellow/red)。 * 2. 名称:英雄名。 * 3. 等级:局外成长等级(Lv.X),颜色按 getLvColor。 * 4. 图标:idle 动画首帧静态展示。 */ private applyHeroUI(hero: typeof HeroInfo[number]) { this.iconVisualToken += 1; if (this.opacityComp) this.opacityComp.opacity = 255; // 背景:按 card_lv 切换颜色底框 if (this.BG_node) { const bgLv = Math.min(Math.max(hero.card_lv ?? 1, 1), 5); const color = CARD_LV_BG_COLORS[bgLv - 1]; this.BG_node.children.forEach(child => child.active = (child.name === color)); } // 名称 this.setLabel(this.name_node, `${hero?.name || ""}`); // 等级:局外成长等级 if (this.lvl_node) { const heroLv = Math.max(1, smc.getHeroOuterLv(this.card_uuid) || hero?.lv || 1); this.lvl_node.string = `Lv.${heroLv}`; this.lvl_node.node.active = true; } // 费用:英雄卡不显示 if (this.cost_node) { this.cost_node.active = false; } // 图标:idle 动画首帧静态展示(镜像翻转,原图朝右,卡面需朝左) if (this.icon_node) { this.updateHeroAnimation(this.icon_node, this.card_uuid, this.iconVisualToken); } } /** * 渲染空槽状态:清空名称、等级、背景、图标等。 */ private applyEmptyUI() { this.iconVisualToken += 1; this.setLabel(this.name_node, ""); if (this.cost_node) { 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); } if (this.icon_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; } /** * 为英雄图标加载 idle 动画剪辑,提取首帧 SpriteFrame 静止展示。 * 使用 token 做竞态保护,确保异步回调时不会覆盖已更新的图标。 */ private updateHeroAnimation(node: Node, uuid: number, token: number) { if (!node || !node.isValid) return; const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); if (sprite) sprite.spriteFrame = null; const hero = HeroInfo[uuid]; if (!hero) return; const path = `game/heros/hero/${hero.path}/idle`; resources.load(path, AnimationClip, (err, clip) => { if (err || !clip) { mLogger.log(this.debugMode, "CardLiteComp", `load hero idle failed ${uuid}`, err); return; } // 竞态保护:仅允许最新一次图标请求落地 if (token !== this.iconVisualToken || !node.isValid) return; const target = node.getComponent(Sprite) || node.getComponentInChildren(Sprite); if (!target || !target.isValid) return; // 帧动画数据存于 spriteFrame 轨道的 _channel._curve._values(裸 SpriteFrame 数组) let firstFrame: SpriteFrame | undefined; for (const track of clip.tracks) { const curve = (track as unknown as { channel?: { curve?: { _values?: unknown[] } } }).channel?.curve; const frame = curve?._values?.[0]; if (frame instanceof SpriteFrame) { firstFrame = frame; break; } } if (firstFrame) { target.spriteFrame = firstFrame; } }); } // ======================== 生命周期钩子 ======================== /** ECS 组件移除时的释放钩子:销毁节点 */ reset() { this.node.destroy(); } }