Files
pixelheros/assets/script/game/map/CardLiteComp.ts
panFD af4c45dc28 feat(hero card): add upgrade reminder feature
1. 新增英雄升级配置判定模块,封装升级条件校验逻辑
2. 为卡牌组件添加可升级标识节点和刷新逻辑
3. 实现英雄列表面板统一刷新所有卡牌的升级标识
4. 监听英雄成长变更事件实时更新升级提示状态
2026-08-30 23:10:08 +08:00

303 lines
11 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. 接收英雄 UUID 并渲染对应卡面。
* 3. 点击卡牌时通过事件通知父组件(HerosListComp)切换选中英雄详情。
* 4. 英雄碎片满足升级条件时显示 canup 标识。
*
* 关键设计:
* - 轻量展示组件,仅用于英雄展示,无拖拽使用、无锁定、无费用扣除等交互。
* - 英雄卡图标加载 idle 动画剪辑并提取首帧静态展示。
* - 通过 iconVisualToken 防止异步加载竞态。
* - canup 标识监听 HERO_GROWTH_UPDATE 事件实时刷新(碎片变动/升级后自动更新)。
*
* 依赖:
* - HeroInfo(heroSet)—— 用于渲染英雄卡面信息
* - canHeroUpgrade(heroUpSet)—— 英雄升级条件判定
* - 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 { oops } from "db://oops-framework/core/Oops";
import { HeroInfo } from "../common/config/heroSet";
import { getLvColor, CARD_LV_BG_COLORS } from "../common/config/GameSet";
import { canHeroUpgrade } from "../common/config/heroUpSet";
import { GameEvent } from "../common/config/GameEvent";
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;
/** 选中态放大倍率 */
private static readonly SELECT_SCALE: number = 1.08;
// ======================== 编辑器绑定节点 ========================
@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!
/** 可升级标识节点(碎片满足升级条件时显示) */
@property(Node)
canup_node: Node = 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();
if (this.canup_node) this.canup_node.active = false;
this.node.active = false;
}
/**
* 刷新可升级标识:碎片满足升级条件时显示 canup 节点。
* 渲染时自动调用一次;升级后由 HerosListComp 统一触发刷新。
*/
refreshCanUp() {
if (!this.canup_node) return;
if (!this.hasCard()) {
this.canup_node.active = false;
return;
}
const growth = smc.getHeroGrowth(this.card_uuid);
this.canup_node.active = canHeroUpgrade(growth.lv, growth.fragments);
}
/**
* 设置选中态:选中卡微微放大,取消选中恢复原尺寸。
* @param selected 是否选中
*/
setSelected(selected: boolean) {
Tween.stopAllByTarget(this.node);
const target = selected ? CardLiteComp.SELECT_SCALE : 1;
tween(this.node)
.to(0.12, { scale: new Vec3(target, target, 1) }, { easing: 'quadOut' })
.start();
}
// ======================== 事件绑定 ========================
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();
}
}