Unity AI Development: un tutorial de FSM gráfico basado en xNode
Publicado: 2022-08-12En "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:
- En Unity, haga clic en Ventana > Administrador de paquetes para abrir la ventana Administrador de paquetes.
- 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.
- 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.
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.
Para crear los estados
Chase
yPatrol
:Haga clic con el botón derecho y elija Estado para crear un nuevo nodo.
Nombre el nodo
Chase
.Regrese al menú desplegable, elija Estado nuevamente para crear un segundo nodo.
Asigne al nodo el nombre
Patrol
.Arrastre y suelte las acciones de
Chase
yPatrol
existentes en sus estados correspondientes recién creados.
Para crear la transición:
Haga clic con el botón derecho y elija Transición para crear un nuevo nodo.
Asigne el objeto
LineOfSightDecision
al campoDecision
de la transición.
Para crear el nodo
RemainInState
:- Haga clic con el botón derecho y elija RemainInState para crear un nuevo nodo.
Para conectar el gráfico:
Conecte la salida
Transitions
del nodoPatrol
a la entradaEntry
del nodoTransition
.Conecte la salida
True State
del nodoTransition
a la entradaEntry
del nodoChase
.Conecte la salida
False State
del nodoTransition
a la entradaEntry
del nodoRemain In State
.
El gráfico debería verse así:
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:
- Haga clic con el botón derecho y elija Nodo inicial para crear un nuevo nodo.
- Adjunte la salida del nodo FSM a la
Entry
de entrada del nodoPatrol
.
El gráfico ahora debería verse así:
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:
Abra el recurso SampleScene.
Localiza nuestro objeto de juego
Enemy
en la ventana de jerarquía de Unity.Reemplace el componente
BaseStateMachine
con el componenteBaseStateMachineGraph
:Haga clic en Agregar componente y seleccione el script
BaseStateMachineGraph
correcto.Asigne nuestro gráfico FSM,
EnemyGraph
, al campoGraph
del componenteBaseStateMachineGraph
.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í:
¡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.