9 Sonido

En el capítulo de introducción vimos como descargar e instalar la librería SDL_Mixer, que usaremos para reproducir música y efectos de sonido en nuestros programas.

Para poder usar esta librería en nuestros programas deberemos incluir el fichero de cabecera "SDL_mixer.h", y enlazar con la librería "SDL2_mixer.dll".

Iniciar y liberar la SDL_mixer

Como es habitual, primero deberemos inicializar la librería, y liberarla cuando ya no la necesitemos.

Para iniciarla usaremos la función Mix_Init con los parámetros adecuados según nuestras especificaciones.

El parámetro para esta función indica a la librería que tipo de ficheros de audio podremos manipular: flac, mp3, ogg, midi, y otros menos habituales, como mod u opus. Los ficheros wave siempre se pueden manejar.

Se pueden seleccionar varios combinando las banderas MIX_InitFlags mediante OR.

Cuando ya no sea necesario manipular audio, y normalmente antes de cerrar el programa, deberemos liberar los recursos utilizados mediante una llamada a la función Mix_Quit.

Mix_Init(MIX_INIT_MP3); // Iniciar SDL_mixer para manejar formato mp3
...
Mix_Quit();

Abrir un dispositivo

El siguiente paso es abrir un dispositivo de audio. Para ello disponemos de dos opciones.

La más sencilla es la función Mix_OpenAudio que abrirá el dispositivo predeterminado, que en la mayoría de los casos es la mejor opción. Esta función requiere cuatro parámetros.

El primer parámetro es la frecuencia a la que se reproducirá el audio, en muestras por segundo. Aunque se puede usar cualquier valor, cuanto mayor sea, mejor será la calidad. Según la documentación, un valor óptimo es 48000, aunque la mayor parte del audio digital está muestreado a 44100 Hz, no tiene mucho sentido usar valores mayores.

El segundo parámetro indica el formato de las muestras, que se puede seleccionar mediante las constantes AUDIO_*, aunque normalmente usaremos el valor MIX_DEFAULT_FORMAT.

El tercero indica el número de canales. Se admiten hasta 8, para el formato 7.1, aunque lo más habitual es usar el valor 1 para salida monofónica o 2 para estéreo.

El cuarto parámetro indica el tamaño del buffer de audio, que suele ser un múltiplo de 1024. Conviene no escoger un valor demasiado pequeño. Los valores 2048 o 4096 son buenas opciones.

La segunda opción es usar la función Mix_OpenAudioDevice, que a diferencia de la anterior, nos permite seleccionar el dispositivo de audio e indicar qué ajustes de audio son flexibles.

A los cuatro parámetros anteriores se añaden otros dos:

El quinto indica el nombre del dispositivo a abrir. Se puede utilizar la función SDL_GetNumAudioDevices para obtener un recuento de los dispositivos disponibles y después SDL_GetAudioDeviceName en un bucle para obtener una lista de dispositivos.

También se puede usar el valor NULL para que seleccionar el dispositivo predeterminado.

El sexto parámetro es un conjunto de banderas SDL_AUDIO_ALLOW_* que indican que ajustes de audio son flexibles. De este modo se puede modificar alguno de los parámetros que hemos indicado para adaptarlos al hardware.

Mix_OpenAudio(44100, MIX_DEFAULT_FORMAT, 2, 2048);
...
Mix_CloseAudio();

Reproducir ficheros de música

SDL_mixer distingue entre dos estructuras de datos para manejar datos de audio.

El chunk está destinado a manejar un sonido previamente decodificado almacenado completamente en memoria. Esto lo hace más rápido de manipular por el dispositivo de audio.

La estructura music está destinada a manejar archivos de sonido que se decodificarán durante la ejecución a medida que sea necesario.

Originalmente cada estructura solo podía manejar determinados formatos de ficheros de audio, aunque esto ya no es así.

La mayor diferencia es que para music solo se dispone de un canal, y para chunk de varios. Esto significa que únicamente se puede reproducir una estructura music simultáneamente, pero chunks se pueden mezclar varios a la vez.

Los sonidos music se manejan mediante una estructura Mix_Music. Se trata de una estructura interna, de la que no tenemos acceso a sus miembros.

Para cargar un archivo de sonido de este tipo usaremos la función Mix_LoadMUS, indicando como parámetro el nombre del fichero que contiene el audio, obteniendo un puntero a una estructura Mix_Music.

Para reproducir el audio usaremos la función Mix_PlayMusic, indicando en el primer parámetro el puntero a la estructura Mix_FreeMusic del sonido, y como segundo parámetro el número de repeticiones. Un valor 0 reproducirá el sonido solo una vez.

Cuando ya no necesitemos esta estructura hay que liberar sus recursos mediante una llamada a Mix_FreeMusic, indicando como parámetro el puntero a la estructura Mix_Music a liberar.

Mix_Music *musica;
...
musica = Mix_LoadMUS("Imagine.mp3");
Mix_PlayMusic(musica, 0);
Mix_FreeMusic(musica);

Si necesitamos saber cuánto dura una estructura música disponemos de la función Mix_MusicDuration, que nos devuelve esa duración en segundos.

Para saber si en un momento determinado se está reproduciendo una estructura música podemos usar la función Mix_PlayingMusic.

Y para volver a reproducir una estructura música desde el principio podemos usar la función Mix_RewindMusic. Solo funcionará si la música aún se está reproduciendo, no lo hará si ya hubiese concluido.

Reproducir audio

Para manejar chunks usaremos una estructura mix_chunk, y para cargar un sonido en este formato llamaremos a la función Mix_LoadWAV, indicando como parámetro la ruta del fichero que contiene el sonido.

Para reproducir un chunk se usa la función Mix_PlayChannel. Esta función requiere tres parámetros.

El primero es el canal en el que se reproducirá el sonido. Lo habitual es usar el valor -1 para que se use el primer canal libre.

El segundo parámetro es un puntero a la estructura mix_chunk del sonido a reproducir.

El tercer parámetro es el número de repeticiones más 1. Si se usa el valor -1 se reproducirá indefinidamente, aunque en la práctica serán unas 65535 veces. Para reproducir el sonido solo una vez usar el valor 0.

Para liberar el chunk cuando ya no lo necesitemos usar la función Mix_FreeChunk, indicando como parámetro el puntero al chunk a liberar.

Mix_Chunk *chunk;
...
chunk = Mix_LoadWAV("explodemini.wav");
Mix_PlayChannel(-1, chunk, 2);
Mix_FreeChunk(chunk);

No se debe confundir el canal por el que se reproduce un sonido chunk con los canales especificados al abrir el dispositivo mediante la función Mix_OpenAudio. Es un poco desafortunada la elección del nombre de este parámetro cuando se habla de reproducir chunks, haberlos llamado pistas o tracks hubiese sido más preciso. Tenemos que pensar en estas pistas como canales de entrada al mezclador. Por ejemplo, en un entorno Windows los sonidos procedentes del navegador, de un reproductor de música, de un juego, los del sistema, etc se mezclan en una única salida estéreo. Podemos tener un número determinado de fuentes de entrada de sonido, y solo una de salida, con los canales físicos del dispositivo. Así es como funciona un mezclador.

Como vimos antes, en SDL_mixer solo se dispone de una pista para reproducir música y de varias (8 por defecto) para reproducir chunks. Todas estas pistas de entrada se mezclan en tantos canales como se especificaron al abrir el dispositivo de audio.

Podemos cambiar de forma dinámica el número de pistas o canales de entrada mediante la función Mix_AllocateChannels.

Es decisión nuestra elegir el canal por el que se dará salida a un chunk. Si sabemos que siempre habrá un canal libre, porque hemos habilitado los suficientes como para que nunca se reproduzcan tantos sonidos al mismo tiempo, pordemos usar el valor -1 para el número de canal en Mix_PlayChannel.

Sin embargo, hemos estimado mal el número de canales necesarios, y usamos esta técnica, es posible que nuestro chunk no se reproduzca, ya que si indicamos -1 para el canal o pista, y no hay ninguno libre, el chunk no se reproducirá.

Así que en ciertas circunstancias nos puede interesar especificar un número de canal concreto. Por ejemplo, nuestro personaje está disparando, y queremos que si un segundo disparo se produce antes de que el sonido del primero haya terminado, se detenga el primero y se escuche el segundo. Es decir, reservamos un canal de entrada para el sonido de los disparos del personaje. Esto nos asegura que siempre se escuchará el más reciente, y cualquier otro que se estuviera reproduciendo anteriormente en el mismo canal se interrumpa.

Este comportamiento es habitual en conversaciones con npcs. Un npc nos está diciendo una frase, pero nosotros respondemos antes de que termine de hablar (ya hemos jugado esa parte del juego varias veces y sabemos lo que va a decir). Automáticamente, se interrumpe lo que estuviera diciendo y empieza a reproducirse la siguiente frase. Si en un caso como este usásemos el valor -1 para el canal y hubiese canales libres, el npc diría las dos frases a la vez, y si no hubiese canales libres no escucharíamos la segunda frase.

Algunas funciones de utilidad para chunks son:

Mix_Playing nos indica si un canal está reproduciendo un sonido actualmente.

La función Mix_ReserveChannels reserva el número de canales indicados para la aplicación. Se reservarán los primeros canales, de 0 a num-1, de modo que no estén disponibles cuando se usa el valor -1 con Mix_PlayChannel. En ese caso solo se usará un canal libre entre num y el número de canales asignados mediante Mix_AllocateChannels, si lo hay. Para usar uno de los canales reservados hay que especificar el canal deseado explícitamente.

Finalmente, la función Mix_ExpireChannel permite detener la reproducción de un sonido en un canal y después de un tiempo indicados. Si se usa el valor -1 para el canal se detendrán todos.

Manipulaciones de sonidos en reproducción

Disponemos de varias funciones para controlar el sonido y la música mientras se están reproduciendo.

Funciones de volumen

  • Mix_MasterVolume nos permite controlar el volumen general de los chunks. El valor de retorno nos indica el volumen actual, y si el parámetro es un valor negativo, generalmente -1, el volumen no se ve afectado.
  • Mix_Volume nos permite controlar el volumen de un único canal específico. El valor de retorno funciona igual que con Mix_MasterVolume.
  • Mix_VolumeMusic nos permite controlar el volumen del canal de música.
  • Mix_SetDistance permite modificar la distancia entre el sonido de un canal y el jugador, virtualmente. Cuanta mayor sea la distancia más bajo será el volumen<./li>

Efectos espaciales

Podemos añadir a los sonidos efectos para simular su procedencia desde diferentes direcciones y distancias.

  • Mix_SetPanning nos permite establecer el volumen del sonido en los canales estéreo derecho e izquierdo

    .
        MixSetPanning(3, 55, 200);
    
  • La función Mix_SetPosition es más sofisticada, y nos permite indicar la posición de la fuente del sonido con respecto al jugador para un canal específico, mediante un ángulo y una distancia. El ángulo se especifica en grados, entre 0º y 360º, siendo 0º la posición frente al observador. Los ángulos crecen en el sentido horario. Los valores indicados se normalizan, de modo que si se indica un ángulo de -45º, la función usará el valor 315.

    La distancia es un valor entre 0 y 255, cuanto mayor, más lejos.

    Mix_SetPosition(4, 45, 87); // 45º a la derecha y 87 de distancia
    
  • Por último, la función Mix_SetReverseStereo permite invertir los canales derecho e izquierdo de un sonido estéreo.

Funciones de pausa

  • Mix_PauseAudio suspende o reanuda toda la salida de audio, dependiendo del valor del parámetro, uno para pausar, cero para reanudar. (Versión 2.8.0).
  • Mix_Pause pausa la salida de audio para un canal determinado.
  • Mix_Resume reanuda la salida de audio para un canal determinado.
  • Mix_Paused consulta si un canal determinado está en pausa.
  • Mix_HaltChannel detiene la reproducción de un canal determinado. Al contrario que una pausa, un canal detenido no puede reanudarse.
  • Mix_PauseMusic pausa el flujo de música.
  • Mix_ResumeMusic reanuda el flujo de música.
  • Mix_PausedMusic consulta si la música está pausada.
  • Mix_HaltMusic detiene la reproducción del flujo musical.

Fade-in y fade-out

  • Mix_FadeInChannel reproduce un fragmento de audio en un canal específico, pero subiendo el volumen durante un intervalo. A los tres parámetros usados en Mix_PlayChannel se añade un cuarto que indica los milisegundos que se tarda en alcanzar el volumen final.
  • Mix_FadeInChannelTimed similar a la anterior, pero se añade otro parámetro con el tiempo máximo del chunk que se reproducirá.
  • Mix_FadeOutChannel detiene un canal después de desvanecerlo durante un tiempo especificado.
  • Mix_FadingChannel consulta el estado de desvanecimiento de un canal.
  • Mix_FadeInMusic reproduce un nuevo objeto musical, incrementando el volumen del audio. A los parámetros usados en Mix_PlayMusic se añade un tercero con los milisegundos que se tarda en alcanzar el volumen final.
  • Mix_FadeInMusicPos reproduce un nuevo objeto musical, incrementando el volumen del audio, desde una posición inicial indicicada en un tercer parámetro.
  • Mix_FadeOutMusic detiene el flujo de música después de desvanecerlo durante un tiempo especificado.
  • Mix_FadingMusic consulta el estado de desvanecimiento del flujo musical.

Grupos de canales

Es posible agrupar ciertos canales para poder manejarlos en conjunto. A cada grupo se le asigna una etiqueta, que es un valor entero, y cada canal puede pertenecer a un único grupo. La etiqueta de valor -1 indica que el canal no pertenece a ningún grupo. El valor -1 se usa para referirse a todos los canales.

Para asignar una etiqueta a un canal se usa la función Mix_GroupChannel indicando como parámetros el número de canal y el valor de la etiqueta.

Si queremos asignar una misma etiqueta a un grupo de canales con identificadores correlativos podemos usar la función Mix_GroupChannels, indicando como parámetros el primer canal del intervalo, el último y el valor de la etiqueta.

Para sacar uno o varios canales de un grupo se usan las dos funciones anteriores, pero indicando como la etiqueta el valor -1.

Las acciones que se pueden asignar a grupos son:

  • Mix_FadeOutGroup, que detiene la reproducción en los canales del grupo que estén reproduciendo sonido después de una atenuación del volumen, indicando en el primer parámetro la etiqueta del grupo y en el segundo el tiempo de atenuación en milisegundos.
  • Mix_HaltGroup, que detiene la reproducción en los canales del grupo indicado, instantáneamente.

La función Mix_GroupAvailable devuelve el primer canal disponible, que no esté reproduciendo un sonido, del grupo indicado como parámetro.

Mix_GroupCount devuelve el número de canales actualmente asociados al grupo indiciado.

Mix_GroupNewer devuelve el número del último canal, dentro del grupo especificado, que empezó a reproducir un sonido.

Mix_GroupOldest devuelve el número del primer canal, dentro del grupo especificado, que empezó a reproducir un sonido.

Si hemos reservado un rango de canales para el uso de la aplicación mediante la función Mix_ReserveChannels, posteriormente podemos dividir ese rango en grupos y usar cada grupo para una categoría de sonidos diferente. De este modo es más sencillo detener la reproducción de una categoría con una única función, o buscar un canal libre para reproducir un nuevo sonido de esa categoría, o elegir qué canal reutilizar si no hay ninguno libre, ya sea el más nuevo o el más antiguo.

Funciones de retrollamada (callback)

Podemos usar algunas funciones que serán invocadas por la librería cuando se cumplan ciertas condiciones.

La función Mix_HookMusicFinished establece una retrollamada que será invocada cuando la música que se está reproduciendo actualmente termine.

La función de retrollada tiene el siguiente prototipo:

void funcion(void);

Esto nos permite automatizar tareas como las listas de reproducción, reproducción aleatoria, etc.

La función Mix_ChannelFinished funciona igual pero con chunks. La retrollamada será invocada cada vez que un canal termine de rerproducir un chunk.

La función de retrollamada tiene éste prototipo:

void funcion(int channel);

Y recibe el número del canal que ha concluido la reproducción.

Información sobre música

Muchos ficheros de música contienen metadatos, y SDL proporciona algunas funciones para recuperarlos.

Mix_GetMusicAlbumTag devuelve el nombre del álbum de un objeto música.

Mix_GetMusicTitleTag devuelve el título de un objeto música.

Mix_GetMusicTitle devuelve el título de un objeto música o el nombre del fichero.

Mix_GetMusicArtistTagdevuelve el nombre del artista de un objeto música.

Mix_GetMusicCopyrightTag devuelve la fecha de copyright de un objeto música.

Otras funciones que proporcionan información sobre objetos de música:

Mix_GetMusicPosition devuelve la posición actual de la reproducción de un objeto música en segundos.

Mix_GetMusicType devuelve el tipo de fichero de música.

Mix_GetMusicVolume devuelve el volumen al que se está reproduciendo la música actualmente.

Algunas clases para manejar sonidos

Como hicimos con otras clases, añadiremos algunas para manejar sonidos.

La clase base para iniciar la librería Mixer.

class Mixer {
public:
    Mixer(int flags=0, int frequency=44100, Uint16 format= MIX_DEFAULT_FORMAT, int channels=2, int chunksize=2048) {
        Mix_Init(flags);
        Mix_OpenAudio(frequency, format, channels, chunksize);
    }
    ~Mixer() {
        Mix_CloseAudio();
        Mix_Quit();
    }
    void Pausa(int on) {
        Mix_PauseAudio(on);
    }
};

Iremos añadiendo nuevos métodos a medida que vayamos necesitándolos.

Una segunda clase nos permite manejar música:

class Music {
protected:
    Mix_Music *music;

public:
    Music() { music=NULL; }
    Music(const char *file) {
        music = Mix_LoadMUS(file);
    }
    ~Music() {
        Mix_FreeMusic(music);
    }
    void Play(int loops) {
        Mix_PlayMusic(music, loops);
    }
    int Volume(int volume) {
        return Mix_VolumeMusic(volume);
    }
    void Pause() {
        Mix_PausedMusic();
    }
    void Resume() {
        Mix_ResumeMusic();
    }
    bool Paused() {
        return Mix_PausedMusic();
    }
    void Halt() {
        Mix_HaltMusic();
    }
    int FadeIn(int loops=0, int ms=1000) {
        return Mix_FadeInMusic(music, loops, ms);
    }
    int FadeInPos(int loops=0, int ms=1000, double pos=0.0) {
        return Mix_FadeInMusicPos(music, loops, ms, pos);
    }
    int FadeOut(int ms) {
        return Mix_FadeOutMusic(ms);
    }
    Mix_Fading Fading() {
        return Mix_FadingMusic();
    }
};

Y por último, una clase para manejar chunks:

class Chunk {
protected:
    Mix_Chunk *chunk;
    int channel;

public:
    Chunk() { chunk=NULL; }
    Chunk(const char *file) {
        chunk = Mix_LoadWAV(file);
    }
    ~Chunk() {
        Mix_FreeChunk(chunk);
    }
    int Play(int ch=-1, int loops=0) {
        channel = Mix_PlayChannel(ch, chunk, loops);
        return channel;
    }
    int MasterVolumen(int volumen) {
        return Mix_MasterVolume(volumen);
    }
    int Volumen(int volumen) {
        return Mix_Volume(channel, volumen);
    }
    void Pausa() {
        Mix_Pause(channel);
    }
    void Resume() {
        Mix_Resume(channel);
    }
    bool Pausado() {
        return Mix_Paused(channel);
    }
    int Halt() {
        return Mix_HaltChannel(channel);
    }
    int FadeIn(int loops=0, int ms=1000) {
        return Mix_FadeInChannel(channel, chunk, loops, ms);
    }
    int FadeInTimed(int loops=0, int ms=1000, int ticks=-1) {
        return Mix_FadeInChannelTimed(channel, chunk, loops, ms, ticks);
    }
    int FadeOut(int ms) {
        return Mix_FadeOutChannel(channel, ms);
    }
    Mix_Fading Fading() {
        return Mix_FadingChannel(channel);
    }
    int SetPosition(Sint16 angle, Uint8 distance) {
        return Mix_SetPosition(channel, angle, distance);
    }
};

Esto nos facilita mucho incorporar sonidos a nuestros juegos. Por ejemplo:

    sdl::Mixer(MIX_INIT_MID)
    sdl::Music musica("musica.mid");
    sdl::Chunk explosion("explode.wav");
...
        musica.Play(0);
        explosion.Play();

Asteroids 3

Es el momento de añadir sonido a nuestro juego.

Queremos escuchar algunos efectos, como los disparos, las explosiones y tal vez la propulsión. Y sí, ya sabemos que en el vacío del espacio no se propagan los sonidos, pero esto es un juego.

Pero antes, añadamos algunos disparos al juego.

Necesitamos definir los gráficos para los disparos y su hitbox, así como la hitbox de la nave, que hasta ahora no habíamos necesitado.

static const std::vector<sdl::Shape> formanave { sdl::FCirculo(-6.0, 0.0, 9.0), sdl::FCircle(8.0, 0.0, 5.0) };
...
static const std::vector<sdl::FPoint> disparo = { {-1.0, 0.0}, {0.0, 1.0}, {1.0, 0.0}, {0.0, -1.0}, {-1.0, 0.0} };
static const std::vector<sdl::Shape> formadisparo { sdl::FCircle(0.0, 0.0 ,1.0) };
Disparo
Disparo
Hitbox de nave
Hitbox de nave

Nuestros disparos son pequeños rombos, y usaremos un círculo de radio uno para su hitbox.

Para la nave usaremos un hitbox compuesto de dos círculos.

Tenemos que decidir cómo se van a comportar los disparos. Hay varias opciones, por ejemplo, que cuando lleguen al borde desaparezcan, o que tengan una distancia máxima de vida. El juego original opta por esta opción, que teniendo en cuenta que los bordes de la ventana se comunican, es probablemente la mejor opción. También podemos optar por dar a los disparos un tiempo de vida, y si su velocidad es constante ambos conceptos son equivalentes, pero es más sencillo controlar el tiempo, indicado en un número de ticks.

Otro punto a decidir es la velocidad de cada disparo. Podemos optar por una velocidad constante, o bien sumar una velocidad base a la velocidad de la nave en el momento del disparo. De nuevo parece que la mejor opción es la segunda, ya que si optamos por una velocidad constante, si nuestra nave es más rápida nos alcanzarán nuestros propios disparos.

Por lo tanto, para cada disparo tendremos que mantener varias propiedades: posición, vector de velocidad, coordenadas trasladadas, hitbox y distancia recorrida. Necesitaremos otra propiedad para saber si un disparo es activo o no. Después de recorrer cierta distancia o de impactar con otro objeto el disparo ya no estará activo.

Diseñaremos una clase para el disparo:

class Disparo {
private:
    std::vector<sdl::FPoint> disparo2;
    sdl::FPoint posicion;
    sdl::FPoint velocidad;
    sdl::Hitbox hitbox;
    float ticksvida;
    bool activo;

    void Trasladar(sdl::FPoint &desplazamiento, sdl::FPoint &escala) {
    // x' = x * x" * cos(α) + y * y" * sin(α) + dx
    // y' = -x * x" * sin(α) + y * y" * cos(α) + dy
        for(size_t i = 0; i < disparo2.size(); i++) {
            disparo2[i] = disparo[i].Move(0.0, desplazamiento, escala);
        }
    }

public:
    // Sumamos 100.0 a la velocidad de la nave:
    Disparo(sdl::FPoint pos, float alfa, sdl::FPoint v) : posicion(pos),
        velocidad(v.X()+150*SDL_cosf(factor*alfa), v.Y()-150*SDL_sinf(factor*alfa)), ticksvida(2000), activo(true) {
        disparo2.resize(disparo.size());
        for(size_t i=0; i<formadisparo.size(); i++) hitbox.InsertShape(formadisparo[i]);
        hitbox.ResizeT();
    }
    void Actualizar(Uint64 tick, sdl::FPoint escala) {
        ticksvida -= tick;
        if(ticksvida < 0) activo=false;
        else {
            posicion.X() += (velocidad.X()*(tick)/1000.0);
            posicion.Y() += (velocidad.Y()*(tick)/1000.0);
            if(posicion.X()>:645) posicion.X() -=650;
            if(posicion.X()<-5) posicion.X() +=650;
            if(posicion.Y()>485) posicion.Y() -=490;
            if(posicion.Y()<-5) posicion.Y() +=490;
            // Trasladar asteroide:
            Trasladar(posicion, escala);
            // Trasladar hitbox:
            hitbox.Move(0.0, posicion, escala);
        }
    }
    bool Activo() { return activo; }
    bool Desactivar() { activo=false; }
    sdl::FPoint &Posicion() { return posicion; }
    sdl::FPoint &Velocidad() { return velocidad; }
    std::vector<sdl::FPoint> &Puntos() { return disparo2; }
    bool Colision(Asteroide &a) {
        return hitbox.Collision(a.Hitbox());
    }
};

Tendremos que añadir un vector de disparos a nuestra clase Asteroides:

Modificar la detección de eventos para detectar las pulsaciones de la barra espaciadora y disparar. Queremos que el jugador tenga que pulsar cada vez que quiera lanzar un disparo. No nos vale que deje pulsada la tecla para disparar continuamente. Para ello necesitamos mantener una variable que nos permite conocer si desde la última pulsación se ha soltado la tecla y ya se ha efectuado un disparo. Se trata del miembro booleano disparo, que iniciaremos a false.

class Asteroides : public sdl::Game, sdl::Mixer {
private:
...
    std::vector<Disparo> disparos;
    bool disparo;
...

Nuestro método de tratamiento de eventos queda ahora así:

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

    while(evento.poll()) {
        switch(evento.Tipo()) {
        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_SPACE && !dis) { dis = disparo = true; }
            if(event.Key().keysym.sym == SDLK_ESCAPE) { quit = 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; }
            if(event.Key().keysym.sym == SDLK_SPACE) { dis = 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;
}

En el método Update verificaremos si disparo es true, en cuyo caso añadiremos un disparo al vector de disparos, o usaremos uno de los inactivos y asignaremos false a disparo.

void Asteroides::Update() {
    bool insertado = false;
...
    if(disparo) {
        // Buscar un disparo inactivo:
        for(size_t i=0; i<disparos.size() && !insertado; i++) {
            if(!disparos[i].Activo()) {
                disparos[i] = Disparo(nave.Posicion(),alfa, nave.Velocidad());
                insertado = true;
            }
        }
        if(!insertado) disparos.push_back(Disparo(nave.Posicion(), alfa, nave.Velocidad()));
        disparo=false;
    }
...

Ahora tendremos que decidir cómo se comporta un asteroide cuando recibe un impacto de un proyectil. Queremos que se divida en dos partes del siguiente tamaño más pequeño, excepto los pequeños, que desaparecerán. Pero tendremos que tener cuidado de que los nuevos asteroides no colisionen nada más aparecer, es decir, que no ocupen el mismo espacio; y que no mantengan sus direcciones y velocidades anteriores.

Desde el punto de vista de la física, si un objeto de poca masa y poca velocidad choca con un objeto de mayor masa y velocidad parecida, el efecto en la trayectoria del segundo es mínimo: no podría romperlo ni alterar su trayectoria y velocidad de manera apreciable. Sin embargo, si hacemos que intervengan otras fuerzas, por ejemplo, si nuestro proyectil tiene una carga explosiva, esas fuerzas si pueden tener efectos notables.

Para simplificar, en lugar de manejar fuerzas de explosiones, consideraremos que el proyectil tiene una masa comparativamente grande con respecto al asteroide, de forma que su energía cinética pueda afectar a su velocidad y trayectoria.

La nueva velocidad y trayectoria se puede calcular del mismo modo que las colisiones, pero ahora tendremos dos asteroides después de la colisión, con trayectorias opuestas en la normal a la colisión. Aplicaremos el vector de velocidad calculado para el asteroide después de la colisión a los dos nuevos asteroides, sumando a cada uno un nuevo vector perpendicular a la normal, de modo que ambos fragmentos se separen.

También tendremos que separar las posiciones de los asteroides después de la división, de modo que no interfieran entre ellos. Esto nos obliga a trasladar también las posiciones y moverlas en direcciones contrarias en la línea transversal. Todo esto lo haremos en un nuevo método DividirAsteroide en la clase Asteroides.

Por cierto, SDL 2 dispone de muchas de las funciones ASNI C en la propia librería, precedidas con el prefijo "SDL_". En concreto, las funciones trigonométricas además disponen de versiones float y double. Usaremos esas funciones en lugar de las estándar, por si hubiese algún tipo de optimización.

Hay que añadir algunos métodos y datos más a la clase Asteroide para saber si aún existe y evitar detectar colisiones, o pintar asteroides que ya hemos destruido.

También añadiremos una rutina al método Actualizar para detectar colisiones entre disparos y asteroides.

    for(size_t i=0; i<disparos.size(); i++) {
        if(disparos[i].Activo()) {
            for(size_t j=0; j<asteroides.size(); j++) {
                if(asteroides[j].Activo() && disparos[i].Colision(asteroides[j])) {
                    DividirAsteroide(j, i);
                    disparos[i].Desactivar();
                }
            }
        }
    }

Solo nos queda añadir un bucle al método Render para pintar los disparos.

    renderer.SetDrawColor(rojo);
    for(size_t i=0; i<disparos.size(); i++)
        if(disparos[i].Activo())
            renderer.DrawLinesF(disparos[i].Puntos());

Sonidos

Ahora ya podemos añadir sonidos.

Para empezar, heredaremos la clase Asteroides de Game y de Mixer, de modo que se inicialice la librería de audio también.

Después añadiremos objetos para manipular música y algunos efectos. En nuestro ejemplo, la música está en formato midi, y los efectos en formato ogg. Crearemos tres objetos Chunk para diferentes explosiones, y otro para el disparo. Y un objeto Music para la música.

Iniciaremos todos estos objetos en el constructor.

class Asteroides : public sdl::Game, sdl::Mixer {
...
    sdl::Music musica;
    sdl::Chunk explosion[3];
    sdl::Chunk laser;
...
    Asteroides(sdl::Window& window, sdl::Renderer& renderer) : 
      sdl::Game(window, renderer, 800, 600), 
      sdl::Mixer(MIX_INIT_MID|MIX_INIT_OGG), 
      nave({320.0, 240.0}, 0.0),
      acelerar(0.0), alfa(0.0), disparo(false),
      musica("asi_hablo_zaratustra_amanecer.mid"), 
      explosion {"explosion1.ogg", "explosion2.ogg", "explosion3.ogg"}, 
      laser("laser3.mp3") {
...
        musica.Play(0);
...

Para reproducir la música, en el mismo constructor invocamos al método Play.

Lo mismo se aplica a los sonidos, que se activarán al disparar o al destruir un asteroide.

De momento detectamos también las colisiones entre asteroides y nave, pero solo reproducimos un sonido de explosión, dejaremos la parte de la destrucción de la nave para futuros capítulos.

class Asteroides : public sdl::Juego, sdl::Mixer {
...
    void DestruirNave() { explosion[1].Play(); }
    bool ColisionNave(int iAsteroide) { return asteroides[iAsteroide].Hitbox().Collision(nave.Hitbox()); }
...
void Asteroides::Update() {
...
    for(size_t i=0; i<asteroides.size(); i++) {
        if(asteroides[i].Activo() && ColisionNave(i)) {
            DestruirNave();
        }
    }
...

Ejemplo Asteroides 3

Para visualizar los hitboxes compilar con la macro VERHITBOX definida. Se mostrarán los hitboxes como cuadrados, aunque en realidad son círculos. SDL 2 no dispone de funciones para dibujar círculos, aunque existe una librería adicional que lo permite, y que veremos en otro capítulo. De modo que los hitboxes mostrados son solo aproximaciones.

Nombre Fichero Fecha Tamaño Contador Descarga
Asteroides 3 sdl_asteroides3.zip 2024-07-03 150007 bytes 46