ブラウザ版カラーポップの実装を解説
盤面の配列、落ちものの移動と回転、同じ色の探索、消去後の落下と連鎖まで、ゲームページの実装を順番に紹介します。
カラーポップは、同じ色のピースを4つ以上つなげて消していく、ぷよぷよ風の落ちものパズルです。操作自体は左右移動と回転、落下だけですが、消去、重力、連鎖、ゲームオーバーを正しい順番で処理する必要があります。
今回のゲームは、PHPで共通のページ部分を読み込み、盤面の更新と入力処理はJavaScriptで行っています。盤面は6列12行に固定し、落下中の組と着地済みのピースを分けて管理することで、判定と表示を整理しました。
盤面を二次元配列で持つ
盤面は行ごとの配列として作り、空いている場所をnull、埋まっている場所を色の情報を持つオブジェクトで表します。画面のマスを直接調べるのではなく、ゲームの正しい状態をJavaScriptの配列に置くのがポイントです。
const W = 6;
const H = 12;
function makeGrid() {
grid = Array.from(
{ length: H },
() => Array(W).fill(null)
);
}各要素に{ color: 'red' }のような値を入れると、色だけでなく「落ちてきたばかりか」といった状態も追加できます。実装では、着地したピースにfreshを付け、次の描画で新しいピースとして見せています。
落下する2個組を座標と回転で表す
落下するピースは、左側の基準位置xとy、向きrot、2個の色を持つオブジェクトです。向きごとの相対座標を用意しておけば、回転のたびに盤面へ直接書き込まずに、表示位置だけを計算できます。
function cells(piece) {
const offsets = [
[[0, 0], [1, 0]],
[[0, 0], [0, 1]],
[[0, 0], [-1, 0]],
[[0, 0], [0, -1]]
][piece.rot];
return offsets.map(([dx, dy]) => [
piece.x + dx,
piece.y + dy
]);
}rotが0なら横向き、1なら下向き、2なら反対向き、3なら上向きです。落下中の2個を「盤面にまだ固定されていない一時的なオブジェクト」として扱うことで、移動中のピースと着地済みのピースを混ぜずに済みます。
移動前に置けるかを判定する
左右移動や回転では、先に移動後の座標を仮に作り、すべてのマスが盤面内で空いているかを確認します。壁や他のピースにぶつかる場合は、実際のpairを変更しません。
function canPlace(piece, rot = piece.rot, dx = 0, dy = 0) {
const test = {
...piece,
rot,
x: piece.x + dx,
y: piece.y + dy
};
return cells(test).every(([x, y]) =>
x >= 0 && x < W &&
y < H &&
(y < 0 || !grid[y][x])
);
}上端から出ているy < 0は許可しています。回転直後に一部が画面上端より上へ出ることがあるためです。一方で、横方向の範囲外や、盤面下端、すでに埋まったマスは許可しません。
回転時には、現在位置、左右1マス、左右2マスの順に試します。壁際で回転できるように少し横へずらす処理で、落ちものゲームでいうウォールキックに相当します。
着地した組を盤面へ固定する
自動落下やボタン操作で1マス下へ進めない場合、組を着地させます。ここで初めて2個のピースをgridへ書き込み、その後のグループ探索対象にします。
function settle() {
cells(pair).forEach(([x, y], index) => {
if (y >= 0) {
grid[y][x] = {
color: pair.colors[index],
fresh: true
};
}
});
resolving = true;
resolveChains();
}resolvingは、消去アニメーションや落下処理の途中で入力を受け付けないためのフラグです。処理中に次のピースを動かせるようにすると、盤面の途中状態が壊れるため、連鎖が終わるまで移動や回転を止めています。
同じ色のグループを探索する
消えるグループを探すときは、まだ調べていないマスから探索を始め、上下左右に同じ色が続く限りグループへ追加します。斜めはつながりとして扱いません。探索にはスタックを使った深さ優先探索を使っています。
function findGroups() {
const seen = new Set();
const groups = [];
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const key = `${x},${y}`;
if (!grid[y][x] || seen.has(key)) continue;
const color = grid[y][x].color;
const group = [];
const stack = [[x, y]];
seen.add(key);
while (stack.length) {
const [cx, cy] = stack.pop();
group.push([cx, cy]);
for (const [nx, ny] of [
[cx + 1, cy], [cx - 1, cy],
[cx, cy + 1], [cx, cy - 1]
]) {
const nextKey = `${nx},${ny}`;
if (nx >= 0 && nx < W &&
ny >= 0 && ny < H &&
grid[ny][nx]?.color === color &&
!seen.has(nextKey)) {
seen.add(nextKey);
stack.push([nx, ny]);
}
}
}
if (group.length >= 4) groups.push(group);
}
}
return groups;
}seenに座標を保存することで、同じマスを何度も探索しません。探索が終わったグループの大きさが4以上なら、消去対象として返します。盤面全体を走査しているため、別々の場所にある複数グループも同じターンで消せます。
消去、重力、連鎖を順番に実行する
グループが見つかったら、まず該当マスをnullにします。その後、各列を下から集め直して、空白を上へ詰めます。落下後に新しいグループができる可能性があるため、もう一度探索します。
for (let x = 0; x < W; x++) {
const stack = [];
for (let y = H - 1; y >= 0; y--) {
if (grid[y][x]) stack.push(grid[y][x]);
}
for (let y = H - 1; y >= 0; y--) {
grid[y][x] = stack[H - 1 - y] || null;
}
}下の行からピースだけを取り出して、同じく下の行から詰め直すと、空白を意識した複雑な入れ替えをせずに重力を表現できます。重力が終わったあとにresolveChainsをもう一度呼ぶのが連鎖の中心です。
function resolveChains() {
const groups = findGroups();
if (!groups.length) {
resolving = false;
chain = 0;
spawn();
render();
return;
}
chain++;
const doomed = groups.flat();
doomed.forEach(([x, y]) => grid[y][x] = null);
score += doomed.length * 10 * chain;
render();
setTimeout(() => {
applyGravity();
render();
setTimeout(resolveChains, 180);
}, 460);
}実際の実装では、消えるピースを一時的なclearingPiecesへ保存し、460ミリ秒の表示を挟んでから重力を適用します。さらに180ミリ秒待って再探索することで、消去、落下、次の消去が画面でも分かるようになります。
ゲームループで自動落下させる
一定間隔のsetIntervalではなく、requestAnimationFrameを使ったゲームループで経過時間を測っています。スコアに応じて落下速度を変えつつ、画面の更新タイミングに合わせて処理できます。
function loop(now) {
const dt = now - last;
last = now;
if (running && !paused && !resolving) {
dropClock += dt;
const speed = Math.max(
190,
720 - Math.floor(score / 300) * 25
);
if (dropClock > speed) {
dropClock = 0;
if (down()) render();
}
}
requestAnimationFrame(loop);
}前回のフレームからの経過時間をdropClockへ加算し、設定した落下時間を超えたときだけ1マス下へ進めます。処理中や一時停止中は時計を進めないため、再開した瞬間に意図せず落下することもありません。
キーボードとタッチ操作を同じ処理へ集約する
キーボード、画面上のボタン、スマートフォンの操作は、最終的にmove、rotate、down、hardDropの同じ関数を呼び出します。入力方法ごとにゲーム処理を複製しないことが、動作のずれを防ぐポイントです。
const actions = {
ArrowLeft: 'left', ArrowRight: 'right',
ArrowUp: 'rotate', ArrowDown: 'down',
KeyA: 'left', KeyD: 'right',
KeyW: 'rotate', KeyS: 'down'
};
document.addEventListener('keydown', event => {
if (event.code === 'Space') {
event.preventDefault();
hardDrop();
return;
}
const action = actions[event.code];
if (action) {
event.preventDefault();
act(action);
}
});画面ボタンもdata-actionの値を使ってactへ渡します。Pキーの一時停止や、ページが非表示になったときの自動一時停止も、ゲームの状態フラグを切り替える同じ設計です。
スコア、音声、ベストスコアを管理する
得点は消した個数に連鎖数を掛けて加算します。連鎖数が大きいほど高得点になるため、すぐに消すだけでなく、あえて組み上げてから消す戦略が生まれます。ベストスコアはlocalStorageへ保存し、次回アクセスでも表示できるようにしています。
score += removedCount * 10 * chain;
if (score > best) {
best = score;
localStorage.setItem(
'puyo-pop-best',
String(best)
);
}BGMと効果音はWeb Audio APIで生成しています。ブラウザの自動再生制限に対応するため、AudioContextの初期化はゲーム開始やボタン操作など、ユーザー操作の中で行います。BGMと効果音のON/OFF、音量も画面から変更できます。
実装して分かったこと
カラーポップで特に重要なのは、落下中、着地後、消去アニメーション中という盤面の状態を混ぜないことです。盤面配列をゲームの正しい状態として扱い、落ちものの移動、グループ探索、重力、描画をそれぞれ分けると、連鎖の流れを追いやすくなります。
実際のカラーポップでは、同じ色を4つ以上つなげると消え、落下後にさらに消えると連鎖になります。コードを読むときは、まずcanPlace、findGroups、resolveChains、loopの4つを追うと、ゲーム全体の流れをつかみやすくなります。