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

你的每次点击都过了一遍行为树:BehaviorSelector优先级实战

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

服务端二开的朋友经常问我:为什么玩家点了地面角色不走?为什么自动挂机有时候捡了尸体不挖矿?十个里面九个,问题出在 BehaviorSelector 这一层。这套东西引擎文档基本不讲,但它是玩家输入和角色行为之间唯一的裁判,今天把它讲透。

一、你的点击不是命令,是申请

先纠正一个直觉。很多从其他引擎过来的朋友以为"点击地面 = 下达移动指令",在 996 引擎里不是。玩家的每次输入都会原封不动递给行为树,由裁判从一摞候选行为里挑一个当下最合适的。看入口:

lua
-- 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 出七个行为,这个顺序就是优先级,从高到低一个都不能记错:

lua
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

为什么扔技能排第一?战斗里手速就是命,只要玩家有技能输入意图,哪怕角色正在走路也要立刻中断去放技能。为什么挖矿排第六?因为它是生产动作,永远不该抢战斗的优先权。这个座次表是行为设计的宪法:你加新行为,先想清楚它该插在哪个位置,插错了位置整套手感就毁了。 真实事故后面案例里讲。

再看裁决循环本体:

lua
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 开头有三条直接退回的路:

lua
-- 摆摊中:任何输入都别想动我
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。

mermaid
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 引擎多走了一步:

lua
-- 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 介入、二次遍历挖矿接管的完整过程:

demo
skill-bt-priority-1003a

五、实战案例:AutoPickup 插错位置,全服战士集体罢工

接一单二开,需求是"自动拾取掉落",实习生把 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 里有两段几乎一样的代码,很多人以为是复制粘贴事故,其实是故意的:

lua
-- 释放技能可能需要走位,不等下一帧,当前帧生效
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 技术组基于 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 技术组