En este post te explicaré cómo programar un juego de Tetris sin importar el lenguaje de programación. Vamos a ver las operaciones que se deben hacer y cómo lograr cada requisito del juego.

Vamos a ver cómo programar Tetris e implementar lo siguiente:
- Bajar el tetrimino
- Mover el tetrimino
- Saber si el tetrimino ya llegó hasta abajo
- Calcular colisiones
- Tetrimino de reserva
- Mostrar siguiente tetrimino
- Dibujar cuadrícula del juego y pieza en movimiento
- Rotar pieza
Actualmente he programado 3 versiones de tetris.
- Con JavaScript que se puede jugar en el navegador web
- Con ANSI C usando Allegro
- Con C++ en Arduino
Me gustó mucho hacerlo con C por sus apuntadores, pues nos permite ahorrar todavía más RAM y evitar globales encapsulando el comportamiento de las funciones sin que modifiquen otra cosa.
Sobre la memoria y tipos de datos
Más adelante cuando explique lo de la cuadrícula te darás cuenta de que estoy usando enteros de 8 bits y que estoy usando cada bit como un cuadro de dicha cuadrícula.
Lo hice de esta manera porque quise ahorrar memoria al portarlo para Arduino UNO (donde solo hay 2KB de RAM).
Esto nos permite ahorrar memoria pero:
- Hace la lógica del juego más compleja, pues hay que cambiar bits de cada byte usando operaciones a nivel de bits
- No permite guardar más información de las piezas en la cuadrícula. Solo te dice si hay un cuadro relleno o no. Si quieres guardar los colores de la pieza no es posible.
Obviamente tú puedes quitar este requerimiento.
La cuadrícula
La cuadrícula solamente va a mostrar el espacio vacío y las piezas que ya hayan caído. No va a mostrar la pieza actual que el jugador todavía puede mover, esa se dibuja por separado. Aquí solo tenemos las piezas ya caídas previamente.
Todo el juego se desarrolla en una cuadrícula o matriz (arreglo de 2 dimensiones). Primero veamos cómo declarar la cuadrícula sin expandir los bytes.
#define ALTO 3
#define ANCHO 2
uint8_t cuadricula[ALTO][ANCHO] = {
{0, 0},
{0, 0},
{0, 0},
}
Estamos usando solamente 6 bytes en nuestra cuadrícula de 3x2, pero realmente, como vamos a usar cada bit, tendríamos algo así en su representación binaria:
{00000000, 00000000},
{00000000, 00000000},
{00000000, 00000000},
Haciendo que efectivamente nuestra cuadrícula sea de ALTO * ANCHO * 8
.
En nuestro ejemplo que tenemos 3x2 tendríamos realmente una cuadrícula de 3x16
Esta cuadrícula tendrá todos sus valores en cero al inicio, y cuando el tetrimino que va bajando toque el suelo vamos a copiar los valores de ese tetrimino a la cuadrícula.
Entonces esta cuadrícula no va a tener el tetrimino en movimiento (el que mueve el jugador), solo tendrá los tetriminos ya caídos.
Dibujar cuadrícula expandiendo bytes
Hasta este punto hemos visto cómo definir la cuadrícula pero todavía no te he mostrado cómo dibujarla y expandir cada byte, ya que no solo será un recorrido simple de matriz, también debemos recorrer cada byte.
Comenzamos recorriendo la matriz como lo haríamos normalmente y, por cada valor existente, recorremos desde el bit 0 hasta el bit 7 así:
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#define ANCHO_CUADRICULA 2 // Recuerda que se multiplicará por BITS_EN_UN_BYTE así que si es 2 en realidad es 16
#define ALTO_CUADRICULA 5
#define BITS_EN_UN_BYTE 8
#define MAXIMO_INDICE_BIT_EN_BYTE 7
int main()
{
uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA] = {
{0, 0},
{0, 0},
{0, 0},
{0, 0},
{0, 0},
};
for (int y = 0; y < ALTO_CUADRICULA; y++)
{
for (int x = 0; x < ANCHO_CUADRICULA; x++)
{
for (int i = 0; i < BITS_EN_UN_BYTE; i++)
{
int encendido = (cuadricula[y][x] >> (MAXIMO_INDICE_BIT_EN_BYTE - i)) & 1;
if (encendido)
{
}
else
{
}
}
}
}
return 0;
}
La variable encendido
nos va a decir si ese bit dentro de ese byte está relleno o no.
Básicamente nos dirá el estado de ese cuadro de la cuadrícula.
Centrémonos en el recorrido de cuadricula[y][x]
porque vamos a recorrer un uint8_t
que es un entero sin signo de 8 bits, por eso vamos desde 0 hasta 7.
Empezamos en el MSB o bit que está hasta la izquierda, y terminamos en el LSB o bit que está hasta la derecha.
Aquí ya solo tenemos un uint8_t
porque ya accedimos a él a través de cuadricula[y][x]
así que
para facilitar el entendimiento saquemos ese uint8_t
(que representa un fragmento de fila) y supongamos que
su valor es 0x9A
que en decimal es 154 y en binario se ve así (con espacios para mejorar legibilidad): 1001 1010
Lo he definido así para que tenga varios ceros y unos y nos sirva de ejemplo. Comenzamos el recorrido desde 0 hasta 7:
for (int i = 0; i < BITS_EN_UN_BYTE; i++)
En el primer paso i
tendrá el valor de 0 y sabemos que para este ejemplo cuadricula[y][x]
tiene el valor 154, así que lo siguiente:
int encendido = (cuadricula[y][x] >> (MAXIMO_INDICE_BIT_EN_BYTE - i)) & 1;
Se convierte en:
int encendido = (154 >> (7 - 0)) & 1;
Recuerda: 154 es cuadricula[y][x]
, 7
es el máximo índice que un bit puede tener en un byte y 0
es el índice del ciclo for
Luego sería:
int encendido = (154 >> (7)) & 1;
Vamos a centrarnos en el desplazamiento a la derecha. Vamos a desplazar el 154 7 veces a la derecha. Si en binario se ve así:
1001 1010
^-- Este es el bit que nos interesa, porque i=0
Al desplazarlo se va a ver así:
0000 0001
^-- Este es el bit desplazado
Recuerda que el desplazar a la derecha también puede ser visto como rellenar con ceros a la izquierda.
Entonces hasta el momento tenemos que 154 >> 7
es 1
. Se simplifica todavía más:
int encendido = (1) & 1;
No te confundas: el 1 que está entre paréntesis es lo que había en cuadricula[y][x]
ya desplazado
según el índice de bit en el que estamos, y el 1
con el que le vamos a hacer un and es un número constante
que siempre va a tener el mismo valor y con el cual lo vamos a comparar.
Entonces lo que falta es hacer un AND.
El 154 ya desplazado 7 veces: 0000 0001
El 1 constante: 0000 0001
Resultado: 0000 0001
Y el resultado es un 1. Eso indica que en ese índice sí hay un cuadro relleno, lo cual es correcto, pues si vemos el 154
original y vemos el índice 0 veremos que ahí tiene un 1: 1001 1010
Ahora veamos lo que pasa cuando el índice del bit es 1, es decir, la segunda posición. Recordemos de nuevo el código:
int encendido = (cuadricula[y][x] >> (MAXIMO_INDICE_BIT_EN_BYTE - i)) & 1;
Se convierte a:
int encendido = (154 >> (7 - 1)) & 1;
En donde 154 sigue siendo nuestro número que está en cuadricula[y][x]
, 7
es el máximo índice de un bit al recorrer un byte y 1
es el índice bit actual del ciclo.
Se simplifica así:
int encendido = (154 >> (6)) & 1;
Es momento de recorrer el 154 6 veces a la derecha.
1001 1010
^-- Este es el bit que nos interesa, porque i=1
Al desplazarlo 6 veces:
0000 0010
^-- Este es el bit ya desplazado
Y le vamos a hacer un AND con el 1
constante:
Este es el número desplazado: 0000 0010
Este es el 1 constante: 0000 0001
Resultado AND: 0000 0000
El resultado será un 0, lo cual es correcto pues si miramos el número 154 original
podemos notar que en su índice 1 (segunda posición) tiene un 0: 1001 1010
El truco del desplazamiento es básicamente poner el bit que nos interesa en el extremo de la derecha,
y como el 1
constante con el que hacemos las comparaciones se ve así en binario: 00000001
también tiene un 1 en su extremo derecho y
nos sirve para comparar con AND.
Lo escribo aquí si no lo he escrito antes: esta cuadrícula va a estar vacía al inicio del juego. Cuando un tetrimino caiga entonces será copiada a la cuadrícula y ya se irá llenando.
Ejemplo de dibujo de cuadrícula
Aquí te estoy explicando cómo programar el juego de Tetris en cualquier lenguaje de programación, pero déjame mostrarte un ejemplo de cómo dibujarla en Allegro 5 con C:
/*
=========================================
Comienza dibujo de la cuadrícula donde las piezas ya han caído
=========================================
*/
float xCoordenada = 0, yCoordenada = 0;
for (int y = 0; y < ALTO_CUADRICULA; y++)
{
for (int x = 0; x < ANCHO_CUADRICULA; x++)
{
for (int i = 0; i < BITS_EN_UN_BYTE; i++)
{
int encendido = (otraCuadricula[y][x] >> (MAXIMO_INDICE_BIT_EN_BYTE - i)) & 1;
// Si hay un 1 en este bit entonces dibujamos la imagen
if (encendido)
{
/**/
float sw = al_get_bitmap_width(imagen_pieza_caida);
float sh = al_get_bitmap_height(imagen_pieza_caida);
float dx = xCoordenada + GROSOR_BORDE;
float dy = yCoordenada + GROSOR_BORDE;
float dw = MEDIDA_CUADRO;
float dh = MEDIDA_CUADRO;
al_draw_scaled_bitmap(imagen_pieza_caida,
0, 0, sw, sh,
dx, dy, dw, dh, 0);
/** */
}
else
{
// Si no, un rectángulo negro que coincida con el fondo
al_draw_filled_rectangle(xCoordenada + GROSOR_BORDE, yCoordenada + GROSOR_BORDE, xCoordenada + MEDIDA_CUADRO + GROSOR_BORDE, yCoordenada + MEDIDA_CUADRO + GROSOR_BORDE, encendido == 0 ? negro : rojo);
}
xCoordenada += MEDIDA_CUADRO;
}
}
xCoordenada = 0;
yCoordenada += MEDIDA_CUADRO;
}
/*
=========================================
Termina dibujo de la cuadrícula donde las piezas ya han caído
=========================================
*/
Y aquí un fragmento de código que hace lo mismo pero con Arduino en una pantalla OLED. Ahí el código es más simple pues no usamos bitmaps, solo rectángulos:
// Dibujamos toda la cuadrícula...
for (int y = 0; y < ALTO_CUADRICULA; y++)
{
for (int x = 0; x < ANCHO_CUADRICULA; x++)
{
for (int i = 0; i < BITS_EN_UN_BYTE; i++)
{
int encendido = (otraCuadricula[y][x] >> (MAXIMO_INDICE_BIT_EN_BYTE - i)) & 1;
int verdaderoX = (x * BITS_EN_UN_BYTE) + i;
if (encendido)
{
display.fillRect(verdaderoX * MEDIDA_CUADRO, (y + OFFSET_Y) * MEDIDA_CUADRO,
MEDIDA_CUADRO, MEDIDA_CUADRO, SSD1306_WHITE);
}
else
{
display.fillRect(verdaderoX * MEDIDA_CUADRO, (y + OFFSET_Y) * MEDIDA_CUADRO,
MEDIDA_CUADRO, MEDIDA_CUADRO, SSD1306_BLACK);
}
}
}
}
Representación de un tetrimino
Volvemos de nuevo al ahorro de RAM. Cualquier tetrimino, según mis cálculos, cabe perfectamente en una cuadrícula de 4x4.
Podríamos usar un arreglo de enteros, pero como queremos ahorrar
memoria usamos un uint16_t
pues tiene 16 bits y nos permite representar
perfectamente un tetrimino.
Olvidemos por un momento el uint16_t y centrémonos en la cuadricula. Tomemos la pieza T como ejemplo, en una cuadrícula de 4x4 se vería así:
███
█
Y con unos y ceros se vería así:
1110
0100
0000
0000
Entonces un uint16_t nos alcanza perfectamente. Para definir esa T podemos tomar cada fila y representarlo como un hexadecimal, Quedando así:
1110 Es 8+4+2 = 14, que sería una E
0100 es 4 = 4
0000 es 0
0000 es 0
Y el número sería: 0xE400 que en decimal sería 58368, pero para los fines que requerimos es mejor representarlo como hexadecimal para facilidad de lectura por el programador (al compilador no le importa)
Eso nos lleva a la función que inicializa todos los tetriminos. Ahí podrás ver cómo se representa cada uno de ellos.
void inicializarPiezas(struct TetriminoParaElegir piezas[TOTAL_TETRIMINOS_DISPONIBLES])
{
/*
Veamos la Z es
1100
0110
0000
0000
Que sería C6
*/
piezas[0].cuadricula = 0xC600;
/*
La L es
1000
1000
1100
0000
Que sería 88C0
*/
piezas[1].cuadricula = 0x88C0;
/*
Ahora una línea
1111
0000
0000
0000
Solo sería F000
*/
piezas[2].cuadricula = 0xF000;
/*
Ahora el cuadro
1100
1100
0000
0000
Que sería CC00
*/
piezas[3].cuadricula = 0xCC00;
/*
Ahora la T
Esa es
1110
0100
0000
0000
Que sería E400
*/
piezas[4].cuadricula = 0xE400;
/*
La L invertida que sería
1110
0010
0000
0000
E200
*/
piezas[5].cuadricula = 0xe200;
/*
La Z invertida que sería
0110
1100
0000
0000
6C00
*/
piezas[6].cuadricula = 0x6c00;
}
Tetrimino en movimiento
El tetrimino tiene sus propias coordenadas x e y además de su cuadrícula:
struct Tetrimino
{
uint16_t cuadricula;
uint8_t x, y;
};
Y los tetriminos para elegir simplemente tienen cuadrícula, pues esos no se están moviendo, solo representan su cuadrícula:
struct TetriminoParaElegir
{
uint16_t cuadricula;
};
Dibujar tetrimino - Recorrer cuadrícula
Sabemos que un tetrimino cabe en una cuadrícula de 4x4, pero no toda esa cuadrícula está rellena, solo algunas partes de ella dependiendo del tetrimino.
Por ello es que debemos buscar la manera de recorrer el uint16_t
y saber cuál espacio está relleno y cuál no.
Una manera de recorrerlo es empezando en el bit 0 y terminando en el 15, sacando las coordenadas x e y en cada paso.
Primero veamos cómo recorrer bit por bit:
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#define BITS_EN_UINT16 16
#define MAXIMO_INDICE_BIT_EN_UINT16 15
#define BITS_POR_FILA_PARA_TETRIMINO 4
struct Tetrimino
{
uint16_t cuadricula;
uint8_t x, y;
};
int main()
{
struct Tetrimino piezaActual = {
.x = 0,
.y = 0,
.cuadricula = 0xc600};
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
}
return 0;
}
Solo es un ciclo normal de 0 a 15. Empezamos recorriendo el bit de la izquierda también conocido como MSB y terminamos recorriendo el de hasta la derecha que es el LSB.
Recuerda: solo recorremos la cuadrícula del tetrimino que es de 4x4, y dentro de esa cuadrícula puede que haya espacios llenos o vacíos, dependiendo todo de la pieza.
Para saber si hay un espacio lleno (o un 1 en ese bit) hacemos:
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
Tenemos que la cuadrícula es 0xc600 representando al tetrimino Z y en binario (separado por legibilidad) se ve así:
1100 0110 0000 0000
Empezamos en el índice 0, así que la operación sería:
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (15 - 0)) & 1;
Queremos saber si en el índice actual (0) hay un 1 o un cero, así que desplazamos el bit que nos interesa 15 posiciones a la derecha para que quede en el último índice, es decir, lo pegamos absolutamente a la derecha:
Original:
1100 0110 0000 0000
^--- Este es el bit que nos interesa pues el índice de bit es 0
Desplazado a la derecha 15 veces:
0000 0000 0000 0001
Ahora es este ----^
Lo hemos pegado a la derecha porque a continuación le vamos a hacer un AND con el número 1 que justamente tiene un 1 en su LSB.
Nuestro número desplazado: 0000 0000 0000 0001
El número 1: 0000 0000 0000 0001
Al hacer el AND recuerda que solo devolverá 1 cuando los dos valores sean 1.
El resultado es 1, indicando que en esa posición hay un cuadro relleno.
No te confundas: siempre vamos a hacer un AND con el 1 sin importar el número desplazado. En este caso coincidió que tanto el número desplazado como el 1 son iguales, pero no siempre será así.
Ya sabemos que el índice 0 sí está relleno y también el 1 pues nuestro número 0xc600 es:
1100 0110 0000 0000
Simulemos lo que pasará cuando el índice sea 2. Recordemos la operación:
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
Vamos en el índice 2 así que queda así:
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (15 - 2)) & 1;
Que será:
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (13)) & 1;
Hay que desplazar nuestro número 13 veces a la derecha:
Original:
1100 0110 0000 0000
^---- Este es el bit que nos interesa porque indiceBit es 2
Desplazado:
0000 0000 0000 0110
^---- Aquí está desplazado
Y al hacer un and con el 1 sería:
0000 0000 0000 0110
AND
0000 0000 0000 0001
___________________
0000 0000 0000 0000
Cuya representación decimal sería 0, indicando que ese espacio no está relleno.
Hasta ahora ya sabemos si el cuadro está relleno o vacío pero necesitamos saber la coordenada x e y porque recuerda que dividimos los 16 bits en 4 filas.
Para calcular x obtenemos el residuo de dividir el índice del bit actual entre los bits por fila (4).
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
Y para calcular y dividimos el índice del bit actual entre los bits por fila (4) redondeando hacia abajo
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
Veamos el siguiente código que recorre toda la pieza y te imprime las coordenadas así como si ese cuadro está relleno o no:
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#define BITS_EN_UINT16 16
#define MAXIMO_INDICE_BIT_EN_UINT16 15
#define BITS_POR_FILA_PARA_TETRIMINO 4
struct Tetrimino
{
uint16_t cuadricula;
uint8_t x, y;
};
int main()
{
struct Tetrimino piezaActual = {
.x = 0,
.y = 0,
.cuadricula = 0xc600};
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
printf("En x=%d,y=%d hay un %d\n", xRelativoDentroDeCuadricula, YRelativoDentroDeCuadricula, hayUnCuadroDeTetriminoEnLaCoordenadaActual);
}
return 0;
}
La salida es:
En x=0,y=0 hay un 1
En x=1,y=0 hay un 1
En x=2,y=0 hay un 0
En x=3,y=0 hay un 0
En x=0,y=1 hay un 0
En x=1,y=1 hay un 1
En x=2,y=1 hay un 1
En x=3,y=1 hay un 0
En x=0,y=2 hay un 0
En x=1,y=2 hay un 0
En x=2,y=2 hay un 0
En x=3,y=2 hay un 0
En x=0,y=3 hay un 0
En x=1,y=3 hay un 0
En x=2,y=3 hay un 0
En x=3,y=3 hay un 0
Teniendo las coordenadas y la bandera que indica si el cuadro está relleno o no ya podemos dibujar el tetrimino en cualquier lugar. Recuerda que las coordenadas son internas de la propia pieza, a esto todavía debes sumarle la posición inicial de la pieza.
Ejemplo de dibujo de tetrimino
Al igual que hice con la cuadrícula te voy a mostrar un ejemplo ya funcional de cómo dibujar el tetrimino en Allegro 5:
/*
=========================================
Empieza dibujo de la pieza (tetrimino) en movimiento
=========================================
*/
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (piezaActual.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int sumaX = piezaActual.x + xRelativoDentroDeCuadricula;
int sumaY = piezaActual.y + YRelativoDentroDeCuadricula;
int8_t indicePiezaFantasma = indiceYParaFantasma(&piezaActual, otraCuadricula);
// Este es el fantasma
al_draw_rectangle((sumaX * MEDIDA_CUADRO) + GROSOR_BORDE, ((YRelativoDentroDeCuadricula + indicePiezaFantasma) * MEDIDA_CUADRO) + GROSOR_BORDE, (sumaX * MEDIDA_CUADRO) + MEDIDA_CUADRO + GROSOR_BORDE, (((YRelativoDentroDeCuadricula + indicePiezaFantasma) * MEDIDA_CUADRO) + MEDIDA_CUADRO) + GROSOR_BORDE, azul, 2);
// Y esta es la pieza en movimiento
float sx = 0;
float sy = 0;
float sw = al_get_bitmap_width(imagen_pieza_movimiento);
float sh = al_get_bitmap_height(imagen_pieza_movimiento);
float dx = (sumaX * MEDIDA_CUADRO) + GROSOR_BORDE;
float dy = (sumaY * MEDIDA_CUADRO) + GROSOR_BORDE;
float dw = MEDIDA_CUADRO;
float dh = MEDIDA_CUADRO;
al_draw_scaled_bitmap(imagen_pieza_movimiento,
sx, sy, sw, sh,
dx, dy, dw, dh, 0);
}
}
/*
=========================================
Termina dibujo de la pieza (tetrimino) en movimiento
=========================================
*/
Y un código un poco más simple que se ejecuta en Arduino para dibujarlo en la pantalla OLED:
// Comienza nuevo código...
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (tetrimino.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int sumaX = tetrimino.x + xRelativoDentroDeCuadricula;
int sumaY = tetrimino.y + YRelativoDentroDeCuadricula;
// Dibujar pieza normal
display.fillRect(sumaX * MEDIDA_CUADRO, (sumaY + OFFSET_Y) * MEDIDA_CUADRO,
MEDIDA_CUADRO, MEDIDA_CUADRO, SSD1306_WHITE);
}
}
Aquí queda demostrado que xRelativoDentroDeCuadricula
solo es la coordenada interna
en la cuadrícula que usamos para representar al tetrimino, misma que mide 4x4, y por
otro lado debemos sumarle tetrimino.x
que representa la coordenada sobre la cuadrícula
(hacemos lo mismo con tetrimino.y
y YRelativoDentroDeCuadricula
)
Dibujando ambas cosas
El tetrimino nunca se pone en la cuadrícula. Se dibuja la cuadrícula en el lienzo del juego (o pantalla OLED) y después se dibuja el tetrimino (igualmente sobre el lienzo, pantalla OLED o lo que sea que se ocupe para dibujar), pero éste último solamente se copia a la cuadrícula cuando ha tocado el suelo.
Entonces los pasos son:
- Dibujar cuadrícula. Pintar partes rellenas con un color distinto a las partes vacías. Las partes vacías de la cuadrícula deben ser pintadas igualmente con un color negro o con el color de fondo
- Dibujar tetrimino en movimiento
- Repetir esto en cada paso del dibujo y al mover el tetrimino
Como la cuadrícula va a pintar de negro los cuadros vacíos no se va a ver el rastro del tetrimino, por eso es importante pintar las zonas vacías de un color distinto a las zonas o cuadros llenos.
Por ejemplo, en Arduino con OLED lo hago así:
// Dibujamos toda la cuadrícula...
for (int y = 0; y < ALTO_CUADRICULA; y++)
{
for (int x = 0; x < ANCHO_CUADRICULA; x++)
{
for (int i = 0; i < BITS_EN_UN_BYTE; i++)
{
int encendido = (otraCuadricula[y][x] >> (MAXIMO_INDICE_BIT_EN_BYTE - i)) & 1;
int verdaderoX = (x * BITS_EN_UN_BYTE) + i;
if (encendido)
{
display.fillRect(verdaderoX * MEDIDA_CUADRO, (y + OFFSET_Y) * MEDIDA_CUADRO,
MEDIDA_CUADRO, MEDIDA_CUADRO, SSD1306_WHITE);
}
else
{
/*
Fíjate que si no está encendido yo pinto un rectángulo negro para limpiar
*/
display.fillRect(verdaderoX * MEDIDA_CUADRO, (y + OFFSET_Y) * MEDIDA_CUADRO,
MEDIDA_CUADRO, MEDIDA_CUADRO, SSD1306_BLACK);
}
}
}
}
// Y ahora el tetrimino
// Comienza nuevo código...
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (tetrimino.cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int sumaX = tetrimino.x + xRelativoDentroDeCuadricula;
int sumaY = tetrimino.y + YRelativoDentroDeCuadricula;
// Dibujar pieza normal
display.fillRect(sumaX * MEDIDA_CUADRO, (sumaY + OFFSET_Y) * MEDIDA_CUADRO,
MEDIDA_CUADRO, MEDIDA_CUADRO, SSD1306_WHITE);
}
}
Colisiones
Calcular las colisiones en un juego de tetris es realmente sencillo pues solo hay que revisar que no se salga de los límites y también que no haya cuadros llenos en la cuadrícula.
Hasta este punto ya sabemos cómo saber si un cuadro del tetrimino está lleno o vacío así como saber si un cuadro de la cuadrícula está lleno o vacío, pues lo hemos visto previamente haciendo desplazamiento de bits.
Lo que yo hago es:
- Simular que el tetrimino avanza en una dirección modificando su x o y
- Después de hacer esa simulación, comprobar si colisiona con algo
- Esta simulación se hace cuando el usuario quiere mover el tetrimino o el timer quiere bajar la pieza. Si la simulación dice que hay una colisión entonces no permitimos que se mueva
- Dicho con otras palabras, cuando el usuario quiere mover el tetrimino primero simulamos el movimiento y solo lo hacemos si no colisiona en la simulación
Y para calcular la colisión debemos recordar que el tetrimino vive en una cuadrícula de 4x4 pero que no todos los cuadros están llenos, así que debemos recorrer esa cuadrícula y solo tomar en cuenta los cuadros que están llenos.
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (tetrimino->cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (!hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
continue;
}
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un cuadro lleno en el tetrimino
}
Una vez que sabemos que hay un cuadro lleno calculamos su coordenada x e y internas (dentro de la cuadrícula 4x4) y le sumamos las propias coordenadas x e y del tetrimino sobre la cuadrícula.
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int xEnCuadriculaDespuesDeModificar = tetrimino->x + xRelativoDentroDeCuadricula + modificadorX;
int yEnCuadriculaDespuesDeModificar = tetrimino->y + YRelativoDentroDeCuadricula + modificadorY;
Finalmente sumamos el modificador de x y el modificador de y. Estos modificadores son para calcular la colisión en el siguiente movimiento.
Por ejemplo, si quisieras saber si puedes moverlo a la derecha entonces pondrías el modificador x en 1, si quisieras saber si puedes moverlo a la izquierda el modificador x sería -1, para moverlo hacia abajo sería el modificador y en 1.
Toma en cuenta que en el código de arriba xRelativoDentroDeCuadricula
indica la coordenada x dentro de la cuadrícula 4x4 del tetrimino. A eso le sumamos tetrimino->x
que indica la esquina superior izquierda donde comienza
la cuadrícula 4x4 sobre la cuadrícula del juego y finalmente modificadorX
es para simular el movimiento de la pieza.
Hacemos lo mismo con la coordenada Y.
Luego calculamos colisiones con bordes. Si regresa true es que sí hubo una colisión.
// Límites con anchos y altos de la cuadrícula
if (xEnCuadriculaDespuesDeModificar > ANCHO_CUADRICULA * BITS_EN_UN_BYTE - 1)
{
return true;
}
if (xEnCuadriculaDespuesDeModificar < 0)
{
return true;
}
if (yEnCuadriculaDespuesDeModificar < 0)
{
return true;
}
if (yEnCuadriculaDespuesDeModificar > ALTO_CUADRICULA - 1)
{
return true;
}
Llegados hasta este punto sabemos que:
- Sí hay un cuadro lleno en el tetrimino
- No colisionó con ninguna pared ni llegó al suelo
Falta la última comprobación: saber si colisiona con una pieza ya caída en la cuadrícula. Para ello hacemos lo siguiente:
/*
Hasta este punto las coordenadas ya son seguras y ya las tenemos simuladas con el avance
*/
int xEnByteDeCuadricula = xEnCuadriculaDespuesDeModificar / BITS_EN_UN_BYTE;
int indiceBitDeByteEnCuadricula = xEnCuadriculaDespuesDeModificar % BITS_EN_UN_BYTE;
if ((cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] >> (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula)) & 1)
{
return true;
}
Y todo queda encerrado en la siguiente función:
/*
Recibe un apuntador al tetrimino, la cuadrícula del tetris y dos modificadores x e y.
La función aumentará las coordenadas del tetrimino a partir de los modificadores simulando un avance y
después va a devolver true si el tetrimino colisiona con una pared, suelo u otras piezas
*/
bool tetriminoColisionaConCuadriculaAlAvanzar(struct Tetrimino *tetrimino, uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA], int8_t modificadorX, int8_t modificadorY)
{
/*
Nuevo código porque usamos uint16_t
*/
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (tetrimino->cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (!hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
continue;
}
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int xEnCuadriculaDespuesDeModificar = tetrimino->x + xRelativoDentroDeCuadricula + modificadorX;
int yEnCuadriculaDespuesDeModificar = tetrimino->y + YRelativoDentroDeCuadricula + modificadorY;
// Límites con anchos y altos de la cuadrícula
if (xEnCuadriculaDespuesDeModificar > ANCHO_CUADRICULA * BITS_EN_UN_BYTE - 1)
{
return true;
}
if (xEnCuadriculaDespuesDeModificar < 0)
{
return true;
}
if (yEnCuadriculaDespuesDeModificar < 0)
{
return true;
}
if (yEnCuadriculaDespuesDeModificar > ALTO_CUADRICULA - 1)
{
return true;
}
/*
Hasta este punto las coordenadas ya son seguras y ya las tenemos simuladas con el avance
*/
int xEnByteDeCuadricula = xEnCuadriculaDespuesDeModificar / BITS_EN_UN_BYTE;
int indiceBitDeByteEnCuadricula = xEnCuadriculaDespuesDeModificar % BITS_EN_UN_BYTE;
if ((cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] >> (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula)) & 1)
{
return true;
}
}
return false;
}
Presta mucha atención a esta función pues la vamos a usar para saber si podemos bajar el tetrimino, saber si ya es momento de copiarlo a la cuadrícula y aparecer un nuevo tetrimino en la parte superior, así como saber el índice de la pieza fantasma (pieza que te dice lo más bajo que tu pieza podría caer según su coordenada x)
Colisión al rotar
Además de la colisión al mover el tetrimino también debemos revisar la colisión al rotarlo, pues a veces esta operación mueve el tetrimino.
Volvamos de nuevo a la cuadrícula de 4x4, supongamos que tenemos una línea:
1000
1000
1000
1000
Si yo la roto, quedará así:
1111
0000
0000
0000
Es decir, la muevo de vertical a horizontal. Pero si la línea ya estaba pegada a la pared derecha, no podría rotarla, ya que si la rotara mientras está en la pared derecha se saldría de la cuadrícula por 3 cuadros.
Por ello necesitamos una funcón que te dice si la pieza colisiona al rotarla. Voy a explicar la rotación más adelante, pero mientras tanto mira la función que hace ese trabajo. Es muy similar a la que revisa la colisión con paredes, pues al final son simulaciones “del futuro”:
/*
Recibe un apuntador al tetrimino y la cuadrícula del tetris
*/
bool tetriminoColisionaConCuadriculaAlRotar(struct Tetrimino *tetrimino, uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA])
{
/*
Nuevo código porque usamos uint16_t
*/
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
// Primero rotamos. No usaremos tetrimino->cuadricula sino lo rotado
uint16_t rotado = rotar90CW(tetrimino->cuadricula);
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (rotado >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (!hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
continue;
}
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int xEnCuadriculaDespuesDeModificar = tetrimino->x + xRelativoDentroDeCuadricula;
int yEnCuadriculaDespuesDeModificar = tetrimino->y + YRelativoDentroDeCuadricula;
// Límites con anchos y altos de la cuadrícula
if (xEnCuadriculaDespuesDeModificar > ANCHO_CUADRICULA * BITS_EN_UN_BYTE - 1)
{
return true;
}
if (xEnCuadriculaDespuesDeModificar < 0)
{
return true;
}
if (yEnCuadriculaDespuesDeModificar < 0)
{
return true;
}
if (yEnCuadriculaDespuesDeModificar > ALTO_CUADRICULA - 1)
{
return true;
}
/*
Hasta este punto las coordenadas ya son seguras y ya las tenemos simuladas con el avance
*/
int xEnByteDeCuadricula = xEnCuadriculaDespuesDeModificar / BITS_EN_UN_BYTE;
int indiceBitDeByteEnCuadricula = xEnCuadriculaDespuesDeModificar % BITS_EN_UN_BYTE;
if ((cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] >> (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula)) & 1)
{
return true;
}
}
return false;
}
Lo que cambia es:
// Primero rotamos. No usaremos tetrimino->cuadricula sino lo rotado
uint16_t rotado = rotar90CW(tetrimino->cuadricula);
No rotamos la pieza, obtenemos la cuadrícula 4x4 de cómo se vería rotado y ahora sí comprobamos las colisiones.
Mover tetrimino
Mover el tetrimino en cualquier dirección implica comprobar si no colisiona y modificar la propiedad
x
o y
del mismo. Aquí un ejemplo:
else if (teclaPresionada == ALLEGRO_KEY_H || teclaPresionada == ALLEGRO_KEY_LEFT)
{
if (!tetriminoColisionaConCuadriculaAlAvanzar(&piezaActual, otraCuadricula, -1, 0))
{
piezaActual.x--;
}
}
Se traduce a un if(!no colisiona al mover a la izquierda) entonces retrocede en x. Recuerda que en este ejemplo
el -1
es el modificador x, ya que queremos moverlo a la izquierda.
Solo modificamos la coordenada x de piezaActual
si no colisiona, y todo eso se ejecuta cuando alguien presiona
la letra H o la flecha izquierda en Allegro 5, aunque también tenemos un ejemplo con el Arduino, la OLED y un botón:
// Derecha
if (!digitalRead(PIN_BOTON_DERECHA))
{
haPresionadoBotonDerechaPreviamente = true;
}
else
{
if (haPresionadoBotonDerechaPreviamente)
{
haPresionadoBotonDerechaPreviamente = false;
if (!tetriminoColisionaConCuadriculaAlAvanzar(&tetrimino, otraCuadricula, 1, 0))
{
tetrimino.x++;
}
}
}
Aquí uso digitalRead
para saber si han presionado un botón. Eso sí: muevo la pieza solo cuando sueltan
el botón, por ello es que uso la bandera haPresionadoBotonDerechaPreviamente
. En este ejemplo
estoy moviendo la pieza a la derecha, por eso es que el modificador x es 1.
Y ya para terminar de ejemplificar te dejo el código de todas las direcciones:
// Derecha
if (!digitalRead(PIN_BOTON_DERECHA))
{
haPresionadoBotonDerechaPreviamente = true;
}
else
{
if (haPresionadoBotonDerechaPreviamente)
{
haPresionadoBotonDerechaPreviamente = false;
if (!tetriminoColisionaConCuadriculaAlAvanzar(&tetrimino, otraCuadricula, 1, 0))
{
tetrimino.x++;
}
}
}
// Izquierda
if (!digitalRead(PIN_BOTON_IZQUIERDA))
{
haPresionadoBotonIzquierdaPreviamente = true;
}
else
{
if (haPresionadoBotonIzquierdaPreviamente)
{
haPresionadoBotonIzquierdaPreviamente = false;
if (!tetriminoColisionaConCuadriculaAlAvanzar(&tetrimino, otraCuadricula, -1, 0))
{
tetrimino.x--;
}
}
}
// Abajo
if (!digitalRead(PIN_BOTON_ABAJO))
{
haPresionadoBotonAbajoPreviamente = true;
}
else
{
if (haPresionadoBotonAbajoPreviamente)
{
haPresionadoBotonAbajoPreviamente = false;
// bajarTetrimino(&tetrimino, otraCuadricula, &banderaTocoSuelo, &puntaje, &juegoTerminado);
int8_t posibleIndice = indiceYParaFantasma(&tetrimino, otraCuadricula);
if (posibleIndice != -1)
{
tetrimino.y = indiceYParaFantasma(&tetrimino, otraCuadricula);
banderaTocoSuelo = true;
bajarTetrimino(&tetrimino, otraCuadricula, &banderaTocoSuelo, &puntaje, &juegoTerminado);
}
}
}
Todavía no le prestes mucha atención a la operación que baja el tetrimino pues esa la voy a explicar más adelante ya que este movimiento es distinto porque en uno de esos movimientos puede que la pieza ya deba ser parte de la cuadrícula general.
Bajar tetrimino
El tetrimino normalmente va a bajar por indicación del usuario o por un timer del programa.
Bajar un tetrimino implica aumentar en 1 la coordenada y
del mismo, verificando que no
colisione con nada, pero aquí puede haber distintos resultados.
En Allegro 5 con C hago que el tetrimino baje así:
else if (event.timer.source == timer_bajar_pieza)
{
if (!juegoTerminado)
{
ResultadoAlBajar r = bajarTetrimino(&piezaActual, &piezaSiguiente, otraCuadricula, &banderaTocoSuelo, &puntajeGlobal, &juegoTerminado, piezas, &haUsadoLaReserva);
En Arduino lo hago verificando los millis:
unsigned long intervalo = TIEMPO_MINIMO_BAJAR_PIEZA_MS + (unsigned long)((TIEMPO_INICIAL_BAJAR_PIEZA_MS - TIEMPO_MINIMO_BAJAR_PIEZA_MS) * exp(-0.01 * puntaje));
if (milisegundosActuales - ultimosMilisegundos >= intervalo)
{
if (!juegoTerminado)
{
bajarTetrimino(&tetrimino, otraCuadricula, &banderaTocoSuelo, &puntaje, &juegoTerminado);
ultimosMilisegundos = milisegundosActuales;
}
}
En cualquiera de los casos, existen las siguientes opciones:
- Puede que no haya nada debajo del tetrimino y que el mismo pueda bajar normalmente sin colisionar. A esto le llamo un resultado de tipo “nada” porque no pasó nada
- Puede que el tetrimino sí colisione con algo debajo, y cuando colisiona hay 2 posibles resultados.
Cuando colisiona con algo debajo puede ser que haya llegado a la parte inferior de la cuadrícula o que haya colisionado con un tetrimino ya caído en la cuadrícula.
Si colisiona con algo de la cuadrícula puede que haya sido error del usuario, así que solamente establecemos una bandera que indica que ya ha tocado el suelo.
Y aquí vienen los dos posibles resultados.
- Si la bandera es true entonces significa que el usuario ya dejó caer la pieza ahí, ya sea porque se le acabó el tiempo, no pudo mover la pieza o eso quiso
- Si la bandera es false la establecemos en true para que en el siguiente evento pase lo del punto 1. A esto le llamo “tocado el límite”
El punto 2 es necesario porque el usuario pudo haber chocado con un tetrimino caído pero inmediatamente pudo haber movido el tetrimino haciendo que ya pueda seguir bajando.
En caso de que la bandera esté en true
(punto 1) significa que ya se le dio al usuario tiempo para
mover su pieza porque ya tocó el suelo pero no quiso o no pudo hacerlo, y esto
hace que copiemos el tetrimino a la cuadrícula general, limpiemos las líneas vacías, aumentemos
puntaje y aparezcamos una nueva pieza.
Primero veamos cómo se copia el tetrimino actual a la cuadrícula general. Recorremos el tetrimino como lo hacemos al dibujarlo, ya que solo nos interesan los cuadros llenos de su cuadrícula 4x4
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (tetrimino->cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (!hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
continue;
}
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int xEnCuadriculaDespuesDeModificar = tetrimino->x + xRelativoDentroDeCuadricula;
int yEnCuadriculaDespuesDeModificar = tetrimino->y + YRelativoDentroDeCuadricula;
int xEnByteDeCuadricula = xEnCuadriculaDespuesDeModificar / BITS_EN_UN_BYTE;
int indiceBitDeByteEnCuadricula = xEnCuadriculaDespuesDeModificar % BITS_EN_UN_BYTE;
cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] = cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] | (1 << (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula));
}
Calculamos xRelativoDentroDeCuadricula
y YRelativoDentroDeCuadricula
que ya hemos visto
previamente, estos solo nos dicen las coordenadas dentro de la cuadrícula 4x4 del tetrimino.
Luego le sumamos tetrimino->x
y tetrimino->y
para sacar la coordenada de la cuadrícula general,
pero recuerda que esta cuadrícula general está expandida ya que estamos usando cada bit de los bytes
contenidos en cuadricula
, por lo que no podemos usar xEnCuadriculaDespuesDeModificar
para acceder
a la matriz porque estaría fuera de los límites.
Para calcular la x
de la matriz hay que dividir xEnCuadriculaDespuesDeModificar
entre 8
y por eso
es que tenemos xEnByteDeCuadricula
, pero además de eso necesitamos saber cuál de todos los
bits de ese byte hay que modificar, por ello calculamos indiceBitDeByteEnCuadricula
.
Sin perdernos, las variables son:
xRelativoDentroDeCuadricula
indica la coordenada x en la cuadrícula interna del tetrimino que mide 4x4xEnCuadriculaDespuesDeModificar
indica la coordenada x de dónde queda ese cuadro del tetrimino si la sobreponemos en la cuadrículaxEnByteDeCuadricula
indica un índice (y no coordenada) de la matriz que representa la cuadrícula y que nos va a servir para modificar la matriz, no para dibujar, ya quexEnCuadriculaDespuesDeModificar
está calculada suponiendo que los bytes ya fueron expandidosindiceBitDeByteEnCuadricula
indica el índice del bit que debemos modificar una vez que tengamos eluint8_t
al acceder acuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula]
- Las coordenadas y se calculan de manera similar y no necesitan tantos cálculos porque ahí no estamos expandiendo ningún byte
Y luego hacemos un:
cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] = cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] | (1 << (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula));
Vamos a verlo con un ejemplo para entenderlo de mejor manera. Supongamos que tenemos una cuadrícula de 2 de ancho y 3 de alto, que al final será de 16 de ancho y 3 de alto.
La cuadrícula está vacía.
El tetrimino tiene la cuadrícula 0xe400
que es una T, x
en 6 (su esquina superior derecha está 2 cuadros a la izquierda de la mitad horizontal de la cuadrícula ya expandida)
y ya ha tocado el final de la cuadrícula así que su y
está en 1 (no en 0 porque no está hasta arriba y no en 2 porque eso sacaría la mitad del tetrimino fuera de la cuadrícula hacia abajo)
Así que ya es momento de copiarla a la cuadrícula. Veamos el código de nuevo:
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int xEnCuadriculaDespuesDeModificar = tetrimino->x + xRelativoDentroDeCuadricula;
int yEnCuadriculaDespuesDeModificar = tetrimino->y + YRelativoDentroDeCuadricula;
int xEnByteDeCuadricula = xEnCuadriculaDespuesDeModificar / BITS_EN_UN_BYTE;
int indiceBitDeByteEnCuadricula = xEnCuadriculaDespuesDeModificar % BITS_EN_UN_BYTE;
cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] = cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] | (1 << (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula));
Supongamos que el indiceBit
es 0
, ya que en el índice 0 sí hay un cuadro lleno (recuerda que la T es 0xe400
o 1110 0100 0000 0000
) y se ve así:
1110
^-- Estamos en este bit
0100
0000
0000
Aquí xRelativoDentroDeCuadricula
sería 0 % 4
que es 0. Lo cual es correcto, esta pieza tiene la coordenada x en 0
dentro de su cuadrícula interna.
Luego YRelativoDentroDeCuadricula
sería 0 / 4
que es igualmente 0, lo cual también es correcto porque esta pieza tiene la coordenada y
en 0
Vamos con xEnCuadriculaDespuesDeModificar
que sería 6 + 0
(el 6 es tetrimino->x
y el 0
es xRelativoDentroDeCuadricula
). Entonces queda en 6
Seguimos con yEnCuadriculaDespuesDeModificar
y queda en 1 + 0
, o sea 1
Lo interesante viene a continuación. Sabemos que este cuadro va a tener que ser copiado en la coordenada x=6, pero en la matriz
solo tenemos 2 valores de tipo uint8_t
, mismos que tienen 8 bits. Por lógica sabemos que ese cuadro será copiado en el primer byte ya que su
coordenada no es mayor a 7, ¿pero cómo lo calculamos en el programa?
Aquí viene el cálculo de xEnByteDeCuadricula
que sería 6 / 8
, el 6 porque es xEnCuadriculaDespuesDeModificar
y el 8 porque son los bits que existen en un byte. Ya sabemos que el byte que debemos modificar es el 0
porque al hacer 6/8
de manera entera el resultado es 0 (como si se redondeara hacia abajo).
Finalmente calculamos indiceBitDeByteEnCuadricula
que viene dado por xEnCuadriculaDespuesDeModificar % 8
, es decir, sacar el residuo de dividir 6
entre 8, mismo que es 6
.
En resumen vamos a acceder a cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula]
para modificar el uint8_t
que
se encuentra ahí, específicamente modificando el bit que tiene en el índice 6
Voy a volver a copiar la operación:
cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] = cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] | (1 << (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula));
Entonces vamos a asignarle a la matriz en esos índices un nuevo uint8_t
, cuyo valor será lo que ya había ahí mismo pero con una operación OR con
el resultado de desplazar el 1 cierta cantidad de bits a la izquierda. Recordemos que hasta este momento la cuadrícula está
vacía, por lo que todos sus bytes (o uint8_t
s ) están en cero.
Centrémonos en la asignación:
cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] | (1 << (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula));
Ya sabemos que cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula]
es 0 y que indiceBitDeByteEnCuadricula
es 6 así que se reduce a:
0 | (1 << (8 - 6));
Reducimos todavía más:
0 | (1 << (2));
Buen momento para recordar que el 1 se ve así en binario:
00000001
Y que si lo desplazamos 2 a la izquierda sería:
00000100
^-- Aquí está nuestro 1 desplazado
Entonces estamos listos para hacer la operación OR.
Este es nuestro 1 desplazado: 00000100
Este es nuestro uint8_t: 00000000
Resultado: 00000100
Y con eso ya tendremos copiado ese cuadro de nuestro tetrimino a la cuadrícula general. Pongamos otro ejemplo,
supongamos que ya había una pieza caída y que algunos cuadros ya estaban copiados, de modo
que en la matriz no había un 0
sino un 0xf0
que en binario se ve así: 11110000
Este es nuestro 1 desplazado: 00000100
Este es nuestro uint8_t: 11110000
Resultado: 11110100
Como puedes ver, la copia funciona bien pues solo hacemos un or y eso no limpia bits.
Ahora que veo este código me pregunto si no hay una manera más simple de hacer esta asignación ya que el or igualmente podría funcionar con la cuadrícula completa, sabiendo únicamente en dónde dividirla. Tal vez lo haga más adelante.
Cuando acabamos de copiar los cuadros del tetrimino a la cuadrícula general debemos revisar si después de colocar el tetrimino existe alguna línea llena, para ello hacemos:
uint8_t lineasEliminadasConsecutivamente = 0;
while (1)
{
int8_t posibleIndiceFilaLlena = indiceFilaLlena(cuadricula);
// No hay filas llenas. Nada que limpiar
if (posibleIndiceFilaLlena == -1)
{
*puntajeGlobal += lineasEliminadasConsecutivamente;
break;
}
else
{
limpiarFilaYBajarFilasSuperiores(posibleIndiceFilaLlena, cuadricula);
lineasEliminadasConsecutivamente++;
}
}
Aquí necesitamos conocer el índice (coordenada y) de la fila llena, que no siempre existirá, por eso puede regresar -1. Entonces veamos la función.
No olvides que para este punto el tetrimino que iba bajando ya fue copiado a la cuadrícula
/*
Devuelve el primer índice de la fila llena comenzando desde abajo (desde ALTO_CUADRICULA - 1)
Si no hay ninguna fila llena devuelve -1
*/
int8_t indiceFilaLlena(uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA])
{
for (int y = ALTO_CUADRICULA - 1; y >= 0; y--)
{
bool filaLlena = true;
for (int x = 0; x < ANCHO_CUADRICULA; x++)
{
filaLlena = filaLlena && cuadricula[y][x] == 255;
}
if (filaLlena)
{
return y;
}
}
return -1;
}
Empezamos hasta abajo de la cuadrícula, en y=alto cuadrícula - 1
y vamos subiendo hasta
que y=0
. En cada paso de y recorremos toda esa fila y comprobamos si todos los valores
de esa fila son exactamente 255
. Recordemos que 255
es el máximo para uint8_t
y si su byte
está en ese valor es porque todo ese byte tiene sus bits en 1.
Pero no basta con que un byte sea 255, debemos comprobar que toda esa fila lo sea, por eso hacemos
el filaLlena = filaLlena && cuadricula[y][x] == 255
. con que un byte no sea 255 la condición no se va a cumplir y filaLlena
estará en false
.
Si acabamos de recorrer toda esa fila y al final filaLlena
es true
devolvemos directamente ese
índice de y pues nos importa empezar con las filas de hasta abajo. Si acabamos de recorrer todo
y en ningún momento se hizo el return y
entonces devolvemos un -1
porque significa que no hubo ninguna fila llena.
Y luego tenemos la otra función que limpia las fillas llenas y baja las de arriba:
void limpiarFilaYBajarFilasSuperiores(int8_t indiceFila, uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA])
{
// Bajamos las superiores
for (; indiceFila > 0; indiceFila--)
{
memcpy(cuadricula[indiceFila], cuadricula[indiceFila - 1], sizeof(cuadricula[indiceFila]));
}
// Y estamos seguros de que hasta arriba (y=0) se quedó una fila disponible, la dejamos en ceros
memset(cuadricula[0], 0, sizeof(cuadricula[0]));
}
Esta función necesita el índice de la fila llena. Los parámetros de
memcpy
son, en orden: destino, origen, tamaño.
Empieza un ciclo for que comienza en el índice de la fila y va a ir hacia arriba mientras indiceFila sea mayor a 0. Lo que estamos haciendo es mover la fila de arriba hacia la fila actual (o sea, bajar filas superiores).
Luego del ciclo sabemos que hasta arriba hay una fila disponible así que la limpiamos y dejamos en ceros con
la función memset
.
Finalmente concluimos con que la función bajarTetrimino
hace varias
cosas y devuelve el siguiente enum:
typedef enum
{
MENOS_DE_CUATRO_LINEAS,
CUATRO_LINEAS_O_MAS,
TOCADO_LIMITE, // Ya está a punto de ser parte de la cuadrícula
NADA, // Bajó normalmente y no eliminó líneas
ACOMODADA, // Ya es parte de la cuadrícula
CANTIDAD_ESTADOS_BAJAR
} ResultadoAlBajar;
La función completa se ve así:
ResultadoAlBajar bajarTetrimino(struct Tetrimino *tetrimino, struct Tetrimino *siguiente, uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA], bool *bandera, unsigned long *puntajeGlobal, bool *juegoTerminado, struct TetriminoParaElegir piezas[TOTAL_TETRIMINOS_DISPONIBLES], bool *haUsadoReserva)
{
if (!tetriminoColisionaConCuadriculaAlAvanzar(tetrimino, cuadricula, 0, 1))
{
tetrimino->y++;
*bandera = false;
return NADA;
}
else
{
// Ya te había avisado que te movieras. Esto significa que no te moviste y por lo tanto toca spawnear
// una nueva pieza
if (bandera)
{
/*
Otra vez código nuevo porque migramos a uint16_t
Primero toca copiar la pieza actual a la cuadrícula maestra
*/
for (uint8_t indiceBit = 0; indiceBit < BITS_EN_UINT16; indiceBit++)
{
bool hayUnCuadroDeTetriminoEnLaCoordenadaActual = (tetrimino->cuadricula >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
if (!hayUnCuadroDeTetriminoEnLaCoordenadaActual)
{
continue;
}
// Llegados aquí sabemos que el "continue" no se ejecutó y que SÍ hay un tetrimino
// Coordenadas sobre la cuadrícula después de aplicar los modificadores
uint8_t xRelativoDentroDeCuadricula = indiceBit % BITS_POR_FILA_PARA_TETRIMINO;
uint8_t YRelativoDentroDeCuadricula = indiceBit / BITS_POR_FILA_PARA_TETRIMINO;
int xEnCuadriculaDespuesDeModificar = tetrimino->x + xRelativoDentroDeCuadricula;
int yEnCuadriculaDespuesDeModificar = tetrimino->y + YRelativoDentroDeCuadricula;
int xEnByteDeCuadricula = xEnCuadriculaDespuesDeModificar / BITS_EN_UN_BYTE;
int indiceBitDeByteEnCuadricula = xEnCuadriculaDespuesDeModificar % BITS_EN_UN_BYTE;
cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] = cuadricula[yEnCuadriculaDespuesDeModificar][xEnByteDeCuadricula] | (1 << (MAXIMO_INDICE_BIT_EN_BYTE - indiceBitDeByteEnCuadricula));
}
// Limpiar filas y bajar filas superiores hasta que ya no haya filas llenas
/*
Aquí puede que haya un "tetris", entonces calculamos un puntaje o cosas de esas, así que
calculamos el puntaje
*/
uint8_t lineasEliminadasConsecutivamente = 0;
while (1)
{
int8_t posibleIndiceFilaLlena = indiceFilaLlena(cuadricula);
// No hay filas llenas. Nada que limpiar
if (posibleIndiceFilaLlena == -1)
{
*puntajeGlobal += lineasEliminadasConsecutivamente;
break;
}
else
{
limpiarFilaYBajarFilasSuperiores(posibleIndiceFilaLlena, cuadricula);
lineasEliminadasConsecutivamente++;
}
}
printf("Líneas consecutivas: %d\n", lineasEliminadasConsecutivamente);
elegirSiguientePieza(tetrimino, siguiente, piezas);
if (tetriminoColisionaConCuadriculaAlAvanzar(tetrimino, cuadricula, 0, 0))
{
*juegoTerminado = true;
}
// Llegados hasta este punto sabemos que podemos aparecer la nueva pieza y que sí hay
// espacio para ella.
*haUsadoReserva = false;
if (lineasEliminadasConsecutivamente > 0 && lineasEliminadasConsecutivamente < 4)
{
return MENOS_DE_CUATRO_LINEAS;
}
else if (lineasEliminadasConsecutivamente >= 4)
{
return CUATRO_LINEAS_O_MAS;
}
else
{
return ACOMODADA;
}
}
else
{
// No puedes bajar pero te doy un tiempo para que te puedas mover
*bandera = true;
return TOCADO_LIMITE;
}
}
}
Necesitamos saber las líneas que ha limpiado, o si no ha limpiado ninguna, sobre todo para los sonidos del juego y cosas similares. Por ejemplo:
if (teclaPresionada == ALLEGRO_KEY_UP || teclaPresionada == ALLEGRO_KEY_K)
{
piezaActual.y = indiceYParaFantasma(&piezaActual, otraCuadricula);
banderaTocoSuelo = true;
ResultadoAlBajar r = bajarTetrimino(&piezaActual, &piezaSiguiente, otraCuadricula, &banderaTocoSuelo, &puntajeGlobal, &juegoTerminado, piezas, &haUsadoLaReserva);
switch (r)
{
case CUATRO_LINEAS_O_MAS:
al_play_sample(
sonido_4_lineas,
1.0,
0.0,
1.0,
ALLEGRO_PLAYMODE_ONCE,
&id_sonido_4_lineas);
break;
case MENOS_DE_CUATRO_LINEAS:
al_play_sample(
sonido_una_linea,
1.0,
0.0,
1.0,
ALLEGRO_PLAYMODE_ONCE,
&id_sonido_una_linea);
break;
default:
break;
}
}
Ese fragmento es del código que usa Allegro y C. Para Arduino todavía no he implementado sonidos, imagino que lo haré muy simple con un zumbador.
Rotar tetrimino
Cuando hice el juego de Tetris en JavaScript me dio pereza encontrar la fórmula de la rotación y simplemente puse variantes ya rotadas de la misma pieza, pues ahí no era prioridad el ahorro de RAM.
Ahora que lo he hecho ahorrando memoria tuve que encontrar la manera, y la función es:
uint16_t rotar90CW(uint16_t pieza)
{
// (x', y') = ( y, 3 - x )
uint16_t rotado = 0;
for (int y = 0; y < 4; y++)
{
for (int x = 0; x < 4; x++)
{
int bit = (pieza >> (MAXIMO_INDICE_BIT_EN_UINT16 - (y * 4 + x))) & 1;
int xPrima = y;
int yPrima = 3 - x;
rotado |= bit << (MAXIMO_INDICE_BIT_EN_UINT16 - (yPrima * 4 + xPrima));
}
}
while ((rotado & 0xF000) == 0)
{
rotado <<= 4;
}
while ((rotado & 0x8888) == 0)
{
rotado <<= 1;
}
return rotado;
}
Esa función rota y también empuja la pieza a la izquierda y hacia arriba. Antes de explicarlo hay que centrarnos en la fórmula que dice:
(x', y') = ( y, 3 - x )
El 3 es muy importante y tiene que ver con la medida de la cuadrícula. La cuadrícula es
de 4x4, así que el índice mínimo que puedo tener es 0, y el máximo es 3. El punto
medio se calcula como (índice mínimo + índice máximo) / 2
que en este caso sería
(0 + 3) / 2
resultando en 1.5, el 1.5 es el centro geométrico.
El 3 es el doble del punto medio y lo usamos por simplicidad, ya que no voy a profundizar en la traslación al origen, rotación y traslación de vuelta.
Nota: si nuestra cuadrícula fuera de 5x5 nuestro centro geométrico sería 2, y así se visualiza de mejor manera, pero en este caso es de 4x4.
Básicamente lo que hace es rotar un punto en el plano cartesiano. Vamos a poner de nuevo de ejemplo a la T, que se ve así:
1110
0100
0000
0000
Tomemos su punto de la esquina superior izquierda que es y=0
,x=0
y apliquemos la fórmula:
x=y, y= 3 - x
Sustituimos:
x=0,y=3
Y se ve así:
0110
0100
0000
1000
Luego tomamos el segundo punto que está en x=1
,y=0
y queda así:
x=y,y=3-x
Que sería
x=0,y=2
Y va viéndose así:
0010
0100
1000
1000
Tomamos el punto que está en x=2
,y=0
, aplicamos x=y
,y=3-x
y queda:
x=0,y=1
Se va viendo así:
0000
1100
1000
1000
No voy a tomar el punto de x=3
,y=0
por simplicidad, porque no tiene nada ahí, y me
voy a saltar hasta x=1
,y=1
ya que ahí sí hay un cuadro que no he rotado. Aplicamos
x=y
,y=3-x
y queda x=1
,y=2
Se va viendo así:
0000
1000
1100
1000
Como puedes ver hemos rotado nuestra T 90 grados a la derecha, pero en el movimiento se ha desplazado hacia abajo, por ello es que luego la subimos y también la pegamos a la izquierda.
Ahora sí vamos a la función. Tenemos el índice
x
e y
desde 0
hasta 3
en un for:
for (int y = 0; y < 4; y++)
{
for (int x = 0; x < 4; x++)
{
}
}
Y dentro calculamos el índice del bit. Es como cuando recorríamos
la cuadrícula del uint16_t
y a partir de ese índice calculábamos
x e y pero ahora es al revés.
Aquí el índice y
va a ir hasta que sea menor a 4 porque 4 es el alto de nuestra cuadrícula, y el
índice x
va a ir hasta que sea menor que 4 porque 4 es el ancho de la cuadrícula. Ambos terminan
en 3, pero dejo claro de dónde viene cada 4.
Luego debemos calcular el índice del bit como lo dije hace un momento, llamemos a este el índice lineal, y se calcula así:
(y * 4 + x)
Cuando y=0
,x=0
, el índice será 0
. Cuando y=0
,x=1
el índice será 1
. Si avanzamos un
poco más, cuando y=3
,x=3
(que son las coordenadas del cuadro de la esquina inferior derecha)
será (3 * 4 + 3) = 15
, lo cual es correcto porque 15
es el máximo índice para un uint16_t.
Podemos mejorar esa parte del código así:
int indiceBit = (y * 4 + x);
int bit = (pieza >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
Por cierto, pieza
aquí representa la cuadrícula uint16_t
del tetrimino. Ahora de nuevo
vamos con la T (0xe400
) pero ya como un uint16_t
que se ve así:
1110
0100
0000
0000
El código que hace la rotación es (rotado
se define fuera del ciclo y es 0
al inicio) el siguiente:
int indiceBit = (y * 4 + x);
int bit = (pieza >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
int xPrima = y;
int yPrima = 3 - x;
rotado |= bit << (MAXIMO_INDICE_BIT_EN_UINT16 - (yPrima * 4 + xPrima));
Cuando indiceBit
sea 0
entonces se hará lo siguiente:
int bit = (0xe400 >> (15 - 0)) & 1;
Se convierte a:
int bit = (0xe400 >> (15 - 0)) & 1;
Que sería:
int bit = (0xe400 >> 15) & 1;
El 0xe400
se ve así en binario:
0xe400 binario: 1110 0100 0000 0000
^-- Nos importa este bit, pues dijimos que indiceBit es 0
Desplazado 15 veces: 0000 0000 0000 0001
^-- Aquí está el bit desplazado
Básicamente estamos pegando a la derecha el bit que nos interesa así como lo hemos hecho en otras ocasiones. Volvemos al código que dice:
int bit = (0xe400 >> 15) & 1;
Ya sabemos que lo de los paréntesis es 1
como decimal, así que queda:
int bit = (1) & 1;
Y el resultado será 1
decimal al hacer el AND. Ya tenemos que bit
es 1
, veamos el código
que nos falta.
int indiceBit = (y * 4 + x);
int bit = (pieza >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBit)) & 1;
int xPrima = y;
int yPrima = 3 - x;
rotado |= bit << (MAXIMO_INDICE_BIT_EN_UINT16 - (yPrima * 4 + xPrima));
Ya tenemos que indiceBit
es 0
, bit
es 1
, x
es 0
, y
es 0
. Así que
xPrima
será 0
y yPrima
será 3
. Lo siguiente es
establecer ese bit en rotado
, calculando ahora el indiceBit
pero de destino, porque
como hemos cambiado x e y obviamente vamos a usar otro índice.
De hecho he mejorado la función todavía más y va quedando así:
uint16_t rotado = 0;
for (int y = 0; y < 4; y++)
{
for (int x = 0; x < 4; x++)
{
int indiceBitOrigen = (y * 4 + x);
int bit = (pieza >> (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBitOrigen)) & 1;
int xPrima = y;
int yPrima = 3 - x;
int indiceBitDestino = (yPrima * 4 + xPrima);
rotado |= bit << (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBitDestino);
}
}
Pero bueno, sigamos en lo que estábamos. Ya tenemos el índice de destino. Como dijimos
desde el inicio indiceBitOrigen
es 0
, e indiceBitDestino
será, en este caso, 3 * 4 + 0
que es 12
. Justo aquí está ocurriendo la rotación pues vamos a mover el bit
que representa un cuadro lleno del tetrimino desde x=0
,y=0
a x=0
,y=3
, y
dicho en términos lineales vamos a mover el bit 0
al bit 12
.
Ahora nos falta la siguiente línea:
rotado |= bit << (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBitDestino);
Que será hacer que rotado
sea igual a lo que hay en rotado
pero con una
operación OR con BIT << (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBitDestino)
.
Centrémonos en esta última operación. Dice que vamos a hacer:
bit << (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBitDestino);
Ya sabemos que bit
es 1
decimal y que indiceBitDestino
es 12
así que
se va despejando:
1 << (15 - 12);
Luego:
1 << (3);
Si el 1 en binario es 00000001
entonces:
Original: 00000001
Desplazado 3 a la izquierda: 00001000
Así que 1 << 3
es 8
en decimal.
El código original era:
rotado |= bit << (MAXIMO_INDICE_BIT_EN_UINT16 - indiceBitDestino);
Ahora será:
rotado |= 8
Como rotado es en este caso 0
la operación será la siguiente. Como haremos
un OR con distintos tipos de datos (int
con uint16_t
) debemos alinear los LSB de ambos para hacer las
operaciones.
rotado es: 0000 0000 0000 0000
El 8 es: 0000 1000
Or: 0000 0000 0000 1000
Y finalmente tenemos que rotado
hasta este punto será 8
.
Recapitulando: Teníamos la T que se ve así en binario: 1110 0100 0000 0000
, rotamos
el bit del índice 0 y después de calcular coordenadas terminó en el índice 12 de rotado
que se ve así: 0000 0000 0000 1000
Voy a copiar y pegar lo que dije previamente cuando simulamos la rotación sin código:
Tomemos su punto de la esquina superior izquierda que es y=0
,x=0
y apliquemos la fórmula:
x=y, y= 3 - x
Sustituimos:
x=0,y=3
Y se ve así:
0110
0100
0000
1000
Fíjate en que el resultado es exactamente el mismo. No tiene los unos
del inicio, porque al inicio rotado
es un cero, pero sí tiene
el bit en el índice 12 y así se van a ir colocando los demás.
Pegar tetrimino a esquina superior izquierda
Vemos que el código también tiene lo siguiente:
while ((rotado & 0xF000) == 0)
{
rotado <<= 4;
}
Tengo la explicación:
Recordemos que 0xf000
es
1111
0000
0000
0000
Y le hacemos un AND con la pieza rotada. Va a devolver
0
siempre que la pieza tenga únicamente ceros en los primeros 4 bits
Por ejemplo, tenemos la pieza
0100
0110
0010
0000
Hacemos un AND:
0100011000100000
&
1111000000000000
El resultado es 0100000000000000, mismo que es distinto a 0000000000000000, es correcto porque no tiene filas vacías al inicio.
Pero ahora veamos con la siguiente pieza:
0000
0110
0011
0000
Le hacemos un AND:
0000011000110000
&
1111000000000000
El resultado es 0000000000000000
Lo cual es exactamente a 0
Entonces cuando se cumple esta condición desplazamos 4 bits a la izquierda, lo que es como subir la pieza
Y luego tenemos:
while ((rotado & 0x8888) == 0)
{
rotado <<= 1;
}
Te explico de nuevo. 0x8888
sería:
1000
1000
1000
1000
Y hacemos lo mismo pero ahora es para pegarlo o arrimarlo a la izquierda, siempre y cuando toda la primera columna esté vacía.
Tetrimino fantasma
Yo llamo fantasma a la posición más cercana a la parte más baja de la cuadrícula donde el tetrimino puede caer.
Si quieres regresa al inicio y revisa la imagen de nuevo. El tetrimino fantasma está indicado en color azul sin relleno y te indica dónde va a caer.
Para saber dónde va a caer el tetrimino hay que intentar bajarlo y revisar si colisiona, en caso de que colisione entonces lo bajamos una coordenada y revisamos si colisiona de nuevo.
Se explica mejor con el siguiente código. Mira cómo es que tetriminoColisionaConCuadriculaAlAvanzar
sigue siendo relevante, por eso te dije que las colisiones son importantes y que esa función nos
iba a ser de ayuda en varios escenarios.
int8_t indiceYParaFantasma(struct Tetrimino *tetrimino, uint8_t cuadricula[ALTO_CUADRICULA][ANCHO_CUADRICULA])
{
for (uint8_t y = 0; y <= ALTO_CUADRICULA; y++)
{
if (tetriminoColisionaConCuadriculaAlAvanzar(tetrimino, cuadricula, 0, y - tetrimino->y))
{
return y - 1;
}
}
// TODO: tal vez no usar un uint8_t y devolver -1
return -1;
}
Hagamos un ejemplo. Empezamos en y=0, suponemos que tetrimino->y
es 0 y que la cuadrícula está vacía. Toma en cuenta que y será la coordenada simulada
que nos dirá qué tan lejos podemos ir.
Entonces preguntamos si el tetrimino colisiona. El modificador será y - tetrimino->y
que
se convierte en 0 - 0
, cuyo resultado es 0
. Como no colisiona entonces se sigue aumentando
hasta que la simulación llega hasta abajo.
Por cierto, hay un error que todavía no he arreglado y dice:
- A veces el fantasma se muestra encima de la verdadera pieza cuando uno la intenta meter en un hueco
- Siguiendo el punto de arriba, si bajaste tu pieza y la vas a meter a la izquierda o derecha y presionas Arriba (que baja la pieza al instante) se va a poner donde está el fantasma, es decir, arriba, cosa errónea
Imagino que se solucionaría comparando si el índice Y del fantasma es
menor que el piezaActual.y
, pero revisaré más adelante, solo quiero dejarlo documentado
Siguiente tetrimino y mezcla
Para terminar voy a dejar un poco más de información sobre cómo mezclar las piezas para que se sientan un poco más aleatorias y evitar que salgan repetidas.
También te enseñaré a mostrar el siguiente tetrimino.
Primero veamos la función que inicializa los tetriminos, es decir, define sus cuadrículas 4x4:
void inicializarPiezas(struct TetriminoParaElegir piezas[TOTAL_TETRIMINOS_DISPONIBLES])
{
/*
Veamos la Z es
1100
0110
0000
0000
Que sería C6
*/
piezas[0].cuadricula = 0xC600;
/*
La L es
1000
1000
1100
0000
Que sería 88C0
*/
piezas[1].cuadricula = 0x88C0;
/*
Ahora una línea
1111
0000
0000
0000
Solo sería F000
*/
piezas[2].cuadricula = 0xF000;
/*
Ahora el cuadro
1100
1100
0000
0000
Que sería CC00
*/
piezas[3].cuadricula = 0xCC00;
/*
Ahora la T
Esa es
1110
0100
0000
0000
Que sería E400
*/
piezas[4].cuadricula = 0xE400;
/*
La L invertida que sería
1110
0010
0000
0000
E200
*/
piezas[5].cuadricula = 0xe200;
/*
La Z invertida que sería
0110
1100
0000
0000
6C00
*/
piezas[6].cuadricula = 0x6c00;
}
Y luego tenemos la función que las aleatoriza. Básicamente lo que hace es mezclar el arreglo (que podemos ver como una caja de tetriminos), y siempre que queremos otro tetrimino se elige el siguiente de la caja.
Cuando se llega al final de la caja de tetriminos se vuelve a mezclar. Para llevar el registro de cuál pieza hemos sacado usamos una variable global.
He usado el algoritmo de Fisher-Yates que es muy fácil de implementar y sirve para barajar en los juegos de azar según wikipedia
Por cierto, esto asegura que sea poco probable que nos toquen dos tetriminos iguales seguidos. La posibilidad todavía existe, pero es menor.
void aleatorizarPiezas(struct TetriminoParaElegir piezas[TOTAL_TETRIMINOS_DISPONIBLES])
{
/*
Cantidad = Tamaño(Array)
Recorrer con k desde Cantidad-1 hasta 1 Regresivamente
az = Random(entre 0 y k)
tmp = Array(az)
Array(az) = Array(k)
Array(k) = tmp
Siguiente
*/
for (int k = TOTAL_TETRIMINOS_DISPONIBLES - 1; k > 0; k--)
{
uint8_t az = rand() % (k + 1);
struct TetriminoParaElegir tmp = piezas[az];
piezas[az] = piezas[k];
piezas[k] = tmp;
}
}
Esta función se invoca en el main
después
de inicializar las piezas:
inicializarPiezas(piezas);
aleatorizarPiezas(piezas);
Y cada vez que se elige la pieza aleatoria tenemos una bandera que indica el índice o número de tetrimino en el que vamos:
void elegirPiezaAleatoria(struct Tetrimino *destino, struct TetriminoParaElegir piezasDisponibles[TOTAL_TETRIMINOS_DISPONIBLES])
{
destino->cuadricula = piezasDisponibles[indiceGlobalTetrimino].cuadricula;
destino->x = MITAD_CUADRICULA_X;
destino->y = 0;
indiceGlobalTetrimino++;
if (indiceGlobalTetrimino > TOTAL_TETRIMINOS_DISPONIBLES - 1)
{
aleatorizarPiezas(piezasDisponibles);
indiceGlobalTetrimino = 0;
}
}
Fíjate que si el índice ya llega al final lo reiniciamos, pero cuando lo reiniciamos volvemos a aleatorizar las piezas.
Para saber cuál es el siguiente tetrimino sacamos dos tetriminos de una vez al inicio.
struct Tetrimino piezaActual;
struct Tetrimino piezaSiguiente;
struct Tetrimino reserva = {0};
struct TetriminoParaElegir piezas[TOTAL_TETRIMINOS_DISPONIBLES];
bool haUsadoLaReserva = false;
inicializarPiezas(piezas);
aleatorizarPiezas(piezas);
elegirPiezaAleatoria(&piezaActual, piezas);
elegirPiezaAleatoria(&piezaSiguiente, piezas);
Así el tetrimino estará en piezaActual
y el siguiente en piezaSiguiente
. Cuando
el tetrimino actual toca el suelo entonces intercambiamos y elegimos la
siguiente pieza:
void elegirSiguientePieza(struct Tetrimino *actual, struct Tetrimino *siguiente, struct TetriminoParaElegir piezas[TOTAL_TETRIMINOS_DISPONIBLES])
{
actual->cuadricula = siguiente->cuadricula;
actual->x = siguiente->x = MITAD_CUADRICULA_X;
actual->y = siguiente->y = 0;
elegirPiezaAleatoria(siguiente, piezas);
}
Tetrimino de reserva
En algunos juegos de Tetris que he jugado se permite tener una reserva, es decir, mover el tetrimino actual a una reserva por si no te conviene.
Yo lo he implementado en la variable reserva
que se ve en el código de arriba. Cuando
el usuario presiona la tecla que activa la reserva hago lo siguiente:
if (reserva.cuadricula == 0)
{
reserva.cuadricula = piezaActual.cuadricula;
reserva.x = MITAD_CUADRICULA_X;
reserva.y = 0;
elegirSiguientePieza(&piezaActual, &piezaSiguiente, piezas);
}
else
{
if (!haUsadoLaReserva)
{
/*
Aquí no recuerdo si está bien reiniciar la x e y o dejarla como
está. Si la dejáramos como está sería más complejo para el jugador,
así que yo pongo la pieza en y=0, o sea, hasta arriba, así le da un respiro al jugador
*/
struct Tetrimino temporal = {0};
temporal.cuadricula = piezaActual.cuadricula;
piezaActual.cuadricula = reserva.cuadricula;
reserva.cuadricula = temporal.cuadricula;
piezaActual.x = MITAD_CUADRICULA_X;
piezaActual.y = 0;
haUsadoLaReserva = true;
}
}
Los if
son necesarios porque al inicio de todo la reserva estará vacía, así que hay que ver
si su cuadrícula está vacía (es un uint16_t
que estará en 0
). También
hay que revisar si ya ha usado la reserva en ese ciclo de juego, ya que solo se puede usar
una vez hasta que el tetrimino actual se copie a la cuadrícula.
Juego terminado
Aquí solamente establecemos una bandera que indica que ya hemos perdido. Yo he
detectado que el jugador pierde cuando ya no puede bajar la pieza, y está
dentro de la función bajarTetrimino
.
Reiniciamos el juego limpiando la cuadrícula, reiniciando el puntaje y eligiendo el siguiente tetrimino.
puntajeGlobal = 0;
memset(otraCuadricula, 0, ANCHO_CUADRICULA * ALTO_CUADRICULA);
juegoTerminado = false;
elegirSiguientePieza(&piezaActual, &piezaSiguiente, piezas);
Ahora que estoy viendo este código me pregunto si no sería buena idea volver a barajar los tetriminos al reiniciar el juego.
Conclusión
Me gusta jugar el juego de Tetris, y programarlo me gusta todavía más. Todo esto que explico es para cualquiera que quiera programar el juego de Tetris en su propia plataforma y para mí mismo, pues puede que lo olvide en un futuro.
Lo he escrito en C porque siento que una vez implementado en C se puede programar en cualquier otra plataforma con el mismo algoritmo.
Eso sí: no existen apuntadores en la mayoría de otros lenguajes de programación, pero el algoritmo sigue siendo el mismo.