feat: 新增卡牌长按预览功能与英雄信息等级展示

1.  为所有卡牌组件添加长按0.5秒弹出详情面板功能,支持点击/取消取消计时
2.  为英雄配置添加分级能力说明数组,信息面板支持逐行展示各等级效果
3.  重构英雄信息面板UI布局与渲染逻辑,优化动画加载方式
4.  更新卡牌预制体尺寸与样式,适配新的交互与展示需求
This commit is contained in:
panFD
2026-07-27 00:31:29 +08:00
parent cb2bc37a68
commit 93b2a4a155
9 changed files with 3852 additions and 4452 deletions

View File

@@ -19,10 +19,10 @@
* - Hero —— 英雄 ECS 实体类(用于出售删除)
* - UIID.IBox —— 英雄详情弹窗 ID
*/
import { _decorator, Animation, AnimationClip, Button, Event, EventTouch, Input, Label, Node, NodeEventType, Sprite, UITransform, input, resources, CCInteger, SpriteFrame } from "cc";
import { _decorator, AnimationClip, Button, Event, EventTouch, Input, Label, Layout, Node, NodeEventType, Sprite, UITransform, input, resources, CCInteger, SpriteFrame } 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 { heroInfo, HeroInfo } from "../common/config/heroSet";
import { HeroAttrsComp } from "../hero/HeroAttrsComp";
import { smc } from "../common/SingletonModuleComp";
import { Hero } from "../hero/Hero";
@@ -108,6 +108,8 @@ export class HInfoComp extends CCComp {
private nameLabel: Label | null = null;
/** 技能信息标签缓存引用 */
private infoLabel: Label | null = null;
/** infos 逐条展示的 Label 池索引0复用预制体 Label其余动态创建 */
private infoLineLabels: Label[] = [];
/** AP 标签缓存引用 */
private apLabel: Label | null = null;
/** AP 加成标签缓存引用 */
@@ -202,7 +204,7 @@ export class HInfoComp extends CCComp {
if (this.hpPlusLabel) this.hpPlusLabel.node.active = false;
}
if (this.infoLabel) {
this.infoLabel.string = config.info ?? "";
this.renderFallbackLines(config.info ?? "");
}
if (this.cdLabel) {
this.cdLabel.string = config.t_inv ? `${config.t_inv}s` : "0s";
@@ -243,7 +245,7 @@ export class HInfoComp extends CCComp {
}
if (this.infoLabel) {
this.infoLabel.string = buildSkillDesc(hero);
this.renderHeroInfoLines(hero);
}
if (this.cdLabel) {
@@ -351,7 +353,11 @@ export class HInfoComp extends CCComp {
// ---- 技能信息标签 ----
if (this.infoLabel) {
const heroData = HeroInfo[heroUuid];
this.infoLabel.string = heroData ? buildSkillDesc(heroData) : "";
if (heroData) {
this.renderHeroInfoLines(heroData);
} else {
this.renderFallbackLines("");
}
}
// ---- 数值标签 ----
@@ -405,6 +411,61 @@ export class HInfoComp extends CCComp {
return !!(this.model as any)?.ent;
}
/**
* 逐条展示英雄能力说明infos 每项一行(自动加 Lv 前缀Lv 着色)。
* 复用预制体首条 Label其余按需创建/隐藏,避免频繁增删节点。
* 未配置 infos 时(如怪物)回退为单行 buildSkillDesc 描述。
* @param hero 英雄静态配置
*/
private renderHeroInfoLines(hero: heroInfo) {
if (!this.infoLabel) return;
const hasInfos = !!(hero.infos?.length);
const count = hasInfos ? hero.infos!.length : 1;
// 按需扩容 Label 池(克隆首条保持样式一致)
for (let i = this.infoLineLabels.length; i < count; i++) {
const node = new Node(`info_${i}`);
node.layer = this.info_node.layer;
this.info_node.addChild(node);
const label = node.addComponent(Label);
label.fontSize = this.infoLabel.fontSize;
label.lineHeight = this.infoLabel.lineHeight;
label.horizontalAlign = Label.HorizontalAlign.LEFT;
label.verticalAlign = Label.VerticalAlign.TOP;
label.color = this.infoLabel.color;
this.infoLineLabels.push(label);
}
for (let i = 0; i < this.infoLineLabels.length; i++) {
const label = this.infoLineLabels[i];
if (i < count) {
label.node.active = true;
if (hasInfos) {
label.string = `Lv${i + 1}. ${hero.infos![i]}`;
// 无富文本时用白色突出 Lv 前缀外的正文,保持简洁
label.color = this.infoLabel.color;
} else {
label.string = buildSkillDesc(hero);
}
} else {
label.node.active = false;
}
}
}
/**
* 单行文本展示(技能卡/无配置英雄):隐藏逐条 Label 池,只保留首行。
* @param text 展示文本
*/
private renderFallbackLines(text: string) {
if (!this.infoLabel) return;
this.infoLabel.node.active = true;
this.infoLabel.string = text;
for (let i = 1; i < this.infoLineLabels.length; i++) {
this.infoLineLabels[i].node.active = false;
}
}
// ======================== 内部工具方法 ========================
/** 缓存 AP / HP Label 引用,避免每次刷新都遍历节点树 */
@@ -415,14 +476,22 @@ export class HInfoComp extends CCComp {
if (!this.infoLabel && this.info_node) {
this.infoLabel = this.info_node.getComponentInChildren(Label);
if (!this.infoLabel) {
const child = this.info_node.children[0] || this.info_node;
this.infoLabel = child.getComponent(Label) || child.addComponent(Label);
const child = new Node("info_0");
child.layer = this.info_node.layer;
this.info_node.addChild(child);
this.infoLabel = child.addComponent(Label);
this.infoLabel.fontSize = 20;
this.infoLabel.lineHeight = 28;
this.infoLabel.overflow = Label.Overflow.SHRINK;
this.infoLabel.horizontalAlign = Label.HorizontalAlign.LEFT;
this.infoLabel.verticalAlign = Label.VerticalAlign.TOP;
}
// 垂直 Layout 让多条 Label 自动逐行排布
let layout = this.info_node.getComponent(Layout);
if (!layout) layout = this.info_node.addComponent(Layout);
layout.type = Layout.Type.VERTICAL;
layout.resizeMode = Layout.ResizeMode.CONTAINER;
layout.spacingY = 4;
this.infoLineLabels = [this.infoLabel];
}
if (!this.apLabel && this.ap_node) {
this.apLabel = this.ap_node.getChildByName("val")?.getComponent(Label) || null;
@@ -454,7 +523,8 @@ export class HInfoComp extends CCComp {
// ======================== 英雄动画 ========================
/**
* 为英雄图标加载并播放 idle 动画(带竞态保护)
* 为英雄图标加载 idle 动画剪辑,提取首帧 SpriteFrame 静止展示
* 不经 Animation 组件采样,零运行时开销;帧名各异也无需关心。
* @param node 图标节点
* @param uuid 英雄 UUID
* @param token 视觉令牌
@@ -464,12 +534,7 @@ export class HInfoComp extends CCComp {
const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite);
if (sprite) sprite.spriteFrame = null;
const hero = HeroInfo[uuid];
if (!hero) {
this.clearIconAnimation(node);
return;
}
const anim = node.getComponent(Animation) || node.addComponent(Animation);
this.clearAnimationClips(anim);
if (!hero) return;
const path = `game/heros/hero/${hero.path}/idle`;
resources.load(path, AnimationClip, (err, clip) => {
if (err || !clip) return;
@@ -479,9 +544,24 @@ export class HInfoComp extends CCComp {
} else {
if (!this.model || this.model.hero_uuid !== uuid) return;
}
this.clearAnimationClips(anim);
anim.addClip(clip);
anim.play("idle");
// 异步加载期间面板可能已关闭销毁
if (!node || !node.isValid) return;
const target = node.getComponent(Sprite) || node.getComponentInChildren(Sprite);
if (!target || !target.isValid) return;
// 帧动画数据存于 spriteFrame 轨道的 _channel._curve._values裸 SpriteFrame 数组)。
// 注_values 为引擎私有字段,但本版本公开 values getter 反序列化后为空,只能读私有字段取首帧。
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;
}
});
}
@@ -494,7 +574,6 @@ export class HInfoComp extends CCComp {
*/
private updateSkillAnimation(node: Node, config: CardConfig, token: number) {
if (!node) return;
this.clearIconAnimation(node);
const sprite = node.getComponent(Sprite) || node.getComponentInChildren(Sprite);
if (!sprite) return;
sprite.spriteFrame = null;
@@ -520,21 +599,6 @@ export class HInfoComp extends CCComp {
}
}
/** 停止并清除图标节点上的动画 */
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));
}
// ======================== 交互 ========================
private bindEvents() {
@@ -637,9 +701,8 @@ export class HInfoComp extends CCComp {
oops.gui.remove(UIID.HInfo);
}
/** ECS 组件移除时的释放钩子:清理动画资源并销毁节点 */
/** ECS 组件移除时的释放钩子:重置绑定状态 */
reset() {
this.clearIconAnimation(this.icon_node);
this.iconVisualToken = 0;
this.iconHeroUuid = 0;
this.model = null;