Sviluppo di Unity AI: un tutorial sulla macchina a stati finiti
Pubblicato: 2022-03-11Nel competitivo mondo dei giochi, gli sviluppatori si sforzano di offrire un'esperienza utente divertente a coloro che interagiscono con i personaggi non giocanti (NPC) che creiamo. Gli sviluppatori possono fornire questa interattività utilizzando macchine a stati finiti (FSM) per creare soluzioni di intelligenza artificiale che simulano l'intelligenza nei nostri NPC.
Le tendenze dell'IA si sono spostate sugli alberi comportamentali, ma gli FSM rimangono rilevanti. Sono incorporati, in una capacità o nell'altra, praticamente in ogni gioco elettronico.
Anatomia di un FSM
Un FSM è un modello di calcolo in cui solo uno di un numero finito di stati ipotetici può essere attivo contemporaneamente. Un FSM passa da uno stato all'altro, rispondendo a condizioni o input. I suoi componenti principali includono:
| Componente | Descrizione |
|---|---|
| Stato | Uno di un insieme finito di opzioni che indica l'attuale condizione generale di un FSM; ogni dato stato include un insieme associato di azioni |
| Azione | Cosa fa uno stato quando l'FSM lo interroga |
| Decisione | La logica che stabilisce quando avviene una transizione |
| Transizione | Il processo di cambiamento degli stati |
Mentre ci concentreremo sugli FSM dal punto di vista dell'implementazione dell'IA, anche concetti come macchine a stati di animazione e stati di gioco generali rientrano nell'ombrello di FSM.
Visualizzazione di un FSM
Consideriamo l'esempio del classico gioco arcade Pac-Man. Nello stato iniziale del gioco (lo stato "inseguimento"), gli NPC sono fantasmi colorati che inseguono e alla fine superano il giocatore. I fantasmi passano allo stato di evasione ogni volta che il giocatore mangia un power pellet e sperimenta un power-up, acquisendo la capacità di mangiare i fantasmi. I fantasmi, ora di colore blu, eludono il giocatore fino allo scadere del power-up e i fantasmi tornano allo stato di inseguimento, in cui vengono ripristinati i loro comportamenti e colori originali.
Un fantasma di Pac-Man è sempre in uno dei due stati: insegui o evadi. Naturalmente, dobbiamo fornire due transizioni: una da inseguire a eludere, l'altra da eludere a inseguire:
La macchina a stati finiti, in base alla progettazione, interroga lo stato corrente, che interroga le decisioni e le azioni di quello stato. Il diagramma seguente rappresenta il nostro esempio di Pac-Man e mostra una decisione che controlla lo stato del potenziamento del giocatore. Se è iniziato un potenziamento, gli NPC passano dall'inseguimento all'evasione. Se un potenziamento è terminato, gli NPC passano dall'evasione all'inseguimento. Infine, se non ci sono modifiche all'accensione, non si verifica alcuna transizione.
Scalabilità
Gli FSM ci liberano per costruire un'IA modulare. Ad esempio, con una sola nuova azione, possiamo creare un NPC con un nuovo comportamento. Pertanto, possiamo attribuire una nuova azione, il consumo di un power pellet, a uno dei nostri fantasmi Pac-Man, dandogli la possibilità di mangiare power pellet mentre elude il giocatore. Possiamo riutilizzare le azioni, le decisioni e le transizioni esistenti per supportare questo comportamento.
Poiché le risorse necessarie per sviluppare un NPC unico sono minime, siamo ben posizionati per soddisfare i requisiti di progetto in evoluzione di più NPC unici. D'altra parte, un numero eccessivo di stati e transizioni può farci impigliare in una macchina a stati sottili, un FSM la cui sovrabbondanza di connessioni rende difficile il debug e la manutenzione.
Implementazione di un FSM in Unity
Per dimostrare come implementare una macchina a stati finiti in Unity, creiamo un semplice gioco stealth. La nostra architettura incorporerà ScriptableObject s, che sono contenitori di dati in grado di archiviare e condividere informazioni in tutta l'applicazione, in modo che non sia necessario riprodurle. Gli ScriptableObject sono in grado di eseguire elaborazioni limitate, come invocare azioni e interrogare decisioni. Oltre alla documentazione ufficiale di Unity, il vecchio discorso sull'architettura di gioco con Scriptable Objects rimane un'ottima risorsa se vuoi approfondire.
Prima di aggiungere l'IA a questo progetto iniziale pronto per la compilazione, considera l'architettura proposta:
Nel nostro gioco di esempio, il nemico (un NPC rappresentato da una capsula blu) pattuglia. Quando il nemico vede il giocatore (rappresentato da una capsula grigia), il nemico inizia a seguire il giocatore:
A differenza di Pac-Man, il nemico nel nostro gioco non tornerà allo stato predefinito ("pattuglia") una volta che segue il giocatore.
Creare classi
Iniziamo creando le nostre classi. In una nuova cartella degli scripts , aggiungeremo tutti i blocchi di costruzione dell'architettura proposti come script C#.
Implementazione della classe BaseStateMachine
La classe BaseStateMachine è l'unico MonoBehavior che aggiungeremo per accedere ai nostri NPC abilitati all'IA. Per semplicità, la nostra BaseStateMachine sarà essenziale. Se volessimo, tuttavia, potremmo aggiungere un FSM personalizzato ereditato che memorizza parametri aggiuntivi e riferimenti a componenti aggiuntivi. Nota che il codice non verrà compilato correttamente finché non avremo aggiunto la nostra classe BaseState , cosa che faremo più avanti nel nostro tutorial.
Il codice per BaseStateMachine fa riferimento ed esegue lo stato corrente per eseguire le azioni e vedere se è giustificata una transizione:
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); } } } Implementazione della classe BaseState
Il nostro stato è del tipo BaseState , che deriviamo da uno ScriptableObject . BaseState include un unico metodo, Execute , che prende BaseStateMachine come argomento e gli passa azioni e transizioni. Ecco come appare BaseState :
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } Implementazione delle classi State e RemainInState
Ora deriviamo due classi da BaseState . Innanzitutto, abbiamo la classe State , che memorizza i riferimenti ad azioni e transizioni, include due elenchi (uno per le azioni, l'altro per le transizioni) e sovrascrive e chiama la base Execute su azioni e transizioni:
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); } } } In secondo luogo, abbiamo la classe RemainInState , che dice all'FSM quando non eseguire una transizione:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } Nota che queste classi non verranno compilate fino a quando non avremo aggiunto le FSMAction , Decision e Transition .
Implementazione della classe di FSMAction
Nel diagramma dell'architettura FSM proposta, la classe FSMAction di base è denominata "Azione". Tuttavia, creeremo la classe FSMAction di base e utilizzeremo il nome FSMAction (poiché Action è già utilizzato dallo spazio dei nomi di .NET System ).
FSMAction , uno ScriptableObject , non può elaborare funzioni in modo indipendente, quindi lo definiremo come una classe astratta. Con il progredire del nostro sviluppo, potremmo richiedere una singola azione per servire più di uno stato. Fortunatamente, possiamo associare FSMAction a tutti gli stati di tutti gli FSM che desideriamo.
La classe astratta FSMAction si presenta così:
using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } Implementazione delle classi di Decision e Transition
Per completare il nostro FSM, definiremo altre due classi. Innanzitutto, abbiamo Decision , una classe astratta da cui tutte le altre decisioni definirebbero il loro comportamento personalizzato:

using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } La seconda classe, Transition , contiene l'oggetto Decision e due stati:
- Uno stato in cui passare se la
Decisiondiventa vera. - Un altro stato a cui passare se la
Decisionrisulta falsa.
Si presenta così:
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; } } }Tutto ciò che abbiamo costruito fino a questo punto dovrebbe essere compilato senza errori. In caso di problemi, controlla la versione di Unity Editor, che può causare errori se non aggiornata. Assicurati che tutti i file siano stati clonati correttamente dalla cartella del progetto originale e che tutte le variabili accessibili pubblicamente non siano dichiarate private.
Creazione di azioni e decisioni personalizzate
Ora, con il lavoro pesante fatto, siamo pronti per implementare azioni e decisioni personalizzate in una nuova cartella di scripts .
Implementazione delle classi di Patrol e Chase
Quando analizziamo i componenti principali del diagramma FSM del nostro gioco Stealth di esempio, vediamo che il nostro NPC può trovarsi in uno dei due stati seguenti:
- Stato di pattuglia — Associati allo stato sono:
- Un'azione: l'NPC visita punti di pattuglia casuali in tutto il mondo.
- Una transizione: l'NPC controlla se il giocatore è in vista e, in tal caso, passa allo stato di inseguimento.
- Una decisione: l'NPC controlla se il giocatore è in vista.
- Chase state — Associato allo stato è:
- Un'azione: l'NPC insegue il giocatore.
Possiamo riutilizzare la nostra implementazione di transizione esistente tramite la GUI di Unity, come discuteremo in seguito. Questo lascia due azioni ( PatrolAction e ChaseAction ) e una decisione per noi da codificare.
L'azione dello stato di pattuglia (che deriva dalla base FSMAction ) sovrascrive il metodo Execute per ottenere due componenti:
-
PatrolPoints, che tiene traccia dei punti di pattuglia. -
NavMeshAgent, l'implementazione di Unity per la navigazione nello spazio 3D.
L'override verifica quindi se l'agente AI ha raggiunto la sua destinazione e, in tal caso, si sposta alla destinazione successiva. Si presenta così:
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); } } } Potremmo prendere in considerazione la memorizzazione nella cache dei componenti PatrolPoints e NavMeshAgent . La memorizzazione nella cache ci consentirebbe di condividere ScriptableObject per le azioni tra gli agenti senza l'impatto sulle prestazioni dell'esecuzione di GetComponent su ogni query della macchina a stati finiti.
Per essere chiari, non possiamo memorizzare nella cache le istanze dei componenti nel metodo Execute . Quindi, invece, aggiungeremo un metodo GetComponent personalizzato a BaseStateMachine . Il nostro GetComponent personalizzato memorizza nella cache l'istanza la prima volta che viene chiamata, restituendo l'istanza memorizzata nella cache su chiamate consecutive. Per riferimento, questa è l'implementazione di BaseStateMachine con memorizzazione nella 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; } } } Come la sua controparte PatrolAction , la classe ChaseAction l'override del metodo Execute per ottenere i componenti PatrolPoints e NavMeshAgent . Al contrario, tuttavia, dopo aver verificato se l'agente AI ha raggiunto la sua destinazione, l'azione di classe ChaseAction imposta la destinazione su 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); } } } Implementazione della classe InLineOfSightDecision
L'ultimo pezzo è la classe InLineOfSightDecision , che eredita la Decision di base e ottiene il componente EnemySightSensor per verificare se il giocatore è nella linea di vista dell'NPC:
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(); } } }Attaccare i comportamenti agli Stati
Siamo finalmente pronti per allegare comportamenti all'agente Enemy . Questi vengono creati nella finestra del progetto dell'editor di Unity.
Aggiunta degli Stati di Patrol e Chase
Creiamo due stati e li chiamiamo "Pattuglia" e "Inseguimento":
- Fare clic con il tasto destro > Crea > FSM > Stato
Mentre siamo qui, creiamo anche un oggetto RemainInState :
- Fare clic con il pulsante destro del mouse > Crea > FSM > Rimani nello stato
Ora è il momento di creare le azioni che abbiamo appena codificato:
- Fare clic con il tasto destro > Crea > FSM > Azione > Pattuglia
- Fare clic con il pulsante destro del mouse > Crea > FSM > Azione > Insegui
Per codificare la Decision :
- Fare clic con il pulsante destro del mouse > Crea > FSM > Decisioni > In linea di vista
Per abilitare una transizione da PatrolState a ChaseState , creiamo prima l'oggetto script di transizione:
- Fare clic con il tasto destro > Crea > FSM > Transizione
- Scegli un nome che ti piace. Ho chiamato il mio Spotted Enemy.
Popoleremo la finestra di ispezione risultante come segue:
Quindi completeremo la finestra di dialogo dell'ispettore Chase State come segue:
Successivamente, completeremo la finestra di dialogo Stato di pattuglia:
Infine, aggiungeremo il componente BaseStateMachine all'oggetto nemico: nella finestra Progetto dell'editor di Unity, apri la risorsa SampleScene, seleziona l'oggetto Nemico dal pannello Gerarchia e, nella finestra Inspector, seleziona Aggiungi componente > Macchina a stati di base :
Per qualsiasi problema, ricontrolla che i tuoi oggetti di gioco siano configurati correttamente. Ad esempio, verifica che l'oggetto Enemy includa il componente script PatrolPoints e gli oggetti Point1 , Point2 e così via. Queste informazioni possono andare perse con una versione errata dell'editor.
Ora sei pronto per giocare al gioco di esempio e osserva che il nemico seguirà il giocatore quando il giocatore entrerà nella linea di vista del nemico.
Utilizzo degli FSM per creare un'esperienza utente divertente e interattiva
In questo tutorial sulla macchina a stati finiti, abbiamo creato un'IA altamente modulare basata su FSM (e il corrispondente repository GitHub) che possiamo riutilizzare in progetti futuri. Grazie a questa modularità, possiamo sempre aggiungere potenza alla nostra IA introducendo nuovi componenti.
Ma la nostra architettura apre anche la strada alla prima progettazione grafica FSM, che eleverebbe la nostra esperienza di sviluppatore a un nuovo livello di professionalità. Potremmo quindi creare FSM per i nostri giochi più rapidamente e con una migliore precisione creativa.
