Programación en C++ de la PlayStation 1: desarrollo de un sencillo juego 2D desde cero 
La PlayStation 1 fue la primera consola de 32 bits lanzada por Sony en 1994. Está basada en un procesador MIPS R3000A, dispone de 4 Mb de memoria RAM, una GPU basada en framebuffer, un coprocesador de sonido y un coprocesador para cálculo geométrico especializado en proyección 3D. A lo largo de este artículo se describirá todo el proceso para programar esta consola desde cero: desde compilar la toolchain hasta desarrollar un juego sencillo en 2D.

Breve descripción de la plataforma

La mejor fuente de información técnica sobre la PlayStation 1 es la web de Nocash. La PlayStation 1 dispone de 4 Mb de memoria RAM accesible por la CPU y de 1 Mb de memoria VRAM accesible desde la GPU (espacio de direcciones de la GPU no compartido con la CPU). El coprocesador de sonido dispone de 512 Kb de RAM para muestras y procesado de señal de audio.

Las tarjetas de memoria y los mandos se acceden mediante protocolo SPI. El procesador MIPS, al igual que el ARM, no tiene un espacio de direcciones de memoria separado del espacio de direcciones E/S, por lo que los puertos de E/S para el control de periféricos están mapeados en el espacio de direcciones fuera de los 4 Mb de RAM.

Proceso de arranque

Los discos de PlayStation 1 son CD-ROM estándar ISO 9660 con información encriptada de licencia en los primeros sectores de los mismos. Cuando la PlayStation 1 arranca, tras comprobar la licencia del disco y mostrar el logo inicial, busca un fichero de texto llamado "SYSTEM.CNF" en la carpeta raíz, que contiene, entre otros datos, la ruta dentro del CD-ROM donde está el ejecutable de arranque. En ausencia de este fichero "SYSTEM.CNF", directamente busca un fichero que se llame "PSX.EXE" en la carpeta raiz del CD-ROM y lo carga a partir de la dirección 0x80010000.

El fichero "PSX.EXE" tiene una cabecera de 2048 bytes en la que se indica la dirección de arranque, la región del juego, el valor del puntero de pila en el arranque, etc:



Como se puede ver, la BIOS de la PlayStation 1 carga del CD-ROM el código del fichero EXE, lo escribe a partir de la dirección 0x80010000 y luego salta a la dirección de memoria indicada por el campo PC de la cabecera (normalmente también 0x80010000, pero puede ser otra dirección dentro del espacio de 4 Mb) y es ahí donde debe estar el punto de entrada del ejecutable.

GPU

La GPU de la PlayStation 1 se encarga de manejar el frame buffer y la VRAM. La GPU no realiza transformaciones geométricas 3D, es una GPU 2D. Las transformaciones geométricas para el cálculo 3D las realiza otro coprocesador aparte: el GTE (Geometry Transformation Engine), del que no hablaré en esta entrada ya que el objetivo es hacer una prueba de concepto y un juego sencillo 2D.

La mayor particularidad de la GPU de la PlayStation 1 es que ve la VRAM (de 1 Mb) como un framebuffer de 1024x512 pixels y los comandos de GPU no hacen referencia a direcciones de memoria sino a coordenadas en el framebuffer: copiar rectángulos, mapear texturas, pintar. Esto facilita mucho el trabajo de gestión de la VRAM. Cada pixel visible (que esté dentro del área de visualización) puede abarcar 16 bits o 24 bits. Lo habitual es aplicar la técnica del swap en el framebuffer:
configurar 2 zonas en el framebuffer, con el mismo tamaño que el área de visualización
framebuffer_visualizado = el que se está mostrando ahora
framebuffer_trabajo = el que está oculto a la visualización
bucle infinito:
esperar retrazo vertical (vsync)
cambiar área visualización a framebuffer_trabajo
intercambiar en GPU framebuffer_visualizado y framebuffer_trabajo
pintar_escena(framebuffer_trabajo)
fin bucle

Si asumimos una profundidad de color de 16 bits para el área de visualización y un modo gráfico de 320x240 (relación de aspecto 4:3), podemos colocar dos framebuffers uno encima del otro, dejando el resto de la VRAM para sprites y texturas:



Si configuramos la GPU para que, en la ventana de visualización, cada pixel sea de 24 bits, cada frame ocupará 480 "pixels" de ancho, ya que cada 2 pixels de 24 bits equivalen a 3 pixels de 16 bits. Yo he optado por asumir siempre 16 bits por pixel para facilitar todas las operaciones gráficas.

Para inicializar la GPU en 320x240 PAL y 16 bits por pixel:
void psx::gpu_set_vertical_display_center(int16_t rel) {
    uint16_t y1 = 0x002B + rel;
    uint16_t y2 = 0x011B + rel;
    GP1 = 0x07000000 | ((((uint32_t) y2) << 10) & 0x000FFC00) | (((uint32_t) y1) & 0x000003FF);
}

void psx::gpu_init() {
    // reset GPU
    GP1 = 0x00000000;
    // display mode: 16 bit, 320x240, pal
    GP1 = 0x08000009;
    // horizontal display range, typical PAL values: x1 = 0x260, x2 = 0x260 + (320 *8) = 0xC60
    //GP1 = 0x06C60260;
    GP1 = 0x06000000 | (((uint32_t) (0x270 + (320 * 8))) << 12) | ((uint32_t) 0x270);
    // vertical display range, typical PAL values: y1 = 0xA3 - (240 / 2) = 0xA3 - 120 = 0x2B, y2 = 0xA3 + (240 / 2) = 0xA3 + 120 = 0x11B
    // 00000111 0000 0100011011 0000101011 = 00000111 00000100 01101100 00101011 = 0x07046C2B
    //                    0x11B       0x2B
    //GP1 = 0x07046C2B;
    gpu_set_vertical_display_center(0);
    // enable display
    GP1 = 0x03000000;
    // start of display area in VRAM (0, 0)
    // 00000101 00000 000000000 0000000000
    //                y         x
    GP1 = 0x05000000;
    // start of display area in VRAM (0, 120)
    // 00000101 00000 001111000 0000000000 = 00000101 00000001 11100000 00000000 = 0x0501E000
    //                y         x
    ////GP1 = 0x0501E000;
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
    // allow drawing to display area (bit 10 = 1)
    GP0 = 0xE1000400;
    // set drawing area: top left (x1 = y1 = 0)
    GP0 = 0xE3000000;
    // set drawing area: bottom right: x2 = 1023 = 0b1111111111, y2 = 511 = 0b111111111
    // 11100100 00000 000000000 0000000000
    //                      511       1023
    //                111111111 1111111111
    // 11100100 00000 111111111 1111111111 = 11100100 00000111 11111111 11111111 = 0xE407FFFF
    GP0 = 0xE407FFFF;

    // set drawing offset = 0
    GP0 = 0xE5000000;
    // disable all interrupts
    I_MASK = 0;
    // fill entire VRAM with 0
    GP0 = 0x02000000;
    GP0 = 0x00000000;
    GP0 = (((uint32_t) 512) << 16) | ((uint32_t) 1024);
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
}

Como se puede comprobar, trabajar con la GPU consiste en enviar comandos seguidos de datos a las direcciones de memoria GP0 y GP1. A continuación se puede ver el código de lo que sería el comando "monochrome rectangle":
void psx::gpu_draw_rectangle(uint16_t x, uint16_t y, uint16_t width, uint16_t height, uint32_t color) {
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
    GP0 = 0x60000000 | (color & 0x00FFFFFF); 
    GP0 = (((uint32_t) y) << 16) | ((uint32_t) x);
    GP0 = (((uint32_t) height) << 16) | ((uint32_t) width);
}

Como se comentó al principio, la RAM de la CPU es independiente de la VRAM y para hacer transferencias entre ambas hay que usar comandos GPU acompañados de escrituras/lecturas de datos a través de un registro o mediante DMA.
void psx::gpu_copy_ram_to_vram(const uint32_t *src, uint32_t n, uint16_t dst_x, uint16_t dst_y, uint16_t dst_width, uint16_t dst_height) {
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
    GP0 = 0xE6000000;                             // mask setting: draw always, leave bit 15 as transfered
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ; 
    GP0 = 0xA0000000;                                                 // copy rectangle CPU to VRAM
    GP0 = (((uint32_t) dst_y) << 16) | ((uint32_t) dst_x);            // destination coords
    GP0 = (((uint32_t) dst_height) << 16) | ((uint32_t) dst_width);   // size
    while (n > 0) {
        GP0 = *src;
        src++;
        n--;
    }
}

Al contrario que las GPUs de las consolas predecesoras de su época, la PlayStation 1 no maneja el concepto de sprites: se dibuja directamente en el framebuffer, por lo que los sprites deben ser gestionados "a mano", copiando regiones de un sitio a otro de la VRAM (por ejemplo, el comando "textured rectangle" permite copiar rectángulos de una zona a otra de la VRAM sin escalar y asumiendo que el los pixels con el valor 0x0000 son transparentes).
void psx::gpu_draw_8x8_tile(uint8_t src_x, uint8_t src_y, uint16_t dst_x, uint16_t dst_y, uint8_t tp_x, uint8_t tp_y) {    // tp_x = 0..15, tp_y = 0..1
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
    GP0 = 0xE6000000;                             // mask setting: draw always, leave bit 15 as transfered
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
    GP0 = 0xE1000500 | (((uint32_t) tp_x) & 0x0000000F) | ((((uint32_t) tp_y) << 4) & 0x00000010);                             // texture page, drawing enabled, 15 bit texture
    while (!(GPUSTAT & (((uint32_t) 1) << 26)))
        ;
    GP0 = 0x75000000;                                                 // texture rectangle, opaque, 8x8, raw texture
    GP0 = (((uint32_t) dst_y) << 16) | ((uint32_t) dst_x);
    GP0 = (((uint32_t) src_y) << 8) | ((uint32_t) src_x);
}

Como se puede ver, en todos los comandos de pintado se indican directamente coordenadas en el framebuffer, no direcciones de memoria en la VRAM.

Compilar el compilador

De la misma forma que se ha hecho para otras plataformas, compilaremos una toolchain de GNU sin sistema operativo, basada en binutils, gcc y newlib para el target "mipsel-none-elf" (aunque la arquitectura MIPS puede funcionar tanto en modo big endian como en modo little endian, en la PlayStation 1, la CPU está fijada a modo little endian y por eso el primer elemento de la terna de la toolchain debe ser "mipsel").

binutils
mkdir -p /opt/baremetalmipsel/src
cd /opt/baremetalmipsel/src
wget https://ftp.gnu.org/gnu/binutils/binutils-2.46.0.tar.xz
tar xf binutils-2.46.0.tar.xz
mkdir -p /opt/baremetalmipsel/build/binutils-2.46.0
cd /opt/baremetalmipsel/build/binutils-2.46.0
../../src/binutils-2.46.0/configure --prefix=/opt/baremetalmipsel --target=mipsel-none-elf --disable-nls
make
make install


gcc (fase 1)
cd /opt/baremetalmipsel/src
wget https://ftp.gnu.org/gnu/gcc/gcc-15.1.0/gcc-15.2.0.tar.xz
wget https://ftp.gnu.org/gnu/gmp/gmp-6.3.0.tar.xz
wget https://ftp.gnu.org/gnu/mpc/mpc-1.4.1.tar.gz
wget https://ftp.gnu.org/gnu/mpfr/mpfr-4.2.2.tar.xz
tar xf gcc-15.2.0.tar.xz
tar xf gmp-6.3.0.tar.xz
tar xf mpc-1.4.1.tar.gz
tar xf mpfr-4.2.2.tar.xz
mv gmp-6.3.0 gcc-15.2.0/gmp
mv mpc-1.4.1 gcc-15.2.0/mpc
mv mpfr-4.2.2 gcc-15.2.0/mpfr
mkdir -p /opt/baremetalmipsel/build/gcc-15.2.0-stage-1
cd /opt/baremetalmipsel/build/gcc-15.2.0-stage-1
export PATH=/opt/baremetalmipsel/bin:${PATH}
../../src/gcc-15.2.0/configure --prefix=/opt/baremetalmipsel --target=mipsel-none-elf --enable-languages=c,c++ --without-headers --disable-nls --disable-threads --disable-shared --disable-libssp --with-newlib &#8211;disable-libstdcxx
make all-gcc all-target-libgcc
make install-gcc install-target-libgcc


newlib
cd /opt/baremetalmipsel/src
git clone https://sourceware.org/git/newlib-cygwin.git
mkdir -p /opt/baremetalmipsel/build/newlib
cd /opt/baremetalmipsel/build/newlib
../../src/newlib-cygwin/configure --prefix=/opt/baremetalmipsel --target=mipsel-none-elf
make
make install


gcc (fase 2)
mkdir -p /opt/baremetalmipsel/build/gcc-15.2.0-stage-2
cd /opt/baremetalmipsel/build/gcc-15.2.0-stage-2
../../src/gcc-15.2.0/configure --prefix=/opt/baremetalmipsel --target=mipsel-none-elf --enable-languages=c,c++ --disable-nls --disable-threads --disable-shared --disable-libssp --with-newlib --with-headers=../../src/newlib-cygwin/newlib/libc/include
make
make install

De esta manera ya tendríamos instalada nuestra toolchain en "/opt/baremetalmipsel/bin".

Script de enlazado y código de arranque

El script de enlazado que se usará está basado en el publicado por Xoddiel con algunas modificaciones menores. Le he añadido la referencia a "startup.o" que es mi forma preferida de implementar las funciones de arranque antes de invocar la función "main":
RAM_BASE    = 0x80000000;            /* this is the start of our main memory segment */
RAM_SIZE = 2M; /* PSX has 2 MiB of RAM */
BIOS_SIZE = 64K; /* PSX reserves the lower 64 KiB of RAM for BIOS/kernel */
HEADER_SIZE = 2K; /* PSX EXE files must start with a 2 KiB header */
LOAD_ADDR = RAM_BASE + BIOS_SIZE; /* address where our binary will be loaded (0x80010000) */
STACK_INIT = RAM_BASE + 0x001FFF00; /* the top of our stack (remember, stack grows downwards) */

/* the layout of our memory */
MEMORY {
HEADER : ORIGIN = LOAD_ADDR - HEADER_SIZE, LENGTH = HEADER_SIZE
RAM (rwx) : ORIGIN = LOAD_ADDR, LENGTH = RAM_SIZE - (LOAD_ADDR - RAM_BASE)
}

/* here we tell the linker how should the file be filled with data */
SECTIONS {
/* this is our PSX EXE header */
.psx_exe_header : {
/* magic number (ASCII string "PS-X EXE") */
BYTE(0x50); BYTE(0x53); BYTE(0x2d); BYTE(0x58);
BYTE(0x20); BYTE(0x45); BYTE(0x58); BYTE(0x45);

/* 8 unused bytes */
QUAD(0);

/* our entry point */
LONG(ABSOLUTE(__text_start));

/* intial value of global pointer (I don't think this is used by Rust) */
LONG(0);

/* address where our binary gets loaded to */
LONG(LOAD_ADDR);

/* number of bytes that should be loaded (after this header) */
LONG(__bss_start - __text_start);

/* 16 unused bytes */
QUAD(0); QUAD(0);

/* stack base pointer */
LONG(STACK_INIT);

/* initial stack offset */
LONG(0);

/* 24 unused bytes */
QUAD(0); QUAD(0); LONG(0);

/* region indicator (North America, Europe, Japan) */
KEEP(*(.region));

/* alignment to 2 KiB */
. = ALIGN(HEADER_SIZE);
} > HEADER

/* here is where our code lives */
.text : {
__text_start = .;

startup.o (.startup)

/* our constructors table */
__ctors_start = .;
,*(.ctors*)
__ctors_end = .;
ASSERT((__ctors_end - __ctors_start) % 4 == 0, "Invalid .ctors section");

/* our destructors table */
__dtors_start = .;
,*(.dtors*)
__dtors_end = .;
ASSERT((__dtors_end - __dtors_start) % 4 == 0, "Invalid .dtors section");

/* the majority of our code */
,*(.text*)

__text_end = .;
} > RAM

/* this is where all of our static variables, strings, etc. live */
.data : {
__data_start = .;
,*(.data*)
,*(.rodata*)
,*(.got)

/* padding to a multiple of 2K is required for loading from ISO */
. = ALIGN(2048);
__data_end = .;
} > RAM

/* this is that uninitialized .bss section, I was talking about */
.bss (NOLOAD) : {
__bss_start = .;
,*(.bss*)
,*(COMMON)
__bss_end = .;
} > RAM

/* make the heap word-aligned */
. = ALIGN(4);
__heap_start = .;

/* drop all sorts of useless metadata */
/DISCARD/ : {
*(.MIPS.abiflags)
*(.reginfo)
*(.eh_frame_hdr)
*(.eh_frame)
}
}

En "startup.cpp" defino la función "_startup" en la sección ".startup" (los nombres son arbitrarios, no tienen por qué ser esos) y en el script de enlazado indico que el punto de entrada es el código que está en la sección ".startup".
using namespace std;

extern "C" {
    extern void (*__ctors_start)();
    extern void (*__ctors_end)();
    extern void (*__dtors_start)();
    extern void (*__dtors_end)();
}

void _callConstructors() {
    void (**constructor)() = &__ctors_start;
    while (constructor != &__ctors_end) {
        (*constructor)();
        constructor++;
    }
}

void _callDestructors() {
    void (**destructor)() = &__dtors_start;
    while (destructor != &__dtors_end) {
        (*destructor)();
        destructor++;
    }
}

extern int main();

extern "C" {
    extern unsigned char __bss_start;
    extern unsigned char __bss_end;
}

void _initBssRAM() {
    // init .bss section with zeros
    unsigned char *p = &__bss_start;
    while (p != &__bss_end) {
        *p = 0;
        p++;
    }
}

void _startup() __attribute__((section(".startup")));   // startup located at begining of EXE

void _startup() {
    _initBssRAM();
    _callConstructors();
    main();
    _callDestructors();
    while (true)
        ;
}

extern "C" void __cxa_pure_virtual() {}
void *__dso_handle = 0;
extern "C" void __cxa_atexit() {}

Como todo el .EXE se carga en RAM, las variables globales inicializadas no hace falta que sean copiadas y sólo es necesario inicializar a cero la zona de memoria "BSS" (función "_initBssRam") e invocar los constructores globales que defina el compilador en la sección ".ctors" (función "_callConstructors").

Compilación de un proyecto

Para compilar un proyecto, compilamos todos los fuentes del mismo de la siguiente manera:
$ mipsel-none-elf-g++ -std=c++20 -march=r3000 -fno-exceptions -fno-rtti -nostartfiles -nodefaultlibs -G 0 -c -o fichero1.o fichero1.cpp
$ mipsel-none-elf-g++ -std=c++20 -march=r3000 -fno-exceptions -fno-rtti -nostartfiles -nodefaultlibs -G 0 -c -o fichero2.o fichero2.cpp
$ ...
$ mipsel-none-elf-g++ -std=c++20 -march=r3000 -fno-exceptions -fno-rtti -nostartfiles -nodefaultlibs -G 0 -c -o startup.o startup.cpp

Enlazamos los ficheros objeto que acabamos de generar con el script de enlazado:
$ mipsel-none-elf-g++ -std=c++20 -march=r3000 -fno-exceptions -fno-rtti -nostartfiles -nodefaultlibs -G 0 -T ps1.ld -o main.elf startup.o fichero1.o fichero2.o ...

Esto produce un fichero "main.elf" que se puede convertir a PSX.EXE fácilmente de la siguiente manera:
$ mipsel-none-elf-objcopy -O binary main.elf psx.exe

En la cabecera del fichero "psx.exe", el campo "filesize" contiene el tamaño del fichero "psx.exe" excluyendo la cabecera de 2048 bytes y dicho valor debe ser múltiplo de 2048 bytes (2048 bytes es el tamaño de un sector del CD-ROM). Para asegurarnos de que este campo tiene un valor múltiplo de 2048 hice el script "round_up_size.sh", que hace un redondeo hacia arriba de dicho valor en caso necesario en la misma cabecera del fichero "psx.exe".

Después de obtener el fichero PSX.EXE y tras invocar el script "round_up_size.sh" montamos la imagen del CD-ROM que lo contendrá (junto con otros ficheros que queramos incluir en el CD-ROM: datos de juego, sprites, texturas, audio, etc.). Para crear una imagen de CD-ROM compatible con PlayStation 1 lo mejor es usar la herramienta psximager mediante los siguientes pasos.

1. Creamos un fichero "catálogo" para el psximager que describa la estructura del CD-ROM:
system_area {
file "license.sys"
}

volume {
system_id [PLAYSTATION]
volume_id [MYGAME]
publisher_id [AVELI]
creation_date 2026-05-04 22:00:00.00 0
}

dir {
file PSX.EXE
}

El fichero "license.sys" incluye los datos de licencia del juego. En mi caso los extraje de un juego comercial para la misma región (Europa) que mi PlayStation 1 (incluí el fichero "license.sys" en el targz del proyecto).

2. Los ficheros que queremos que vayan en el CD-ROM los colocaremos en una carpeta que se llame igual que el fichero catálogo sin la extensión:
$ mkdir MYGAME
$ cp psx.exe MYGAME/PSX.EXE # SIEMPRE en mayúsculas para cumplir ISO9660
$ psxbuild -c MYGAME.cat

Tras estos pasos ya tendremos nuestros dos ficheros "MYGAME.bin" y "MYGAME.cue", que podremos tostar en un CD-ROM físico y ejecutarlo en una PlayStation 1 o en un emulador.

Nótese que la "licencia" de los programas que hagamos así no será válida (no está firmada digitalmente por Sony), por lo que, para ejecutar nuestro juego en una PlayStation 1 real, ésta deberá estar "chipeada" o permitir, mediante algún tipo de mod, la ejecución de código no firmado. En mi caso, modifiqué mi PlayStation 1 con el mod PSIO,que permite lanzar juegos desde tarjeta de memoria SD sin desactivar el lector de CD de la consola.

Prueba de concepto

Como primera prueba de concepto mostraremos un rectángulo alternativamente de dos colores diferentes en pantalla a razón de un cambio de color por segundo, sincronizado con el retrazo vertical de la GPU. Uno de los colores será verde o amarillo en función de si detectamos que se está pulsando el botón "derecha" en el gamepad del jugador 1.

int main() {
    gpu_init();
    gpu_copy_font();
    gpu_draw_rectangle(0, 0, 320, 240, 0x00FF0000); 
    gpu_draw_text(0, 0, "A simple GPU example");
    uint32_t n = 50;
    bool b = false;
    while (true) {
        gpu_wait_vblank();
        uint16_t m = get_controller_mask();
        n--;
        if (n == 0) {
            b = !b;
            if (b) {
                if ((m & ((uint16_t) 1) << 5) == 0)
                    gpu_draw_rectangle(10, 10, 300, 220, 0x0000FF00);   // right button pressed = green rectangle
                else
                    gpu_draw_rectangle(10, 10, 300, 220, 0x0000FFFF);   // no button pressed = yellow rectangle
            }
            else
                gpu_draw_rectangle(10, 10, 300, 220, 0x000000FF);
            n = 50;
        }
    }
    return 0;
}

El resultado es fondo azul, el texto arriba a la derecha y el rectángulo central cambiando 1 vez por segundo y cambiando de color en función de si se está pulsando el botón "derecha" del gamepad.



Diseño de un juego 2D sencillo

Haremos un clon del Arkanoid o Breakout: el típico juego con la pelotita que rebota y en el que hay que romper todos los bloques. Definimos el siguiente grafo de pantallas:



Y utilizaremos la abstracción de la clase "screen_t" que se ha utilizado en otros proyectos de desarrollo de otras consolas:
extern "C++" {
    namespace psx {
        using namespace std;

        class shared_data_t {
        };      

        class screen_t {
            public:
                shared_data_t &shared_data;
                screen_t(shared_data_t &shared) : shared_data(shared) { };
                virtual void on_load() = 0;
                virtual screen_t *on_vblank(uint16_t drawable_y_origin, bool &swap_fb, uint16_t controller_buttons) = 0;
                virtual void on_unload() = 0;
        };

        class game_shared_data_t : public shared_data_t {
            public:
                uint16_t current_level_index;
                int16_t vertical_adjust_rel;
        };
    }
}

Características principales de este patrón de diseño:

- El constructor de "screen_t" recibe una referencia a un objeto de tipo "shared_data_t", que contiene los datos que compartirán todas las pantallas.

- La función miembro "void on_load()" se invoca cuando la pantalla se carga.

- La función miembro "screen_t *on_vblank(uint16_t drawable_y_origin, bool &swap_fb, uint16_t controller_buttons)" recibe la coordenada "y" del framebuffer donde la pantalla puede escribir si quiere, una referencia a un booleano donde devolverá si ha escrito en dicho framebuffer y, por tanto, hay que hace swap de los framebuffers en el siguiente retrazo vertical. Esta función miembro recibe también el estado de los botones del gamepad. Devuelve "nullptr" si no hay que cambiar de pantalla y un puntero a un objeto te tipo "screen_t" en caso de que haya que cambiar de pantalla.

- La función miembro "void on_unload()" se invoca en el caso de que la invocación a "on_vblank" haya devuelto diferente de nullptr y antes de invocar el "on_load" de la siguiente pantalla.

El bucle principal del juego quedaría de la siguiente manera:
int main() {
    controller_t controller;
    random_t random;
    game_shared_data_t shared_data;
    shared_data.vertical_adjust_rel = 0;
    // construct all the screens
    ...
    // link the screens
    ...
    // start with menu screen
    screen_t *current_screen = &menu_screen;
    // init GPU and double buffer
    gpu_init();
    gpu_copy_font();
    uint16_t drawable_y_origin = 240;
    gpu_set_visible(0, 0);
    bool swap_fb = false;
    current_screen->on_load();    // load first screen
    while (true) {
        gpu_wait_vblank();
        controller.update();
        random.update(controller.buttons);
        if (swap_fb) {
            if (drawable_y_origin == 0) {
                drawable_y_origin = 240;
                gpu_set_visible(0, 0);
            }
            else {
                drawable_y_origin = 0;
                gpu_set_visible(0, 240);
            }
        }
        screen_t *next_screen = current_screen->on_vblank(drawable_y_origin, swap_fb, controller.buttons);
        if (next_screen != nullptr) {
            current_screen->on_unload();
            current_screen = next_screen;
            current_screen->on_load();
        }
    }
    return 0;
}

Siguiendo este modelo, la programación de las diferentes pantallas se hace más sencilla ya que nos podemos centrar en resolver cada problema. A continuación en términos generales, los aspectos más importantes de cada pantalla:

- menu_screen_t: Muestra el menú principal de 2 opciones con el que se navega con izquierda y derecha en el gamepad y se elige opción con el botón aspa.

- adjust_screen_t: Muestra una pantalla con bordes de color. Pulsando arriba y abajo en el gamepad nos permite centrar la imagen en la TV (la señal de vídeo de la PlayStation 1 es analógica y, en aquella época, había televisiones donde era necesario ajustar la visualización para que se viese la imagen centrada). En el caso de la PlayStation 1, el ajuste horizontal no es necesario, por lo que sólo se permite el ajuste vertical.

- board_screen_t: Es la pantalla principal, donde sale el tablero, los bloques, la bola que rebota, etc. Incluye las mecánicas de rebote y de puntuación, así como un timer que cuando llega a cero pasa a la pantalla de game over, un "score" y un "level" para indicar en qué nivel nos encontramos. Cuando terminamos un nivel (borramos todos sus bloques antes de que acabe el tiempo) pasamos a la pantalla "you win". Si la pelota toca suelo y tenemos un "score" de 0 o de 1 también pasamos a la pantalla de game over.

- you_win_screen_t: En esta pantalla mostramos el nivel que se ha terminado correctamente. Si quedan niveles aún, se cambia de nivel y se espera a que el jugador pulse aspa para pasar de nivel o, si no quedan niveles por pasar, se muestra un "you win!" y se pasa a la pantalla de menú.

- game_over_screen_t: En esta pantalla mostramos un "game over" y espera unos segundos antes de volver a la pantalla de menú.

El juego es muy básico y no permite guardar partidas ni puntuaciones. A continuación puede verse el juego en acción en una PlayStation 1 real.







Todo el código está disponible en la sección soft.

Comentarios 

Agregar comentario

Rellene los campos de abajo para dejar su comentario.









Extras (Negrita / Cursiva / URL / Imagen):