GAME

ジュエルカラムの実装を解説

落下する3個の宝石、盤面への固定、縦・横・斜めの連結、消去と連鎖まで、ゲームページの実装を順番に紹介します。

ジュエルカラムは、3個の宝石を縦に落とし、同じ色を3つ以上つなげて消していく落ちものパズルです。左右移動と回転だけで遊べますが、落下中の組と盤面に固定された宝石を分けること、縦・横・斜めのつながりを探すこと、消去後に宝石を下へ詰めて連鎖を判定することが重要になります。

今回のゲームは、PHPで共通ヘッダーとページの枠組みを読み込み、ゲームの状態管理と描画はブラウザ上のJavaScriptで行っています。盤面はHTML要素のグリッドとして作り、CSSで宝石の色、落下、消去、連鎖を表現しています。BGMと効果音にはWeb Audio APIを使っています。

盤面と落下中の組を分けて管理する

盤面は6列13行の二次元配列です。空きマスはnull、宝石があるマスは色と表示用の状態を持つオブジェクトにします。一方、落下中の3個はcurrentとして別に持ち、着地するまで盤面へ書き込みません。

ゲームの状態JAVASCRIPT
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個それぞれの行を計算し、左右の範囲、下端、既存の宝石との重なりを確認します。盤面の外に出ている上側だけは許可します。

配置できるか確認するJAVASCRIPT
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個を一つずつ下へ送ることで、同じ列のまま色の配置を回転できます。

宝石の順番を回転するJAVASCRIPT
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で記録します。

着地した宝石を保存するJAVASCRIPT
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へ登録します。

連結した宝石を探すJAVASCRIPT
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へ変え、各列の宝石だけを下から詰め直します。詰めた結果、新しい連結ができたら同じ処理を繰り返します。

消去と落下を連鎖させるJAVASCRIPT
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へつなぎます。入力経路ごとに別の移動処理を持たせないことで、キーボードと画面ボタンの挙動を揃えています。

自動落下とキー入力JAVASCRIPT
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へ保存するため、同じブラウザで次回も記録を確認できます。

ベストスコアを保存するJAVASCRIPT
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のアニメーションで状態の違いを示します。

盤面をHTMLへ描画するJAVASCRIPT
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は最初の操作後に初期化し、ブラウザの自動再生制限にも対応しています。

短い効果音を作るJAVASCRIPT
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の順に追うと、ゲーム全体の流れをつかみやすくなります。