9 脳トレパズルゲーム「フルーツパネル」を作った

はじめに

Claude AIを使って、シニア向けの脳トレパズルゲームを作りました。

このゲームは、果物の絵文字を使ったマッチングパズルで、時間制限がなくゆっくり楽しめる設計になっています。

技術スタックとファイル構成

このゲームは、HTML、CSS、JavaScriptのみで作られています。
外部ライブラリやフレームワークは使っていません。単一のHTMLファイルにすべてのコードが含まれており、ブラウザで開くだけで動作します。

状態管理は、グローバル変数を使ったシンプルな方式です。
レベル、スコア、手数、経過時間、選択中のパネル、グリッドの状態など、すべてをJavaScriptの変数で管理します。

グリッドのレイアウトにはCSS Gridを使用しました。4列の固定幅で、行数は動的に変わります。
grid-template-columns: repeat(4, 1fr)という指定で、4つの均等な列を作ります。

.grid {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 8px;
    margin-bottom: 20px;
    background: #f5f5f5;
    padding: 10px;
    border-radius: 15px;
    position: relative;
}Code language: CSS (css)

基本コンセプトとゲームの基本ルール

シニア向けということで、画面要素は大きく、タップのみで操作できる設計にしました。

時間制限はなく、プレイヤーは自分のペースでゆっくり考えながら遊べます。当初は神経衰弱のような仕組みも検討しましたが、最終的に「絵柄を揃えて消すパズル」に決定しました。

ただし、ここに独自のルールを加えています。果物の名前の文字数と、揃える個数が連動する仕組みです。例えば「もも」は2文字なので2個、「ぶどう」は3文字なので3個揃える必要があります。この仕組みにより、レベルが上がって文字数の多い果物が登場すると、自然に難易度も上がっていきます。

レベル1では2文字と3文字の果物のみが登場し、グリッドは4列3行の12マスです。レベルが上がるごとに文字数の長い果物が追加され、同時にグリッドも1行ずつ増えていきます。レベル4では最大6文字の果物まで登場し、グリッドは4列6行の24マスになります。

果物データの設計

果物は、文字数と絵文字の組み合わせに注意をして、以下のような構成になりました。

  • 2文字は「もも🍑」と「なし🍐」、
  • 3文字は「ぶどう🍇」「いちご🍓」「バナナ🍌」「キウイ🥝」、
  • 4文字は「オレンジ🍊」と「マンゴー🥭」、
  • 5文字は「さくらんぼ🍒」と「ココナッツ🥥」、
  • 6文字は「ブルーベリー🫐」と「パイナップル🍍」です。
const fruitData = {
    2: [
        {name: 'もも', icon: '🍑'},
        {name: 'なし', icon: '🍐'}
    ],
    3: [
        {name: 'ぶどう', icon: '🍇'},
        {name: 'いちご', icon: '🍓'},
        {name: 'バナナ', icon: '🍌'},
        {name: 'キウイ', icon: '🥝'}
    ],
    4: [
        {name: 'オレンジ', icon: '🍊'},
        {name: 'マンゴー', icon: '🥭'}
    ],
    5: [
        {name: 'さくらんぼ', icon: '🍒'},
        {name: 'ココナッツ', icon: '🥥'}
    ],
    6: [
        {name: 'ブルーベリー', icon: '🫐'},
        {name: 'パイナップル', icon: '🍍'}
    ]
};Code language: JavaScript (javascript)

遊んでみると「オレンジ」が3文字の「みかん」と紛らわしいのですが、4文字の果物が少ないのでとりあえずそのまにしています。

得点とレベルアップの仕組み

得点システムは「消した個数の2乗」にしています。

一度に果物を多く消すほど、得点が跳ね上がる仕組みです。例えば3個消すと9点ですが、5個消すと25点になります(連鎖数については、後から追加しました)。

// 得点計算(消した個数の2乗×連鎖数)
const deleteCount = selected.length;
const points = deleteCount * deleteCount * chainCount;Code language: JavaScript (javascript)

レベルアップの条件は「次レベルの2乗×10点」です。レベル1から2へは2の2乗×10で40点、レベル2から3へは3の2乗×10で90点が必要になります。

// 次のレベルに必要な得点
function getNextLevelScore() {
    const nextLevel = level + 1;
    return nextLevel * nextLevel * 10;
}Code language: JavaScript (javascript)

レベル4を超える条件(5の2乗×10で250点)に到達すると、ゲームクリアです。

選択時の自動拡張機能

そのまま遊ぶと単調だったので、プレイヤーが文字数分のパネルを選択したとき、その選択に隣接する同じ果物も自動的に追加するようにしました。

// 選択を隣接する同じ果物に拡張
function expandSelection() {
    const rows = getRows();
    const cols = 4;
    const fruitName = grid[selected[0]].name;
    const visited = new Set(selected);
    const queue = [...selected];

    while (queue.length > 0) {
        const idx = queue.shift();
        const r = Math.floor(idx / cols);
        const c = idx % cols;

        // 上下左右をチェック
        const neighbors = [];
        if (r > 0) neighbors.push((r - 1) * cols + c); // 上
        if (r < rows - 1) neighbors.push((r + 1) * cols + c); // 下
        if (c > 0) neighbors.push(r * cols + (c - 1)); // 左
        if (c < cols - 1) neighbors.push(r * cols + (c + 1)); // 右

        for (const neighborIdx of neighbors) {
            if (!visited.has(neighborIdx) && grid[neighborIdx].name === fruitName) {
                visited.add(neighborIdx);
                selected.push(neighborIdx);
                queue.push(neighborIdx);
            }
        }
    }
}Code language: JavaScript (javascript)

例えば、プレイヤーが「ぶどう」を3個選んだとします。その3個に隣接してもう1個「ぶどう」があれば、自動的に4個セットで消えます。得点は3の2乗の9点ではなく、4の2乗の16点です。これにより、少し戦略的なプレイが可能になります。

実装には幅優先探索(BFS)というアルゴリズムを使いました。
あるパネルから始めて、上下左右の隣接するパネルを順番にチェックします。同じ果物なら同じグループに追加し、さらにその隣接パネルもチェックする処理を繰り返します。

// 自動マッチを探す
function findAutoMatches() {
    const rows = getRows();
    const cols = 4;
    const visited = new Set();
    const allMatched = new Set();

    // 各セルから連結成分を探す
    for (let row = 0; row < rows; row++) {
        for (let col = 0; col < cols; col++) {
            const startIdx = row * cols + col;

            if (visited.has(startIdx)) continue;

            const fruit = grid[startIdx];
            const requiredCount = fruit.name.length;

            // BFSでつながっている同じ果物を探す
            const connected = [];
            const queue = [startIdx];
            const localVisited = new Set();

            while (queue.length > 0) {
                const idx = queue.shift();

                if (localVisited.has(idx)) continue;
                localVisited.add(idx);
                visited.add(idx);

                if (grid[idx].name !== fruit.name) continue;

                connected.push(idx);

                // 上下左右をチェック
                const r = Math.floor(idx / cols);
                const c = idx % cols;

                // 上
                if (r > 0) {
                    const upIdx = (r - 1) * cols + c;
                    if (!localVisited.has(upIdx)) {
                        queue.push(upIdx);
                    }
                }

                // 下
                if (r < rows - 1) {
                    const downIdx = (r + 1) * cols + c;
                    if (!localVisited.has(downIdx)) {
                        queue.push(downIdx);
                    }
                }

                // 左
                if (c > 0) {
                    const leftIdx = r * cols + (c - 1);
                    if (!localVisited.has(leftIdx)) {
                        queue.push(leftIdx);
                    }
                }

                // 右
                if (c < cols - 1) {
                    const rightIdx = r * cols + (c + 1);
                    if (!localVisited.has(rightIdx)) {
                        queue.push(rightIdx);
                    }
                }
            }

            // つながった数が必要数以上なら消す対象に追加
            if (connected.length >= requiredCount) {
                connected.forEach(i => allMatched.add(i));
            }
        }
    }

    return Array.from(allMatched);
}Code language: JavaScript (javascript)

パネルの落下処理の実装

パズルゲームで最も重要な部分の一つが、パネルを消した後の落下処理です。

この実装には複雑なロジックが必要でした。

  1. まず、消されたパネルがどの列にあるかを特定します。
  2. 各列ごとに、消されたパネルより上にあったパネルを下に詰めます。
  3. そして空いた一番上のマスに、新しいパネルを追加する流れです。

実装では、列ごとに処理を分けました。4列あるので、それぞれ独立して計算します。消されたパネルの位置を記録し、その列の配列から該当するパネルを取り除きます。残ったパネルは自動的に詰まった状態になり、足りない分だけ新しいパネルを先頭に追加すれば完成です。

// 消すセルを列ごとに整理
const removedByColumn = [[], [], [], []];
selected.forEach(idx => {
    const col = idx % 4;
    const row = Math.floor(idx / 4);
    removedByColumn[col].push({idx, row});
});

// 各列ごとに処理
for (let col = 0; col < 4; col++) {
    if (removedByColumn[col].length === 0) continue;

    const removed = removedByColumn[col].sort((a, b) => b.row - a.row);
    const removedCount = removed.length;

    // 新しいグリッドを作成(この列のみ)
    const columnCells = [];
    for (let row = 0; row < rows; row++) {
        const idx = row * 4 + col;
        columnCells.push(grid[idx]);
    }

    // 消えるセルを取り除く
    const newColumn = columnCells.filter((_, idx) => {
        const gridIdx = idx * 4 + col;
        return !removed.some(r => r.idx === gridIdx);
    });

    // 上に新しいパネルを追加
    for (let i = 0; i < removedCount; i++) {
        newColumn.unshift(getRandomFruit());
    }

    // グリッドに戻す
    for (let row = 0; row < rows; row++) {
        const idx = row * 4 + col;
        grid[idx] = newColumn[row];
    }
}Code language: JavaScript (javascript)

アニメーションでは、新しく追加されたパネルと、消されたパネルより上にあったパネルにだけ落下エフェクトをつけました。消されたパネルより下にあったパネルは動かないため、アニメーションも不要です。

// 落下アニメーション対象のパネルを記録
const topRemovedRow = Math.min(...removed.map(r => r.row));

for (let row = 0; row < rows; row++) {
    const idx = row * 4 + col;
    grid[idx] = newColumn[row];

    // 消された一番上の行より上にあるパネルは全て落下対象
    if (row <= topRemovedRow + removedCount - 1) {
        fallingPanelIndices.push(idx);
    }
}Code language: JavaScript (javascript)

アニメーションはCSSのキーフレームを使いました。消えるパネルは拡大してから縮小し、同時に背景色を黄色に変えることで目立たせます。落下するパネルは、上から降ってくるように見せるため、Y軸方向の移動と透明度の変化を組み合わせました。パネルを消すときの効果音は、Web Audio APIを使って生成しました。

レベルアップ時のグリッド拡張

レベルが上がるときには、既存のパネルはそのままにして上に1行追加する方式にしました。グリッドを作り直す方が楽ですが、プレイヤーが築いた盤面の状態を維持したかったからです。

実装では、配列の先頭に4個の新しいパネルを追加します。

// レベルアップ時は上に1行追加
for (let i = 0; i < 4; i++) {
    grid.unshift(getRandomFruit());
}

renderGrid();
updateDisplay();

// 新しく追加された1行目にだけ落下アニメーション
await new Promise(resolve => setTimeout(resolve, 50));
const newCells = document.querySelectorAll('.cell');
for (let i = 0; i < 4; i++) {
    if (newCells[i]) {
        newCells[i].classList.add('falling');
    }
}Code language: JavaScript (javascript)

JavaScriptのunshiftメソッドを使えば簡単です。追加された1行目のパネルだけに落下アニメーションをつけることで、新しい行が降ってきた印象を与えます。

連鎖システムの導入

落下処理の後、自動的に揃ったパネルを検出して消す連鎖システムを実装しました。

グループの個数が文字数以上なら、自動的に消える仕組みです。縦横に隣接している同じ果物を、つながった一つのグループとして扱います。そのL字型や十字型など、どんな形でもつながっていれば対象になります。

連鎖が発生すると、得点に倍率がかかります。

// 連鎖チェック
const autoMatches = findAutoMatches();
if (autoMatches.length > 0) {
    selected = autoMatches;
    await processMatch(chainCount + 1); // 連鎖カウントを増やして再帰
}Code language: JavaScript (javascript)

1回目は通常通り「個数の2乗×1」ですが、2連鎖なら「個数の2乗×2」、3連鎖なら「個数の2乗×3」と増えていきます。

重要なのは、連鎖処理が完全に終わってからレベルアップ処理を実行することです。連鎖の途中でグリッドのサイズが変わると、インデックス(配列の位置)の計算が狂ってしまいます。

// マッチチェックと落下処理
async function checkMatch() {
    animating = true;
    moves++;
    await processMatch(1); // 連鎖カウント1から開始

    // 連鎖が完全に終わってからレベルアップチェック
    await checkLevelUp();

    updateGiveupButton();
    animating = false;
}Code language: JavaScript (javascript)

ギブアップ機能の実装

ゲーム中に「ギブアップ」ボタンを設置しました。しかし、単純にゲームを終了するのではなく、まだ消せる組み合わせがあるかチェックします。

チェック方法は、盤面上の各果物の個数を数えます。ある果物の個数が、その文字数以上あれば「まだ消せる」と判定します。例えば「もも」が2個以上、「ぶどう」が3個以上あれば、理論上は消すことが可能です。

// 消せる組み合わせがあるかチェック
function canMakeMatch() {
    const rows = getRows();
    const cols = 4;
    const fruitCounts = {};

    // 各果物の数をカウント
    grid.forEach(fruit => {
        const name = fruit.name;
        fruitCounts[name] = (fruitCounts[name] || 0) + 1;
    });

    // 文字数分あるかチェック
    for (const name in fruitCounts) {
        const requiredCount = name.length;
        if (fruitCounts[name] >= requiredCount) {
            return true;
        }
    }

    return false;
}Code language: JavaScript (javascript)

まだ消せる状態でギブアップを押すと、10点減点され、手数が1増えます。ゲームは続行です。本当に消せる組み合わせがない場合のみ、ゲームオーバーとなりスコアが記録されます。

スコア記録システム

ゲーム終了時に、得点、手数、経過時間(秒)を記録します。記録はブラウザのlocalStorageに保存され、直近10件まで保持されます。

記録データはJSON形式で保存します。スコア、手数、経過時間に加えて、記録した日付も含めました。新しい記録は配列の先頭に追加し、11件目以降は削除する仕組みです。

// スコアを記録
function recordScore() {
    const elapsedTime = Math.floor((Date.now() - startTime) / 1000);
    scoreHistory.unshift({
        score: totalScore,
        moves: moves,
        time: elapsedTime,
        date: new Date().toLocaleDateString()
    });

    // 直近10件のみ保持
    if (scoreHistory.length > 10) {
        scoreHistory = scoreHistory.slice(0, 10);
    }

    saveScoreHistory();
    displayScoreHistory();
}

// スコア履歴を保存
function saveScoreHistory() {
    localStorage.setItem('fruitPuzzleScores', JSON.stringify(scoreHistory));
}

// ローカルストレージからスコア履歴を読み込み
function loadScoreHistory() {
    const saved = localStorage.getItem('fruitPuzzleScores');
    if (saved) {
        scoreHistory = JSON.parse(saved);
    }
}Code language: JavaScript (javascript)

スタート画面とゲームオーバー画面を統一し、そこにハイスコアと履歴を表示します。プレイヤーは過去の成績を確認してから、新しいゲームを始められます。

タイマーの実装

経過時間を表示するため、JavaScriptのsetIntervalを使ってタイマーを実装しました。1秒ごとに現在時刻とゲーム開始時刻の差を計算し、秒数で表示します。

ゲーム終了時にはclearIntervalでタイマーを停止します。この処理を忘れると、バックグラウンドでタイマーが動き続け、メモリリークの原因になります。

まとめ

Claude AIとの対話を通じて、要件を少しずつ明確にしながらゲームを作り上げました。最初は単純な3マッチパズルでしたが、文字数連動の仕組み、連鎖システム、隣接拡張機能などを追加することで、独自性のあるゲームに進化しています。

技術的には、BFSアルゴリズムによる連結成分の検出、列ごとの落下処理、Web Audio APIによる効果音生成など、パズルゲームに必要な要素を実装しました。シニア向けという制約が、むしろシンプルで遊びやすいゲームデザインを生み出す結果となっています。