Desenvolvimento de IA do Unity: um tutorial de máquina de estado finito

Publicados: 2022-03-11

No mundo competitivo dos jogos, os desenvolvedores se esforçam para oferecer uma experiência de usuário divertida para aqueles que interagem com os personagens não-jogadores (NPCs) que criamos. Os desenvolvedores podem oferecer essa interatividade usando máquinas de estado finito (FSMs) para criar soluções de IA que simulam inteligência em nossos NPCs.

As tendências de IA mudaram para árvores comportamentais, mas os FSMs continuam relevantes. Eles são incorporados – de uma forma ou de outra – em praticamente todos os jogos eletrônicos.

Anatomia de um FSM

Um FSM é um modelo de computação no qual apenas um de um número finito de estados hipotéticos pode estar ativo ao mesmo tempo. Um FSM transita de um estado para outro, respondendo a condições ou entradas. Seus componentes principais incluem:

Componente Descrição
Estado Um de um conjunto finito de opções indicando a condição geral atual de um FSM; qualquer estado inclui um conjunto associado de ações
Açao O que um estado faz quando o FSM o consulta
Decisão A lógica que estabelece quando uma transição ocorre
Transição O processo de mudança de estado

Embora nos concentremos em FSMs da perspectiva da implementação de IA, conceitos como máquinas de estado de animação e estados gerais de jogo também se enquadram no guarda-chuva do FSM.

Visualizando um FSM

Vamos considerar o exemplo do clássico jogo de arcade Pac-Man. No estado inicial do jogo (o estado de “perseguição”), os NPCs são fantasmas coloridos que perseguem e eventualmente ultrapassam o jogador. Os fantasmas transitam para o estado de evasão sempre que o jogador come uma bola de energia e experimenta um power-up, ganhando a capacidade de comer os fantasmas. Os fantasmas, agora de cor azul, evitam o jogador até que o power-up expire e os fantasmas voltem ao estado de perseguição, no qual seus comportamentos e cores originais são restaurados.

Um fantasma do Pac-Man está sempre em um dos dois estados: perseguir ou fugir. Naturalmente, devemos fornecer duas transições - uma de perseguição para evasão, a outra de evasão para perseguição:

Diagrama: À esquerda está o estado de perseguição. Uma seta (indicando que o jogador comeu o pellet de energia) leva ao estado de evasão à direita. Uma segunda seta (indicando que o pellet de energia expirou) leva de volta ao estado de perseguição à esquerda.
Transições entre estados fantasmas do Pac-Man

A máquina de estado finito, por design, consulta o estado atual, que consulta a(s) decisão(ões) e ação(ões) desse estado. O diagrama a seguir representa nosso exemplo de Pac-Man e mostra uma decisão que verifica o status do power-up do jogador. Se um power-up começou, os NPCs passam de perseguição para evasão. Se um power-up terminou, os NPCs passam de evadir para perseguir. Finalmente, se não houver mudança de energização, nenhuma transição ocorrerá.

Diagrama em forma de diamante representando um ciclo: Começando à esquerda, há um estado de perseguição que implica uma ação correspondente. O estado de perseguição então aponta para o topo, onde há uma decisão: Se o jogador comeu uma bola de energia, continuamos para o estado de evasão e a ação de evasão à direita. O estado de evasão aponta para uma decisão na parte inferior: Se o pellet de energia expirou, continuamos de volta ao nosso ponto de partida.
Componentes do Pac-Man Ghost FSM

Escalabilidade

FSMs nos libertam para construir IA modular. Por exemplo, com apenas uma nova ação, podemos criar um NPC com um novo comportamento. Assim, podemos atribuir uma nova ação – comer uma bola de energia – a um de nossos fantasmas do Pac-Man, dando a ele a capacidade de comer bolas de energia enquanto evita o jogador. Podemos reutilizar ações, decisões e transições existentes para dar suporte a esse comportamento.

Como os recursos necessários para desenvolver um NPC exclusivo são mínimos, estamos bem posicionados para atender aos requisitos de projeto em evolução de vários NPCs exclusivos. Por outro lado, um número excessivo de estados e transições pode nos enredar em uma máquina de estado de espaguete - um FSM cuja superabundância de conexões dificulta a depuração e a manutenção.

Implementando um FSM no Unity

Para demonstrar como implementar uma máquina de estado finito no Unity, vamos criar um jogo furtivo simples. Nossa arquitetura irá incorporar ScriptableObject , que são contêineres de dados que podem armazenar e compartilhar informações em toda a aplicação, para que não precisemos reproduzi-las. ScriptableObject são capazes de processamento limitado, como invocar ações e consultar decisões. Além da documentação oficial do Unity, a palestra mais antiga sobre Arquitetura de Jogos com Objetos Scriptable continua sendo um excelente recurso se você quiser se aprofundar.

Antes de adicionarmos IA a este projeto inicial pronto para compilar, considere a arquitetura proposta:

Diagrama: Sete caixas que se conectam umas às outras, descritas em ordem de aparição, da esquerda para cima: A caixa rotulada BaseStateMachine inclui + CurrentState: BaseState. BaseStateMachine se conecta a BaseState com uma seta bidirecional. A caixa rotulada BaseState inclui + Execute(BaseStateMachine): void. BaseState se conecta a BaseStateMachine com uma seta bidirecional. Setas monodirecionais de State e RemainInState se conectam a BaseState. A caixa rotulada Estado inclui + Execute(BaseStateMachine): void, + Ações: Lista<Ação> e + Transição: Lista<Transição>. State se conecta a BaseState com uma seta monodirecional, a Action com uma seta monodirecional rotulada "1" e a Transition com uma seta monodirecional rotulada "1". A caixa rotulada RemainInState inclui + Execute(BaseStateMachine): void. RemainInState se conecta a BaseState com uma seta monodirecional. A caixa rotulada Ação inclui + Execute(BaseStateMachine): void. Uma seta monodirecional rotulada como "1" de State conecta-se a Action. A caixa rotulada Transition inclui + Decide(BaseStateMachine): void, + TransitionDecision: Decisão, + TrueState: BaseState e + FalseState: BaseState. A transição se conecta à Decisão com uma seta monodirecional. Uma seta monodirecional rotulada como "1" do Estado conecta-se à Transição. A caixa rotulada Decisão inclui + Decide(BaseStateMachine): bool.
Arquitetura FSM proposta

Em nosso exemplo de jogo, o inimigo (um NPC representado por uma cápsula azul) patrulha. Quando o inimigo vê o jogador (representado por uma cápsula cinza), o inimigo começa a seguir o jogador:

Diagrama: Cinco caixas que se conectam umas às outras, descritas em ordem de aparição, da esquerda para cima: A caixa rotulada Patrulha se conecta à caixa rotulada SE o jogador estiver na linha de visão com uma seta monodirecional e à caixa rotulada Ação de patrulha com uma seta monodirecional rotulada como "estado". A caixa rotulada IF player está na linha de visão, com um rótulo adicional "decisão", logo abaixo da caixa. A caixa rotulada IF player está na linha de visão conecta-se à caixa rotulada Chase com uma seta monodirecional. Uma seta monodirecional da caixa rotulada Patrulha conecta-se à caixa rotulada SE o jogador estiver na linha de visão. A caixa rotulada Chase se conecta à caixa rotulada Chase Action com uma seta monodirecional rotulada como "estado". Uma seta monodirecional da caixa rotulada IF player está na linha de visão conecta-se à caixa rotulada Chase. Uma seta de seta monodirecional da caixa rotulada Patrol conecta-se à caixa rotulada Patrol Action. Uma seta de seta monodirecional da caixa Chase se conecta à caixa Chase Action.
Componentes principais do nosso exemplo de jogo furtivo FSM

Ao contrário do Pac-Man, o inimigo em nosso jogo não retornará ao estado padrão (“patrulha”) depois de seguir o jogador.

Criando aulas

Vamos começar criando nossas classes. Em uma nova pasta de scripts , adicionaremos todos os blocos de construção de arquitetura propostos como scripts C#.

Implementando a classe BaseStateMachine

A classe BaseStateMachine é o único MonoBehavior que adicionaremos para acessar nossos NPCs habilitados para IA. Para simplificar, nosso BaseStateMachine será básico. Se quiséssemos, no entanto, poderíamos adicionar um FSM personalizado herdado que armazena parâmetros adicionais e referências a componentes adicionais. Observe que o código não será compilado corretamente até que tenhamos adicionado nossa classe BaseState , o que faremos posteriormente em nosso tutorial.

O código para BaseStateMachine refere-se e executa o estado atual para realizar as ações e ver se uma transição é garantida:

 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 a classe BaseState

Nosso estado é do tipo BaseState , que derivamos de um ScriptableObject . BaseState inclui um único método, Execute , tomando BaseStateMachine como seu argumento e passando para ele ações e transições. É assim que o BaseState se parece:

 using UnityEngine; namespace Demo.FSM { public class BaseState : ScriptableObject { public virtual void Execute(BaseStateMachine machine) { } } }

Implementando as classes State e RemainInState

Agora derivamos duas classes de BaseState . Primeiro, temos a classe State , que armazena referências a ações e transições, inclui duas listas (uma para ações e outra para transições) e substitui e chama a base Execute em ações e transições:

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

Segundo, temos a classe RemainInState , que informa ao FSM quando não realizar uma transição:

 using UnityEngine; namespace Demo.FSM { [CreateAssetMenu(menuName = "FSM/Remain In State", fileName = "RemainInState")] public sealed class RemainInState : BaseState { } }

Observe que essas classes não serão compiladas até que tenhamos adicionado as FSMAction , Decision e Transition .

Implementando a classe FSMAction

No diagrama de arquitetura proposta do FSM, a classe base FSMAction é rotulada como “Ação”. No entanto, criaremos a classe base FSMAction e usaremos o nome FSMAction (já que Action já está em uso pelo namespace .NET System ).

FSMAction , um ScriptableObject , não pode processar funções independentemente, então vamos defini-lo como uma classe abstrata. À medida que nosso desenvolvimento progride, podemos exigir uma única ação para atender a mais de um estado. Felizmente, podemos associar FSMAction com quantos estados de quantos FSMs desejarmos.

A classe abstrata FSMAction se parece com isso:

 using UnityEngine; namespace Demo.FSM { public abstract class FSMAction : ScriptableObject { public abstract void Execute(BaseStateMachine stateMachine); } }

Implementando as Classes de Decision e Transition

Para finalizar nosso FSM, vamos definir mais duas classes. Primeiro, temos Decision , uma classe abstrata da qual todas as outras decisões definiriam seu comportamento personalizado:

 using UnityEngine; namespace Demo.FSM { public abstract class Decision : ScriptableObject { public abstract bool Decide(BaseStateMachine state); } }

A segunda classe, Transition , contém o objeto Decision e dois estados:

  • Um estado para o qual fazer a transição se a Decision for verdadeira.
  • Outro estado para fazer a transição se a Decision for falsa.

Se parece com isso:

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

Tudo o que construímos até este ponto deve ser compilado sem erros. Se você tiver problemas, verifique sua versão do Unity Editor, que pode causar erros se estiver desatualizada. Certifique-se de que todos os arquivos tenham sido clonados corretamente da pasta do projeto original e que todas as variáveis ​​acessadas publicamente não sejam declaradas privadas.

Criando ações e decisões personalizadas

Agora, com o trabalho pesado feito, estamos prontos para implementar ações e decisões personalizadas em uma nova pasta de scripts .

Implementando as classes Patrol e Chase

Quando analisamos os componentes principais do diagrama FSM de nosso exemplo de jogo furtivo, vemos que nosso NPC pode estar em um dos dois estados:

  1. Estado de patrulha — Associados ao estado estão:
    • Uma ação: NPC visita pontos de patrulha aleatórios ao redor do mundo.
    • Uma transição: NPC verifica se o jogador está à vista e, em caso afirmativo, transita para o estado de perseguição.
    • Uma decisão: NPC verifica se o jogador está à vista.
  2. Estado de perseguição — Associado ao estado está:
    • Uma ação: NPC persegue o jogador.

Podemos reutilizar nossa implementação de transição existente por meio da GUI do Unity, como discutiremos mais adiante. Isso deixa duas ações ( PatrolAction e ChaseAction ) e uma decisão para codificarmos.

A ação de estado de patrulha (que deriva da base FSMAction ) substitui o método Execute para obter dois componentes:

  1. PatrolPoints , que rastreia os pontos de patrulha.
  2. NavMeshAgent , implementação do Unity para navegação no espaço 3D.

A substituição verifica se o agente do AI atingiu seu destino e, em caso afirmativo, move-se para o próximo destino. Se parece com isso:

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

Podemos considerar armazenar em cache os componentes PatrolPoints e NavMeshAgent . O armazenamento em cache nos permitiria compartilhar ScriptableObject para ações entre agentes sem o impacto no desempenho de executar GetComponent em cada consulta da máquina de estado finito.

Para ser claro, não podemos armazenar em cache instâncias de componentes no método Execute . Então, em vez disso, adicionaremos um método GetComponent personalizado a BaseStateMachine . Nosso GetComponent personalizado armazenaria em cache a instância na primeira vez que ela fosse chamada, retornando a instância armazenada em cache em chamadas consecutivas. Para referência, esta é a implementação de BaseStateMachine com cache:

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

Assim como sua contraparte PatrolAction , a classe ChaseAction substitui o método Execute para obter os componentes PatrolPoints e NavMeshAgent . Em contraste, no entanto, após verificar se o agente de IA atingiu seu destino, a ação de classe ChaseAction define o destino como 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); } } }

Implementando a classe InLineOfSightDecision

A peça final é a classe InLineOfSightDecision , que herda a base Decision e obtém o componente EnemySightSensor para verificar se o jogador está na linha de visão do 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(); } } }

Anexando Comportamentos a Estados

Estamos finalmente prontos para anexar comportamentos ao agente Enemy . Eles são criados na janela Project do Unity Editor.

Adicionando os estados de Patrol e Chase

Vamos criar dois estados e nomeá-los “Patrulha” e “Perseguição”:

  • Clique com o botão direito > Criar > FSM > Estado

Enquanto estiver aqui, vamos também criar um objeto RemainInState :

  • Clique com o botão direito > Criar > FSM > Permanecer no estado

Agora, é hora de criar as ações que acabamos de codificar:

  • Clique com o botão direito > Criar > FSM > Ação > Patrulha
  • Clique com o botão direito > Criar > FSM > Ação > Chase

Para codificar a Decision :

  • Clique com o botão direito > Criar > FSM > Decisões > Na Linha de Visão

Para habilitar uma transição de PatrolState para ChaseState , vamos primeiro criar o objeto script de transição:

  • Clique com o botão direito > Criar > FSM > Transição
  • Escolha um nome que você goste. Chamei meu inimigo manchado.

Vamos preencher a janela do inspetor resultante da seguinte forma:

A tela Spotted Enemy (Transition) inclui quatro linhas: O valor do script é definido como "Transition" e está esmaecido. O valor de Decision é definido como "LineOfSightDecision (In Line Of Sight)". O valor de True State é definido como "ChaseState (State)". O valor de False State é definido como "RemainInState (Remain In State)".
Preenchendo a janela do Inspetor do Inimigo Manchado (Transição)

Em seguida, concluiremos a caixa de diálogo do inspetor de estado de perseguição da seguinte maneira:

A tela Chase State (State) começa com um rótulo "Abrir". Ao lado do rótulo "Script" "Estado" é selecionado. Ao lado do rótulo "Ação", "1" é selecionado. Na lista suspensa "Ação", "Elemento 0 Chase Action (Chase Action)" é selecionado. Há um sinal de mais e um sinal de menos que segue. Ao lado do rótulo "Transições", "0" é selecionado. Na lista suspensa "Transições", "Lista está vazia" é exibida. Há um sinal de mais e um sinal de menos que segue.
Preenchendo a janela Chase State Inspector

Em seguida, completaremos a caixa de diálogo Patrol State:

A tela Patrol State (State) começa com um rótulo "Abrir". Ao lado do rótulo "Script" "Estado" é selecionado. Ao lado do rótulo "Ação", "1" é selecionado. Na lista suspensa "Ação", "Ação de patrulha do elemento 0 (ação de patrulha)" é selecionada. Há um sinal de mais e menos que segue. Ao lado do rótulo "Transições", "1" é selecionado. Na lista suspensa "Transições", "Elemento 0 SpottedEnemy (Transição)" é exibido. Há um sinal de mais e um sinal de menos que segue.
Preenchendo a janela do Inspetor Estadual de Patrulha

Por fim, adicionaremos o componente BaseStateMachine ao objeto inimigo: Na janela Projeto do Unity Editor, abra o recurso SampleScene, selecione o objeto Enemy no painel Hierarquia e, na janela Inspetor, selecione Adicionar componente > Máquina de estado básico :

A tela Base State Machine (Script): Ao lado do rótulo "Script" acinzentado, "BaseStateMachine" está selecionado e esmaecido. Ao lado do rótulo "Initial State", "PatrolState (State)" é selecionado.
Adicionando o componente Base State Machine (Script)

Para quaisquer problemas, verifique novamente se os objetos do jogo estão configurados corretamente. Por exemplo, confirme se o objeto Enemy inclui o componente de script PatrolPoints e os objetos Point1 , Point2 , etc. Essas informações podem ser perdidas com a versão incorreta do editor.

Agora você está pronto para jogar o jogo de amostra e observar que o inimigo seguirá o jogador quando ele entrar na linha de visão do inimigo.

Usando FSMs para criar uma experiência de usuário divertida e interativa

Neste tutorial de máquina de estado finito, criamos uma IA altamente modular baseada em FSM (e repositório GitHub correspondente) que podemos reutilizar em projetos futuros. Graças a essa modularidade, sempre podemos adicionar energia à nossa IA introduzindo novos componentes.

Mas nossa arquitetura também abre caminho para o design FSM gráfico, o que elevaria nossa experiência de desenvolvedor a um novo nível de profissionalismo. Poderíamos então criar FSMs para nossos jogos mais rapidamente e com melhor precisão criativa.