martes, 20 de noviembre de 2012

Ejemplo #5: TETRIS en C++.

En el ejemplo siguiente voy a desarrollar un juego de Tetris utilizando C++.

Tetris fue diseñado por Alexey Pajitnov en 1984, en una computadora Electronika 60 cuando trabajaba en la Academia Soviética de Ciencias.

Durante el juego las piezas caen desde la parte superior de la pantalla, y se apilan a medida que se depositan sobre la parte inferior, o sobre las piezas anteriores que se van acumulando. Los tipos de piezas son 7 en total, todos con diferentes formas (algunos son el inverso de otros). El objetivo del juego es lograr colocar las piezas de forma tal que no queden espacios entre una y otra. Para esto, el jugador puede rotarlas antes de que lleguen a apilarse. Cada vez que una linea horizontal ha sido completada la misma desaparece y las piezas que estaban acumuladas sobre esta linea caen una linea hacia abajo permitiendo así que el juego continúe. Finalmente el juego concluye cuando las piezas apiladas han alcanzado la parte superior de la pantalla.



Consideraciones generales:

En principio este juego comprende varios desafíos. Vamos a enumerarlos para poder desarrollar más fácilmente su resolución. Necesitamos resolver:

-El formato de diseño de las piezas y su rotación.

-La forma en que se van a almacenar las piezas apiladas.

-El control de colisión para que las piezas puedan detener el movimiento y apilarse.

Sabemos que será necesario implementar un loop principal que mantendrá a las piezas siempre cayendo, cambiando de pieza y comenzando nuevamente cada vez que una se apila.

Ahora vamos a ir viendo como se resuelven estos tres problemas principales en cada una de las tres clases que utilizaremos para este proyecto.

Al igual que en los ejemplos anteriores, al final de este artículo se encuentra el link del proyecto, listo para descargar y compilar.

Sobre el enfoque del código, objetos y clases

El lenguaje de programación que vamos a utilizar es C++. Este es un lenguaje orientado a objetos así que vamos a hacer uso de las características propias del lenguaje.

El compilador utilizado fue GNU GCC con el IDE CodeBlocks.

Para mantener la simplicidad no utilizaré en este ejemplo ningún tipo de librerías gráficas, pero sí una técnica muy interesante de buffering doble en consola mediante el API de Windows.

Vamos a dividir el proyecto en 3 clases: la clase Piezas, la clase Tablero, y la clase Tetris que será la clase principal del juego.


La clase 'Piezas':

Vamos a tratar aquí el primer problema: el diseño y rotación de piezas.

Para saber como diseñarlas tenemos que saber primero como es que van a rotar. Esto podemos conseguirlo de dos maneras. Podemos crear una pieza de cada tipo y luego utilizar una formula para leerla en otros sentidos durante la ejecución del programa, o también podemos almacenar cada pieza con sus 4 rotaciones ya predefinidas. La forma que vamos a utilizar es esta ultima.

Habiendo definido lo anterior llegamos a la conclusión de que vamos a necesitar un array de, por lo menos, 2 dimensiones: una para la cantidad de piezas (7), y otra para cada rotación de cada pieza (4). Ahora bien, la pieza mas pequeña (el cuadrado) requiere una matriz de 2x2 para ser almacenada, y la pieza mas extensa (el palito) requiere una matriz de 4x4. Para poder utilizar un mismo array para todas las piezas vamos a tener que tomar como parámetro el tamaño de la pieza mas grande. Sin embargo, para poder realizar una correcta rotación de cada una de las piezas debemos respetar un eje de rotación central, lo cual no nos permitiría emplear una matriz de 4x4, de manera tal que tendremos que utilizar una de 5x5. Así es como finalmente el array que necesitamos para poder almacenar todas las piezas es un array de 4 dimensiones: [7][4][5][5].

Ahora necesitamos inicializar el array en la clase Piezas. Según los principios del paradigma orientado a objetos y las normas ISO C++ un array no puede (y no debería poder) ser inicializado dentro de una clase. En el plano teórico esto es correcto, desde el punto de vista práctico nos resultaría muy conveniente poder definir las piezas dentro de la clase Piezas. Ya que no podemos utilizar la notación de lista para la inicialización del array (sólo se puede usar en la declaración del mismo, que ya fue declarado en el header) y es muy impráctico cargar los valores uno por uno, vamos a realizar un pequeño hack Utilizaremos un array de caracteres y copiaremos los valores al array utilizando la función strcpy() de strings.h.

Hemos resuelto de esta manera uno de los principales problemas del proyecto utilizando un array de tipo char.

Como estos datos son de acceso privado tendremos que hacer también una función get para poder acceder a los mismos desde afuera de la clase.

A continuación vemos el código completo de la clase 'Piezas' y el header correspondiente:





La clase 'Tablero':

Para representar el tablero vamos a utilizar una matriz de dos dimensiones. Nuestro tablero de juego va a tener 20x20 y necesitamos agregar 4 bordes que marquen los límites con lo cual vamos a necesitar una matriz de 22x22.

La matriz, de tipo int, será inicializada con el valor 0 en todos los indices a excepción de los bordes que serán inicializados en 1. Esto queda expuesto en el método inicializo().

También vamos a tener que utilizar métodos get y set para poder leer y escribir los campos privados desde afuera de la clase.

El funcionamiento del tablero es simple. Las piezas iran avanzando por la pantalla, al momento de detenerse y apilarse (determinado por la detección de colisión) tomaremos los valores de posición que utilizamos para imprimir la pieza en pantalla y los utilizaremos para determinar su posición en la matriz. Una vez determinada la misma le asignaremos el valor 1 a cada indice de la matriz que coincida con un bloque sólido de la pieza. Podemos observar este mecanismo en el método imprimoEnMatriz(Piezas, int, int, int, int) que recibe como parámetros un objeto Piezas que contiene todas las piezas, el nro de pieza, el nro de rotación, la posición de la pieza sobre el eje X y la posición sobre el eje Y.

Una vez almacenados en la matriz no será un problema mostrar en pantalla el tablero con los espacios ocupados imprimiendo bloques en los lugares donde el valor sea 1 y espacios en blanco donde el valor sea 0.

  Tambiénqueda listo el tablero para poder realizar la detección de colisión de las piezas nuevas con las piezas apiladas anteriormente. Esto lo veremos en la clase 'Tetris'.

Incluiremos en esta clase dos funciones mas que servirán para comprobar si hay lineas completas y para despejar las lineas completas en caso de que las haya.

La primera recorre la matriz de abajo hacia arriba buscando las lineas donde todos los elementos tengan asignado el valor 1, si encuentra un 0 pasa a la próxima linea. Si encuentra una linea completa guarda el numero de linea. El máximo de lineas completas que se pueden realizar a la ves es 4 de manera que buscamos hasta 4 lineas completas y las almacenamos en un vector de 4 elementos. En caso de que se encuentren menos lineas completas el resto de los elementos tendrán asignado el valor 0.

La segunda función es la que despeja las lineas. Para eso se posiciona en la linea detectada en LineasCompletas[] y a partir de ahí recorre la matriz hacia arriba copiando cada linea un lugar hacia abajo.

Vemos el código completo de la clase 'Tablero' y el header:






La clase 'Tetris':

Esta es nuestra clase principal. Desde aquí vamos a instanciar a las otras clases y también es donde encontramos el loop de juego.

Un poco mas adelante en este artículo pasaremos a explicar todas las funciones pero en esta visión general quisiera apuntar al núcleo que hace funcionar el juego y que se encuentra en el corazón de esta clase.

Sabemos que para mostrar en pantalla una pieza que esta almacenada en una matriz multidimensional de 5x5 necesitamos anidar dos bucles for de 5 vueltas cada uno, de esta manera recorremos punto por punto la pieza e imprimimos un bloque donde encontremos guardado un valor 1 y un espacio en blanco donde encontremos un valor 0. Esto mostrará la pieza en pantalla.

Si además queremos que esta pieza se vaya moviendo en dirección hacia abajo tenemos que agregar un tercer bucle for que contenga los dos anteriores. Este le pasará su valor a la función que posiciona el cursor para mostrar la pieza que lo utilizará sumando su valor sobre el eje Y. A medida que el valor se vaya incrementando la pieza irá avanzando hacia abajo por la pantalla.

Nos quedan así tres for anidados: el primero desde adentro recorre las columnas de la pieza, el segundo las filas, y el tercero actualiza su posición sobre el eje Y.

Dentro del primer for que es el que itera con mas frecuencia y el que imprime los bloques y espacios es donde vamos a realizar varias operaciones.

La primera de ellas será llamar a una función que comprueba si hay colisión próxima. Para esto le pasamos a la función la posición del elemento que estamos imprimiendo en pantalla. Dentro de la función lo comparamos con las posiciones próximas hacia abajo y hacia los lados en la matriz de acuerdo a donde esta la pieza en este momento anticipándose a un posible próximo movimiento en cualquier dirección. En tal caso si la colisión es detectada hacia los laterales no nos permitirá seguir moviendo la pieza en esa dirección, y si se detecta colisión hacia abajo detendrá completamente el movimiento de la pieza para guardarla en la matriz y que pase a ser parte del tablero.

Las otras operaciones que realizamos son para comprobar los valores de la matriz del tablero mientras vamos imprimiendo la pieza en pantalla. Si, por ejemplo, en el elemento 0-0 de la pieza actual hay un espacio en blanco, pero en el tablero ese espacio esta ocupado por una pared u otra pieza entonces ponemos en pantalla el bloque correspondiente en vez de un espacio en blanco.

Queda expuesto de esta manera que es lo que necesitamos para resolver el problema de la detección de colisiones y el movimiento de las piezas.

Faltaría poder comprobar si es posible realizar una rotación o no. Esta comprobación sera realizada cada vez que el jugador presione la tecla de rotar y antes de hacer el movimiento. Vamos a resolverlo mediante una función que anticipe la posición de la pieza rotada y detecte si tiene o no espacio libre en esa posición.

Sobre el funcionamiento del buffering doble en la consola:

Vamos a utilizar una técnica muy sencilla de double buffering para poder redibujar la pantalla sin interrupciones.

Primero vamos a inicializar el buffer 1 y el buffer 2 y crear los campos necesarios para su utilización. Esto lo encontramos luego de la declaración de funciones:



Le asigno el buffer actual a buffer1, luego creo un nuevo buffer y se lo asigno a buffer2.

Será necesario crear un array que servirá para copiar temporalmente el contenido que queremos copiar de un buffer al otro. Este sera de tipo CHAR_INFO, lo cual nos permitirá tambien guardar la información de color, y tendra el tamaño del numero total de caracteres de la pantalla.

Voy a necesitar también dos campos de tipo COORD, que almacenan cada uno dos puntos (x e y) para guardar la posición de destino del buffer a copiar y sus dimensiones.

Finalmente creamos dos campos de tipo SMALL_RECT, que almacenan cada uno cuatro puntos para guardar las dimensiones de la pantalla. Utilizaremos uno para lectura y otro para escritura.

Al comienzo del método main nos vemos a encontrar con la siguiente linea:



De esta manera establecemos que el buffer que vamos a estar viendo en pantalla va a ser el segundo, o sea el que fue creado por nosotros.

A partir de aquí lo que va a ocurrir es que el programa va a estar redibujando la pantalla siempre en buffer1, que permanece oculto, y cada vez que termine de dibujar va a copiar el contenido de buffer1 en buffer2, que es el que sale por pantalla. Esto lo vamos a realizar mediante la función actualizarScreenBuffer() que vemos a continuación:



Ahora que han sido resueltos los problemas principales que plantea el desarrollo de este ejemplo presento el código completo de la clase 'Tetris':



En el header FUNC.H podremos encontrar algunas de las funciones que hemos utilizado también en otros ejemplos:



De esta manera queda presentado el proyecto completo de un juego de Tetris en C++. 

Hemos visto como resolver los principales problemas que encontramos en este tipo de juegos. También vimos características propias del lenguaje C++ y de la programación orientada a objetos, y utilizamos una técnica sencilla de double buffering en la consola mediante el API de Windows.


Links de descarga del proyecto:

https://dl.dropbox.com/u/103165598/TetrisFinal.rar



No hay comentarios:

Publicar un comentario