Algunas notas acerca del diseño e implementación del clásico juego de cartas del Solitario escrito en JavaScript.

La operativa del juego es bastante conocida, y básicamente consiste en sacar cartas de dos en dos de una baraja para agruparlas en cuatro mazos, uno por cada palo de la baraja, pudiéndose ayudar de unos mazos auxiliares en los que se pueden acumular y descubrir nuevas cartas.

La mayor dificultad, en cuanto a programación se refiere, ha sido la implementación del Drag and Drop para arrastrar una o varias cartas de golpe de un montón a otro. Aunque gracias a la gran cantidad de páginas que he podido encontrar en Internet acerca de este tema no ha resultado excesivamente difícil hacer funcionar el efecto correctamente.

Mención aparte requiere el diseño y dibujado de las cartas. Y en especial el de las caras de las figuras del joker, la reina y el rey, a pesar de su aparente simplicidad.

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.

Solitaire

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 encargada de crear los objetos del juego de forma dinámica, de recibir y responder a los eventos de ratón, y contiene la lógica de control y de fin de partida.

Bunch: Esta clase representa cada uno de los montones en los que se pueden depositar o extraer cartas. Contiene un atributo que indica que tipo de montón concreto representa cada instancia de esta clase.

Card: Representa a cada una de las cartas de la baraja. Contiene una referencia al objeto HTML que la representa y al mazo en el que se encuentra, así como su palo, color y número, y un indicador de si se encuentra boca arriba o boca abajo.

Deck: Es la clase que describe la baraja. Contiene atributos con el número de cartas, el número de palos, las dimensiones de las cartas, y el directorio raíz donde se encuentran las imágenes. Contiene un método utilizado al iniciar el juego, y que se encarga de repartir las cartas sobre el tablero evitando que una misma carta se repita más de una vez.

Creación de Mazos

Mientras que las clases de la baraja y de las cartas no implican gran dificultad, la que representa los mazos tiene el inconveniente de que cada instancia concreta debe conocer el tipo de mazo concreto que representa, ya que su ubicación y reglas son distintas para cada uno de ellos.

Este el tipo de tareas que pueden resolverse facilmente mediante herencia, una característica que soporta JavaScript mediante una técnica de prototipado, pero con la que no he acabado de sentirme cómodo, razón por la cual me he decidido a implementar la herencia a través de un atributo type que utilizan los métodos para determinar cómo deben comportarse.

La clase Board es la encargada de crear todos los mazos del tablero mediante la función createBunchs en la que se llama al método createBunch indicando el tipo de mazo a crear, su coordenada superior izquierda, y el número de cartas iniciales de las que debe constar el mazo:

  this.createBunchs = function() {
    self.createBunch("pile",  50, 100, 1);
    self.createBunch("pile", 125, 100, 2);
    self.createBunch("pile", 200, 100, 3);
    self.createBunch("pile", 275, 100, 4);
    self.createBunch("pile", 350, 100, 5);
    self.createBunch("pile", 425, 100, 6);
    self.createBunch("pile", 500, 100, 7);
    self.createBunch("deck",   5,   0, 0, self.createBunch("pair", 80, 0, 2) );
    self.createBunch("end",  275,   0, 0);
    self.createBunch("end",  350,   0, 0);
    self.createBunch("end",  425,   0, 0);
    self.createBunch("end",  500,   0, 0);
  }

El tipo «pile» representa los mazos auxiliares, «deck» la baraja, «pair» el mazo en el que se ponen las cartas sacadas de dos a dos de la baraja, y «end» los mazos finales.

El único mazo que se construye de forma especial es el de la baraja, al que se le pasa un quinto parámetro que hace referencia a otro mazo, el «pair», para que se sepa al hacer doble-click sobre él donde tienen que ponerse las cartas sacadas.

Referencias

Una de las decisiones de diseño que tomé durante la implementación de juegos anteriores fue la de asignar un gestor de eventos de ratón a cada uno de los elementos HTML, e incluso la de poner un único objeto transparente sobre el resto para recoger todos los eventos; algo que ahora sé que ni siquiera funciona correctamente con Internet Explorer, ya que ignora los clicks sobre las áreas transparentes.

Para este juego me planteé inicialmente hacer que cada carta recibiera directamente las pulsaciones del ratón y las reenviara al tablero. Sin embargo, descubrí que podía hacerlo todo con un sólo gestor de eventos centralizado. La clave estuvo en darme cuenta de que a los elementos HTML de la página se le pueden añadir atributos en tiempo de ejecución de igual forma que se hace con cualquier otra clase en JavaScript. Así, las instancias de la clase Bunch guardan una referencia a un div de la página, y los divs guardan una referencia a la instancia de la clase:

  this.div = div;

  this.div.bunch = this;

Y lo mismo sucede con las imágenes de las cartas:

  this.img = img;

  this.img.card = this;

De esta forma, cuando se produce un evento de ratón se puede obtener el elemento HTML sobre el que se ha producido, y obtener a su vez de él la instancia de la clase que lo representa en el juego.

Para entender esto mejor quizás sea conveniente utilizar la nomenclatura alternativa de JavaScript en la que queda patente que todos los atributos de una clase se tratan como un array asociativo, y que pueden añadirse nuevos atributos en cualquier momento:

  this["div"] = div;

  this.div["bunch"] = this;

Drag and Drop

El efecto de drag and drop se basa en el correcto uso de los eventos de ratón. Hace unos días escribí una explicación detallada con un código muy simplificado y fácil de seguir, y que puede puede encontrarse en http://www.inmensia.com/blog/20060207/1.html.

Para empezar, hay que poner gestores para cada uno de los eventos de ratón:

  this.registerMouse = function() {
    document.ondblclick  = self.onDblClick;
    document.onmousedown = self.onMouseDown;
    document.onmousemove = self.onMouseMove;
    document.onmouseup   = self.onMouseUp;
  }

A continuación hay que detectar si el objeto sobre el que se pulsa es una carta, algo que se hace comprobando que su clase CSS es «card». El resto de código del evento (que debería haber sacado a otro método para simplificarlo), se encarga de comprobar si se admite hacer drag de la carta en función del mazo en el que se encuentra, y anotar las coordenadas actuales en las que se encuentran cada uno de los objetos siendo arrastrados:

  this.onMouseDown = function(e) {
    var object = getMouseObject(e);
    if (object.className == "card") {
      var card = object.card;
     
      self.drags = card.bunch.getDrags(card);
      if (self.drags.length > 0) {
        self.dragX = pxToNumber(self.drags[0].img.style.left) - getMouseX(e);
        self.dragY = pxToNumber(self.drags[0].img.style.top)  - getMouseY(e);
       
        for (var i in self.drags) {
          self.drags[i].originalLeft   = self.drags[i].img.style.left;
          self.drags[i].originalTop    = self.drags[i].img.style.top;
          self.drags[i].originalZIndex = self.drags[i].img.style.zIndex;
          self.drags[i].img.style.zIndex = 1000 + i;
        }

        return(false);
      }
    }
  }

En el método onMouseMove se actualiza la posición de cada uno de las cartas que se están arrastrando ajustando su posición en función de la del ratón:

  this.onMouseMove = function(e) {
    var left = self.dragX + getMouseX(e);
    var top  = self.dragY + getMouseY(e);
   
    for (var i in self.drags) {
      self.drags[i].img.style.left = String(left) + "px";
      self.drags[i].img.style.top  = String(top + (10 * i) ) + "px";
    }
   
    return(self.drags.length == 0);
  }

El último evento a considerar es onMouseUp donde se libera el ratón y las cartas que están siendo arrastradas. Si se suelta sobre un mazo se intenta colocar las cartas en el mismo, y si no se devuelven a su posición original:

  this.onMouseUp = function(e) {
    if (self.drags.length) {
   
      var left = self.dragX + getMouseX(e);
      var top  = self.dragY + getMouseY(e);
     
      var bunch = self.getBunchDrop(left, top);
      if (bunch)
        for (var i in self.drags)
          bunch.adquireCard(self.drags[i]);
      else
        for (var i in self.drags){
          self.drags[i].img.style.left   = self.drags[i].originalLeft;
          self.drags[i].img.style.top    = self.drags[i].originalTop;
          self.drags[i].img.style.zIndex = self.drags[i].originalZIndex;
        }
       
      self.drags.length = 0;
      self.checkEnd();
    }
  }

Detección de Mazo

Cuando se suelta una carta que está siendo arrastrada con el ratón, se comprueba si dicha carta se encuentra sobre un mazo, y si este la admite.

El método getBunchDrop comprueba primero si se está sobre uno de los rectángulos de borde discontinuo que representan los mazos. Y si no se está se comprueba si se encuentra sobre alguna de las cartas que componen el mazo, para permitir que la carta siendo arrastrada pueda soltarse en cualquier punto sobre él, sin necesidad de arrastrarla hasta un sitio concreto.

Si se detecta que se está soltando la carta sobre el mazo entonces se pregunta al mazo si admite la carta siendo soltada.

  this.getBunchDrop = function(left, top) {
    for (var i in self.bunchs) {
      if ( self.collition(left, top, self.bunchs[i].div) )
        if ( self.bunchs[i].acceptCards(self.drags) )
          return(self.bunchs[i]);

      for (var j in self.bunchs[i].cards)
        if ( self.collition(left, top, self.bunchs[i].cards[j].img) )
          if ( self.bunchs[i].acceptCards(self.drags) )
            return(self.bunchs[i]);
    }
  }

El método collition se utiliza para la detección de colisiones, y es una simple rutina de comprobación que compara los límites de un rectángulo (el de la carta soltada) sobre otro rectángulo (el del bunch o el de otra carta).

  this.collition = function(left, top, element) {
    return( (left >= pxToNumber(element.style.left) ) &&
            (left <  pxToNumber(element.style.left) + self.deck.cardWidth) &&
            (top  >= pxToNumber(element.style.top) ) &&
            (top  <  pxToNumber(element.style.top) + self.deck.cardHeight) );
  }

Aceptación de cartas

El algoritmo de aceptación de cartas por parte de un mazo depende del tipo de mazo concreto en el que se intentan depositar estas.

Para los mazos auxiliares las comprobaciones son:
– Si el mazo está vacío entonces la carta debe ser un rey (número 13)
– Si el mazo tiene alguna carta entonces la carta que esté por encima del resto debe estar boca arriba, tener un número inmediatamente inferior a la que se quiere depositar y un color distinto.

  this.acceptCardPile = function(card) {
    if (self.cards.length == 0)
      return(card.number == 13);

    if (self.onTop().reverse == false)
      if (self.onTop().number == card.number + 1)
        return(self.onTop().color != card.color);
     
    return(false);
  }

Para los mazos finales las comprobaciones son:

– Si el mazo está vacío entonces la carta debe ser un as (número 1)
– Si el mazo tiene alguna carta entonces la carta que esté por encima del resto debe tener un número inmediatamente inferior a la que se quiere depositar y el mismo palo.

  this.acceptCardEnd = function(card) {
    if (self.cards.length == 0)
      return(card.number == 1);

    if (self.cards.length == card.number - 1)
      return(self.cards[0].suit == card.suit);

    return(false);
  }

El palo, color y número de una carta se determina cuando se crea la misma en función de su número identificador, que va de 0 a 52, tantos números como cartas tiene la baraja.

El palo de la carta se determina diviendo dicho identificador por el número de cartas que tiene un palo, el número de la carta sacando el módulo, y su color diviendo y aplicando módulo 2 al resultado, ya que dos palos son negros y los otros dos rojos:

  this.suit    = Math.floor(id / self.bunch.board.deck.cardSuit);
  this.number  = Math.floor(id % self.bunch.board.deck.cardSuit) + 1;
  this.color   = Math.floor(id / self.bunch.board.deck.cardSuit) % 2;

Funciones auxiliares

Para que conseguir que el programa funcione en los dos navegadores con los que estoy probando los juegos, Firefox e Internet Explorer, he creado una serie de funciones auxiliares que se encargan de extraer de la forma correcta los argumentos de los eventos del ratón en función del sistema de eventos de cada navegador en particular:

function getMouseObject(e) {
  return(e? e.target: window.event.srcElement);
}

function getMouseX(e) {
  return(e? e.clientX: window.event.clientX);
}
 
function getMouseY(e) {
  return(e? e.clientY: window.event.clientY);
}

Posibles mejoras

El juego admite muchas mejoras, cito a continuación algunas de las más evidentes:

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

– Añadir distintos diseños de cartas, con distintos reversos, permitiendo al jugador elegir la baraja con la que quiere jugar.

– Permitir variar algunas de las reglas del juego, como el número de cartas que se extraen de la baraja de una sola vez, o el número de montones auxiliares disponibles.

– Permitir deshacer las jugadas realizadas.

– Avisar al jugador cuando el solitario no puede resolverse.