Unity人工智能开发—基于xNode的图形化FSM教程

685 阅读13分钟

Unity人工智能开发。基于xNode的图形化FSM教程

用xNode把你的Unity AI游戏提升到新的水平。在本教程中,我们用图形用户界面来提升我们基于FSM的人工智能,提供一个增强的开发环境。

在《Unity人工智能开发。在 "Unity人工智能开发:有限状态机教程"中,我们创建了一个简单的隐身游戏--一个基于FSM的模块化人工智能。在游戏中,一个敌方特工在游戏空间中巡逻。当它发现玩家时,敌人会改变其状态,跟随玩家而不是巡逻。

在我们Unity之旅的第二站,我们将建立一个图形用户界面(GUI)来更快速地创建我们的有限状态机(FSM)的核心组件,并改善Unity开发者的体验

快速复习

前面的教程中详细介绍的FSM是由C#脚本的架构块构建的。我们将自定义的 ScriptableObject行动和决定作为类。这种ScriptableObject 的方法使得FSM易于维护和定制。在本教程中,我们用图形选项取代FSM的拖放式ScriptableObjects。

我还为那些想让游戏更容易获胜的人写了一个更新的脚本。要实现这一点,只需用这个缩小敌人视野的脚本来替换玩家检测脚本即可。

开始使用xNode

我们将使用xNode建立我们的图形编辑器,这是一个基于节点的行为树框架,可以直观地显示我们的FSM流程。虽然Unity的GraphView可以完成这项工作,但它的API是实验性的,而且记录很少。xNode的用户界面提供了卓越的开发者体验,便于我们的FSM的原型设计和快速扩展。

让我们使用Unity软件包管理器将xNode作为一个Git依赖项添加到我们的项目中:

  1. 在Unity中,点击Window > Package Manager来启动Package Manager窗口。
  2. 点击窗口左上角的**+(加号),选择从git URL添加包**,显示一个文本字段。
  3. 在没有标记的文本框中输入或粘贴https://github.com/siccity/xNode.git ,然后点击添加按钮。

现在我们准备深入了解xNode的关键组件了:

Node代表一个节点,一个图的最基本单位。在这个xNode教程中,我们从Node 类中派生出一些新的类,这些类声明配备有自定义功能和角色的节点。
NodeGraph代表一个节点的集合(Node 类实例)和连接它们的边。在这个xNode教程中,我们从NodeGraph 中派生出一个新的类,用于操作和评估节点。
NodePort代表一个通信门,一个输入或输出类型的端口,位于NodeGraphNode 实例之间。NodePort 类是xNode独有的。
[Input] 属性[Input] 属性添加到一个端口,指定它为一个输入,使该端口能够将值传递给它所在的节点。可以把[Input] 属性看作是一个函数参数。
[Output] 属性[Output] 属性添加到端口中, 将其指定为输出, 使得端口能够从其所属的节点中传递数值。把[Output] 属性看成是一个函数的返回值。

视觉化的xNode构建环境

在xNode中,我们用图来工作,每个StateTransition 都是一个节点的形式。输入和/或输出连接使节点能够与我们图中的任何或所有其他节点相联系。

让我们想象一个有三个输入值的节点:两个任意值和一个布尔值。该节点将输出两个任意类型的输入值中的一个,这取决于布尔输入是真还是假。

一个例子Branch 节点

为了将我们现有的FSM转换为图形,我们修改了StateTransition 类来继承 Node类而不是ScriptableObject 类。我们创建一个类型为NodeGraph的图对象,以包含我们所有的StateTransition 对象。

修改BaseStateMachine ,作为基础类型使用

开始构建图形界面,为我们现有的BaseStateMachine 类添加两个新的虚拟方法。

Init将初始状态分配给CurrentState 属性
Execute执行当前状态

将这些方法声明为虚拟方法允许我们覆盖它们,因此我们可以定义继承BaseStateMachine 类的初始化和执行的自定义行为:

using System;
using System.Collections.Generic;
using UnityEngine;

namespace Demo.FSM
{
    public class BaseStateMachine : MonoBehaviour
    {
        [SerializeField] private BaseState _initialState;
        private Dictionary<Type, Component> _cachedComponents;
        private void Awake()
        {
            Init();
            _cachedComponents = new Dictionary<Type, Component>();
        }

        public BaseState CurrentState { get; set; }

        private void Update()
        {
            Execute();
        }

        public virtual void Init()
        {
            CurrentState = _initialState;
        }

        public virtual void Execute()
        {
            CurrentState.Execute(this);
        }

       // Allows us to execute consecutive calls of GetComponent in O(1) time
        public new T GetComponent<T>() where T : Component
        {
            if(_cachedComponents.ContainsKey(typeof(T)))
                return _cachedComponents[typeof(T)] as T;

            var component = base.GetComponent<T>();
            if(component != null)
            {
                _cachedComponents.Add(typeof(T), component);
            }
            return component;
        }

    }
}

接下来,在我们的FSM 文件夹下,让我们创建:

FSMGraph一个文件夹
BaseStateMachineGraph一个C#类在其中FSMGraph

目前,BaseStateMachineGraph 将只继承BaseStateMachine 类:

using UnityEngine;

namespace Demo.FSM.Graph
{
    public class BaseStateMachineGraph : BaseStateMachine
    {
    }
}

在我们创建我们的基础节点类型之前,我们不能向BaseStateMachineGraph 添加功能;接下来让我们来做这个。

实现NodeGraph ,并创建一个基础节点类型

在我们新创建的FSMGraph 文件夹下,我们将创建。

FSMGraph一个类

现在,FSMGraph 将仅仅继承NodeGraph 类(没有增加功能):

using UnityEngine;
using XNode;

namespace Demo.FSM.Graph
{
    [CreateAssetMenu(menuName = "FSM/FSM Graph")]
    public class FSMGraph : NodeGraph
    {
    }
}

在我们为我们的节点创建类之前,让我们添加:

FSMNodeBase一个类,作为我们所有节点的基类。

FSMNodeBase 类将包含一个名为Entry 的输入,类型为FSMNodeBase ,以使我们能够将节点彼此连接起来。

我们还将添加两个辅助函数:

GetFirst检索连接到请求输出的第一个节点
GetAllOnPort检索所有连接到请求的输出的剩余节点
using System.Collections.Generic;
using XNode;

namespace Demo.FSM.Graph
{
    public abstract class FSMNodeBase : Node
    {
        [Input(backingValue = ShowBackingValue.Never)] public FSMNodeBase Entry;

        protected IEnumerable<T> GetAllOnPort<T>(string fieldName) where T : FSMNodeBase
        {
            NodePort port = GetOutputPort(fieldName);
            for (var portIndex = 0; portIndex < port.ConnectionCount; portIndex++)
            {
                yield return port.GetConnection(portIndex).node as T;
            }
        }

        protected T GetFirst<T>(string fieldName) where T : FSMNodeBase
        {
            NodePort port = GetOutputPort(fieldName);
            if (port.ConnectionCount > 0)
                return port.GetConnection(0).node as T;
            return null;
        }
    }
} 

最终,我们将有两种类型的状态节点;让我们添加一个类来支持这些节点:

BaseStateNode一个基类支持StateNodeRemainInStateNode
namespace Demo.FSM.Graph
{
    public abstract class BaseStateNode : FSMNodeBase
    {
    }
} 

接下来,修改BaseStateMachineGraph 类:

using UnityEngine;
namespace Demo.FSM.Graph
{
    public class BaseStateMachineGraph : BaseStateMachine
    {
        public new BaseStateNode CurrentState { get; set; }
    }
}

在这里,我们隐藏了从基类继承的CurrentState 属性,并将其类型从BaseState 改为BaseStateNode

为我们的FSM图创建构件

接下来,为了形成我们的FSM的主要构件,让我们在我们的FSMGraph 文件夹中添加三个新类。

StateNode代表一个代理的状态。在执行时,StateNode 遍历连接到StateNode 输出端口的TransitionNodes(通过一个辅助方法检索)。StateNode 询问每一个节点是否过渡到不同的状态或保持节点的状态不变。
RemainInStateNode表示节点应保持在当前状态。
TransitionNode做出过渡到不同状态或保持原状的决定。

之前的Unity FSM教程中,State 类对转换列表进行迭代。在xNode中,StateNode 作为State的等价物来迭代通过我们的GetAllOnPort 辅助方法检索的节点。

现在给出站连接(过渡节点)添加一个[Output] 属性,以表明它们应该是GUI的一部分。根据xNode的设计,该属性的值来自于源节点:包含标有[Output] 属性的字段的节点。由于我们正在使用[Output][Input] 属性来描述关系和连接,这些关系和连接将被xNode GUI设置,我们不能像通常那样对待这些值。考虑一下我们如何通过ActionsTransitions 进行迭代。

using System.Collections.Generic;
namespace Demo.FSM.Graph
{
    [CreateNodeMenu("State")]
    public sealed class StateNode : BaseStateNode 
    {
        public List<FSMAction> Actions;
        [Output] public List<TransitionNode> Transitions;
        public void Execute(BaseStateMachineGraph baseStateMachine)
        {
            foreach (var action in Actions)
                action.Execute(baseStateMachine);
            foreach (var transition in GetAllOnPort<TransitionNode>(nameof(Transitions)))
                transition.Execute(baseStateMachine);
        }
    }
}

在这种情况下,Transitions 输出可以有多个节点连接到它;我们必须调用GetAllOnPort 辅助方法来获得[Output] 的连接列表。

RemainInStateNode 到目前为止是我们最简单的类。没有执行任何逻辑, ,只是指示我们的代理--我们游戏中的敌人--保持其当前状态。RemainInStateNode

namespace Demo.FSM.Graph
{
    [CreateNodeMenu("Remain In State")]
    public sealed class RemainInStateNode : BaseStateNode
    {
    }
}

在这一点上,TransitionNode 类仍然是不完整的,不会被编译。一旦我们更新这个类,相关的错误就会清除。

为了构建TransitionNode ,我们需要绕过xNode的要求,即输出的值起源于源节点--就像我们在构建StateNodeStateNodeTransitionNode 的一个主要区别是,TransitionNode的输出可能只依附于一个节点。在我们的例子中,GetFirst 将获取附着在我们每个端口上的一个节点(一个状态节点在真情况下过渡到,另一个在假情况下过渡到)。

namespace Demo.FSM.Graph
{
    [CreateNodeMenu("Transition")]
    public sealed class TransitionNode : FSMNodeBase
    {
        public Decision Decision;
        [Output] public BaseStateNode TrueState;
        [Output] public BaseStateNode FalseState;
        public void Execute(BaseStateMachineGraph stateMachine)
        {
            var trueState = GetFirst<BaseStateNode>(nameof(TrueState));
            var falseState = GetFirst<BaseStateNode>(nameof(FalseState));
            var decision = Decision.Decide(stateMachine);
            if (decision && !(trueState is RemainInStateNode))
            {
                stateMachine.CurrentState = trueState;
            }
            else if(!decision && !(falseState is RemainInStateNode))
                stateMachine.CurrentState = falseState;
        }
    }
}

让我们看看我们的代码所产生的图形结果。

创建视觉图

随着所有FSM类的整理,我们可以继续为游戏中的敌人代理创建FSM图。在Unity项目窗口中,右键单击EnemyAI 文件夹并选择。创建 > FSM > FSM图。为了使我们的图更容易识别,让我们把它改名为EnemyGraph

在xNode Graph编辑器窗口中,右击显示一个下拉菜单,列出State(状态)、Transition(过渡)和RemainInState(保持状态)。如果该窗口不可见,双击EnemyGraph 文件以启动xNode Graph编辑器窗口:

  1. 要创建ChasePatrol 状态:

    1. 右键单击并选择状态来创建一个新的节点。

    2. 将该节点命名为Chase

    3. 回到下拉菜单,再次选择State来创建第二个节点。

    4. 将该节点命名为Patrol

    5. 将现有的ChasePatrol 动作拖到新创建的相应状态。

  2. 要创建过渡:

    1. 右键单击并选择 "过渡"来创建一个新节点。

    2. LineOfSightDecision 对象分配到过渡的Decision 领域。

  3. 要创建RemainInState 节点:

    1. 右击并选择RemainInState来创建一个新节点。
  4. 要连接图形:

    1. Patrol 节点的Transitions 输出连接到Transition 节点的Entry 输入。

    2. Transition 节点的True State 输出连接到Chase 节点的Entry 输入。

    3. Transition 节点的False State 输出连接到Remain In State 节点的Entry 输入。

初步了解我们的FSM图

图中没有任何东西表明哪个节点--PatrolChase 状态是我们的初始节点。BaseStateMachineGraph 类检测到了四个节点,但是由于没有指示器,所以无法选择初始状态。

为了解决这个问题,我们来创建。

FSMInitialNode一个类,其类型为StateNode 的单一输出被命名为InitialNode

我们的输出InitialNode ,表示初始状态。接下来,在FSMInitialNode ,创建。

NextNode一个属性,使我们能够获取连接到InitialNode 输出的节点。
using XNode;
namespace Demo.FSM.Graph
{
    [CreateNodeMenu("Initial Node"), NodeTint("#00ff52")]
    public class FSMInitialNode : Node
    {
        [Output] public StateNode InitialNode;
        public StateNode NextNode
        {
            get
            {
                var port = GetOutputPort("InitialNode");
                if (port == null || port.ConnectionCount == 0)
                    return null;
                return port.GetConnection(0).node as StateNode;
            }
        }
    }
}

现在我们创建了FSMInitialNode 类,我们可以把它连接到初始状态的Entry 输入,并通过NextNode 属性返回初始状态。

让我们回到我们的图中,添加初始节点。在xNode编辑器窗口中:

  1. 右键单击并选择Initial Node来创建一个新的节点。
  2. FSM节点的输出附加到Patrol 节点的Entry 输入。

现在图应该是这样的。

我们的FSM图在初始节点上附加了巡逻状态

为了使我们的生活更容易,我们将在FSMGraph

InitialState一个属性

当我们第一次尝试检索InitialState 属性的值时,该属性的getter将遍历图中的所有节点,因为它试图找到FSMInitialNode 。一旦找到了FSMInitialNode ,我们就使用NextNode 属性来找到我们的初始状态节点。

using System.Linq;
using UnityEngine;
using XNode;
namespace Demo.FSM.Graph
{
    [CreateAssetMenu(menuName = "FSM/FSM Graph")]
    public sealed class FSMGraph : NodeGraph
    {
        private StateNode _initialState;
        public StateNode InitialState
        {
            get
            {
                if (_initialState == null)
                    _initialState = FindInitialStateNode();
                return _initialState;
            }
        }
        private StateNode FindInitialStateNode()
        {
            var initialNode = nodes.FirstOrDefault(x => x is FSMInitialNode);
            if (initialNode != null)
            {
                return (initialNode as FSMInitialNode).NextNode;
            }
            return null;
        }
    }
}

接下来,在我们的BaseStateMachineGraph ,让我们引用FSMGraph ,并重写我们的BaseStateMachine'sInitExecute 方法。覆盖InitCurrentState 设置为图的初始状态,覆盖ExecuteCurrentState 上调用Execute

using UnityEngine;
namespace Demo.FSM.Graph
{
    public class BaseStateMachineGraph : BaseStateMachine
    {
        [SerializeField] private FSMGraph _graph;
        public new BaseStateNode CurrentState { get; set; }
        public override void Init()
        {
            CurrentState = _graph.InitialState;
        }
        public override void Execute()
        {
            ((StateNode)CurrentState).Execute(this);
        }
    }
}

现在,让我们把图应用到Enemy对象上,看看它的运行情况。

测试FSM图

为了准备测试,在Unity编辑器的项目窗口中:

  1. 打开SampleScene资产。

  2. 在Unity层次结构窗口中找到我们的Enemy 游戏对象。

  3. BaseStateMachineGraph 组件替换BaseStateMachine 组件。

    1. 点击添加组件并选择正确的BaseStateMachineGraph 脚本。

    2. 将我们的FSM图,EnemyGraph ,分配给BaseStateMachineGraph 组件的Graph 领域。

    3. 通过右击并选择删除组件,删除BaseStateMachine 组件(因为它不再需要了)。

Enemy 游戏对象应该看起来像这样。

我们的Enemy 游戏对象

就这样了!现在我们有了一个带有图形编辑器的模块化FSM。点击 "播放"按钮可以看到,以图形方式创建的敌人AI与我们之前创建的ScriptableObject 敌人完全一样。

勇往直前优化我们的FSM

有一点需要注意。随着你为你的游戏开发更复杂的人工智能,状态和转换的数量会增加,FSM会变得混乱和难以阅读。图形编辑器会变得像一张由多个状态和多个转换终止的线条组成的网,反之亦然,使FSM难以调试。

就像在上一个教程中一样,我们邀请你把代码变成你自己的,优化你的隐身游戏,并解决这些问题。想象一下,用颜色标记你的状态节点,以表明一个节点是活动的还是不活动的,或者调整RemainInStateInitial 节点的大小,以限制其屏幕空间,这将是多么有帮助。

这样的改进不仅仅是外观上的。颜色和大小的参考将有助于识别何时何地进行调试。一个容易看懂的图表也更容易评估、分析和理解。任何下一步都取决于你--在我们的图形编辑器的基础上,你可以对开发者的经验进行无限的改进。

了解基础知识

我们为什么要使用有限状态机?

有限状态机(FSM)是一种计算模型,在给定的时间内只能处于一种状态。因此,我们可以将任何需要这种功能的系统(例如,管理交通灯或电梯)表示为一个FSM。我们也可以用FSM来开发游戏AI和游戏管理系统。

人工智能是FSM的一种应用吗?

添加人工智能是实现FSM的一种方式。在FSM的情况下,人工智能采取编码类和自定义动作、决定和行为的形式。

FSM是图吗?

FSMs可以被表示为图。从理论上讲,图是顶点和边。在xNode FSM的情况下,顶点代表状态和转换,而边代表转换流。