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つは回避から追跡へです。

図:左側は追跡状態です。矢印(プレイヤーがパワーペレットを食べたことを示す)は、右側の回避状態につながります。 2番目の矢印(パワーペレットがタイムアウトしたことを示す)は、左側の追跡状態に戻ります。
パックマンゴースト状態間の遷移

有限状態マシンは、設計上、現在の状態を照会します。現在の状態は、その状態の決定とアクションを照会します。 次の図は、パックマンの例を表しており、プレーヤーのパワーアップのステータスを確認する決定を示しています。 パワーアップが開始された場合、NPCは追跡から回避に移行します。 パワーアップが終了すると、NPCは回避から追跡に移行します。 最後に、電源投入時の変更がない場合、遷移は発生しません。

サイクルを表すひし形の図:左側から、対応するアクションを示す追跡状態があります。次に、追跡状態が上を指し、そこで決定があります。プレイヤーがパワーペレットを食べた場合、回避状態に進み、右側のアクションを回避します。回避状態は、下部の決定を示しています。パワーペレットがタイムアウトした場合は、開始点に戻ります。
Pac-ManGhostFSMのコンポーネント

スケーラビリティ

FSMは、モジュラーAIを構築するために私たちを解放します。 たとえば、1つの新しいアクションだけで、新しい動作のNPCを作成できます。 したがって、新しいアクション、つまりパワーペレットを食べることを、パックマンゴーストの1つに帰することができ、プレーヤーを回避しながらパワーペレットを食べることができます。 この動作をサポートするために、既存のアクション、決定、および遷移を再利用できます。

ユニークなNPCを開発するために必要なリソースは最小限であるため、複数のユニークなNPCの進化するプロジェクト要件を満たすのに適した立場にあります。 一方、状態と遷移の数が多すぎると、スパゲッティステートマシン(接続が多すぎるためにデバッグと保守が困難なFSM)に巻き込まれる可能性があります。

UnityでのFSMの実装

Unityに有限状態マシンを実装する方法を示すために、簡単なステルスゲームを作成しましょう。 私たちのアーキテクチャには、アプリケーション全体で情報を格納および共有できるデータコンテナであるScriptableObjectが組み込まれているため、情報を複製する必要はありません。 ScriptableObjectは、アクションの呼び出しや決定のクエリなど、制限された処理が可能です。 Unityの公式ドキュメントに加えて、スクリプト可能なオブジェクトを使用した古いゲームアーキテクチャの話は、さらに深く掘り下げたい場合に優れたリソースのままです。

この最初のコンパイル可能なプロジェクトにAIを追加する前に、提案されたアーキテクチャを検討してください。

図:左/上から、外観順に説明された、相互に接続する7つのボックス:BaseStateMachineというラベルの付いたボックスには、+ CurrentState:BaseStateが含まれます。 BaseStateMachineは、双方向の矢印でBaseStateに接続します。 BaseStateというラベルの付いたボックスには、+ Execute(BaseStateMachine):voidが含まれます。 BaseStateは、双方向の矢印でBaseStateMachineに接続します。 StateおよびRemainInStateからの一方向の矢印はBaseStateに接続します。 Stateというラベルの付いたボックスには、+ Execute(BaseStateMachine):void、+ Actions:List< Action&gt ;、および+ Transition:List< Transition>が含まれます。 Stateは、一方向の矢印でBaseStateに接続し、「1」のラベルが付いた一方向の矢印でActionに接続し、「1」のラベルが付いた一方向の矢印でTransitionに接続します。 RemainInStateというラベルの付いたボックスには、+ Execute(BaseStateMachine):voidが含まれています。 RemainInStateは、単方向の矢印でBaseStateに接続します。アクションというラベルの付いたボックスには、+ Execute(BaseStateMachine):voidが含まれます。 Stateから「1」というラベルの付いた一方向の矢印がActionに接続します。 Transitionというラベルの付いたボックスには、+ Decide(BaseStateMachine):void、+ TransitionDecision:Decision、+ TrueState:BaseState、および+ FalseState:BaseStateが含まれます。遷移は、一方向の矢印で決定に接続します。 Stateから「1」というラベルの付いた一方向の矢印がTransitionに接続します。 「決定」というラベルの付いたボックスには、「+決定(BaseStateMachine):bool」が含まれます。
提案されたFSMアーキテクチャ

サンプルゲームでは、敵(青いカプセルで表されるNPC)がパトロールします。 敵がプレーヤー(灰色のカプセルで表される)を見ると、敵はプレーヤーの追跡を開始します。

図:左/上から外観順に説明された、互いに接続する5つのボックス:パトロールというラベルの付いたボックスは、一方向の矢印で見通し内にある場合のパトロールというラベルの付いたボックスに接続し、パトロールアクションというラベルの付いたボックスに接続します。 「状態」というラベルの付いた一方向の矢印。 IFプレーヤーというラベルの付いたボックスが見通し内にあり、ボックスのすぐ下に追加のelabel「決定」があります。 IF player is in of sightというラベルの付いたボックスは、Chaseというラベルの付いたボックスに一方向の矢印で接続します。パトロールというラベルの付いたボックスからの単方向矢印は、見通し内にある場合にプレーヤーというラベルの付いたボックスに接続します。 Chaseというラベルの付いたボックスは、「state」というラベルの付いた一方向の矢印でChaseActionというラベルの付いたボックスに接続します。 IFプレーヤーというラベルの付いたボックスからの単方向矢印が見通し内にあり、チェイスというラベルの付いたボックスに接続します。 Patrolというラベルの付いたボックスからの一方向の矢印矢印は、PatrolActionというラベルの付いたボックスに接続します。 Chaseというラベルの付いたボックスからの一方向の矢印矢印は、ChaseActionというラベルの付いたボックスに接続します。
サンプルステルスゲーム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には、 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 { } }

これらのクラスは、 FSMActionDecision 、および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. パトロール状態—状態に関連付けられているのは次のとおりです。
    • 1つのアクション:NPCは世界中のランダムなパトロールポイントを訪問します。
    • 1つの移行:NPCは、プレイヤーが視界に入っているかどうかを確認し、視界に入っている場合は追跡状態に移行します。
    • 1つの決定:NPCはプレイヤーが見えているかどうかをチェックします。
  2. チェイス状態—状態に関連付けられているのは次のとおりです。
    • 1つのアクション:NPCがプレイヤーを追いかけます。

後で説明するように、UnityのGUIを介して既存の移行実装を再利用できます。 これにより、2つのアクション( PatrolActionChaseAction )とコーディングの決定が残ります。

パトロール状態アクション(ベースFSMActionから派生)は、 Executeメソッドをオーバーライドして、次の2つのコンポーネントを取得します。

  1. パトロールポイントを追跡するPatrolPoints
  2. 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のプロジェクトウィンドウで作成されます。

PatrolChase状態の追加

2つの状態を作成し、それらに「パトロール」と「チェイス」という名前を付けましょう。

  • 右クリック>作成>FSM>状態

ここで、 RemainInStateオブジェクトも作成しましょう。

  • 右クリック>作成>FSM>状態を維持

次に、コーディングしたアクションを作成します。

  • 右クリック>作成>FSM>アクション>パトロール
  • 右クリック>作成>FSM>アクション>チェイス

Decisionをコーディングするには:

  • 右クリック>作成>FSM>決定>見通し内

ChaseStateからPatrolStateへの移行を有効にするには、最初に移行スクリプト可能なオブジェクトを作成しましょう。

  • 右クリック>作成>FSM>遷移
  • お好きな名前をお選びください。 私は私の斑点のある敵と呼んだ。

結果のインスペクターウィンドウに次のようにデータを入力します。

Spotted Enemy(Transition)画面には4行あります。スクリプトの値は「Transition」に設定され、グレー表示されます。 Decisionの値は「LineOfSightDecision(In Line OfSight)」に設定されます。 True Stateの値は「ChaseState(State)」に設定されます。 False Stateの値は、「RemainInState(Remain InState)」に設定されます。
発見された敵(トランジション)インスペクターウィンドウへの記入

次に、次のようにChaseStateinspectorダイアログを完了します。

Chase State(State)画面は、「Open」というラベルで始まります。ラベルの横にある「スクリプト」「状態」が選択されています。 「アクション」ラベルの横に「1」が選択されています。 [アクション]ドロップダウンから、[要素0チェイスアクション(チェイスアクション)]が選択されています。それに続くプラス記号とマイナス記号があります。 「トランジション」ラベルの横にある「0」が選択されています。 [トランジション]ドロップダウンから、[リストは空です]が表示されます。それに続くプラス記号とマイナス記号があります。
チェイスステートインスペクターウィンドウへの入力

次に、[パトロール状態]ダイアログを完了します。

パトロール状態(状態)画面は、「開く」というラベルで始まります。ラベルの横にある「スクリプト」「状態」が選択されています。 「アクション」ラベルの横に「1」が選択されています。 [アクション]ドロップダウンから、[要素0パトロールアクション(パトロールアクション)]が選択されます。それに続くプラスマイナス記号があります。 「トランジション」ラベルの横にある「1」が選択されています。 [トランジション]ドロップダウンから、[要素0 SpottedEnemy(トランジション)]が表示されます。それに続くプラス記号とマイナス記号があります。
パトロール状態検査ウィンドウへの記入

最後に、 BaseStateMachineコンポーネントを敵オブジェクトに追加します。UnityEditorのProjectウィンドウで、SampleSceneアセットを開き、HierarchyパネルからEnemyオブジェクトを選択し、InspectorウィンドウでAdd Component> Base State Machine :を選択します。

ベースステートマシン(スクリプト)画面:グレー表示された「スクリプト」ラベルの横にある「BaseStateMachine」が選択され、グレー表示されます。 「初期状態」ラベルの横に、「パトロール状態(状態)」が選択されています。
ベースステートマシン(スクリプト)コンポーネントの追加

問題がある場合は、ゲームオブジェクトが正しく構成されていることを再確認してください。 たとえば、EnemyオブジェクトにPatrolPointsスクリプトコンポーネントとオブジェクトPoint1Point2などが含まれていることを確認します。この情報は、エディターのバージョンが正しくないと失われる可能性があります。

これで、サンプルゲームをプレイする準備が整いました。プレーヤーが敵の視線に足を踏み入れたときに、敵がプレーヤーを追跡することを確認します。

FSMを使用して、楽しくインタラクティブなユーザーエクスペリエンスを作成する

この有限状態マシンのチュートリアルでは、将来のプロジェクトで再利用できる、高度にモジュール化されたFSMベースのAI(および対応するGitHubリポジトリ)を作成しました。 このモジュール性のおかげで、新しいコンポーネントを導入することで、いつでもAIにパワーを加えることができます。

しかし、私たちのアーキテクチャは、グラフィックファーストのFSM設計への道も開きます。これにより、開発者のエクスペリエンスが新しいレベルのプロフェッショナリズムに引き上げられます。 そうすれば、ゲーム用のFSMをより迅速に、そしてより優れたクリエイティブ精度で作成できます。