主页
文章
分类
系列
标签
AI 框架
发布于: 2020-1-5   更新于: 2021-12-12   收录于: AI
文章字数: 2189   阅读时间: 5 分钟  

AI 框架

主流 AI 的设计

有限状态机

有限状态自动机 (Finite State Machine,FSM) 是表示有限多个状态以及在这些状态(State)之间转移(Transition)和动作(Action)的数学模型
有限状态机的模型体现了两点:

  1. 状态首先是离散的:某一时刻只能处于某种状态之下,且需要满足某种条件才能从一种状态转移到另一种状态
  2. 状态总数是有限的
    对象身上任何数据都是状态,这种方法又要把一些状态定义成一种新的节点,对象身上状态变化会引起节点之间的转换,执行对应的方法,比如 OnEnter、OnExit 等等
    这里以怪物来举例,怪物可以分为多种状态,巡逻,攻击,追逐,返回
    怪物的状态变化有:
  • 巡逻->追逐 巡逻状态发现远处有敌人变追逐状态
  • 巡逻->攻击 巡逻发现可以攻击敌人变攻击状态
  • 攻击->追逐 攻击状态发现敌人有段距离于是去追逐
  • 攻击->返回 攻击状态发现距离敌人过远变返回状态
  • 追逐->返回 追逐状态发现距离敌人过远变返回状态

这里面涉及太多的状态转换,任何两个节点都可能需要连接,一旦节点更多,将成为超级复杂的网状结构,复杂度是 N 的平方级,维护起来十分困难
为了解决网状结构变复杂的问题于是又升级为分层状态机等等
当然各种打补丁的方法还是没能解决本质的问题

行为树

可能大家都觉得状态机解决复杂 AI 实在太困难了,于是有人想出了行为树来做 AI
行为树的 AI 是响应式 AI,这棵树从上往下(或者从左往右执行,这里以从上往下举例)实际上是把 action 节点排了个优先级,上面的 action 最先判断是否满足条件,满足则执行
行为树的复杂度是 N,比状态机大大简化,但是仍然存在不少缺陷,AI 太复杂的时候,树会变得非常大,而且难以重构
行为树的另外一个缺陷是某些 action 节点是个持久的过程,也就是说是个协程,行为树管理起协程起来不太好处理,比如上面的例子,需要移动到目标身边,这个移动究竟是做成协程呢,还是每帧 move 呢?这是个难题,怎么做都不舒服

我的做法

首先 AI 是什么呢?
AI 就是不停的根据当前的状态,执行相应的行为,这就是 AI 的本质!
这两句话分成两部分,一是状态判断,二是执行行为
状态判断好理解,行为是啥?
以上面状态机的怪物举例子,怪物的行为就是巡逻,攻击敌人,返回巡逻点

比如:

巡逻 —— 当怪物在巡逻范围内,周围没有敌人,选择下一个巡逻点,移动
攻击敌人 —— 当怪物发现警戒范围内有敌人,如果攻击距离够就攻击,不够就移动过去攻击
返回 —— 当怪物发现离出生点超过一定距离,加上无敌 buff,往出生点移动,到了出生点,删除无敌 buff

跟状态机不一样的是,这 3 个状态的变化完全不关心上一个状态是啥,只关心当前的条件是否满足,满足就执行行为
行为可能能瞬间执行,也可能是一段持续的过程。比如巡逻,选下一个巡逻点移动过去,走到了再选一个点,不停的循环
比如攻击敌人,可能需要移动到目标去攻击

怎么设计这个 AI 框架呢?到这里就十分简单了,抽象出 AI 节点,每个节点包含条件判断,跟执行行为。行为方法应该是一个协程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class AINode
{
	public virtual bool Check(Unit unit) // 检测条件是否满足
	{		
	}

	public virtual ETTask Run(Unit unit)
	{		
	}
}

进一步思考,假如怪物在巡逻过程中,发现敌人,那么怪物应该要打断当前的巡逻,转而去执行攻击敌人的行为
因此我们行为应该需要支持被打断,也就是说行为协程应该支持取消,这点特别需要注意,行为 Run 方法中任何协程都要支持取消操作!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class AINode
{
	public virtual bool Check(Unit unit)
	{		
	}

	public virtual ETVoid Run(Unit unit, ETCancelToken cancelToken)
	{
	}
}

实现三个ai节点 XunLuoNode(巡逻) GongjiNode(攻击) FanHuiNode(返回)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class XunLuoNode: AINode
{
	public virtual bool Check(Unit unit)
	{
		if (不在巡逻范围)
		{
			return false;
		}
		if (周围有敌人)
		{
			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;
			}
		}
	}
}

同理可以实现另外两个节点。光设计出节点还不行,还需要把各个节点串起来,这样 AI 才能转动

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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. 行为中如果有协程必须能够取消,并且传入 cancelToken,否则会出异常,因为怪物一旦满足执行下个节点,需要取消当前协程
  2. 跟行为树与状态机不同,节点的作用只是一块逻辑,节点并不需要共享。共享的是协程方法,比如 MoveToAsync,怪物巡逻节点可以使用,怪物攻击敌人节点中追击敌人也可以使用
  3. 节点可以做的非常庞大,比如自动做任务节点,移动到 npc,接任务,根据任务的子任务做子任务,比如移动到怪点打怪,移动到采集物去采集等等,做完所有子任务,移动到交任务 npc 交任务。所有的一切都是写在一个 while 循环中,利用协程串起来

思考一个问题,怎么设计一个压测机器人呢?压测机器人需要做到什么?自动做任务,自动玩各种系统,自动攻击敌人,会反击,会找人聊天等等。把上面说的每一条做成一个 AI 节点即可