Desenvolvimento de IA do Unity: um tutorial de FSM gráfico baseado em xNode

Publicados: 2022-08-12

Em “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:

  1. No Unity, clique em Janela > Gerenciador de Pacotes para iniciar a janela Gerenciador de Pacotes.
  2. 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.
  3. 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.

O nó Branch, representado por um grande retângulo no centro, inclui o pseudocódigo "If C == True A Else B." À esquerda estão três retângulos, cada um com uma seta que aponta para o nó Branch: "A (arbitrário)", "B (arbitrário)" e "C (booleano)". O nó Branch, por fim, possui uma seta que aponta para um retângulo de "Saída".
Um exemplo de nó Branch

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.

  1. Para criar os estados Chase e Patrol :

    1. Clique com o botão direito do mouse e escolha Estado para criar um novo nó.

    2. Nomeie o nó como Chase .

    3. Retorne ao menu suspenso, escolha Estado novamente para criar um segundo nó.

    4. Nomeie o nó Patrol .

    5. Arraste e solte as ações Chase e Patrol existentes para seus estados correspondentes recém-criados.

  2. Para criar a transição:

    1. Clique com o botão direito do mouse e escolha Transição para criar um novo nó.

    2. Atribua o objeto LineOfSightDecision ao campo Decision da transição.

  3. Para criar o nó RemainInState :

    1. Clique com o botão direito do mouse e escolha RemainInState para criar um novo nó.
  4. Para conectar o gráfico:

    1. Conecte a saída Transitions do nó Patrol à entrada Entry do nó Transition .

    2. Conecte a saída True State do nó Transition à entrada Entry do nó Chase .

    3. Conecte a saída False State do nó Transition à entrada Entry do nó Remain In State .

O gráfico deve ficar assim:

Quatro nós representados como quatro retângulos, cada um com círculos de entrada de entrada em seu lado superior esquerdo. Da esquerda para a direita, o nó do estado de patrulha exibe uma ação: Ação de patrulha. O nó Patrol state também inclui um círculo de saída Transitions em seu lado inferior direito que se conecta ao círculo Entry do nó Transition. O nó Transição exibe uma decisão: LineOfSight. Ele tem dois círculos de saída em seu lado inferior direito, True State e False State. True State se conecta ao círculo de entrada de nossa terceira estrutura, o nó de estado Chase. O nó do estado Chase exibe uma ação: Chase Action. O nó de estado Chase tem um círculo de saída Transitions. O segundo dos dois círculos de saída de Transition, False State, conecta-se ao círculo Entry de nossa quarta e última estrutura, o nó RemainInState (que aparece abaixo do nó Chase state).
A visão inicial do nosso gráfico FSM

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:

  1. Clique com o botão direito do mouse e escolha Nó inicial para criar um novo nó.
  2. Anexe a saída do Nó FSM à entrada de Entry do Nó Patrol .

O gráfico agora deve ficar assim:

O mesmo gráfico da imagem anterior, com um retângulo verde do Nó FSM adicionado à esquerda dos outros quatro retângulos. Ele tem uma saída do Nodo Inicial (representada por um círculo azul) que se conecta à entrada "Entrada" do nodo Patrulha (representada por um círculo vermelho escuro).
Nosso gráfico FSM com o nó inicial anexado ao estado da patrulha

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:

  1. Abra o ativo SampleScene.

  2. Localize nosso objeto de jogo Enemy na janela de hierarquia do Unity.

  3. Substitua o componente BaseStateMachine pelo componente BaseStateMachineGraph :

    1. Clique em Adicionar componente e selecione o script BaseStateMachineGraph correto.

    2. Atribua nosso gráfico FSM, EnemyGraph , ao campo Graph do componente BaseStateMachineGraph .

    3. 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:

De cima para baixo, na tela Inspetor, há uma marca de verificação ao lado de Inimigo. "Jogador" está selecionado no menu suspenso Tag, "Inimigo" está selecionado no menu suspenso Camada. A lista suspensa Transformar mostra a posição, a rotação e a escala. O menu suspenso Capsule é compactado e as listas suspensas Mesh Renderer, Capsule Collider e Nav Mesh Agent aparecem compactadas com uma marca de verificação à esquerda. A lista suspensa do Sensor de Visão do Inimigo mostra o Script e Ignore Mask. A lista suspensa PatrolPoints mostra o Script e quatro PatrolPoints. Há uma marca de seleção ao lado da lista suspensa Base State Machine Graph (Script). O script mostra "BaseStateMachineGraph", o estado inicial mostra "None (Base State) e o gráfico mostra "EnemyGraph (FSM Graph)". isto.
Nosso objeto de jogo Enemy

É 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.