Cómo programar el Puzle Deslizante desde cero

Un tutorial paso a paso para construir el clásico puzle de las quince fichas y el hueco en HTML, CSS y JavaScript. La parte interesante no es moverlas: es barajarlas sin que el tablero resultante sea, la mitad de las veces, matemáticamente imposible de resolver.

Puzle deslizante de plástico rojo con fichas numeradas color crema y un hueco vacío, sobre fondo neutro, evocando el juguete clásico de los años 80

El puzle deslizante —también conocido como 15 Puzzle, o simplemente "el de las fichas rojas"— es uno de esos juguetes que cualquiera reconoce en cuanto lo ve, aunque no recuerde el nombre: un marco de plástico, quince fichas numeradas y un hueco vacío por el que las vas empujando hasta ordenarlas del 1 al 15. Se inventó en el siglo XIX y todavía se sigue fabricando en versión bolsillo. En este tutorial lo construyo desde cero en HTML, CSS y JavaScript, sin canvas y sin dependencias, con la misma rejilla de divs que ya usé en Parejas y en Buscaminas.

Mover las fichas es la parte fácil. Lo que de verdad merece explicarse es cómo se baraja el tablero al empezar una partida nueva, porque ahí hay una trampa matemática que, si la ignoras, te deja con un puzle irresoluble la mitad de las veces.

La idea antes de tocar código

El tablero es una rejilla de 4×4: dieciséis casillas, quince fichas numeradas y un hueco. Solo se puede mover una ficha si está pegada al hueco (arriba, abajo, izquierda o derecha), y moverla consiste simplemente en intercambiarla de sitio con el hueco. No hay más regla que esa. El juego termina cuando las quince fichas están en orden y el hueco ha vuelto a la esquina inferior derecha.

Representar esto en código es sencillo: un array plano de 16 posiciones, donde cada posición guarda el número de la ficha que hay ahí (o un 0 para el hueco). La posición ganadora es, por tanto, siempre la misma: [1, 2, 3, ..., 15, 0].

El problema de barajar

Aquí está la trampa. Si generas una posición inicial completamente al azar —cualquier permutación de los dieciséis valores—, hay un 50% de probabilidades de que ese tablero no tenga solución. No es una cuestión de suerte del jugador: es una propiedad matemática de las permutaciones. Cada intercambio de dos fichas cualesquiera cambia la "paridad" de la permutación, y solo la mitad de las paridades posibles son alcanzables deslizando fichas legalmente desde la posición resuelta. Puedes barajar un tablero real de plástico levantando las fichas con la uña, pero un ordenador que reparte números al azar no tiene ese privilegio, y se puede quedar tranquilamente con un puzle que es imposible de resolver por definición, por mucho que el jugador lo intente.

La solución es tan simple como elegante: en vez de barajar al azar, partimos del tablero resuelto y hacemos un montón de movimientos legales aleatorios, como si un jugador invisible estuviera deslizando fichas sin ton ni son. Como cada movimiento es reversible, el tablero resultante es, por construcción, siempre resoluble.

function mezclar() {
  tablero = SOLUCION.slice();
  blankIndex = TOTAL - 1;
  let anterior = -1;
  for (let i = 0; i < 200; i++) {
    const vecinos = indicesVecinos(blankIndex).filter(v => v !== anterior);
    const elegido = vecinos[Math.floor(Math.random() * vecinos.length)];
    [tablero[blankIndex], tablero[elegido]] = [tablero[elegido], tablero[blankIndex]];
    anterior = blankIndex;
    blankIndex = elegido;
  }
}

Doscientos movimientos aleatorios son de sobra para que el tablero quede irreconocible. El detalle de filter(v => v !== anterior) evita que el hueco deshaga inmediatamente el movimiento que acaba de hacer —sin él, el barajado tiende a quedarse dando vueltas en la misma esquina en vez de repartirse por todo el tablero.

Encontrar los vecinos de una casilla

Tanto para barajar como para decidir qué fichas se pueden mover, necesito una función que, dado el índice de una casilla en el array plano, devuelva los índices de sus vecinas ortogonales (arriba, abajo, izquierda, derecha), descartando las que se salen del tablero:

function indicesVecinos(indice) {
  const fila = Math.floor(indice / COLUMNAS);
  const columna = indice % COLUMNAS;
  const resultado = [];
  if (fila > 0) resultado.push(indice - COLUMNAS);
  if (fila < FILAS - 1) resultado.push(indice + COLUMNAS);
  if (columna > 0) resultado.push(indice - 1);
  if (columna < COLUMNAS - 1) resultado.push(indice + 1);
  return resultado;
}

Esta función se reutiliza tres veces en todo el juego: para barajar, para decidir si un clic sobre una ficha es válido, y para mover el hueco con el teclado. Convertir un índice plano en fila y columna con una división y un módulo, y volver a aplanarlo sumando o restando el número de columnas, es un patrón que aparece constantemente en juegos de rejilla —ya lo usé para los vecinos del Buscaminas, aunque allí las diagonales también contaban.

Mover una ficha

Con indicesVecinos ya resuelto, comprobar si un clic es válido es una sola línea: la ficha pulsada tiene que estar entre los vecinos del hueco.

function manejarClickFicha(indice) {
  if (terminado) return;
  if (!indicesVecinos(blankIndex).includes(indice)) return;
  deslizar(indice);
}

function deslizar(indice) {
  if (!tiempoInicio) iniciarCronometro();
  [tablero[blankIndex], tablero[indice]] = [tablero[indice], tablero[blankIndex]];
  blankIndex = indice;
  movimientos++;
  pintar();
  actualizarMarcadores();

  if (comprobarVictoria()) {
    terminado = true;
    clearInterval(intervaloTiempo);
    mensajeEl.textContent = `¡Resuelto en ${movimientos} movimientos y ${tiempoEl.textContent}!`;
  }
}

deslizar es, en el fondo, un intercambio de posiciones en el array —exactamente el mismo intercambio que usa mezclar, solo que esta vez lo dispara el jugador en vez de un bucle aleatorio. Después de cada movimiento se comprueba si el tablero coincide con la solución comparándolo posición a posición.

Las flechas mueven el hueco, no la ficha

Además del clic, el juego responde al teclado. Aquí conviene fijar un convenio claro, porque es fácil liarse: en este tutorial, la flecha indica hacia dónde se mueve el hueco, no la ficha. Pulsar la flecha derecha mueve el hueco a la derecha, lo que en la práctica desliza hacia la izquierda la ficha que estaba a su derecha.

function moverHueco(direccion) {
  if (terminado) return;
  const fila = Math.floor(blankIndex / COLUMNAS);
  const columna = blankIndex % COLUMNAS;
  let objetivo = -1;
  if (direccion === 'arriba' && fila > 0) objetivo = blankIndex - COLUMNAS;
  if (direccion === 'abajo' && fila < FILAS - 1) objetivo = blankIndex + COLUMNAS;
  if (direccion === 'izquierda' && columna > 0) objetivo = blankIndex - 1;
  if (direccion === 'derecha' && columna < COLUMNAS - 1) objetivo = blankIndex + 1;
  if (objetivo !== -1) deslizar(objetivo);
}

Si la casilla objetivo se sale del tablero (el hueco ya está en el borde), objetivo se queda en -1 y no pasa nada. El evento de teclado, igual que en 2048, solo actúa cuando el ratón está sobre el tablero o el foco está dentro de él, para no interceptar las flechas si el jugador está, por ejemplo, haciendo scroll por el resto del artículo.

El marco rojo

Visualmente quería que se pareciera al juguete de plástico real, no a una rejilla neutra de app. El marco exterior del tablero es rojo bermellón (el mismo tono de acento que usa el resto de paigar.eu, una coincidencia que aproveché con gusto) y cada ficha tiene una sombra inferior sólida que simula el relieve de una tecla de plástico:

.pd-ficha {
  background: #F9F6F2;
  border-radius: 5px;
  box-shadow: 0 3px 0 rgba(0, 0, 0, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.6);
}

.pd-ficha:active {
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.25);
  transform: translateY(2px);
}

La sombra de 3 píxeles hacia abajo, combinada con un brillo interior sutil en el borde superior, da la sensación de una ficha ligeramente elevada. Al pulsarla (:active), la sombra se reduce a 1 píxel y la ficha baja 2 píxeles con translateY: el efecto óptico de un botón que se hunde al presionarlo, sin una sola línea de JavaScript. El hueco, en cambio, no lleva ni fondo ni sombra: dejando que se vea el rojo del marco por debajo, parece literalmente un agujero en el tablero.

Cosas que se pueden añadir

La base es un puzle completo y jugable, pero hay mejoras naturales: animar el deslizamiento de la ficha con una transición CSS en vez de repintar el tablero entero de golpe, guardar el mejor tiempo y el menor número de movimientos en localStorage, o sustituir los números por una imagen partida en dieciséis fragmentos —la versión "foto" de este mismo puzle, que es puramente un cambio de lo que dibuja cada ficha, no de la lógica de movimiento. También sería sencillo añadir un contador de "movimientos mínimos posibles" con un algoritmo de resolución automática (A* con la distancia de Manhattan como heurística es el clásico para este problema), aunque eso ya se sale bastante del alcance de un tutorial introductorio.

El prototipo funcional

Aquí abajo está el juego completo: clic en una ficha junto al hueco, flechas del teclado, o toca la ficha en móvil.

Otros tutoriales de la serie: Buscaminas · Parejas · 2048 · Picas y Fijas.

Abrir en página propia

Puzle Deslizante

Movimientos
0
Tiempo
0:00

Clic en una ficha junto al hueco para deslizarla. También funciona con las flechas del teclado (mueven el hueco) y, en móvil, tocando la ficha.

···
Otras entradas