Unity AI 开发:有限状态机教程
已发表: 2022-03-11在竞争激烈的游戏世界中,开发人员努力为那些与我们创建的非玩家角色 (NPC) 互动的人提供有趣的用户体验。 开发人员可以通过使用有限状态机 (FSM) 创建模拟 NPC 智能的 AI 解决方案来提供这种交互性。
AI 趋势已转向行为树,但 FSM 仍然相关。 它们以一种或另一种身份被整合到几乎所有电子游戏中。
FSM 剖析
FSM 是一种计算模型,其中一次只能激活有限数量的假设状态中的一个。 FSM 从一种状态转换到另一种状态,响应条件或输入。 其核心组件包括:
| 零件 | 描述 |
|---|---|
| 状态 | 指示 FSM 当前整体状况的一组有限选项之一; 任何给定的状态都包含一组关联的动作 |
| 行动 | FSM 查询状态时的状态 |
| 决定 | 确定何时发生转换的逻辑 |
| 过渡 | 改变状态的过程 |
虽然我们将从 AI 实现的角度关注 FSM,但动画状态机和一般游戏状态等概念也属于 FSM 范畴。
可视化 FSM
让我们以经典街机游戏吃豆人为例。 在游戏的初始状态(“追逐”状态)中,NPC 是色彩缤纷的幽灵,追逐并最终超越玩家。 每当玩家吃掉一个能量球并经历一次能量提升时,鬼魂就会进入闪避状态,从而获得吃鬼魂的能力。 鬼魂现在是蓝色的,它们会躲避玩家,直到加电超时,鬼魂会转换回追逐状态,在这种状态下它们的原始行为和颜色会恢复。
吃豆人幽灵总是处于两种状态之一:追逐或逃避。 自然,我们必须提供两种转换——一个从追逐到逃避,另一个从逃避到追逐:
根据设计,有限状态机查询当前状态,该状态查询该状态的决策和动作。 下图展示了我们的吃豆人示例,并显示了检查玩家上电状态的决定。 如果开始加电,NPC 会从追逐转变为躲避。 如果上电结束,NPC 会从躲避转变为追逐。 最后,如果没有上电变化,则不会发生转换。
可扩展性
FSM 让我们可以自由地构建模块化 AI。 例如,只需一个新动作,我们就可以创建一个具有新行为的 NPC。 因此,我们可以将一个新动作——吃能量球——归于我们的一个吃豆人幽灵,让它能够在躲避玩家的同时吃下能量球。 我们可以重用现有的操作、决策和转换来支持这种行为。
由于开发独特的 NPC 所需的资源很少,我们可以很好地满足多个独特 NPC 不断变化的项目需求。 另一方面,过多的状态和转换会使我们陷入意大利面条状态机——一种 FSM,其过多的连接使其难以调试和维护。
在 Unity 中实现 FSM
为了演示如何在 Unity 中实现有限状态机,让我们创建一个简单的隐形游戏。 我们的架构将包含ScriptableObject ,它们是可以在整个应用程序中存储和共享信息的数据容器,因此我们不需要复制它。 ScriptableObject能够进行有限的处理,例如调用动作和查询决策。 除了 Unity 的官方文档之外,如果您想深入了解,较早的Game Architecture with Scriptable Objects演讲仍然是一个极好的资源。
在我们将 AI 添加到这个初始的可编译项目之前,请考虑建议的架构:
在我们的示例游戏中,敌人(一个由蓝色胶囊代表的 NPC)巡逻。 当敌人看到玩家(由灰色胶囊表示)时,敌人开始跟随玩家:
与吃豆人相比,我们游戏中的敌人一旦跟随玩家就不会返回默认状态(“巡逻”)。
创建类
让我们从创建我们的类开始。 在一个新的scripts文件夹中,我们将所有建议的架构构建块添加为 C# 脚本。
实现BaseStateMachine类
BaseStateMachine类是我们将添加以访问启用 AI 的 NPC 的唯一MonoBehavior 。 为简单起见,我们的BaseStateMachine将是准系统。 但是,如果我们愿意,我们可以添加一个继承的自定义 FSM,它存储附加参数和对附加组件的引用。 请注意,在我们添加BaseState类之前,代码将无法正确编译,我们将在后面的教程中执行此操作。
BaseStateMachine的代码引用并执行当前状态以执行操作并查看是否需要转换:
using UnityEngine; namespace Demo.FSM { public class BaseStateMachine : MonoBehaviour { [SerializeField] private BaseState _initialState; private void Awake() { CurrentState = _initialState; } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } } } 实现BaseState类
我们的状态是从ScriptableObject派生的BaseState类型。 BaseState包括一个方法, Execute ,将BaseStateMachine作为其参数并将动作和转换传递给它。 这就是BaseState的样子:
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } 实现State和RemainInState类
我们现在从BaseState派生两个类。 首先,我们有State类,它存储对操作和转换的引用,包括两个列表(一个用于操作,另一个用于转换),并覆盖并调用操作和转换的基Execute :
using System.Collections.Generic; using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/State")] public sealed class State : BaseState { public List<FSMAction> Action = new List<FSMAction>(); public List<Transition> Transitions = new List<Transition>(); public override void Execute(BaseStateMachine machine) { foreach (var action in Action) action.Execute(machine); foreach(var transition in Transitions) transition.Execute(machine); } } } 其次,我们有RemainInState类,它告诉 FSM 何时不执行转换:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } 请注意,在我们添加FSMAction 、 Decision和Transition类之前,这些类不会编译。
实现FSMAction类
在提议的 FSM 架构图中,基本FSMAction类被标记为“Action”。 但是,我们将创建基础FSMAction类并使用名称FSMAction (因为Action已被 .NET System命名空间使用)。
FSMAction是一个ScriptableObject ,不能独立处理函数,所以我们将它定义为一个抽象类。 随着我们的发展进程,我们可能需要一个动作来服务多个州。 幸运的是,我们可以将FSMAction与来自尽可能多的 FSM 的尽可能多的状态相关联。
FSMAction抽象类如下所示:
using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } 实现Decision和Transition类
为了完成我们的 FSM,我们将再定义两个类。 首先,我们有Decision ,这是一个抽象类,所有其他决策都将从中定义它们的自定义行为:
using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } 第二个类Transition包含Decision对象和两个状态:
- 如果
Decision结果为真,则转换到的状态。 - 如果
Decision产生错误,则转换到另一个状态。
它看起来像这样:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Transition")] public sealed class Transition : ScriptableObject { public Decision Decision; public BaseState TrueState; public BaseState FalseState; public void Execute(BaseStateMachine stateMachine) { if(Decision.Decide(stateMachine) && !(TrueState is RemainInState)) stateMachine.CurrentState = TrueState; else if(!(FalseState is RemainInState)) stateMachine.CurrentState = FalseState; } } }到目前为止,我们构建的所有内容都应该可以编译而没有任何错误。 如果您遇到问题,请检查您的 Unity 编辑器版本,如果过时可能会导致错误。 确保所有文件都已从原始项目文件夹中正确克隆,并且所有公开访问的变量均未声明为私有。

创建自定义操作和决策
现在,完成繁重的工作后,我们准备在新的scripts文件夹中实施自定义操作和决策。
实现Patrol和Chase类
当我们分析我们的示例 Stealth Game FSM 图的核心组件时,我们看到我们的 NPC 可以处于以下两种状态之一:
- 巡逻状态——与状态相关的是:
- 一个动作:NPC随机访问世界各地的巡逻点。
- 一种过渡:NPC 检查玩家是否在视线范围内,如果是,则过渡到追逐状态。
- 一个决定:NPC 检查玩家是否在视线范围内。
- 追逐状态——与状态相关的是:
- 一个动作:NPC追逐玩家。
我们可以通过 Unity 的 GUI 重用我们现有的转换实现,我们将在后面讨论。 这留下了两个动作( PatrolAction和ChaseAction )和一个让我们编码的决定。
巡逻状态操作(从基础FSMAction派生)覆盖Execute方法以获取两个组件:
-
PatrolPoints,跟踪巡逻点。 -
NavMeshAgent,Unity 在 3D 空间中导航的实现。
然后,覆盖检查 AI 代理是否已到达其目的地,如果是,则移动到下一个目的地。 它看起来像这样:
using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Patrol")] public class PatrolAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var patrolPoints = stateMachine.GetComponent<PatrolPoints>(); if (patrolPoints.HasReached(navMeshAgent)) navMeshAgent.SetDestination(patrolPoints.GetNext().position); } } } 我们可能要考虑缓存PatrolPoints和NavMeshAgent组件。 缓存将允许我们在代理之间共享ScriptableObject以执行操作,而不会在有限状态机的每个查询上运行GetComponent对性能产生影响。
需要明确的是,我们不能在Execute方法中缓存组件实例。 因此,我们将向BaseStateMachine添加一个自定义GetComponent方法。 我们的自定义GetComponent将在第一次调用时缓存实例,并在连续调用时返回缓存的实例。 作为参考,这是带有缓存的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() { CurrentState = _initialState; _cachedComponents = new Dictionary<Type, Component>(); } public BaseState CurrentState { get; set; } private void Update() { CurrentState.Execute(this); } 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; } } } 与其对应的PatrolAction一样, ChaseAction类重写Execute方法以获取PatrolPoints和NavMeshAgent组件。 然而,相比之下,在检查 AI 代理是否已到达其目的地之后, ChaseAction类操作将目的地设置为Player.position :
using Demo.Enemy; using Demo.FSM; using UnityEngine; using UnityEngine.AI; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Actions/Chase")] public class ChaseAction : FSMAction { public override void Execute(BaseStateMachine stateMachine) { var navMeshAgent = stateMachine.GetComponent<NavMeshAgent>(); var enemySightSensor = stateMachine.GetComponent<EnemySightSensor>(); navMeshAgent.SetDestination(enemySightSensor.Player.position); } } } 实现InLineOfSightDecision类
最后一部分是InLineOfSightDecision类,它继承了基本的Decision并获取EnemySightSensor组件来检查玩家是否在 NPC 的视线范围内:
using Demo.Enemy; using Demo.FSM; using UnityEngine; namespace Demo.MyFSM { [CreateAssetMenu(menuName = "FSM/Decisions/In Line Of Sight")] public class InLineOfSightDecision : Decision { public override bool Decide(BaseStateMachine stateMachine) { var enemyInLineOfSight = stateMachine.GetComponent<EnemySightSensor>(); return enemyInLineOfSight.Ping(); } } }将行为附加到状态
我们终于准备好将行为附加到Enemy代理。 这些是在 Unity 编辑器的项目窗口中创建的。
添加Patrol和Chase状态
让我们创建两个状态并将它们命名为“Patrol”和“Chase”:
- 右键单击 > 创建 > FSM > 状态
在这里,我们还要创建一个RemainInState对象:
- 右键单击 > 创建 > FSM > 保持状态
现在,是时候创建我们刚刚编写的操作了:
- 右键单击 > 创建 > FSM > 操作 > 巡逻
- 右键单击 > 创建 > FSM > 动作 > 追逐
编码Decision :
- 右键单击 > 创建 > FSM > 决策 > 在视线内
要启用从PatrolState到ChaseState的转换,让我们首先创建转换脚本对象:
- 右键单击 > 创建 > FSM > 过渡
- 选择一个你喜欢的名字。 我打电话给我的 Spotted Enemy。
我们将按如下方式填充生成的检查器窗口:
然后我们将完成 Chase State 检查器对话框,如下所示:
接下来,我们将完成巡逻状态对话框:
最后,我们将BaseStateMachine组件添加到敌人对象: 在 Unity Editor 的 Project 窗口中,打开 SampleScene 资源,从 Hierarchy 面板中选择 Enemy 对象,然后在 Inspector 窗口中,选择Add Component > Base State Machine :
对于任何问题,请仔细检查您的游戏对象是否配置正确。 例如,确认 Enemy 对象包括PatrolPoints脚本组件和对象Point1 、 Point2等。此信息可能会因编辑器版本控制不正确而丢失。
现在你可以开始玩样例游戏了,观察当玩家进入敌人视线时敌人会跟随玩家。
使用 FSM 创建有趣的交互式用户体验
在这个有限状态机教程中,我们创建了一个高度模块化的基于 FSM 的 AI(以及相应的 GitHub 存储库),我们可以在未来的项目中重用它。 由于这种模块化,我们总是可以通过引入新组件来为我们的人工智能增加力量。
但我们的架构也为图形优先的 FSM 设计铺平了道路,这将把我们的开发人员体验提升到一个新的专业水平。 然后,我们可以更快地为我们的游戏创建 FSM,并且具有更高的创作准确性。
