Sviluppo di Unity AI: un tutorial FSM grafico basato su xNode

Pubblicato: 2022-08-12

In "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:

  1. In Unity, fai clic su Finestra > Package Manager per avviare la finestra Package Manager.
  2. 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.
  3. 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.

Il nodo Branch, rappresentato da un grande rettangolo al centro, include lo pseudocodice "If C == True A Else B." Sulla sinistra ci sono tre rettangoli, ognuno dei quali ha una freccia che punta al nodo Branch: "A (arbitrario)," "B (arbitrario)" e "C (booleano)". Il nodo Branch, infine, ha una freccia che punta a un rettangolo "Output".
Un esempio di nodo Branch

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.

  1. Per creare gli stati Chase and Patrol :

    1. Fare clic con il pulsante destro del mouse e scegliere Stato per creare un nuovo nodo.

    2. Assegna un nome al nodo Chase .

    3. Torna al menu a tendina, scegli di nuovo Stato per creare un secondo nodo.

    4. Assegna un nome al nodo Patrol .

    5. Trascina e rilascia le azioni Chase e Patrol esistenti negli stati corrispondenti appena creati.

  2. Per creare la transizione:

    1. Fare clic con il pulsante destro del mouse e scegliere Transizione per creare un nuovo nodo.

    2. Assegna l'oggetto LineOfSightDecision al campo Decision della transizione.

  3. Per creare il nodo RemainInState :

    1. Fare clic con il pulsante destro del mouse e scegliere RemainInState per creare un nuovo nodo.
  4. Per collegare il grafico:

    1. Collegare l'output Transitions del nodo Patrol all'input Entry del nodo Transition .

    2. Collega l'output True State del nodo Transition all'input Entry del nodo Chase .

    3. Collegare l'uscita False State del nodo Transition all'ingresso Entry del nodo Remain In State .

Il grafico dovrebbe assomigliare a questo:

Quattro nodi rappresentati come quattro rettangoli, ciascuno con cerchi di input Entry in alto a sinistra. Da sinistra a destra, il nodo Stato di pattuglia visualizza un'azione: Azione di pattuglia. Il nodo Stato di pattuglia include anche un cerchio di uscita Transizioni sul lato inferiore destro che si collega al cerchio di entrata del nodo di transizione. Il nodo Transizione mostra una decisione: LineOfSight. Ha due cerchi di output in basso a destra, True State e False State. True State si collega al cerchio di entrata della nostra terza struttura, il nodo dello stato Chase. Il nodo dello stato Chase visualizza un'azione: Chase Action. Il nodo dello stato Chase ha un cerchio di output Transizioni. Il secondo dei due cerchi di output di Transition, False State, si collega al cerchio di entrata della nostra quarta e ultima struttura, il nodo RemainInState (che appare sotto il nodo dello stato Chase).
Uno sguardo iniziale al nostro grafico FSM

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:

  1. Fare clic con il pulsante destro del mouse e scegliere Nodo iniziale per creare un nuovo nodo.
  2. Allega l'output del nodo FSM all'input Entry del nodo Patrol .

Il grafico ora dovrebbe assomigliare a questo:

Lo stesso grafico della nostra immagine precedente, con un rettangolo verde del nodo FSM aggiunto a sinistra degli altri quattro rettangoli. Ha un output del nodo iniziale (rappresentato da un cerchio blu) che si collega all'ingresso "Entry" del nodo Patrol (rappresentato da un cerchio rosso scuro).
Il nostro grafico FSM con il nodo iniziale allegato allo stato di pattuglia

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:

  1. Apri la risorsa SampleScene.

  2. Individua il nostro oggetto di gioco Enemy nella finestra della gerarchia dell'Unità.

  3. Sostituisci il componente BaseStateMachine con il componente BaseStateMachineGraph :

    1. Fare clic su Aggiungi componente e selezionare lo script BaseStateMachineGraph corretto.

    2. Assegna il nostro grafico FSM, EnemyGraph , al campo Graph del componente BaseStateMachineGraph .

    3. 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:

Dall'alto verso il basso, nella schermata dell'Inspector, c'è un segno di spunta accanto a Enemy. "Giocatore" è selezionato nell'elenco a discesa Tag, "Nemico" è selezionato nell'elenco a discesa Livello. L'elenco a discesa Trasforma mostra posizione, rotazione e scala. Il menu a discesa Capsule è compresso e i menu a discesa Mesh Renderer, Capsule Collider e Nav Mesh Agent vengono visualizzati compressi con un segno di spunta alla loro sinistra. Il menu a tendina Enemy Sight Sensor mostra lo Script e Ignora Mask. Il menu a discesa PatrolPoints mostra lo Script e quattro PatrolPoints. È presente un segno di spunta accanto al menu a discesa Base State Machine Graph (Script). Lo script mostra "BaseStateMachineGraph", lo stato iniziale mostra "Nessuno (stato di base) e il grafico mostra "EnemyGraph (grafico FSM)." Infine, il menu a discesa Blue Enemy (Materiale) viene compresso e viene visualizzato un pulsante "Aggiungi componente" sotto esso.
Il nostro oggetto di gioco Enemy

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.