10 Hilos y temporizadores

SDL proporciona algunas funciones útiles para hacer varias tareas al mismo tiempo, es decir, programación multihilo.

Temporizadores

La forma más simple de multihilo son los temporizadores.

A menudo necesitaremos realizar algunas tareas independientemente del bucle del juego.

Podríamos utilizar marcas de tiempo, por ejemplo, queremos que se ejecute determinada función diez segundos después de que ocurra un evento. Podemos obtener los ticks en ese momento, asignar a una variable ese valor más 10000 y verificar en cada iteración si los ticks ha llegado a ese valor.

Pero SDL nos proporciona una herramienta mucho mejor y mucho más versatil.

En lugar de verificar en cada iteración si ha transcurrido el tiempo deseado, podemos crear un temporizador, o tantos como necesitemos, descargando a nuestro bucle de tareas que en la mayor parte de las iteraciones no hacen otra cosa que consumir recursos y tiempo.

Un temporizador funciona como un despertador, le programamos un tiempo de espera, y cuando ese tiempo haya transcurrido se invocará la función que le hayamos indicado.

La función indicada es lo que se conoce como callback o retrollamada, una función invocada por la librería cuando se cumplen ciertas condiciones, en el caso de los temporizadores, cuando se cumple el tiempo de espera indicado.

Las retrollamadas de temporizador se ajustan al prototipo SDL_TimerCallback:

Uint32 TimerCallback(Uint32 intervalo, void *param) {
...
}

El primer parámetro es el valor del tiempo de espera, y el segundo es un parámetro que podemos definir a nuestra conveniencia.

El valor de retorno indica el periodo de tiempo para la siguiente temporización. Si queremos que se repita con el mismo intervalo, retornaremos intervalo, si queremos otro valor, podemos indicarlo igualmente, y si no queremos repetir, retornaremos 0.

El tiempo de espera indicado al retornar tiene el cuenta el tiempo requerido para ejecutar la retrollamada. Por ejemplo, si queremos que se vuelva a invocar la retrollamada en 1000ms, y la función requiere 200ms, el tiempo de espera será de 800ms, aunque hayamos indicado en el valor de retorno 1000.

Crear un temporizador es simple. Para ello usaremos la función SDL_AddTimer, indicando como parámetros el tiempo de espera, la función de retrollamada y un puntero a los datos que queremos pasar a la retrollamada.

El valor de retorno es el identificador del temporizador, que necesitaremos si queremos desactivarlo.

Uint32 TimerCallback1(Uint32 intervalo, void *param) {
    char *msg = static_cast<char*>(param);
    printf("%s\n", msg);
    return 0;
}

Uint32 TimerCallback2(Uint32 intervalo, void *param) {
    char *msg = static_cast<char*>(param);
    printf("%s\n", msg);
    return intervalo;
}
...
    char *msg1="Han transcurrido 2 segundos";
    char *msg2="Ha transcurrido otro segundo";
    SDL_TimerID id1 = SDL_AddTimer(2000, TimerCallback1, msg1);
    SDL_TimerID id2 = SDL_AddTimer(1000, TimerCallback2, msg2);
...
    SDL_RemoveTimer(id2);
...

Finalmente, y como ocurre con cualquier otro recurso del sistema, tendremos que eliminar los temporizadores activos antes de terminar el programa. Para ello usaremos la función SDL_RemoveTimer.

Hilos

Un hilo o thread es un proceso que se ejecuta en paralelo con el resto de tareas. Puede ser una ejecución simultánea, si nuestra CPU dispone de varios nucleos, o paralela, cuando el sistema operativo gestiona el uso de la CPU entre varias tareas alternativamente.

En general casi nunca nos afectará mucho si un hilo se ejecuta en otro núcleo o no, sobre todo si el procesador es lo suficientemente rápido.

Para crear un hilo necesitamos definir una función con un prototipo de tipo SDL_ThreadFunction, que es una función que devuelve un entero y admite como parámetro un puntero genérico, que servirá para manejar datos definidos por la aplicación.

Una vez definida la función de nuestro hilo deberemos crear el hilo. Para ello disponemos de dos funciones:

  • SDL_CreateThread, que tendrá como parámetros un puntero a la función del hilo, un nombre para el hilo y un puntero a los datos que recibirá la función del hilo como parámetro. El valor de retorno será un puntero a un objeto de tipo SDL_Thread que nos servirá para manipular el hilo si fuese necesario.
  • SDL_CreateThreadWithStackSize, es igual a la anterior pero el tercer parámetro es ahora el tamaño para la pila del hilo, y el cuarto el puntero a los datos que recibirá la función del hilo como parámetro.

Tenemos que diseñar algún mecanismo que nos asegure que todos los hilos terminan de ejecutarse antes de que nuestra aplicación termine, de otro modo se pueden producir fugas de memoria.

Esto se puede hacer mediante una variable global, cuyo valor usaremos como condición para terminar el hilo, o puede ser parte de los parámetros que le pasamos al hilo, de modo que podamos terminar cada hilo desde otros procesos. Crear hilos con bucles infinitos incondicionales se considera una mala práctica y no debe hacerse.

A menudo nuestro programa u otro hilo necesita el resultado de la ejecución de otros hilos o simplemente que otros hilos terminen de procesarse antes de seguir su propia ejecución. También puede interesarnos que algún hilo concreto termine de procesarse normalmente, en lugar de forzar su regreso, antes de terminar la aplicación. La función SDL_WaitThread detiene la ejecución hasta que el hilo indicado en el primer parámetro haya concluido. El segundo parámetro recoge el valor de retorno del hilo que ha concluido. De hecho, esta función es la única forma de recuperar ese valor de retorno.

Solo un proceso puede invocar a esta función sobre el cada hilo.

Un hilo puede estar asociado a otro que espera que termine o puede estar libre. Para que esté libre usaremos la función SDL_DetachThread indicando como parámetro el puntero que obtuvimos al crearlo. Liberar un hilo con esta función no lo interrumpe, sencillamente su finalización no se notificará a ningún otro hilo, ni ningún otro proceso podrá usar la función SDL_WaitThread para esperar a que termine. Para cada hilo se puede usar una u otra función, pero no ambas a la vez.

Es posible, y bastante habitual, usar la misma función para crear varios hilos, pasando a cada hilo un parámetro para los datos de usuario. Por ejemplo, podemos crear una función de cuenta atrás, crear varios hilos con esa función, y diferentes valores iniciales de cuenta atrás:

struct Cuenta {
    int v;
    int vi;
};

int Hilo(void *param) {
    Cuenta *cuenta = static_cast<Cuenta*>(param);
    cuenta->vi=cuenta->v;
    while(cuenta->v > 0) {
        cuenta->v--;
        printf("Cuenta(%d): %d\n", cuenta->vi, cuenta->v);
        SDL_Delay(100);
    }
    return 0;
}
...
    Cuenta cu[] = { {10,0}, {34,0}, {5,0} };
    char *nombre[] = { "hilo1", "hilo2", "hilo3" };
    SDL_Thread *hHilo[3];
    for(int i=0; i<3; i++)
        hHilo[i] = SDL_CreateThread(Hilo, nombre[i], &cu[i]);

    SDL_WaitThread(hHilo[0], NULL);
    SDL_WaitThread(hHilo[1], NULL);
    SDL_WaitThread(hHilo[2], NULL);
...

Los hilos pueden tener diferentes prioridades, lo que se traduce en la atención que el sistema operativo les dedica. Por defecto se crean con la prioridad SDL_THREAD_PRIORITY_NORMAL, pero se puede modificar, si el sistema operativo lo permite, mediante la función SDL_SetThreadPriority.

Otras funciones relacionadas con hilos son SDL_GetThreadID y SDL_GetThreadName, que permiten recuperar el identificador y el nombre de un hilo, respectivamente.

Mutex

Mutex es una abreviatura de mutual exclusion o exclusión mutua. Es un mecanismo que se usa para evitar que varios procesos entren simultáneamente en una sección crítica.

Una sección crítica es un código que puede modificar un recurso compartido. Podemos ver un mutex como el palo de hablar, cuando en ciertos debates o discusiones solo la persona que tiene el palo de hablar está en el uso de la palabra. En programación, cuando un proceso necesita acceder a una sección crítica, pide o crea un mutex y lo bloquea, y hasta que lo libere ningún otro proceso podrá acceder a esa sección.

El uso de mutex es habitual el programación multihilo o concurrente, cuando determinados recursos pueden ser modificados por varios hilos o usuarios diferentes.

Se puede crear un mutex mediante la función SDL_CreateMutex. Crear un mutex no lo bloquea, cuando es creado un mutex permanecerá desbloqueado.

Si estamos creando una aplicación que puede tener varias instancias en ejecución simultáneamente, que use temporizadores o hilos, que acceda a recursos en red (bases de datos, ficheros remotos, etc), o dispositivos que no admitan concurrencia, deberemos localizar todas esas secciones críticas y crear un mutex para cada una de ellas.

Cuando un proceso necesite acceder a una sección crítica, intentará bloquear el mutex correspondiente. Si está libre el sistema lo bloqueará para él y podrán continuar ejecutándose. Una vez termine de acceder a esa sección desbloqueará el mutex para que otros procesos puedan acceder a ella.

Si el mutex no está libre la ejecución quedará detenida en ese punto hasta que lo esté.

Hay dos forma de bloquear un mutex:

  • La función SDL_LockMutex bloqueará el mutex si no está ya bloqueado o bien detendrá la ejecución hasta que se desbloquee.
  • SDL_TryLockMutex si el mutex no está bloqueado funciona igual que la función SDL_LockMutex, pero si lo está no detendrá la ejecución, sino que retornará con el valor SDL_MUTEX_TIMEOUT, permitiendo que la ejecución continúe. Esto nos da la oportunidad de que el proceso pueda seguir ejecutando código a la espera de que la sección crítica esté disponible.

El sistema mantiene una cuenta de los bloqueos de un mutex. El mismo proceso puede bloquear un mutex varias veces, y para desbloquearlo debe hacerlo tantas veces como lo ha bloqueado.

Para desbloquear un mutex se usa la función SDL_UnlockMutex.

Finalmente, cuando un mutex ya no sea necesario se debe destruir mediante una llamada a la función SDL_DestroyMutex.

Condiciones

Las condiciones se usan, junto a los mutex, para sincronizar tareas. Uno o varios que necesitan acceder a una sección crítica pueden necesitar que antes se hayan completado ciertas tareas. Podemos crear otro hilo que se encargue de realizar las tareas previas, y una vez haya terminado, indique al resto que ya pueden proceder.

Este es el caso más simple, pero un hilo puede requerir varias condiciones, o la ejecución de ciertos hilos puede depender de otros que a su vez dependan de otros, etc. En esto se basa la programación concurrente, como la resolución de problemas mediante procesos paralelos.

No sé hasta qué punto esto es frecuente en programación de juegos, pero está claro que los que desarrollaron SDL 2 consideraron interesante incluirlo en la librería.

Las condiciones se manejan mediante variables de condición SDL_cond que para nosotros son estructuras opacas, internas a la librería.

Para crear una variable de condición usaremos la función SDL_CreateCond, y cuando ya no se necesite, la liberaremos con una llamada a la función SDL_DestroyCond, de forma análoga a lo que hacemos con los mutex.

Una condición puede estar señalada o no estarlo. Inicialmente, cuando se crea, estará no señalada. Para señalarla usaremos la función SDL_CondSignal, esto hará que de forma automática se reanude uno de los hilos que estuviese esperando que esta condición fuese señalada.

La función SDL_CondBroadcast también señala la condición, pero al contrario que la anterior, permitirá que reanuden todos los hilos que estuviesen esperando que esta condición fuese señalada.

La función SDL_CondWait espera a que una condición sea señalada. Requiere dos parámetros. El primero es la condición de espera, y el segundo un mutex previamente bloqueado. La función desbloqueará el mutex y permanecerá esperando hasta que se señale la condición. Cuando esto suceda volverá a bloquear el mutex y continuará la ejecución.

La función SDL_CondWaitTimeout funciona de forma similar, pero permite especificar un tiempo de espera máximo. Transcurrido ese tiempo, aunque no se haya señalado la condición, se vuelve a bloquear el mutex y se reanuda la ejecución.

En el siguiente ejemplo, el hilo1 esperará a que termine el hilo2 antes de terminar su ejecición.

SDL_cond *cond;
SDL_mutex *mutex;

int Hilo1(void *param) {
    SDL_LockMutex(mutex);

    printf("Hilo1 esperando condicion\n");
    SDL_CondWait(cond, mutex);

    printf("Ejecutar Hilo1\n");
    SDL_Delay(2000);
    SDL_UnlockMutex(mutex);
}

int Hilo2(void *param) {
    SDL_LockMutex(mutex);
    printf("Ejecutar hilo2\n");
    SDL_Delay(2000);
    SDL_UnlockMutex(mutex);
    SDL_CondSignal(cond);
}
...
    mutex = SDL_CreateMutex();
    cond = SDL_CreateCond();

    SDL_Thread *hHilo1 = SDL_CreateThread(Hilo1, "Hilo1", NULL);
    SDL_Thread *hHilo2 = SDL_CreateThread(Hilo2, "Hilo2", NULL);

    SDL_Delay(4000);

    SDL_WaitThread(hHilo1, NULL);
    SDL_WaitThread(hHilo2, NULL);

    SDL_DestroyCond(cond);
    SDL_DestroyMutex(mutex);
...

Semáforos

Un semáforo es una variable entera, compartida entre varios procesos. Al contrario que los mutex, los semáforos pueden gestionar secciones críticas que pueden ser compartidas por varios procesos.

Se usan principalmente para la sincronización de procesos y el control de acceso a recursos comunes en un entorno concurrente.

Si al variable entera solo puede tomar los valores 0 y 1, hablaremos de semáforos binarios, que aunque pueden usarse como un mutex, en realidad son mecanismos diferentes. Al contrario que los mutex, que son mecanismos de bloqueo, los semáforos son mecanismos de señalización.

Supongamos que tenemos un juego multijugador que admite hasta cuatro jugadores. Podemos crear un semáforo con valor 4, y cada jugador que quiera participar decrementará el valor del semáforo, si no es cero, y obtendrá el permiso para ejecutar el juego. Cuando termine volverá a incrementar el valor del semáforo. Si el valor es cero quedará a la espera, y cuando el sistema detecte que el valor del semáforo es distinto de cero "despertará" al jugador que podrá decrementar el semáforo y obtener el permiso.

Los semáforos también pueden usarse para sincronizar procesos, es decir, para hacer que determinadas tareas se ejecuten en el orden deseado.

Tenemos un hilo que realiza tres tareas: T1, T2 y T3, y otro hilo que realiza otras tres:T4, T5 y T6. Sin embargo, necesitamos asegurarnos de que la tarea T2 se termine de ejecutar siempre antes de que comience T5. Podemos usar un semáforo con valor inicial 0. En el segundo hilo, antes de empezar la tarea T5 esperamos a que el semáforo esté señalado, y en el primer hilo señalamos el semáforo después de haber concluido la tarea T2.

Para crear un semáforo se usa la función SDL_CreateSemaphore, indicando como parámetro el valor inicial.

Cuando ya no sea necesario tendremos que destruir el semáforo llamando a la función SDL_DestroySemaphore.

Cuando un proceso quiera hacer uso de un recurso regulado por un semáforo invocará a la función SDL_SemWait, que esperará hasta que el valor del semáforo sea mayor que cero y lo decrementará.

La función SDL_SemWaitTimeout es similar, pero añade un segundo parámetro que especifica un tiempo máximo de espera. Si transcurrido ese tiempo el semáforo no se ha podido decrementar el semáforo, el proceso continuará, pero la función retronará con SDL_MUTEX_TIMEDOUT.

La función SDL_SemTryWait equivale a SDL_SemWaitTimeout con un tiempo de espera nulo.

Una vez el proceso de la sección crítica ha concluido, se debe llamar a la función SDL_SemPost para volver a incrementar el valor del semáforo.

En cualquier momento podemos recuperar el valor actual del semáforo mediante la función SDL_SemValue

Ejemplo de sincronización de procesos:

#include <sdl2/SDL.h>
#include <cstdio>

SDL_sem *sincro;

int Hilo1(void *data) {
    // Hacer algo
    std::printf("Tarea T1\n");
    SDL_Delay(2000);

    // Tarea prioritaria T1, antes de T2
    std::printf("Tarea T2 antes de T5\n");
    SDL_SemPost(sincro);
    SDL_Delay(500);

    // Otras tareas
    std::printf("Tarea T3\n");
    SDL_Delay(2000);
    return 0;
}

int Hilo2(void *data) {
    // Hacer algo
    std::printf("Tarea T4\n");
    SDL_Delay(1000);

    SDL_SemWait(sincro);
    //Tarea T2 debe ejecutarse después de T1
    std::printf("Tarea T5 despues de T2\n");
    SDL_Delay(500);

    // Otras tareas
    std::printf("Tarea T6\n");
    SDL_Delay(1000);
    return 0;
}

int main( int argc, char * argv[] )
{
    SDL_Thread *hilo1;
    SDL_Thread *hilo2;

    SDL_Init(SDL_INIT_EVERYTHING);
    sincro = SDL_CreateSemaphore(0);

    hilo1 = SDL_CreateThread(Hilo1, "Hilo1", NULL);
    hilo2 = SDL_CreateThread(Hilo2, "Hilo2", NULL);

    SDL_WaitThread(hilo1, NULL);
    SDL_WaitThread(hilo2, NULL);
    SDL_DestroySemaphore(sincro);
    SDL_Quit();
    return 0;
}

Para una información más detallada del funcionamiento de los semáforos ver baeldung.com y lazyfoo.net.

Asteroids 4

Vamos a añadir algunas clases a nuestro wrapper.

Clase temporizador

Nuestra clase Temporizador necesitará una función de retrollamada definida por el usuario.

class Timer {
protected:
    SDL_TimerCallback CallBack;
    SDL_TimerID id;
    Uint32 interval;
    void *param;

public:
    Timer() : CallBack(0), id(0), interval(0), param(NULL) {}
    Timer(Uint32 i, void *p) : interval(i), param(p) {}
    ~Timer() {
        if(id!=0) SDL_RemoveTimer(id);
    }
    void SetCallback(SDL_TimerCallback callback) {
        CallBack = callback;
    }
    Uint32 SetInterval(Uint32 i) {
        Uint32 retval = interval;
        interval = i;
        return retval;
    }
    void* SetParam(void *p) {
        void* retval = param;
        param = p;
        return retval;
    }
    SDL_TimerID Init() {
        id = SDL_AddTimer(interval, CallBack, param);
        return id;
    }
    SDL_TimerID Id() const { return id; }
};

Por ejemplo, crearemos un temporizador creando una instancia de esta clase:

    sdl::Timer tempo;
    tempo.SetCallback(CallBack);
    tempo.SetInterval(200);
    tempo.Init();

Y definiremos una función de retrollamada para él:

Uint32 CallBack(Uint32 interval, void *param) {
    // Hacer algo
    return interval;
}

Clase Thread

También crearemos una clase para manejar hilos:

class Thread {
protected:
    SDL_ThreadFunction tfunction;
    void* data;
    SDL_Thread *handle;
    int retval;
    bool detach;
public:
    Thread() : tfunction(NULL), data(NULL), handle(NULL), detach(false) {}
    Thread(SDL_ThreadFunction funct, const char* name, void* dat, const size_t stack=0) : tfunction(funct), data(dat), detach(false) {
        handle = SDL_CreateThreadWithStackSize(tfunction, name, stack, data);
    }
    ~Thread() {
        if(!detach) SDL_WaitThread(handle, &retval);
    }
    void SetFunction(SDL_ThreadFunction funct) { tfunction=funct; }
    void SetData(void *dat) { data = dat; }
    void Init(const char* name, const size_t stack=0) {
        if(tfunction != NULL) handle = SDL_CreateThreadWithStackSize(tfunction, name, stack, data);
    }
    int Wait() {
        if(!detach) {
            SDL_WaitThread(handle, &retval);
        } else {
            retval=-1;
        }
        return retval;
    }
    void Detach() {
        SDL_DetachThread(handle);
        detach = true;
    }
    const char* GetName() const {
        if(!detach) return SDL_GetThreadName(handle);
        return NULL;
    }
    SDL_threadID GetId() {
        if(!detach) return SDL_GetThreadID(handle);
        return 0;
    }
};

Para crear un hilo necesitamos definir una función:

int MiHilo(void* data) {
    int i=0;
    do {
        // Hacer algo
    } while(i++<25);
    return 0;
}

Y crear un objeto de la clase Hilo:

    sdl::Thread hilo1(MiHilo, "Mi hilo", NULL);

Vamos a aprovechar los temporizadores y los hilos para añadir otro elemento del juego. En el juego original, de forma aleatoria, aparece un platillo volante siguiendo una trayectoria impredecible, que también puede dispararnos, aunque con no muy buena puntería, y que no es inmune a los asteroides.

Hay dos variantes del platillo volante: una pequeña y otra más grande. Como nuestros gráficos son vectoriales, la diferencia entre ellos es solo la escala, así que nos sirve el mismo gráfico.

Programaremos un temporizador para que, una vez transcurrido el tiempo indicado, active un platillo volante.

El hilo se encargará de decidir sus cambios de dirección y de efectuar los disparos.

Cuando el platillo sea destruido, se lanzará otro temporizador con un tiempo aleatorio.

Platillo
Platillo
Hitbox de platillo
Hitbox de platillo

Pero primero necesitamos diseñar los gráficos para nuestro platillo volante. En este caso no podemos trazar todos los segmentos que componen el gráfico de un único trazo, de modo que tendremos que modificar un poco el modo en que codificamos los gráficos.

Una forma sencilla de almacenar los gráficos vectoriales que permita usarlos aunque no puedan dibujarse de un solo trazo es almacenar en vectores separados los vértices y las aristas.

Otra forma es utilizar varias listas de puntos unidos por aristas, similares a las que hemos usado hasta ahora.

O sencillamente podemos usar varias estructuras, como hicimos con la nave y la estela.

static const std::vector<sdl::FPoint> platilloA = {
    {-10.0,-4.0}, {-8.0, -8.0}, {7.0,-8.0}, {9.0,-4.0}, {15.0,2.0},
    {9.0,8.0}, {-10.0,8.0}, {-16.0,2.0}, {-10.0,-4.0}, {9.0, -4.0} };
static const std::vector<sdl::FPoint> platilloB = { {-16.0,2.0}, {15.0, 2.0} };
static const std::vector<sdl::Shape> formaplatillo = { 
    sdl::FRect(-9.0, -8.0, 18.0, 17.0), 
    sdl::FCircle(-9.0, 2.0, 6.0), 
    sdl::FCircle(8.0, 2.0, 6.0) };

Como hemos usado un rectángulo para formar el hitbox, necesitaremos completar nuestra clase base Hitbox para que sea capaz de detectar colisiones entre círculos y rectángulos. Aprovecharemos para detectar también colisiones entre rectángulos.

class Hitbox {
protected:
    bool CollisionCircleCircle(FCircle &c1, FCircle &c2);
    bool CollisionRectCircle(FCircle &c, FRect &r);
    bool CollisionRectRect(FRect &r1, FRect &r2);
...
};
...
bool Hitbox::CollisionCircleCircle(FCircle  &c1, FCircle &c2) {
    if(((c1.R()+c2.R())*(c1.R()+c2.R())) >=
       ((c1.Centro().X()-c2.Centro().X())*(c1.Centro().X()-c2.Centro().X())+
        (c1.Centro().Y()-c2.Centro().Y())*(c1.Centro().Y()-c2.Centro().Y())))
        return true;
    return false;
}

bool Hitbox::CollisionRectRect(FRect &r1, FRect &r2) {
    return SDL_HasIntersectionF(r1.get(), r2.get());
}

bool Hitbox::CollisionRectCircle(FRect &re, FCircle &ci) {
    FPoint pt = ci.Centro();
    if(pt.X() < re.X()) pt.X() = re.X();
    if(pt.Y() < re.Y()) pt.Y() = re.Y();
    if(pt.X() > re.X()+re.W()) pt.X() = re.X()+re.W();
    if(pt.Y() > re.Y()+re.H()) pt.Y() = re.Y()+re.H();
    return ((pt.X()-ci.X())*(pt.X()-ci.X())+(pt.Y()-ci.Y())*(pt.Y()-ci.Y())) <= ci.R()*ci.R();
}

bool Hitbox::Collision(Hitbox &hitbox2) {
    for(size_t i=0; i<hitboxT.size(); i++) {
        for(size_t j=0; j/lt;hitbox2.hitboxT.size(); j++) {
            if(hitbox[i].type==circle && hitbox2.hitbox[j].type==circle) {
                return CollisionCircleCircle(hitboxT[i].ci, hitbox2.hitboxT[j].ci);
            } else
            if(hitbox[i].type==rectangle && hitbox2.hitbox[j].type==rectangle) {
                return CollisionRectRect(hitboxT[i].re, hitbox2.hitboxT[j].re);
            } else
            if(hitbox[i].type==rectangle) {
                return CollisionRectCircle(hitboxT[i].re, hitbox2.hitboxT[j].ci);
            } else
                return ColisionRectCirculo(hitbox2.hitboxT[j].re, hitboxT[i].ci);
            }
        }
    }
    return false;
}

Para que aparezca el primer platillo esperaremos lo necesario para que el jugador haya podido despejar el campo de asteroides. Si el platillo sale cuando haya muchos puede resultar de ayuda más que aumentar la dificultad, ya que es muy probable que choque con un asteroide, destruyéndolo y destruyéndose a si mismo. Empezaremos con 40 segundos, lo que no quiere decir que tenga que aparecer exactamente en ese momento.

Para que la aparición de platillos sea lo bastante aleatoria usaremos un primer tiempo de espera menor, por ejemplo 15 segundos, y haremos que la aparición sea aleatoria en un porcentaje, por ejemplo 20%. Si no se genera un platillo, reiniciaremos el temporizador con un tiempo menor, digamos, 10 segundos, y aumentaremos el porcentaje de aparición, por ejemplo al 40%, y así sucesivamente. Iremos ajustando estos valores en función de nuestra experiencia de juego.

nTiempoProbabilidad
11520%
21040%
3760%
4470%

De momento usaremos valores fijos, de un 50% de probabilidad cada quince segundos.

Nota: debido a que diferentes hilos y temporizadores pueden ejecutarse en diferentes núcleos de la CPU, y al modo en que está programada la función rand(), es posible que las secuencias aleatorias no sean tan impredecibles como debieran. Por eso hemos tenido que establecer una semilla la primera vez que se ejecuta un hilo o un temporizador.

Nuestro platillo disparará en direcciones aleatorias y solo puede tener un disparo activo en cada momento. Un platillo puede chocar contra un asteroide, dividiéndolo o destruyéndolo, contra uno de nuestros disparos o contra la nave. En cualquier caso, será destruido.

Añadiremos un objeto Platillo, otro Disparo, otro Timer y dos nuevos métodos privados para detectar colisiones a la clase Asteroides:

class Asteroides : public sdl::Game, sdl::Mixer {
private:
...
    Platillo platillo;
    Disparo disparoP;
    sdl::Timer tPlatillo;
,,,
    bool ColisionPlatillo(int iAsteroide) { return asteroides[iAsteroide].Hitbox().Collision(platillo.Hitbox()); }
    bool ColisionNavePlatillo() { return nave.Hitbox().Collision(platillo.Hitbox()); }
...

En el constructor de la clase Asteroides iniciamos el temporizador:

...
        tPlatillo.SetCallback(cbPlatillo);
        tPlatillo.SetInterval(intervalo);
        tPlatillo.SetParam(&platillo);
        tPlatillo.Init();
...

Como parámetro para el temporizador le pasamos un puntero al objeto Platillo.

Y la función para el temporizador tiene éste código, al menos inicialmente:

Uint32 cbPlatillo(Uint32 intervalo, void* param) {
    Platillo *platillo = static_cast<Platillo*>(param);
    if(platillo->EsPrimero()) { // Establecer una semilla rand la primera vez
        std::srand(std::time(NULL));
        platillo->Primero(false);
    }
    if(sdl::random(100)<probabilidad) {
        platillo->Activar();
        return 0;
    }
    return intervalo;
}

En el método Render añadiremos el código necesario para mostrar el platillo y sus disparos:

void Asteroides::Render() {
...
    sdl::Color azul1("#4040ffff");
    sdl::Color azul2("#6060ffff");
...
    if(disparoP.Activo())
        renderer.DrawLinesF(disparoP.Puntos());
    if(platillo.Activo()) {
        renderer.SetDrawColor(azul1);
        renderer.DrawLinesF(platillo.PuntosPlatilloA());
        renderer.SetDrawColor(azul2);
        renderer.DrawLinesF(platillo.PuntosPlatilloB());
    }
...

Para calcular colisiones y trayectorias de asteroides divididos por disparos o colisiones del platillo tendremos que modificar la rutina de DividirAsteroide. En lugar de usar el índice del disparo usaremos posición y vector de velocidad del objeto.

Nuestro método Update se está complicando mucho debido al número de objetos y las interacciones entre ellos, así que vamos a refactorizarlo un poco.

Tenemos que actualizar las posiciones de:

  • La nave.
  • Los asteroides.
  • El platillo, si está activo.
  • Los disparos de la nave.
  • El disparo del platillo.

Tenemos que detectar las siguientes colisiones y establecer sus consecuencias:

  • Asteroides con:
    • La nave: Destruir nave, fragmentar asteroide.
    • El platillo: Destruir platillo, fragmentar asteroide. Lanzar temporizador de platillo.
    • Asteroide: Rebote elástico.
  • Platillo con:
    • Destruir nave, destruir platillo. Lanzar temporizador. (En el futuro esto cambiará en función de las vidas que le queden al jugador).
  • Disparos de nave con:
    • El platillo: Destrucción de platillo y disparo. Lanzar temporizador.
    • Asteroide: Fragmentar asteroide, destruir disparo.
  • Disparo de platillo con:
    • La nave: Destrucción de nave, destruir disparo.
    • Asteroides: Fragmentación de asteroide, destruir disparo.
    • Si el disparo no está activo, pero el platillo sí, crear un nuevo disparo.

Crearemos algunos métodos auxiliares para las siguientes tareas:

  • DestruirNave: de momento no hará nada, salvo reproducir un sonido de explosión. En el futuro tendrá que descontar una vida y volver a situar la nave en el centro. Será inmune hasta que no se detecten colisiones con otros objetos.
  • DestruirPlatillo: desactiva el platillo y reinicia el temporizador para crear uno nuevo.
  • DividirAsteroide: depende del ángulo del choque y de los vectores de velocidad. Divide el asteroide si es grande o mediano y calcula las nuevas velocidades y posiciones. Si el asteroide es pequeño, lo destruye. Modificaremos el método para que use un asteroide y la masa, posición y velocidad del objeto que ha colisionado con él.
  • RebotarAsteroides: calcular nuevas velocidades de asteroides después de colisión.
  • SonidoImpactoAsteroide: reproduce un sonido de explosión en función de la distancia y orientación.

Tan solo nos queda hacer que el platillo cambie de dirección cada cierto tiempo y de forma aleatoria.

Podríamos usar un segundo temporizador, pero para ilustrar su uso, usaremos un hilo.

Al hilo le pasaremos un puntero al objeto Platillo, y si está activo, cada cierto tiempo, modificaremos su vector de velocidad de forma aleatoria. Cada segundo la nave cambiará su trayectoria en un ángulo en el rango de +/- 45º.

int hiloPlatillo(void *param) {
    Uint64 t=SDL_GetTicks64();
    Platillo *platillo = static_cast<Platillo*>(param);
    float alfa;
    sdl::FPoint v2;

    while(!platillo->Salir()) {
        // Cada segundo:
        if(SDL_GetTicks64()-t > 1000) {
// x' =  x" * cos(α) + y" * sin(α)
// y' = -x" * sin(α) + y" * cos(α)
            alfa = sdl::random(pi/2.0)-(pi/4.0);
            v2.X() =  platillo->Velocidad().X()*SDL_cosf(alfa)+platillo->Velocidad().Y()*SDL_sinf(alfa);
            v2.Y() = -platillo->Velocidad().X()*SDL_sinf(alfa)+platillo->Velocidad().Y()*SDL_cosf(alfa);
            platillo->Velocidad() = v2;
            t = SDL_GetTicks64();
        }
    }
    return 0;
}

Ejemplo Asteroides 4

Nombre Fichero Fecha Tamaño Contador Descarga
Asteroides 4 sdl_asteroides4.zip 2024-07-03 152929 bytes 46