CHUAN2 DEV ENGINE
996 正版授权研发中心 · 360 授权合作教学中心 · 抖音传奇直播合作授权 · 快手推广运营商授权
OFFICIAL LICENSED ACADEMY 查验官方授权证书 →
// 威海旷世互娱教学基地 · 技术文章
进阶实战游戏功能996引擎

996引擎Buff系统解剖:中毒变绿、冰冻发白是怎么挂上去的

2026-10-03 05:25 作者:996 技术组 996引擎Lua教程传奇脚本进阶实战游戏功能996引擎

前两天群里有个架设的朋友问了个需求:中毒的人要变绿、每两秒掉一次血、毒还能叠五层,问是不是要在服务端把整套状态机重写。我说你先别慌,这个需求在 996 引擎客户端这边,一个 BuffEntity 子类加一张工厂表就够了。今天就把 Buff 这套系统从进消息到自动过期,整条链路扒开讲。

一、Buff 不是状态,是个有寿命的对象

很多从别的引擎转过来的兄弟,一提 Buff 就想到"给角色加个标志位"。996 引擎不是这么玩的,每个 Buff 都是独立对象,有自己的生老病死。看 buff/BuffEntity.lua 的构造函数:

lua
function BuffEntity:ctor(data)
    self._actorID  = data.actorID    -- 挂在谁身上
    self._buffID   = data.buffID     -- 什么类型的Buff
    self._param    = data.param      -- 服务端下发的参数,一般存时长
    self._ol       = data.ol         -- 叠层相关
    self._replaceId = data.replaceId -- 顶替关系

    if self._buffID > 10000 then
        -- 大于10000:param 直接是绝对时间戳(到期那一刻)
        self._endTime = tonumber(data.param) or GetServerTime()
    else
        -- 小于10000:param 是"还剩多少秒",要加上当前服务器时间
        self._endTime = (tonumber(data.param) or 0) + GetServerTime()
    end
    self._autoRemove = data.autoRemove
    self._isInvalid  = false
end

重点看那个 buffID > 10000 的分支,这是新手栽得最深的一个坑。同一个 _param 字段,在两套 ID 面前含义完全不同:小 ID 的 param 是"持续 30 秒"这种相对时长,大 ID 的 param 是"2099 年 12 月 31 日到期"这种绝对时间戳。为什么这么设计?因为有些特殊 Buff 是运营活动用的,策划要求"跨天凌晨整点统一失效",绝对时间戳下发一次就不用管了;普通战斗毒嘛,相对时长随下随生效。你调 Buff 时长发现"改了没反应",八成是往绝对时间戳那类 Buff 里塞了个 30,等于让它 1970 年就过期了。

另外注意 GetServerTime(),全程不许用 os.time()。客户端时钟被玩家随手一改,你的 Buff 要么永生要么暴毙,这种 bug 上线了能查一宿。

Tick 的心跳也值得单独说一句:

lua
-- 只有标了 autoRemove 的Buff才自己看表下课
function BuffEntity:Tick(dt)
    if self._autoRemove then
        if GetServerTime() > self._endTime then
            self:disable()      -- 打上失效标记,等 BuffManager 收尸
        end
    end
end

注意 disable() 只是把 _isInvalid 置成 true,真正从表里删掉是 BuffManager 统一干的。谁负责标记、谁负责清理,分工钉得死死的,这就是为什么驱散、死亡、过期三条路最后都汇到同一个出口,不会漏。你写自己的系统时照这个规矩来:对象只许举手,不许自己把自己埋了。

二、工厂表:一张表管几十种 Buff 的出生

Buff 类型这么多,引擎怎么知道该 new 哪个类?答案在 BuffManager.lua,一张纯函数表:

lua
local buffItemsC = {
    [mmo.BUFF_ID_ICE]        = function(data) return BuffEntityIce.new(data)       end,
    [mmo.BUFF_ID_COLOR]      = function(data) return BuffEntityColor.new(data)     end,
    [mmo.BUFF_ID_FREEZED_GRAY] = function(data) return BuffEntityFreezed.new(data) end,
    [mmo.BUFF_ID_SNEAK]      = function(data) return BuffEntitySneak.new(data)     end,
    [mmo.BUFF_ID_HORSE]      = function(data) return BuffEntityHorse.new(data)     end,
    -- 兜底:没登记的Buff都走这里
    [-1] = function(data)
        local BuffProxy = global.Facade:retrieveProxy(global.ProxyTable.BuffProxy)
        return BuffProxy:IsAvatarBuff(data.buffID) and BuffEntityAvatar.new(data) or BuffEntityAnim.new(data)
    end
}

function BuffManager:CreateBuffItem(data)
    local buffID = data.buffID
    return (buffItemsC[buffID] or buffItemsC[-1])(data)
end

这写法值得抄进你自己的代码里。新产品加 Buff,不用去改 CreateBuffItem 一个字,往表里添一行就完事,[-1] 兜底还保证没登记的类型不会报错——要么走头像换装类,要么走普通动画类。比一长串 elseif buffID == xxx then 舒服多了,改起来也只动一处。

兜底里那个 IsAvatarBuff 判断也有讲究:换装类 Buff(变身、时装)要替换整个角色形象,普通 Buff 只挂个动画特效,两者的资源管理逻辑不一样,所以兜底也要分家。

顺带说下兜底里的 BuffEntityAnim,这是没登记 Buff 的默认归宿,也是你自己做"挂个特效转圈圈"类 Buff 最该继承的类。它要的配置就三样:特效资源 ID、挂点(头顶还是脚下)、是否跟随转向。当年我们做过一个"香火祝福"活动 Buff,玩家头顶飘一炷香,从写配置到真机看到效果,前后二十分钟——框架给你把路铺到这个程度,你要还在自己 new 节点、自己算位置、自己管回收,那就是跟框架较劲,较劲的结果一定是漏一种边界情况。

mermaid
flowchart TD
A[服务端下发 AddBuffEntity] --> B[BuffManager:CreateBuffItem]
B --> C{buffItemsC 表里有这个ID?}
C -->|有| D[对应子类 new 出来]
C -->|没有| E[兜底: Avatar 或 Anim]
D --> F[OnEnter 挂表现]
E --> F
F --> G[BuffManager:Tick 每帧巡检]
G --> H{GetServerTime > endTime?}
H -->|是| I[IsInvalid = true]
I --> J[RmvBuff + OnExit 还原]
H -->|否| G

三、染色一族:八种毒其实是一个类

看工厂表你会发现,红毒绿毒紫毒冰冻麻痹雷霆,全都是 BuffEntityColor.new。因为客户端这边的"中毒变绿",本质上就是给角色精灵叠一层半透明染色罩,颜色不同而已。引擎里专门有一张表管这事:

lua
local colorBuffMap = {
    [mmo.BUFF_ID_ICE]              = 1,   -- 冰冻
    [mmo.BUFF_ID_COLOR]            = 1,   -- 变色
    [mmo.BUFF_ID_COLORS]           = 1,   -- 换色
    [mmo.BUFF_ID_FREEZED_GRAY]     = 1,   -- 麻痹
    [mmo.BUFF_ID_POISONING_RED]    = 1,   -- 红毒
    [mmo.BUFF_ID_POISONING_GREEN]  = 1,   -- 绿毒
    [mmo.BUFF_ID_THUNDER_SWORD]    = 1,   -- 雷霆
    [mmo.BUFF_ID_POISONING_PURPLE] = 1,   -- 紫毒
}

实战里你要加个"黑毒",流程就三步:常量表加个 BUFF_ID_DARK_POISON(避开 10000 以上那批特殊 ID)、工厂表加一行指向 BuffEntityColor、colorBuffMap 也添上。十分钟的事,一行服务端都不用动——伤害逻辑人家早就有了,你只是让玩家"看得见中毒"。

还有个反向的表 ignoreBuffMap,里面登记的 Buff 不进每帧 Tick,纯静态表现。这就是引擎抠性能抠出来的分类意识:能不动的不动,每帧扫的越少越好。

给角色上 Buff 的时候,方向感也是个容易漏的细节。 Buff 实体里有专门的 UpdateSfxDir 接口,怪转向的时候 BuffManager 会跟着刷新特效朝向——火符的火焰罩在背后飘还是正面飘,差的就是这一下。你要做带方向的 Buff(比如背后插的旗子、肩上的火把),记得把自己类的 UpdateSfxDir 返回 true 并且实现转向逻辑,不然怪物一转身特效就"穿模"了。

三点五、图标显隐的隐藏开关:buffshow 字段

还有个特别实用但文档里几乎不提的字段。看 BuffManager:GetDataByUID 这段:

lua
local config, n = BuffProxy:GetConfig()
for i = 1, n do
    local v = config[i]
    -- buffshow == 1 的Buff不进图标栏,只走表现
    if v and items[v.ID] and v.buffshow ~= 1 then
        local item = items[v.ID]
        -- 往UI的Buff列表里塞图标数据
    end
end

意思很直白:配置表里 buffshow 填 1 的 Buff,照样挂角色身上生效,但图标栏里不显示。什么场景用?骑马的 Buff、划水挂机的 Buff、纯视觉的染发 Buff,往图标栏里塞一排,玩家看着心烦,策划也不想要。有了这个字段,"要效果不要图标"就是一个表格数字的事,一行代码都不用改。有架设者自己写了套"隐藏图标"的补丁,改了三百行,后来发现引擎早就留了口子,白干一晚上——这故事我逢人必讲,改代码之前,先把配置表每个字段过一遍,引擎作者早替你想好了。

下面这个演示,你连续点几次"上毒"看看层数染色怎么变深、伤害怎么翻倍;中毒期间点"冰冻",怪直接定住连待机动画都停了,脚下结冰晶、天上下雪粒,倒计时走完自动解冻——这就是 Tick 加 autoRemove 的完整生命周期:

demo
fx-buff-poison-ice-1003a

四、实战案例:毒叠加的五层改法

原版绿毒不叠层,服务端来了第二次加毒就是刷新时长。有个服要做"叠毒流"刺客,客户端要改三处,我拿真实改动给大家算个数。

第一步,叠层存储。AddBuff 的时候判断同 ID 已存在就不再 new,改调 UpdateOl:

lua
-- 已有同ID Buff时刷新而不是新建
function BuffEntity:UpdateOl(ol)
    if ol then
        self._ol = ol          -- 服务端下发的当前层数
    end
end

第二步,刷新到期时间。叠层时长要不要续杯,跟策划吵也要吵清楚:我们最后的方案是叠一层续满 8 秒,那就重算 _endTime = GetServerTime() + 8。BuffEntity 里现成的 UpdateParam 就是干这个的,直接复用。

第三步,表现随层数加深。染色罩透明度从 0.16 起步,每层加 0.13,封顶 0.75——超过 0.75 角色就绿得看不清脸了,PVP 里对手是谁都认不出,这种细节策划想不到,你得替他把关。伤害数字服务端算,每跳 130 × 层数,1.6 秒一跳,五层满毒 22 秒能灌进去四千多血,够让全服刺客改行玩毒了。

这套改完上线实测:同屏二十个叠满毒的怪,帧率纹丝不动。因为染色只是改了精灵的一个渲染参数,不产生任何新节点,这就是为什么颜色类 Buff 全塞给一个类的私心——表现成本最低。

复盘这个案子最有价值的经验是改动的边界:三处改动,全程没碰 BuffEntity.lua 基类一个字。基类是所有 Buff 的地基,地基上动一锹,几十个子类跟着晃。能走子类覆盖的不动基类,能走配置表的不动子类,能走工厂表登记的不动工厂函数——这套"能不动就不动"的优先级,比任何设计模式教材都实在。后来那个服二开加专注、加疾风、加反伤,全是在工厂表添行,一晚上能上三四个 Buff 玩法,这才叫框架红利。

五、三个前人踩过的坑

坑一:副驾驶的 Buff。 构造函数里有段不起眼的代码:骑马副驾玩家的 Buff 一律 _visible = false。当年有服主反馈"马上的人怎么不冒绿泡",折腾半天发现是引擎故意为之——双人骑乘时两人贴得很近,两套 Buff 特效叠一起糊成一团。要放开就把这段判断改掉,但先把特效错开位置,不然回头又得改回来。

坑二:过期不清,只在 Tick 里收尸。 BuffManager:Tick 每帧扫 _buffItems,发现 IsInvalid 才移除。这意味着 Buff 过期到真正消失最多差一帧,表现上无感,但你要是在别处直接读 _buffItems 判断状态,就会读到"已经死了还没下葬"的 Buff。判断状态一律走 BuffProxy 的查询接口,别自己扒底层表。

坑三:顶替关系 _replaceId。 同类 Buff 互顶的逻辑服务端下发 replaceId,客户端只管照做。有架设者自己客户端里写了"高级毒顶低级毒"的判断,结果服务端也在顶,两边顶来顶去把正常的毒顶没了。记住分工:客户端表现层永远不做规则判断,规则是服务端的领地。

坑四:潜行 Buff 的特殊待遇。 BuffManager 构造里有这么一句:self._handleOnMainActBegin = {} --潜行buff 需监听主玩家位置。潜行不是挂个透明罩就完事——走到怪物身边多近才算暴露、移动会不会破隐,这些都要盯着主玩家坐标算,所以潜行 Buff 单独注册了主玩家动作开始的通知,别的 Buff 都不配享受这个待遇。你想抄一套"靠近NPC自动现形"的玩法,照潜行这条路走:进 listNotificationInterests 兴趣列表,在 handleNotification 里接住,别自己开定时器扫地图,一秒扫一次的定时器就是帧率杀手。

调试 Buff 顺手推荐两招。第一招,把 _DEBUG 和 _LOG_RECV_ABLE 打开(引擎网络层就带这开关,后面讲网络那篇细说),服务端每下发一个 AddBuffEntity,控制台把 buffID、param、ol 打得清清楚楚,跟客户端 _buffItems 对着看,五分钟定位"谁没挂上"。第二招,专治"图标和表现对不上":图标栏读的是配置表过滤后的列表,表现读的是 _buffItems 原始表,两边过滤规则不一样,先确认你改的是同一边再动手。

六、常见疑问

问:Buff 的图标倒计时条在哪做?
数据源就是 BuffEntity:GetEndTime(),拿它减 GetServerTime() 得剩余秒数,UI 层每 0.2 秒刷一次足够,别每帧刷文本。

问:服务端没下发过期消息,客户端怎么知道毒没了?
就是靠 Tick 里的 autoRemove 自己判 GetServerTime() > _endTime。所以客户端时间源必须跟服务端对齐,进游戏时的校时逻辑不能省。

问:我想让中毒的人走路留下绿色脚印,从哪个类下手?
别动 BuffEntityColor,去 ActorEffectManager 加脚步特效挂点,Buff 里只做每帧位置同步。特效实例的生命周期归特效管理器管,Buff 只管"我现在还活着",两个系统解耦了以后才不会出现"驱散了毒脚印还在"的灵异现场。

问:冰冻和麻痹看着差不多,为什么分成两个类?
冰冻(BuffEntityIce)走 BuffEntityAnim 一支,重点在"动画停摆+冰晶包装";麻痹(BuffEntityFreezed)重点在整体变灰的操作封锁感。类是按"表现意图"分的,不是按"数值效果"分的——数值都是服务端说了算,客户端只关心"我该给玩家看什么"。以后你面对两个长得像的 Buff 犹豫要不要合并成一个类,就用这条标准去量:看的东西一样才能合并。

👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。

作者履历与出处

本文由 996 技术组基于 996 引擎官方知识库与浮生梦老师课程体系整理。团队长期从事传奇类引擎 Lua 后端逻辑、客户端界面与商业版本交付,内容以官方知识库与真实项目为出处,按版本持续修订。

← 返回文章地图返回研学路径

幂尔框架 · 实战干货 · 接口调用

LATEST ARTICLES

全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →

进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 06:06 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:55 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:52 996 技术组
进阶实战游戏功能

别再全局变量满天飞:996引擎消息通知机制入门

评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…

2026-10-03 05:51 996 技术组
进阶实战游戏功能

背包一卡卡三年:GUIQuickCell懒加载列表救了你

做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实…

2026-10-03 05:46 996 技术组
进阶实战游戏功能

i2、C32、i8s都是什么鬼?996引擎网络协议类型系统速查

新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1 、 I4 、 C32…

2026-10-03 05:40 996 技术组