Detalles de implementación de un juego de emparejamiento y eliminación de bloques de colores escrito completamente en JavaScript.

El objetivo del juego es eliminar todos los cuadros de colores del tablero descubriendo las agrupaciones de cuadros de un mismo color. Al pasar el cursor del ratón sobre un cuadro se muestra su color, y si se pulsa sobre el mismo se queda fijo, momento en el que se puede empezar a seleccionar más cuadros del mismo color vecinos no diagonales al fijado.

Cuando se han seleccionado todos los cuadros vecinos, la agrupación completa se elimina del tablero. Al eliminar una agrupación todos los cuadros que estuvieran por encima caen cubriendo los huecos creados.

El juego comienza cuando se carga la página, y termina cuando se han eliminado todos los cuadros del tablero o se llega a una disposición en la que no es posible seguir eliminando cuadros. Pulsando F5 en cualquier momento se genera un nuevo tablero comenzando el juego desde el principio.

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.

Discovery

Clases

El juego se compone de las siguientes clases:

Game: Es la clase que representa la aplicación. Instancia el tablero y le cede el control.

Board: Contiene el algoritmo de generación aleatoria del tablero, recibe los eventos del ratón y todos los métodos que implementan la lógica de selección de los cuadros.

Cell: Es básicamente una clase de datos que representa a cada uno de los cuadros de colores con un par de métodos útiles para cambiarlos de color y posición.

Grid: Contiene atributos que definen las dimensiones de la rejilla de juego, los colores utilizados, y métodos para dada una posición sobre la rejilla obtener la posición sobre la ventana.

Tablero

El tablero de juego es una cuadrícula con tantas filas y columnas como indiquen los atributos de la clase Grid:

  this.cellRows      = 10;
  this.cellCols      = 10;
  this.cellWidth     = 35;
  this.cellHeight    = 35;

Y el color de cada cuadro del tablero se escoge entre los disponibles en un array de colores que también contiene la clase Grid:

  this.cellBackground = "#FFFFFF";
  this.cellColors     = new Array("#FFFF00", "#00FF00", "#00FFFF");

Los colores se escogen de forma totalmente aleatoria mediante la función Math.random de JavaScript, que genera un número al azar entre 0 y 1, cuyo uso se ha encapsulado y generalizado en la función global rand que genera un número entero aleatorio entre 0 y el parámetro x que se le pasa:

function rand(x) {
  return( Math.floor( Math.random() * x ) );
}

Por cada uno de los cuadros de colores se genera de forma dinámica un elemento HTML de tipo div y un objeto instancia de la clase Cell. A cada div generado se le asocia su objeto Cell correspondiente, y a cada objeto Cell su div generado, lo que permite una navegación muy sencilla en las dos direcciones.

Todos los objetos de tipo Cell generados se almacenan en el atributo cells de la clase Board, que no es más que un array de arrays de tantas filas y columnas como tiene el tablero de juego.

  this.createCells = function() {
    for (var row = 0; row != self.grid.cellRows; ++ row) {
      self.cells[row] = new Array(self.grid.cellCols);
      for (var col = 0; col != self.grid.cellCols; ++ col)
        self.createCell(row, col);
    }
  }

El método createCell de la clase Board se limita a crear un div de forma dinámica mediante la función document.createElement(«div») y a añadirlo en tiempo de ejecución a la página HTML mediante la función appendChild de JavaScript.

Otros atributos de la clase Board incluyen una referencia al cuadro actual sobre el que se encuentra el cursor del ratón, un array con los cuadros actualmente seleccionados, un array con los cuadros que restan para formar un grupo de un mismo color, y el número total de cuadros eliminados.

  this.cellReverse   = null;
  this.cellsSelected = new Array();
  this.cellsToGroup  = new Array();
  this.cellsRemoved  = 0;

Eventos de Ratón

El juego captura dos eventos de ratón. Por una parte el evento onmousemove, que permite ver el color de los cuadros cuando se pasa cursor del ratón por encima de ellos, y por otra parte el evento onclick, que permite fijar el color de un cuadro cuando se hace click sobre el mismo.

  this.registerMouse = function() {
    document.onmousemove = self.onMouseMove;
    document.onclick     = self.onClick;
  }

La parte más sencilla es la de mostrar el color de un cuadro cuando se pasa el ratón por encima. Se limita a restaurar el color del cuadro anterior sobre el que se encontraba el ratón, si es que estaba sobre alguno, y a mostrar el color del nuevo cuadro.

  this.onMouseMove = function(e) {
    if (self.removing == true)
      return;
     
    self.flipPrevious();
    self.flipNew(e);
  }

La comprobación sobre la variable removing se añadió a última hora y se utiliza para evitar que se ejecute el evento si se está ejecutando el efecto que se ve cuando se elimina un grupo de cuadros de color.

El método flipPrevious comprueba si había un cuadro previo mostrando su color y le asigna el color de fondo.

  this.flipPrevious = function() {
    if (self.cellReverse != null) {
      self.cellReverse.flip(self.grid.cellBackground);
      self.cellReverse = null;
    }
  }

El método flipNew comprueba si el ratón se encuentra sobre un cuadro de color, es decir, sobre un elemento div con clase «cell». Si lo está se cambia para que muestre su color, si es que no lo estaba mostrando ya.

  this.flipNew = function(e) {
    var div = getMouseObject(e);
    if ( self.isCell(div) )
      if (div.cell.selected == false) {
        self.cellReverse = div.cell;
        self.cellReverse.flip(self.cellReverse.color);
      }
  }

El otro evento de ratón que captura el programa se encarga de seleccionar los cuadros, y de igual forma que con el evento anterior se evita que se ejecute mientras se está realizando el efecto de eliminación de grupos de colores.

  this.onClick = function(e) {
    if (self.removing == true)
      return;

    var div = getMouseObject(e);
    if ( self.isCell(div) )
      self.onClickCell(div.cell);
  }

El método toma el objeto sobre el que se está haciendo click con el ratón y comprueba que sea un cuadro de color, si lo es llama a la función onClickCell que le quita la selección si el cuadro si estaba ya seleccionado, o se la pone si no lo estaba.

  this.onClickCell = function(cell) {
    if (cell.selected)
      self.unselectCell(cell);
    else
      if ( self.selectCell(cell) )
        if ( self.removeGroup() )
          if ( self.isEnd() )
            alert("Congratulations!!! You have removed all the squares from the board.\r\nClose this message and press F5 to play a new game.");
  }

Cuando se selecciona un cuadro se comprueba si se ha terminado de seleccionar todo el grupo de colores adyacentes, y si es así se elimina todo el grupo. Si como resultado de la eliminación del grupo sucede que el tablero se queda sin cuadros de colores entonces se muestra el mensaje de fin de juego.

Selección

Un cuadro de color se puede seleccionar si se cumple alguna de estas dos condiciones:
– No hay ningún cuadro actualmente seleccionado. Este caso se corresponde a cuando se selecciona el primer cuadro.
– El cuadro tiene al menos un vecino seleccionado del mismo color. Este caso se corresponde a cuando se tiene uno más cuadros seleccionados y se selecciona un nuevo cuadro.

Estas dos condiciones se implementan en el método canSelect de la clase Board:

  this.canSelect = function(cell) {
    if (self.cellsSelected.length == 0)
      return(true);

    return( self.countNeighbours(cell, true) >= 1 );
  }

La función countNeighbours obtiene el número de vecinos que tiene un determinado cuadro. Entendiendo por vecino a cualquiera de las cuadros que se encuentran a la derecha, izquierda, arriba o abajo del cuadro dado y que tienen su mismo color.

  this.countNeighbours = function(cell, selected) {
    return( self.getNeighbours(cell, selected).length );
  }

  this.getNeighbours = function(cell, selected) {
    var neighbours = new Array();

    neighbours = self.getNeighbour(neighbours, cell, -1,  0, selected);
    neighbours = self.getNeighbour(neighbours, cell,  0, -1, selected);
    neighbours = self.getNeighbour(neighbours, cell,  0,  1, selected);
    neighbours = self.getNeighbour(neighbours, cell,  1,  0, selected);

    return(neighbours);
  }

  this.getNeighbour = function(neighbours, cell, deltaRow, deltaCol, selected) {
    var row = cell.row + deltaRow;
    var col = cell.col + deltaCol;

    if ( self.isNeighbour(row, col, cell.color, selected) )
      neighbours.push(self.cells[row][col]);

    return(neighbours);
  }

  this.isNeighbour = function(row, col, color, selected) {
    if ( self.isValidCell(row, col) == false)
      return(false);

    if (self.cells[row][col].color != color)
      return(false);

    return(self.cells[row][col].selected == selected);
  }

El parámetro selected se utiliza para indicar si se quiere contar vecinos seleccionados (true) o no (false).

Por otra parte, a un cuadro de color se le puede quitar la selección si se cumplen las siguientes condiciones:
– Tiene cero o un vecino. Este caso se corresponde a cuando se tiene un único cuadro seleccionado o cuando se intenta quitar la selección de un cuadro de un extremo del grupo actual seleccionado.
– No tiene tres o más vecinos. Este caso se corresponde a cuando se intenta quitar un cuadro que se encuentra rodeado por otros cuadros seleccionados.
– Tiene dos vecinos y existe un camino alternativo para ir desde un vecino al otro pasando sólo por los cuadros actuales seleccionados. Este caso se corresponde a cuando se intenta eliminar la selección de un cuadro que haría que el grupo actual seleccionado se dividiera en dos grupos.

Todas estas condiciones se implementan en el método canUnselect de la clase Board:

  this.canUnselect = function(cell) {
    var neighbours = this.getNeighbours(cell, true);

    if (neighbours.length <= 1)
      return(true);

    if (neighbours.length >= 3)
      return(false);

    var region = self.cellsSelected.slice();
    var origin = neighbours[0];
    var target = neighbours[1];

    removeFromArray(region, cell);

    return( self.findPath(region, origin.row, origin.col, target) == true );
  }

El método findPath es una función recursiva que trata de encontrar un camino entre dos cuadros a partir de uno dado pasando sólo por los cuadros que se le indican:

  this.findPath = function(region, row, col, target) {
    if ( self.isValidCell(row, col) == false)
      return(false);

    if (self.cells[row][col] == target)
      return(true);

    if ( findOnArray(region, self.cells[row][col]) == null)
      return(false);

    removeFromArray(region, self.cells[row][col]);

    return( self.findPath(region, row - 1, col    , target) ||
            self.findPath(region, row    , col - 1, target) ||
            self.findPath(region, row    , col + 1, target) ||
            self.findPath(region, row + 1, col    , target) );
  }

Cuando se selecciona un cuadro se añade al array cellsSelected de la clase Board, y sus vecinos del mismo color que aún no se han seleccionado al array cellsToGroup. Cuando se quita la selección de un cuadro se realiza el proceso inverso, quitándolo del array cellsSelected y añadiéndolo a cellsToGroup. De esta forma resulta fácil averiguar cuando se ha completado la selección de un grupo completo y se puede eliminar del tablero.

Eliminación de Cuadros

Cuando se selecciona un grupo completo de colores se activa un timer que reduce el tamaño de los mismos en cada activación. Este efecto fue un añadido de última hora que se implementó porque resultaba muy brusco el efecto de desaparición de los cuadros.

Para hacer caer los cuadros se recorren las columnas de izquierda a derecha y de abajo hacia arriba. Cuando se encuentra un hueco se cambia la posición de los cuadros que se encuentran por encima del hueco para cubrirlo:

  this.dropCells = function() {
    for (var col = 0; col != self.grid.cellCols; ++ col)
      for (var row = self.grid.cellRows - 1; row > 0; -- row)
        if (self.cells[row][col] == null)
          self.dropColumn(row, col);
  }
 
  this.dropColumn = function(row, col) {
    for (var y = row - 1; y >= 0; -- y)
      if (self.cells[y][col] != null) {
        self.cells[row][col] = self.cells[y][col];
        self.cells[y][col]   = null;
       
        self.cells[row][col].row = row;
        self.cells[row][col].col = col;
        self.cells[row][col].drop( self.grid.getLeft(col), self.grid.getTop(row) );
        break;
      }
  }

Posibles mejoras

El juego admite muchas mejoras, cito a continuación algunas de ellas:

– Crear algún tipo de motivo o trama para el reverso de los cuadros de colores, el blanco actual resulta muy simple y da la impresión de estar inacabado.

– 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.

– 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.

– Cuando caen los cuadros y una columna queda vacía, deberían desplazarse las columnas laterales juntándose los cuadros que queden en el tablero para hacer más fácil el juego.

– Mejorar el algoritmo de generación del tablero, actualmente es completamente aleatorio y a veces se produce mucha dispersión entre los cuadros de un mismo color.

– Controlar la dificultad del tablero generado, cambiando el número de celdas y el número de colores a medida que se vayan resolviendo tableros y avanzando en el juego.