首页 > 其他分享 >游戏AI行为决策——GOAP(目标导向型行动规划)

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

时间:2023-12-30 21:00:50浏览次数:31  
标签:状态 动作 导向 AI private int GOAP GoapWorldState public

游戏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)。如果你对这篇文章内容有不解之处、不满之处,也欢迎评论区指出、严肃批评 (我有注意到的话

标签:状态,动作,导向,AI,private,int,GOAP,GoapWorldState,public
From: https://www.cnblogs.com/OwlCat/p/17936809

相关文章

  • [Codeforces] CF1547E Air Conditioners
    CF1547EAirConditioners题目传送门这道题我的思路严重劣于题解思路,所以请慎用但是自认为我的贪心比dp好理解点题意有\(q\)组数据,每组第一排表示有\(n\)个方格和\(k\)个空调,第二排是每个空调的位置\(a_i\),第三排是每个空调的温度\(t_i\)。每个空调对周围的影响......
  • 【玩转腾讯混元大模型】怎么说?我用混元AI大模型开发了个IDEA插件
    前言halo我是杨不易呀,在混元大模型内测阶段就已经体验了一番当时打开页面的时候灵感模块让我大吃一惊这么多角色模型真的太屌了,随后我立马进行了代码处理水平和上下文的效果结果一般般但是到如今混元大模型代码处理水平提升超过20%,代码处理效果在实测中高于ChatGPT6.34%Human......
  • AI大模型引领数智未来【坚果派-坚果】
    AI大模型引领数智未来作者:坚果华为HDE,润开鸿生态技术专家,坚果派创始人,OpenHarmony布道师,开发者联盟优秀讲师,2023年开源之夏导师,2023年OpenHarmony应用创新赛导师,OpenHarmony金融应用创新赛导师,RISC-V+OpenHarmony应用创意赛导师,OpenHarmony三方库贡献者,开放原子开源基金会技术+生......
  • langchain 系列
    langchain-基础langchain-LCELlangchain-Documentslangchain-Modellangchain-Retrievallangchain-Chainslangchain-Agentlangchain-Memory与多轮对话......
  • 无涯教程-Java 正则 - Matcher StringBuffer appendTail(StringBuffer sb)函数
    java.time.Matcher.appendTail(StringBuffersb)方法实现了附加和替换操作。StringBufferappendTail-声明以下是java.time.Matcher.appendTail(StringBuffersb)方法的声明。publicMatcherappendTail(StringBuffersb)sb -目标字符串缓冲区。StringBufferappend......
  • 如何屏蔽各大AI公司爬虫User Agent
    罗列各大AI公司Scraper爬虫Crawler使用的UserAgent,教您如何在robots.txt里面屏蔽这些爬虫的访问,禁止它们下载您的网站内容以训练AI模型,保护数据,降低带宽,防止宕机GPTBotGPTBot是OpenAI使用的网络爬虫,用于下载LLM(大型语言模型)的训练数据,为ChatGPT等人工智能产品提供支持......
  • 新火种AI|AI正在让汽车成为“消费电子产品”
    作者:一号编辑:小迪AI正在让汽车产品消费电子化12月28日,铺垫许久的小米汽车首款产品——小米SU7正式在北京亮相。命里注定要造“电车”的雷军,在台上重磅发布了小米的五大自研核心技术。在车型设计、新能源技术以及智能科技方面都取得了突破。“科技大厂”小米正式驶入新能源赛道。雷......
  • IntelliJ IDEA 2023.3.2 的 AI Assistant 终于被激活了,但我是这样干的!
    大家好,欢迎来到程序视点!我是小二哥。前言在IntelliJIDEA2023.3.1发布后,每天都有小伙伴询问AIAssistant的激活问题。在JetBrainsIDE重磅推出的AI助手,我和大家一样,都想尽快解锁这一插件。幸运的是,在刚发布的IntelliJIDEA2023.3.2中,我终于激活了AIAssistant。AI......
  • 区域人数超员AI算法模型的应用介绍
    视频AI智能分析技术已经深入到人类生活的各个角落,与社会发展的方方面面紧密相连。从日常生活中的各种场景,如人脸识别、车牌识别,到工业生产中的安全监控,如工厂园区的翻越围栏识别、入侵识别、工地的安全帽识别、车间流水线产品的品质缺陷AI检测等,AI智能分析都发挥着不可或缺的作用。......
  • 探索大语言模型 :首场英智未来AI沙龙精彩回顾
    12月27日,英智未来主办的第一期英智AI沙龙《大语言模型创新应用与最新发展现状》在深圳南山顺利举行。本次沙龙汇集了来自IT、文娱、金融等行业的精英人士和AI爱好者,共同探讨大语言模型在各领域的创新应用及其发展趋势。  以大模型为核心的通用人工智能正在驱动新一轮智......