Unity AI Development: Un tutorial grafic FSM bazat pe xNode

Publicat: 2022-08-12

În „Unity AI Development: A Finite-state Machine Tutorial”, am creat un joc ascuns simplu – un AI modular bazat pe FSM. În joc, un agent inamic patrulează spațiul de joc. Când îl observă pe jucător, inamicul își schimbă starea și îl urmărește pe jucător în loc să patruleze.

În această a doua etapă a călătoriei noastre Unity, vom construi o interfață grafică cu utilizatorul (GUI) pentru a crea componentele de bază ale mașinii noastre cu stări finite (FSM) mai rapid și cu o experiență îmbunătățită pentru dezvoltatori Unity.

O reîmprospătare rapidă

FSM detaliat în tutorialul anterior a fost construit din blocuri arhitecturale ca scripturi C#. Am adăugat acțiuni și decizii personalizate ScriptableObject ca clase. Abordarea ScriptableObject a permis un FSM ușor de întreținut și personalizabil. În acest tutorial, înlocuim elementele ScriptableObject cu glisare și plasare ale FSM cu o opțiune grafică.

De asemenea, am scris un script actualizat pentru cei dintre voi care doresc să facă jocul mai ușor de câștigat. Pentru implementare, trebuie doar să înlocuiți scriptul de detectare a jucătorului cu acesta care îngustează câmpul vizual al inamicului.

Noțiuni introductive cu xNode

Vom construi editorul nostru grafic folosind xNode, un cadru pentru arbori de comportament bazați pe noduri care va afișa vizual fluxul FSM-ului nostru. Deși GraphView de la Unity poate îndeplini treaba, API-ul său este atât experimental, cât și puțin documentat. Interfața cu utilizatorul xNode oferă o experiență superioară pentru dezvoltatori, facilitând crearea de prototipuri și extinderea rapidă a FSM-ului nostru.

Să adăugăm xNode la proiectul nostru ca dependență Git folosind Managerul de pachete Unity:

  1. În Unity, faceți clic pe Window > Package Manager pentru a lansa fereastra Package Manager.
  2. Faceți clic pe + (semnul plus) în colțul din stânga sus al ferestrei și selectați Adăugați pachet din URL-ul git pentru a afișa un câmp de text.
  3. Tastați sau inserați https://github.com/siccity/xNode.git în caseta de text fără etichetă și faceți clic pe butonul Adăugați .

Acum suntem gata să ne aprofundăm și să înțelegem componentele cheie ale xNode:

Clasa Node Reprezintă un nod, cea mai fundamentală unitate a unui grafic. În acest tutorial xNode, derivăm din clasa Node noi clase care declară noduri echipate cu funcționalități și roluri personalizate.
Clasa NodeGraph Reprezintă o colecție de noduri (instanțele clasei Node ) și marginile care le conectează. În acest tutorial xNode, derivăm din NodeGraph o nouă clasă care manipulează și evaluează nodurile.
Clasa NodePort Reprezintă o poartă de comunicație, un port de tip input sau tip output, situat între instanțele Node într-un NodeGraph . Clasa NodePort este unică pentru xNode.
Atributul [Input] Adăugarea atributului [Input] la un port îl desemnează ca intrare, permițând portului să transmită valori nodului din care face parte. Gândiți-vă la atributul [Input] ca la un parametru de funcție.
Atributul [Output] Adăugarea atributului [Output] la un port îl desemnează ca o ieșire, permițând portului să transmită valori de la nodul din care face parte. Gândiți-vă la atributul [Output] ca la valoarea returnată a unei funcții.

Vizualizarea mediului de construcție xNode

În xNode, lucrăm cu grafice în care fiecare State și Transition ia forma unui nod. Conexiunile de intrare și/sau de ieșire permit nodului să se relaționeze cu oricare sau cu toate celelalte noduri din graficul nostru.

Să ne imaginăm un nod cu trei valori de intrare: două arbitrare și una booleană. Nodul va scoate una dintre cele două valori de intrare de tip arbitrar, în funcție de faptul dacă intrarea booleană este adevărată sau falsă.

Nodul Branch, reprezentat printr-un dreptunghi mare în centru, include pseudocodul „Dacă C == Adevărat A Altfel B”. În stânga sunt trei dreptunghiuri, fiecare dintre ele având o săgeată care indică nodul Branch: „A (arbitrar),” „B (arbitrar)” și „C (boolean)”. Nodul Branch, în cele din urmă, are o săgeată care indică un dreptunghi „Ieșire”.
Un exemplu de nod Branch

Pentru a converti FSM-ul nostru existent într-un grafic, modificăm clasele State și Transition pentru a moșteni clasa Node în loc de clasa ScriptableObject . Creăm un obiect grafic de tipul NodeGraph pentru a conține toate obiectele noastre State și Transition .

Modificarea BaseStateMachine pentru a fi utilizată ca tip de bază

Începeți să construiți interfața grafică adăugând două metode virtuale noi la clasa noastră existentă BaseStateMachine :

Init Atribuie starea inițială proprietății CurrentState
Execute Execută starea curentă

Declararea acestor metode ca fiind virtuale ne permite să le suprascriem, astfel încât să putem defini comportamentele personalizate ale claselor care moștenesc clasa BaseStateMachine pentru inițializare și execuție:

 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; } } }

Apoi, în folderul nostru FSM , să creăm:

FSMGraph Un dosar
BaseStateMachineGraph Clasa AC# din FSMGraph

Deocamdată, BaseStateMachineGraph va moșteni doar clasa BaseStateMachine :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }

Nu putem adăuga funcționalitate la BaseStateMachineGraph până când nu creăm tipul nostru de nod de bază; hai să facem asta în continuare.

Implementarea NodeGraph și crearea unui tip de nod de bază

Sub folderul FSMGraph nou creat, vom crea:

FSMGraph O clasa

Pentru moment, FSMGraph va moșteni doar clasa NodeGraph (fără funcționalitate adăugată):

 using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }

Înainte de a crea clase pentru nodurile noastre, să adăugăm:

FSMNodeBase O clasă care va fi folosită ca clasă de bază de către toate nodurile noastre

Clasa FSMNodeBase va conține o intrare numită Entry de tip FSMNodeBase pentru a ne permite să conectăm nodurile între ele.

Vom adăuga, de asemenea, două funcții de ajutor:

GetFirst Preia primul nod conectat la ieșirea solicitată
GetAllOnPort Preia toate nodurile rămase care se conectează la ieșirea solicitată
 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; } } }

În cele din urmă, vom avea două tipuri de noduri de stat; să adăugăm o clasă pentru a sprijini acestea:

BaseStateNode O clasă de bază care să accepte atât StateNode , cât și RemainInStateNode
 namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }

Apoi, modificați clasa BaseStateMachineGraph :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }

Aici, am ascuns proprietatea CurrentState moștenită de la clasa de bază și am schimbat tipul acesteia din BaseState în BaseStateNode .

Crearea elementelor de bază pentru graficul nostru FSM

În continuare, pentru a forma blocurile principale ale FSM-ului nostru, să adăugăm trei clase noi în folderul nostru FSMGraph :

StateNode Reprezintă starea unui agent. La execuție, StateNode iterează peste TransitionNode -urile conectate la portul de ieșire al StateNode (recuperat printr-o metodă helper). StateNode întreabă pe fiecare dacă trece nodul într-o stare diferită sau lasă starea nodului așa cum este.
RemainInStateNode Indică că un nod ar trebui să rămână în starea curentă.
TransitionNode Ia decizia de a trece la un alt stat sau de a rămâne în aceeași stare.

În tutorialul anterior al Unity FSM, clasa State iterează peste lista de tranziții. Aici, în xNode, StateNode servește ca echivalent State pentru a itera peste nodurile preluate prin metoda noastră de ajutor GetAllOnPort .

Acum adăugați un atribut [Output] la conexiunile de ieșire (nodurile de tranziție) pentru a indica faptul că acestea ar trebui să facă parte din GUI. Prin proiectarea xNode, valoarea atributului își are originea în nodul sursă: nodul care conține câmpul marcat cu atributul [Output] . Deoarece folosim atributele [Output] și [Input] pentru a descrie relațiile și conexiunile care vor fi setate de interfața grafică xNode, nu putem trata aceste valori așa cum am face-o în mod normal. Luați în considerare modul în care repetăm Actions versus 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); } } }

În acest caz, ieșirea Transitions poate avea mai multe noduri atașate; trebuie să apelăm metoda de ajutor GetAllOnPort pentru a obține o listă a conexiunilor [Output] .

RemainInStateNode este, de departe, cea mai simplă clasă a noastră. Neexecutând nicio logică, RemainInStateNode îi indică doar agentului nostru – în cazul jocului nostru, inamicul – să rămână în starea sa actuală:

 namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }

În acest moment, clasa TransitionNode este încă incompletă și nu se va compila. Erorile asociate se vor șterge odată ce vom actualiza clasa.

Pentru a construi TransitionNode , trebuie să ocolim cerința xNode ca valoarea rezultatului să provină din nodul sursă, așa cum am făcut atunci când am construit StateNode . O diferență majoră între StateNode și TransitionNode este că ieșirea lui TransitionNode se poate atașa la un singur nod. În cazul nostru, GetFirst va prelua un singur nod atașat fiecăruia dintre porturile noastre (un nod de stare la care trece în cazul adevărat și altul la care trece în cazul fals):

 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; } } }

Să aruncăm o privire la rezultatele grafice din codul nostru.

Crearea graficului vizual

Cu toate clasele FSM rezolvate, putem continua să creăm graficul nostru FSM pentru agentul inamic al jocului. În fereastra proiectului Unity, faceți clic dreapta pe folderul EnemyAI și alegeți: Creare > FSM > FSM Graph . Pentru a face graficul nostru mai ușor de identificat, să-l redenumim EnemyGraph .

În fereastra editorului xNode Graph, faceți clic dreapta pentru a afișa un meniu drop-down care listează State , Transition și RemainInState . Dacă fereastra nu este vizibilă, faceți dublu clic pe fișierul EnemyGraph pentru a lansa fereastra editorului xNode Graph.

  1. Pentru a crea stările Chase și Patrol :

    1. Faceți clic dreapta și alegeți State pentru a crea un nou nod.

    2. Numiți nodul Chase .

    3. Reveniți la meniul derulant, alegeți din nou Stare pentru a crea un al doilea nod.

    4. Denumiți nodul Patrol .

    5. Trageți și plasați acțiunile existente Chase și Patrol în stările corespunzătoare nou create.

  2. Pentru a crea tranziția:

    1. Faceți clic dreapta și alegeți Tranziție pentru a crea un nou nod.

    2. Atribuiți obiectul LineOfSightDecision câmpului de Decision al tranziției.

  3. Pentru a crea nodul RemainInState :

    1. Faceți clic dreapta și alegeți RemainInState pentru a crea un nou nod.
  4. Pentru a conecta graficul:

    1. Conectați ieșirea de Transitions a nodului de Patrol la Entry de intrare a nodului de Transition .

    2. Conectați ieșirea True State a nodului de Transition la intrarea Entry a nodului Chase .

    3. Conectați ieșirea False State a nodului de Transition la intrarea de Entry a nodului Remain In State .

Graficul ar trebui să arate astfel:

Patru noduri reprezentate ca patru dreptunghiuri, fiecare cu cercuri de intrare Entry în partea stângă sus. De la stânga la dreapta, nodul de stare de patrulare afișează o acțiune: Acțiune de patrulare. Nodul de stare de patrulare include, de asemenea, un cerc de ieșire Tranziții în partea dreaptă jos, care se conectează la cercul de intrare al nodului Tranziție. Nodul Tranziție afișează o decizie: LineOfSight. Are două cercuri de ieșire în partea din dreapta jos, stare adevărată și stare falsă. Starea adevărată se conectează la cercul de intrare al celei de-a treia structuri, nodul de stare Chase. Nodul de stare Chase afișează o acțiune: Chase Action. Nodul de stare Chase are un cerc de ieșire Tranziții. Al doilea dintre cele două cercuri de ieșire ale Transition, False State, se conectează la cercul Entry al celei de-a patra și ultima noastră structură, nodul RemainInState (care apare sub nodul de stare Chase).
Privirea inițială asupra graficului nostru FSM

Nimic din grafic nu indică care nod – starea Patrol sau Chase – este nodul nostru inițial. Clasa BaseStateMachineGraph detectează patru noduri dar, fără indicatori prezenți, nu poate alege starea inițială.

Pentru a rezolva această problemă, să creăm:

FSMInitialNode O clasă a cărei ieșire unică de tip StateNode este numită InitialNode

Ieșirea noastră InitialNode denotă starea inițială. Apoi, în FSMInitialNode , creați:

NextNode O proprietate care ne permite să preluăm nodul conectat la ieșirea 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; } } } }

Acum că am creat clasa FSMInitialNode , o putem conecta la intrarea Entry a stării inițiale și să returnăm starea inițială prin proprietatea NextNode .

Să ne întoarcem la graficul nostru și să adăugăm nodul inițial. În fereastra editorului xNode:

  1. Faceți clic dreapta și alegeți Initial Node pentru a crea un nou nod.
  2. Atașați ieșirea nodului FSM la intrarea de Entry a nodului Patrol .

Graficul ar trebui să arate acum astfel:

Același grafic ca în imaginea anterioară, cu un dreptunghi verde FSM Node adăugat la stânga celorlalte patru dreptunghiuri. Are o ieșire Nod inițial (reprezentată printr-un cerc albastru) care se conectează la intrarea „Intrare” a nodului Patrol (reprezentată printr-un cerc roșu închis).
Graficul nostru FSM cu nodul inițial atașat statului de patrulare

Pentru a ne ușura viața, vom adăuga la FSMGraph :

InitialState O proprietate

Prima dată când încercăm să recuperăm valoarea proprietății InitialState , getterul proprietății va traversa toate nodurile din graficul nostru în timp ce încearcă să găsească FSMInitialNode . Odată ce FSMInitialNode este localizat, folosim proprietatea NextNode pentru a găsi nodul nostru de stare inițială:

 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; } } }

Apoi, în BaseStateMachineGraph , să facem referire la FSMGraph și să înlocuim metodele noastre de Init și Execute ale BaseStateMachine . Overriding Init setează CurrentState ca stare inițială a graficului și suprascriind Execute apelează Execute pe 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); } } }

Acum, să aplicăm graficul obiectului Inamic și să-l vedem în acțiune.

Testarea graficului FSM

În pregătirea pentru testare, în fereastra Proiectului Editorului Unity:

  1. Deschideți materialul SampleScene.

  2. Localizați obiectul jocului Enemy în fereastra ierarhiei Unity.

  3. Înlocuiți componenta BaseStateMachine cu componenta BaseStateMachineGraph :

    1. Faceți clic pe Adăugare componentă și selectați scriptul BaseStateMachineGraph corect.

    2. Atribuiți graficul nostru FSM, EnemyGraph , câmpului Graph al componentei BaseStateMachineGraph .

    3. Ștergeți componenta BaseStateMachine (deoarece nu mai este necesară) făcând clic dreapta și selectând Eliminare componentă .

Obiectul de joc Enemy ar trebui să arate astfel:

De sus în jos, în ecranul Inspector, există o bifare lângă Enemy. „Player” este selectat în meniul drop-down Tag, „Enemy” este selectat în drop-down Layer. Meniul derulant Transformare arată poziția, rotația și scara. Meniul derulant Capsule este comprimat, iar meniurile derulante Mesh Renderer, Capsule Collider și Nav Mesh Agent apar comprimate cu o bifă în stânga lor. Meniul derulant Senzorul de vedere al inamicului afișează Scriptul și masca de ignorare. Meniul derulant PatrolPoints arată Scriptul și patru PatrolPoints. Există o bifă lângă meniul derulant Graficul de stare de bază a mașinii (Script). Scriptul arată „BaseStateMachineGraph”, Starea inițială arată „Niciun (Stare de bază), iar Graph arată „EnemyGraph (FSM Graph).” În cele din urmă, meniul derulant Blue Enemy (Material) este comprimat și un buton „Adăugați componentă” apare mai jos. aceasta.
Obiectul nostru de joc Enemy

Asta e! Acum avem un FSM modular cu un editor grafic. Făcând clic pe butonul Play arată că AI inamic creat grafic funcționează exact ca inamicul nostru ScriptableObject creat anterior.

Mergem înainte: Optimizarea FSM-ului nostru

Un cuvânt de precauție: pe măsură ce dezvoltați IA mai sofisticată pentru jocul dvs., numărul de stări și tranziții crește, iar FSM devine confuz și dificil de citit. Editorul grafic devine să semene cu o rețea de linii care își au originea în mai multe stări și se termină la mai multe tranziții - și invers, făcând FSM dificil de depanat.

Ca și în tutorialul anterior, vă invităm să vă faceți propriul cod, să vă optimizați jocul stealth și să abordați aceste preocupări. Imaginați-vă cât de util ar fi să codificați cu culori nodurile de stat pentru a indica dacă un nod este activ sau inactiv, sau redimensionați nodurile RemainInState și Initial pentru a limita spațiul imobiliar al ecranului.

Astfel de îmbunătățiri nu sunt doar cosmetice. Referințele de culoare și dimensiune ar ajuta la identificarea unde și când să depanați. Un grafic care este ușor pentru ochi este, de asemenea, mai simplu de evaluat, analizat și înțeles. Orice următor pași depinde de dvs., cu baza editorului nostru grafic, nu există limită pentru îmbunătățirile experienței de dezvoltator pe care le puteți face.

Citiți suplimentare pe blogul Toptal Engineering:

  • Cele mai frecvente 10 greșeli pe care le fac dezvoltatorii Unity
  • Unitate cu MVC: Cum să-ți ridici nivelul de dezvoltare a jocului
  • Stăpânirea camerelor 2D în Unity: Un tutorial pentru dezvoltatorii de jocuri
  • Cele mai bune practici și sfaturi Unity de la Toptal Developers

Blogul Toptal Engineering își exprimă recunoștința lui Goran Lalic pentru expertiza și revizuirea tehnică a acestui articol.