玩家盯着被怪物围住的角落发愁:"这货挡路,怎么绕都绕不过去。"这不是 A* 的活,是另一套系统的活——动态障碍。996 引擎把它放在 logic/ObstacleController.lua,一个坐在消息中枢上的调度员:全场景所有会动的东西,只要可能挡路,都得向它报到。今天讲完这一篇,"地图障碍"这四个字在你眼里就分成了两层。
先把"为什么要有动态障碍"这个根本问题摆正。静态墙解决的问题叫"哪里不能走",动态障碍解决的问题叫"此刻哪里不能走"。前者一张地图表管到天荒地老,后者每一秒都在变。分清这两个问题的人,才会明白为什么引擎宁可多养一个 Mediator 也要把活体障碍从地图数据里拆出来——拆开之后,静态层可以精打细算地一次加载,动态层可以肆无忌惮地随时增删,两层互不拖累。合在一起管的方案不是没有,一试便知:地图数据变成读写皆动的高频表,缓存、寻路、点击判定全线跟着抖。
障碍的第一层是静态墙:地图数据里 isObstacle 标记的格子,山体、建筑、水面,开局加载终生不变——A* 寻路那篇的 mapData[x][y] == 0 查的就是它。第二层是本篇的主角:活体障碍。站着的怪、摆摊的玩家、地上的效果,都会让一个本来可走的格子临时变成"过不去"。
为什么活物要算障碍?给个真实的数字感受一下。没有动态障碍时,玩家的寻路会把路线规划进怪物占着的格子——走过去,撞上,被怪打,寻路重算,再规划一条又穿怪的路线。玩家的角色像喝醉了一样在怪堆里进进出出。动态障碍上线后,寻路天生绕开活物,路线一次到位。某服做过前后对比:怪物密集区(同屏 40 怪)的寻路重算次数从每分钟 180 次降到 22 次,寻路请求省了八成八——障碍数据越准,寻路越省,这是用信息换计算的经典买卖。
动态层和静态层还有个职责上的分工细节:静态层归地图数据管,动态层归 ObstacleController 管,但查询口是统一的。上层系统(寻路、点击判定、技能目标筛选)问一句"这个格能不能过",两层各自作答再汇总——调用方永远不知道下面是几层。这层"统一问答口"让后来加第三层障碍(比如活动临时路障)时,调用方一行不改:新层自己注册进问答口就行。分层不管多深,问答口必须只有一个,这是障碍系统的架构底线。
ObstacleController 的特殊之处在于它的身份:它是个 Mediator,坐在消息中枢上听广播。看它的兴趣列表就知道它盯的东西有多杂:
function ObstacleController:listNotificationInterests()
local noticeTable = global.NoticeTable
return
{
noticeTable.ActorInOfView, -- 角色进视野
noticeTable.ActorOutOfView, -- 角色出视野
noticeTable.ActorPlayerDie, -- 玩家死
noticeTable.ActorMonsterDie, -- 怪死
noticeTable.ActorMonsterDeath, -- 怪死亡动画
noticeTable.ActorRevive, -- 复活
noticeTable.ActorEffectInOfView, -- 特效进视野
noticeTable.ActorEffectOutOfView,
noticeTable.PlayerStallStatusChange,-- 摆摊开合
noticeTable.ActorSafeZoneChange, -- 安全区变化
noticeTable.ActorMapXYChange, -- 挪格
noticeTable.MapData_Load_Success, -- 地图加载完成
}
end
十二种通知,每一种都是"某个格子的可走性变了"的信号:怪进视野它要占格、怪死了要腾格、玩家摆摊要占格、收摊要腾格、安全区变化连带影响、连特效进视野都要登记。这张表就是动态障碍的"变化源清单",你二开加新的占格实体(比如玩家放的路障道具),照着这张表加通知就行。
收到通知后干什么?核心是 DynamicPathPoints——重建动态路径点集合。但直接在每个通知里重建会出乱子:一帧之内怪死了三只、玩家挪了两格,五次重建里四次是白算。引擎的解法是把重建挂在移动动作完成的节拍上:
function ObstacleController:onRegister()
ObstacleController.super.onRegister(self)
local function actCallback(actor, act)
-- 非移动类action 不触发重建
if not GUIFunction:IsMoveAction(act) then
return
end
self:DynamicPathPoints() -- 移动动作完成才重建一次
end
global.gamePlayerController:AddHandleOnActCompleted(actCallback)
end
注意这个回调挂在 gamePlayerController 的动作完成上——主玩家每走完一格,动态路径点重建一次。其余十二种通知只是把"脏标记"立起来,真正的重建等到主玩家下一次落脚时统一做。节拍器合并脏标记,这套手法和移动状态机的 IsMoveTime 合批、HUD 血条的十毫秒班车是三胞胎:变化可以很碎,消费必须成拍。
还有个常量放在文件头,看着不起眼,实际是全系统的哨兵:
local INVALID_MAP_POS = 0xFFFF
0xFFFF(65535)是"无效地图坐标"的保留值。寻路请求的目标是障碍格、或者坐标非法时,系统不返回"失败"这种模糊答复,而是给一个明确到不可能存在的坐标——调用方拿到 0xFFFF 就知道"此路不通",连判断类型都省了。哨兵值要选得足够离谱,65535 对一张几十乘几十的地图来说就是"永远不可能是真格子",零歧义。
哨兵值的使用还有一条配套纪律:它只用于"对内"的信号传递,不能漏到展示层。玩家点了不可达的目标,UI 层收到的必须是已被翻译过的结果(提示音、红叉标记),而不是一个 0xFFFF 的裸值——展示层渲染 65535 这个数字的截图,在玩家社区就是笑料。哨兵是系统间的暗号,暗号出内网就要换成白话,这条和协议篇"哨兵 range=-1"的讨论是同一个硬币的两面。
flowchart TD
A[十二种通知: 进出视野/死亡/复活/摆摊/挪格...] --> B[立脏标记, 不立刻算]
B --> C[主玩家移动动作完成]
C --> D[DynamicPathPoints 统一重建]
D --> E{活物算障碍开关?}
E -->|开| F[动态层 = 视野内活体所在格]
E -->|关| G[动态层 = 空, 只有静态墙]
F --> H[寻路时: 静态层 + 动态层 双查]
G --> H
H --> I{目标格被占?}
I -->|是| J[返回 INVALID_MAP_POS 0xFFFF]
I -->|否| K[正常寻路]
动态障碍最容易做错的是"什么都挡"。引擎的豁免逻辑藏在各通知的处理分支里,归纳出来四条:死的让路、远的不管、自己不让、特效看情况。死了的怪尸体垫底还能挖肉,路要放行;视野外的活体根本不在本地,无格可占;主玩家自己永远不算自己的障碍(不然每次寻路第一步就把自己堵死);特效类要不要占路看具体玩法(火墙占,装饰光环不占)。
"自己不让"这条值得单独讲讲,它是新手必踩的坑。有人把主玩家也登记进障碍表,结果角色站着不动时给自己下达移动命令,寻路一算起点就是障碍格,返回 0xFFFF,角色原地装死。排查两小时发现是"我堵我自己"。任何以自己为参照物的计算,先把自己从集合里摘出去——这条原则在挂机系统(自己不打自己)、Buff 系统(驱散不驱自己身上的光环除外条款)反复出现,算是一族问题。
还有一个反直觉的豁免:正在移动中的角色不算硬障碍。玩家 A 走路走到一半,他的格子算出发格还是目标格?引擎的答案是出发格——到达确认才换登记。这意味着你可以规划一条穿过"正在走的人"的路线,而且大概率走得通(对方也在动)。看起来像 bug,实际是深思熟虑的取舍:如果把移动中的角色按目标格硬挡,人流量大的主城里每条路都绕成蜘蛛网,而这些移动者下一秒就走了。动态障碍要挡的是"驻留",不是"路过",这句话值一次架构评审。
下面这个演示把双层障碍摆上了台面:红框怪会自己溜达,每一次挪格都是一次 ActorMapXYChange,动态重建计数器实时上涨;寻路路线遇到怪自动绕行;关掉"活物算障碍"再寻一次路,同一条路线直接穿怪而过——开关一拨,世界规则改变:
fx-dyn-obstacle-1003e
某服主城安全区被摆摊玩家塞满,主干道彻底堵死,真实玩家点城内任意目标都返回"不可达"。这个案例的难点在于:摆摊是合法行为,不能靠驱散解决,得靠障碍系统自己聪明。
治理方案是给摆摊障碍加"密度豁免":同格摆摊数达到 3 个时,该格的障碍标记降级为"可走但减速"——寻路允许穿摊,但路径成本加 14(借用斜走的成本位),A* 自然只在万不得已时才穿摊。改动量:DynamicPathPoints 里加一个按格计数再定级的判断,半天工时。效果:主城寻路失败率从 17% 降到 0.3%,摆摊玩家零投诉(摊还在,只是路让了)。
这个方案的核心思想是障碍的软硬分级:硬障碍(墙、怪)绝对绕行,软障碍(摆摊、安全区边缘)高成本绕行。A* 天然支持成本化的软障碍——把"是否可走"改成"走的代价是多少",寻路算法一个字不用改。动态障碍的终局形态不是开关,是调参,这一单把这句话钉死了。
方案的第二个衍生改动更巧妙:摆摊一条街单独设了"步行街模式"——整条街的摊位格统一按软障碍处理且只按软障碍处理,硬障碍判定整体关闭。玩家穿越步行街时不会被密集摊位逼出地图边缘,观感上"人在摊位间穿行"。这个模式后来被多个服抄去做了跳蚤市场玩法。同一个 ObstacleController,硬开关、软成本、区域模式三种用法,你二开时先问自己要哪一档,别一上来就改算法。
**问:动态障碍会不会和 A 的搜索窗口冲突?*
会配合而不是冲突。动态障碍只在视野内登记(视野外的怪无格可占),而 A* 的搜索窗口是起点周边 49×33——两套范围高度重叠,动态障碍查的就是窗口内的活体。这种"范围对齐"不是巧合,是引擎有意的省内存设计:看得见的才可能挡路。
问:怪物移动中(两格之间)算哪个格的障碍?
算出发格。到达确认(handleActionCompleted)才换新格登记——和走路确认机制同一个节拍。移动中的怪处于"两格都不硬挡"的模糊地带,硬挡会让玩家看着空格绕路,观感极差;软处理就是接受这个瞬间的误差,反正下一格落脚就重建。
问:跨地图时障碍表怎么处理?
切图 Cleanup 全清,新地图从 MapData_Load_Success 开始重建静态层,动态层随视野进入自然长出来。障碍表和视野管理一样,都是"跟着地图走"的数据,切图清理是它们共同的宿命——忘了清的后果和视野管理那篇的"脏表隐身"一个剂量。
问:点击判定和寻路判定会不会打架?
不会,它们吃同一份障碍数据:点击选中先查"点上有没有活体"(动态层优先,因为人只点看得见的),寻路再查"路上能不能过"(双层都查)。两个消费者一个数据源,判定结果天然一致——你点得中的东西,也一定是绕路时要避开的,这种一致性玩家说不出来但感受得到。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
全服喇叭喊话"恭喜 &<PLAYER_NAME & 获得 &<ITEM_NAME/2001 &",这条公告里的两个占位符是怎…
读引擎 UI 代码时你一定会撞见这样的写法: self._quickUI.btnClose 、 SL:GetValue("x…
玩家手机锁屏再解锁,游戏还在原地;地铁过隧道断网半分钟,回连后接着玩——这两件"理所当然"背后是 logic/gameWor…
每个引擎都有一个"心脏":每帧跳动一次、按固定顺序叫醒所有系统的主循环。996 引擎的心脏在 logic/gameWorld…
玩家买一瓶药,背包角标的元宝、商店界面的元宝、充值面板的元宝三处数字同时跳——这个瞬间几乎没有玩家会注意到,但做客户端的人都…
新接手引擎渲染层的人,打开场景会看到一锅粥:地图、角色、特效、血条、UI 全糊在一起。996 引擎的答案是把场景拆成一张"座…