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

这篇具有很好参考价值的文章主要介绍了游戏AI行为决策——GOAP(目标导向型行动规划)。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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

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

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

前言

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

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

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

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

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

运行逻辑

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

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

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

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

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

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

同样不难:

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

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

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

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

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

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

代码实现

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

1. 世界状态

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

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

问题在于:

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

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


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

游戏AI行为决策——GOAP(目标导向型行动规划)
  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位不然画不下

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

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

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

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

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

项目链接

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

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

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

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

到了这里,关于游戏AI行为决策——GOAP(目标导向型行动规划)的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • 论文阅读笔记 — 第2篇 — 一种基于Epsilon支配的多目标优化问题档案导向均衡优化器

    同上一篇笔记。 论文阅读笔记 — 第1篇 — 一种具有全局优化策略的增强MSIQDE算法-CSDN博客 这一篇论文同样也属于群智能优化领域,主要研究其Abstarct和introduction以及论文结构,具体算法细节不深入探讨(群智能优化算法总体思路大都差不多)。如有兴趣或者需要用到该算法

    2024年01月22日
    浏览(49)
  • unity行为决策树实战详解

    一、行为决策树的概念 行为决策树是一种用于游戏AI的决策模型,它将游戏AI的行为分解为一系列的决策节点,并通过节点之间的连接关系来描述游戏AI的行为逻辑。在行为决策树中,每个节点都代表一个行为或决策,例如移动、攻击、逃跑等,而节点之间的连接关系则代表了

    2024年02月12日
    浏览(36)
  • 联想全面打造AI导向的智能基础设施,领跑中国智能化变革

    8月18日,“智算无限 全栈智能   联想AI算力战略暨AI服务器新品发布会” 在银川 成功 举办 。 会上,联想 对外 发布 联想AI算力战略以及两款AI服务器新品, 同时还推出了联想智算中心解决方案和服务核心产品 。 联想通过 AI内嵌的智能终端、AI导向的基础设施、AI原生的方案

    2024年02月12日
    浏览(44)
  • SQL SERVER ANALYSIS SERVICES决策树、聚类、关联规则挖掘分析电商购物网站的用户行为数据...

    假如你有一个购物类的网站,那么你如何给你的客户来推荐产品呢? ( 点击文末“阅读原文”获取完整文档、 数据 ) 相关视频 这个功能在很多电商类网站都有,那么,通过SQL Server Analysis Services的数据挖掘功能,你也可以轻松的来构建类似的功能。 将分为三个部分来演示

    2024年02月16日
    浏览(53)
  • 供应链的多目标协同决策

    随着产业革命从马力到算力,云平台上有了大数据、移动互联网、物联网、人工智能等技术。回望近两三百年的科技发展历程,在这个时代,不论是移动互联,还是元宇宙,社会的方方面面都在数字化。因为数字化,我们更可以精确到个人,甚至是不同阶段的不同需求。买过

    2024年02月11日
    浏览(52)
  • 目标检测应用场景—数据集【NO.25】牛行为检测数据集

      写在前面:数据集对应应用场景,不同的应用场景有不同的检测难点以及对应改进方法,本系列整理汇总领域内的数据集,方便大家下载数据集,若无法下载可关注后私信领取。关注免费领取整理好的数据集资料!今天分享一个 非常好的非常小众的研究方向,有应用创新

    2024年01月19日
    浏览(34)
  • 基于Yolov5+Deepsort+SlowFast算法实现视频目标识别、追踪与行为实时检测

    前段时间打算做一个目标行为检测的项目,翻阅了大量资料,也借鉴了不少项目,最终感觉Yolov5+Deepsort+Slowfast实现实时动作检测这个项目不错,因此进行了实现。 总的来说,我们需要能够实现实时检测视频中的人物,并且能够识别目标的动作,所以我们拆解需求后,整理核心

    2024年01月20日
    浏览(68)
  • 【数据分析项目实战】篇1:游戏数据分析——新增、付费和用户行为评估

    目录 0 结论 1 背景介绍 1.1 游戏介绍 1.2 数据集介绍 2 分析思路 3 新增用户分析 3.1 新增用户数: 3.2 每日新增用户数: 3.3 分析 4 活跃度分析 4.1 用户平均在线时长 4.2 付费用户平均在线时长 4.3 日活跃用户(日平均在线时长10min)数及占比 4.4 分析与建议 5 游戏行为分析 5.1 对比

    2023年04月08日
    浏览(107)
  • AI - 决策树模型

    🤔决策树算法 决策树的思想来源可以追溯到古希腊时期,当时的哲学家们就已经开始使用类似于决策树的图形来表示逻辑推理过程。然而,决策树作为一种科学的决策分析工具,其发展主要发生在20世纪。 在20世纪50年代,美国兰德公司的研究人员在研究军事策略时首次提出

    2024年04月16日
    浏览(37)
  • 目标检测算法之YOLOv5在乒乓球赛事中运动员行为分析领域的应用实例详解(基础版--上)

    目录 YOLOv5乒乓球赛事中运动员行为分析 优化措施 优化代码 继续优化 在乒乓球赛事中,YOLOv5可以应用于运动员行为分析,通过实时识别和追踪运动员的动作,帮助教练分析技术动作,或者为观众提供更丰富的观赛体验。下面是一个简单的应用实例和相关代码片段。 首先,需

    2024年02月22日
    浏览(104)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包