— AI分享站

第一部分 第二部分

行为树最后一个要讲的地方,是关于前提(Precondition),在第一部分里,我略微提到了一下,这次我们来仔细看看,再来看看关于前提的纯虚基类的定义:

 1: class BevNodePrecondition
 2: {
 3: public:
 4:     virtual bool ExternalCondition(const BevNodeInputParam& input) const = 0;
 5: };

每一个前提类,都需要实现这个判断的虚函数。我在《用类来表示逻辑运算–关于行为树前提的一种实现方式》提到,我们可以用类来表示逻辑运算,这样的好处是可以做到模块化,同样的判断条件可以复用,所以在库中,我也实现了这种逻辑的表达方式,定义了基本的逻辑运算类

 1: class BevNodePreconditionTRUE{};
 2: class BevNodePreconditionFALSE{};
 3: class BevNodePreconditionNOT{};
 4: class BevNodePreconditionAND{};
 5: class BevNodePreconditionOR{};
 6: class BevNodePreconditionXOR{};

从这些类的名字应该就可以明显的看出这些类的含义了,和逻辑操作符一样,有些类的构造函数需要两个参数,以此来表示二元的逻辑运算(AND,OR,XOR),有些只需要一个参数,以此来表示一元的逻辑运算(NOT)。前提类被用来附在行为树的节点上(每一个节点都可以附加),默认情况下,节点上是没有前提类的,也就是不存在“外在前提”,而只有“内在前提”,这和附了一个BevNodePreconditionTRUE(永远返回True)的“外在前提”的节点是等价的。

好了,行为树库的内容基本就是这些了。接下去我们来看看例子程序,介绍如何用库来创建行为树,例子的代码在BevTreeTest这个工程中,编译后可直接运行,这个例子分别演示了三个行为树,从简单到复杂,单击鼠标可以在这三个例子间切换。这个程序实现了这样一个功能,“在场景地图上,定时会产生一个目标点,智能体就会根据行为树的定义,用不同的行为模式移动到目标点”。

在这个程序中,我为智能体一共定义了4个行为:

 1: class NOD_Idle{};      //空闲,表现是颜色不停变化
 2: class NOD_Breathe{};   //呼吸,表现是大小规律性变化
 3: class NOD_MoveTo{};    //移动,平移到某目标点
 4: class NOD_FaceTo{};    //转向,转向到某方向

再定义了2个“外在前提”:

 1: class CON_HasReachedTarget{};    //是否到达目标点
 2: class CON_HasFacedToTarget{};    //是否朝向目标点

我就用第一个例子来说,第一例子的行为树图如下:

BevTreeTest1

这是一个很简单的行为树,根节点是一个带优先级的行为节点,所以MoveTo比Idle的优先级高,MoveTo带有一个“外在前提”,“当没有到达目标点”时,会选在MoveTo的行为,反之,则选Idle的行为。

在代码中,可以这样来定义这棵行为树

 1: BevNode& ret =
 2:     BevNodeFactory: :o CreatePrioritySelectorNode(NULL, "root");
 3:         BevNodeFactory: :o CreateTeminalNode<NOD_MoveTo>(&ret, "move to")
 4:             .SetNodePrecondition(new BevNodePreconditionNOT(new CON_HasReachedTarget()));
 5:         BevNodeFactory: :o CreateTeminalNode<NOD_Idle>(&ret, "idle")
 6:             .SetNodePrecondition(new BevNodePreconditionTRUE());
 7: m_BevTreeRoot = &ret;

我在库中定义了一些工厂方法,帮助创建相关的节点。值得注意的是,我在这里演示了用类表示逻辑的用法。我在定义行为树的时候,会用一些格式上的缩进,来表示相应的父子结构,这仅仅是为了视觉上比较明了。当然,以后可以改进行为树的定义接口,更可以用数据文件来定义行为树。

这样定义完毕后,我们就可以用行为树来决策我们的行为了,代码相当简单

 1: BevNodeInputParam input(&m_BevTreeInputData);
 2: BevNodeOutputParam output(&m_BevTreeOutputdata);
 3: if(m_BevTreeRoot->Evaluate(input))
 4: {
 5:     m_BevTreeRoot->Tick(input, output);
 6: }

在例子中,我尽量把行为树中要输出的变量写到BevNodeOutputParam结构中(而不是直接修改智能体的信息),这样做的好处是可以让行为树的输入和输出的接口相当清晰,做成黑盒,可以参考我在这里的讨论。

第二个例子演示了并行节点的用法,第三个例子演示了序列节点的用法,就不多说了,大家可以自行看代码。

所有的代码可以通过以下方式获得:

下载地址:

GoogleCode下载点(exe文件夹中已包含可执行文件)

也可用svn通过以下地址来得:

http://tsiu.googlecode.com/svn/branches/blogver/

编译方法:

用VS2005以上打开,选择Debug NoDx或者Release NoDx,编译后,运行BevTreeTest.

相关代码:

TAI_BevTree.h

TAI_BevTree.cpp

关于TsiU

TsiU是我一直在维护的一个自己用的小型的框架,我平时做的一些AI的sample,或者一些工具,都会基于这个框架,TsiU有一些基本的UI控件库,网络模块库,GDI绘图模块,D3D绘图模块等等,可以快速的做成一个小型的示例程序,很方便(具体可参考SampleApps里的例子程序),并且整个架构是用Object的方式来组织,非常容易理解和扩展。整个框架很轻量化,基本就是做了一些底层的基本的功能,这样我在平时做东西的时候,就不需要重新写底层了,把精力都放在高层的实现了。以后分享代码都会基于这个框架,大家也可以通过svn来随时update到我最新的改动。下图就是TsiU里的几个工程介绍,代码不多,大家想看的也可以自己看一下 :)

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

第一部分

上一次说到了节点的基类,它描述了在行为树上一个节点的基本结构。我们知道,在行为树上有两大类的节点,一种我称之为“控制节点”,像“选择节点”,“并行节点”,“序列节点”都属于此类,这类节点负责行为树逻辑的控制,是和具体的游戏逻辑无关的,属于行为树库的一部分,并且这类节点一般不会作为叶节点。还有一类称为“行为节点”,也就是行为树上挂载的具体行为,是和游戏逻辑相关的,不属于行为树库的一部分,需要自己去继承和实现,这类节点一般都作为叶节点出现。

先来看看“行为节点”的代码,我先从节点的基类继承了一个所有“行为节点”的基类

 1: class BevNodeTerminal : public BevNode
 2: {}

在它的Tick方法中,我做了一个简单的状态机(可以自行看代码),负责处理进入行为(Enter),更新行为(Execute),退出行为(Exit),所有的行为节点应该继承自BevNodeTerminal类,并且重写这些虚函数,在进入和退出行为里,可以做一个初始化和清理的工作:

 1: class BevNodeTerminal : public BevNode
 2: {
 3: protected:
 4:     virtual void                _DoEnter(const BevNodeInputParam& input)                                {}
 5:     virtual BevRunningStatus    _DoExecute(const BevNodeInputParam& input, BevNodeOutputParam& output)  { return k_BRS_Finish;}
 6:     virtual void                _DoExit(const BevNodeInputParam& input, BevRunningStatus _ui_ExitID)    {}
 7: }

值得注意的是,在Tick方法中,它有一个返回值,表示当前节点是否处理完毕,在库中,我定义了一个enum来表示节点的运行状态:

 1: enum BevRunningStatus
 2: {
 3:     k_BRS_Executing                 = 0,
 4:     k_BRS_Finish                    = 1,
 5:     ...
 6: };

当返回k_BRS_Finish的时候,就表示当前节点已经处理完毕了,如果再次进入该节点,就认为是重新进入了。用上面描述的那个状态机的来说的话就是,如果是重新进入,会先调用_DoEnter方法,然后调用_DoExecute方法,如果_DoExecute返回正在运行(k_BRS_Executing),那么以后再进入这个节点就会直接调用_DoExectue,如果返回已经结束(k_BRS_Finish),则会调用_DoExit,以后再进入这个节点就会重新调用_DoEnter方法了。

对于控制节点来说,它的运行状态和子节点的运行状态是息息相关的,比如,选择节点的运行状态,就是它当前选择的这个节点的运行状态,并且,有时控制节点的控制逻辑也和子节点的运行状态有关,比如序列节点,当它前一个子节点运行结束,序列节点就会自动的切换到下一个子节点运行。所以在实现具体的行为类时,我们应该要正确的返回节点的运行状态。在例子程序中,我做的一个“空闲”(idle)的行为节点,就能很好的说明问题:

 1: class NOD_Idle : public BevNodeTerminal
 2: {
 3: public:
 4:     NOD_Idle(BevNode* _o_ParentNode)
 5:         :BevNodeTerminal(_o_ParentNode)
 6:     {}
 7: protected:
 8:     virtual void _DoEnter(const BevNodeInputParam& input)
 9:     {
 10:         m_WaitingTime = 0.5f;
 11:     }
 12:     virtual BevRunningStatus _DoExecute(const BevNodeInputParam& input, BevNodeOutputParam& output)
 13:     {
 14:         const BevInputData& inputData = input.GetRealDataType<BevInputData>();
 15:         BevOutputData& outputData = output.GetRealDataType<BevOutputData>();
 16:
 17:         f32 timeStep = inputData.m_TimeStep;
 18:         m_WaitingTime -= timeStep;
 19:         if(m_WaitingTime < 0)
 20:         {
 21:             outputData.m_BodyColor = D_Color(rand() % 256, rand() % 256, rand() % 256);
 22:             return k_BRS_Finish;
 23:         }
 24:         return k_BRS_Executing;
 25:     }
 26: private:
 27:     float m_WaitingTime;
 28: };

这段代码中的某些内容不明白也没有关系,我们主要关注的是关于节点运行状态的部分。这个Idle行为做了一件这样的事,就是不停的变换自己的颜色,间隔是0.5秒,当时间一到,就会返回运行结束(k_BRS_Finish),并输出当前的颜色,当时间还没到,则返回运行中(k_BRS_Executing),并且维持当前颜色。可以看到,我们用运行状态控制了计时器的重置,选择在_DoEnter方法中重置了计时器,当然,更合理的做法是在时间一到的时候,就重置计时器,并且永远返回运行中,不过这个例子里,我主要就是想用来演示运行状态,和_DoEnter的相关用法。

接下去再来看看控制节点,我一共写了5种控制节点,带优先级的选择节点(BevNodePrioritySelector),不带优先级的选择节点(BevNodeNonePrioritySelector),序列节点(BevNodeSequence),并行节点(BevNodeParallel),循环节点(BevNodeLoop),这些节点的进入条件和选择逻辑都是按照在行为树中改节点的定义来做的,我想用一张表格来说明:

测试(Evaluate) 更新(Tick)
带优先级的选择节点(BevNodePrioritySelector) 从第一个子节点开始依次遍历所有的子节点,调用其Evaluate方法,当发现存在可以运行的子节点时,记录子节点索引,停止遍历,返回True。 调用可以运行的子节点的Tick方法,用它所返回的运行状态作为自身的运行状态返回
不带优先级的选择节点(BevNodeNonePrioritySelector) 先调用上一个运行的子节点(若存在)的Evaluate方法,如果可以运行,则继续运保存该节点的索引,返回True,如果不能运行,则重新选择(同带优先级的选择节点的选择方式) 调用可以运行的子节点的Tick方法,用它所返回的运行状态作为自身的运行状态返回
序列节点(BevNodeSequence) 若是从头开始的,则调用第一个子节点的Evaluate方法,将其返回值作为自身的返回值返回。否则,调用当前运行节点的Evaluate方法,将其返回值作为自身的返回值返回。 调用可以运行的子节点的Tick方法,若返回运行结束,则将下一个子节点作为当前运行节点,若当前已是最后一个子节点,表示该序列已经运行结束,则自身返回运行结束。若子节点返回运行中,则用它所返回的运行状态作为自身的运行状态返回
并行节点(BevNodeParallel) 依次调用所有的子节点的Evaluate方法,若所有的子节点都返回True,则自身也返回True,否则,返回False 调用所有子节点的Tick方法,若并行节点是“或者”的关系,则只要有一个子节点返回运行结束,那自身就返回运行结束。若并行节点是“并且”的关系,则只有所有的子节点返回结束,自身才返回运行结束
循环节点(BevNodeLoop) 预设的循环次数到了就返回False,否则,只调用第一个子节点的Evaluate方法,用它所返回的值作为自身的值返回 只调用第一个节点的Tick方法,若返回运行结束,则看是否需要重复运行,若循环次数没到,则自身返回运行中,若循环次数已到,则返回运行结束

可能看表格内的描述会感觉有点拗口,可以结合代码一起看,会理解的更好。特别要提一点的是,在某些控制节点的Evaluate方法中,我会修改和记录可以运行的节点索引,当调用Tick的时候,就可以用这个索引来找到可以运行的节点了。这种模式和我以前提到的行为树更新模式有点不太一样,不过本质上是相同的。

(待续…)

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

最近一直在忙新项目的准备,甚少涉及AI的东西,所以博客也疏于更新。春节前,收到一个网友的邮件,说看了行为树的一些东西,但还是不知道如何去入手实现,我就乘着春节假期,动手写了一个简单的行为树的库,和大家一起边分析代码,边说说行为树的具体实现方法。这个库很简单,一共也就800行的代码左右,不过麻雀虽小,五脏俱全,行为树中的主要部分基本都有涵盖,包括前提(Precondition),选择节点(Selector),并行节点(Parallel),序列节点(Sequence)等等。在分析代码前,如果有朋友对行为树的相关概念还不是很了解,建议先阅读本站上对于行为树介绍的相关文章。

这次的代码以及示例程序,还是基于我自己维护的一个框架TsiU,在系列文章的最后会给出下载链接。

行为树,由名字就可以看到,它是一个树结构,通过各个节点相互连接,所以我先定义了节点的基类:

 1: class BevNode{}

要把树链接起来,需要在这个类中保留父节点指针,和子节点指针,我用了一个固定的数组来保存子节点指针,它的大小是16,也就是说,一个节点最多可以有16个子节点

 1: class BevNode
 2: {
 3: protected:
 4:     BevNode*    mao_ChildNodeList[k_BLimited_MaxChildNodeCnt];
 5:     ...
 6:     BevNode*    mo_ParentNode;
 7: }

有了这些变量的定义,我们就可以串联起一颗树了。到目前为止,这个节点类还仅仅是一个树的节点,作为行为树的节点还差了些东西,在以前的介绍中,我们知道行为树的每一个节点都可以绑定一个称为前提(Precondition)的部分,用来作为是否进入这个节点的条件,在我的实现中,我把这个前提拆分成了两个部分,一个称为“内在前提”,一个称为“外在前提”。“内在前提”是和节点类静态绑定的(也就是说,这个节点的固有前提),而“外在前提”是可以和节点做动态绑定的。这样做的原因是,由于在行为树上,节点是可以被复用的,在不同的子树上他的进入条件往往是不同的。比如,“移动”,这是一个常见的行为节点,逃跑的时候,可能需要“移动”,追击的时候也需要“移动”,但进入这个节点需要不同的“外在前提”,所以这里就需要让节点支持动态绑定的前提。“内在前提”,我用继承的方式来实现,而“外在前提”,我用了另一个类来实现

 1: class BevNode
 2: {
 3: public:
 4:     bool Evaluate(const BevNodeInputParam& input)
 5:     {
 6:         return (mo_NodePrecondition == NULL || mo_NodePrecondition->ExternalCondition(input)) && _DoEvaluate(input);
 7:     }
 8: protected:
 9:     virtual bool _DoEvaluate(const BevNodeInputParam& input)
 10:     {
 11:         return true;
 12:     }
 13: protected:
 14:     BevNodePrecondition* mo_NodePrecondition;
 15: }

可以看到这里用到了一个叫做BevNodePrecondition的类,用来表示“外在前提”,他是一个纯虚函数,只有一个方法,先看一下它的定义,后面会有详细的讨论。

 1: class BevNodePrecondition
 2: {
 3: public:
 4:     virtual bool ExternalCondition(const BevNodeInputParam& input) const = 0;
 5: };

_DoEvaluate虚方法就是需要被子类继承并实现的“内在前提”,这两种前提在Evaluate方法中被结合了起来,用来检测进入条件,当返回True时,就表示当前节点可以被运行。返回False时,就表示当前节点进入条件不满足,不能被运行。

在节点基类的中,还有两个重要的方法是:

 1: class BevNode
 2: {
 3: public:
 4:     void Transition(const BevNodeInputParam& input)
 5:     {
 6:         _DoTransition(input);
 7:     }
 8:     BevRunningStatus Tick(const BevNodeInputParam& input, BevNodeOutputParam& output)
 9:     {
 10:         return _DoTick(input, output);
 11:     }
 12: }

转移(Transition)的概念是第一次出现,转移(Transition)指从上一个可运行的节点切换到另一个节点的行为。这个方法会被在节点切换的时候调用,比如,在一个带优先级的选择节点下有节点A,和节点B,节点A的优先级高于节点B,当前运行的节点是B,然后发现节点A可以运行了,但带优先级的选择节点就会选择去运行节点A,这时就会调用节点B的Transition方法,所以在这个方法中,一般可以用来做一些清理的工作。Tick方法就是通常的更新方法,就不多说了。

再来看一下这三个重要方法的参数,一共有两种类型的参数,BevNodeInputParam和BevNodeOutputParam,前者是传入参数,可以认为是行为树的输入,用const作为限定符,表示只读,后者是传出参数,可以认为是行为树的输出,可以修改。其实,从代码中可以看到,这两种类型的本质都是一样的,都是一个名为AnyData的类

 1: typedef AnyData BevNodeInputParam;
 2: typedef AnyData BevNodeOutputParam;

由于输入和输出参数是游戏相关的,所以这里用AnyData这个类来表示,这个类可以存放任意的数据结构,所以,这个类中真正的内容是需要玩家自己定义的。

最后来看看行为树是如何被定义和更新的(可以在示例程序中找到相关代码)

 1: //define input & output data
 2: struct BevInputData{...}
 3: struct BevOutputData{...}
 4: BevInputData    m_BevTreeInputData;
 5: BevOutputData   m_BevTreeOutputdata;
 6: ....
 7: //create tree
 8: m_BevTreeRoot = CreateTree();
 9: ...
 10: //update
 11: BevNodeInputParam input(&m_BevTreeInputData);
 12: BevNodeOutputParam output(&m_BevTreeOutputdata);
 13: if(m_BevTreeRoot->Evaluate(input))
 14: {
 15:     m_BevTreeRoot->Tick(input, output);
 16: }
  1. 定义自己的输入和输出参数(BevInputData,BevOutputData)
  2. 创建行为树,保存根节点指针(m_BevTreeRoot)
  3. 测试是否有可以运行的节点,如有则更新

(待续…)

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

自开博以来,经常会有网友发信给我,询问一些关于AI方面的问题,一般我都会尽力一一回答,也希望我的这些经验,对网友有些帮助,我想,有些问题可能是大家都会有的,所以,这一次,我会把一些网友的提问,和我的回答列在这个地方,供更多的同学参考,也欢迎一起讨论,才疏学浅,不吝赐教。

Q:你好,我在你的博客上看了你的一些关于行为树的文章,写得很好,让我受益匪浅。我想实践一下怎么用,在网上找了一个相关的库libbehavior(https://code.google.com/p/libbehavior/),想用这个库做个小例子,它里面有一些演示程序,但牵涉到很多库,不好学习,我就想将一个简单的状态机示例程序源码改成用行为树实现,发现还是很茫然,不知道你是否有空给些提示,谢谢!

A:我下载了程序简单看了下,libbehavior好像已经实现了一个可用的behavior tree了,虽然可能还不够完善,但基本的行为树的样子已经有了,你可以考虑基于他这个,然后根据自己的需求添加。 不知道你有没有看到它里面的sample,我从Scenario3.h里摘录了一段建行为树的例子来说明下,我还画了一张示意图作为参考

 1: //opponent->brain是一个ParallelNode,并行的节点
 2: //在并行节点上添加第一个子节点,行为是转向目标TurnTowardsTarget
 3: opponent->brain->addChild(new TurnTowardsTarget(1));
 4: //建立一个随机节点作为第二个子节点,下面三个序列节点应该会随机选一个执行
 5: ProbabilityNode* pNode = new ProbabilityNode();
 6: //在这个随机节点上,建立一个序列节点
 7: pNode->addChild((new SequentialNode())
 8:         //在序列节点上建立第一个子节点,感觉这个应该是个判断条件
 9:          ->addChild(new BoolCondition<GameObject>(&GameObject::alignedWithPlayer,true))
 10:         //在序列节点上建立第二个子节点,行为是射击Fire
 11:          ->addChild(new Fire())
 12:         //在序列节点上建立第二个子节点,行为是等待CD,防止太频繁
 13:          ->addChild(new Cooldown(500)),5.0);
 14: //在这个随机节点上,再建立一个序列节点,下面的子节点就不说明了
 15: pNode->addChild((new SequentialNode())
 16:          ->addChild(new FloatCondition<GameObject>(&Ship::getXPosition,LESS_THAN_FP,200,1))
 17:          ->addChild(new GotoPoint(point(300,50),500))
 18:          ->addChild(new Cooldown(200)))
 19: //在这个随机节点上,再建立一个序列节点,下面的子节点就不说明了
 20: pNode->addChild((new SequentialNode())
 21:          ->addChild(new FloatCondition<GameObject>(&Ship::getXPosition,GREATER_OR_CLOSE,200,1))
 22:          ->addChild(new GotoPoint(point(100,50),500))
 23:          ->addChild(new Cooldown(200)));
 24: //在这个随机节点的上面做一个循环节点,把上面的行为无限循环
 25: opponent->brain
 26:     ->addChild((new RepeatNode(-1))
 27:         ->addChild(pNode));

Scenario3BevTree

 

所以如果你要把状态机改成行为树的话,可以考虑用我说过的Selector节点+Precondition的方式(libbehavior里好像是叫PriorityNode,但它好像没有Preconditon的概念),因为状态机里不是要跳转嘛,你原先每一个state都会有进入条件的,比如下面这个

 1: if (pMiner->PocketsFull())
 2: {
 3:     pMiner->ChangeState(VisitBankAndDepositGold::Instance());
 4: }

这样的话,你可以把pMiner->PocketsFull()作为VisitBankAndDepositGold的Precondition挂在一个Selector节点下面,然后让Selector节点帮助你选择应该进入那个状态。我建议,你可以先看看libbehavior每一个Scenario的行为树创建部分的代码,这样应该会有点感觉,另外,可以边参考我网上的文章,边来看libbehavior\BehaviorTree-src里的源码,会好理解很多 :) ,希望对你有所帮助!欢迎继续来信交流!(状态机和行为树之间转换可以参看这里这里

Q:看了你写的一系列文章,写的真不错。我也最进在游戏行业,做AI相关的工作。涉及一些游戏AI设计的问题,能否指教一下。目前我们游戏AI设计的部分考虑NPC的AI部分可配置成AI执行脚本文件方式。我考虑是可以设计AI框架,依据游戏对象的配置,框架决定加载相应的脚本引擎。我看 《浅谈层次化的AI架构》 不错,你能不能分享一下AI设计中如何分离AI框架和AI脚本内容的呢。分离的话,感觉状态转换是应该在AI脚本中完成的吧? 暴露AI引擎的API接口的通用设计一般是怎么样的呢?学习中...., 谢谢。

A:如果想在脚本中写很多AI逻辑的话,如何调试是个比较麻烦的事情,需要比较好的调试接口。我用过一种做法,你可以试试看,就是用脚本做AI的配置,逻辑还是在C++端。C++提供一个个AI逻辑模块,然后在脚本端把它配置起来,因为我用的是行为树(可以参看我博客上关于行为树的相关文章),所以比较容易配置。接口的话,首先最好是引擎能支持反射,然后就能方便的导出函数和变量直接给脚本用,至少我用lua做脚本语言,这样是可行的。

Q:您好。今天再搜索层次状态机相关资料的时候进入了您的博客。本人只在游戏中使用过简单的FSM,属于AI初学者,阅读了几篇文章后产生了几个问题:

  • HFSM和行为树到底是什么关系。我对行为树和状态机的区别还是把握不了……
  • 您有一文章说:大部分的AI,都可以分成“决策”和“行为”,二者通过一个双缓冲的“请求层”通信,但是这篇文章您没有举例子,我非常模糊。
  • 对“并行节点”我还是很模糊……您可以给我举个例子吗……麻烦了…。

A:谢谢,针对每一个问题,我的回答如下:

  • HFSM和行为树其实没什么关系,是两种不同的AI结构,区别的话,可以看我在这里的评论http://www.aisharing.com/archives/90#comment-111
  • 有人和你提出了一样的问题,可以参看我这里的回复,http://www.aisharing.com/archives/86#comment-115
  • 比如一个并行节点下有A和B两个节点,那在一次循环里,如果前提满足的话,既会执行A,又会执行B,举个实际的例子,比如要描述“又吃又喝”,就可以用并行节点:)

Q:最近在学习行为树,有幸拜读了您几篇关于行为树的文章,感觉收获颇多。刚开始接触行为树,所知有限,特来请教几个问题:

  • 关于以下代码,在文章评论中,您回复说 input 是传入行为树的参数,那么 request 是指输入的事件吗?比如移动事件、死亡事件等。 如果是事件,那您实例代码的行为树是事件驱动的,还是Tick驱动?
 1: action = root.FindNextAction(input);
 2: if action is not empty then
 3:     action.Execute(request,  input)  //request是输出的请求
 4: else
 5:     print “no action is available”
  • 在FSM中,状态进入和退出时可以做相对应的事情,比如进入某个状态身上挂个循环特效,退出该状态将这个特效移除,在行为树上,怎么实现同样的功能?
  • 按我现在对行为树的理解,感觉行为树就是将FSM中的状态变成了子树,挂在行为树的相应位置,通过行为树的节点完成状态的迁移。但是,在您文章的总结中提到:"在AI设计过程中,一般来说,我们并不是先有状态机,再去转化成行为树的,当我们选择用行为树的时候,我们就要充分的理解控制节点,前提,节点等概念,并试着用行为树的逻辑方式去思考和设计。",看后对自己将状态视为子树的理解产生了很大的怀疑,您能详细地解释下这句话吗?不知是否可以提供一张您平时设计的较复杂些的行为树供我学习之用?

A:谢谢,针对每一个问题,我的回答如下:

  • request是输出的请求,也就是行为树的输出,就像你说的,像移动,死亡等,可以看看我博客上关于分层次的AI架构的相关文章,整个行为树的决策还是在Tick中更新的。
  • 一般我在做行为树的Execute时会分成三个阶段,onEnter() ,onExecute(),onExit(),在第一次进入这个节点的时候,会调用onEnter,在从这个节点切换出去的时候,会调用onExit()。每个行为树的节点,我都会做成这种1P(前提Precondition) + 3E(Enter, Execute, Exit)的结构
  • 行为树的思考方式是指,对于每一个节点我都需要明确知道它的进入条件是什么,它要做的事情是什么。把跳转逻辑写到控制节点中,用控制节点来控制行为树的选择流程。和状态机不同的是,对于行为树的节点,一般不需要知道它是从哪个节点过来的,它只是需要关心,满足什么条件(前提)可以进入这个节点,这些条件的判断依据都是通过行为树的输入参数传入的,所以行为树和黑板的组合是很好用的,可以看看我博客上关于黑板和行为树结合的文章
  • 项目里的行为树图不太好给,呵呵,而且需要解释才能明白。复杂的话,一般就会用到序列节点,并行节点等等。行为树的好处就是只要定义了进入前提,就可以加入到现有的行为树中,这样就越来越复杂了~,不过,这也就是行为树的好处了 :)

Q:Hi,在我们现在的项目里面涉及到AI的一些东西,之前学习了关于你写得行为树,正好可以发挥一下,现在有一个问题,就是关于怪物行走,怪物行走我们这边的做法就是发目标A*路径给客户端通知其他玩家,然后,怪物每秒刷一步就走一步...这样的做法...我想问问...你们那边关于怪物行走是如何实现的呢?巡逻和追击 两个地方行为怎么做互斥?

A:针对你的问题,我在想哦,对于怪物移动的话,可以分两个部分,一般的移动,就是你说的巡逻,可以不需要用A*,因为毕竟A*的开销是比较大的,因为怪物一般的移动不会太复杂,而且一般是在一定的区域内移动(当然这是看你们怎么设计了),可以在区域内设置几个路点,然后每过一段时间选择临近的路点就可以了,也就是仅仅做一步的“寻路”。如果是要追击玩家的话,可以尝试用到A*。

网页游戏我没做过,其他游戏里,我们用到过同步位置信息的方式,也就是每隔一段时间同步一次位置,然后两次同步之间的移动,都是客户端本地模拟。如果模拟后的位置和服务器上的位置有偏差,简单的做法是瞬移到服务器上的位置,好一点的话就是平滑的从客户端现在的位置移动到服务器上的位置。

巡逻和追击我觉得比较大的不同是,巡逻中是没有目标敌人的,而追击是存在目标敌人的,所以这样就可以做到互斥了。

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

(ecto真不好用,居然写得东西都没了,害我只能把这篇文章再写了一遍,郁闷啊,跪求一个mac下的支持wordpress的离线博客编辑软件)

提到参数,在我们写程序的时候,作为一个常用的提醒是,一般都会避免去使用“魔数”(Magic Number),因为它含义不清,比如if(i>5){...},如果没有任何注释的话,很难有人会理解这个“5”是什么意思。在AI中,这样静态的参数的使用更频繁,有时为了定义一些阈值,有时为了可以给游戏设计人员调整AI行为等等,当这些参数慢慢多起来的时候,就需要更好的来管理,修改和查看,所以,如何设计一个好的参数系统就很有必要了。这个想法也源自我上一个项目的一些教训,在上个项目中,我们最后一共定义了超过1600个参数,由于历史原因,由一个简单的参数系统来维护,但实际操作中,这样的简单系统存在很多问题,首先参数没有很好的文档化,导致游戏设计人员很难搞清楚这个参数到底是干什么的,最后需要程序员的不停参与,工作的粘合度就提高了,另外也没有做到逻辑的关联,无法了解这些参数之间的相互关系。总之,这样那样的问题促成了我对于设计一个新的参数系统的想法。

相较与原来那个参数系统的种种问题,对于一个新的参数系统,我希望它能有如下的特性:

  • 仅支持静态的参数:对于游戏来说是只读的,也就是游戏在运行时不允许修改这些参数,但可以通过外部工具进行调试
  • 支持文档化
  • 支持实时的修改:不需要重新启动游戏
  • 支持序列化到文件:可以将调整好的参数存成文件,以便下一次启动游戏时生效
  • 支持逻辑关联
  • 支持自动的合法性检查
  • 高效和便捷的定义和调用

以上列出的大部分特性还是比较容易理解的,对于第5点,我特别的说明一下,假设有一个参数A,当A的值发生变化的时候,逻辑上,参数(B1, B2, B3, … , Bn)也会相应发生变化,那我们就称A和(B1, B2, B3, … , Bn)有“逻辑关联”,也就是A的变化会影响到(B1, B2, B3, … , Bn)的值,反之不成立,也就是说,逻辑关联是单向的。举个例子,比如我们有一个可以调整游戏难度的参数,它关联到敌人的血量,攻击值,防御值,当把游戏难度调成“高”时,血量,攻击值,防御值会加倍,当把游戏的难度调成“低”时,血量,攻击值,防御值会减半。在我们原来的参数系统中,这种逻辑关联是隐含的,也就是说所有的参数并没有直接的相互关联,他们的结构是“平面化”的,彼此相互独立,所以当游戏设计人员想调高游戏难度的时候,他就需要记得相应的要去调整血量,攻击值,防御值。这样很容易造成错误和疏漏,而且这种隐含的逻辑关联无处可循,只能存与程序员活或者游戏设计人员的脑海中。

针对我们的需求,我定义了一个参数P所需要包含的各个部分

  • 可识别的名称
  • 当前值:该参数当前的值(一定是合法值),供游戏读取
  • 参数类型:表示这个参数是离散型的,还是连续型的
  • 上下限数组:给连续型参数检查合法性使用,如果值不在取值区间中,则为不合法的参数值,用数组的原因,主要是可能有多个取值区间,[Vmin1, Vmax1], [Vmin2, Vmax2], … , [Vminn, Vmaxn]
  • 候选值数组:给离散型参数检查合法性使用,如果值不在候选值数组中,则为不合法的参数值。
  • 参数描述:对于该参数的简要描述
  • 趋势描述:对于该参数的变化所带来的影响的简要描述
  • 逻辑关联项数组:该参数所影响的参数项列表(P1, P2, … , Pn)
  • 被逻辑关联项数组:能影响该参数的参数项列表(P1, P2, … , Pn)

作为补充说明,对于上下限的定义,还需要有一个标志来表示是开区间还是闭区间。在逻辑关联项的数组中,和每个参数项还需要绑定一个非常重要的“关联方式”的实现。关联方式是非常灵活的,可能非常简单,就像我们前面举的那个例子,仅仅是加倍和减半,但有时可能非常复杂,有更多的逻辑计算,所以,我想可以调用一段代码来计算关联方式,可以是虚函数,也可以是一段脚本。更新关联项的方式是递归的,直到所有的关联项都被更新为止。文档化有几个部分组成,一个是名称,要求要可以识别,一个是两段描述,这两个部分很难做到要写成什么格式(文档的东西一般都是自觉自愿),但作为规范是要求定义参数的人能详细填写的,另一个部分就是关联项和合法性提示,这个可以用代码来生成文档信息(试想一下开发环境中的代码提示功能)。

我想,有了这样一个参数结构的定义,要实现一个参数系统来满足我上面的要求,并不是一件很难的事情,当然现在的这些只是我一个粗略的想法,还没有实现,有兴趣的同学可以实现一个,我有时间的话,也会做一个出来,因为它很独立,可以作为第三方库用在任何的引擎中,实现技巧的话,我想用“反射”(Reflection)做的话,对于序列化,对于做一个可视化的编辑器,对于文档化,都是挺有帮助的,值得一试。

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

记得以前我在博客中,提到过一种层次化的AI架构,这种架构的核心就是定义了“请求层”的概念,用来分隔决策和行为,并通过行为请求来清晰的定义了决策和行为之间的输入输出关系,不过,当我们仔细审视这个结构的时候,发现其中貌似缺失了对于某种情况的处理,这就是我今天要谈到,如何处理“被动式的行为请求”

一般来说,我们通常所认为的决策是一种“主动式(Active)的行为请求”,比如,我按了个键,玩家所控制的角色就是做出某些行为,或者AI通过对于当前情况的判断,做出了下一步的行为决策,所以说,主动式的行为请求,就是表达了一种“我想要去做什么”的语义。在游戏中,大部分的行为都是输入主动式的行为,那FPS(第一人称射击)类游戏来说,它可能包含的主动式行为有,移动,跳跃,射击,与游戏场景中物体的交互(拾取物品,开门)等等,当玩家和NPC在行动的时候,就是在这些已经定义的主动式行为中,做出决策。在我们以前提到的那种层次化的AI架构中,可以很好的满足这些需求。

但在游戏中,还是一些行为并不在主动式的行为中,比如死亡,当角色生命值等于0的时候,游戏系统就会触发这个行为请求,它不是由玩家或者NPC所发起的,而是有游戏系统判断出某些条件满足的时候,自动触发并赋予角色的行为请求。这一类的请求,我们就可以称之为“被动式(Passive)的行为请求”,在谈如何处理前,我们可以先看看这种请求有什么样的特点。

首先它可能会覆盖原本的主动式的行为请求。比如玩家想要快跑向前走,那他就会发出一个“快速移动”的行为请求,但就在这个时候,角色正好被横在路中间的一根树干绊了一下,当游戏系统检测到这个事件的时候,就会触发一个“绊倒”的行为请求,期望做一个摔倒的行为。显然,“绊倒”这个行为不是玩家主动做出的,是属于被动式的请求,而且这个请求应该被立刻响应(要不就不真实了),所以,当比较这两个请求的时候,我们就会用“绊倒”覆盖掉原先的“快速移动”,将其作为当前请求传给行为层。那我这里为什么说是“可能会覆盖”呢?因为并不是所有的被动请求都需要被响应的,这取决于当前产生的主动和被动,这两个请求的优先级设定,这种优先级设定一般来说会来自与游戏设计的需要。假设,我们在游戏中有一个行为是“超级移动”,这种行为很强大,效果也很炫,是游戏的一个很大的卖点和可玩点,所以游戏设计者就希望尽量能让玩家触发出这种行为,以提到游戏的游戏性和画面效果,所以当玩家触发“超级移动”的时候,我们可能就会忽略掉同时触发的“绊倒”这个行为请求。

其次,它是触发式的,所以意味着同一时刻它可能会有多个。试想一下,对于主动式请求来说,因为角色不可能分身嘛(除非是超人游戏),所以一般来说,同一时刻主动请求只会有一个,也就说,你不可能既想做这个,又想做那个。而被动式请求,就完全不同,在同一时刻,可能会有多个条件被满足,然后同时触发了多个被动行为请求,比如在被绊倒的那一刻,你中了一枪,然后生命值正好减到0(真倒霉。。),所以同时就产生了“绊倒”,“死亡”两个被动请求。那我们就面对,多个请求需要权衡的情况,在上述的例子里,显然,“死亡”的优先级应该是最高的,所以它会成为最后的请求被送到行为层。

还有一个特点就是关于被动请求产生源,在角色与角色交互的时候会非常容易产生被动式的行为请求。举个FIFA的例子(对此我研究了很多 :) ),在足球游戏中,我们可以看到很多球员间交互的例子,比如碰撞后的摔倒,争头球的挤位,带球过程中的拉扯,躲避铲球的跳跃,等等,这些都是被动式的行为请求,是玩家不可操控的,而且这些请求,不像我前面举到的例子,是单个人身上的,而是属于一种“相互作用(Interaction)”的行为请求。它的触发和处理,都会牵涉到两个,甚至多个人的判断。

谈完了特点,接下去来思考下如何来处理这些请求。当然,处理的方案多种多样,我先抛砖引玉的谈谈我想到的两种结构。还是基于原有的层次化的AI架构模型,做一些小的修改。第一种方案采用的是比较直观的集中化的处理。先定义一个结构表示当前时刻所有产生的行为请求。

 1: struct CandidateRequests
 2: {
 3:     Request        m_ActiveRequest;      //主动请求,假设同一时刻只有一个 
 4:     Array<Request> m_PassiveRequests;    //被动请求,同一时刻可以有多个
 5: }

然后,我们定义一个模块来负责处理这个结构,并产生一个最终的行为请求,值得注意的是,我们把当前正在处理的请求也作为考虑的因素之一,传入这个函数中。

 1: class RequestFinalizedModule
 2: {
 3: public:
 4:     static Request GetFinalRequest(
 5:                 const Entity& entity,
 6:                 const Request& curReq,
 7:                 const CandidateRequests& nextReq)
 8:     {
 9:         //添加选择的规则,根据当前请求和下一时刻的候选请求,抉择出最终应该做的行为
 10:     }
 11: }

集中式的处理方式将所以处理的可能性放到一个函数中,这样的好处是便于排查可能导致的选择不正确的问题,而且对于原有的结构没有什么更改,仅仅是在决策和请求层之间加了一个处理模块。缺点就是这块的代码,随着优先级关系的复杂,会显的相对比较乱,不过将脏代码堆在一个地方,也是一种不错的设计 :) ,下面是修改后的架构图

layered-ai-architecture_modified

另一种方案是让游戏系统不直接触发被动式请求,而是触发一些标记,然后AI在决策的时候将这些标记作为决策参考信息的一部分,最终做出一个合理的行为决策,这种方案延伸了决策部分的定义,使其即能产生主动式请求,也能产生被动式请求。如果用行为树的话,可以非常好的表示出这种优先级的关系,如下图:

bt-flag-passive-action

这种方式的优点是可以用到行为树本身对于优先级处理的优势,而不需要额外的添加模块,只需修改原本行为树的设计即可,作为游戏信息的一部分,也可以沿用原有的收集游戏信息的相关模块,做适当的扩展就可以了。缺点是,由于我们将原有的触发式的模式,变成了轮询式的,所以,可能会降低行为树的些许效率,而且对于行为树的设计也提出了更高的要求。不过从架构上来看,是比较清晰,并且能很好的融入原有的设计。修改的架构图如下:

layered-ai-architecture_modified_2

好,就聊到这里了,不知道大家有什么想法,欢迎一起留言讨论。

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

football city stars online logo

官方网站由此进入

《全民足球》是育碧开发的首款休闲类网络游戏。游戏以街头足球为主题,基于城市街区的真实地图概念,支持8名玩家进行同场竞技。游戏拥有独一无二的角色扮演加足球竞技的游戏模式,玩家可创建各具特点的角色球员,通过比赛和任务,提升其属性技能和天赋,获取奖励和成就。玩家并可基于所在城市街区创建足球俱乐部,和同城玩家一起创造辉煌。

拥有高品质全物理运算的核心游戏性,支持2对2, 3对3和4对4玩家间比赛,3大游戏模式: 单人训练, 自由对战和组队对战,守门员为AI。

玩家可以按自己喜好创建球员角色,自定义项包括性别、场上位置、惯用脚、身高、肤色、脸型、发型和着装等。

玩家也可以在游戏内商店购买其他服装和形象来装扮角色。

独一无二的角色扮演+足球游戏模式,3个球员职业: 前锋, 中场和后卫,6大球员属性: 力量, 速度, 传球, 射门, 带球和防守,每个只有拥有各具特色的技能和天赋。

所有的角色系统均可按玩家喜好升级。

团队配合可以增加士气能量。当士气槽积满后, 会触发团队高潮时刻,在团队高潮时刻, 球员属性会大幅提升, 部分技能也会触发更强大更炫酷的动作。

任务系统有一系列目标任务让玩家在了解游戏的同时获得经验和游戏币。主线剧情任务帮助玩家从玩家从普通球员成为一个球星。

真实地图系统在游戏中内建了和真实世界一致的地图,让玩家更容易和同城同区的其他玩家进行游戏。

玩家从自己所在的街区开始游戏, 通过角色升级逐步探索所在城市乃至世界。

其他社交功能包括: 好友系统, 聊天, 邮箱, 足球俱乐部系统等。

玩家可以在游戏内商店购买服装, 道具, 技能等物品,在购买服装前可以进行试穿。玩家同样可以把商品作为礼物赠送给其他玩家。

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

最近在做项目前期的一些调研的工作,研究并参考了几个引擎和框架的设计,包括内部引擎,商业引擎,和开源引擎,通过比较和学习后,觉得对于游戏中的实体实现,用“组合”的设计模式会比用“继承”的更为便利,想到我早些时候的一个项目里用到的一个引擎,也是实现了组合式的实体,而且对于AI程序员来说也是和“游戏实体”打交道最多的,一个好的设计可以大大的提高代码的质量和可维护性。所以,我觉得很有必要在这里记录一下,也和大家一起分享一下我的一些心得。

在学习面向对象编程的时候,一个很重要的概念就是“万物皆对象”,我们可以把现实世界的物体抽象成一个个的Object,并且通过继承的方式实现多样化的对象集。这个是面向对象编程的一些很重要的概念。我想学过的同学都应该对此非常的熟悉。

游戏中也是一样的,一般游戏内都会抽象出一个称之为“游戏实体”(Entity)的类,当然也有直接叫Object的,不管名字怎么样,概念上是相通的,总之都是对于游戏中可能的任何物体一种提炼。在引擎中用继承的方式扩展Entity是一种比较常见和直观的方式,可以充分利用到面向对象编程中的多态的优势,虽然可能大家都比较熟悉这种方式,但我想还是用一个比较简单的例子来帮助理解,假设我们仅仅做三个类层次的话,可以从Entity这个基类中先继承出,静态实体(Static Entity)和动态实体(Dynamic Entity),静态实体表示没有运动信息的实体,比如一些静态的建筑,地图的网格等等,动态实体表示带运动信息的实体,比如人物,机械,怪物等等,这个作为第二个层次,最下面的一个层次可以是具体的物件,就像我上面所具的那些例子。

继承的好处是直观,而且分工合作也不错,大家的协作可以互不干扰,但“继承式”的实体结构有一个问题,我想用过此类模式的同学应该也有所体会,就是随着开发的进行,基类的“体积”会日益庞大,因为当我们发现子类间有一些共通的地方时,我们可以选择的办法之一,就是把这个共通的地方写到他们共有的基类里面去,我们用上面的例子举例的话,当“人物”和“怪物”有一些属性是共通的时候,我们就不得不把这些代码移到“动态实体”这个类中,当然这还不是最糟,当“人物”和“建筑”有共通的东西时,我们就只能把代码写到最高层的“Entity”中了,而且这些共通的代码可能“怪物”这个类并不需要,但因为它也继承自Entity,所以也“被迫”的包含了这些代码。所以基类代码的可维护性就变的很差,虽然,好的继承关系的设计可以一定程度上缓解这样的问题,但并不能从根本上解决。

为了解决这样的问题,所以,现在很多的引擎架构里,都提出了“组合式”实体的概念,“组合式”的概念就类似与小孩搭积木,通过用不同部件来“组合”出不同的物体。

比如,对于“静态的建筑”这个实体来说,它可以由“空间属性模块”,“显示模块”组成,“空间属性模块”包括了位置,朝向等信息,“显示模块”包括了这个建筑的模型,贴图等和渲染相关的信息。那对于“人物”这个实体来说,他的模块就更多了,除了“空间属性模块”,“显示模块”,还会包括,“人物AI模块”,“动画模块”,“物理模块”等等。对于“怪物”来说,它可能包含了和“人物”差不多的信息,但我们会给它一个“怪物AI的模块,而不是“人物AI模块”。但这时,也许游戏设计师需要一个“高级的人形怪物”,它更聪明,需要有像人一样的AI,如果按照原来“继承式”的设计方式,我们可能需要从“怪物”这个类里继承,然后再从“人物”这个类里拷贝AI部分的代码,或者将这部分代码移到“动态实体”类中,以便于在两个类中共用,但在“组合式”的设计中,我们要做的,仅仅是将那些模块重新组合,用“人物的AI模块”来替代原来的“怪物AI模块”,这样我们就得到了一个全新的实体。

这就是“组合式”实体概念,这些用来“组合”的元件,称之为“组件(Component)”,或者“轨道(Track)”,这些组件是可继承的,所以从本质上来说,它将基于实体的继承,转移到了“组件”的继承上,将实体和属性分开,达到了简化实体类的作用。明白了它的概念,那从实现上来说就更简单了,我们可以在实体类中,包含一个存有组件基类指针的数组,然后在构建实体的时候,将相关的组件添加进来就可以了。然后在更新实体的时候,将所有在这个实体上的组件一并更新即可,代码我就不在这里写了。

“组件式”实体的概念和实现并不是很复杂,但效果却是惊人的,它用一个简单的设计模式,使得代码更易维护,而且,不同的程序员可以更合理的分工实现各自的组件部分。像AI程序员,就可以专注与对于AI组件的开发了,比如,可以做一系列的AI组件,对应不同的难度的AI,只要将这些组件和实体相连,就可以实现虽然怪物的外观相同(因为“显示组件”是一样的),但AI截然不同的效果了。对于3D程序员也是这样,比如,我可以先做一个真实效果的渲染组件,但可能我还想试试卡通渲染效果,那只要再实现一个这样的组件,将原来那个替换掉就可以了。

当然,“组合式”实体的开发中,也有些问题值得大家思考,最重要的一个问题就是,“如何在组件间传递数据?",最实际的一个例子就是,“空间组件”中存有实体的位置信息,那其他的组件开发者肯定是需要得到这些信息的,所以在组件间传递数据的功能是必不可少的,我不推荐直接开放诸如GetComponent这种直接得到组件指针的接口,那如何安全而有效的传递数据呢,这个仁者见仁,智者见智问题就留给大家讨论了。

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

第一部分

上次我们说到,游戏中的运动系统一般有两种方式,“动画配合运动”以及“运动配合动画”。对于第一种方式,由于是采用运动函数或者经验数据表,所以可以很简单的将“未来时间”带入其中,来预测未来某一时刻的运动结果。但对于第二种情况,因为所有的运动结果都是从动画中取得的,如果不知道动画信息,就无法知道相应的运动结果,所以,简单的将时间带入是不能作出预测的,对于这样的情况,如果我们要做预测的话,就不得不将动画的因素考虑在里面。

举个简单的例子,一个人做一个跑动中转身停下的行为,假设他当前时刻T1,处于A1位置,速度是V1,朝向是F1(假设和速度方向一致),他的目标状态是速度是0(没有速度),朝向是F2,位置不指定。这时当我们给出未来时间T2,应该怎样来做这个运动预测呢?

PredictableLocomotionEx

和第一种运动系统不同的是,这里我们需要一个额外的模块,称之为“动画选择模块”。在我们实际要去完成这个运动目标的时候,我们会用“动画选择模块”去选出每一步的动画,比如最直观就是“直线跑”,然后会产生一个“急停”使速度从V1到0,最后是“原地转身”使朝向从F1到F2,当这些动画做完后,我们就可以得到这个人最终的“目标位置”。由于是运动配合动画的,所以对于他的最终位置,我们完全是靠这些动画实际的运行结果而得到的。

PredictableLocomotionAnimseq

当我们要做预测的时候,显然我们也是需要用“动画选择模块”做预先的动画预测的,所以,在设计可预测的运动系统的时候,就需要将“动画选择模块”独立出来。如下图所示:

PredictableLocomotionSystem

动画选择模块的输入是“当前状态”和“目标状态”,输出是一个动画的序列,这就有点像以前我们讨论过的“计划器”(Planner),相当于为我们的运动做了一个计划(对于这样的计划器的实现,就不在这次的讨论中了)。有了这样的计划后,当我们将时间T2带入,就可以通过读取动画信息的方式来获得T2时刻的运动信息了,见下图,AT指动画的时间(Animation Duration)

PredictableLocomotionTimeSeq

对于给定的“当前状态”和“目标状态”,都会有一组动画序列与之对应,所以如果是基于目前的运动状态所作的预测,那我们可以重用我们已有的动画序列来提高效率。如果是对于假想的运动状态的预测,那我们就需要用“动画选择模块”重新做一个新的动画序列,然后再得出未来时间的运动状态,这也是为什么我们需要独立的“动画选择模块”的原因,但如果频繁做较长步骤的假想预测,可能会产生一些性能上的问题,这是需要注意的地方,当然,这也取决于“动画选择模块”实现的复杂度和动画的丰富程度,比如在上个例子中,如果我们有一个“急停转身”的动画,那我们就可以减少动画序列的个数,也就是减少了计划的步长了。

另外,在实践中,我们会采用两种运动方式混用的情况,比如对于“直线跑”,我们会用运动函数来实现,而对于“转身”,“急停”这样的行为,我们会采用第二种方式来实现,对于这样的运动系统,也可以用到这样的“计划器”,只是这个计划中的某一步换用函数方式罢了,对于上面例子,我们可以参考下图:

PredictableLocomotionTimeSeq2

除了预测未来时间的运动状态外,可能我们还会预测到达某一个状态所要用到的时间,当我们有了上面的系统后,这也会非常容易做到,比如上面的例子,如果我们要预测他到“目标状态”需要多少时间,那我们只需要把动画序列中每一个动画的时间求和就可以了,T = T1 + AT(Run) + AT(Scram) + AT(Turn)。

可预测的运动系统对于某些游戏可能是一个非常重要的系统,希望上面的讨论对大家有所帮助。

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More

前端时间举家出游了一次,加上国庆期间一直跑东跑西,博客的更新就一直没跟上,距上一篇文章也是好久了呢,有时感觉一个人维护也有点小累,所以如果大家有好的想法,好的分享,也可以投稿给我,我想能有这样一个分享的平台,让志同道合的朋友一起讨论学习,也算是为中国游戏技术的发展贡献点绵薄之力,虽然我是付不起稿费的,但看着文章的浏览数和回复也是挺有成就感的事情,对吧 :)

好,言归正传,这次想大家来讨论讨论如何来做一个“可预测的运动系统”。首先来说说什么是游戏里的运动系统。

对于游戏里某一个智能体(就是带AI的物体),当AI决策结束,并且告诉行为层要干什么,行为层就会让它“动”起来,动的过程中可能是没有动画的,比如一辆车会前进,会后退,也可能是有动画的,比如一个人会走,会跳。显然一辆车的运动方式和一个人的运动方式是不同,那具体控制它如何运动,就是有专门的“运动系统”来负责了。

严格意义上来说,“运动系统”的实现并不在AI的范畴内,而是属于“游戏物理引擎”部分,但在某些时候,作为AI程序员,会对运动系统提出一些特殊的要求,比如今天我们会说到的“可预测性”,这些要求会用来来辅助AI系统的决策。在我现在的项目中,我们对预测的要求相当高,希望在决策的时候能做一个精确的运动预测,但比较遗憾的是,现有的引擎由于这样那样的原因,并不能提供这样一个精准预测,预测结果和运行结果存在较大的差距,所以我在抱怨的同时(因为我用的最多嘛),也在思考如何做这样一个系统,把想到的一些东西也记录在这里。

一个比较简单的“可预测的运动系统”就是匀速直线运动,我们可以根据匀速直线运动的公式,来预测将来任意时刻的位置,速度等运动信息。当然,游戏里的运动系统要复杂的多。一般来说,游戏里的运动可以大致分为两种:

  • 动画配合运动
  • 运动配合动画

第一种就是运动和动画是分离的,我可以用任何的运动函数来移动物体,动画只是配上去的效果而已,这种方式有动画和没有动画,对于它的运动效果来说是不变的,动画只是一种锦上添花的表现,因为运动方式都是自己定义的,所以我们可以很容易控制它运动效果,但缺点是,可能会产生“滑步”的现象,就是人感觉在太空漫步一样,可以想象我们用跑步的速度配一个走路动画的效果。

第二种是物体的运动效果是跟着动画的,如果动画中移动了1米,那我在播放这个动画的时候,这个物体也就移动了1米,这种方式就不存在一个所谓的“运动函数”的概念,因为都是动画数据驱动的,所以运动系统基本上就是从动画中取得当前物体的位置,这样的好处就是避免了“滑步”的问题,但是对于程序员来说,它不可控,完全取决于数据。

所以,当我们想要做一个“可预测的运动系统”的时候,就不得不考虑上面的两种情况,因为在不同的游戏中,运动系统的实现是不同的,有的用了第一种,有的用了第二种,有的是在第一和第二中之间切换的。

为了更好的表述“可预测的运动系统”,我需要定义一个运动状态的结构来描述当前物体的运动状态:

 1: struct PhyXState
 2: {
 3:     Vec3 m_v3Position;
 4:     Vec3 m_v3Velocity;
 5:     Vec2 m_v2Facing;
 6: };

这个结构里包括位置(3维向量),速度(3维向量)和朝向(2维向量),这是描述物体运动状态的三个基本量,对于通用的“可预测的运动系统”来说,它的输入输出就可以这样来描述:

PredictableLocomotionSystem1

输入是当前状态(Current PhyXState),目标状态(Target PhyXState),当前时间(Current Time)和想要预测的未来时间(Future Time),输出就是在未来时间的状态(PhyXState at Future Time)。特别要指出的是,如果预测的运动状态是基于当前的运动行为,那我们可以不传入“目标状态”,“当前状态”和“当前时间”,仅仅传入“未来时间”即可,因为对于当前运动行为而言,其内部已经保存了“目标状态”,“当前状态”和“当前时间”了。但作为通用描述,还是将这三项列在其中。

我们仔细考虑的话,会发现,其实运动系统天生是带有些许预测功能的,当每一帧在更新的时候,就是向运动系统传入了这些参数,然后得出了当前运动物体应该处于的运动状态,在这些参数中唯一值得注意的是“未来时间”,在正常的游戏循环中,“未来时间”是被指定为:

未来时间 = 当前时间 + 步长(Time Step)

也许你会觉得问题似乎是解决了,因为这样的话,不是就可以精确预测任意时候的运动状态了吗?

但回想一下我上面所说的游戏里用的两种运动方式,那问题就并不是这么简单了,对于第一种运动系统,确实不怎么修改就可以实现精确的预测,因为这种运动系统一般是基于数学函数,或者经验数据表(就是预先算出不同输入下的运动数据值,然后通过查表的方式来返回结果),要做的仅仅是封装一个接口,但对于第二种,或者对于混用的运动系统,我们就需要考虑更多的问题了,……。

(待续)

————————————————————————
作者:Finney
Blog:AI分享站(http://www.aisharing.com/)
Email:finneytang@gmail.com
本文欢迎转载和引用,请保留本说明并注明出处
————————————————————————

Read More