Lección 10 – Colisiones (2) Bullet Hell mem estática

Aventuras en Megadrive:
Bullet Hell (mem estatica)

 

UN POCO DE C

En la entrada anterior vimos como detectar colisiones de forma sencilla, pero al fin y al cabo sólo lo hacíamos entre dos sprites. ¿Que ocurriría si hubiesen decenas de ellos? ¿Existen formas de optimizar las colisiones? ¿Nos quedaremos cortos de memoria o CPU?

Vamos a divertirnos con el mítico Thunder Force IV

 

video03

En la inmensidad del espacio nuestra nave, el Boss y balas, muchas balas.

¿Cómo plantear esta escena? MD puede manejar hasta 80 sprites. Para no desperdiciar sprites en un boss enorme, éste va a ser un simple fondo. De esta forma la nave es sólo un sprite y podemos dejar el resto para tener muuuuchas balas:

  • Sprite de la nave: 1
  • Núm. máx. de balas a poner por la nave:  15
  • Sprite del BOSS: ninguno, es un fondo
  • Núm. máx. de balas a poner por el BOSS:  40
  • Las balas no colisionan entre ellas.
  • Bala del player que colisione con el Boss = 1 sprite explosión por bala.
  • 1 bala del Boss que colisione con el player = 1 sprite explosión.
  • Núm. max de explosiones en pantalla:  10

Esto nos da un total de 65 sprites como máximo, quedando 14 para otros usos (marcadores, animaciones del boss, power ups, etc). Recordemos que el primer sprite del VDP se lo reserva el SGDK para uso propio (el sprite 0). De ahí que sean 14 y no 15 los teóricos sprites restantes.

¿Cómo manejamos los datos de todos los sprites? Hay que tener en cuenta que se van a estar creando y destruyendo balas constantemente. En otro tipo de juego podemos crear variables y sprites al principio de la fase, pero en un Shoot ‘em up se van a generar balas y enemigos constantemente.

 

Estructuras

Una manera de almacenar información de forma ordenada es crear estructuras.
Por ejemplo, para la nave del player:

struct{
  Sprite *spr_player; //puntero al sprite
  int x, y;           //posición
  int x1,y1,x2,y2;    //caja de colisión
  int tempo_disparo;  //tempo de repetición de disparo
}NAVE;

De esta forma podemos acceder fácilmente a las variables:

 //Inicializa variables
NAVE.x = 64;
NAVE.y = 145;
NAVE.tempo_disparo = 0;

 

Ahora bien, para las balas, ¿creamos un array con tantas balas como pueda crear el player y el boss? ¿Utilizamos estructuras? ¿Reutilizamos variables y/o arrays a medida que las balas salgan por la pantalla? Podríamos suponer que el primer disparo creado será el primero en eliminarse (por salir de pantalla o golpear algo), y por tanto re-usar esa variable, array o estructura, pero no siempre se elimina el primero. No tiene porqué.

Normalmente en estos casos recurriríamos a usar memoria dinámica. Crear balas según haga falta y eliminarlas cuando corresponda. Sin embargo en esta entrada vamos a utilizar una aproximación estática:

//BALAS NAVE Y BOSS
struct estructura_bala{
  int a, x, y; // a=activa, x,y=coordenadas.
  int tipo;    // 0=disparo normal, 1=disparo triple
  Sprite *spr; // sprite
};

struct estructura_bala lista_balas_player[MAX_BALAS_PLAYER];
struct estructura_bala lista_balas_boss[MAX_BALAS_BOSS];

Primero creo una estructura pero sólo el esqueleto. A partir de él creo dos arrays de structs llamados: lista_balas_player y lista_balas_boss.

Ambos arrays van a crearse en ese momento y con una tamaño fijo (aprox. estática). Ambos contienen información, un struct estructura_bala por cada posición del array.

 

lista_balas_player

El primer array lista_balas_player es un array de elementos estructura_bala con tantos elementos como defina la constante MAX_BALAS_PLAYER (=15).

La clave del struct estructura_bala es el elemento int a. Durante el inicio del juego, pondremos todos los elementos del array a 0, incluyendo a=0:

Toda bala con a=0 es una bala inactiva

Para crear una nueva bala del player, lo que hacemos es recorrer los elementos del array lista_balas_player y mirar elemento a elemento cuando vale su parámetro ‘a’. En cuanto encontremos un elemento con a=0 hablamos de un elemento no activo, con lo cual podemos utilizarlo. En caso contrario, seguimos recorriendo el array.

//crea una bala en la primera pos libre del array
//tipo: 0=disparo normal, 1=disparo triple
void crea_bala_player(int tipo)
{
  for(int cont = 0; cont <MAX_BALAS_PLAYER; cont++)
  {
    if(lista_balas_player[cont].a == 0)
    {
      lista_balas_player[cont].a = 1;
      lista_balas_player[cont].x = NAVE.x+30;
      lista_balas_player[cont].y = NAVE.y+15;
      lista_balas_player[cont].tipo = tipo;
      lista_balas_player[cont].spr = SPR_addSprite(&bala_sprite, lista_balas_player[cont].x,
                                     lista_balas_player[cont].y, TILE_ATTR(PAL0,TRUE,FALSE, FALSE));
      break;
    }
  }
}

El array por tanto no se recorre entero a no ser que todas las posiciones estén usadas.

De la misma forma, para mover las balas del player después de ser creadas, usamos una función que recorre el array y sólo mueve balas activas (a!=0):

//- Mueve bala según su tipo (recto o en diagonal)
//- Elimina la bala si se sale de la pantalla
//- Elimina la bala si colisiona con la caja de colisión del enemigo
void mantenimiento_balas_player()
{
  for(int cont = 0; cont <MAX_BALAS_PLAYER; cont++)
  {
    if(lista_balas_player[cont].a != 0) //solo las activas
    {
      if(lista_balas_player[cont].tipo==0)       lista_balas_player[cont].x+=VELOCIDAD_BALA_PLAYER;
      else if(lista_balas_player[cont].tipo==1){ lista_balas_player[cont].x+=VELOCIDAD_BALA_PLAYER; lista_balas_player[cont].y--; }
      else if(lista_balas_player[cont].tipo==2){ lista_balas_player[cont].x+=VELOCIDAD_BALA_PLAYER; lista_balas_player[cont].y++; }
      
      SPR_setPosition(lista_balas_player[cont].spr,lista_balas_player[cont].x,lista_balas_player[cont].y );

      //sale de la pantalla
      if(lista_balas_player[cont].x>=330 || lista_balas_player[cont].y<=0 || lista_balas_player[cont].y>=220)
      {
        lista_balas_player[cont].a = 0 ; //marca como inactivo en el vector
        SPR_releaseSprite(lista_balas_player[cont].spr); //se carga sprite del VDP
      }

      //choca con la caja de colisión del boss
      if(lista_balas_player[cont].x>BOSS.x1 && lista_balas_player[cont].x<BOSS.x2 &&
         lista_balas_player[cont].y>BOSS.y1 && lista_balas_player[cont].y<BOSS.y2)
      {
        lista_balas_player[cont].a = 0 ;
        SPR_releaseSprite(lista_balas_player[cont].spr);
        crea_explosion(lista_balas_player[cont].x,lista_balas_player[cont].y);
      }
    }
  }
}

Naturalmente al salir de la bala de la pantalla o chocar, liberaremos esa entrada del array poniendo a=0. También habrá que liberar el sprite del VDP.

 

lista_balas_boss

Con las balas del Boss vamos a trabajar *casi* de la misma manera. ¿Por qué? El Boss puede disparar hasta MAX_BALAS_BOSS (=40 balas). Por tanto sufriremos en el bucle que gestiona sus balas.

Una forma sencilla de evitar ciclos innecesarios en dicho bucle es ejecutarlo tantas veces como balas existan en ese momento en pantalla. Esto no significa ejecutar el bucle ‘n’ veces (donde ‘n’=num balas existentes), porque es posible algunas balas=’posiciones del array’ no estén activas (a=0). Significa hacerlo tantas veces como sea necesario hasta alcanzar el núm de balas existentes y a continuación interrumpir el bucle.

Usaremos dos contadores:

  • num_balas_boss: núm. de balas del boss en pantalla (activas). Se incrementa cada vez que sea crea una y se decrementa cada vez que una bala sale de pantalla o choca con el player. Variable global.
  • i: simple contador, se incrementa cuando, en el bucle de gestión de balas, a=1.

Es más complicado explicarlo que verlo:

//VIDA DE UNA BALA DEL BOSS
//- Mueve bala según su tipo: 0,1,2: recto, diagonal-izq-arriba, diagonal-izq-abajo
//- Elimina la bala si se sale de la pantalla o si toca la caja de colisión del player
//- Cuando dentro del bucle tratamos tantas balas como balas activas, salimos del bucle
void mantenimiento_balas_boss()
{
  for(int cont = 0, i=0; cont < MAX_BALAS_BOSS; cont++)
  {
    if(lista_balas_boss[cont].a == 1) //solo las activas
    {
      i++; if(i>num_balas_boss) break; //no vamos a hacer más ciclos del bucle que los necesarios

      if(lista_balas_boss[cont].tipo==0) lista_balas_boss[cont].x-=VELOCIDAD_BALA_BOSS;
      if(lista_balas_boss[cont].tipo==1){ lista_balas_boss[cont].x-=VELOCIDAD_BALA_BOSS; lista_balas_boss[cont].y--; }
      if(lista_balas_boss[cont].tipo==2){ lista_balas_boss[cont].x-=VELOCIDAD_BALA_BOSS; lista_balas_boss[cont].y++; }

      SPR_setPosition(lista_balas_boss[cont].spr,lista_balas_boss[cont].x,lista_balas_boss[cont].y );

        //sale de la pantalla
        if(lista_balas_boss[cont].x<=0 || lista_balas_boss[cont].y<=0 || lista_balas_boss[cont].y>=220)
        {
          lista_balas_boss[cont].a = NULL; //marca como inactivo en el vector
          SPR_releaseSprite(lista_balas_boss[cont].spr); //se carga sprite del VDP
          num_balas_boss--;
        }
        //choca con la caja de colision del player
        else if( lista_balas_boss[cont].x>NAVE.x1 && lista_balas_boss[cont].x<NAVE.x2 &&
                 lista_balas_boss[cont].y>NAVE.y1 && lista_balas_boss[cont].y<NAVE.y2 )
             {
               lista_balas_boss[cont].a = 0 ;
               SPR_releaseSprite(lista_balas_boss[cont].spr);
               crea_explosion(lista_balas_boss[cont].x,lista_balas_boss[cont].y);
               num_balas_boss--;
             }
    }
  }
}

 

JUGANDO CON EL BULLET HELL

Compilas y dale un rato al ejemplo de esta entradaComo siempre en mi github.

Juega con las constantes del programa para hacer más rápidas y lentas las balas, además de variar la frecuencia con la que se generan.

Comprueba como suben y bajan los FPS utilizando esta función:

VDP_showFPS(TRUE);

Con el código de ejemplo consigo :

  • Sólo disparando la nave: un mín de 38 fps, pero suele estar por encima de 44.
  • Sólo disparando el Boss: un mín de 33 fps, pero suele estar por encima de 37.
  • Bullet Hell(todo a la vez): mín de 18 fps, pero suele mantenerse en 20-24 fps.

 

Notarás que a veces las balas del Boss golpean a la nave y desaparecen, sin explosión alguna, otras veces sí verás la explosión. No hay nada incorrecto, simplemente cuando no aparece la explosión en la nave es porque ya hemos alcanzado MAX_EXPLOSIONES.

Podría mejorarse mucho la sensación de tener un Boss gigante moviendo el fondo arriba/abajo y actualizando la posición de la caja de colisiones del Boss, así como usar algunos sprites para darle vida. Esto lo dejo para otra ocasión.

Quizá alguien se pregunte cómo no he utilizado mem. dinámica para no malgastar memoria con unos arrays tan grandes. Lo veremos en la siguiente entrada.

 

GITHUB

El código de esta lección, y de todas las demás, lo podrás encontrar en mi github:

https://github.com/danibusvlc/aventuras-en-megadrive