游戏AI行为决策——GOAP(附代码与项目)
新的一年即将到来,感觉还剩一种常见的游戏AI决策方法不讲的话,有些过意不去。就在这年的尾巴与大家一起交流下「目标导向型行为规划(GOAP)」吧!
另外,我觉得只是讲代码实现而没有联系具体项目,可能还是不容易理解的。所以这次我会在文末附上一个由本文所述代码实现的一个小demo,方便大家更好理解其运作。
前言
像先前提到的有限状态机、行为树、HTN,它们实现的AI行为,虽说能针对不同环境作出不同反应,但应对方法是写死了的。有限状态机终究是在几个状态间进行切换、行为树也是根据提前设计好的树来搜索……你会发现,游戏AI角色表现出的智能程度,终究与开发者的设计结构有关,就有限状态机而言,各个状态如何切换很大程度上就影响了AI智能的表现。
那有没有什么决策方法,能够仅需设计好角色需要的动作,而它自己就能合理决定要选择哪些动作完成目标呢?这样的话,角色AI的行为智能程度会更上一层楼,毕竟它不再被写死的决策结构束缚;我们在添加更多AI行为时,也可以简单地直接将它放在角色需要的动作集里就好,减少了工作量,不必像行为树那样,还要考虑节点间的连接。
没错,GOAP就可以做到。(咳咳,虽说为了突出GOAP的特点进行了一番拉踩(ˉ▽ˉ;)。但请注意,并不是说GOAP就比其它决策方法好,后面也会提到它的缺点。选择何种决策方法还得根据实际项目和自身需求)
PS:本教程需要你具备以下前提知识:
运行逻辑
我们来看个简单的寻路问题:你能找到从A到B的最短路线吗?注意,道路是单向的哦。
聪明如你,这并不难找到:
现在,加大难度,假设每条道路口都有一个门,红色表示门关上了,蓝色表示能开着,你还能找出可达成的最短A到B路线吗?
同样不难:
这样就足够了,GOAP的规划就是这么一个过程。只是把每个节点都当成一个状态,每条道路都当作一个动作、道路长度作为动作代价、路口的门作为动作执行条件,然后像你这样寻找出一条可以执行的最短「路线」,并记录下途径的道路(注意,不是节点)这样就得到了 「动作序列」,再让AI角色逐一执行。GOAP中的图会长成下面这样(偷懒了≡(▔﹏▔)≡,只画出了一条路的样子,但相信你们能举一反三的):
GOAP就是在不断执行「从现有状态到目标状态」,上图中的 「现有状态」 和 「目标状态」 分别就是「饿」和「饱」。请注意,虽说用了不同形状,但中间的那些椭圆节点,比如「在上网」,也是和「饿」、「饱」同类别的存在。也就是说「在上网」也可以作为现有状态或目标状态。
可想而知,只要状态够多,动作够多,AI就能做出更复杂的动作。虽说这对其它决策方法也成立,但GOAP不需要我们显示地手动设置各动作、状态之间的关系,它能自行规划出要做的一系列动作,更省事且更智能,甚至可以规划出超出原本设想但又合理的动作序列。
希望我讲明白了它的运作,下面一起来实现一个简单的GOAP进一步了解吧!顺带提一嘴,在Unity资源商店有免费的GOAP插件,并且做了可视化处理以及多线程优化,各位真的想将GOAP运用于项目的话,更推荐去学习使用成熟的插件。ˋ( ° ▽、° )
代码实现
代码实现参考了GitHub上一C语言版本的GOAP。
1. 世界状态
所谓「世界状态」其实就是存储所有的状态放在一块儿的合集。而状态其实还有一个隐藏身份——动作条件。是的,状态也充当了动作的执行条件,比如之前图中的条件「有流量」,它其实也是一个状态。
世界状态会因 自然因素 变化,比如「饱」会随着时间流逝而变「饿」;也会因角色自身的一些 动作导致 变化,比如一个角色多运动,也会使「饱」变「饿」。
问题在于:
- GOAP规划需要时时获取最新的状态,才能保证规划结果的合理性(否则饿晕了还想着运动);
- 「世界状态」中有些状态是「共享」的,比如之前说的时间,但还有一些状态是私有的,比如「饱」,是我饱、你饱还是他饱?在一个合集里该如何区分?
噢~如果你看过上一篇关于HTN的文章的话,你会发现这是如此的眼熟。不过没看过也没关系,我们将采取一种新的实现「世界状态」的方法——原子表示。
PS:在传统人工智能Agent中,对于环境的表示方式有三种:
- 原子表示(Atomic):就是单纯描述某个状态有无,通常每个状态都只用布尔值(True/False)表示就可以,比如「有流量」。
- 要素化表示(Factored):进一步描述状态的具体数值,这时,状态可以有不同的类型,可以是字符串、整数、布尔值……在HTN中,我们就是用这种方式实现的。
- 结构化表示(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>
public GoapWorldState Effect_OnPlan(GoapWorldState worldState)
{
var res = new GoapWorldState();
var care = ~effect.DontCare;
var newState = (worldState.Values & effect.DontCare) | (effect.Values & care);
res.SetValues(newState);
res.SetDontCare(worldState.DontCare & effect.DontCare);
return res;
}
/// <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)
{
return Equals(heap[value.HeapIndex], value);
}
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);//启发值比较
}
}
4. 动作集
照理说,动作集不过是动作的集合,单独将它也制成一个类,是为了方便「动作序列」规划,主要体现在GetPossibleTrans函数,根据传入的节点的世界状态,在合集中遍历出「前提条件」满足的动作:
public class GoapActionSet
{
//动作存储字典,键为动作名字,值为GoapAction动作
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="start">起始状态,用于启发函数计算</param>
/// <param name="goal">目标状态,同样用于启发函数计算</param>
/// <param name="actionNames">用于存储找到的可行动作的名字,有名字方便找到动作函数</param>
/// <returns>找到的所有可达节点</returns>
public List<GoapAstarNode> GetPossibleTrans(GoapAstarNode curNode, GoapWorldState start, GoapWorldState goal, 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.MetCondition(curState) ) //如果动作条件满足就记录下来
{
actionNames.Add(act.Key);
var nextState = act.Value.Effect_OnPlan(curState); //获得影响后的世界状态副本,以便进一步规划
neighbors.Add(new GoapAstarNode(nextState, curNode, start.CalcCorrelation(nextState), goal, 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="start">初始世界状态</param>
/// <param name="goal">目标世界状态</param>
/// <param name="actionSet">动作集</param>
/// <returns>需执行的动作名称,弹出顺序即为执行顺序</returns>
public static Stack<string> Plan(GoapWorldState start, GoapWorldState goal, GoapActionSet actionSet)
{
openList.Clear();
closeList.Clear();
var n0 = new GoapAstarNode(start, null, 0, goal, default);
openList.PushHeap(n0);
var goalCare = ~goal.DontCare;
var goalVal = goal.Values & goalCare;
while(!openList.IsEmpty)
{
var curState = openList.Top;
closeList.Add(curState);
openList.PopHeap();
if((curState.WorldState.Values & goalCare) == goalVal || openList.IsFull)
{
return GenerateFinalPlan(curState);
}
var neighbors = actionSet.GetPossibleTrans(curState, start, goal, 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 Stack<string>();
}
/// <summary>
/// 根据最终节点回溯,获取最终执行动作集
/// </summary>
/// <param name="endNode"></param>
/// <returns>动作栈,弹出顺序即为执行顺序</returns>
private static Stack<string> GenerateFinalPlan(GoapAstarNode endNode)
{
var planStack = new Stack<string>();
if (endNode.Parent == null)
{
return planStack;
}
planStack.Push(endNode.FromActionName);
var tpNode = endNode.Parent;
while(tpNode.Parent != null)
{
planStack.Push(tpNode.FromActionName);
tpNode = tpNode.Parent;
}
return planStack;
}
}
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 Stack<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.TryPop(out string curActionName);
if (canContinue)//如果成功取出动作,就根据动作名,选出对应函数和动作
{
curActionFunc = actionFuncs[curActionName];
curAction = actionSet[curActionName];
}
}
/*如果canContinue为false,那curActionFunc为null,也视作失败(其
实应该是「全部完成」,但全部完成和失败是一样的,都要重新规划)。所
以只有当canContinue && 当前动作条件满足 时,才读取当前原子动作的运行状态,否则就视为「失败」。*/
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的了解。
到这里就结束了捏,新的一年即将到来,祝大家学习进步、学有所成╰( ̄ω ̄o)。如果你对这篇文章内容有不解之处、不满之处,也欢迎评论区指出、严肃批评 (我有注意到的话