Sviluppo di Unity AI: un tutorial FSM grafico basato su xNode
Pubblicato: 2022-08-12In "Unity AI Development: A Finite-state Machine Tutorial", abbiamo creato un semplice gioco stealth: un'IA modulare basata su FSM. Nel gioco, un agente nemico pattuglia lo spazio di gioco. Quando individua il giocatore, il nemico cambia il suo stato e segue il giocatore invece di pattugliare.
In questa seconda tappa del nostro viaggio Unity, costruiremo un'interfaccia utente grafica (GUI) per creare i componenti principali della nostra macchina a stati finiti (FSM) più rapidamente e con un'esperienza di sviluppo Unity migliorata.
Un rapido aggiornamento
L'FSM dettagliato nell'esercitazione precedente è stato creato con blocchi architettonici come script C#. Abbiamo aggiunto azioni e decisioni ScriptableObject
personalizzate come classi. L'approccio ScriptableObject
ha consentito un FSM facilmente manutenibile e personalizzabile. In questo tutorial, sostituiamo gli ScriptableObject
trascinabili di FSM con un'opzione grafica.
Ho anche scritto uno script aggiornato per quelli di voi che vogliono rendere il gioco più facile da vincere. Per implementare, basta sostituire lo script di rilevamento del giocatore con questo che restringe il campo visivo del nemico.
Iniziare con xNode
Costruiremo il nostro editor grafico utilizzando xNode, un framework per alberi comportamentali basati su nodi che visualizzeranno visivamente il flusso del nostro FSM. Sebbene GraphView di Unity possa svolgere il lavoro, la sua API è sia sperimentale che scarsamente documentata. L'interfaccia utente di xNode offre un'esperienza di sviluppo superiore, facilitando la prototipazione e la rapida espansione del nostro FSM.
Aggiungiamo xNode al nostro progetto come dipendenza Git usando Unity Package Manager:
- In Unity, fai clic su Finestra > Package Manager per avviare la finestra Package Manager.
- Fai clic su + (il segno più) nell'angolo in alto a sinistra della finestra e seleziona Aggiungi pacchetto da git URL per visualizzare un campo di testo.
- Digita o incolla
https://github.com/siccity/xNode.git
nella casella di testo senza etichetta e fai clic sul pulsante Aggiungi .
Ora siamo pronti per approfondire e comprendere i componenti chiave di xNode:
Classe Node | Rappresenta un nodo, l'unità fondamentale di un grafo. In questo tutorial xNode, deriviamo dalla classe Node nuove classi che dichiarano nodi dotati di funzionalità e ruoli personalizzati. |
Classe NodeGraph | Rappresenta una raccolta di nodi ( istanze della classe Node ) e gli spigoli che li connettono. In questo tutorial xNode, deriviamo da NodeGraph una nuova classe che manipola e valuta i nodi. |
Classe NodePort | Rappresenta una porta di comunicazione, una porta di tipo input o di tipo output, situata tra le istanze di Node in un NodeGraph . La classe NodePort è univoca per xNode. |
Attributo [Input] | L'aggiunta dell'attributo [Input] a una porta la designa come input, consentendo alla porta di passare valori al nodo di cui fa parte. Pensa all'attributo [Input] come a un parametro di funzione. |
Attributo [Output] | L'aggiunta dell'attributo [Output] a una porta la designa come output, consentendo alla porta di trasmettere valori dal nodo di cui fa parte. Pensa all'attributo [Output] come al valore di ritorno di una funzione. |
Visualizzazione dell'ambiente di costruzione di xNode
In xNode, lavoriamo con grafici in cui ogni State
e Transition
assume la forma di un nodo. Le connessioni di input e/o output consentono al nodo di relazionarsi con uno o tutti gli altri nodi nel nostro grafico.
Immaginiamo un nodo con tre valori di input: due arbitrari e uno booleano. Il nodo emetterà uno dei due valori di input di tipo arbitrario, a seconda che l'input booleano sia true o false.
Per convertire il nostro FSM esistente in un grafico, modifichiamo le classi State
e Transition
per ereditare la classe Node
invece della classe ScriptableObject
. Creiamo un oggetto grafico di tipo NodeGraph
per contenere tutti i nostri oggetti State
e Transition
.
Modifica di BaseStateMachine
da utilizzare come tipo di base
Inizia a creare l'interfaccia grafica aggiungendo due nuovi metodi virtuali alla nostra classe BaseStateMachine
esistente:
Init | Assegna lo stato iniziale alla proprietà CurrentState |
Execute | Esegue lo stato corrente |
Dichiarare questi metodi come virtuali ci consente di sovrascriverli, in modo da poter definire i comportamenti personalizzati delle classi che ereditano la classe BaseStateMachine
per l'inizializzazione e l'esecuzione:
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; } } }
Quindi, nella nostra cartella FSM
, creiamo:
FSMGraph | Una cartella |
BaseStateMachineGraph | Classe AC# all'interno di FSMGraph |
Per il momento, BaseStateMachineGraph
erediterà solo la classe BaseStateMachine
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
Non possiamo aggiungere funzionalità a BaseStateMachineGraph
finché non creiamo il nostro tipo di nodo di base; facciamolo dopo.
Implementazione NodeGraph
e creazione di un tipo di nodo di base
Nella nostra cartella FSMGraph
appena creata, creeremo:
FSMGraph | Una classe |
Per ora, FSMGraph
erediterà solo la classe NodeGraph
(senza funzionalità aggiuntive):
using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }
Prima di creare classi per i nostri nodi, aggiungiamo:
FSMNodeBase | Una classe da utilizzare come classe base da tutti i nostri nodi |
La classe FSMNodeBase
conterrà un input denominato Entry
di tipo FSMNodeBase
per consentirci di connettere i nodi tra loro.
Aggiungeremo anche due funzioni di supporto:
GetFirst | Recupera il primo nodo connesso all'output richiesto |
GetAllOnPort | Recupera tutti i nodi rimanenti che si connettono all'output richiesto |
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; } } }
In definitiva, avremo due tipi di nodi di stato; aggiungiamo una classe per supportare questi:
BaseStateNode | Una classe base per supportare sia StateNode che RemainInStateNode |
namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }
Quindi, modifica la classe BaseStateMachineGraph
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }
Qui abbiamo nascosto la proprietà CurrentState
ereditata dalla classe base e cambiato il suo tipo da BaseState
a BaseStateNode
.
Creazione di elementi costitutivi per il nostro grafico FSM
Quindi, per formare i blocchi costitutivi principali del nostro FSM, aggiungiamo tre nuove classi alla nostra cartella FSMGraph
:
StateNode | Rappresenta lo stato di un agente. Durante l'esecuzione, StateNode esegue l'iterazione sui TransitionNode collegati alla porta di output di StateNode (recuperata da un metodo di supporto). StateNode interroga ciascuno se trasferire il nodo a uno stato diverso o lasciare lo stato del nodo così com'è. |
RemainInStateNode | Indica che un nodo deve rimanere nello stato corrente. |
TransitionNode | Prende la decisione di passare a uno stato diverso o rimanere nello stesso stato. |
Nell'esercitazione Unity FSM precedente, la classe State
scorre l'elenco delle transizioni. Qui in xNode, StateNode
funge da equivalente di State
per eseguire un'iterazione sui nodi recuperati tramite il nostro metodo di supporto GetAllOnPort
.
Ora aggiungi un attributo [Output]
alle connessioni in uscita (i nodi di transizione) per indicare che dovrebbero far parte della GUI. Per progettazione di xNode, il valore dell'attributo ha origine nel nodo di origine: il nodo contenente il campo contrassegnato con l'attributo [Output]
. Poiché utilizziamo gli attributi [Output]
e [Input]
per descrivere le relazioni e le connessioni che verranno impostate dalla GUI di xNode, non possiamo trattare questi valori come faremmo normalmente. Considera come ripetiamo le Actions
rispetto 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); } } }
In questo caso, l'output di Transitions
può avere più nodi collegati; dobbiamo chiamare il metodo di supporto GetAllOnPort
per ottenere un elenco delle connessioni [Output]
.
RemainInStateNode
è, di gran lunga, la nostra classe più semplice. Non eseguendo alcuna logica, RemainInStateNode
indica semplicemente al nostro agente, nel caso del nostro gioco, il nemico, di rimanere nel suo stato attuale:
namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }
A questo punto, la classe TransitionNode
è ancora incompleta e non verrà compilata. Gli errori associati verranno cancellati una volta aggiornata la classe.
Per creare TransitionNode
, dobbiamo aggirare il requisito di xNode che il valore dell'output abbia origine nel nodo di origine, come abbiamo fatto quando abbiamo creato StateNode
. Una delle principali differenze tra StateNode
e TransitionNode
è che l'output di TransitionNode
può essere collegato a un solo nodo. Nel nostro caso, GetFirst
un nodo collegato a ciascuna delle nostre porte (un nodo di stato a cui passare nel caso true e un altro a cui passare nel caso falso):
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; } } }
Diamo un'occhiata ai risultati grafici del nostro codice.
Creazione del grafico visivo
Con tutte le classi FSM risolte, possiamo procedere alla creazione del nostro grafico FSM per l'agente nemico del gioco. Nella finestra del progetto Unity, fai clic con il pulsante destro del mouse sulla cartella EnemyAI
e scegli: Crea > FSM > Grafico FSM . Per rendere il nostro grafico più facile da identificare, rinominiamolo EnemyGraph
.
Nella finestra dell'editor di xNode Graph, fare clic con il pulsante destro del mouse per visualizzare un menu a discesa che elenca State , Transition e RemainInState . Se la finestra non è visibile, fare doppio clic sul file EnemyGraph
per avviare la finestra dell'editor di xNode Graph.
Per creare gli stati
Chase
andPatrol
:Fare clic con il pulsante destro del mouse e scegliere Stato per creare un nuovo nodo.
Assegna un nome al nodo
Chase
.Torna al menu a tendina, scegli di nuovo Stato per creare un secondo nodo.
Assegna un nome al nodo
Patrol
.Trascina e rilascia le azioni
Chase
ePatrol
esistenti negli stati corrispondenti appena creati.
Per creare la transizione:
Fare clic con il pulsante destro del mouse e scegliere Transizione per creare un nuovo nodo.
Assegna l'oggetto
LineOfSightDecision
al campoDecision
della transizione.
Per creare il nodo
RemainInState
:- Fare clic con il pulsante destro del mouse e scegliere RemainInState per creare un nuovo nodo.
Per collegare il grafico:
Collegare l'output
Transitions
del nodoPatrol
all'inputEntry
del nodoTransition
.Collega l'output
True State
del nodoTransition
all'inputEntry
del nodoChase
.Collegare l'uscita
False State
del nodoTransition
all'ingressoEntry
del nodoRemain In State
.
Il grafico dovrebbe assomigliare a questo:
Niente nel grafico indica quale nodo, lo stato di Patrol
o Chase
, è il nostro nodo iniziale. La classe BaseStateMachineGraph
rileva quattro nodi ma, senza indicatori presenti, non può scegliere lo stato iniziale.
Per risolvere questo problema, creiamo:
FSMInitialNode | Una classe il cui singolo output di tipo StateNode è denominato InitialNode |
Il nostro output InitialNode
denota lo stato iniziale. Quindi, in FSMInitialNode
, crea:
NextNode | Una proprietà che ci consente di recuperare il nodo connesso all'output 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; } } } }
Ora che abbiamo creato la classe FSMInitialNode
, possiamo collegarla all'ingresso Entry
dello stato iniziale e restituire lo stato iniziale tramite la proprietà NextNode
.
Torniamo al nostro grafico e aggiungiamo il nodo iniziale. Nella finestra dell'editor di xNode:
- Fare clic con il pulsante destro del mouse e scegliere Nodo iniziale per creare un nuovo nodo.
- Allega l'output del nodo FSM all'input
Entry
del nodoPatrol
.
Il grafico ora dovrebbe assomigliare a questo:
Per semplificarci la vita, aggiungeremo a FSMGraph
:
InitialState | Una proprietà |
La prima volta che proviamo a recuperare il valore della proprietà InitialState
, il getter della proprietà attraverserà tutti i nodi nel nostro grafico mentre cerca di trovare FSMInitialNode
. Una volta FSMInitialNode
, utilizziamo la proprietà NextNode
per trovare il nostro nodo di stato iniziale:
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; } } }
Successivamente, nel nostro BaseStateMachineGraph
, facciamo riferimento FSMGraph
e sovrascriviamo i nostri metodi Init
ed Execute
di BaseStateMachine
. L'override di Init
imposta CurrentState
come stato iniziale del grafico e l'override di Execute
chiama Execute
on CurrentState
:
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); } } }
Ora applichiamo il grafico all'oggetto Enemy e vediamolo in azione.
Testare il grafico FSM
In preparazione per il test, nella finestra Progetto dell'editor di Unity:
Apri la risorsa SampleScene.
Individua il nostro oggetto di gioco
Enemy
nella finestra della gerarchia dell'Unità.Sostituisci il componente
BaseStateMachine
con il componenteBaseStateMachineGraph
:Fare clic su Aggiungi componente e selezionare lo script
BaseStateMachineGraph
corretto.Assegna il nostro grafico FSM,
EnemyGraph
, al campoGraph
del componenteBaseStateMachineGraph
.Elimina il componente
BaseStateMachine
(poiché non è più necessario) facendo clic con il pulsante destro del mouse e selezionando Rimuovi componente .
L'oggetto di gioco Enemy
dovrebbe assomigliare a questo:
Questo è tutto! Ora abbiamo un FSM modulare con un editor grafico. Fare clic sul pulsante Riproduci mostra che l'IA nemica creata graficamente funziona esattamente come il nostro nemico ScriptableObject
creato in precedenza.
Andare avanti: ottimizzare il nostro FSM
Un avvertimento: man mano che sviluppi un'IA più sofisticata per il tuo gioco, il numero di stati e transizioni aumenta e l'FSM diventa confuso e difficile da leggere. L'editor grafico diventa simile a una rete di linee che hanno origine in più stati e terminano in più transizioni e viceversa, rendendo difficile il debug dell'FSM.
Come nel tutorial precedente, ti invitiamo a creare il tuo codice, ottimizzare il tuo gioco stealth e affrontare questi problemi. Immagina quanto sarebbe utile codificare a colori i tuoi nodi di stato per indicare se un nodo è attivo o inattivo, o ridimensionare i nodi RemainInState
e Initial
per limitare il loro spazio sullo schermo.
Tali miglioramenti non sono solo cosmetici. I riferimenti a colori e dimensioni aiuterebbero a identificare dove e quando eseguire il debug. Un grafico facile da vedere è anche più semplice da valutare, analizzare e comprendere. Tutti i passaggi successivi spettano a te: con le fondamenta del nostro editor grafico in atto, non c'è limite ai miglioramenti dell'esperienza degli sviluppatori che puoi apportare.
Ulteriori letture sul blog di Toptal Engineering:
- I 10 errori più comuni che fanno gli sviluppatori di Unity
- Unity With MVC: come aumentare di livello lo sviluppo del tuo gioco
- Padroneggiare le fotocamere 2D in Unity: un tutorial per gli sviluppatori di giochi
- Migliori pratiche e suggerimenti di Unity di Toptal Developers
Il blog di Toptal Engineering estende la sua gratitudine a Goran Lalic per la sua esperienza e revisione tecnica di questo articolo.