Unity con MVC: cómo subir de nivel el desarrollo de tu juego

Publicado: 2022-03-11

Los programadores primerizos generalmente comienzan a aprender el oficio con el clásico programa Hello World . A partir de ahí, seguirán asignaciones cada vez más grandes. Cada nuevo desafío lleva a casa una lección importante:

Cuanto más grande es el proyecto, más grande es el espagueti.

Pronto, es fácil ver que en equipos grandes o pequeños, uno no puede hacer imprudentemente lo que le plazca. El código debe mantenerse y puede durar mucho tiempo. Las empresas para las que has trabajado no pueden simplemente buscar tu información de contacto y preguntarte cada vez que quieren arreglar o mejorar el código base (y tú tampoco quieres que lo hagan).

Esta es la razón por la que existen patrones de diseño de software; imponen reglas simples para dictar la estructura general de un proyecto de software. Ayudan a uno o más programadores a separar las piezas centrales de un proyecto grande y organizarlas de manera estandarizada, eliminando la confusión cuando se encuentra alguna parte desconocida del código base.

Estas reglas, cuando todos las siguen, permiten que el código heredado se mantenga y navegue mejor, y que se agregue código nuevo con mayor rapidez. Se dedica menos tiempo a planificar la metodología de desarrollo. Dado que los problemas no vienen en un solo sabor, no existe un patrón de diseño de bala de plata. Uno debe considerar cuidadosamente los puntos fuertes y débiles de cada patrón y encontrar el que mejor se adapte al desafío en cuestión.

En este tutorial, relataré mi experiencia con la popular plataforma de desarrollo de juegos Unity y el patrón Model-View-Controller (MVC) para el desarrollo de juegos. En mis siete años de desarrollo, después de haber luchado con mi parte justa de espaguetis de desarrollo de juegos, he logrado una gran estructura de código y velocidad de desarrollo utilizando este patrón de diseño.

Comenzaré explicando un poco de la arquitectura base de Unity, el patrón Entity-Component. Luego pasaré a explicar cómo encaja MVC encima y usaré un pequeño proyecto simulado como ejemplo.

Motivación

En la literatura de software encontraremos un gran número de patrones de diseño. A pesar de que tienen un conjunto de reglas, los desarrolladores generalmente modificarán un poco las reglas para adaptar mejor el patrón a su problema específico.

Esta “libertad de programación” es una prueba de que todavía no hemos encontrado un método único y definitivo para diseñar software. Por lo tanto, este artículo no pretende ser la solución definitiva a su problema, sino mostrar los beneficios y posibilidades de dos patrones bien conocidos: Entidad-Componente y Modelo-Vista-Controlador.

El patrón entidad-componente

Entidad-Componente (EC) es un patrón de diseño donde primero definimos la jerarquía de elementos que componen la aplicación (Entidades), y luego, definimos las características y datos que cada uno contendrá (Componentes). En términos más "programadores", una Entidad puede ser un objeto con una matriz de 0 o más Componentes. Vamos a representar una Entidad como esta:

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

Aquí hay un ejemplo simple de un árbol 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 es un buen patrón para aliviar los problemas de la herencia múltiple, donde una estructura de clases compleja puede presentar problemas como el problema del diamante donde una clase D, que hereda dos clases, B y C, con la misma clase base A, puede presentar conflictos porque cómo B y C modifican las características de A de manera diferente.

IMAGEN: PROBLEMA DEL DIAMANTE

Este tipo de problemas pueden ser comunes en el desarrollo de juegos, donde la herencia se usa mucho.

Al desglosar las funciones y los controladores de datos en componentes más pequeños, se pueden adjuntar y reutilizar en diferentes entidades sin depender de herencias múltiples (que, por cierto, ni siquiera es una función de C# o Javascript, los principales lenguajes utilizados por Unity ).

Donde el componente de entidad se queda corto

Al estar un nivel por encima de OOP, EC ayuda a desfragmentar y organizar mejor la arquitectura de su código. Sin embargo, en proyectos grandes, todavía somos "demasiado libres" y podemos encontrarnos en un "océano de características", teniendo dificultades para encontrar las Entidades y Componentes correctos, o averiguar cómo deben interactuar. Hay infinitas formas de ensamblar Entidades y Componentes para una tarea determinada.

IMAGEN: CE CARACTERÍSTICA OCÉANO

Una forma de evitar un lío es imponer algunas pautas adicionales sobre Entity-Component. Por ejemplo, una forma en que me gusta pensar sobre el software es dividirlo en tres categorías diferentes:

  • Algunos manejan los datos sin procesar, lo que permite que se creen, lean, actualicen, eliminen o busquen (es decir, el concepto CRUD).
  • Otros implementan la interfaz para que otros elementos interactúen, detectando eventos relacionados con su alcance y activando notificaciones cuando ocurren.
  • Finalmente, algunos elementos son responsables de recibir estas notificaciones, tomar decisiones de lógica comercial y decidir cómo se deben manipular los datos.

Afortunadamente, ya tenemos un patrón que se comporta exactamente de esta manera.

El patrón Modelo-Vista-Controlador (MVC)

El patrón Modelo-Vista-Controlador (MVC) divide el software en tres componentes principales: Modelos (CRUD de datos), Vistas (Interfaz/Detección) y Controladores (Decisión/Acción). MVC es lo suficientemente flexible como para implementarse incluso sobre ECS u OOP.

El desarrollo de juegos y UI tiene el flujo de trabajo habitual de esperar la entrada de un usuario u otra condición desencadenante, enviar notificaciones de esos eventos a un lugar apropiado, decidir qué hacer en respuesta y actualizar los datos en consecuencia. Estas acciones muestran claramente la compatibilidad de estas aplicaciones con MVC.

Esta metodología introduce otra capa de abstracción que ayudará con la planificación del software y también permitirá a los nuevos programadores navegar incluso en una base de código más grande. Al dividir el proceso de pensamiento en datos, interfaz y decisiones, los desarrolladores pueden reducir la cantidad de archivos de origen que se deben buscar para agregar o corregir la funcionalidad.

Unidad y CE

Primero echemos un vistazo más de cerca a lo que Unity nos ofrece por adelantado.

Unity es una plataforma de desarrollo basada en EC, donde todas las Entidades son instancias de GameObject y las características que las hacen "visibles", "movibles", "interactuables", etc., las proporcionan las clases que amplían el Component .

El Panel de jerarquía y el Panel de inspección del editor de Unity brindan una forma poderosa de ensamblar su aplicación, adjuntar componentes, configurar su estado inicial y arrancar su juego con mucho menos código fuente de lo que normalmente sería.

CAPTURA DE PANTALLA: PANEL DE JERARQUÍA
Panel de jerarquía con cuatro GameObjects a la derecha

CAPTURA DE PANTALLA: PANEL DEL INSPECTOR
Panel Inspector con los componentes de un GameObject

Aún así, como hemos comentado, podemos encontrarnos con el problema de "demasiadas funciones" y encontrarnos en una jerarquía gigantesca, con funciones dispersas por todas partes, lo que hace que la vida de un desarrollador sea mucho más difícil.

Pensando a la manera de MVC, podemos, en cambio, comenzar dividiendo las cosas según su función, estructurando nuestra aplicación como el siguiente ejemplo:

CAPTURA DE PANTALLA: ESTRUCTURA DE EJEMPLO DE UNITY MVC

Adaptación de MVC a un entorno de desarrollo de juegos

Ahora, me gustaría presentar dos pequeñas modificaciones al patrón MVC genérico, que ayudan a adaptarlo a situaciones únicas con las que me he encontrado al construir proyectos de Unity con MVC:

  1. Las referencias a la clase MVC se dispersan fácilmente por todo el código. - Dentro de Unity, los desarrolladores normalmente deben arrastrar y soltar instancias para hacerlas accesibles, o llegar a ellas a través de instrucciones de búsqueda engorrosas como GetComponent( ... ) . - Se producirá un infierno de referencias perdidas si Unity falla o algún error hace que desaparezcan todas las referencias arrastradas. - Esto hace que sea necesario tener un único objeto de referencia raíz, a través del cual se pueden alcanzar y recuperar todas las instancias de la Aplicación .
  2. Algunos elementos encapsulan la funcionalidad general que debería ser altamente reutilizable y que no cae naturalmente en una de las tres categorías principales de Modelo, Vista o Controlador. A estos me gusta llamarlos simplemente Componentes . También son "Componentes" en el sentido de Entidad-Componente, pero simplemente actúan como ayudantes en el marco MVC. - Por ejemplo, un Componente Rotator , que solo gira las cosas a una velocidad angular dada y no notifica, almacena ni decide nada.

Para ayudar a aliviar estos dos problemas, se me ocurrió un patrón modificado que llamo AMVCC , o Application-Model-View-Controller-Component.

IMAGEN: DIAGRAMA AMVCC

  • Aplicación : punto de entrada único a su aplicación y contenedor de todas las instancias críticas y datos relacionados con la aplicación.
  • MVC - Ya deberías saber esto. :)
  • Componente : secuencia de comandos pequeña y bien contenida que se puede reutilizar.

Estas dos modificaciones han satisfecho mis necesidades para todos los proyectos en los que las he usado.

Ejemplo: 10 rebotes

Como un ejemplo simple, veamos un pequeño juego llamado 10 Bounces , donde usaré los elementos centrales del patrón AMVCC.

La configuración del juego es simple: una Ball con un SphereCollider y un Rigidbody (que comenzará a caer después de "Jugar"), un Cube como suelo y 5 scripts para formar el AMVCC.

Jerarquía

Antes de programar, generalmente empiezo en la jerarquía y creo un esquema de mi clase y activos. Siempre siguiendo este nuevo estilo AMVCC.

CAPTURA DE PANTALLA: CONSTRUYENDO LA JERARQUÍA

Como podemos ver, la view GameObject contiene todos los elementos visuales y también aquellos con otros scripts de View . Los GameObjects model y controller , para pequeños proyectos, suelen contener sólo sus respectivos scripts. Para proyectos más grandes, contendrán GameObjects con scripts más específicos.

Cuando alguien que navega por tu proyecto quiere acceder a:

  • Datos: Ir a application > model > ...
  • Lógica/Flujo de trabajo: vaya a application > controller > ...
  • Representación/Interfaz/Detección: Vaya a application > view > ...

Si todos los equipos siguen estas reglas simples, los proyectos heredados no deberían convertirse en un problema.

Tenga en cuenta que no hay un contenedor de Component porque, como hemos comentado, son más flexibles y se pueden adjuntar a diferentes elementos cuando lo desee el desarrollador.

secuencias de comandos

Nota: Los scripts que se muestran a continuación son versiones abstractas de implementaciones del mundo real. Una implementación detallada no beneficiaría mucho al lector. Sin embargo, si desea explorar más, aquí está el enlace a mi marco MVC personal para Unity, Unity MVC. Encontrará clases básicas que implementan el marco estructural AMVCC necesario para la mayoría de las aplicaciones.

Echemos un vistazo a la estructura de los scripts de 10 Bounces .

Antes de comenzar, para aquellos que no estén familiarizados con el flujo de trabajo de Unity, aclaremos brevemente cómo funcionan juntos los scripts y los GameObjects. En Unity, los "Componentes", en el sentido de Entidad-Componente, están representados por la clase MonoBehaviour . Para que exista uno durante el tiempo de ejecución, el desarrollador debe arrastrar y soltar su archivo de origen en un GameObject (que es la "Entidad" del patrón Entidad-Componente) o usar el comando AddComponent<YourMonobehaviour>() . Después de esto, se creará una instancia del script y estará listo para usar durante la ejecución.

Para comenzar, definimos la clase de aplicación (la "A" en AMVCC), que será la clase principal que contenga referencias a todos los elementos del juego instanciados. También crearemos una clase base auxiliar llamada Element , que nos da acceso a la instancia de la Aplicación y las instancias MVC de sus hijos.

Con esto en mente, definamos la clase de Application (la “A” en AMVCC), que tendrá una instancia única. Dentro de él, tres variables, model , view y controller , nos darán puntos de acceso para todas las instancias de MVC durante el tiempo de ejecución. Estas variables deben ser MonoBehaviour s con referencias public a los scripts deseados.

Luego, también crearemos una clase base auxiliar llamada Element , que nos da acceso a la instancia de la Aplicación. Este acceso permitirá que todas las clases de MVC se comuniquen entre sí.

Tenga en cuenta que ambas clases extienden MonoBehaviour . Son “Componentes” que se adjuntarán a las “Entidades” de GameObject.

 // 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() { } }

Desde BounceElement podemos crear las clases principales de MVC. Los BounceModel , BounceView y BounceController normalmente actúan como contenedores para instancias más especializadas, pero dado que este es un ejemplo simple, solo View tendrá una estructura anidada. El modelo y el controlador se pueden hacer en un script para cada uno:

 // 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!!”); } }

Con todos los scripts creados, podemos proceder a adjuntarlos y configurarlos.

El diseño de la jerarquía debería ser así:

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

Usando BounceModel como ejemplo, podemos ver cómo se ve en el editor de Unity:

CAPTURA DE PANTALLA: BounceModel EN INSPECTOR
BounceModel con los campos bounces y winCondition .

Con todos los scripts configurados y el juego ejecutándose, deberíamos obtener este resultado en el Panel de consola .

CAPTURA DE PANTALLA: SALIDA DE LA CONSOLA

Notificaciones

Como se muestra en el ejemplo anterior, cuando la pelota toca el suelo, su vista ejecuta app.controller.OnBallGroundHit() que es un método. No es, de ninguna manera, "incorrecto" hacer eso para todas las notificaciones en la aplicación. Sin embargo, en mi experiencia, he logrado mejores resultados utilizando un sistema de notificación simple implementado en la clase de aplicación AMVCC.

Para implementar eso, actualicemos el diseño de BounceApplication para que sea:

 // 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() { /* ... */ } }

A continuación, necesitamos un nuevo script en el que todos los desarrolladores agreguen los nombres de los eventos de notificación, que se pueden enviar durante la ejecución.

 // 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”; /* ... */ }

Es fácil ver que, de esta manera, se mejora la legibilidad del código porque los desarrolladores no necesitan buscar en todo el código fuente los métodos controller.OnSomethingComplexName para comprender qué tipo de acciones pueden ocurrir durante la ejecución. Al verificar solo un archivo, es posible comprender el comportamiento general de la aplicación.

Ahora, solo necesitamos adaptar BallView y BounceController para manejar este nuevo 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; } } }

Los proyectos más grandes tendrán muchas notificaciones. Por lo tanto, para evitar obtener una gran estructura de caso de cambio, es recomendable crear diferentes controladores y hacer que manejen diferentes ámbitos de notificación.

AMVCC en el mundo real

Este ejemplo ha mostrado un caso de uso simple para el patrón AMVCC. Ajustar su forma de pensar en términos de los tres elementos de MVC y aprender a visualizar las entidades como una jerarquía ordenada son las habilidades que deben pulirse.

En proyectos más grandes, los desarrolladores se enfrentarán a escenarios más complejos y dudas sobre si algo debe ser una Vista o un Controlador, o si una clase determinada debe separarse más a fondo en otras más pequeñas.

Reglas generales (por Eduardo)

No hay ninguna "Guía universal para la clasificación de MVC" en ninguna parte. Pero hay algunas reglas simples que normalmente sigo para ayudarme a determinar si debo definir algo como Modelo, Vista o Controlador y también cuándo dividir una clase determinada en partes más pequeñas.

Por lo general, esto sucede de forma orgánica mientras pienso en la arquitectura del software o durante la creación de secuencias de comandos.

Clasificación de clases

Modelos

  • Conserva los datos y el estado principales de la aplicación, como la health del jugador o la ammo de las armas.
  • Serializar, deserializar y/o convertir entre tipos.
  • Carga/guarda datos (localmente o en la web).
  • Notificar a los Controladores el avance de las operaciones.
  • Almacene el estado del juego para la máquina de estados finitos del juego.
  • Nunca acceda a Vistas.

Puntos de vista

  • Puede obtener datos de modelos para representar el estado actualizado del juego para el usuario. Por ejemplo, un método View player.Run() puede usar internamente model.speed para manifestar las habilidades del jugador.
  • Nunca debe mutar modelos.
  • Implementa estrictamente las funcionalidades de su clase. Por ejemplo:
    • Un PlayerView no debe implementar la detección de entrada ni modificar el estado del juego.
    • Una vista debe actuar como una caja negra que tiene una interfaz y notifica eventos importantes.
    • No almacena datos básicos (como velocidad, salud, vidas,...).

Controladores

  • No almacene datos básicos.
  • A veces puede filtrar notificaciones de Vistas no deseadas.
  • Actualizar y utilizar los datos del Modelo.
  • Administra el flujo de trabajo de escena de Unity.

Jerarquía de clases

En este caso, no hay muchos pasos que seguir. Por lo general, percibo que alguna clase debe dividirse cuando las variables comienzan a mostrar demasiados "prefijos" o comienzan a aparecer demasiadas variantes del mismo elemento (como las clases de Player en un MMO o los tipos de Gun en un FPS).

Por ejemplo, un solo Model que contenga los datos del jugador tendría muchos datos del jugador A, datos del playerDataA, playerDataB,... o un Controller que maneje las notificaciones del jugador tendría OnPlayerDidA,OnPlayerDidB,... . Queremos reducir el tamaño del script y deshacernos de los prefijos de player y OnPlayer .

Permítanme demostrar el uso de una clase Model porque es más simple de entender usando solo datos.

Durante la programación, suelo empezar con una única clase de Model que contiene todos los datos del juego.

 // 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; }

Es fácil ver que cuanto más complejo sea el juego, más variables obtendrá. Con suficiente complejidad, podríamos terminar con una clase gigante que contenga variables model.playerABCDFoo . Los elementos anidados simplificarán la finalización del código y también darán espacio para cambiar entre variaciones de datos.

 // 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 }

Con esta configuración de clases, los desarrolladores pueden navegar intuitivamente en el código fuente un concepto a la vez. Supongamos un juego de disparos en primera persona, donde las armas y sus configuraciones pueden volverse realmente numerosas. El hecho de que GunModel esté contenido en una clase permite la creación de una lista de Prefabs (GameObjects preconfigurados para duplicarse y reutilizarse rápidamente en el juego) para cada categoría y almacenarse para su uso posterior.

Por el contrario, si toda la información del arma se almacenara junta en la única clase GunModel , en variables como gun0Ammo , gun1Ammo , gun0Clips , etc., entonces el usuario, cuando se enfrenta a la necesidad de almacenar los datos del Gun , tendría que almacenar toda la información. Model que incluye los datos del Player no deseados. En este caso, sería obvio que una nueva clase GunModel sería mejor.

IMAGEN: JERARQUÍA DE CLASES
Mejorar la jerarquía de clases.

Como en todo, hay dos caras de la moneda. A veces, uno puede compartimentar innecesariamente y aumentar la complejidad del código. Solo la experiencia puede perfeccionar sus habilidades lo suficiente como para encontrar la mejor clasificación de MVC para su proyecto.

Nueva habilidad especial de desarrollo de juegos desbloqueada: juegos de Unity con el patrón MVC.
Pío

Conclusión

Hay toneladas de patrones de software por ahí. En esta publicación, traté de mostrar el que más me ayudó en proyectos anteriores. Los desarrolladores siempre deben absorber nuevos conocimientos, pero siempre cuestionarlos también. Espero que este tutorial te ayude a aprender algo nuevo y, al mismo tiempo, te sirva de trampolín para desarrollar tu propio estilo.

Además, realmente te animo a que investigues otros patrones y encuentres el que más te convenga. Un buen punto de partida es este artículo de Wikipedia, con su excelente lista de patrones y sus características.

Si le gusta el patrón AMVCC y desea probarlo, no olvide probar mi biblioteca, Unity MVC , que contiene todas las clases principales necesarias para iniciar una aplicación AMVCC.


Lecturas adicionales en el blog de ingeniería de Toptal:

  • Unity AI Development: un tutorial de máquina de estado finito