Unity AI Development: un tutorial de máquina de estado finito
Publicado: 2022-03-11En el mundo competitivo de los juegos, los desarrolladores se esfuerzan por ofrecer una experiencia de usuario entretenida para aquellos que interactúan con los personajes que no son jugadores (NPC) que creamos. Los desarrolladores pueden ofrecer esta interactividad mediante el uso de máquinas de estado finito (FSM) para crear soluciones de IA que simulen la inteligencia en nuestros NPC.
Las tendencias de la IA se han desplazado hacia los árboles de comportamiento, pero los FSM siguen siendo relevantes. Están incorporados, de una forma u otra, en prácticamente todos los juegos electrónicos.
Anatomía de un FSM
Un FSM es un modelo de computación en el que solo uno de un número finito de estados hipotéticos puede estar activo a la vez. Una FSM pasa de un estado a otro, respondiendo a condiciones o entradas. Sus componentes principales incluyen:
| Componente | Descripción |
|---|---|
| Estado | Una de un conjunto finito de opciones que indican la condición general actual de un FSM; cualquier estado dado incluye un conjunto asociado de acciones |
| Acción | Qué hace un estado cuando el FSM lo consulta |
| Decisión | La lógica que establece cuándo tiene lugar una transición |
| Transición | El proceso de cambio de estado. |
Si bien nos centraremos en las FSM desde la perspectiva de la implementación de la IA, conceptos como las máquinas de estado de animación y los estados generales del juego también se incluyen en la categoría de FSM.
Visualizando un FSM
Consideremos el ejemplo del clásico juego de arcade Pac-Man. En el estado inicial del juego (el estado de "persecución"), los NPC son fantasmas coloridos que persiguen y finalmente superan al jugador. Los fantasmas pasan al estado de evasión cada vez que el jugador come una pastilla de energía y experimenta un encendido, obteniendo la capacidad de comerse los fantasmas. Los fantasmas, ahora de color azul, evaden al jugador hasta que se agota el tiempo de encendido y los fantasmas vuelven al estado de persecución, en el que se restauran sus comportamientos y colores originales.
Un fantasma de Pac-Man siempre está en uno de dos estados: perseguir o evadir. Naturalmente, debemos proporcionar dos transiciones, una de persecución a evasión, la otra de evasión a persecución:
La máquina de estados finitos, por diseño, consulta el estado actual, que consulta las decisiones y acciones de ese estado. El siguiente diagrama representa nuestro ejemplo de Pac-Man y muestra una decisión que verifica el estado del encendido del jugador. Si ha comenzado un encendido, los NPC pasan de perseguir a evadir. Si un encendido ha terminado, los NPC pasan de evadir a perseguir. Finalmente, si no hay un cambio de encendido, no ocurre ninguna transición.
Escalabilidad
Los FSM nos liberan para construir IA modular. Por ejemplo, con solo una nueva acción, podemos crear un NPC con un nuevo comportamiento. Por lo tanto, podemos atribuir una nueva acción, comer una pastilla de poder, a uno de nuestros fantasmas de Pac-Man, dándole la capacidad de comer pastillas de poder mientras evade al jugador. Podemos reutilizar acciones, decisiones y transiciones existentes para respaldar este comportamiento.
Dado que los recursos necesarios para desarrollar un NPC único son mínimos, estamos bien posicionados para cumplir con los requisitos de proyectos en evolución de múltiples NPC únicos. Por otro lado, un número excesivo de estados y transiciones puede enredarnos en una máquina de estados espagueti, una FSM cuya sobreabundancia de conexiones dificulta su depuración y mantenimiento.
Implementando un FSM en Unity
Para demostrar cómo implementar una máquina de estados finitos en Unity, creemos un juego de sigilo simple. Nuestra arquitectura incorporará ScriptableObject s, que son contenedores de datos que pueden almacenar y compartir información en toda la aplicación, para que no necesitemos reproducirla. ScriptableObject s son capaces de procesamiento limitado, como invocar acciones y consultar decisiones. Además de la documentación oficial de Unity, la charla más antigua sobre arquitectura de juegos con objetos programables sigue siendo un excelente recurso si desea profundizar más.
Antes de agregar AI a este proyecto inicial listo para compilar, considere la arquitectura propuesta:
En nuestro juego de muestra, el enemigo (un NPC representado por una cápsula azul) patrulla. Cuando el enemigo ve al jugador (representado por una cápsula gris), el enemigo comienza a seguir al jugador:
A diferencia de Pac-Man, el enemigo en nuestro juego no volverá al estado predeterminado ("patrulla") una vez que siga al jugador.
Creación de clases
Comencemos por crear nuestras clases. En una nueva carpeta de secuencias de scripts , agregaremos todos los bloques de construcción arquitectónicos propuestos como secuencias de comandos C#.
Implementando la clase BaseStateMachine
La clase BaseStateMachine es el único MonoBehavior que agregaremos para acceder a nuestros NPC habilitados para IA. En aras de la simplicidad, nuestra BaseStateMachine será básica. Sin embargo, si quisiéramos, podríamos agregar un FSM personalizado heredado que almacene parámetros adicionales y referencias a componentes adicionales. Tenga en cuenta que el código no se compilará correctamente hasta que hayamos agregado nuestra clase BaseState , lo que haremos más adelante en nuestro tutorial.
El código para BaseStateMachine se refiere y ejecuta el estado actual para realizar las acciones y ver si se justifica una transición:
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); } } } Implementando la clase BaseState
Nuestro estado es del tipo BaseState , que derivamos de un ScriptableObject . BaseState incluye un único método, Execute , que toma BaseStateMachine como argumento y le pasa acciones y transiciones. Así es como se ve BaseState :
using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } } Implementando las clases State y RemainInState
Ahora derivamos dos clases de BaseState . Primero, tenemos la clase State , que almacena referencias a acciones y transiciones, incluye dos listas (una para acciones y otra para transiciones) y anula y llama a la base Execute en acciones y transiciones:
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); } } } En segundo lugar, tenemos la clase RemainInState , que le dice al FSM cuándo no realizar una transición:
using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } } Tenga en cuenta que estas clases no se compilarán hasta que hayamos agregado las FSMAction , Decision y Transition .
Implementación de la clase FSMAction
En el diagrama de la arquitectura FSM propuesta, la clase base FSMAction está etiquetada como "Acción". Sin embargo, crearemos la clase base FSMAction y usaremos el nombre FSMAction (dado que Action ya está en uso en el espacio de nombres del System .NET).
FSMAction , un ScriptableObject , no puede procesar funciones de forma independiente, por lo que lo definiremos como una clase abstracta. A medida que avanza nuestro desarrollo, es posible que necesitemos una sola acción para servir a más de un estado. Afortunadamente, podemos asociar FSMAction con tantos estados de tantas FSM como deseemos.
La clase abstracta FSMAction se ve así:

using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } } Implementación de las clases de Decision y Transition
Para finalizar nuestra FSM, definiremos dos clases más. Primero, tenemos Decision , una clase abstracta a partir de la cual todas las demás decisiones definirían su comportamiento personalizado:
using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } } La segunda clase, Transition , contiene el objeto Decision y dos estados:
- Un estado al que hacer la transición si la
Decisiones verdadera. - Otro estado al que hacer la transición si la
Decisionarroja falso.
Se parece a esto:
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; } } }Todo lo que hemos construido hasta este punto debería compilarse sin ningún error. Si experimenta problemas, verifique su versión de Unity Editor, que puede causar errores si está desactualizada. Asegúrese de que todos los archivos se hayan clonado correctamente desde la carpeta del proyecto original y que todas las variables de acceso público no se declaren privadas.
Crear acciones y decisiones personalizadas
Ahora, con el trabajo pesado hecho, estamos listos para implementar acciones y decisiones personalizadas en una nueva carpeta de scripts .
Implementando las Clases de Patrol y Chase
Cuando analizamos los componentes principales de nuestro diagrama FSM de ejemplo de Stealth Game, vemos que nuestro NPC puede estar en uno de dos estados:
- Estado de patrulla : asociados con el estado están:
- Una acción: NPC visita puntos de patrulla aleatorios en todo el mundo.
- Una transición: NPC verifica si el jugador está a la vista y, de ser así, pasa al estado de persecución.
- Una decisión: NPC verifica si el jugador está a la vista.
- Estado de Chase — Asociado con el estado está:
- Una acción: NPC persigue al jugador.
Podemos reutilizar nuestra implementación de transición existente a través de la GUI de Unity, como veremos más adelante. Esto deja dos acciones ( PatrolAction y ChaseAction ) y una decisión para que codifiquemos.
La acción de estado de patrulla (que se deriva de la base FSMAction ) anula el método Execute para obtener dos componentes:
-
PatrolPoints, que realiza un seguimiento de los puntos de patrulla. -
NavMeshAgent, la implementación de Unity para la navegación en el espacio 3D.
La anulación luego verifica si el agente AI ha llegado a su destino y, de ser así, se mueve al siguiente destino. Se parece a esto:
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); } } } Es posible que deseemos considerar almacenar en caché los componentes PatrolPoints y NavMeshAgent . El almacenamiento en caché nos permitiría compartir ScriptableObject s para acciones entre agentes sin el impacto en el rendimiento de ejecutar GetComponent en cada consulta de la máquina de estado finito.
Para ser claros, no podemos almacenar en caché instancias de componentes en el método Execute . Entonces, en su lugar, agregaremos un método GetComponent personalizado a BaseStateMachine . Nuestro GetComponent personalizado almacenaría en caché la instancia la primera vez que se llama, devolviendo la instancia almacenada en caché en llamadas consecutivas. Como referencia, esta es la implementación de BaseStateMachine con almacenamiento en caché:
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; } } } Al igual que su contraparte PatrolAction , la clase ChaseAction anula el método Execute para obtener los componentes PatrolPoints y NavMeshAgent . Sin embargo, por el contrario, después de comprobar si el agente de IA ha llegado a su destino, la acción de clase ChaseAction establece el destino en 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); } } } Implementación de la clase InLineOfSightDecision
La pieza final es la clase InLineOfSightDecision , que hereda la Decision base y obtiene el componente EnemySightSensor para verificar si el jugador está en la línea de visión del 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(); } } }Adjuntar comportamientos a estados
Finalmente estamos listos para adjuntar comportamientos al agente Enemy . Estos se crean en la ventana Proyecto del Editor de Unity.
Adición de los estados de Patrol y Chase
Vamos a crear dos estados y nombrarlos "Patrulla" y "Persecución":
- Haga clic derecho> Crear> FSM> Estado
Mientras estamos aquí, también creemos un objeto RemainInState :
- Haga clic derecho> Crear> FSM> Permanecer en estado
Ahora es el momento de crear las acciones que acabamos de codificar:
- Haga clic derecho> Crear> FSM> Acción> Patrullar
- Haga clic derecho> Crear> FSM> Acción> Chase
Para codificar la Decision :
- Haga clic derecho> Crear> FSM> Decisiones> En la línea de visión
Para habilitar una transición de PatrolState a ChaseState , primero creemos el objeto programable de transición:
- Haga clic derecho> Crear> FSM> Transición
- Elige un nombre que te guste. Llamé al mío Spotted Enemy.
Completaremos la ventana de inspección resultante de la siguiente manera:
Luego completaremos el cuadro de diálogo del inspector Chase State de la siguiente manera:
A continuación, completaremos el cuadro de diálogo Estado de patrulla:
Finalmente, agregaremos el componente BaseStateMachine al objeto enemigo: en la ventana Proyecto del editor de Unity, abra el recurso SampleScene, seleccione el objeto Enemigo del panel Jerarquía y, en la ventana Inspector, seleccione Agregar componente > Máquina de estado base :
Para cualquier problema, verifique que los objetos de su juego estén configurados correctamente. Por ejemplo, confirme que el objeto Enemy incluye el componente de secuencia de comandos PatrolPoints y los objetos Point1 , Point2 , etc. Esta información se puede perder con una versión incorrecta del editor.
Ahora está listo para jugar el juego de muestra y observar que el enemigo seguirá al jugador cuando este entre en la línea de visión del enemigo.
Uso de FSM para crear una experiencia de usuario divertida e interactiva
En este tutorial de máquina de estado finito, creamos una IA basada en FSM altamente modular (y el correspondiente repositorio de GitHub) que podemos reutilizar en proyectos futuros. Gracias a esta modularidad, siempre podemos añadir potencia a nuestra IA introduciendo nuevos componentes.
Pero nuestra arquitectura también allana el camino para el diseño gráfico de FSM, lo que elevaría nuestra experiencia de desarrollador a un nuevo nivel de profesionalismo. Entonces podríamos crear FSM para nuestros juegos más rápidamente y con mayor precisión creativa.
