游戏中的AI-行为树

游戏中,常见用AI实现方式有2种,状态机和行为树。下面主要简介绍行为树,行为树是用一棵多叉树来表示AI,树的中间节点为控制节点控制着AI的执行流程,叶子节点为行为节点,描述了AI的具体行为。

一、案例描述

山贼的AI需求描述如下:

  • 视野内没有敌人则在一定范围内巡逻
  • 视野出现敌人则走过去攻击敌人
  • 当自己血量 < 20% 则逃跑

下面将分别用状态机和行为树来描述山贼的AI

二、状态机

上图是用状态机来描述山贼的AI, 目前来看状态机的逻辑还是非常清晰,代码实现也比较简单。但随着项目的推进,策划随时可能增加需求。假如现在需要加一个 “石化”状态,描述如下:
在逃跑的过程中,如果追击者数量>2,则立即把自己石化,持续5s,此时自己不能被攻击也不能攻击目标
在状态机中加入“石化”状态时,我们需要考虑它跟现有每个状态之间是否有联系,输入输出状态分别有哪些。

1.1 状态机存在的不足

在项目中随时可能增加新状态、减少状态或者改变状态之间的迁移关系,如果状态越来越多,任何一点小修改都会产生很大的工作量,代码中会出现大量的判断跳转,代码的逻辑会变得越来越混乱,如果负责状态机的同事离职了,这会带来很大的问题。
行为树的出现从根本上解决了这些问题,它把每个行为作为一个原子项,提供给策划编辑,让策划来决定AI的执行流程,程序只需要集中精力根据需求增加新的行为,不用关心具体流程。用种方式,程序的工作会得到很大程度的解放,即使有一天交给其他同事维护也比较容易。

三、行为树

行为树是由控制节点、装饰节点、行为节点组成的一棵n叉树,中间节点一般为控制节点和装饰节,用于控制行为树的执行流程,它们相对固定,一旦确定几乎不会变化;叶子节点由行为节点或条件节点组成,它实现了AI中的各种行为,程序的大部分工作都是丰富行为节点。需要注意的是,行为树的每个节点都有一个返回值,它们分别是:

  • 成功(Success)
  • 失败(failed)
  • 运行中(running):表示当前帧没有执行完成、下帧继续执行。

下面列出了行为树常用的节点类型,主要用于说明行为树的原理,类型可能不全面;但只要明白了原理,根据项目需求增加类型即可。

1.1 控制类节点

控制节点一般为中间节点,用于控制行为树的执行流程,决定了其子节点是以顺序、并行、随机或其它方式执行。

  • 顺序节点(Sequences)
    依次执行所有子节点,若当前子节点返回成功,则继续执行下一个子节点;若子当前节点返回失败,则中断后续子节点的执行,并把结果返回给父节点。
    如下图所求:节点1返回成功,继续执行节点2;节点2返回失败,则把结果返回给Sequences的父节点,节点3并不会执行。顺序节点相当于and语义。

  • 选择节点(Selector)
    依次执行所有子节点,若当前子节点返回成功,则中断后续节点运行,并把结果返回给父节点。如下图所示:
    相当于or语义

  • 并行节点(Parallel)
    依次执行所有子节点,无论失败与否,都会把所有子节点执行一遍。至于Parallel节点该返回什么值给父节点,这要看需求。比如:成功数 > 失败数返回成功,否则返回失败。
    如下图所示:

  • 随机节点(Random)
    随机选择一个子节点来运行。

  • 记忆节点(MemSequences、MemSelector)
    功能和顺序节点、选择节点类似,唯一不同是会保存当前执行进度(比如:保存当前子节点索引),下一帧继续执行当前节点,如果当前节点是中间节点,则会跳过前面的节点。

1.2 装饰节点(Decorator)
  • 逆变节点(Inverter):对子节点的返回值取反,相当于not语义,它只会有一个子节点。
  • 成功节点(Succeeder):不管其子节点返回何值,都会返回Success给父节点
  • 重复节点(Repeater):重复执行n次子节点。
  • 重复直至失败节点(Repeat Until Fail):重复执行子节点,直到失败为上;同样也有类似的重复直至成功节点这里就不列出了。
  • 执行一段时间(MaxTime):重复执行子节点一段时间

节点的类型是灵活多变的,不同的项目有不同的需求,上面只列出了常用的。

1.3 行为节点(Action)

行为节点都是叶节点,控制节点用于控制行为执行的流程,行为节点则表示具体功能,比如:战斗,逃跑,巡逻等。它至少包含两个函数:

  • Init:用于初始化节点,比如读取配置数据初始化当前节点,只会执行一次。
  • OnTick:每一帧都会执行,节点的主要逻辑都在此函数中实现或调用。
1.3.1 行为节点代码实现

这是一个在指定范围内查找道具的行为节点例子(Ation):

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
//节点结构
type FindItem struct {
b3core.Action
index string
etype EntityType
dis float32
}

//初始化函数,参数setting为节点的配置数据
//此函数在加载节点时调用
func (this *FindItem) Initialize(setting *b3config.BTNodeCfg) {
this.Action.Initialize(setting)
this.index = setting.GetPropertyAsString("index") //道具枚举用于缓存道具的key
this.etype = EntityType(setting.GetPropertyAsInt("etype"))//道具类型
this.dis = float32(setting.GetProperty("range"))//查找范围
}

//遍历节点时,此函数会被调用,每个节点都有OnTick函数
func (this *FindItem) OnTick(tick *b3core.Tick) b3.Status {
f := tick.GetTarget().(*Fighter) //玩家对象
tick.Blackboard.Set(this.index, int32(0), "", "") //清空老数据
ball := f.FindNearItem(this.dis, this.etype) //在附近搜索道具
if nil == ball {//如果没找到,向父节点返回FAILURE
return b3.FAILURE
}

id := ball.GetID() //获取道具id
tick.Blackboard.Set(this.index, id, "", "") //缓存道具id
return b3.SUCCESS//向父节点返回SUCCESS

//因为行为树的节点返回值必须是FAILURE、SUCCESS、RUNNING, 所以Tick中产生的结果只能通过其它方式传回去,比如例子中的Blackboard
}
1.3.2 用行为树表示山贼AI

用行为树来表示的山贼AI,并加上了“石化”需求,下图黄色部分:
需求描述:在逃跑的过程中,如果追击者数量>2,则立即把自己石化,持续5s,此时自己不能被攻击也不能攻击目标。

对于程序来说,只需要写”石化”的逻辑即可,至于这个行为用在哪里,执行顺序以及和其它行为的关系,则由策划来决定。在本例中,条件判断其实可以放在行为节里,这里把它独立出来主要是为了方便表达。

三、状态机与行为树比较

1.1 状态机
  • 优点:实现简单、执行效率高。
  • 缺点:随着状态数量的增多,状态之间的关系会越来越复杂,代码变得难以维护。
1.2 行为树
  • 优点:结构清晰、节点间关系弱,程序大部分工作是丰富行为节点,AI流程交由策划完成。
  • 缺点:每次tick都会遍历整棵行为树(Mem子节点除外),若树的深度很深,效率将变得低下。

四、关于状态机行为树的思考

从状态机和行为树的特征可以看出,状态机和行为树都存在明显的优缺点。我们可不可以只取它们的优点呢?
在游戏中,若主动怪的视野范围内没有目标它的行为是很简单的,一般会在一定范围内巡逻。如果用行为树,不管视野内有没有人,每帧都会遍历所有非行为节点,这造成了很大的资源浪费。
如果用状态机实现巡逻、死亡、逃跑,进入战斗后的行为用行为树,这会是一个有效的优化。尤其是怪物很多时,大部分时间段,大部分怪都处于巡逻或idle状态,完全没有必要遍历行为树。
如果项目的AI比较简单,比如小游戏之类的。用状态机是个不错的选择。

参考:https://github.com/magicsea/behavior3go