Cocos Creator 3.x RichText 实现关键词悬停提示(基于 `_segments` 的扩展实践)

在 Cocos Creator 里,RichText 自带了 <on click="handler" param="xxx"> 这种点击事件能力,用起来很顺手。但它没有提供"hover(鼠标悬停)"事件支持,如果我们希望在 PC 上对某些术语悬停时弹出解释,就需要自己动手扩展一下。

这篇文章记录我在项目里给 RichText 加"关键词悬停提示"的完整过程,最终方案是在不修改引擎源码 的前提下,通过访问 RichText 的内部 _segments 结构,给带 <on click> 的片段绑定悬停事件。


一、需求背景

业务场景大概是这样:

  • 说明文案走表格配置;
  • 一段文本在固定宽度(比如 500px)下自动换行;
  • 文案中有 1--3 个"术语",需要高亮显示,并在 PC 上:
    • 鼠标悬停时弹出提示;
    • 点击时也能弹出提示(移动端只能点击)。

示例文案(逻辑上):

当玩家处于「立直」状态,并摸到「自摸」牌时,将获得加番。

我们希望在富文本里用类似以下写法标记关键词:

txt 复制代码
当玩家处于<on click="onClicked" param="riichi">立直</on>状态,
并摸到<on click="onClicked" param="tsumo">自摸</on>牌时,将获得加番。

这样:

  • 点击「立直」「自摸」可以触发 onClicked(event, param)
  • 同时希望"鼠标悬停在这些文字上时"也弹出对应提示。

二、为什么不用多个 Label 来拼?

最直觉的做法是:

  • 用多个 Label 拼成一整段话;
  • 对"名词 Label"挂鼠标事件。

但在自动换行和布局上会很麻烦,比如这些问题:

  • Label A 的内容可能会超过 500px 自动换行;
  • Label B 需要"紧接着 Label A 的最后一行"继续往后排;
  • Label C 又要紧接 Label B 的最后一行......

这本质上是在自己实现一个文本排版引擎,要计算每一行的宽度、换行位置和每个 Label 的起始坐标,复杂度很高。

而 RichText 已经帮我们解决了自动换行和多段样式的问题,如果只是"在少数关键词上做交互",没有必要在排版上重造轮子。


三、RichText 自带的 <on> 能力

官方文档里的示例大概是这样:

ts 复制代码
import { _decorator, Component, EventTouch } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('ClickEventHandler')
export class ClickEventHandler extends Component {
    onClicked(eventTouch: EventTouch, param: string) {
        console.log('onClicked', param);
    }
}

配合 RichText 字符串:

txt 复制代码
<on click="onClicked" param="hello">Click Me</on>

RichText 会做两件事:

  • 解析 <on> 标签,知道"这一段文本是可点击的";
  • 点击这段文字时,调用挂在同节点上的 ClickEventHandler.onClicked(event, param)

但是:

  • 官方只实现了"点击事件",没有 hover;
  • 也没有给 Label 组件暴露"我是不是来自 <on>"这种标记。

于是,我们需要想办法"找出哪些渲染出来的片段是带 <on click> 的",再自己挂上 hover。


四、最终方案思路:利用 _segments 做扩展

在调试 RichText 实例时,可以看到一个内部字段 _segments(不同版本字段名可能略有差异,比如 _segments / _labelSegments):

它是一个数组,每一项代表一段渲染好的文本片段,结构大致如下:

ts 复制代码
{
    node: Node        // 用于渲染这一小段文字的节点
    text: string      // 文本内容
    clickHandler?: any
    clickParam?: string
    // 其他颜色、size 等信息
}

也就是说:

  • 只有来自 <on click="..."> 的片段才会有 clickHandler 字段
  • clickParam 就是 <on click="..." param="xxx"> 里的 param
  • node 是这段富文本的渲染节点,通常挂了一个 Label 组件。

因此可以基于 _segments 做这样一件事:

  1. 遍历 _segments,筛选出 seg.clickHandler 存在的片段;
  2. 从这些片段里拿到 seg.nodeseg.clickParam(或者 seg.text);
  3. 为这些 node 绑定 MOUSE_ENTER / MOUSE_LEAVE 事件;
  4. 悬停时,通过 node → key 的映射弹出提示。

这样 hover 只会作用在"绑定了 click 的片段"上,普通文本不会响应。


五、完整代码实现:RichTextClickBridge

这是最后整理出来的桥接组件代码:

ts 复制代码
import { _decorator, Component, EventTouch, EventMouse, Node, RichText } from 'cc';
import { PromptController } from '../../../controllers/PromptController';

const { ccclass, menu } = _decorator;

@ccclass
@menu('富文本/RichTextClickBridge')
export class RichTextClickBridge extends Component {
    private nodeParamMap: Map<Node, string> = new Map<Node, string>();

    onEnable() {
        this.scheduleOnce(() => {
            this.refresh();
        }, 0);
    }

    onDisable() {
        this.clearHoverEvents();
        this.nodeParamMap.clear();
    }

    public refresh() {
        this.clearHoverEvents();
        this.buildHoverFromSegments();
        this.registerHoverEvents();
    }

    onClicked(event: EventTouch, param: string) {
        if (!param) {
            return;
        }
        if (PromptController.Instance) {
            PromptController.Instance.popTip(param);
        }
    }

    private buildHoverFromSegments() {
        this.nodeParamMap.clear();
        const richText = this.getComponent(RichText);
        if (!richText) {
            return;
        }
        const anyRichText = richText as any;
        const segments = anyRichText._segments as any[];
        if (!segments || segments.length === 0) {
            return;
        }
        for (const seg of segments) {
            if (!seg || !seg.clickHandler) {
                continue;
            }
            const node = seg.node as Node;
            if (!node) {
                continue;
            }
            const param = seg.clickParam as string;
            const text = seg.text as string;
            const key = param && param.length > 0 ? param : text;
            if (!key) {
                continue;
            }
            this.nodeParamMap.set(node, key);
        }
    }

    private registerHoverEvents() {
        if (this.nodeParamMap.size === 0) {
            return;
        }
        this.nodeParamMap.forEach((_, node) => {
            node.on(Node.EventType.MOUSE_ENTER, this.onSegmentEnter, this);
            node.on(Node.EventType.MOUSE_LEAVE, this.onSegmentLeave, this);
        });
    }

    private clearHoverEvents() {
        if (this.nodeParamMap.size === 0) {
            return;
        }
        this.nodeParamMap.forEach((_, node) => {
            node.off(Node.EventType.MOUSE_ENTER, this.onSegmentEnter, this);
            node.off(Node.EventType.MOUSE_LEAVE, this.onSegmentLeave, this);
        });
    }

    private onSegmentEnter(event: EventMouse) {
        const node = (event.currentTarget as Node) || (event.target as Node);
        if (!node) {
            return;
        }
        const key = this.nodeParamMap.get(node);
        if (!key) {
            return;
        }
        if (!PromptController.Instance) {
            return;
        }
        PromptController.Instance.popTip(key);
    }

    private onSegmentLeave(event: EventMouse) {
    }
}

关键点总结:

  • nodeParamMap: Map<Node, string>:记录"片段节点 → 说明 key",key 优先用 param,否则 fallback 到 text
  • buildHoverFromSegments()
    • anyRichText._segments 里遍历所有片段;
    • if (!seg.clickHandler) continue 只保留绑定了 click 的片段;
    • seg.node 作为 map 的 key,seg.clickParamseg.text 作为 value;
  • registerHoverEvents() / clearHoverEvents() 负责统一绑定/解绑 hover;
  • onSegmentEnter()nodeParamMap 查出 key,调用 PromptController.popTip(key)

六、如何在项目中使用

  1. 在场景/Prefab 中:

    • 给 RichText 节点挂上 RichTextClickBridge 组件;
    • 确保同一个节点上也挂了 RichText 组件。
  2. 文案中用 <on> 标记关键词,比如:

    txt 复制代码
    当玩家处于<on click="onClicked" param="riichi">立直</on>状态,
    并摸到<on click="onClicked" param="tsumo">自摸</on>牌时,将获得加番。
  3. 如果 string 是在运行时赋值,赋值后记得调用 refresh()

    ts 复制代码
    richText.string = configString;
    
    const bridge = richText.getComponent(RichTextClickBridge);
    bridge?.refresh();

这样:

  • 点击关键词时,RichText 会调用 onClicked(event, param),你可以用 param 去查配置表;
  • PC 上鼠标悬停在关键词上时,RichTextClickBridge 会调用同一套提示逻辑(用 param 或 text 做 key);
  • 移动端不触发悬停,只保留点击行为,体验自然退化。

七、这算不算"反射修改引擎源码"?

严格地说:

  • 没有修改引擎源码 :没有改任何 engine ts/js 文件,只是在用户逻辑里读取了一个内部字段 _segments
  • 但确实是"反射式访问内部结构"
    • _segmentsclickHandlerclickParam 都不是公开 API;
    • 通过 (richText as any)._segments 的方式绕过类型系统访问内部实现,这种方式有点类似反射。

优点:

  • 精确复用引擎已经解析好的片段和 param;
  • 不用重新解析富文本字符串,也不用自己做排版;
  • 悬停和点击能共享同一套数据结构。

风险:

  • 第三方或未来版本中 _segments 的结构可能变化(字段名/字段含义);
  • 升级 Cocos Creator 后,需要重新检查这段逻辑;
  • 在团队文档/博客中篇尾注明"依赖当前版本 RichText 内部实现",以免被误用到不兼容版本。

八、总结

这次实践的关键点在于:

  • 排版和自动换行仍然完全交给 RichText
  • 交互层面,点击走官方 <on click>,悬停通过 _segments 拿到 click 段的 node 再自己挂事件
  • 通过小小的"反射式扩展",在不动引擎源码的前提下,实现了一个非常贴合需求的功能:
    "配置表驱动的富文本说明 + 关键词高亮 + PC 悬停/点击弹提示"。

如果你的项目里也有类似的富文本交互需求,这种做法可以作为一个参考:

优先复用引擎已有能力,再在运行时结构上做精确扩展,而不是一开始就放弃 RichText 去手写排版。

相关推荐
mxwin5 小时前
Unity Shader 半透明物体为什么不能写入深度缓冲?
unity·游戏引擎·shader
晚枫歌F6 小时前
三层时间轮的实现
网络·unity·游戏引擎
努力长头发的程序猿11 小时前
Unity使用ScriptableObject序列化资源
unity·游戏引擎
mxwin11 小时前
Unity Shader 手写基于 PBR 的 URP Lit Shader 核心光照计算
unity·游戏引擎·shader
魔士于安11 小时前
Unity windows 同步 异步 打开文件文件夹工具
游戏·unity·游戏引擎·贴图·模型
笑虾12 小时前
cocos2d-x lua 加载 Cocos Studio 导出的 csb
游戏引擎·lua·cocos2d
魔士于安12 小时前
unity lowpoly 风格 城市 建筑 道路 交通标志
游戏·unity·游戏引擎·贴图·模型
mxwin12 小时前
Unity GPU Shader 性能优化指南
unity·游戏引擎·shader
董董女友1 天前
unity mcp 配置指南
unity·游戏引擎
垂葛酒肝汤1 天前
Unity的可视化网格和文字标签
unity·游戏引擎