ET 框架 AI 框架设计:基于条件判断与可中断并发行为的节点轮询方案
【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET
导读
本文围绕 ET 框架(Unity3D Client And C# Server Framework)的设计者所撰写的 AI 框架设计文档展开,完整阐述其"AI = 根据当前状态执行相应行为"的核心思想,给出一种不同于状态机与行为树的节点轮询 + 可中断并发行为AI 方案:抽象出带条件判断(Check)与行为执行(Run)的AINode节点,配合每秒一次的调度循环在节点间切换,并利用协程取消令牌实现行为中断。读者学完后将能理解状态机与行为树的复杂度缺陷、掌握 ET 风格的 AI 节点编写与调度套路,并能在当前仓库源码中定位到支撑该方案的全部底层设施(ETCancellationToken、TimerComponent、ETTask)。
1. 传统 AI 方案的两种思路及其缺陷
设计者从多年游戏 AI 开发经验中总结出一个观点:AI 难写的本质不是开发者水平问题,而是工具(设计模式)选错了。在引入 ET 方案之前,业界主流的 AI 写法主要有两种。
a. 状态机:节点间转换关系呈 N² 复杂度的网状结构
状态机把对象上的任意数据都当作"状态",并将一些状态抽象成一种新类型的节点,当对象状态发生变化时触发节点间转换,并执行对应的OnEnter、OnExit等回调。
以怪物为例,可以把怪物划分为巡逻(Patrol)、攻击(Attack)、追击(Chase)、返回(Return)等状态,其状态转换关系为:
| 转换 | 触发条件 |
|---|---|
| Patrol → Chase | 巡逻时发现远处敌人,进入追击 |
| Patrol → Attack | 巡逻时发现可攻击的敌人,进入攻击 |
| Attack → Chase | 攻击时发现敌人距离过远,改为追击 |
| Attack → Return | 攻击时发现敌人距离过远,进入返回 |
| Chase → Return | 追击时发现与敌人距离过远,进入返回 |
问题立刻显现:仅仅 4 个状态就需要罗列如此多的转换关系,稍不留神就会遗漏。当节点增多后,任意两个节点之间都可能需要连线,状态机退化为一张超复杂的网状结构,维护复杂度与节点数的平方(N²)成正比。后来出现的层次状态机(Hierarchical State Machine)等补丁方案,本质上只是在打补丁,并未解决"状态转换关系爆炸"这一根本问题。文档中的结论是:状态机不好用,不是使用者的责任,而是状态机这种模式本身的问题。
b. 行为树:复杂度降为 N,但难以处理持久化并发行为
行为树是响应式 AI:树从上到下(或从左到右,本文按从上到下讨论)执行,本质上是给动作节点排优先级——上面的动作先判断条件是否满足,满足则执行。
行为树的复杂度为 N,相比状态机大幅简化,但仍存在明显缺陷:
- AI 过于复杂时树会变得非常庞大,难以重新配置。文档以"挂机机器人"为例:要做一个类人的机器人 AI,自动做任务、打怪、玩系统、聊天甚至攻击他人,这样的树复杂度将难以想象。
- 行为树对"持久化过程"(并发过程)管理不佳。例如"移动到目标身边"这个动作,是做成并发过程,还是每帧移动一步?两种做法都不舒服——这正是行为树处理持续性行为的痛点。
2. ET 的核心 AI 思想:状态判断 + 行为执行
在批判完传统方案后,文档给出 ET 方案的两句灵魂总结:
AI 就是不断根据当前状态,执行相应的行为。
这两句话被拆成两个部分:状态判断与行为执行。
- 状态判断:检查当前条件是否满足;
- 行为执行:执行对应的行为逻辑。
沿用怪物 AI 的例子:
| 行为 | 条件(Check) | 行为内容(Run) |
|---|---|---|
| 巡逻 | 在巡逻范围内、周围无敌人 | 选择下一个巡逻点并移动过去,到达后停留一段时间再选下一个点 |
| 攻击敌人 | 在警戒范围内发现敌人 | 攻击距离足够则攻击,不足则移动过去再攻击 |
| 返回 | 与出生点距离超过阈值 | 加上无敌 buff,移动到出生点,到达后移除无敌 buff |
关键区别在于:与状态机不同,这三个状态的切换完全不关心上一个状态是什么,只关心当前条件是否满足,满足就执行对应行为。行为可以瞬间完成,也可以是持续过程(如巡逻是"选点 → 移动 → 到达 → 停留 → 再选点"的循环)。
3. AINode 抽象节点:条件判断 + 可取消并发行为
基于上述思想,AI 框架设计变得非常简洁:抽象一个 AI 节点,每个节点包含一个条件判断,附带一个行为实现;行为方法应是一个并发过程。
文档中的第一版节点定义:
public class AINode { public virtual bool Check(Unit unit) // 测试条件是否满足 { } public virtual ETTask Run(Unit unit) { } }进一步思考:怪物在巡逻时发现敌人,应当中断当前巡逻,转去执行攻击行为。因此行为必须支持被中断,即行为并发过程必须支持取消。这里文档特别强调:
行为 Run 方法中的任何并发过程都必须支持取消操作!
于是节点定义升级为:
public class AINode { public virtual bool Check(Unit unit) { } public virtual ETVoid Run(Unit unit, ETCancelToken cancelToken) { } }3.1 命名演进的源码佐证:ETCancelToken → ETCancellationToken
文档写作时使用的类型名为ETCancelToken与TimeComponent,属于早期版本命名。在当前仓库中,该类型已演进为ETCancellationToken,位于 ETCancellationToken.cs,其核心实现为:
public class ETCancellationToken { private HashSetComponent<Action> actions = HashSetComponent<Action>.Create(); public object Context; // 可以带一个数据 public void Add(Action callback) { // 如果action是null,绝对不能添加,要抛异常,说明有协程泄漏 this.actions.Add(callback); } public void Remove(Action callback) { this.actions?.Remove(callback); } public bool IsDispose() { return this.actions == null; } public void Cancel() { if (this.actions == null) { return; } this.Invoke(); } // ... }从源码可以看到Cancel()的内部机制:取消令牌内部维护一个HashSetComponent<Action>回调集合,Cancel()一次性取出全部回调并逐个Invoke(),之后将集合置空(IsDispose()为 true)。这意味着:
- 每个并发过程在开始前通过
Add(callback)注册自己的取消回调; - 取消时,所有注册的回调被同步触发,从而打断对应的
await; - 取消后令牌不可复用(
actions == null),这也是文档调度循环中每次切换节点前都new 一个新令牌的原因。
配套的辅助扩展位于 ETCancellationTokenHelper.cs,提供了IsCancel()(判断是否已被取消)、TimeoutAsync(超时自动取消)等能力,均可用于 AI 节点内部的并发控制。
4. 实战:实现巡逻 / 攻击 / 返回三个 AI 节点
在明确了节点抽象后,文档演示了三个具体节点:XunLuoNode(巡逻)、GongjiNode(攻击)、FanHuiNode(返回)。巡逻节点完整实现如下:
public class XunLuoNode: AINode { public virtual bool Check(Unit unit) { if (not in patrol range) // 不在巡逻范围内 { return false; } if (there are enemies around) // 周围有敌人 { return false; } return true; } public virtual ETVoid Run(Unit unit, ETCancelToken cancelToken) { while (true) { Vector3 nextPoint = FindNextPoint(); bool ret = await MoveToAsync(nextPoint, cancelToken); // 移动到目标点,返回 false 表示过程被取消 if (!ret) { return; } // 停留两秒,注意这里任何并发过程都必须能被取消 bool ret = await TimeComponent.Instance.Wait(2000, cancelToken); if (!ret) { return; } } } }攻击、返回两个节点可用同样的套路实现。需要注意这段示例中的两个写法在仓库中的对应关系:
TimeComponent.Instance.Wait(time, cancelToken):对应当前仓库的 TimerComponent.WaitAsync。其源码显示,WaitAsync内部会通过ETTask.GetContextAsync<ETCancellationToken>()从当前协程上下文取出取消令牌,并向令牌注册CancelAction:
public static async ETTask WaitAsync(this TimerComponent self, long time) { if (time == 0) { return; } long timeNow = self.GetNow(); ETTask tcs = ETTask.Create(true); TimerAction timer = self.CreateTimerAction(TimerClass.OnceWaitTimer, timeNow, time, 0, tcs); long timerId = timer.Id; ETCancellationToken cancellationToken = await ETTask.GetContextAsync<ETCancellationToken>(); try { cancellationToken?.Add(CancelAction); await tcs; } finally { cancellationToken?.Remove(CancelAction); } return; void CancelAction() { if (!self.Remove(timerId)) { return; } tcs.SetResult(); } }这段源码从底层印证了文档强调的规则:只要把 cancelToken 传入并发方法,取消时定时器会被立即移除、等待立即返回——这正是"任何并发过程必须可取消"的实现保证。ETTask/ETVoid/Coroutine()等类型与扩展则位于 ETTask.cs 与 ETTaskExtensions.cs。
- 移动并发:示例中的
MoveToAsync(nextPoint, cancelToken)在移动模块(cn.etetet.move)中有对应实现,AI 节点只负责调用,不重复实现移动逻辑,这也呼应了后文"并发方法共享复用"的原则。
5. AI 调度循环:每秒轮询节点并切换
设计好节点还不够,还需要把节点串联起来,让 AI 能轮转起来。文档给出的调度核心代码:
AINode[] aiNodes = {xunLuoNode, gongjiNode, fanHuiNode}; AINode current; ETCancelToken cancelToken; while(true) { // 每秒重新判断是否有新行为满足条件,这个时间可自行设置 await TimeComponent.Instance.Wait(1000); AINode next; foreach(var node in aiNodes) { if (node.Check()) { next = node; break; } } if (next == null) { continue; } // 如果下一个节点与当前节点相同,则不执行 if (next == current) { continue; } // 停止当前并发过程 cancelToken.Cancel(); // 执行下一个并发过程 cancelToken = new ETCancelToken(); next.Run(unit, cancelToken).Coroutine(); }这段代码逻辑非常清晰,可以拆解为四个步骤:
- 周期判断:每隔 1 秒(时间可自行配置)重新遍历一次节点数组,取第一个条件满足的节点作为
next——数组顺序即行为优先级,这一点与行为树"上面优先"的语义一致; - 空转保护:没有任何节点满足条件则
continue,保持当前行为不动; - 去重优化:
next == current时直接跳过,避免对正在执行的行为做无意义的重复启动; - 中断切换:先
cancelToken.Cancel()停掉当前并发过程,再new一个全新令牌并Run(...).Coroutine()启动新行为。
Run(...).Coroutine()即 ETTask.cs 中定义的火并忘(fire-and-forget)启动方式,让行为以协程方式异步运行,不阻塞调度循环。
6. 使用中的三个误区(要点总结)
文档最后总结了使用该方案时必须注意的三个要点,这也是"可取消并发"设计最容易踩坑的地方:
- 行为中存在并发过程则必须可取消,并传入 cancelToken。否则一旦怪物满足下一个节点的执行条件,就无法中断当前并发过程,AI 将卡死或错乱。这是本方案成立的前提,由 TimerComponentSystem.cs 的
WaitAsync实现从底层保证。 - 与行为树、状态机不同,节点只是一段逻辑,节点本身不需要共享;共享的是并发方法。例如
MoveToAsync,怪物的巡逻节点可以用,攻击节点追击敌人时同样可以使用。这大大降低了代码重复,也降低了节点间的耦合。 - 节点可以做得很"大"。例如"自动做任务"节点:移动到 NPC → 接任务 → 根据任务的子任务逐个完成(移动到怪物点打怪、移动到采集点采集)→ 全部完成后移动到任务 NPC 交任务。整段逻辑只需写在一个
while循环里,用一段"并发串"(协程序列)串联起来即可。
7. 延伸:用节点拼装挂机机器人
文档在结尾抛出一个大问题:如何设计挂机机器人(bot)?挂机机器人需要的能力包括:自动做任务、自动玩各种系统、自动攻击敌人、被攻击会反击、会找人聊天等。
答案在本文的框架下变得异常简单——把上面提到的每一项能力各做成一个 AI 节点:
- 自动做任务 →
TaskNode - 自动玩系统 →
SystemNode - 自动攻击敌人 →
AttackNode - 被攻击反击 →
CounterAttackNode - 找人聊天 →
ChatNode
再将这些节点按优先级放进调度循环的节点数组,配合各自的条件判断(Check),机器人就能像怪物 AI 一样自主轮转:任务没做完时执行任务节点,周围有敌人时切换攻击节点,空闲时找人聊天……正如文档所言:"兄弟,AI 简单还是不简单?"——当 AI 被拆解为"条件判断 + 行为并发"的节点组合后,复杂度被分解到单个节点内部,整体维护成本远低于状态机的 N² 网状结构和行为树的巨型树。
8. 小结
ET 的这套 AI 方案,核心可以概括为一句话:用"条件判断"代替"状态转换",用"可取消的并发行为"代替"状态回调",用"每秒优先级轮询"代替"树遍历"。
- 复杂度可控:节点间无显式连线,切换关系收敛为"数组顺序即优先级",新增/删除行为只需增删数组元素;
- 并发友好:行为天然是协程,配合
ETCancellationToken实现干净的中断语义,底层由 TimerComponent.WaitAsync 提供取消保证; - 可组合可复用:节点即逻辑、并发方法即共享资产,小到怪物 AI,大到挂机机器人,都是同一套节点 + 调度循环的拼装。
感兴趣的读者可以在仓库中继续深入阅读 ETCancellationToken.cs、ETCancellationTokenHelper.cs 与 TimerComponentSystem.cs,并结合 cn.etetet.move 模块的移动实现,将本文的示例落地为可直接运行的怪物 AI。
【免费下载链接】ETUnity3D Client And C# Server Framework项目地址: https://gitcode.com/GitHub_Trending/et/ET
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考