|
|
Artículo realizado por
En este artículo vamos a investigar, mediante planteamientos, algunas formas posibles de abordar la creación de un Tetris. Como todos sabréis ya, el Tetris sentó cátedra en lo que a juegos de habilidad / inteligencia se refiere. Lanzado, aproximadamente, en 1987 pronto se convertiría en un éxito que, a día de hoy, sigue siendo jugado por miles de usuarios de todo el mundo. Junto a su publicación para ordenadores de 8 bits, el Tetris, también tuvo versión en máquinas recreativas. En fin, es difícil encontrar algún formato en el que el Tetris no haya triunfado o persona que no lo haya probado. Antes de proceder a realizar el artículo, comentar que una parte muy importante de las capturas están sacadas del juego "Petris". Este programa, ha sido creado por Macedonia y regalado en el número 8. Es un Tetris que funciona bajo Windows 9x. y NT de carácter muy básico. Puede servir de ayuda para analizar el funcionamiento de este tipo de juegos.
Fernando Rodríguez.
Así funciona un Tetris
La complejidad que entraña crear un Tetris no es muy compleja, sin embargo, tiene algunos detalles que nos podrán dar algún que otro problema. Antes de comenzar a realizar un juego debemos de proceder a analizar todas las dificultades que nos podemos encontrar a la hora de codificar. La mecánica del Tetris es bien sencilla. El jugador ha de colocar las piezas que le van apareciendo por la parte superior de la pantalla dentro de una tabla de m filas y n columnas. Se ha de intentar que las piezas que van cayendo se sitúen de tal forma que formen una línea con las que ya se encuentren en la tabla (con los fragmentos de otras piezas). En caso de que lo logremos, las porciones de las piezas que intervienen en la formación de la misma línea desaparecerán haciendo que todas las que se encuentren de esa línea, para arriba, desciendan una fila hacia abajo (y, por lo tanto, dejen más filas para que el jugador pueda manipular las piezas futuras). En el caso de que el jugador no logre hacer una línea la pieza quedará ubicada íntegramente, con lo que limitará el espacio de juego, haciendo que aumente la dificultad para colocar la pieza que venga a continuación.
Otro de los aspectos que no podemos pasar por alto, es el referido a la forma y orientación de las piezas que manejamos en el juego. Si nos fijamos, hay un total de 5 piezas básicas que van cambiando de color de forma aleatoria. Aquí están todas las piezas del Tetris básico:
Todas giran hacia la izquierda cada vez que pulsamos la tecla de giro, es decir, no hay piezas que giren hacia la izquierda y piezas que lo hagan a la derecha. La pregunta que nos deberemos de hacer entonces es cómo poder representar de forma eficiente las piezas y los giros. Teniendo siempre presente las colisiones que se puedan producir (contra otras piezas o contra los límites del área del juego, esto es, la tabla en donde vamos alojando las piezas).
De la observación del juego podemos sacar varias conclusiones. Para empezar que vamos a necesitar almacenar en algún sitio las piezas que vayamos colocando en la tabla (representación del área de juego). Por otro lado, deberemos de implementar un método para almacenar las figuras y conocer su forma (representación de las figuras). También habrá que hablar de las colisiones que se puedan producir en nuestro juego. Estas colisiones podrán deberse a que hemos chocado contra algún límite del área de juego (colisiones entre los límites del área de juego) o que hemos colisionado con alguna otra pieza (colisiones entre piezas). Una vez que sepamos cómo detectar una colisión (contra límite y contra pieza), será el momento de pensar cómo "adivinar" que hemos hecho una línea (Cómo averiguar si hemos hecho línea). Por último, habrá que implementar un temporizador o similar que nos ayude a actualizar de forma más o menos rápida (según la dificultad) el estado de las piezas que vayan cayendo (evolución del juego). Todo esto será lo que veremos a continuación.
La parte más importante de todas es la referida a la representación del área del juego (supongo que todo el mundo que está leyendo este artículo ha jugado alguna vez al Tetris). Como todos recordaréis, en el Tetris vamos alojando las piezas en una tabla según éstas van cayendo de la parte superior de la pantalla a la base de la misma, con una determinada velocidad. La tabla será rectangular con mayor altura que anchura. Dicho esto, la forma más fácil de implementar la tabla sería con un array de mxn. A esta solución estática también hay que añadir la solución dinámica, es decir, en el caso de que queráis hacer un código actualizable y robusto, os recomendaría que os creaseis una clase que implementara arrays mediante listas de listas, es decir, de forma dinámica. Esto permitiría incluso definir el área de juego en tiempo de ejecución. Pero si lo que nos interesa es hacer un programa cerrado sin ningún otro objetivo que ejercitarnos, un array vale más que de sobra y ahorrará algo de tiempo de trabajo, sobre todo de cara a la depuración.
Así pues, un área como el que sale en la figura perfectamente puede implementarse con una matriz (array bidimensional o una lista de listas) de m filas y n columnas. Ahora la siguiente pregunta es ¿qué guardo en cada posición de la matriz?. Para responder a esta pregunta, convendría estudiar también la forma en la que vamos a implementar las piezas, pues el área del juego, esto es, la matriz, no hace otra cosa que guardar las piezas que el jugador ha ido utilizando, y utiliza, durante la partida. La aproximación más básica nos dice que hay zonas del área de juego que están ocupadas y otras que no lo están. Para poder representar esto, bastaría con tener un valor 0 en las posiciones de la matriz libres y un 1 en las ocupadas. Sin embargo, como vamos a utilizar piezas de distintos colores (o, incluso, de distintas propiedades), convendría generalizar el valor 1 a valores distintos de 0, de tal forma que valores como el 2, el 3, el 4 o 14, también indicaran que esa zona está ocupada. Así, si a cada código distinto de 0 le asignamos un color, podríamos identificar cada zona de la matriz ocupada por una porción de pieza de un determinado color. Por ejemplo, veamos la figura siguiente:
Como podemos ver, un código 0 en el área de juego, esto es, en la matriz o array, representa casilla libre (que no hay pieza o, en otras palabras, que puede ocuparse) mientras que todos aquellos valores distintos de 0 representan una porción de pieza de un determinado color.
Esta forma de representar el área de juego, ya nos puede permitir pensar en la forma de almacenamiento que llevarán nuestras piezas o figuras.
Hasta ahora hemos quedado en que una matriz (implementada a gusto de cada cual) va a ser la estructura de datos que represente, internamente, el área de juego. También hemos fijado que la matriz va a contener distintos códigos que se pueden agrupar en dos conjuntos:
El siguiente paso es pensar en la forma de almacenar y representar las figuras. Conviene, sin embargo, tener presente qué tipo de figuras existen en un Tetris básico así como los giros o diferentes estados que pueden tener. En la figura [A] vemos un área de juego de un Tetris con rejilla (para constatar un par de puntos que nos van a ayudar a razonar el modo de representación de piezas).
Observando la figura [A], uno se da cuenta de los siguientes detalles:
El que todas las piezas estén formadas por cuadrados, nos permite ajustar de forma muy elegante los sprites que vamos a utilizar. Si todas las piezas están formadas por cuadrados, bastará con tener un cuadrado, es decir, una porción de pieza, por cada color posible de pieza. En otras palabras, si en nuestro Tetris las piezas pueden tener color rojo, azul, verde y amarillo, bastará con tener un cuadrado de color rojo, azul, verde y amarillo ya que la unión de varios de ellos (de cuatro), nos formará una pieza del juego. Así de simple y claro. ¿Esto que nos evita?, pues nos evita tener que almacenar un gráfico por cada pieza en cada una de sus posiciones posibles o estados. Pensemos que cada pieza tiene 4 estados de giro, si tenemos un total de 5 piezas distintas tendríamos que dibujar 4*5=20 estados diferentes (20 sprites diferentes). Mediante este método construimos las piezas durante el transcurso de juego, ahorrando espacio en disco y memoria para gráficos (además los cálculos serán mínimos). Y, sobre todo, dando una mayor flexibilidad al programa de cara a modificaciones o inserción de nuevas piezas.
Sin embargo, y tal como podíamos ver cuando se mostraron todas las figuras que intervienen en un Tetris básico (primera figura del artículo), es necesario implementar una serie de posiciones diferentes para cada pieza. Aún más, es necesario, para este sistema de almacenamiento, implementar algún modo de saber cómo se han de disponer los cuadrados para formas las piezas. En otras palabras, ¿cómo sabemos cuál es la pieza que hay que poner en pantalla cuando se realiza un giro?. Para resolver este problema, deberemos de almacenar tantas plantillas como posiciones de giro haya por pieza. Así, si cualquier pieza tiene 4 posibles posiciones de giro, deberemos de almacenar 4 plantillas. ¿Cómo podemos construir esas plantillas?. Bien, bastará con disponer de un array de pxq, donde p = anchura de la pieza y q = altura de la pieza, que contenga un "1" en donde haya que poner cuadrado de la figura y un "0" donde no haga falta. Obviamente, habrá que disponer un array de estas dimensiones por plantilla.
Una buena forma de implementar esta idea, es construir una lista dinámica circular (después del último nodo va el primero), en la que cada nodo contenga la plantilla del frame actual. Cada vez que la pieza pega un giro, pasamos al siguiente nodo de la lista y construimos la pieza en base a la plantilla que nos encontremos. He aquí un ejemplo de cómo podría verse esto gráficamente:
Si nos construimos una clase que contenga todas las propiedades y métodos necesarios para una pieza (mantenimiento de la lista, atributos de la pieza, plantillas, etc), crear una nueva pieza para el juego sería tan sencillo como crear una nueva instancia a esa clase. Es más, si lo implementamos de tal forma que las plantillas se carguen desde disco, la flexibilidad estaría garantizada de por vida (según se carga el juego, se abre el fichero que contiene la plantilla para cada pieza. Se crean tantos objetos distintos como piezas diferentes existan en el juego y se cargan sus plantillas en sus respectivas listas de frames. Estos objetos, simplemente servirán de modelo pues, durante el juego, deberemos de disponer de uno temporal que será el que vaya modificando los atributos de la pieza que vamos manejando. Así, cuando aparezca la pieza "barra" por la parte superior, bastará con igualar el objeto temporal al objeto que contiene la plantilla para la pieza "barra" y comenzar a manipularlo. Cuando una pieza acaba su periplo por la pantalla, se reutilizará el objeto temporal igualándose al objeto que sea pertinente. Esto es mejor que disponer de un solo objeto ya que por este método, deberíamos de acceder a disco para cargar su plantilla de forma continuada).
Hasta ahora, ya hemos analizado una posible forma de almacenar y mantener las piezas. Está claro que ahora llega el turno de meternos más a fondo con los que es el "runtime" del juego, esto es, el bucle de ejecución.
Durante el transcurso de la partida al Tetris, disponemos de un tiempo límite para realizar todas las acciones pertinentes sobre la pieza que estamos manipulando. Dependiendo del nivel en el que nos encontremos, el tiempo será mayor o menor y, por lo tanto, variará la dificultad del mismo. Al ir pasando los segundos, la pieza irá cayendo una posición tras otra hacia la parte final de la tabla. Esto continuará hasta que la pieza toque el fondo del área de juego o bien, hasta que colisione con otra y le sea imposible seguir bajando.
Podemos imaginarnos que en una partida al Tetris, el tiempo se encuentra "ranurado". Es decir, hay celdas de tiempo y, en cada una de ellas, se realizan una serie de acciones. Pasado el tiempo que dura una celda, se baja una posición la pieza y volvemos a realizar las acciones que se pueden llevar dentro de esa celda de tiempo. Cada pieza dispondrá de su propio espacio ranurado de tiempo hasta que le es imposible descender más por el área de juego. En ese momento, la pieza actual deja de existir y aparece otra con su nuevo espacio ranurado de tiempo.
Está claro, que hay que implementar algún tipo de temporizador para el juego. Un temporizador permite al programador establecer señales de alarma que, pasado un intervalo de tiempo (milisegundos o segundos), se activan ejecutando la función que tengan encomendada. En nuestro caso, no sería otra que disminuir en una posición la componente "y" de la pieza y redibujarla en pantalla (para saber cómo hacer podéis consultar este estupendo artículo sobre Sprites). Una vez hecho esto, se volverán a ejecutar las operaciones comunes a una celda de tiempo hasta que, de nuevo, vuelva a vencer el temporizador.
Si trabajáramos bajo MS-DOS, no quedaría más remedio que acudir a la rutina del vector de interrupciones (1Ch) que se ejecuta tras la llamada a la interrupción que mantiene el reloj del sistema (8Ch). Desde ahí, deberíamos de medir cuántos segundos (o milisegundos) han pasado desde que comenzó la ranura de tiempo. Dependiendo de la dificultad del juego, podremos bajar la pieza cada 1 segundo, 2, 3... Si la programación se efectúa desde Windows, hará falta acudir al bucle despachador de mensajes y optimizarlo al máximo para que trabaje para nuestra aplicación aún cuando no se esté emitiendo ningún tipo de mensaje a nuestra ventana. Una buena forma de controlar los intervalos de tiempo es con la función
GetTickCount que nos devuelve el número de milisegundos que lleva ejecutándose la sesión de Windows actual. Si guardamos en variables estáticas un valor, por ejemplo, Xo = GetTickCount () y luego vamos haciendo if (Xo + [Tiempo que dura una ranura] == GetTickCount () ). Podremos temporizar el juego. El uso de los temporizadores es muy interesante de cara al desarrollo de videojuegos, y bien merecerían un artículo a parte.Uno de los aspectos más importantes del análisis, es el referido a las colisiones que puedan sufrir nuestras piezas. Debemos de tener en cuenta que las colisiones deben de separarse en dos grupos:
Las que se producen cuando chocamos con los límites del área del juego.
Las que se producen cuando chocamos con fragmentos de otras piezas del juego.
Vamos a proceder a su análisis a continuación.
Colisiones entre los límites del área de juego
Las piezas pueden colisionar contra los límites del área de juego, siempre que intentemos moverlas más allá de los límites izquierdo, derecho y base de nuestra "matriz" de juego. Es decir, si el jugador comienza a pulsar de forma continuada a la tecla derecha, llegará un momento en el que la pieza chocará contra la parte lateral derecha. Esto mismo sucederá en la parte lateral izquierda si el jugador comienza a pulsar continuamente la tecla de movimiento a la izquierda. ¿Y en la base?. Pues esta colisión ocurrirá cuando el usuario haga que su pieza colisione con el fondo de la matriz. Esto producirá que el tiempo de vida de la pieza que estamos manejando termine y que salga otra por la parte superior. He aquí un dibujo explicativo de cada tipo de colisión.
Pero no sólo debemos de pensar que una colisión se produce cuando se intenta mover en sentido horizontal (izquierda - derecha) o vertical (hacia abajo); también puede darse el caso de que la colisión se produzca cuando vayamos a realizar un giro de una pieza. Sí, puede ocurrir que estemos en un lateral de la pantalla y que por la naturaleza de la pieza que estamos utilizando (por ejemplo, suponed que estamos usando una barra en horizontal), el realizar un giro supone que nuestra pieza va a ocupar hacia el lateral por el que no podemos movernos más, una colisión. En este caso, deberemos de detectar también la colisión y no permitir que nuestra pieza realice el giro. En la figura siguiente, se ve cómo la barra en horizontal no puede realizar un giro estando en el tope del lateral derecho. Más aún, puesto que tiene una longitud de 4 casillas, no podrá realizar un giro estando en posición vertical, a menos que esté a 3 casillas de distancia del lateral. En caso contrario, la barra en horizontal se saldría del área de juego.
Según estos datos, deberemos de contener en todo momento las posiciones (x,y) de inicio de nuestras piezas para que, siempre que el usuario vaya a realizar un movimiento, calculemos por adelantado las nuevas posiciones de nuestras piezas, entrando en juego las alturas y anchuras propias (que variarán dependiendo del frame en el que nos encontremos. Por ejemplo, la barra en horizontal tendrá una anchura de 4 casillas y una altura de 1 casilla y, la barra en vertical tendrá una altura de 4 casillas y una anchura de 1 casilla. Todo esto debería de figurar ligado a cada una de las plantillas de la figura).
Una sencilla forma de ver el proceso de cálculo de colisión podría ser:
Nota:
Sea
(x, y) la posición de nuestra figura.Colisión con límite derecho: Si el usuario pulsa la tecla de movimiento hacia la derecha, deberemos de hacer la comprobación:
if ( (x+1)+ancho <= limiteDerecho ) { permitimos movimiento }
else { no permitimos movimiento }
Colisión con límite izquierdo: Si el usuario pulsa la tecla de movimiento hacia la izquierda, deberemos de hacer la comprobación:
if ( (x-1) >= limiteIzquierdo ) { permitimos movimiento }
else { no permitimos movimiento }
Colisión con límite de base: Si el usuario pulsa la tecla de movimiento hacia la abajo o la pieza toca la base al llegar por si sóla.
if ( (y+1)+ alto <= limiteDerecho ) { permitimos movimiento }
else { no permitimos movimiento }
Como se puede observar, el proceso es bastante simple en lo básico. ¿Cómo haríamos para detectar una colisión al realizar un giro?. Bien sencillo. No habrá que escribir un código especial, bastará con obtener la
anchura y la altura del "frame" o plantilla que viene a continuación de la pieza actual, y aplicarle exactamente las mismas comprobaciones que antes. Lo único que hacemos, es sustituir "virtualmente" la pieza actual por la siguiente y comprobar si un movimiento de ésta última produciría algún tipo de colisión. Colisiones entre piezasLas colisiones entre piezas añaden algo más de dificultad a la hora de hacer giros, ya que la pieza actual puede chocar con los fragmentos que hayan en su camino de giro y por ello no poder realizar el cambio de posición. Por lo demás, también deberemos de realizar una comprobación de colisión cuando nuestra pieza se mueva hacia la izquierda, derecha o hacia abajo. Debemos de tener en cuenta que se nos puede presentar una situación como la siguiente:
En este ejemplo, vemos cómo una pieza que intenta moverse hacia la derecha no puede por el sencillo motivo de que hay fragmentos de otras piezas que le impiden el movimiento. Poder controlar esto es muy fácil, pues tan sólo deberemos de comprobar, internamente, si al mover una posición nuestra figura (a la izquierda, derecha o hacia abajo), ocupa alguna zona que ya estaba previamente ocupada. En este caso, no podremos permitir el movimiento. Si este tipo de colisión se da, por ejemplo, cuando el temporizador asignado a la figura que estamos manejando vence y hace que ésta tenga que bajar una posición, significará que no podrá bajar y que, por lo tanto, se ha acabado su tiempo de vida en el juego y tiene que salir otra por la parte superior.
El otro problema que podía darse al manejar una pieza, era el referido al de girarla. Si nuestra figura va a ser girada en un determinado sentido y encuentra fragmentos de otras figuras, ésta no podrá girar. ¿Cómo podemos solucionar este problema?. Bien, lo primero que deberemos de hacer es tomar las propiedades del frame siguiente al actual, más concretamente la anchura y la altura. Una vez hecho esto, deberemos de discutir dichos valores:
Si la anchura y la altura son iguales. En este caso, nos encontramos con que la figura realmente ocupa la misma zona al realizar un giro. Esto ocurrirá si nuestra figura es un cuadrado y, por tanto, no haremos nada.
Si la altura es mayor que la anchura. Lo que deberemos de hacer es recorrer la porción del array del área de juego que va desde la posición (x,y) hasta la posición (x+altura-1,y+altura-1). Esto es así, porque esa es la porción que se supone "barrería" nuestra pieza al girar. Así pues, bastará saber si en esa área hay alguna posición del área de juego distinta a 0 (es decir, que contiene algún fragmento de pieza) ya que, en ese caso, no podríamos realizar el giro.
Si la altura es menor que la anchura. Es idéntico que en el caso anterior, salvo que en lugar de sumar altura, sumaríamos anchura. Recorreríamos la porción de área de juego que iría de (x,y) a (x+anchura-1,y+anchura-1).
Una vez estudiadas las colisiones, convendría hacerse la pregunta ¿y cuál de las dos compruebo antes?. Es obvio que, en cada movimiento de pieza, hay que realizar una comparación de colisión en el escenario. Lo ideal sería tratar ambas colisiones por separado. Si al encontrarnos una colisión en los límites del área de juego, ya no realizaríamos comprobación de colisión con los fragmentos de piezas y viceversa.
Siempre que la vida de una pieza ha terminado, esto es, en el mismo instante que vence la ranura de tiempo y no puede bajar una posición porque hay colisión (contra la base del área de juego o contra otros fragmentos de piezas), hay que comprobar si ha hecho línea. Una pieza hará línea cuando alguno de sus fragmentos logren hacer que la fila en la que se han colocado todas las casillas estén ocupadas. Para verlo más claro, esta figura:
Aquí podemos ver cómo la barra que acabamos de colocar hace que dos de sus casillas hagan línea (la casilla 1 y 3, comenzando a contar de arriba a abajo). ¿Cómo detectar esto?. Bien, antes de explicar cómo detectar línea, convendría que pensáramos mejor lo que pasa cuando una pieza hace línea. Puntos a tener en cuenta:
Una vez definidos estos tres puntos básicos, podríamos establecer los siguientes pasos:
Comprobar sí hemos hecho línea. Para ello deberemos de examinar todas las filas del área de juego en los que nuestra pieza se ha establecido, recorriéndolas. Si en algún momento encontramos que hay un valor igual a 0, significará que ha quedado un hueco sin ocupar y, por lo tanto, que no se ha hecho línea. Aquí yo recomendaría que se hiciera un recorrido derecha - izquierda o izquierda - derecha, es decir, que se evitara recorrer una fila de forma secuencial (todo el rato a la derecha o todo el rato a la izquierda). En lugar de ello, una alternancia extremo derecho - extremo izquierdo aumentaría en gran medida la efectividad de los cálculos pues, de no existir línea, habría más posibilidad de saberlo antes. Se guardará un contador que indique el número máximo de filas que han hecho línea y un buffer que nos indique las filas que han hecho línea.
Si hay alguna línea, procedemos. En el caso de que el contador de líneas hechas sea distinto de 0, significará que se ha hecho línea y que debemos de bajar la altura de los "montones de fragmentos". Básicamente, el proceso será una recolocación de las filas del área de juego. El montón bajará una altura igual al número de filas que han hecho línea. En líneas generales, el mecanismo consistirá en evaluar todas las filas desde la última que ha hecho línea hacia arriba, marcando, en un principio, todas las filas como "desocupadas". Una vez hecho esto, se evaluarán las filas que no hacen línea (porque son las que tienen las piezas que van a ser recolocadas) y se irán situando en las filas marcadas como vacías, hasta que no quede ninguna.
Actualizamos estructuras internas. Como es lógico, una vez que se hace línea habrá que evaluar unas cuantas cosas como son los puntos que recibe el jugador, si ha pasado de fase o si tiene que salir otra pieza, etc.
Lo expuesto aquí no es más que un análisis bastante acelerado de todo lo que sería el mecanismo interno de cara a la realización de un Tetris básico. Algunos temas no se han enfocado más a fondo por el simple hecho de que no se pretendía hacer de este artículo un listado en donde copiar la solución para hacer un Tetris. Creo que la mayor parte de las bases de este juego están expuestas. Ahora bien, todo aquel que quiera hacer un Tetris, que se olvide de agarrarse a este documento únicamente pues un juego tan trivial como el Tetris, esconde unos cuantos secretos más que sólo se desvelan cuando se toma en serio el proyecto. Así que, si quieres hacer un Tetris léete este documento una vez, obtén una idea general y luego ponte a escribir tu propia documentación.
ÚLTIMA REVISIÓN EN JULIO DE 1999
|
|