/** * @file IBoxComp.ts * @description 技能/装备详情弹窗组件(IBox,UI 视图层) * * 职责: * 1. 作为详情弹窗,展示单个技能或装备的 **功能描述详情**。 * 2. 通过 init(args) 接收技能 UUID、装备 UUID 或运行时技能数据。 * 3. 使用单个 RichText 以 BBCode 展示名称(蓝色)、CD/持续时间(亮绿色)、结构化效果描述。 * 4. 点击弹窗任意区域关闭自身。 * * 关键设计: * - 复用 HeroSkillDesc 的结构化模板渲染,数值实时从配置读取,杜绝漂移。 * - 技能:展示名称、等级、CD、效果描述(SkillDescSet 模板)。 * - 装备:展示名称、全部驻场光环词条(FieldDescSet 模板)。 * - 不走 oops.gui,由调用方手动实例化并添加到场景,通过 init() 初始化。 * * 依赖: * - HeroSkillDesc(buildSingleSkillRich / buildEquipRich)—— 描述生成 * - SkillSet / FieldSkillSet(SkillSet)—— 技能/光环静态配置 * - EquipSet(findEquipByUuid)—— 装备静态配置 */ import { _decorator, Label, NodeEventType, RichText, UITransform, Node, v3, Widget } 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 { SkillSet, SkillOverrides } from "../common/config/SkillSet"; import { buildEquipRich, buildSingleSkillRich } from "../common/config/HeroSkillDesc"; import { findEquipByUuid } from "../common/config/EquipSet"; const { ccclass, property } = _decorator; /** IBox 初始化参数 */ export interface IBoxInitArgs { /** SkillSet 技能 UUID(技能详情模式) */ skillUuid?: number; /** 技能等级(默认 1) */ skillLv?: number; /** 技能覆写参数(如药水卡自定义档位) */ overrides?: SkillOverrides; /** EquipSet 装备 UUID(装备详情模式) */ equipUuid?: number; /** 运行时技能数据(运行时技能详情模式,取第一个技能展示) */ skills?: Record; } /** * IBoxComp —— 技能/装备详情弹窗视图组件 * * 由调用方通过 instantiate + addChild 手动实例化,然后调用 init(args) 初始化。 * 示例: * const node = instantiate(this.iboxPrefab); * this.node.addChild(node); * node.getComponent(IBoxComp).init({ skillUuid: 6301, anchorPos: this.node.worldPosition }); */ @ccclass('IBoxComp') @ecs.register('IBoxComp', false) export class IBoxComp extends CCComp { // ======================== 编辑器绑定节点 ======================== @property(RichText) massage_node: RichText = null! // ======================== 布局常量 ======================== /** 内容顶部留白(像素) */ private static readonly PADDING_TOP: number = 10; /** 内容底部留白(像素) */ private static readonly PADDING_BOTTOM: number = 10; /** 根节点最小高度(像素),防止过矮 */ private static readonly MIN_HEIGHT: number = 60; /** * 初始化并渲染弹窗内容。 * @param args 见 IBoxInitArgs */ public init(args: IBoxInitArgs) { this.render(args); } /** 绑定点击关闭事件 */ onLoad() { this.node.on(NodeEventType.TOUCH_END, this.onTapClose, this); } /** 解绑点击事件 */ onDestroy() { super.onDestroy(); if (this.node && this.node.isValid) { this.node.off(NodeEventType.TOUCH_END, this.onTapClose, this); } } /** ECS 组件移除时销毁节点 */ reset() { this.node.destroy(); } // ======================== 渲染逻辑 ======================== /** * 主渲染方法:根据 args 模式分发到技能详情或装备详情。 */ private render(args: IBoxInitArgs) { // 装备详情模式 if (args?.equipUuid !== undefined) { this.renderEquip(args.equipUuid); return; } // 运行时技能详情模式(取第一个技能) if (args?.skills) { const first = Object.values(args.skills)[0]; if (first) { this.renderSkill(first.uuid, first.lv); return; } } // 技能详情模式 if (args?.skillUuid !== undefined) { this.renderSkill(args.skillUuid, args.skillLv ?? 1, args.overrides); return; } this.setHeroName(""); this.setRichText("暂无信息"); } /** * 渲染技能详情:名称、等级、CD、结构化效果描述。 * @param skillUuid SkillSet 技能 UUID * @param lv 技能等级 * @param overrides 可选覆写参数 */ private renderSkill(skillUuid: number, lv: number, overrides?: SkillOverrides) { const skill = SkillSet[skillUuid]; if (!skill) { this.setHeroName(""); this.setRichText("未知技能"); return; } this.setHeroName(skill.name); this.setRichText(buildSingleSkillRich(skillUuid, overrides, lv)); } /** * 渲染装备详情:名称、全部驻场光环词条。 * @param equipUuid EquipSet 装备 UUID */ private renderEquip(equipUuid: number) { const equip = findEquipByUuid(equipUuid); if (!equip) { this.setHeroName(""); this.setRichText("未知装备"); return; } this.setHeroName(equip.name); this.setRichText(buildEquipRich(equip.field ?? [])); } /** 将 BBCode 文本写入 RichText */ private setRichText(text: string) { if (this.massage_node && this.massage_node.isValid) { this.massage_node.string = text; } // 文本变更后,下一帧 RichText 完成排版再刷新弹窗高度 this.scheduleOnce(() => this.refreshLayout(), 0); } /** * 根据 RichText 实际排版高度自适应弹窗尺寸。 * 弹窗(Bg 背景 + 根节点)采用"顶部锚定"模式: * 顶部固定在图标上方偏移处,内容条数越多向下生长越高, * 因此展示位置无需随高度补偿。 */ private refreshLayout() { if (!this.node || !this.node.isValid) return; const rootUI = this.node.getComponent(UITransform); const bgNode = this.node.getChildByName("Bg"); const bgUI = bgNode?.getComponent(UITransform) ?? null; const richNode = this.massage_node?.node ?? null; const richUI = richNode?.getComponent(UITransform) ?? null; if (!rootUI || !bgUI || !richNode || !richUI) return; // 1. 计算目标高度:RichText 排版后的实际高度 + 上下留白 const contentH = richUI.height; const targetH = Math.max( IBoxComp.MIN_HEIGHT, contentH + IBoxComp.PADDING_TOP + IBoxComp.PADDING_BOTTOM ); // 2. 根节点:直接设为顶部锚定(0.5, 1),锚点 y=0 即弹窗顶边, // 调用方仍按原逻辑把节点放到"图标上方偏移"处即可。 rootUI.setAnchorPoint(0.5, 1); rootUI.setContentSize(rootUI.width, targetH); // 3. Bg 背景:同步顶部锚定并撑满根节点(Bg 相对根节点 y=0 即为顶边) // 预制体中 Bg 带有旧版 Widget(强制拉伸),必须禁用,否则会覆盖本处布局 const bgWidget = bgNode!.getComponent(Widget); if (bgWidget) bgWidget.enabled = false; bgUI.setAnchorPoint(0.5, 1); bgUI.setContentSize(bgUI.width, targetH); bgNode!.setPosition(0, 0, bgNode!.position.z); // 4. RichText:锚点改为顶部锚定,贴齐顶部留白处 richUI.setAnchorPoint(0.5, 1); richNode.setPosition(v3(richNode.position.x, -IBoxComp.PADDING_TOP, richNode.position.z)); } /** * 设置标题名称标签 * @param name 技能/装备名称 */ private setHeroName(name: string) { const bgNode = this.node.getChildByName("Bg"); const nameNode = bgNode?.getChildByName("name"); const valNode = nameNode?.getChildByName("val"); const label = valNode?.getComponent(Label) || valNode?.getComponentInChildren(Label); if (label) { label.string = name; } } /** 点击弹窗任意区域关闭自身 */ private onTapClose() { if (this.node && this.node.isValid) { this.node.destroy(); } } }