Desarrollo en C++ de un pequeño juego para Game Boy Advance 
A raiz del anterior post en el que se introdujeron los conceptos y el código básico para el desarrollo de aplicaciones y juegos para la Game Boy Advance, he desarrollado un pequeño juego de tablero basado en bloques deslizantes e inspirado en los puzzles intermedios que aparecen en el juego "Mario + Rabbids Kingdom Battle".

Mecánica de los bloques deslizantes

Disponemos de un tablero de juego dividido en cuadrícula (en nuestro caso de 9x9 huecos), la mayoría de ellas transitables y otras no transitables. En el tablero de juego se disponen entre 1 y 4 bloques (siempre en huecos transitables), cada uno de ellos de un color. Por cada bloque habrá un hueco en el tablero de juego que será el sitio destino donde debe llevarse a dicho bloque, es decir, si en nuestro tablero tenemos 3 bloques, uno rojo, otro verde y otro azul, en el tablero existirán también 3 huecos marcados como destino del bloque rojo, destino del bloque verde y destino del bloque azul, respectivamente.



El jugador mueve los bloques por turnos con la restricciones siguientes:

- Un bloque sólo puede moverse en una dirección en la que el tablero sea transitable y no haya otro bloque impidiendo su movimiento.

- La longitud del recorrido de un bloque en una dirección será siempre la máxima posible hasta que el bloque se encuentre con un obstáculo (hueco no transitable u otro bloque). dicho de otra forma, los bloques se mueven hasta que "chocan" con el borde del tablero (hueco no transitable) o con otro bloque, no pueden frenarse a mitad de recorrido.





El objetivo del juego es llevar cada bloque hasta su respectivo hueco (el bloque rojo hacia el hueco rojo, el bloque verde hacia el hueco verde y así con todos) antes de que se cumpla el tiempo de juego.

Esta mecánica es la que se puede encontrar en algunos mini juegos de puzzle de "Mario + Rabbids Kingdom Battle", para Nintendo Switch.


(Imagen extraida de Neoseeker)

Experiencia previa

Tengo experiencia cero en desarrollar juegos y esta es, por tanto, la primera vez que lo hago. Me lo he planteado como un reto de "puedo hacerlo" y, para el lector experimentado en el desarrollo de juegos, es muy probable que haya hecho algunas (o muchas) cosas bastante mal, usando de forma incorrecta algún patrón de diseño o incluso haber implementado pésimamente algunos aspectos. Vayan por delante mis disculpas a esos desarrolladores.

Pantallas y diseño

Las pantallas del juego las he representado como un grafo en el que los nodos son las pantallas en sí y los arcos son las transiciones entre pantallas. El grafo del juego sería el siguiente:



Modelo de máquina de estados

A partir de este grafo se desarrolló un modelo sencillo en el que cada nodo es un "objeto pantalla". Se definió la clase "Screen" y la clase "ScreenManager". Cada pantalla debe heredar de la clase "Screen" y será un objeto, mientras que la clase "ScreenManager" se instancia una sola vez y se encarga de gestionar la navegación entre las pantallas del juego.

Como vimos en el anterior post relativo al desarrollo para Game Boy Advance, la forma ideal de acceder a la memoria de vídeo (VRAM, paleta, etc.) para que no se produzca ruido o errores de pintado es durante el tiempo de retrazo vertical así que lo que haremos será orientar toda la arquitectura en ese sentido. Al principio se inicializan todas las pantallas (objetos de algún tipo que herede de "Screen") que va a tener el juego (ya sea de forma global o de forma local a la función main) y se inicializa el objeto de tipo "ScreenManager" pasándole la pantalla inicial.

#include "Screen.H"
#include "ScreenTitle.H"
#include "ScreenMenu.H"
#include "ScreenFileSelectionMenu.H"
#include "ScreenBoardSelectionMenu.H"
#include "GameStatus.H"
#include "ScreenDeleteFileMenu.H"
#include "ScreenConfirmStartBoardMenu.H"
#include "ScreenBoard.H"
#include "ScreenPauseMenu.H"

using namespace std;
using namespace avelino;

typedef void (*fptr)(void);

#define  ISR_PTR   *((fptr *) 0x03007FFC)
#define  IME       *((volatile uint32_t *) 0x04000208)
#define  IE        *((volatile uint16_t *) 0x04000200)
#define  IF        *((volatile uint16_t *) 0x04000202)
#define  IFBIOS    *((volatile uint16_t *) 0x03007FF8)
#define  DISPSTAT  *((volatile uint16_t *) 0x04000004)

void isr()  __attribute__((target("arm")));

void isr() {
    // mark interrupt as served
    IF = 1;
    IFBIOS |= 1;
}

void enableInterrupts() {
    IME = 0;
    ISR_PTR = isr;
    DISPSTAT = ((uint16_t) 1) << 3;
    IE = 1;   // v-blank
    IME = 1;
}

int main() {
    enableInterrupts();
    GameStatus status;
    // define screens
    ScreenTitle st;
    ScreenFileSelectionMenu sfsm;
    ScreenBoardSelectionMenu sbsm;
    ScreenDeleteFileMenu sdfm;
    ScreenConfirmStartBoardMenu scsbm;
    ScreenBoard sb;
    ScreenPauseMenu spm;
    // link screens
    st.nextScreen = &sfsm;
    sfsm.backScreen = &st;
    sfsm.nextScreen = &sbsm;
    sbsm.backScreen = &sfsm;
    sbsm.deleteFileScreen = &sdfm;
    sbsm.boardSelectedScreen = &scsbm;
    sdfm.backScreen = &sbsm;
    sdfm.justDeletedScreen = &sfsm;
    scsbm.backScreen = &sbsm;
    scsbm.yesScreen = &sb;
    sb.pauseScreen = &spm;
    sb.afterFinishScreen = &sbsm;
    spm.continueScreen = &sb;
    spm.finishScreen = &sbsm;
    // start screen manager
    ScreenManager m;
    m.init(st, status);
    while (true) {
        asm volatile ("swi 0x05");    // wait for v-blank
        m.onVBlank();
    }
}

En el bucle principal del juego lo único que se hace es esperar a que se produzca la interrupción de retrazo vertical y, en cuanto se produce, se invoca la función miembro "ScreenManager::onVBlank()".

// Screen.H

        class InterScreenData {   // any data
        };

        class Screen {
            public:
                virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0;
                virtual Screen *onVBlank(Keypad &keypad) = 0;                              // return "this" to stay on same screen or other screen to switch to
                virtual InterScreenData *onUnload() = 0;
        };

        class ScreenManager {
            public:
                Screen *currentScreen;
                Keypad keypad;
                ScreenManager() : currentScreen(NULL) { };
                void init(Screen &initialScreen, InterScreenData &initialInterScreenData);
                void onVBlank();
        };

// Screen.cc

void ScreenManager::init(Screen &initialScreen, InterScreenData &initialInterScreenData) {
    this->keypad.init();
    this->currentScreen = &initialScreen;   // start with first screen
    this->currentScreen->onLoad(&initialInterScreenData);
}


void ScreenManager::onVBlank() {
    this->keypad.onVBlank();
    Screen *next = this->currentScreen->onVBlank(this->keypad);
    if (next != this->currentScreen) {    // switch to another screen
        InterScreenData *isd = this->currentScreen->onUnload();   // get data from outgoing screen
        next->onLoad(isd);                                        // and passing it to incomming screen
        this->currentScreen = next;
    }
}

La función miembro "init" del objeto de clase "ScreenManager" inicializa el teclado, asigna la pantalla actual a la pantalla que le llega por parámetros, invoca la función miembro "onLoad" de dicha pantalla inicial y regresa. A partir de entonces, cada vez que haya un retrazo vertical, se invocará la función miembro "onVBlank()" del objeto de tipo "ScreenManager" que, a su vez, invocará la función miembro "onVBlank()" del objeto de tipo "Screen" que tiene como pantalla actual. Esta función miembro "Screen::onVBlank()" devuelve "this" en caso de que queramos continuar en esta pantalla para el siguiente retrazo o un puntero a otro objeto "Screen" en caso de que queramos cambiar de pantalla para el siguiente retrazo. En caso de que devuelva diferente de la pantalla actual ("this"), se invoca a la función miembro "onUnload" de la pantalla "saliente" y a continuación a la función miembro "onLoad" de la pantalla "entrante". Con esta mecánica podemos implementar la lógica de navegación entre pantallas.

Funciones miembro de la clase Screen

class InterScreenData {   // any data
};

class Screen {
    public:
        virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0;
        virtual Screen *onVBlank(Keypad &keypad) = 0;                              // return "this" to stay on same screen or other screen to switch to
        virtual InterScreenData *onUnload() = 0;
};

La clase Screen define 3 funciones miembro virtuales puras que deberán ser implementadas por las subclases que hereden de ella:

void Screen::onLoad(InterScreenData *dataFromPreviousScreen)

Esta función miembro se invoca en el momento de cargar una pantalla y antes de invocar por primera vez a su función miembro "onVBlank". En esta función miembro se inicializarán las variables y los datos de la pantalla así como la configuración del controlador LCD, los backgrounds, los sprites y demás, de tal manera que todo quede "listo para pintar" esta pantalla. Lo recomentable es, al principio de esta función miembro, poner a 1 el bit 7 del registro DISPCNT para deshabilitar el acceso a la VRAM desde el controlador LCD y permitir que la CPU acceda a la VRAM de forma rápida y, justo antes de salir de esta función miembro, configurar el mismo registro DISPCNT con el modo gráfico, los backgrounds, sprites y demás opciones que se necesiten para esta pantalla.

Screen *Screen:onVBlank(Keypad &keypad)

Esta función miembro se llamará en cada retrazo vertical mientras ésta pantalla sea la pantalla activa. Recibe por parámetro una referencia a un objeto de clase Keypad (donde puede leerse el estado de las teclas) y devuelve un puntero a una pantalla válida: si devuelve "this" se permanece en la pantalla (objeto Screen) actual y en el siguiente retrazo vertical se volverá a invocar de nuevo a la misma función miembro "onVBlank" del mismo objeto Screen. En caso de que se devuelva un objeto (una pantalla, de tipo Screen) diferente a "this", el ScreenManager interpretará que se desea cambiar de pantalla por lo que invocará la función miembro "onUnload" de la pantalla "saliente" y a continuación a la función miembro "onLoad" de la pantalla "entrante". Para el siguiente retrazo vertical se invocará la función miembro "onVBlank" de la pantalla entrante y así sucesivamente.

InterScreenData *Screen::onUnload()

Esta función miembro es la que invoca el objecto de tipo ScreenManager cuando la función miembro "onVBlank" de pantalla actual devuelve una pantalla distinta a ella misma, porque entiende que se quiere pasar a otra pantalla. Como se puede ver, "onUnload" devuelve un puntero a un objeto de tipo "InterScreenData" que es pasado en el "onLoad" de la pantalla entrante. La intención de este objeto de tipo "InterScreenData" es de servir como estado del juego, de tal manera que todas las pantallas (objetos de tipo base Screen) vean los mismos datos del juego (en otras palabras, una forma elegante de tener "variables globales"). Nótese que la clase InterScreenData es una clase vacía, cuando hacemos el juego decidimos qué queremos que sea InterScreenData, ya que puede ser cualquier cosa.

Toda la mecánica del juego se define mediante estas tres funciones miembro en cada pantalla.

Ejemplo de implementación de un pantalla: ScreenTitle



Ésta es la pantalla de arranque. Muestra un fondo prediseñado en modo 3 (bitmap de 15 bits de color por pixel) y muestra el texto "- Press START-" haciendo que parpadee de forma suave hasta que el usuario pulsa "START" para continuar a la siguiente pantalla.

- En la función miembro "onLoad" se pone a 1 el bit 7 de DISPCNT para deshabilitar el LCD y acelerar el acceso a VRAM desde la CPU, luego se copia la imagen de fondo de la ROM a una parte de la VRAM, se inicializa la paleta para los sprites, se pintan las letras como sprites, se configura el alpha blending (parpadeo suave) para los sprites y, antes de salir, se configura el controlador LCD: modo 3, background layer 2 y objetos (sprites) habilitados.

void ScreenTitle::print(const char *text) {
    char *p = (char *) text;
    // calculate string length
    uint16_t n = 0;
    while (*p != 0) {
        p++;
        n++;
    }
    this->objUsed = n;
    // draw sprites as text
    const int16_t Y = 100;
    int16_t x = 120 - ((n << 3) >> 1);
    uint16_t i = 0;
    p = (char *) text;
    while (*p != 0) {
        OAM[i++] = Y | (((uint16_t) 1) << 13);
        OAM[i++] = x & 0x01FF;
        OAM[i++] = 512 + ((*p - ' ') << 1);
        i++;
        x += 8;
        p++;
    }
}

void ScreenTitle::onLoad(InterScreenData *dataFromPreviousScreen) {
    this->interScreenData = dataFromPreviousScreen;
    this->alpha = 0;
    this->objUsed = 0;
    this->timer = 5 * 60;   // 5 seconds
    // force blank to access VRAM
    DISPCNT = ((uint16_t) 1) << 7;
    // draw on framebuffer
    //memcpy16(MODE3_VRAM_FB, ANA, 240 * 160);
    memcpy16(MODE3_VRAM_FB, TITLE_RGB_IMAGE, 240 * 160);
    // configure OBJ 256 color palette (all colors white)
    memcpy16(OBJ_PALETTE, PALETTE, 256);
    // load OBJ tiles from FONT_1
    memcpy16((uint16_t *) OBJ_TILES_VRAM, (uint16_t *) FONT_1, 3072);
    // configure OBJs to print a text (this call updates this->objUsed to free the sprites on unload
    this->print("- Press START -");
    // 1st target is OBJ, 2nd target is BG2, fx = alpha blending
    BLDCNT = (((uint16_t) 1) << 4) | (((uint16_t) 1) << 6) | (((uint16_t) 1) << 10);
    // transparency at 50% (8/16 for both OBJ and BG2 intensities)
    BLDALPHA = (((uint16_t) this->alpha) << 8) | ((uint16_t) (16 - this->alpha));
    // enable mode 3 with background layer 2, enable obj, enable obj 1-d mapping
    DISPCNT = ((uint16_t) 3) | (((uint16_t) 1) << 10) | (((uint16_t) 1) << 12) | (((uint16_t) 1) << 6);
}


- En la función miembro "onVBlank()" se actualiza el registro de alpha blending para producir el efecto de parpadeo suave. Si se detecta que se pulsa el botón START se devuelve "nextScreen" (que será la pantalla de selección de fichero), en caso contrario se devuelve siempre "this" para indicar que seguimos en esta pantalla.

Screen *ScreenTitle::onVBlank(Keypad &keypad) {
    // recalculate sprite alpha blending
    this->alpha += ALPHA_INC;
    uint16_t alphaIntegerPart = this->alpha >> 8;
    if (alphaIntegerPart > 16) {
        this->alpha = 0;
        alphaIntegerPart = 0;
    }
    BLDALPHA = (((uint16_t) alphaIntegerPart) << 8) | ((uint16_t) (16 - alphaIntegerPart));
    // next screen when pressing START, else keep screen
    if (keypad.mask & KEY_START)
        return this->nextScreen;
    else
        return this;
}


- En la función miembro "onUnload", se reinicia el registro de alpha blending y los sprites usados.

InterScreenData *ScreenTitle::onUnload() {
    // free the sprites (OBJs) used
    uint16_t n = this->objUsed;
    uint16_t j = 0;
    while (n > 0) {
        OAM[j] = SCREEN_HEIGHT | (((uint16_t) 1) << 13);
        OAM[j + 1] = SCREEN_WIDTH & 0x01FF;
        j += 4;
        n--;
    }
    // disable alpha blending
    BLDCNT = 0;
    return this->interScreenData;
}


Esta forma de escribir el código facilita mucho poder concentrarse en cada pantalla del juego por separado, pues cada pantalla tiene su terna onLoad/onVBlank/onUnload. Hay que tener en cuenta algunas cosas:

- Hay que garantizar que las cosas que inicialicemos y configuremos en onLoad luego las dejemos con su valor por defecto o desactivadas en el onUnload, para que, si la siguiente pantalla no las vaya a usar, no le afecten.

- En la función miembro "onVBlank" hay que tratar de hacer la menor cantidad de cosas posible ya que se trata de una función miembro que se invoca en el retrazo vertical, y, por tanto, se dispone de una pequeña ventana de ejecución hasta el siguiente retrazo. De la misma manera, dentro de la propia función lo ideal es hacer los cambios en la VRAM o en los registro del LCD lo antes posible, por si es inevitable que su ejecución se prolongue más ciclos de los que dura el retrazo. Cuantas más cosas dejemos preparadas y precalculadas en la función miembro "onLoad", mejor.

Teniendo esta filosofía en mente ya podemos ir diseñando las diferentes pantallas del juego.

Diagrama de clases



La herencia y el polimorfismo nos permiten factorizar muy fácilmente algunas pantallas que son muy similares unas a otras. Por ejemplo ScreenFileSelectionMenu, ScreenPauseMenu, ScreenYesNoMenu, ScreenConfirmStartBoardMenu y ScreenDeleteFileMenu, se aprovechan de esta característica de C++ y la única clase que debe lidiar con el hardware (LCD, teclas, etc.) es ScreenMenu, ya que el resto heredan de ella y permiten cambiar los mensajes, las opciones y las acciones del menú de forma muy elegante, tan sólo redefiniendo las funciones miembro virtuales puras con otras adecuadas.

La pantalla de juego principal: ScreenBoard

Vamos a centrarnos en la clase ScreenBoard, que es la que implementa la pantalla de juego principal, que gestiona el tablero de juego y la mecánica del mismo. A continuación podemos ver un grafo con la máquina de estados de esta pantalla:



Durante el estado "MARK" se muestra un marcador que parpadea sobre un bloque y dándole a las flechas cambiamos de bloque.



Si, estando en el estado "MARK" pulsamos A, nos ponemos en modo "STEER" ("girar las ruedas") que lo que hace es calcular los posibles movimientos del bloque actual y marcarlos con flechas. Una vez en el estado "STEER" las flechas sirven para empujar el bloque seleccionado en la dirección deseada pasando al modo "MOVE" o podemos pasarnos al modo "MARK" si no queremos mover el bloque, pulsando B de nuevo.



El estado "MOVE" es el estado en el que la pantalla realiza la animación de movimiento de un bloque hasta su nueva posición. Cuando el bloque que se está moviendo llega a su posición final se "consolida" en el tablero y se evalúa el tablero para saber si el usuario ha ganado. Si el usuario ha ganado se va al estado "END_WIN", si no ha ganado aún, se pasa al estado "MARK".



En caso de que el cronómetro de la fase llegue a cero se pierde (estado "END_LOSE") mientras que si logramos ubicar a todos los bloques en sus correspondientes rectángulos antes de que el cronómetro de la fase llegue a cero, ganamos (estado "END_WIN").



"ScreenBoard::onVBlank" puede devolver tres valores:

- this: Mientras el tablero esté en juego de forma normal.

- this->pauseScreen: Puntero a pantalla de pausa, en caso de pulsar Start estando en el estado "MARK".

- this->afterFinishScreen: Puntero a pantalla después de finalización (tanto si gana como si pierde, desde el estado "FINISHED").

Mecanismo de guardado de partidas

En la Game Boy Advance es posible utilizar diferentes mecanismos de guardado de datos de forma no volátil (que no se pierdan al apagar la consola) tanto basados en SRAM no volátil como basados en Flash o en EEPROM. En nuestro caso hemos optado por usar el mecanismo de SRAM no volátil, que es el más sencillo. Sólo hay que tener en cuenta las siguientes restricciones:

- Tenemos 64 Kbytes de memoria SRAM no volátil a partir de 0x0E000000 (hasta 0x0E00FFFF).

- Es una zona de memoria direccionable a nivel de byte (debe escribirse y leerse en esta zona de memoria mediante escrituras y lecturas de un byte de anchura, no de 16 ni de 32 bits).

- Para acceder a dicha zona de memoria es necesario escribir previamente un 3 en el registro WAITCNT para indicar al controlador del bus que dicha memoria es lenta y requiere de 8 ciclos de espera. Lo ideal es guardar el valor actual de WAITCNT antes de acceder, hacer WAITCNT = 3, acceder al espacio de memoria y al final restaurar el valor de WAITCNT anterior.

Para que los cartuchos flash y los emuladores detecten correctamente el tipo de backup que vamos a utilizar en nuestro juego es necesario situar una cadena de caracteres en algún sitio de la rom del juego para que sea reconocible el tipo de backup que se usará. En nuestro caso, al usar SRAM colocaremos la cadena "SRAM_V113" en una posición de memoria cualquiera de la ROM que sea múltiplo de 2 bytes. La cadena debe ocupar una cantidad múltiplo de 4 bytes de longitud y los bytes restantes deberán contener ceros. En nuestro caso lo más sencillo es incluir dicha cadena en el array "CARTRIDGE_HEADER", justo después de la cabecera de Nintendo:

__attribute__((section(".cartridge_header")))
const uint8_t CARTRIDGE_HEADER[256] = {
    0x3E, 0x00, 0x00, 0xEA,     // branch to +0x100   0b 11101010 00000000 00000000 00111110 --> 0xEA 0x00 0x00 0x3E

                            0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a,   // nintendo logo
    0x84, 0xe4, 0x09, 0xad, 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19,
    0x93, 0x09, 0xce, 0x20, 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33,
    0x82, 0xe3, 0xce, 0xbf, 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0,
    0x13, 0x72, 0xa7, 0xfc, 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27,
    0xfc, 0x03, 0x98, 0x76, 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00,
    0x40, 0xa7, 0x0e, 0xfd, 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85,
    0x60, 0xd6, 0x80, 0x25, 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff,
    0xbb, 0x3e, 0x03, 0x44, 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63,
    0x87, 0xf0, 0x3c, 0xaf, 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07,

    'E', 'J', 'E', 'M', 'P', 'L', 'O', 0x00, 0x00, 0x00, 0x00, 0x00,      // game title

    'A', 'E', 'J', 'S',        // game code 'A' + two characters + 'S' (for spanish)

    '0', '1',            // maker code

    0x96,                // fixed value

    0x00,                // GBA

    0x00,                // device type

    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,          // reserved

    0x00,                // software version

    0xC1,                // complement check

    0x00, 0x00,          // reserved

    'S', 'R', 'A', 'M', '_', 'V', '1', '1', '3', 0x00, 0x00, 0x00   // backup id string after official header to indicate the backup method (SRAM at 0x0E000000)
};

De esta forma, usando las clases SaveFile y SaveFileManager, podemos cargar y guardar partidas ya que internamente utilizan las funciones "memset8" y "memcpy8" que aseguran un acceso a nivel de byte a la zona de memoria entre 0x0E000000 y 0x0E00FFFF y, además, controlan que el registro WAITCNT=3 cuando se accede dicha zona.

Diseño de niveles

Los niveles se definen en Level.H y Level.cc como un array de constantes que se aloja en el mismo espacio de memoria que el resto de la ROM del cartucho. Cada nivel incluye:

- Nombre corto (2 letras).

- Nombre largo (9 letras).

- Configuración de tablero en sí (un valor de 16 bits por cada baldosa, con 9x9 baldosas).

- Tiempo en minutos y segundos para resolverlo.

- Vector con niveles que se desbloquean cuando este nivel se termina.

El diseño de niveles para este juego creo que excede mi capacidad como desarrollador por varias razones:

- Los niveles deberían ser de complejidad creciente, eligiendo una cantidad de bloques y un tablero acordes al progreso actual.

- También hay que elegir bien el tiempo máximo de resolución y buscar un equilibrio entre que el juego no sea muy fácil (tableros sencillos y/o tiempo largo) y que no sea muy difícil (tableros complejos y/o tiempos de resolución cortos).

Para este caso, y por pura "curiosidad teórico-computacional", desarrollé un pequeño programa (también en C++ pero no para compilar de forma cruzada, sino para compilar y ejecutar en un ordenador) que permite generar niveles jugables aleatorios a partir de ciertos parámetros iniciales. El programa parte de una situación final de ganar (todos los bloques en sus correspondientes baldosas destino) y va generando movimientos hacia atrás hasta obtener un tablero en el que lo bloques estén dispersos y se pueda usar como tablero de juego inicial para un nivel.

# primero generamos un fichero binario con la forma del nivel,
# indicando las celdas transitables con 0x0000 y las celdas no transitables con 0x0080 (nótese que los valores son little-endian)
# 9x9 valores de 16 bits = 162 bytes
echo “0000 0000 8000 8000 0000 0000 0000 0000 0000” | xxd -r -ps > board1.bin
echo “0000 0000 8000 8000 0000 0000 0000 0000 0000” | xxd -r -ps >> board1.bin
echo “0000 0000 0000 0000 0000 0000 0000 0000 0000” | xxd -r -ps >> board1.bin
echo “0000 0000 0000 0000 0000 0000 0000 0000 0000” | xxd -r -ps >> board1.bin
echo “0000 0000 0000 0000 0000 0000 0000 0000 8000” | xxd -r -ps >> board1.bin
echo “0000 0000 8000 0000 0000 0000 0000 0000 8000” | xxd -r -ps >> board1.bin
echo “0000 0000 8000 0000 0000 0000 0000 0000 8000” | xxd -r -ps >> board1.bin
echo “0000 0000 8000 8000 8000 0000 0000 8000 8000” | xxd -r -ps >> board1.bin
echo “0000 0000 8000 8000 8000 0000 0000 8000 8000” | xxd -r -ps >> board1.bin
# luego decidimos los bloques que queremos generar con una máscara de bits: el bloque rojo vale 1, el verde 2, el azul 4 y el amarillo 8
# pasando un 7 significa que queremos generar un nivel que use los bloques rojo, verde y azul
# el tercer parámetro es el número de iteraciones máximas
./level_generator board1.bin 7 2000

Mediante este algoritmo se han generado los niveles A4 al C1. Los niveles A1 al A3 se han generado a mano y del C2 en adelante los niveles están vacíos, no son "ganables" y por ahora no se pueden desbloquear. El código fuente está disponible para quien quiera completar niveles o rediseñarlos.



Todo, el código fuente junto con una ROM precompilada, puede descargarse de la sección soft.


[ 2 comentarios ] ( 32225 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1060 )

<< <Anterior | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Siguiente> >>