游戏AI行为决策——GOAP(附代码与项目)

新的一年即将到来,感觉还剩一种常见的游戏AI决策方法不讲的话,有些过意不去。就在这年的尾巴与大家一起交流下「目标导向型行为规划(GOAP)」吧!

另外,我觉得只是讲代码实现而没有联系具体项目,可能还是不容易理解的。所以这次我会在文末附上一个由本文所述代码实现的一个小demo,方便大家更好理解其运作。

前言

像先前提到的有限状态机、行为树HTN,它们实现的AI行为,虽说能针对不同环境作出不同反应,但应对方法是写死了的。有限状态机终究是在几个状态间进行切换、行为树也是根据提前设计好的树来搜索……你会发现,游戏AI角色表现出的智能程度,终究与开发者的设计结构有关,就有限状态机而言,各个状态如何切换很大程度上就影响了AI智能的表现。

那有没有什么决策方法,能够仅需设计好角色需要的动作,而它自己就能合理决定要选择哪些动作完成目标呢?这样的话,角色AI的行为智能程度会更上一层楼,毕竟它不再被写死的决策结构束缚;我们在添加更多AI行为时,也可以简单地直接将它放在角色需要的动作集里就好,减少了工作量,不必像行为树那样,还要考虑节点间的连接。

没错,GOAP就可以做到。(咳咳,虽说为了突出GOAP的特点进行了一番拉踩(ˉ▽ˉ;)。但请注意,并不是说GOAP就比其它决策方法好,后面也会提到它的缺点。选择何种决策方法还得根据实际项目和自身需求

PS:本教程需要你具备以下前提知识

  1. 知道数据结构 堆/优先队列、栈
  2. 知道A星寻路的流程,如不了解可看此视频,非广告,只是我当时学的时候感觉这个还可以。
  3. 基本的位运算与位存储(能理解Unity中的Layer和LayerMask就行)

运行逻辑

我们来看个简单的寻路问题:你能找到从A到B的最短路线吗?注意,道路是单向的哦。

聪明如你,这并不难找到:

现在,加大难度,假设每条道路口都有一个门,红色表示门关上了,蓝色表示能开着,你还能找出可达成的最短A到B路线吗?

同样不难:

这样就足够了,GOAP的规划就是这么一个过程。只是把每个节点都当成一个状态,每条道路都当作一个动作、道路长度作为动作代价、路口的门作为动作执行条件,然后像你这样寻找出一条可以执行的最短「路线」,并记录下途径的道路(注意,不是节点)这样就得到了 「动作序列」,再让AI角色逐一执行。GOAP中的图会长成下面这样(偷懒了≡(▔﹏▔)≡,只画出了一条路的样子,但相信你们能举一反三的):

GOAP就是在不断执行「从现有状态到目标状态」,上图中的 「现有状态」「目标状态」 分别就是「饿」和「饱」。请注意,虽说用了不同形状,但中间的那些椭圆节点,比如「在上网」,也是和「饿」、「饱」同类别的存在。也就是说「在上网」也可以作为现有状态或目标状态。

可想而知,只要状态够多,动作够多,AI就能做出更复杂的动作。虽说这对其它决策方法也成立,但GOAP不需要我们显示地手动设置各动作、状态之间的关系,它能自行规划出要做的一系列动作,更省事且更智能,甚至可以规划出超出原本设想但又合理的动作序列。

希望我讲明白了它的运作(如果还是感觉有点不懂,可以看看这个视频),下面一起来实现一个简单的GOAP进一步了解吧!顺带提一嘴,在Unity资源商店有免费的GOAP插件,并且做了可视化处理以及多线程优化,各位真的想将GOAP运用于项目的话,更推荐去学习使用成熟的插件。ˋ( ° ▽、° )

代码实现

代码实现参考了GitHub上一C语言版本的GOAP

1. 世界状态

所谓「世界状态」其实就是存储所有的状态放在一块儿的合集。而状态其实还有一个隐藏身份——动作条件。是的,状态也充当了动作的执行条件,比如之前图中的条件「有流量」,它其实也是一个状态。

世界状态会因 自然因素 变化,比如「饱」会随着时间流逝而变「饿」;也会因角色自身的一些 动作导致 变化,比如一个角色多运动,也会使「饱」变「饿」。

问题在于:

  1. GOAP规划需要时时获取最新的状态,才能保证规划结果的合理性(否则饿晕了还想着运动);
  2. 「世界状态」中有些状态是「共享」的,比如之前说的时间,但还有一些状态是私有的,比如「饱」,是我饱、你饱还是他饱?在一个合集里该如何区分?

噢~如果你看过上一篇关于HTN的文章的话,你会发现这是如此的眼熟。不过没看过也没关系,我们将采取一种新的实现「世界状态」的方法——原子表示


PS:在传统人工智能Agent中,对于环境的表示方式有三种:

  1. 原子表示(Atomic):就是单纯描述某个状态有无,通常每个状态都只用布尔值(True/False)表示就可以,比如「有流量」。
  2. 要素化表示(Factored):进一步描述状态的具体数值,这时,状态可以有不同的类型,可以是字符串、整数、布尔值……在HTN中,我们就是用这种方式实现的。
  3. 结构化表示(Structured):再进一步,每个状态不但描述具体数值,还存储于其它数据的连接关系,就像数据结构中的图的节点那样。

接下来将采用 位存储 的方式进行原子表示,因为借助位运算可以方便且高效地实现比较,还省空间。缺点就是有些难懂,所以,我希望你了解如int、long的二进制存储方式或者Unity中LayerMask,再来看以下内容。当然,这段代码之后我也会做些举例说明:

/// <summary>
/// 用位表示的世界状态
/// </summary>
public class GoapWorldState
{
public const int MAXATOMS = 64;//存储的状态数上限,由于用long类型存储,最多就是64(long类型为64位整数)
public long Values => values;//世界状态值
public long DontCare => dontCare;//标记未被使用的位
public long Shared => shared;//判断共享状态位
private readonly Dictionary<string, int> namesTable;//存储各个状态名字与其在values中的对应位,方便查找状态
private int curNamsLen;//存储的已用状态的长度
private long values;
private long dontCare;
private long shared;
/// <summary>
/// 初始化为空白世界状态
/// </summary>
public GoapWorldState()
{
//赋值0,可将二进制位全置0;赋值-1,可将二进制位全置1
namesTable = new Dictionary<string, int>();
values = 0L; //全置0,意为世界状态默认为false
dontCare = -1L; //全置1,意为世界状态的位全没有被使用
shared = -1L; //将shard的位全置1
curNamsLen = 0;
}
/// <summary>
/// 基于某世界状态的进一步创建,相当于复制状态设置但清空值
/// </summary>
public GoapWorldState(GoapWorldState worldState)
{
namesTable = new Dictionary<string, int>(worldState.namesTable);//复制状态名称与位的分配
values = 0L;
dontCare = -1L;
curNamsLen = worldState.curNamsLen;//同样复制已使用的位长度
shared = worldState.shared;//保留状态共享性的信息
}
/// <summary>
/// 根据状态名,修改单个状态的值
/// </summary>
/// <param name="atomName">状态名</param>
/// <param name="value">状态值</param>
/// <param name="isShared">设置状态是否为共享</param>
/// <returns>修改成功与否</returns>
public bool SetAtomValue(string atomName, bool value = false, bool isShared = false)
{
var pos = GetIdxOfAtomName(atomName);//获取状态对应的位
if (pos == -1) return false;//如果不存在该状态,就返回false
//将该位 置为指定value
var mask = 1L << pos;
values = value ? (values | mask) : (values & ~mask);
dontCare &= ~mask;//标记该位已被使用
if (!isShared)//如果该状态不共享,则修改共享位信息
{
shared &= ~mask;
}
return true;//设置成功,返回true
}
/// <summary>
/// 计算该世界状态与指定世界状态的相关度
/// </summary>
public int CalcCorrelation(GoapWorldState to)
{
var care = to.dontCare ^ -1L;
var diff = (values & care) ^ (to.values & care);
int dist = 0; //统计有多少位是相同的,以表示相关度
for (int i = 0; i < MAXATOMS;++i)
{
/*因为规划时找的是最小代价的动作,所以相关度越高理应代价越小
这样才能被优先选取,故用--,而非++*/
if ((diff & (1L << i)) != 0)
--dist;
}
return dist;
}
public void SetValues(long newValues)
{
values = newValues;
}
public void SetDontCare(long newDontCare)
{
dontCare = newDontCare;
}
public void Clear()
{
values = 0L;
namesTable.Clear();
curNamsLen = 0;
dontCare = -1L;
}
/// <summary>
/// 通过状态名获取单个状态在Values中的位,如果没包含会尝试添加
/// </summary>
/// <param name="atomName">状态名</param>
/// <returns>状态所在位</returns>
private int GetIdxOfAtomName(string atomName)
{
if(namesTable.TryGetValue(atomName, out int idx))
{
return idx;
}
if(curNamsLen < MAXATOMS)
{
namesTable.Add(atomName, curNamsLen);
return curNamsLen++;
}
return -1;
}
}

我们以添加两个状态为例,相信看了这个,你会更容易理解相关函数的内容。虽说总共有64位世界状态,但这里只看4位不然画不下:

将世界状态分为「私有」和「共享」,我们就可以让角色更新「私有」部分,而全局系统更新「共享」部分。当需要角色规划时,我们就用位运算将该角色的「私有」与世界的「共享」进行整合,得到对于这个角色而言的当前世界状态。这样对于不同角色,它们就能得到对各自的而言的世界状态啦!

如果去除注释,这个类的内容其实并不多,在使用时几乎只要用到SetAtomValue函数,像这样:

worldState = new GoapWorldState();
worldState.SetAtomValue("血量健康", true);
worldState.SetAtomValue("大半夜", false, true);

2. 动作

我们之前说过,动作包含一个「前提条件」,其实和HTN一样,它还包含一个「行为影响」,相当于之前图中道路指向的椭圆表示的状态。它们也都是世界状态,注意是世界状态,而不是单个状态!

为什么不设置成单个?首先,「前提条件」和「行为影响」本身就可能是多个状态组合成的,用单个不合适;其次,将它们也设置成世界状态(64位的long类型),方便进行统一处理与位运算。Unity中的Layer不也是这样,对吧。

只有当前世界状态与「前提条件」对应位的值相同时,才算满足前提条件,这个动作才有被选择的机会。而动作一旦执行成功,世界状态就会发送变化,对应位上的值会被赋值为「行为影响」所设置的值。

/// <summary>
/// Goap动作,也是Goap图中的边
/// </summary>
public class GoapAction
{
public int Cost{ get; private set; } //动作代价,作为AI规划的依据
private readonly GoapWorldState precondition; //动作得以执行的前提条件
private readonly GoapWorldState effect; //动作成功执行后带来的影响,体现在对世界状态的改变 /// <summary>
/// 根据给定世界状态样式创建「前提条件」和「行为影响」,
/// 这为了让它们的位与世界状态保持一致,方便进行位运算
/// </summary>
/// <param name="baseState">作为基准的世界状态</param>
/// <param name="cost">动作代价</param>
public GoapAction(GoapWorldState baseState, int cost = 1)
{
Cost = cost;
precondition = new GoapWorldState(baseState);
effect = new GoapWorldState(baseState);
}
/// <summary>
/// 判断是否满足动作执行的前提条件
/// </summary>
/// <param name="worldState">当前世界状态</param>
/// <returns>是否满足前提</returns>
public bool MetCondition(GoapWorldState worldState)
{
var care = ~precondition.DontCare;
return (precondition.Values & care) == (worldState.Values & care);
} //---------------------------------------------------------------
/// <summary>
/// 判断世界状态是否可由执行影响导致
/// </summary>
/// <param name="worldState">当前世界状态</param>
/// <returns>是否能导致</returns>
public bool MetEffect(GoapWorldState worldState)
{
var care = ~effect.DontCare;
return (effect.Values & care) == (worldState.Values & care);
} public GoapWorldState GetPrecondition()
{
return precondition;
}
//---------------------------------------------------------------- /// <summary>
/// 动作实际执行成功的影响
/// </summary>
/// <param name="worldState">实际世界状态</param>
public void Effect_OnRun(GoapWorldState worldState)
{
worldState.SetValues((worldState.Values & effect.DontCare) | (effect.Values & ~effect.DontCare));
}
/// <summary>
/// 设置动作前提条件,利用元组,方便一次性设置多个
/// </summary>
public GoapAction SetPrecontidion(params (string, bool)[] atomName)
{
foreach(var atom in atomName)
{
precondition.SetAtomValue(atom.Item1, atom.Item2);
}
return this;
}
/// <summary>
/// 设置动作影响
/// </summary>
public GoapAction SetEffect(params (string, bool)[] atomName)
{
foreach (var atom in atomName)
{
effect.SetAtomValue(atom.Item1, atom.Item2);
}
return this;
}
public void Clear()
{
precondition.Clear();
effect.Clear();
}
}

你可能发现了这个动作类的奇怪之处——它没有像OnRunning或OnUpdate之类的动作执行函数,这样一来要如何执行动作?是的,这个类主要是用来充当图的边,来连接各个状态,它会作为<string, GoapAction>字典中的值,并于一个动作名字符串绑定。我们会通过动作名,再查找另一个同样以动作名为键、但值为事件的字典,找到对应的事件,这个事件才是真正运行的动作函数。

这样岂不多此一举?其实这是为了提高GOAP图的重用性。如果GOAP中的道路并不是真正的动作函数,而是用了动作名来标记。那么我们可以为多个角色设计同一种动作,但不同的表现。比如「攻击」动作,在弓箭手中就是射击函数,枪手中就是开火函数……这样一来,即便不同角色都可以使用同一张GOAP图,不用重复创建(除非有特殊需求)。

这样是GOAP的一般做法,只用少数GOAP图,而不同角色可以共同使用一张GOAP图来进行互不干扰的规划。这可以省很多代码量,试想在有限状态机中,不做特殊处理你都无法让不同敌人共用「攻击」状态,就得不断写大同小异的代码。GOAP的这种将结构与逻辑分离的做法,就可以很方便地复用结构或进行定制化设计,也是其优势之一。

3. A星节点

接下来要实现的就是图的节点……欸?不是说状态就是节点吗,怎么还要定义节点类呢?这是为了方便寻找「路径」,GOAP会采用启发式搜索,就像A星寻路所用的那样。所谓「启发式搜索」就是有按照一定 「启发值」 进行的搜索,它的反面就是「盲目搜索」,如深度优先搜索、广度优先搜索。启发式搜索需要设计 「启发函数」 来计算「启发值」。

在A星寻路中,我们通过计算「当前位置离起点的距离 + 当前位置离终点的距离」做为启发值来寻找最短路径;类似的,在我们实现的这个GOAP中,我们会通过计算「起点状态至当前状态 累计的动作代价 + 当前状态 与目标状态的相关度」作为启发值。

累计代价,也相当于与起始状态的「距离」;与目标状态的相关度,在世界状态类中已经说明了,就是比较当前状态与目标状态的有效位的值有多少是相同的,通常相同的越多就越接近。


PS:在寻路时,常需要选取已探索过的节点中具有最小启发值的节点。用遍历倒也能做到,但总归效率不高,故可以用「堆」,也就是 「优先队列」

//堆属于常用数据结构中的一种,我默认大家都会了,原理就不加以注释说明了
public interface IMyHeapItem<T> : IComparable<T>
{
int HeapIndex { get; set; }
}
public class MyHeap<T> where T : IMyHeapItem<T>
{
public int NowLength { get; private set; }
public int MaxLength { get; private set; }
public T Top => heap[0];
public bool IsEmpty => NowLength == 0;
public bool IsFull => NowLength >= MaxLength - 1;
private readonly bool isReverse;
private readonly T[] heap; public MyHeap(int maxLength, bool isReverse = false)
{
NowLength = 0;
MaxLength = maxLength;
heap = new T[MaxLength + 1];
this.isReverse = isReverse;
}
public T this[int index]
{
get => heap[index];
}
public void PushHeap(T value)
{
if (NowLength < MaxLength)
{
value.HeapIndex = NowLength;
heap[NowLength] = value;
Swim(NowLength);
++NowLength;
}
}
public void PopHeap()
{
if (NowLength > 0)
{
heap[0] = heap[--NowLength];
heap[0].HeapIndex = 0;
Sink(0);
}
}
public bool Contains(T value)
{
foreach(var v in heap)
{
if(Equals(v, value))
{
return true;
}
}
return false;
}
public T Find(T value)
{
if (Contains(value))
return heap[value.HeapIndex];
return default;
}
public void Clear()
{
for (int i = 0; i < NowLength; ++i)
{
heap[i].HeapIndex = 0;
}
NowLength = 0;
}
private void SwapValue(T a, T b)
{
heap[a.HeapIndex] = b;
heap[b.HeapIndex] = a;
(b.HeapIndex, a.HeapIndex) = (a.HeapIndex, b.HeapIndex);
} private void Swim(int index)
{
int father;
while (index > 0)
{
father = (index - 1) >> 1;
if (IsBetter(heap[index], heap[father]))
{
SwapValue(heap[father], heap[index]);
index = father;
}
else return;
}
} private void Sink(int index)
{
int largest, left = (index << 1) + 1;
while (left < NowLength)
{
largest = left + 1 < NowLength && IsBetter(heap[left + 1], heap[left]) ? left + 1 : left;
if (IsBetter(heap[index], heap[largest]))
largest = index;
if (largest == index) return;
SwapValue(heap[largest], heap[index]);
index = largest;
left = (index << 1) + 1;
}
}
private bool IsBetter(T v1, T v2)
{
return isReverse ? (v2.CompareTo(v1) < 0 ): (v1.CompareTo(v2) < 0);
}
}

节点类的实现如下:

public class GoapAstarNode : IMyHeapItem<GoapAstarNode>
{
public int G => g;
public GoapWorldState WorldState => worldState;
public GoapAstarNode Parent => parent;//记录上一个节点,寻路完成后溯回出动作序列
public string FromActionName => fromActionName;//记录上一个动作的名字
public int HeapIndex { get; set; }
private readonly GoapWorldState worldState;
private readonly GoapAstarNode parent;
private readonly int h;//与目标状态的相关度
private int f;//启发值f
private int g;//起始状态至此的累计动作代价
private readonly string fromActionName; public GoapAstarNode(GoapWorldState curState, GoapAstarNode parent, int g, GoapWorldState goal, string fromActionName)
{
worldState = curState;
this.parent = parent;
this.g = g;
this.fromActionName = fromActionName;
h = curState.CalcCorrelation(goal);
f = g + h;
}
public void SetGCost(int g)//设置g值
{
this.g = g;
f = g + h;
}
public int CompareTo(GoapAstarNode other)
{
return f.CompareTo(other.f);//启发值比较
}
/* HashSet会比较GetHashCode以及从Equals返回的bool值,判断元素是否相等
MyHeap也会根据Equals判断是否相等
需要重写这两个方法*/
public override int GetHashCode()
{
return (worldState.Values & ~worldState.DontCare).GetHashCode();
}
public override bool Equals(object obj)
{
return GetHashCode() == obj.GetHashCode();
}
}

4. 动作集

照理说,动作集不过是动作的合集,单独将它也制成一个类,是为了方便「动作序列」规划,主要体现在GetPossibleTrans函数,根据传入的节点的世界状态,在合集中遍历出「前提条件」满足的动作:

public class GoapActionSet
{
private readonly Dictionary<string, GoapAction> actionSet;
public GoapActionSet()
{
actionSet = new Dictionary<string, GoapAction>();
}
public GoapAction this[string idx]
{
get => actionSet[idx];
}
public GoapActionSet AddAction(string actionName, GoapAction newAction)
{
actionSet.Add(actionName, newAction);
return this;
}
/// <summary>
/// 根据当前节点搜索可进一步执行的动作
/// </summary>
/// <param name="curNode">当前图节点</param>
/// <param name="from">起始状态,用于启发函数计算</param>
/// <param name="to">目标状态,同样用于启发函数计算</param>
/// <param name="actionNames">用于存储找到的可行动作的名字,有名字方便找到动作函数</param>
/// <returns>找到的所有可行动作</returns>
public List<GoapAstarNode> GetPossibleTrans(GoapAstarNode curNode, GoapWorldState from, GoapWorldState to, out List<string> actionNames)
{
var curState = curNode.WorldState;
var neighbors = new List<GoapAstarNode>();
actionNames = new List<string>();
foreach(var act in actionSet)
{
if( act.Value.MetEffect(curState) ) //如果动作的影响能造就当前世界状态,就选中
{
actionNames.Add(act.Key);
var nextState = act.Value.GetPrecondition(); //获得动作的条件,以便倒推
neighbors.Add(new GoapAstarNode(nextState, curNode, from.CalcCorrelation(nextState), to, act.Key));
}
}
return neighbors;
}
}

5. A星寻路

一切条件都准备好了,现在实现下用来「寻路」的类。首先,我们会进行反向搜索,意思是说,我们不会「起始状态-->目标状态」,而是「目标状态-->起始状态」,如果成功找到,就将得到的动作序列逆向执行。

为什么这么麻烦?其实恰恰相反,这还是一种简化。如果真的「起始状态-->目标状态」,未必最终会找到目标状态(因为有可能能抵达的动作暂时条件不满足);但反向搜索,必定会包含目标状态,也一定会找到一条路(因为总会抵达一个当前已经符合的世界状态,否则就是设计的有问题了),只不过可能不是最短的。

我们也能接受这种结果,虽说非最优解,但这种不确定因素,也变相让AI增加了点随机性,更接近真实决策情况。

它的整体搜索过程和A星寻路是一样的:

/// <summary>
/// Goap A星启发式搜索
/// </summary>
public static class GoapAstar
{
private static readonly MyHeap<GoapAstarNode> openList;
private static readonly HashSet<GoapAstarNode> closeList;
static GoapAstar()
{
openList = new MyHeap<GoapAstarNode>(GoapWorldState.MAXATOMS);
closeList = new HashSet<GoapAstarNode>();
}
/// <summary>
/// 根据给定初始世界状态和目标世界状态,从动作集中规划出可达成目标的动作
/// </summary>
/// <param name="from">初始世界状态</param>
/// <param name="to">目标世界状态</param>
/// <param name="actionSet">动作集</param>
/// <returns>需执行的动作名称,弹出顺序即为执行顺序</returns>
public static Queue<string> Plan(GoapWorldState from, GoapWorldState to, GoapActionSet actionSet)
{
openList.Clear();
closeList.Clear();
// 实际要的是from --> to,但在代码中寻找时是to --> from
var n0 = new GoapAstarNode(to, null, 0, from, default); //创建一个目标状态节点
openList.PushHeap(n0);
while(!openList.IsEmpty)
{
var curState = openList.Top;
var curCare = ~curState.WorldState.DontCare;
closeList.Add(curState);
openList.PopHeap();
if((curState.WorldState.Values & curCare) == (from.Values & curCare) || openList.IsFull)
{
return GenerateFinalPlan(curState);
}
var neighbors = actionSet.GetPossibleTrans(curState, from, to, out List<string> actions);
for(int i = 0; i < neighbors.Count; ++i)
{
if (closeList.Contains(neighbors[i]))
continue;
var cost = curState.G + actionSet[actions[i]].Cost;
var isWithoutOpen = !openList.Contains(neighbors[i]);
if (isWithoutOpen || cost < neighbors[i].G)
{
neighbors[i].SetGCost(cost);
if (isWithoutOpen)
{
openList.PushHeap(neighbors[i]);
}
}
}
}
return new Queue<string>();
}
/// <summary>
/// 根据最终节点回溯,获取最终执行动作集
/// </summary>
/// <param name="endNode"></param>
/// <returns>动作队列,弹出顺序即为执行顺序</returns>
private static Queue<string> GenerateFinalPlan(GoapAstarNode endNode)
{
var planQueue = new Queue<string>();
if (endNode.Parent == null)
{
return planQueue;
}
planQueue.Enqueue(endNode.FromActionName);
var tpNode = endNode.Parent;
while(tpNode.Parent != null)
{
planQueue.Enqueue(tpNode.FromActionName);
tpNode = tpNode.Parent;
}
return planQueue;
}
}

6. 代理器

我们最后创建一个「代理器」,它用来整合了上述内容,并统筹运行:

/// <summary>
/// 运行结果状态枚举(和往期决策方法使用的一样)
/// </summary>
public enum EStatus
{
Failure, Success, Running, Aborted, Invalid
}
public class GoapAgent
{
private readonly GoapActionSet actionSet; //动作集
private readonly GoapWorldState curSelfState; //当前自身状态,主要是存储私有状态
private readonly Dictionary<string, Func<EStatus>> actionFuncs; //各动作名字对应的动作函数
private Queue<string> actionPlan;//存储规划出的动作序列 private EStatus curState;//存储当前动作的执行结果
private bool canContinue;//是否能够继续执行,记录动作序列全部是否执行完了
private GoapAction curAction;//记录当前执行的动作
private Func<EStatus> curActionFunc;//记录当前运行的动作函数 /// <summary>
/// 初始化代理器
/// </summary>
/// <param name="baseWorldState">世界状态,用来复制成自身状态</param>
/// <param name="actionSet">动作集</param>
public GoapAgent(GoapWorldState baseWorldState, GoapActionSet actionSet)
{
curSelfState = new GoapWorldState(baseWorldState);
curSelfState.SetValues(baseWorldState.Values);
curSelfState.SetDontCare(baseWorldState.DontCare);
actionFuncs = new Dictionary<string, Func<EStatus>>();
this.actionSet = actionSet;
}
/// <summary>
/// 修改自身状态值
/// </summary>
public bool SetAtomValue(string stateName, bool value)
{
return curSelfState.SetAtomValue(stateName, value);
}
/// <summary>
/// 为动作名设置对应的动作函数
/// </summary>
public void SetActionFunc(string actionName, Func<EStatus> func)
{
actionFuncs.Add(actionName, func);
}
/// <summary>
/// 规划GOAP并运行
/// </summary>
/// <param name="curWorldState"></param>
/// <param name="goal"></param>
public void RunPlan(GoapWorldState curWorldState, GoapWorldState goal)
{
UpdateSelfState(curWorldState);//将自身的私有状态与世界的共享状态融合,得到真正的「当前世界状态」
if (curState == EStatus.Failure) //当前状态为「失败」,就表示动作执行失败
{
//那就重新规划,找出新的动作序列
actionPlan = GoapAstar.Plan(curSelfState, goal, actionSet);
}
if(curState == EStatus.Success)//执行结果为「成功」,表示动作顺利执行完
{
curAction.Effect_OnRun(curWorldState); //动作就会对全局世界状态造成影响
/*这同样要更新自身状态,以防这次改变的是「私有」状态,全局世界状态可是只维护「共享」部分。
所以需要自身状态也记录下这次影响,即便是共享状态也没关系,反正下次会与世界的共享状态融合*/
curSelfState.SetValues(curWorldState.Values);
}
//如果执行结果不是「运行中」,就表示上个动作要么成功了,要么失败了。都该取出动作序列中新的动作来执行
if (curState != EStatus.Running)
{
canContinue = actionPlan.TryDequeue(out string curActionName);
if (canContinue)//如果成功取出动作,就根据动作名,选出对应函数和动作
{
curActionFunc = actionFuncs[curActionName];
curAction = actionSet[curActionName];
}
}
curState = canContinue && curAction.MetCondition(curSelfState) ? curActionFunc() : EStatus.Failure;
}
/// <summary>
/// 更新自身状态的共享部分与当前世界状态同步
/// </summary>
private void UpdateSelfState(GoapWorldState curWorldState)
{
curSelfState.SetValues(curWorldState.Values & curWorldState.Shared | curSelfState.Values & ~curWorldState.Shared);
}
}

这个类中,RunPlan函数与上一期的HTN中的基本一样。但我想可能有些人还不大明白UpdateSelfState函数是如何融合自身状态与世界状态的,我就简单举个例吧:

可以看到得到的值,恰好保留了世界状态的共享部分和自身状态的私有部分。其实这也并非「恰好」,这样的位运算理应得到这样的结果才是。你也可以自己动手尝试一些值或者用更多位的数来验证。

项目链接

最后,这里附上一个小项目(是个自释放压缩包exe,运行解压后就可以得到unitypackage文件,导入空项目中即可),可以更直接地看到这些类是怎么被实际使用的。这个项目很简单,单纯的让一个角色根据目标点与自身锚点的距离来决定挥拳方式,还可以将面板的Finded(发现目标)设置为false,它会进行其它动作。这些都是用状态机就可以实现的,但你可以通过这个项目来比较二者之间的实现差别,加深对GOAP的了解。

GOAP的缺点主要是在设计难度上,它的设计相较FSM、行为树那些不那么直接,你需要把控好动作的条件和影响对应的状态,比其它决策方法更费脑子些。因为GOAP没有显示的结构,如何定义好一个状态,使它能在逻辑层面合理地成为一个动作的前提条件,又能成为另一个动作条件的影响结果(比如「有流量」想想看,将其做为条件可以设计什么动作?做为影响结果又应该怎么设计呢?)是比较考验开发人员的架构设计的。但毋庸置疑的是,在面对较复杂的AI时,它的代码量一定是小于FSM、行为树和HTN的。而且添加和减少动作也不需要进行过多代码修改,只要将新行动加入到动作集或将欲剔除的动作从动作集中删去就可以,这也是它没有显示结构的好处。

到这里就结束了捏,新的一年即将到来,祝大家学习进步、学有所成╰( ̄ω ̄o)。如果你对这篇文章内容有不解之处、不满之处,也欢迎评论区指出、严肃批评 (我有注意到的话

游戏AI行为决策——GOAP(目标导向型行动规划)的更多相关文章

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

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

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

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

  3. 王亮:游戏AI探索之旅——从alphago到moba游戏

    欢迎大家前往腾讯云+社区,获取更多腾讯海量技术实践干货哦~ 本文由云加社区技术沙龙 发表于云+社区专栏 演讲嘉宾:王亮,腾讯AI高级研究员.2013年加入腾讯,从事大数据预测以及游戏AI研发工作.目前 ...

  4. 【转】关于AI的目标导向型行动计划

    作者:Brent Owens 目标导向型行动计划(简称GOAP)是一种能够轻松呈现给你的代理选择的AI系统,也是帮助你可以无需维持一个庞大且复杂的有限状态机而做出明智的决策的机器. 演示版本 在这一演 ...

  5. 游戏AI——GOAP技术要点

    目录 什么是GOAP(Goal-Oriented Action Planning) 介绍 细节 难点与挑战 世界表达 具体类型表示 字符串表示 bool转化为枚举 规划器 Regoap流程 Middl ...

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

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

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

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

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

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

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

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

  10. 游戏AI之路径规划(3)

    目录 使用路径点(Way Point)作为节点 洪水填充算法创建路径点 使用导航网(Navigation Mesh)作为节点 区域分割 预计算 路径查询表 路径成本查询表 寻路的改进 平均帧运算 路径 ...

随机推荐

  1. Yaml配置文件语法详解

    YAML 简介 YAML,即 "YAML Ain't a Markup Language"(YAML 不是一种标记语言)的递归缩写,YAML 意思其实是" Yet Ano ...

  2. P1387

    #include<iostream> #include<utility> using namespace std; typedef long long ll; #define ...

  3. [oeasy]python0083_[趣味拓展]字体样式_正常_加亮_变暗_控制序列

    字体样式 回忆上次内容 上次了解了 一个新的转义模式 \033 逃逸控制字符 esc   esc 让输出 退出 标准输出流 进行 控制信息的设置 可以 清屏 也可以 设置光标输出的位置     还能做 ...

  4. oeasy教您玩转vim - 16 - # 行内贴靠

    行头行尾 回忆上节课内容 跳跃 向前跳跃是 f 向后跳跃是 F 继续 保持方向是 ; 改变方向是 , 可以加上 [count] 来加速 还有什么好玩的吗? 动手 #这次还是用无配置的方式启动 vi - ...

  5. PointNet笔记

    可能遇到的问题 在windows上运行pointnet的代码时,可能会遇到一些问题: 1.比如提示OSError: no file with expected extension, 这是因为可视化的s ...

  6. 制作KubeVirt镜像

    目录 制作KubeVirt镜像 1. 准备磁盘文件 2. 编写Dockerfile 3. 构建镜像 4. 上传镜像到仓库(可选) 5. 导出镜像 6. 虚拟机yaml文件 7. 启动虚拟机 8. 启动 ...

  7. 缓存框架 Caffeine 的可视化探索与实践

    作者:vivo 互联网服务器团队-  Wang Zhi Caffeine 作为一个高性能的缓存框架而被大量使用.本文基于Caffeine已有的基础进行定制化开发实现可视化功能. 一.背景 Caffei ...

  8. 解决cnpm syscall: ‘rename‘

    1.删了cnpm npm uninstall -g cnpm 2.指定版本下载cnpm npm install cnpm@7.1.0 -g

  9. 【Spring Data JPA】08 多表关系 Part1 一对多关系操作

    表关系概述: 1.一 对应 一 一对夫妻,一个男人只能有一个老婆,一个女人只能有一个老公,即这种对应关系 2.一 对应 多 [多对一] 一个年级具有多个班级,一个班级具有对应的所属年级,即这种上下层级 ...

  10. 英语表达中address和solve的区别

    "Address" 和 "solve" 都表示处理问题,但在具体用法和含义上有所不同: Address: 含义: 处理.应对.讨论或提及问题. 强调: 关注并开 ...