Desenvolver uma base de código modular e flexível com o padrão de programação de estado
Ao implementar padrões comuns de design de programação de jogos em seu projeto Unity, você pode criar e manter com eficiência uma base de código limpa, organizada e legível. Os padrões de design não apenas reduzem a refatoração e o tempo gasto com testes, mas também aceleram os processos de integração e desenvolvimento, contribuindo para uma base sólida para o crescimento do jogo, da equipe de desenvolvimento e dos negócios.
Pense nos padrões de projeto não como soluções prontas que você pode copiar e colar em seu código, mas como ferramentas extras que podem ajudá-lo a criar aplicativos maiores e escalonáveis quando usados corretamente.
Esta página analisa o padrão State e como ele pode facilitar o gerenciamento de sua base de código.
O conteúdo aqui é baseado no livro eletrônico gratuito, Eleve o nível de seu código com padrões de programação de jogosque explica padrões de design bem conhecidos e compartilha exemplos práticos para usá-los em seu projeto Unity.
Outros artigos da série de padrões de design de programação de jogos Unity estão disponíveis no hub de práticas recomendadas do Unity ou clique nos links a seguir:
Imagine a construção de um personagem jogável. Em um momento, o personagem pode estar de pé no chão. Mova o controle e ele parecerá correr ou andar. Pressione o botão de salto e o personagem saltará no ar. Alguns quadros depois, ele aterrissa e retorna à sua posição ociosa, em pé.
A interatividade dos jogos de computador exige o rastreamento e o gerenciamento de muitos sistemas que mudam no tempo de execução. Se você desenhar um diagrama que represente os diferentes estados do seu personagem, poderá chegar a algo parecido com a imagem acima:
Ele se assemelha a um fluxograma, com algumas diferenças:
- Ele consiste em vários estados (Idling/Standing, Walking, Running, Jumping, etc.), e somente um estado atual está ativo em um determinado momento.
- Cada estado pode acionar uma transição para outro estado com base nas condições em tempo de execução.
- Quando ocorre uma transição, o estado de saída se torna o novo estado ativo.
Esse diagrama ilustra algo chamado de máquina de estado finito (FSM). No desenvolvimento de jogos, um caso de uso típico de um FSM é rastrear o estado interno de um objeto ou de um "ator do jogo", como o personagem jogável. Há muitos casos de uso para um FSM no desenvolvimento de jogos e, se você tem alguma experiência no desenvolvimento de um projeto no Unity, provavelmente já empregou um FSM no contexto das máquinas de estado de animação no Unity.
Um FSM é definido por uma lista de seus estados. Ele tem um estado inicial com condições para cada transição. Um FSM pode estar em exatamente um de um número finito de estados em um determinado momento, com a possibilidade de mudar de um estado para outro em resposta a entradas externas que resultam em uma transição.
O padrão de design State, por outro lado, define uma interface que representa um estado e uma classe que implementa essa interface para cada estado. O contexto, ou a classe, que precisa alterar seu comportamento com base no estado mantém uma referência ao objeto de estado atual. Quando o estado interno do contexto muda, ele simplesmente atualiza a referência ao objeto de estado para apontar para um objeto diferente, o que altera o comportamento do contexto.
O padrão State é semelhante ao FSM, pois também permite o gerenciamento de diferentes estados e a transição entre eles. Entretanto, um FSM é normalmente implementado usando uma instrução switch, enquanto o padrão de design State define uma interface que representa um estado e uma classe que implementa essa interface para cada estado.
O padrão de estado é amplamente usado no desenvolvimento de jogos e pode ser uma maneira eficaz de gerenciar os diferentes estados de um jogo, como um menu principal, um estado de jogabilidade e um estado de fim de jogo.
Vamos ver o padrão de estado em ação com o exemplo da seção a seguir.
Há um projeto de demonstração disponível no Github que fornece o código de exemplo nesta seção.
Uma maneira simplificada de descrever um FSM básico no código pode ser semelhante ao exemplo abaixo, que usa um enum e uma instrução switch.
Primeiro, você define um enum PlayerControllerState que consiste em três estados: Idle, Walk e Jump.
Em seguida, switch é usado como uma instrução condicional no loop de atualização para testar em que estado você está no momento. Dependendo do estado, você pode chamar as funções apropriadas para executar o comportamento específico que se aplica.
Isso pode funcionar, mas o script PlayerController pode ficar confuso rapidamente, principalmente porque você precisa formular as condições para a transição entre os estados. O uso de uma instrução switch para gerenciar o estado de um jogo com um script não é considerado a melhor prática, pois pode levar a um código complexo e de difícil manutenção. A declaração do switch pode se tornar grande e difícil de entender à medida que o número de estados e transições aumenta.
Além disso, isso dificulta a adição de novos estados ou transições, pois é necessário fazer alterações na instrução switch. O padrão State, por outro lado, permite um design mais modular e extensível, facilitando a adição de novos estados ou transições.
Vamos reimplementar o padrão de estado para reorganizar a lógica do PlayerController. Esse exemplo de código também está disponível no projeto de demonstração hospedado no Github.
De acordo com o Gang of Four original, o padrão de design de estado resolve dois problemas:
- Um objeto deve mudar seu comportamento quando seu estado interno for alterado.
- O comportamento específico do estado é definido de forma independente. A adição de novos estados não afeta o comportamento dos estados existentes.
No exemplo de código anterior, o UnrefactoredPlayerController pode rastrear alterações de estado, mas não satisfaz o segundo problema. Você deseja minimizar o impacto nos estados existentes ao adicionar novos estados. Em vez disso, você pode encapsular um estado como um objeto.
Imagine a estruturação de cada um dos estados em seu exemplo como o diagrama acima. Aqui, você entra no estado apropriado e faz um loop em cada quadro até que uma condição faça com que o fluxo de controle saia. Em outras palavras, você encapsula o estado específico com uma entrada, uma atualização e uma saída.
Para implementar o padrão acima, crie uma interface chamada IState. Cada estado concreto em seu jogo implementará a interface seguindo essa convenção:
- Uma entrada: Essa lógica é executada ao entrar no estado pela primeira vez.
- Atualizar: Essa lógica é executada a cada quadro (às vezes chamada de Execute ou Tick). Você pode segmentar ainda mais o método Update como o MonoBehaviour faz, usando um FixedUpdate para física, LateUpdate e assim por diante. Qualquer funcionalidade no Update é executada a cada quadro até que seja detectada uma condição que acione uma mudança de estado.
- Uma saída: O código aqui é executado antes de deixar o estado e fazer a transição para um novo estado.
Você precisará criar uma classe para cada estado que implemente IState. No projeto de exemplo, uma classe separada foi configurada para WalkState, IdleState e JumpState.
Outra classe, StateMachine.cs, gerenciará como o fluxo de controle entra e sai dos estados. Com os três estados de exemplo, a máquina de estado poderia se parecer com o exemplo de código abaixo.
Para seguir o padrão, a máquina de estado faz referência a um objeto público para cada estado sob seu gerenciamento (nesse caso, walkState, jumpState e idleState). Como a máquina de estado não herda do MonoBehaviour, use um construtor para configurar cada instância.
Você pode passar quaisquer parâmetros necessários para o construtor. No projeto de exemplo, um PlayerController é referenciado em cada estado. Em seguida, você usa isso para atualizar cada estado por quadro (veja o exemplo IdleState abaixo).
Observe o seguinte sobre o conceito de máquina de estado:
- O atributo Serializable permite que você exiba o StateMachine.cs (e seus campos públicos) no Inspector. Outro MonoBehaviour (por exemplo, um PlayerController ou EnemyController) pode então usar a máquina de estado como um campo.
- A propriedade CurrentState é somente de leitura. O próprio StateMachine.cs não define explicitamente esse campo. Um objeto externo, como o PlayerController, pode invocar o método Initialize para definir o estado padrão.
- Cada objeto de estado determina suas próprias condições para chamar o método TransitionTo para alterar o estado ativo no momento. Você pode passar todas as dependências necessárias (inclusive a própria máquina de estado) para cada estado ao configurar a instância do StateMachine.
No projeto de exemplo, o PlayerController já inclui uma referência ao StateMachine, portanto, você só passa um parâmetro de jogador.
Cada objeto de estado gerenciará sua própria lógica interna, e você pode criar quantos estados forem necessários para descrever seu GameObject ou componente. Cada um deles tem sua própria classe que implementa o IState. De acordo com os princípios SOLID, adicionar mais estados tem um impacto mínimo sobre os estados criados anteriormente.
Aqui está um exemplo do IdleState.
Semelhante ao script StateMachine.cs, o construtor é usado para passar o objeto PlayerController. Esse player contém uma referência à máquina de estado e tudo o mais necessário para a lógica de atualização. O IdleState monitora a velocidade ou o estado de salto do Character Controller e, em seguida, invoca o método TransitionTo da máquina de estado adequadamente.
Analise também o projeto de amostra para a implementação do WalkState e do JumpState. Em vez de ter uma grande classe que alterna o comportamento, cada estado tem sua própria lógica de atualização, permitindo que funcionem independentemente um do outro.
O padrão de estado pode ajudá-lo a aderir aos princípios SOLID ao configurar a lógica interna de um objeto. Cada estado é relativamente pequeno e registra apenas as condições de transição para outro estado. De acordo com o princípio aberto-fechado, é possível adicionar mais estados sem afetar os existentes e evitar declarações switch ou if complicadas em um script monolítico.
Você também pode expandir sua funcionalidade para comunicar alterações de estado a objetos externos. Talvez você queira adicionar eventos (consulte o padrão de observador). Ter um evento ao entrar ou sair de um estado pode notificar os ouvintes relevantes e fazer com que eles respondam em tempo de execução.
Por outro lado, se você tiver apenas alguns estados para rastrear, a estrutura extra pode ser um exagero. Esse padrão talvez só faça sentido se você espera que seus estados cresçam até uma certa complexidade. Como em qualquer outro padrão de design, você precisará avaliar os prós e os contras com base nas necessidades do seu jogo específico.
Recursos mais avançados para programação em Unity
O livro eletrônico Eleve o nível de seu código com padrões de programação de jogosfornece mais exemplos de como usar padrões de design no Unity.
Todos os e-books e artigos técnicos avançados do Unity estão disponíveis no hub de práticas recomendadas. Os e-books também estão disponíveis na página de práticas recomendadas avançadas na documentação.