在做一些游戏AI时,比如游戏里面的角色、npc、怪物等一些预设的AI逻辑,最简单的时候用if...else...,但是当游戏逻辑有点复杂时就显得有点力不从心,单单看这一大堆的if...else都恶心到吐。目前比较流行的ai模型有状态机和行为树(Behavior tree).
状态机的实现我这里就不多加讨论了
当游戏中的角色,npc,怪物等的决策不太复杂时状态机很有效,然而随着决策的复杂,状态机的缺点也慢慢的体现出来了
罗列状态机比较突出的几个缺点:
1、每一个状态的逻辑会随着新的状态的增加而越来越复杂。
2、状态机状态的复用性很差,一旦一些因素变化导致环境发生变化,你只能新增一个状态,并给这个新状态添加连接及其跳转逻辑。
3、没办法并行处理多个状态。
行为树
1、高度模块化状态,去掉状态中的逻辑跳转,使得状态编程一个"行为"。
2、行为和行为之间的跳转是通过父节点的类型来决定的。并且可以通过并行节点来并行处理多个状态。
3、通过增加控制节点的类型,可以达到复用行为的目的。
关于行为树网上有不少相关文章,大部分都是理论方面的东西,对于行为树的实现,不少朋友不知从何下手。最近相对空闲之余,写了一个简单的行为树库。
如果没有这方面基础的同学请先网上找下这方面的资料,先了解下行为树的一些基本的知识点。
行为树的一些基本的控制节点。我们先实现几个最基本的控制节点。可以根据项目的需要再加一些其他控制节点。
1、选择节点
从头到尾按顺序选择执行条件为真的节点
2、带记忆的选择节点
从上一次执行的子节点开始,按顺序选择执行条件为true的节点
3、序列节点
从头到尾按顺序执行每个子节点,遇到false为止
4、带记忆的序列节点
从上一次执行的子节点开始,按顺序执行每个子节点,遇到false为止
实现部分:
定义一个节点的基类:
#ifndef __BevNode_H__
#define __BevNode_H__
#include <vector>
//#include "BevComm.h"
using namespace std;
namespace BT
{
enum eBevState
{
E_BevState_Success,//成功
E_BevState_Fail,//失败
E_BevState_Running,//该节点正在运行
};
class BevNode
{
public:
BevNode()
: m_pParent(nullptr)
{
}
~BevNode()
{
for (auto pNode : m_VecChildren)
{
delete pNode;
pNode = nullptr;
}
m_VecChildren.clear();
}
void addChild(BevNode* pBevNode);
void setParent(BevNode* pParent){ m_pParent = pParent; }
BevNode* getParent(){ return m_pParent; }
virtual eBevState execute(float fDelta)
{
return E_BevState_Fail;
}
protected:
BevNode* m_pParent;
vector<BevNode*> m_VecChildren;
};
}
#endif
选择节点
#include "BevComm.h"</span>
namespace BT
{
class Selector : public BevNode
{
public:
Selector(){}
virtual ~Selector(){}
virtual eBevState execute(float fDelta);
};
}
#include "Selector.h"
using namespace BT;
eBevState Selector::execute(float fDelta)
{
eBevState result = eBevState::E_BevState_Fail;
for (auto pNode : m_VecChildren)
{
eBevState status = pNode->execute(fDelta);
if (status != eBevState::E_BevState_Fail)
{
result = status;
break;
}
}
return result;
}
带记忆的选择节点
#include "memorySelector.h"
using namespace BT;
eBevState memorySelector::execute(float fDelta)
{
for (int i = m_nLastNode; i < m_VecChildren.size(); ++i)
{
BevNode* pNode = m_VecChildren[i];
eBevState status = pNode->execute(fDelta);
if (status != eBevState::E_BevState_Fail)
{
if (status == eBevState::E_BevState_Running)
{
m_nLastNode = i;
return status;
}
}
}
return eBevState::E_BevState_Fail;
}
序列节点
#include "SequenceNode.h"
using namespace BT;
eBevState SequenceNode::execute(float fDelta)
{
for (auto pNode : m_VecChildren)
{
eBevState status = pNode->execute(fDelta);
if (status != eBevState::E_BevState_Success)
{
return status;
}
}
return eBevState::E_BevState_Success;
}
带记忆的序列节点
#include "memorySequence.h"
using namespace BT;
eBevState memorySequence::execute(float fDelta)
{
for (int i = m_nLastIndex; i < m_VecChildren.size(); ++i)
{
BevNode* pNode = m_VecChildren[i];
eBevState status = pNode->execute(fDelta);
if (status != eBevState::E_BevState_Success)
{
if (status == eBevState::E_BevState_Running)
{
m_nLastIndex = i;
return status;
}
}
}
m_nLastIndex = 0;
return eBevState::E_BevState_Fail;
}
叶子节点(LeafNode)
叶子节点也就是真正跟我们逻辑相关的节点了。
首先叶子节点需要 进入时的逻辑(即该节点的初始化逻辑),运行逻辑,退出该节点时的逻辑。因为叶子节点直接跟业务逻辑挂钩,一开始实现时我是把处理具体逻辑的类继承于叶子节点。这样做的弊端是随着业务逻辑的复杂,基本上每个业务逻辑都要写一个业务逻辑的节点。而且这些业务逻辑节点不好共用,跟具体逻辑的耦合性太高了。后来想了想,干脆所有具体业务逻辑的节点都使用叶子节点,那么不一样的业务逻辑怎么处理呢?每个业务逻辑类不一样的无非就是进入时的逻辑(即该节点的初始化逻辑),运行逻辑,退出该节点时的逻辑,那么好办了,我们可以通过函数指针的形式,把不一样的逻辑传到叶子节点里面,这样所有的业务逻辑都可以使用叶子节点类LeafNode了。
下来开始上代码
#ifndef __LeafNode_H__
#define __LeafNode_H__
#include <functional>
#include "BevNode.h"
namespace BT
{
//外部强制中断
enum eInterruptState
{
E_IS_NONE,
E_IS_FAIL,
E_IS_SUCCESS,
};
class LeafNode;
//刚进入时的初始化操作,外部可能需要跟该节点交互,所以把该节点的指针传出去,下面两个函数同理
typedef std::function<void(LeafNode*)> enterFunc;
//退出时执行的逻辑
typedef std::function<void(LeafNode*)> exitFunctypedef std::function<eBevState(LeafNode*, float)> executeFunc;;//运行逻辑
class LeafNode : public BevNode
{
public:
LeafNode();
virtual ~LeafNode();
virtual eBevState execute(float fDelta);
void interruptState(eInterruptState nInterruptState);
void setEnterFunc(const enterFunc& enterFun);
void setExecuteFunc(const executeFunc& executeFun);
void setExitFunc(const exitFunc& exitFun);
private:
<span style="font-family: Arial, Helvetica, sans-serif;"><span style="white-space:pre"> </span>//控制该节点的初始化,执行和退出</span>
<span style="white-space:pre"> </span>enum
{
E_LS_ENTER,
E_LS_RUNNING,
E_LS_EXIT,
};
private:
int m_nInterrupt;
int m_nLeafStatus;
enterFunc m_enterFunc;
executeFunc m_executeFunc;
exitFunc m_exitFunc;
<span style="white-space:pre"> </span>};
}
#endif
//LeafNode.cpp
#include "LeafNode.h"
using namespace BT;
LeafNode::LeafNode()
: m_nInterrupt(E_IS_NONE)
, m_nLeafStatus(E_LS_ENTER)
, m_enterFunc(nullptr)
, m_executeFunc(nullptr)
, m_exitFunc(nullptr)
{
}
LeafNode::~LeafNode()
{
}
eBevState LeafNode::execute(float fDelta)
{
eBevState status = E_BevState_Success;
<span style="white-space:pre"> </span>//进入时
if (m_nLeafStatus == E_LS_ENTER)
{
if (m_enterFunc)
{
m_enterFunc(this);
}
m_nLeafStatus = E_LS_RUNNING;
}
<span style="white-space:pre"> </span>//执行该节点
if (m_nLeafStatus == E_LS_RUNNING)
{
if (E_IS_NONE == m_nInterrupt)
{
if (m_executeFunc)
{
status = m_executeFunc(this, fDelta);
if (status != eBevState::E_BevState_Running)
{
m_nLeafStatus = E_LS_EXIT;
}
}
else
{
//m_nLeafStatus = E_LS_EXIT;
status = E_BevState_Running;
}
}
else
{
//被打断
if (E_IS_FAIL == m_nInterrupt)
{
status = E_BevState_Fail;
}
else if (E_IS_SUCCESS == m_nInterrupt)
{
status = E_BevState_Success;
}
m_nLeafStatus = E_LS_EXIT;
}
}
<span style="white-space:pre"> </span>//退出该节点
if (m_nLeafStatus == E_LS_EXIT)
{
if (m_exitFunc)
{
m_exitFunc(this);
}
m_nLeafStatus = E_LS_ENTER;
m_nInterrupt = E_IS_NONE;
}
return status;
}
void BT::LeafNode::setEnterFunc(const enterFunc& enterFun)
{
m_enterFunc = enterFun;
}
void BT::LeafNode::setExecuteFunc(const executeFunc& executeFun)
{
m_executeFunc = executeFun;
}
void BT::LeafNode::setExitFunc(const enterFunc& exitFun)
{
m_exitFunc = exitFun;
}
void BT::LeafNode::interruptState(eInterruptState nInterruptState)
{
m_nInterrupt = nInterruptState;
}
条件节点:跟具体逻辑节点一样,可以使用LeafNode节点来把条件逻辑传进来执行。
到此一个简单的行为树框架已经完成,当然目前的控制节点太少了,需要补充更丰富的控制节点来满足我们的逻辑需要。
后续有时间我会写一些demo来说明下如何使用该框架。