Unity-KI-Entwicklung: Ein endliches Maschinen-Tutorial
Veröffentlicht: 2022-03-11In der kompetitiven Gaming-Welt streben Entwickler danach, denjenigen, die mit den von uns erstellten Nicht-Spieler-Charakteren (NPCs) interagieren, ein unterhaltsames Benutzererlebnis zu bieten. Entwickler können diese Interaktivität bereitstellen, indem sie Finite-State-Maschinen (FSMs) verwenden, um KI-Lösungen zu erstellen, die Intelligenz in unseren NPCs simulieren.
KI-Trends haben sich zu Verhaltensbäumen verlagert, aber FSMs bleiben relevant. Sie sind – in der einen oder anderen Funktion – in praktisch jedes elektronische Spiel integriert.
Anatomie eines FSM
Ein FSM ist ein Berechnungsmodell, in dem nur einer einer endlichen Anzahl von hypothetischen Zuständen gleichzeitig aktiv sein kann. Ein FSM geht von einem Zustand in einen anderen über und reagiert auf Bedingungen oder Eingaben. Zu seinen Kernkomponenten gehören:
| Komponente | Beschreibung |
|---|---|
| Bundesland | Eine aus einer endlichen Menge von Optionen, die den aktuellen Gesamtzustand einer FSM angibt; Jeder gegebene Zustand enthält einen zugeordneten Satz von Aktionen |
| Aktion | Was ein Zustand tut, wenn der FSM ihn abfragt |
| Entscheidung | Die Logik, die festlegt, wann ein Übergang stattfindet |
| Übergang | Der Prozess der Zustandsänderung |
Während wir uns auf FSMs aus der Perspektive der KI-Implementierung konzentrieren, fallen auch Konzepte wie Animationszustandsmaschinen und allgemeine Spielzustände unter das Dach von FSM.
Visualisierung eines FSM
Betrachten wir das Beispiel des klassischen Arcade-Spiels Pac-Man. Im Anfangszustand des Spiels (dem „Jagd“-Zustand) sind die NPCs farbenfrohe Geister, die den Spieler verfolgen und schließlich überholen. Die Geister wechseln in den Ausweichzustand, wenn der Spieler ein Power-Pellet isst und ein Power-Up erfährt, wodurch er die Fähigkeit erhält, die Geister zu essen. Die jetzt blauen Geister weichen dem Spieler aus, bis das Einschalten abläuft und die Geister in den Verfolgungszustand zurückkehren, in dem ihr ursprüngliches Verhalten und ihre ursprünglichen Farben wiederhergestellt werden.
Ein Pac-Man-Geist befindet sich immer in einem von zwei Zuständen: jagen oder ausweichen. Natürlich müssen wir zwei Übergänge bereitstellen – einen von der Verfolgung zum Ausweichen, den anderen vom Ausweichen zur Verfolgung:
Die Finite-State-Maschine fragt konstruktionsbedingt den aktuellen Zustand ab, der die Entscheidung(en) und Aktion(en) dieses Zustands abfragt. Das folgende Diagramm stellt unser Pac-Man-Beispiel dar und zeigt eine Entscheidung, die den Status des Einschaltens des Spielers überprüft. Wenn ein Power-Up begonnen hat, gehen die NPCs von der Jagd auf das Ausweichen über. Wenn ein Power-Up beendet ist, wechseln die NPCs von Ausweichen zu Jagen. Wenn es schließlich keine Einschaltänderung gibt, tritt kein Übergang auf.
Skalierbarkeit
FSMs geben uns die Freiheit, modulare KI zu bauen. Beispielsweise können wir mit nur einer einzigen neuen Aktion einen NPC mit einem neuen Verhalten erstellen. So können wir einem unserer Pac-Man-Geister eine neue Aktion zuschreiben – das Essen eines Power-Pellets – und ihm die Fähigkeit geben, Power-Pellets zu essen, während er dem Spieler ausweicht. Wir können vorhandene Aktionen, Entscheidungen und Übergänge wiederverwenden, um dieses Verhalten zu unterstützen.
Da die Ressourcen, die für die Entwicklung eines einzigartigen NPCs erforderlich sind, minimal sind, sind wir gut positioniert, um die sich entwickelnden Projektanforderungen mehrerer einzigartiger NPCs zu erfüllen. Andererseits kann uns eine übermäßige Anzahl von Zuständen und Übergängen in eine Spaghetti-Zustandsmaschine verwickeln – eine FSM, deren Überfluss an Verbindungen das Debuggen und Warten erschwert.
Implementieren eines FSM in Unity
Um zu demonstrieren, wie ein endlicher Automat in Unity implementiert wird, erstellen wir ein einfaches Stealth-Spiel. Unsere Architektur wird ScriptableObject s enthalten, bei denen es sich um Datencontainer handelt, die Informationen in der gesamten Anwendung speichern und freigeben können, sodass wir sie nicht reproduzieren müssen. ScriptableObject s können nur eingeschränkt verarbeitet werden, wie z. B. das Aufrufen von Aktionen und das Abfragen von Entscheidungen. Neben der offiziellen Dokumentation von Unity bleibt der ältere Vortrag über die Spielarchitektur mit skriptfähigen Objekten eine hervorragende Ressource, wenn Sie tiefer eintauchen möchten.
Bevor wir KI zu diesem anfänglichen, fertig kompilierbaren Projekt hinzufügen, betrachten Sie die vorgeschlagene Architektur:
In unserem Beispielspiel patrouilliert der Feind (ein NPC, dargestellt durch eine blaue Kapsel). Wenn der Feind den Spieler sieht (dargestellt durch eine graue Kapsel), beginnt der Feind, dem Spieler zu folgen:
Im Gegensatz zu Pac-Man kehrt der Feind in unserem Spiel nicht in den Standardzustand („Patrouille“) zurück, sobald er dem Spieler folgt.
Klassen erstellen
Beginnen wir mit der Erstellung unserer Klassen. In einem neuen scripts fügen wir alle vorgeschlagenen Architekturbausteine als C#-Skripts hinzu.
Implementieren der BaseStateMachine Klasse
Die BaseStateMachine -Klasse ist das einzige MonoBehavior , das wir hinzufügen werden, um auf unsere KI-fähigen NPCs zuzugreifen. Der Einfachheit halber wird unsere BaseStateMachine nackt sein. Wenn wir wollten, könnten wir jedoch einen geerbten benutzerdefinierten FSM hinzufügen, der zusätzliche Parameter und Verweise auf zusätzliche Komponenten speichert. Beachten Sie, dass der Code nicht ordnungsgemäß kompiliert wird, bis wir unsere BaseState -Klasse hinzugefügt haben, was wir später in unserem Tutorial tun werden.
Der Code für BaseStateMachine bezieht sich auf den aktuellen Zustand und führt ihn aus, um die Aktionen auszuführen und zu prüfen, ob ein Übergang gerechtfertigt ist:
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); } } } Implementieren der BaseState Klasse
Unser Zustand ist vom Typ BaseState , den wir von einem ScriptableObject ableiten. BaseState enthält eine einzelne Methode, Execute , die BaseStateMachine als Argument verwendet und Aktionen und Übergänge an sie übergibt. So sieht BaseState aus:
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } Implementieren der State und RemainInState Klassen
Wir leiten nun zwei Klassen von BaseState ab. Erstens haben wir die State -Klasse, die Verweise auf Aktionen und Übergänge speichert, zwei Listen enthält (eine für Aktionen, die andere für Übergänge) und die Execute von Aktionen und Übergängen überschreibt und aufruft:
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); } } } Zweitens haben wir die RemainInState -Klasse, die dem FSM mitteilt, wann kein Übergang durchgeführt werden soll:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } Beachten Sie, dass diese Klassen nicht kompiliert werden, bis wir die Klassen FSMAction , Decision und Transition hinzugefügt haben.
Implementieren der FSMAction Klasse
Im Diagramm der vorgeschlagenen FSM-Architektur ist die Basis- FSMAction -Klasse mit „Action“ gekennzeichnet. Wir erstellen jedoch die Basisklasse FSMAction und verwenden den Namen FSMAction (da Action bereits vom .NET System Namespace verwendet wird).
FSMAction , ein ScriptableObject , kann Funktionen nicht unabhängig verarbeiten, daher definieren wir es als abstrakte Klasse. Während unsere Entwicklung fortschreitet, benötigen wir möglicherweise eine einzige Aktion, um mehr als einem Staat zu dienen. Glücklicherweise können wir FSMAction mit so vielen Zuständen von so vielen FSMs verknüpfen, wie wir möchten.

Die abstrakte Klasse FSMAction sieht folgendermaßen aus:
using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } Implementieren der Decision und Transition
Um unsere FSM abzuschließen, werden wir zwei weitere Klassen definieren. Zuerst haben wir Decision , eine abstrakte Klasse, von der aus alle anderen Entscheidungen ihr benutzerdefiniertes Verhalten definieren würden:
using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } Die zweite Klasse, Transition , enthält das Decision und zwei Zustände:
- Ein Zustand, in den übergegangen werden soll, wenn die
Decisionwahr ergibt. - Ein anderer Zustand, in den übergegangen werden kann, wenn die
Decisionfalsch ergibt.
Es sieht aus wie das:
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; } } }Alles, was wir bis zu diesem Punkt aufgebaut haben, sollte ohne Fehler kompilieren. Wenn Probleme auftreten, überprüfen Sie Ihre Unity-Editor-Version, die zu Fehlern führen kann, wenn sie veraltet ist. Stellen Sie sicher, dass alle Dateien ordnungsgemäß aus dem ursprünglichen Projektordner geklont wurden und dass alle öffentlich zugänglichen Variablen nicht als privat deklariert sind.
Erstellen benutzerdefinierter Aktionen und Entscheidungen
Jetzt, nachdem die schwere Arbeit erledigt ist, sind wir bereit, benutzerdefinierte Aktionen und Entscheidungen in einem neuen scripts zu implementieren.
Implementieren der Patrol und Chase -Klassen
Wenn wir die Kernkomponenten unseres Beispiel-Stealth-Game-FSM-Diagramms analysieren, sehen wir, dass sich unser NPC in einem von zwei Zuständen befinden kann:
- Patrouillenstaat - Mit dem Staat verbunden sind:
- Eine Aktion: NPC besucht zufällige Patrouillenpunkte auf der ganzen Welt.
- Ein Übergang: Der NPC prüft, ob der Spieler in Sicht ist und wechselt gegebenenfalls in den Verfolgungszustand.
- Eine Entscheidung: NPC prüft, ob der Spieler in Sicht ist.
- Chase-Zustand – Mit dem Zustand verbunden ist:
- Eine Aktion: NPC jagt den Spieler.
Wir können unsere vorhandene Übergangsimplementierung über die GUI von Unity wiederverwenden, wie wir später besprechen werden. Damit bleiben zwei Aktionen ( PatrolAction und ChaseAction ) und eine Entscheidung für uns, zu codieren.
Die Patrouillenzustandsaktion (die von der Basis- FSMAction ) überschreibt die Execute -Methode, um zwei Komponenten zu erhalten:
-
PatrolPoints, das Patrouillenpunkte verfolgt. -
NavMeshAgent, Unitys Implementierung für die Navigation im 3D-Raum.
Der Override prüft dann, ob der AI-Agent sein Ziel erreicht hat und bewegt sich gegebenenfalls zum nächsten Ziel. Es sieht aus wie das:
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); } } } Wir sollten in Betracht ziehen, die PatrolPoints und NavMeshAgent Komponenten zwischenzuspeichern. Caching würde es uns ermöglichen, ScriptableObject s für Aktionen zwischen Agenten zu teilen, ohne dass die Leistung durch die Ausführung von GetComponent bei jeder Abfrage des endlichen Automaten beeinträchtigt wird.
Um es klarzustellen, wir können keine Komponenteninstanzen in der Execute Methode zwischenspeichern. Stattdessen fügen wir BaseStateMachine eine benutzerdefinierte GetComponent Methode BaseStateMachine . Unsere benutzerdefinierte GetComponent würde die Instanz beim ersten Aufruf zwischenspeichern und die zwischengespeicherte Instanz bei aufeinanderfolgenden Aufrufen zurückgeben. Als Referenz ist dies die Implementierung von BaseStateMachine mit Caching:
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; } } } Wie ihr Gegenstück PatrolAction überschreibt die ChaseAction -Klasse die Execute -Methode, um PatrolPoints und NavMeshAgent Komponenten abzurufen. Im Gegensatz dazu setzt die ChaseAction nach der Überprüfung, ob der KI-Agent sein Ziel erreicht hat, das Ziel auf 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); } } } Implementieren der InLineOfSightDecision Klasse
Das letzte Stück ist die InLineOfSightDecision -Klasse, die die Basisentscheidung erbt und die Decision -Komponente dazu EnemySightSensor , zu überprüfen, ob sich der Spieler in der Sichtlinie des NPC befindet:
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(); } } }Anhängen von Verhaltensweisen an Zustände
Endlich sind wir bereit, dem Enemy Agenten Verhaltensweisen zuzuordnen. Diese werden im Projektfenster des Unity-Editors erstellt.
Hinzufügen der Patrol und Chase
Lassen Sie uns zwei Zustände erstellen und sie „Patrouille“ und „Verfolgung“ nennen:
- Rechtsklick > Erstellen > FSM > Status
Lassen Sie uns hier auch ein RemainInState Objekt erstellen:
- Rechtsklick > Erstellen > FSM > Im Zustand bleiben
Jetzt ist es an der Zeit, die Aktionen zu erstellen, die wir gerade codiert haben:
- Rechtsklick > Erstellen > FSM > Aktion > Patrouille
- Rechtsklick > Erstellen > FSM > Aktion > Verfolgung
Um die Decision zu codieren:
- Rechtsklick > Erstellen > FSM > Entscheidungen > In Sichtlinie
Um einen Übergang von PatrolState zu ChaseState zu ermöglichen, erstellen wir zunächst das skriptfähige Übergangsobjekt:
- Rechtsklick > Erstellen > FSM > Übergang
- Wählen Sie einen Namen, der Ihnen gefällt. Ich habe meinen Spotted Enemy genannt.
Wir werden das resultierende Inspektorfenster wie folgt füllen:
Dann vervollständigen wir das Chase State Inspector-Dialogfeld wie folgt:
Als Nächstes vervollständigen wir das Dialogfeld Patrouillenstatus:
Schließlich fügen wir dem feindlichen Objekt die BaseStateMachine Komponente hinzu: Öffnen Sie im Projektfenster des Unity-Editors das SampleScene -Asset, wählen Sie das feindliche Objekt im Hierarchiebereich aus und wählen Sie im Inspektorfenster Komponente hinzufügen > Basiszustandsmaschine aus:
Überprüfen Sie bei Problemen, ob Ihre Spielobjekte korrekt konfiguriert sind. Bestätigen Sie beispielsweise, dass das Enemy-Objekt die PatrolPoints -Skriptkomponente und die Objekte Point1 , Point2 usw. enthält. Diese Informationen können bei falscher Versionierung des Editors verloren gehen.
Jetzt können Sie das Beispielspiel spielen und beobachten, dass der Feind dem Spieler folgt, wenn der Spieler in die Sichtlinie des Feindes tritt.
Verwenden von FSMs zum Erstellen einer unterhaltsamen, interaktiven Benutzererfahrung
In diesem Tutorial zu endlichen Zustandsautomaten haben wir eine hochgradig modulare FSM-basierte KI (und das entsprechende GitHub-Repo) erstellt, die wir in zukünftigen Projekten wiederverwenden können. Dank dieser Modularität können wir unsere KI immer leistungsfähiger machen, indem wir neue Komponenten einführen.
Aber unsere Architektur ebnet auch den Weg für ein grafisches FSM-Design, das unsere Entwicklererfahrung auf eine neue Ebene der Professionalität heben würde. Wir könnten dann FSMs für unsere Spiele schneller erstellen – und mit besserer kreativer Genauigkeit.
