ジュエルカラムの実装を解説
落下する3個の宝石、盤面への固定、縦・横・斜めの連結、消去と連鎖まで、ゲームページの実装を順番に紹介します。
ジュエルカラムは、3個の宝石を縦に落とし、同じ色を3つ以上つなげて消していく落ちものパズルです。左右移動と回転だけで遊べますが、落下中の組と盤面に固定された宝石を分けること、縦・横・斜めのつながりを探すこと、消去後に宝石を下へ詰めて連鎖を判定することが重要になります。
今回のゲームは、PHPで共通ヘッダーとページの枠組みを読み込み、ゲームの状態管理と描画はブラウザ上のJavaScriptで行っています。盤面はHTML要素のグリッドとして作り、CSSで宝石の色、落下、消去、連鎖を表現しています。BGMと効果音にはWeb Audio APIを使っています。
盤面と落下中の組を分けて管理する
盤面は6列13行の二次元配列です。空きマスはnull、宝石があるマスは色と表示用の状態を持つオブジェクトにします。一方、落下中の3個はcurrentとして別に持ち、着地するまで盤面へ書き込みません。
const W = 6;
const H = 13;
let board;
let current;
let score;
let running = false;
let paused = false;
let busy = false;
const makePiece = () => ({
x: 2,
y: -3,
colors: [randomColor(), randomColor(), randomColor()]
});current.yを画面上端より上の負の値から始めることで、3個組が少しずつ盤面へ入ってくるようにしています。busyは消去アニメーションや落下処理の途中で入力を止めるためのフラグです。途中の盤面へ別の操作が割り込むと、連鎖処理と描画の順番が壊れてしまいます。
落下する宝石を移動できるか判定する
3個組は常に縦に並び、colorsの0番目が上、2番目が下になります。移動前には、3個それぞれの行を計算し、左右の範囲、下端、既存の宝石との重なりを確認します。盤面の外に出ている上側だけは許可します。
const valid = (piece, y = piece.y, x = piece.x) =>
piece.colors.every((_, index) => {
const row = y + index;
return x >= 0 && x < W &&
(row < 0 || (row < H && !board[row][x]));
});
const move = dx => {
if (!running || paused || busy || !current) return;
if (valid(current, current.y, current.x + dx)) {
current.x += dx;
render();
}
};移動後の座標をvalidへ渡し、問題がなければcurrent.xだけを更新します。先に盤面へ書き込んでから戻す方法にせず、仮の座標を検査してから状態を変更することで、壁や他の宝石へめり込む操作を防げます。
回転は色の順番を入れ替える
このゲームの回転は、3個組の形を変えるのではなく、縦に並ぶ宝石の順番を入れ替えます。下の宝石を上へ移し、上の2個を一つずつ下へ送ることで、同じ列のまま色の配置を回転できます。
const rotate = () => {
if (!running || paused || busy || !current) return;
const rotated = [
current.colors[2],
current.colors[0],
current.colors[1]
];
if (valid({ ...current, colors: rotated })) {
current.colors = rotated;
render();
}
};回転後の配列をコピーした一時オブジェクトで検査し、置ける場合だけ本物のcurrent.colorsを更新します。回転によって下側が盤面の宝石と重なる場合は、回転を無効にします。
着地した組を盤面へ固定する
一定時間ごとの自動落下、下キー、落とす操作のいずれでも、1マス下へ移動できなくなったら着地です。その時点で初めて3個の色を盤面へ保存し、新しく置かれた宝石であることをnewで記録します。
current.colors.forEach((color, index) => {
const y = current.y + index;
if (y >= 0) {
board[y][current.x] = {
color,
new: true
};
}
});
current = null;
render();
await wait(150);
clearNewFlags();
await resolve();着地直後はnewを使って宝石を強調表示し、短い待ち時間のあとにフラグを消します。新しく置かれた宝石と、消去・連鎖に参加する宝石を画面上で見分けられるため、動きの理由を理解しやすくなります。
縦・横・斜めのつながりを探す
同じ色が3つ以上つながった場所を探すため、縦、横、右下方向の斜め、右上方向の斜めという4方向を調べます。反対方向は同じ線を重複して調べるため不要です。線の始点だけから同じ色を伸ばし、3個以上になった座標をSetへ登録します。
const findMatches = () => {
const marked = new Set();
[[0, 1], [1, 0], [1, 1], [1, -1]].forEach(([dy, dx]) => {
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const cell = board[y][x];
if (!cell) continue;
const before = y - dy;
const beforeX = x - dx;
if (before >= 0 && before < H &&
beforeX >= 0 && beforeX < W &&
board[before][beforeX]?.color === cell.color) continue;
const line = [];
let yy = y;
let xx = x;
while (yy >= 0 && yy < H && xx >= 0 && xx < W &&
board[yy][xx]?.color === cell.color) {
line.push(`${xx},${yy}`);
yy += dy;
xx += dx;
}
if (line.length >= 3) line.forEach(key => marked.add(key));
}
});
return marked;
};始点の手前に同じ色がある場合は、その線をすでに別の始点から調べているためスキップします。見つけた座標を一つのSetへまとめることで、縦と横が交差している宝石や、複数の線に含まれる宝石を一度だけ消去できます。
消去、重力、連鎖を繰り返す
連結が見つかったら、すぐに配列から削除するのではなく、まず連鎖対象として描画して短いアニメーションを見せます。その後に対象をnullへ変え、各列の宝石だけを下から詰め直します。詰めた結果、新しい連結ができたら同じ処理を繰り返します。
const resolve = async () => {
let chain = 0;
while (true) {
const matches = findMatches();
if (!matches.size) break;
chain++;
chainEl.textContent = chain;
score += matches.size * 10 * chain;
updateScore();
render(matches);
await wait(430);
matches.forEach(key => {
const [x, y] = key.split(',').map(Number);
board[y][x] = null;
});
for (let x = 0; x < W; x++) {
const packed = board.map(row => row[x]).filter(Boolean);
let index = packed.length - 1;
for (let y = H - 1; y >= 0; y--) {
board[y][x] = index >= 0 ? packed[index--] : null;
}
}
render();
await wait(130);
}
chainEl.textContent = chain;
return chain;
};スコアは消した個数に連鎖数を掛けています。1回目の消去より2回目、3回目の消去を高くすることで、連鎖を狙う意味が生まれます。whileの条件を「新しい組み合わせが見つかる間」とすることで、連鎖の回数をあらかじめ決めずに済みます。
落下速度と入力をゲーム状態へ集約する
落下はsetIntervalで一定間隔に実行し、キーボードとタッチボタンは同じmove、rotate、stepDownへつなぎます。入力経路ごとに別の移動処理を持たせないことで、キーボードと画面ボタンの挙動を揃えています。
const stepDown = async (hard = false) => {
if (!running || paused || busy || !current) return;
if (hard) {
while (valid(current, current.y + 1)) current.y++;
await lockPiece();
return;
}
if (valid(current, current.y + 1)) {
current.y++;
render();
} else {
await lockPiece();
}
};
document.addEventListener('keydown', event => {
if (event.key === 'ArrowLeft') move(-1);
if (event.key === 'ArrowRight') move(1);
if (event.key === 'ArrowDown') stepDown();
if (event.key === 'ArrowUp') rotate();
if (event.key === ' ') stepDown(true);
});runningはプレイ中か、pausedは一時停止中か、busyは連鎖処理中かを表します。すべての操作関数の入口でこれらを確認するため、一時停止中に宝石が動いたり、消去中に次の組を置けたりする問題を防げます。
ゲームオーバーとスコアを管理する
新しい組を作ったあと、初期位置へ置けなければ盤面がいっぱいです。そこで落下タイマーを止め、結果モーダルへスコアと最高連鎖を表示します。ベストスコアはlocalStorageへ保存するため、同じブラウザで次回も記録を確認できます。
let best = Number(
localStorage.getItem('jewelColumnsBest') || 0
);
const updateScore = () => {
scoreEl.textContent = score;
if (score > best) {
best = score;
bestEl.textContent = best;
localStorage.setItem('jewelColumnsBest', best);
}
};保存する値は数字ですが、localStorageは文字列として扱うため、読み出し時にNumberへ変換しています。サーバーへ送信する仕組みではないので、端末やブラウザをまたいだランキングではなく、そのブラウザ内の自己ベストです。
DOM描画で新しい宝石と連鎖を見せる
描画ではまず盤面の13行6列を走査し、固定済みの宝石をHTMLへ変換します。次に、固定前のcurrentを同じ盤面へ重ねて表示します。消去対象にはcolumns-chain、着地直後の宝石にはcolumns-newを付け、CSSのアニメーションで状態の違いを示します。
const render = (chain = new Set()) => {
let html = '';
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
const cell = board[y][x];
const effect = cell?.new ? 'columns-new' : '';
html += `<div class="columns-cell">` +
(cell ? jewelStyle(x, y, cell.color, effect) : '') +
`</div>`;
}
if (current && !busy) {
current.colors.forEach((color, index) => {
const y = current.y + index;
if (y >= 0) html += jewelStyle(current.x, y, color, 'columns-current');
});
}
boardEl.innerHTML = html;
};ゲーム内部の正しい状態はboardとcurrentにあり、DOMはその結果を表示する役割に限定しています。移動や消去のたびに盤面全体を再描画するため、固定済みと落下中の宝石を同じ見た目の処理で更新できます。
BGM、効果音、設定をブラウザに保存する
音声ファイルを用意する代わりに、Web Audio APIの発振器で短い音を作っています。移動、回転、落下、消去、ゲームオーバーで音程や波形を変え、BGMは一定間隔で複数の音を鳴らします。AudioContextは最初の操作後に初期化し、ブラウザの自動再生制限にも対応しています。
const tone = (frequency, duration, type = 'sine', gain = .08) => {
if (!audio) return;
const now = audio.ctx.currentTime;
const oscillator = audio.ctx.createOscillator();
const volume = audio.ctx.createGain();
oscillator.type = type;
oscillator.frequency.value = frequency;
volume.gain.setValueAtTime(gain, now);
volume.gain.exponentialRampToValueAtTime(.001, now + duration);
oscillator.connect(volume).connect(audio.ctx.destination);
oscillator.start(now);
oscillator.stop(now + duration);
};BGM、効果音、音量の設定はjewelColumnsBgm、jewelColumnsSfx、jewelColumnsVolumeというキーで保存します。次に遊ぶときも設定が戻らないため、ゲーム本体の状態と環境設定を分けて管理できます。
実装して分かったこと
ジュエルカラムでは、落下中の組を盤面へ混ぜるタイミングと、消去後に重力を適用する順番が特に重要です。先に消去判定をしてから宝石を詰め、詰め終わった盤面でもう一度判定することで、連鎖を自然に表現できます。
また、縦・横・斜めの4方向を一つの探索処理へまとめ、入力方法も共通の移動関数へ集約すると、ルールを変更しやすくなります。実際のジュエルカラムでは、盤面の動き、新しく置かれた宝石、消える宝石、連鎖に参加する宝石が分かるように表示しています。コードを読むときは、まずvalid、findMatches、resolveの順に追うと、ゲーム全体の流れをつかみやすくなります。