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 去手写排版。

相关推荐
猫不吃咸鱼3 小时前
Unity单手轮盘控制2D/3D物体移动
3d·unity·游戏引擎
向宇it1 天前
2025年技术总结 | 在Unity游戏开发路上的持续探索与沉淀
游戏·unity·c#·游戏引擎
技术小甜甜1 天前
【Godot】【入门】输入系统详解:InputMap 动作映射(键鼠/手柄一套代码通吃)
游戏引擎·godot
Thomas_YXQ1 天前
Unity3D IL2CPP如何调用Burst
开发语言·unity·编辑器·游戏引擎
老朱佩琪!2 天前
Unity享元模式
unity·游戏引擎·享元模式
三和尚2 天前
AI开发之Cursor的下载安装以及Unity-MCP下载安装到你的个人Unity项目中(一)
unity·ai·游戏引擎·cursor·unity-mcp·unity自动化
DaLiangChen2 天前
Unity 导览相机实现:键鼠控制自由漫游(WASD 移动 + 右键旋转)
数码相机·unity·游戏引擎
沉默金鱼3 天前
Unity实用技能-UI进度条
ui·unity·游戏引擎
老朱佩琪!3 天前
Unity离线开发经验分享
unity·游戏引擎