【需求分析】
结合具体的实际需求来:假设有这样一个NPC,绕着固定的某个点以半径20米随机巡逻,让主角进入时,开始朝着主角攻击,距离主角3米时,开始攻击主角,如果距离主角超过10米,则重新巡逻
需求描述起来很简单,但实现起来不简单,对于这样一个需求,首先做行为拆解:
- 固定20米巡逻
- 进入巡逻范围开始追击
- 距离主角3米时开始攻击
- 追击时距离主角超过10米开始回归巡逻
行为基本就是条件+动作,动作很简单,复杂的是条件。
其次,划分行为所属的状态。
如何做状态划分是任意的,划分的原则是尽可能让状态更少,这里将NPC的四个行为分成巡逻和追击两个状态。
我们明明说的是行为树,为何这里又涉及到状态了:
- 举例不复杂,行为很少
- 这里的状态和状态机中的状态不是一个意思,行为树中也需要状态,这些状态隐含于条件之下,是不同层次的状态,而状态机中的状态都是同一层次下的
- 可以将行为树看作是具有不同层次状态的分层状态机,其没有脱离状态机的范畴
最后,对每个状态下的行为划分优先级,优先级高的行为先判断
【一般实现】
因为条件是实时变化的,所以我们需要在Tick中不断做条件判断。
我们专注于条件,动作先省略。
public class SimpleNPCAI:MonoBehaviour
{
private GameObject owner;
private GameObject target;
private Vector3 idleCenterPos;
private bool idle;
private void Update()
{
if(idle)
{
if(Vector3.Distance(idleCenterPos, target.transform.position)<20)
{
Debug.Log("执行巡逻时的动作");
}
else
{
idle = false;
}
}
else
{
var dis = Vector3.Distance(owner.transform.position, target.transform.position);
if(dis<3)
{
Debug.Log("执行攻击的动作");
}
else if(dis>10)
{
Debug.Log("执行回归巡逻的动作");
idle = true;
}
else
{
Debug.Log("执行寻路追踪的目标的动作");
}
}
}
}
【行为树实现】
根据前文的描述,可以确认出如下几点,这些点构成了我们需要创建的类、类的字段、类的方法:
- 有行为树和节点两个概念,对应两个类,有不同种类的节点,继而继承出不同类型的节点。
- 行为树需要有一个进入的根节点
- 行为树和节点之前要互相持有,确定归属关系
- 行为树和主体要互相持有,确定归属关系
- 不同节点要有ID、Name、Type等做区分
- 父节点要有子节点
- 没有做编辑配置,需要有添加删除各类节点的方法
- 每个节点有自己的执行逻辑
- 等等
合起来代码如下:
public class SimpleBT
{
private GameObject owner;
private Node rootNode;
public void Init(GameObject owner)
{
this.owner = owner;
}
public NodeStatus Update()
{
var res = rootNode.Update();
return res;
}
public void Destroy()
{
}
}
public enum NodeStatus
{
Success,
Fail,
Running,
}
public class Node
{
public string nodeName;
public int nodeId;
public NodeStatus status;
public SimpleBT owner;
public virtual void Init(string nodeName, int nodeId, SimpleBT owner)
{
this.owner = owner;
this.nodeName = nodeName;
this.nodeId = nodeId;
}
public NodeStatus Update()
{
return OnUpdate();
}
protected virtual NodeStatus OnUpdate()
{
return NodeStatus.Success;
}
public virtual void Destroy()
{
}
}
public class RootNode:Node
{
public Node subNode;
protected override NodeStatus OnUpdate()
{
return subNode.Update();
}
}
public class ControlNode:Node
{
public List<Node> subNodes;
protected override NodeStatus OnUpdate()
{
return Update();
}
}
public class SequenceNode:ControlNode
{
protected override NodeStatus OnUpdate()
{
foreach (var node in subNodes)
{
var status = node.Update();
if (status != NodeStatus.Success)
return status;
}
return NodeStatus.Success;
}
}
public class ActionNode:Node
{
protected override NodeStatus OnUpdate()
{
return base.OnUpdate();
}
}
基本逻辑如下:
- 主体调用SimpleBT的Init,添加节点方法,节点的Init方法完成整个行为树的数据初始化
- 主体调用SimpleBT的Update方法,继而层层调用到不同节点的Update方法
- 主体调用SimpleBT的Destroy方法,不同节点的Destroy方法销毁整个行为树
【几点说明】
是否继承MonoBehaviour的区别
继承的好处在于可以将脚本直接挂载在GameObject上,同时也可以很方便的在Inspector中做数据显示
不继承时数据显示也能做,会麻烦些,同时在代码中添加行为树。一般来说,做通用的东西,其核心不会继承MonoBehaviour
如何添加数据
我们没有在示例代码中给出添加节点的方法,这些方法的实现比较容易
添加节点方法本质是为了给定行为树的初始化数据,通常,初始化数据要支持编辑配置
我们这里假定初始化数据已经有了
【如何使用】
基本的调用如下:
public class NPC:MonoBehaviour
{
private SimpleBT bt;
public bool idle;
public Vector3 idleCenterPos;
public GameObject target;
void Start()
{
bt = new SimpleBT();
bt.Init(this.gameObject);
//初始化bt数据
}
void Update()
{
bt.Update();
}
void OnDestroy()
{
bt.Destroy();
}
}
在数据初始化中,RootNode的subNode是一个SequenceNode
SequenceNode的subNode依次是不同的ActionNode。这里简单来说就两个Node,每个状态下一种,Node实现如下
这里为了保真逻辑的正常执行,节点返回的NodeStatus有些奇怪。实际上应该有更多的节点来保证逻辑的正常,我们再后面的文章中结合实际来添加更多节点。
public class IdleNode:ActionNode
{
protected override NodeStatus OnUpdate()
{
var npc = owner.owner.GetComponent<NPC>();
var res = NodeStatus.Fail;
if(npc != null)
{
if (npc.idle)
{
if (Vector3.Distance(npc.idleCenterPos, npc.target.transform.position) < 20)
{
Debug.Log("执行巡逻时的动作");
res = NodeStatus.Fail;
}
else
{
npc.idle = false;
res = NodeStatus.Success;
}
}
else
{
res = NodeStatus.Success;
}
}
return res;
}
}
public class AttackNode : ActionNode
{
protected override NodeStatus OnUpdate()
{
var npc = owner.owner.GetComponent<NPC>();
var res = NodeStatus.Fail;
if(npc != null)
{
var dis = Vector3.Distance(owner.owner.transform.position, npc.target.transform.position);
if (dis < 3)
{
Debug.Log("执行攻击的动作");
}
else if (dis > 10)
{
Debug.Log("执行回归巡逻的动作");
npc.idle = true;
}
else
{
Debug.Log("执行寻路追踪的目标的动作");
}
res = NodeStatus.Success;
}
return res;
}
}
前后对比可以发现:真正的执行动作的代码区别不大,执行时所需参数的来源和之前一样