Detalles de implementación de un remake del clásico juego Mahjong escrito completamente en JavaScript.
Al contrario de lo realizado en artículos previos de esta serie, dedicada a explicar en detalle el código fuente de los juegos publicados en esta web, voy a tratar esta vez de mostrar menos código fuente y explicar más algunos aspectos concretos que he tenido que resolver para conseguir un mejor acabado, como son la selección aleatoria de fichas para conseguir una distribución uniforme de las mismas, y la sensación de profundidad de las fichas que hacen que el conjunto parezca un juego 3D cuando en realidad es 2D (2D y medio realmente).
El juego no reviste mucha dificultad en si mismo, y la reglas son muy sencillas. Hay que emparejar fichas libres con el mismo dibujo dos a dos pinchando sobre las mismas. Una ficha se considera libre cuando no tiene ninguna otra encima, y no tiene ninguna otra ficha pegada a su izquierda o derecha. El juego termina cuando se han quitado todas las fichas del tablero o no quedan fichas libres.
La forma en la que se disponen la fichas sobre el tablero es fija, pero su distribución es aleatoria, y no está garantizado que una determinada distribución tenga solución.
Código fuente
Al ser un programa escrito completamente en JavaScript, todo el código fuente se encuentra embebido dentro de la propia página HTML desde la que se ejecuta el juego, por lo que para acceder al mismo basta con seleccionar la opción «ver código fuente de la página» del navegador.
Clases
El juego se compone de las siguientes clases:
– Game: Es la clase que representa la aplicación. Se limita a instanciar el tablero y cederle el control.
– Board: Es la clase de gestión principal. Es la encargada de crear y almacenar las fichas, recibe los eventos de ratón, y contiene la lógica de control y fin de partida.
– Map: Es una clase de datos que define el aspecto del tablero, del número de fichas a repartir, del tamaño y ubicación de la imagen de cada ficha, y contiene dos arrays en los que se destacan las fichas que tienen una posición especial dentro del tablero, como son la ficha que se encuentra arriba del todo y las fichas de los extremos.
– Grid: Contiene un array con las fichas disponibles que no se han retirado y los métodos para saber si una ficha está libre o no.
– Position: Es una clase auxiliar que se utiliza para almacenar la posición de una ficha dentro del tablero.
– Special: Otra clase auxiliar en la que se guardan las posiciones de las fichas que no se encuentran correctamente alineadas dentro del tablero con respecto al resto de fichas.
– Blocked: Una tercera clase auxilar en la que se guarda para una ficha determinada referencias a las fichas especiales que impiden que pueda retirarse del tablero.
– PinGenerator: Esta clase contiene la lógica de generación de fichas de forma aleatoria. Su nombre deriva de Picture Number Generator. Es la encarga de distribuir de manera uniforme las fichas por el tablero.
– Token: Representa cada una de las fichas presentes en el tablero. Recibe de forma individual las pulsaciones de teclado y las redirige a la clase Board.
– Selector: Es la clase que representa el cuadro selector de color celeste que aparece rodeando una ficha cuando se selecciona. Contiene una referencia a la ficha actual seleccionada.
Tablero
La forma del tablero, el número de fichas, y otros detalles como el tamaño y ubicación de las imágenes se encuentran en la clase Map.
El diseño del tablero se encuentra en el array pattern que se compone a su vez de 5 arrays, uno por cada altura a la que se pueden colocar las fichas. Cada uno de estos arrays contiene a su vez 16 elementos, uno por cada una de las columnas que puede tener como máximo el tablero. Y cada uno de estos elementos contiene un valor numérico hexadecimal de 8 bits, donde cada uno de los bits representa cada una de las 8 filas que puede tener como máximo el tablero. Si un bit vale 1 significa que hay que colocar una ficha en esa posición (altura, columna, fila).
El array special contiene una lista de las fichas que se encuentran en una posición especial dentro del tablero, es decir, que no quedan alineadas dentro de una rejilla cuadricular, como las fichas de los laterales o la que se encuentra encima de todas. Para cada una de estas fichas se guarda el porcentaje respecto al tamaño de la ficha que ha de desplazarse. Así, un par de valores (0.5, 0) indica que la ficha ha de desplazarse un 50% de su tamaño respecto a su posición horizontal original y dejarla como está respecto a su posición vertical original.
El array blocked contiene una lista de las fichas que se encuentran bloqueadas por las fichas que se sitúan en posiciones especiales. Para una de estas fichas se guardan tres índices dentro del array de fichas especiales indicando cuales las bloquean por encima, izquierda y derecha. Así, un conjunto de valores (null, null, 1) indica que la ficha se encuentra bloqueada por la derecha por la ficha especial 1.
El grueso del dibujado del tablero se encuentra en los métodos createTokens y createColumn de la clase Board, que se limitan a recorrer mediante bucles el array pattern y comprobar uno a uno los bits de los valores para determinar si debe dibujarse una ficha o no, y si ocupa una posición especial o no.
this.createColumn = function(level, column, pattern) { for (var row = 0; pattern != 0; ++ row) { if (pattern & 0x80) { var left = column * (self.map.tokenWidth - self.map.tokenShadowWidth) - level; var top = row * (self.map.tokenHeight - self.map.tokenShadowHeight) - (level * self.map.tokenShadowHeight); var special = self.map.getSpecial(level, column, row); var offsetLeft = special? special.perLeft * self.map.tokenWidth : 0; var offsetTop = special? special.perTop * self.map.tokenHeight: 0; self.createToken( new Position(level, column, row), left + offsetLeft, top + offsetTop, self.map.tokenWidth, self.map.tokenHeight); } pattern <<= 1; } }
Para conseguir que las fichas parezcan que están unas encimas de otras las imágenes están dibujadas intentando simular un poco de perspectiva, aunque en realidad no la tienen. Lo que tienen es un borde negro de unos pocos pixels en la parte derecha simulando sombra, y un pequeño frontal en la parte inferior dando sensación de volumen.

Las fichas se dibujan solapadas unas encima de otras, tapando las más cercanas los frontales de las más lejanas, y tapando las de la derecha las sombras de las que se encuentran a su izquierda. Para hacer esto se dibujan de abajo hacia arriba y desde la izquierda hacia la derecha, barriendo primero las columnas y luego las filas.
El truco final consiste en dibujar las fichas desplazadas un poco hacia la izquierda a medida que aumenta la altura, logrando el efecto de perspectiva que se aprecia sobre todo en las esquinas de cada una de las alturas de las que consta el tablero, donde una ficha parece sobresalir un poco sobre la que se encuentra debajo.
Generación aleatoria de fichas
Esta fue una de las partes que más pruebas necesitó hasta conseguir un procedimiento que generase una buena distribución de fichas.
El juego se compone de 36 fichas distintas, y cada ficha se repite 4 veces en el tablero. El objetivo de la clase de generación de fichas es conseguir una buena distribución de las mismas sobre el tablero y que algunas de las distribuciones generadas permitan terminar el juego.
El esquema original de distribución aleatoria partía de un array de 36 elementos que se inicializaban a 4. A continuación en un bucle se generaba un número aleatorio entre 1 y 36, y se restaba 1 al elemento correspondiente dentro del array. Y si el elemento valía 0 se buscaba el siguiente elemento dentro del array con valor distinto de cero. Esta solución no funcionaba bien porque la función Math.random() que se utiliza en JavaScript para generar números aleatorios no genera una buena distribución de números, de forma que algunas fichas tendían a salir de forma repetida, casi consecutiva, y otras a no salir nunca.
La solución que al final implementé es muy parecida y está recogida en la clase PinGenerator. Lo que hace es crear un array de 36 elementos de igual forma que se describió anteriormente, y generar números aleatorios restando 1 del elemento aleatorio generado. La diferencia es que al tiempo que se hace esto se van actualizando dos variables en las que se lleva la cuenta del número de fichas generadas hasta el momento. De forma que primero se genera un grupo de las 36 fichas sin repetir ninguna, a continuación otro segundo grupo sin repetir tampoco, y así hasta completar los 4 grupos que conforman el tablero.
El atributo group de la clase almacena el número de grupo en proceso, y el atributo generated el número de piezas generadas hasta el momento para el grupo. Cuando se alcanzan las 36 piezas se cambia de grupo. Aunque en realidad en la clase no se trata con los valores 36 y 4 sino que se reciben como parámetro (desde la clase Map) para conseguir tener una clase más genérica.
Selector
La clase Selector representa el marco de selección de color celeste que se superpone sobre la ficha actual seleccionada. En la práctica no es más que un elemento HTML de tipo div con una clase CSS definida de una forma «conveniente»:
.selector { position: absolute; overflow: hidden; border: 2px solid #00FFFF; color: #000000; background: transparent; }
El marco que parece rodear las fichas corresponde con el borde del div, que tiene un par de pixels de grosor de color celeste, y la ficha se deja ver por debajo del marco gracias a que se ha definido como transparente su color de fondo.
Para conseguir que el marco se adapte perfectamente a la ficha seleccionada, sin ocultar otras fichas de su alrededor, se tiene en cuenta la posición y altura en la que se encuentra la ficha seleccionada dentro del tablero leyendo el valor de sus atributos left, top y zIndex, y ubicando el selector en la misma posición pero a una altura mayor:
this.show = function(token) { self.div.style.zIndex = Number(token.img.style.zIndex) + 1; self.div.style.left = String( pxToNumber(token.img.style.left) - 1) + "px"; self.div.style.top = String( pxToNumber(token.img.style.top) - 1) + "px"; if (!self.token) board.appendChild(self.div); }
Lógica de Control
La lógica del juego es muy sencilla y se encuentra toda contenida en el método onClick de la clase Board.
this.onClick = function(token) {
La función recibe una referencia a la ficha que acaba de ser pinchada y procede a realizar con ella una serie de comprobaciones. La condición principal es que la ficha pinchada esté libre, de forma que si no lo está entonces no es necesario realizar ninguna otra comprobación.
La clase Grid es la que controla si una ficha está libre o no comprobando en un array (copia del array patterns de la clase Map) si la ficha tiene alguna ficha encima y si no tiene al menos alguna ficha a su izquierda o derecha.
if ( self.grid.isTokenFree(token) ) {
Si la ficha está libre entonces comprueba si es la misma ficha que estaba anteriormente seleccionada o es una nueva, y si la ficha es la misma que ya estaba seleccionada entonces la libera.
if ( self.grid.isTokenFree(token) ) { if (!self.selector.token) { self.selector.select(token); } else { if (self.selector.token == token) { self.selector.unselect(); }
Si ninguna de las condiciones anteriores se cumple, entonces quiere decir que el jugador ha hecho click sobre una segunda ficha con la idea de eliminarla. Si la imagen (pin) de la ficha actual seleccionada y la nueva ficha pinchada es la misma entonces se procede a eliminar las dos fichas del array de tokens:
else { if (self.selector.token.pin == token.pin) { self.removeToken(self.selector.token); self.removeToken(token); self.selector.unselect(); } } }
La última comprobación es la de fin de partida, que se realiza simplemente comprobando que no queden más fichas sobre el tablero:
if (self.tokens.length == 0) { alert("Congratulations!!! You have removed all tokens from board.\r\nClose this message and press F5 to play a new game."); } } }
Puntos de mejora
Aunque no es mi intención continuar evolucionando el juego, siempre hay algunas mejoras que podrían realizarse sobre el mismo. Cito a continuación algunas de ellas:
– Añadir un pequeño reloj contador de tiempo. De esta forma el jugador no sólo competiría contra el tablero, sino también contra el tiempo. Esta mejora sería fácil de hacer programando un evento de ejecución periódica y en cada activación del mismo mostrar el tiempo transcurrido desde el inicio de la partida.
– Si se añade un contador de tiempo entonces también sería interesante salvar el mejor tiempo conseguido, dando así otro motivo para continuar jugando. Para hacer esto se deberían utilizar cookies.
– Añadir otros tableros, es decir, otras disposiciones de fichas sobre la ventana. Lo mejor sería que pudiera elegirse el tablero con el que se quiere jugar. O hacer un modo quest en la que los nuevos tableros sólo estuvieran disponibles a medida que se resuelven tableros anteriores. Para conseguir esto habría que definir más clases Map con distribuciones de fichas distintas, e incluso con fichas con otros dibujos.
– Posibilidad de deshacer jugadas. Y relacionada con esta, otra opción útil podría ser reiniciar completamente el tablero, como si se deshacieran todas las jugadas realizadas y se empezara de nuevo con la misma distribución de fichas. Para hacer esto habría que ir guardando en una estructura de pila todas las jugadas realizadas, de forma que pudieran deshacerse las jugadas mediante la re-inserción de las fichas eliminadas.
– Llevar una cuenta de las piezas que quedan libres en el tablero. De esta forma se podría avisar al jugador cuando el contador llega a cero indicándole que el tablero no puede resolverse.