目录
前言
unity行为树简介
一个简单的敌人AI
正文
个人对行为树的理解
有限状态机与行为树
基本框架
BTNode
DataBase
行为树入口
行为树的事件GraphEvent
发送事件
监听事件
脚本发送事件
行为树的管理&操作
一、操作单颗树
二、管理所有树
自定义Task任务
1.引入命名空间:
2.明确继承的Task类型:
3.知晓Task内部函数的执行流程:
总结
行为树的如下几种优点
> 静态性
> 直观性
> 复用性
> 扩展性
目前在Unity3D游戏中一般复杂的AI都可以看到行为树的身影,简单的AI使用状态机来实现就可以了,建议提前学习,做好准备,这叫“不打无准备之仗”哈哈哈。
行为树的概念出现已经很多年了,总的来说,就是使用各种经典的控制节点+行为节点进行组合,从而实现复杂的AI。
Behavior Designer插件里,主要有四种概念节点,都称之为Task。包括:
(1) Composites 组合节点,包括经典的:Sequence,Selector,Parallel
(2) Decorator 装饰节点,顾名思义,就是为仅有的一个子节点额外添加一些功能,比如让子task一直运行直到其返回某个运行状态值,或者将task的返回值取反等等
(3) Actions 行为节点,行为节点是真正做事的节点,其为叶节点。Behavior Designer插件中自带了不少Action节点,如果不够用,也可以编写自己的Action。一般来说都要编写自己的Action,除非用户是一个不懂脚本的美术或者策划,只想简单地控制一些物件的属性。
(4) Conditinals 条件节点 ,用于判断某条件是否成立。目前看来,是Behavior Designer为了贯彻职责单一的原则,将判断专门作为一个节点独立处理,比如判断某目标是否在视野内,其实在攻击的Action里面也可以写,但是这样Action就不单一了,不利于视野判断处理的复用。一般条件节点出现在Sequence控制节点中,其后紧跟条件成立后的Action节点。
行为树(Behavior Tree)具有如下的特性:
它的4大类型的节点:1. Composite 2.Decorator 3.Condition 4. Action Node
任何Node被执行后,必须向其Parent Node报告执行结果:成功 / 失败。
这简单的成功 / 失败汇报原则被很巧妙地用于控制整棵树的决策方向。
当处于监视范围内,跑向玩家,当处于攻击范围内,攻击玩家,否则呆在原地,用行为树表示如下:
目前为止我的理解是有的时候行为树式可以看成一个状态机的。
selecter选择大状态,大状态里的selecter选择小状态,这些同级的状态存在从左到右的优先级,从而简化了一些判断条件。
既然有状态就有判断状态执不执行的判断语句,判断语句可以sequencer与condition组合使用,也可直接用conditional节点其实时一样的。
光这样还不行,因为不是动态的,进入一个action之后的每帧会等待这个任务完成,而不会重新从左到右检测条件去选择任务。(比如小怪在巡逻,他见到玩家可能不会攻击,它此时进入巡逻状态了,没执行检测玩家语句,所以看不见玩家。)这样就应该把selecter设为Dynamic,虽然巡逻的任务没有结束,但每帧都按优先级先判断左侧的条件,看到玩家就会切换到chase状态。
if(){}
else if(){}
else if(){
if(){}
else{}
}
if(){
if(){}
else if() {}
else{}
else{}
为什么很多人认为有限状态机很麻烦?
因为从某些方面来说,有限状态机则是舍掉了每个状态的优先级,而这样换来的则是高拓展性,每新增状态时只要加转换条件就行了。另外每个状态都分开也增加了可维护性。但是因为舍掉优先级把任何两个状态的转换都用条件判断来实现这样的不便之处是每个状态都要为它可以转换到的状态写转换条件,这样无疑增加了工作量。可以参考unity的动画状态机当状态太多的时候。
行为树则更像是我们平时写脚本,既保留了每个状态的优先级关系,省略了状态机因舍弃状态优先级而增加的状态转换条件,又可以模块化出各个状态,实现高拓展性和高维护性(每个selecter下面的子树都是一个状态,如果优先级和树的层级关系设计的好的话是可以弄出状态机那味儿的,这行为树多是件美事啊 看下边儿),行为树设计的好写代码的结构一定也很清晰。
Tips:构建一个行为树的时候不应该是盲目的而是有一个整体的通过selecter和sequencer规划清晰的结构,这样才不会盲目的乱连节点。
行为树节点(BTNode)作为行为树所有节点的base Class,它需要有以下基本属性与函数/接口:
属性
节点名称(name
)
孩子节点列表(childList
)
节点准入条件(precondition
)
黑板(Database
)
冷却间隔(interval
)
是否激活(activated
)
函数/接口
public virtual void Activate (Database database)
)protected virtual bool DoEvaluate ()
)public bool Evaluate ()
)public virtual BTResult Tick ()
)public virtual void Clear ()
)public virtual void Add/Remove Child(BTNode aNode)
)private bool CheckTimer ()
)BTNode提供给子类的接口中最重要的两个是DoEvaluate()和Tick()。
而DoEvaludate给子类提供个性化检查的接口(注意和Evaluate的不同),例如Sequence的检查和Priority Selector的检查是不一样的。例如Sequence和Priority Selector里都有节点A,B,C。第一次检查的时候,
Sequence只检查A就可以了,因为A不通过Evaluate,那么这个Sequence就没办法从头开始执行,所以Sequence的DoEvaludate也不通过。
而Priority Selector则先检查A,A不通过就检查B,如此类推,仅当所有的子结点都无法通过Evaluate的时候,才会不通过DoEvaludate。
Tick是节点执行的接口,仅仅当Evaluate通过时,才会执行。子类需要重载Tick,才能达到所想要的逻辑。例如Sequence和Priority Selector,它们的Tick也是不一样的:
Sequence里当active child节点A Tick返回Ended时,Sequence就会将当前的active child设成节点B(如果有B的话),并返回Running。当Sequence最后的子结点N Tick返回Ended时,Sequence也返回Ended。
Priority Selector则是当目前的active child返回Ended的时候,它也返回Ended。Running的时候,它也返回Running。
正是通过重载DoEvaluate和Tick,BT框架实现了Sequence,PrioritySelector,Parallel,ParalleFlexible这几个逻辑节点。如果你有特殊的需求,也可以重载DoEvaluate和Tick来实现:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
namespace BT {
///
/// BT node is the base of any nodes in BT framework.
///
public abstract class BTNode {
//节点名称
public string name;
//孩子节点列表
protected List _children;
//节点属性
public List children {get{return _children;}}
// Used to check the node can be entered.
//节点准入条件
public BTPrecondition precondition;
//数据库
public Database database;
//间隔
// Cooldown function.
public float interval = 0;
//最后时间评估
private float _lastTimeEvaluated = 0;
//是否激活
public bool activated;
public BTNode () : this (null) {}
///
/// 构造
///
/// 准入条件
public BTNode (BTPrecondition precondition) {
this.precondition = precondition;
}
// To use with BTNode's constructor to provide initialization delay
// public virtual void Init () {}
///
/// 激活数据库
///
/// 数据库
public virtual void Activate (Database database) {
if (activated) return ;
this.database = database;
// Init();
if (precondition != null) {
precondition.Activate(database);
}
if (_children != null) {
foreach (BTNode child in _children) {
child.Activate(database);
}
}
activated = true;
}
public bool Evaluate () {
bool coolDownOK = CheckTimer();
return activated && coolDownOK && (precondition == null || precondition.Check()) && DoEvaluate();
}
protected virtual bool DoEvaluate () {return true;}
public virtual BTResult Tick () {return BTResult.Ended;}
public virtual void Clear () {}
public virtual void AddChild (BTNode aNode) {
if (_children == null) {
_children = new List();
}
if (aNode != null) {
_children.Add(aNode);
}
}
public virtual void RemoveChild (BTNode aNode) {
if (_children != null && aNode != null) {
_children.Remove(aNode);
}
}
// Check if cooldown is finished.
private bool CheckTimer () {
if (Time.time - _lastTimeEvaluated > interval) {
_lastTimeEvaluated = Time.time;
return true;
}
return false;
}
}
public enum BTResult {
Ended = 1,
Running = 2,
}
}
数据库作为存放所有数据的地方,能够通过key-Value的方式去调取任意数据,你可以理解为全局变量黑板,我们可以手动添加数据,并通过节点来访问数据:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using System;
///
/// Database is the blackboard in a classic blackboard system.
/// (I found the name "blackboard" a bit hard to understand so I call it database ;p)
///
/// It is the place to store data from local nodes, cross-tree nodes, and even other scripts.
/// Nodes can read the data inside a database by the use of a string, or an int id of the data.
/// The latter one is prefered for efficiency's sake.
///
public class Database : MonoBehaviour {
// _database & _dataNames are 1 to 1 relationship
private List
之前的代码都是行为树框架本身,现在,我们需要通过节点去构建这个行为树入口,以能够真正的使用:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using BT;
// How to use:
// 1. Initiate values in the database for the children to use.
// 2. Initiate BT _root
// 3. Some actions & preconditions that will be used later
// 4. Add children nodes
// 5. Activate the _root, including the children nodes' initialization
public abstract class BTTree : MonoBehaviour {
protected BTNode _root = null;
[HideInInspector]
public Database database;
[HideInInspector]
public bool isRunning = true;
public const string RESET = "Rest";
private static int _resetId;
void Awake () {
Init();
_root.Activate(database);
}
void Update () {
if (!isRunning) return;
if (database.GetData(RESET)) {
Reset();
database.SetData(RESET, false);
}
// Iterate the BT tree now!
if (_root.Evaluate()) {
_root.Tick();
}
}
void OnDestroy () {
if (_root != null) {
_root.Clear();
}
}
// Need to be called at the initialization code in the children.
protected virtual void Init () {
database = GetComponent();
if (database == null) {
database = gameObject.AddComponent();
}
_resetId = database.GetDataId(RESET);
database.SetData(_resetId, false);
}
protected void Reset () {
if (_root != null) {
_root.Clear();
}
}
}
当发送一个事件时,场景里的所有的owener都可以同时响应这个事件。
也可以通过脚本来发送事件,做受击响应可行。
这是我们项目里面,一个敌人绑定了行为树,自动创建的behavior tree 脚本:
将红框放大可以看到:
行为树组件包含以下几个属性:
那当我们有需要的时候,如何代码操作这些变量呢?
(1)我们必须先找到要操作的树
找树的方法1:定义一个Public的 BehaviorTree tree = new BehaviorTree();,然后面板拖拽赋值。
找树的方法2:定义一个privarte的 BehaviorTree tree = new BehaviorTree();,然后通过GameObject 的Find查找物体,然后获得物体上面的组件来得到的。
(2)代码操作该树
using BehaviorDesigner.Runtime.Tasks;//引用不可少
using BehaviorDesigner.Runtime;
public class Tree : MonoBehaviour {
public BehaviorTree tree = new BehaviorTree();
void Start () {
tree.enabled = false;
var a = tree.GetAllVariables();
tree.StartWhenEnabled = false;
var b = tree.FindTasksWithName("AI_Daze");
}
}
上面代码只是简单的演示一下,可以操作行为树的数据。其实 面板截图里面的所有变量都可以操作,除此之外,tree还有很多的属性和方法都可以操作。
当行为树运行时,将会自动创建一个带有行为管理器组件的新游戏对象,并且该对象上面绑有 behavior manager组件。此组件管理你场景中所有的执行的行为树
你可以控制行为树的更新类型,以及更新时间等等
Update Interval:更新频率
Every Frame:每帧都更新行为树
Specify Seconds:定义个一个更新间隔时间
Manual:手动调用更新,选择这个后需要通过脚本来调用行为树的更新
Task Execution Type:任务执行类型
No Duplicates:不重复
Repeater Task:重复任务节点。如果设置成了5,那么每帧被执行5次
BehaviorManager.instance.Tick();
此外,如果你想让不同的行为树都有各自独立的更新间隔的话,可以这样:
BehaviorManager.instance.Tick(BehaviorTree);
更多方法,请查看BehaviorManager类
一般复合类和装饰类的Task是够用的,甚至有些根本用不到,而具体的行为类Task和条件类Task从来都不能满足我们的需求,而且自己写这类Task可以很大程度的简化整个行为树结构。
自己写Task的步骤如下:
using BehaviorDesigner.Runtime;
using BehaviorDesigner.Runtime.Tasks;
public class MyInputMove : Action
public class MyIsInput : Conditional
观察上图就会发现和Unity中编写脚本大同小异,不一样的地方就是这里的Update有返回值,要返回该任务的执行状态,只有在Running状态时才每帧调用:
using UnityEngine;
using BehaviorDesigner.Runtime;
using BehaviorDesigner.Runtime.Tasks;
public class MyInputMove : Action
{
public SharedFloat speed = 5f;
public override TaskStatus OnUpdate()
{
float inputX = Input.GetAxis("Horizontal");
float inputZ = Input.GetAxis("Vertical");
if (inputX != 0 || inputZ != 0)
{
Vector3 movement = new Vector3(inputX, 0, inputZ);
transform.Translate(movement*Time.deltaTime*speed.Value);
return TaskStatus.Running;
}
return TaskStatus.Success;
}
}
越复杂的功能越需要简单的基础,否则最后连自己都玩不过来。
静态是使用行为树需要非常着重的一个要点:即使系统需要某些"动态"性。
其实诸如Stimulus这类动态安插的Node看似强大,但却破坏了本来易于理解的静态性,弊大于利。
Halo3相对于Halo2对BT AI的一个改进就是去除Stimulus的动态性。取而代之的做法是使用Behavior Masks,Encounter Attitude,Inhibitions。
原则就是保持全部Node静态,只是根据事件和环境来检查是否启用Node。
静态性直接带来的好处就是整棵树的规划无需再运行时动态调整,为很多优化和预编辑都带来方便。
行为树可以方便地把复杂的AI知识条目组织得非常直观。默认的Composite Node的从begin往end的Child Node迭代方式就像是处理一个
预设优先策略队列,也非常符合人类的正常思考模式:先最优再次优。
行为树编辑器对优秀的程序员来说也是唾手可得。
各种Node,包括Leaf Node,可复用性都极高。实现NPC AI的个性区别甚至可以通过在一棵共用的行为树上不同的位置来安插Impulse来达到目的。当然,当NPC需要一个完全不同的大脑,比如100级大BOSS,与其绞尽脑汁在一棵公用BT安插Impulse,不如重头设计一棵专属BT。
虽然上述Node之间的组合和搭配使用几乎覆盖所有AI需求。
但也可以容易地为项目量身定做新的Composite Node或Decorator Node。
还可以积累一个项目相关的Node Lib,长远来说非常有价值。