Files
pixelheros/assets/script/game/map/CardLiteComp.ts
panFD bef521683c refactor(heroList): 替换卡牌选中高亮方式为显隐标记
1.  重构英雄列表卡牌选中逻辑,移除缩放高亮,改用active子节点显隐标记选中状态
2.  重构CardLiteComp,新增setSelected方法统一处理选中态
3.  调整卡牌预制体尺寸与布局,新增选中标记节点
4.  优化英雄图标加载逻辑,改为静态首帧展示而非播放动画
5.  调整图集边框配置与精灵帧引用
2026-08-23 23:40:54 +08:00

410 lines
15 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 CardLiteComp.ts
* @description 英雄图鉴卡预制体组件UI 视图层)
*
* 职责:
* 1. 管理英雄图鉴中单张卡牌的 **显示**(名称、图标动画、费用、卡池等级、等级)。
* 2. 接收 CardConfig 数据并渲染对应卡面(英雄 / 技能 / 特殊卡)。
* 3. 点击卡牌时通过事件通知父组件HerosListComp切换选中英雄详情。
*
* 关键设计:
* - 轻量版 CardComp无拖拽使用、无锁定、无费用扣除等交互。
* - 英雄卡图标加载 idle 动画剪辑并提取首帧静态展示;非英雄卡使用静态图标。
* - 通过 iconVisualToken 防止异步加载竞态。
*
* 依赖:
* - CardConfig / CardType / CKind 等卡牌数据结构CardSet
* - HeroInfoheroSet—— 用于渲染英雄卡面信息
* - SkillSetSkillSet—— 用于渲染技能卡图标
* - 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 { CardConfig, CardType, SpecialRefreshCardList, SpecialUpgradeCardList, CKind, CardPoolList } from "../common/config/CardSet";
import { SkillCardList } from "../common/config/SCardSet";
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;
/** card_lv → BG_node 子节点颜色名映射1=green 2=blue 3=purple 4=yellow 5=red */
const BG_COLORS = ["green", "blue", "purple", "yellow", "red"];
/**
* 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 直接设置英雄卡(纯展示场景)。
* 从 HeroInfo 读取静态配置,不再依赖 CardPoolList。
* @param uuid 英雄 UUID
*/
setHeroByUuid(uuid: number) {
const hero = HeroInfo[uuid];
if (!hero) return;
this.setData({
uuid: hero.uuid,
type: CardType.Hero,
cost: 0,
weight: 0,
card_lv: hero.card_lv ?? 1,
kind: CKind.Hero,
hero_lv: smc.getHeroOuterLv(hero.uuid)
});
}
/** 查询当前是否有卡牌数据 */
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;
}
/**
* 设置选中态:切换根节点下 `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.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. 特殊卡:显示卡名、静态图标。
*
* 同时根据 card_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",
});
// 背景:按 card_lv 切换颜色底框BG_node 下为 green/blue/purple/yellow/red 子节点)
if (this.BG_node) {
const bgLv = Math.min(Math.max(this.cardData.base_card_lv ?? this.cardData.card_lv ?? 1, 1), 5);
const color = BG_COLORS[bgLv - 1];
this.BG_node.children.forEach(child => child.active = (child.name === color));
}
if (this.card_type === CardType.Hero) {
const hero = HeroInfo[this.card_uuid];
const heroLv = Math.max(1, smc.getHeroOuterLv(this.card_uuid) || 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 = SkillCardList.find(c => c.uuid === this.card_uuid) ?? 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;
// 动态升级卡:显示对应英雄名 + "升级"后缀(注意:升级卡绑定的是 target_hero_eid 而非 uuid
// 此轻量列表场景通常不展示升级卡;为保持渲染健壮性,回退到本卡 uuid 查 HeroInfo
if (this.card_type === CardType.SpecialUpgrade && this.cardData.target_hero_eid) {
const targetHero = HeroInfo[this.card_uuid];
this.setLabel(this.name_node, `${targetHero?.name || ""} 升级`);
} 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}`);
}
}
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));
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);
}
if (this.icon_node) {
this.icon_node.setScale(new Vec3(1, 1, 1));
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 动画剪辑,提取首帧 SpriteFrame 静止展示(参考 CHeroComp
* 使用 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();
}
}