服务端刷一只怪,客户端从收到一个包到屏幕上冒出一只活蹦乱跳的怪,中间要走五站。这条"翻译链"在 logic/netMonsterController.lua,今天跟着一条 MSG_SC_NET_MONSTER_BORN 包走完全程——你会看到网络层、actor 层、动画层、音效层、广播层怎么在两百行代码里排成一条流水线。
netMonsterController 在引擎架构里的身份是"翻译官":服务端说的是协议语言(消息号加字节流),场景说的是对象语言(actor 加状态机),两边词汇完全不通。controller 的工作就是把每个进场的包翻译成一次 CreateActor、把每个动作包翻译成一次 SetAction。这个定位决定了它的代码形态:几乎没有自己的逻辑,全是转发和翻译——翻译官不创作,只忠实转述。它和 netPlayerController、netNpcController 是三胞胎,学会一个等于认识三个。
翻译官还有一项隐含职责:核对身份。收到的每个包都带着 actorID,controller 第一件事是去 actorManager 查这个 ID 在不在自己的登记簿上——不在的包直接丢弃。这层校验拦的是"包比建档先到"的时序乱序(服务端先发了动作包后发了建档包,或建档包丢了),拦下来的动作包不能执行,否则就是在给空气发指令。时序乱序是网络编程的常态而非异常,翻译链上的每一步都要假设"前面的步骤可能还没发生"。
校验失败的处理也有讲究:丢弃可以,静默不行。丢弃的包要计数(同 ID 丢弃超过阈值说明建档链路出问题了,要告警)。丢弃是防御,计数是哨兵,两个动作配套才有完整的防御体系——这条在行为树篇的日志三板斧、通知篇的乒乓计数里都是同一个思路的变体。
翻译链的入口是消息处理器,动作只有一个——建档:
local paramT = {clothID = 0} -- 引擎真细节:这张参数表全文件共用一张
function netMonsterController:AddNetMonsterToWorld(actorID, clothID)
paramT.clothID = clothID -- 只改字段,不新建表
local netMonster = global.actorManager:CreateActor(actorID, global.MMO.ACTOR_MONSTER, paramT)
if nil == netMonster then
return nil
end
-- bind handler
netMonster:SetGameActorActionHandler(self) -- 动作回调绑回controller
return netMonster
end
local paramT = {clothID = 0} 这行是全篇最值得盯的细节:一张参数表全文件复用,每次刷怪只改 clothID 字段就传进去。为什么这么抠?刷怪是批量操作——一波活动刷五十只怪,五十张一次性参数表就是五十个短命 Lua 对象,GC 压力实实在在。单例参数表加字段改写,是"高频调用带参数"场景的标准答案,和动画档案池、下载任务池是同一思想在最细粒度上的落地。
单表复用有一个隐含前提要挑明:CreateActor 拿到表之后必须立刻消费(把字段抄进自己的成员),不能长期持有这张表的引用——下一只怪的 clothID 会把上一只的覆写掉。引擎的 actorManager 正是这么做的。共享参数表的纪律和共享音效表一样:即取即用,绝不存引用,破了这条纪律,表还是那张表,数据全成了别人的。
CreateActor 之后立刻绑 SetGameActorActionHandler(self)——controller 自己当怪的动作者回调接收人。从此这只怪的每个动作开始/完成都会敲 controller 的门,第五站的广播才有了源头。
动作开始的回调里藏着音效链路:
function netMonsterController:handleActionBegin(actor, act)
netMonsterController.super.handleActionBegin(self, actor, act)
actorIDActT.actorID = actor:GetID()
actorIDActT.act = act
SL:onLUAEvent(LUA_EVENT_MONSTER_ACTION_BEGIN, actorIDActT) -- 动作开始广播
-- monster audio
local clothID = actor:GetAnimationID()
if act == global.MMO.ACTION_DIE then
-- 死亡音效位(当前版本静音,留给策划配)
elseif act == global.MMO.ACTION_ATTACK then
local slt = global.SLT.AudioPlayTable3 -- 共享音效参数表
slt.type = global.MMO.SND_TYPE_MON_ATTACK
slt.index = clothID
global.Facade:sendNotification(global.NoticeTable.Audio_Play, slt)
elseif act == global.MMO.ACTION_WALK or act == global.MMO.ACTION_RUN then
...同款三行,只换 type...
elseif act == global.MMO.ACTION_BORN then
...同款三行...
end
end
global.SLT.AudioPlayTable3 出场了——config 目录 ShareLuaTable 里预建的共享表(那一排 AudioPlayTable 到 AudioPlayTable4)。音效通知是高频事件:一群怪走路,脚步声事件每秒几十条,每条都 new 一张参数表就是纯浪费。引擎预建了四张共享表轮换着用(三号表专供怪物),同一瞬间只有一条通知在用一张表——共享参数表的并发前提是"用完即弃、单线程消费",通知分发正好满足。这个细节和 ShareLuaTable 文件名里那句"节省多余的 NewLuaTable"注释互为印证:整个引擎的每一张共享表,都是 GC 账本上省出来的一行。
每个动作分支三行结构完全一致,只有 type 不同——有人会问为什么不写成循环查表。因为四个分支的音效类型、触发条件各有微调(死亡那个还被注释掉了留白给策划),平铺直叙的 if-elseif 在"各自会各自演化"的场景里,比优雅的表驱动更抗改动。分支少且会分化时,平铺胜过抽象,这句和队列篇"分支多才抽象"凑成一对。
那行被注释掉的死亡音效也是条线索:SND_TYPE_MON_DIE 的位子留着,实现被注释——大概率是当年死亡音效和尸体消失的时序打架(音效还没播完怪已经没了),策划选择先砍掉保平稳。代码里这种"留着位子"的注释不是垃圾,是需求的存根,清理代码时看到它们要三思:你删的可能是策划还没兑现的排期。
音效系统在这条链里还有个隐藏细节:走路和跑步共用 SND_TYPE_MON_MOVE 一个类型。脚步声的区分靠 index(动画 ID)间接实现——不同怪的脚步声音色不同,音效系统按 clothID 选素材。类型管"哪个通道",索引管"哪个素材",两级寻址让音效表不用为每种怪每类动作建一个类型,事件规模从"怪物数×动作数"压到"动作数"。这套二维寻址的思想和通知表、配置表一脉相承,你在设计自己的事件系统时值得原样搬走。
完成回调里还藏着一段专为某类怪写的特例:
-- 大刀坐标恢复
if actor:GetRaceServer() == global.MMO.ACTOR_SERVER_RACE_KNIEF then
local wX, wY = global.sceneManager:MapPos2WorldPos(actor:GetMapX(), actor:GetMapY(), true)
local dir = G_ActorUtils.GetActorAttrByID(actor:GetID(), global.MMO.ACTOR_ATTR_DIR)
actor:setPosition(wX, wY)
actor:SetDirection(dir)
actor:DirtyAnimFlag()
end
大刀卫士的攻击动作自带位移(抡出去半格),动作播完人没回原位,下一次攻击的判定点和贴图就对不上。引擎的做法是动作完成时强制把它拽回服务端认定的格子坐标。这个特例的价值不在功能,在范式:表现漂移的兜底方案是"动作完成时向权威坐标复位"——凡是有位移的动作(冲锋、击退、抓取),都可以套这个兜底。你在二开加"拉人"类技能时,这个复位三件套(算世界坐标、设朝向、立脏牌)直接抄。
flowchart TD
A[服务端 MSG_SC_NET_MONSTER_BORN] --> B[handle_MSG_SC_NET_MONSTER_BORN]
B --> C[AddNetMonsterToWorld]
C --> D[paramT.clothID 改字段(单表复用)]
D --> E[actorManager:CreateActor 建档]
E --> F[SetGameActorActionHandler 绑回调]
F --> G[BORN 动作播放 + SND_TYPE_MON_BORN]
G --> H[Tick: actT≤0.00001 完成]
H --> I[广播 ActorMonsterBirth + LUA_EVENT]
I --> J[怪进入 IDLE, 正常生活]
BORN 动作播完的瞬间,完成回调里连发三个广播:
function netMonsterController:handleActionCompleted(actor, act)
...
-- 出生完成,广播
if act == global.MMO.ACTION_BORN then
actorIDActorT.actorID = actor:GetID()
actorIDActorT.actor = actor
global.Facade:sendNotification(global.NoticeTable.ActorMonsterBirth, actorIDActorT)
actorIDT.actorID = actor:GetID()
SLBridge:onLUAEvent(LUA_EVENT_MONSTER_BIRTH, actorIDT)
elseif act == global.MMO.ACTION_CAVE then
...钻洞态处理...
end
end
注意广播发了两路:Facade 通知(Lua 内部系统认领)加 SLBridge 的 LUA_EVENT(外部桥接层,接埋点统计、SDK上报)。同一条事实,两套总线各发一次——因为消费者不同:内部系统走 Facade,外部数据管道走 Bridge。一条事实、多总线广播,按消费者选通道,这是通知机制那篇"事实广播"的进阶形态。
两张广播参数表也不同:actorIDActorT 带整个 actor 引用(内部系统要用对象),actorIDT 只带 ID(外部桥接层跨进程,传对象引用有风险)。同一事实两种载荷,按通道的消费能力裁剪——这个细节把"广播要考虑消费者"从口号落到了字段级。
分支里的 CAVE(钻洞/休眠态)也值得一瞥:怪物完成钻洞动作后置 IsSleepInCave 标记,广播 ActorMonsterCaved。洞里蛰伏的怪不参与渲染 Tick,出洞才醒——又是一个"用状态标记换性能"的标本。
还有一段注释为大刀而生的代码:GetRaceServer() == ACTOR_SERVER_RACE_KNIEF 的怪动作完成时强制回正坐标和朝向。大刀卫士这类特殊怪的动作会带位移,引擎给它单独加了坐标复位——特殊怪的特殊逻辑,用 race 编号分流,不污染通用路径。你现在明白为什么引擎里到处是 race 判断了吧:不是设计不优雅,是怪的世界里总有几个不听话的。
下面这个演示把整条流水线搬上了台面:点"服务端刷一波",三条 BORN 包排队进 controller,逐条翻译成 CreateActor,绿色光柱里怪物出生,BORN 动作播完广播 ActorMonsterBirth;底部的计数器就是这条链的五站流量表,音效开关控制 AudioPlayTable3 的事件统计:
skill-net-spawn-1003e
某服活动"魔族入侵"每波刷 30 只怪,玩家反馈"怪是分批蹦出来的,拖拖拉拉两三秒"。排查发现不是网络慢,是 30 条 BORN 包在同帧到达后,30 次 CreateActor 加 30 次 BORN 动画同时挤在几帧内,加载高峰把后续包挤到了下一帧——雪崩式的挤压延迟。
治理方案是给翻译链加"入场节流":同帧最多翻译 8 条 BORN 包,剩余的顺延到下一帧,30 只怪在 4 帧内全部落地(66 毫秒),玩家感知是"唰地一下全出来了"。单帧峰值从 30 次建档压到 8 次,加载毛刺消失。工时:两小时。这个案例是网络协议篇"粘包分批"的镜像问题——那边是服务端合批发,这边是客户端分帧收,发收两端都要有节流阀,流水线才不憋。
第二刀给了出生动画:30 只怪同时播 BORN 动作加出生音效,音效事件同帧 30 条把音频通道挤爆。加了一条"同帧同类音效只播一条"的去重,音效事件从 30 条砍到 1 条,听感不变。表现层的批量事件,永远只演一份——这条经验后来写进了我们所有"群体事件"的实现规范。
治理后的验收方式也沉淀下来:录屏逐帧数怪物落地的帧分布,30 只怪应该在 4 到 6 帧内全部出现且间隔均匀;帧间隔忽大忽小说明还有挤压残留。用眼睛验收性能不如用帧序列验收性能,一次录屏比十次"感觉流畅"可靠。
这单还顺手验证了 paramT 复用在大批量下的表现:30 次建档共用一张参数表,GC 报告里零新增临时表——单表复用在批量场景的价值被实锤。反过来说,如果当年引擎用的是"每次 new 一张表"的写法,这一单的性能账就要多算一笔 GC,节流方案的收益也会打折扣。底层省下的每一分,都是上层优化的本金,这条因果链在三十行翻译官身上体现得明明白白。
问:AddNetMonsterToWorld 返回 nil 什么时候发生?
actorID 冲突(同 ID 已存在)或 actorManager 登记满。返回 nil 后 controller 直接吞掉这个包——宁可这只怪不出现,也不能让重复建档把场景表搅乱。吞包要有日志,排查"少怪"工单时,这条日志就是答案。
问:怪的 clothID 和 animationID 是一回事吗?
clothID 是服务器下发的"外装编号",CreateActor 时换算成客户端的 AnimationID 去动画缓存查档。变异外形(变身卡)就是服务端临时改 clothID 下发——怪物那篇讲过的"档案按类型分家"在这里接上了头。
问:mNextActionCache 这个动作缓存是干嘛的?
怪正在播放动作时又收到新技能包,新动作先进缓存排队,当前动作播完自动衔接——和玩家行为树的"输入缓冲"(攻击篇的温和档改造)是同一个思想:高速事件流不允许丢包,只允许排队。
问:怪物出生为什么要有 BORN 动作,直接站着出现不行吗?
直接出现会有"凭空冒出"的突兀感,BORN 动作给了玩家"这只怪是新来的"的明确信号——战术上你可以选择先手,情感上不会觉得被偷袭。出生动画时长(约 0.8 秒)也是平衡参数:太短失去信号意义,太长变成活靶子。一切动画时长的背后都是博弈,这条在 Boss 慢刀那篇说过,这里是它的日常版。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…