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