7 Organizar el código

Hasta ahora hemos usado las funciones SDL2 directamente, como librería C. Y aunque no existe una librería C++ oficial, podemos encontrar varios envoltorios o "wrappers" para SDL2, de los que dejaré algunos enlaces más abajo. Pero nosotros vamos a crear nuestras propias clases para manejar nuestros juegos de una forma más ordenada. Esto nos facilitará la programación de juegos a medida que se vayan complicando.

De todos modos, al menos en principio, iremos creando nuestro propio envoltorio para SDL2.

Y lo haremos al mismo tiempo que programamos nuestro primer juego SDL2.

Encapsulado de SDL2

Aunque, al menos en mi opinión, usar la librería C de SDL2 puede ser al principio más simple, a la larga, y a medida que vayamos añadiendo cosas a un juego, usar una librería C++ que la envuelva mostrará sus ventajas.

Espacio con nombre

Todas las clases para encapsular SDL2 estarán declaradas en el espacio con nombre sdl:

// La ruta de SDL.h depende de la carpeta en la que se guarde este fichero
#include <SDL.h>

namespace sdl {
...
} // namespace sdl

Clase SDL2

Empezaremos con una clase básica para manipular la librería, esto es, iniciarla y liberarla. Esta clase, como otras, irá evolucionando para añadir funcionalidades a medida que vayamos necesitándolas.

class SDL2 {
protected:
    bool valid = true;

public:
    SDL2(Uint32 flags=SDL_INIT_EVERYTHING) {
        valid = (SDL_Init(flags) == 0);
    }
    ~SDL2() {
        if(valid) SDL_Quit();
    }
};

Clase FPoint

Aunque también crearemos una clase para encapsular SDL_Point, nos será mucho más útil usar en este juego en particular la versión con coordenadas en coma flotante, ya que tendremos que aplicar traslaciones, rotaciones y cambios de escala a los gráficos, y los valores enteros no son apropiados para eso.

class FPoint {
protected:
    SDL_FPoint point;
public:
    FPoint(float x=0.0, float y=0.0) {
        point.x = x;
        point.y = y;
    }
    FPoint(SDL_FPoint &p) : point(p) {}
    SDL_FPoint &Get() { return point; }
    FPoint Move(float alfa, const sdl::FPoint &displacement, const sdl::FPoint &scale) const {
        // x' = x * x" * cos(α) + y * y" * sin(α) + dx
        // y' = -x * x" * sin(α) + y * y" * cos(α) + dy
        return {
            scale.point.x*point.x * static_cast<float>(cos(alfa)) +
            scale.point.y*point.y * static_cast<float>(sin(alfa)) +
            scale.point.x*displacement.point.x,
            -scale.point.x*point.x * static_cast<float>(sin(alfa)) +
            scale.point.y*point.y * static_cast<float>(cos(alfa)) +
            scale.point.y*displacement.point.y};
    }
    float &X() { return point.x; }
    float &Y() { return point.y; }
};

Clase Window

Para manejar ventanas crearemos una clase Window, a la que también añadiremos nuevos miembros a medida que los necesitemos.

class Window {
protected:
    SDL_Window* window = nullptr;

public:
    Window() : window(NULL) {}
    Window(const std::string &titulo, int w, int h, SDL_WindowFlags flags) {
        window = SDL_CreateWindow(titulo.c_str(), SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
                w, h, flags);
    }
    ~Window() {
        SDL_DestroyWindow(window);
    }
    SDL_Window* Get() const { return window; }
    void GetSize(int *w, int *h) {
        SDL_GetWindowSize(window, w, h);
    }
    void SetSize(int w, int h) {
        SDL_SetWindowSize(window, w, h);
    }
    void Center() {
        SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
    }
};

Clase Color

En esta clase encapsula un objeto del tipo SDL_Color. Incluiremos dos constructores, uno a partir de los valores de las componentes r, g, b y a y otro usando una cadena con el formato de colores de HTML.

También incluiremos métodos para leer o modificar componentes individuales.

Finalmente, también añadiremos un operador de conversión de tipo. Esto nos permitirá pasar un objeto de esta clase a funciones SDL que requieran un parámetro de tipo SDL_Color.

class Color {
private:
    SDL_Color color;

public:
    Color(Uint8 r=0, Uint8 g=0, Uint8 b=0, Uint8 a=255) {
        color.r=r;
        color.g=g;
        color.b=b;
        color.a=a;
    }
    Color(const std::string c) { // Formato #RRGGBBAA, RRGGBBAA, #RRGGBB, RRGGBB, #RGBA, #RGB, RGBA, RGB
        std::string col = c;
        std::string R, G, B, A;

        if(c.substr(0,1) == "#") col = c.substr(1);
        else col = c;
        switch(col.size()) {
        case 8: // RRGGBBAA
            color.a = stoi(col.substr(6,2), nullptr, 16);
        case 6: // RRGGBB
            color.r = stoi(col.substr(0,2), nullptr, 16);
            color.g = stoi(col.substr(2,2), nullptr, 16);
            color.b = stoi(col.substr(4,2), nullptr, 16);
            break;
        case 4: // RGBA
            A = col.substr(3,1)+col.substr(3,1);
            color.a = stoi(A, nullptr, 16);
        case 3: // RGB
            R = col.substr(0,1)+col.substr(0,1);
            G = col.substr(1,1)+col.substr(1,1);
            B = col.substr(2,1)+col.substr(2,1);
            color.r = stoi(R, nullptr, 16);
            color.g = stoi(G, nullptr, 16);
            color.b = stoi(B, nullptr, 16);
            break;
        }
    }
    operator SDL_Color() { return color; }
    Uint8 GetR() const { return color.r; }
    Uint8 GetG() const { return color.g; }
    Uint8 GetB() const { return color.b; }
    Uint8 GetA() const { return color.a; }
    void SetR(Uint8 r) { color.r = r; }
    void SetG(Uint8 g) { color.g = g; }
    void SetB(Uint8 b) { color.b = b; }
    void SetA(Uint8 a) { color.a = a; }
};

Clase Renderer

Del mismo modo, para manejar renderers, crearemos una clase Renderer, a la que también añadiremos nuevos miembros a medida que los necesitemos.

class Renderer {
protected:
    SDL_Renderer *renderer;

public:
    Renderer() : renderer(NULL) {}
    Renderer(Window &window, SDL_RendererFlags flags) {
        renderer = SDL_CreateRenderer(window.get(), -1, flags);
    }
    ~Renderer() {
        SDL_DestroyRenderer(renderer);
    }
    operator SDL_Renderer*() { return renderer; }
    void SetDrawColor(Uint8 r, Uint8 g, Uint8 b, Uint8 a) {
        SDL_SetRenderDrawColor(renderer, r, g, b, a);
    }
    void SetDrawColor(const Color& color) {
        SDL_SetRenderDrawColor(renderer, color.GetR(), color.GetG(), color.GetB(), color.GetA());
    }
    void Clear() {
        SDL_RenderClear(renderer);
    }
    void DrawLinesF(std::vector<FPoint> puntos) {
        for(size_t i=0; i<puntos.size()-1; i++) {
            SDL_RenderDrawLineF(renderer, puntos[i].X(), puntos[i].Y(), puntos[i+1].X(), puntos[i+1].Y());
        }
    }
    void DrawFPoint(FPoint punto) {
        SDL_RenderDrawPointF(renderer, punto.X(), punto.Y());
    }
    void DrawFRect(FRect re) {
        SDL_RenderDrawRectF(renderer, re.get());
    }
    void Present() {
        SDL_RenderPresent(renderer);
    }
};

Clase Event

Para manejar eventos, crearemos una clase Event, a la que de momento solo hemos añadido un par de métodos y posteriormente añadiremos nuevos a medida que los necesitemos.

class Event {
protected:
    SDL_Event evento;

public:
	Event() { memset(static_cast<void*>(&evento), 0, sizeof(SDL_Event)); }
	bool poll() { return SDL_PollEvent(&evento); } // return SDL_PollEvent(reinterpret_cast<SDL_Event*>(this)); }
	Uint32 Type() { return evento.type; }
	SDL_Event &get() { return evento; }
};

Clase Game

Y para terminar encapsularemos el juego en una clase virtual:

class Game {
protected:
    Window& window;
    Renderer& renderer;
    Uint64 tick, tick0;
    Event event;
    bool quit;

public:
    Game(Window &win, Renderer &ren, int w=800, int h=600) : window(win), renderer(ren), quit(false) {
        window.SetSize(w, h);
        window.Center();
        std::srand(std::time(NULL));
    }

    Window &getWindow() { return window; }
    Renderer &getRenderer() { return renderer; }
    bool Quit() { return quit; }
    virtual void Init()=0;
    virtual void Events()=0;
    virtual void Update()=0;
    virtual void Render()=0;
    virtual void Run();
};

void Game::run() {
    Init();
    tick0 = SDL_GetTicks64();
    do {
        tick = SDL_GetTicks64()-tick0;
        tick0 = SDL_GetTicks64();
        Events();
        Update();
        Render();
        SDL_Delay(1);
    } while(!Quit());
}

Esto nos permite usar una clase derivada para nuestros en la que podremos añadir o modificar lo que sea necesario. Por ejemplo, si no tocamos el constructor, nuestro juego creará una ventana centrada de 800 por 600 pixels sin bordes. Pero nuestra clase derivada puede crear otros tipos de ventana sobrecargando el constructor. Para cada juego será necesario sobrecargar los métodos Init, Events, Update y Render, ya que son virtuales puros, pero nada nos impide sobrecargar los métodos Run o Quit si fuese necesario.

Algunos wrappers

Después de buscar un rato he encontrado al menos cinco librerías de clases que encapsulan SDL2:

sdlpp

La librerías sdlpp define clases para: ventana, renderer (con y sin soporte SDL2_gfx), textura, superficie, audio y eventos.

SDL2_gfx es una librería que proporciona funciones para gráficos como líneas, elipses, rectángulos, con diferentes grosores de línea.

mika314/sdlpp, por Mika Pi.

SDL2_gfx, por Michael Fitzmayer.

sdl2pp

La librería sdl2++ es otro wrapper sobre SDL2 para C++20. Incluye clases para ventana, renderer, textura, superficie, eventos, y algunas utilidades.

SDL2++, por Nathan Ward.

lib SDL2++

Probablemente la más completa, incluye otras librerías SDL2 como, SLD_image, SDL_mixer y SDL_ttf, y define clases para audio, punto, rectángulo, renderer, superficie, textura, ventana, RWops, etc.

lib SDL2++, por AMDmi3.

cpp-sdl2

También muy completa, aunque no incluye otras librerías SDL2, define clases para audio, punto, rectángulo, renderer, superficie, textura, ventana, Joystick, haptic, mouse, etc.

Se trata solo de ficheros de cabecera, sin código, y además contiene una documentación muy completa.

cpp-sdl2, por Léo.

Centurion

Otra librería bastante completa que incluye otras librerías SDL2 como, SLD_image, SDL_mixer y SDL_ttf.

Centurion, por Albin Johansson.

Asteroids (1)

Empezaremos a crear el juego aplicando conceptos que vayamos mostrando en cada capítulo. Asteroids es un buen primer candidato ya que no requiere muchos recursos, los gráficos vectoriales son muy sencillos de implementar y requieren poco esfuerzo para su diseño y manejo. Además nos permite explorar algunos conceptos interesantes.

Asteroids es un juego original de Atari, de 1979.

El juego es simple. Disponemos de una "nave espacial" en un campo de asteroides que se mueven por la pantalla. Podemos disparar contra ellos, de modo que se vayan fracturando hasta que finalmente son destruidos. Si chocamos contra uno de ellos la nave se destruye. De forma aleatoria aparecerán platillos volantes que también nos disparan y pueden destruirnos.

Cuando hayamos destruido todos los asteroides, el juego vuelve a empezar, y se repite hasta que hayamos perdido todas las vidas.

Además de disparar también podemos mover la nave, girando a derecha o izquierda y acelerando. Para perder velocidad, y siguiendo las leyes de la física al menos en este punto, deberemos acelerar en la dirección contraria de avance. Es decir, la aceleración se aplica en la misma dirección que apunta la nave.

Nave Asteroids
Nave Asteroids

Empezaremos con algo simple. Crearemos una nave que podamos mover por la pantalla usando las teclas del cursor.

Lo primero que necesitamos es la definición de los gráficos de nuestra nave. En este caso se trata de gráficos vectoriales, que podemos diseñar usando un programa de dibujo. Un gráfico para la nave y un segundo gráfico para los gases de escape cuando la nave acelere.

Hay que tener presente que en nuestro sistema de coordenadas los valores de x crecen hacia la derecha y los de y hacia abajo. Del mismo modo, los ángulos crecen en el sentido de las agujas del reloj.

Para nuestro diseño de la nave es importante que la orientación sea con la parte delantera apuntando a la derecha.

A partir del diseño tenemos que obtener los segmentos, convirtiendo el gráfico a números:

(10,0), (40,9), (10,18), (14,9), (10,0)
(9,5), (0,9), (9,13)

Tenemos que tener en cuenta que será necesario rotar la nave en diferentes ángulos, y que esas rotaciones, tal como hemos diseñado los métodos de traslación en la clase FPoint, se hacen alrededor de 0,0. Podríamos añadir métodos para rotar alrededor de un punto cualquiera, pero en nuestro caso resulta más económico, en cálculos, centrar el gráfico de nuestra nave.

Si situamos el centro de la nave en las coordenadas (25,9), los nuevos valores para el gráfico vectorial quedan:

(-15,-9), (15,0), (-15,9), (-11,0), (-15,-9)
(-16,-4), (-25,0), (-16,4)

Usaremos coordenadas en coma flotante para los gráficos, de modo que las declaraciones quedan así:

static constexpr int npNave = 5;
static const std::vector<sdl::FPoint> nave = { {15.0, 0.0}, {-15.0, -9.0}, {-11.0, 0.0}, {-15.0, 9.0}, {15.0, 0.0} };
static constexpr int npEstela = 3;
static const std::vector<sdl::FPoint> estela = { {-16.0, -4.0}, {-25.0, 0.0}, {-16.0, 4.0} };

Clase nave

Crearemos una clase para manejar nuestra nave.

Necesitaremos almacenar algunos valores:

  • La posicion actual de la nave en la ventana, que guardaremos en un objeto FPoint.
  • La velocidad actual de la nave, que es un vector, y guardaremos también en un objeto FPoint.
  • Un vector nave2 para almacenar los puntos del gráfico de la nave después de actualizarlos, que se usarán para renderizarla.
  • Un vector estela2 para almacenar los puntos del gráfico de la estela después de actualizarlos, que se usarán para renderizarla.

Los métodos son, además del constructor, algunas funciones de interfaz y la función Actualizar que como indica su nombre, actualiza los parámetros de velocidad y posición, así como los puntos del gráfico que usaremos para mostrar la nave en la ventana.

class Nave {
private:
    std::vector<sdl::FPoint> nave2;
    std::vector<sdl::FPoint> estela2;
    sdl::FPoint posicion;
    sdl::FPoint velocidad;
    void trasladar(float alfa, sdl::FPoint &desplazamiento, sdl::FPoint &escala) {
        for(size_t i = 0; i < nave.size(); i++) {
            nave2[i] = nave[i].Trasladar(alfa, desplazamiento, escala);
        }
        for(size_t i = 0; i < estela.size(); i++) {
            estela2[i] = estela[i].Trasladar(alfa, desplazamiento, escala);
        }
    }

public:
    Nave(const sdl::FPoint &pos, const float alfa) : posicion(pos), velocidad(0.0, 0.0) {
        nave2.resize(nave.size());
        estela2.resize(estela.size());
    }
    void Actualizar(Uint64 tick, float acelerar, float alfa, sdl::FPoint escala) {
        velocidad.X() += acelerar*cos(factor*alfa);
        velocidad.Y() += -acelerar*sin(factor*alfa);
        posicion.X() += (velocidad.X()*(tick)/1000.0);
        posicion.Y() += (velocidad.Y()*(tick)/1000.0);
        if(posicion.X()>635) posicion.X() -=640;
        if(posicion.X()<5) posicion.X() +=640;
        if(posicion.Y()>475) posicion.Y() -=480;
        if(posicion.Y()<5) posicion.Y() +=480;
        // Trasladar nave:
        trasladar(factor*alfa, posicion, escala);
    }
    std::vector<sdl::FPoint> &PuntosNave() { return nave2; }
    std::vector<sdl::FPoint> &PuntosEstela() { return estela2; }
    sdl::FPoint Posicion() { return posicion; }
};

Clase Asteroides

Nuestra última clase, de momento, es la del propio juego Asteroides que derivaremos de Game.

Necesitaremos algunos valores:

  • La escala que usaremos para adaptar el tamaño de los gráficos dependiendo de las dimensiones de la ventana. De momento usaremos toda el área de la ventana, aunque eso implique usar diferentes valores de escala horizontal y vertical. Usaremos un FPoint.
  • Una instancia de Nave, para manejar la nave.
  • Una variable acelerar que indica el valor de la aceleración actual, y que depende de los fps. Usaremos un valor float.
  • Una variable alfa que contiene el ángulo actual de la nave.

En cuanto a los métodos, tenemos un constructor, una función de interfaz para obtener una referencia de escala y las funciones miembros que son virtuales en Game: Init, Events, Update y Render, y que tendremos que definir.

class Asteroides : public sdl::Game {
private:
    sdl::FPoint escala; // Escala del juego (ajustada a una resolución de 640x480
    Nave nave;
    float acelerar;
    float alfa;

public:
    Asteroides() : sdl::Game(), nave({320.0, 240.0}, 0.0),
        acelerar(0.0), alfa(0.0) {
        int w, h;
        window.GetSize(&w, &h);
        escala = sdl::FPoint(w/640.0, h/480.0);
    }
    const sdl::FPoint &Escala() { return escala; }
    void Init();
    void Events();
    void Update();
    void Render();
};

Nuestra función Iniciar de momento no hace nada.

Events procesará los eventos del teclado, girando la nave o acelerando en cada caso:

void Asteroides::Events() {
    static bool izq;
    static bool der;
    static bool arr;

    while(event.Poll()) {
        switch(event.Type()) {
        case SDL_KEYDOWN:
            if(event.Key().keysym.sym == SDLK_LEFT)  { izq = true; }
            if(event.Key().keysym.sym == SDLK_RIGHT) { der = true; }
            if(event.Key().keysym.sym == SDLK_UP)    { arr = true; }
            if(event.Key().keysym.sym == SDLK_ESCAPE) {
                salir = true; }
            break;
        case SDL_KEYUP:
            if(event.Key().keysym.sym == SDLK_LEFT)  { izq = false; }
            if(event.Key().keysym.sym == SDLK_RIGHT) { der = false; }
            if(event.Key().keysym.sym == SDLK_UP)    { arr = false; }
            break;
        }
    }
    acelerar = 0.0;
    if(izq && !der) alfa -= tick/10.0;
    if(!izq && der) alfa += tick/10.0;
    if(alfa < 0) alfa+=360.0;
    if(alfa >= 360.0) alfa-=360.0;
    if(arr) acelerar = tick/15.0;
}

La cantidad en que varía el ángulo alfa depende del tiempo que haya pasado desde la última iteración del bucle de juego, por eso se expresa en función de tick. Está ajustado en 100º por segundo, por eso dividimos entre 10. Se puede variar la velocidad de giro modificando ese valor.

También nos aseguramos de que el valor de alfa esté entre 0 y 360, que aunque en principio parece que no sería necesario, si los ángulos crecen demasiado podríamos llegar a perder precisión.

El valor de acelerar también depende del tiempo entre cuadros. De momento no se ha ajustado a una aceleración o velocidad máxima.

Nuestra función Render limpia el renderizador, dibuja la nave, y si es necesario la estela, y presenta el renderer en la ventana.

void Asteroides::Render() {
    // Renderizado:
    sdl::Color negro("#000000ff");
    sdl::Color blanco("#ffffffff");
    sdl::Color amarillo("#ffff00ff");

    renderer.SetDrawColor(negro);
    renderer.Clear();
    renderer.SetDrawColor(blanco);
    renderer.DrawLinesF(nave.PuntosNave());
    if(acelerar != 0.0) {
        renderer.SetDrawColor(amarillo);
        renderer.DrawLinesF(nave.PuntosEstela());
    }
    renderer.Present();
}

Update actualiza la velocidad y los puntos de la nave en función de su velocidad actual, la aceleración, el ángulo y la escala.

void Asteroides::Update() {
    nave.Actualizar(tick, acelerar, alfa, escala);
}

Función main

Solo queda definir la función main que inicializa la librería, crea una instancia del juego, y lo ejecuta.

int main( int argc, char * argv[] ) {
    sdl::SDL2 sdl(SDL_INIT_EVERYTHING);

    sdl::Window window ("Asteroides", 800, 600, (SDL_WindowFlags)(SDL_WINDOW_SHOWN | SDL_WINDOW_BORDERLESS));
    sdl::Renderer renderer(window, SDL_RENDERER_ACCELERATED);

    Asteroides asteroides(window, renderer);

    asteroides.Run();
    return 0;
}

Ejemplo Asteroides 1

Nombre Fichero Fecha Tamaño Contador Descarga
Asteroides 1 sdl_asteroides1.zip 2024-07-03 7461 bytes 34