さて、2048系のスライドパズルを自分で実装しようとしたとき、最初にぶつかる設計上の落とし穴が「1本のライン上で[2, 2, 4]や[4, 4, 4]が並んでいるとき、どちら側から優先して合体し、どこで合体を止めるか」という問題です。

もしナイーブなループ処理を書いてしまい、[2, 2, 4]を右へスライドしたときに「まず左の2と2が合体して4になり、その4がさらに右の4と合体して1ターンで8になる」という多重マージ(二段合体)が起きてしまうと、パズルとしての手数管理や偶奇計算が完全に崩壊してしまいます。そこで今回は、js/game-core.jsにおける1ターン1合体原則の実装と、それを教材化したインタラクティブ練習場(/practice)のLessonCoreクラス設計について解説します。

01. 3次元空間の9本(または16本)の独立軸ライン抽出ロジック

まず3次元盤面grid[x][y][z]に対して、プレイヤーが6方向(x+, x-, y+, y-, z+, z-)のいずれかを入力したとき、エンジンは空間全体を「移動軸に沿ったN本の独立した1次元ライン」に分解します。3×3×3なら3 × 3 = 9本、4×4×4なら4 × 4 = 16本の平行な直線になるわけです。

// js/game-core.js : 移動軸に直交する2軸を固定し、移動軸に沿った座標列を取り出す
for (let a = 0; a < this.size; a++) {
  for (let b = 0; b < this.size; b++) {
    const coords = [];
    for (let c = 0; c < this.size; c++) {
      const idx = dir > 0 ? (this.size - 1 - c) : c;
      if (axis === 'x') coords.push({ x: idx, y: a, z: b });
      else if (axis === 'y') coords.push({ x: a, y: idx, z: b });
      else coords.push({ x: a, y: b, z: idx });
    }
    this.processLine(coords, moveEventsAll, mergeEventsAll);
  }
}

このように3重ループで座標列coordsを正規化してしまえば、移動方向がX軸だろうがY軸(上下)だろうがZ軸(奥・手前)だろうが、その後のマージ処理はすべて共通の1次元ライン処理processLine(coords, ...)ひとつに集約できます。3Dだからといって3種類の別々の合体コードを書く必要はないわけですよ。

02. 着地側優先マージと「i += 2」による1ターン1合体原則の保証

では、抽出された1本のラインcoordsを処理するprocessLine()の中身を見てみましょう。

// js/game-core.js : processLine() のコア走査ロジック
const tiles = [];
for (const pos of coords) {
  const tile = this.grid[pos.x][pos.y][pos.z];
  if (tile) tiles.push({ tile, from: pos });
}

const result = [];
let i = 0;
while (i < tiles.length) {
  const current = tiles[i].tile;
  const next = (i + 1 < tiles.length) ? tiles[i + 1].tile : null;

  if (next && current.type !== 'glitch' && next.type !== 'glitch' && current.value === next.value) {
    const newValue = current.value * 2;
    result.push({ value: newValue, merged: true });
    i += 2; // 合体した2個分を一気にスキップし、二段合体を防止!
  } else {
    result.push({ value: current.value, merged: false });
    i += 1;
  }
}

たった十数行のシンプルなコードですが、ここに2048の物理法則を決定づける2つの重要な仕掛けが入っています。

  1. 進行方向の「着地側(壁側)」を先頭にする座標生成(idx = dir > 0 ? (this.size - 1 - c) : c):dir > 0(右x+・上y+・手前z+への移動)のときは、インデックスが大きい側(size - 1)が着地する壁になります。そこで最初に座標配列を作る段階で壁側から逆順に並べることで、「必ず進行方向の壁に近いタイルから順番に走査する」ように正規化しています。
  2. 合体成立時のインデックス2個飛ばし(i += 2):tiles[i]とtiles[i + 1]の値が一致して合体が成立すると、新しく生成されたタイルは直接resultへ格納され、tiles配列には戻されません。さらにi += 2によって合体相手のtiles[i + 1]を飛び越えて次の要素tiles[i + 2]へ移るため、「合体して生まれたばかりのタイルが、同じターン中に後続のタイルと再合体すること」は構造上100%起こり得ないわけです。

03. 具体例で検証:[4, 4, 4] と [2, 2, 4] は1回のスライドでどう変化するか

このアルゴリズムが実際の盤面でどう動くか、プレイヤーがよく迷う2つの具体例でトレースしてみましょう。

1次元ラインにおける着地側優先マージと1ターン1合体原則のトレース表
初期ライン(左→右) 操作方向 正規化配列 tiles(壁側から順) 1ターン後の結果(左→右) 挙動の理由
[4, 4, 4] 左(x-) 左端から走査:[4(左), 4(中), 4(右)] [8, 4, 空] 着地側である左端の4と中央の4が優先合体して8になり、右端の4は合体相手を失って2番目に詰まる。
[4, 4, 4] 右(x+) 右端から走査:[4(右), 4(中), 4(左)] [空, 4, 8] 着地側である右端の4と中央の4が優先合体して8になり、左端の4が中央へ詰まる。
[2, 2, 4] 右(x+) 右端から走査:[4(右), 2(中), 2(左)] [空, 4, 4] まず先頭の4と2を比較(不一致で4が確定)。次に2と2が合体して4になるが、1ターン1合体原則により右端の4とは合体せず[空, 4, 4]で止まる。

この「着地側から優先してペアが組まれる」「1ターンに二段合体はしない」という2つの法則を頭に入れておくと、3×3×3の狭い盤面で「どちらの壁に向かってスライドさせれば狙った位置に8を作れるか」が100%確定的に計算できるようになります。

04. 本番環境と100%同じ物理挙動でストレージを隔離する「LessonCore」クラス

さて、こうした3D特有のスライド挙動やコーナーロック、リカバリー手順を文章だけで説明してもなかなか直感的に掴みにくいため、実際にブラウザ上で固定課題盤面を動かして試せる「インタラクティブ練習場(/practice)」を開発しました。

この練習場を実装するにあたってこだわったのが、「練習場専用の簡易モックを作るのではなく、本番のGame2048Coreクラスをそのまま継承して動かす」という設計です。js/practice-engine.jsの先頭部分を見てみましょう。

// js/practice-engine.js : 本番の Game2048Core を継承し、localStorage への書き込みだけを無効化する
class LessonCore extends Game2048Core {
  loadBestScores() {
    return { classic: 0, hard: 0, party: 0 };
  }
  saveBestScores() {
    // 練習場での操作がプレイヤーの本番ベストスコアを上書きしないよう no-op 化
  }
}

もし練習場用に別物のスライド関数を書いてしまうと、後から本番の仕様を調整した際に「本番と練習場で微妙に挙動が違う」というバグの温床になります。そこでclass LessonCore extends Game2048Coreとして本番エンジンを直接継承し、localStorageを読み書きするloadBestScores()とsaveBestScores()だけをオーバーライドして無害化(no-op化)しました。さらに、練習場ではランダムな新規タイルの出現(spawnTilesForMove)を停止できるため、純粋に「この1手で盤面がどう移動・合体するか」だけを何度でも再現実験できるわけです。

05. 全6方向の1手先を並列シミュレーションする「compare(core)」の仕組み

もうひとつ、練習場の目玉機能として実装したのが、現在の盤面から「上下左右前後(全6方向)に動かしたらそれぞれどうなるか」をリアルタイムで一覧比較するインスペクタ機能です。これもjs/practice-engine.js内で、現在のLessonCoreインスタンスを6個クローンして実際にmove()を走らせることで実現しています。

具体的には、compare(core)関数を使って全6方向それぞれの「有効移動かどうか」「何組マージするか」「コーナーの最大タイル(512)が剥がれずに維持されているか」「空きマスがいくつになるか」をその場で自動採点し、テーブルとして可視化しています。頭の中で3次元配列の反転と合体をイメージするのが難しいときは、まず練習場のドリルで6方向の比較テーブルを眺めながら手を動かしてみるのが一番の近道だと思うわけです。

次回(第6回)は、3Dキューブの死角(内部コア(1,1,1))を可視化する「X-Ray透過モード・Layer SlicerのWebGLマテリアル制御と、画像ファイル0枚のCanvas2D動的テクスチャ生成」について解説します。それでは、また次回の記事で!

Interactive Tactical Lab

インタラクティブ練習場で6方向比較テーブルを試す

角固定・空きマス確保・着地側優先マージの挙動を、新規タイル出現なしの固定局面で1手ずつ検証できます。

📐 インタラクティブ練習場を開く ➔ 📘 角固定の図解ガイドを読む