GAME

ブラウザ版カラーポップの実装を解説

盤面の配列、落ちものの移動と回転、同じ色の探索、消去後の落下と連鎖まで、ゲームページの実装を順番に紹介します。

カラーポップは、同じ色のピースを4つ以上つなげて消していく、ぷよぷよ風の落ちものパズルです。操作自体は左右移動と回転、落下だけですが、消去、重力、連鎖、ゲームオーバーを正しい順番で処理する必要があります。

今回のゲームは、PHPで共通のページ部分を読み込み、盤面の更新と入力処理はJavaScriptで行っています。盤面は6列12行に固定し、落下中の組と着地済みのピースを分けて管理することで、判定と表示を整理しました。

盤面を二次元配列で持つ

盤面は行ごとの配列として作り、空いている場所をnull、埋まっている場所を色の情報を持つオブジェクトで表します。画面のマスを直接調べるのではなく、ゲームの正しい状態をJavaScriptの配列に置くのがポイントです。

空の盤面を作る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個の色を持つオブジェクトです。向きごとの相対座標を用意しておけば、回転のたびに盤面へ直接書き込まずに、表示位置だけを計算できます。

組の位置を計算するJAVASCRIPT
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を変更しません。

配置できるか確認するJAVASCRIPT
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へ書き込み、その後のグループ探索対象にします。

着地して消去処理へ進むJAVASCRIPT
function settle() {
    cells(pair).forEach(([x, y], index) => {
        if (y >= 0) {
            grid[y][x] = {
                color: pair.colors[index],
                fresh: true
            };
        }
    });

    resolving = true;
    resolveChains();
}

resolvingは、消去アニメーションや落下処理の途中で入力を受け付けないためのフラグです。処理中に次のピースを動かせるようにすると、盤面の途中状態が壊れるため、連鎖が終わるまで移動や回転を止めています。

同じ色のグループを探索する

消えるグループを探すときは、まだ調べていないマスから探索を始め、上下左右に同じ色が続く限りグループへ追加します。斜めはつながりとして扱いません。探索にはスタックを使った深さ優先探索を使っています。

同じ色のグループを探すJAVASCRIPT
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にします。その後、各列を下から集め直して、空白を上へ詰めます。落下後に新しいグループができる可能性があるため、もう一度探索します。

列ごとにピースを落とすJAVASCRIPT
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をもう一度呼ぶのが連鎖の中心です。

連鎖を続けるJAVASCRIPT
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を使ったゲームループで経過時間を測っています。スコアに応じて落下速度を変えつつ、画面の更新タイミングに合わせて処理できます。

時間を測って落下させるJAVASCRIPT
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の同じ関数を呼び出します。入力方法ごとにゲーム処理を複製しないことが、動作のずれを防ぐポイントです。

キー入力を操作名へ変換するJAVASCRIPT
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へ保存し、次回アクセスでも表示できるようにしています。

ベストスコアを保存するJAVASCRIPT
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つを追うと、ゲーム全体の流れをつかみやすくなります。