Programación de videojuegos con 5 principios SOLID, patrones de diseño y buenas prácticas
En el desarrollo de videojuegos, crear un título único y entretenido es tan importante como asegurarse de que el proceso de desarrollo sea eficiente, mantenible y escalable, en especial, juegos que vayan a tener un LTV largo (Life Time Value). Los títulos con un LTV largo suelen ser aquellos realizados por empresas o estudios que desean rentabilizar sus proyectos durante mucho tiempo. Aquí es donde los principios SOLID, un conjunto de cinco principios de diseño orientado a objetos, los patrones de diseño y las buenas prácticas, se convierten en herramientas indispensables para cualquier desarrollador de juegos. Al aplicar dichas técnicas podemos crear una aplicación más estable, durarera, escalable y mantenible, permitiendo al dueño del proyecto explotar su rendimiento a largo plazo.
Vamos a utilizar el ejemplo para crear un hipotético videojuego «Shoot ‘em up» que cumpliera con estas necesidades, no solo podemos mejorar la calidad del código, sino también facilitar la adición de nuevas características y la colaboración entre los miembros del equipo.
Escucha este artículo con el audio generado para mayor accesibilidad (Inglés)
S – Single Responsibility Principle (SRP)
El principio de Responsabilidad Única perteneciente a SOLID sostiene que una clase debe tener una y solo una razón para cambiar. Este principio promueve la modularidad, facilitando el testing y la mantenibilidad del código.
Aplicación en un «Shoot ‘em up» que deseamos crear
Imaginemos que estamos desarrollando un juego de naves espaciales donde el jugador debe esquivar y destruir enemigos mientras avanza a través de niveles. Siguiendo el SRP, dividimos las responsabilidades en diferentes clases: una clase PlayerController para gestionar la entrada del usuario, PlayerMovement para el movimiento de la nave, y PlayerHealth para el seguimiento de la salud del jugador. Esta separación hace que cada componente sea más fácil de entender, probar y modificar de forma aislada.
public class PlayerController : MonoBehaviour {
private PlayerMovement movement;
private PlayerHealth health;
void Update() {
movement.HandleMovement(Input.GetAxis("Horizontal"), Input.GetAxis("Vertical"));
if (Input.GetKeyDown(KeyCode.Space)) {
// Lógica de disparo
}
}
}
O – Open/Closed Principle (OCP)
El principio Abierto/Cerrado de SOLID insta a que las entidades de software (clases, módulos, funciones, etc.) deben estar abiertas para la extensión, pero cerradas para la modificación. Esto significa que deberíamos ser capaces de agregar nuevas funcionalidades sin cambiar el código existente.
En nuestro juego de naves clásico, podríamos tener una variedad de enemigos con diferentes comportamientos. Implementando el OCP, podemos diseñar un sistema donde los nuevos tipos de enemigos se pueden agregar sin alterar el sistema de enemigos existente. Esto se puede lograr mediante la creación de una clase base abstracta Enemy y subclases específicas para cada tipo de enemigo.
public abstract class Enemy {
public abstract void Attack();
}
public class KamikazeEnemy : Enemy {
public override void Attack() {
// Implementación específica del ataque kamikaze
}
}
Al adherirnos al SRP y OCP, no solo facilitamos la mantenibilidad y expansión de nuestro juego, sino que también establecemos una sólida base para aplicar los demás principios SOLID. La modularidad resultante de seguir el SRP nos permite extender nuestro juego fácilmente conforme lo dicta el OCP, sin incurrir en el costo de modificar extensamente el código existente.
Este enfoque inicial sobre los principios SOLID sienta las bases para un desarrollo de videojuegos más estructurado y mantenible. Al contemplar estos principios desde la etapa de diseño, los desarrolladores pueden crear juegos más robustos y flexibles, preparados para crecer y evolucionar con las demandas del proyecto durante todo su LTV.
Continuando con nuestro enfoque en los Principios SOLID aplicados al desarrollo de un videojuego «Shoot ‘em up», profundizamos en el Liskov Substitution Principle (LSP), el Interface Segregation Principle (ISP) y el Dependency Inversion Principle (DIP). Estos principios, al igual que el SRP y OCP, juegan roles cruciales en la construcción de un código robusto, flexible y mantenible.
L – Liskov Substitution Principle (LSP)
El Principio de Sustitución de Liskov correspondiente a SOLID establece que los objetos de un programa deben ser reemplazables por instancias de sus subtipos sin alterar la correcta ejecución del programa. En otras palabras, las subclases deben ser sustituibles por sus clases base.
En el contexto de nuestro juego Shoot ‘em up, este principio se puede ilustrar con el sistema de armamento. Supongamos que cada nave, tanto del jugador como de los enemigos, puede equiparse con diferentes tipos de armas. Implementando el LSP, diseñamos una clase base Weapon con una operación Fire que todas las armas concretas implementan. Tanto las naves del jugador como las de los enemigos pueden referirse a sus armas mediante la abstracción Weapon, permitiendo cambiar dinámicamente las armas sin afectar el funcionamiento de las naves.
public abstract class Weapon {
public abstract void Fire();
}
public class LaserGun : Weapon {
public override void Fire() {
// Lógica específica de disparo láser
}
}
public class MissileLauncher : Weapon {
public override void Fire() {
// Lógica específica de lanzamiento de misiles
}
}
Con el LSP, aseguramos que el sistema de armas sea extensible y fácilmente modificable, manteniendo la coherencia y la funcionalidad del juego.
I – Interface Segregation Principle (ISP)
El Principio de Segregación de Interfaces de SOLID sugiere que ningún cliente debería verse forzado a depender de interfaces que no utiliza. Este principio promueve interfaces pequeñas y específicas sobre una única interfaz genérica.
Aplicando el ISP en nuestro juego, podríamos tener diferentes interfaces para los diversos aspectos de las naves, como movimiento y ataque. Por ejemplo, una interfaz IMovable para el movimiento y una interfaz IAttackable para el ataque. Esto permite que diferentes tipos de naves implementen solo las funcionalidades que requieren, evitando la dependencia de métodos innecesarios.
public interface IMovable {
void Move();
}
public interface IAttackable {
void Attack();
}
public class PlayerShip : IMovable, IAttackable {
public void Move() {
// Implementación del movimiento del jugador
}
public void Attack() {
// Implementación del ataque del jugador
}
}
Este enfoque no solo mejora la organización del código sino que también aumenta su claridad, haciendo más explícitas las capacidades de las distintas entidades del juego.
D – Dependency Inversion Principle (DIP)
El Principio de Inversión de Dependencia asociado a SOLID aconseja que las dependencias en el código deben ser en abstracciones, no en concreciones. Es decir, las clases de alto nivel no deben depender de clases de bajo nivel, sino de interfaces o clases abstractas.
En nuestro «Shoot ‘em up», este principio puede aplicarse al sistema de control de niveles o al gestor de estados del juego. En lugar de que los componentes del juego dependan directamente de implementaciones concretas para cargar niveles o cambiar estados, pueden depender de abstracciones. Esto facilita, por ejemplo, la introducción de nuevos tipos de niveles o cambios en la lógica de transición entre estados del juego sin necesidad de modificar los componentes que dependen de estas funcionalidades.
public interface ILevelLoader {
void LoadLevel(int levelNumber);
}
public class LevelLoader : ILevelLoader {
public void LoadLevel(int levelNumber) {
// Carga el nivel especificado
}
}
Al seguir el DIP, el código se vuelve más flexible y menos acoplado, permitiendo que las modificaciones y la expansión del juego sean más manejables.
Estos principios SOLID, cuando se aplican cuidadosamente, pueden transformar radicalmente el desarrollo de un videojuego, llevándolo de ser simplemente funcional a ser bien estructurado, extensible y fácilmente mantenible. En el contexto de un «Shoot ‘em up», estos principios nos ayudan a construir un juego que puede crecer con nuestras ideas, facilitando la adición de nuevas características y mejorando la colaboración en equipo.
Ahora que hemos establecido cómo los principios SOLID pueden fundamentar el desarrollo de un videojuego robusto y mantenible, profundicemos en el mundo de los patrones de diseño. Estas estrategias consolidadas y probadas por muchos expertos/as anteriormente ofrecen soluciones a problemas comunes en el diseño de software, y su aplicación puede ser especialmente potente en el desarrollo de videojuegos, incluido un «humilde» jueguecito de naves «Shoot ‘em up» que podriamos ir expandiendo y ampliando con nuevas actualizaciones, versiones y re-ediciones para sacarle partido a largo plazo ¿no os parece?
Patrones de Diseño en el Desarrollo de Videojuegos
Los patrones de diseño no solo complementan los principios SOLID, sino que también proporcionan un lenguaje común para los desarrolladores, permitiendo un entendimiento más profundo y una solución más clara a los desafíos de diseño que surgen durante el desarrollo de un juego.
Singleton: Un Control Centralizado
El patrón Singleton asegura que una clase tenga una única instancia en todo el programa, ofreciendo un punto de acceso global a esa instancia. Es ideal para gestionar recursos o configuraciones que deben ser accesibles de manera uniforme y centralizada.
Sigamos con nuestro humilde pero elegante ^^ «Shoot ‘em up»
Imagina un GameManager que controla el flujo del juego, como seguir la puntuación, manejar las transiciones de nivel y gestionar el estado del juego (por ejemplo, jugando, en pausa, o game over). Utilizar el Singleton para el GameManager garantiza que estas funciones críticas estén coordinadas desde un único punto de referencia.
public class GameManager {
private static GameManager instance;
public static GameManager Instance {
get {
if (instance == null) {
instance = new GameManager();
}
return instance;
}
}
// Ejemplo de método dentro del GameManager
public void EndGame() {
// Lógica para terminar el juego
}
}
Factory Method: Flexibilidad en la Creación de Objetos
El patrón Factory Method define una interfaz para crear un objeto, pero deja que las subclases decidan qué clase instanciar. Facilita la adición de nuevos tipos de objetos sin cambiar el código existente que los usa.
Considera el diseño de un sistema de enemigos. Podrías tener diferentes tipos de enemigos con comportamientos únicos (por ejemplo, enemigos que atacan directamente, enemigos que escapan, etc.). Implementar un EnemyFactory permite crear diferentes enemigos según las necesidades del nivel actual, manteniendo la lógica de creación separada y fácilmente modificable.
public abstract class EnemyFactory {
public abstract Enemy CreateEnemy();
}
public class DirectAttackEnemyFactory : EnemyFactory {
public override Enemy CreateEnemy() {
return new DirectAttackEnemy();
}
}
public class EvadeEnemyFactory : EnemyFactory {
public override Enemy CreateEnemy() {
return new EvadeEnemy();
}
}
public class Player {
void OnEnable() {
PowerUp.OnPowerUpPicked += GainPowerUp;
Enemy.OnDamageTaken += TakeDamage;
}
void GainPowerUp(PowerUp powerUp) {
// Lógica para manejar el power-up recogido
}
void TakeDamage(int damage) {
// Lógica para manejar el daño recibido
}
}
La aplicación de patrones de diseño como Singleton, Factory Method y Observer refuerza los principios SOLID e introduce un nivel de abstracción y flexibilidad que permite a los desarrolladores concentrarse en la creatividad y la innovación, sabiendo que tienen una base sólida y extensible. Estos patrones facilitan la adición de nuevas características, la modificación de comportamientos existentes, gestión de estados, eventos complejos en el juego, todo mientras se mantiene el código organizado, mantenible y escalable.
Avanzando en este proyecto hipotético, es crucial identificar y rectificar las malas prácticas. Estas acciones no solo mejoran el código actual, sino que también previenen complicaciones futuras, haciendo que el mantenimiento y la expansión del juego sean más manejables.
Transformando Malas Prácticas en Buenas
1. Acoplamiento Excesivo
Mala Práctica: Un sistema de juego donde los enemigos necesitan conocer y modificar directamente el estado del jugador, como su salud o munición, crea un acoplamiento excesivo. Este diseño hace que el código sea frágil y difícil de modificar o extender.
public class Enemy {
public void AttackPlayer(Player player) {
player.health -= 10; // Acceso directo y modificación del estado del jugador
}
}
Buena Práctica: Implementar un sistema de mensajes o eventos reduce el acoplamiento, promoviendo un diseño más modular y flexible.
public class Player : MonoBehaviour {
public static event Action OnPlayerDamaged;
public void TakeDamage(int damage) {
OnPlayerDamaged?.Invoke(damage);
}
}
public class Enemy {
public void AttackPlayer() {
Player.OnPlayerDamaged?.Invoke(10);
}
}
Referencias Directas y Gestión de Dependencias
Mala Práctica: Usar referencias directas para acceder o modificar el estado de otros objetos en el juego, como un enemigo que directamente pausa el GameManager, no solo es una señal de acoplamiento excesivo sino que también complica el manejo de dependencias.
public class Enemy {
void OnDeath() {
GameManager.instance.PauseGame(); // Referencia directa al GameManager
}
}
Buena Práctica: Invertir las dependencias utilizando interfaces o eventos permite que el código sea más reutilizable y sus componentes, más independientes entre sí.
public interface IGameController {
void PauseGame();
}
// GameManager implementa IGameController
public class GameManager : MonoBehaviour, IGameController {
public static IGameController instance { get; private set; }
void Awake() {
instance = this;
}
public void PauseGame() {
// Lógica para pausar el juego
}
}
public class Enemy {
void OnDeath() {
GameManager.instance.PauseGame();
}
}
Violación del Single Responsibility Principle (SRP)
Mala Práctica: Una clase que gestiona múltiples responsabilidades, como un PlayerController que maneja la entrada del jugador, su movimiento, ataque y salud, contradice el SRP, haciendo que el código sea más difícil de mantener y extender.
public class PlayerController : MonoBehaviour {
void Update() {
HandleInput();
MovePlayer();
Attack();
CheckHealth();
}
// Métodos para manejar la entrada, movimiento, ataque y salud...
}
Buena Práctica: Separar estas responsabilidades en clases distintas mejora la cohesión y facilita la gestión y expansión del código.
public class PlayerInput : MonoBehaviour {
void Update() {
HandleInput();
}
}
public class PlayerMovement : MonoBehaviour {
public void Move(Vector3 direction) {
// Lógica de movimiento
}
}
public class PlayerAttack : MonoBehaviour {
public void Attack() {
// Lógica de ataque
}
}
public class PlayerHealth : MonoBehaviour {
public void TakeDamage(int damage) {
// Lógica para gestionar la salud
}
}
Al abordar estas malas prácticas y adoptar soluciones más robustas, no solo mejoramos la calidad y mantenibilidad del código de nuestro juego sino que también establecemos una base sólida para futuras expansiones y modificaciones.
Por otro lado al reflexionar sobre cómo los principios SOLID, los patrones de diseño y la rectificación de malas prácticas impactan en el desarrollo de videojuegos, es crucial reconocer su influencia no solo en la calidad del código sino también en la experiencia final del jugador. Estos aspectos técnicos, aunque pueden parecer distantes de la jugabilidad y la experiencia, son fundamentales para construir un juego que no solo sea divertido de jugar, sino también rico en características, estable y receptivo a la expansión futura.
Impacto en la Calidad del Juego y la Experiencia del Jugador
Estabilidad y Rendimiento
Una base de código bien estructurada y mantenible contribuye directamente a la estabilidad del juego. Los principios SOLID y los patrones de diseño fomentan la creación de sistemas desacoplados y cohesivos, lo que reduce la probabilidad de errores y comportamientos imprevistos. Esto significa menos fallos y bloqueos para el jugador, asegurando una experiencia de juego fluida y agradable.
Extensibilidad y Características Nuevas
La capacidad de añadir nuevas características sin alterar significativamente el código existente (facilitada por el OCP y el uso de patrones como Factory Method) permite a los desarrolladores introducir nuevos elementos de juego, niveles, enemigos y power-ups con relativa facilidad. Esto no solo agiliza el proceso de desarrollo, sino que nos permite actualizar el proyecto más frecuentemente y enriquecer la experiencia del jugador con contenido fresco y variado, manteniendo el juego interesante y atractivo a lo largo del tiempo.
Personalización y Modificación
Un código bien organizado y modular abre la puerta a niveles más profundos de personalización y modificación por parte de la comunidad. Los jugadores y creadores de mods porían entender y modificar el juego con mayor facilidad si llegamos a implementar esta capa para modders, lo que potencia una comunidad activa y comprometida alrededor del juego. Esto no solo prolonga la vida útil del juego, sino que también genera un vínculo más fuerte entre los desarrolladores y la comunidad de jugadores.
Mejora en la Experiencia de Juego
La aplicación del LSP y el DIP, por ejemplo, asegura que los sistemas de juego sean flexibles y capaces de adaptarse a las necesidades de diferentes tipos de jugadores. Esto puede traducirse en una personalización más profunda de las opciones de juego, permitiendo a los jugadores ajustar su experiencia según sus preferencias personales, esto nos permitiría realizar versiones y adaptaciones del proyecto para jugadores más exigentes, más casuales, plataformas de un tipo o de otro considerando las diferentes necesidades para cada contexto. Por ejemplo, si el juego tiene controles táctiles para tabletas o smartphones es posible que la dificultad deba ser algo más baja y el ritmo de juego un poco más sencillo, en cambio si el juego es para PC/consola es posible que el jugador sea más exigente y quiera un ritmo más frenético y un control más preciso del juego.
Iteración Rápida en el Desarrollo
La capacidad de iterar rápidamente, facilitada por un código limpio y mantenible, permite a los desarrolladores probar nuevas ideas y mecánicas de juego sin temor a romper partes existentes del juego. Esto fomenta un ambiente creativo donde la innovación es recompensada, lo que se refleja en un producto final más original y emocionante para el jugador.
En última instancia, estos principios y prácticas son herramientas en las manos de los creadores de juegos, destinadas a dar vida a visiones creativas sin las limitaciones impuestas por un código desorganizado o inflexible. Al adoptar estas estrategias, los equipos de desarrollo pueden enfocarse en lo que mejor saben hacer: crear experiencias de juego memorables y emocionantes que cautivan a los jugadores y dejan una marca duradera en el panorama del entretenimiento interactivo.
Muy bien, pero… no caigamos en la Sobreingenieria
La sobreingeniería, si recordáis igual que el Imperio en Star Wars cuando construyo su Estrella de la Muerte, tenía un punto débil que olvidaron considerar, provocando el desastre absoluto.
El acto de hacer un sistema más complicado de lo necesario, es una trampa común en el desarrollo de software, incluido el desarrollo de videojuegos. Si bien es importante seguir buenas prácticas y principios de diseño, también es crucial encontrar el equilibrio adecuado para no añadir complejidad innecesaria. Aquí tienes algunas situaciones en las que es preferible evitar la sobreingeniería:
Prototipos y Pruebas de Concepto
Cuando estás explorando ideas nuevas y construyendo prototipos o pruebas de concepto, la velocidad y la agilidad son clave. En estas fases iniciales, el objetivo es validar ideas rápidamente, no construir un sistema perfecto y completamente escalable. La sobreingeniería puede retrasar este proceso y limitar la experimentación.
Proyectos con Requisitos Cambiantes
En el desarrollo de juegos, los requisitos pueden cambiar frecuentemente, especialmente en equipos pequeños o en proyectos indie. Si inviertes demasiado tiempo en construir una arquitectura compleja desde el principio, puedes encontrarte necesitando rehacer gran parte del trabajo cuando los requisitos cambien. Mantén las cosas simples y adaptables, y refina tu arquitectura a medida que el proyecto se vuelve más estable.
Añadir Complejidad sin Valor evidente
Cualquier característica o sistema que desarrolles debe tener un propósito claro y añadir valor al proyecto. Si te encuentras añadiendo complejidad por el simple hecho de seguir un principio o patrón de diseño sin que aporte beneficios tangibles al proyecto, probablemente estés cayendo en la sobreingeniería. Evalúa constantemente el costo-beneficio de cada decisión de diseño.
Optimización Prematura
La optimización prematura, o el esfuerzo por hacer que tu código sea lo más eficiente posible desde el principio, es una forma de sobreingeniería. En las primeras etapas del desarrollo, es más importante que el sistema funcione correctamente y sea fácil de modificar. La optimización debe abordarse una vez que el sistema esté funcionando y hayas identificado cuellos de botella reales a través de perfiles de rendimiento.
En Proyectos con Recursos Limitados
Si trabajas en un equipo pequeño o con un presupuesto limitado, la sobreingeniería puede agotar rápidamente tus recursos sin proporcionar un retorno de la inversión proporcional. En estos casos, es esencial priorizar y centrarse en las características y sistemas que tienen el mayor impacto en la experiencia del jugador.
Evitar la sobreingeniería no significa ignorar las buenas prácticas de diseño y desarrollo. Más bien, se trata de aplicar esos principios de manera pragmática, con un enfoque claro en los objetivos del proyecto, las necesidades del usuario final, y la eficiencia del proceso de desarrollo. Encuentra el equilibrio entre hacerlo «correcto» y hacerlo «simple», y recuerda que a menudo es posible iterar y mejorar el diseño a medida que el proyecto evoluciona.
Si te ha gustado este artículo y estás interesado en profundizar en SOLID, patrones de diseño, separación de código y buenas prácticas para crear videojuegos más profesionalmente, te invitamos a explorar nuestros Bootcamps de Programación Avanzada de Videojuegos con Unity y Programación Avanzada de Videojuegos con Unreal. En Level Up, entendemos la importancia de una sólida formación técnica combinada con una aplicación práctica, especialmente en el dinámico campo del desarrollo de videojuegos. A lo largo del bootcamp, trabajarás mano a mano con profesionales de la industria, sumergiéndote en un aprendizaje intensivo que te preparará para enfrentar los desafíos reales del desarrollo de videojuegos. Adquirirás conocimientos prácticos y experiencia directa en todas las fases del proceso de desarrollo. Happy coding!
![Level Up [Game Dev Hub]](https://www.levelup-gamedevhub.com/wp-content/uploads/2022/06/Logo_LevelUp_HUB_Positivo-2.png)

