Saltar al contenido
GameGenresEnglish
Puzle

Combinación de fichas

Mueves, intercambias o eliges fichas en una cuadrícula para juntar piezas iguales, que desaparecen y dejan caer otras. El reto es leer el tablero y preparar reacciones en cadena.

También se llama: combina tres, combinar 3, match-3, unir fichas

Mecánicas que lo definen

  • Un tablero en cuadrícula lleno de fichas de pocos tipos, que se distinguen por color, forma o ambos.
  • Una acción para reunir iguales, que según el juego es intercambiar dos vecinas, elegir un grupo, emparejar dos fichas libres o dejar caer piezas.
  • Las combinaciones desaparecen, las fichas de arriba caen y entran nuevas, lo que puede formar más combinaciones en cascada.
  • Un límite que da tensión, como un número de movimientos, un reloj o un tablero que se va llenando.
  • Combinar más de tres suele crear fichas especiales que limpian zonas grandes del tablero.

Bucle de juego

Recorrer el tablero con la vista, encontrar una combinación (o la que prepara una cascada), hacerla, ver cómo cae todo y volver a buscar.

Demo propia

Controles. Haz clic o toca una figura y luego una vecina (arriba, abajo o a los lados), o arrastra una figura sobre su vecina. Espacio o Enter empiezan la partida.

Abrir en pantalla completa

Un tablero de 8 × 8 figuras de colores. Intercambias dos vecinas para formar líneas de tres o más iguales, que desaparecen; las de arriba caen, entran figuras nuevas y a veces se forman cascadas que multiplican los puntos. Tienes 20 movimientos para llegar a la meta.

Hecha con Kaplay, en unas 196 líneas de JavaScript.

Cómo está hecha

  1. Configuración

    Se fijan el tamaño del tablero, los movimientos y la meta de puntos, que son los números que más cambian la dificultad. Cada tipo de figura tiene un color y también una forma distinta (círculo, cuadrado, triángulo, rombo, hexágono y pentágono), para que el tablero se lea sin depender solo del color.

    kaplay({
      width: 640, height: 360, letterbox: true, pixelDensity, background: [30, 24, 48],
      // The board is played with the mouse or by touch; the keyboard only starts the game.
      buttons: { start: { keyboard: ["space", "enter"] } },
    });
    
    const SIZE = 8; // the board is SIZE × SIZE cells
    const CELL = 40;
    const BOARD_X = 290; // top-left corner of the board, in pixels
    const BOARD_Y = 20;
    const MOVES = 20;
    const TARGET = 1600; // points needed before running out of moves
    const SLIDE_SPEED = 700; // pixels per second for swaps and falls
    
    // Each kind of gem has a colour AND a shape, so the board can be read without relying on colour.
    const polygon = (sides, r, turn) =>
      Array.from({ length: sides }, (_, i) => Vec2.fromAngle(turn - 90 + (i * 360) / sides).scale(r));
    const GEMS = [
      { color: rgb(235, 80, 90), pts: null }, // circle
      { color: rgb(250, 200, 60), pts: polygon(4, 17, 45) }, // square
      { color: rgb(90, 200, 110), pts: polygon(3, 18, 0) }, // triangle
      { color: rgb(80, 150, 240), pts: polygon(4, 17, 0) }, // diamond
      { color: rgb(180, 110, 230), pts: polygon(6, 16, 0) }, // hexagon
      { color: rgb(245, 145, 60), pts: polygon(5, 16, 0) }, // pentagon
    ];
  2. El tablero como matriz

    El tablero es una matriz de 8 × 8 en la que cada casilla guarda una figura: su tipo y cuánto le falta en pantalla para llegar a su sitio (dx, dy). Al crearlo se vuelve a sortear toda figura que formaría tres en línea con las dos de su izquierda o las dos de arriba, para que la partida no empiece con combinaciones hechas.

    // The board is a matrix of gems: grid[y][x] = { kind, dx, dy }. dx/dy is how far the
    // gem still is from its cell on screen; it shrinks to 0 to animate swaps and falls.
    const newGem = (dy = 0) => ({ kind: randi(GEMS.length), dx: 0, dy });
    // A fresh board with no lines already made: re-roll any gem that would complete
    // three in a row with the two to its left or the two above it.
    function newBoard() {
      const grid = [];
      for (let y = 0; y < SIZE; y++) {
        grid.push([]);
        for (let x = 0; x < SIZE; x++) {
          const left = x >= 2 && grid[y][x - 1].kind === grid[y][x - 2].kind ? grid[y][x - 1].kind : -1;
          const above = y >= 2 && grid[y - 1][x].kind === grid[y - 2][x].kind ? grid[y - 1][x].kind : -1;
          let gem;
          do gem = newGem();
          while (gem.kind === left || gem.kind === above);
          grid[y].push(gem);
        }
      }
      return grid;
    }
    const swap = (grid, a, b) => ([grid[a.y][a.x], grid[b.y][b.x]] = [grid[b.y][b.x], grid[a.y][a.x]]);
  3. Detectar combinaciones

    findMatches() recorre cada casilla y mira si ella y las dos siguientes, a la derecha o hacia abajo, son del mismo tipo. Si lo son, guarda las tres en un conjunto. Los tríos se solapan, así que una línea de cuatro o cinco queda marcada entera, y el conjunto evita contar dos veces la misma casilla cuando una línea horizontal y una vertical se cruzan. hasMove() prueba todos los intercambios posibles y los deshace, para saber si queda alguna jugada.

    // Look at every cell: if it and the next two (to the right, or below) are the same
    // kind, those three are part of a line. Overlapping triples also cover lines of 4 or 5.
    function findMatches(grid) {
      const found = new Set();
      const kindAt = (x, y) => grid[y]?.[x]?.kind;
      for (let y = 0; y < SIZE; y++) {
        for (let x = 0; x < SIZE; x++) {
          const kind = kindAt(x, y);
          if (kind === undefined) continue;
          if (kindAt(x + 1, y) === kind && kindAt(x + 2, y) === kind) [0, 1, 2].forEach((i) => found.add(`${x + i},${y}`));
          if (kindAt(x, y + 1) === kind && kindAt(x, y + 2) === kind) [0, 1, 2].forEach((i) => found.add(`${x},${y + i}`));
        }
      }
      return found;
    }
    
    // Is there any swap left that would make a line? Try them all and undo each one.
    function hasMove(grid) {
      const works = (a, b) => {
        swap(grid, a, b);
        const ok = findMatches(grid).size > 0;
        swap(grid, a, b);
        return ok;
      };
      for (let y = 0; y < SIZE; y++)
        for (let x = 0; x < SIZE; x++)
          if ((x + 1 < SIZE && works({ x, y }, { x: x + 1, y })) || (y + 1 < SIZE && works({ x, y }, { x, y: y + 1 }))) return true;
      return false;
    }
  4. La gravedad del tablero

    Tras borrar una línea, la matriz queda con huecos. applyGravity() recorre cada columna de abajo hacia arriba y baja cada figura a la casilla libre más baja; luego rellena las de arriba con figuras nuevas. En los datos, la figura cambia de casilla al instante; en pantalla, recibe un desplazamiento negativo (dy) que se reduce a cero en cada fotograma y así se ve caer. Las nuevas empiezan por encima del tablero.

    // Column by column, from the bottom up: each gem drops into the lowest free cell,
    // then new gems fill the top, starting above the board so they visibly fall in.
    function applyGravity(grid) {
      for (let x = 0; x < SIZE; x++) {
        let free = SIZE - 1; // lowest empty row in this column
        for (let y = SIZE - 1; y >= 0; y--) {
          const gem = grid[y][x];
          if (!gem) continue;
          grid[y][x] = null;
          grid[free][x] = gem;
          gem.dy -= (free - y) * CELL; // it is drawn where it was and slides down
          free--;
        }
        for (let y = free; y >= 0; y--) grid[y][x] = newGem(-(free + 1) * CELL);
      }
    }
  5. El intercambio

    Intercambiar es cambiar dos casillas de la matriz y animar el deslizamiento. La regla del género es que solo vale el intercambio que forma al menos una línea; si no la forma, se deshace y no gasta movimiento. Mientras dura la animación, el tablero no acepta nuevas jugadas.

    const adjacent = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1;
    // After swapping in the matrix, each gem starts drawn at the other's place and slides.
    function slideSwap(a, b) {
      swap(grid, a, b);
      Object.assign(grid[a.y][a.x], { dx: (b.x - a.x) * CELL, dy: (b.y - a.y) * CELL });
      Object.assign(grid[b.y][b.x], { dx: (a.x - b.x) * CELL, dy: (a.y - b.y) * CELL });
    }
    async function trySwap(a, b) {
      [busy, selected] = [true, null];
      slideSwap(a, b);
      await settle();
      if (findMatches(grid).size === 0) {
        // Only swaps that make a line are allowed: undo it, and it costs no move.
        slideSwap(a, b);
        await settle();
        say(t({ es: "Ese cambio no forma una línea", en: "That swap makes no line" }));
      } else {
        moves--;
        await resolve();
        if (score >= TARGET || moves === 0) return go("end", score >= TARGET, score);
      }
      busy = false;
    }
  6. Cascadas y multiplicador

    resolve() repite un ciclo: encontrar líneas, borrarlas, dejar caer y rellenar, y volver a buscar. Cada vuelta después de la primera es una cascada que el jugador no hizo de forma directa, y puntúa multiplicada por el número de vuelta. Ahí está la profundidad del género: una jugada en la parte baja del tablero mueve más figuras y tiene más probabilidades de desatar una cadena.

    // Clear → fall → refill → look again. Every extra round is a cascade the player did
    // not make directly, and it scores with a growing multiplier.
    async function resolve() {
      let chain = 1;
      for (let found = findMatches(grid); found.size > 0; found = findMatches(grid), chain++) {
        clearing = found; // highlighted for a moment before vanishing
        await sleep(0.25);
        const points = found.size * 10 * chain;
        score += points;
        if (chain > 1) say(t({ es: "Cascada", en: "Cascade" }) + ` ×${chain}  +${points}`);
        // Empty those cells: the matrix now has holes for gravity to fill.
        for (const [x, y] of [...found].map((key) => key.split(",").map(Number))) grid[y][x] = null;
        clearing = new Set();
        applyGravity(grid);
        await settle();
      }
      // No swap left anywhere? Deal a new board so the game never gets stuck.
      if (!hasMove(grid)) {
        grid = newBoard();
        say(t({ es: "Sin jugadas: tablero nuevo", en: "No moves left: new board" }));
      }
    }
  7. Clic, toque y arrastre

    cellAt() convierte la posición del puntero en una casilla. Al soltar el botón, si empezaste en una figura y terminas en su vecina, es un arrastre y se intercambian. Si soltaste en la misma, es un clic que selecciona la figura, y el siguiente clic en una vecina hace el intercambio. Kaplay trata el toque como un clic, así que funciona igual en móvil.

    // Click or tap a gem and then a neighbour, or drag from one gem onto its neighbour.
    const cellAt = (p) => {
      const [x, y] = [Math.floor((p.x - BOARD_X) / CELL), Math.floor((p.y - BOARD_Y) / CELL)];
      return x >= 0 && x < SIZE && y >= 0 && y < SIZE ? { x, y } : null;
    };
    const same = (a, b) => a && b && a.x === b.x && a.y === b.y;
    function pick(cell) {
      if (selected && adjacent(selected, cell)) trySwap(selected, cell);
      else selected = same(selected, cell) ? null : cell;
    }
    let pressed = null;
    onMousePress(() => (pressed = cellAt(mousePos())));
    onMouseRelease(() => {
      const cell = cellAt(mousePos());
      if (!busy && cell && pressed) {
        if (adjacent(pressed, cell)) trySwap(pressed, cell); // drag
        else if (same(pressed, cell)) pick(cell); // click
      }
      pressed = null;
    });
  8. Animación y dibujo

    En cada fotograma, onUpdate acerca a cero los desplazamientos de todas las figuras a velocidad constante, y onDraw pinta cada una en su casilla más su desplazamiento. Las figuras que van a desaparecer se resaltan un instante. El marcador muestra los puntos, la meta, los movimientos que quedan y una barra de progreso.

    const toward0 = (v, step) => Math.sign(v) * Math.max(0, Math.abs(v) - step);
    onUpdate(() => {
      const step = SLIDE_SPEED * dt();
      grid.forEach((row) => row.forEach((g) => g && Object.assign(g, { dx: toward0(g.dx, step), dy: toward0(g.dy, step) })));
      messageTime -= dt();
    });
    const outline = (cell, c) =>
      drawRect({ pos: vec2(BOARD_X + cell.x * CELL + 2, BOARD_Y + cell.y * CELL + 2), width: CELL - 4, height: CELL - 4, fill: false, outline: { width: 3, color: c } });
    onDraw(() => {
      drawRect({ pos: vec2(BOARD_X, BOARD_Y), width: SIZE * CELL, height: SIZE * CELL, color: rgb(18, 14, 32), radius: 6 });
      grid.forEach((row, y) =>
        row.forEach((g, x) => {
          const p = vec2(BOARD_X + x * CELL + CELL / 2 + g?.dx, BOARD_Y + y * CELL + CELL / 2 + g?.dy);
          if (!g || p.y < BOARD_Y) return; // gems still above the board are not drawn yet
          if (clearing.has(`${x},${y}`)) drawCircle({ pos: p, radius: 19, color: rgb(255, 255, 255), opacity: 0.7 });
          const { color, pts } = GEMS[g.kind];
          if (pts) drawPolygon({ pos: p, pts, color });
          else drawCircle({ pos: p, radius: 15, color });
        }),
      );
      if (selected) outline(selected, rgb(255, 255, 255));
      const hud = t({ es: `Puntos: ${score} / ${TARGET}\nMovimientos: ${moves}`, en: `Score: ${score} / ${TARGET}\nMoves: ${moves}` });
      drawText({ text: hud, pos: vec2(24, 28), size: 18, font: FONT, lineSpacing: 10 });
      drawRect({ pos: vec2(24, 100), width: 240, height: 10, color: rgb(60, 50, 90), radius: 5 });
      drawRect({ pos: vec2(24, 100), width: 240 * Math.min(1, score / TARGET), height: 10, color: rgb(250, 200, 60), radius: 5 });
      if (messageTime > 0) drawText({ text: message, pos: vec2(24, 130), size: 16, font: FONT, width: 250, color: rgb(255, 220, 140) });
    });
  9. Escenas

    El juego tiene tres escenas (title, game y end) y go() salta de una a otra. La partida termina al llegar a la meta o al gastar el último movimiento.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
      add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 200, 255)]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () =>
      screen(
        t({ es: "Combina tres", en: "Match three" }),
        t({
          es: `Intercambia dos figuras vecinas para formar una línea de tres o más iguales.\nConsigue ${TARGET} puntos en ${MOVES} movimientos; las cascadas multiplican.\nToca una figura y luego su vecina, o arrástrala.\n\nPulsa espacio, Enter o toca para empezar.`,
          en: `Swap two neighbouring pieces to make a line of three or more alike.\nScore ${TARGET} points in ${MOVES} moves; cascades multiply.\nTap a piece and then its neighbour, or drag it.\n\nPress space, Enter or tap to start.`,
        }),
        () => go("game"),
      ),
    );
    
    scene("end", (won, score) =>
      screen(
        won ? t({ es: "¡Meta alcanzada!", en: "Target reached!" }) : t({ es: "Se acabaron los movimientos", en: "Out of moves" }),
        t({ es: `Puntos: ${score} de ${TARGET}.\nPulsa espacio o toca para jugar otra vez.`, en: `Score: ${score} of ${TARGET}.\nPress space or tap to play again.` }),
        () => go("game"),
      ),
    );
    
    go("title");

Qué simplificamos

  • No hay figuras especiales; en el género, una línea de cuatro o cinco suele crear una pieza que explota o borra una fila entera.
  • Hay una sola meta (puntos) y un solo nivel; los juegos comerciales encadenan cientos de niveles con objetivos distintos y obstáculos en el tablero.
  • Si no queda ninguna jugada posible se reparte un tablero nuevo entero, en lugar de barajar las figuras que hay.
  • Las figuras se dibujan con formas simples, sin sonido ni efectos de partículas.

Para modificarla

  • Haz que una línea de cuatro cree una figura especial que, al combinarse, borre toda su fila.
  • Añade un botón de pista que resalte una jugada válida. Puedes reutilizar la lógica de hasMove().
  • Cambia el número de tipos de figura (de 6 a 5 o a 7) y anota cómo cambian la cantidad de cascadas y la dificultad.
  • Diseña un nivel con otro objetivo, por ejemplo borrar 15 figuras de un tipo en 15 movimientos.
Código completo
// Tile-matching (match-three) demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";

// #region setup
kaplay({
  width: 640, height: 360, letterbox: true, pixelDensity, background: [30, 24, 48],
  // The board is played with the mouse or by touch; the keyboard only starts the game.
  buttons: { start: { keyboard: ["space", "enter"] } },
});

const SIZE = 8; // the board is SIZE × SIZE cells
const CELL = 40;
const BOARD_X = 290; // top-left corner of the board, in pixels
const BOARD_Y = 20;
const MOVES = 20;
const TARGET = 1600; // points needed before running out of moves
const SLIDE_SPEED = 700; // pixels per second for swaps and falls

// Each kind of gem has a colour AND a shape, so the board can be read without relying on colour.
const polygon = (sides, r, turn) =>
  Array.from({ length: sides }, (_, i) => Vec2.fromAngle(turn - 90 + (i * 360) / sides).scale(r));
const GEMS = [
  { color: rgb(235, 80, 90), pts: null }, // circle
  { color: rgb(250, 200, 60), pts: polygon(4, 17, 45) }, // square
  { color: rgb(90, 200, 110), pts: polygon(3, 18, 0) }, // triangle
  { color: rgb(80, 150, 240), pts: polygon(4, 17, 0) }, // diamond
  { color: rgb(180, 110, 230), pts: polygon(6, 16, 0) }, // hexagon
  { color: rgb(245, 145, 60), pts: polygon(5, 16, 0) }, // pentagon
];
// #endregion

// #region board
// The board is a matrix of gems: grid[y][x] = { kind, dx, dy }. dx/dy is how far the
// gem still is from its cell on screen; it shrinks to 0 to animate swaps and falls.
const newGem = (dy = 0) => ({ kind: randi(GEMS.length), dx: 0, dy });
// A fresh board with no lines already made: re-roll any gem that would complete
// three in a row with the two to its left or the two above it.
function newBoard() {
  const grid = [];
  for (let y = 0; y < SIZE; y++) {
    grid.push([]);
    for (let x = 0; x < SIZE; x++) {
      const left = x >= 2 && grid[y][x - 1].kind === grid[y][x - 2].kind ? grid[y][x - 1].kind : -1;
      const above = y >= 2 && grid[y - 1][x].kind === grid[y - 2][x].kind ? grid[y - 1][x].kind : -1;
      let gem;
      do gem = newGem();
      while (gem.kind === left || gem.kind === above);
      grid[y].push(gem);
    }
  }
  return grid;
}
const swap = (grid, a, b) => ([grid[a.y][a.x], grid[b.y][b.x]] = [grid[b.y][b.x], grid[a.y][a.x]]);
// #endregion

// #region matches
// Look at every cell: if it and the next two (to the right, or below) are the same
// kind, those three are part of a line. Overlapping triples also cover lines of 4 or 5.
function findMatches(grid) {
  const found = new Set();
  const kindAt = (x, y) => grid[y]?.[x]?.kind;
  for (let y = 0; y < SIZE; y++) {
    for (let x = 0; x < SIZE; x++) {
      const kind = kindAt(x, y);
      if (kind === undefined) continue;
      if (kindAt(x + 1, y) === kind && kindAt(x + 2, y) === kind) [0, 1, 2].forEach((i) => found.add(`${x + i},${y}`));
      if (kindAt(x, y + 1) === kind && kindAt(x, y + 2) === kind) [0, 1, 2].forEach((i) => found.add(`${x},${y + i}`));
    }
  }
  return found;
}

// Is there any swap left that would make a line? Try them all and undo each one.
function hasMove(grid) {
  const works = (a, b) => {
    swap(grid, a, b);
    const ok = findMatches(grid).size > 0;
    swap(grid, a, b);
    return ok;
  };
  for (let y = 0; y < SIZE; y++)
    for (let x = 0; x < SIZE; x++)
      if ((x + 1 < SIZE && works({ x, y }, { x: x + 1, y })) || (y + 1 < SIZE && works({ x, y }, { x, y: y + 1 }))) return true;
  return false;
}
// #endregion

// #region gravity
// Column by column, from the bottom up: each gem drops into the lowest free cell,
// then new gems fill the top, starting above the board so they visibly fall in.
function applyGravity(grid) {
  for (let x = 0; x < SIZE; x++) {
    let free = SIZE - 1; // lowest empty row in this column
    for (let y = SIZE - 1; y >= 0; y--) {
      const gem = grid[y][x];
      if (!gem) continue;
      grid[y][x] = null;
      grid[free][x] = gem;
      gem.dy -= (free - y) * CELL; // it is drawn where it was and slides down
      free--;
    }
    for (let y = free; y >= 0; y--) grid[y][x] = newGem(-(free + 1) * CELL);
  }
}
// #endregion

scene("game", () => {
  let grid = newBoard();
  let [score, moves, busy, selected, clearing, message, messageTime] = [0, MOVES, false, null, new Set(), "", 0];
  const say = (text) => ([message, messageTime] = [text, 1.5]);
  const sleep = (s) => new Promise((done) => wait(s, done));
  const moving = () => grid.some((row) => row.some((g) => g && (g.dx || g.dy)));
  const settle = async () => { while (moving()) await sleep(0.02); };

  // #region swap
  const adjacent = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y) === 1;
  // After swapping in the matrix, each gem starts drawn at the other's place and slides.
  function slideSwap(a, b) {
    swap(grid, a, b);
    Object.assign(grid[a.y][a.x], { dx: (b.x - a.x) * CELL, dy: (b.y - a.y) * CELL });
    Object.assign(grid[b.y][b.x], { dx: (a.x - b.x) * CELL, dy: (a.y - b.y) * CELL });
  }
  async function trySwap(a, b) {
    [busy, selected] = [true, null];
    slideSwap(a, b);
    await settle();
    if (findMatches(grid).size === 0) {
      // Only swaps that make a line are allowed: undo it, and it costs no move.
      slideSwap(a, b);
      await settle();
      say(t({ es: "Ese cambio no forma una línea", en: "That swap makes no line" }));
    } else {
      moves--;
      await resolve();
      if (score >= TARGET || moves === 0) return go("end", score >= TARGET, score);
    }
    busy = false;
  }
  // #endregion

  // #region cascades
  // Clear → fall → refill → look again. Every extra round is a cascade the player did
  // not make directly, and it scores with a growing multiplier.
  async function resolve() {
    let chain = 1;
    for (let found = findMatches(grid); found.size > 0; found = findMatches(grid), chain++) {
      clearing = found; // highlighted for a moment before vanishing
      await sleep(0.25);
      const points = found.size * 10 * chain;
      score += points;
      if (chain > 1) say(t({ es: "Cascada", en: "Cascade" }) + ` ×${chain}  +${points}`);
      // Empty those cells: the matrix now has holes for gravity to fill.
      for (const [x, y] of [...found].map((key) => key.split(",").map(Number))) grid[y][x] = null;
      clearing = new Set();
      applyGravity(grid);
      await settle();
    }
    // No swap left anywhere? Deal a new board so the game never gets stuck.
    if (!hasMove(grid)) {
      grid = newBoard();
      say(t({ es: "Sin jugadas: tablero nuevo", en: "No moves left: new board" }));
    }
  }
  // #endregion

  // #region input
  // Click or tap a gem and then a neighbour, or drag from one gem onto its neighbour.
  const cellAt = (p) => {
    const [x, y] = [Math.floor((p.x - BOARD_X) / CELL), Math.floor((p.y - BOARD_Y) / CELL)];
    return x >= 0 && x < SIZE && y >= 0 && y < SIZE ? { x, y } : null;
  };
  const same = (a, b) => a && b && a.x === b.x && a.y === b.y;
  function pick(cell) {
    if (selected && adjacent(selected, cell)) trySwap(selected, cell);
    else selected = same(selected, cell) ? null : cell;
  }
  let pressed = null;
  onMousePress(() => (pressed = cellAt(mousePos())));
  onMouseRelease(() => {
    const cell = cellAt(mousePos());
    if (!busy && cell && pressed) {
      if (adjacent(pressed, cell)) trySwap(pressed, cell); // drag
      else if (same(pressed, cell)) pick(cell); // click
    }
    pressed = null;
  });
  // #endregion

  // #region draw
  const toward0 = (v, step) => Math.sign(v) * Math.max(0, Math.abs(v) - step);
  onUpdate(() => {
    const step = SLIDE_SPEED * dt();
    grid.forEach((row) => row.forEach((g) => g && Object.assign(g, { dx: toward0(g.dx, step), dy: toward0(g.dy, step) })));
    messageTime -= dt();
  });
  const outline = (cell, c) =>
    drawRect({ pos: vec2(BOARD_X + cell.x * CELL + 2, BOARD_Y + cell.y * CELL + 2), width: CELL - 4, height: CELL - 4, fill: false, outline: { width: 3, color: c } });
  onDraw(() => {
    drawRect({ pos: vec2(BOARD_X, BOARD_Y), width: SIZE * CELL, height: SIZE * CELL, color: rgb(18, 14, 32), radius: 6 });
    grid.forEach((row, y) =>
      row.forEach((g, x) => {
        const p = vec2(BOARD_X + x * CELL + CELL / 2 + g?.dx, BOARD_Y + y * CELL + CELL / 2 + g?.dy);
        if (!g || p.y < BOARD_Y) return; // gems still above the board are not drawn yet
        if (clearing.has(`${x},${y}`)) drawCircle({ pos: p, radius: 19, color: rgb(255, 255, 255), opacity: 0.7 });
        const { color, pts } = GEMS[g.kind];
        if (pts) drawPolygon({ pos: p, pts, color });
        else drawCircle({ pos: p, radius: 15, color });
      }),
    );
    if (selected) outline(selected, rgb(255, 255, 255));
    const hud = t({ es: `Puntos: ${score} / ${TARGET}\nMovimientos: ${moves}`, en: `Score: ${score} / ${TARGET}\nMoves: ${moves}` });
    drawText({ text: hud, pos: vec2(24, 28), size: 18, font: FONT, lineSpacing: 10 });
    drawRect({ pos: vec2(24, 100), width: 240, height: 10, color: rgb(60, 50, 90), radius: 5 });
    drawRect({ pos: vec2(24, 100), width: 240 * Math.min(1, score / TARGET), height: 10, color: rgb(250, 200, 60), radius: 5 });
    if (messageTime > 0) drawText({ text: message, pos: vec2(24, 130), size: 16, font: FONT, width: 250, color: rgb(255, 220, 140) });
  });
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
  add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(210, 200, 255)]);
  onButtonPress("start", next);
  onClick(next);
}

scene("title", () =>
  screen(
    t({ es: "Combina tres", en: "Match three" }),
    t({
      es: `Intercambia dos figuras vecinas para formar una línea de tres o más iguales.\nConsigue ${TARGET} puntos en ${MOVES} movimientos; las cascadas multiplican.\nToca una figura y luego su vecina, o arrástrala.\n\nPulsa espacio, Enter o toca para empezar.`,
      en: `Swap two neighbouring pieces to make a line of three or more alike.\nScore ${TARGET} points in ${MOVES} moves; cascades multiply.\nTap a piece and then its neighbour, or drag it.\n\nPress space, Enter or tap to start.`,
    }),
    () => go("game"),
  ),
);

scene("end", (won, score) =>
  screen(
    won ? t({ es: "¡Meta alcanzada!", en: "Target reached!" }) : t({ es: "Se acabaron los movimientos", en: "Out of moves" }),
    t({ es: `Puntos: ${score} de ${TARGET}.\nPulsa espacio o toca para jugar otra vez.`, en: `Score: ${score} of ${TARGET}.\nPress space or tap to play again.` }),
    () => go("game"),
  ),
);

go("title");
// #endregion

Retos de diseño

  • Generar el tablero y el relleno al azar sin que aparezcan combinaciones ya hechas, comprobar si queda alguna jugada y barajar si no.
  • Encadenar las animaciones de intercambio, desaparición, caída, relleno y cascada sin que la entrada del jugador rompa el estado, separando la lógica del dibujo.
  • Que el tablero se lea bien, con formas distintas además de colores para quien no los distingue y una pista si el jugador pasa un rato sin mover.
  • Medir la suerte, porque los rellenos al azar deciden muchas cascadas y la dificultad de un nivel solo se conoce jugándolo muchas veces, a menudo con un programa que lo simula.
  • Diseñar muchos niveles con objetivos y obstáculos distintos sin que se sientan repetidos.

Historia

  1. 1985
    OrigenSameGame

    Chain Shot! (1985), de Kuniaki Moribe, luego llamado SameGame: eliminas grupos de fichas del mismo color y las demás caen al hueco.

    SameGame (Wikipedia)
  2. 1986
    Shanghai

    Shanghai, de Activision, lleva a la computadora el emparejamiento del mahjong solitario.

    Shanghai (video game) (Wikipedia)
  3. 1989
    Columns

    Columns, un juego de bloques que caen, da origen a una línea de juegos de combinación basados en desplazar o intercambiar fichas.

    Tile-matching video game (Wikipedia)
  4. 1994
    Shariki

    Shariki, de Eugene Alemzhin, es el primero de los juegos que luego se llamarían «combina tres», en los que formas grupos de tres o más fichas iguales en una cuadrícula.

    Tile-matching video game (Wikipedia)
  5. 1995
    Tetris Attack

    Panel de Pon (Tetris Attack fuera de Japón) lleva el intercambio de fichas a un montón que sube sin parar desde abajo, con modo para dos jugadores.

    Tetris Attack (Wikipedia)
  6. 2001
    Bejeweled

    Sale Bejeweled, que había nacido en 2000 como juego de navegador. Se le atribuye haber popularizado el combina tres y lanzado la industria de los juegos casuales.

    Bejeweled (video game) (Wikipedia)
  7. 2007
    Puzzle Quest: Challenge of the Warlords

    Puzzle Quest une el combina tres con el juego de rol, de modo que los combates se libran por turnos sobre un tablero de gemas.

    Puzzle Quest: Challenge of the Warlords (Wikipedia)
  8. 2012
    Candy Crush Saga

    Candy Crush Saga, de King, sale en Facebook y luego en móviles como juego gratuito por niveles, cada uno con un objetivo que se debe cumplir en un número fijo de movimientos.

    Candy Crush Saga (Wikipedia)

Juegos de referencia

1985

SameGame

Kuniaki Moribe. FM-8, FM-7

Eliges un grupo de bloques vecinos del mismo color para quitarlo, y los de arriba caen a ocupar el hueco. El reto es vaciar el tablero lo más posible. Salió en 1985 con el nombre Chain Shot!, publicado en la revista japonesa Gekkan ASCII.

1986

Shanghai

Activision (Brodie Lockard). Mac, Amiga, Atari ST, Commodore 64, MS-DOS

Un solitario de fichas de mahjong apiladas en el que retiras parejas iguales que estén libres por un lado. Hay que elegir el orden con cuidado para no quedar bloqueado.

1989

Columns

Jay Geertsen. HP 9000, MS-DOS, Mac, arcade, Mega Drive

Caen columnas de tres joyas de colores que puedes reordenar mientras bajan; tres o más iguales en línea, también en diagonal, desaparecen. Jay Geertsen lo compartió en 1989 y Sega lo lanzó en arcades y consolas.

1994

Shariki

Eugene Alemzhin. MS-DOS

Intercambias dos bolas vecinas para alinear tres o más del mismo color, que desaparecen y dejan caer otras nuevas. Cada intercambio debe formar una línea, y la partida acaba cuando ya no queda ninguno posible.

1995

Tetris Attack

Intelligent Systems. Super Nintendo, Game Boy

Los bloques suben sin parar desde abajo y tú intercambias dos vecinos en horizontal para alinear tres o más del mismo color. Salió en Japón como Panel de Pon y, pese a su nombre occidental, no tiene relación con Tetris.

2001

Bejeweled

PopCap Games. navegador, Windows

Intercambias dos gemas vecinas para alinear tres o más iguales. Nació en 2000 como el juego de navegador Diamond Mine y su versión comercial salió en 2001; se le atribuye haber popularizado el combina tres y lanzado la industria de los juegos casuales.

2007

Puzzle Quest: Challenge of the Warlords

Infinite Interactive. Nintendo DS, PlayStation Portable

Mezcla el juego de rol con el combina tres. Los combates se libran por turnos en un tablero de gemas, donde cada combinación puede dañar al rival o dar maná para lanzar hechizos, y entre combate y combate exploras un mundo de fantasía.

2012

Candy Crush Saga

King. Facebook, iOS, Android

Intercambias caramelos vecinos para alinear tres o más; las combinaciones de cuatro o más crean caramelos especiales. Cada nivel tiene un objetivo que se debe cumplir en un número fijo de movimientos. Es gratuito, con compras dentro del juego.

2018

Emoji Swap

Curtis Robinson. navegador

Combina tres con defensa de torres. Cada línea de tres emojis crea una torre según su tipo y, cuando se te acaban los intercambios, cae la noche e invaden los alienígenas. Se hizo para la competencia js13kGames 2018.

Ejercicio

Juega la demo dos veces y anota qué jugadas provocaron cascadas y en qué parte del tablero estaban. Luego cambia TARGET, MOVES o la cantidad de tipos de figura hasta ganar más o menos la mitad de las partidas, y explica qué número cambiaste y por qué.

Fuentes