ブラウザ版マージ2048の実装を解説
4×4の盤面、タイルの移動と合成、Undo、キーボード・タッチ操作、アニメーション、BGMまで、数字合成パズルの実装を順番に紹介します。
マージ2048は、同じ数字のタイルを重ねて合成し、2048のタイルを作るパズルゲームです。ルールはシンプルですが、移動方向ごとの処理、同じ手の中での合成回数、操作方法の違い、手を戻す機能など、状態管理を学べる題材が多く含まれています。
今回のゲームは、PHPで共通ヘッダーやページの枠組みを読み込み、盤面の更新と入力処理はブラウザ上のJavaScriptで行っています。盤面はCanvasではなくHTML要素として描画し、CSSで数字ごとの色と移動アニメーションを設定しました。
盤面を16個の配列で管理する
4×4の盤面は、16個の数値を持つ一次元配列として管理しています。値が0なら空きマス、それ以外ならタイルの数字です。行と列から配列の位置を求める式を共通に使うことで、表示や隣接マスの確認を同じデータに対して行えます。
let board = Array(16).fill(0);
let score = 0;
let best = 0;
let history = [];
let won = false;
let ended = false;scoreとbestは表示する得点、historyはUndo用の履歴です。wonとendedを分けておくことで、2048を作った後も盤面を続けて遊べる状態と、ゲームオーバーで操作を止める状態を区別できます。
最初のタイルを空きマスへ置く
新しいゲームでは、16マスを空にしてから2枚のタイルを追加します。空きマスのインデックスだけを集め、その中からランダムに一つ選びます。盤面に空きがない場合は-1を返し、追加処理を行いません。
function addTile() {
const empty = board
.map((value, index) => value === 0 ? index : -1)
.filter(index => index >= 0);
if (!empty.length) return -1;
const index = empty[Math.floor(Math.random() * empty.length)];
board[index] = 2;
return index;
}実際のゲームでは、盤面にある大きな数字の段階を参考にして、2の累乗から新しい数字を選びます。追加された位置を戻り値で渡しておくと、描画後にそのタイルだけ出現アニメーションを付けられます。
移動方向ごとの1列を作る
上下左右の移動を個別に書くと、同じ処理の重複が増えます。そこで、移動方向と列番号を受け取り、その方向から見た4マスのインデックスを返すlineIndicesを用意しています。左向きなら左から右、右向きなら右から左の順に並べます。
function lineIndices(direction, line) {
const indexes = [];
for (let step = 0; step < 4; step++) {
const row = direction === 'up' || direction === 'down'
? (direction === 'up' ? step : 3 - step) : line;
const column = direction === 'left' || direction === 'right'
? (direction === 'left' ? step : 3 - step) : line;
indexes.push(row * 4 + column);
}
return indexes;
}たとえば左移動では各行を左から調べ、上移動では各列を上から調べます。方向に応じた順番を先に作ることで、以降の「空白を詰める」「隣り合う同じ数字を合成する」という処理を全方向で共有できます。
空白を詰めて同じ数字を合成する
1列の処理では、まず0を除いた数字だけを取り出します。先頭から順に結果へ追加し、直前の数字と同じなら2枚を一つの数字に置き換えます。合成後の値をスコアへ加算し、最後に4マスになるまで0を追加します。
const values = indexes.map(index => board[index]).filter(Boolean);
const result = [];
values.forEach(value => {
result.push(value);
const tail = result.length - 1;
if (tail > 0 && result[tail] === result[tail - 1]) {
const mergedValue = result[tail] * 2;
result.splice(tail - 1, 2, mergedValue);
score += mergedValue;
}
});
while (result.length < 4) result.push(0);合成した直後に同じタイルをもう一度合成しないよう、結果配列へ置き換えた位置を基準に処理しています。たとえば「2、2、2」では、最初の2枚だけが4になり、残った2とは同じ手の中で再合成されません。2048のルールを正しく保つために重要な部分です。
スワイプとキーボードで入力を分ける
キーボードと画面上の方向ボタンでは盤面全体を動かします。一方、タッチ操作では指を置いたタイルを起点にし、スワイプした方向へそのタイルだけを動かします。pointerdownで開始位置を保存し、pointerupで移動距離と方向を計算します。
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
touchStart = null;
if (Math.max(Math.abs(dx), Math.abs(dy)) < 25) return;
const direction = Math.abs(dy) >= Math.abs(dx)
? (dy > 0 ? 'down' : 'up')
: (dx > 0 ? 'right' : 'left');
moveSelected(start.index, direction);移動距離が25ピクセル未満ならタップとみなし、盤面を動かしません。横と縦の絶対値を比べて主な方向を決めるため、斜めに近いスワイプでも一方向へ安定して判定できます。キーボードではArrowキーとWASDを同じ方向名へ変換してmoveへ渡しています。
Undoのために変更前の盤面を保存する
移動前の盤面とスコアをコピーしてhistoryへ保存し、Undoを押したときに最後の履歴を戻します。配列そのものを保存すると、後の移動で内容が書き換わるため、sliceでコピーを作る必要があります。
const before = board.slice();
const beforeScore = score;
// タイルを移動・合成する処理
history.push({ board: before, score: beforeScore });
if (history.length > 20) history.shift();履歴を最大20手に制限しているため、長時間遊んでも保存するデータが増え続けません。Undo後は盤面を再描画し、保存内容も更新します。終了後はUndoを無効にして、結果画面の状態と食い違わないようにしています。
描画とアニメーションを分ける
ゲームの正しい状態はboardにあり、renderは配列をHTMLへ反映する役割に絞っています。移動後に、追加された位置と合成された位置を別々に渡し、新しいタイルの出現や合成時の拡大をCSSアニメーションで表示します。
function render() {
boardEl.replaceChildren();
board.forEach(value => {
const cell = document.createElement('div');
cell.className = 'merge-cell';
if (value) {
cell.className += ` merge-tile merge-v${Math.min(value, 2048)}`;
cell.textContent = value;
cell.setAttribute('aria-label', `${value}のタイル`);
} else {
cell.setAttribute('aria-label', '空きマス');
}
boardEl.append(cell);
});
}移動中のタイルは、移動元と移動先の座標を取得して一時的なゴースト要素を作ります。CSSのtransformで移動元から移動先まで動かし、合成後のタイルには別のクラスを付けて拡大表示します。これにより、動いたタイル、追加されたタイル、合成されたタイルを見分けられます。
スコア、クリア、ゲームオーバーを判定する
移動できたときだけ新しいタイルを追加し、2048を含むか、もう動かせない状態かを確認します。盤面に空きマスがなく、上下左右の隣接マスにも同じ数字がなければゲームオーバーです。
function isBlocked() {
if (board.includes(0)) return false;
for (let row = 0; row < 4; row++) {
for (let column = 0; column < 4; column++) {
const value = board[row * 4 + column];
if (column < 3 && value === board[row * 4 + column + 1]) {
return false;
}
if (row < 3 && value === board[(row + 1) * 4 + column]) {
return false;
}
}
}
return true;
}同じ数字が横か縦に隣り合っていれば、まだ合成できるためゲームオーバーではありません。斜めの隣接は合成できないので確認しません。2048を作ったときは結果表示を出しますが、さらに高い数字を目指せるように盤面自体は残しています。
localStorageで盤面と設定を保存する
ブラウザを閉じても続きから遊べるよう、盤面、スコア、クリア状態をlocalStorageへ保存しています。BGM、効果音、音量も別の設定キーに保存し、次回アクセス時に復元します。保存に失敗してもゲーム自体は遊べるよう、読み書きはtry...catchで囲んでいます。
function saveGame() {
try {
localStorage.setItem(gameKey, JSON.stringify({
board,
score,
won,
ended
}));
localStorage.setItem('mitys-merge-2048-best', String(best));
} catch (error) {
// 保存できない環境でもゲームは継続する
}
}BGMと効果音をWeb Audio APIで生成する
音声ファイルは読み込まず、Web Audio APIの発振器とゲインを使ってBGMと効果音を生成しています。BGMと効果音を別のゲインへ接続しておくことで、サウンド設定を個別に切り替えられます。ブラウザの自動再生制限があるため、最初の操作でAudioContextを初期化または再開します。
function musicTick() {
if (!bgmOn || ended) return;
const notes = [196, 0, 246.94, 293.66, 0, 369.99];
const note = notes[musicStep++ % notes.length];
if (note) tone(note, .34, 'triangle', .035, 0, 'bgm');
}
function startMusic() {
if (!musicTimer && audio && audio.state === 'running' && bgmOn) {
musicTick();
musicTimer = setInterval(musicTick, 330);
}
}音符配列に0を入れることで、一定間隔の休符も表現しています。ゲーム終了時にはタイマーを止め、タブが非表示になった場合もブラウザの負荷や意図しない再生を抑えます。音量スライダーはマスターゲインを更新するため、BGMと効果音の両方へ反映されます。
実装して分かったこと
マージ2048では、見た目のタイルを動かすことよりも、移動前の順序を保ったまま空白を詰め、合成の回数を正しく制御することが重要です。盤面を配列で管理し、移動方向に応じたインデックスを先に作ることで、4方向の処理を一つの流れにまとめられました。
ゲームを遊ぶときは、実際のマージ2048のページで、キーボード、方向ボタン、スワイプそれぞれの操作を試せます。コードを読むときは、まずaddTile、move、moveSelected、isBlockedの4つを追うと、盤面の変化とゲーム全体の流れをつかみやすくなります。