怪物巡逻是最不起眼的状态,也是最容易被改坏的状态。玩家不看它的时候觉得"怪走来走去很自然",一旦节奏错了立刻出戏:要么怪像踩了风火轮,要么像在水里走。节奏的裁判是 actor/gameActorStateMonsterWalk.lua 里的一行 min 公式——今天从这一行讲起,把怪物巡逻的时序账算清楚。
巡逻状态在怪物状态族里的戏份排第二(仅次于待机):一只怪 24 小时里九成时间在巡逻,战斗只占它人生的零头。这意味着巡逻代码的每一点瑕疵都会被放大几万倍——一帧的顿挫、一格的滑步,乘上全服怪物数和运行时长,就是玩家眼里"这游戏做得糙"的直接证据。最不起眼的状态最需要打磨,因为它曝光时间最长。
怪物的走路时长,玩家走路那篇埋过伏笔(怪物继承同一个移动基类),它的覆写只有五行:
function gameActorStateMonsterWalk:GetMoveTime(actor)
-- 问动画管理器要这套走路动画的总时长
local animTime = global.FrameAnimManager:GetAnimTotalDuration(
actor:GetAnimationID(), global.MMO.SFANIM_TYPE_MONSTER, global.MMO.ANIM_WALK)
-- 和配置步长÷走速比一下,取较慢者
return mMin(animTime, actor:GetWalkStepTime() / actor:GetWalkSpeed())
end
OnAnimLoadCompleted 的补时差也和攻击、死亡同款:动画迟到时拿"总时长减已播时长"重设倒计时,一格不多一格不少。走路、攻击、死亡三个状态各抄一遍这段,抄到第三遍你应该看出门道了——凡是"时长以动画为基准"的状态,都需要这份迟到补偿,因为图集加载完不成就没法开始计时,开始了就得从中间接。这套补偿模板是怪物状态族的公共零件,你写第四个动画基准状态时直接复制。
min(动画时长, 步长÷速度)——取两者中更慢的那个。为什么取慢不取快?因为快的那边迁就慢的那边,走路动画才不会"脚底打滑":动画 0.5 秒迈完一个循环,你逼它 0.3 秒走完一格,脚步频率对不上位移,视觉上是"溜冰";反过来动画 0.9 秒你只给 0.5 秒,怪物半路切帧,看着像卡碟。取 min 让两者中较慢的一方成为节拍,另一方的轻微不同步肉眼无感。这个公式和攻击状态的"快于帧速度就加速"条款是一对亲兄弟:都是动画优先,慢的一边定调。
顺带理清三个参数的分工:animTime 是美术定的(这套腿倒得多快),GetWalkStepTime 是策划定的(这个怪多灵活),GetWalkSpeed 是战斗系统定的(加速缓速 Buff)。三方在 min 里会师,美术、策划、程序各管一个旋钮,公式只负责取慢——改动互踩的概率被这个公式天然压低了。
巡逻开跑之前还有一个绕不开的前置:出生。怪物出生带一个 BORN 动作(从地里钻出来、光柱降临之类),这段时间是出生保护期——不可选中、不可攻击、自己也不巡逻。第五站刷怪包那篇讲过出生动画的广播(ActorMonsterBirth),广播发出意味着保护期结束,AI 才开始派巡逻单。
出生保护的时长就是 BORN 动画的时长,同样问动画管理器要。为什么必须有这个保护?两个原因。技术侧:怪的图集刚加载、坐标刚登记,立即投入巡逻会出现"半加载走路"的鬼畜帧。玩法侧:刷怪是空间的突发事件,给 0.8 秒的保护期等于给玩家一个"怪来了"的缓冲,刷在脚边也不至于秒被咬——保护期是给系统的准备时间,也是给玩家的反应时间,一份时间两份用途。
出生保护还有个玩家熟悉的变体:PVP 里的出生点无敌。复活点的三秒无敌和怪物的 BORN 保护是同一个设计在不同场景的投影——都在回答"刚出生的脆弱期怎么办"。区别只在保护内容的实现:怪物的保护是"AI 不理它",玩家的保护是"伤害判空"。机制因对象而异,设计思想一脉相承,读引擎时把这种"同思想不同实现"的对子找出来,比单个系统读懂得更快。
保护期的玩家体验也有正反两面要说。正面:缓冲给了反应时间。反面:Boss 战的连续刷新小怪如果每只都保护 0.8 秒,玩家会觉得"刷出来的怪碰不得",手感能闷。我们服的做法是给活动刷怪一个可配置的保护缩放(活动怪 0.4 倍保护时长),普通野怪维持全额——保护期是参数,不是信仰,该缩就缩。
flowchart TD
A[服务端刷怪包] --> B[BORN 出生动作 0.8s 保护期]
B --> C[播完广播 ActorMonsterBirth]
C --> D[AI 派巡逻单: 目标点A/B]
D --> E[OnEnter ACTION_WALK]
E --> F[GetMoveTime = min 动画时长, 步长÷速度]
F --> G[逐格移动 + 服务器确认]
G --> H{到巡逻点?}
H -->|是| I[IDLE 停顿 1~3秒 后折返]
H -->|否| F
E -->|玩家进入仇恨| J[打断 → ATTACK]
巡逻的完整节拍是"走一格 → 确认 → 下一格 → 到点 → 停顿 → 折返"。那个"停顿"是点睛之笔:真怪物到达巡逻点后会发呆一到三秒(左右张望的动作帧),再折返。这个发呆不是偷懒,是拟真——匀速永动的巡逻是最假的行为,加一个随机停顿,怪立刻"活"了。停顿时长还要带随机(1 到 3 秒均匀分布),固定停顿会被玩家摸出规律,形成"掐秒过怪"的速通玩法。
发呆的时长分布也有讲究:均匀分布比正态分布好。均匀分布让怪的停顿 truly 不可预测,掐表党彻底死心;正态分布有集中区间,时间长了玩家照样摸出平均等待。随机数的"随机感"不来自算法复杂,来自分布选择——这是随机性设计的入门课,巡逻发呆就是最好的练习题。
发呆时怪物还会做一件小事:左右张望的朝向切换。朝向切换触发的是我们在走路篇讲过的老链路——DirtyAnimFlag 立脏牌、BuffManager:UpdateBuffSfxDir 同步特效朝向。一只发呆张望的怪,牵动的是动画缓存和 Buff 系统两处更新,这些成本都花在"像活物"这个目标上。真实感的本质就是一堆便宜小动作的堆叠,每个都不贵,加齐了才活。
巡逻点通常两个到四个,AI 在点间循环。点怎么来的?怪物出生时服务端下发巡逻半径(常见 3 到 5 格),客户端在半径内随机取点——这样多只同类型怪的巡逻路线天然错开,不会排着队走齐步。
仇恨打断是巡逻的终点:玩家进入仇恨范围(切比雪夫距离判定,和掉落拾取同款),巡逻状态被打断进 ATTACK。被打断的巡逻不恢复——打完架怪会重新找路回巡逻点,而不是接着上次的半截路走。巡逻是可弃动作,仇恨是最高优先,行为树的裁决在这里又赢一局。
下面这个演示把双约束公式做成了可视化:拖"动画总时长"和"步长÷速度"两个滑块,看蓝色的动画条和紫色的配置条谁更慢、生效的约束是哪边;点"重新出生"复习 0.8 秒保护期;巡逻点 A、B 之间的虚线就是怪的 commute 路线:
fx-monster-patrol-1003f
某服新换了一批怪物美术资源,玩家反馈"怪走路像在太空漂移"。定位半天,发现是美术给新怪配的走路动画从 8 帧加到了 14 帧,动画总时长从 0.5 秒变成 0.9 秒——而怪的 WalkStepTime 还是老配置 0.4 秒。min 公式取慢,怪按 0.9 秒走一格,但客户端的巡逻点间距没变,脚步动画频率看着是原速——动画改了、位移没跟,脚滑就是这么来的。
修法是让动画和步长重新对齐:美术按每格 0.5 秒的目标重排新动画帧间隔,策划把 WalkStepTime 按新怪的"体质"重新标定(重甲怪 0.7 秒、轻甲怪 0.4 秒)。修复后的验收标准我留给了组里:截取一段巡逻录屏逐帧看,怪的脚步落地帧必须和格子切换帧对齐,误差不超过一帧。这个"脚步对齐帧"验收后来成了所有新怪上线的固定检查项,太空步投诉归零。
那次排查还顺手建立了一张"动画-步长对照表":每种怪的动画总时长、WalkStepTime、WalkSpeed、生效的 GetMoveTime 四列并排,谁慢一目了然。表格里三只怪的动画时长明显超标(0.9 秒以上),全部回炉重排。排查出的问题要沉淀成对照表,下次美术换资源前先把表甩过去——事前对齐的成本是事后排查的十分之一,这张表现在还是美术组的交接文档第一页。
这个案例的深层教训是:min 公式保证了"不穿帮的下限",但上限的手感需要动画和位移的持续对齐。公式是保底,调参是修行,两者别混为一谈。
修复的对齐工作里还有个实用技巧:美术调帧间隔时别一帧一帧手调,让程序写个小工具按"目标总时长÷帧数"自动铺帧——铺完美术只在关键帧(落脚帧、蓄力帧)上微调。批量铺帧加手工点睛的流程,把一套走路动画的对齐时间从半天压到一小时。工具替人干粗活,人只干细活,这个分工在任何调参场景都成立。
补一个当年排障时的弯路供参考:一开始我们怀疑是渲染层的插值把位移抹平了,往 smooth_tick 和 mir_tick 里各插了五处日志,查了一整晚毫无收获。第二天换个思路"从数据入手",把动画时长和步长直接打印出来对比,十秒钟看到 0.9 对 0.4 的悬殊。先看数字再怀疑代码——排查手感类问题的第一动作永远是打印参与计算的原始参数,这一课我讲给每个新人,但他们总要自己踩一遍才信。
问:玩家走路为什么不取 min?
玩家走路的动画是"通用步行循环",不跟单只怪的体型绑定,帧间隔自由;玩家的节奏完全由步长÷速度决定,动画只是贴上去的皮。怪物动画个性化强(每种怪自己的步态),才需要 min 来保底。有无专属动画,决定了时长听谁的。
问:巡逻半径能不能动态改?
能,活动里"怪群游走"就是服务端动态下发新巡逻中心。客户端跟着更新巡逻点集合即可,但正在巡逻中的怪要到当前格走完才认新点——半路改目标会出现急转弯。改点不急,落脚再改,和走路确认机制同一个脾气。
问:两只怪巡逻路线重叠怎么办?
引擎不管(怪的密度低到撞路线概率很小),撞上了靠动态障碍让它们互相绕。要彻底避免,服务端发巡逻点时做"占用避让"——先到先得,后到的怪取偏移点。这个属于服务端 AI 的活,客户端不掺和。
问:怪物发呆时的朝向会变吗?
会,发呆期间按低概率随机换朝向(模拟左顾右盼),换朝向走的是朝向刷新那套老链路(DirtyAnimFlag 加 Buff 特效转向)。别小看这个细节:发呆中永远面朝一个方向的怪,玩家看着像贴图。巡逻状态的真实感是靠一堆这种小动作堆出来的,每个都不贵,加齐了就活。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…