6 Teclado y ratón

Cualquier acción del usuario y ciertas condiciones del sistema se notifican a SDL mediante eventos. Por ejemplo, cuando el usuario pulsa una tecla, mueve el ratón, modifica el tamaño o cierra una ventana, etc, se crea un evento y el programa puede consultarlo para reflejar esa acción en el juego.

Los eventos están clasificados en diferentes tipos, que puedes consultar en SDL_EventType. En este capítulo veremos algunos de ellos, principalmente los relacionados con las entradas: teclado y ratón, y dejaremos para más adelante los del joystick y el controlador, así como el resto de eventos.

Para atender a los eventos, lo primero que necesitamos es una estructura SDL_EventType, así que la declararemos:

SDL_Event test_event;

En cada iteración del bucle del juego tendremos que recuperar y procesar todos los eventos que se hayan generado desde la iteración anterior:

while (SDL_PollEvent(&test_event)) {
     switch (test_event.type) {
        case SDL_KEYDOWN:
            // Procesar teclas pulsadas.
            break;
...
        default: break; // Ignorar los que no nos interesen,
}

Este proceso es el primero en cada iteración del bucle del juego.

Eventos del teclado

Para procesar las entradas del teclado tenemos que atender a los eventos SDL_KEYDOWN y SDL_KEYUP.

Tengamos en cuenta que procesamos eventos. Esto quiere decir que cuando procesamos la pulsación de una tecla, esa pulsación se produjo en el periodo de procesamiento del cuadro anterior. Es decir, siempre habrá pasado un tiempo desde que el usuario pulsó la tecla hasta que el juego procesa esa pulsación.

Afortunadamente, un juego al que se pueda jugar, procesará el bucle de juego varias veces por segundo, por lo que esos retardos serán de muy pocos milisegundos, y raramente serán perceptibles para el jugador.

Tomemos por ejemplo que queremos que nuestro personaje se mueva por la ventana al pulsar las teclas de dirección, y añadiremos una condición para terminar el juego, al pulsar la tecla de escape.

Nuestro procesamiento de eventos quedaría así:

    SDL_Event test_event;
    float dirh, dirv;
    bool salir = false;
    bool izq = false;
    bool der = false;
    bool arr = false;
    bool aba = false;
...
        while(SDL_PollEvent(&test_event)) {
            switch(test_event.type) {
            case SDL_KEYDOWN:
                if(test_event.key.keysym.sym == SDLK_LEFT)  { izq = true; }
                if(test_event.key.keysym.sym == SDLK_RIGHT) { der = true; }
                if(test_event.key.keysym.sym == SDLK_UP)    { arr = true; }
                if(test_event.key.keysym.sym == SDLK_DOWN)  { aba = true;  }
                if(test_event.key.keysym.sym == SDLK_ESCAPE) { salir = true; }
                break;
            case SDL_KEYUP:
                if(test_event.key.keysym.sym == SDLK_LEFT)  { izq = false; }
                if(test_event.key.keysym.sym == SDLK_RIGHT) { der = false; }
                if(test_event.key.keysym.sym == SDLK_UP)    { arr = false; }
                if(test_event.key.keysym.sym == SDLK_DOWN)  { aba = false; }
                break;
            }
        }
        dirh = 0.0;
        dirv = 0.0;
        if(izq && !der) dirh = -1.0;
        if(!izq && der) dirh = +1.0;
        if(arr && !aba) dirv = -1.0;
        if(!arr && aba) dirv = +1.0;

Esto hará que el personaje se mueva mientras una o varias de las teclas de dirección permanezca pulsada. A no ser que pulsemos teclas con direcciones opuestas. Si pulsamos simultáneamente las teclas de derecha e izquierda, no habrá movimiento horizontal y si pulsamos simultáneamente las teclas de arriba e abajo, no habrá movimiento vertical.

Para el movimiento necesitamos procesar los dos eventos, el de pulsación de tecla y el de liberación. Pero no será necesario hacerlo para la salida.

También hay que tener en cuenta que el origen de coordenadas está en la esquina superior izquierda de la ventana. Eso quiere decir que las coordenadas horizontales crecen hacia la derecha, y las verticales hacia abajo. Por eso al pulsar la tecla hacia arriba, el valor de dirv es -1.

Movimientos

Mover los objetos en función de los eventos no es tan simple como puede parecer. Recordemos que nuestro bucle de juego se puede ejecutar varios cientos de veces por segundo, o solo unas decenas de veces, dependiendo del entorno en que se ejecute. Esto significa que si, por ejemplo, en cada iteración verificamos que una tecla está pulsada, y en consecuencia el personaje se mueve una cantidad fija, no lo hará a la misma velocidad en todos los entornos.

Por otra parte, dado que las ventanas tienen un número entero de pixels, podemos pensar que para dar las coordenadas de un objeto en la ventana bastará con variables enteras, pero en general esto no es del todo así.

Hay que tener en cuenta que el bucle del juego se puede ejecutar en muy poco tiempo, y como norma general en tiempos no constantes, es decir, nuestro juego puede mostrar muchos fps y dentro de un rango de valores. Por eso los desplazamientos serán necesariamente y en general, de valores de pixels no enteros y no constantes. Si solo tenemos en cuenta los valores de desplazamiento enteros, en el peor de los casos, cuando estos sean menores que uno nuestros objetos no se moverán. Y en el mejor lo harán a velocidades no constantes, debido al error acumulado de despreciar los decimales.

Así que deberemos trabajar con valores en coma flotante, tanto para velocidades como para valores de desplazamientos entre cuadros y por tanto, de coordenadas.

SDL nos proporciona estructuras para manipular puntos y rectángulos tanto con valores enteros SDL_Point y SDL_Rect, como para valores en coma flotante SDL_FPoint y SDL_FRect. Así que elegiremos las opciones con coma flotante, que nos permiten lidiar con todas las variables que nos encontraremos al codificar nuestros juegos./p>

También deberemos tener en cuenta los ticks que hayan transcurrido desde la última vez que hicimos los cálculos.

    Uint64 tick, tick0;
    SDL_FRect rect = {320.0, 240.0, 20.0, 20.0};
    float velocidad = 200.0; // Pixels por segundo
...
        tick = SDL_GetTicks64()-tick0;
        tick0 = SDL_GetTicks64();
        rect.x += dirh*(velocidad*(tick)/1000.0);
        rect.y += dirv*(velocidad*(tick)/1000.0);

En este ejemplo tenemos en tick el número de milisegundos que han transcurrido desde el cuadro anterior, y a continuación guardamos los ticks actuales en tick0 para poder usarlos en la siguiente iteración del bucle del juego.

La nueva posición de nuestro rectángulo se calcula en función del número de los milisegundos transcurridos y de la velocidad a la que se mueve nuestro rectángulo, que depende también de las teclas pulsadas.

La velocidad está expresada en pixels por segundo, de modo que si han transcurrido 1000 milisegundos desde la iteración del bucle el desplazamiento será velocidad*tick/1000.0, es decir 200.0*1000/1000.0, o sea, 200 pixels.

Si solo han transcurrido 10 milisegundos, el desplazamiento será 200.0*10/1000, o sea, 2 pixels.

Pero esto funciona también correctamente con velocidades pequeñas. Si la velocidad es de 20 pixels por segundo, en 10 milisegundos nos desplazaremos 20.0*10/1000, o sea, 0.2 pixels. En la ventana no se apreciará desplazamiento, ya que las coordenadas de los pixels son valores enteros, pero al cabo de 5 cuadros nos habremos desplazado un pixel. Y además, la velocidad será independiente de los milisegundos que transcurran entre cuadros consecutivos.

En el ejemplo 7 usamos rectángulos con coordenadas enteras, pero como las posiciones solo dependían del tiempo, y no de acciones del usuario, podíamos calcularlas en cada iteración del bucle. En el caso actual, como dependemos de las pulsaciones de teclas, las posiciones se calculan de forma acumulativa, de modo que no podemos usar valores enteros para manipularlas.

Ejemplo 8

Nombre Fichero Fecha Tamaño Contador Descarga
Ejemplo 8 sdl_008.zip 2024-01-07 1489 bytes 32

Ratón

Para procesar las entradas del ratón tenemos que atender a los eventos SDL_MOUSEBUTTONDOWN, SDL_MOUSEBUTTONUP, para detectar pulsaciones y liberaciones de los botones del ratón, SDL_MOUSEMOTION, para detectar movimientos del ratón y SDL_MOUSEWHEEL para procesar los movimientos de la rueda del ratón.

Tomemos el mismo ejemplo que antes, en el que queremos que nuestro personaje se mueva por la ventana al pulsar con el botón izquierdo sobre el punto de la ventana donde queramos moverlo, y mantendremos la condición para terminar el juego al pulsar la tecla de escape.

Nuestro procesamiento de eventos quedaría así:

        while(SDL_PollEvent(&test_event)) {
            switch(test_event.type) {
            case SDL_KEYDOWN:
                if(test_event.key.keysym.sym == SDLK_ESCAPE) { salir = true; }
                break;
            case SDL_MOUSEBUTTONUP:
                if(test_event.button.button == SDL_BUTTON_LEFT) {
                    destino.x = test_event.button.x;
                    destino.y = test_event.button.y;
                }
                break;
            case SDL_MOUSEBUTTONDOWN:
            case SDL_MOUSEMOTION:
            case SDL_MOUSEWHEEL:
                break;
            }
        }
        dx = destino.x - rect.x;
        dy = destino.y - rect.y;
        d = sqrt(dx*dx+dy*dy);
        if(d!=0) {
            dirh = dx/d;
            dirv = dy/d;
            dx2 = dirh*(velocidad*(tick)/1000.0);
            dy2 = dirv*(velocidad*(tick)/1000.0);
            if(abs(dx2) > abs(dx)) {
                dx2 = 0;
                rect.x = destino.x;
            }
            if(abs(dy2) > abs(dy)) {
                dy2 = 0;
                rect.y = destino.y;
            }
        }

Esto es solo un ejemplo, existen muchas más formas de resolver el movimiento en función de las entradas del ratón.

Aún queremos que nuestro personaje se mueva a una velocidad constante, por lo tanto tendremos que calcular el desplazamiento en la dirección x e y en función de la posición actual y del punto destino, y del tiempo transcurrido desde la última actualización.

Empezamos calculando la distancia desde la posición actual hasta la de destino, dx y dy, y por el teorema de Pitágoras, la distancia total, d.

Después calculamos las componentes unitarias del vector de desplazamiento, dirh y dirv, usando algunos conceptos básicos de trigonometría. dirh es el coseno del ángulo que forma la dirección en la que tendremos que movernos, y dirv el seno. Que se calculan, respectivamente, como la componente x dividida entre la distancia total y la componente y dividida entre la distancia total.

Para calcular el desplazamiento, dx2, dy2, en función de los ticks desde la actualización anterior usamos la misma expresión que en el ejemplo anterior: multiplicamos por la velocidad y por los ticks y dividimos por 1000.

Estos cálculos se realizan en cada iteración del bucle del juego, pero podemos tener un problema si en el último paso el desplazamiento es mayor que la distancia que nos queda por recorrer. Esto puede suceder si tenemos pocos fps. En ese caso, el valor de ticks será alto, y los valores de dx2 y dy2 pueden ser mayores que dx y dy, respectivamente. O sea, nos pasamos de frenada y en la siguiente iteración tendremos que retroceder. Si los fps son más o menos constantes y bajos, lo que sucederá es que nuestro personaje se quedará moviéndose adelante y atrás sobre el punto de destino, sin llegar nunca a él.

Para evitar esto nos aseguramos de que si el desplazamiento calculado es mayor de la distancia a recorrer, nos desplazaremos directamente a la posición de destino.

Puedes verificar este comportamiento añadiendo un retardo de 100ms, y eliminando las líneas:

            if(abs(dx2) > abs(dx)) {
                dx2 = 0;
                rect.x = destino.x;
            }
            if(abs(dy2) > abs(dy)) {
                dy2 = 0;
                rect.y = destino.y;
            }

Ejemplo 9

Nombre Fichero Fecha Tamaño Contador Descarga
Ejemplo 9 sdl_009.zip 2024-01-07 1572 bytes 31

Escala

Otra variable que a veces deberemos tener en cuenta es la escala.

No siempre podremos asumir qué dimensiones tendrá la ventana del juego. Podemos decidir que el usuario elija la resolución, o, lo que ocurrirá normalmente, que el juego de ejecute a pantalla completa. O incluso que el usuario pueda modificar las dimensiones de la ventana durante la ejecución del juego.

En estos casos todo lo anterior varia, ahora las velocidades no podrán expresarse en pixels por segundo, ya que no conoceremos las dimensiones la ventana. Deberemos dividir la ventana en unidades lógicas, que solo coincidirán con pixels para medidas muy concretas de la ventana, y expresar las velocidades en unidades lógicas por segundo.

Las dimensiones de todos los elementos del juego serán función de la escala que apliquemos a esas unidades lógicas.

Si nuestra ventana tiene 640x480 unidades lógicas, en una ventana de 640x480 pixels las escalas horizontal y vertical serán 1.0.

Si ejecutamos el juego en pantalla completa, con una resolución de 1920x1080 pixels, la escala horizontal será 3.0 y la vertical 2.25. Es decir, cada unidad lógica medirá 3x2.25 pixels.

La escala puede afectar al tamaño de los gráficos, y como en el último ejemplo, distorsionarlos, cuando las escalas horizontal y vertical son diferentes. En esos casos tenemos dos opciones:

  • Aceptar la distorsión, y ocupar el área completa de la ventana, manteniendo esas escalas distintas para cada eje.
  • No distorsionar, y usar la menor de las escalas en ambos ejes, con lo que dejaremos sin uso parte de la ventana. O dependiendo del tipo de juego, podremos adaptar el fondo para mostrar más contenido.

Habrá juegos que puedan funcionar perfectamente usando la primera opción, al menos si la diferencia de escala no es excesiva. Pero por norma general, optaremos por la segunda, bien dejando parte de la ventana sin uso, o agrandando el área de juego.

En determinados tipos de juegos, como los sandbox, o aquellos que disponen de un mapa, podremos hacer zoom para alejar o acercar la vista. En esos casos también se aplica un cambio de escala, pero ahora las escalas en ambos ejes son iguales.

Cuando trabajemos con gráficos vectoriales podremos aplicar las fórmulas de traslación, rotación y cambio de escala a cada punto, y los gráficos resultantes siempre se mostrarán a la resolución máxima, ya que se generan a partir de coordenadas.

Cuando los gráficos sean imágenes almacenadas en mapas de bits también podremos aplicar traslaciones, rotaciones y cambios de escala, aplicándolas a los rectángulos de destino de renderizado. Pero ahora no siempre se mantendrá una resolución equivalente entre los gráficos de origen y los de destino.

Podemos evitar un pixelado excesivo en destino usando gráficos de origen con mucha resolución, y evitando que se puedan hacer cambios de escala de valores excesivamente grandes, por ejemplo, que la escala máxima sea 1:1, es decir, un pixel de origen se traslada a un pixel de destino.

Si los márgenes de escala son muy grandes una solución es crear varios juegos de gráficos para cada rango de escalas. Por ejemplo, para las escalas grandes usaremos un juego de gráficos en muy alta resolución, para las escalas medianas otro juego de gráficos de resolución menor, etc.

Hay infinidad de opciones para que mostrar gráficos de calidad, como evitar rotaciones arbitrarias, permitiendo solo de 90º en 90º.