GAME

ブラウザ版カラーフィルの実装を解説

盤面の連結領域を塗り広げる処理から、解ける問題の生成、ヒント、Undo、獲得マスのアニメーションまで、ゲームページの実装を順番に紹介します。

カラーフィルは、左上から始まる領域の色を選び、上下左右でつながるマスを少しずつ取り込んで盤面全体を一色にするパズルです。色を変えるたびに領域が広がるため、どの色を選ぶかと残り手数の管理が重要になります。

このゲームは、PHPで共通レイアウトを読み込み、HTMLで盤面の枠を用意し、JavaScriptでゲームを進行させています。盤面ルールはengine.jsに分離し、画面操作や表示更新はgame.jsにまとめました。ルール部分を独立させたことで、ブラウザ表示とテストで同じ処理を再利用できます。

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

盤面は、各マスの色番号を一次元配列に並べています。8×8なら64個、12×12なら144個の数値を持ち、インデックスから行と列を計算します。配列の形を統一すると、難易度によって盤面サイズが変わっても同じルール関数を使えます。

盤面を作成するJAVASCRIPT
const config = levels[level] || levels.easy;
const board = Array.from(
    { length: config.size ** 2 },
    () => Math.floor(random() * config.colors)
);

board[0]が左上のスタート領域の色です。色番号は0から始め、表示時だけ色の配列からカラーコードや名前を取り出します。ゲーム状態を色番号で持つと、比較やコピーが簡単になります。

上下左右の連結領域を探索する

カラーフィルの中心は、左上から同じ色でつながっている領域を求める処理です。斜めはつながらないため、各マスから左・右・上・下だけを調べます。探索にはキューを使った幅優先探索を利用しています。

スタート領域を求めるJAVASCRIPT
function region(board, size) {
    const seen = new Set([0]);
    const queue = [0];

    for (let head = 0; head < queue.length; head++) {
        const i = queue[head];
        const neighbors = [];
        if (i % size > 0) neighbors.push(i - 1);
        if (i % size < size - 1) neighbors.push(i + 1);
        if (i >= size) neighbors.push(i - size);
        if (i + size < board.length) neighbors.push(i + size);

        for (const next of neighbors) {
            if (!seen.has(next) && board[next] === board[0]) {
                seen.add(next);
                queue.push(next);
            }
        }
    }
    return seen;
}

seenにはすでに調べたマスを保存します。これにより同じマスを繰り返し探索せず、盤面の端でも配列の範囲外へアクセスしません。領域の大きさは、進行率の表示、勝利判定、ヒントの評価で共通して利用します。

色を変更して領域を広げる

色を選んだら、現在の連結領域に含まれるマスだけを新しい色へ変更します。その後にもう一度regionを実行すると、新しい色で隣接するマスまで領域に含まれます。現在と同じ色は選べないようにしているため、必ず色の変化が起こります。

選んだ色で塗り替えるJAVASCRIPT
function fill(board, size, color) {
    const next = board.slice();
    if (color !== board[0]) {
        for (const i of region(board, size)) next[i] = color;
    }
    return next;
}

元の配列を直接書き換えず、sliceでコピーした配列を返しています。変更前の盤面を履歴に保存しやすく、Undoやアニメーションの比較にも使える設計です。

手数内で解ける盤面を生成する

ランダムな盤面をそのまま出すと、難しすぎたり、設定した手数内で解けなかったりします。そこで盤面作成時に、現在の領域を最も大きく広げる色を選び続ける簡易ソルバーを実行し、必要な手数を求めています。

最も広がる色を選ぶJAVASCRIPT
function bestMove(board, size, colors) {
    let choice = null;
    let most = -1;

    for (let color = 0; color < colors; color++) {
        if (color === board[0]) continue;
        const count = region(fill(board, size, color), size).size;
        if (count > most) {
            most = count;
            choice = color;
        }
    }
    return choice;
}
解答手数を余裕に変換するJAVASCRIPT
const solution = solve(board, config.size, config.colors);
return { ...config, board, limit: solution.length + config.extra };

solveで得た手数に、難易度ごとの余裕を加えて制限手数にしています。これは最短解を保証するアルゴリズムではありませんが、毎回まったく解けない盤面を出さず、初級・中級・上級で異なる余裕を持たせられます。

ゲーム進行とUndoを管理する

画面側では、現在の盤面、履歴、使用手数、結果を変数で管理しています。色を選ぶ前に盤面のコピーを履歴へ保存し、Undoでは直前の配列を取り出して手数も戻します。

色を選んだときの処理JAVASCRIPT
function choose(color) {
    if (outcome || color === board[0]) return;

    const previous = rules.region(board, puzzle.size);
    history.push(board.slice());
    board = rules.fill(board, puzzle.size, color);
    moves++;
    const gained = rules.region(board, puzzle.size).size - previous.size;
    render(previous);

    if (rules.region(board, puzzle.size).size === board.length) finish(true);
    else if (moves >= puzzle.limit) finish(false);
}

変更前の領域をpreviousとして渡すことで、今回新しく獲得したマスを描画時に判定できます。全面が一色になればクリア、手数を使い切れば失敗として結果ダイアログを表示します。

新しく広がったマスをアニメーションする

色を変更しただけでは、どのマスが追加されたのか分かりにくくなります。描画時に変更後の領域と変更前の領域を比較し、今回初めて領域に入ったマスへcf-gainedクラスを付けています。

獲得マスを判定するJAVASCRIPT
cell.className = 'cf-cell'
    + (owned.has(i) ? ' cf-owned' : '')
    + (i === 0 ? ' cf-origin' : '')
    + (previous.size && owned.has(i) && !previous.has(i)
        ? ' cf-gained' : '');
CSSで広がりを示すCSS
.cf-cell.cf-gained {
    animation: cf-spread .35s ease-out;
}

@keyframes cf-spread {
    50% {
        filter: brightness(1.35);
        transform: scale(.9);
    }
}

全体を派手に動かすのではなく、新しく加わったマスだけを短く強調しています。盤面の状態変化と視覚表現を分けることで、アニメーションを追加してもルール計算が複雑になりません。

ヒントと設定を実装する

ヒントでは、選択可能な色を一つずつ試し、塗り替えた後の領域が最も大きくなる色を表示します。ヒントを使っても手数は消費しないため、初めて遊ぶ人がルールを理解する補助になります。

ヒントを表示するJAVASCRIPT
function hint() {
    if (outcome) return;
    const color = rules.bestMove(board, puzzle.size, puzzle.colors);
    for (const button of paletteEl.children) {
        button.classList.toggle(
            'cf-hint', Number(button.dataset.color) === color
        );
    }
}

BGM、効果音、音量、色番号表示の設定はlocalStorageへ保存しています。保存に失敗する環境もあるため、読み書きはtry-catchで囲み、設定がなくてもゲーム本体は動くようにしています。

Web Audio APIとレスポンシブ対応

効果音とBGMは音声ファイルではなくWeb Audio APIで生成しています。音の種類ごとにゲインを分け、BGMと効果音を個別にON・OFFできるようにしました。ブラウザの自動再生制限があるため、AudioContextの開始はユーザー操作後に行います。

盤面はCSS Gridで作り、難易度に応じて列数を変更します。スマートフォンでも盤面と操作パネルが重ならないよう、ゲーム本体と設定欄を縦に並べるメディアクエリを用意しています。

実装して分かったこと

カラーフィルでは、色を変更する処理そのものよりも、「どこまでが現在の領域か」を毎回正しく求めることが重要です。領域探索を一つの関数にまとめたことで、塗り替え、進行率、勝利判定、ヒント、問題生成を同じルールで扱えました。

ゲームを遊ぶときは、実際のカラーフィルのページで、まず初級の盤面を試せます。コードを読むときは、region、fill、solveの順に追うと、盤面の変化とゲーム進行の関係をつかみやすくなります。