We Blog Weblog

15パズル

Web

2025年2月15日

みなさんこんにちは。
ケミストのWeb担当みやのです。

「Web」記事では、これまでに得たWebに関する知識を記録として残していきたいと思います。

今回は「15パズル」を作ってみました。

こんな感じになればクリアです。さあレッツプレイ!

おめでとう!完成です!

HTML

まずは枠組みを作っていきます。

<div id="fifteen">
  <div class="tile" id="tile-0"></div>
  <div class="tile" id="tile-1"></div>
  <div class="tile" id="tile-2"></div>
  <div class="tile" id="tile-3"></div>
  <div class="tile" id="tile-4"></div>
  <div class="tile" id="tile-5"></div>
  <div class="tile" id="tile-6"></div>
  <div class="tile" id="tile-7"></div>
  <div class="tile" id="tile-8"></div>
  <div class="tile" id="tile-9"></div>
  <div class="tile" id="tile-10"></div>
  <div class="tile" id="tile-11"></div>
  <div class="tile" id="tile-12"></div>
  <div class="tile" id="tile-13"></div>
  <div class="tile" id="tile-14"></div>
  <div class="tile empty" id="tile-15"></div>
</div>
<button id="shuffle" onclick="shuffleAndSetColors()">シャッフル</button>
<h4 id="victory-message">おめでとう!完成です!</h4>

タイルを16個作ります(最後の1個は空白タイルです)

シャッフルボタンと、勝利メッセージを配置します。

CSS

CSSは以下の通りです。

#fifteen {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  grid-template-rows: repeat(4, 1fr);
  gap: 5px;
  width: 100%;
  max-width: 400px;
  aspect-ratio: 1 / 1;
  margin: 20px auto;
  z-index: -1;
}
.tile {
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: #4CAF50;
  color: white;
  border-radius: 5px;
  cursor: pointer;
  transition: transform 0.2s ease;
  width: 100%;
  height: 100%;
  font-size: 3rem;
  font-weight: 700;
}
.empty {
  background-color: transparent;
  cursor: default;
}
#victory-message {
  margin-top: 20px;
  display: none;
}

グリッドレイアウトで4×4で配置します。

勝利メッセージは非表示にしておきます。

JavaScript

ここからが俺のステージだ!本番です。

1~15までの数字と空白をランダムに配置するのですが、

15パズルには、最終的にこんな感じになってしまう「絶対に解けない配置」が存在するようです。

「逆転数」と「空白タイルの行位置」の合計が奇数になる場合は「絶対に解けない配置」となります。

逆転数とは、数値を順番に並べていって、数値の順番が逆転している組み合わせがあれば1とカウントします。これをすべてのタイル1つずつでカウントしていって合計します。

上記の例では、タイルを空白を除いて並べると[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 14]となり、1~14では逆転は起こっておらず、15の後に14が来ているので逆転数は1となり、空白タイルは4行目にあるので、これは「絶対に解けない配置」となります。

この配置は避けたいところです。

タイルをシャッフルする関数を作ります。

function shuffleTiles() {
  const order = Array.from({ length: 16 }, (_, i) => i);
  let emptyRow = 3, emptyCol = 3;
  for (let i = 0; i < 1000; i++) {
    const direction = ['up', 'down', 'left', 'right'];
    const moves = direction.map(dir => getMove(emptyRow, emptyCol, dir)).filter(move => move !== null);
    const move = moves[Math.floor(Math.random() * moves.length)];
    if (move) {
      const [newRow, newCol, index] = move;
      [order[emptyRow * 4 + emptyCol], order[newRow * 4 + newCol]] = [order[newRow * 4 + newCol], order[emptyRow * 4 + emptyCol]];
      emptyRow = newRow;
      emptyCol = newCol;
    }
  }
  order.forEach((value, index) => {
    if (value < 15) {
      tiles.eq(index).text(value + 1).removeClass('empty');
      tiles.eq(index).css('background-color', tileColors[value]);
    } else {
      tiles.eq(index).text('').addClass('empty');
      emptyIndex = index;
      tiles.eq(index).css('background-color', 'transparent');
    }
  });
  victoryMessage.hide(); 
  stopFireworks();
}
function getMove(row, col, direction) {
    switch (direction) {
      case 'up': return row > 0 ? [row - 1, col, (row - 1) * 4 + col] : null;
      case 'down': return row < 3 ? [row + 1, col, (row + 1) * 4 + col] : null;
      case 'left': return col > 0 ? [row, col - 1, row * 4 + col - 1] : null;
      case 'right': return col < 3 ? [row, col + 1, row * 4 + col + 1] : null;
      default: return null;
    }
}

「ランダムに配置して、そこから解けない配置を除外する」のではなく「正解から1000手ランダムに動かす」ことで絶対に解けるような配置にしました。


せっかくなので、タイルの色が4色(赤、青、緑、黄)のうちからランダムに変わるようにしてみました。

function setRandomTileColor() {
  const colors = ['#007bff', '#28a745', '#dc3545', '#ffc107'];
  const randomColor = colors[Math.floor(Math.random() * colors.length)];
  tiles.each(function(index) {
    if (!$(this).hasClass('empty')) {
      tileColors[index] = randomColor;
      $(this).css('background-color', randomColor);
    } else {
      $(this).css('background-color', 'transparent');
    }
  });
}
function shuffleAndSetColors() {
  shuffleTiles();
  setRandomTileColor();
}

シャッフルボタンを押すことでshuffleTiles()とsetRandomTileColorの両方が発動するようにしておきます。


最後に、タイルの移動とゲームクリアに関する関数です。

function slideTile(index) {
  if (isAnimating) return;
  const [emptyRow, emptyCol] = [Math.floor(emptyIndex / 4), emptyIndex % 4];
  const [tileRow, tileCol] = [Math.floor(index / 4), index % 4];
  const isAdjacent = (Math.abs(emptyRow - tileRow) === 1 && emptyCol === tileCol) || (Math.abs(emptyCol - tileCol) === 1 && emptyRow === tileRow);
  if (isAdjacent) {
    isAnimating = true;
    const tileToMove = tiles.eq(index);
    const emptyTile = tiles.eq(emptyIndex);
    tileToMove.animate({ 
      left: `${(emptyCol - tileCol) * 100}%`,
      top: `${(emptyRow - tileRow) * 100}%`
    }, 200, function() {
      emptyTile.text(tileToMove.text()).removeClass('empty').css('background-color', tileToMove.css('background-color'));
      tileToMove.text('').addClass('empty').css('background-color', 'transparent');
      emptyIndex = index;
      tileToMove.css({ left: 0, top: 0 });
      isAnimating = false;
      if (isWinningCondition()) {
        setTimeout(() => {
          victoryMessage.show();
          fireworks();
        }, 200);
      }
    });
    tileToMove.css({
      position: 'relative',
      left: 0,
      top: 0
    }).css('z-index', 1);
  }
}
function isWinningCondition() {
  for (let i = 0; i < 15; i++) {
    if (parseInt(tiles.eq(i).text()) !== i + 1) {
      return false;
    }
  }
  return true;
}
tiles.each(function(index) {
  $(this).on('click', () => slideTile(index));
});
$(document).ready(function() {
  setRandomTileColor();
  shuffleTiles();
});

今後の課題

・各タイルに画像をはめ込みたい
→画像だけにすると難易度が上がりそうなので、画像+数字+枠線にして完成したら数字と枠線が消えて絵柄が完成する、という感じにしてみたいです。

・タッチデバイスに対応させたい
→スワイプの動きにタイルがついてくる動きを試してみたのですが、枠をはみ出しちゃったりして断念しました。

・まとめてタイルが動くようにしたい
→空白タイル関連で相当苦戦したので、これも難しいかも・・・

この記事を書いた人
みやの
Web・DTP担当

Contact Us

ご意見、ご相談、料金のお見積もりなど、お気軽にお問い合わせください。

お問い合わせはこちら

TOP