Files
pixelheros/assets/script/game/map/IBoxComp.ts
panFD 0fe6542b87 refactor(map): 优化装备/技能信息弹窗布局逻辑
1. 调整信息框Y轴偏移量从80改为60
2. 新增弹窗自适应高度功能,根据内容动态调整弹窗尺寸
3. 采用顶部锚定模式,弹窗顶部固定偏移位置随内容高度自动向下延伸
4. 优化弹窗内边距和最小高度限制,避免弹窗过窄过矮
2026-08-03 00:40:47 +08:00

224 lines
8.5 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 IBoxComp.ts
* @description 技能/装备详情弹窗组件IBoxUI 视图层)
*
* 职责:
* 1. 作为详情弹窗,展示单个技能或装备的 **功能描述详情**。
* 2. 通过 init(args) 接收技能 UUID、装备 UUID 或运行时技能数据。
* 3. 使用单个 RichText 以 BBCode 展示名称蓝色、CD/持续时间(亮绿色)、结构化效果描述。
* 4. 点击弹窗任意区域关闭自身。
*
* 关键设计:
* - 复用 HeroSkillDesc 的结构化模板渲染,数值实时从配置读取,杜绝漂移。
* - 技能展示名称、等级、CD、效果描述SkillDescSet 模板)。
* - 装备展示名称、全部驻场光环词条FieldDescSet 模板)。
* - 不走 oops.gui由调用方手动实例化并添加到场景通过 init() 初始化。
*
* 依赖:
* - HeroSkillDescbuildSingleSkillRich / buildEquipRich—— 描述生成
* - SkillSet / FieldSkillSetSkillSet—— 技能/光环静态配置
* - EquipSetfindEquipByUuid—— 装备静态配置
*/
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<number, { uuid: number; lv: number; cd: number; ccd: number }>;
}
/**
* 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();
}
}
}