¿Aprendiendo a programar para MS-DOS con DIV2 Games Studio? ¡Hagámos un bullet hell! (capítulo 3)
En esta tercera entrega de la serie de programación de DIV, continuaremos con el enfoque establecido en la entrada anterior, partiendo de un prototipo básico y progresando gradualmente hacia la creación de un juego atractivo.... y en este caso tenemos incluso un juego COMPLETO (sí, en mayúsculas). Este capítulo incorpora el feedback recibido de la entrada anterior y se centra en mejorar la legibilidad del código, asegurando que los cambios entre las distintas versiones del ejercicio sean fácilmente identificables y además esta vez lo he escrito en castellano. Aún así prepararos un bocadillo y un café bien cargado, que los ejemplos finales no son sencillos.
Si eres nuevo, o no estás familiarizado con Div Games Studio, se trata de un entorno de programación de videojuegos para MS-DOS lanzado en 1998. Este lenguaje de programación es procedural, no orientado a objetos, y ofrece numerosas características atractivas para su época, tales como el uso de estructuras, punteros, reserva dinámica de memoria (con malloc/free) y una curva de aprendizaje relativamente sencilla. De hecho, mucha gente aprendió a programar con este lenguaje en España y se hizo tan popular a finales de los 90 que incluso llegaron a comercializarse revistas comerciales dedicados a este framework.
Uno de los aspectos más destacados del feedback recibido de la entrada anterior fueron las dudas sobre la utilidad de las estructuras, cómo diferenciar distintos tipos de enemigos o la eficiencia de implementar un proceso unificado para el dibujo, en lugar de permitir que cada instancia dibuje su propio gráfico. Esta reflexión me llevó a considerar el desarrollo de un juego de naves, similar a Space Invaders o Galaga, puesto que son elementos comunes en este tipo de juegos, donde debemos de dibujar grandes tipos de enemigos, de distintos tipos y evitando que el juego se ralentice.
Así que empecé a trabajar en un programa llamado "vshooter" de "Vertical Shooter", en honor al género de este tipo de juegos de marcianitos. Como siempre, recordad que todos estos ejemplos de código los tengo subidos en mi Github (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT01.PRG).
// Arrancamos el juego a 320x200 a 60 FPS con la paleta cargada
set_mode(m320x200);// 320x200
set_fps(60,0);
load_pal("DIV2.PAL");
// Creamos la nave del jugador
jugador();
// Bucle principal del juego
LOOP
FRAME;
END
END
// Proceso: jugador
// Descripción: Controla la nave del jugador.
// - Maneja el dibujo, movimientos y disparos en un bucle continuo.
PROCESSjugador();
PRIVATE
vel=3;// Velocidad de movimiento
tiempo_disparo=0;// Contador para controlar la frecuencia de disparos
// El dibujo de la nave se compone de varias partes
nave_circulo;// ID del dibujo del círculo de la nave
nave_linea1;// ID del dibujo de la primera línea de la nave
nave_linea2;// ID del dibujo de la segunda línea de la nave
nave_linea3;// ID del dibujo de la tercera línea de la nave
BEGIN
// Posición inicial de la nave en el centro inferior de la pantalla
x=160;// Centro horizontal
y=170;// Cerca del fondo
// Bucle principal del jugador
LOOP
// Dibujamos la nave del jugador
// Borrar los dibujos anteriores
IF(nave_circulo!=0)delete_draw(nave_circulo);END
IF(nave_linea1!=0)delete_draw(nave_linea1);END
IF(nave_linea2!=0)delete_draw(nave_linea2);END
IF(nave_linea3!=0)delete_draw(nave_linea3);END
// Dibujar la nave
nave_circulo=draw(5,31,15,0,x-8,y-8,x+8,y+8);
nave_linea1=draw(1,31,15,0,x-6,y+8,x,y-10);
nave_linea2=draw(1,31,15,0,x+6,y+8,x,y-10);
nave_linea3=draw(1,31,15,0,x-6,y+8,x+6,y+8);
// Movimiento de la nave
IF(key(_left)ANDx>10)x-=vel;END
IF(key(_right)ANDx<310)x+=vel;END
IF(key(_up)ANDy>10)y-=vel;END
IF(key(_down)ANDy<190)y+=vel;END
// Disparar
tiempo_disparo++;
IF(key(_space)ANDtiempo_disparo>5)
// Crear bala
bala_jugador(x,y-15);
tiempo_disparo=0;
END
// Salir con ESC
IF(key(_esc))
EXIT("Gracias por jugar.",0);
END
FRAME;// Esperar al siguiente frame
END
END
// Proceso: bala_jugador
// Descripción: Controla el movimiento de una bala disparada por el jugador.
// - Se mueve hacia arriba y se elimina si sale de la pantalla.
PROCESSbala_jugador(x,y);
PRIVATE
bala_draw;// ID del dibujo de la bala
BEGIN
LOOP
// Borramos dibujo anterior si existe
IF(bala_draw!=0)delete_draw(bala_draw);END
// Dibujamos la bala
bala_draw=draw(5,10,15,0,x-3,y-3,x+3,y+3);
// Movemos la bala hacia arriba
y-=5;
// Eliminamos la bala si sale de la pantalla
IF(y<-10)RETURN;END
FRAME;// Esperar al siguiente frame
END
END
Este ejemplo que os he puesto es terriblemente sencillo: Manejamos una especie de botijo azul, que se desplaza pulsando los cursores (las teclas de arriba/abajo/izquierda/derecha), dispara balas redondas pulsando espacio y salimos del juego pulsando escape. Ya está, no tiene nada más, tienes un proceso de jugador, un proceso de bala_jugador, ambos se dibujan en pantalla con primitivas gráficas (ni siquiera sprites) y hacemos uso de una variable de "tiempo_disparo" para controlar que las balas se disparen por ráfagas.
Cosas chulas que no suelen verse: Si os fijáis en la bala_jugador, tenemos un bucle infinito (LOOP) que paramos con un RETURN. Aquí no actúa como "BREAK" del bucle, si no que indica que la ejecución del proceso acaba ahí. Sencillamente, cuando quieras forzar que una instancia deje de "vivir", la puedes "matar" con un return.
Ahora vamos a añadir enemigos. De hecho, vamos a añadir 2, uno de cada tipo y tendrán patrones de movimiento distinto. Uno se desplazará de derecha a izquierda y de izquierda a derecha y cada vez que llegue a un extremo de la pantalla bajará ligeramente. El otro se moverá haciendo ligeros círculos, casi imperceptibles (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT02.PRG).
Para ello añadiremos una instancia de cada tipo de enemigo en el main:
PROGRAMvshooter;
// Programa principal
BEGIN
// Arrancamos el juego a 320x200 a 60 FPS con la paleta cargada
set_mode(m320x200);// 320x200
set_fps(60,0);
load_pal("DIV2.PAL");
// Creamos la nave del jugador
jugador();
// Creamos 2 enemigos: uno tipo 1 y uno tipo 2
enemigo_tipo1(50,30);
enemigo_tipo2(200,40);
// Bucle principal del juego
LOOP
FRAME;
END
END
E implementamos ambos enemigos:
// Proceso: enemigo_tipo1
// Descripción: Controla el comportamiento de un enemigo tipo 1.
// - Se mueve horizontalmente, dispara balas y verifica si muere o llega abajo.
Si os fijáis, a nivel de implementación son muy parecidos. Tienen sus
parámetros y verificaciones propias, pero a su vez ambos tienen una estructura parecida, donde llamamos a un proceso de dibujo y controlamos su movimiento:
// Función: dibujar_enemigo_tipo1
// Descripción: Dibuja el enemigo tipo 1, borrando los dibujos anteriores.
// Parámetros:
// - box_offset: Puntero al ID de la caja. -> Usamos el puntero para modificar la variable original.
// - circulo_offset: Puntero al ID del círculo. -> Usamos el puntero para modificar la variable original.
// - x: Posición x.
// - y: Posición y.
// Los punteros sirven para modificar las variables originales fuera de la función.
// - Si modificas una varariable del puntero dentro de la función, se refleja fuera también.
// - Sirve para evitar el uso de variables globales innecesarias.
// Los PROCESS se ejecutan en paralelo, mientras que las FUNCTION se ejecutan de forma secuencial
// - Es decir, hasta que no termine esta función, el proceso que lo llama queda bloqueado
- Dividir el objeto, el comportamiento y su dibujado en procesos distintos. Esto suele hacerse para para reducir la complejidad del código, haciendo que sea más sencillo de leer (no es lo mismo un proceso gigante que tres pequeños) y de mantener.
- Hago uso de punteros tanto para el dibujado como para el movimiento de los enemigos. ¿Qué es un puntero? Digamos que tanto la variable "x" de enemigo, como la variable "x_offset" de mover_enemigo, comparten la misma región de memoria. O dicho de otra forma, si modificas una de esas dos variables, estás modificando realmente ambas variables a la vez. Esto es muy útil si quieres que varios procesos "comuniquen" entre ellos en tiempo real, sin tener que recurrir a variables globales.
¿Se puede hacer todo eso por valor en vez de punteros? Sí. ¿Estoy complicando el código innecesariamente? También. Pero pensad que ahora mismo sólo tenemos dos enemigos en pantalla. Mañana tendremos 160.
Bueno, pues ahora que ya tenemos un jugador, balas y enemigos, podemos plantearnos ya hacer un juego: Pongamos más enemigos en pantalla, que disparen con patrones distintos, explosiones y que el jugador y los enemigos mueran al colisionar con las balas. Atentos, que se vienen curvas (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT03.PRG).
En este ejemplo (el tercero), si hacéis un diff con el anterior veréis que hay muchísimos cambios, en gran parte porque, además de añadir todo lo que os voy a describir, he remplazado muchos "magic numbers" por constantes. Y también me lié y cambié de lugar varias funciones. ¿Qué es un "magic number"? Pues ni más ni menos que todos los valores "hardcodeados" que veis por el código. Está claro que si quieres centrar un proceso en una pantalla de 320x200, puedes directamente decir que su posición X es 160 y su posición Y es 100, pero en programación los llamamos números "mágicos" porque sacados de contexto no significan nada. Imagínate dentro de 6 meses releyendo tu código: "¿Por qué puse aquí 80? ¿Por que sí y punto?". Pues esos números mágicos o cadenas mágicas (para el caso de los strings) se corrigen poniendo constantes. La constante nos permite aplicar un nombre que le otorga un contexto en lectura y que, cuando se compila, no se trata como si fuera una variable, si no que el compilador ya mete directamente el valor esperado donde corresponda.
En este ejemplo, para crear enemigos simplemente he creado un proceso independiente en el que llamo a un bucle FROM para crear 5 instancias de cada enemigo. Cosas a tener en cuenta: A diferencia del bucle FOR, el FROM sólo permite operar con constantes, entre otras cosas porque lo que hace el compilador es repetir X veces el bloque de instrucciones que contenga dicho bucle. O dicho de otra forma, no hay un componente condicional que haga variar el número de iteraciones que tendrá el bucle.
// Función: crear_enemigos
// Descripción: Crea los procesos de enemigos al inicio de cada nivel.
// - Genera 5 enemigos tipo 1 (a la izquierda) y 5 tipo 2 (a la derecha).
PROCESScrear_enemigos();
PRIVATE
i;// Variable para el bucle
BEGIN
// Crea 5 enemigos tipo 1 en posiciones espaciadas en la izquierda
FROMi=0TO4;
enemigo_tipo1(ESPACIADO_ENEMIGO1_X+i*ESPACIADO_ENEMIGO1_X,ESPACIADO_ENEMIGO1_Y_INICIAL+i*ESPACIADO_ENEMIGO1_Y);// Posición x e y calculada
END
// Crea 5 enemigos tipo 2 en posiciones espaciadas en la derecha
FROMi=0TO4;
enemigo_tipo2(ESPACIADO_ENEMIGO2_X_INICIAL+i*ESPACIADO_ENEMIGO2_X,ESPACIADO_ENEMIGO2_Y_INICIAL+i*ESPACIADO_ENEMIGO2_Y);// Posición x e y calculada
END
END
Luego, defino también una función de de iniciar_nivel, que me permite llamar a ese crear enemigos, inicializar variables para la partida, invocar al jugador y le he implementado una cuenta regresiva de "3, 2, 1... GO!". Si os fijáis aquí, estoy definiendo una función y no un proceso, pero a dicha función le meto instancias de "FRAME". ¿Por qué? ¿Las funciones no son métodos que devuelven resultados? Pues esto es uno de los trucos que nos permite Div Games Studio: Las funciones, en la práctica, tienen un comportamiento identico a los procesos (aka "instancias"), pero bloquean la ejecución de su proceso padre. Es decir, hasta que incio_nivel no termine de ejecutarse, el proceso que lo llama (en este caso el bucle principal del juego) para su ejecución.
// Función: inicio_nivel
// Descripción: Inicializa un nuevo nivel mostrando una cuenta regresiva (3, 2, 1, GO!) y creando los procesos de enemigos y jugador.
// - Desactiva controles durante la cuenta y los activa al final.
// Los PROCESS se ejecutan en paralelo, mientras que las FUNCTION se ejecutan de forma secuencial
// - Aunque tenga FRAME dentro, es una función y bloquea el proceso que la llama (hack de DIV)
// - Es decir, hasta que no termine esta función, el proceso que lo llama queda bloqueado
FUNCTIONinicio_nivel();
PRIVATE
INTcontador=3;// Cuenta regresiva: 3, 2, 1, GO!
INTtiempo=0;// Contador de frames
STRINGcontador_str;// String para mostrar contador
BEGIN
// Desactivamos controles durante la cuenta regresiva
controles_activos=0;
// Creamos naves pero sin movimiento
crear_enemigos();// Creamos enemigos
jugador();// Crear jugador
// Esperar un momento para que se vean las naves
FRAME;
FRAME;
// Cuenta regresiva 3, 2, 1, GO!
WHILE(contador>0)
contador_str=itoa(contador);// Convertimos el número a string (itoi = integer to ascii)
delete_text(all_text);// Limpiamos todos los textos
mostrar_textos_hud();// Volmemos a dibujar los textos del HUD
controles_activos=1;// Activar controles para empezar el juego
END
De hecho, también defino funciones "bloqueantes" para calcular las colisiones del jugador con las balas, con sus enemigos y de los enemigos con las balas y el jugador. El algoritmo de la colisiones o es muy complicado, pero necesita un punto de atención en get_id(type tipo_instancia). El funcionamiento de get_id es muy peculiar: Si tienes varias instancias de un tipo de enemigo, te devuelve sólo el ID de la instancia del primer enemigo... pero si vuelves a usar get_id te devuelve el del segundo enemigo... y si lo vuelves a usar te devuelve el del tercer enemigo... y llega un momento que devolverá 0 (cuando no queden más identificadores en la cola). La gracia es que cada vez que el proceso llama a "FRAME", esa cola de identificadores se reinicia y si lo usas vuelve a darte el primer enemigo de la cola. Sabiendo esto, podemos crear bucles que recorran todos los enemigos que hay en pantalla desde una función externa y comprobar si entra dentro de un área (las famosas "hitboxes) de los videojuegos:
// Función: comprobar_colision_jugador_enemigos
// Descripción: Verifica si la nave del jugador colisiona con algún enemigo.
// - Si hay colisión, suma puntos, crea explosiones y termina el juego.
// Parámetros:
// - x: Posición x de la nave (por valor, ya que no se modifica).
// - y: Posición y de la nave (por valor, ya que no se modifica).
// Los PROCESS se ejecutan en paralelo, mientras que las FUNCTION se ejecutan de forma secuencial
// - Es decir, hasta que no termine esta función, el proceso que lo llama queda bloqueado
explosion(id_enemigo.x,id_enemigo.y,COLOR_ENEMIGO2);// Explosión del enemigo
enemigo_muerto_id=id_enemigo;// Marcar enemigo para morir
explosion(x,y,COLOR_NAVE);// Explosión del jugador
game_over=1;// Terminar juego
RETURN;
END
// Siguiente enemigo, hasta que no hagamos FRAME, get_id() devolverá el siguiente ID
// cuando no queden más, devolverá 0 y saldremos del bucle
id_enemigo=get_id(typeenemigo_tipo2);
END
END
// Función: comprobar_colision_bala_enemigos
// Descripción: Verifica si la bala del jugador colisiona con algún enemigo. Si hay colisión, suma puntos, crea explosión y marca al enemigo para morir.
// Parámetros:
// - x: Posición x de la bala.
// - y: Posición y de la bala.
// Retorna: 1 si hay colisión, 0 si no.
// Los PROCESS se ejecutan en paralelo, mientras que las FUNCTION se ejecutan de forma secuencial
// - Es decir, hasta que no termine esta función, el proceso que lo llama queda bloqueado
FUNCTIONcomprobar_colision_bala_enemigos(x,y);
PRIVATE
id_enemigo;// ID del enemigo para iterar
BEGIN
// Comprobamos si hay colisión con enemigos tipo 1
// Para ello obtenemos el primer ID de enemigo tipo 1
Con esto ya tenemos algo jugable. Ahora bien, queda feo tener un proceso por tipo de enemigo. Si mañana queremos tener 50, ¿creamos 50 procesos distintos en el código? Bueno, pues auqi viene el cuarto ejemplo, que a nivel funcional ofrece exactamente lo mismo que el anterior, pero con optiomizaciones (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT04.PRG).
Para emepzar creo una variable de tipo local llamada "tipo". Las variables locales en Div Games Studio actuan como propiedades. Es decir, todos los procesos del programa (enemigo, iniciar_nivel, jugador...) pasan a tener una variable llamada "tipo", pero esta variable, en su contexto, es independiente: Puedes crear 50 instancias de un proceso y asignarle valores disntintos a cada uno de ellos.
// Variables locales
// Las variables locales son atributos presentes en TODOS los process.
// - Su valor puede ser modificado únicamente desde su propio process
// - No obstante, podemos consultarlas desde cualquier otro process -> TIPO = enemigo.tipo
// - Sirve para falsear la orientación a objetos, actuaría como una "propiedad" del objeto en modo read only
LOCAL
INTtipo;// Tipo de enemigo (1 o 2), empleado sólo en el proceso genérico de enemigos
// Variables internas
Dicho esto, borro las definiciones de enemigo_tipo1 y enemigo_tipo2 y creo un único proceso que haga uso de esa variable local:
// Proceso: enemigo
// Descripción: Controla el comportamiento de un enemigo (tipo 1 o 2).
// - Se mueve según el tipo, dispara balas y verifica si muere.
// Parámetros:
// - x: Posición inicial x.
// - y: Posición inicial y.
// - tipo: Tipo de enemigo (1 o 2).
// (OPTIMIZACIÓN: Proceso genérico para ambos tipos de enemigos.
// Reduce duplicación de código. Usa parámetro 'tipo' para diferenciar comportamiento.
// Mejora mantenibilidad: cambios en un lugar afectan a ambos tipos.)
PROCESSenemigo(x,y,tipo);
PRIVATE
dir=1;// Para tipo 1
ang=0;// Para tipo 2
tiempo=0;
temp_id=0;// Para iterar en bucles
enemigo_draws[5];// 0: box/circulo para tipo1, 1-4: lineas para tipo2, 5: circulo tipo2
¿Podría haberme limitado a usar una variable privada tipo y pasarlo como parámetro a enemigo? Pues sí, pero me apetecía explicar que las variables locales existen y para qué sirven. De hecho, ahora que definimos la variable como local, puede ser consultada de forma externa por otras funciones y procesos, cosa que no podríamos hacer si la variable fuera privada:
// Función: comprobar_colision_bala_enemigos
// Descripción: Verifica si la bala del jugador colisiona con algún enemigo. Si hay colisión, suma puntos, crea explosión y marca al enemigo para morir.
// Parámetros:
// - x: Posición x de la bala.
// - y: Posición y de la bala.
// Retorna: 1 si hay colisión, 0 si no.
// Los PROCESS se ejecutan en paralelo, mientras que las FUNCTION se ejecutan de forma secuencial
// - Es decir, hasta que no termine esta función, el proceso que lo llama queda bloqueado
FUNCTIONcomprobar_colision_bala_enemigos(x,y);
PRIVATE
id_enemigo;// ID del enemigo para iterar
color_explosion;// Color para la explosión del enemigo
Pero no es la única optimización que podríamos aplicar. De hecho, en el ejemplo 5 seguimos sin cambiar funcionalmente el juego y nos centramos en ir aún más lejos con las optimizaciones (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT05.PRG). ¿Cómo? Pues con dos grandes conocidos del capítulo 2 (que os ha dado pesadillas): ¡Uso de estructuras y de un único hilo para dibujar a todos los enemigos!
Para empezar, ese hermosa variable local que habíamos creado nos la cepillamos y creamos una estructura genérica para los eneigos. En ella definimos su posición (x, y), el tipo de enemigo, si está vivo o muerto, la dirección de su desplazamiento, su ángulo de movimiento (para el tipo 2) y un array de enteros donde almacenar los ID de las primitivas gráficas que lo representan.
// Definición de struct para enemigo
STRUCTenemigo[10]
INTx;// Posición X
INTy;// Posición Y
INTtipo;// Tipo de enemigo (1 o 2)
INTactivo;// Si está activo (1) o muerto/inactivo (0)
INTdir;// Dirección horizontal para tipo1 (1 derecha, -1 izquierda)
INTang;// Ángulo para movimiento circular de tipo2
INTdraw[5];// Array de IDs de dibujos (5 slots por enemigo)
END
Luego, borramos el proceso enemigo y creamos un "proceso_enemigos" para gestionar TODOS los enemigos en bucle. ¡Fijaros que reutilizamos los métodos ya existentes de dibujo y movimiento (gracias a la magia de los punteros), pero recorremos todos los enemigos que hay en la estructura para ver si están vivos o muertos y actuar en consecuencia:
// Proceso: proceso_enemigos
// Descripción: Controla el comportamiento de todos los enemigos usando un STRUCT array.
// - Maneja movimiento, disparos y eliminación de enemigos tipo 1 y 2.
// (OPTIMIZACIÓN: Proceso genérico para ambos tipos de enemigos.
// Reduce duplicación de código. Usa parámetro 'tipo' para diferenciar comportamiento.
// Mejora mantenibilidad: cambios en un lugar afectan a ambos tipos.)
PROCESSproceso_enemigos();
PRIVATE
INTi,j;
INTtiempo_disparo[10];// Contador de tiempo para disparos de cada enemigo
Fijaros que también, a su vez, el código acaba siendo más sencillo cuando manejamos estructuras. Por ejemplo, reducimos aún más el tamaño de las funciones de detección colisiones:
// Función: comprobar_colision_bala_enemigos
// Descripción: Verifica si la bala del jugador colisiona con algún enemigo. Si hay colisión, retorna el index del enemigo.
// Parámetros:
// - x: Posición x de la bala.
// - y: Posición y de la bala.
// Retorna: Index del enemigo colisionado (0-4), o -1 si no hay colisión.
// Los PROCESS se ejecutan en paralelo, mientras que las FUNCTION se ejecutan de forma secuencial
// - Es decir, hasta que no termine esta función, el proceso que lo llama queda bloqueado
RETURN(i);// Retornamos el index del enemigo colisionado
END
END
END
RETURN(-1);// No hay colisión
END
Bueno, pues ahora que tenemos un juego potable y algo optimizado, podemos empezar a trabajar con sprites y aquí entra el ejemplo 6 (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT06.PRG). Este ejemplo es básicamente el anterior, pero adaptado para trabajar con sprites.
El mayor cambio reside en que, como he querido animar al personaje principal, he utilizado distintos gráficos para él en función de la tecla que pulsamos y de si estamos disparando o no. Es decir, si nos desplazamos o si disparamos, el gráfico va cambiando de forma dinámica.
Básicamente tenemos un fichero FPG para él con 16 gráficos. Los 8 primeros representan las 8 posiciones posibles en las que podemos depslazarnos (arriba, abajo, izquierda, derecha y diagonales) cuando no estamos disparando y las 8 siguientes es lo mismo, pero disparando. Sobre qué gráfico va primero, pues lo gestiono como si fueran las agujas del reloj -> 1 sería mirar arriba, 2 arriba/derecha, 3 derecha... y así hasta 8 izquierda/arriba.
// Función: calcular_grafico_jugador
// Descripción: Calcula el ID del gráfico según dirección y estado de disparo
Sobre los sprites en cuestión, decidí bastame en el universo de Saga of Tanya The Evil de Carlo Zen (su mangaka, Chika Tojo, me cae genial). Su protagonista, Tanya, la he modelado varias veces en Blender y decidí hacer uso de una versión modificada que subí a sketchfab en verano y que, pese a ser uno de mis modelos preferidos y de los que más orgulloso estoy, lleva la friolera de cero likes (https://sketchfab.com/3d-models/tanya-degurechaff-from-saga-of-tanya-the-evil-fb725e63784d4839928aa8ea6fe28e56).
Y esto me consumió batante tiempo, puesto que el modelo de Tanya no lo tenía animado conforme yo quería para este juego y tuve que "riguear" poses nuevas para luego renderizarlas.
Para los enemigos fue más sencillo. Generé modelos con Meshy.AI y luego los rendericé por Blender.
Y para los escenarios tiré de ChatGPT.
Ahora bien, el proceso de adaptar los gráficos para Div Games Studio fue bastante tortuoso. Lo primero es que, por comodidad, decidí emplear una única paleta de colores para todos los niveles. Esto me obligaba, por ejemplo, a crearme una paleta en Gimp para poder hacer uso de ella en el modo "Imagen/Modo/Indexado". Además, para mitiguar la limitacion de 256 colores forcé el Dithering activando el tramado de color posicionado:
Para crear la paleta en Gimp, lo que hice fue crear desde Div2 un dibujo random en formato PCX con la tableta "DIV2.PAL".
Por otro lado también implementé una pequeña explosión que se vea en el fusil de Tanya cuando dispara:
// Proceso: explosion_pequena
// Descripción: Crea una animación de explosión pequeña para efectos de disparo
// - Más pequeña y rápida que la explosión normal
// Parámetros:
// - x: Posición x de la explosión.
// - y: Posición y de la explosión.
// - color: Color de la explosión.
PROCESSexplosion_pequena(x,y,color);
PRIVATE
i;// Contador para el bucle
radio=3;// Radio inicial más pequeño
exp_draw;// ID del dibujo de la explosión
BEGIN
// Animamos la explosión pequeña
FROMi=0TO3;// Menos iteraciones para que sea más rápida
Ahora bien, con este ejemplo 6 me di cuenta de una cosa bastante básica y es que, si desplazaba al personaje a una esquina y dejaba que los enemigos dispararan... pues habían ralentizaciones, el juego tenía bajones de frames. Y es que, aunque habíamos optimizado los enemigos, ¡pero no las balas! Y justamente en un juego de tipo bullet-hell como el que estabamos creando ahora mismo, era obvio que en pantalla tendríamos más balas que enemigos. "Lo siento mucho, me he equivocado, no volverá a ocurrir".
// Definición de struct para balas del jugador (OPTIMIZACIÓN: Lista enlazada separada)
// Almacena balas del jugador en array con lista enlazada para optimizar acceso
STRUCTbala_jugador[MAX_BALAS_JUGADOR]
INTx;// Posición X de la bala
INTy;// Posición Y de la bala
INTactivo;// Si está activa (1) o inactiva (0)
INTdx;// Delta X precalculado para movimiento (siempre 0 para jugador)
INTdy;// Delta Y precalculado para movimiento (hacia arriba)
INTtamano;// Tamaño precalculado para dibujo
INTcolor;// Color precalculado para dibujo
INTdraw_id;// ID del dibujo de la bala
INTsiguiente;// Índice del siguiente slot en la lista (-1 = fin de lista)
END
// Definición de struct para balas enemigas (OPTIMIZACIÓN: Lista enlazada separada)
// Almacena balas enemigas en array con lista enlazada para optimizar acceso
STRUCTbala_enemigo[MAX_BALAS_ENEMIGO]
INTx;// Posición X de la bala
INTy;// Posición Y de la bala
INTtipo;// Tipo de bala enemiga (1=enemigo1, 2=enemigo2)
INTactivo;// Si está activa (1) o inactiva (0)
INTangulo;// Ángulo de movimiento (para balas enemigo tipo 2)
INTdx;// Delta X precalculado para movimiento
INTdy;// Delta Y precalculado para movimiento
INTtamano;// Tamaño precalculado para dibujo
INTcolor;// Color precalculado para dibujo
INTdraw_id;// ID del dibujo de la bala
INTsiguiente;// Índice del siguiente slot en la lista (-1 = fin de lista)
END
Cosas a tener en cuenta: Veréis un INT de "Siguiente". Esto es debido a que, como tienen que haber tantas balas en pantalla, hace falta hacer un sistema de listas. Si digo al compilador que pueden haber 256 balas en pantalla como máximo, resulta contraproducente crear bucles donde se recorran las 256 posiciones del array de balas, existan o no, para cada verificación que tenga que ver con balas. Lo que sí que puedo hacer es marcar con un "-1" en siguiente para decirle a un bucle "no sigas recorriendo el array de balas, no ay más ahora mismo:
// Proceso: proceso_balas_jugador (OPTIMIZACIÓN: Proceso separado para balas del jugador)
// Descripción: Proceso dedicado que gestiona solo balas del jugador usando lista enlazada
// - Solo procesa balas activas del jugador siguiendo la lista enlazada
// - Mueve, dibuja y verifica colisiones con enemigos
// - Elimina balas que salen de pantalla o colisionan
PROCESSproceso_balas_jugador();
PRIVATE
INTactual;// Índice de la bala actual en la lista
INTanterior;// Índice de la bala anterior en la lista
INTsiguiente;// Índice de la bala siguiente (guardado antes de posible eliminación)
INTenemigo_index;// Índice del enemigo colisionado
INTcolor_explosion;// Color de explosión
INTdebe_eliminar;// Flag para marcar si debe eliminarse
BEGIN
LOOP
// Si el juego terminó, salimos
IF(game_over==1)RETURN;END
// PROCESAR BALAS DEL JUGADOR
anterior=-1;
actual=primera_bala_jugador_activa;
WHILE(actual!=-1)
// Guardar el siguiente antes de posibles modificaciones
Ahora que hemos reoptimizado el código para evitar bajones de frames, deberíamos adaptar el juego para que sea más divertido. Quiero decir, ahora mismo tenemos un escenario inmovil con diez enemigos fijos... y en eso consiste nuestro ejemplo 8 (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT08.PRG). En esta versión implementamos un scroll (con start_scroll, no el falso scroll del capítulo 2), repartimos 80 enemigos por todo el escenario e implementamos rutinas para revisar que los enemigos que están fuera de la pantalla no se dibujen y que las barras que salen de nuestra pantalla se destruyan (pensad que las coordenadas no son iguales si usamos scroll o no).
// Si el enemigo ya no está activo o el juego terminó, salir
IF(enemigo[index].activo==0ORgame_over==1)
RETURN;
END
// Actualizar posición en pantalla (convertir de coordenadas de mapa a pantalla)
x=enemigo[index].x-scroll.x0;
y=enemigo[index].y-scroll.y0;
// Actualizar gráfico según tipo
IF(enemigo[index].tipo==1)
graph=GRAFICO_ENEMIGO1;
// Rotar según dirección de movimiento
IF(enemigo[index].dir==1)
angle=ANGULO_ROTACION_ENEMIGO;// Rotar 15° a la derecha
ELSE
angle=ANGULO_ROTACION_ENEMIGO_OPUESTA;// Rotar 15° a la izquierda
END
size=ESCALA_ENEMIGO;
ELSE
graph=GRAFICO_ENEMIGO2;
angle=0;
size=100;
END
FRAME;
END
END
Con todo esto ya vamos teniendo un juego más o menos potable, pero siempre podemos ir a más. En el siguiente ejemplo, el 9, (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT09.PRG), vamos más lejos e implementamos un sistema de niveles distintos, cada uno con su propio fichero FPG. En cada nivel, además, incrementamos el número de enemigos: Empezamos con 40, después 50, después 60... y así vamos sumando de diez en diez en cada fase. Además, el reparto de enemigos lo hacemos de forma escalonada, haciendo que haya pocos enemigos de inicio y muchos al final. También se implementa un sistema de vidas, para que el personaje del jugador no muera al primer toque e incluso cambiamos su fichero FPG en función del número de toques recibido (para dibujarlo sin casco al recibir el primer balazo y sin mochila al recibir el segundo).
Por último tenemos el ejemplo 10, el que contiene el juego acabado. Aquí creamos el enemigo de tipo 3, añadimos un ranking por puntuación, cargamos cinemáticas entre niveles y añadimos efectos de sonido y música (https://github.com/LeHamsterRuso/DIV2Examples/blob/main/PRG/VSHOOT10.PRG). Este es el que presenta mayor dificultad de todos, ya que si haceís un diff con el anterior veréis que cambia casi por completo.
Sobre la música, se tratan de canciones generadas con SUNO (una plataforma de música generada por IA) que he convertido a wav. En mi caso tengo un mac y esto me permite hacerlo con un simple comando:
Este comando lo que hace es generar un wav en sonido estéreo a 11025Hz. Es una calidad bastante mala para los estándares actuales, pero me permite tener canciones de menos de 3 MB (el formato wav abulta mucho y las máquinas con MS-DOS no pueden permitirse malgastar RAM).
Y para finalizar, aquí os dejo el gameplay completo del juego:
Prometo que intentaré que el próximo capítulo sea de un juego más fácil y menos complicado de programar.
No hay comentarios:
Publicar un comentario
Si te ha gustado la entrada o consideras que algún dato es erróneo o símplemente deseas dar algún consejo, no dudes en dejar un comentario. Todo feedback es bienvenido siempre que sea respetuoso. También puedes contactarme por estas redes sociales https://linktr.ee/hamster_ruso si lo consideras necesario.
No hay comentarios:
Publicar un comentario
Si te ha gustado la entrada o consideras que algún dato es erróneo o símplemente deseas dar algún consejo, no dudes en dejar un comentario. Todo feedback es bienvenido siempre que sea respetuoso. También puedes contactarme por estas redes sociales https://linktr.ee/hamster_ruso si lo consideras necesario.