Crea tu propio Tetris

Probablemente unos de los videojuegos más jugado y más versionado en la historia ha sido el Tetris. Este simple juego ha demostrado que una genial idea sin necesidad de un desarrollo complejo puede ser más adictivo que cualquier juego de producción multimillonaria.

En este post vamos a desarrollar una versión sencilla del Tetris utilizando JavaScript. Para ello explicaremos puntos interesantes que hay que tener en cuenta y las posibles soluciones que podemos utilizar. Finalmente entregaremos un código fuente completo de ejemplo que podréis copiar y ejecutar.

Introducción

Este juego fue creado por el soviético Alekséi Pázhitnov. Como si de un diamante en bruto se tratase, los derechos de este juego fueron disputados por Atari y Nintendo. Finalmente, adquirió su fama distribuyéndose en la videoconsola Game Boy. También habría que hacer mención a la fámosa versión de las recreativas con su archiconocida música y el bailarín que danzaba al finalizar cada fase.

El juego consiste en un tablero bidimensional con perspectiva vertical. Durante el juego aparecen piezas compuestas por cuatro cuadrados que caen por el tablero. El objetivo del juego es conseguir evitar que se llegue a la cima del tablero rellenando, con las piezas que caen, filas completas. Para ello, el usuario podrá mover y rotar las piezas que van cayendo. Cuando se completa una fila, esta desaparece moviendo las celdas superiores hacia abajo.

Visión general del código fuente

La representación del tablero del juego la realizaremos mediante una matriz de 20×9 donde los valores que hay en el interior representa el color correspondiente. El valor 0 corresponderá con el vacio y los valores mayores de 0 corresponderá a la pieza que hay en ese tablero y en consecuencia el color a representar. La pieza movil no la almacenaremos en el tablero hasta que no se haya posado, esta pieza se obtendrá aleatoriamente y dispondrá 4 variables numéricas que la representarán. Estas variables serán: el tipo de pieza, la fila actual, la columna actual y cuantas rotaciones de 90º se han realizado.

Para desarrollar el videojuego hay que plantear varios puntos de dificultad fácil/media que iremos comentando a lo largo del post. Inicialmente comentaremos diferentes formas de representar las piezas y tomaremos un método en el que haya que escribir poco código. También hay que plantear como conseguir si es posible mover o rotar una pieza que se está deslizando teniendo en cuenta el tablero que disponemos y donde está la pieza. Finalmente, haremos una mención al algoritmo que detecta las filas completamente rellenas y borra la fila.

Diseño de las piezas

Existen diferentes estrategias para diseñar las piezas que van a caer. Crear un algoritmo que diseñe las piezas automáticamente, aunque es complejo, es posible. Sin embargo, este algoritmo se aprovecha cuando se pretende crear piezas de más cuadros como (6, 7 … ). En el caso del Tetris con 4 cuadros por pieza, el total de piezas posibles es 7 por lo que buscar una estrategia de definición preestablecida es menos costosa en líneas de código.

Dado a que vamos a definir nosotros mismos las figuras, la opción ideal para conseguir tratar las diferentes piezas de manera homogénea es crear un vector de piezas de manera que mediante un índice seleccionaremos una pieza u otra.

Podemos utilizar multitud de estrategias en el momento de diseñar nosotros mismos las piezas. Una opción es utilizar una matriz de booleanos de manera que se represente con un 1 si existe una pieza o un 0 si no hay cuadrado de la pieza. Veamos un ejemplo para la L


[[0, 1, 0],
 [0, 1, 0],
 [0, 1, 1],
 [0, 0, 0]]

Esta forma de trabajar, aunque mejora la compresión del diseño de las piezas, es más laborioso de tratar y de rotar por no utilizar coordenadas X,Y. Con esta solución no es posible aplicar matrices de rotación utilizadas y enseñadas en asignaturas de informática gráfica o similares.

Para un mejor tratamiento de rotaciones de las piezas es más sencillo utilizar coordenadas [x,y]. En nuestro caso, en vez de hablar de posiciones [x,y] trabajaremos con [ fila, columna ] ya que nuestra intención almacenar información en una matriz.

Aunque es posible, es complejo trabajar con coordenadas absolutas (respecto a la matriz tablero) ya que cada vez que rotemos deberemos trasladar los puntos al eje, aplicar la rotación y volver a desplazar la pieza al punto donde estaba.

En vez de esto, vamos a definir coordenadas relativas respecto a un cuadro de la pieza central. Este cuadro será el punto de referencia para aplicar la rotación. Por ejemplo, para la pieza L serían configurarían las siguientes coordenadas [fila, columna]:


[[-1,0],
[0,0],
[1,0],
[1,1]]

Con esto sería suficiente. En nuestro caso, para definir las diferentes piezas, vamos a establecer un paso adicional que consistirá en definir estas coordenadas con ciertos constantes o valores, para así no repetir las coordenadas respectivamente. Para ello, estableceremos unos valores para un punto relativo. Estos serían los puntos mínimos que habría que configurar:


[_, 6, 3, _]
[7, 5, 0, 1]
[_, 4, 2, _]

En el código fuente los definiremos mediante la matriz pos


var pos=[
    [0,0],
    [0,1],
    [-1,0],
    [1,0],
    [-1,-1],
    [0,-1],
    [1,-1],
    [0,-2]
];

Como se puede ver, hemos dejado huecos _ ya que utilizando las referencias donde hay número ya se puede diseñar cualquier pieza del juego. Por ejemplo, la figura L (que estará rotada 180º respecto a los ejemplos anteriores) sería:


[0,1,4,5]

Finalmente, comentar que no todas las piezas rotan 4 veces … ¿o si?. Hay figuras como el cuadrado o el palo que, analizándolo detenidamente, cuando rotan se obtiene la misma figura. El caso más evidente es cuando un cuadrado rota y se aprecia que la figura es la misma.

Sin embargo, si tenemos en cuenta que hay un cuadro central y las demás rotan respecto a ese cuadro, si aplicamos rotación sobre ese cuadro central, la figura se moverá aunque tome la misma forma, suceso que en la versión original del Tetris no ocurría. Por ello, para evitar que piezas simétricas se desplacen al rotar, no dejaremos que roten siempre 4 veces.

Para solventar esto, en la configuración de la pieza, vamos a añadir un valor en la primera posición del vector que indique cuantas veces puede rotar. Por ejemplo, en el caso de la L que puede rotar 4 veces la configuración sería:


[4,0,1,4,5]

Finalmente, es interesante tratar las piezas de manera homogénea y que se seleccione una u otra en función de un valor numérico. Por ello, almacenaremos todas las piezas de manera vectorial:


var piezas=[
    [4,0,1,2,3], 
    [4,0,1,5,6],  
    [4,0,1,5,4],
    [2,0,1,5,7],
    [2,0,2,5,6],
    [2,0,3,5,4],
    [1,0,5,6,3]
];

Rotación de la pieza

Al trabajar con coordenadas relativas se nos hace más fácil la rotación de la pieza utilizando una matriz de rotación. La fórmula genérica de esta matriz es multiplicar un vector de puntos [x,y] por la siguiente matriz:


[[cos(a), -sen(a)],
 [sen(a), cos(a)]]

En nuestro caso, vamos a utilizar únicamente 90º y, en el caso de precisar rotar múltiplos de 90º cómo 180º o 270º, repetiremos la operación varias veces. Conociendo que (a) será (90º), no es necesario ya trabajar con senos o cosenos. Nuestra matriz será:


[[0, -1],
[1, 0]]

Finalmente, me gustaría hacer mención para ser estríctamente correcto, que el planteamiento anterior tiene unos errores conceptuales. Por una parte, nuestra matriz de coordenadas es [fila, columna] en vez de [x, y]. Al estar cambiados, las posiciones de referencia el angulo 90º en realidad sería -90º. Sin embargo, esto se compensa con otro fallo de concepto. En la pantalla las posiciones y más altas están en la parte inferior de la pantalla, al revés de un eje de coordenadas. Por ello, la imagen que representamos está invertida, así que la rotación que sería de -90º en realidad sería 90º.

A efectos prácticos, no creo que haya que darle mayor importancia ya que lo que ocurrirá es que en vez de girar en un sentido, girará en otro. Si se deseas cambiar el sentido del giro, tan solo habrá que aplicar la matriz de ángulo -90º:


[[0, 1],
[-1, 0]]

Detectar colisiones

Otra dificultad que nos podemos encontrar es como hacer que detecte que hay una colisión con los laterales u otra pieza, para dejar o no mover o rotar la pieza. La solución es más fácil de lo que parece. Plantear una estrategia predictiva que es crear algoritmos que detecten como será la pieza sin mover o rotar, puede ser complejo.

En cambio, dado que tenemos ya los algoritmos de mover y rotar y esto únicamente se gestiona mediante tres sencillas variables numéricas, sería más sencillo crear un algoritmo que detecte si hay colisión una vez se haya movido o rotado la pieza y en el caso de que haya colisión deshacer el último movimiento. De esta manera podemos reutilizar el mismo algoritmo para mover y para rotar. Es decir, tanto para mover como para rotar sería algo similar a esto:


hacer(); //Mover o rotar pieza
si hay colision() entonces
    deshacer();
fsi;

En el caso del movimiento hacia abajo, habría que poner alguna lógica más para posar la pieza y sacar una nueva pieza.

Detectar líneas completas

Finalmente, me gustaría hacer una pequeña mención del algoritmo de detectar líneas rellenas. El algoritmo en sí no es complejo. Tan solo se trata de recorrer las filas y para cada una contar cuantos cuadros hay rellenos (o comprobar si hay algún vacío) y en el caso que se detecte línea completa, recorrer otra submatriz de la fila 1 (o 0) hasta la fila completada (o fila -1).

El punto que me gustaría hacer mención es sobre este segundo recorrido. Cuando se plantea una iteración para desplazar celdas de un vector o matriz, es importante siempre recorrer en el sentido contrario al sentido de desplazamiento de los valores.

En este caso, como deseamos desplazar los valores del tablero hacia abajo, la variable indice que se utiliza en la iteración de las filas deberá ir de abajo a arriba. En caso contrario, lo que realizaríamos es copiar la información de la celda superior en toda la submatriz.


para (var f2=f;f2 > 0;f2--) hacer
    para (var c2=0;c2 < 9;c2++) hacer
        tablero[f2][c2]=tablero[f2-1][c2];
    }
}

Código fuente

Finalmente, aquí tenéis el código fuente en JavaScript para que lo podáis probar. Comentar que no ha habido intención de poner esfuerzo en la parte gráfica, ni en la interacción con el usuario en la detección de teclas. Estos temas podrían dar para un post diferente.


<html>
<style type="text/css">
        .tetris{
             border-collapse: collapse;
        }
        .tetris tr td{
             width:20px;
             height:20px;
             padding: 0px;
        }
        .celda0{
             background-color: #000000;
        }
        .celda1{
             background-color: #0000FF;
        }
        .celda2{
             background-color: #FF0000;
        }
        .celda3{
             background-color: #00FF00;
        }
        .celda4{
             background-color: #FFFF00;
        }
        .celda5{
             background-color: #00FFFF;
        }
        .celda6{
             background-color: #FF00FF;
        }
        .celda7{
             background-color: #888888;
        }
</style>
<script>
        var velocidad=50000; //velocidad del juego
        var fpi, cpi, rot; //fila, columna y rotación de la ficha
        var tablero;  //matriz con el tablero
        var pieza=0; //pieza
    var record=0;  //almacena la mejor puntuación
    var lineas=0;   //almacena la  puntuación actual
        var pos=[  //Valores referencia de coordenadas relativas
              [0,0],
              [0,1],
              [-1,0],
              [1,0],
              [-1,-1],
              [0,-1],
              [1,-1],
              [0,-2]
        ];
        var piezas=[  //Diseño de las piezas, el primer valor de cada fila corresponde con el número de rotaciones posibles
              [4,0,1,2,3], 
              [4,0,1,5,6],  
              [4,0,1,5,4],
              [2,0,1,5,7],
              [2,0,2,5,6],
              [2,0,3,5,4],
              [1,0,5,6,3]
    ];
    //Genera una nueva partida inicializando las variables
    function nuevaPartida(){ 
                velocidad=50000;
                tablero=new Array(20); 
                for (var n=0;n < 20;n++){
                     tablero[n]=new Array(9);
                     for (var m=0;m < 9;m++){
                          tablero[n][m]=0;
                     }
                }
        lineas=0;
                nuevaPieza();
    }
    //Detecta si una fila columna del tablero está libre para ser ocupada
        function cuadroNoDisponible(f,c){
        if (f < 0) return false;
        return (c < 0 || c >= 9 || f >= 20 || tablero[f][c] > 0);
    }
    //Detecta si la pieza activa colisiona fuera del tablero o con otra pieza
        function colisionaPieza(){
        for (var v=1;v < 5;v++){
            var des=piezas[pieza][v];
            var pos2=rotarCasilla(pos[des]);
            if (cuadroNoDisponible(pos2 [ 0 ] +fpi, pos2 [ 1 ]+cpi)){
                return true;
            }
        }
        return false;
        }
    //Detecta si hay lineas completas y si las hay las computa y borra la linea desplazando la submatriz superior
        function detectarLineas(){
        for (var f=0;f < 20;f++){
            var contarCuadros=0;
            for (var c=0;c < 9;c++){
                if (tablero[f][c]>0){
                    contarCuadros++;
                }
            }
            if (contarCuadros==9){
                for (var f2=f;f2 > 0;f2--){
                    for (var c2=0;c2 < 9;c2++){
                        tablero[f2][c2]=tablero[f2-1][c2];
                    }
                }
                lineas++;
            }
        }
    }
    //Baja la pieza, si toca otra pieza o el suelo, saca una nueva pieza
        function bajarPieza(){
        fpi=fpi+1;
        if (colisionaPieza()){
            fpi=fpi-1;
            for (v=1;v < 5;v++){
                des=piezas[pieza][v];
                var pos2=rotarCasilla(pos[des]);
                if (pos2 [ 0 ] +fpi >= 0 && pos2 [ 0 ] +fpi < 20 &&
                    pos2 [ 1 ] +cpi >=0 && pos2 [ 1 ] +cpi < 9){
                    tablero[pos2 [ 0 ] +fpi][pos2 [ 1 ] +cpi]=pieza+1;
                }
            }
            detectarLineas();
            //Si hay algun cuadro en la fila 0 reinicia el juego
            var reiniciar=0;
            for (var c=0;c < 9;c++){
                if (tablero [ 0 ] [ c ] !=0){
                    reiniciar=1;
                }
            }
            if (reiniciar==1){
                if (lineas > record){
                    record=lineas;
                }
                nuevaPartida();
            }else{
                nuevaPieza();
            }
        }
        }
    //Mueve la pieza lateralmente
    function moverPieza(des){
        cpi=cpi+des;
        if (colisionaPieza()){
            cpi=cpi-des;
        }
    }
    //Rota la pieza según el número de rotaciones posibles tenga la pieza activa. (posición 0 de la pieza)
    function rotarPieza(){
                rot=rot+1;
                if (rot==piezas[pieza] [ 0 ] ){
            rot=0;
        }
        if (colisionaPieza()){
            rot=rot-1;
                    if (rot==-1){
                rot=piezas[pieza] [ 0 ] -1;
            }
        }
    }
    //Obtiene unas coordenadas f,c y las rota 90 grados
    function rotarCasilla(celda){
        var pos2=[celda [ 0 ] , celda [ 1 ] ];
        for (var n=0;n < rot ;n++){
            var f=pos2 [ 1 ]; 
            var c=-pos2 [ 0 ] ;
            pos2 [ 0 ] =f;
            pos2 [ 1 ] =c;
        }
        return pos2;
    }
    //Genera una nueva pieza aleatoriamente
    function nuevaPieza(){
        cpi=3;
        fpi=0;
        rot=0;
        pieza=Math.floor(Math.random()*7);
        }
    //Ejecución principal del juego, realiza la animación y repinta
        function tick(){
        bajarPieza();
        pintar();
        setTimeout('tick()', velocidad/100);
        }
    //Pinta el tablero (lo genera con html) y lo plasma en un div.
    function pintar(){
        var lt=" <";
        var des;
        var html="<table class='tetris'>"
        for (var f=0;f < 20;f++){
            html+="<tr>";
            for (var c=0;c < 9;c++){
                var color=tablero[f][c];
                if (color==0){
                    for (v=1;v < 5;v++){
                        des=piezas[pieza][v];
                        var pos2=rotarCasilla(pos[des]);
                        if (f==fpi+pos2 [ 0 ]   && c==cpi+pos2 [ 1 ] ){
                            color=pieza+1;
                        }
                    }
                }
                html+="<td class='celda" + color + "'/>";
                    }
            html+=lt+"/tr>";
                }
        html+=lt+"/table>";
        html+="<br />Lineas : " + lineas;
        html+="<br />Record : " + record;
        document.getElementById('tetris').innerHTML=html;
                velocidad=Math.max(velocidad-1,500);
        
    }
    //Al iniciar la pagina inicia el juego
        function eventoCargar(){
                nuevaPartida();
                setTimeout('tick()', 1);
        }
    //Al pulsar una tecla
        function tecla(e){
                var characterCode = (e && e.which)? e.which: e.keyCode;
                switch (characterCode){
        case 37:
                        moverPieza(-1);
            break;
        case 38:
            rotarPieza();
            break;
        case 39:
                        moverPieza(1);
            break;
        case 40:
            bajarPieza();
            break;
                }
        pintar();
        }
</script>
<body onload='eventoCargar()' onkeydown="tecla(event)">
    <div id='tetris'/>
</body>
</html>

Portada de Genbeta