Unity AI 开发:有限状态机教程

已发表: 2022-03-11

在竞争激烈的游戏世界中,开发人员努力为那些与我们创建的非玩家角色 (NPC) 互动的人提供有趣的用户体验。 开发人员可以通过使用有限状态机 (FSM) 创建模拟 NPC 智能的 AI 解决方案来提供这种交互性。

AI 趋势已转向行为树,但 FSM 仍然相关。 它们以一种或另一种身份被整合到几乎所有电子游戏中。

FSM 剖析

FSM 是一种计算模型,其中一次只能激活有限数量的假设状态中的一个。 FSM 从一种状态转换到另一种状态,响应条件或输入。 其核心组件包括:

零件描述
状态指示 FSM 当前整体状况的一组有限选项之一; 任何给定的状态都包含一组关联的动作
行动FSM 查询状态时的状态
决定确定何时发生转换的逻辑
过渡改变状态的过程

虽然我们将从 AI 实现的角度关注 FSM,但动画状态机和一般游戏状态等概念也属于 FSM 范畴。

可视化 FSM

让我们以经典街机游戏吃豆人为例。 在游戏的初始状态(“追逐”状态)中,NPC 是色彩缤纷的幽灵,追逐并最终超越玩家。 每当玩家吃掉一个能量球并经历一次能量提升时,鬼魂就会进入闪避状态,从而获得吃鬼魂的能力。 鬼魂现在是蓝色的,它们会躲避玩家,直到加电超时,鬼魂会转换回追逐状态,在这种状态下它们的原始行为和颜色会恢复。

吃豆人幽灵总是处于两种状态之一:追逐或逃避。 自然,我们必须提供两种转换——一个从追逐到逃避,另一个从逃避到追逐:

图:左边是追逐状态。箭头(表示玩家吃了能量弹)导致右侧的闪避状态。第二个箭头(表示电源颗粒超时)返回左侧的追逐状态。
吃豆人幽灵状态之间的转换

根据设计,有限状态机查询当前状态,该状态查询该状态的决策和动作。 下图展示了我们的吃豆人示例,并显示了检查玩家上电状态的决定。 如果开始加电,NPC 会从追逐转变为躲避。 如果上电结束,NPC 会从躲避转变为追逐。 最后,如果没有上电变化,则不会发生转换。

代表循环的菱形图:从左边开始,有一个追逐状态,暗示着相应的动作。追逐状态然后指向顶部,在那里有一个决定:如果玩家吃了一个能量球,我们继续进入闪避状态并在右侧闪避动作。回避状态指向底部的一个决定:如果能量球超时,我们继续回到我们的起点。
吃豆人幽灵 FSM 的组件

可扩展性

FSM 让我们可以自由地构建模块化 AI。 例如,只需一个新动作,我们就可以创建一个具有新行为的 NPC。 因此,我们可以将一个新动作——吃能量球——归于我们的一个吃豆人幽灵,让它能够在躲避玩家的同时吃下能量球。 我们可以重用现有的操作、决策和转换来支持这种行为。

由于开发独特的 NPC 所需的资源很少,我们可以很好地满足多个独特 NPC 不断变化的项目需求。 另一方面,过多的状态和转换会使我们陷入意大利面条状态机——一种 FSM,其过多的连接使其难以调试和维护。

在 Unity 中实现 FSM

为了演示如何在 Unity 中实现有限状态机,让我们创建一个简单的隐形游戏。 我们的架构将包含ScriptableObject ,它们是可以在整个应用程序中存储和共享信息的数据容器,因此我们不需要复制它。 ScriptableObject能够进行有限的处理,例如调用动作和查询决策。 除了 Unity 的官方文档之外,如果您想深入了解,较早的Game Architecture with Scriptable Objects演讲仍然是一个极好的资源。

在我们将 AI 添加到这个初始的可编译项目之前,请考虑建议的架构:

图:从左/上按出现顺序描述的七个相互连接的框:标有 BaseStateMachine 的框包括 + CurrentState:BaseState。 BaseStateMachine 使用双向箭头连接到 BaseState。标有 BaseState 的框包括 + Execute(BaseStateMachine): void。 BaseState 使用双向箭头连接到 BaseStateMachine。来自 State 和 RemainInState 的单向箭头连接到 BaseState。标记为 State 的框包括 + Execute(BaseStateMachine): void、+ Actions: List<Action> 和 + Transition: List<Transition>。 State 用一个单向箭头连接到 BaseState,用一个标有“1”的单向箭头连接到 Action,用一个标有“1”的单向箭头连接到 Transition。标有 RemainInState 的框包括 + Execute(BaseStateMachine): void。 RemainInState 使用单向箭头连接到 BaseState。标有 Action 的框包括 + Execute(BaseStateMachine): void。一个从 State 标记为“1”的单向箭头连接到 Action。标记为 Transition 的框包括 + Decide(BaseStateMachine):void、+ TransitionDecision:Decision、+ TrueState:BaseState 和 + FalseState:BaseState。过渡使用单向箭头连接到决策。一个从 State 标记为“1”的单向箭头连接到 Transition。标有 Decision 的框包括 + Decide(BaseStateMachine): bool。
提议的 FSM 架构

在我们的示例游戏中,敌人(一个由蓝色胶囊代表的 NPC)巡逻。 当敌人看到玩家(由灰色胶囊表示)时,敌人开始跟随玩家:

图表:从左/上按出现顺序描述的五个相互连接的框:标有 Patrol 的框连接到标有 IF player 的框用单向箭头在视线内,并连接到标有 Patrol Action 的框标记为“状态”的单向箭头。标有 IF 播放器的框在视线内,在框下方有一个附加的 elabel“决定”。标有 IF player 在视线内的框连接到标有 Chase 的带有单向箭头的框。来自标有巡逻的框的单向箭头连接到标有 IF 玩家的框在视线内。标记为 Chase 的框通过标记为“状态”的单向箭头连接到标记为 Chase Action 的框。来自标有 IF player 的框的单向箭头在视线内连接到标有 Chase 的框。来自标有巡逻的框的单向箭头连接到标有巡逻行动的框。来自标有 Chase 的框的单向箭头连接到标有 Chase Action 的框。
我们的示例隐身游戏 FSM 的核心组件

与吃豆人相比,我们游戏中的敌人一旦跟随玩家就不会返回默认状态(“巡逻”)。

创建类

让我们从创建我们的类开始。 在一个新的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) { } } }

实现StateRemainInState

我们现在从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 { } }

请注意,在我们添加FSMActionDecisionTransition类之前,这些类不会编译。

实现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); } }

实现DecisionTransition

为了完成我们的 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文件夹中实施自定义操作和决策。

实现PatrolChase

当我们分析我们的示例 Stealth Game FSM 图的核心组件时,我们看到我们的 NPC 可以处于以下两种状态之一:

  1. 巡逻状态——与状态相关的是:
    • 一个动作:NPC随机访问世界各地的巡逻点。
    • 一种过渡:NPC 检查玩家是否在视线范围内,如果是,则过渡到追逐状态。
    • 一个决定:NPC 检查玩家是否在视线范围内。
  2. 追逐状态——与状态相关的是:
    • 一个动作:NPC追逐玩家。

我们可以通过 Unity 的 GUI 重用我们现有的转换实现,我们将在后面讨论。 这留下了两个动作( PatrolActionChaseAction )和一个让我们编码的决定。

巡逻状态操作(从基础FSMAction派生)覆盖Execute方法以获取两个组件:

  1. PatrolPoints ,跟踪巡逻点。
  2. 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); } } }

我们可能要考虑缓存PatrolPointsNavMeshAgent组件。 缓存将允许我们在代理之间共享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方法以获取PatrolPointsNavMeshAgent组件。 然而,相比之下,在检查 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 编辑器的项目窗口中创建的。

添加PatrolChase状态

让我们创建两个状态并将它们命名为“Patrol”和“Chase”:

  • 右键单击 > 创建 > FSM > 状态

在这里,我们还要创建一个RemainInState对象:

  • 右键单击 > 创建 > FSM > 保持状态

现在,是时候创建我们刚刚编写的操作了:

  • 右键单击 > 创建 > FSM > 操作 > 巡逻
  • 右键单击 > 创建 > FSM > 动作 > 追逐

编码Decision

  • 右键单击 > 创建 > FSM > 决策 > 在视线内

要启用从PatrolStateChaseState的转换,让我们首先创建转换脚本对象:

  • 右键单击 > 创建 > FSM > 过渡
  • 选择一个你喜欢的名字。 我打电话给我的 Spotted Enemy。

我们将按如下方式填充生成的检查器窗口:

Spotted Enemy (Transition) 屏幕包括四行: Script 的值设置为“Transition”并显示为灰色。决策的值设置为“LineOfSightDecision (In Line Of Sight)”。 True State 的值设置为“ChaseState (State)”。 False State 的值设置为“RemainInState(保持在状态)”。
填写 Spotted Enemy (Transition) Inspector 窗口

然后我们将完成 Chase State 检查器对话框,如下所示:

Chase State(状态)屏幕以标签“Open”开头。在标签“脚本”旁边选择“状态”。在“动作”标签旁边,选择了“1”。从“Action”下拉列表中,选择“Element 0 Chase Action (Chase Action)”。后面有加号和减号。在“转换”标签旁边,选择了“0”。在“转换”下拉列表中,显示“列表为空”。后面有加号和减号。
填写 Chase State Inspector 窗口

接下来,我们将完成巡逻状态对话框:

巡逻状态 (State) 屏幕以标签“打开”开头。在标签“脚本”旁边选择“状态”。在“动作”标签旁边,选择了“1”。从“Action”下拉菜单中,选择“Element 0 Patrol Action (Patrol Action)”。后面有一个加号和减号。在“转换”标签旁边,选择了“1”。从“过渡”下拉列表中,显示“元素 0 SpottedEnemy(过渡)”。后面有加号和减号。
填写巡逻状态检查器窗口

最后,我们将BaseStateMachine组件添加到敌人对象: 在 Unity Editor 的 Project 窗口中,打开 SampleScene 资源,从 Hierarchy 面板中选择 Enemy 对象,然后在 Inspector 窗口中,选择Add Component > Base State Machine

基本状态机(脚本)屏幕:在灰显的“脚本”标签旁边,“BaseStateMachine”被选中并灰显。在“Initial State”标签旁边,选择了“PatrolState (State)”。
添加基本​​状态机(脚本)组件

对于任何问题,请仔细检查您的游戏对象是否配置正确。 例如,确认 Enemy 对象包括PatrolPoints脚本组件和对象Point1Point2等。此信息可能会因编辑器版本控制不正确而丢失。

现在你可以开始玩样例游戏了,观察当玩家进入敌人视线时敌人会跟随玩家。

使用 FSM 创建有趣的交互式用户体验

在这个有限状态机教程中,我们创建了一个高度模块化的基于 FSM 的 AI(以及相应的 GitHub 存储库),我们可以在未来的项目中重用它。 由于这种模块化,我们总是可以通过引入新组件来为我们的人工智能增加力量。

但我们的架构也为图形优先的 FSM 设计铺平了道路,这将把我们的开发人员体验提升到一个新的专业水平。 然后,我们可以更快地为我们的游戏创建 FSM,并且具有更高的创作准确性。