Unity AI Development: Ein xNode-basiertes grafisches FSM-Tutorial
Veröffentlicht: 2022-08-12In „Unity AI Development: A Finite-state Machine Tutorial“ haben wir ein einfaches Stealth-Spiel entwickelt – eine modulare FSM-basierte KI. Im Spiel patrouilliert ein feindlicher Agent im Spielraum. Wenn es den Spieler entdeckt, ändert der Feind seinen Zustand und folgt dem Spieler, anstatt zu patrouillieren.
In dieser zweiten Etappe unserer Unity-Reise werden wir eine grafische Benutzeroberfläche (GUI) erstellen, um die Kernkomponenten unserer Finite-State-Machine (FSM) schneller und mit einer verbesserten Unity-Entwicklererfahrung zu erstellen.
Eine schnelle Auffrischung
Der im vorherigen Tutorial beschriebene FSM wurde aus Architekturblöcken als C#-Skripts erstellt. Wir haben benutzerdefinierte ScriptableObject
Aktionen und -Entscheidungen als Klassen hinzugefügt. Der ScriptableObject
-Ansatz ermöglichte eine einfach wartbare und anpassbare FSM. In diesem Tutorial ersetzen wir die Drag-and-Drop- ScriptableObject
des FSM durch eine grafische Option.
Ich habe auch ein aktualisiertes Skript für diejenigen unter Ihnen geschrieben, die es einfacher machen möchten, das Spiel zu gewinnen. Ersetzen Sie zur Implementierung einfach das Spielererkennungsskript durch dieses, das das Sichtfeld des Feindes einschränkt.
Erste Schritte mit xNode
Wir werden unseren grafischen Editor mit xNode erstellen, einem Framework für knotenbasierte Verhaltensbäume, das den Fluss unseres FSM visuell darstellt. Obwohl GraphView von Unity die Aufgabe erfüllen kann, ist seine API sowohl experimentell als auch spärlich dokumentiert. Die Benutzeroberfläche von xNode bietet ein hervorragendes Entwicklererlebnis und erleichtert das Prototyping und die schnelle Erweiterung unseres FSM.
Fügen wir unserem Projekt xNode als Git-Abhängigkeit mit dem Unity Package Manager hinzu:
- Klicken Sie in Unity auf Window > Package Manager , um das Package Manager-Fenster zu starten.
- Klicken Sie auf + (das Pluszeichen) in der oberen linken Ecke des Fensters und wählen Sie Paket von Git-URL hinzufügen aus , um ein Textfeld anzuzeigen.
- Geben oder fügen Sie
https://github.com/siccity/xNode.git
in das unbeschriftete Textfeld ein und klicken Sie auf die Schaltfläche Hinzufügen .
Jetzt sind wir bereit, tief einzutauchen und die Schlüsselkomponenten von xNode zu verstehen:
Node | Repräsentiert einen Knoten, die grundlegendste Einheit eines Diagramms. In diesem xNode-Tutorial leiten wir von der Node -Klasse neue Klassen ab, die Knoten deklarieren, die mit benutzerdefinierten Funktionen und Rollen ausgestattet sind. |
NodeGraph -Klasse | Stellt eine Sammlung von Knoten ( Node -Klasseninstanzen) und die Kanten dar, die sie verbinden. In diesem xNode-Tutorial leiten wir von NodeGraph eine neue Klasse ab, die die Knoten manipuliert und auswertet. |
NodePort -Klasse | Repräsentiert ein Kommunikationsgatter, einen Port vom Typ Input oder vom Typ Output, der sich zwischen Node -Instanzen in einem NodeGraph . Die NodePort -Klasse ist einzigartig für xNode. |
[Input] -Attribut | Das Hinzufügen des Attributs [Input] zu einem Port kennzeichnet ihn als Eingang und ermöglicht es dem Port, Werte an den Knoten zu übergeben, zu dem er gehört. Stellen Sie sich das Attribut [Input] als einen Funktionsparameter vor. |
[Output] -Attribut | Das Hinzufügen des Attributs [Output] zu einem Port kennzeichnet ihn als Ausgang, wodurch der Port in die Lage versetzt wird, Werte von dem Knoten zu übergeben, zu dem er gehört. Stellen Sie sich das Attribut [Output] als den Rückgabewert einer Funktion vor. |
Visualisierung der xNode Building Environment
In xNode arbeiten wir mit Graphen, bei denen jeder State
und Transition
die Form eines Knotens hat. Eingabe- und/oder Ausgabeverbindung(en) ermöglichen es dem Knoten, sich auf beliebige oder alle anderen Knoten in unserem Graphen zu beziehen.
Stellen wir uns einen Knoten mit drei Eingabewerten vor: zwei willkürliche und einen booleschen Wert. Der Knoten gibt einen der beiden willkürlichen Eingabewerte aus, je nachdem, ob die boolesche Eingabe wahr oder falsch ist.
Um unser vorhandenes FSM in ein Diagramm umzuwandeln, ändern wir die Klassen State
und Transition
so, dass sie die Klasse Node
anstelle der Klasse ScriptableObject
erben. Wir erstellen ein Diagrammobjekt vom Typ NodeGraph
, das alle unsere State
und Transition
enthält.
Ändern von BaseStateMachine
zur Verwendung als Basistyp
Beginnen Sie mit dem Erstellen der grafischen Oberfläche, indem Sie unserer vorhandenen BaseStateMachine
-Klasse zwei neue virtuelle Methoden hinzufügen:
Init | Weist der CurrentState Eigenschaft den Anfangszustand zu |
Execute | Führt den aktuellen Zustand aus |
Indem wir diese Methoden als virtuell deklarieren, können wir sie überschreiben, sodass wir das benutzerdefinierte Verhalten von Klassen definieren können, die die BaseStateMachine
-Klasse für die Initialisierung und Ausführung erben:
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; } } }
Als nächstes erstellen wir unter unserem FSM
-Ordner:
FSMGraph | Ein Ordner |
BaseStateMachineGraph | AC#-Klasse innerhalb FSMGraph |
Vorerst erbt BaseStateMachineGraph
nur die BaseStateMachine
-Klasse:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
Wir können BaseStateMachineGraph
keine Funktionalität hinzufügen, bis wir unseren Basisknotentyp erstellt haben; machen wir das als nächstes.
Implementieren NodeGraph
und Erstellen eines Basisknotentyps
Unter unserem neu erstellten FSMGraph
Ordner erstellen wir:
FSMGraph | Eine Klasse |
Im Moment erbt FSMGraph
nur die NodeGraph
-Klasse (ohne zusätzliche Funktionalität):
using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }
Bevor wir Klassen für unsere Knoten erstellen, fügen wir Folgendes hinzu:
FSMNodeBase | Eine Klasse, die von allen unseren Knoten als Basisklasse verwendet werden soll |
Die FSMNodeBase
-Klasse enthält eine Eingabe namens Entry
vom Typ FSMNodeBase
, damit wir Knoten miteinander verbinden können.
Wir werden auch zwei Hilfsfunktionen hinzufügen:
GetFirst | Ruft den ersten Knoten ab, der mit der angeforderten Ausgabe verbunden ist |
GetAllOnPort | Ruft alle verbleibenden Knoten ab, die mit der angeforderten Ausgabe verbunden sind |
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; } } }
Letztendlich werden wir zwei Arten von Zustandsknoten haben; Lassen Sie uns eine Klasse hinzufügen, um diese zu unterstützen:
BaseStateNode | Eine Basisklasse zur Unterstützung von StateNode und RemainInStateNode |
namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }
Ändern Sie als Nächstes die BaseStateMachineGraph
-Klasse:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }
Hier haben wir die von der Basisklasse geerbte Eigenschaft CurrentState
ausgeblendet und ihren Typ von BaseState
in BaseStateNode
.
Erstellen von Bausteinen für unser FSM-Diagramm
Um die Hauptbausteine unseres FSM zu bilden, fügen wir als Nächstes drei neue Klassen zu unserem FSMGraph
Ordner hinzu:
StateNode | Stellt den Zustand eines Agenten dar. Bei der Ausführung iteriert StateNode über die TransitionNode s, die mit dem Ausgangsport des StateNode (von einer Hilfsmethode abgerufen). StateNode fragt jeden ab, ob der Knoten in einen anderen Zustand übergehen oder den Zustand des Knotens unverändert lassen soll. |
RemainInStateNode | Gibt an, dass ein Knoten im aktuellen Zustand bleiben soll. |
TransitionNode | Trifft die Entscheidung, in einen anderen Zustand zu wechseln oder im selben Zustand zu bleiben. |
Im vorherigen Unity FSM-Lernprogramm durchläuft die State
-Klasse die Übergangsliste. Hier in xNode dient StateNode
als Äquivalent von State
, um über die Knoten zu iterieren, die über unsere GetAllOnPort
-Hilfsmethode abgerufen werden.
Fügen Sie nun den ausgehenden Verbindungen (den Übergangsknoten) ein [Output]
-Attribut hinzu, um anzuzeigen, dass sie Teil der GUI sein sollen. Durch das Design von xNode stammt der Wert des Attributs aus dem Quellknoten: dem Knoten, der das mit dem [Output]
-Attribut markierte Feld enthält. Da wir [Output]
- und [Input]
-Attribute verwenden, um Beziehungen und Verbindungen zu beschreiben, die von der xNode-GUI festgelegt werden, können wir diese Werte nicht wie gewohnt behandeln. Überlegen Sie, wie wir Actions
versus Transitions
durchlaufen:
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); } } }
In diesem Fall können an die Transitions
Ausgabe mehrere Knoten angehängt werden; Wir müssen die GetAllOnPort
-Hilfsmethode aufrufen, um eine Liste der [Output]
-Verbindungen zu erhalten.
RemainInStateNode
ist bei weitem unsere einfachste Klasse. RemainInStateNode
führt keine Logik aus und zeigt unserem Agenten – in unserem Spiel dem Feind – lediglich an, in seinem aktuellen Zustand zu bleiben:
namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }
Zu diesem Zeitpunkt ist die TransitionNode
-Klasse noch unvollständig und wird nicht kompiliert. Die zugehörigen Fehler werden gelöscht, sobald wir die Klasse aktualisieren.
Um TransitionNode
zu erstellen, müssen wir die Anforderung von xNode umgehen, dass der Wert der Ausgabe aus dem Quellknoten stammt – wie wir es beim Erstellen von StateNode
. Ein Hauptunterschied zwischen StateNode
und TransitionNode
besteht darin, dass die Ausgabe von TransitionNode
nur an einen Knoten angehängt werden kann. In unserem Fall GetFirst
den einen Knoten ab, der an jeden unserer Ports angeschlossen ist (ein Zustandsknoten, zu dem im wahren Fall übergegangen wird, und ein anderer, zu dem im falschen Fall übergegangen wird):
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; } } }
Werfen wir einen Blick auf die grafischen Ergebnisse unseres Codes.
Erstellen des visuellen Diagramms
Nachdem alle FSM-Klassen aussortiert sind, können wir damit fortfahren, unser FSM-Diagramm für den feindlichen Agenten des Spiels zu erstellen. Klicken Sie im Unity-Projektfenster mit der rechten Maustaste auf den Ordner EnemyAI
und wählen Sie: Create > FSM > FSM Graph . Um unser Diagramm leichter zu identifizieren, benennen wir es in EnemyGraph
.
Klicken Sie im xNode-Grafikeditorfenster mit der rechten Maustaste, um ein Dropdown-Menü anzuzeigen, das State , Transition und RemainInState auflistet . Wenn das Fenster nicht sichtbar ist, doppelklicken Sie auf die EnemyGraph
-Datei, um das Fenster des xNode-Graph-Editors zu starten.
So erstellen Sie die Zustände „
Chase
undPatrol
“:Klicken Sie mit der rechten Maustaste und wählen Sie State , um einen neuen Knoten zu erstellen.
Benennen Sie den Knoten
Chase
.Kehren Sie zum Dropdown-Menü zurück und wählen Sie erneut State , um einen zweiten Knoten zu erstellen.
Benennen Sie den Knoten
Patrol
.Ziehen Sie die vorhandenen
Chase
undPatrol
per Drag & Drop in ihre neu erstellten entsprechenden Zustände.
So erstellen Sie den Übergang:
Klicken Sie mit der rechten Maustaste und wählen Sie Übergang , um einen neuen Knoten zu erstellen.
Weisen Sie das
LineOfSightDecision
Objekt demDecision
des Übergangs zu.
So erstellen Sie den
RemainInState
-Knoten:- Klicken Sie mit der rechten Maustaste, und wählen Sie RemainInState aus, um einen neuen Knoten zu erstellen.
Um den Graphen zu verbinden:
Verbinden Sie den
Transitions
-Ausgang desPatrol
-Knotens mit demEntry
-Eingang desTransition
-Knotens.Verbinden Sie den
True State
-Ausgang desTransition
-Knotens mit demEntry
-Eingang desChase
-Knotens.Verbinden Sie den
False State
-Ausgang desTransition
-Knotens mit demEntry
-Eingang desRemain In State
-Knotens.
Die Grafik sollte wie folgt aussehen:
Nichts in der Grafik zeigt an, welcher Knoten – der Patrol
oder Chase
– unser Anfangsknoten ist. Die BaseStateMachineGraph
-Klasse erkennt vier Knoten, kann aber ohne vorhandene Indikatoren den Anfangszustand nicht auswählen.
Um dieses Problem zu beheben, erstellen wir Folgendes:
FSMInitialNode | Eine Klasse, deren einzelne Ausgabe vom Typ StateNode den Namen InitialNode |
Unsere Ausgabe InitialNode
bezeichnet den Anfangszustand. Erstellen Sie als Nächstes in FSMInitialNode
:
NextNode | Eine Eigenschaft, die es uns ermöglicht, den Knoten abzurufen, der mit der InitialNode Ausgabe verbunden ist |
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; } } } }
Nachdem wir die Klasse FSMInitialNode
erstellt haben, können wir sie mit dem Eingang Entry
des Anfangszustands verbinden und den Anfangszustand über die Eigenschaft NextNode
.
Kehren wir zu unserem Diagramm zurück und fügen den Anfangsknoten hinzu. Im xNode-Editorfenster:
- Klicken Sie mit der rechten Maustaste und wählen Sie Anfangsknoten , um einen neuen Knoten zu erstellen.
- Verbinden Sie den Ausgang des FSM-Knotens mit dem
Entry
desPatrol
-Knotens.
Die Grafik sollte nun so aussehen:
Um unser Leben einfacher zu machen, fügen wir FSMGraph
:
InitialState | Ein Besitz |
Wenn wir zum ersten Mal versuchen, den Wert der Eigenschaft InitialState
abzurufen, durchläuft der Getter der Eigenschaft alle Knoten in unserem Diagramm, während er versucht, FSMInitialNode
zu finden. Sobald FSMInitialNode
wurde, verwenden wir die NextNode
Eigenschaft, um unseren Anfangszustandsknoten zu finden:
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; } } }
Als Nächstes verweisen wir in unserem BaseStateMachineGraph
auf FSMGraph
und überschreiben die Init
- und Execute
-Methoden unseres BaseStateMachine
. Das Überschreiben von Init
legt CurrentState
als Anfangszustand des Diagramms fest, und das Überschreiben von Execute
ruft Execute
on CurrentState
auf:
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); } } }
Wenden wir nun den Graphen auf das Enemy-Objekt an und sehen ihn in Aktion.
Testen des FSM-Diagramms
In Vorbereitung auf das Testen im Projektfenster des Unity-Editors:
Öffnen Sie das SampleScene-Asset.
Suchen Sie unser
Enemy
Spielobjekt im Unity-Hierarchiefenster.Ersetzen Sie die
BaseStateMachine
Komponente durch dieBaseStateMachineGraph
Komponente:Klicken Sie auf Komponente hinzufügen und wählen Sie das richtige
BaseStateMachineGraph
Skript aus.Weisen Sie unser FSM-Diagramm
EnemyGraph
dem FeldGraph
derBaseStateMachineGraph
Komponente zu.Löschen Sie die
BaseStateMachine
Komponente (da sie nicht mehr benötigt wird), indem Sie mit der rechten Maustaste klicken und Komponente entfernen auswählen.
Das Enemy
-Spielobjekt sollte wie folgt aussehen:
Das ist es! Jetzt haben wir ein modulares FSM mit einem grafischen Editor. Ein Klick auf die Play- Schaltfläche zeigt, dass die grafisch erstellte Feind-KI genau wie unser zuvor erstellter ScriptableObject
-Feind funktioniert.
Forging Ahead: Optimierung unseres FSM
Ein Wort der Vorsicht: Wenn Sie eine ausgefeiltere KI für Ihr Spiel entwickeln, nimmt die Anzahl der Zustände und Übergänge zu, und der FSM wird verwirrend und schwer lesbar. Der grafische Editor ähnelt einem Netz von Linien, die in mehreren Zuständen entstehen und an mehreren Übergängen enden – und umgekehrt, was das Debuggen des FSM erschwert.
Wie im vorherigen Tutorial laden wir Sie ein, den Code zu Ihrem eigenen zu machen, Ihr Stealth-Spiel zu optimieren und diese Bedenken auszuräumen. Stellen Sie sich vor, wie hilfreich es wäre, Ihre Zustandsknoten farblich zu codieren, um anzuzeigen, ob ein Knoten aktiv oder inaktiv ist, oder die Größe der RemainInState
und Initial
-Knoten zu ändern, um ihre Bildschirmfläche zu begrenzen.
Solche Verbesserungen sind nicht nur kosmetischer Natur. Farb- und Größenreferenzen würden helfen, zu erkennen, wo und wann debuggt werden muss. Eine gut sichtbare Grafik ist auch einfacher zu beurteilen, zu analysieren und zu verstehen. Alle nächsten Schritte liegen bei Ihnen – mit der Grundlage unseres grafischen Editors sind den Verbesserungen der Entwicklererfahrung keine Grenzen gesetzt.
Weiterführende Literatur im Toptal Engineering Blog:
- Die 10 häufigsten Fehler, die Unity-Entwickler machen
- Einheit mit MVC: Wie Sie Ihre Spieleentwicklung verbessern
- 2D-Kameras in Unity beherrschen: Ein Tutorial für Spieleentwickler
- Unity Best Practices und Tipps von Toptal-Entwicklern
Der Toptal Engineering Blog dankt Goran Lalic für sein Fachwissen und seine technische Überprüfung dieses Artikels.