feat(hero): 新增英雄详情弹窗与图鉴页面重构

1.  新增HeroInfoComp英雄详情弹窗,支持英雄属性展示、升级操作、前后切换功能
2.  重构HerosListComp图鉴页面,将详情面板逻辑剥离至弹窗,点击卡片直接打开详情页
3.  新增英雄列表排序工具函数,统一图鉴与弹窗切换的排序逻辑
4.  优化战斗渲染排序逻辑,移除无关的Y轴渲染权重计算
5.  新增英雄详情弹窗UI配置与预制体资源
This commit is contained in:
panFD
2026-09-18 11:22:03 +08:00
parent 93dc59a616
commit 63a9f6cba5
9 changed files with 16410 additions and 15910 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
"ver": "1.1.50",
"importer": "prefab",
"imported": true,
"uuid": "620ee559-61bc-470d-b85d-e881bac42067",
"files": [
".json"
],
"subMetas": {},
"userData": {
"syncNodeName": "heroInfo"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,8 @@ export enum UIID {
Mission, Mission,
/** 技能信息弹窗(技能小图标点击弹出) */ /** 技能信息弹窗(技能小图标点击弹出) */
SInfo, SInfo,
/** 英雄详情弹窗(图鉴卡片点击弹出:含属性/技能/升级/前后切换) */
HeroInfo,
} }
/** 打开界面方式的配置数据 */ /** 打开界面方式的配置数据 */
@@ -41,5 +43,6 @@ export var UIConfigData: { [key: number]: UIConfig } = {
// [UIID.Talents]: { layer: LayerType.UI, prefab: "gui/element/talents" }, // [UIID.Talents]: { layer: LayerType.UI, prefab: "gui/element/talents" },
[UIID.Mission]: { layer: LayerType.UI, prefab: "gui/element/mission" }, [UIID.Mission]: { layer: LayerType.UI, prefab: "gui/element/mission" },
[UIID.SInfo]: { layer: LayerType.PopUp, prefab: "gui/element/SInfoBox" }, [UIID.SInfo]: { layer: LayerType.PopUp, prefab: "gui/element/SInfoBox" },
[UIID.HeroInfo]: { layer: LayerType.PopUp, prefab: "gui/element/heroInfo", bundle: "resources" },
} }

View File

@@ -11,6 +11,7 @@
* - 升级表 key 为目标等级:need = HERO_UPGRADE_NEED_FRAGMENTS[lv + 1],查不到即满级。 * - 升级表 key 为目标等级:need = HERO_UPGRADE_NEED_FRAGMENTS[lv + 1],查不到即满级。
*/ */
import { HERO_UPGRADE_NEED_FRAGMENTS } from "./GameSet"; import { HERO_UPGRADE_NEED_FRAGMENTS } from "./GameSet";
import { HeroInfo, HeroList } from "./heroSet";
/** 英雄升级状态快照 */ /** 英雄升级状态快照 */
export interface HeroUpgradeState { export interface HeroUpgradeState {
@@ -75,3 +76,25 @@ export function getHeroUpgradeState(lv: number, fragments: number): HeroUpgradeS
canUpgrade: !isMax && fragments >= (need as number), canUpgrade: !isMax && fragments >= (need as number),
}; };
} }
/**
* 图鉴展示排序:可升级优先 → 现有等级降序 → 品质降序。
* Why: 图鉴(HerosListComp)与英雄详情(HeroInfoComp)共用同一排序口径,
* 保证图鉴中的卡片顺序与详情面板"前后切换"顺序一致。
* @param getGrowth 查询英雄成长数据(lv / fragments)的回调,通常由 smc.getHeroGrowth 注入
* @returns 排序后的英雄 uuid 数组(新数组,不修改 HeroList)
*/
export function getSortedHeroList(getGrowth: (uuid: number) => { lv: number; fragments: number }): number[] {
return [...HeroList].sort((a, b) => {
const ga = getGrowth(a);
const gb = getGrowth(b);
// 可升级的一批排在最左
const ca = canHeroUpgrade(ga.lv, ga.fragments) ? 0 : 1;
const cb = canHeroUpgrade(gb.lv, gb.fragments) ? 0 : 1;
if (ca !== cb) return ca - cb;
// 同批内:现有等级降序优先
if (ga.lv !== gb.lv) return gb.lv - ga.lv;
// 等级相同:品质降序(rarity 越大品质越高)
return (HeroInfo[b]?.rarity ?? 1) - (HeroInfo[a]?.rarity ?? 1);
});
}

View File

@@ -56,7 +56,7 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
private readonly renderSortInterval = 0.05; private readonly renderSortInterval = 0.05;
private lastRenderSortAt = 0; private lastRenderSortAt = 0;
private heroViewMatcher: ecs.IMatcher | null = null; private heroViewMatcher: ecs.IMatcher | null = null;
private readonly renderEntries: { node: Node; bossPriority: number; frontScore: number; spawnOrder: number; eid: number; laneScore: number; isCastingMax: boolean }[] = []; private readonly renderEntries: { node: Node; spawnOrder: number; eid: number; isCastingMax: boolean }[] = [];
private renderEntryCount = 0; private renderEntryCount = 0;
// ======================== 英雄编队排队 ======================== // ======================== 英雄编队排队 ========================
@@ -431,8 +431,8 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
const monRoot = layerNode.getChildByName("MON"); const monRoot = layerNode.getChildByName("MON");
const bossRoot = layerNode.getChildByName("BOSS"); const bossRoot = layerNode.getChildByName("BOSS");
/** 分层重排同层节点:各阵营层内按 Y 轴路次 -> 前后位置 -> 出生序 -> eid 排序 /** 分层重排同层节点:层内层级只由「是否释放大招」决定,出生序/eid 仅保证排序稳定
* (Boss 已有独立 BOSS 层固定在最底,无需 bossPriority 参与排序) */ * (Boss 已有独立 BOSS 层固定在最底,天然在最下层) */
this.renderEntryCount = 0; this.renderEntryCount = 0;
ecs.query(this.getHeroViewMatcher()).forEach(e => { ecs.query(this.getHeroViewMatcher()).forEach(e => {
const attrs = e.get(HeroAttrsComp); const attrs = e.get(HeroAttrsComp);
@@ -456,20 +456,13 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
const move = e.get(MoveComp); const move = e.get(MoveComp);
const spawnOrder = move?.spawnOrder ?? 0; const spawnOrder = move?.spawnOrder ?? 0;
// 按 Y 轴计算渲染层级权重(Y 越小,说明越靠近屏幕下方,应该渲染在越前面)
const laneScore = -actorView.node.position.y;
// X 轴权重:站在前排的(交战处)优先渲染
const frontScore = attrs.fac === FacSet.HERO ? actorView.node.position.x : -actorView.node.position.x;
const entryIndex = this.renderEntryCount; const entryIndex = this.renderEntryCount;
let entry = this.renderEntries[entryIndex]; let entry = this.renderEntries[entryIndex];
if (!entry) { if (!entry) {
entry = { node: actorView.node, bossPriority: 0, frontScore: 0, spawnOrder: 0, eid: 0, laneScore: 0, isCastingMax: false }; entry = { node: actorView.node, spawnOrder: 0, eid: 0, isCastingMax: false };
this.renderEntries.push(entry); this.renderEntries.push(entry);
} }
entry.node = actorView.node; entry.node = actorView.node;
entry.laneScore = laneScore;
entry.frontScore = frontScore;
entry.spawnOrder = spawnOrder; entry.spawnOrder = spawnOrder;
entry.eid = e.eid; entry.eid = e.eid;
entry.isCastingMax = actorView.isCastingMax; entry.isCastingMax = actorView.isCastingMax;
@@ -478,11 +471,9 @@ export class MoveSystem extends ecs.ComblockSystem implements ecs.ISystemUpdate
this.renderEntries.length = this.renderEntryCount; this.renderEntries.length = this.renderEntryCount;
this.renderEntries.sort((a, b) => { this.renderEntries.sort((a, b) => {
// 正在播放大招的英雄排在最后,siblingIndex 越大渲染层级越靠上 // 层级只受大招影响:正在播放大招的单位排在最后,siblingIndex 越大渲染层级越靠上
if (a.isCastingMax !== b.isCastingMax) return a.isCastingMax ? 1 : -1; if (a.isCastingMax !== b.isCastingMax) return a.isCastingMax ? 1 : -1;
// Y轴靠下的(laneScore 较大)应该在后面,从而渲染在上面 // 与位置无关的稳定排序:出生序 -> eid
if (Math.abs(a.laneScore - b.laneScore) > 10) return a.laneScore - b.laneScore;
if (a.frontScore !== b.frontScore) return a.frontScore - b.frontScore;
if (a.spawnOrder !== b.spawnOrder) return a.spawnOrder - b.spawnOrder; if (a.spawnOrder !== b.spawnOrder) return a.spawnOrder - b.spawnOrder;
return a.eid - b.eid; return a.eid - b.eid;
}); });

View File

@@ -0,0 +1,687 @@
/**
* @file HeroInfoComp.ts
* @description 英雄详情弹窗(UI 视图层,oops.gui 弹窗)
*
* 职责:
* 1. 展示单个英雄的详情:idle 动画、名称、AP / DEF / HP / CD、技能描述、
* 稀有度底框、局外等级、职业、碎片数量、升级按钮、技能图标列表。
* 2. 提供升级操作(消耗碎片提升局外等级,成功后播放升级特效)。
* 3. 提供前后英雄切换按钮,按图鉴排序顺序循环切换(getSortedHeroList)。
* 4. 提供关闭按钮 / 遮罩点击关闭弹窗。
*
* 使用(oops.gui 模块):
* oops.gui.open(UIID.HeroInfo, { uuid: 5001 });
*
* 关键设计:
* - 详情渲染逻辑与原 HerosListComp 详情面板同源(已迁移至此),
* HerosListComp 仅保留卡片列表,点击卡片改为打开本弹窗。
* - 前后切换按 getSortedHeroList 计算的顺序循环(与图鉴页面卡片顺序一致)。
* - hero_icon 使用 Animation + iconVisualToken 机制防止异步加载竞态。
*
* 依赖:
* - HeroInfo / heroSet —— 英雄静态配置
* - getSortedHeroList(heroUpSet)—— 图鉴排序
* - smc.getHeroGrowth / smc.getHeroOuterLv / smc.upgradeHero —— 成长数据
* - buildHeroSkillDesc(HeroSkillText)—— 技能描述生成器
* - SSkillBoxComp / ChipComp / SmallTipComp —— 子组件
*/
import { _decorator, Animation, AnimationClip, Button, EventTouch, Label, Node, NodeEventType, Prefab, RichText, Sprite, Tween, Vec3, instantiate, resources, tween } 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 { mLogger } from "../common/Logger";
import { HeroInfo, SkillTriggerType, LvFieldEntry, LvTriggerEntry, TriggerGrouped, HSkillInfo, getHeroDisplayStats, LvBonusAttrsEntry } from "../common/config/heroSet";
import { buildHeroSkillDesc } from "../common/HeroSkillText";
import { outlineRich } from "../common/RichOutline";
import { GameEvent } from "../common/config/GameEvent";
import { HERO_UPGRADE_NEED_FRAGMENTS, RARITY_BG_COLORS } from "../common/config/GameSet";
import { getSortedHeroList } from "../common/config/heroUpSet";
import { ChipComp } from "./ChipComp";
import { SmallTipComp } from "./SmallTipComp";
import { SSkillBoxComp } from "./SSkillBoxComp";
import { smc } from "../common/SingletonModuleComp";
const { property, ccclass } = _decorator;
/** HeroInfo 弹窗初始化参数 */
export interface HeroInfoParams {
/** 英雄模板 uuid */
uuid: number;
}
@ccclass('HeroInfoComp')
@ecs.register('HeroInfoComp', false)
export class HeroInfoComp extends CCComp {
// ======================== 编辑器绑定节点 ========================
@property(Node)
hero_icon = null!
@property(Node)
ap_node = null!
@property(Node)
def_node = null!
@property(Node)
hp_node = null!
@property(Node)
cd_node = null!
/** 卡牌背景底框节点(按卡池等级切换子节点显示) */
@property(Node)
BG_node = null!
@property(Node)
info_node = null!
@property(Node)
name_node = null!
/** 卡池等级 Label,直接显示 X */
@property(Label)
lv_label = null!
/** 职业展示节点(子节点按 HRole 枚举值命名 0~5) */
@property(Node)
role_node = null!
/** 升级按钮节点(smalltip 气泡已作为其子节点挂在下面) */
@property(Node)
upgrade_btn = null!
/** 碎片数量显示节点(Label) */
@property(Node)
fragment_node = null!
/** 碎片图标节点(挂载 ChipComp 用于渲染碎片图标) */
@property(Node)
chip_node = null!
/** 技能描述节点 */
@property(Node)
infos_node = null!
/** 技能图标容器节点 */
@property(Node)
skills_node = null!
/** 技能小图标预制体(根节点挂 SSkillBoxComp) */
@property(Prefab)
skill_icon_prefab = null!
/** 升级特效动画节点(内含升级效果 Animation,点击升级时从头播放) */
@property(Node)
upgrade_anim_node = null!
/** 碎片需求/总量 Label(显示 当前碎片 / 下级需求) */
@property(Label)
fragment_label = null!
/** 升级提升加成说明 RichText(展示升级后的属性提升,数值绿色高亮) */
@property(RichText)
upgrade_info_label = null!
/** 关闭按钮节点 */
@property({ type: Node, displayName: "关闭按钮" })
close_btn: Node = null!
/** 上一个英雄切换按钮节点 */
@property({ type: Node, displayName: "上一个英雄按钮" })
prev_btn: Node = null!
/** 下一个英雄切换按钮节点 */
@property({ type: Node, displayName: "下一个英雄按钮" })
next_btn: Node = null!
/** 全屏遮罩节点(点击弹窗主体以外区域关闭,可选) */
@property({ type: Node, displayName: "遮罩节点" })
mask_node: Node = null!
// ======================== 运行时状态 ========================
/** 当前展示的英雄模板 uuid */
huuid: number = 0
private iconVisualToken: number = 0
debugMode: boolean = false
// ======================== 生命周期 ========================
protected onLoad(): void {
// 关闭按钮点击
if (this.close_btn && this.close_btn.isValid) {
this.close_btn.on(NodeEventType.TOUCH_END, this.onCloseClick, this);
}
// 前后切换按钮
if (this.prev_btn && this.prev_btn.isValid) {
this.prev_btn.on(NodeEventType.TOUCH_END, this.onPrevClick, this);
}
if (this.next_btn && this.next_btn.isValid) {
this.next_btn.on(NodeEventType.TOUCH_END, this.onNextClick, this);
}
// 遮罩点击关闭
if (this.mask_node && this.mask_node.isValid) {
this.mask_node.on(NodeEventType.TOUCH_END, this.onMaskClick, this);
}
}
/**
* oops.gui 添加到舞台回调(框架约定入口)
* @param params 见 HeroInfoParams
*/
onAdded(params: HeroInfoParams) {
this.huuid = params?.uuid ?? 0;
this.bindUpgradeBtn();
if (this.upgrade_anim_node) this.upgrade_anim_node.active = false;
if (this.huuid) {
this.updateHeroDetail(this.huuid);
}
}
protected onEnable(): void {
oops.message.on(GameEvent.HERO_GROWTH_UPDATE, this.onGrowthUpdate, this);
}
protected onDisable(): void {
oops.message.off(GameEvent.HERO_GROWTH_UPDATE, this.onGrowthUpdate, this);
}
onDestroy() {
super.onDestroy();
if (this.close_btn && this.close_btn.isValid) {
this.close_btn.off(NodeEventType.TOUCH_END, this.onCloseClick, this);
}
if (this.prev_btn && this.prev_btn.isValid) {
this.prev_btn.off(NodeEventType.TOUCH_END, this.onPrevClick, this);
}
if (this.next_btn && this.next_btn.isValid) {
this.next_btn.off(NodeEventType.TOUCH_END, this.onNextClick, this);
}
if (this.mask_node && this.mask_node.isValid) {
this.mask_node.off(NodeEventType.TOUCH_END, this.onMaskClick, this);
}
if (this.upgrade_btn && this.upgrade_btn.isValid) {
this.upgrade_btn.off(Button.EventType.CLICK, this.onUpgradeClick, this);
}
}
private onGrowthUpdate() {
if (this.huuid) {
this.updateHeroDetail(this.huuid);
}
}
// ======================== 按钮回调 ========================
/** 关闭按钮点击 */
private onCloseClick(event: EventTouch) {
event.propagationStopped = true;
oops.audio.playEffect("music/button");
this.close();
}
/** 遮罩点击关闭 */
private onMaskClick(event: EventTouch) {
event.propagationStopped = true;
oops.audio.playEffect("music/button");
this.close();
}
/** 关闭弹窗 */
private close() {
if (!this.node || !this.node.isValid || !this.node.parent) return;
oops.gui.removeByNode(this.node);
}
/** 切换到上一个英雄(按图鉴排序循环) */
private onPrevClick(event: EventTouch) {
event.propagationStopped = true;
oops.audio.playEffect("music/button");
this.switchHero(-1);
}
/** 切换到下一个英雄(按图鉴排序循环) */
private onNextClick(event: EventTouch) {
event.propagationStopped = true;
oops.audio.playEffect("music/button");
this.switchHero(1);
}
/**
* 按图鉴排序顺序循环切换英雄
* @param delta 1 下一个,-1 上一个
*/
private switchHero(delta: 1 | -1) {
const sorted = getSortedHeroList(uuid => smc.getHeroGrowth(uuid));
if (sorted.length === 0) return;
const idx = sorted.indexOf(this.huuid);
// 当前 uuid 不在列表中(异常)时回退到第一个
const cur = idx >= 0 ? idx : 0;
const next = (cur + delta + sorted.length) % sorted.length;
this.huuid = sorted[next];
this.updateHeroDetail(this.huuid);
}
// ======================== 升级 ========================
/**
* 绑定升级按钮点击事件
* 在 onAdded 中调用,确保 @property 属性已正确赋值
*/
private bindUpgradeBtn() {
if (!this.upgrade_btn) {
mLogger.error(this.debugMode, "HeroInfoComp", "upgrade_btn 未绑定,请检查编辑器属性面板");
return;
}
// 确保 smalltip 气泡初始隐藏,避免遮挡按钮触摸(组件缺失时兜底补挂)
const tipNode = this.upgrade_btn.getChildByName("smalltip");
if (tipNode) {
tipNode.getComponent(SmallTipComp) ?? tipNode.addComponent(SmallTipComp);
tipNode.active = false;
}
let btn = this.upgrade_btn.getComponent(Button);
if (!btn) btn = this.upgrade_btn.addComponent(Button);
btn.interactable = true;
this.upgrade_btn.off(Button.EventType.CLICK, this.onUpgradeClick, this);
this.upgrade_btn.on(Button.EventType.CLICK, this.onUpgradeClick, this);
}
/**
* 升级按钮点击回调
* 消耗碎片提升英雄局外等级,成功后刷新详情面板
*/
onUpgradeClick() {
const uuid = this.huuid;
if (!uuid) return;
this.playUpgradeBtnAnim();
const success = smc.upgradeHero(uuid);
if (success) {
mLogger.log(this.debugMode, "HeroInfoComp", `英雄 ${uuid} 升级成功`);
this.playUpgradeAnim();
this.updateHeroDetail(uuid);
} else {
const growth = smc.getHeroGrowth(uuid);
const need = HERO_UPGRADE_NEED_FRAGMENTS[growth.lv + 1];
// 碎片不足 / 已满级:气泡提示
const tipText = need === undefined ? "已满级" : "碎片不足";
this.showSmallTip(tipText);
}
}
/**
* 播放升级按钮按压回弹动画
*/
private playUpgradeBtnAnim() {
if (!this.upgrade_btn) return;
Tween.stopAllByTarget(this.upgrade_btn);
this.upgrade_btn.setScale(1, 1, 1);
tween(this.upgrade_btn)
.to(0.08, { scale: new Vec3(0.9, 0.9, 1) }, { easing: 'quadOut' })
.to(0.12, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' })
.to(0.08, { scale: new Vec3(1, 1, 1) }, { easing: 'quadIn' })
.start();
}
/**
* 播放升级特效动画
* 每次升级成功均从头播放;动画未播完时再次升级,立即重新开始
*/
private playUpgradeAnim() {
if (!this.upgrade_anim_node) return;
const anim = this.upgrade_anim_node.getComponent(Animation);
const clip = anim?.clips?.[0];
if (!anim || !clip) {
mLogger.log(this.debugMode, "HeroInfoComp", "playUpgradeAnim: 缺少 Animation 组件或动画剪辑");
return;
}
this.upgrade_anim_node.active = true;
anim.stop();
const animState = anim.getState(clip.name);
if (animState) {
animState.off('finished', this.onUpgradeAnimFinished, this);
animState.once('finished', this.onUpgradeAnimFinished, this);
}
anim.play(clip.name);
}
/**
* 升级特效动画播放结束回调:隐藏特效节点
*/
private onUpgradeAnimFinished() {
if (this.upgrade_anim_node && this.upgrade_anim_node.isValid) {
this.upgrade_anim_node.active = false;
}
}
/**
* 在升级按钮附近弹出 smalltip 气泡提示
* @param text 提示文案
*/
private showSmallTip(text: string) {
const tipNode = this.upgrade_btn?.getChildByName("smalltip");
if (!tipNode || !tipNode.isValid) {
mLogger.log(this.debugMode, "HeroInfoComp", `未在 upgrade_btn 下找到 smalltip 节点,无法提示:${text}`);
return;
}
const tip = tipNode.getComponent(SmallTipComp) ?? tipNode.addComponent(SmallTipComp);
tip.show(text);
}
// ======================== 详情面板 ========================
private updateHeroDetail(uuid: number) {
const hero = HeroInfo[uuid];
if (!hero) return;
// 属性按当前局外等级计算
const heroLv = Math.max(1, smc.getHeroOuterLv(uuid));
this.setLabelText(this.name_node, hero.name || "");
const stats = getHeroDisplayStats(hero, heroLv);
this.setLabelText(this.ap_node, `${stats.ap}`);
this.setLabelText(this.def_node, `${stats.def}`);
this.setLabelText(this.hp_node, `${stats.hp}`);
this.updateCdDisplay(hero);
if (this.info_node) {
// Label 承载:HeroSkillText 输出富文本,剥离 BBCode 标签转纯文本
const desc = buildHeroSkillDesc(hero.uuid, true, true).replace(/<[^>]+>/g, "");
const infoLabel = this.info_node.getChildByName("info")?.getComponent(Label)
|| this.info_node.getComponent(Label)
|| this.info_node.getComponentInChildren(Label);
if (infoLabel) infoLabel.string = desc || hero.info || "";
}
this.updatePoolLvBg(hero.rarity ?? 1);
this.updateLvDisplay(uuid);
this.updateRoleDisplay(hero);
this.updateHeroAnimation(uuid);
this.updateGrowthPanel(uuid);
this.updateSkillIcons(hero);
}
private updatePoolLvBg(rarity: number) {
if (!this.BG_node) return;
// BG_node 下直接放置颜色节点(green/blue/purple/yellow/red),按卡池等级激活对应颜色
const color = RARITY_BG_COLORS[Math.min(rarity, 5) - 1];
this.BG_node.children.forEach(child => {
child.active = (child.name === color);
});
}
private updateCdDisplay(hero: typeof HeroInfo[number]) {
if (!this.cd_node) return;
const skillKeys = Object.keys(hero.skills);
if (skillKeys.length === 0) return;
const firstSkill = hero.skills[Number(skillKeys[0])];
if (firstSkill) {
this.setLabelText(this.cd_node, `${firstSkill.cd}s`);
}
}
private updateLvDisplay(uuid: number) {
if (!this.lv_label) return;
const outerLv = smc.getHeroOuterLv(uuid);
this.lv_label.string = `${outerLv}`;
}
private updateRoleDisplay(hero: typeof HeroInfo[number]) {
if (!this.role_node) return;
this.role_node.active = true;
const roleStr = `${hero.role ?? 0}`;
this.role_node.children.forEach(child => {
child.active = (child.name === roleStr);
});
}
/**
* 更新局外成长面板:碎片数量与升级按钮状态
* @param uuid 英雄模板 uuid
*/
private updateGrowthPanel(uuid: number) {
const growth = smc.getHeroGrowth(uuid);
const needFragments = HERO_UPGRADE_NEED_FRAGMENTS[growth.lv + 1];
const isMaxLv = needFragments === undefined;
// 碎片数量显示:当前拥有 / 下一级所需(满级时显示 MAX)
const fragmentText = isMaxLv
? `${growth.fragments} / MAX`
: `${growth.fragments} / ${needFragments}`;
if (this.fragment_node) {
this.setLabelText(this.fragment_node, fragmentText);
}
if (this.fragment_label) {
this.fragment_label.string = fragmentText;
}
// 升级提升加成说明
this.updateUpgradeInfoLabel(uuid, growth.lv, isMaxLv);
// 碎片图标:使用 ChipComp 渲染英雄图标
if (this.chip_node) {
const chipComp = this.chip_node.getComponent(ChipComp) || this.chip_node.addComponent(ChipComp);
chipComp.setHeroByUuid(uuid);
}
// 升级按钮状态:始终可点击,碎片不足时通过气泡提示
if (this.upgrade_btn) {
this.upgrade_btn.active = true;
const btnComp = this.upgrade_btn.getComponent(Button);
if (btnComp) btnComp.interactable = true;
}
}
// ======================== 技能图标列表 ========================
/**
* 刷新升级提升加成说明 RichText
* 展示口径:对比当前等级与下一等级,仅列出 AP/HP/DEF 属性差值,单行展示
*/
private updateUpgradeInfoLabel(uuid: number, curLv: number, isMaxLv: boolean) {
if (!this.upgrade_info_label) return;
const hero = HeroInfo[uuid];
if (!hero) { this.upgrade_info_label.string = ""; return; }
if (isMaxLv) {
this.upgrade_info_label.string = outlineRich("已满级");
return;
}
const nextLv = curLv + 1;
const cur = getHeroDisplayStats(hero, curLv);
const nxt = getHeroDisplayStats(hero, nextLv);
// 属性提升差值:单行展示 AP/HP/DEF,数值绿色高亮
const parts: string[] = [];
if (nxt.hp > cur.hp) parts.push(`生命<color=#00FF00>+${nxt.hp - cur.hp}</color>`);
if (nxt.ap > cur.ap) parts.push(`攻击<color=#00FF00>+${nxt.ap - cur.ap}</color>`);
if (nxt.def > cur.def) parts.push(`防御<color=#00FF00>+${nxt.def - cur.def}</color>`);
this.upgrade_info_label.string = parts.length > 0
? outlineRich(`升级属性提升:${parts.join(" ")}`)
: outlineRich(`升级至 Lv.${nextLv}`);
}
/** 详情面板展示的触发技能槽位(按触发时机排序) */
private static readonly PANEL_TRIGGER_TYPES: SkillTriggerType[] = [
SkillTriggerType.Call,
SkillTriggerType.FStart,
SkillTriggerType.Atking,
SkillTriggerType.Atked,
SkillTriggerType.Dead,
SkillTriggerType.FEnd,
SkillTriggerType.Field,
];
/**
* 刷新当前英雄的技能图标列表:
* 1. 大招:skills[1] 及以后(首个为普攻,跳过)。
* 2. 触发技能:每个触发槽位整组视为一个技能。
* 以局外等级为当前等级,未达解锁档的显示 locked。
*/
private updateSkillIcons(hero: typeof HeroInfo[number]) {
if (!this.skills_node || !this.skill_icon_prefab) return;
// 清空旧图标(显式销毁,避免节点泄漏)
for (let i = this.skills_node.children.length - 1; i >= 0; i--) {
this.skills_node.children[i].destroy();
}
const lv = Math.max(1, smc.getHeroOuterLv(hero.uuid));
// ---- 大招:skills[1] 及以后 ----
Object.values(hero.skills).slice(1).forEach(hSkill => {
const sLv = hSkill.lv_entries?.length
? (hSkill.lv_entries.filter(e => e.lv <= lv).pop()?.s_lv ?? hSkill.lv)
: hSkill.lv;
this.addSkillIcon(hSkill.uuid, sLv, false, false);
});
// ---- 普攻 lv2 强化档 ----
this.addNormalLv2Icon(Object.values(hero.skills)[0], lv);
// ---- 触发技能 ----
for (const type of HeroInfoComp.PANEL_TRIGGER_TYPES) {
if (type === SkillTriggerType.Field) {
this.addFieldGroupIcon(hero[SkillTriggerType.Field], lv);
} else {
this.addTriggerGroupIcon(hero[type] as TriggerGrouped | undefined, lv, type);
}
}
// ---- bonus 档位 ----
this.addBonusIcons(hero.bonus, lv);
}
/**
* bonus 档位图标:配置了 icon 的档位各出一个图标(追加在技能列表最末)。
*/
private addBonusIcons(bonus: LvBonusAttrsEntry[] | undefined, lv: number) {
if (!bonus?.length) return;
for (const entry of bonus) {
if (!entry.icon) continue;
const node = instantiate(this.skill_icon_prefab);
node.name = `bonus_lv${entry.lv}`;
const comp = node.getComponent(SSkillBoxComp) || node.addComponent(SSkillBoxComp);
comp.initBonus(this.huuid, entry, lv < entry.lv);
this.skills_node.addChild(node);
}
}
/**
* 实例化技能小图标并初始化
*/
private addSkillIcon(s_uuid: number, s_lv: number, locked: boolean, isField: boolean, slot?: SkillTriggerType, isGroup: boolean = false, normalLv2: boolean = false, unlockLv?: number) {
const node = instantiate(this.skill_icon_prefab);
node.name = `skill_${s_uuid}`;
const comp = node.getComponent(SSkillBoxComp) || node.addComponent(SSkillBoxComp);
comp.initHeroSkill(s_uuid, s_lv, locked, isField, this.huuid, slot, isGroup, normalLv2, unlockLv);
this.skills_node.addChild(node);
}
/**
* 触发技能槽位(call/fstart/atking/atked/dead)整组一个图标
*/
private addTriggerGroupIcon(grouped: TriggerGrouped | undefined, lv: number, type: SkillTriggerType) {
if (!grouped) return;
const keys = Object.keys(grouped).map(Number).filter(k => grouped[k]?.length);
if (keys.length === 0) return;
const sorted = [...grouped[keys[0]]].sort((a, b) => a.lv - b.lv);
let active: LvTriggerEntry | undefined;
for (const e of sorted) { if (e.lv <= lv) active = e; }
let anyActive = !!active;
for (const key of keys) {
if (anyActive) break;
for (const e of grouped[key] ?? []) {
if (e.lv <= lv) { anyActive = true; break; }
}
}
const pick = active ?? sorted[0];
this.addSkillIcon(keys[0], pick.s_lv ?? 1, !anyActive, false, type, true, false, anyActive ? undefined : pick.lv);
}
/**
* 驻场光环槽位整组一个图标
*/
private addFieldGroupIcon(field: LvFieldEntry[] | undefined, lv: number) {
if (!field?.length) return;
const sorted = [...field].sort((a, b) => a.lv - b.lv);
let active: LvFieldEntry | undefined;
for (const e of sorted) { if (e.lv <= lv) active = e; }
const pick = active ?? sorted[0];
const firstUuid = pick.uuids[0]?.uuid;
if (!firstUuid) return;
this.addSkillIcon(firstUuid, 1, !active, true, SkillTriggerType.Field, true, false, active ? undefined : pick.lv);
}
/**
* 普攻 lv2+ 强化档作为独立技能图标
*/
private addNormalLv2Icon(normal: HSkillInfo | undefined, lv: number) {
const entry = normal?.lv_entries?.[0];
if (!normal || !entry) return;
this.addSkillIcon(normal.uuid, entry.s_lv ?? 2, lv < entry.lv, false, undefined, false, true, entry.lv);
const icon = this.skills_node.children[this.skills_node.children.length - 1];
if (icon) icon.name = `skill_${normal.uuid}_lv2`;
}
// ======================== 英雄动画 ========================
private updateHeroAnimation(uuid: number) {
if (!this.hero_icon) return;
const hero = HeroInfo[uuid];
if (!hero) return;
const sprite = this.hero_icon.getComponent(Sprite) || this.hero_icon.getComponentInChildren(Sprite);
if (sprite) sprite.spriteFrame = null;
const anim = this.hero_icon.getComponent(Animation) || this.hero_icon.addComponent(Animation);
this.clearAnimationClips(anim);
this.iconVisualToken += 1;
const token = this.iconVisualToken;
const path = `game/heros/hero/${hero.path}/idle`;
resources.load(path, AnimationClip, (err, clip) => {
if (err || !clip) {
mLogger.log(this.debugMode, "HeroInfoComp", `load hero animation failed ${uuid}`, err);
return;
}
if (token !== this.iconVisualToken) return;
this.clearAnimationClips(anim);
anim.addClip(clip);
anim.play("idle");
});
}
private clearAnimationClips(anim: Animation) {
const clips = anim.clips;
if (clips && clips.length > 0) {
for (let i = clips.length - 1; i >= 0; i--) {
const clip = clips[i];
if (clip) anim.removeClip(clip, true);
}
}
}
// ======================== UI 工具 ========================
private setLabelText(node: Node, text: string) {
if (!node) return;
const label = node.getComponent(Label) || node.getComponentInChildren(Label);
if (label) {
label.string = text;
}
}
reset() {
this.node.destroy();
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "16f4e273-a558-4bbe-8d0d-2c0210d9e9fe",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,37 +1,32 @@
/** /**
* @file HerosListComp.ts * @file HerosListComp.ts
* @description 英雄图鉴弹出页面(UI 视图层) * @description 英雄图鉴页面(UI 视图层)
* *
* 职责: * 职责:
* 1. 以卡片列表形式展示所有可用英雄(使用 CardLiteComp 预制体)。 * 1. 以卡片列表形式展示所有可用英雄(使用 CardLiteComp 预制体)。
* 2. 点击卡片选中英雄,右侧详情面板显示 idle 动画、名称、AP、HP、CD、技能描述。 * 2. 点击卡片打开英雄详情弹窗(UIID.HeroInfo),详情/升级在 HeroInfoComp 中处理。
* 3. 支持卡池等级筛选(全部 / Lv1 / Lv2 / Lv3)。 * 3. 卡片排序:可升级优先 → 现有等级降序 → 品质降序(getSortedHeroList)。
* *
* 关键设计: * 关键设计:
* - cards_node 为卡片容器,通过 instantiate(card_lite_prefab) 动态生成卡片。 * - cards_node 为卡片容器,通过 instantiate(card_lite_prefab) 动态生成卡片。
* - 选中状态通过 selectNode 高亮管理,同一时间只有一张卡片高亮。 * - 升级/碎片变动后通过 HERO_GROWTH_UPDATE 事件统一刷新所有卡片的可升级标识。
* - hero_icon 使用 Animation + iconVisualToken 机制防止异步加载竞态。 * - 详情面板已从本组件剥离,由 HeroInfoComp 承载。
* *
* 依赖: * 依赖:
* - HeroInfo / HeroList(heroSet)—— 英雄静态配置与全量 UUID 列表 * - getSortedHeroList(heroUpSet)—— 图鉴排序
* - CardLiteComp —— 轻量卡片组件 * - CardLiteComp —— 轻量卡片组件
* - buildHeroSkillDesc(HeroSkillText)—— 技能描述生成器(局外等级口径) * - UIID.HeroInfo(oops.gui)—— 英雄详情弹窗
*/ */
import { _decorator, Animation, AnimationClip, Button, Label, Node, Prefab, RichText, Sprite, UITransform, Vec3, Widget, instantiate, resources, tween, Tween } from "cc"; import { _decorator, Node, Prefab, UITransform, instantiate } from "cc";
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS"; import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp"; import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { oops } from "db://oops-framework/core/Oops"; import { oops } from "db://oops-framework/core/Oops";
import { mLogger } from "../common/Logger"; import { mLogger } from "../common/Logger";
import { HeroInfo, HeroList, SkillTriggerType, LvFieldEntry, LvTriggerEntry, TriggerGrouped, HSkillInfo, getHeroDisplayStats, LvBonusAttrsEntry } from "../common/config/heroSet"; import { HeroInfo, HeroList } from "../common/config/heroSet";
import { buildHeroSkillDesc } from "../common/HeroSkillText";
import { outlineRich } from "../common/RichOutline";
import { GameEvent } from "../common/config/GameEvent"; import { GameEvent } from "../common/config/GameEvent";
import { HERO_UPGRADE_NEED_FRAGMENTS, RARITY_BG_COLORS } from "../common/config/GameSet"; import { getSortedHeroList } from "../common/config/heroUpSet";
import { canHeroUpgrade } from "../common/config/heroUpSet"; import { UIID } from "../common/config/GameUIConfig";
import { CardLiteComp } from "./CardLiteComp"; import { CardLiteComp } from "./CardLiteComp";
import { ChipComp } from "./ChipComp";
import { SmallTipComp } from "./SmallTipComp";
import { SSkillBoxComp } from "./SSkillBoxComp";
import { smc } from "../common/SingletonModuleComp"; import { smc } from "../common/SingletonModuleComp";
const { property, ccclass } = _decorator; const { property, ccclass } = _decorator;
@@ -41,80 +36,12 @@ const { property, ccclass } = _decorator;
export class HerosListComp extends CCComp { export class HerosListComp extends CCComp {
// ======================== 编辑器绑定节点 ======================== // ======================== 编辑器绑定节点 ========================
@property(Node)
hero_icon = null!
@property(Node)
ap_node = null!
@property(Node)
def_node = null!
@property(Node)
hp_node = null!
@property(Node)
cd_node = null!
/** 卡牌背景底框节点(按卡池等级切换子节点显示) */
@property(Node)
BG_node = null!
@property(Node)
info_node = null!
@property(Node)
name_node = null!
@property(Node) @property(Node)
cards_node = null! cards_node = null!
@property(Prefab) @property(Prefab)
card_lite_prefab = null! card_lite_prefab = null!
/** 卡池等级 Label,直接显示 X */
@property(Label)
lv_label = null!
/** 职业展示节点(子节点按 HRole 枚举值命名 0~5:0=坦克 1=战士 2=刺客 3=射手 4=法师 5=辅助) */
@property(Node)
role_node = null!
/** 升级按钮节点(smalltip 气泡已作为其子节点挂在下面) */
@property(Node)
upgrade_btn = null!
/** 碎片数量显示节点(Label) */
@property(Node)
fragment_node = null!
/** 碎片图标节点(挂载 ChipComp 用于渲染碎片图标) */
@property(Node)
chip_node = null!
/** 技能描述节点() */
@property(Node)
infos_node = null!
/** 技能图标容器节点(动态挂载 SSkillBoxComp 实例,展示当前英雄技能) */
@property(Node)
skills_node = null!
/** 技能小图标预制体(根节点挂 SSkillBoxComp) */
@property(Prefab)
skill_icon_prefab = null!
/** 升级特效动画节点(内含升级效果 Animation,点击升级时从头播放) */
@property(Node)
upgrade_anim_node = null!
/** 碎片需求/总量 Label(显示 当前碎片 / 下级需求) */
@property(Label)
fragment_label = null!
/** 升级提升加成说明 RichText(展示升级后的属性提升,数值绿色高亮) */
@property(RichText)
upgrade_info_label = null!
/** 卡片宽度 */ /** 卡片宽度 */
@property @property
card_w = 120 card_w = 120
@@ -129,61 +56,14 @@ export class HerosListComp extends CCComp {
// ======================== 运行时状态 ======================== // ======================== 运行时状态 ========================
huuid: number = 0
private iconVisualToken: number = 0
private selectNode: Node | null = null
debugMode: boolean = false debugMode: boolean = false
start() { start() {
this.bindUpgradeBtn()
this.initCardList() this.initCardList()
// 升级特效动画初始隐藏,点击升级时才显示播放
if (this.upgrade_anim_node) this.upgrade_anim_node.active = false
if (HeroList.length > 0) {
this.onCardSelect(HeroList[0])
}
}
protected onLoad(): void {
// onLoad 阶段属性可能尚未绑定,延迟到 start 再处理
}
/**
* 绑定升级按钮点击事件
* 在 start 中调用,确保 @property 属性已正确赋值
*/
private bindUpgradeBtn() {
mLogger.log(this.debugMode, "HerosListComp", "bindUpgradeBtn", {
hasUpgradeBtn: !!this.upgrade_btn,
upgradeBtnName: this.upgrade_btn?.name,
upgradeBtnActive: this.upgrade_btn?.active,
upgradeBtnValid: this.upgrade_btn?.isValid,
})
if (!this.upgrade_btn) {
mLogger.error(this.debugMode, "HerosListComp", "upgrade_btn 未绑定,请检查编辑器属性面板")
return
}
// 确保 smalltip 气泡初始隐藏,避免遮挡按钮触摸(组件缺失时兜底补挂)
const tipNode = this.upgrade_btn.getChildByName("smalltip")
if (tipNode) {
tipNode.getComponent(SmallTipComp) ?? tipNode.addComponent(SmallTipComp)
tipNode.active = false
}
let btn = this.upgrade_btn.getComponent(Button)
if (!btn) btn = this.upgrade_btn.addComponent(Button)
btn.interactable = true
this.upgrade_btn.on(Button.EventType.CLICK, this.onUpgradeClick, this)
mLogger.log(this.debugMode, "HerosListComp", "升级按钮点击事件已绑定")
} }
protected onEnable(): void { protected onEnable(): void {
oops.message.on(GameEvent.HERO_GROWTH_UPDATE, this.onGrowthUpdate, this) oops.message.on(GameEvent.HERO_GROWTH_UPDATE, this.onGrowthUpdate, this)
if (this.cards_node && this.cards_node.children.length > 0) {
this.onCardSelect(this.huuid || HeroList[0])
}
} }
protected onDisable(): void { protected onDisable(): void {
@@ -191,13 +71,10 @@ export class HerosListComp extends CCComp {
} }
private onGrowthUpdate() { private onGrowthUpdate() {
if (this.huuid) {
this.updateHeroDetail(this.huuid)
}
this.refreshAllCardsCanUp() this.refreshAllCardsCanUp()
} }
/** 升级/碎片变动后统一刷新所有卡片的可升级标识与等级数字(升级入口仅在本面板,无需卡片各自监听事件) */ /** 升级/碎片变动后统一刷新所有卡片的可升级标识与等级数字 */
private refreshAllCardsCanUp() { private refreshAllCardsCanUp() {
if (!this.cards_node) return if (!this.cards_node) return
this.cards_node.children.forEach(cardNode => { this.cards_node.children.forEach(cardNode => {
@@ -211,143 +88,15 @@ export class HerosListComp extends CCComp {
this.node.active = false this.node.active = false
} }
/**
* 升级按钮点击回调
* 消耗碎片提升英雄局外等级,成功后刷新详情面板
*/
onUpgradeClick() {
mLogger.log(this.debugMode, "HerosListComp", "onUpgradeClick 被触发", { huuid: this.huuid })
const uuid = this.huuid
if (!uuid) return
// 点击动画:按压回弹
this.playUpgradeBtnAnim()
const success = smc.upgradeHero(uuid)
if (success) {
mLogger.log(this.debugMode, "HerosListComp", `英雄 ${uuid} 升级成功`)
this.playUpgradeAnim()
this.updateHeroDetail(uuid)
} else {
const growth = smc.getHeroGrowth(uuid)
const need = HERO_UPGRADE_NEED_FRAGMENTS[growth.lv + 1]
mLogger.log(this.debugMode, "HerosListComp", `英雄 ${uuid} 升级失败`, {
currentLv: growth.lv,
fragments: growth.fragments,
needFragments: need ?? "MAX",
})
// 碎片不足 / 已满级:气泡提示
const tipText = need === undefined ? "已满级" : "碎片不足"
this.showSmallTip(tipText)
}
}
/**
* 播放升级按钮按压回弹动画
*/
private playUpgradeBtnAnim() {
if (!this.upgrade_btn) {
mLogger.log(this.debugMode, "HerosListComp", "playUpgradeBtnAnim: upgrade_btn 为空")
return
}
mLogger.log(this.debugMode, "HerosListComp", "播放按钮动画")
Tween.stopAllByTarget(this.upgrade_btn)
this.upgrade_btn.setScale(1, 1, 1)
tween(this.upgrade_btn)
.to(0.08, { scale: new Vec3(0.9, 0.9, 1) }, { easing: 'quadOut' })
.to(0.12, { scale: new Vec3(1.1, 1.1, 1) }, { easing: 'quadOut' })
.to(0.08, { scale: new Vec3(1, 1, 1) }, { easing: 'quadIn' })
.start()
}
/**
* 播放升级特效动画
* 每次升级成功均从头播放;动画未播完时再次升级,立即重新开始
*/
private playUpgradeAnim() {
if (!this.upgrade_anim_node) {
mLogger.log(this.debugMode, "HerosListComp", "playUpgradeAnim: upgrade_anim_node 未绑定")
return
}
const anim = this.upgrade_anim_node.getComponent(Animation)
const clip = anim?.clips?.[0]
if (!anim || !clip) {
mLogger.log(this.debugMode, "HerosListComp", "playUpgradeAnim: 缺少 Animation 组件或动画剪辑")
return
}
this.upgrade_anim_node.active = true
// 停止当前播放(如有),保证从头开始
anim.stop()
const animState = anim.getState(clip.name)
if (animState) {
// 防止重复注册,播放结束后自动隐藏特效节点
animState.off('finished', this.onUpgradeAnimFinished, this)
animState.once('finished', this.onUpgradeAnimFinished, this)
}
anim.play(clip.name)
}
/**
* 升级特效动画播放结束回调:隐藏特效节点
*/
private onUpgradeAnimFinished() {
if (this.upgrade_anim_node && this.upgrade_anim_node.isValid) {
this.upgrade_anim_node.active = false
}
}
/**
* 在升级按钮附近弹出 smalltip 气泡提示
* @param text 提示文案
*/
private showSmallTip(text: string) {
// smalltip 作为 upgrade_btn 的子节点挂在下面,统一走 SmallTipComp 播动画
const tipNode = this.upgrade_btn?.getChildByName("smalltip")
if (!tipNode || !tipNode.isValid) {
mLogger.log(this.debugMode, "HerosListComp", `未在 upgrade_btn 下找到 smalltip 节点,无法提示:${text}`)
return
}
const tip = tipNode.getComponent(SmallTipComp) ?? tipNode.addComponent(SmallTipComp)
tip.show(text)
}
// ======================== 卡片列表 ======================== // ======================== 卡片列表 ========================
private initCardList() { private initCardList() {
mLogger.log(this.debugMode, "HerosListComp", "initCardList start", {
hasCardsNode: !!this.cards_node,
hasPrefab: !!this.card_lite_prefab,
heroListLen: HeroList.length,
})
if (!this.cards_node || !this.card_lite_prefab) return if (!this.cards_node || !this.card_lite_prefab) return
mLogger.log(this.debugMode, "HerosListComp", "cards_node info", {
name: this.cards_node.name,
active: this.cards_node.active,
childrenCount: this.cards_node.children.length,
pos: this.cards_node.position.toString(),
scale: this.cards_node.scale.toString(),
parentName: this.cards_node.parent?.name || "none",
})
this.cards_node.removeAllChildren() this.cards_node.removeAllChildren()
const sorted = [...HeroList].sort((a, b) => { // 排序与详情面板"前后切换"同源(getSortedHeroList),保证两个入口顺序一致
const ga = smc.getHeroGrowth(a) const sorted = getSortedHeroList(uuid => smc.getHeroGrowth(uuid))
const gb = smc.getHeroGrowth(b)
// 可升级的一批排在最左
const ca = canHeroUpgrade(ga.lv, ga.fragments) ? 0 : 1
const cb = canHeroUpgrade(gb.lv, gb.fragments) ? 0 : 1
if (ca !== cb) return ca - cb
// 同批内:现有等级降序优先
if (ga.lv !== gb.lv) return gb.lv - ga.lv
// 等级相同:品质降序(rarity 越大品质越高)
return (HeroInfo[b]?.rarity ?? 1) - (HeroInfo[a]?.rarity ?? 1)
});
for (const uuid of sorted) { for (const uuid of sorted) {
const hero = HeroInfo[uuid] const hero = HeroInfo[uuid]
@@ -356,20 +105,12 @@ export class HerosListComp extends CCComp {
const cardNode = instantiate(this.card_lite_prefab) const cardNode = instantiate(this.card_lite_prefab)
cardNode.name = `card_${uuid}` cardNode.name = `card_${uuid}`
mLogger.log(this.debugMode, "HerosListComp", `card instantiated ${uuid}`, {
cardActive: cardNode.active,
cardPos: cardNode.position.toString(),
cardScale: cardNode.scale.toString(),
cardChildren: cardNode.children.map(c => c.name),
cardWidth: cardNode.getComponent("cc.UITransform")?.width,
cardHeight: cardNode.getComponent("cc.UITransform")?.height,
})
const comp = cardNode.getComponent(CardLiteComp) || cardNode.addComponent(CardLiteComp) const comp = cardNode.getComponent(CardLiteComp) || cardNode.addComponent(CardLiteComp)
comp.setHeroByUuid(uuid) comp.setHeroByUuid(uuid)
comp.onClickCallback = (cardComp: CardLiteComp) => { comp.onClickCallback = () => {
this.onCardSelect(uuid) // 点击卡片:打开英雄详情弹窗(详情/升级/前后切换由 HeroInfoComp 承载)
this.highlightCard(cardNode) oops.audio.playEffect("music/button");
oops.gui.open(UIID.HeroInfo, { uuid })
} }
this.cards_node.addChild(cardNode) this.cards_node.addChild(cardNode)
@@ -401,374 +142,6 @@ export class HerosListComp extends CCComp {
if (uiTrans) { if (uiTrans) {
uiTrans.setContentSize(contentW, contentH) uiTrans.setContentSize(contentW, contentH)
} }
mLogger.log(this.debugMode, "HerosListComp", "updateContentSize", {
total, cols, rows, contentW, contentH,
})
}
private highlightCard(cardNode: Node) {
// 取消旧选中:关闭其 active 标识节点
if (this.selectNode) {
this.selectNode.getComponent(CardLiteComp)?.setSelected(false)
}
this.selectNode = cardNode
// 新选中:激活根节点下的 active 标识节点(替代原缩放高亮)
cardNode.getComponent(CardLiteComp)?.setSelected(true)
}
private onCardSelect(uuid: number) {
this.huuid = uuid
this.updateHeroDetail(uuid)
}
// ======================== 详情面板 ========================
private updateHeroDetail(uuid: number) {
const hero = HeroInfo[uuid]
if (!hero) return
// 属性按当前局外等级计算(与技能面板 updateSkillIcons 同源,均为 getHeroOuterLv)
const heroLv = Math.max(1, smc.getHeroOuterLv(uuid))
this.setLabelText(this.name_node, hero.name || "")
// 属性面板与运行时口径一致:基础值 × 品质系数 + 职业成长 + bonus + lv12 专项
const stats = getHeroDisplayStats(hero, heroLv)
this.setLabelText(this.ap_node, `${stats.ap}`)
this.setLabelText(this.def_node, `${stats.def}`)
this.setLabelText(this.hp_node, `${stats.hp}`)
this.updateCdDisplay(hero)
if (this.info_node) {
// Label 承载:HeroSkillText 输出富文本,剥离 BBCode 标签转纯文本
const desc = buildHeroSkillDesc(hero.uuid, true, true).replace(/<[^>]+>/g, "")
const infoLabel = this.info_node.getChildByName("info")?.getComponent(Label)
|| this.info_node.getComponent(Label)
|| this.info_node.getComponentInChildren(Label)
if (infoLabel) infoLabel.string = desc || hero.info || ""
}
this.updatePoolLvBg(hero.rarity ?? 1)
this.updateLvDisplay(uuid)
this.updateRoleDisplay(hero)
this.updateHeroAnimation(uuid)
this.updateGrowthPanel(uuid)
this.updateSkillIcons(hero)
}
private updatePoolLvBg(rarity: number) {
if (!this.BG_node) return
// BG_node 下直接放置颜色节点(green/blue/purple/yellow/red),按卡池等级激活对应颜色
const color = RARITY_BG_COLORS[Math.min(rarity, 5) - 1]
this.BG_node.children.forEach(child => {
child.active = (child.name === color)
})
}
private updateCdDisplay(hero: typeof HeroInfo[number]) {
if (!this.cd_node) return
const skillKeys = Object.keys(hero.skills)
if (skillKeys.length === 0) return
const firstSkill = hero.skills[Number(skillKeys[0])]
if (firstSkill) {
this.setLabelText(this.cd_node, `${firstSkill.cd}s`)
}
}
private updateLvDisplay(uuid: number) {
if (!this.lv_label) return
const outerLv = smc.getHeroOuterLv(uuid)
this.lv_label.string = `${outerLv}`
}
private updateRoleDisplay(hero: typeof HeroInfo[number]) {
if (!this.role_node) return
this.role_node.active = true
const roleStr = `${hero.role ?? 0}`
this.role_node.children.forEach(child => {
child.active = (child.name === roleStr)
})
}
/**
* 更新局外成长面板:碎片数量与升级按钮状态
* @param uuid 英雄模板 uuid
*/
private updateGrowthPanel(uuid: number) {
const growth = smc.getHeroGrowth(uuid)
const needFragments = HERO_UPGRADE_NEED_FRAGMENTS[growth.lv + 1]
const isMaxLv = needFragments === undefined
// 碎片数量显示:当前拥有 / 下一级所需(满级时显示 MAX)
if (this.fragment_node) {
const fragmentText = isMaxLv
? `${growth.fragments} / MAX`
: `${growth.fragments} / ${needFragments}`
this.setLabelText(this.fragment_node, fragmentText)
}
// 独立碎片 Label(编辑器可直接绑定 Label 组件,与 fragment_node 二选一或并存)
if (this.fragment_label) {
const fragmentText = isMaxLv
? `${growth.fragments} / MAX`
: `${growth.fragments} / ${needFragments}`
this.fragment_label.string = fragmentText
}
// 升级提升加成说明
this.updateUpgradeInfoLabel(uuid, growth.lv, isMaxLv)
// 碎片图标:使用 ChipComp 渲染英雄图标(自动读取 HeroInfo 配置)
if (this.chip_node) {
const chipComp = this.chip_node.getComponent(ChipComp) || this.chip_node.addComponent(ChipComp)
chipComp.setHeroByUuid(uuid)
}
// 升级按钮状态:始终可点击,点击后由 onUpgradeClick 判断碎片是否足够
// 碎片不足时按钮保持可点,通过气泡提示用户(而非禁用按钮)
if (this.upgrade_btn) {
this.upgrade_btn.active = true
const btnComp = this.upgrade_btn.getComponent("cc.Button") as import("cc").Button | null
if (btnComp) btnComp.interactable = true
}
}
// ======================== 技能图标列表 ========================
/**
* 刷新升级提升加成说明 RichText
* 展示口径:对比当前等级与下一等级,仅列出 AP/HP/DEF 属性差值,单行展示
* 数值用绿色高亮,整体通过 outlineRich 包裹描边标签
* @param uuid 英雄模板 uuid
* @param curLv 当前局外等级(≥1)
* @param isMaxLv 是否已满级
*/
private updateUpgradeInfoLabel(uuid: number, curLv: number, isMaxLv: boolean) {
if (!this.upgrade_info_label) return
const hero = HeroInfo[uuid]
if (!hero) { this.upgrade_info_label.string = ""; return }
if (isMaxLv) {
this.upgrade_info_label.string = outlineRich("已满级")
return
}
const nextLv = curLv + 1
const cur = getHeroDisplayStats(hero, curLv)
const nxt = getHeroDisplayStats(hero, nextLv)
// 属性提升差值:单行展示 AP/HP/DEF,数值绿色高亮
const parts: string[] = []
if (nxt.hp > cur.hp) parts.push(`生命<color=#00FF00>+${nxt.hp - cur.hp}</color>`)
if (nxt.ap > cur.ap) parts.push(`攻击<color=#00FF00>+${nxt.ap - cur.ap}</color>`)
if (nxt.def > cur.def) parts.push(`防御<color=#00FF00>+${nxt.def - cur.def}</color>`)
this.upgrade_info_label.string = parts.length > 0
? outlineRich(`升级属性提升:${parts.join(" ")}`)
: outlineRich(`升级至 Lv.${nextLv}`)
}
/** 图鉴面板展示的触发技能槽位(按触发时机排序:召唤→回合开始→攻击后→受击后→死亡→回合结束→常驻光环) */
private static readonly PANEL_TRIGGER_TYPES: SkillTriggerType[] = [
SkillTriggerType.Call,
SkillTriggerType.FStart,
SkillTriggerType.Atking,
SkillTriggerType.Atked,
SkillTriggerType.Dead,
SkillTriggerType.FEnd,
SkillTriggerType.Field,
]
/**
* 刷新当前英雄的技能图标列表:
* 1. 大招:skills[1] 及以后(首个为普攻,跳过)。
* 2. 触发技能:每个触发槽位(Call/FStart/Atking/Atked/Dead/FEnd/Field)整组视为一个技能,
* 只出一个图标,组内多技能/词条合并展示(复活已纳入 dead)。
* 以局外等级(getHeroOuterLv)为当前等级,未达解锁档的显示 locked,
* 已激活的显示当前等级对应的技能等级。
*/
private updateSkillIcons(hero: typeof HeroInfo[number]) {
if (!this.skills_node || !this.skill_icon_prefab) return
// 清空旧图标(显式销毁,避免节点泄漏)
for (let i = this.skills_node.children.length - 1; i >= 0; i--) {
this.skills_node.children[i].destroy()
}
// huuid 已在 onCardSelect 赋值,addSkillIcon 用它携带英雄语境
const lv = Math.max(1, smc.getHeroOuterLv(hero.uuid))
// ---- 大招:skills[1] 及以后(英雄出场即激活,与能量条同语义,恒不锁定) ----
Object.values(hero.skills).slice(1).forEach(hSkill => {
// 技能等级展示与 HeroSkillText 取档同规则:lv_entries 取 ≤lv 最大档的 s_lv,无生效档回退配置 lv
const sLv = hSkill.lv_entries?.length
? (hSkill.lv_entries.filter(e => e.lv <= lv).pop()?.s_lv ?? hSkill.lv)
: hSkill.lv
this.addSkillIcon(hSkill.uuid, sLv, false, false)
})
// ---- 普攻 lv2 强化档:作为独立技能图标(名称/图标取 lv1,弹窗只展示 lv2 说明) ----
this.addNormalLv2Icon(Object.values(hero.skills)[0], lv)
// ---- 触发技能:每个触发槽位整组一个图标,按 PANEL_TRIGGER_TYPES 顺序追加 ----
for (const type of HerosListComp.PANEL_TRIGGER_TYPES) {
if (type === SkillTriggerType.Field) {
this.addFieldGroupIcon(hero[SkillTriggerType.Field], lv)
} else {
this.addTriggerGroupIcon(hero[type] as TriggerGrouped | undefined, lv, type)
}
}
// ---- bonus 档位:配置了 icon 的档位在技能列表最末各出一个图标 ----
this.addBonusIcons(hero.bonus, lv)
}
/**
* bonus 档位图标:配置了 icon 的档位各出一个图标(追加在技能列表最末)。
* 激活判定:英雄局外等级 ≥ 档位 lv;未激活显示锁定遮罩。
* 点击弹窗走 SinfoComp bonus 专属渲染(名称/图标取档位配置,主说明为 attrs 效果)。
*/
private addBonusIcons(bonus: LvBonusAttrsEntry[] | undefined, lv: number) {
if (!bonus?.length) return
for (const entry of bonus) {
if (!entry.icon) continue
const node = instantiate(this.skill_icon_prefab)
node.name = `bonus_lv${entry.lv}`
const comp = node.getComponent(SSkillBoxComp) || node.addComponent(SSkillBoxComp)
comp.initBonus(this.huuid, entry, lv < entry.lv)
this.skills_node.addChild(node)
}
}
/**
* 实例化技能小图标并初始化
* @param s_uuid 技能 UUID(SkillSet 或 FieldSkillSet;整组模式下取组内首个,仅用于图标)
* @param s_lv 当前等级对应的技能等级(locked 时为解锁预览档)
* @param locked 当前等级未激活 → 显示锁定遮罩
* @param isField true 表示驻场光环词条(图标取 FieldSkillSet)
* @param slot 技能所属槽位(普攻/大招传 undefined;触发/光环传对应类型,
* 同一底座不同槽位视为不同技能,弹窗按槽位取档;复活已纳入 dead)
* @param isGroup 整组模式:该图标代表整个触发槽位,组名取组内首个自定义名,
* 弹窗按整组合并渲染(组内多技能/词条统一说明)
* @param normalLv2 普攻 lv2 专属模式:该图标代表普攻 lv2 强化档,名称/图标取 lv1,
* 弹窗只展示 lv2 档位说明
* @param unlockLv 未激活时的解锁所需英雄等级(用于"x级解锁"展示,与技能等级 s_lv 解耦)
*/
private addSkillIcon(s_uuid: number, s_lv: number, locked: boolean, isField: boolean, slot?: SkillTriggerType, isGroup: boolean = false, normalLv2: boolean = false, unlockLv?: number) {
const node = instantiate(this.skill_icon_prefab)
node.name = `skill_${s_uuid}`
const comp = node.getComponent(SSkillBoxComp) || node.addComponent(SSkillBoxComp)
// 携带所属英雄与槽位,点击弹窗按英雄技能分档描述渲染
comp.initHeroSkill(s_uuid, s_lv, locked, isField, this.huuid, slot, isGroup, normalLv2, unlockLv)
this.skills_node.addChild(node)
}
/**
* 触发技能槽位(call/fstart/atking/atked/dead)整组一个图标:
* 组内多 s_uuid 合并展示,图标/等级取组内首个 s_uuid 的生效档(未激活预览最低档);
* 整组激活判定:组内任一 s_uuid 存在 ≤lv 生效档即视为已激活(与 resolveTriggerByLv 同语义)
* @param type 触发槽位(随图标透传,弹窗按整组取档)
*/
private addTriggerGroupIcon(grouped: TriggerGrouped | undefined, lv: number, type: SkillTriggerType) {
if (!grouped) return
const keys = Object.keys(grouped).map(Number).filter(k => grouped[k]?.length)
if (keys.length === 0) return
// 图标/等级取组内首个 s_uuid 的生效档
const sorted = [...grouped[keys[0]]].sort((a, b) => a.lv - b.lv)
let active: LvTriggerEntry | undefined
for (const e of sorted) { if (e.lv <= lv) active = e }
// 整组激活判定:组内任一 s_uuid 存在生效档即激活
let anyActive = !!active
for (const key of keys) {
if (anyActive) break
for (const e of grouped[key] ?? []) {
if (e.lv <= lv) { anyActive = true; break }
}
}
const pick = active ?? sorted[0]
this.addSkillIcon(keys[0], pick.s_lv ?? 1, !anyActive, false, type, true, false, anyActive ? undefined : pick.lv)
}
/**
* 驻场光环槽位整组一个图标:组内多词条合并展示,
* 图标取生效档(未激活预览最低档)的首个词条
*/
private addFieldGroupIcon(field: LvFieldEntry[] | undefined, lv: number) {
if (!field?.length) return
const sorted = [...field].sort((a, b) => a.lv - b.lv)
let active: LvFieldEntry | undefined
for (const e of sorted) { if (e.lv <= lv) active = e }
const pick = active ?? sorted[0]
const firstUuid = pick.uuids[0]?.uuid
if (!firstUuid) return
// 词条档位无 s_lv 概念,技能等级恒为 1(等级标签展示技能等级而非解锁的英雄等级)
this.addSkillIcon(firstUuid, 1, !active, true, SkillTriggerType.Field, true, false, active ? undefined : pick.lv)
}
/** 复活技能已纳入 dead 触发组,通过 dead 配置展示(revive_hp / revive_count),无需单独处理 */
/**
* 普攻 lv2+ 强化档作为独立技能图标:
* 普攻(skills[0])lv_entries 存在档时单独出图标,名称/图标取 lv1(s_uuid 相同),
* 等级显示偏移一档(配置 Lv2 → Lv1,SSkillBoxComp 统一处理),弹窗按 hero_normal_lv2
* 模式只展示 lv2+ 档位说明;激活判定按档位的英雄等级(lv 阈值)。
* Why: 普攻 lv2 档是独立成长机制(如附加暴击/冰冻),需让玩家明确感知其存在与解锁条件。
*/
private addNormalLv2Icon(normal: HSkillInfo | undefined, lv: number) {
const entry = normal?.lv_entries?.[0]
if (!normal || !entry) return
// 节点命名区分 lv2 档(普攻与触发槽位可能同底座,uuid 重名会互相干扰)
this.addSkillIcon(normal.uuid, entry.s_lv ?? 2, lv < entry.lv, false, undefined, false, true, entry.lv)
const icon = this.skills_node.children[this.skills_node.children.length - 1]
if (icon) icon.name = `skill_${normal.uuid}_lv2`
}
// ======================== 英雄动画 ========================
private updateHeroAnimation(uuid: number) {
if (!this.hero_icon) return
const hero = HeroInfo[uuid]
if (!hero) return
const sprite = this.hero_icon.getComponent(Sprite) || this.hero_icon.getComponentInChildren(Sprite)
if (sprite) sprite.spriteFrame = null
const anim = this.hero_icon.getComponent(Animation) || this.hero_icon.addComponent(Animation)
this.clearAnimationClips(anim)
this.iconVisualToken += 1
const token = this.iconVisualToken
const path = `game/heros/hero/${hero.path}/idle`
resources.load(path, AnimationClip, (err, clip) => {
if (err || !clip) {
mLogger.log(this.debugMode, "HerosListComp", `load hero animation failed ${uuid}`, err)
return
}
if (token !== this.iconVisualToken) return
this.clearAnimationClips(anim)
anim.addClip(clip)
anim.play("idle")
})
}
private clearAnimationClips(anim: Animation) {
const clips = anim.clips
if (clips && clips.length > 0) {
for (let i = clips.length - 1; i >= 0; i--) {
const clip = clips[i]
if (clip) anim.removeClip(clip, true)
}
}
}
// ======================== UI 工具 ========================
private setLabelText(node: Node, text: string) {
if (!node) return
const label = node.getComponent(Label) || node.getComponentInChildren(Label)
if (label) {
label.string = text
}
} }
reset() { reset() {