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 依赖项添加到我们的项目中:
- 在 Unity 中,单击窗口 > 包管理器以启动包管理器窗口。
- 单击窗口左上角的+ (加号)并选择Add package from git URL以显示文本字段。
- 在未标记的文本框中键入或粘贴
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 中,我们使用图,其中每个State
和Transition
都采用节点的形式。 输入和/或输出连接使节点能够与我们图中的任何或所有其他节点相关联。
让我们想象一个具有三个输入值的节点:两个任意值和一个布尔值。 该节点将输出两个任意类型的输入值之一,具体取决于布尔输入是真还是假。
要将现有的 FSM 转换为图,我们修改State
和Transition
类以继承Node
类而不是ScriptableObject
类。 我们创建一个NodeGraph
类型的图形对象来包含我们所有的State
和Transition
对象。
修改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
类将包含一个名为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 | 支持StateNode 和RemainInStateNode 的基类 |
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 设置的关系和连接,因此我们不能像往常一样对待这些值。 考虑我们如何迭代Actions
与Transitions
:
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
时所做的那样。 StateNode
和TransitionNode
之间的一个主要区别是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 编辑器窗口中,右键单击以显示一个下拉菜单,其中列出了State 、 Transition和RemainInState 。 如果该窗口不可见,请双击EnemyGraph
文件以启动 xNode Graph 编辑器窗口。
要创建
Chase
和Patrol
状态:右键单击并选择State以创建一个新节点。
将节点命名为
Chase
。返回下拉菜单,再次选择State以创建第二个节点。
将节点命名为
Patrol
。将现有的
Chase
和Patrol
操作拖放到它们新创建的相应状态。
要创建过渡:
右键单击并选择转换以创建一个新节点。
将
LineOfSightDecision
对象分配给转换的Decision
字段。
创建
RemainInState
节点:- 右键单击并选择RemainInState以创建一个新节点。
要连接图表:
将
Patrol
节点的Transitions
输出连接到Transition
节点的Entry
输入。将
Transition
节点的True State
输出连接到Chase
节点的Entry
输入。将
Transition
节点的False State
输出连接到Remain In State
节点的Entry
输入。
该图应如下所示:
图中没有任何内容表明哪个节点( Patrol
或Chase
状态)是我们的初始节点。 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 编辑器窗口中:
- 右键单击并选择初始节点以创建一个新节点。
- 将FSM Node的输出附加到
Patrol
节点的Entry
输入。
该图现在应如下所示:
为了让我们的生活更轻松,我们将添加到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
的Init
和Execute
方法。 覆盖Init
将CurrentState
设置为图的初始状态,覆盖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 编辑器的项目窗口中:
打开 SampleScene 资产。
在 Unity 层次结构窗口中找到我们的
Enemy
游戏对象。将
BaseStateMachine
组件替换为BaseStateMachineGraph
组件:单击添加组件并选择正确的
BaseStateMachineGraph
脚本。将我们的 FSM 图
EnemyGraph
分配给BaseStateMachineGraph
组件的Graph
字段。通过右键单击并选择Remove Component来删除
BaseStateMachine
组件(因为它不再需要)。
Enemy
游戏对象应如下所示:
而已! 现在我们有了一个带有图形编辑器的模块化 FSM。 单击Play按钮显示以图形方式创建的敌人 AI 的工作方式与我们之前创建的ScriptableObject
敌人完全一样。
砥砺前行:优化我们的 FSM
提醒一句:当您为游戏开发更复杂的 AI 时,状态和转换的数量会增加,并且 FSM 会变得混乱且难以阅读。 图形编辑器变得类似于一个由多个状态开始并在多个转换处终止的线网,反之亦然,这使得 FSM 难以调试。
与之前的教程一样,我们邀请您编写自己的代码,优化您的潜行游戏,并解决这些问题。 想象一下,对状态节点进行颜色编码以指示节点是活动还是非活动,或者调整RemainInState
和Initial
节点的大小以限制其屏幕空间会有多大帮助。
这种增强不仅仅是装饰性的。 颜色和尺寸参考将有助于确定调试的地点和时间。 直观的图表也更易于评估、分析和理解。 任何后续步骤都取决于您 — 在我们的图形编辑器的基础上,您可以进行的开发人员体验改进没有限制。
进一步阅读 Toptal 工程博客:
- Unity 开发人员最常犯的 10 个错误
- Unity 与 MVC:如何升级您的游戏开发
- 在 Unity 中掌握 2D 相机:游戏开发者教程
- Toptal 开发人员提供的 Unity 最佳实践和技巧
Toptal 工程博客对 Goran Lalic 对本文的专业知识和技术评论表示感谢。