Unity com MVC: como elevar o nível do seu desenvolvimento de jogos

Publicados: 2022-03-11

Os programadores de primeira viagem geralmente começam a aprender o ofício com o programa clássico Hello World . A partir daí, tarefas cada vez maiores são obrigadas a seguir. Cada novo desafio leva para casa uma lição importante:

Quanto maior o projeto, maior o espaguete.

Logo, é fácil ver que em equipes grandes ou pequenas, não se pode fazer o que se quer de forma imprudente. O código deve ser mantido e pode durar muito tempo. As empresas para as quais você trabalhou não podem simplesmente procurar suas informações de contato e perguntar sempre que quiserem corrigir ou melhorar a base de código (e você também não quer).

É por isso que existem padrões de projeto de software; eles impõem regras simples para ditar a estrutura geral de um projeto de software. Eles ajudam um ou mais programadores a separar as partes principais de um grande projeto e organizá-las de maneira padronizada, eliminando confusão quando alguma parte desconhecida da base de código é encontrada.

Essas regras, quando seguidas por todos, permitem que o código legado seja melhor mantido e navegado, e que o novo código seja adicionado mais rapidamente. Menos tempo é gasto planejando a metodologia de desenvolvimento. Como os problemas não vêm em um único sabor, não há um padrão de projeto de bala de prata. Deve-se considerar cuidadosamente os pontos fortes e fracos de cada padrão e encontrar o melhor ajuste para o desafio em mãos.

Neste tutorial, relatarei minha experiência com a popular plataforma de desenvolvimento de jogos Unity e o padrão Model-View-Controller (MVC) para desenvolvimento de jogos. Em meus sete anos de desenvolvimento, tendo lutado com meu quinhão de espaguete de desenvolvimento de jogos, tenho alcançado uma ótima estrutura de código e velocidade de desenvolvimento usando esse padrão de design.

Vou começar explicando um pouco da arquitetura base do Unity, o padrão Entity-Component. Em seguida, explicarei como o MVC se encaixa nele e usarei um pequeno projeto simulado como exemplo.

Motivação

Na literatura de software, encontraremos um grande número de padrões de projeto. Mesmo que tenham um conjunto de regras, os desenvolvedores geralmente fazem algumas mudanças de regras para melhor adaptar o padrão ao seu problema específico.

Essa “liberdade de programação” é a prova de que ainda não encontramos um método único e definitivo para projetar software. Assim, este artigo não pretende ser a solução definitiva para o seu problema, mas sim mostrar os benefícios e possibilidades de dois padrões bem conhecidos: Entity-Component e Model-View-Controller.

O padrão de componente de entidade

Entidade-Componente (EC) é um padrão de projeto onde primeiro definimos a hierarquia dos elementos que compõem a aplicação (Entidades) e, posteriormente, definimos os recursos e dados que cada um conterá (Componentes). Em termos mais “programadores”, uma Entidade pode ser um objeto com um array de 0 ou mais Componentes. Vamos representar uma Entidade assim:

 some-entity [component0, component1, ...]

Aqui está um exemplo simples de uma árvore EC.

 - app [Application] - game [Game] - player [KeyboardInput, Renderer] - enemies - spider [SpiderAI, Renderer] - ogre [OgreAI, Renderer] - ui [UI] - hud [HUD, MouseInput, Renderer] - pause-menu [PauseMenu, MouseInput, Renderer] - victory-modal [VictoryModal, MouseInput, Renderer] - defeat-modal [DefeatModal, MouseInput, Renderer]

EC é um bom padrão para aliviar os problemas de herança múltipla, onde uma estrutura de classe complexa pode introduzir problemas como o problema do diamante onde uma classe D, herdando duas classes, B e C, com a mesma classe base A, pode introduzir conflitos porque como B e C modificam as características de A de forma diferente.

IMAGEM: PROBLEMA DO DIAMANTE

Esses tipos de problemas podem ser comuns no desenvolvimento de jogos, onde a herança é frequentemente usada extensivamente.

Ao dividir os recursos e manipuladores de dados em Componentes menores, eles podem ser anexados e reutilizados em diferentes Entities sem depender de heranças múltiplas (o que, aliás, nem é um recurso de C# ou Javascript, as principais linguagens usadas pelo Unity ).

Onde o componente da entidade fica aquém

Estando um nível acima do OOP, o EC ajuda a desfragmentar e organizar melhor sua arquitetura de código. No entanto, em grandes projetos, ainda somos “muito livres” e podemos nos encontrar em um “oceano de recursos”, tendo dificuldade em encontrar as Entidades e Componentes certos ou descobrir como eles devem interagir. Existem infinitas maneiras de montar Entidades e Componentes para uma determinada tarefa.

IMAGEM: EC CARACTERÍSTICA OCEANO

Uma maneira de evitar confusão é impor algumas diretrizes adicionais sobre o Entity-Component. Por exemplo, uma maneira que gosto de pensar sobre software é dividi-lo em três categorias diferentes:

  • Alguns manipulam os dados brutos, permitindo que sejam criados, lidos, atualizados, excluídos ou pesquisados ​​(ou seja, o conceito CRUD).
  • Outros implementam a interface para outros elementos interagirem, detectando eventos relacionados ao seu escopo e acionando notificações quando eles ocorrem.
  • Por fim, alguns elementos são responsáveis ​​por receber essas notificações, tomar decisões de lógica de negócios e decidir como os dados devem ser manipulados.

Felizmente, já temos um padrão que se comporta exatamente dessa maneira.

O padrão Model-View-Controller (MVC)

O padrão Model-View-Controller (MVC) divide o software em três componentes principais: Modelos (Dados CRUD), Visualizações (Interface/Detecção) e Controladores (Decisão/Ação). O MVC é flexível o suficiente para ser implementado mesmo em cima do ECS ou OOP.

O desenvolvimento de jogos e interface do usuário tem o fluxo de trabalho usual de aguardar a entrada de um usuário ou outra condição de acionamento, enviar notificação desses eventos em algum lugar apropriado, decidir o que fazer em resposta e atualizar os dados de acordo. Essas ações mostram claramente a compatibilidade desses aplicativos com o MVC.

Essa metodologia introduz outra camada de abstração que ajudará no planejamento do software, além de permitir que novos programadores naveguem mesmo em uma base de código maior. Ao dividir o processo de pensamento em dados, interface e decisões, os desenvolvedores podem reduzir o número de arquivos de origem que devem ser pesquisados ​​para adicionar ou corrigir funcionalidades.

Unidade e EC

Vamos primeiro dar uma olhada no que o Unity nos dá de antemão.

Unity é uma plataforma de desenvolvimento baseada em EC, onde todas as Entities são instâncias de GameObject e os recursos que as tornam “visíveis”, “móveis”, “interacionáveis” e assim por diante, são fornecidos por classes que estendem Component .

O painel de hierarquia e o painel de inspeção do editor Unity fornecem uma maneira poderosa de montar seu aplicativo, anexar componentes, configurar seu estado inicial e inicializar seu jogo com muito menos código-fonte do que normalmente.

CAPTURA DE TELA: PAINEL DE HIERARQUIA
Painel de hierarquia com quatro GameObjects à direita

CAPTURA DE TELA: PAINEL DE INSPETOR
Painel Inspetor com os componentes de um GameObject

Ainda assim, como já discutimos, podemos nos deparar com o problema de “muitos recursos” e nos encontrar em uma hierarquia gigantesca, com recursos espalhados por toda parte, dificultando muito a vida de um desenvolvedor.

Pensando no modo MVC, podemos, em vez disso, começar dividindo as coisas de acordo com sua função, estruturando nossa aplicação conforme o exemplo abaixo:

CAPTURA DE TELA: ESTRUTURA DE EXEMPLO DE UNITY MVC

Adaptando o MVC a um ambiente de desenvolvimento de jogos

Agora, gostaria de apresentar duas pequenas modificações no padrão MVC genérico, que ajudam a adaptá-lo a situações únicas que encontrei construindo projetos Unity com MVC:

  1. As referências de classe MVC ficam facilmente espalhadas por todo o código. - Dentro do Unity, os desenvolvedores normalmente devem arrastar e soltar instâncias para torná-las acessíveis, ou então alcançá-las por meio de instruções find complicadas como GetComponent( ... ) . - O inferno de referência perdida acontecerá se o Unity travar ou algum bug fizer com que todas as referências arrastadas desapareçam. - Isso torna necessário ter um único objeto de referência raiz, por meio do qual todas as instâncias do Aplicativo podem ser acessadas e recuperadas.
  2. Alguns elementos encapsulam funcionalidades gerais que devem ser altamente reutilizáveis ​​e que não se enquadram naturalmente em uma das três categorias principais de Modelo, Visualização ou Controlador. Esses eu gosto de chamar simplesmente de Componentes . Eles também são “Componentes” no sentido de Entidade-Componente, mas meramente atuam como auxiliares no framework MVC. - Por exemplo, um Componente Rotator , que apenas gira as coisas por uma determinada velocidade angular e não notifica, armazena ou decide nada.

Para ajudar a aliviar esses dois problemas, criei um padrão modificado que chamo de AMVCC ou Application-Model-View-Controller-Component.

IMAGEM: DIAGRAMA AMVCC

  • Aplicativo - Ponto de entrada único para seu aplicativo e contêiner de todas as instâncias críticas e dados relacionados ao aplicativo.
  • MVC - Você já deve saber disso. :)
  • Componente - Script pequeno e bem contido que pode ser reutilizado.

Essas duas modificações satisfizeram minhas necessidades para todos os projetos em que as usei.

Exemplo: 10 Saltos

Como um exemplo simples, vejamos um pequeno jogo chamado 10 Bounces , onde farei uso dos elementos centrais do padrão AMVCC.

A configuração do jogo é simples: Uma Ball com um SphereCollider e um Rigidbody (que começará a cair após “Play”), um Cube como chão e 5 scripts para compor o AMVCC.

Hierarquia

Antes do script, geralmente começo na hierarquia e crio um esboço da minha classe e ativos. Sempre seguindo esse novo estilo AMVCC.

CAPTURA DE TELA: CONSTRUINDO A HIERARQUIA

Como podemos ver, a view GameObject contém todos os elementos visuais e também aqueles com outros scripts de View . O model e controller GameObjects, para projetos pequenos, geralmente contém apenas seus respectivos scripts. Para projetos maiores, eles conterão GameObjects com scripts mais específicos.

Quando alguém navegando em seu projeto deseja acessar:

  • Dados: Vá para application > model > ...
  • Lógica/fluxo de trabalho: Vá para application > controller > ...
  • Renderização/Interface/Detecção: Vá para application > view > ...

Se todas as equipes seguirem essas regras simples, os projetos legados não devem se tornar um problema.

Observe que não há contêiner de Component porque, como discutimos, eles são mais flexíveis e podem ser anexados a diferentes elementos à vontade do desenvolvedor.

Script

Nota: Os scripts mostrados abaixo são versões abstratas de implementações do mundo real. Uma implementação detalhada não beneficiaria muito o leitor. No entanto, se você quiser explorar mais, aqui está o link para minha estrutura MVC pessoal para Unity, Unity MVC. Você encontrará classes principais que implementam a estrutura estrutural AMVCC necessária para a maioria dos aplicativos.

Vamos dar uma olhada na estrutura dos scripts para 10 Bounces .

Antes de começar, para aqueles que não estão familiarizados com o fluxo de trabalho do Unity, vamos esclarecer brevemente como os scripts e os GameObjects funcionam juntos. No Unity, “Components”, no sentido de Entity-Component, são representados pela classe MonoBehaviour . Para que um exista durante o tempo de execução, o desenvolvedor deve arrastar e soltar seu arquivo de origem em um GameObject (que é a “Entidade” do padrão Entity-Component) ou usar o comando AddComponent<YourMonobehaviour>() . Após isso, o script será instanciado e estará pronto para uso durante a execução.

Para começar, definimos a classe Application (o “A” em AMVCC), que será a classe principal contendo referências a todos os elementos do jogo instanciados. Também criaremos uma classe base auxiliar chamada Element , que nos dá acesso à instância do Application e às instâncias MVC de seus filhos.

Com isso em mente, vamos definir a classe Application (o “A” em AMVCC), que terá uma instância única. Dentro dele, três variáveis, model , view e controller , nos darão pontos de acesso para todas as instâncias MVC durante o tempo de execução. Essas variáveis ​​devem ser MonoBehaviour s com referências public aos scripts desejados.

Em seguida, também criaremos uma classe base auxiliar chamada Element , que nos dará acesso à instância do Application. Este acesso permitirá que todas as classes MVC alcancem todas as outras.

Observe que ambas as classes estendem MonoBehaviour . São “Componentes” que serão anexados ao GameObject “Entidades”.

 // BounceApplication.cs // Base class for all elements in this application. public class BounceElement : MonoBehaviour { // Gives access to the application and all instances. public BounceApplication app { get { return GameObject.FindObjectOfType<BounceApplication>(); }} } // 10 Bounces Entry Point. public class BounceApplication : MonoBehaviour { // Reference to the root instances of the MVC. public BounceModel model; public BounceView view; public BounceController controller; // Init things here void Start() { } }

A partir do BounceElement podemos criar as classes principais do MVC. Os BounceModel , BounceView e BounceController geralmente atuam como contêineres para instâncias mais especializadas, mas como este é um exemplo simples, apenas a View terá uma estrutura aninhada. O Model e o Controller podem ser feitos em um script para cada:

 // BounceModel.cs // Contains all data related to the app. public class BounceModel : BounceElement { // Data public int bounces; public int winCondition; }
 // BounceView .cs // Contains all views related to the app. public class BounceView : BounceElement { // Reference to the ball public BallView ball; }
 // BallView.cs // Describes the Ball view and its features. public class BallView : BounceElement { // Only this is necessary. Physics is doing the rest of work. // Callback called upon collision. void OnCollisionEnter() { app.controller.OnBallGroundHit(); } }
 // BounceController.cs // Controls the app workflow. public class BounceController : BounceElement { // Handles the ball hit event public void OnBallGroundHit() { app.model.bounces++; Debug.Log(“Bounce ”+app.model.bounce); if(app.model.bounces >= app.model.winCondition) { app.view.ball.enabled = false; app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball OnGameComplete(); } } // Handles the win condition public void OnGameComplete() { Debug.Log(“Victory!!”); } }

Com todos os scripts criados, podemos prosseguir para anexá-los e configurá-los.

O layout da hierarquia deve ser assim:

 - application [BounceApplication] - model [BounceModel] - controller [BounceController] - view [BounceView] - ... - ball [BallView] - ...

Usando o BounceModel como exemplo, podemos ver como fica no editor do Unity:

CAPTURA DE TELA: BounceModel NO INSPECTOR
BounceModel com os campos bounces e winCondition .

Com todos os scripts definidos e o jogo rodando, devemos obter esta saída no Painel do Console .

CAPTURA DE TELA: SAÍDA DO CONSOLE

Notificações

Conforme mostrado no exemplo acima, quando a bola atinge o solo sua view executa app.controller.OnBallGroundHit() que é um método. Não é, de forma alguma, “errado” fazer isso para todas as notificações no aplicativo. No entanto, na minha experiência, obtive melhores resultados usando um sistema de notificação simples implementado na classe AMVCC Application.

Para implementar isso, vamos atualizar o layout do BounceApplication para:

 // BounceApplication.cs class BounceApplication { // Iterates all Controllers and delegates the notification data // This method can easily be found because every class is “BounceElement” and has an “app” // instance. public void Notify(string p_event_path, Object p_target, params object[] p_data) { BounceController[] controller_list = GetAllControllers(); foreach(BounceController c in controller_list) { c.OnNotification(p_event_path,p_target,p_data); } } // Fetches all scene Controllers. public BounceController[] GetAllControllers() { /* ... */ } }

Em seguida, precisamos de um novo script onde todos os desenvolvedores adicionarão os nomes dos eventos de notificação, que podem ser despachados durante a execução.

 // BounceNotifications.cs // This class will give static access to the events strings. class BounceNotification { static public string BallHitGround = “ball.hit.ground”; static public string GameComplete = “game.complete”; /* ... */ static public string GameStart = “game.start”; static public string SceneLoad = “scene.load”; /* ... */ }

É fácil ver que, dessa forma, a legibilidade do código é melhorada, pois os desenvolvedores não precisam pesquisar em todo o código-fonte os métodos controller.OnSomethingComplexName para entender que tipo de ações podem ocorrer durante a execução. Verificando apenas um arquivo, é possível entender o comportamento geral da aplicação.

Agora, precisamos apenas adaptar o BallView e o BounceController para lidar com esse novo sistema.

 // BallView.cs // Describes the Ball view and its features. public class BallView : BounceElement { // Only this is necessary. Physics is doing the rest of work. // Callback called upon collision. void OnCollisionEnter() { app.Notify(BounceNotification.BallHitGround,this); } }
 // BounceController.cs // Controls the app workflow. public class BounceController : BounceElement { // Handles the ball hit event public void OnNotification(string p_event_path,Object p_target,params object[] p_data) { switch(p_event_path) { case BounceNotification.BallHitGround: app.model.bounces++; Debug.Log(“Bounce ”+app.model.bounce); if(app.model.bounces >= app.model.winCondition) { app.view.ball.enabled = false; app.view.ball.GetComponent<RigidBody>().isKinematic=true; // stops the ball // Notify itself and other controllers possibly interested in the event app.Notify(BounceNotification.GameComplete,this); } break; case BounceNotification.GameComplete: Debug.Log(“Victory!!”); break; } } }

Projetos maiores terão muitas notificações. Portanto, para evitar uma grande estrutura de switch-case, é aconselhável criar diferentes controladores e fazê-los lidar com diferentes escopos de notificação.

AMVCC no mundo real

Este exemplo mostrou um caso de uso simples para o padrão AMVCC. Ajustar sua maneira de pensar em termos dos três elementos do MVC e aprender a visualizar as entidades como uma hierarquia ordenada são as habilidades que devem ser aprimoradas.

Em projetos maiores, os desenvolvedores se depararão com cenários mais complexos e dúvidas sobre se algo deve ser uma View ou um Controller, ou se uma determinada classe deve ser separada mais detalhadamente em outras menores.

Regras Práticas (por Eduardo)

Não existe nenhum “Guia Universal para classificação MVC” em nenhum lugar. Mas existem algumas regras simples que normalmente sigo para me ajudar a determinar se devo definir algo como Model, View ou Controller e também quando dividir uma determinada classe em partes menores.

Normalmente, isso acontece organicamente enquanto penso na arquitetura do software ou durante o script.

Classificação de classe

Modelos

  • Mantenha os dados principais e o estado do aplicativo, como health do jogador ou ammo de arma .
  • Serialize, desserialize e/ou converta entre tipos.
  • Carregar/salvar dados (localmente ou na web).
  • Notificar os controladores sobre o andamento das operações.
  • Armazene o estado do jogo para a máquina de estados finitos do jogo.
  • Nunca acesse Visualizações.

Visualizações

  • Pode obter dados de modelos para representar o estado atualizado do jogo para o usuário. Por exemplo, um método View player.Run() pode usar model.speed internamente para manifestar as habilidades do jogador.
  • Nunca deve alterar Models.
  • Implementa estritamente as funcionalidades de sua classe. Por exemplo:
    • Um PlayerView não deve implementar a detecção de entrada ou modificar o Game State.
    • Uma View deve atuar como uma caixa preta que possui uma interface e notifica sobre eventos importantes.
    • Não armazena dados principais (como velocidade, saúde, vidas,…).

Controladores

  • Não armazene dados principais.
  • Às vezes, pode filtrar notificações de visualizações indesejadas.
  • Atualize e use os dados do modelo.
  • Gerencia o fluxo de trabalho de cena do Unity.

Hierarquia de Classe

Neste caso, não há muitos passos que eu sigo. Normalmente, percebo que alguma classe precisa ser dividida quando as variáveis ​​começam a mostrar muitos “prefixos”, ou muitas variantes do mesmo elemento começam a aparecer (como classes de Player em um MMO ou tipos de Gun em um FPS).

Por exemplo, um único Model contendo os dados do Player teria muitos playerDataA, playerDataB, playerDataA, playerDataB,... ou um Controller manipulando as notificações do Player teria OnPlayerDidA,OnPlayerDidB,... . Queremos reduzir o tamanho do script e nos livrar dos prefixos player e OnPlayer .

Deixe-me demonstrar usando uma classe Model porque é mais simples de entender usando apenas dados.

Durante a programação, geralmente começo com uma única classe Model contendo todos os dados do jogo.

 // Model.cs class Model { public float playerHealth; public int playerLives; public GameObject playerGunPrefabA; public int playerGunAmmoA; public GameObject playerGunPrefabB; public int playerGunAmmoB; // Ops Gun[CDE ...] will appear... /* ... */ public float gameSpeed; public int gameLevel; }

É fácil ver que quanto mais complexo for o jogo, mais variáveis ​​se tornarão. Com complexidade suficiente, poderíamos acabar com uma classe gigante contendo variáveis model.playerABCDFoo . Elementos de aninhamento simplificarão o preenchimento de código e também darão espaço para alternar entre variações de dados.

 // Model.cs class Model { public PlayerModel player; // Container of the Player data. public GameModel game; // Container of the Game data. }
 // GameModel.cs class GameModel { public float speed; // Game running speed (influencing the difficulty) public int level; // Current game level/stage loaded }
 // PlayerModel.cs class PlayerModel { public float health; // Player health from 0.0 to 1.0. public int lives; // Player “retry” count after he dies. public GunModel[] guns; // Now a Player can have an array of guns to switch ingame. }
 // GunModel.cs class GunModel { public GunType type; // Enumeration of Gun types. public GameObject prefab; // Template of the 3D Asset of the weapon. public int ammo; // Current number of bullets public int clips; // Number of reloads possible }

Com essa configuração de classes, os desenvolvedores podem navegar intuitivamente no código-fonte, um conceito por vez. Vamos supor um jogo de tiro em primeira pessoa, onde as armas e suas configurações podem se tornar realmente numerosas. O fato de GunModel estar contido em uma classe permite a criação de uma lista de Prefabs (GameObjects pré-configurados para serem rapidamente duplicados e reutilizados no jogo) para cada categoria e armazenados para uso posterior.

Por outro lado, se as informações da arma fossem todas armazenadas juntas em uma única classe GunModel , em variáveis ​​como gun0Ammo , gun1Ammo , gun0Clips e assim por diante, o usuário, quando confrontado com a necessidade de armazenar os dados da Gun , precisaria armazenar todo o Model incluindo os dados indesejados do Player . Nesse caso, seria óbvio que uma nova classe GunModel seria melhor.

IMAGEM: HIERARQUIA DE CLASSE
Melhorar a hierarquia de classes.

Como em tudo, há dois lados da moeda. Às vezes, pode-se desnecessariamente compartimentalizar e aumentar a complexidade do código. Somente a experiência pode aprimorar suas habilidades o suficiente para encontrar a melhor classificação MVC para seu projeto.

Nova habilidade especial do desenvolvedor de jogos desbloqueada: jogos Unity com o padrão MVC.
Tweet

Conclusão

Existem toneladas de padrões de software por aí. Neste post, tentei mostrar o que mais me ajudou em projetos anteriores. Os desenvolvedores devem sempre absorver novos conhecimentos, mas sempre questioná-los também. Espero que este tutorial ajude você a aprender algo novo e, ao mesmo tempo, sirva como um trampolim para desenvolver seu próprio estilo.

Além disso, eu realmente encorajo você a pesquisar outros padrões e encontrar aquele que melhor combina com você. Um bom ponto de partida é este artigo da Wikipedia, com sua excelente lista de padrões e suas características.

Se você gosta do padrão AMVCC e gostaria de testá-lo, não se esqueça de experimentar minha biblioteca, Unity MVC , que contém todas as classes principais necessárias para iniciar um aplicativo AMVCC.


Leitura adicional no Blog da Toptal Engineering:

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