Unity AI開発:有限状態マシンのチュートリアル
公開: 2022-03-11競争の激しいゲームの世界では、開発者は、私たちが作成するノンプレイヤーキャラクター(NPC)と対話する人々に楽しいユーザーエクスペリエンスを提供するよう努めています。 開発者は、有限状態マシン(FSM)を使用して、NPCのインテリジェンスをシミュレートするAIソリューションを作成することにより、この対話性を実現できます。
AIのトレンドは行動ツリーにシフトしていますが、FSMは引き続き関連性があります。 それらは、事実上すべての電子ゲームに(何らかの形で)組み込まれています。
FSMの構造
FSMは、有限数の仮想状態のうち1つだけを一度にアクティブにできる計算モデルです。 FSMは、条件または入力に応答して、ある状態から別の状態に遷移します。 そのコアコンポーネントは次のとおりです。
| 成分 | 説明 |
|---|---|
| 州 | FSMの現在の全体的な状態を示す有限のオプションセットの1つ。 任意の状態には、関連する一連のアクションが含まれます |
| アクション | FSMが状態を照会すると、状態はどうなりますか |
| 決断 | 遷移が発生するときに確立するロジック |
| 遷移 | 状態変化のプロセス |
AI実装の観点からFSMに焦点を当てますが、アニメーションステートマシンや一般的なゲームステートなどの概念もFSMの傘下にあります。
FSMの視覚化
古典的なアーケードゲームのパックマンの例を考えてみましょう。 ゲームの初期状態(「追跡」状態)では、NPCはカラフルな幽霊であり、プレイヤーを追跡し、最終的にはそれを上回ります。 プレイヤーがパワーペレットを食べてパワーアップを経験するたびに、ゴーストは回避状態に移行し、ゴーストを食べる能力を獲得します。 ゴーストは青色になり、電源投入がタイムアウトしてゴーストが追跡状態に戻り、元の動作と色が復元されるまで、プレーヤーを回避します。
パックマンゴーストは、常に追跡または回避の2つの状態のいずれかになります。 当然、2つの遷移を提供する必要があります。1つは追跡から回避へ、もう1つは回避から追跡へです。
有限状態マシンは、設計上、現在の状態を照会します。現在の状態は、その状態の決定とアクションを照会します。 次の図は、パックマンの例を表しており、プレーヤーのパワーアップのステータスを確認する決定を示しています。 パワーアップが開始された場合、NPCは追跡から回避に移行します。 パワーアップが終了すると、NPCは回避から追跡に移行します。 最後に、電源投入時の変更がない場合、遷移は発生しません。
スケーラビリティ
FSMは、モジュラーAIを構築するために私たちを解放します。 たとえば、1つの新しいアクションだけで、新しい動作のNPCを作成できます。 したがって、新しいアクション、つまりパワーペレットを食べることを、パックマンゴーストの1つに帰することができ、プレーヤーを回避しながらパワーペレットを食べることができます。 この動作をサポートするために、既存のアクション、決定、および遷移を再利用できます。
ユニークなNPCを開発するために必要なリソースは最小限であるため、複数のユニークなNPCの進化するプロジェクト要件を満たすのに適した立場にあります。 一方、状態と遷移の数が多すぎると、スパゲッティステートマシン(接続が多すぎるためにデバッグと保守が困難なFSM)に巻き込まれる可能性があります。
UnityでのFSMの実装
Unityに有限状態マシンを実装する方法を示すために、簡単なステルスゲームを作成しましょう。 私たちのアーキテクチャには、アプリケーション全体で情報を格納および共有できるデータコンテナであるScriptableObjectが組み込まれているため、情報を複製する必要はありません。 ScriptableObjectは、アクションの呼び出しや決定のクエリなど、制限された処理が可能です。 Unityの公式ドキュメントに加えて、スクリプト可能なオブジェクトを使用した古いゲームアーキテクチャの話は、さらに深く掘り下げたい場合に優れたリソースのままです。
この最初のコンパイル可能なプロジェクトに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には、 BaseStateMachineを引数として取り、アクションと遷移を渡す単一のメソッドExecuteが含まれています。 BaseStateの外観は次のとおりです。
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } StateクラスとRemainInStateクラスの実装
ここで、 BaseStateから2つのクラスを派生させます。 まず、アクションとトランジションへの参照を格納するStateクラスがあり、2つのリスト(1つはアクション用、もう1つはトランジション用)を含み、アクションとトランジションでベース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); } } } 次に、遷移を実行しないタイミングをFSMに指示するRemainInStateクラスがあります。
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } これらのクラスは、 FSMAction 、 Decision 、およびTransitionクラスを追加するまでコンパイルされないことに注意してください。
FSMActionクラスの実装
提案されたFSMアーキテクチャ図では、基本FSMActionクラスに「アクション」というラベルが付けられています。 ただし、基本FSMActionクラスを作成し、 FSMActionという名前を使用します( Actionは.NET System名前空間ですでに使用されているため)。
ScriptableObjectであるFSMActionは関数を独立して処理できないため、抽象クラスとして定義します。 開発が進むにつれて、複数の州にサービスを提供するために1つのアクションが必要になる場合があります。 幸い、 FSMActionは、必要な数のFSMの状態に関連付けることができます。
FSMAction抽象クラスは次のようになります。
using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } DecisionクラスとTransitionクラスの実装
FSMを完成させるために、さらに2つのクラスを定義します。 まず、 Decisionがあります。これは、他のすべての決定がカスタム動作を定義する抽象クラスです。
using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } 2番目のクラスTransitionには、 Decisionオブジェクトと2つの状態が含まれています。
-
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 Editorのバージョンを確認してください。古くなっていると、エラーが発生する可能性があります。 すべてのファイルが元のプロジェクトフォルダーから適切に複製されていること、およびパブリックにアクセスされるすべての変数がプライベートとして宣言されていないことを確認してください。

カスタムアクションと決定の作成
これで、手間のかかる作業が完了したので、新しいscriptsフォルダーにカスタムアクションと決定を実装する準備が整いました。
PatrolクラスとChaseクラスの実装
サンプルのステルスゲームFSMダイアグラムのコアコンポーネントを分析すると、NPCが次の2つの状態のいずれかになり得ることがわかります。
- パトロール状態—状態に関連付けられているのは次のとおりです。
- 1つのアクション:NPCは世界中のランダムなパトロールポイントを訪問します。
- 1つの移行:NPCは、プレイヤーが視界に入っているかどうかを確認し、視界に入っている場合は追跡状態に移行します。
- 1つの決定:NPCはプレイヤーが見えているかどうかをチェックします。
- チェイス状態—状態に関連付けられているのは次のとおりです。
- 1つのアクション:NPCがプレイヤーを追いかけます。
後で説明するように、UnityのGUIを介して既存の移行実装を再利用できます。 これにより、2つのアクション( PatrolActionとChaseAction )とコーディングの決定が残ります。
パトロール状態アクション(ベースFSMActionから派生)は、 Executeメソッドをオーバーライドして、次の2つのコンポーネントを取得します。
- パトロールポイントを追跡する
PatrolPoints。 -
NavMeshAgent、3D空間でのナビゲーションのためのUnityの実装。
次に、オーバーライドは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コンポーネントのキャッシュを検討することをお勧めします。 キャッシングにより、有限状態マシンの各クエリでGetComponentを実行することによるパフォーマンスへの影響なしに、エージェント間でアクションのScriptableObjectを共有できます。
明確にするために、 Executeメソッドでコンポーネントインスタンスをキャッシュすることはできません。 その代わりに、カスタムGetComponentメソッドをBaseStateMachineに追加します。 カスタム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エージェントにアタッチする準備が整いました。 これらは、UnityEditorのプロジェクトウィンドウで作成されます。
PatrolとChase状態の追加
2つの状態を作成し、それらに「パトロール」と「チェイス」という名前を付けましょう。
- 右クリック>作成>FSM>状態
ここで、 RemainInStateオブジェクトも作成しましょう。
- 右クリック>作成>FSM>状態を維持
次に、コーディングしたアクションを作成します。
- 右クリック>作成>FSM>アクション>パトロール
- 右クリック>作成>FSM>アクション>チェイス
Decisionをコーディングするには:
- 右クリック>作成>FSM>決定>見通し内
ChaseStateからPatrolStateへの移行を有効にするには、最初に移行スクリプト可能なオブジェクトを作成しましょう。
- 右クリック>作成>FSM>遷移
- お好きな名前をお選びください。 私は私の斑点のある敵と呼んだ。
結果のインスペクターウィンドウに次のようにデータを入力します。
次に、次のようにChaseStateinspectorダイアログを完了します。
次に、[パトロール状態]ダイアログを完了します。
最後に、 BaseStateMachineコンポーネントを敵オブジェクトに追加します。UnityEditorのProjectウィンドウで、SampleSceneアセットを開き、HierarchyパネルからEnemyオブジェクトを選択し、InspectorウィンドウでAdd Component> Base State Machine :を選択します。
問題がある場合は、ゲームオブジェクトが正しく構成されていることを再確認してください。 たとえば、EnemyオブジェクトにPatrolPointsスクリプトコンポーネントとオブジェクトPoint1 、 Point2などが含まれていることを確認します。この情報は、エディターのバージョンが正しくないと失われる可能性があります。
これで、サンプルゲームをプレイする準備が整いました。プレーヤーが敵の視線に足を踏み入れたときに、敵がプレーヤーを追跡することを確認します。
FSMを使用して、楽しくインタラクティブなユーザーエクスペリエンスを作成する
この有限状態マシンのチュートリアルでは、将来のプロジェクトで再利用できる、高度にモジュール化されたFSMベースのAI(および対応するGitHubリポジトリ)を作成しました。 このモジュール性のおかげで、新しいコンポーネントを導入することで、いつでもAIにパワーを加えることができます。
しかし、私たちのアーキテクチャは、グラフィックファーストのFSM設計への道も開きます。これにより、開発者のエクスペリエンスが新しいレベルのプロフェッショナリズムに引き上げられます。 そうすれば、ゲーム用のFSMをより迅速に、そしてより優れたクリエイティブ精度で作成できます。
