Reactで、数字を並べ替える15パズルを作る方法

​​React​CSS,Playwright,TypeScript

15パズルの盤面は、16個の数の配列で持てます。左上から右へ、上の段から下の段へ読んだ順に並べ、空きマスを 0 にします。押したタイルが空きマスの上下左右の隣なら、2つの値を入れ替えるだけで1手が進みます。

ただし、作るときにつまずきやすい点が3つあります。ランダムに混ぜると半分は解けない並びになること、配列の位置の差だけで隣を判定すると段の端から次の段へ動けてしまうこと、タイルの描き方によっては下へ動かしたタイルが滑らずに飛ぶことです。どれも、書き換えたものと比べて確かめました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で操作しました。Firefox と Safari、スマートフォンのブラウザでのタップやスワイプ、スクリーンリーダーでの読み上げは試していません。

水色のタイルが4×4の枠に並び、いちばん下の段が「空き 13 14 15」になっている。左上に「手数:0」、右上に「クリック 0回目/4回」。カーソルが6を押しても何も動かず、手数は0のまま。続けて13、14、15を順に押すと、それぞれ左の空きマスへ滑り、手数が1、2、3と増える。そろうとタイルが濃い水色に変わり、下に「クリア! 3手でそろいました」と出る
空きマスの隣でない6を押しても動かず、13、14、15を押すと1枚ずつ滑ってそろう様子。カーソル、押した瞬間の輪、回数の表示、手数が増えたときのピンクは GIF 用に重ねたもの。「並べ直す」ボタンは隠している

盤面の配列と、空きマスの隣だけを動かす関数

盤面の操作は、React から切り離して puzzle.ts にまとめました。

ts
// 盤面は、左上から右へ、上から下へ読んだ順の配列。0 は空きマス
export type Board = number[];
export const SIZE = 4; // 横に並ぶマスの数

// そろった盤面(1, 2, …, 15, 空き)
export function solvedBoard(size = SIZE): Board {
  const n = size * size;
  return Array.from({ length: n }, (_, i) => (i === n - 1 ? 0 : i + 1));
}

export function isSolved(board: Board): boolean {
  return board.every((v, i) => v === (i === board.length - 1 ? 0 : i + 1));
}

// 位置 i と j が、上下左右に隣り合っているか
export function isAdjacent(i: number, j: number, size = SIZE): boolean {
  const [ri, ci] = [Math.floor(i / size), i % size];
  const [rj, cj] = [Math.floor(j / size), j % size];
  return Math.abs(ri - rj) + Math.abs(ci - cj) === 1;
}

// 位置 i のタイルを空きマスへ動かした新しい盤面を返す。動かせないときは null
export function move(board: Board, i: number, size = SIZE): Board | null {
  const blank = board.indexOf(0);
  if (!isAdjacent(i, blank, size)) return null;
  const next = [...board];
  [next[i], next[blank]] = [next[blank], next[i]];
  return next;
}

// 解ける並びかどうか(転倒数と、空きマスの段から判定する)
export function isSolvable(board: Board, size = SIZE): boolean {
  const tiles = board.filter((v) => v !== 0);
  let inversions = 0;
  for (let a = 0; a < tiles.length; a++) {
    for (let b = a + 1; b < tiles.length; b++) {
      if (tiles[a] > tiles[b]) inversions++;
    }
  }
  if (size % 2 === 1) return inversions % 2 === 0;
  const rows = board.length / size;
  const blankRowFromBottom = rows - Math.floor(board.indexOf(0) / size); // いちばん下の段が 1
  return (inversions + blankRowFromBottom) % 2 === 1;
}

// 解けて、まだそろっていない並びを作る
export function shuffleBoard(size = SIZE, rand: () => number = Math.random): Board {
  const a = solvedBoard(size);
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  if (!isSolvable(a, size)) {
    // 空きマス以外の2枚を入れ替えると、解けるかどうかが反対になる
    const [p, q] = [0, 1, 2].filter((k) => a[k] !== 0);
    [a[p], a[q]] = [a[q], a[p]];
  }
  return isSolved(a) ? shuffleBoard(size, rand) : a;
}

move は、押した位置 i と空きマスの位置が隣り合っていれば、2つを入れ替えた新しい配列を返します。元の配列は書き換えません。React の state として使うので、新しい配列を返す形にしています。

隣かどうかを調べる isAdjacent では、位置を段(Math.floor(i / size))と列(i % size)に分けてから比べています。段の差と列の差を足して1なら、上下左右の隣です。

位置の差だけで判定すると、段の端から次の段へ動く

隣の判定は、Math.abs(i - j) === 1 || Math.abs(i - j) === size のように、位置の差が1か4かで書くこともできそうです。ところが、この書き方では、1段目の右端(位置3)と2段目の左端(位置4)も、差が1なので隣と判定されます。

isAdjacent をこの書き方に変えたもので試しました。空きマスが2段目の左端にある並び(1 2 3 4 / 空き 5 6 7 / …)で、1段目の右端の 4 を押すと、4 が2段目の左端に移り、手数が1になりました。記事のコードでは、同じ操作で何も動かず、手数は0のままでした。

4×4の盤面で調べると、位置の差で隣と判定される組は27組あり、そのうち3組は実際には隣ではありませんでした。1〜3段目の右端と、次の段の左端の組です。

この動きは見た目がおかしいだけではありません。動かす前の並びは解ける並びでしたが、段をまたいで 4 を動かした後の並びは、次の節の判定で解けない並びになりました。

ただ混ぜると、半分は解けない並びになる

1〜15と空きマスを Fisher–Yates のシャッフルで混ぜただけでは、タイルを動かしてそろえられる並びになるとは限りません。たとえば、そろった盤面から 14 と 15 だけを入れ替えた並びは、isSolvable の判定で解けない並びになりました。検証環境で10万回混ぜたところ、isSolvable が false になった並びは 49,943 回でした。

isSolvable は、2つの数で判定しています。1つ目は転倒数です。空きマスを除いてタイルを読んだ順に並べたとき、前にあるほうが大きい組の数です。2つ目は、空きマスが下から何段目にあるかです。横のマスの数が4のように偶数のときは、この2つの和が奇数なら解ける並びです。横3×縦3のように横のマスの数が奇数のときは、転倒数が偶数なら解ける並びです。

この判定が合っているかは、小さい盤面で確かめました。そろった盤面からタイルを動かしてたどり着ける並びを、幅優先探索ですべて集め、すべての並べ方について isSolvable の結果と比べました。

text
横3×縦3: 並べ方 362880 通り / 動かしてたどり着ける 181440 通り / isSolvable が true 181440 通り / 判定が一致 362880 通り
横4×縦2: 並べ方 40320 通り / 動かしてたどり着ける 20160 通り / isSolvable が true 20160 通り / 判定が一致 40320 通り
横2×縦2: 並べ方 24 通り / 動かしてたどり着ける 12 通り / isSolvable が true 12 通り / 判定が一致 24 通り

横3×縦3、横4×縦2、横2×縦2のどれも、たどり着ける並びはちょうど半分で、すべての並べ方で isSolvable の結果と一致しました。

4×4は並べ方が16!(20,922,789,888,000)通りあるので、2つの方法で確かめました。そろった盤面から200手ずつランダムに動かした並びを1万通り作ると、どれも isSolvable が true でした。また、ランダムな並びを10万通り作り、それぞれで動かせる1手をすべて試した 299,818 回で、動かす前と後の isSolvable の結果が変わったことは1度もありませんでした。1手で結果が変わらないのであれば、isSolvable が true のそろった並びには、false の並びからはたどり着けません。

shuffleBoard では、混ぜた並びが解けないときに、空きマス以外の2枚を入れ替えています。同じ10万通りの並びで、この2枚を入れ替えると、どれも isSolvable の結果が反対になりました。最後に、たまたまそろった並びになっていたら、混ぜ直します。shuffleBoard で10万回作った並びは、解けない並びも、そろっている並びも0回でした。

押した数と矢印キーで動かすコンポーネント

コンポーネントは次のとおりです。state は盤面の配列 board と、手数の moves の2つです。

tsx
import { useState, type KeyboardEvent } from 'react';
import { SIZE, isSolved, move, shuffleBoard, type Board } from './puzzle';

type Props = { initial?: Board }; // initial は、決まった並びから始めたいとき(検証用)

// 矢印キーの向きに動かすタイルが、空きマスから見てどちらにあるか
const FROM: Record<string, [number, number]> = {
  ArrowUp: [1, 0], ArrowDown: [-1, 0], ArrowLeft: [0, 1], ArrowRight: [0, -1],
};

export function FifteenPuzzle({ initial }: Props) {
  const [board, setBoard] = useState<Board>(() => initial ?? shuffleBoard());
  const [moves, setMoves] = useState(0);
  const solved = isSolved(board);

  function tryMove(i: number) {
    if (solved) return;
    const next = move(board, i);
    if (!next) return; // 空きマスの隣でなければ何もしない
    setBoard(next);
    setMoves((m) => m + 1);
  }

  function onKeyDown(e: KeyboardEvent) {
    const d = FROM[e.key];
    if (!d) return;
    e.preventDefault();
    const blank = board.indexOf(0);
    const r = Math.floor(blank / SIZE) + d[0];
    const c = (blank % SIZE) + d[1];
    if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) return;
    tryMove(r * SIZE + c);
  }

  function reset() {
    setBoard(shuffleBoard());
    setMoves(0);
  }

  // タイルは番号の順に並べ、位置は transform で決める(DOM の順番を変えない)
  const tiles = Array.from({ length: SIZE * SIZE - 1 }, (_, k) => k + 1);

  return (
    <div className="puzzle">
      <p className="moves">手数:{moves}</p>
      <div className="board" onKeyDown={onKeyDown}>
        {tiles.map((n) => {
          const i = board.indexOf(n);
          return (
            <button
              key={n}
              type="button"
              className="tile"
              style={{ transform: `translate(${(i % SIZE) * 100}%, ${Math.floor(i / SIZE) * 100}%)` }}
              onClick={() => tryMove(i)}
              disabled={solved}
            >
              {n}
            </button>
          );
        })}
      </div>
      <p className="status" role="status">{solved ? `クリア! ${moves}手でそろいました` : ''}</p>
      <button type="button" className="reset" onClick={reset}>並べ直す</button>
    </div>
  );
}

そろったかどうかは state に持たせず、描画のたびに isSolved(board) で求めています。そろったら、タイルを disabled にして押せなくし、「クリア! 3手でそろいました」のような文を出します。initial は、決まった並びから始めて確かめるために付けた props です。渡さなければ shuffleBoard で混ぜた並びから始まります。

検証環境で、Math.random をシードを固定した乱数に置き換え、Playwright で操作しました。最初の並びは「10 4 9 1 / 14 5 2 15 / 7 12 3 13 / 6 8 11 空き」でした。空きマスの隣でない 10 を押しても、並びは変わらず、手数は0のままでした。空きマスの上にある 13 を押すと、13 が空きマスへ移り、手数が1になりました。「並べ直す」を押すと、別の並びになり、手数は0に戻りました。

3手でそろう並び(いちばん下の段が「空き 13 14 15」)から始めて、13、14、15 を順に押すと、「クリア! 3手でそろいました」と表示され、15枚のタイルがすべて disabled になりました。

矢印キーは、空きマスへ向かう向きで考える

タイルは <button> なので、Tab でフォーカスを移して Enter や Space で押せます。検証では、3手でそろう並びで Tab を13回押すと 13 にフォーカスが移り、Enter を押すと 13 が動きました。続けて Tab で 14 に移り、Space を押すと 14 が動きました。それに加えて、盤面の <div> に onKeyDown を付け、矢印キーでも動かせるようにしました。ボタンで起きたキーのイベントは盤面の <div> まで伝わるので、どのタイルにフォーカスがあっても受け取れます。

FROM は、押した矢印の向きにタイルを動かすとき、そのタイルが空きマスから見てどこにあるかを表しています。← を押したら、空きマスの右のタイルを左へ動かします。↑ なら、空きマスの下のタイルを上へ動かします。その位置が盤面の外なら、何もしません。e.preventDefault() は、矢印キーの既定の動作を止めるために呼んでいます。ページを縦に長くして試すと、盤面の外の「並べ直す」ボタンにフォーカスして ↓ を押したときはページが 40px スクロールしました。タイルにフォーカスして ↓ を押したときは、タイルが動き、ページはスクロールしませんでした。

3手でそろう並びで、タイルにフォーカスして ← を3回押すと、13、14、15 が順に左へ動き、そろいました。空きマスが右端にあるときに ← を押すと、何も動かず、手数も増えませんでした。

タイルを番号の順に描いて、位置は transform で決める

タイルを並べる CSS は次のとおりです。

css
.board {
  position: relative; width: 288px; height: 288px;
  background: #eefafd; border: 4px solid #00c8e8; border-radius: 8px; box-sizing: content-box;
}
.tile {
  position: absolute; left: 0; top: 0; width: 72px; height: 72px;
  padding: 0; border: 3px solid #eefafd; border-radius: 10px; box-sizing: border-box;
  background: #00c8e8; color: #fff; font-size: 26px; font-weight: bold; cursor: pointer;
  transition: transform 0.15s ease-out;
}
.tile:disabled { cursor: default; background: #067a98; }
@media (prefers-reduced-motion: reduce) {
  .tile { transition: none; }
}

盤面の <div> を position: relative にし、タイルはすべて左上に重ねて置きます。そのうえで、コンポーネントの中で translate(列×100%, 段×100%) を指定して、それぞれのマスへずらしています。タイルの大きさは 72px で、検証環境では1回動かすと、タイルがちょうど 72px(1マス分)移りました。transform に transition を付けているので、位置が変わると 0.15 秒かけて滑ります。

コンポーネントでは、タイルを盤面の配列の順ではなく、1〜15の番号の順に描いています。位置が変わっても、DOM の中のボタンの順番は変わりません。

盤面の配列の順で描くと、下へ動かすタイルだけが飛ぶ

tiles.map の代わりに board.map((n, i) => …) で、盤面の配列の順にタイルを描くものと比べました。key はどちらも番号(n)にしてあります。向きごとに1枚ずつ動かし、押してから30ミリ秒後のタイルの位置と、そのタイルで動いている transition の数を調べると、次のようになりました。

動かす向き 番号の順に描いたもの 盤面の配列の順に描いたもの
左へ・右へ 途中まで動いている(transition 1) 途中まで動いている(transition 1)
上へ 途中まで動いている(transition 1) 途中まで動いている(transition 1)
下へ 途中まで動いている(transition 1) もう動いた先にある(transition 0)

混ぜた並びから40手続けて動かしても、盤面の配列の順に描いたものでは、下へ動かした10手はどれも transition がなく、ほかの向きの30手はすべて transition がありました。番号の順に描いたものは、40手すべてで transition がありました。

右端の列の2段目と3段目を切り出し、8 のタイルを下へ動かしたときの 0ms、50ms、100ms、150ms を横に並べた画像が2段ある。上の段(タイルを番号の順に描いたもの)では、8 が上のマスから下のマスへ少しずつ下がっていく。下の段(盤面の配列の順で描いたもの)では、0ms の時点からもう 8 が下のマスにある
8 を下へ動かしたとき。番号の順に描いたものは滑り、盤面の配列の順に描いたものは最初から動いた先にある

盤面の <div> にボタンが追加し直されたかを、MutationObserver で記録すると、違いが見えました。盤面の配列の順に描いたものでは、8 を下へ動かすと、8 のボタンが追加し直されていました。12 を上へ動かしたときは、追加し直されたのは 13、14、15 のボタンで、動かした 12 は追加し直されていませんでした。左や右へ動かしたときは、追加し直されたボタンはありませんでした。番号の順に描いたものでは、どの向きでも、追加し直されたボタンはありませんでした。

試した4つの動きのうち、動かしたタイル自身が追加し直されたのは、下へ動かしたときだけでした。検証環境では、そのタイルの transition が始まらず、動いた先にすぐ表示されました。空きマスは描いていないので、左右に動かしてもタイルの順番は変わりません。番号の順に描けば、配列の中身が変わってもボタンの順番はいつも同じです。

React の公式ドキュメントにある、key が必要な理由の説明には、並べ替えで位置が変わっても、key のおかげで React はその項目を一意に識別できる、と書かれています。今回の比較では、key を番号にしていても、盤面の配列の順に描くと、ボタンが DOM に追加し直されました。このパズルでは、タイルを番号の順に描くことで、どの向きに動かしても滑るようになりました。

動きを減らす設定のとき

CSS の最後では、端末で動きを減らす設定が有効なとき(prefers-reduced-motion が reduce のとき)に、transition を外しています(MDN にある prefers-reduced-motion の説明)。Playwright で動きを減らす設定(reducedMotion: 'reduce')にすると、タイルの transition-duration は 0s になりました。この設定で 13 を押すと、動いている transition はなく、13 はすぐに空きマスの位置へ移りました。

​Posted by 86