Files
pixelheros/assets/script/game/map/CardLiteComp.ts
panFD a7223669cc feat(heroList): add fragment display and level text refresh
1. 新增英雄碎片数量文本与进度条UI组件绑定和显示逻辑
2. 新增refreshLv方法统一刷新英雄等级数字,移除Lv.前缀
3. 优化卡牌组件注释,补充碎片管理职责
4. 统一英雄列表面板刷新所有卡牌的可升级标识和等级、碎片状态
2026-09-04 22:25:50 +08:00

352 lines
13 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, ProgressBar } 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, RARITY_BG_COLORS } from "../common/config/GameSet";
import { canHeroUpgrade, getHeroUpgradeState } 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!
/** 碎片数量文本(显示 "当前碎片/升级所需碎片") */
@property(Label)
frag_label: Label = null!
/** 碎片进度条(fill = 当前碎片 / 升级所需碎片,满级时满格) */
@property(ProgressBar)
frag_progress: ProgressBar = 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;
// onLoad 在 addChild 时才触发,晚于 setHeroByUuid;此处按已赋值的数据重新判定 canup,避免覆盖渲染结果
this.refreshCanUp();
}
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);
this.refreshCanUp();
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.updateFragmentsUI(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);
this.updateFragmentsUI(true);
}
/**
* 刷新卡片上的等级数字(局外成长等级)。
* 升级后由 HerosListComp 统一调用,避免卡片重复渲染整个 UI。
*/
refreshLv() {
if (!this.hasCard() || !this.lvl_node) return;
const hero = HeroInfo[this.card_uuid];
const heroLv = Math.max(1, smc.getHeroOuterLv(this.card_uuid) || hero?.lv || 1);
this.lvl_node.string = `${heroLv}`;
}
/**
* 刷新碎片文本与进度条:显示 "当前碎片/升级所需碎片",进度为 fragments/need。
* 满级时进度条满格并显示 "MAX"。
* @param visible 是否显示碎片区域(clear 时隐藏)
*/
private updateFragmentsUI(visible: boolean) {
if (this.frag_label) {
this.frag_label.node.active = visible;
if (visible) {
const growth = smc.getHeroGrowth(this.card_uuid);
const state = getHeroUpgradeState(growth.lv, growth.fragments);
this.frag_label.string = state.isMaxLv ? "MAX" : `${state.fragments}/${state.needFragments}`;
}
}
if (this.frag_progress) {
this.frag_progress.node.active = visible;
if (visible) {
const growth = smc.getHeroGrowth(this.card_uuid);
const state = getHeroUpgradeState(growth.lv, growth.fragments);
// 未满级时 needFragments 必然 > 0(来自升级表配置),直接除即可
this.frag_progress.progress = state.isMaxLv ? 1 : Math.min(state.fragments / state.needFragments, 1);
}
}
}
/**
* 设置选中态:选中卡微微放大,取消选中恢复原尺寸。
* @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. 背景:按 rarity 切换颜色底框(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;
// 背景:按 rarity 切换颜色底框
if (this.BG_node) {
const bgLv = Math.min(Math.max(hero.rarity ?? 1, 1), 5);
const color = RARITY_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 = `${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();
}
}