Unity AI Development: Un tutorial de mașină cu stări finite
Publicat: 2022-03-11În lumea competitivă a jocurilor, dezvoltatorii se străduiesc să ofere o experiență de utilizator distractivă pentru cei care interacționează cu personajele non-player (NPC) pe care le creăm. Dezvoltatorii pot oferi această interactivitate folosind mașini cu stări finite (FSM) pentru a crea soluții AI care simulează inteligența în NPC-urile noastre.
Tendințele AI s-au mutat către arbori comportamentali, dar FSM-urile rămân relevante. Sunt încorporate – într-o calitate sau alta – în aproape fiecare joc electronic.
Anatomia unui FSM
Un FSM este un model de calcul în care doar una dintr-un număr finit de stări ipotetice poate fi activă la un moment dat. Un FSM trece de la o stare la alta, răspunzând la condiții sau intrări. Componentele sale de bază includ:
| Componentă | Descriere |
|---|---|
| Stat | Una dintr-un set finit de opțiuni care indică starea generală curentă a unui FSM; orice stare dată include un set asociat de acțiuni |
| Acțiune | Ce face un stat când FSM îl interoghează |
| Decizie | Logica care stabilește când are loc o tranziție |
| Tranziție | Procesul de schimbare a stărilor |
În timp ce ne vom concentra asupra FSM-urilor din perspectiva implementării AI, concepte precum mașinile de stare de animație și stările generale ale jocului se încadrează, de asemenea, sub umbrela FSM.
Vizualizarea unui FSM
Să luăm în considerare exemplul jocului arcade clasic Pac-Man. În starea inițială a jocului (starea „căutare”), NPC-urile sunt fantome colorate care îl urmăresc și în cele din urmă îl depășesc pe jucător. Fantomele trec în starea de evadare ori de câte ori jucătorul mănâncă o pelită de putere și experimentează o pornire, câștigând capacitatea de a mânca fantomele. Fantomele, acum de culoare albastră, evadează jucătorul până la expirarea timpului de pornire, iar fantomele trec înapoi la starea de urmărire, în care comportamentele și culorile lor originale sunt restaurate.
O fantomă Pac-Man se află întotdeauna într-una din cele două stări: urmărire sau sustragere. Desigur, trebuie să asigurăm două tranziții – una de la urmărire la evadare, cealaltă de la eludare la urmărire:
Mașina cu stări finite, prin proiectare, interogează starea curentă, care interogează decizia (deciziile) și acțiunea (acțiunile) acelei stări. Următoarea diagramă reprezintă exemplul nostru Pac-Man și arată o decizie care verifică starea pornirii jucătorului. Dacă a început o pornire, NPC-urile trec de la urmărire la evadare. Dacă o pornire s-a încheiat, NPC-urile trec de la eludare la urmărire. În cele din urmă, dacă nu există nicio schimbare de pornire, nu are loc nicio tranziție.
Scalabilitate
FSM-urile ne eliberează pentru a construi IA modulară. De exemplu, cu o singură acțiune nouă, putem crea un NPC cu un comportament nou. Astfel, putem atribui o nouă acțiune – mâncarea unei pelete de putere – uneia dintre fantomele noastre Pac-Man, dându-i capacitatea de a mânca pelete de putere în timp ce evadează jucătorul. Putem reutiliza acțiunile, deciziile și tranzițiile existente pentru a susține acest comportament.
Deoarece resursele necesare pentru a dezvolta un NPC unic sunt minime, suntem bine poziționați pentru a îndeplini cerințele de proiect în evoluție ale mai multor NPC-uri unice. Pe de altă parte, un număr excesiv de stări și tranziții ne poate încurca într-o mașină cu stări spaghetti - un FSM a cărui supraabundență de conexiuni face dificilă depanarea și întreținerea.
Implementarea unui FSM în Unity
Pentru a demonstra cum să implementăm o mașină cu stări finite în Unity, să creăm un joc ascuns simplu. Arhitectura noastră va încorpora ScriptableObject uri, care sunt containere de date care pot stoca și partaja informații în întreaga aplicație, astfel încât să nu fie nevoie să le reproducem. ScriptableObject sunt capabile de procesare limitată, cum ar fi invocarea acțiunilor și interogarea deciziilor. Pe lângă documentația oficială a Unity, discuția mai veche Arhitectură de joc cu obiecte scriptabile rămâne o resursă excelentă dacă doriți să vă aprofundați.
Înainte de a adăuga AI la acest proiect inițial gata de compilat, luați în considerare arhitectura propusă:
În jocul nostru exemplu, inamicul (un NPC reprezentat de o capsulă albastră) patrulează. Când inamicul vede jucătorul (reprezentat printr-o capsulă gri), inamicul începe să urmărească jucătorul:
Spre deosebire de Pac-Man, inamicul din jocul nostru nu va reveni la starea implicită („patrulă”) odată ce îl urmărește pe jucător.
Crearea de clase
Să începem prin a ne crea clasele. Într-un nou folder de scripts , vom adăuga toate blocurile de construcție arhitecturale propuse ca scripturi C#.
Implementarea clasei BaseStateMachine
Clasa BaseStateMachine este singurul MonoBehavior pe care îl vom adăuga pentru a accesa NPC-urile noastre activate cu AI. De dragul simplității, BaseStateMachine va fi simplă. Dacă am dori, totuși, am putea adăuga un FSM personalizat moștenit care stochează parametri suplimentari și referințe la componente suplimentare. Rețineți că codul nu se va compila corect până când nu vom adăuga clasa noastră BaseState , lucru pe care îl vom face mai târziu în tutorialul nostru.
Codul pentru BaseStateMachine se referă la și execută starea curentă pentru a efectua acțiunile și pentru a vedea dacă o tranziție este justificată:
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); } } } Implementarea clasei BaseState
Starea noastră este de tipul BaseState , pe care o derivăm dintr-un ScriptableObject . BaseState include o singură metodă, Execute , luând BaseStateMachine drept argument și transmițându-i acțiuni și tranziții. Iată cum arată BaseState :
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } Implementarea claselor State și RemainInState
Acum derivăm două clase din BaseState . În primul rând, avem clasa State , care stochează referințe la acțiuni și tranziții, include două liste (una pentru acțiuni, cealaltă pentru tranziții) și suprascrie și apelează baza Execute pe acțiuni și tranziții:
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); } } } În al doilea rând, avem clasa RemainInState , care îi spune FSM-ului când să nu efectueze o tranziție:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } Rețineți că aceste clase nu se vor compila până când nu am adăugat FSMAction , Decision și Transition .
Implementarea clasei FSMAction
În diagrama Arhitectura FSM propusă, clasa de bază FSMAction este etichetată „Acțiune”. Cu toate acestea, vom crea clasa de bază FSMAction și vom folosi numele FSMAction (deoarece Action este deja utilizată de spațiul de nume .NET System ).
FSMAction , un ScriptableObject , nu poate procesa funcții în mod independent, așa că îl vom defini ca o clasă abstractă. Pe măsură ce dezvoltarea noastră progresează, este posibil să avem nevoie de o singură acțiune pentru a servi mai mult de un stat. Din fericire, putem asocia FSMAction cu câte state din câte FSM ne dorim.

Clasa abstractă FSMAction arată astfel:
using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } Implementarea Claselor de Decision și Transition
Pentru a finaliza FSM-ul nostru, vom defini încă două clase. În primul rând, avem Decision , o clasă abstractă din care toate celelalte decizii ar defini comportamentul lor personalizat:
using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } A doua clasă, Transition , conține obiectul Decision și două stări:
- O stare la care să treceți dacă
Decisioneste adevărată. - Un alt stat la care să treceți dacă
Decisioneste falsă.
Arata cam asa:
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; } } }Tot ceea ce am construit până în acest moment ar trebui să compilați fără erori. Dacă întâmpinați probleme, verificați versiunea editorului Unity, care poate cauza erori dacă este învechită. Asigurați-vă că toate fișierele au fost clonate corect din folderul original al proiectului și că toate variabilele accesate public nu sunt declarate private.
Crearea de acțiuni și decizii personalizate
Acum, cu sarcinile grele încheiate, suntem gata să implementăm acțiuni și decizii personalizate într-un nou folder de scripts .
Implementarea claselor de Patrol și Chase
Când analizăm componentele de bază ale diagramei FSM ale jocului nostru Stealth exemplu, vedem că NPC-ul nostru poate fi în una dintre cele două stări:
- Statul de patrulare — Asociați cu statul sunt:
- O singură acțiune: NPC vizitează puncte de patrulare aleatorii din întreaga lume.
- O tranziție: NPC verifică dacă jucătorul este la vedere și, dacă da, trece la starea de urmărire.
- O singură decizie: NPC verifică dacă jucătorul este în vizor.
- Starea de urmărire — Asociat cu starea este:
- O singură acțiune: NPC-ul urmărește jucătorul.
Putem reutiliza implementarea noastră existentă de tranziție prin GUI-ul Unity, așa cum vom discuta mai târziu. Acest lucru lasă două acțiuni ( PatrolAction și ChaseAction ) și o decizie pe care o vom codifica.
Acțiunea de stare de patrulare (care derivă din baza FSMAction ) suprascrie metoda Execute pentru a obține două componente:
-
PatrolPoints, care urmărește punctele de patrulare. -
NavMeshAgent, implementarea Unity pentru navigarea în spațiul 3D.
Suprascrierea verifică apoi dacă agentul AI a ajuns la destinație și, dacă da, trece la următoarea destinație. Arata cam asa:
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); } } } Poate dorim să luăm în considerare stocarea în cache a componentelor PatrolPoints și NavMeshAgent . Memorarea în cache ne-ar permite să partajăm ScriptableObject uri pentru acțiuni între agenți, fără impactul asupra performanței rulării GetComponent pe fiecare interogare a mașinii cu stări finite.
Pentru a fi clar, nu putem stoca în cache instanțele componente în metoda Execute . Deci, în schimb, vom adăuga o metodă personalizată GetComponent la BaseStateMachine . GetComponent nostru personalizat ar stoca în cache instanța prima dată când este apelată, returnând instanța stocată în cache la apeluri consecutive. Pentru referință, aceasta este implementarea BaseStateMachine cu cache:
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; } } } La fel ca omologul său PatrolAction , clasa ChaseAction suprascrie metoda Execute pentru a obține componentele PatrolPoints și NavMeshAgent . În schimb, totuși, după ce verifică dacă agentul AI a ajuns la destinație, acțiunea de clasă ChaseAction setează destinația la 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); } } } Implementarea clasei InLineOfSightDecision
Piesa finală este clasa InLineOfSightDecision , care moștenește Decision de bază și primește componenta EnemySightSensor pentru a verifica dacă jucătorul se află în linia vizuală a NPC-ului:
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(); } } }Atașarea comportamentelor statelor
Suntem în sfârșit gata să atașăm comportamente agentului Enemy . Acestea sunt create în fereastra Proiect a Editorului Unity.
Adăugarea statelor de Patrol și Chase
Să creăm două stări și să le numim „Patrulă” și „Gonire”:
- Faceți clic dreapta > Creare > FSM > Stat
În timp ce aici, să creăm și un obiect RemainInState :
- Faceți clic dreapta > Creare > FSM > Rămâne în stare
Acum, este timpul să creăm acțiunile pe care tocmai le-am codificat:
- Faceți clic dreapta > Creare > FSM > Acțiune > Patrol
- Faceți clic dreapta > Creare > FSM > Acțiune > Urmărire
Pentru a codifica Decision :
- Faceți clic dreapta > Creare > FSM > Decizii > În linia vizuală
Pentru a activa o tranziție de la PatrolState la ChaseState , să creăm mai întâi obiectul scriptabil de tranziție:
- Faceți clic dreapta > Creare > FSM > Tranziție
- Alegeți un nume care vă place. Mi-am numit Inamicul Patat.
Vom popula fereastra de inspector rezultată după cum urmează:
Apoi vom finaliza dialogul inspector Chase State după cum urmează:
În continuare, vom finaliza dialogul Stare de patrulare:
În cele din urmă, vom adăuga componenta BaseStateMachine la obiectul inamic: În fereastra Proiect a Editorului Unity, deschideți activul SampleScene, selectați obiectul Inamic din panoul Ierarhie și, în fereastra Inspector, selectați Adăugare componentă > Mașină de stat de bază :
Pentru orice problemă, verificați din nou dacă obiectele de joc sunt configurate corect. De exemplu, confirmați că obiectul Enemy include componenta de script PatrolPoints și obiectele Point1 , Point2 etc. Aceste informații se pot pierde cu versiunea incorectă a editorului.
Acum sunteți gata să jucați exemplul de joc și să observați că inamicul îl va urma pe jucător atunci când acesta intră în linia vizuală a inamicului.
Utilizarea FSM-urilor pentru a crea o experiență de utilizator distractivă și interactivă
În acest tutorial de mașină cu stări finite, am creat un AI foarte modular bazat pe FSM (și depozitul GitHub corespunzător) pe care îl putem reutiliza în proiecte viitoare. Datorită acestei modularități, putem întotdeauna adăuga putere AI prin introducerea de noi componente.
Dar arhitectura noastră deschide, de asemenea, calea pentru design-ul FSM, în primul rând grafic, care ne-ar ridica experiența dezvoltatorilor la un nou nivel de profesionalism. Apoi am putea crea FSM-uri pentru jocurile noastre mai rapid și cu o mai bună acuratețe creativă.
