Unity com MVC: como elevar o nível do seu desenvolvimento de jogos
Publicados: 2022-03-11Os 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.
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.
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.
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:
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:
- 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. - 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.
- 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.
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:
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 .
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 ouammo
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 usarmodel.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,…).
- Um
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.
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.
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