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 依存関係としてプロジェクトに追加しましょう。
- Unity で、[ウィンドウ] > [パッケージ マネージャー] をクリックして、[パッケージ マネージャー] ウィンドウを起動します。
- ウィンドウの左上隅にある+ (プラス記号) をクリックし、[ Add package from git URL ] を選択してテキスト フィールドを表示します。
- ラベルのないテキスト ボックスに
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 では、各State
とTransition
がノードの形をとるグラフを操作します。 入力および/または出力接続により、ノードをグラフ内の任意またはすべての他のノードに関連付けることができます。
2 つの任意値と 1 つのブール値の 3 つの入力値を持つノードを想像してみましょう。 このノードは、ブール入力が true か false かに応じて、2 つの任意の型の入力値のいずれかを出力します。
既存の 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# クラス |
当面の間、 BaseStateMachineGraph
はBaseStateMachine
クラスのみを継承します。
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
ベース ノード タイプを作成するまで、 BaseStateMachineGraph
に機能を追加することはできません。 次はそうしましょう。
NodeGraph
の実装とベース ノード タイプの作成
新しく作成したFSMGraph
フォルダーの下に、以下を作成します。
FSMGraph | クラス |
今のところ、 FSMGraph
はNodeGraph
クラスのみを継承します (追加機能はありません)。
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 | StateNode とRemainInStateNode の両方をサポートする基本クラス |
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 | エージェントの状態を表します。 実行時に、 StateNode はStateNode の出力ポート (ヘルパー メソッドによって取得) に接続されたTransitionNode を反復処理します。 StateNode は、ノードを別の状態に遷移させるか、ノードの状態をそのままにしておくかをそれぞれに問い合わせます。 |
RemainInStateNode | ノードを現在の状態のままにする必要があることを示します。 |
TransitionNode | 別の状態に移行するか、同じ状態にとどまるかを決定します。 |
前の Unity FSM チュートリアルでは、 State
クラスはトランジション リストを繰り返します。 ここで xNode では、 StateNode
は、 GetAllOnPort
ヘルパー メソッドを介して取得されたノードを反復処理するState
と同等の機能を果たします。
[Output]
属性を発信接続 (遷移ノード) に追加して、それらが GUI の一部であることを示します。 xNode の設計により、属性の値はソース ノード[Output]
属性でマークされたフィールドを含むノード) に由来します。 [Output]
属性と[Input]
属性を使用して、xNode GUI によって設定される関係と接続を記述しているため、これらの値を通常のように扱うことはできません。 Actions
とTransitions
を反復する方法を考えてみましょう:
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 の要件を回避する必要があります。 StateNode
とTransitionNode
の主な違いは、 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 グラフ エディタ ウィンドウで、右クリックして、 State 、 Transition 、およびRemainInStateをリストするドロップダウン メニューを表示します。 ウィンドウが表示されていない場合は、 EnemyGraph
ファイルをダブルクリックして、xNode グラフ エディター ウィンドウを起動します。
Chase
状態とPatrol
状態を作成するには:右クリックしてStateを選択し、新しいノードを作成します。
ノードに
Chase
という名前を付けます。ドロップダウン メニューに戻り、もう一度Stateを選択して 2 つ目のノードを作成します。
ノードに
Patrol
という名前を付けます。既存の
Chase
およびPatrol
アクションを、新しく作成された対応する状態にドラッグ アンド ドロップします。
トランジションを作成するには:
右クリックして [トランジション] を選択し、新しいノードを作成します。
LineOfSightDecision
オブジェクトを遷移のDecision
フィールドに割り当てます。
RemainInState
ノードを作成するには:- 右クリックしてRemainInStateを選択し、新しいノードを作成します。
グラフを接続するには:
Patrol
ノードのTransitions
出力をTransition
ノードのEntry
入力に接続します。Transition
ノードのTrue State
出力をChase
ノードのEntry
入力に接続します。Transition
ノードのFalse State
出力をRemain In State
ノードのEntry
入力に接続します。
グラフは次のようになります。
グラフには、どのノード ( 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 エディター ウィンドウで:
- 右クリックして [初期ノード] を選択し、新しいノードを作成します。
- FSM Nodeの出力を
Patrol
ノードのEntry
入力に接続します。
グラフは次のようになります。
私たちの生活を楽にするために、 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; } } }
次に、 BaseStateMachineGraph
でFSMGraph
を参照し、 BaseStateMachine
のInit
メソッドとExecute
メソッドをオーバーライドします。 Init
をオーバーライドするとCurrentState
がグラフの初期状態として設定され、 Execute
をオーバーライドするとCurrentState
でExecute
が呼び出されます。
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 エディターの [プロジェクト] ウィンドウで:
SampleScene アセットを開きます。
Unity 階層ウィンドウで
Enemy
ゲーム オブジェクトを見つけます。BaseStateMachine
コンポーネントをBaseStateMachineGraph
コンポーネントに置き換えます。[コンポーネントの追加] をクリックし、正しい
BaseStateMachineGraph
スクリプトを選択します。FSM グラフ
EnemyGraph
をBaseStateMachineGraph
コンポーネントのGraph
フィールドに割り当てます。右クリックして [コンポーネントの削除] を選択し、
BaseStateMachine
コンポーネントを削除します (不要になったため)。
Enemy
ゲーム オブジェクトは次のようになります。
それでおしまい! これで、グラフィック エディターを備えたモジュラー FSM が完成しました。 [再生] ボタンをクリックすると、グラフィカルに作成された敵の AI が、以前に作成したScriptableObject
の敵とまったく同じように機能することがわかります。
前進: FSM の最適化
注意点: ゲーム用により高度な AI を開発すると、ステートとトランジションの数が増え、FSM がわかりにくくなり、読みにくくなります。 グラフィカル エディターは、複数の状態で始まり、複数の遷移で終了する一連の線のように成長し、その逆も同様であり、FSM のデバッグが困難になります。
前のチュートリアルと同様に、独自のコードを作成し、ステルス ゲームを最適化し、これらの問題に対処することをお勧めします。 状態ノードを色分けしてノードがアクティブか非アクティブかを示したり、 RemainInState
ノードとInitial
ノードのサイズを変更して画面の領域を制限したりすることがどれほど役立つか想像してみてください。
このような機能強化は、単に表面的なものではありません。 色とサイズの参照は、いつどこでデバッグするかを特定するのに役立ちます。 見やすいグラフは、評価、分析、理解も簡単です。 次のステップはあなた次第です。グラフィカル エディターの基盤が整っているので、デベロッパー エクスペリエンスの改善に制限はありません。
Toptal Engineering ブログの詳細情報:
- Unity 開発者が犯しがちな 10 の間違い
- Unity with MVC: ゲーム開発をレベルアップする方法
- Unity で 2D カメラをマスターする: ゲーム開発者向けのチュートリアル
- Toptal 開発者による Unity のベスト プラクティスとヒント
トップタル エンジニアリング ブログは、この記事の専門知識と技術的なレビューについて Goran Lalic に感謝の意を表します。