Unity AI Development: un tutorial de FSM gráfico basado en xNode

Publicado: 2022-08-12

En "Unity AI Development: A Finite-state Machine Tutorial", creamos un juego de sigilo simple: una IA modular basada en FSM. En el juego, un agente enemigo patrulla el espacio de juego. Cuando detecta al jugador, el enemigo cambia su estado y lo sigue en lugar de patrullar.

En esta segunda etapa de nuestro viaje de Unity, crearemos una interfaz gráfica de usuario (GUI) para crear los componentes principales de nuestra máquina de estado finito (FSM) más rápidamente y con una experiencia de desarrollador de Unity mejorada.

Un repaso rápido

El FSM detallado en el tutorial anterior se creó a partir de bloques arquitectónicos como scripts de C#. Agregamos acciones y decisiones personalizadas de ScriptableObject como clases. El enfoque de ScriptableObject permitió un FSM fácil de mantener y personalizable. En este tutorial, reemplazamos los ScriptableObject s de arrastrar y soltar de FSM con una opción gráfica.

También he escrito un guión actualizado para aquellos de ustedes que quieren hacer que el juego sea más fácil de ganar. Para implementar, simplemente reemplace el script de detección de jugadores con este que reduce el campo de visión del enemigo.

Primeros pasos con xNode

Construiremos nuestro editor gráfico utilizando xNode, un marco para árboles de comportamiento basados ​​en nodos que mostrarán visualmente el flujo de nuestro FSM. Aunque GraphView de Unity puede realizar el trabajo, su API es experimental y está escasamente documentada. La interfaz de usuario de xNode ofrece una experiencia de desarrollador superior, lo que facilita la creación de prototipos y la rápida expansión de nuestro FSM.

Agreguemos xNode a nuestro proyecto como una dependencia de Git usando Unity Package Manager:

  1. En Unity, haga clic en Ventana > Administrador de paquetes para abrir la ventana Administrador de paquetes.
  2. Haga clic en + (el signo más) en la esquina superior izquierda de la ventana y seleccione Agregar paquete desde la URL de git para mostrar un campo de texto.
  3. Escriba o pegue https://github.com/siccity/xNode.git en el cuadro de texto sin etiqueta y haga clic en el botón Agregar .

Ahora estamos listos para profundizar y comprender los componentes clave de xNode:

clase de Node Representa un nodo, la unidad más fundamental de un gráfico. En este tutorial de xNode, derivamos de la clase Node nuevas clases que declaran nodos equipados con funciones y funciones personalizadas.
clase de NodeGraph de nodo Representa una colección de nodos (instancias de la clase Node ) y los bordes que los conectan. En este tutorial de xNode, derivamos de NodeGraph una nueva clase que manipula y evalúa los nodos.
clase de puerto de NodePort Representa una puerta de comunicación, un puerto de tipo entrada o tipo salida, ubicado entre instancias de Node en un NodeGraph . La clase NodePort es exclusiva de xNode.
[Input] atributo La adición del atributo [Input] a un puerto lo designa como una entrada, lo que permite que el puerto pase valores al nodo del que forma parte. Piense en el atributo [Input] como un parámetro de función.
Atributo [Output] La adición del atributo [Output] a un puerto lo designa como una salida, lo que permite que el puerto pase valores desde el nodo del que forma parte. Piense en el atributo [Output] como el valor de retorno de una función.

Visualización del entorno de construcción de xNode

En xNode trabajamos con grafos donde cada State y Transition toma la forma de un nodo. Las conexiones de entrada y/o salida permiten que el nodo se relacione con cualquiera o todos los demás nodos de nuestro gráfico.

Imaginemos un nodo con tres valores de entrada: dos arbitrarios y uno booleano. El nodo generará uno de los dos valores de entrada de tipo arbitrario, dependiendo de si la entrada booleana es verdadera o falsa.

El nodo Branch, representado por un gran rectángulo en el centro, incluye el pseudocódigo "If C == True A Else B". A la izquierda hay tres rectángulos, cada uno de los cuales tiene una flecha que apunta al nodo Rama: "A (arbitrario)", "B (arbitrario)" y "C (booleano)". El nodo Rama, finalmente, tiene una flecha que apunta a un rectángulo de "Salida".
Un nodo Branch ejemplo

Para convertir nuestro FSM existente en un gráfico, modificamos las clases State y Transition para heredar la clase Node en lugar de la clase ScriptableObject . Creamos un objeto de gráfico de tipo NodeGraph para contener todos nuestros objetos de State y Transition .

Modificación de BaseStateMachine para usar como tipo base

Comience a construir la interfaz gráfica agregando dos nuevos métodos virtuales a nuestra clase BaseStateMachine existente:

Init Asigna el estado inicial a la propiedad CurrentState
Execute Ejecuta el estado actual

Declarar estos métodos como virtuales nos permite anularlos, por lo que podemos definir los comportamientos personalizados de las clases que heredan la clase BaseStateMachine para la inicialización y ejecución:

 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; } } }

A continuación, en nuestra carpeta FSM , creemos:

FSMGraph Una carpeta
BaseStateMachineGraph Clase AC# dentro de FSMGraph

Por el momento, BaseStateMachineGraph heredará solo la clase BaseStateMachine :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }

No podemos agregar funcionalidad a BaseStateMachineGraph hasta que creemos nuestro tipo de nodo base; hagamos eso a continuación.

Implementación NodeGraph y creación de un tipo de nodo base

En nuestra carpeta FSMGraph recién creada, crearemos:

FSMGraph Una clase

Por ahora, FSMGraph heredará solo la clase NodeGraph (sin funcionalidad adicional):

 using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }

Antes de crear clases para nuestros nodos, agreguemos:

FSMNodeBase Una clase para ser utilizada como clase base por todos nuestros nodos.

La clase FSMNodeBase contendrá una entrada denominada Entry de tipo FSMNodeBase para permitirnos conectar nodos entre sí.

También agregaremos dos funciones auxiliares:

GetFirst Recupera el primer nodo conectado a la salida solicitada
GetAllOnPort Recupera todos los nodos restantes que se conectan a la salida solicitada
 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; } } }

En última instancia, tendremos dos tipos de nodos de estado; agreguemos una clase para admitir estos:

BaseStateNode Una clase base para admitir tanto StateNode como RemainInStateNode
 namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }

A continuación, modifique la clase BaseStateMachineGraph :

 using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }

Aquí, ocultamos la propiedad CurrentState heredada de la clase base y cambiamos su tipo de BaseState a BaseStateNode .

Creando bloques de construcción para nuestro gráfico FSM

A continuación, para formar los bloques de construcción principales de nuestro FSM, agreguemos tres nuevas clases a nuestra carpeta FSMGraph :

StateNode Representa el estado de un agente. Al ejecutar, StateNode itera sobre los TransitionNode conectados al puerto de salida de StateNode (recuperado por un método auxiliar). StateNode consulta a cada uno si desea hacer la transición del nodo a un estado diferente o dejar el estado del nodo como está.
RemainInStateNode Indica que un nodo debe permanecer en el estado actual.
TransitionNode Toma la decisión de hacer la transición a un estado diferente o permanecer en el mismo estado.

En el tutorial anterior de Unity FSM, la clase State itera sobre la lista de transiciones. Aquí en xNode, StateNode sirve como el equivalente de State para iterar sobre los nodos recuperados a través de nuestro método auxiliar GetAllOnPort .

Ahora agregue un atributo [Output] a las conexiones salientes (los nodos de transición) para indicar que deben ser parte de la GUI. Por el diseño de xNode, el valor del atributo se origina en el nodo de origen: el nodo que contiene el campo marcado con el atributo [Output] . Como estamos usando los atributos [Output] y [Input] para describir las relaciones y conexiones que establecerá la GUI de xNode, no podemos tratar estos valores como lo haríamos normalmente. Considere cómo iteramos a través de 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); } } }

En este caso, la salida de Transitions puede tener varios nodos adjuntos; tenemos que llamar al método auxiliar GetAllOnPort para obtener una lista de las conexiones [Output] .

RemainInStateNode es, con diferencia, nuestra clase más sencilla. Sin ejecutar ninguna lógica, RemainInStateNode simplemente indica a nuestro agente (en el caso de nuestro juego, el enemigo) que permanezca en su estado actual:

 namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }

En este punto, la clase TransitionNode aún está incompleta y no se compilará. Los errores asociados se borrarán una vez que actualicemos la clase.

Para compilar TransitionNode , debemos evitar el requisito de xNode de que el valor de la salida se origine en el nodo de origen, como hicimos cuando creamos StateNode . Una diferencia importante entre StateNode y TransitionNode es que la salida de TransitionNode puede adjuntarse a un solo nodo. En nuestro caso, GetFirst buscará el nodo adjunto a cada uno de nuestros puertos (un nodo de estado al que hacer la transición en el caso verdadero y otro al que hacer la transición en el caso falso):

 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; } } }

Echemos un vistazo a los resultados gráficos de nuestro código.

Crear el gráfico visual

Con todas las clases de FSM resueltas, podemos proceder a crear nuestro gráfico de FSM para el agente enemigo del juego. En la ventana del proyecto de Unity, haga clic con el botón derecho en la carpeta EnemyAI y elija: Create > FSM > FSM Graph . Para que nuestro gráfico sea más fácil de identificar, vamos a cambiarle el nombre a EnemyGraph .

En la ventana del editor de gráficos de xNode, haga clic con el botón derecho para revelar un menú desplegable que enumera State , Transition y RemainInState . Si la ventana no está visible, haga doble clic en el archivo EnemyGraph para iniciar la ventana del editor de gráficos xNode.

  1. Para crear los estados Chase y Patrol :

    1. Haga clic con el botón derecho y elija Estado para crear un nuevo nodo.

    2. Nombre el nodo Chase .

    3. Regrese al menú desplegable, elija Estado nuevamente para crear un segundo nodo.

    4. Asigne al nodo el nombre Patrol .

    5. Arrastre y suelte las acciones de Chase y Patrol existentes en sus estados correspondientes recién creados.

  2. Para crear la transición:

    1. Haga clic con el botón derecho y elija Transición para crear un nuevo nodo.

    2. Asigne el objeto LineOfSightDecision al campo Decision de la transición.

  3. Para crear el nodo RemainInState :

    1. Haga clic con el botón derecho y elija RemainInState para crear un nuevo nodo.
  4. Para conectar el gráfico:

    1. Conecte la salida Transitions del nodo Patrol a la entrada Entry del nodo Transition .

    2. Conecte la salida True State del nodo Transition a la entrada Entry del nodo Chase .

    3. Conecte la salida False State del nodo Transition a la entrada Entry del nodo Remain In State .

El gráfico debería verse así:

Cuatro nodos representados como cuatro rectángulos, cada uno con círculos de entrada de entrada en la parte superior izquierda. De izquierda a derecha, el nodo de estado Patrulla muestra una acción: Acción de patrulla. El nodo de estado de Patrulla también incluye un círculo de salida de Transiciones en su parte inferior derecha que se conecta al círculo de Entrada del nodo de Transición. El nodo Transición muestra una decisión: LineOfSight. Tiene dos círculos de salida en la parte inferior derecha, Estado verdadero y Estado falso. True State se conecta al círculo de entrada de nuestra tercera estructura, el nodo de estado Chase. El nodo de estado Chase muestra una acción: Chase Action. El nodo de estado Chase tiene un círculo de salida de transiciones. El segundo de los dos círculos de salida de Transition, False State, se conecta al círculo de entrada de nuestra cuarta y última estructura, el nodo RemainInState (que aparece debajo del nodo de estado Chase).
La mirada inicial a nuestro gráfico FSM

Nada en el gráfico indica qué nodo, el estado Patrol o Chase , es nuestro nodo inicial. La clase BaseStateMachineGraph detecta cuatro nodos pero, sin indicadores presentes, no puede elegir el estado inicial.

Para resolver este problema, vamos a crear:

FSMInitialNode Una clase cuya única salida de tipo StateNode se denomina InitialNode

Nuestra salida InitialNode denota el estado inicial. A continuación, en FSMInitialNode , cree:

NextNode Una propiedad que nos permite obtener el nodo conectado a la salida de 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; } } } }

Ahora que creamos la clase FSMInitialNode , podemos conectarla a la entrada Entry del estado inicial y devolver el estado inicial a través de la propiedad NextNode .

Volvamos a nuestro gráfico y agreguemos el nodo inicial. En la ventana del editor xNode:

  1. Haga clic con el botón derecho y elija Nodo inicial para crear un nuevo nodo.
  2. Adjunte la salida del nodo FSM a la Entry de entrada del nodo Patrol .

El gráfico ahora debería verse así:

El mismo gráfico que en nuestra imagen anterior, con un rectángulo verde de nodo FSM agregado a la izquierda de los otros cuatro rectángulos. Tiene una salida de nodo inicial (representada por un círculo azul) que se conecta a la entrada de "Entrada" del nodo de patrulla (representada por un círculo rojo oscuro).
Nuestro gráfico FSM con el nodo inicial adjunto al estado de patrulla

Para facilitarnos la vida, agregaremos a FSMGraph :

InitialState Una propiedad

La primera vez que intentamos recuperar el valor de la propiedad InitialState , el captador de la propiedad recorrerá todos los nodos de nuestro gráfico mientras intenta encontrar FSMInitialNode . Una vez que se encuentra FSMInitialNode , usamos la propiedad NextNode para encontrar nuestro nodo de estado inicial:

 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; } } }

A continuación, en nuestro BaseStateMachineGraph , hagamos referencia FSMGraph y anulemos los métodos Init y Execute de nuestro BaseStateMachine . Anular Init establece CurrentState como el estado inicial del gráfico, y anular Execute llama a Execute en 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); } } }

Ahora, apliquemos el gráfico al objeto Enemy y veamos cómo funciona.

Prueba del gráfico FSM

En preparación para la prueba, en la ventana Proyecto del Editor de Unity:

  1. Abra el recurso SampleScene.

  2. Localiza nuestro objeto de juego Enemy en la ventana de jerarquía de Unity.

  3. Reemplace el componente BaseStateMachine con el componente BaseStateMachineGraph :

    1. Haga clic en Agregar componente y seleccione el script BaseStateMachineGraph correcto.

    2. Asigne nuestro gráfico FSM, EnemyGraph , al campo Graph del componente BaseStateMachineGraph .

    3. Elimine el componente BaseStateMachine (ya que ya no es necesario) haciendo clic con el botón derecho y seleccionando Eliminar componente .

El objeto del juego Enemy debería verse así:

De arriba a abajo, en la pantalla Inspector, hay una marca junto a Enemigo. "Jugador" está seleccionado en el menú desplegable Etiqueta, "Enemigo" está seleccionado en el menú desplegable Capa. El menú desplegable Transformar muestra la posición, la rotación y la escala. El menú desplegable Capsule está comprimido, y los menús desplegables Mesh Renderer, Capsule Collider y Nav Mesh Agent aparecen comprimidos con una marca a su izquierda. El menú desplegable del sensor de visión del enemigo muestra el guión y la máscara de ignorar. El menú desplegable PatrolPoints muestra el Script y cuatro PatrolPoints. Hay una marca de verificación junto al menú desplegable Gráfico de máquina de estado base (Script). La secuencia de comandos muestra "BaseStateMachineGraph", Initial State muestra "Ninguno (Estado base) y Graph muestra "EnemyGraph (FSM Graph)". Finalmente, el menú desplegable Blue Enemy (Material) está comprimido y aparece un botón "Agregar componente" debajo eso.
Nuestro objeto de juego Enemy

¡Eso es todo! Ahora tenemos un FSM modular con un editor gráfico. Al hacer clic en el botón Reproducir , se muestra que la IA enemiga creada gráficamente funciona exactamente igual que nuestro enemigo ScriptableObject creado anteriormente.

Avanzando: Optimizando nuestro FSM

Una advertencia: a medida que desarrolla una IA más sofisticada para su juego, aumenta la cantidad de estados y transiciones, y el FSM se vuelve confuso y difícil de leer. El editor gráfico crece para parecerse a una red de líneas que se originan en múltiples estados y terminan en múltiples transiciones, y viceversa, lo que dificulta la depuración del FSM.

Al igual que en el tutorial anterior, lo invitamos a que haga suyo el código, optimice su juego de sigilo y aborde estas inquietudes. Imagine lo útil que sería codificar por colores sus nodos de estado para indicar si un nodo está activo o inactivo, o cambiar el tamaño de los nodos RemainInState e Initial para limitar su espacio en pantalla.

Estas mejoras no son meramente cosméticas. Las referencias de color y tamaño ayudarían a identificar dónde y cuándo depurar. Un gráfico que es agradable a la vista también es más sencillo de evaluar, analizar y comprender. Los próximos pasos dependen de usted: con la base de nuestro editor gráfico en su lugar, no hay límite para las mejoras que puede realizar en la experiencia del desarrollador.

Lecturas adicionales en el blog de ingeniería de Toptal:

  • Los 10 errores más comunes que cometen los desarrolladores de Unity
  • Unity con MVC: cómo subir de nivel el desarrollo de tu juego
  • Dominar cámaras 2D en Unity: un tutorial para desarrolladores de juegos
  • Mejores prácticas y consejos de Unity por parte de los desarrolladores de Toptal

El blog de ingeniería de Toptal extiende su agradecimiento a Goran Lalic por su experiencia y revisión técnica de este artículo.