O que você obterá desta página: Descubra estratégias eficazes para arquitetar o código de um projeto em crescimento, para que escale ordenadamente e com menos problemas. À medida que o seu projeto cresce, será necessário modificar e limpar o design várias vezes. É sempre interessante deixar as alterações atuais de lado, separa as coisas em elementos menores para endireitá-los e, depois, unir tudo novamente.
Vejamos alguns exemplos de código de um jogo muito básico no estilo Pong que minha equipe criou para minha palestra na Unite Berlin. Como você pode ver na imagem acima, há duas pás e quatro paredes - na parte superior e inferior, e à esquerda e à direita -, alguma lógica de jogo e a interface de usuário de pontuação. Também há um script simples na raquete para as paredes.
Este exemplo se baseia em alguns princípios importantes:
- Uma “coisa” = um Prefab
- A lógica personalizada para uma “coisa” = um MonoBehavior
- Um aplicativo = uma cena que contém os Prefabs interligados
Esses princípios funcionam em um projeto muito simples como esse, mas teremos que mudar a estrutura se quisermos que cresça. Portanto, quais são as estratégias que podemos usar para organizar o código?
Em primeiro lugar, vamos esclarecer qualquer confusão sobre as diferenças entre instâncias, Prefabs e ScriptableObjects. Aqui está o componente Paddle no GameObject Paddle do Player 1, visualizado no Inspector:
Podemos ver que há três parâmetros nele. No entanto, nada nesta visualização indica o que o código subjacente espera de mim.
Faz sentido para mim alterar o eixo de entrada na raquete esquerda alterando-o na instância, ou eu deveria fazer isso no Prefab? Assumo que o eixo de entrada é diferente para ambos os jogadores, logo, provavelmente deveria ser alterado na instância. E a escala de velocidade do movimento? Isso é algo que eu deveria mudar na instância ou no Prefab?
Vejamos o código que representa o componente Paddle.
Se pararmos para pensar um instante, perceberemos que os diferentes parâmetros estão sendo usados de maneiras diferentes no nosso programa. Devemos alterar o InputAxisName individualmente para cada jogador: O MovementSpeedScaleFactor e o PositionScale devem ser compartilhados por ambos os jogadores. Aqui está uma estratégia que pode orientar você ao usar instâncias, Prefabs e ScriptableObjects:
- Você precisa de algo uma única vez? Crie um Prefab e, depois, instancie.
- Você precisa de algo várias vezes, possivelmente com algumas modificações específicas de instância? Então, crie um Prefab, instancie e substitua algumas configurações.
- Deseja garantir a mesma configuração em várias instâncias? Crie um ScriptableObject e extraia dados dele.
Veja como usamos ScriptableObjects com o componente Paddle no próximo exemplo de código.
Como movemos essas configurações para um ScriptableObject do tipo PaddleData, temos apenas uma referência a PaddleData no componente Paddle. No final, temos dois itens no Inspector: um PaddleData e duas instâncias Paddle. Você ainda pode alterar o nome do eixo e para qual pacote de configurações compartilhadas cada raquete aponta. A nova estrutura permite que você veja a intenção por trás das diferentes configurações com mais facilidade.
Se fosse um jogo realmente em desenvolvimento, você veria os MonoBehaviors individuais crescerem cada vez mais. Vamos ver como podemos dividi-los trabalhando com o chamado Princípio de responsabilidade única, que estipula que cada classe deve lidar com uma única coisa. Se aplicado corretamente, você deverá ser capaz de dar respostas curtas às perguntas "o que uma determinada classe faz?" e "o que ela não faz?". Isso facilita para todos os desenvolvedores da sua equipe entenderem o que as classes individuais fazem. É um princípio que você pode aplicar a uma base de código de qualquer tamanho. Vejamos um exemplo simples, conforme mostrado na imagem acima.
Ele mostra o código para uma bola. Não parece muito, mas analisando de forma mais detalhada, vemos que a bola tem uma velocidade que é usada pelo designer para definir o vetor de velocidade inicial da bola e pela simulação de física caseira para acompanhar a velocidade atual da bola.
Estamos reutilizando a mesma variável para dois fins ligeiramente diferentes. Assim que a bola começa a se movimentar, a informação sobre a velocidade inicial é perdida.
A simulação de física caseira não é apenas o movimento em FixedUpdate(), também engloba a reação quando a bola atinge uma parede.
No fundo do callback OnTriggerEnter() está uma operação Destroy(). É lá que a lógica de acionamento exclui seu próprio GameObject. Em bases de código grandes, é raro permitir que as entidades se excluam. A tendência é que os proprietários excluam suas próprias coisas.
Há uma oportunidade aqui para dividir as coisas em partes menores. Há diversos tipos diferentes de responsabilidades nessas classes — lógica de jogo, tratamento de entradas, simulações de física, apresentações e muito mais.
Aqui estão algumas maneiras de criar essas partes menores:
- A lógica de jogo geral, o tratamento de entradas, a simulação de física e a apresentação podem residir dentro de MonoBehaviors, ScriptableObjects ou classes brutas em C#.
- Para expor parâmetros no Inspector, pode-se usar MonoBehaviors ou ScriptableObjects.
- Handlers de eventos da engine e o gerenciamento da vida útil de um GameObject precisam residir dentro de MonoBehaviors.
Penso que para muitos jogos, vale a pena tirar o máximo de código possível dos MonoBehaviors. Um jeito de fazer isso é usando ScriptableObjects e já há alguns ótimos recursos disponíveis para esse método.
Mover MonoBehaviors para classes em C# comuns é outro método a ser considerado, mas quais são os benefícios disso?
Classes em C# comuns têm recursos de linguagem melhores do que os próprios objetos do Unity para decompor o código em blocos pequenos e combináveis. Além disso, código em C# comum pode ser compartilhado com bases de código em .NET fora do Unity.
Por outro lado, se você usar classes em C# comuns, o editor não entenderá os objetos e não conseguirá exibi-los de maneira nativa no Inspector, e assim por diante.
Com esse método, a ideia é dividir a lógica por tipo de responsabilidade. Se voltarmos para o exemplo da bola, movemos a simulação de física simples para uma classe em C# chamada BallSimulation. O único trabalho que precisa fazer é integrar a física e reagir sempre que a bola atingir algo.
No entanto, faz sentido para uma simulação de bola tomar decisões baseadas no que ela atinge? Isso soa mais como lógica de jogo. No final, Ball tem uma parte lógica que controla a simulação de certo modo e o resultado dessa simulação é retroalimentado no MonoBehavior.
Se olharmos a versão reorganizada acima, uma mudança significativa é que a operação Destroy() não está mais enterrada várias camadas abaixo. Há somente algumas áreas claras de responsabilidade que permanecem no MoneBehavior.
Podemos fazer mais coisas com isso. Se você observar a lógica de atualização da posição em FixedUpdate(), verá que o código precisa enviar uma posição e, depois, retorna uma nova posição de lá. A simulação da bola não é dona da localização da bola, ela executa um ponto de simulação com base na localização fornecida da bola e, depois, retorna o resultado.
Se usarmos interfaces, talvez seja possível compartilhar uma parte do MonoBehavior da bola com a simulação, apenas as partes necessárias (veja a imagem acima).
Vejamos o código novamente. A classe Ball implementa uma interface simples. A classe LocalPositionAdapter possibilita a entrega de uma referência ao objeto Ball para outra classe. Não entregamos o objeto Ball completo, somente o aspecto LocalPositionAdapter dele.
BallLogic também precisa informar Ball sobre o momento de destruir o GameObject. Em vez de retornar uma flag, Ball pode fornecer uma autorização para BallLogic. É isso que a última linha marcada na versão reorganizada faz. Isso oferece um design organizado: há muita lógica de boilerplate, mas cada classe tem uma finalidade muito bem definida.
Usando esses princípios você pode manter um projeto de uma pessoa bem estruturado.
Vejamos soluções de arquitetura de software para projetos um pouco maiores. Se usarmos o exemplo do jogo Ball, ao introduzir mais classes específicas no código — BallLogic, BallSimulation, etc. — poderemos construir um hierarquia:
A classe MonoBehaviours precisa conhecer todo o restante, pois ela encerra todas as outras lógicas, mas os pedaços de simulação do jogo não precisam saber como a lógica funciona. Eles apenas executam uma simulação. Às vezes, a lógica fornece sinais à simulação, e esta reage de acordo.
É benéfico lidar com a entrada em um local separado e autônomo. É lá que os eventos de entrada são gerados e, depois, inseridos na lógica. O que acontece depois depende da simulação.
Isso funciona bem para entrada e simulação. No entanto, você provavelmente terá problemas com tudo aquilo que for relacionado a apresentação, por exemplo, lógica que gera efeitos especiais, atualização dos contadores de pontuação e assim por diante.
A apresentação precisa saber o que está acontecendo nos outros sistemas, mas não precisa ter acesso completo a todos esses sistemas. Se possível, tente separar a lógica e a apresentação. Tente chegar ao ponto em que executa a base de código em dois modos: somente lógica e lógica mais apresentação.
Às vezes, será necessário conectar lógica e apresentação, para que a apresentação seja atualizada nos tempos certos. Ainda assim, o objetivo deve ser apenas fornecer a apresentação com o que precisa para exibir corretamente, e nada mais. Dessa forma, você obterá um limite natural entre as duas partes, o que reduzirá a complexidade geral do jogo que está desenvolvendo.
Às vezes, não há problemas em ter uma classe que contém somente dados, sem incorporar toda a lógica e as operações que podem ser feitas com esses dados na mesma classe.
Também pode ser uma boa ideia criar classes sem dados, mas que contêm funções cuja finalidade é manipular objetos recebidos.
O interessante sobre um método estático é que se você assumir que ele não afeta variáveis globais, é possível identificar o escopo daquilo que o método pode afetar apenas observando o que é transmitido na forma de argumentos ao chamar o método. Você não precisar olhar para a implementação do método.
Essa abordagem entra no aspecto de programação funcional. O principal bloco de construção é: você envia algo para uma função e a função retorna um resultado ou talvez modifique um dos parâmetros de saída. Tente essa abordagem, talvez você descubra que obtém menos bugs em relação à programação orientada a objetos clássica.
Você também pode separar objetos inserindo lógica de cola entre eles. Utilizando o exemplo de jogo no estilo Pong novamente: como a lógica Ball e a apresentação Score conversarão entre si? A lógica da bola informará a apresentação da pontuação quando algo acontecer com a bola? A lógica da pontuação consultará a lógica Ball? De algum jeito precisarão conversar entre si.
Você pode criar um objeto de buffer cuja única finalidade é fornecer área de armazenamento em que a lógica pode gravar coisas e a apresentação pode ler coisas. Ou, você pode colocar uma fila entre elas, para que o sistema de lógica possa colocar as coisas na fila, que serão lidas pela apresentação.
Uma boa maneira de separar lógica e apresentação à medida que o jogo cresce é com um barramento de mensagens. O princípio central das mensagens é que nem um recebedor, nem um emissor, têm conhecimento um do outro, mas ambos conhecem o barramento/sistema de mensagens. Portanto, uma apresentação de pontuação precisa que o sistema de mensagens informe quaisquer eventos de alteração na pontuação. Depois, a lógica de jogo publicará eventos no sistema de mensagens que indicam uma mudança nos pontos de um jogador. Um bom ponto de partida se você quiser separar os sistemas é usar UnityEvents — ou escreva o seu próprio. Depois, você pode ter barramentos separados para fins diferentes.
Pare de usar LoadSceneMode.Single e use LoadSceneMode.Additive no lugar.
Use descarregamentos explícitos quando quiser descarregar uma cena — mais cedo ou mais tarde você precisará manter alguns objetos vivos durante a transição da cena.
Pare de usar DontDestroyOnLoad também. Faz com que você perca o controle sobre a vida útil de um objeto. Na verdade, se você estiver carregando coisas com LoadSceneMode.Additive, não precisará usar DontDestroyOnLoad. Em vez disso, coloque os objetos de vida longa em uma cena especial de vida longa.
Outra dica que tem sido útil para todos os jogos com os quais trabalhei foi oferecer suporte a um encerramento limpo e controlado.
Faça com que o aplicativo seja capaz de liberar praticamente todos os recursos antes de sair do aplicativo. Se possível, nenhuma variável global deve permanecer atribuída e nenhum GameObject deve estar marcado com DontDestroyOnLoad.
Quando você tem uma ordem específica para encerrar as coisas, fica mais fácil identificar erros e encontrar vazamentos de recursos. Isso também deixará o Unity Editor em um bom estado ao sair do modo Play. O Unity não realiza um recarregamento completo do domínio ao sair do modo Play. Se você tiver um encerramento limpo, será menos provável que o editor ou qualquer tipo de scripts de modo de edição demonstrem comportamentos estranhos após a execução do jogo no editor.
Você pode fazer isso usando um sistema de controle de versão, como Git, Perforce ou Plastic. Armazene todos os assets como texto e mova os objetos para fora dos arquivos de cena, transformando-os em Prefabs. Por fim, divida os arquivos de cena em várias cenas menores, mas saiba que isso poderá exigir ferramentas adicionais.
Se, em breve, você fará parte de uma equipe de, digamos, 10 ou mais pessoas, precisará trabalhar com automação de processos.
Como programador criativo você quer fazer o trabalho único e cuidadoso, e deixar o máximo possível das partes repetitivas para a automação.
Comece escrevendo testes para o código. Especificamente, se você estiver tirando coisas de MonoBehaviours e colocando em classes regulares, é muito fácil usar uma estrutura de testes de unidade para lógica e simulação. Não faz sentido em todo lugar, mas costuma tornar o código acessível a outros programas mais adiante.
Os testes não se limitam ao código. Você também deve testar o conteúdo. Se tiver criadores de conteúdo na equipe, será melhor para todos se eles tiverem uma maneira padronizada para validar o conteúdo criado rapidamente.
Lógica de teste — como a validação de um Prefab ou a validação de alguns dados recebidos por meio de um editor personalizado — deve estar naturalmente disponível aos criadores de conteúdo. Se puderem simplesmente clicar em um botão no editor e obter uma validação rápida, logo perceberão o tempo economizado.
O próximo passo é configurar o Test Runner do Unity para realizar novos testes automáticos dos itens regularmente. Configure-o como parte do sistema de compilação, para que também execute todos os testes. Uma prática recomendada é configurar notificações de modo que, quando ocorrer um problema, seus colegas recebam uma notificação no Slack ou por e-mail.
As jogadas automatizadas envolvem a criação de uma IA que possa jogar o seu jogo e registrar os erros. De maneira simplificada, qualquer erro encontrado pela IA é um a menos para você perder tempo!
No nosso caso, configuramos dez clientes de jogo na mesma máquina, com as configurações de detalhes mais baixas e deixamos todos serem executados. Observamos eles travarem e, depois, analisamos os logs. Cada travamento desses clientes era tempo economizado para nós, já que não precisamos jogar nós mesmos, nem pedir para alguém jogar, com o intuito de encontrar bugs. Isso significou que, quando de fato testamos a jogabilidade nós mesmos e com outros jogadores, pudemos nos concentrar na avaliação da diversão do jogo, na localização de glitches visuais e assim por diante.