GAME

フォーラインの実装を解説

6行7列の盤面、駒を落とす処理、縦・横・斜めの4連判定、難易度別CPU、キーボード操作、アニメーション、BGM・効果音まで、ゲームページの実装を順番に紹介します。

フォーラインは、7列6段の盤面に駒を落とし、縦・横・斜めのいずれかで先に4つをつなげるゲームです。ルールはシンプルですが、列の一番下から空きを探す処理、直前の着手を起点にした連結判定、CPUの手番、入力を一時的に止める状態管理など、ブラウザゲームの基本をまとめて学べます。

ゲームページはPHPで共通ヘッダーなどを読み込み、HTMLで操作パネルと盤面の入れ物を用意しています。ゲームの状態変更と描画、CPUの思考、音声はJavaScriptで処理し、CSSは駒の落下と4連の発光を表現する役割に分けています。

盤面を6行7列の配列で管理する

盤面は6行7列の二次元配列です。空きマスを0、プレイヤーの黄色い駒を1、CPUの赤い駒を2としておくと、駒の有無と所有者を同じ値で管理できます。

盤面を初期化するJAVASCRIPT
const ROWS = 6;
const COLS = 7;

function createBoard() {
    return Array.from(
        { length: ROWS },
        () => Array(COLS).fill(0)
    );
}

let board = createBoard();

行番号は上から下へ、列番号は左から右へ進みます。描画するときもこの順でマスを生成するため、画面の配置と配列の座標が一致します。beginやresetで新しい配列を作れば、前の対戦の駒を残さず盤面を初期化できます。

空いている一番下の行を探す

プレイヤーは列を選ぶだけで、駒の位置を直接指定しません。選んだ列の最下段から上へ調べ、最初に見つかった空きマスへ駒を置きます。列の上まで埋まっている場合は、-1を返して着手できないことを表します。

駒を置ける行を探すJAVASCRIPT
function findRow(col) {
    for (let row = ROWS - 1; row >= 0; row -= 1) {
        if (board[row][col] === 0) return row;
    }
    return -1;
}

この関数で「重力」を表現できます。上から空きを探すのではなく、下から探すことで、駒が途中で浮かずに積み上がります。全列の一番上が埋まっているかを調べれば、盤面が満杯かどうかも判定できます。

着手できる列を集めるJAVASCRIPT
function validColumns() {
    return Array.from({ length: COLS }, (_, col) => col)
        .filter(col => board[0][col] === 0);
}

function isFull() {
    return validColumns().length === 0;
}

列の最上段が空いていれば、その列にはまだ駒を置けます。この一覧は、プレイヤーの入力確認だけでなく、CPUが選べる列の絞り込みにも使い回しています。

4方向だけを調べて4連を判定する

新しい駒を置いたとき、盤面全体を毎回調べる必要はありません。勝敗に影響するのは直前に置いた駒を含む連結だけなので、そのマスを起点に、横、縦、右下方向、左下方向の4方向を確認します。

同じ駒が続くラインを集めるJAVASCRIPT
function lineFrom(row, col, dr, dc, token) {
    const line = [[row, col]];

    for (const direction of [-1, 1]) {
        let r = row + dr * direction;
        let c = col + dc * direction;
        while (
            r >= 0 && r < ROWS &&
            c >= 0 && c < COLS &&
            board[r][c] === token
        ) {
            line.push([r, c]);
            r += dr * direction;
            c += dc * direction;
        }
    }
    return line;
}

function winningLine(row, col, token) {
    for (const [dr, dc] of [
        [0, 1], [1, 0], [1, 1], [1, -1]
    ]) {
        const line = lineFrom(row, col, dr, dc, token);
        if (line.length >= 4) return line;
    }
    return null;
}

lineFromは、指定した方向の両側へ進みます。たとえば横方向なら、置いた駒から左と右を調べて一つのラインにまとめます。中央の駒を起点にすることで、左側と右側に分かれていた同じ色の駒も合計して判定できます。盤面の外へ出ないように、行と列の範囲を毎回確認しています。

駒の追加と4連の成立を分ける

着手処理では、まず盤面の値を更新し、その直後に落下表示を行います。少し待ってから勝敗を調べることで、駒が落ちてから4連が光る順番になります。判定中はbusyをtrueにして、アニメーション中の連続入力を受け付けません。

駒を落としてから勝敗を調べるJAVASCRIPT
function dropPiece(row, col, token) {
    board[row][col] = token;
    busy = true;
    renderBoard({ row, col });

    window.setTimeout(() => {
        const line = winningLine(row, col, token);
        finishMove(token, line);
    }, 460);
}

renderBoardへ落としたマスをdropとして渡すため、その駒だけにis-newクラスを付けられます。判定結果のラインは、勝利時の再描画へwinningとして渡します。ゲームのルールと演出に必要な情報を、同じ状態から別々に利用している点がポイントです。

追加された駒と勝利した4連を表示で区別する

画面上では、新しく追加された駒を落下アニメーションで表し、4連になった駒を発光させています。ゲーム内部ではどちらも同じ駒ですが、描画関数へ異なる情報を渡すことで、プレイヤーが何が起きたのかを理解しやすくしています。

盤面をDOMへ描画するJAVASCRIPT
function renderBoard(drop = null, winning = []) {
    const winningSet = new Set(
        winning.map(([row, col]) => `${row}:${col}`)
    );
    boardEl.replaceChildren();

    for (let row = 0; row < ROWS; row += 1) {
        for (let col = 0; col < COLS; col += 1) {
            const piece = document.createElement('span');
            piece.className = 'cf-piece';
            if (board[row][col] === 1) piece.classList.add('player');
            if (board[row][col] === 2) piece.classList.add('cpu');
            if (drop?.row === row && drop?.col === col) {
                piece.classList.add('is-new');
            }
            if (winningSet.has(`${row}:${col}`)) {
                piece.classList.add('is-winning');
            }
        }
    }
}

実際の描画では、マスをボタンとして生成し、その中に駒のspanを追加しています。盤面を毎回作り直す方式なので、前の勝利表示や古い駒のクラスが残りません。CSSではis-newの落下とis-winningの発光を別々に定義しています。

CPUは難易度ごとに選び方を変える

CPUの初級は合法な列からランダムに選びます。中級以上では、まずCPU自身が次の一手で勝てる列を探し、その次にプレイヤーの4連を止める列を探します。上級では中央付近の列を選びやすくし、単純なランダムよりも中央を使う対戦になります。

CPUの列を決めるJAVASCRIPT
function findCpuMove() {
    const columns = validColumns();
    if (difficultyEl.value === 'easy') {
        return columns[Math.floor(Math.random() * columns.length)];
    }

    const win = findTacticalMove(2);
    if (win >= 0) return win;
    const block = findTacticalMove(1);
    if (block >= 0) return block;

    if (difficultyEl.value === 'hard') {
        const center = columns.filter(col => Math.abs(col - 3) <= 1);
        const pool = center.length ? center : columns;
        return pool[Math.floor(Math.random() * pool.length)];
    }

    const weighted = columns.flatMap(col =>
        Array(4 - Math.abs(col - 3)).fill(col)
    );
    return weighted[Math.floor(Math.random() * weighted.length)];
}

findTacticalMoveは、候補の列へ一時的に駒を置き、winningLineで4連が成立するかを調べます。調べ終わったら空きマスへ戻すため、実際の着手前に盤面を壊しません。CPU専用の複雑な勝利判定を作らず、プレイヤーと同じ4連判定を再利用しています。

手番と入力ロックを状態で管理する

runningは対戦中か、busyは駒のアニメーションやCPUの手を待っているか、gameOverは終了したかを表します。クリック処理の入口でこれらを確認するため、終了後やCPUの思考中にプレイヤーが駒を追加することを防げます。

入力を受け付ける条件JAVASCRIPT
function chooseColumn(col) {
    if (!running || busy || gameOver) return;

    const row = findRow(col);
    if (row < 0) {
        messageEl.textContent =
            'その列はいっぱいです。別の列を選んでください。';
        tone(130, .06, 'sawtooth');
        return;
    }
    dropPiece(row, col, 1);
}

プレイヤーの着手後は、CPUの手が終わるまでbusyを維持します。CPUの着手が終わったらbusyを解除し、メッセージと盤面を更新します。引き分けでは、isFullが真になった時点で終了処理へ進みます。

キーボードとタッチに対応する

画面の列ボタンをクリックできるだけでなく、数字キー1~7で列を選び、左右キーでフォーカスを移動し、EnterまたはSpaceで駒を落とせます。各マスをボタンとして作っているため、タッチ操作にも対応しやすく、キーボード操作では現在の列へフォーカスを戻せます。

数字キーと左右キーを処理するJAVASCRIPT
document.addEventListener('keydown', event => {
    if (/^Digit[1-7]$/.test(event.code)) {
        selectedColumn = Number(event.code.slice(-1)) - 1;
        chooseColumn(selectedColumn);
    }

    if (event.code === 'ArrowLeft' || event.code === 'ArrowRight') {
        event.preventDefault();
        selectedColumn = Math.max(
            0,
            Math.min(COLS - 1, selectedColumn +
                (event.code === 'ArrowRight' ? 1 : -1))
        );
        boardEl.querySelector(
            `[data-column="${selectedColumn}"]`
        )?.focus();
    }
});

HTML側のセルには列番号をdata-columnとして持たせ、ARIAラベルにも「何列何段か」を設定しています。見た目だけでなく、キーボードと読み上げでも盤面の位置を把握できるようにしています。

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

BGMと効果音は外部音声ファイルではなく、Web Audio APIの発振器から生成しています。ブラウザの自動再生制限に対応するため、ゲーム開始ボタンなどユーザー操作の中でAudioContextを作成し、再生を始めます。

短い効果音を鳴らすJAVASCRIPT
function tone(freq, duration = .08, type = 'square', volume = .13) {
    if (!audio || audio.state !== 'running' || !sfxOn) return;

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

プレイヤーとCPUで周波数を変え、勝利時と敗北時には複数の音を少しずつずらして鳴らします。BGMは一定間隔のタイマーで音階を切り替え、BGMと効果音を別々のスイッチで停止できるようにしています。画面を離れたときはBGMを止めるため、戻ったときに意図しない再生が続きません。

実装のポイント

フォーラインでは、盤面、手番、入力ロック、勝利ライン、音声をそれぞれ役割の異なる状態として管理しています。特に重要なのは、駒の位置を決めるfindRowと、勝利を調べるwinningLineをプレイヤーとCPUの両方で使い回すことです。

ゲーム画面では、駒の追加を落下アニメーション、4連の成立を発光で表現しています。ルール判定と描画用の情報を分けることで、見た目の演出を増やしてもゲームの状態を壊しにくくなります。実際のフォーラインでは、まず中央の列を使いながら、相手の次の4連を止める手を探してみてください。