/** * @file HeroInfoComp.ts * @description 英雄详情弹窗(UI 视图层,oops.gui 弹窗) * * 职责: * 1. 展示单个英雄的详情:idle 动画、名称、AP / DEF / HP / CD、技能描述、 * 稀有度底框、局外等级、职业、碎片数量、升级按钮、技能图标列表。 * 2. 提供升级操作(消耗碎片提升局外等级,成功后播放升级特效)。 * 3. 提供前后英雄切换按钮,按图鉴排序顺序循环切换(品质降序 → uuid 升序)。 * 4. 提供关闭按钮 / 遮罩点击关闭弹窗。 * * 使用(oops.gui 模块): * oops.gui.open(UIID.HeroInfo, { uuid: 5001 }); * * 关键设计: * - 详情渲染逻辑与原 HerosListComp 详情面板同源(已迁移至此), * HerosListComp 仅保留卡片列表,点击卡片改为打开本弹窗。 * - 前后切换按品质降序 → uuid 升序循环(与图鉴页面卡片顺序一致)。 * - hero_icon 使用 Animation + iconVisualToken 机制防止异步加载竞态。 * * 依赖: * - HeroInfo / HeroList(heroSet)—— 英雄静态配置与全量 UUID 列表 * - 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, HeroList, 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 { 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! /** 碎片图标节点(挂载 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) { // 与图鉴页面排序同源:仅按品质降序 → uuid 升序 const sorted = [...HeroList].sort((a, b) => { const ra = HeroInfo[a]?.rarity ?? 1; const rb = HeroInfo[b]?.rarity ?? 1; if (ra !== rb) return rb - ra; return a - b; }); 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_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(`生命+${nxt.hp - cur.hp}`); if (nxt.ap > cur.ap) parts.push(`攻击+${nxt.ap - cur.ap}`); if (nxt.def > cur.def) parts.push(`防御+${nxt.def - cur.def}`); 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(); } }