GAME

ブラウザ版キャンディーマッチの実装を解説

8×8の盤面、隣り合うキャンディーの交換、3個以上のマッチ判定、消去後の落下と連鎖まで、ゲームページの実装を順番に紹介します。

キャンディーマッチは、隣り合うキャンディーを入れ替え、同じ色を横または縦に3個以上そろえて消すマッチ3パズルです。消した場所へ新しいキャンディーが落ち、さらにそろうと連鎖になります。

今回のゲームは、PHPで共通のページ部分を読み込み、盤面の状態管理とゲーム進行はJavaScriptで行っています。8×8の盤面に6色のキャンディーを配置し、30手以内に目標スコアへ到達するルールです。

盤面を1次元配列で管理する

画面は8列8行ですが、JavaScriptでは64個の色を1つの配列に並べています。座標から配列の位置を求める関数を用意すると、描画、交換、判定で同じデータ構造を利用できます。

盤面と座標変換JAVASCRIPT
const W = 8;
const H = 8;
const colors = ['red', 'blue', 'green', 'yellow', 'purple', 'orange'];

const index = (x, y) => y * W + x;
const fromIndex = i => [i % W, Math.floor(i / W)];
const randomColor = () =>
    colors[Math.floor(Math.random() * colors.length)];

function makeBoard() {
    board = Array.from({ length: W * H }, randomColor);
}

indexは座標を配列のインデックスへ変換し、fromIndexはその逆を行います。DOMのマスを直接ゲームの状態として扱わず、boardを正しい状態として描画する設計にすることで、盤面の入れ替えやリセットを一か所に集約できます。

最初からそろっている盤面を避ける

ランダムに色を置くだけでは、ゲーム開始時から3個以上そろったり、どこにも交換できる場所がなかったりします。盤面を作るたびに、開始時のマッチと可能な手を確認し、条件に合わなければ作り直します。

初期盤面を作るJAVASCRIPT
function makeBoard() {
    do {
        board = Array.from({ length: W * H }, randomColor);
    } while (hasStartingMatches() || !hasPossibleMove());
}

function hasStartingMatches() {
    return findMatches().size > 0;
}

開始時の盤面には消える組を置かず、少なくとも1手は動かせるようにしています。ランダム生成を無限に続けないためには、色数や盤面サイズに対して現実的な条件を設定することも大切です。

横と縦のマッチを探す

マッチ判定では、各マスを起点に同じ色が左右へ何個続くか、上下へ何個続くかを調べます。3個以上になった座標をSetへ追加することで、横と縦の組が交差していても同じマスを重複して数えません。

3個以上の組を探すJAVASCRIPT
const key = (x, y) => `${x},${y}`;

function findMatches() {
    const matches = new Set();

    for (let y = 0; y < H; y++) {
        for (let x = 0; x < W; x++) {
            const color = board[index(x, y)];
            if (!color) continue;

            let line = [[x, y]];
            for (let nx = x - 1; nx >= 0 &&
                board[index(nx, y)] === color; nx--) {
                line.push([nx, y]);
            }
            for (let nx = x + 1; nx < W &&
                board[index(nx, y)] === color; nx++) {
                line.push([nx, y]);
            }
            if (line.length >= 3) {
                line.forEach(([cx, cy]) => matches.add(key(cx, cy)));
            }

            line = [[x, y]];
            for (let ny = y - 1; ny >= 0 &&
                board[index(x, ny)] === color; ny--) {
                line.push([x, ny]);
            }
            for (let ny = y + 1; ny < H &&
                board[index(x, ny)] === color; ny++) {
                line.push([x, ny]);
            }
            if (line.length >= 3) {
                line.forEach(([cx, cy]) => matches.add(key(cx, cy)));
            }
        }
    }
    return matches;
}

横方向と縦方向を別々に調べるため、L字型の組み合わせや、横と縦が交差する組み合わせも処理できます。座標を文字列キーに変換してSetへ保存するのは、同じキャンディーを一度だけ消すためです。

隣り合う2個だけを交換する

プレイヤーが選んだ2マスは、上下左右に隣り合っている場合だけ交換できます。斜めや離れたマスを選んだときは交換せず、次の選択として扱います。

隣接判定と交換JAVASCRIPT
function adjacent(a, b) {
    const [ax, ay] = fromIndex(a);
    const [bx, by] = fromIndex(b);
    return Math.abs(ax - bx) + Math.abs(ay - by) === 1;
}

function swap(a, b) {
    [board[a], board[b]] = [board[b], board[a]];
    render(new Set(), new Set(), new Set([a, b]));
}

交換前の盤面をsliceで保存しておくと、交換後にマッチが見つからなかった場合に元へ戻せます。これにより、そろわない交換で盤面だけが変わることを防ぎます。

マッチしない交換を巻き戻す

2個を交換した直後にfindMatchesを実行し、組がなければ交換前の配列を復元します。組が見つかった場合だけ手数を減らし、消去と連鎖へ進みます。

交換後の処理JAVASCRIPT
async function resolveSwap(second, before) {
    const match = findMatches();
    if (!match.size) {
        await wait(230);
        returnSwap(before);
        return;
    }

    busy = true;
    moves--;
    update();
    await resolveMatches(match, 1);
    busy = false;
    cursor = second;
}

busyは消去アニメーション中の二重入力を防ぐフラグです。非同期処理の途中で次の交換を許可すると、消去前の盤面と消去後の盤面が混ざってしまうため、処理中は入力を止めています。

消去、落下、補充を行う

マッチしたキャンディーを一度nullにし、各列を下から詰め直します。空いた上側には新しい色を補充し、落下したキャンディーにはfreshの表示用情報を付けます。

列ごとに落下と補充を行うJAVASCRIPT
for (let x = 0; x < W; x++) {
    let write = H - 1;

    for (let y = H - 1; y >= 0; y--) {
        const value = board[index(x, y)];
        if (value) {
            board[index(x, write)] = value;
            if (write !== y) fresh.add(index(x, write));
            write--;
        }
    }

    while (write >= 0) {
        board[index(x, write)] = randomColor();
        fresh.add(index(x, write));
        write--;
    }
}

下から順番に値を詰めると、空白を個別に探して移動するよりも処理を簡潔にできます。消去された位置を埋めた後、もう一度findMatchesを呼ぶことで連鎖を検出します。

連鎖とスコアを管理する

消去後の盤面で新しいマッチが見つかった場合、連鎖数を1増やして同じ処理を繰り返します。スコアは消した個数と連鎖数を掛けて加算し、連鎖するほど高得点になるようにしています。

消去と連鎖JAVASCRIPT
async function resolveMatches(match, chain) {
    const marked = new Set([...match].map(value => {
        const [x, y] = value.split(',').map(Number);
        return index(x, y);
    }));

    render(match, new Set(), marked);
    await wait(370);
    marked.forEach(i => board[i] = null);
    score += marked.size * 20 * chain;
    update();

    // 落下と補充の後に再判定する
    const fresh = new Set();
    await wait(110);
    render(new Set(), fresh);
    await wait(320);
    const next = findMatches();
    if (next.size) await resolveMatches(next, chain + 1);
}

実装では、消える組に金色の輪、落ちてきた組に白い輪を付けて、追加された要素と消去対象を区別しています。状態の更新と描画のタイミングを分けることで、消去から落下、次の連鎖までを画面で確認できます。

盤面に動かせる手が残っているか調べる

手数が残っていても、どの隣接交換でもマッチを作れない場合があります。そのままではゲームが進まないため、すべての候補を一時的に交換してマッチの有無を調べます。

可能な交換を探すJAVASCRIPT
function hasPossibleMove() {
    for (let i = 0; i < board.length; i++) {
        for (const direction of [1, W, -1, -W]) {
            const j = i + direction;
            if (j < 0 || j >= board.length) continue;
            if (direction === 1 && i % W === W - 1) continue;
            if (direction === -1 && i % W === 0) continue;

            [board[i], board[j]] = [board[j], board[i]];
            const possible = findMatches().size > 0;
            [board[i], board[j]] = [board[j], board[i]];
            if (possible) return true;
        }
    }
    return false;
}

盤面を一時的に変更したら、判定後に必ず元の順番へ戻します。列の端で左右をまたいでしまわないように、横移動の候補では列番号も確認しています。

クリックとキーボード操作を同じ処理へ集約する

マウスやタップでは2個のマスを順番に選び、キーボードではカーソル位置を移動してEnterやSpaceで選択します。どちらも最終的には同じchoose関数を呼ぶため、操作方法ごとにゲームルールを複製せずに済みます。

マスを選択するJAVASCRIPT
function choose(i) {
    if (!running || paused || busy) return;

    if (selected < 0) {
        selected = i;
        render();
        return;
    }
    if (selected === i) {
        selected = -1;
        render();
        return;
    }
    if (!adjacent(selected, i)) {
        selected = i;
        render();
        return;
    }

    const previous = board.slice();
    swap(selected, i);
    selected = -1;
    resolveSwap(i, previous);
}

選択中のマスにはselectedクラスを付け、交換中の2マスにはmovingクラスを付けます。見た目の演出は状態の変化に合わせて追加し、判定処理とは分離しています。

目標スコア、手数、ベストスコアを保存する

目標スコアは100~10,000点に制限し、ゲーム開始時に読み込みます。ベストスコアと目標値、BGM・効果音の設定はlocalStorageへ保存するため、同じブラウザーで次回も設定を引き継げます。

目標値とベストスコアJAVASCRIPT
let best = Number(
    localStorage.getItem('candy-crush-best') || 0
);
let target = Math.min(10000, Math.max(100,
    Number(localStorage.getItem('candy-crush-target') || 1200)
));

if (score > best) {
    best = score;
    localStorage.setItem('candy-crush-best', String(best));
}

ゲーム中にスコアがベストを超えたらすぐ保存し、ゲーム終了を待たずに記録を更新します。目標スコアに達した場合はクリア、30手を使い切った場合はゲームオーバーとしてオーバーレイを表示します。

Web Audio APIでBGMと効果音を作る

BGMと効果音は音声ファイルを読み込まず、Web Audio APIの発振器とゲインを使って生成しています。ブラウザーの自動再生制限があるため、AudioContextはゲーム開始やボタン操作など、ユーザー操作の中で初期化します。

短い効果音を鳴らすJAVASCRIPT
function tone(freq, duration = .1, type = 'sine', gain = .06) {
    if (!audio || audio.ctx.state !== 'running' || !sfxOn) return;

    const now = audio.ctx.currentTime;
    const oscillator = audio.ctx.createOscillator();
    const volume = audio.ctx.createGain();
    oscillator.type = type;
    oscillator.frequency.value = freq;
    volume.gain.setValueAtTime(.0001, now);
    volume.gain.exponentialRampToValueAtTime(gain, now + .015);
    volume.gain.exponentialRampToValueAtTime(.0001, now + duration);
    oscillator.connect(volume).connect(audio.ctx.destination);
    oscillator.start(now);
    oscillator.stop(now + duration + .03);
}

マッチ時は連鎖数に応じて音程を変え、交換できない操作には低い音を鳴らしています。BGMと効果音を別々にON/OFFできるようにし、音量スライダーの値をゲインへ反映しています。

実装して分かったこと

マッチ3パズルでは、交換、判定、消去、重力、補充、連鎖という処理の順番を崩さないことが重要です。盤面配列を正しい状態として扱い、findMatches、resolveSwap、resolveMatchesを中心に処理を分けると、アニメーション中の入力や無効交換も整理しやすくなります。

実際のキャンディーマッチでは、追加されたキャンディーを白い輪、消える組を金色の輪で表示しています。コードを読むときは、まず盤面の生成、マッチ判定、連鎖処理、入力処理の順番で追うと、ゲーム全体の流れをつかみやすくなります。