¿Qué estás buscando?
Hero background image
Last updated May 2019, 10 min. read

Cómo construir el código conforme crece tu proyecto

Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

Lo que encontrarás en esta página: Estrategias efectivas para diseñar el código de un proyecto en crecimiento a fin de 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 varias veces. Siempre es bueno alejarse un poco de los cambios que estás haciendo, dividir las cosas en elementos más pequeños para enderezarlas y, luego, volver a unir todo.

El artículo corre por cuenta de Mikael Kalms, director de tecnología del estudio de videojuegos sueco Fall Damage. Mikael tiene más de 20 años de experiencia en el desarrollo y envío de juegos. Después de todo este tiempo, sigue muy interesado en cómo construir el código para que los proyectos puedan crecer de una manera segura y eficiente.

Cómo construir el código conforme crece tu proyecto

De lo simple a lo complejo

Veamos algunos ejemplos de código de un juego básico tipo Pong que mi equipo creó para mi charla Unite Berlín. Como puedes ver en la imagen de arriba, hay dos paletas y cuatro paredes (arriba y abajo, e izquierda y derecha), algo de lógica de juego y la interfaz de usuario (UI) de puntaje. Hay un script simple tanto en la paleta como en las paredes.

Este ejemplo se basa 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 simple como este, pero tendremos que cambiar la estructura si queremos que esto crezca. Entonces, ¿cuáles son las estrategias que podemos usar para organizar el código?

Cómo construir el código conforme crece tu proyecto_Parámetros de los componentes

Instancias, Prefabs y ScriptableObjects

Primero, eliminemos la confusión sobre las diferencias entre instancias, Prefabs y ScriptableObjects. Este es el componente Paddle del GameObject Paddle del jugador 1, que se ve en el Inspector:

Podemos ver que tiene tres parámetros. Sin embargo, nada en esta vista me indica lo que el código subyacente espera de mí.

¿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 cambiarse en la instancia. ¿Qué sucede con Movement Speed Scale? ¿Debería cambiar eso en la instancia o en el Prefab?

Veamos el código que representa el componente Paddle.

Cómo construir el código conforme crece tu proyecto_Parámetros en el código

Parámetros en un ejemplo de código simple

Si nos detenemos a pensar un momento, nos daremos cuenta de que los diferentes parámetros se utilizan de diferentes maneras en nuestro programa. Debemos cambiar el InputAxisName de forma individual para cada jugador: MovementSpeedScaleFactor y PositionScale deben ser compartidos por ambos jugadores. Esta es una estrategia que puede guiarte para saber cuándo usar instancias, Prefabs y ScriptableObjects:

  • ¿Necesitas hacer algo una sola vez? Crea un prefab y, luego, instáncialo.
  • ¿Necesitas hacer algo varias veces, posiblemente con algunas modificaciones específicas para cada instancia? Luego, puedes crear un prefab, instanciarlo y anular algunos ajustes.
  • ¿Quieres garantizar la misma configuración en varias instancias? Luego, crea un ScriptableObject y envía los datos fuente desde allí.

Mira cómo usamos ScriptableObjects con nuestro componente Paddle en el siguiente ejemplo de código.

Cómo construir el código conforme crece tu proyecto_Con ScriptableObjects

Uso de ScriptableObjects

Dado 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 paquete de ajustes compartidos al que apunta cada paleta. La nueva estructura te permite ver la intención de los diferentes ajustes con mayor facilidad.

Cómo construir el código conforme crece tu proyecto_Principio de responsabilidad única

Separar los MonoBehaviours grandes

Si este fuera un juego en desarrollo real, verías que los MonoBehaviours individuales crecen cada vez más. Veamos cómo podemos dividirlos si trabajamos a partir de lo que se llama el principio de responsabilidad única, que estipula que cada clase debe manejar una sola cosa. Si se aplica correctamente, deberías poder dar respuestas breves a las preguntas "¿qué hace una clase en particular?" y "¿qué no hace?". Esto facilita que todos los desarrolladores de tu equipo entiendan lo que hacen las clases individuales. Es un principio que puedes aplicar a una base de código de cualquier tamaño. Veamos un ejemplo simple, como se muestra en la imagen de arriba.

Muestra el código de una pelota. Aunque parece poco, al observar con atención descubrimos que la pelota tiene una velocidad que se utiliza tanto en el diseñador para establecer el vector de velocidad inicial de la pelota como en la simulación de física casera para hacer un seguimiento de la velocidad actual de la pelota.

Estamos reutilizando la misma variable para dos propósitos ligeramente diferentes. En cuanto la pelota comienza a moverse, se pierde la información sobre la velocidad inicial.

La simulación de física casera no es solo el movimiento en FixedUpdate(); también abarca la reacción cuando la pelota entra en contacto con 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 grandes, es raro permitir que las entidades se eliminen a sí mismas. En cambio, la tendencia es que los propietarios eliminen cosas que posean.

Aquí tenemos la oportunidad de dividir las cosas en partes más pequeñas. Estas clases tienen varios tipos de responsabilidades diferentes: lógica del juego, administración de las entradas, simulaciones de física, presentaciones y mucho más.

Estas son algunas maneras de crear esas partes más pequeñas:

  • La lógica general del juego, el manejo de las entradas, la simulación de física y la presentación podrían permanecer dentro de las clases MonoBehaviours, ScriptableObjects o C# sin procesar.
  • Para exponer parámetros en el Inspector, se pueden utilizar MonoBehaviors u ScriptableObjects.
  • Los administradores de eventos del motor y la administración del ciclo de vida de un GameObject deben permanecer dentro de MonoBehaviours.

Creo que, para muchos juegos, vale la pena sacar la mayor cantidad de código posible de MonoBehaviours. Una opción es utilizar ScriptableObjects y ya existen algunos recursos fabulosos sobre este método.

Cómo construir el código conforme crece tu proyecto_Mover los monocomportamientos a clases C-sharp

Desde los MonoBehaviours hasta las clases normales de C#

Trasladar los MonoBehaviours a clases normales de C# es otro método que se debe considerar, pero ¿cuáles son los beneficios de esto?

Las clases normales de C# tienen mejores instalaciones de lenguaje que los objetos de Unity para dividir el código en fragmentos pequeños y componibles. Además, el código común de C# se puede compartir con 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 forma nativa en el Inspector, etc.

Con este método, quieres dividir la lógica por 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. El único trabajo que tiene que hacer es integrarse con la física y reaccionar cuando la pelota entra en contacto con algo.

Sin embargo, ¿tiene sentido que una simulación de pelota tome decisiones basadas en lo que realmente golpea? Eso parece más una lógica de juego. Al final, tenemos que Ball tiene una porción de lógica que controla la simulación de alguna manera, y el resultado de esa simulación se retroalimenta al MonoBehaviour.

Si observamos 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 claras de responsabilidad en el MoneBehaviour.

Podemos hacer más cosas con esto. Si observamos la lógica de actualización de posición en FixedUpdate(), podemos ver que el código debe enviar una posición y devuelve una nueva posición a partir de ahí. En realidad, la simulación de pelota no posee la ubicación de la pelota. Ejecuta una simulación basada en la ubicación proporcionada de una pelota y devuelve el resultado.

Cómo construir el código conforme crece tu proyecto_Uso de interfaces

Uso de interfaces

Si usamos interfaces, tal vez podamos compartir una porción de ese MonoBehaviour de la pelota con la simulación, solo 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 una referencia al objeto Ball a otra clase. No entregamos todo el objeto Ball, solo su aspecto LocalPositionAdapter.

BallLogic también necesita informar a Ball cuando llegue el momento de destruir el GameObject. En lugar de devolver una marca, Ball puede proporcionar un delegado a BallLogic. Eso es lo que hace la última línea marcada en la versión reorganizada. Esto nos da un diseño excelente: hay mucha lógica, pero cada clase tiene un propósito claramente definido.

Al utilizar estos principios, puedes mantener una buena estructura de un proyecto unipersonal.

Cómo construir el código conforme crece tu proyecto_Arquitectura de software

Arquitectura de software

Veamos las soluciones de arquitectura de software para proyectos un poco más grandes. Si usamos el ejemplo del juego de pelota, una vez que comencemos a introducir clases más específicas en el código (BallLogic, BallSimulation, etc.), deberíamos poder construir una jerarquía:

Los MonoBehaviours tienen que saber todo lo demás porque simplemente envuelven toda esa otra lógica, pero las piezas de simulación del juego no necesariamente necesitan saber cómo funciona la lógica. Solo ejecutan una simulación. Algunas veces, la lógica envía señales a la simulación y la simulación reacciona en consecuencia.

Es beneficioso administrar las entradas en un lugar separado y autónomo. Aquí es donde se generan los eventos de entrada y luego se envían a 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 probable que encuentres problemas con cualquier cosa relacionada con la presentación, por ejemplo, la lógica que genera efectos especiales, la actualización de los contadores de puntuación, etc.

Lógica y presentación

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 en que puedas ejecutar tu base de código en dos modos: solo lógica y lógica más 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 proporcionar a la presentación solo lo que necesita para mostrarse correctamente, y nada más. De esta manera, obtendrás un límite natural entre las dos partes, lo que reducirá la complejidad general del juego que estás construyendo.

Clases de solo datos y clases auxiliares

Algunas veces, está bien tener una clase que contenga solo datos, sin incorporar toda la lógica y las operaciones que se pueden hacer 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 se le están dando.

Métodos estáticos

Lo bueno de un método estático es que, si supones que no toca ninguna variable global, puedes identificar el alcance de lo que el método puede afectar con solo mirar lo que se transmite como argumentos al llamar al método. No es necesario revisar la implementación del método en absoluto.

Este enfoque se refiere al campo de la programación funcional. El componente básico es que envías algo a una función y esta devuelve un resultado o quizás modifica uno de los parámetros de salida. Prueba este enfoque: es posible que obtengas menos errores en comparación con la programación clásica orientada a objetos.

Cómo desacoplar tus objetos

También puedes desacoplar objetos insertando 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 suceda algo con respecto a la pelota? ¿La lógica del marcador enviará una consulta a la lógica de la pelota? Necesitarán hablar entre ellos de alguna manera.

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. También puedes poner una cola entre ellas para que el sistema de la lógica pueda poner cosas en la cola y la presentación pueda leer lo que sale de ella.

Una buena opción para desacoplar la lógica de la presentación a medida que tu juego crece es con un bus de mensajes. El principio básico de la mensajería es que ni el receptor ni el emisor conocen a la otra parte, pero ambos conocen el bus/sistema de mensajes. Por lo tanto, una presentación de puntaje necesita saber del sistema de mensajería sobre cualquier evento que cambie el puntaje. La lógica del juego publicará eventos en el sistema de mensajería que indiquen un cambio en los puntos para un jugador. Si quieres desacoplar sistemas, un buen punto de partida es utilizar UnityEvents o escribir los tuyos propios. Después, podrás tener buses separados para propósitos diferentes.

Cargando escenas

Deja de usar LoadSceneMode.Single y usa 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 usar DontDestroyOnLoad. Te hace perder el control sobre el ciclo de vida de un objeto. De hecho, si cargas cosas con LoadSceneMode.Additive, no necesitarás usar DontDestroyOnLoad. Coloca tus objetos de larga vida en una escena especial de larga vida.

Un apagado limpio y controlado

Otro consejo que me ha resultado útil en todos los juegos en los que he trabajado ha sido apoyar un apagado limpio y controlado.

Haz que tu aplicación sea capaz de liberar prácticamente todos los recursos antes de cerrarse. Si es posible, aún no se deben asignar variables globales y ningún GameObject se debe marcar con DontDestroyOnLoad.

Cuando tienes un orden particular para apagar las cosas, te resultará 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 realiza una recarga completa de dominio al salir del modo de juego. Si tienes un apagado limpio, es menos probable que el editor o cualquier tipo de scripting en modo de edición muestren comportamientos extraños después de ejecutar tu juego en el editor.

Reducción del dolor con la fusión de archivos de escena

Puedes hacerlo mediante un sistema Version Control como Git, Perforce o Plastic. Almacena todos los assets como texto y saca los objetos de los archivos de escenas convirtiéndolos en prefabs. Por último, divide los archivos de escena en varias escenas más pequeñas, pero recuerda que esto puede requerir herramientas adicionales.

Automatización de procesos para pruebas de código

Si pronto formarás un equipo de, por ejemplo, 10 personas o más, entonces deberás trabajar en la automatización de procesos.

Como programador creativo, quieres hacer el trabajo único y cuidadoso y dejar que la automatización se encargue de la mayor parte posible de las partes repetitivas.

Para empezar, escribe pruebas para tu código. Específicamente, si estás sacando cosas de los MonoBehaviours para enviarlas a clases comunes, es muy sencillo utilizar un marco de trabajo de unidades de prueba para crear unidades de prueba tanto para la lógica como para la simulación. No tiene sentido en todas partes, pero tiende a hacer que tu código sea accesible para otros programadores más adelante.

Automatización de procesos para pruebas de contenido

Las pruebas no se refieren solo al código. También quieres probar tu contenido. Si tienes creadores de contenido en tu equipo, todos funcionarán mejor si cuentan con una forma estandarizada de validar rápidamente el contenido que crean.

Los creadores de contenido deben poder acceder fácilmente a la lógica de prueba, como la validación de un prefab o la validación de algunos datos ingresados a través de un editor personalizado. Si solo pueden hacer clic en un botón del Editor y obtener una validación rápida, pronto se darán cuenta de que esto les ahorra tiempo.

El siguiente paso es configurar Unity Test Runner para repetir las pruebas de forma automática y regular. 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.

Creación de recorridos del juego (playthroughs) automatizados

Los recorridos del juego (playthroughs) automatizados implican crear una IA que pueda jugar y, luego, registrar los errores. En pocas palabras, ¡cualquier error que encuentre tu AI es uno menos que tendrás que dedicar tiempo a encontrar!

En nuestro caso, configuramos alrededor de 10 clientes de juego en la misma máquina, con la configuración de detalles más baja, y los ejecutamos todos. Los vimos estrellarse y luego revisamos los registros. Cada vez que uno de estos clientes se bloqueaba, nos ahorrábamos el tiempo, ya que no teníamos que jugar el juego nosotros mismos, ni hacer que alguien más lo hiciera, para encontrar errores. Eso significó que, cuando probamos el juego nosotros mismos y con otros jugadores, pudimos enfocarnos en si era divertido, dónde estaban las fallas visuales, etc.

Cómo construir el código conforme crece tu proyecto | Evitar deudas técnicas | Unity