从上古卷轴中形形色色的人物,到NBA2K中挥洒汗水的球员,从使命召唤中诡计多端的敌人,到刺客信条中栩栩如生的人群。游戏AI几乎存在于游戏中的每个角落,默默构建出一个令人神往的庞大游戏世界。

那么这些复杂的AI又是怎么实现的呢?下面就让我们来了解并亲手实现一下游戏AI基础架构之一的行为树。

行为树简介

行为树是一种树状的数据结构,树上的每一个节点都是一个行为。每次调用会从根节点开始遍历,通过检查行为的执行状态来执行不同的节点。他的优点是耦合度低扩展性强,每个行为可以与其他行为完全独立。目前的行为树已经可以将几乎任意架构(如规划器,效用论等)应用于AI之上。

class BehaviorTree
{
public:
BehaviorTree(Behavior* InRoot) { Root = InRoot; }
void Tick()
{
Root->Tick();
}
bool HaveRoot() { return Root?true:false; }
void SetRoot(Behavior* InNode) { Root= InNode; }
void Release() { Root->Release(); }
private:
Behavior* Root;
};

上面提供了行为树的实现,行为树有一个根节点和一个Tick()方法,在游戏过程中每个一段时间会调用依次Tick方法,令行为树从根节点开始执行。

行为(behavior)

行为(behavior)是行为树最基础的概念,是几乎所有行为树节点的基类,是一个抽象接口,而如动作条件等节点则是它的具体实现。

下面是Behavior的实现,省略掉了一些简单的判断状态的方法完整源码可以参照文尾的github链接

class Behavior
{
public:
//释放对象所占资源
virtual void Release() = 0;
//包装函数,防止打破调用契约
EStatus Tick(); EStatus GetStatus() { return Status; }
virtual void AddChild(Behavior* Child){}; protected:
//创建对象请调用Create()释放对象请调用Release()
Behavior():Status(EStatus::Invalid){}
virtual ~Behavior() {}
virtual void OnInitialize() {};
virtual EStatus Update() = 0;
virtual void OnTerminate(EStatus Status) {}; protected:
EStatus Status;
};

Behavior接口是所有行为树节点的核心,且我规定所有节点的构造和析构方法都必须是protected,以防止在栈上创建对象,所有的节点对象通过Create()静态方法在堆上创建,通过Release()方法销毁,由于Behavior是个抽象接口,故没有提供Create()方法,本接口满足如下契约

  • 在Update方法被首次调用前,调用一次OnInitialize函数,负责初始化等操作
  • Update()方法在行为树每次更新时调用且仅调用一次。
  • 当行为不再处于运行状态时,调用一次OnTerminate(),并根据返回状态不同执行不同的逻辑

为了保证契约不被打破,我们将这三个方法包装在Tick()方法里。Tick()的实现如下

//update方法被首次调用前执行OnInitlize方法,每次行为树更新时调用一次update方法
//当刚刚更新的行为不再运行时调用OnTerminate方法
if (Status != EStatus::Running)
{
OnInitialize();
} Status = Update(); if (Status != EStatus::Running)
{
OnTerminate(Status);
} return Status;

其中返回值Estatus是一个枚举值,表示节点运行状态。

enum class EStatus:uint8_t
{
Invalid, //初始状态
Success, //成功
Failure, //失败
Running, //运行
Aborted, //终止
};

动作(Action)

动作是行为树的叶子节点,表示角色做的具体操作(如攻击,上弹,防御等),负责改变游戏世界的状态。动作节点可直接继承自Behavior节点,通过实现不同的Update()方法实现不同的逻辑,在OnInitialize()方法中获取数据和资源,在OnTerminate中释放资源。

//动作基类
class Action :public Behavior
{
public:
virtual void Release() { delete this; } protected:
Action() {}
virtual ~Action() {}
};

在这里我实现了一个动作基类,主要是为了一个公用的Release方法负责释放节点内存空间,所有动作节点均可继承自这个方法

条件

条件同样是行为树的叶子节点,用于查看游戏世界信息(如敌人是否在攻击范围内,周围是否有可攀爬物体等),通过返回状态表示条件的成功。

//条件基类
class Condition :public Behavior
{
public:
virtual void Release() { delete this; } protected:
Condition(bool InIsNegation):IsNegation(InIsNegation) {}
virtual ~Condition() {} protected:
//是否取反
bool IsNegation=false;
};

这里我实现了条件基类,一个IsNegation来标识条件是否取反(比如是否看见敌人可以变为是否没有看见敌人)

装饰器(Decorator)

装饰器(Decorator)是只有一个子节点的行为,顾名思义,装饰即是在子节点的原有逻辑上增添细节(如重复执行子节点,改变子节点返回状态等)

//装饰器
class Decorator :public Behavior
{
public:
virtual void AddChild(Behavior* InChild) { Child=InChild; }
protected:
Decorator() {}
virtual ~Decorator(){}
Behavior* Child;
};

实现了装饰器基类,下面我们来实现下具体的装饰器,也就是上面提到的重复执行多次子节点的装饰器

class Repeat :public Decorator
{
public:
static Behavior* Create(int InLimited) { return new Repeat(InLimited); }
virtual void Release() { Child->Release(); delete this; }
protected:
Repeat(int InLimited) :Limited(InLimited) {}
virtual ~Repeat(){}
virtual void OnInitialize() { Count = 0; }
virtual EStatus Update()override;
virtual Behavior* Create() { return nullptr; }
protected:
int Limited = 3;
int Count = 0;
};

正如上面提到的,Create函数负责创建节点,Release负责释放

其中Update()方法的实现如下

EStatus Repeat::Update()
{
while (true)
{
Child->Tick();
if (Child->IsRunning())return EStatus::Success;
if (Child->IsFailuer())return EStatus::Failure;
if (++Count == Limited)return EStatus::Success;
Child->Reset();
}
return EStatus::Invalid;
}

逻辑很简单,如果执行失败就立即返回,执行中就继续执行,执行成功就把计数器+1重复执行

复合行为

我们将行为树中具有多个子节点的行为称为复合节点,通过复合节点我们可以将简单节点组合为更有趣更复杂的行为逻辑。

下面实现了一个符合节点的基类,将一些公用的方法放在了里面(如添加清除子节点等)

//复合节点基类
class Composite:public Behavior
{
virtual void AddChild(Behavior* InChild) override{Childern.push_back(InChild);}
void RemoveChild(Behavior* InChild);
void ClearChild() { Childern.clear(); }
virtual void Release()
{
for (auto it : Childern)
{
it->Release();
} delete this;
} protected:
Composite() {}
virtual ~Composite() {}
using Behaviors = std::vector<Behavior*>;
Behaviors Childern;
};

顺序器(Sequence)

顺序器(Sequence)是复合节点的一种,它依次执行每个子行为,直到所有子行为执行成功或者有一个失败为止。

//顺序器:依次执行所有节点直到其中一个失败或者全部成功位置
class Sequence :public Composite
{
public:
virtual std::string Name() override { return "Sequence"; }
static Behavior* Create() { return new Sequence(); }
protected:
Sequence() {}
virtual ~Sequence(){}
virtual void OnInitialize() override { CurrChild = Childern.begin();}
virtual EStatus Update() override; protected:
Behaviors::iterator CurrChild;
};

其中Update()方法的实现如下

EStatus Sequence::Update()
{
while (true)
{
EStatus s = (*CurrChild)->Tick();
//如果执行成功了就继续执行,否则返回
if (s != EStatus::Success)
return s;
if (++CurrChild == Childern.end())
return EStatus::Success;
}
return EStatus::Invalid; //循环意外终止
}

选择器(Selector)

选择器(Selector)是另一种常用的复合行为,它会依次执行每个子行为直到其中一个成功执行或者全部失败为止

由于与顺序器仅仅是Update函数不同,下面仅贴出Update方法

EStatus Selector::Update()
{
while (true)
{
EStatus s = (*CurrChild)->Tick();
if (s != EStatus::Failure)
return s;
//如果执行失败了就继续执行,否则返回
if (++CurrChild == Childern.end())
return EStatus::Failure;
}
return EStatus::Invalid; //循环意外终止
}

并行器(Parallel)

顾名思义,并行器(Parallel)是一种让多个行为并行执行的节点。但仔细观察便会发现实际上只是他们的更新函数在同一帧被多次调用而已。

//并行器:多个行为并行执行
class Parallel :public Composite
{
public:
static Behavior* Create(EPolicy InSucess, EPolicy InFailure){return new Parallel(InSucess, InFailure); }
virtual std::string Name() override { return "Parallel"; } protected:
Parallel(EPolicy InSucess, EPolicy InFailure) :SucessPolicy(InSucess), FailurePolicy(InFailure) {}
virtual ~Parallel() {}
virtual EStatus Update() override;
virtual void OnTerminate(EStatus InStatus) override; protected:
EPolicy SucessPolicy;
EPolicy FailurePolicy;
};

这里的Epolicy是一个枚举类型,表示成功和失败的条件(是成功或失败一个还是全部成功或失败)

//Parallel节点成功与失败的要求,是全部成功/失败,还是一个成功/失败
enum class EPolicy :uint8_t
{
RequireOne,
RequireAll,
};

update函数实现如下


EStatus Parallel::Update()
{
int SuccessCount = 0, FailureCount = 0;
int ChildernSize = Childern.size();
for (auto it : Childern)
{
if (!it->IsTerminate())
it->Tick(); if (it->IsSuccess())
{
++SuccessCount;
if (SucessPolicy == EPolicy::RequireOne)
{
it->Reset();
return EStatus::Success;
} } if (it->IsFailuer())
{
++FailureCount;
if (FailurePolicy == EPolicy::RequireOne)
{
it->Reset();
return EStatus::Failure;
}
}
} if (FailurePolicy == EPolicy::RequireAll&&FailureCount == ChildernSize)
{
for (auto it : Childern)
{
it->Reset();
} return EStatus::Failure;
}
if (SucessPolicy == EPolicy::RequireAll&&SuccessCount == ChildernSize)
{
for (auto it : Childern)
{
it->Reset();
}
return EStatus::Success;
} return EStatus::Running;
}

在代码中,并行器每次更新都执行每一个尚未终结的子行为,并检查成功和失败条件,如果满足则立即返回。

另外,当并行器满足条件提前退出时,所有正在执行的子行为也应该立即被终止,我们在OnTerminate()函数中调用每个子节点的终止方法

void Parallel::OnTerminate(EStatus InStatus)
{
for (auto it : Childern)
{
if (it->IsRunning())
it->Abort();
}
}

监视器(Monitor)

监视器是并行器的应用之一,通过在行为运行过程中不断检查是否满足某条件,如果不满足则立刻退出。将条件放在并行器的尾部即可。

主动选择器

主动选择器是选择器的一种,与普通的选择器不同的是,主动选择器会不断的主动检查已经做出的决策,并不断的尝试高优先级行为的可行性,当高优先级行为可行时胡立即打断低优先级行为的执行(如正在巡逻的过程中发现敌人,即时中断巡逻,立即攻击敌人)。

其Update()方法和OnInitialize方法实现如下

//初始化时将CurrChild初始化为子节点的末尾
virtual void OnInitialize() override { CurrChild = Childern.end(); } EStatus ActiveSelector::Update()
{
//每次执行前先保存的当前节点
Behaviors::iterator Previous = CurrChild;
//调用父类OnInlitiallize函数让选择器每次重新选取节点
Selector::OnInitialize();
EStatus result = Selector::Update();
//如果优先级更高的节点成功执行或者原节点执行失败则终止当前节点的执行
if (Previous != Childern.end()&CurrChild != Previous)
{
(*Previous)->Abort();
} return result;
}

示例##

这里我创建了一名角色,该角色一开始处于巡逻状态,一旦发现敌人,先检查自己生命值是否过低,如果是就逃跑,否则就攻击敌人,攻击过程中如果生命值过低也会中断攻击,立即逃跑,如果敌人死亡则立即停止攻击,这里我们使用了构建器来创建了一棵行为树,关于构建器的实现后面会讲到,这里每个函数创建了对应函数名字的节点,

//构建行为树:角色一开始处于巡逻状态,一旦发现敌人,先检查自己生命值是否过低,如果是就逃跑,否则就攻击敌人,攻击过程中如果生命值过低也会中断攻击,立即逃跑,如果敌人死亡则立即停止攻击
BehaviorTreeBuilder* Builder = new BehaviorTreeBuilder();
BehaviorTree* Bt=Builder
->ActiveSelector()
->Sequence()
->Condition(EConditionMode::IsSeeEnemy,false)
->Back()
->ActiveSelector()
-> Sequence()
->Condition(EConditionMode::IsHealthLow,false)
->Back()
->Action(EActionMode::Runaway)
->Back()
->Back()
->Monitor(EPolicy::RequireAll,EPolicy::RequireOne)
->Condition(EConditionMode::IsEnemyDead,true)
->Back()
->Action(EActionMode::Attack)
->Back()
->Back()
->Back()
->Back()
->Action(EActionMode::Patrol)
->End(); delete Builder;

然后我通过一个循环模拟行为树的执行。同时在各条件节点内部通过随机数表示条件是否执行成功(具体见文末github源码)

	//模拟执行行为树
for (int i = 0; i < 10; ++i)
{
Bt->Tick();
std::cout << std::endl;
}

执行结果如下,由于随机数的存在每次执行结果都不一样

构建器的实现

上面创建行为树的时候用到了构建器,下面我就介绍一下自己的构建器实现

//行为树构建器,用来构建一棵行为树,通过前序遍历方式配合Back()和End()方法进行构建
class BehaviorTreeBuilder
{
public:
BehaviorTreeBuilder() { }
~BehaviorTreeBuilder() { }
BehaviorTreeBuilder* Sequence();
BehaviorTreeBuilder* Action(EActionMode ActionModes);
BehaviorTreeBuilder* Condition(EConditionMode ConditionMode,bool IsNegation);
BehaviorTreeBuilder* Selector();
BehaviorTreeBuilder* Repeat(int RepeatNum);
BehaviorTreeBuilder* ActiveSelector();
BehaviorTreeBuilder* Filter();
BehaviorTreeBuilder* Parallel(EPolicy InSucess, EPolicy InFailure);
BehaviorTreeBuilder* Monitor(EPolicy InSucess, EPolicy InFailure);
BehaviorTreeBuilder* Back();
BehaviorTree* End(); private:
void AddBehavior(Behavior* NewBehavior); private:
Behavior* TreeRoot=nullptr;
//用于存储节点的堆栈
std::stack<Behavior*> NodeStack;
};
BehaviorTreeBuilder* BehaviorTreeBuilder::Sequence()
{
Behavior* Sq=Sequence::Create();
AddBehavior(Sq);
return this;
} void BehaviorTreeBuilder::AddBehavior(Behavior* NewBehavior)
{
assert(NewBehavior);
//如果没有根节点设置新节点为根节点
if (!TreeRoot)
{
TreeRoot=NewBehavior;
}
//否则设置新节点为堆栈顶部节点的子节点
else
{
NodeStack.top()->AddChild(NewBehavior);
} //将新节点压入堆栈
NodeStack.push(NewBehavior);
} BehaviorTreeBuilder* BehaviorTreeBuilder::Back()
{
NodeStack.pop();
return this;
} BehaviorTree* BehaviorTreeBuilder::End()
{
while (!NodeStack.empty())
{
NodeStack.pop();
}
BehaviorTree* Tmp= new BehaviorTree(TreeRoot);
TreeRoot = nullptr;
return Tmp;
}

在上面的实现中,我在每个方法里创建对应节点,检测当前是否有根节点,如果没有则将其设为根节点,如果有则将其设为堆栈顶部节点的子节点,随后将其压入堆栈,每次调用back则退栈,每个创建节点的方法都返回this以方便调用下一个方法,最后通过End()表示行为树创建完成并返回构建好的行为树。

那么上面就是行为树的介绍和实现了,下一篇我们将对行为树进行优化,慢慢进入第二代行为树。

[github地址][1]

游戏AI-行为树理论及实现的更多相关文章

  1. 如何建立一个完整的游戏AI

    http://blog.friskit.me/2012/04/how-to-build-a-perfect-game-ai/ 人工智能(Artificial Intelligence)在游戏中使用已经 ...

  2. 游戏AI-行为树

    参考: 游戏AI—行为树研究及实现 GAD腾讯游戏开发者平台:游戏中的人工智能AI 腾讯开源项目behaviac 占坑,待编辑

  3. 使用行为树(Behavior Tree)实现游戏AI

    ——————————————————————— 谈到游戏AI,很明显智能体拥有的知识条目越多,便显得更智能,但维护庞大数量的知识条目是个噩梦:使用有限状态机(FSM),分层有限状态机(HFSM),决策 ...

  4. 游戏AI之决策结构—有限状态机/行为树(2)

    目录 有限状态机 行为树 控制节点 条件节点 行为节点 装饰节点 总结 额外/细节/优化 游戏AI的决策部分是比较重要的部分,游戏程序的老前辈们留下了两种经过考验的用于AI决策的结构: 有限状态机 行 ...

  5. 做游戏长知识------基于行为树与状态机的游戏AI(一)

    孙广东 2014.6.30 AI. 我们的第一印象可能是机器人,如今主要说在游戏中的应用. 现代的计算机游戏中已经大量融入了AI元素,平时我们进行游戏时产生的交互都是由AI来完毕的.比方在RPG游戏中 ...

  6. Unity教程之-基于行为树与状态机的游戏AI

    AI.我们的第一印象可能是机器人,现在主要说在游戏中的应用.关于AI的相关文章我们在前面也提到过,详细请戳这现代的计算机游戏中已经大量融入了AI元素,平时我们进行游戏时产生的交互都是由AI来完成的.比 ...

  7. 游戏AI之初步介绍(0)

    目录 游戏AI是什么? 游戏AI和理论AI 智能的假象 (更新)游戏AI和机器学习 介绍一些游戏AI 4X游戏AI <求生之路>系列 角色扮演/沙盒游戏中的NPC 游戏AI 需要学些什么? ...

  8. 趣说游戏AI开发:对状态机的褒扬和批判

    0x00 前言 因为临近年关工作繁忙,已经有一段时间没有更新博客了.到了元旦终于有时间来写点东西,既是积累也是分享.如题目所示,本文要来聊一聊在游戏开发中经常会涉及到的话题--游戏AI.设计游戏AI的 ...

  9. 对弈类游戏的人工智能(5)--2048游戏AI的解读

    前言: 闲得没事, 网上搜"游戏AI", 看到一篇<<2048游戏的最佳算法是?来看看AI版作者的回答>>的文章. 而这篇文章刚好和之前讲的对弈类游戏AI对 ...

随机推荐

  1. Robotframework-Appium系列:安装配置

    1.   Robotframework-android系列:安装配置 1.1. 安装环境 64位win10家庭中文版 1.1. 安装说明 网上robotframework-appium安装资料也不少, ...

  2. selenium_Alert

    网页测试,最避免不了的就是弹出框,但是弹出框你真的分的清吗? Alert prompt comfirm 先来认识一下这三个弹窗 代码如下 <!DOCTYPE html> <html ...

  3. github创建远程仓库

    创建远程仓库 当你已经在本地创建了一个Git仓库后,又想在GitHub创建一个Git仓库,并且让这两个仓库进行远程同步,这样,GitHub上的仓库既可以作为备份,又可以让其他人通过该仓库来协作,真是一 ...

  4. springmvc跨域+token验证(app后台框架搭建二)

    这是app后台框架搭建的第二课,主要针对app应用是跨域的运用,讲解怎么配置跨域服务:其次讲解怎么进行token验证,通过拦截器设置token验证和把token设置到http报文中.主要有如下:   ...

  5. ASP.NET Core缓存静态资源

    背景 缓存样式表,JavaScript或图像文件等静态资源可以提高您网站的性能.在客户端,总是从缓存中加载一个静态文件,这样可以减少对服务器的请求数量,从而减少获取页面及其资源的时间.在服务器端,由于 ...

  6. thinphp原生异步分页

    异步分页: $sql="............";   $result=$m->query($sql);   $count =count($result);   $page ...

  7. 如何实现 Service 伸缩?- 每天5分钟玩转 Docker 容器技术(97)

    上一节部署了只有一个副本的 Service,不过对于 web 服务,我们通常会运行多个实例.这样可以负载均衡,同时也能提供高可用. swarm 要实现这个目标非常简单,增加 service 的副本数就 ...

  8. 记一次改造react脚手架的过程

    公司突然组织需要重新搭建一个基于node的论坛系统,前端采用react,上网找了一些脚手架,或多或少不能满足自己的需求,最终在基于YeoMan的react脚手架generator-react-webp ...

  9. Less的@import指令

    Less的@import指令 Less中,可以通过 @import指令来导入外部文件.@import指令可以放在代码中的任何位置,导入文件时的处理方式取决于文件的扩展名: 如果扩展名是 .css,文件 ...

  10. PAT 1003. Emergency (25) dij+增加点权数组和最短路径个数数组

    1003. Emergency (25) 时间限制 400 ms 内存限制 65536 kB 代码长度限制 16000 B 判题程序 Standard 作者 CHEN, Yue As an emerg ...