服务端二开的朋友经常问我:为什么玩家点了地面角色不走?为什么自动挂机有时候捡了尸体不挖矿?十个里面九个,问题出在 BehaviorSelector 这一层。这套东西引擎文档基本不讲,但它是玩家输入和角色行为之间唯一的裁判,今天把它讲透。
先纠正一个直觉。很多从其他引擎过来的朋友以为"点击地面 = 下达移动指令",在 996 引擎里不是。玩家的每次输入都会原封不动递给行为树,由裁判从一摞候选行为里挑一个当下最合适的。看入口:
-- BehaviorController.lua:引擎把玩家输入处理权交给行为树
function BehaviorController:InitOnEnterWorld()
global.gamePlayerController:RegisterLuaInputHandler(handler(self, self.ProcessInput))
self._selector = BehaviorSelector.new()
end
function BehaviorController:ProcessInput(player, actCompleted)
if self._selector then
self._selector:Process(player, actCompleted) -- 每次输入都过一遍树
end
end
RegisterLuaInputHandler 这个名字值得记:整个玩家的鼠标点击、键盘操作,最终都汇到这一个入口,再由行为树分发给具体行为。所以你以后查"我点了怎么没反应",第一站就是这里——先确认输入进没进 Process,再看哪个行为把它截胡了。
顺着输入流往前再走一步。玩家点地面,产生的不是坐标,而是先进入 PlayerInputProxy 这个输入代理,路径点排队、技能 ID 缓存、施法目标,全暂存在代理里,行为树每帧来问一遍"有没有新活"。为什么套一层代理?因为输入产生的速率和裁决消费的速率不一致:一帧之内玩家可能连点三下,裁判一帧只能开庭一次,没个缓冲区就得丢输入。凡是生产者和消费者节奏对不上的地方,中间都要垫一个队列,这个套路从引擎的 _launchCache 到 _launchCache 的输入代理到处都是,看多了你自己写系统时会下意识用。
还有个细节:InitOnEnterWorld 是进地图才调用的,不是启动时。也就是说切地图的瞬间行为树整个重建,七个行为对象全部重新 new。你在行为对象里存的任何跨局状态(比如"我上一次挖矿的位置")切图就没了,要持久化得放 Proxy 层。吃过这个亏的人不在少数:挂机脚本在 A 图好好的,传送去 B 图就失忆,查半天发现是状态存错了层。
BehaviorSelector:Init 里按顺序 new 出七个行为,这个顺序就是优先级,从高到低一个都不能记错:
function BehaviorSelector:Init()
self._behaviors = {
BehaviorKLaunch.new(), -- 1 扔技能(最高优先级)
BehaviorTurn.new(), -- 2 转向
BehaviorMove.new(), -- 3 走位移动
BehaviorDig.new(), -- 4 挖肉
BehaviorCorpse.new(), -- 5 吃尸体/拾取
BehaviorMining.new(), -- 6 挖矿
BehaviorLaunch.new() -- 7 施法(带起手的完整施法流程)
}
self._nBehavior = #self._behaviors
end
为什么扔技能排第一?战斗里手速就是命,只要玩家有技能输入意图,哪怕角色正在走路也要立刻中断去放技能。为什么挖矿排第六?因为它是生产动作,永远不该抢战斗的优先权。这个座次表是行为设计的宪法:你加新行为,先想清楚它该插在哪个位置,插错了位置整套手感就毁了。 真实事故后面案例里讲。
再看裁决循环本体:
local status = BehaviorConfig.BTSTATUS_Failure
local currBehavior = nil
for i = 1, self._nBehavior do
local behavior = self._behaviors[i]
status = behavior:Update(player, actCompleted)
if status == BehaviorConfig.BTSTATUS_Success or status == BehaviorConfig.BTSTATUS_Running then
currBehavior = behavior
break -- 有人接管就立刻停,后面的全部不看
end
end
三个状态各管一件事:Success 是"这事我一帧办完了",Running 是"这活我包了,接下来归我",Failure 是"不归我,下一个"。注意扫到 Success 或 Running 就 break,优先级低的行为连被询问的机会都没有——这就是"打断"的实现方式:高优先级行为每次都先被问,它一接手,低优先级自然靠边站。
裁判也不是每次都开庭,Process 开头有三条直接退回的路:
-- 摆摊中:任何输入都别想动我
if SL:GetValue("STALL_MY_TRADING_STATUS") then
if actCompleted ~= global.MMO.ACTION_IDLE then
facade:sendNotification(global.NoticeTable.ActorEnterIdleAction, playerID)
end
return nil
end
-- 野蛮冲撞的等待帧/失败帧:同样直通待机
if actCompleted == global.MMO.ACTION_DASH_WAITING then
facade:sendNotification(global.NoticeTable.ActorEnterIdleAction, playerID)
return nil
end
摆摊那一条最典型:做生意的时候被朋友拉去打怪,点了半天地不动,以为客户端卡了——不是卡,是裁判压根没开庭。做"摆摊保护"类二开需求,学引擎这个手法:在最高层短路,别在各行为里各自为政地判断,否则漏一处就是 bug。
flowchart TD
A[玩家输入] --> B{摆摊/野蛮等待中?}
B -->|是| C[直通 ACTION_IDLE 退出]
B -->|否| D[第一轮遍历 7 行为]
D --> E{有 Success/Running?}
E -->|是| F[该行为接管本帧]
E -->|否| G[AutoController:stateBegin 自动战斗选目标]
G --> H[第二轮遍历 7 行为]
H --> I{有 Success/Running?}
I -->|是| F
I -->|否| J[ActorEnterIdleAction 待机]
第一轮七个行为全说"不归我",正常人的思路是直接待机。996 引擎多走了一步:
-- status 还是 Failure:交给自动战斗控制器看看
if status == BehaviorConfig.BTSTATUS_Failure then
if self._autoCtl then
self._autoCtl:stateBegin(player, actCompleted)
end
end
-- 自动战斗选择后,会产生新的行为,再遍历一次
if status == BehaviorConfig.BTSTATUS_Failure then
for i = 1, self._nBehavior do
local behavior = self._behaviors[i]
status = behavior:Update(player, actCompleted)
if status == BehaviorConfig.BTSTATUS_Success or status == BehaviorConfig.BTSTATUS_Running then
currBehavior = behavior
break
end
end
end
stateBegin 会根据挂机设置挑一个目标(最近的怪、要挖的矿),往输入代理里塞回一个"新意图",然后整个列表重新扫一遍。这就是为什么你开着自动挖矿,站在矿边上什么都不点,角色也会自己动起来——第一轮人类输入是空的,全失败;AutoController 塞了个挖矿意图;第二轮 Mining 一口咬住 Success。
第二轮再全失败,才轮到最后那句待机兜底。三段式:人类意图、自动意图、原地待机,一层比一层保守。这个结构抄到任何挂机系统里都不过时。
下面这个演示把裁判工作台搬了出来:四个场景按钮,看扫描指针怎么从 KLaunch 一路往下扫、哪里亮红叉、哪里亮绿勾;重点玩"点了技能但没目标"和"键盘乱按",后者能看到第一轮全灭后 AutoController 介入、二次遍历挖矿接管的完整过程:
skill-bt-priority-1003a
接一单二开,需求是"自动拾取掉落",实习生把 BehaviorPickup new 出来直接插在列表第 1 位——毕竟"捡东西最要紧"。结果上线当晚炸锅:战士劈砍动作的前摇帧里输入被 Pickup 截胡,技能放不出来;更邪门的是站在怪堆里角色反复弯腰捡东西,打一下捡一下,像在跳光slot舞。
问题就出在座次上。Pickup 插第 1 位,它每帧先被问,只要视野里有掉落物它就回 Running,KLaunch 永远轮不上。正确位置是第 5 位和 6 位之间——拾取比挖矿急,但必须给战斗让路。挪完位置,一切正常。
这单我总结了一条铁律:优先级表里每个位置都有语义,1 到 3 是战斗即时响应区,4 到 6 是生产采集区,7 是完整流程区。 新行为先问自己"玩家在激战时它敢不敢打断技能",敢,进前 3;不敢但比挖矿急,进中间;是个有起手有收尾的完整流程,放最后。拿不准就放低不放高——插低了表现是"偶尔没触发",插高了表现是"整个职业残废",两个 bug 的严重度差一个数量级。
事故复盘的时候我们还发现一个次生问题:Pickup 在第 1 位的时候,连摆摊保护都失效了。按理说摆摊短路在 Process 开头,轮不到 Pickup 撒野——查下来是实习生"顺手优化",把三条短路判断挪进了循环里,想省一次空转。挪完之后短路依赖的某个取值在 Pickup 里被改掉了,短路条件失效。这个次生 bug 的教训比主 bug 还贵:裁判庭规要改只能改裁判,别把法条塞进律师手里。 短路逻辑留在循环外,一行业务都不许往 Process 里塞,这条现在写进了我们团队的评审清单。
行为树这套"排队打分选一人"的结构,脱离游戏也到处能用。我拿它重写过客服工单分配:工单进来先问 VIP 通道(KLaunch 位)、再问转人工(Turn 位)、再问机器人自动回复(Move 位),一层层问下去,有人接单就 break。上线后客服主管最大的感受是"优先级一眼看得懂",因为整个分配逻辑就是一张从上到下的表,新人培训十分钟。
抄的时候注意三个适配点。第一,候选数量别贪多,超过十五个就该分组了,一层摊三十个节点,每次裁决扫到底的性能和可读性都崩。第二,Failure 要便宜:每个行为的 Update 在"不归我管"时必须立刻返回,做了重计算再失败,整棵树的扫描成本就是七个行为的满负荷——引擎里挖矿行为判断"附近有没有矿"用的是格子查询不是全图扫描,就为了失败时够快。第三,日志要带轮次,第一轮第二轮混在一条日志里,你看回放会疯。
这套路子的上限也说一下。行为树解决"每帧选一个当下最合适的行为",它不管长线规划——"先引怪到角落再集火"这种多步谋略,得靠上层策略层把"意图"喂进来,树只管执行。很多二开团队想在行为树里写战术,写着写成就成了面条,就是没分清执行层和决策层。引擎自己也是这么分的:AutoController 是决策,Selector 是执行,井水不犯河水。
Process 里有两段几乎一样的代码,很多人以为是复制粘贴事故,其实是故意的:
-- 释放技能可能需要走位,不等下一帧,当前帧生效
if currBehavior and currBehavior:GetType() == BehaviorConfig.BehaviorType.BehaviorLaunch then
local inputProxy = global.Facade:retrieveProxy(global.ProxyTable.PlayerInputProxy)
local pathPoints = inputProxy:GetCurrPathPoint()
if pathPoints > 0 and inputProxy:GetLaunchSkillID() ~= nil then
local behavior = self._behaviors[3] -- 直接点名 3 号 Move
status = behavior:Update(player, actCompleted)
...
场景是这样:玩家点了远处一个怪放技能,技能射程不够,需要先走两步。如果等下一帧再让 Move 接手,角色会顿一帧;引擎的做法是本轮裁决出 Launch 之后,立刻回头把 Move 拎出来问一遍"这一帧能不能先走位",用直接下标 self._behaviors[3] 点名——牺牲一点灵活性换零延迟。注释里那句"不等下一帧,当前帧生效"就是全部理由。你看,框架作者也会为手感开小灶,关键是开得明明白白。
查行为树问题我固定用三招。第一招,在 for 循环里临时加一行 print(i, behavior:GetType(), status),一点击输入,七个行为谁说了什么一目了然,90% 的"没反应"问题两分钟定位。第二招,怀疑被短路时,把摆摊那几个 if 临时加 print,确认输入根本没进循环。第三招,查自动战斗,把 stateBegin 前后的 status 打出来,看 AutoController 到底塞没塞回意图。三招全是 print,不丢人,行为树的问题 print 比断点快,因为它本质是每帧重放的一条流水线。
再送一条压箱底的:把 print 输出抄进表格里横向排开——一行一次输入,七列对应七个行为,格子填 S、R、F。连测二十次输入,这张表就是整套手感问题的体检报告,哪一列在它不该红的场景红了,一目了然。我们管这叫行为矩阵,交付二开项目时附一张,甲方运营排查问题的工单量直接砍半。
问:actCompleted 参数是什么?
上一个动作的完成状态。行为树用它判断"当前角色忙不忙":正在挥刀时走路行为会看你新点的格子要不要排队,全靠这个参数传递上下文。
问:怪物 AI 也用这套 Selector 吗?
不用。这套是玩家输入专用,怪物走 gameActorStateMonster* 状态族加各自的 AI 控制器,两套体系。别把挂机逻辑写进怪物状态机,也别给玩家行为树加"仇恨"概念——半自动挂机是玩家意志的延伸,怪物的自动化是纯 AI,边界要分清。
问:能挂多个行为同时 Running 吗?
这套设计里不行,单选中破,一次只有一个 currBehavior 接管。要"边走边回蓝"这种并行需求,回蓝属于 Buff 系统的活,别塞行为树。行为树管"角色此刻在干什么",Buff 管"角色身上在发生什么",混着用早晚打架。
👉 完整课程入口:996 全套课程体系(千余节课录) | 想跟浮生梦老师系统学的,看 LUA 高并发商业架构路径。
全站技术干货持续更新:996 引擎 / Lua 实战帖,语法、参数与示例一篇讲透。进入文章地图 · 查看全部 →
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
评审二开代码的时候,我看到最多的坏味道不是写错,是"穿墙":怪物死亡的函数里直接调 UI 刷新,UI 按钮回调里直接改怪物状…
做排行榜、背包、邮件列表的兄弟,迟早会遇到同一张工单:"列表一打开掉帧,滑动像幻灯片"。99% 的原因是把一千行数据老老实实…
新接手 996 引擎客户端的人,打开 network/networkUtil.lua 看到满屏的 i1 、 I4 、 C32…