公屏上那行会自己变色的会长名字、活动公告里金光闪闪的奖励提示、聊天框里一键跳转的传送链接——这些花活背后是同一套东西:996 引擎的富文本系统 util/RichTextHelp.lua。这文件看着是 UI 小工具,实际上是全引擎"文字表演"的调度台,今天从两种染色格式讲到流水变色,把客服提文案需求的那些花活全部落地。
先交代它在 UI 栈里的位置。引擎的 UI 底层有一套原生富文本组件(ccui.RichText),能装文字、图片、换行等元素,但直接用它你得手工构造每个元素,麻烦。RichTextHelp 是包在组件外面的帮手层:你给它一段带参数的文字描述,它负责拆解、染色、建元素、入队。这个"组件裸奔"与"帮手包装"的双层结构在引擎里到处都是——TableViewEx 对滚动列表、QuickCell 对格子做的是同一件事。读引擎 UI 代码的诀窍:先找 Help/Ex 结尾的帮手层,业务代码 90% 只跟帮手层打交道,原生组件是帮手的地基,地基看懂一层就够。
富文本的每个文字片段都带一个颜色参数,引擎的解析函数很宽容:
function RichTextHelp:PushText(richText, text, colorParam, size, link, ttfConfig, fontName)
local color = colorParam
if type(colorParam) ~= "table" then
if tonumber(colorParam) then
-- 数字色号:走全局色表,比如白=1 红=2(M2 老色号体系)
color = GET_COLOR_BYID_C3B(tonumber(colorParam))
elseif string.find(tostring(colorParam), "#") then
-- 十六进制:美术直接从 PS 里抄 "#FFD700"
color = ConvertHexStrToColor3B(colorParam)
end
end
...
end
两种格式各有主人。数字色号是 M2 老体系的遗产,服主和脚本哥用了二十年,张口就是"称号染 2 号色";十六进制是美术的母语,设计稿上抄下来就是。引擎把它们捏在同一个入口,tonumber 走老色表、带 # 走十六进制,两边的人都能用自己顺手的语言下需求。你做活动公告时让策划填 #FFD700,做脚本触发时让服务端填色号 2,谁也不用迁就谁。
数字色号那张全局色表(GET_COLOR_BYID_C3B)值得单独交代一句:它是全引擎的颜色宪法,品质色(白绿蓝紫橙)、阵营色、VIP 色全登记在里面。后果有好有坏:好处是全服的"紫色装备"永远一个紫,不会东家一个紫罗兰西家一个葡萄紫;坏处是有人想给某个活动单独调色,改色表会波及全服所有紫色——正确姿势是活动用十六进制走旁路,别动宪法。这两个函数的关系,本质是"语义色"和"装饰色"的分界线:涉及玩家理解的(品质、阵营)锁死在色表里保持一致,纯装饰的(公告高亮、特效文字)走十六进制放开自由。分界的标准只有一条:改了这个色,玩家会不会误解什么?
实战里真正值钱的是那个 type(colorParam) ~= "table" 的判断:传表进来直接原样用(内部是 Color3B 结构),传字符串才走解析。一个参数三种喂法,函数不挑食。这种宽容不是接口设计课教的,是被无数需求喂出来的——文字染色的调用方太多了,谁也别想统一谁。
会长名字轮着变红变金那个效果,核心是 PushAutoText 加一张色表:
function RichTextHelp:SetAutoColor(richText, richElement, colorParam)
local auto_color = {
colors = colorParam, -- 色表:如 {"#FF0000","#FFD700","#3FB950"}
idx = 1
}
if not richText.auto_color_elements then
richText.auto_color_elements = {}
local function callback()
for a_rich_element, a_auto_color in pairs(richText.auto_color_elements) do
local a_idx = a_auto_color.idx + 1
if a_idx > #a_auto_color.colors then
a_idx = 1 -- 轮到头了从头来
end
a_auto_color.idx = a_idx
local colorParam = a_auto_color.colors[a_idx]
local color = colorParam
if tonumber(colorParam) then
color = GET_COLOR_BYID_C3B(tonumber(colorParam))
elseif string.find(tostring(colorParam), "#") then
color = ConvertHexStrToColor3B(colorParam)
end
richText:setElementColor(a_rich_element, color) -- 只改色,不重建元素
end
end
schedule(richText, callback, 1) -- 每秒一跳
end
richText.auto_color_elements[richElement] = auto_color
end
三个工程细节,个个值得抄。第一,schedule(richText, callback, 1) 的定时器挂在富文本节点自身——节点销毁定时器跟着销毁,不会有孤儿计时器在后台空转。第二,变色用的是 setElementColor 原地改色,不是拆掉重建文字元素,每秒的刷新成本几乎为零。第三,auto_color_elements 表建在富文本对象身上而不是全局,每个聊天框自己管自己的流水,互不干扰,一个界面关掉整套轮播自然消失。
这套设计的生命周期管理尤其值得单讲一遍,因为它是"挂靠式定时器"的标准范本。定时器的宿主是节点,节点的生死就是定时器的生死,中间不需要任何人记得清理。对比一下反例:全局 schedule 加一张全局表,界面关了定时器还在跑,表里的元素引用还在,渲染层想释放内存时发现引用计数永远清不零——内存泄漏的经典配方,全引擎的"越玩越卡"案例里一半有它的身影。我们组内评审 UI 代码有一条固定动作:看到 schedule 就问宿主是谁,答不上来的直接打回。一个函数一个习惯,省掉的是整类事故。
性能上要心里有数:一条流水文本是每秒一次的全元素遍历乘变色计算,单条无感;但群聊界面一屏二十条流水名字,每秒二十次遍历就开始有感了。引擎没拦你,分寸自己拿——我们的规矩是一屏流水文本不超过三条,超过就说明设计在偷懒,该用别的方式做视觉层次了。
flowchart TD
A[业务方要染色文字] --> B{要流水变色?}
B -->|否| C[PushText: 数字色号或十六进制]
C --> D[RichElementText 入队]
B -->|是| E[PushAutoText: 首色先入队]
E --> F[SetAutoColor 挂色表 + schedule 1s]
F --> G[每秒 setElementColor 轮换]
D --> H[richText 渲染]
G --> H
PushText 里还有个容易被忽略的分支:传了 link 参数时,元素创建走 52 号构造而不是 32 号——带链接的文字可点击。聊天框里"点击传送"、"点击报名"、物品名点击看详情,全是这个分支在撑。
实现要点在文字元素的 outlineColor 和 outlineSize:链接文字默认带描边,视觉上和普通文字区隔开。你可以把链接文字理解成"穿了件 UI 外衣的文字"——文字本体还是那个文字,但挂上了点击热区和描边皮肤。做"物品名称在聊天框中可查看详情"需求时,链的就是物品 ID,点击后走查询协议,和通知机制那篇的 Proxy 查询是同一套。
实战坑提醒一句:链接热区的可点击范围是文字包围盒,中文没有空格分词,一长串链接文字贴着普通文字排,玩家点不准。我们总结的排版规范:链接文字前后必须各留一个全角空格,热区命中率的工单从一天五条降到零。UI 细节的坑,从来不在代码里,在排版习惯里。
链接还有个安全问题必须交代:链接参数会被存进元素对象,如果直接把玩家聊天内容拼进 link 字段,就是给注入攻击开门——恶意玩家在聊天里塞伪造链接,点下去触发的是你不想触发的协议。规范做法是链接白名单制:link 字段只允许服务端下发的受控值(传送地图 ID、物品 ID),玩家生成的文本永远只走纯文字分支。安全审查里"用户输入能不能到达可执行参数"这一问,在富文本这里的答案就是 link 字段,审查聊天功能时专门盯这一处。
再补一个字号的故事。PushText 的 size 参数默认走 ConstantConfig.DEFAULT_FONT_SIZE,引擎全局默认值。有服做"大字体老年模式",把默认字号改大两号,结果公屏富文本没跟着变——因为聊天框的调用方写死了 size 16。修法是把散落的硬编码字号全部收口到配置读取,跟相机那篇"视口尺寸收口"是同一个病同一个药方。全局可调的东西,代码里不许有第二份真相,这条铁律在 UI 层的违例率最高,评审时专盯数字字面量。
下面这个演示把公屏聊天框搬了过来:正文颜色随你点色板切换(PushText 的十六进制格式),名字默认流水变色(PushAutoText + schedule 每秒轮换),取消勾选就退回固定色——观察名字颜色的每秒一跳,那就是 SetAutoColor 的节拍:
skill-richtext-fc-1003c
有个服的公告系统乱成一锅粥:跑马灯一套格式、聊天公告另一套、邮件标题又一套,三处代码三套解析,策划改个公告颜色要找三个程序员。我们用 RichTextHelp 做了统一收口,工时四天,收益立竿见影。
收口方案是定一份"公告标记规范":文案组用 {色号|文字} 的简易标记写文案,一个解析函数把标记转成 PushText 调用序列,三处展示端全走这一套。跑马灯的逐字弹出、聊天框的静态展示、邮件的富文本渲染,只是同一个元素序列的三种播放方式。策划从此自助改文案,公告相关的零散工单从月均 14 条降到 2 条。
迁移的过程也有一段教训值得记。老公告格式里混着一种"半角竖线"的写法,某次活动文案里恰好有竖线当分隔符,解析器直接把颜色代码吞了半句,公告变成了"盛大充值活动#FF0000仅需1元"这种裸奔样式,玩家截图当乐子传了一星期。修法是解析器加转义约定:正文的竖线写成双写。这类"标记符号撞上正文"的事故在所有标记语言体系里都会发生,闭源小系统的优势是可以自己定转义规则,代价是规范文档必须跟上——我们后来把标记规范贴在了文案组的交接文档第一页,三年没再出过格式事故。
这单沉淀的经验是:富文本系统的价值不在渲染,在文案生产的民主化。 谁的内容谁排版,程序只提供安全的三件套(颜色、字号、链接)。后来我们又加了白名单校验——标记里只允许色号和字号,杜绝了策划手滑把脚本语句粘进公告的诡异事故。给用户自由,同时给自由画边界,工具类系统的产品设计就这一个公式。
收口之后还有个衍生收益值得记录:因为三处展示端吃同一份元素序列,我们顺手做出了"一套公告三端差异化"的能力——跑马灯只播前 40 字加省略号、聊天框全文、邮件端加粗标题。以前是三处各写各的截断逻辑,现在是播放器各自的呈现偏好。数据同源、表现各异,这八个字是富文本收口的全部精髓,也是所有内容系统设计的通用心法:把"内容是什么"和"内容怎么演"焊死在一起的那天,就是你每天加班改三处代码的开始。
问:流水变色的色表能配几个颜色?
没有硬上限,但 3 到 5 个是观感和成本的最优区间。色太多轮一遍要五六秒,玩家感知不到"流水",只觉得这名字在闪;三个色一秒一跳,三秒一个轮回,节奏感刚好。
问:十六进制要不要支持 #RGB 三位简写?
引擎原版只认六位。真要支持,在 ConvertHexStrToColor3B 前面加一层三位转六位(每位重复一次)就行,五行代码。但我们实测发现策划和美术都习惯写六位,简写是白做工,需求不存在的功能别预支。
问:富文本能混排图片表情吗?
能,RichElementImage 就是干这个的,聊天框的小表情全走它。和文字元素的混排自动换行要注意行高取两者的最大值,不然表情会把行撑得忽高忽低。表情大小建议统一 20 像素见方,超过两行文本的富文本性能开始有感,界面设计时留好两行的余量就好。
问:PushBr 换行和直接在文本里塞 \n 有什么区别?
引擎的文本元素对 \n 的换行支持看渲染器版本,不稳定;PushBr 显式插入 RichElementNewLine 元素,跨版本行为一致。规范就是用 PushBr,别赌实现细节。这跟协议那篇"不动旧字段"是一个道理:能走的正路走正路,省不了几行代码。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…