Lo que obtendrá de esta página: Aprende estrategias eficaces para diseñar el código de un proyecto en crecimiento a fin que se pueda ampliar su escala adecuadamente y con menos problemas. A medida que tu proyecto crece, tendrás que modificar y limpiar su diseño en repetidas oportunidades. Cuando estás haciendo cambios, siempre es bueno detenerse por un momento, separar las cosas en elementos más pequeños a fin de prepararlas mejor y, luego volver a juntar todo.
Veamos algunos ejemplos de código de un juego muy básico de estilo Pong que mi equipo creó para mi charla de Unite Berlín. Como puedes ver en la imagen superior, hay dos palas y cuatro paredes -en la parte superior e inferior, y a izquierda y derecha-, algo de lógica de juego y la IU de puntuación. También hay un script sencillo para las paletas y las paredes.
Este ejemplo está basado en algunos principios clave:
- Una "cosa" = un prefab
- La lógica personalizada para una "cosa" = un MonoBehaviour
- Una aplicación = una escena que contiene los prefabs intervinculados
Esos principios funcionan para un proyecto muy sencillo como este, pero tendremos que cambiar la estructura si queremos que el proyecto crezca. ¿Cuáles son las estrategias que podemos utilizar para organizar el código?
Primero, vamos a asegurarnos de que no haya confusión sobre las diferencias entre las instancias, los Prefabs y los ScriptableObjects. Este es el componente Paddle en el GameObject Paddle del Jugador 1, como se ve en el Inspector:
Podemos ver que tiene tres parámetros. Sin embargo, ninguna parte de esta vista me indica lo que el código subyacente espera que yo haga.
¿Debería cambiar el eje de entrada de la paleta izquierda en la instancia, o debería hacerlo en el Prefab? Supongo que el eje de entrada es diferente para ambos jugadores, por lo que probablemente debería hacerlo en la instancia. ¿Y la escala de velocidad de movimiento? ¿Debería cambiarla en la instancia o en el Prefab?
Echemos un vistazo al código que representa el componente Paddle.
Si lo pensamos por un momento, nos daremos cuenta de que los distintos parámetros se utilizan de formas diferentes en nuestro programa. Debemos cambiar el InputAxisName individualmente para cada jugador: MovementSpeedScaleFactor y PositionScale deben ser compartidos por ambos jugadores. La siguiente estrategia te puede ayudar a saber cuándo usar instancias, Prefabs y ScriptableObjects:
- ¿Necesitas hacer algo una sola vez? Crea un Prefab e instáncialo.
- ¿Necesitas hacer algo varias veces, posiblemente con algunas modificaciones específicas para cada instancia? Puedes crear un Prefab, instanciarlo y anular algunas configuraciones.
- ¿Quieres asegurarte de tener la misma configuración en diferentes instancias? Crea un ScriptableObject y envía los datos fuente desde ahí.
Mira cómo usar ScriptableObjects en nuestro componente Paddle en el siguiente ejemplo de código.
Debido a que pasamos estas configuraciones a un ScriptableObject de tipo PaddleData, solo tenemos una referencia a ese PaddleData en nuestro componente Paddle. Al final, tenemos dos elementos en el Inspector: un PaddleData y dos instancias de Paddle. Puedes cambiar el nombre del eje y el conjunto de configuraciones compartidas al que apunta cada paleta. La nueva estructura te permite ver la intención de las diferentes configuraciones de forma más sencilla.
Si este fuera un juego real en desarrollo, verías como crecen cada vez más los MonoBehaviours individuales. Veamos cómo podemos dividirlos utilizando el principio de responsabilidad única, que dice que cada clase debe manejar una sola cosa. Si se aplica correctamente, debería ser capaz de dar respuestas breves a las preguntas "¿qué hace una clase concreta?", así como "¿qué no hace?". Esto facilita que todos los desarrolladores de su equipo entiendan lo que hacen las clases individuales. Puedes aplicar este principio a una base de código de cualquier tamaño. Veamos un ejemplo sencillo, tal como se muestra en la imagen de arriba.
Este es el código de una pelota. Aunque se ve bastante sencillo, al observar con atención descubrimos que la pelota tiene una velocidad, la cual se utiliza en el diseñador para establecer el vector de velocidad inicial de la pelota, y también se usa en la simulación de física casera, donde hace un seguimiento de la velocidad actual de la pelota.
Estamos reutilizando la misma variable para dos propósitos ligeramente diferentes. En cuanto la pelota empieza a moverse, se pierde la información relacionada con la velocidad inicial.
La simulación de física casera no solo es el movimiento que existe en FixedUpdate(); sino que también abarca la reacción que se presenta cuando la pelota choca contra una pared.
En lo profundo de OnTriggerEnter() hay una operación Destroy(). Aquí es donde la lógica del trigger elimina su propio GameObject. En las bases de código de gran tamaño, pocas veces se permite que las entidades se eliminen a sí mismas, y la tendencia es hacer que los propietarios eliminen los elementos que posean.
Aquí tenemos la oportunidad de dividir las cosas en partes de menor tamaño. Estas clases tienen varios tipos de responsabilidades diferentes: lógica del juego, administración de las entradas, simulaciones de física, presentaciones, etc.
Estas son algunas opciones para crear esas partes de menor tamaño:
- La lógica de juego general, la administración de las entradas, la simulación de física y la presentación pueden permanecer dentro de las clases MonoBehaviours, ScriptableObjects o C# sin procesar.
- Puedes utilizar MonoBehaviours o ScriptableObjects para mostrar los parámetros en el Inspector.
- Los administradores de eventos del motor, así como la administración del ciclo de vida de un GameObject, deben permanecer dentro de MonoBehaviours.
Creo que, para muchos juegos, vale la pena mantener la mayor cantidad de código posible fuera de los MonoBehaviours. Una opción para hacer esto es mediante ScriptableObjects, y existen algunos excelentes recursos relacionados con este método.
Pasar de los MonoBehaviours a las clases comunes de C# es otro método que podemos analizar. Pero ¿qué ventajas tiene esta opción?
Las clases comunes de C# tienen mejores instalaciones de lenguaje que los objetos de Unity para dividir el código en fragmentos pequeños y acoplables. Además, el código común de C# se puede compartir con las bases de código nativas de .NET fuera de Unity.
Por otro lado, si usas clases normales de C#, el Editor no entiende los objetos y no puede mostrarlos de manera nativa en el Inspector, etc.
Con este método, el objetivo es dividir la lógica según el tipo de responsabilidad. Si regresamos al ejemplo de la pelota, pasamos la simulación de física simple a una clase de C# que llamamos BallSimulation. Ahora, solo tiene que encargarse de la integración de física y de reaccionar cuando la bola choque contra algún otro objeto.
Sin embargo, ¿tiene sentido que una simulación de pelota tome decisiones basadas en los objetos con los que choca? Eso parece más una lógica de juego. Al final, la clase Ball tiene una porción de lógica que controla la simulación de algunas maneras, y el resultado de esa simulación se envía de regreso al MonoBehaviour.
Si revisamos la versión reorganizada de arriba, uno de los cambios importantes es que la operación Destroy() ya no está sepultada bajo muchas capas. En este punto, solo quedan unas pocas áreas de responsabilidad claramente definidas en el MonoBehaviour:
Podemos hacer más cosas con esto. En la lógica de actualización de posición de FixedUpdate(), podemos ver que el código debe enviar una posición y devuelve una nueva posición a partir de esa información. En realidad, la simulación de la pelota no posee la ubicación de la pelota, sino que ejecuta una simulación con base en la ubicación proporcionada de una pelota y devuelve el resultado.
Si usamos interfaces, tal vez podamos compartir una porción de ese MonoBehaviour de la pelota con la simulación, la cual contenga únicamente las partes que necesita (consulta la imagen de arriba).
Veamos otra vez el código. La clase Ball implementa una interfaz sencilla. La clase LocalPositionAdapter permite entregar a otra clase una referencia al objeto Ball. No entregamos todo el objeto Ball, solo su aspecto LocalPositionAdapter.
BallLogic también debe informar a Ball cuando llegue el momento de destruir el GameObject. En lugar de devolver una alerta, Ball puede proporcionar un delegado a BallLogic. Eso es lo que hace la última línea marcada de la versión reorganizada. Esto nos ofrece un diseño bastante interesante: hay mucha lógica repetida, pero cada clase tiene un propósito claramente definido.
Al utilizar estos principios, puedes mantener una buena estructura en un proyecto de una sola persona.
Veamos algunas soluciones de arquitectura de software para los proyectos un poco más grandes. Si utilizamos el ejemplo del juego de pelota, al empezar a introducir clases más específicas en el código, como BallLogic, BallSimulation, etc., deberíamos poder construir una jerarquía:
Los MonoBehaviours necesitan información sobre todo lo demás porque simplemente envuelven el resto de la lógica, pero las piezas de simulación del juego no necesitan saber cómo funciona la lógica. Solamente ejecutan una simulación. Algunas veces, la lógica envía señales a la simulación, y la simulación muestra la reacción correspondiente.
Es una buena idea administrar las entradas en un lugar separado y autónomo. En ese lugar se generan los eventos de entrada y después se envían hacia la lógica. Lo que sucede después depende de la simulación.
Esto funciona bien para las entradas y la simulación. Sin embargo, es posible que encuentres problemas con cualquier cosa relacionada con la presentación, como la lógica que activa efectos especiales, la actualización de los contadores del marcador, etc.
La presentación necesita saber lo que sucede en otros sistemas, pero no necesita tener acceso total a todos esos sistemas. Si es posible, intenta separar la lógica de la presentación. Intenta llegar al punto donde puedas ejecutar tu base de código en dos modos: solo lógica, o lógica y presentación.
Algunas veces, deberás conectar la lógica con la presentación para que la presentación se actualice en los momentos adecuados. Sin embargo, el objetivo debe ser enviar a la presentación únicamente lo que necesita para mostrarse correctamente, y nada más. De esta manera, tendrás un límite natural entre las dos partes, lo que te permitirá reducir la complejidad general del juego que estás creando.
Algunas veces, puedes tener una clase que contenga únicamente datos, sin incorporar toda la lógica y las operaciones que se pueden realizar con esos datos en la misma clase.
También puede ser una buena idea crear clases que no posean ningún dato, pero que contengan funciones cuyo propósito sea manipular los objetos que reciben.
Lo bueno de los métodos estáticos es que, si supones que no entran en contacto con ninguna variable global, puedes identificar el alcance de lo que el método puede afectar con solo ver lo que se pasa como argumentos al llamar el método. No es necesario revisar la implementación del método en ningún momento.
Este enfoque se acerca al campo de la programación funcional. El concepto básico es que envías algo a una función y la función devuelve un resultado, o tal vez modifica uno de los parámetros de salida. Al probar este enfoque, puedes encontrar menos errores que en la programación clásica orientada a los objetos.
También puedes desacoplar los objetos al insertar una lógica de pegamento entre ellos. Si tomamos nuevamente el ejemplo del juego tipo Pong: ¿cómo se comunicarán entre sí la lógica de la pelota y la presentación del marcador? ¿La lógica de la pelota informará a la presentación del marcador cuando algo suceda en relación con la pelota? ¿La lógica del marcador enviará una consulta a la lógica de la pelota? Necesitan comunicarse entre ellas de alguna forma.
Puedes crear un objeto de búfer cuyo único propósito sea proporcionar un área de almacenamiento donde la lógica pueda escribir cosas y la presentación pueda leer cosas. O también puedes colocar una lista de prioridad entre ellas, para que el sistema de la lógica pueda poner cosas en la lista y la presentación pueda leer lo que sale de la lista.
Una buena opción para desacoplar la lógica de la presentación conforme crece tu juego es con un bus de mensajes. El principio básico de la mensajería es que ni el receptor ni el emisor tienen información sobre la otra parte, pero ambos están al tanto del bus/sistema de mensajes. En este ejemplo, la presentación de marcador necesita que el sistema de mensajes le envíe información sobre cualquier evento que cambie el marcador. Entonces, la lógica de juego publicará los eventos en el sistema de mensajería para indicar un cambio en los puntos de un jugador. Si quieres desacoplar sistemas, un buen primer paso es usar UnityEvents o escribir los tuyos. Después, podrás tener buses independientes para propósitos independientes.
Deja de usar LoadSceneMode.Single y utiliza LoadSceneMode.Additive en su lugar.
Utiliza descargas explícitas cuando quieras descargar una escena. Tarde o temprano necesitarás mantener algunos objetos activos durante la transición de una escena.
También deja de utilizar DontDestroyOnLoad. Esto provoca que pierdas el control sobre el ciclo de vida de un objeto. De hecho, si cargas cosas con LoadSceneMode.Additive, no necesitarás utilizar DontDestroyOnLoad. En lugar de esto, coloca tus objetos de vida prolongada en una escena especial de vida prolongada.
Otro consejo que me ha servido mucho en todos los juegos con los que he trabajado es fomentar un apagado limpio y controlado.
Asegúrate de que tu aplicación sea capaz de liberar prácticamente todos los recursos antes de cerrarse. Si es posible, no debería quedar ninguna variable global asignada y ningún GameObject debería estar marcado con DontDestroyOnLoad.
Al tener un orden particular para apagar las cosas, será más fácil detectar los errores y las fugas de recursos. Esto también te permitirá dejar Unity Editor en un buen estado al salir del modo Play. Unity no hace una recarga total de dominios al salir del modo Play. Si tienes un apagado limpio, es menos probable que el Editor o cualquier tipo de scripting del modo de edición muestren comportamientos extraños después de ejecutar tu juego en el Editor.
Puedes hacer esto al utilizar un sistema de control de versiones, como Git, Perforce o Plastic. Almacena todos los assets como texto y saca tus objetos de los archivos de escenas al convertirlos en Prefabs. Por último, divide los archivos de escenas en varias escenas más pequeñas, pero recuerda que esto puede requerir herramientas adicionales.
Si pronto planeas tener un equipo de unas 10 personas o más, deberás trabajar en la automatización de procesos.
Al ser un programador creativo, tu objetivo es enfocarte en el trabajo único y meticuloso, y dejar que la automatización se encargue de las partes repetitivas en la medida de lo posible.
Para empezar, escribe pruebas para tu código. Específicamente, si estás sacando cosas de los MonoBehaviours para enviarlas a clases comunes, puedes utilizar de manera sencilla un marco de trabajo de unidades de prueba para crear unidades de prueba para la lógica y la simulación. El resultado no es igual de útil en todas partes, pero suele hacer que tu código sea más accesible para otros programadores en el futuro.
Las pruebas no solo se refieren al código. También tienes que probar tu contenido. Si tienes creadores de contenido en tu equipo, todos funcionarán mejor si cuentas con un proceso estandarizado para validar rápidamente el contenido que crean.
Los creadores de contenido deben poder acceder fácilmente a una lógica de prueba, como la validación de un Prefab o la validación de algunos datos ingresados mediante un editor personalizado. Si pueden obtener una validación rápida con solo hacer clic en un botón del Editor, pronto se darán cuenta de que esto les ayuda a ahorrar tiempo.
El siguiente paso es configurar Unity Test Runner para repetir las pruebas de forma automática y constante. Configúralo como parte de tu sistema de compilación para que también ejecute todas tus pruebas. Una práctica recomendada es configurar las notificaciones para que, cuando se presente un problema, tus compañeros del equipo reciban una notificación por Slack o por correo electrónico.