Desarrollo de una miniconsola de videojuegos portátil (1): diseño hardware 
A lo largo de 4 entradas consecutivas en el blog iré detallando todo el desarrollo y la implementación de una miniconsola de videojuegos portátil que he desarrollado para mi hijo. La idea era hacer una consola al estilo "maquinita" o "game & watch" pero algo más elaborada, alimentada con batería recargable y con algunos juegos prefijados. En esta entrada me centraré en el diseño hardware y el desarrollo de las librerías básicas para acceso al hardware.

Características principales

- Microcontrolador GD32VF103: núcleo RISC-V de 32 bits a 96 MHz, con 256 Kb de Flash y 32 Kb de SRAM.

- Pantalla: Módulo GMG12864 basado en el controlador de display ST7565 de 128x64 pixels en blanco y negro (sin escalas de grises, cada pixel encendido o apagado).

- Botonera: Cruceta (arriba, abajo, izquierda y derecha) más dos botones adicionales (A y B) de funcionalidad personalizable.

- Alimentación: Batería de una celda de LiPo o LiIon de 1200 mAh (3.7 voltios) para unas 6 horas de juego continuado. Recargable mediante módulo de controlador de carga con conector USB-C y con interruptor de encendido.



A continuación una foto del frontal de la consola (encendida aunque aún sin caja).



Y de la parte trasera, donde se puede ver todo el trabajo de soldadura (a muchos técnicos en electrónica seguramente les sangrarán los ojos, pero bueno, hice lo que pude, se me da mejor programar que soldar).



Pantalla

Aprovechando que el microcontrolador tiene una potencia razonable se opta por un modelo de pantalla con una capa de abstracción basada en framebuffer, de tal manera que los juegos de la consola escribirán en un framebuffer de lineal de $128 \times 64 = 8192$ bytes. Para encender o apagar el pixel (x, y) se escribirá un 1 o un 0, respectivamente, en el offset $\left( y \times 128 \right) + x$ del framebuffer:

frameBuffer[(y * 128) + x] = 1;     // encender pixel (x, y)
frameBuffer[(y * 128) + x] = 0;     // apagar pixel (x, y)

Habrá una clase encargada de traducir la información del framebuffer en transferencias SPI al módulo GMG12864 para que se pinte de forma adecuada la pantalla. Esta abstracción nos permite adaptarnos a pantallas futuras y no depender sólo de esa pantalla en concreto, además de que facilita el desarrollo y las pruebas como veremos más adelante.

Botonera

La botonera se implementa con 6 botones mecánicos con el común a masa. Las 6 entradas GPIO en el microcontrolador se configuraon como GPIOs en pullup y así nos ahorramos tener que poner resistencias de pull-up por fuera. Se opta por no poner circuitería antirrebote en los botones para abaratar costes: el antirrebote se realizará por software, mediante una máquina de estados que, con temporizadores, evitará que se produzcan rebotes en la acción de las teclas.

Alimentación

La alimentación es muy sencilla, se utiliza un módulo TP4056 para una celda LiPo o LiIon de 3.7 voltios que ya viene con conector USB-C para carga y salida estabilizada que puede ir directa a la entrada de 5 voltios del módulo del microcontrolador. Toda la consola requiere 3.3 voltios para funcionar pero, como el convertidor de voltaje de la placa del microcontrolador tiene un dropout muy bajo, se pueden meter los 3.7 voltios de salida de la controladora de carga por la entrada de 5 voltios de la placa del microcontrolador. El interruptor de alimentación se coloca en serie con la alimentación que llega al microcontrolador y a la pantalla de tal manera que, aunque el interruptor de la consola esté apagado, su batería se podrá cargar con un cargador estándar USB-C.

Entorno de desarrollo y clases básicas

Como otros desarrollos "grandes" que he hecho siempre intento que el proceso de desarrollo y de depuración sean lo más eficientes posibles y para ello trato siempre de aprovechar el uso de clases abstractas para abstraer el código del hardware específico o la plataforma en la que estoy trabajando. Por ejemplo, para el manejo de la pantalla habrá una clase "Display" que albergará el framebuffer y algunas funciones miembro auxiliares, a continuación se crea una carpeta "gd32vf103" donde irá la implementación específica para el microcontrolador y la pantalla utilizadas "SPIGMG12864Display" que heredará de "Display". Se crea también una carpeta "linux" donde va la implementación específica para Xlib ("XlibDisplay").

A continuación los diagramas de clases de las clases principales del código fuente:



En azul las clases específicas del microcontrolador, en verde las clases específicas de linux y en blanco las clases comunes.




En estos diagramas de clases se pueden ver las clases básicas que constituyen el "framework" de la miniconsola. El elemento central para entender cómo funciona el flujo del software es la clase "Screen" que representa una pantalla (título, menú, un juego en sí, etc.) y la clase "ScreenManager", encargada de ir cambiando de pantallas en función de las necesidades del flujo del programa.

Cada Screen debe implementar las funciones miembro:

void Screen::onLoad(InterScreenData *dataFromPreviousScreen): Esta función se invoca cuando se carga una pantalla, se supone que a partir de este momento el framebuffer es "suyo" por lo que lo lógico en esta función miembro es que se inicialicen variables, se borre el framebuffer, se pinten las partes fijas del mismo, se inicialice la mecánica de esta pantalla, etc.

Screen *Screen::onUpdate(): Esta función se ejecuta cada 20 ms por parte del timer del sistema para que se implementen la mecánica de la pantalla (menú, juego, etc.). Si devuelve *this o nullptr significará que no hay que cambiar de pantalla, en caso contrario significa que queremos cambiarnos a la pantalla correspondiente.

InterScreenData *Screen::onUnload(): Esta función miembro se ejecuta en caso de que la última llamada a "onUpdate()" haya devuelto una "Screen *" válida (no nullptr) y diferente a la actual. La idea es poner aquí código de "terminación" de nuestra pantalla. Un objeto de clase "Screen" puede ser cargado ("onLoad") y descargado ("onUnload") varias veces entre su construcción y su destrucción.

La clase "ScreenManager" heredará de la clase "Task" para implementar la función miembro run, donde se realizará la mecanica del onLoad/onUpdate/onUnload indicada:
class InterScreenData {
}; 
    
class Screen;

class Screen {
    public:
        virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0;
        virtual Screen *onUpdate() = 0;
        virtual InterScreenData *onUnload() = 0;
};

class ScreenManager : public Task {
    public:
        Screen *currentScreen;
        ScreenManager(Screen &initialScreen, InterScreenData *initialInterScreenData);
        virtual void run();
};

ScreenManager::ScreenManager(Screen &initialScreen, InterScreenData *initialInterScreenData) : currentScreen(&initialScreen) {
    this->currentScreen->onLoad(initialInterScreenData);
}   

void ScreenManager::run() {
    Screen *nextScreen = this->currentScreen->onUpdate();
    if ((nextScreen != this->currentScreen) && (nextScreen != nullptr)) {
        InterScreenData *isd = this->currentScreen->onUnload();
        nextScreen->onLoad(isd);
        this->currentScreen = nextScreen;
    }
}

Como se puede ver es una mecánica muy sencilla. A partir de la clase "Screen" se crean todas las pantallas de la aplicación, por ejemplo:

- SplashScreen: Pantalla de bienvenida con una imagen de fondo y un texto de copyright que espera a que pulses un botón para pasar a la siguiente pantalla.

- MenuScreen: Pantalla de menú que permite, a su vez, especializarse para crear diferentes menus (como MainMenuScreen).

- ...

Cualquier clase que herede de Screen y que implemente los tres métodos especificados será otro tipo de pantalla con la funcionalidad que queramos.

Esta forma de programar la aplicación es muy escalable y permite crear fácilmente flujos de código muy elaborados:
int main() {
    // init hardware
    interruptInit();
    RGBLed::init();
    GPIOButtons buttons;
    Random random(buttons);
    SPIGMG12864Display display;
    display.blank();
    // create screens
    InitialSplashScreen initialSplashScreen(display, buttons);
    MainMenuScreen mainMenuScreen(display, buttons);
    TetrisMainScreen tetrisMainScreen(display, buttons, random);
    TanksMainScreen tanksMainScreen(display, buttons, random);
    SnakeMainScreen snakeMainScreen(display, buttons, random);
    SnoopyMainScreen snoopyMainScreen(display, buttons, random);
    // link screens
    initialSplashScreen.setNextScreen(mainMenuScreen);
    mainMenuScreen.setBackScreen(initialSplashScreen);
    mainMenuScreen.setTetrisScreen(tetrisMainScreen);
    mainMenuScreen.setTanksScreen(tanksMainScreen);
    mainMenuScreen.setSnakeScreen(snakeMainScreen);
    mainMenuScreen.setSnoopyScreen(snoopyMainScreen);
    // main loop
    ScreenManager m(initialSplashScreen, nullptr);
    MyListener myListener(display, buttons, m);
    Timer::init(myListener, 20_msForTimer);
    while (true)
        asm volatile ("wfi");
}

No se usan variables globales (no son necesarias):

1. Se inicializa el hardware: el controlador de interrupciones, la pantalla, la botonera y el generador de números pseudoaleatorios.

2. Se construyen todas las pantallas: A todas les pasamos el objeto display y el objeto buttons (a algunas de ellas se les pasa el generador del números pseudoaleatorios).

3. Se enlazan las pantallas: Cada objeto de clase Screen debe tener los punteros a las pantallas hacia las que puede irse a partir de él. Por ejemplo, la pantalla initialSplashScreen debe saber que debe ir a la pantalla mainMenuScreen cuando pulsen un botón. De la misma forma la pantalla de menú, que debe saber a qué pantalla se salta con cada opción.

4. Justo antes del bucle principal: Se le indica al ScreenManager cual es pantalla inicial (la que debe aparecer en el arranque).

5. Bucle principal: En este caso, para ahorrar energía, no se hace el típico bucle "while (true)" sino que se programa el timer del sistema para dispararse cada 20 milisegundos y en la función miembro "timerExpired" del objeto escuchador del timer, se invoca la máquina el "run" de los botones y el "run" del ScreenManager, que es la función miembro encargada de gestionar las pantallas (llamar a onLoad/onUpdate/onUnload de las pantallas). Haciendo el bucle principal podemos utilizar la instrucción ensamblador "wfi" (wait for interrupt) para que, entre iteraciones, el procesador pueda dormirse y así evitar que se consuma mucha batería.



Siguiente entrega

En la siguiente entrega se analizará el diseño y la implementación del Tetris (uno de los cuatro juegos que incluye la miniconsola).



Todo el código y los diseños están en la sección soft.

Comentarios 
Lo sentimos. No se permiten nuevos comentarios después de 90 días.