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:
- În Unity, faceți clic pe Window > Package Manager pentru a lansa fereastra Package Manager.
- 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.
- 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ă.
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.
Pentru a crea stările
Chase
șiPatrol
:Faceți clic dreapta și alegeți State pentru a crea un nou nod.
Numiți nodul
Chase
.Reveniți la meniul derulant, alegeți din nou Stare pentru a crea un al doilea nod.
Denumiți nodul
Patrol
.Trageți și plasați acțiunile existente
Chase
șiPatrol
în stările corespunzătoare nou create.
Pentru a crea tranziția:
Faceți clic dreapta și alegeți Tranziție pentru a crea un nou nod.
Atribuiți obiectul
LineOfSightDecision
câmpului deDecision
al tranziției.
Pentru a crea nodul
RemainInState
:- Faceți clic dreapta și alegeți RemainInState pentru a crea un nou nod.
Pentru a conecta graficul:
Conectați ieșirea de
Transitions
a nodului dePatrol
laEntry
de intrare a nodului deTransition
.Conectați ieșirea
True State
a nodului deTransition
la intrareaEntry
a noduluiChase
.Conectați ieșirea
False State
a nodului deTransition
la intrarea deEntry
a noduluiRemain In State
.
Graficul ar trebui să arate astfel:
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:
- Faceți clic dreapta și alegeți Initial Node pentru a crea un nou nod.
- Atașați ieșirea nodului FSM la intrarea de
Entry
a noduluiPatrol
.
Graficul ar trebui să arate acum astfel:
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:
Deschideți materialul SampleScene.
Localizați obiectul jocului
Enemy
în fereastra ierarhiei Unity.Înlocuiți componenta
BaseStateMachine
cu componentaBaseStateMachineGraph
:Faceți clic pe Adăugare componentă și selectați scriptul
BaseStateMachineGraph
corect.Atribuiți graficul nostru FSM,
EnemyGraph
, câmpuluiGraph
al componenteiBaseStateMachineGraph
.Ș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:
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.