Unity AI 開発: xNode ベースのグラフィカルな FSM チュートリアル

公開: 2022-08-12

「Unity AI Development: A Finite-state Machine Tutorial」では、モジュラー FSM ベースの AI である単純なステルス ゲームを作成しました。 ゲームでは、敵のエージェントがゲーム空間をパトロールします。 プレイヤーを発見すると、敵はその状態を変更し、パトロールする代わりにプレイヤーを追跡します。

Unity ジャーニーのこの第 2 段階では、グラフィカル ユーザー インターフェイス (GUI) を構築して、有限状態マシン (FSM) のコア コンポーネントをより迅速に作成し、Unity 開発者エクスペリエンスを向上させます。

簡単な復習

前のチュートリアルで詳しく説明した FSM は、アーキテクチャ ブロックを C# スクリプトとして構築しました。 カスタムのScriptableObjectアクションと決定をクラスとして追加しました。 ScriptableObjectアプローチにより、保守とカスタマイズが容易な FSM が可能になりました。 このチュートリアルでは、FSM のドラッグ アンド ドロップのScriptableObjectをグラフィカル オプションに置き換えます。

ゲームに勝ちやすくしたい人のために、更新されたスクリプトも書きました。 実装するには、プレイヤー検出スクリプトを、敵の視野を狭めるこのスクリプトに置き換えるだけです。

xNode を使い始める

FSM のフローを視覚的に表示するノードベースのビヘイビア ツリーのフレームワークである xNode を使用して、グラフィカル エディタを構築します。 Unity の GraphView はその仕事を成し遂げることができますが、その API は実験的であり、文書化も不十分です。 xNode のユーザー インターフェイスは優れた開発者エクスペリエンスを提供し、FSM のプロトタイピングと迅速な拡張を促進します。

Unity Package Manager を使用して、xNode を Git 依存関係としてプロジェクトに追加しましょう。

  1. Unity で、[ウィンドウ] > [パッケージ マネージャー] をクリックして、[パッケージ マネージャー] ウィンドウを起動します。
  2. ウィンドウの左上隅にある+ (プラス記号) をクリックし、[ Add package from git URL ] を選択してテキスト フィールドを表示します。
  3. ラベルのないテキスト ボックスに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 では、各StateTransitionがノードの形をとるグラフを操作します。 入力および/または出力接続により、ノードをグラフ内の任意またはすべての他のノードに関連付けることができます。

2 つの任意値と 1 つのブール値の 3 つの入力値を持つノードを想像してみましょう。 このノードは、ブール入力が true か false かに応じて、2 つの任意の型の入力値のいずれかを出力します。

中央の大きな長方形で表される Branch ノードには、疑似コード「If C == True A Else B」が含まれています。左側には 3 つの四角形があり、それぞれに「A (任意)」、「B (任意)」、「C (ブール値)」の Branch ノードを指す矢印があります。最後に、Branch ノードには、「出力」長方形を指す矢印があります。
Branchノードの例

既存の FSM をグラフに変換するには、 StateクラスとTransitionクラスを変更して、 ScriptableObjectクラスの代わりにNodeクラスを継承します。 NodeGraphタイプのグラフ オブジェクトを作成して、すべてのStateオブジェクトとTransitionオブジェクトを含めます。

BaseStateMachineをベース タイプとして使用するように変更する

既存のBaseStateMachineクラスに 2 つの新しい仮想メソッドを追加して、グラフィカル インターフェイスの構築を開始します。

Init 初期状態をCurrentStateプロパティに割り当てます
Execute 現在の状態を実行します

これらのメソッドを仮想として宣言すると、それらをオーバーライドできるため、初期化と実行のために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# クラス

当面の間、 BaseStateMachineGraphBaseStateMachineクラスのみを継承します。

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }

ベース ノード タイプを作成するまで、 BaseStateMachineGraphに機能を追加することはできません。 次はそうしましょう。

NodeGraphの実装とベース ノード タイプの作成

新しく作成したFSMGraphフォルダーの下に、以下を作成します。

FSMGraph クラス

今のところ、 FSMGraphNodeGraphクラスのみを継承します (追加機能はありません)。

 using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }

ノードのクラスを作成する前に、以下を追加しましょう。

FSMNodeBase すべてのノードで基本クラスとして使用されるクラス

FSMNodeBaseクラスには、 FSMNodeBaseタイプのEntryという名前の入力が含まれ、ノードを相互に接続できるようになります。

また、2 つのヘルパー関数を追加します。

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; } } }

最終的に、2 種類の状態ノードができます。 これらをサポートするクラスを追加しましょう。

BaseStateNode StateNodeRemainInStateNodeの両方をサポートする基本クラス
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フォルダーに 3 つの新しいクラスを追加しましょう。

StateNode エージェントの状態を表します。 実行時に、 StateNodeStateNodeの出力ポート (ヘルパー メソッドによって取得) に接続されたTransitionNodeを反復処理します。 StateNodeは、ノードを別の状態に遷移させるか、ノードの状態をそのままにしておくかをそれぞれに問い合わせます。
RemainInStateNode ノードを現在の状態のままにする必要があることを示します。
TransitionNode 別の状態に移行するか、同じ状態にとどまるかを決定します。

前の Unity FSM チュートリアルでは、 Stateクラスはトランジション リストを繰り返します。 ここで xNode では、 StateNodeは、 GetAllOnPortヘルパー メソッドを介して取得されたノードを反復処理するStateと同等の機能を果たします。

[Output]属性を発信接続 (遷移ノード) に追加して、それらが GUI の一部であることを示します。 xNode の設計により、属性の値はソース ノード[Output]属性でマークされたフィールドを含むノード) に由来します。 [Output]属性と[Input]属性を使用して、xNode GUI によって設定される関係と接続を記述しているため、これらの値を通常のように扱うことはできません。 ActionsTransitionsを反復する方法を考えてみましょう:

 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出力には複数のノードを接続できます。 [Output]接続のリストを取得するには、 GetAllOnPortヘルパー メソッドを呼び出す必要があります。

RemainInStateNodeは、最も単純なクラスです。 RemainInStateNodeはロジックを実行せず、エージェント (ゲームの場合は敵) に現在の状態を維持するように指示するだけです。

 namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }

この時点では、 TransitionNodeクラスはまだ不完全であり、コンパイルされません。 クラスを更新すると、関連するエラーはクリアされます。

TransitionNodeを構築するには、 StateNodeを構築したときに行ったように、出力の値がソース ノードで発生するという xNode の要件を回避する必要があります。 StateNodeTransitionNodeの主な違いは、 TransitionNodeの出力が 1 つのノードにしか接続できないことです。 この場合、 GetFirstは各ポートに接続された 1 つのノードを取得します (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 グラフの作成に進むことができます。 Unity プロジェクト ウィンドウで、 EnemyAIフォルダーを右クリックし、[作成] > [FSM] > [FSM グラフ] を選択します。 グラフを識別しやすくするために、名前をEnemyGraphに変更しましょう。

xNode グラフ エディタ ウィンドウで、右クリックして、 StateTransition 、およびRemainInStateをリストするドロップダウン メニューを表示します。 ウィンドウが表示されていない場合は、 EnemyGraphファイルをダブルクリックして、xNode グラフ エディター ウィンドウを起動します。

  1. Chase状態とPatrol状態を作成するには:

    1. 右クリックしてStateを選択し、新しいノードを作成します。

    2. ノードにChaseという名前を付けます。

    3. ドロップダウン メニューに戻り、もう一度Stateを選択して 2 つ目のノードを作成します。

    4. ノードにPatrolという名前を付けます。

    5. 既存のChaseおよびPatrolアクションを、新しく作成された対応する状態にドラッグ アンド ドロップします。

  2. トランジションを作成するには:

    1. 右クリックして [トランジション] を選択し、新しいノードを作成します。

    2. LineOfSightDecisionオブジェクトを遷移のDecisionフィールドに割り当てます。

  3. RemainInStateノードを作成するには:

    1. 右クリックしてRemainInStateを選択し、新しいノードを作成します。
  4. グラフを接続するには:

    1. PatrolノードのTransitions出力をTransitionノードのEntry入力に接続します。

    2. TransitionノードのTrue State出力をChaseノードのEntry入力に接続します。

    3. TransitionノードのFalse State出力をRemain In StateノードのEntry入力に接続します。

グラフは次のようになります。

4 つの長方形として表される 4 つのノード。それぞれの左上に Entry 入力円があります。パトロール状態ノードには、左から右に 1 つのアクション (パトロール アクション) が表示されます。パトロール状態ノードには、遷移ノードのエントリ サークルに接続するその右下に遷移出力サークルも含まれます。 Transition ノードは、LineOfSight という 1 つの決定を表示します。右下に True State と False State の 2 つの出力円があります。 True State は、3 番目の構造である Chase ステート ノードのエントリ サークルに接続します。 Chase 状態ノードには、1 つのアクション (Chase Action) が表示されます。 Chase ステート ノードには、Transitions 出力サークルがあります。 Transition の 2 つの出力サークルの 2 番目である False State は、4 番目で最後の構造である RemainInState ノード (Chase 状態ノードの下に表示される) の Entry サークルに接続します。
FSM グラフの最初の外観

グラフには、どのノード ( Patrol状態またはChase状態) が最初のノードであるかを示すものはありません。 BaseStateMachineGraphクラスは 4 つのノードを検出しますが、インジケーターが存在しないため、初期状態を選択できません。

この問題を解決するには、次のものを作成しましょう。

FSMInitialNode タイプStateNodeの単一出力がInitialNodeという名前のクラス

出力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 エディター ウィンドウで:

  1. 右クリックして [初期ノード] を選択し、新しいノードを作成します。
  2. FSM Nodeの出力をPatrolノードのEntry入力に接続します。

グラフは次のようになります。

前の画像と同じグラフで、他の 4 つの長方形の左側に FSM ノードの緑色の長方形が 1 つ追加されています。これには、パトロール ノードの「エントリ」入力 (濃い赤の円で表される) に接続する初期ノード出力 (青い円で表される) があります。
初期ノードがパトロール状態に接続された FSM グラフ

私たちの生活を楽にするために、 FSMGraphに以下を追加します:

InitialState プロパティ

初めてInitialStateプロパティの値を取得しようとすると、プロパティのゲッターは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; } } }

次に、 BaseStateMachineGraphFSMGraphを参照し、 BaseStateMachineInitメソッドとExecuteメソッドをオーバーライドします。 InitをオーバーライドするとCurrentStateがグラフの初期状態として設定され、 ExecuteをオーバーライドするとCurrentStateExecuteが呼び出されます。

 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 エディターの [プロジェクト] ウィンドウで:

  1. SampleScene アセットを開きます。

  2. Unity 階層ウィンドウでEnemyゲーム オブジェクトを見つけます。

  3. BaseStateMachineコンポーネントをBaseStateMachineGraphコンポーネントに置き換えます。

    1. [コンポーネントの追加] をクリックし、正しいBaseStateMachineGraphスクリプトを選択します。

    2. FSM グラフEnemyGraphBaseStateMachineGraphコンポーネントのGraphフィールドに割り当てます。

    3. 右クリックして [コンポーネントの削除] を選択し、 BaseStateMachineコンポーネントを削除します (不要になったため)。

Enemyゲーム オブジェクトは次のようになります。

上から順に、Inspector 画面で Enemy の横にチェックがあります。タグドロップダウンで「プレイヤー」が選択され、レイヤードロップダウンで「敵」が選択されています。 [変換] ドロップダウンには、位置、回転、およびスケールが表示されます。 Capsule ドロップダウン メニューは圧縮されており、Mesh Renderer、Capsule Collider、および Nav Mesh Agent ドロップダウンは圧縮されて表示され、左側にチェックが付いています。 Enemy Sight Sensor ドロップダウンには、スクリプトと無視マスクが表示されます。 PatrolPoints ドロップダウンには、スクリプトと 4 つの PatrolPoints が表示されます。 [Base State Machine Graph (Script)] ドロップダウンの横にチェック マークがあります。スクリプトは「BaseStateMachineGraph」を示し、初期状態は「None (基本状態)」を示し、グラフは「EnemyGraph (FSM グラフ)」を示します。最後に、Blue Enemy (マテリアル) ドロップダウンが圧縮され、「コンポーネントの追加」ボタンが下に表示されます。それ。
Our Enemyゲームオブジェクト

それでおしまい! これで、グラフィック エディターを備えたモジュラー FSM が完成しました。 [再生] ボタンをクリックすると、グラフィカルに作成された敵の AI が、以前に作成したScriptableObjectの敵とまったく同じように機能することがわかります。

前進: FSM の最適化

注意点: ゲーム用により高度な AI を開発すると、ステートとトランジションの数が増え、FSM がわかりにくくなり、読みにくくなります。 グラフィカル エディターは、複数の状態で始まり、複数の遷移で終了する一連の線のように成長し、その逆も同様であり、FSM のデバッグが困難になります。

前のチュートリアルと同様に、独自のコードを作成し、ステルス ゲームを最適化し、これらの問題に対処することをお勧めします。 状態ノードを色分けしてノードがアクティブか非アクティブかを示したり、 RemainInStateノードとInitialノードのサイズを変更して画面の領域を制限したりすることがどれほど役立つか想像してみてください。

このような機能強化は、単に表面的なものではありません。 色とサイズの参照は、いつどこでデバッグするかを特定するのに役立ちます。 見やすいグラフは、評価、分析、理解も簡単です。 次のステップはあなた次第です。グラフィカル エディターの基盤が整っているので、デベロッパー エクスペリエンスの改善に制限はありません。

Toptal Engineering ブログの詳細情報:

  • Unity 開発者が犯しがちな 10 の間違い
  • Unity with MVC: ゲーム開発をレベルアップする方法
  • Unity で 2D カメラをマスターする: ゲーム開発者向けのチュートリアル
  • Toptal 開発者による Unity のベスト プラクティスとヒント

トップタル エンジニアリング ブログは、この記事の専門知識と技術的なレビューについて Goran Lalic に感謝の意を表します。