Unity AI 开发:基于 xNode 的图形 FSM 教程

已发表: 2022-08-12

在“Unity AI 开发:有限状态机教程”中,我们创建了一个简单的隐形游戏——基于模块化 FSM 的 AI。 在游戏中,一名敌方特工在游戏空间内巡逻。 当它发现玩家时,敌人会改变状态并跟随玩家而不是巡逻。

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

快速复习

上一教程中详述的 FSM 是由 C# 脚本形式的架构块构建的。 我们将自定义ScriptableObject操作和决策添加为类。 ScriptableObject方法允许易于维护和定制的 FSM。 在本教程中,我们将 FSM 的拖放ScriptableObject替换为图形选项。

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

开始使用 xNode

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

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

  1. 在 Unity 中,单击窗口 > 包管理器以启动包管理器窗口。
  2. 单击窗口左上角的+ (加号)并选择Add package from git URL以显示文本字段。
  3. 在未标记的文本框中键入或粘贴https://github.com/siccity/xNode.git ,然后单击添加按钮。

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

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

可视化 xNode 构建环境

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

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

由中心的大矩形表示的 Branch 节点包含伪代码“If C == True A Else B”。左侧是三个矩形,每个矩形都有一个指向分支节点的箭头:“A(任意)”、“B(任意)”和“C(布尔)”。最后,Branch 节点有一个指向“输出”矩形的箭头。
一个示例Branch节点

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

修改BaseStateMachine以用作基本类型

通过向我们现有的BaseStateMachine类添加两个新的虚拟方法开始构建图形界面:

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

将这些方法声明为 virtual 允许我们覆盖它们,因此我们可以定义继承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 FSMGraph中的 AC# 类

目前, 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类将包含一个名为EntryFSMNodeBase类型的输入,以使我们能够将节点彼此连接。

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

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输出端口的TransitionNode (由辅助方法检索)。 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 的要求,即输出的值源自源节点——就像我们在构建StateNode时所做的那样。 StateNodeTransitionNode之间的一个主要区别是TransitionNode的输出可能只附加到一个节点。 在我们的例子中, GetFirst将获取连接到我们每个端口的一个节点(在 true 情况下转换到一个状态节点,在 false 情况下转换到另一个状态节点):

 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 Graph。 在 Unity 项目窗口中,右键单击EnemyAI文件夹并选择: Create > FSM > FSM Graph 。 为了让我们的图更容易识别,让我们将其重命名为EnemyGraph

在 xNode Graph 编辑器窗口中,右键单击以显示一个下拉菜单,其中列出了StateTransitionRemainInState 。 如果该窗口不可见,请双击EnemyGraph文件以启动 xNode Graph 编辑器窗口。

  1. 要创建ChasePatrol状态:

    1. 右键单击并选择State以创建一个新节点。

    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输入。

该图应如下所示:

四个节点表示为四个矩形,每个矩形的左上角都有 Entry 输入圆圈。从左到右,巡逻状态节点显示一个动作:巡逻动作。 Patrol 状态节点还在其右下角包含一个 Transitions 输出圆圈,该圆圈连接到 Transition 节点的 Entry 圆圈。过渡节点显示一个决策:LineOfSight。它的右下角有两个输出圆圈,True State 和 False State。 True State 连接到我们的第三个结构的入口圈,Chase 状态节点。 Chase 状态节点显示一个动作:Chase Action。 Chase 状态节点有一个Transitions 输出圆圈。 Transition 的两个输出圆中的第二个,False State,连接到我们的第四个也是最后一个结构的 Entry 圆,RemainInState 节点(出现在 Chase 状态节点下方)。
初步了解我们的 FSM 图

图中没有任何内容表明哪个节点( PatrolChase状态)是我们的初始节点。 BaseStateMachineGraph类检测到四个节点,但没有指示符,无法选择初始状态。

为了解决这个问题,让我们创建:

FSMInitialNode InitialNode类型的单个输出名为StateNode的类

我们的输出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. 右键单击并选择初始节点以创建一个新节点。
  2. FSM Node的输出附加到Patrol节点的Entry输入。

该图现在应如下所示:

与我们之前的图像相同的图表,在其他四个矩形的左侧添加了一个 FSM 节点绿色矩形。它有一个初始节点输出(由蓝色圆圈表示),连接到巡逻节点的“条目”输入(由深红色圆圈表示)。
我们的 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并覆盖BaseStateMachineInitExecute方法。 覆盖InitCurrentState设置为图的初始状态,覆盖Execute调用CurrentState上的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. BaseStateMachine组件替换为BaseStateMachineGraph组件:

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

    2. 将我们的 FSM 图EnemyGraph分配给BaseStateMachineGraph组件的Graph字段。

    3. 通过右键单击并选择Remove Component来删除BaseStateMachine组件(因为它不再需要)。

Enemy游戏对象应如下所示:

从上到下,在 Inspector 屏幕中,Enemy 旁边有一个检查。在标签下拉列表中选择“玩家”,在层下拉列表中选择“敌人”。变换下拉菜单显示位置、旋转和缩放。 Capsule 下拉菜单被压缩,Mesh Renderer、Capsule Collider 和 Nav Mesh Agent 下拉菜单显示为压缩状态,并在其左侧打勾。 Enemy Sight Sensor 下拉菜单显示 Script and Ignore Mask。 PatrolPoints 下拉菜单显示脚本和四个 PatrolPoints。 Base State Machine Graph (Script) 下拉列表旁边有一个复选标记。 Script显示“BaseStateMachineGraph”,Initial State显示“None(Base State),Graph显示“EnemyGraph(FSM Graph)。”最后,Blue Enemy(Material)下拉框被压缩,下方出现“Add Component”按钮它。
我们的Enemy游戏对象

而已! 现在我们有了一个带有图形编辑器的模块化 FSM。 单击Play按钮显示以图形方式创建的敌人 AI 的工作方式与我们之前创建的ScriptableObject敌人完全一样。

砥砺前行:优化我们的 FSM

提醒一句:当您为游戏开发更复杂的 AI 时,状态和转换的数量会增加,并且 FSM 会变得混乱且难以阅读。 图形编辑器变得类似于一个由多个状态开始并在多个转换处终止的线网,反之亦然,这使得 FSM 难以调试。

与之前的教程一样,我们邀请您编写自己的代码,优化您的潜行游戏,并解决这些问题。 想象一下,对状态节点进行颜色编码以指示节点是活动还是非活动,或者调整RemainInStateInitial节点的大小以限制其屏幕空间会有多大帮助。

这种增强不仅仅是装饰性的。 颜色和尺寸参考将有助于确定调试的地点和时间。 直观的图表也更易于评估、分析和理解。 任何后续步骤都取决于您 — 在我们的图形编辑器的基础上,您可以进行的开发人员体验改进没有限制。

进一步阅读 Toptal 工程博客:

  • Unity 开发人员最常犯的 10 个错误
  • Unity 与 MVC:如何升级您的游戏开发
  • 在 Unity 中掌握 2D 相机:游戏开发者教程
  • Toptal 开发人员提供的 Unity 最佳实践和技巧

Toptal 工程博客对 Goran Lalic 对本文的专业知识和技术评论表示感谢。