游戏AI的实现通常分为两种,有限状态机(FSM)以及行为树,我们这里首先将有限状态机应用到游戏《古月》的怪物AI设计中,在之后的版本中再用行为树替换有限状态机。
2018.1.11更新 目前正在写行为树的实现,预计本周内完成行为树的设计,会更新在博客中,博主目前找到了武汉胡莱游戏的实习工作,预计会实习到6月份,希望能够有所提升。
我们知道,有限状态机维护了一张图,图中的结点代表不同的状态,状态之间通过某种条件触发转换,如果不满足条件则维持原状态。这里以一个简单的例子来说明。
下图为游戏中使用的怪物AI有限状态机。
从图中可以看出,有限状态机表示的AI不可能同时处于图示状态中的多个,而只可能处于其中一种状态,状态的转换取决于转换条件和当前所处状态。
我们不考虑怪物的出生和死亡状态,只考虑图中列出的这几个状态,可以看出,它们的转换关系是非常显而易见的,控制状态转换的核心条件是怪物与玩家的距离,这个距离驱动了整个状态机的状态切换。
简单的有限状态机可以用枚举+switch的方式实现,但这样的实现不可避免地会出现很多问题。首先,若状态改变需要额外的参数控制(比如巡逻状态的巡逻方向),这种处理就不得不在怪物类中增加成员,其次,这种做法也不符合面向对象思想。
状态模式的处理就是把每一种状态都当做对象进行处理,也就是说,我们为每种状态定义一个类,这个类的实例代表了当前目标所处的状态。为了实现这个目标,我们要做下面几步:
1. 定义一个状态基类(或状态接口)。
2. 为每种状态定义新类别继承自基类(或实现状态接口)。
3. 实现新的状态类中的接口。
我们定义的状态基类包含以下接口:
class AIState{
publc:
virtual void enter();
virtual AIState* handle();
virtual void exit();
virtual ~AIState(){};
}
虚函数在这里是必要的,我们后续的状态转换完全依赖了虚函数的动态多态特性。同样的原因,析构函数必须是虚函数,这样才能够保证析构的正确性。
我们简化问题的处理,只考虑游戏世界只存在单个玩家的情况。驱动状态转换的距离就是玩家与AI的距离。在状态中,处理转换过程的函数目前只需要一个参数,就是AI本身,我们以Enemy代表这个AI,handle函数因此需要改写成:
virtual AIState* handle(Enemy& enemy);
由上图的状态转换我们可以写出巡逻状态类声明(cocos2dx):
class PatrolState :public AIState {
public:
AIState* handle(Monomer& enemy, Monomer* attacker) override;
void exit() override;
void update(float deltaTime) override;
void patrol();
};
其中Monomer是Enemy基类。
我们主要关注handle函数的实现。
AIState* PatrolState::handle(Monomer& enemy, Monomer* at) {
auto &en = static_cast (enemy);
auto player = Player::sharePlayer();
auto point = MapPoint(en.getPosition());
auto playerPoint = MapPoint(player->getPosition());
auto distance = point.getDistance(playerPoint);
if (distance>VISUAL_RANGE||player->getStateType() == FStateDeath)
{
en.patrol();
return nullptr;
}
auto attacker = en.getAttackMonomerMajor();
if (distance <= VISUAL_RANGE)
{
if (attacker == nullptr)
{
en.setAttackMonomerMajor(player);
attacker = player;
player->addAgainstMe(&en, 0);
}
auto newState = new FollowTracesState();
enemy.followTheTracks();
return newState;
}
if (attacker)
{
attacker->removeAgainstMe(&en);
en.removeAttackMonomerMajor(attacker);
}
en.patrol();
return nullptr;
}
handle类根据视野范围进行状态切换,如果满足条件就返回新的状态,如果不满足,则返回空指针,表示维持在当前状态。
其它类的设计与patrol类的设计相似,在各自的状态类中,我们执行该状态对应的函数,或转换到其余状态。
在怪物类 Enemy中,我们设计一个update函数,这个函数会逐帧运行(时间驱动)。
void Enemy::update(float delta)
{
auto newState = _mCurrState->handle(*this, m_attackMonomerMajor);
if (newState) {
delete _mCurrState;
_mCurrState = newState;
}
}
_mCurrState是一个指向当前状态的指针(数据成员),在状态发生改变时,newState不为空,我们这时删除原状态指针,更新为新状态。
状态机设计完成之后,下一步就是在当前状态机基础上设计更智能的AI,此外,更进一步的想法是使用行为树来代替有限状态机。
好久没更新了。。事情多算是在为自己找借口吧,行为树版本的AI已经写好有一段时间了,这里做一个简单的总结。
本文中使用的行为树来自一份开源代码(行为树的开源实现),在阅读之前最好能够去看一下原作者关于行为树的简单介绍,本文会简单介绍一下使用到的几种节点。
行为树: 行为树,英文是Behavior Tree,简称BT,是由行为节点组成的树状结构。
节点:行为树上节点主要包括两种,一种行为节点,一种控制节点。行为节点是行为树的叶节点,非叶节点就是控制节点。行为节点真正执行了AI的行为,而控制节点则控制行为树具体自上而下的分支,行为树每次执行都会有一条自上而下的节点连起来的路径表示AI正处于的状态。
行为树主要的控制节点:
带优先级的选择结点:在本次应用中使用最多的控制节点。这种选择节点每次都从第一个节点开始判断前提是否满足,因此带优先级的选择节点要求各节点的前提的约束范围必须从小到大(否则后续的节点永远不可能得到执行)。
不带优先级的选择结点:
与带优先级选择节点不同,不带优先级的选择节点首先判断上次执行节点的前提(请仔细阅读上述博客的内容),若上次节点的前提判断为真,则继续运行该节点,否则,则依次判断所有节点的前提,选择新的节点运行。
因为依次判断的原因,不带优先级的选择节点只要求判断条件互斥。
顺序控制节点:按序执行的控制节点
顺序节点是否能够执行,取决于顺序节点本身的前提以及顺序节点中当前节点的前提是否满足(evaluate方法),若是第一次执行,则取决于第一个节点前提是否满足。
顺序节点的更新操作:顺序节点首先更新当前运行节点的运行状态,若返回完成,则执行下一节点,若为最后一个节点,则返回结束,否则仍返回运行中。
顺序节点的清理操作:顺序节点会将所有值置为初始状态。
关于并行节点和循环节点这里不再介绍。
了解了行为树之后,我们可以总结出一个很重要的点,行为树的最终行为是由当前路径上所有前提条件的相与共同决定,也就是说当前路径上的前提条件必须全部满足(行为树也正是这样进行设计和运行的)。
行为树节点的基本结构:
class BevNode {
BevNode* childNodeList[MAX_CHILD_NODE_CNT];
unsigned int childNodeCount;
BevNode* parentNode;
BevNodePrecondition* nodePrecondition;
virtual bool _DoEvaluate(const BevNodeInputParam& input)
{
return true;
}
virtual void _DoTransition(const BevNodeInputParam& input)
{
}
virtual BevRunningStatus _DoTick(const BevNodeInputParam& input, BevNodeOutputParam& output)
{
return K_BRS_Finish;
}
};
先看数据成员,分别代表节点的子节点、父节点、子节点数量以及前提。
函数方面,分别代表了三种操作:判断(evaluate),更新/执行(tick),清理(transition).
在具体控制节点实现中,判断的实现已经在上述几种控制条件中写出,更新操作基本就是自上而下进行,清理操作用于上一状态退出时的原节点的清理处理。
此处红色节点代表行为节点,绿色节点代表带优先级的选择节点。我们首先按照优先级(取值范围由小到大)排列好根节点的子节点。可以看到,死亡状态节点是优先级最高的。与状态机类似,我们同样设计了逃跑节点以及跟随节点。按照设计好的行为树,我们每一帧去更新行为树的输入,并确定新的行为树状态。可以看到,与状态机相比,行为树的层次更加清晰,代码耦合度更低,同样也更容易扩展,行为树不需要考虑上次的状态,所有行为都由前提决定(所有前提都满足构成一条路径)。因此是状态机的很好替代。