很久之前我曾经介绍过不少游戏角色寻路方面实现的方法,但作为完整角色ai行为,我觉得比较难以介绍,首先这涉及到比较多的知识面,然后实现的方式也很多,比如有限状态机、决策树、神经网络等,我认为各有各的优缺点。最后,能实现这个完整过程的手段和框架设计也很多。所以一般介绍角色ai的文章都比较长篇大论,甚至可以写出很多几百页的书籍。
我的写作能力有限,技术水平也有限,所以一直觉得难以表达这方面的知识。于是我做了一个很简单的demo,打算根据这个demo,来分析一下我自己做游戏角色ai时候的一些思路和方法。
一、demo介绍
这个demo比较简单,主要想实现的目标是这样的:有多种不同的角色共存在场景中,不同的角色从行为、思考的习惯上都是不一样的,而最终会产生不同的表现效果。
这里介绍一些demo里面的角色:
1、主玩家角色。受玩家的输入控制作出各种行为,由于这里主要是研究电脑ai的表现,所以主玩家收到的输入控制暂时只有移动
2、怪物。怪物可以有的思考包括休闲待机、追踪玩家、攻击等。
对于不同的怪物,同一种思考类型的行为方式可以不一样。这里也简单介绍一下:
1.普通小怪。在它觉得应该待机的时候,它可以在指定的巡逻点做环形来回的巡逻行动。当发现玩家进入索敌范围时,采取直接跟随的追逐方式接近玩家,然后在进入攻击范围后采取近身攻击方式。在玩家超出索敌范围时或者离巡逻点超过距离后,会脱离战斗并返回巡逻点继续巡逻。
2.快速追击怪物。在待机的过程中,在两个指定的巡逻点之间做来回往返巡逻。,当发现玩家时,进行拦截式的追逐方式接近玩家。其他行为和普通小怪类似。
3.远程攻击怪物。在待机过程中,站在指定的待机点不动。当发现玩家后,以跟随的追逐方式接近玩家,并在远程距离停下来开始进行远程攻击。当玩家靠近怪物时,怪物会逃走到一定距离后再向玩家进行远程攻击。
由于避免demo过于复杂,所以没有做hp和mp的计算。如果加上了这些计算,角色的ai行为会变得更加复杂,比如会根据当前hp的量来思考是否需要逃跑,根据mp的多少和技能的冷却时间进行技能的选择等。但实际上条件再多实现的原理也是一样,反而增加了说明的难度。
二、制作思路
接下来说一下制作的思路。
首先需要说明的一点是,在做任何逻辑之前,我强烈建议必须做到逻辑和美术资源分离。比如说,一开始的时候,我们并不需要真的有一堆角色模型,一个丰富美观的场景,也不需要有任何的动画特效表现。
这样做的原因很多:
首先,我们的ai逻辑并不一定限于在客户端实现的,而实际上更多的网络游戏的怪物ai都是在服务端做的,所以我们不能被任何美术资源形式所限制,我们的逻辑和算法必须能单独的运行。
然后,如果是客户端做帧同步战斗,必须每个客户端在同一输入条件的情况下得出完全一样的结果,不然就会产生误差。而如果计算是依赖美术资源的,那样产生误差的可能性会比纯逻辑计算大很多。
最后,过多的受到美术资源的限制,不仅会让开发程序的周期变长,还会做思路上受到很多制约。
所以,在开发这种ai行为的程序时,完全可以用纯数据的形式去模拟,最简单的就是在数据层模拟各种角色的坐标、朝向、当前动作和动作时间等。当然纯数据模拟不利于直观的观察计算结果,所以一般可以简单等模拟表现。比如用C++或者AS开发时,可以每一帧都清空渲染,然后重新把需要的角色用点来表示绘制在相应的坐标上。由于我是用Unity来做这个demo的,所以为用了一个简单的带方向的box来代表角色。
接下来,我把整个逻辑分成三层:
1、角色实现层
2、行为层
3、思考层
三、具体实现
1、角色实现层
首先来说角色实现层。对于一个角色来说,不管它是由玩家控制的,还是由电脑控制的,实际上它能做的事情都是一样的。比如我们玩格斗游戏,角色能进行的活动无非是包括了方向的移动,出拳、出脚、出绝招等。可以通过按键来实现的动作行为,我觉得就应该在角色实现层里面具体实现。
针对上面的demo,我们可以把一些基础熟悉建立基类。比如我建立了一个Role类作为角色的基类。里面实现了一些基础的方法,比如Stand、Move、Attack、PlayAnim等。伪代码如下
public class Role {
public int teamId = 1;
public float roteSpeed = 5;
public float speed = 10;
public virtual void Stand()
{
}
public virtual void StopMove()
{
}
public virtual void Move()
{
}
public virtual void Turn(float ang)
{
}
public virtual void PlayAnim(string n)
{
}
}
需要说明的是,角色移动的具体实现一般有2种方式,第一种是直接通过向量来把角色从起点移动到终点,然后把角色的旋转角度转向终点。另外一种是给角色施加往前或者往后的力,角色想要往不同的方向移动,必须通过旋转角度来达到。
第一种方式一般适合于对角度不敏感的游戏,不需要有转向的过程,角色朝向纯粹是客户端表现。第二种方式相对比较复杂,但可以模拟出角色真实的移动过程,包括弧线前进、被挤开等。选择哪种方式还是根据自己需要。我这里采取了第二种方式,通过旋转和施加力的模拟方式让玩家移动。
2、行为层
然后说一下行为层。一些角色的行为比较复杂,比如我们玩kof,角色不同角色有很多固定的连招,比如旧版的草薙京可以蹲下轻拳->轻脚->七十五式改->大蛇薙组成一个连招。在我的角度看,这些连招是把角色基础的动作行为进行组装并按顺序或者条件进行执行,所以连招是一种行为。
上面的demo我们举了一些行为,比如追逐玩家,我们可以通过跟随的方式靠近,或者通过拦截的方式靠近目标。这两种靠近方式,都是使用了基础角色的Move方法去实际实行的,但实现过程中会有算法上的区别,所以我认为他们是不同的行为。
针对demo,我可以建立一个BaseAction的类作为所有行为的基类,里面只有一个方法,叫做Tick(),这个方法用于在每一帧的时候修正当前的行为该有的执行动作。然后建立一个ActionRole的类继承Role类,在ActionRole增加一个类型为BaseAction的叫做curAction的变量。伪代码如下:
public class BaseAction{
public virtual void Tick()
{
}
}
public class ActionRole:Role {
protected BaseAction curAction;
void OnUpdate()
{
if(curAction!=null)
{
curAction.Tick();
}
}
}
在实际需要进行某种行为时,可以写各种的action类继承BaseAction,然后在Tick里面写具体的实现方法。
3、思考层
最后说一下思考层。还是以格斗游戏来举例子。在玩kof的时候,我们知道了怎样操作角色,怎样出招数,怎样出连招了。但在什么时候该闪避,什么时候该出哪个招数,什么时候可以出连招,这需要有一个思考的过程。
影响你思考的因素有各方面的,比如和敌人的距离,敌人的血量,自己的血量,自己是否可以出绝招,敌人当前的行为,等等。有经验和技术的玩家,可以根据这些条件,准确的判断出自己应该采取哪种行为来应对敌人,从而获胜。
当把这个判断的思考过程交给电脑,其实电脑需要做的事情也是一样的,根据条件,判断当前需要作出什么行为。
这里会遇到一个问题,判断的依据是很主观的,比如多远的距离可以开始攻击,低于多少血量应该开始逃跑,多远的距离可以开始追逐敌人,等等。电脑依据这些判断的参数做出各种的行为,而体现出电脑是聪明还是笨,很大程度上就在于这些参数的取舍。
先暂时忽略这个问题,假设我们已经得到了一些比较合理的参数了,我们继续接下来的实现过程。
首先,我们先建立一个ai角色类叫做AIRole继承ActionRole,在执行Action之前,先调用一个Think方法。
伪代码如下:
public class AIRole:ActionRole{
void Update()
{
DoSomeThing();
}
protected virtual void DoSomeThing()
{
Think();
DoAction();
}
protected virtual void Think()
{
//实现思考的过程
}
protected virtual void DoAction()
{
if(curAction!=null)
{
curAction.Tick();
}
}
然后,我们需要定义一些思考的结果类型,或者说是当前我们处于执行什么行为的一个状态。
public class AIType
{
//没有任何ai
public const int NULL = -1;
//站立不动
public const int STAND = 0;
//巡逻
public const int PATROLACTION = 1;
//追捕
public const int CATCH = 2;
//逃走
public const int ESCAPE = 3;
//攻击
public const int ATTACK = 4;
}
然后我们角色身上,或者某个数据层可以获取该角色相关的一堆属性和状态。针对我们的demo,具体就有这么几个
-
自身坐标
-
是否有目标
-
目标的坐标
-
索敌范围
-
攻击范围
-
脱战范围
-
是否有返回点
-
返回点的坐标
-
等等
然后我们可以用多种方法来实现思考的过程
1、简单的有限状态机
2、标准状态机
3、行为树
4、神经网络
下面逐个来看实现的思路,选择一种合适自己的方法来实现思考:
1、简单的有限状态机
这是最简单的一种实现方式,实际上就是通过if else来组合各种的条件。伪代码如下:
假设有一个变量记录当前的思考状态叫做curAIType,那么Think代码就会变成这样:
protected virtual void Think()
{
if(curAIType == AIType.Stand)
{
//通过各种条件判断是否需要转换其他ai
}
else if(curAIType == AIType.PATROLACTION )
{
//通过各种条件判断是否需要转换其他ai
}
……
}
如果当前在某个AI状态中,又符合某些条件,那么就会在think方法里面转换另外一种AI,把当前的curAction改成另外一种Action,然后执行。
简单有限状态机的缺点在于需要手写很多代码,修改条件和参数也很不方便,优点是实现简单,而且执行效率高。如果本身需要实现的AI很简单,不妨可以试试。
2、标准状态机
接下来说的是一个比较标准的状态机。先来说实现。
我们先建立一个状态节点类叫做StatusNode,一个检查条件的类叫做CheckConditionData,需要一个条件数据类叫做ConditionData
伪代码如下:
public class ConditionData{
//自己本身的条件
private string key;//需要取哪个参数作为条件
private string operaType;//可以自己定义,比如大于、小于、等于、不等于之类
private string val;//参与判断的值
private string paramType;//需要判断的参数的类型,比如int、string之类
public bool Excute()
{
if(CheckSelfCondition()==true)
{
return true;
}
else
{
return false;
}
}
private bool CheckSelfCondition()
{
if(string.IsNullOrEmpty(key)==true)
{
return true;
}
//这里写具体条件判断的实现
}
}
然后写检查条件的类的伪代码:
public class CheckConditionData{
private List<ConditionData> conditions;
private int relationType = 0;//0代表and,1代表or。这里指的是条件之间是什么关系
private AIType nextAIType;
public AIType Excute()
{
if(CheckConditions()==true)
{
return nextAIType;
}
else
{
return AIType.NULL;
}
}
private bool CheckConditions()
{
if(conditions==null||conditions.Count == 0)
{
return true;
}
if(relationType == 0)
{
for(int i = 0;i<conditions.Count;i++)
{
if(conditions[i].Excute()==false)
{
return false;
}
}
return true;
}
else
{
for(int i = 0;i<conditions.Count;i++)
{
if(conditions[i].Excute()==true)
{
return true;
}
}
}
}
}
最后写状态节点的伪代码:
public class StatusNode{
private List<CheckConditionData> subNodes;
public AIType Excute()
{
AIType tempAI = null;
for(int i = 0;i<subNodes.Count;i++)
{
tempAI = subNodes[i].Excute();
if(tempAI!=AIType.NULL)
{
return tempAI;
}
}
return AIType.NULL;
}
}
最后,实现AIRole的Think方法:
protected virtual void Think()
{
if(curStatusNode!=null)
{
AIType tempAI = curStatusNode.Excute();
if(tempAI!=AIType.NULL)
{
//进行切换AI的操作
}
}
}
看起来代码很多,好像很复杂。但可以发现,这些实现的代码其实不涉及到具体条件的改变。而具体的条件改变的设置,可以写一个编辑器工具,用于编辑状态节点。
对比简单的有限状态机,这种标准的状态机的优缺点很明显了。缺点是实现复杂,需要写各种对象实现,还需要写编辑器工具。优点是把逻辑和条件编辑分离,可以在不改变代码的情况下,把编辑条件和状态类型的工作交给其他人做,比如策划人员。
3、行为树
所谓的行为树,其实就是由各种节点连接起来的一个树形结构。从根节点出发,经过了多层的枝节点,最终选择到了合适的叶节点。
行为树和状态机,都是通过条件作为判断转换AI的依据。区别在于,状态机的节点是包含着条件,每组条件只对应一个可执行的结果,从层次结构来看,其实它是只有一层的。而且它是线性的,从一个状态,过渡到另外一个状态。但行为树实际上每次都是从根节点出发,去判断每个分支的条件,然后分支还可以继续的套分支,形成的结构是多层的。从根节点出发去判断每一层的条件节点,直到有一个分支的条件全部达到,到达了行为节点(叶节点),那么这条分支就可以返回true,得到了想要的结果并执行。如果在分支的条件节点里面有一层达不到,那就不需要继续往子节点发展,直接返回false,程序继续检索其他分支节点的条件,直到达到一个叶节点位置。
行为树比有限状态机有趣的地方是他并不固定状态的线性转换,可以根据不同的条件配出很复杂的行为,在条件节点里面,还可以加入随机数或者学习系数在里面,做出更多变化的行为。
同样的,行为树在写完代码之后也需要写编辑器工具,让策划人员通过生成节点、拖动节点和连线,生成不同类型角色的不同的行为树配置文件。
伪代码如下:
首先我们需要知道节点可以分为几类:根节点、条件节点、行为节点,我们可以先写一个节点的基类,由于每个节点都需要有一个返回判断这个节点是否走得通,所以需要一个Excute方法,由于节点之间是可以连接的,所以一个节点最基本的功能是可以继续发展下一级的节点,所以我们需要存储一个子节点的列表:
public class BaseNode{
protected List<BaseNode> childrenNodes;//子节点队列
protected int childrenRelationType = 0;//0代表and,1代表or。这里指的是子节点之间是什么关系
public virtual bool Excute()
{
return CheckSelfConditions();
}
protected virtual bool CheckSelfConditions()
{
if(childrenNodes==null||childrenNodes.Count==0)
{
return false;
}
else
{
if(childrenRelationType == 0)
{
for(int i = 0;i<childrenNodes.Count;i++)
{
if(childrenNodes[i].Excute()==false)
{
return false;
}
}
return true;
}
else
{
for(int i = 0;i<childrenNodes.Count;i++)
{
if(childrenNodes[i].Excute()==true)
{
return true;
}
}
return false;
}
}
}
}
接下来写条件节点。我们可以用和状态机一样的条件数据类
public class ConditionData{
//自己本身的条件
private string key;//需要取哪个参数作为条件
private string operaType;//可以自己定义,比如大于、小于、等于、不等于之类
private string val;//参与判断的值
private string paramType;//需要判断的参数的类型,比如int、string之类
public bool Excute()
{
if(CheckSelfCondition()==true)
{
return true;
}
else
{
return false;
}
}
private bool CheckSelfCondition()
{
if(string.IsNullOrEmpty(key)==true)
{
return true;
}
//这里写具体条件判断的实现
}
}
然后写条件节点
public class ConditionNode:BaseNode{
//自身条件
private List<ConditionData> selfConditions;
private int relationType = 0;//0代表and,1代表or。这里指的是子条件之间
public override bool Excute()
{
if(CheckSelfConditions()==true&&CheckChildrenNodes()==true)
{
return true;
}
else
{
return false;
}
}
private bool CheckSelfConditions()
{
if(selfConditions==null||selfConditions.Count==0)
{
return true;
}
else
{
if(relationType == 0)
{
for(int i = 0;i<selfConditions.Count;i++)
{
if(selfConditions[i].Excute()==false)
{
return false;
}
}
return true;
}
else
{
for(int i = 0;i<selfConditions.Count;i++)
{
if(selfConditions[i].Excute()==true)
{
return true;
}
}
return false;
}
}
}
}
最后可以写行为节点
public class ActionNode:BaseNode
{
public override bool Excute()
{
//进行切换action的操作
//最后必然返回true来代表一个分支已经顺利达成
return true;
}
}
所有节点都已经准备完毕了,我们就可以实现AIRole里面的Think方法:
假设有变量aiNode存储了当前角色行为树的根节点,注意总的根节点对象里面的子节点关系必然是or的,这代表了只有一个子节点达成了,就可以停止运行。所以最后代码变得非常简单:
protected virtual void Think()
{
if(aiNode!=null)
{
aiNode.Excute();
}
}
4、神经网络和遗传算法
人工神经网络是由人工神经细胞构成。每一个神经细胞,都有若干个输入,每个输入分配一个权重,然后只有一个输出。整个神经网络是由多层的神经细胞组成。
实际上所谓的若干个输入,也就是影响角色的各种行为,像刚才说的自身的血量啊,敌人的距离啊之类。但实际上神经网络比行为树更彻底的地方是它根本没有固定的判断条件,每个输入的权重都是可以随意的。到了最后,每个细胞只有2种状态:兴奋(激活)和不兴奋(不激活)的。而至于各种输入乘以权重加起来的值达到多少算兴奋,标准也是各种各样的。所以如果权重设置得对,神经网络会表现得非常智能,而权重不对,神经网络有可能做出很傻的判断。
为了让神经网络做出正确的选择,我们可以对他进行训练。比如用遗传算法,先生成非常多的DNA(各种参数)样品,然后赋予给不同的角色让他们行动,以一定时间为一个时代,当一个时代结束后,根据标准评分来给予每一个DNA权重奖励,给它加分。然后用赌轮选择法或者种子选择法之类的选择方式挑选出权重分数高的DNA组成父母进行杂交生成下一代。一直到总体趋势达到合理标准为止。
评分的标准可以是有多项的,比如血量剩余的多少,比如是否有攻击到玩家,比如是否击杀了玩家,这些可以加分。有些是需要扣分的,比如完全没做出行为在发呆的,比如死亡的之类。那么经过很多世代之后,能留下了的DNA都是优良的,可以比较合理的使用的。
由于我对人工神经网络的研究不深,也没有实际应用到项目之中,所以也不提供伪代码了,以免误导别人。
四、一些个人看法
最后说说我个人对选择AI技术的一些小看法。
上面这么多种实现的方式,我感觉是各有好处,不一定越高级的方法就约好。简单的有限状态机是非常简陋,但它实现简单,出错的可能性小,程序运行时的效率也很高。标准状态机和行为树的写法和配置方式比较类似,从代码执行效率上来说,还是状态机比较高。如果不是需要特别复杂的行为判断,我个人觉得状态机已经可以解决大部分问题了。至于人工神经网络,现在运用到游戏领域的例子反而不多见。因为游戏角色的AI不见得是越聪明约好,还需要特意的蠢一下,让玩家觉得玩得高兴,才符合游戏设计的目的。如果不是想比较真实的模拟某种群体行为,想让AI看起来非常真实,让AI具有学习功能,我觉得没必要用到这么高级的技术。而且算法越复杂的方法,运行效率肯定是越低的。
顺带提一下自动寻路技术的选择
自动寻路也被认为是人工智能的一种。我熟悉的寻路算法有2种,一种是A星寻路,另外一种是导航网格寻路(NavMesh)。两种寻路算法究竟哪种比较好呢?
从通用性和效率来说,NavMesh按道理是会比A星要好的。
举个例子,假如我有一个地图,面积是200米*200米的。如果用A星寻路算法,假设我们按1米作为一个格子,总共就会有4万个格子,在A星寻路的过程中,如果遇到了不可走的情况,有可能需要走遍几万个格子,才能得出结论不能走。就算能走,如果从地图的一端走到另外一段,势必也要走几千上万个格子才能找出通路。而且1米一个格子,对于3d游戏来说,是非常不精确的,假如想提高精度,只能把格子缩小,比如缩小到0.5米一个,那么整个地图就变成有16万个格子,这个增长的幅度是很可怕的。如果还需要考虑到三维空间的层叠寻路,A星的每个格子链接的下个格子数量又会多一些,算法就更复杂。
如果换成NavMesh,不管地图有多大,是不是3D层叠的,基本上都可以用几百个以内的凸多边形来表示。如果障碍物少的地图,甚至只有个位数的凸多边形就解决问题了。那么在判断是否有通路的过程,速度理论上只有传统A星算法的几千分之一,甚至几十万分之一。NavMesh的第二部是需要用拐点算法,算向量点积来判断在凸多边形之间是否需要拐弯。这个算法稍微有点复杂,不过由于不需要运行很多次,所以总体来说也不会特别有效率问题。
说了这么多,好像NavMesh很完美,但实际上NavMesh也不是什么时候都适用的。这是因为生成A星数据很简单,只要生成一个二维数组就行了,想临时把某些点变成不可走,也非常容易。但NavMesh的数据是顶点数据和三角形索引之类构成多边形网格的数据,如果中间突然多了一个障碍物,有可能整份多边形寻路数据都要重新生成一遍。而生成多边形寻路数据的算法比较复杂,效率不会很高。这样就导致了如果经常有动态阻挡的情况下,NavMesh就不太合适了。或者可以使用NavMesh结合其他技术来实现动态阻挡,比如在正常寻路的过程,加入触角阻挡判断的方式来绕开阻挡物体。
所以选择什么样的技术手段,还是需要根据自己的实际情况来选择。
本来打算简单的讨论一下角色AI的问题,结果后来也写了不少内容。看来这方面主题的内容还是不容易简短的表达,以后有机会再详细的讨论。