Desenvolvimento de IA do Unity: um tutorial de máquina de estado finito
Publicados: 2022-03-11No 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:
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á.
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:
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:
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
Decisionfor verdadeira. - Outro estado para fazer a transição se a
Decisionfor 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:
- 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.
- 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:
-
PatrolPoints, que rastreia os pontos de patrulha. -
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:
Em seguida, concluiremos a caixa de diálogo do inspetor de estado de perseguição da seguinte maneira:
Em seguida, completaremos a caixa de diálogo Patrol State:
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 :
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.
