游戏AI的实现通常分为两种,有限状态机(FSM)以及行为树
有限状态机(FSM)
概念
有限状态机,又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学模型。
组成
状态机由下列几部分组成:
状态集(States):包括现态和次态在内的一系列状态,用来描述状态机所处的状态。
事件(Event):又被称为“条件”,当满足条件时,将会触发一个动作,或者执行一次状态的迁移。
动作(Action):条件满足后执行的动作。动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
转换(Transition):通过转换函数将状态从现态迁移到次态的动作。迁移后次态变为现态。
特点
有限状态机维护了一张图(如图结构,方框是状态,箭头是状态之间的联系),图中的结点代表不同的状态,状态之间通过某种条件触发转换,如果不满足条件则维持原状态。
这里以一个简单的例子来说明。
下图为游戏中使用的AI有限状态机。

```csharp
实现方式
用枚举配合switch case语句。
enum State
{
CHASE,//追逐玩家
PATROL,//巡逻
};
class MyFSM
{
State m_state;
}
void MyFSM::change(bool isSee)
{
switch(m_state)
{
case CHASE:
if (isSee){
//刷新剩余追逐时间
}
else{
//找随机的点
AI.findRandomPatrol();
//移动到
AI.moveTo();
//等待一会
AI.wait();
}
case PATROL:
if (isSee){
//转向玩家
AI.rotateToFace();
//获得玩家的位置
AI.chasePlayer();
//移动到
AI.moveTo();
}
else{
//
}
}```
什么是行为树?
从定义中可提取出几个关键词:描述人物角色的行为、树状结构、节点。
a. 描述人物角色的行为
站在个体的角度,针对不同的情况,做出不同的决定或行为。

上图描述的是一个角色(蓝块)在几个位置点之间巡逻,当发现不远处有敌人(红块)时,它会跑向敌人进行攻击,击败敌人后会重新回到巡逻的状态。
用接近行为树的构建方式去解读,就是:
有一个角色的行为树,
当它周围没有敌人时,保持巡逻状态;
当它周围有敌人时,前往敌人的位置;
当它靠近敌人,达到攻击的范围时,执行攻击操作;
当敌人被消灭时,周围没有敌人了,回到之前的巡逻状态。
简言之:一个个体,面对不同的情况,执行不同的行为。
b. 树状结构
由上面的场景,按照“根据不同情况,执行不同的行为”,可构建如下这样一个树状图:

由根节点开始,将情况分为3种,如果条件满足,则执行对应的操作:
如果敌人在攻击范围内,执行攻击敌人的行为;
如果敌人在视野范围内,执行靠近敌人的行为;
否则,执行巡逻行为。
c. 节点
由上图可知,行为树是由不同功能的节点,以父子关系连线的方式,形成的一棵树状图。所以节点是行为树中至关重要的元素。
综上,可将行为树的概念概括成一句话:
按照“根据不同情况,执行不同的行为”的思想,由不同功能的节点,以父子关系连线的方式,形成的一棵树状图。
1.2 行为树的基础概念
a. 行为树的节点
行为树的节点按照大类可分为:根节点(Root)、行为节点(Action)、条件节点(Conditional)、组合节点(Composite)、装饰节点(Decorator)。
根节点(Root)

行为树的最顶部节点,也是入口节点。
行为节点(Action)

没有子节点,用以执行具体行为的节点。
条件节点(Conditional)

没有子节点,用以判定条件是否成立的节点。
组合节点(Composite)

连接多个子节点,比较常见的组合节点有:选择节点(Selector)、序列节点(Sequence)。
选择节点(Selector)

1:选择节点调用第1个子节点;
2:子节点1未能成功执行;
3:子节点1的失败状态返回给选择节点;
4:选择节点继续调用第2个子节点;
5:子节点2成功执行;
6:子节点2的成功状态返回给选择节点;
7:选择节点收到成功状态后,不再继续执行后面的子节点。
选择节点类似于“或”的逻辑,一旦存在一个成功执行的子节点,后续的子节点将不再执行。
序列节点(Sequence)

1:序列节点调用第1个子节点;
2:子节点1是一个条件节点,条件判定为成功;
3:子节点1将成功状态返回给序列节点;
4:序列节点继续调用第2个子节点;
5:子节点2是一个行为节点,执行指定的操作;
6:子节点2执行完操作后,将成功状态返回给序列节点;

1:序列节点调用第1个子节点;
2:子节点1是一个条件节点,条件判定为失败;
3:子节点1将失败状态返回给序列节点;
4:序列节点不再调用第2个子节点;
序列节点类似于“且”的逻辑,每个子节点按照顺序依次执行,一旦有一个子节点未能成功执行,后续的子节点将不会被调用。
装饰节点(Decorator)
只有一个子节点,用以执行特定的逻辑。
继续用上面的情景举个例子:

上图在之前的行为树中间新插入了一个循环节点(Repeat),它是装饰节点的一种,用于循环执行其子节点下的逻辑,这样角色就可以一直在巡逻、攻击附近敌人的状态中循环切换。
b. 行为树的状态
行为树的状态常可分为:成功(Success)、失败(Failure)、执行中(Running)。
状态,常用于行为节点和条件节点,有时装饰节点也会用到。
而“执行中”的状态,常用于行为节点,表示等待行为执行完毕,再返回“成功”或“失败”的状态。
- 行为树 vs 有限状态机
讲行为树,经常会有另一个概念被提及——有限状态机(Finite State Machine)。
结合一个场景,简单看看有限状态机和行为树的对比。
有一个机器人,它有这么几种状态(或者说,可以做这么几种行为):

用有限状态机来表现几种状态之间的切换,如下:

对于状态机来说,每个节点都需要知道它接下来要连向哪个节点。
Each action needs to know “What to do next”.
用行为树来构建机器人的行为,如下:

行为树的节点之间的关系可以抽象成如下:

对于行为树来说,每个节点只需要知道自己当前的状态,并返回给父节点。
Each action needs to know “Did I Succeed or Fail?”.
3.1 状态机的优缺点

优点
容易实现(逻辑直来直去)
容易理解
缺点
不易维护、调试(节点之间连线数量是节点数量的平方)
耦合度高,扩展性不强
一张图解释状态机扩展性不强:

3.2 行为树的优缺点

优点
模块化(耦合度较低)
线性,易扩展
缺点
实现难度、理解成本较大:任何一个简单的操作都需要使用节点
更大的性能消耗:需要在定时器中每次从根节点遍历处理逻辑
一张图解释行为树强大的扩展能力:

有限状态机适合处理简单的AI游戏逻辑;
行为树适合处理较复杂的AI游戏逻辑;
- 手写行为树代码

接下来基于文章开头提出的巡逻、攻击敌人的场景,手写一套行为树代码,来学习行为树的实践使用。
4.1 基础代码
基础代码需要定义一个状态枚举:状态,两个基类:节点、树。
a. 枚举:NodeState(节点状态)
public enum NodeState
{
RUNNING,
SUCCESS,
FAILURE
}
b. 基类:Node(节点)
首先定义节点一些属性:节点状态、父节点、子节点:
namespace MyBehaviorTree
{
public class Node
{
protected NodeState state;
public Node parent;
protected List<Node> children = new List<Node>();
public Node()
{
parent = null;
}
public Node(List<Node> children)
{
foreach (Node child in children)
{
_Attach(child);
}
}
private void _Attach(Node node)
{
node.parent = this;
child.Add(node);
}
}
}
然后定义一个虚函数Evaluate,用于执行节点的具体操作,返回节点的状态:
…
public class Node
{
…
public virtual NodeState Evaluate() => NodeState.FAILURE;
…
}
有时节点需要存储一些自定义的数据,所以还要定义一套数据存储的接口:
public class Node
{
…
pivate Dictionary<string, object> _dataContext = new Dictionary<string, object>();
public void SetData(string key, object value)
{
_dataContext[key] = value;
}
public object GetData(string key)
{
object value = null;
if (_dataContext.TryGetValue(key, out value)) return value;
Node node = parent;
while (node != null)
{
value = node.GetData(key);
if (value != null) return value;
node = node.parent;
}
return null;
}
public bool ClearData(string key)
{
if (_dataContext.ContainsKey(key))
{
_dataContext.Remove(key);
return true;
}
Node node = parent;
while (node != null)
{
bool cleared = node.ClearData(key);
if (cleared) return true;
node = node.parent;
}
return false;
}
...
}
c. 基类:Tree(树)
树的定义比较简单,只需要记录根节点就好。因为需要每一帧处理各个节点的逻辑状态,所以Tree类需要继承MonoBehavior,通过在Update函数里面由根节点开始,向子节点遍历去调用逻辑。
namespace MyBehaviorTree
{
public abstract class Tree : MonoBehaviour
{
private Node _root = null;
protected void Start()
{
_root = SetupTree();
}
private void Update()
{
if (_root != null) _root.Evaluate();
}
protected abstract Node SetupTree();
}
}
4.2 构建组合节点

接下来基于节点的基类,构建组合节点。由上图可知,我们需要用到2个组合节点:选择节点(Selector)、序列节点(Sequence)。
a. 选择节点:Selector
选择节点的核心逻辑在于:遍历所有的子节点,一旦有子节点满足条件执行的行为,遍历马上终止掉。
namespace MyBehaviorTree
{
public class Selector : Node
{
public Selector() : base() {}
public Selector(List children) : base(children) {}
public override NodeState Evaluate()
{
foreach (Node node in children)
{
switch (node.Evaluate())
{
case NodeState.FAILURE:
continue;
case NodeState.SUCCESS:
state = NodeState.SUCCESS;
return state;
case NodeState.RUNNING:
state = NodeState.RUNNING;
return state;
default:
continue;
}
}
state = NodeState.FAILURE;
return state;
}
}
}
b. 序列节点:Sequence
序列节点的逻辑和选择节点相近,不同的是:一旦存在子节点返回失败的状态,则马上停止遍历。
namespace MyBehaviorTree
{
public class Sequence : Node
{
public Sequence() : base() {}
public Sequence(List children) : base(children) {}
public override NodeState Evaluate()
{
bool anyChildIsRunning = false;
foreach (Node node in children)
{
switch (node.Evaluate())
{
case NodeState.FAILURE:
state = NodeState.FAILURE;
return state;
case NodeState.SUCCESS:
continue;
case NodeState.RUNNING:
anyChildIsRunning = true;
continue;
default:
state = NodeState.SUCCESS;
return state;
}
}
state = anyChildIsRunning ? NodeState.RUNNING : NodeState.SUCCESS;
return state;
}
}
}
4.3 构建条件节点

在本文的情景中,主要用到两个条件:
判断敌人是否在攻击范围内;
判断敌人是否在视野范围内;
我们的实现思路是:
优先通过物理碰撞体重叠的接口来确定敌人是否在视野范围内;
如果在视野范围内,将敌人记录到节点的数据中心;
然后在判断敌人是否在攻击范围内的逻辑里,优先判断敌人是否在节点数据中,如果在,再去判断距离。
a. 条件:判断敌人是否在视野范围内
using MyBehaviorTree;
public class CheckEnemyInFOVRange : Node
{
private static int _enemyLayerMask = 1 << 6;
// _transform表示攻击敌人的角色
private Transform _transform;
public CheckEnemyInFOVRange(Transform transform)
{
_transform = transform;
}
public override NodeState Evaluate()
{
object t = GetData("target");
if (t == null)
{
// GuardBT.fovRange表示视野的距离,在后面会提及GuardBT
Collider[] colliders = Physics.OverlapSphere(_transform.position,
GuardBT.fovRange, _enemyLayerMask);
if (colliders.Length > 0)
{
parent.parent.SetData("target", colliders[0].transform);
state = NodeState.SUCCESS;
return state;
}
state = NodeState.FAILURE;
return state;
}
state = NodeState.SUCCESS;
return state;
}
}
b. 条件:判断敌人是否在攻击范围内
using MyBehaviorTree;
public class CheckEnemyInAttackRange : Node
{
private static int _enemyLayerMask = 1 << 6;
// _transform表示攻击敌人的角色
private Transform _transform;
public CheckEnemyInAttackRange(Transform transform)
{
_transform = transform;
}
public override NodeState Evaluate()
{
object t = GetData("target");
if (t == null)
{
state = NodeState.FAILURE;
return state;
}
Transform target = (Transform)t;
// GuardBT.attackRange表示攻击范围,后面会讲到GuardBT
if (Vector3.Distance(_transform.position, target.position) <= GuardBT.attackRange)
{
state = NodeState.SUCCESS;
return state;
}
state = NodeState.FAILURE;
return state;
}
}
4.4 构建行为节点

涉及到的行为节点主要有3个,分别是:巡逻、靠近、攻击。
a. 行为节点:TaskPatrol(巡逻)
巡逻的实现思路是:在几个点之间来回移动,角色到达某个点后,停留一会儿,然后继续向下一个点移动。
所以需要定义几个位置点,在类的构造函数中传入。
using MyBehaviorTree;
public class TaskPatrol : Node
{
private Transform _transform;
private Transform[] _waypoints;
private int _currentWaypointIndex = 0;
// 单位:秒
private float _waitTime = 1f;
private float _waitCounter = 0f;
private bool _waiting = false;
public TaskPatrol(Transform transform, Transform[] waypoints)
{
_transform = transform;
_waypoints = waypoints;
}
public override NodeState Evaluate()
{
if (_waiting)
{
_waitCounter += Time.deltaTime;
if (_waitCounter >= _waitTime)
_waiting = false;
}
else
{
Transform wp = _waypoints[_currentWaypointIndex];
if (Vector3.Distance(_transform.position, wp.position) < 0.01f)
{
_transform.position = wp.position;
_waitCounter = 0f;
_waiting = true;
_currentWaypointIndex = (_currentWaypointIndex + 1) % _waypoints.Length;
}
else
{
_transform.position = Vector3.MoveTowards(_transform.position,
wp.position, GuardBT.speed * Time.deltaTime);
_transform.LookAt(wp.position);
}
}
state = NodeState.RUNNING;
return state;
}
}
b. 行为节点:TaskGoToTarget(靠近)
靠近的实现思路是:在发现敌人,将敌人存入节点数据后,获取敌人的位置并靠近。
public class TaskGoToTarget : Node
{
private Transform _transform;
public TaskGoToTarget(Transform transform)
{
_transform = transform;
}
public override NodeState Evaluate()
{
Transform target = (Transform) GetData("target");
if (Vector3.Distance(_transform.position, target.position) > 0.01f)
{
// GuardBT.speed表示移动的速度,,后面会讲到GuardBT
_transform.position = Vector3.MoveTowards(_transform.position,
target.position, GuardBT.speed * Time.deltaTime);
_transform.LookAt(target.position);
}
state = NodeState.RUNNING;
return state;
}
}
c. 行为节点:TaskAttack(攻击)
攻击的实现思路是:每个一定时间对敌人攻击一次,造成伤害。
using MyBehaviorTree;
public class TaskAttack : Node
{
private EnemyManager _enemyManager;
private Transform _transform;
private float _attackTime = 1f;
private float _attackCounter = 0f;
public TaskAttack(Transform transform)
{
_transform = transform;
}
public override NodeState Evaluate()
{
_attackCounter += Time.deltaTime;
if (_attackCounter >= _attackTime)
{
Transform target = (Transform)GetData("target");
var enemyManager = target.GetComponent<EnemyManager>();
bool enemyDead = enemyManager.TakeHit();
if (enemyDead)
{
ClearData("target");
}
else
{
_attackCounter = 0f;
}
}
state = NodeState.RUNNING;
return state;
}
}
4.5 构建行为树

在构建完必要的节点后,最终需要构建一棵具备巡逻、攻击操作的行为树。
行为树类里面定义了前面逻辑中用到的数值,并根据行为树的结构,实例化对应的节点进行构建。
using MyBehaviorTree;
public class GuardBT : Tree
{
public UnityEngine.Transform[] waypoints;
public static float speed = 2f;
public static float fovRange = 6f;
public static float attackRange = 2f;
protected override Node SetupTree()
{
Node root = new Selector(new List<Node>
{
new Sequence(new List<Node>
{
new CheckEnemyInAttackRange(transform),
new TaskAttack(transform),
}),
new Sequence(new List<Node>
{
new CheckEnemyInFOVRange(transform),
new TaskGoToTarget(transform)
}),
new TaskPatrol(transform, waypoints)
});
return root;
}
}
编写好行为树脚本后,将该脚本挂到角色身上,并将几个巡逻位置点依次挂到脚本上,就大致完成了行为树的所有实践流程。案例中涉及的动画等一些细节在文中没有列出,可根据具体情况具体实现。
行为树案例说明
我们在树的顶部增加了一个选择节点。当角色试图进入房间时,他会先试着从门进去,当这样做行不通时,他会尝试从窗户进入。这个简化的示例很好地解释了这个逻辑,但实际的项目里的行为树可要比这个复杂多了。比如说,当这个房间没有窗户的时候,整个“进入房间”的节点会失败来告诉这个角色前往下一个房间?
相比我之前做过的各种 AI 开发的尝试,行为树能够简化 AI 开发的关键因素在于一项任务的失败不再意味着当前所做事情的完全终止(比如,“寻路失败。那我该干什么?”这样的情况),而是符合 AI 系统范式的,行为决策中很自然的一个可预期的结果。
你可以为所有的情况都安排一个“失败保险”来让角色总是知道该做什么。一个例子是 Project zomboid 当中的 EnsureItemInInventory 行为(确认物品在物品栏)(译者:Zomboid 游戏里的物品栏有点像暗黑2里面的腰带和异星工厂里的快捷栏,玩家可以把物品从背包放到这里以供便捷地使用)。这个行为用一个选择节点来决定使用一系列的动作中的某一个,并使用不同参数对相同行为进行递归调用,来确保某个物品在 NPC 的物品栏里(译者:我没有编程背景,故对递归相关的理解恐有偏差,如有错误望告知)。
首先它会检查这个物品是不是已经在这个角色的物品栏里。这是最好的状况,搞定。EnsureItemInInventory 成功,这个物品已经可供使用。
要是这个物品不再角色的物品栏里,那么我们要检查角色携带的一切背包和袋子来寻找这个物品。如果有,那这个物品会来到角色的物品栏上,返回成功。
如果仍然没找到,那么选择节点的第三个分支会判断角色当前所处的房间里有没有这件物品。如果有,那么角色会移动到放有这件物品的位置来将其加入其物品栏。
如果继续失败,NPC 还会有招数可用。他会检查他是否有他需要的那个物品的打造配方,并依次对配方里所需要的每一个素材进行 EnsureItemInInventory 行为的递归调用,这样我们就可以知道 NPC 是否持有用来打造那个物品的全部素材。接下来,角色就可以打造这个物品。又一次成功。
如果还是失败了,那么 EnsureItemInInventory 就失败了。没有其他后续方案,NPC 会将这个物品加入到他的需求列表(或可以理解为一个制造任务单)里,提醒自己接下来要去寻找这个物品。
角色可能会在后面的探索中突然凑齐制作的素材。由于么 EnsureItemInInventory 的递归属性,NPC 会尝试寻找和探索那些最基础的素材,一步一步地最终收集齐打造物品所需要的全部素材。
只是借助于这些相对简单的节点和相互的层级关系,我们一下子就拥有了一个看上去很聪明的 AI。每当 NPC 在其他的行为树里需要确认他是否拥有某件物品时,我们就可以拿出 EnsureItemInInventory 行为反复使用。
我相信在随着游戏的开发,我可能会让 NPC 在没有找到物品之后有其他的后续方案来影响他寻找特别需要的物品时的行为模式,比如在急需工具锤子时会优先前往五金店这样的地点寻找来提高成功率。又比如,有一天我开发了一些新的游戏玩法,让某些物品拥有了临时的替代品,那么寻找物品时的优先级肯定也会受到这一因素的影响。举个例子,与其穿过重重僵尸的包围潜入到一家五金店去寻找一把锤子,不如就拿手头的石头来敲钉子好了,哪怕它没有锤子那么好使。
这些例子所表明的行为树的可扩展性,使得 AI 的开发可以从最简单的“把事情办了”开始逐渐迭代,用新的选择节点添加分支来扩展不同情况下 AI 的行为。丰富的后续方案可以降低一个行为彻底失败情况的出现,从而展现更加合理的 AI 行为。前面提到,NPC 找不到物品时会试图打造物品,实际上这个功能也是后面才加入的。即便没有这个行为逻辑,NPC 也会尝试寻找物品,但这一行为大大提高了 NPC 达成自己目标的能力。
再加上合理地为各种后续方案赋予优先级和条件,哪怕都是编程好的行为,它们也能让 AI 在行为决策时表现得更加自然和聪明。
805

被折叠的 条评论
为什么被折叠?



