Desenvolvimento de IA do Unity: um tutorial de FSM gráfico baseado em xNode
Publicados: 2022-08-12Em “Unity AI Development: A Finite-state Machine Tutorial”, criamos um jogo furtivo simples – uma IA modular baseada em FSM. No jogo, um agente inimigo patrulha o espaço do jogo. Quando avista o jogador, o inimigo muda de estado e segue o jogador em vez de patrulhar.
Nesta segunda etapa de nossa jornada Unity, construiremos uma interface gráfica do usuário (GUI) para criar os componentes principais de nossa máquina de estado finito (FSM) mais rapidamente e com uma experiência aprimorada do desenvolvedor Unity.
Uma atualização rápida
O FSM detalhado no tutorial anterior foi construído de blocos de arquitetura como scripts C#. Adicionamos ações e decisões personalizadas de ScriptableObject
como classes. A abordagem ScriptableObject
permitiu um FSM de fácil manutenção e personalizável. Neste tutorial, substituímos os ScriptableObject
de arrastar e soltar do FSM por uma opção gráfica.
Também escrevi um script atualizado para aqueles que querem tornar o jogo mais fácil de ganhar. Para implementar, basta substituir o script de detecção do jogador por este que estreita o campo de visão do inimigo.
Introdução ao xNode
Construiremos nosso editor gráfico usando xNode, uma estrutura para árvores de comportamento baseadas em nós que exibirá visualmente o fluxo de nosso FSM. Embora o GraphView do Unity possa realizar o trabalho, sua API é experimental e escassamente documentada. A interface de usuário do xNode oferece uma experiência de desenvolvedor superior, facilitando a prototipagem e a rápida expansão do nosso FSM.
Vamos adicionar xNode ao nosso projeto como uma dependência do Git usando o Unity Package Manager:
- No Unity, clique em Janela > Gerenciador de Pacotes para iniciar a janela Gerenciador de Pacotes.
- Clique em + (o sinal de mais) no canto superior esquerdo da janela e selecione Add package from git URL para exibir um campo de texto.
- Digite ou cole
https://github.com/siccity/xNode.git
na caixa de texto sem rótulo e clique no botão Adicionar .
Agora estamos prontos para mergulhar fundo e entender os principais componentes do xNode:
Classe de Node | Representa um nó, a unidade mais fundamental de um gráfico. Neste tutorial de xNode, derivamos da classe Node novas classes que declaram nós equipados com funções e funções personalizadas. |
classe NodeGraph | Representa uma coleção de nós (instâncias da classe Node ) e as arestas que os conectam. Neste tutorial xNode, derivamos do NodeGraph uma nova classe que manipula e avalia os nós. |
Classe NodePort | Representa uma porta de comunicação, uma porta do tipo entrada ou saída do tipo, localizada entre instâncias do Node em um NodeGraph . A classe NodePort é exclusiva do xNode. |
atributo [Input] | A adição do atributo [Input] a uma porta a designa como uma entrada, permitindo que a porta passe valores para o nó do qual faz parte. Pense no atributo [Input] como um parâmetro de função. |
atributo [Output] | A adição do atributo [Output] a uma porta a designa como uma saída, permitindo que a porta passe valores do nó do qual faz parte. Pense no atributo [Output] como o valor de retorno de uma função. |
Visualizando o ambiente de construção xNode
No xNode, trabalhamos com grafos onde cada State
e Transition
assume a forma de um nó. As conexões de entrada e/ou saída permitem que o nó se relacione com qualquer um ou todos os outros nós em nosso gráfico.
Vamos imaginar um nó com três valores de entrada: dois arbitrários e um booleano. O nó produzirá um dos dois valores de entrada de tipo arbitrário, dependendo se a entrada booleana é verdadeira ou falsa.
Para converter nosso FSM existente em um gráfico, modificamos as classes State
e Transition
para herdar a classe Node
em vez da classe ScriptableObject
. Criamos um objeto gráfico do tipo NodeGraph
para conter todos os nossos objetos State
e Transition
.
Modificando BaseStateMachine
para usar como um tipo base
Comece a construir a interface gráfica adicionando dois novos métodos virtuais à nossa classe BaseStateMachine
existente:
Init | Atribui o estado inicial à propriedade CurrentState |
Execute | Executa o estado atual |
Declarar esses métodos como virtuais nos permite substituí-los, para que possamos definir os comportamentos personalizados das classes que herdam a classe BaseStateMachine
para inicialização e execução:
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; } } }
Em seguida, em nossa pasta FSM
, vamos criar:
FSMGraph | Uma pasta |
BaseStateMachineGraph | Classe AC # dentro FSMGraph |
Por enquanto, BaseStateMachineGraph
herdará apenas a classe BaseStateMachine
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { } }
Não podemos adicionar funcionalidade a BaseStateMachineGraph
até criarmos nosso tipo de nó base; vamos fazer isso a seguir.
Implementando NodeGraph
e criando um tipo de nó base
Em nossa pasta FSMGraph
recém-criada, criaremos:
FSMGraph | Uma aula |
Por enquanto, o FSMGraph
herdará apenas a classe NodeGraph
(sem funcionalidade adicional):
using UnityEngine; using XNode; namespace Demo.FSM.Graph { [CreateAssetMenu(menuName = "FSM/FSM Graph")] public class FSMGraph : NodeGraph { } }
Antes de criarmos classes para nossos nós, vamos adicionar:
FSMNodeBase | Uma classe a ser usada como classe base por todos os nossos nós |
A classe FSMNodeBase
conterá uma entrada chamada Entry
do tipo FSMNodeBase
para nos permitir conectar nós uns aos outros.
Também adicionaremos duas funções auxiliares:
GetFirst | Recupera o primeiro nó conectado à saída solicitada |
GetAllOnPort | Recupera todos os nós restantes que se conectam à saída 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; } } }
Por fim, teremos dois tipos de nós de estado; vamos adicionar uma classe para suportar isso:
BaseStateNode | Uma classe base para dar suporte a StateNode e RemainInStateNode |
namespace Demo.FSM.Graph { public abstract class BaseStateNode : FSMNodeBase { } }
Em seguida, modifique a classe BaseStateMachineGraph
:
using UnityEngine; namespace Demo.FSM.Graph { public class BaseStateMachineGraph : BaseStateMachine { public new BaseStateNode CurrentState { get; set; } } }
Aqui, ocultamos a propriedade CurrentState
herdada da classe base e alteramos seu tipo de BaseState
para BaseStateNode
.
Criando blocos de construção para nosso gráfico FSM
Em seguida, para formar os principais blocos de construção do nosso FSM, vamos adicionar três novas classes à nossa pasta FSMGraph
:
StateNode | Representa o estado de um agente. Ao executar, StateNode itera sobre os TransitionNode s conectados à porta de saída do StateNode (recuperado por um método auxiliar). StateNode consulta cada um para fazer a transição do nó para um estado diferente ou deixar o estado do nó como está. |
RemainInStateNode | Indica que um nó deve permanecer no estado atual. |
TransitionNode | Toma a decisão de fazer a transição para um estado diferente ou permanecer no mesmo estado. |
No tutorial anterior do Unity FSM, a classe State
itera sobre a lista de transições. Aqui em xNode, StateNode
serve como equivalente de State
para iterar sobre os nós recuperados por meio de nosso método auxiliar GetAllOnPort
.
Agora adicione um atributo [Output]
às conexões de saída (os nós de transição) para indicar que elas devem fazer parte da GUI. Pelo design do xNode, o valor do atributo se origina no nó de origem: o nó que contém o campo marcado com o atributo [Output]
. Como estamos usando os atributos [Output]
e [Input]
para descrever relacionamentos e conexões que serão definidos pela GUI do xNode, não podemos tratar esses valores como normalmente faríamos. Considere como iteramos por meio 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); } } }
Nesse caso, a saída Transitions
pode ter vários nós anexados a ela; temos que chamar o método auxiliar GetAllOnPort
para obter uma lista das conexões [Output]
.
RemainInStateNode
é, de longe, nossa classe mais simples. Não executando nenhuma lógica, RemainInStateNode
apenas indica ao nosso agente - no caso do nosso jogo, o inimigo - para permanecer em seu estado atual:
namespace Demo.FSM.Graph { [CreateNodeMenu("Remain In State")] public sealed class RemainInStateNode : BaseStateNode { } }
Neste ponto, a classe TransitionNode
ainda está incompleta e não será compilada. Os erros associados serão apagados assim que atualizarmos a classe.
Para construir TransitionNode
, precisamos contornar o requisito do xNode de que o valor da saída se origine no nó de origem — como fizemos quando construímos StateNode
. Uma grande diferença entre StateNode
e TransitionNode
é que a saída de TransitionNode
pode ser anexada a apenas um nó. No nosso caso, GetFirst
irá buscar um nó anexado a cada uma de nossas portas (um nó de estado para a transição no caso verdadeiro e outro para a transição no 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; } } }
Vamos dar uma olhada nos resultados gráficos do nosso código.
Criando o gráfico visual
Com todas as classes FSM resolvidas, podemos continuar a criar nosso gráfico FSM para o agente inimigo do jogo. Na janela do projeto do Unity, clique com o botão direito do mouse na pasta EnemyAI
e escolha: Create > FSM > FSM Graph . Para tornar nosso gráfico mais fácil de identificar, vamos renomeá-lo como EnemyGraph
.
Na janela do editor de gráfico xNode, clique com o botão direito do mouse para revelar um menu suspenso listando State , Transition e RemainInState . Se a janela não estiver visível, clique duas vezes no arquivo EnemyGraph
para iniciar a janela do editor xNode Graph.
Para criar os estados
Chase
ePatrol
:Clique com o botão direito do mouse e escolha Estado para criar um novo nó.
Nomeie o nó como
Chase
.Retorne ao menu suspenso, escolha Estado novamente para criar um segundo nó.
Nomeie o nó
Patrol
.Arraste e solte as ações
Chase
ePatrol
existentes para seus estados correspondentes recém-criados.
Para criar a transição:
Clique com o botão direito do mouse e escolha Transição para criar um novo nó.
Atribua o objeto
LineOfSightDecision
ao campoDecision
da transição.
Para criar o nó
RemainInState
:- Clique com o botão direito do mouse e escolha RemainInState para criar um novo nó.
Para conectar o gráfico:
Conecte a saída
Transitions
do nóPatrol
à entradaEntry
do nóTransition
.Conecte a saída
True State
do nóTransition
à entradaEntry
do nóChase
.Conecte a saída
False State
do nóTransition
à entradaEntry
do nóRemain In State
.
O gráfico deve ficar assim:
Nada no gráfico indica qual nó - o estado de Patrol
ou Chase
- é nosso nó inicial. A classe BaseStateMachineGraph
detecta quatro nós, mas, sem indicadores presentes, não pode escolher o estado inicial.
Para resolver esse problema, vamos criar:
FSMInitialNode | Uma classe cuja única saída do tipo StateNode é denominada InitialNode |
Nossa saída InitialNode
denota o estado inicial. Em seguida, em FSMInitialNode
, crie:
NextNode | Uma propriedade para nos permitir buscar o nó conectado à saída 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; } } } }
Agora que criamos a classe FSMInitialNode
, podemos conectá-la à entrada Entry
do estado inicial e retornar o estado inicial por meio da propriedade NextNode
.
Vamos voltar ao nosso gráfico e adicionar o nó inicial. Na janela do editor xNode:
- Clique com o botão direito do mouse e escolha Nó inicial para criar um novo nó.
- Anexe a saída do Nó FSM à entrada de
Entry
do NóPatrol
.
O gráfico agora deve ficar assim:
Para facilitar nossas vidas, adicionaremos ao FSMGraph
:
InitialState | Uma propriedade |
A primeira vez que tentarmos recuperar o valor da propriedade InitialState
, o getter da propriedade percorrerá todos os nós em nosso gráfico enquanto tenta encontrar FSMInitialNode
. Uma vez localizado o FSMInitialNode
, usamos a propriedade NextNode
para encontrar nosso nó 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; } } }
Em seguida, em nosso BaseStateMachineGraph
, vamos referenciar FSMGraph
e substituir os métodos Init
e Execute
de nosso BaseStateMachine
. A substituição de Init
define CurrentState
como o estado inicial do gráfico e a substituição de chamadas Execute
Execute
em 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); } } }
Agora, vamos aplicar o gráfico ao objeto Enemy e vê-lo em ação.
Testando o gráfico FSM
Em preparação para teste, na janela Projeto do Editor do Unity:
Abra o ativo SampleScene.
Localize nosso objeto de jogo
Enemy
na janela de hierarquia do Unity.Substitua o componente
BaseStateMachine
pelo componenteBaseStateMachineGraph
:Clique em Adicionar componente e selecione o script
BaseStateMachineGraph
correto.Atribua nosso gráfico FSM,
EnemyGraph
, ao campoGraph
do componenteBaseStateMachineGraph
.Exclua o componente
BaseStateMachine
(já que não é mais necessário) clicando com o botão direito do mouse e selecionando Remover componente .
O objeto do jogo Enemy
deve ficar assim:
É isso! Agora temos um FSM modular com editor gráfico. Clicar no botão Reproduzir mostra que a IA inimiga criada graficamente funciona exatamente como nosso inimigo ScriptableObject
criado anteriormente.
Seguindo em frente: otimizando nosso FSM
Uma palavra de cautela: à medida que você desenvolve IA mais sofisticada para o seu jogo, o número de estados e transições aumenta e o FSM se torna confuso e difícil de ler. O editor gráfico cresce para se assemelhar a uma teia de linhas que se originam em vários estados e terminam em várias transições - e vice-versa, dificultando a depuração do FSM.
Como no tutorial anterior, convidamos você a fazer o seu próprio código, otimizar seu jogo furtivo e resolver essas preocupações. Imagine como seria útil codificar por cores seus nós de estado para indicar se um nó está ativo ou inativo, ou redimensionar os nós RemainInState
e Initial
para limitar o espaço da tela.
Tais melhorias não são meramente cosméticas. As referências de cor e tamanho ajudariam a identificar onde e quando depurar. Um gráfico que é fácil de ver também é mais simples de avaliar, analisar e compreender. As próximas etapas dependem de você — com a base do nosso editor gráfico, não há limite para as melhorias que você pode fazer na experiência do desenvolvedor.
Leitura adicional no Blog da Toptal Engineering:
- Os 10 erros mais comuns que os desenvolvedores do Unity cometem
- Unity com MVC: como elevar o nível do seu desenvolvimento de jogos
- Dominando câmeras 2D no Unity: um tutorial para desenvolvedores de jogos
- Práticas recomendadas e dicas do Unity por desenvolvedores da Toptal
O Toptal Engineering Blog agradece a Goran Lalic por sua experiência e revisão técnica deste artigo.