Unity AI Development: un tutorial de máquina de estado finito

Publicado: 2022-03-11

En 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:

Diagrama: A la izquierda está el estado de persecución. Una flecha (que indica que el jugador se comió la pastilla de energía) conduce al estado de evasión de la derecha. Una segunda flecha (que indica que la pastilla de energía se agotó) lleva de vuelta al estado de persecución de la izquierda.
Transiciones entre estados fantasma de Pac-Man

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.

Diagrama en forma de diamante que representa un ciclo: comenzando a la izquierda, hay un estado de persecución que implica una acción correspondiente. El estado de persecución luego apunta a la parte superior, donde hay una decisión: si el jugador comió una bolita de energía, continuamos con el estado de evasión y la acción de evasión a la derecha. El estado de evasión apunta a una decisión en la parte inferior: si la pastilla de energía se agotó, continuamos de regreso a nuestro punto de partida.
Componentes del Pac-Man Ghost FSM

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:

Diagrama: Siete cuadros que se conectan entre sí, descritos en orden de aparición, desde la izquierda/arriba: El cuadro etiquetado como BaseStateMachine incluye + CurrentState: BaseState. BaseStateMachine se conecta a BaseState con una flecha bidireccional. El cuadro denominado BaseState incluye + Execute(BaseStateMachine): void. BaseState se conecta a BaseStateMachine con una flecha bidireccional. Las flechas monodireccionales de State y RemainInState se conectan a BaseState. El cuadro denominado Estado incluye + Ejecutar (BaseStateMachine): void, + Acciones: Listar<Acción> y + Transición: Listar<Transición>. El estado se conecta a BaseState con una flecha monodireccional, a Action con una flecha monodireccional etiquetada como "1" y a Transition con una flecha monodireccional etiquetada como "1". El cuadro etiquetado como RemainInState incluye + Execute(BaseStateMachine): void. RemainInState se conecta a BaseState con una flecha monodireccional. El cuadro denominado Acción incluye + Ejecutar (BaseStateMachine): void. Una flecha monodireccional etiquetada como "1" desde Estado se conecta a Acción. El cuadro denominado Transición incluye + Decide(BaseStateMachine): void, + TransitionDecision: Decisión, + TrueState: BaseState y + FalseState: BaseState. Transición se conecta a Decisión con una flecha monodireccional. Una flecha monodireccional etiquetada como "1" desde Estado se conecta a Transición. El cuadro denominado Decisión incluye + Decide(BaseStateMachine): bool.
Arquitectura FSM 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:

Diagrama: cinco casillas que se conectan entre sí, descritas en orden de aparición, desde la izquierda/arriba: la casilla etiquetada Patrulla se conecta a la casilla etiquetada SI el jugador está en la línea de visión con una flecha monodireccional, y a la casilla etiquetada Acción de patrulla con una flecha monodireccional que está etiquetada como "estado". El cuadro con la etiqueta SI el jugador está en la línea de visión, con una etiqueta adicional "decisión", justo debajo del cuadro. El cuadro con la etiqueta SI el jugador está en la línea de visión se conecta al cuadro con la etiqueta Chase con una flecha monodireccional. Una flecha monodireccional desde el recuadro con la etiqueta Patrulla se conecta al recuadro con la etiqueta SI el jugador está en la línea de visión. El cuadro con la etiqueta Chase se conecta al cuadro con la etiqueta Chase Action con una flecha monodireccional que tiene la etiqueta "estado". Una flecha monodireccional desde el cuadro etiquetado SI el jugador está en la línea de visión se conecta al cuadro etiquetado Chase. Una flecha de flecha monodireccional desde el cuadro con la etiqueta Patrulla se conecta al cuadro con la etiqueta Acción de patrulla. Una flecha de flecha monodireccional desde el cuadro etiquetado Chase se conecta al cuadro etiquetado Chase Action.
Componentes principales de nuestro juego Stealth de muestra FSM

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 Decision es verdadera.
  • Otro estado al que hacer la transición si la Decision arroja 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:

  1. 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.
  2. 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:

  1. PatrolPoints , que realiza un seguimiento de los puntos de patrulla.
  2. 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:

La pantalla Spotted Enemy (Transition) incluye cuatro líneas: El valor de la secuencia de comandos se establece en "Transition" y está atenuado. El valor de la decisión se establece en "LineOfSightDecision (en la línea de visión)". El valor de True State se establece en "ChaseState (Estado)". El valor de False State se establece en "RemainInState (Permanecer en estado)".
Relleno de la ventana del inspector de enemigos detectados (transición)

Luego completaremos el cuadro de diálogo del inspector Chase State de la siguiente manera:

La pantalla Chase State (Estado) comienza con la etiqueta "Abierto". Junto a la etiqueta "Script" se selecciona "Estado". Junto a la etiqueta "Acción", se selecciona "1". En el menú desplegable "Acción", se selecciona "Acción de persecución del elemento 0 (Acción de persecución)". Hay un signo más y un signo menos que sigue. Junto a la etiqueta "Transiciones", se selecciona "0". En el menú desplegable "Transiciones", aparece "La lista está vacía". Hay un signo más y un signo menos que sigue.
Completar la ventana del inspector de estado de Chase

A continuación, completaremos el cuadro de diálogo Estado de patrulla:

La pantalla Patrol State (Estado) comienza con la etiqueta "Abierto". Junto a la etiqueta "Script" se selecciona "Estado". Junto a la etiqueta "Acción", se selecciona "1". En el menú desplegable "Acción", se selecciona "Acción de patrulla del elemento 0 (Acción de patrulla)". Hay un signo más y menos que sigue. Junto a la etiqueta "Transiciones", se selecciona "1". En el menú desplegable "Transiciones", se muestra "Elemento 0 SpottedEnemy (Transición)". Hay un signo más y un signo menos que sigue.
Cómo llenar la ventana del Inspector del Estado de la 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 :

La pantalla Base State Machine (Script): Junto a la etiqueta "Script" atenuada, "BaseStateMachine" está seleccionada y atenuada. Junto a la etiqueta "Estado inicial", se selecciona "PatrolState (Estado)".
Adición del componente de máquina de estado base (secuencia de comandos)

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.