游戏AI行为决策——GOAP(目标导向型行动规划)

发布时间 2023-12-30 21:01:12作者: OwlCat

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

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

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

前言

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

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

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

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

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

运行逻辑

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

image

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

image

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

image

同样不难:

image

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

image

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

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

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

代码实现

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

1. 世界状态

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

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

问题在于:

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

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


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

image
  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位不然画不下

image

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

如果去除注释,这个类的内容其实并不多,在使用时几乎只要用到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函数是如何融合自身状态与世界状态的,我就简单举个例吧:

image

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

项目链接

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

image

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