マッチ3パズルの作り方:盤面の入れ替えと消去
遊びをつくる執筆にClaudeを使用しています。

マッチ3パズルとは、同じ種類のタイルを3つ以上縦か横に並べて消すジャンルです。「キャンディクラッシュ」や「パズル&ドラゴンズ」などで知られるこの形式は、ルールがシンプルな反面、実装すると「どのタイルが消えるか」「消えた後どう落下させるか」という処理の順序設計が意外と複雑です。 この記事では、HTMLとJavaScriptだけでマッチ3パズルを作る手順を解説します。盤面の生成からタイルの入れ替え、消去判定、落下補充まで、処理のつながりを軸に説明します。
この記事の目次
盤面をどう持つか:2次元配列の設計
マッチ3の盤面は、2次元配列(行×列)で管理するのが基本です。たとえば8×8の盤面なら、board[row][col]にタイルの種類を表す数値(0〜4など)を格納します。
初期配列を生成するときの注意点は、最初から3つ以上揃った状態を避けることです。ランダムに数値を埋めると、ゲーム開始直後に即消去が発生してしまいます。これを防ぐには、各セルに値を入れる前に「左2マスと同じ値でないか」「上2マスと同じ値でないか」をチェックし、該当する値を候補から除外します。候補がなくなることは実際にはほぼ起きませんが、念のため「除外しても残った最後の1種類を使う」というフォールバックを入れておくと安全です。
描画は<canvas>を使う方法とHTML要素(div)を使う方法があります。アニメーションを細かく制御したいなら<canvas>が向いていますが、CSSアニメーションで動かすだけならdivのほうが実装が早いです。最初のプロトタイプはdivで作り、動作を確認してから<canvas>に移行するという順番が失敗しにくいです。
入れ替え処理:クリック2回かドラッグか
プレイヤーの操作方式には大きく「タイルを2回クリックして入れ替える」「タイルをドラッグして隣に動かす」の2種類があります。
2回クリック方式は実装が単純です。1回目のクリックで選択状態を記録し、2回目のクリックが「隣接するタイル」ならば入れ替え処理を走らせます。隣接の判定はMath.abs(row1 - row2) + Math.abs(col1 - col2) === 1という条件で確認できます(斜め移動を禁止する場合)。
ドラッグ方式はpointerdown・pointermove・pointerupの3イベントを使います。pointerdownで操作開始座標を記録し、pointerupで終了座標との差分を計算して、上下左右のどちらへの操作かを判定します。差分が一定ピクセル以上でなければ誤操作として無視するしきい値(例:タイルサイズの30%以上)を設けると操作感が改善します。スマートフォンでも動かすならtouchイベントではなくpointerイベントを使うほうが統一して扱えます。
入れ替えた後に3つ揃いが発生しなかった場合は、入れ替えを元に戻す必要があります。この「戻す」処理を忘れると、どの位置でも自由に動かせる不正な状態になります。入れ替え後にマッチ判定を走らせ、一致がなければboard[r1][c1]とboard[r2][c2]を再度交換します。
消去判定:どこで3つ揃いを探すか
消去判定は「全セルをスキャンして3つ揃いを見つける」方式が最もわかりやすいです。横方向と縦方向を別々にスキャンし、消去対象セルの集合(Setや2次元の真偽値配列)に記録します。
横スキャンの手順を番号で示します。
- 各行について列を左から右へ走査します。
- board[r][c]がboard[r][c+1]とboard[r][c+2]と同じ値かどうかを確認します。
- 一致すればその3セルを消去対象に追加します。続くc+3以降も同じ値が続く場合は同様に追加します。
縦スキャンも行と列を入れ替えて同じ処理を行います。この2つのスキャンを1回の判定フェーズにまとめて実行し、消去対象全体を確定してから削除します。「横を消してから縦を判定する」という順序にすると、L字やT字の組み合わせが部分的にしか消えない不具合が起きます。
消去対象が確定したら、対象セルの値を「空き」を示す値(例:-1やnull)に書き換えます。描画側はこの値を受け取って空白として表示します。
落下と補充:処理の順序が重要
消去後のタイルを落下させる処理は、列単位で行います。
各列について、下から上へスキャンして空き(-1)を探します。空きが見つかったら、その上にある最初の非空きセルの値を空きの位置に移動し、元の位置を空きにします。この操作を繰り返すと、空きが列の上部へ「浮き上がる」形で整理されます。
落下が完了したら、上部の空きセルに新しいランダム値を補充します。このとき、補充したタイルが即座に3つ揃いになっても構いません。補充後に再度マッチ判定を走らせ、揃っていれば消去→落下→補充のサイクルを繰り返します(連鎖処理)。サイクルが1回も発火しなくなった時点でプレイヤーの操作を再び受け付けます。
操作受付のオン・オフを忘れると、アニメーション中に次の入れ替えが実行されて盤面が壊れます。isAnimatingのようなフラグを1つ用意し、サイクルが動いている間はtrueに、完全に終わったらfalseに戻す実装が定番です。
アニメーションはオプションです。ロジックが正しく動く状態を先に作り、その後でCSSトランジション(transition: transform 0.2sなど)を乗せるほうが、デバッグが格段に楽になります。
詰まりやすいポイント:よくある実装ミス
スキャン範囲の越境:board[r][c+2]を参照するとき、列数が8ならcは最大5まで(0〜5)しかスキャンしてはいけません。ループの上限をcols - 2にするのを忘れると、undefinedとの比較が発生して一致判定が狂います。
消去と描画の不一致:データ配列の値を変えても画面上のDOM要素やキャンバス描画が古いままになることがあります。「配列を更新する関数」と「配列を読んで描画する関数」を分けておき、必ず描画関数を明示的に呼ぶ設計にすると追いやすくなります。
落下後の再スキャンを忘れる:補充直後に3つ揃いが発生しても消えない、という報告はほぼこの原因です。落下・補充のたびにマッチ判定を再実行するループ構造になっているか確認してください。
有効手がなくなる状態の未処理:長時間プレイしていると「どこを入れ替えても3つ揃いが作れない」状態になることがあります。全隣接ペアを仮に入れ替えてマッチ判定を行い、有効手が0なら盤面を再生成するロジックを入れると完成度が上がります。最初のバージョンでは省略しても問題ありませんが、テストプレイで詰まりが見つかれば優先的に追加してください。
次の行動
まずは8×8の盤面生成と消去判定だけを単体で動かし、コンソールに消去対象座標が正しく出力されることを確認してください。落下・補充・連鎖はその後に追加すると、問題の切り分けが容易になります。動くものができたら、アソバースにHTMLファイル(またはZIPファイル)として投稿して公開できます。
参考資料
- MDN Web Docs — Pointer Events
- MDN Web Docs — Canvas API

