在 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 做这样一件事:
- 遍历
_segments,筛选出seg.clickHandler存在的片段; - 从这些片段里拿到
seg.node和seg.clickParam(或者seg.text); - 为这些 node 绑定
MOUSE_ENTER / MOUSE_LEAVE事件; - 悬停时,通过 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.clickParam或seg.text作为 value;
- 从
registerHoverEvents()/clearHoverEvents()负责统一绑定/解绑 hover;onSegmentEnter()从nodeParamMap查出 key,调用PromptController.popTip(key)。
六、如何在项目中使用
-
在场景/Prefab 中:
- 给 RichText 节点挂上
RichTextClickBridge组件; - 确保同一个节点上也挂了
RichText组件。
- 给 RichText 节点挂上
-
文案中用
<on>标记关键词,比如:txt当玩家处于<on click="onClicked" param="riichi">立直</on>状态, 并摸到<on click="onClicked" param="tsumo">自摸</on>牌时,将获得加番。 -
如果
string是在运行时赋值,赋值后记得调用refresh():tsrichText.string = configString; const bridge = richText.getComponent(RichTextClickBridge); bridge?.refresh();
这样:
- 点击关键词时,RichText 会调用
onClicked(event, param),你可以用 param 去查配置表; - PC 上鼠标悬停在关键词上时,
RichTextClickBridge会调用同一套提示逻辑(用 param 或 text 做 key); - 移动端不触发悬停,只保留点击行为,体验自然退化。
七、这算不算"反射修改引擎源码"?
严格地说:
- 没有修改引擎源码 :没有改任何 engine ts/js 文件,只是在用户逻辑里读取了一个内部字段
_segments; - 但确实是"反射式访问内部结构" :
_segments、clickHandler、clickParam都不是公开 API;- 通过
(richText as any)._segments的方式绕过类型系统访问内部实现,这种方式有点类似反射。
优点:
- 精确复用引擎已经解析好的片段和 param;
- 不用重新解析富文本字符串,也不用自己做排版;
- 悬停和点击能共享同一套数据结构。
风险:
- 第三方或未来版本中
_segments的结构可能变化(字段名/字段含义); - 升级 Cocos Creator 后,需要重新检查这段逻辑;
- 在团队文档/博客中篇尾注明"依赖当前版本 RichText 内部实现",以免被误用到不兼容版本。
八、总结
这次实践的关键点在于:
- 排版和自动换行仍然完全交给 RichText;
- 交互层面,点击走官方
<on click>,悬停通过_segments拿到 click 段的 node 再自己挂事件; - 通过小小的"反射式扩展",在不动引擎源码的前提下,实现了一个非常贴合需求的功能:
"配置表驱动的富文本说明 + 关键词高亮 + PC 悬停/点击弹提示"。
如果你的项目里也有类似的富文本交互需求,这种做法可以作为一个参考:
优先复用引擎已有能力,再在运行时结构上做精确扩展,而不是一开始就放弃 RichText 去手写排版。