【ゲーム】JavaScript:08 フルーツクラッシュ

 この「🍓 フルーツクラッシュゲーム 🍎」は、8×8 のグリッド上で隣接するフルーツをドラッグ&ドロップで入れ替え、同じ絵文字が3つ以上横か縦に並ぶと消去&スコア加算、空いたマスに上からフルーツが落ちてくるパズルゲームです。制限時間 60 秒でどれだけスコアを伸ばせるかを競い、ハイスコアは簡易ランキングに残ります。

🍓 ルール

項目内容
🎮 目的フルーツを入れ替えて、同じ絵柄のフルーツを縦または横に3つ揃えて消す
制限時間60秒間の制限時間内で、できるだけ高いスコアを獲得する。
💯 得点方法3つ以上の同種フルーツが並んで消えると、スコアが+10点加算
🔄 入れ替え隣接したフルーツのみ入れ替え可能(上下左右1マス)
マッチ条件入れ替え後、縦または横に3個以上同じフルーツが揃えば成功
無効な入れ替え揃わなかった場合は元に戻る
⬇️ 補充処理消えたマスには上からフルーツが落ちてきて、新しいフルーツで補充される。
🏆 ランキング機能ゲーム終了時のスコアが上位5件のスコアとして保存・表示される。

🕹️ 遊び方の流れ

  1. 「▶️ スタート」ボタンをクリック
    ・8×8のグリッドにランダムなフルーツが表示されてゲームスタート。
    ・タイマー(60秒)がカウントダウン開始。
  2. フルーツをドラッグ&ドロップ
    ・隣り合うフルーツを入れ替えて、同じフルーツを3つ以上揃える。
    ・揃えると自動で消え、スコアが増える。
  3. 消えたあと、上からフルーツが補充
    ・消えたスペースに、上のフルーツが落ち、上部には新しいフルーツが追加される。
  4. 60秒経過でゲーム終了
    ・スコアに応じて「Win」または「Game Over」のメッセージが表示される。
    ・スコアはランキングに記録され、上位5件が表示される。
  5. 「🔄 リセット」ボタンでリセット
    ・ゲームボードが非表示になり、スコアや時間が初期状態に戻る。

 プレイは画面下の「▶️ スタート」ボタンを押すだけ。グリッドが表示されたら、お好きなフルーツを長押し(ドラッグ)して隣のマスにドロップすることで入れ替えができ、3つ並ぶ組み合わせがあれば自動で消去され新たなフルーツが補充されます。時間内は何度でも組み換え可能ですが、消去できない入れ替えは元に戻ります。
リセットしたいときは「🔄 リセット」で盤面もスコアもタイマーも一気に初期化できます。

 3つ以上並べて消去したときだけ得点が入るのがルールのポイント。並びは横3つ/縦3つで判定し、同時に複数列消せばまとめてスコア加算。時間切れになると自動でゲームオーバーとなり、獲得スコアに応じて「300 点以上なら勝利メッセージ、それ未満なら再挑戦メッセージ」が出ます。その後ハイスコア欄にスコアが登録され、上位5件まで表示されます。

🎮ゲームプレイ

以下のリンク先から実際にプレイできます。

08 フルーツクラッシュ

素材のダウンロード

以下のリンクから使用する素材をダウンロードできます。

fruit_crush_bg.png

ゲーム画面イメージ

プログラム全文(fruit_crush.html)

<!DOCTYPE html>
<!--
  ===========================================================
  Fruit Crush Game  🍓🍌🍇🍊🍎
  Fully‑commented version (2025‑05‑06)
  ───────────────────────────────────────────────────────────
  ・ゲーム内容  :8×8 マスのキャンディクラッシュ系パズル
  ・主な機能  :ドラッグ&ドロップでフルーツ交換/
                   3 つ揃うと消去&落下補充/60 秒タイマー/
                   スコア計算/簡易ランキング表示
  ===========================================================
-->
<html lang="ja">
<head>
    <!-- 文字コードを UTF‑8 に設定(日本語・絵文字どちらも安全に扱える) -->
    <meta charset="UTF-8">

    <!-- レスポンシブ対応:ビューポートをデバイス幅に合わせる -->
    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <!-- ブラウザのタブに表示されるタイトル(絵文字入り) -->
    <title>🍓 フルーツクラッシュゲーム 🍎</title>

    <!--
      --------------------------------------------------------
      ここからインライン CSS
      ‑ ページ全体のレイアウト
      ‑ ボタン/グリッド/アニメーションなどの装飾
      --------------------------------------------------------
    -->
    <style>
        /* ======== 基本レイアウト設定 ======== */
        body {
            /* Flexbox で縦中央寄せ */
            display: flex;
            flex-direction: column;
            align-items: center;

            /* 画面いっぱいの高さを確保 */
            height: 100vh;

            margin: 0;

            /* 背景に繰り返しタイル画像を設定(ゲームの雰囲気アップ) */
            background: url('fruit_crush_bg.png') repeat;
            background-size: cover;

            /* 全体フォント */
            font-family: Arial, sans-serif;
        }

        /* ======== タイトル見出し ======== */
        .main-title {
            font-size: 2.5rem;
            margin: 20px 0;

            /* 文字に軽い影を付けて視認性アップ */
            text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
        }

        /* ======== フェードアウト用アニメーション ======== */
        .fade-out {
            animation: fadeOut 0.5s forwards; /* forwards で最終状態を保持 */
        }
        @keyframes fadeOut {
            from { opacity: 1; }
            to   { opacity: 0; }
        }

        /* ======== 落下演出用アニメーション ======== */
        .fall-down {
            animation: fallDown 0.5s ease-out;
        }
        @keyframes fallDown {
            from { transform: translateY(-50px); }
            to   { transform: translateY(0);      }
        }

        /* (未使用)背景グラデーションを動かすサンプルアニメ */
        @keyframes gradientBackground {
            0%   { background-position: 0%   50%; }
            50%  { background-position: 100% 50%; }
            100% { background-position: 0%   50%; }
        }

        /* ======== スコア & タイマー表示エリア ======== */
        .game-header {
            display: flex;               /* 左右に並べる */
            justify-content: space-between;
            width: 400px;
            margin-bottom: 10px;

            /* 文字装飾 */
            font-family: Arial, sans-serif;
            font-size: 20px;
            font-weight: bold;
            color: #fff;

            /* 半透明背景で読みやすさ確保 */
            background: rgba(0,0,0,0.5);
            padding: 4px 8px;
            border-radius: 8px;
        }

        /* ======== ゲーム盤(8×8 グリッド) ======== */
        .game-container {
            /* CSS Grid で 8 列 × 8 行 を定義 */
            display: grid;
            grid-template-columns: repeat(8, 50px);
            grid-template-rows:    repeat(8, 50px);
            gap: 2px;              /* マス間の隙間 */

            background-color: #222; /* 盤のフチ色 */
            padding: 5px;
            border-radius: 10px;

            /* 初期状態では非表示(スタート時に切り替え) */
            display: none;
        }

        /* ======== 各マスに入るフルーツ絵文字 ======== */
        .candy {
            width: 50px;
            height: 50px;
            border-radius: 10%;    /* 角を軽く丸める */
            cursor: pointer;       /* ホバー時にポインタ表示 */

            /* 中央揃え(Flexbox) */
            display: flex;
            justify-content: center;
            align-items: center;

            font-size: 30px;       /* 絵文字サイズ */
        }

        /* ======== 空マス用クラス ======== */
        .blank {
            background-color: transparent;
        }

        /* ======== START / RESET ボタン周り ======== */
        .button-container {
            margin: 20px;
        }
        .button-container button {
            margin: 0 10px;
            padding: 10px 20px;
            font-size: 16px;
            cursor: pointer;

            /* ボタンを丸みのあるカラフルなグラデーションに */
            border: none;
            border-radius: 25px;
            background: linear-gradient(45deg, #ff9a9e, #fad0c4);
            color: white;

            /* 手書き風フォントでポップ感を演出 */
            font-family: 'Comic Sans MS', sans-serif;
            font-weight: bold;

            /* 影で立体感追加 */
            box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.2);
            transition: all 0.3s ease;
        }
        .button-container button:hover {
            background: linear-gradient(45deg, #fad0c4, #ff9a9e);
            transform: scale(1.1);
            box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.1);
        }

        /* ======== ゲームメッセージ表示エリア ======== */
        #message-area{
            min-height:40px;      /* 高さを確保(レイアウトの揺れ防止) */
            font-size:20px;
            margin:10px 0;
            text-shadow:1px 1px 2px #000;
            color:#fff;
        }

        /* ======== 簡易ランキング表示 ======== */
        .ranking-container {
            margin-top: 20px;
            background: rgba(0,0,0,0.1);
            font-family: Arial, sans-serif;
            font-size: 16px;
            text-align: center;
            color:#fff;
            padding:8px;
            border-radius:8px;
        }
        .ranking-container ul {
            list-style: none;
            padding: 0;
        }
        .ranking-container li {
            margin: 5px 0;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <!-- ================== 画面構成 ================== -->

    <!-- ゲームタイトル -->
    <h1 class="main-title">🍓🍌🍇 フルーツクラッシュゲーム 🍊🍎</h1>

    <!-- スコア & タイマー -->
    <div class="game-header">
        <div>🎯 スコア:<span id="score">0</span></div>
        <div>⏱️ 残り時間:<span id="time">60</span> sec</div>
    </div>

    <!-- 操作用ボタン -->
    <div class="button-container">
        <button id="start-button">▶️ スタート</button>
        <button id="reset-button">🔄 リセット</button>
    </div>

    <!-- ゲーム盤 -->
    <div class="game-container" id="game"></div>

    <!-- メッセージ(勝敗やヒント等) -->
    <div id="message-area"></div>

    <!-- 簡易ランキング -->
    <div class="ranking-container">
        <h3>🏆ランキング🏆</h3>
        <ul id="ranking-list"></ul>
    </div>

    <!--
      ==========================================================
      ここからゲームロジック(純粋 JavaScript)
      ==========================================================
    -->
    <script>
        /* ------------- DOM 要素取得 ------------- */
        const grid          = document.getElementById('game');       // 盤グリッド
        const scoreDisplay  = document.getElementById('score');      // スコア表示
        const timeDisplay   = document.getElementById('time');       // 残り時間表示
        const startButton   = document.getElementById('start-button'); // スタート
        const resetButton   = document.getElementById('reset-button'); // リセット
        const messageArea   = document.getElementById('message-area');// メッセージ

        /* ------------- ゲーム定数 ------------- */
        const gridSize = 8;                                   // 盤の一辺
        const fruits   = ['🍎', '🍌', '🍇', '🍊', '🍓'];       // 5 種のフルーツ絵文字

        /* ------------- ゲーム状態変数 ------------- */
        const squares = [];    // 盤上の <div> を保存(index=ID)
        let score = 0;         // 現在スコア
        let timeRemaining = 60;// タイマー(秒)
        let timerInterval = null;  // setInterval の戻り値(時間管理)
        let matchInterval = null;  // setInterval の戻り値(マッチ判定)

        /* =======================================================
           ユーティリティ:行・列で「同じフルーツが 3 つ並ぶ」かを検出
           ‑ マッチ時は対象マスを空にし、アニメ演出も付加
           ======================================================= */

        function checkRowForThree() {
            let matchFound = false;
            /* 盤を左上→右下へ走査(最後 2 列は 3 連にならないので除外) */
            for (let i = 0; i < gridSize * gridSize - 2; i++) {
                const rowOfThree = [i, i + 1, i + 2];           // 横 3 連
                const decidedFruit = squares[i].textContent;    // 基準フルーツ

                /* 全 3 マスが同じ絵文字かつ空でないか? */
                if (rowOfThree.every(index =>
                        squares[index].textContent === decidedFruit &&
                        decidedFruit !== '')) {

                    /* マッチ! → フェードアウト演出 → 空に */
                    rowOfThree.forEach(index => {
                        squares[index].classList.add('fade-out');
                        setTimeout(() => {
                            squares[index].textContent = '';
                            squares[index].classList.remove('fade-out');
                        }, 500); // 0.5 秒後にリセット
                    });
                    matchFound = true;
                }
            }
            return matchFound;
        }

        /* フルーツを 1 マスずつ下へ落とす(上から補充) */
        function moveDown() {
            for (let i = 0; i < gridSize * (gridSize - 1); i++) {
                /* 下が空なら落下 */
                if (squares[i + gridSize].textContent === '') {
                    squares[i + gridSize].textContent = squares[i].textContent;
                    squares[i + gridSize].classList.add('fall-down');
                    squares[i].textContent = '';
                    setTimeout(() => {
                        squares[i + gridSize].classList.remove('fall-down');
                    }, 500);
                }

                /* 先頭行:空マスはランダム補充 */
                const firstRow = Array.from({ length: gridSize }, (_, i) => i);
                firstRow.forEach(index => {
                    if (squares[index].textContent === '') {
                        const randomFruit = Math.floor(Math.random() * fruits.length);
                        squares[index].textContent = fruits[randomFruit];
                        squares[index].classList.add('fall-down');
                        setTimeout(() => {
                            squares[index].classList.remove('fall-down');
                        }, 500);
                    }
                });
            }
        }

        /* =======================================================
           ボード生成:8×8 マスを生成しランダム配置
           ======================================================= */
        function createBoard() {
            grid.innerHTML = '';     // 既存マスをクリア
            squares.length = 0;      // 配列も初期化

            for (let i = 0; i < gridSize * gridSize; i++) {
                const square = document.createElement('div');
                const randomFruit = Math.floor(Math.random() * fruits.length);
                square.classList.add('candy');
                square.textContent = fruits[randomFruit];

                /* DnD 用属性 */
                square.setAttribute('draggable', true);
                square.setAttribute('id', i); // index を ID に

                grid.appendChild(square);
                squares.push(square);
            }

            /* --- Drag & Drop イベントを各マスに紐付け --- */
            squares.forEach(square => {
                square.addEventListener('dragstart', dragStart);
                square.addEventListener('dragend',   dragEnd);
                square.addEventListener('dragover',  dragOver);
                square.addEventListener('dragenter', dragEnter);
                square.addEventListener('dragleave', dragLeave);
                square.addEventListener('drop',      dragDrop);
            });
        }

        /* =======================================================
           Drag & Drop 実装:フルーツ同士を交換
           ======================================================= */

        let fruitBeingDragged;     // ドラッグ元の絵文字
        let fruitBeingReplaced;    // ドロップ先の絵文字
        let squareIdBeingDragged;  // ドラッグ元 ID
        let squareIdBeingReplaced; // ドロップ先 ID

        /* --- ドラッグ開始 --- */
        function dragStart() {
            fruitBeingDragged   = this.textContent;
            squareIdBeingDragged = parseInt(this.id);
        }

        /* --- ドラッグ中(デフォルト動作を抑止) --- */
        function dragOver(e)  { e.preventDefault(); }
        function dragEnter(e) { e.preventDefault(); }
        function dragLeave()  { /* 使わないが定義だけ残す */ }

        /* --- ドロップ発生 --- */
        function dragDrop() {
            fruitBeingReplaced    = this.textContent;
            squareIdBeingReplaced = parseInt(this.id);

            /* 2 つの絵文字を入れ替え */
            squares[squareIdBeingDragged].textContent = fruitBeingReplaced;
            squares[squareIdBeingReplaced].textContent = fruitBeingDragged;
        }

        /* --- ドラッグ完了 → 入れ替えが有効か判定 --- */
        function dragEnd() {
            /* 交換できるのは上下左右 1 マスのみ */
            const validMoves = [
                squareIdBeingDragged - 1,
                squareIdBeingDragged + 1,
                squareIdBeingDragged - gridSize,
                squareIdBeingDragged + gridSize
            ];
            const validMove = validMoves.includes(squareIdBeingReplaced);

            /* 交換後に 3 連ができるか? */
            const isAValidMatch = checkForMatches();

            if (squareIdBeingReplaced && validMove && isAValidMatch) {
                /* 成功:スコア加算 */
                squareIdBeingReplaced = null;
                score += 10;
                scoreDisplay.textContent = score;
            } else {
                /* 失敗:元に戻す */
                squares[squareIdBeingDragged].textContent = fruitBeingDragged;
                squares[squareIdBeingReplaced].textContent = fruitBeingReplaced;
            }
        }

        /* -------------------------------------------------------
           行・列マッチ判定(※ 同名関数が 2 回定義されているが、
           ソースを変更しない指示のためそのままにしている)
           ------------------------------------------------------- */
        function checkRowForThree() {
            let matchFound = false;
            for (let i = 0; i < gridSize * gridSize - 2; i++) {
                const rowOfThree = [i, i + 1, i + 2];
                const decidedFruit = squares[i].textContent;

                if (rowOfThree.every(index =>
                        squares[index].textContent === decidedFruit &&
                        decidedFruit !== '')) {
                    rowOfThree.forEach(index => squares[index].textContent = '');
                    matchFound = true;
                }
            }
            return matchFound;
        }

        function checkColumnForThree() {
            let matchFound = false;
            for (let i = 0; i < gridSize * (gridSize - 2); i++) {
                const columnOfThree = [i, i + gridSize, i + gridSize * 2];
                const decidedFruit = squares[i].textContent;

                if (columnOfThree.every(index =>
                        squares[index].textContent === decidedFruit &&
                        decidedFruit !== '')) {
                    columnOfThree.forEach(index => squares[index].textContent = '');
                    matchFound = true;
                }
            }
            return matchFound;
        }

        /* 行または列どちらかでマッチすれば true */
        function checkForMatches() {
            const rowMatch    = checkRowForThree();
            const columnMatch = checkColumnForThree();
            return rowMatch || columnMatch;
        }

        /* --- 旧バージョンの moveDown / checkMatches(残置) --- */
        function moveDownLegacy() {
            for (let i = 0; i < gridSize * (gridSize - 1); i++) {
                if (squares[i + gridSize].textContent === '') {
                    squares[i + gridSize].textContent = squares[i].textContent;
                    squares[i].textContent = '';
                }

                const firstRow = Array.from({ length: gridSize }, (_, i) => i);
                firstRow.forEach(index => {
                    if (squares[index].textContent === '') {
                        const randomFruit = Math.floor(Math.random() * fruits.length);
                        squares[index].textContent = fruits[randomFruit];
                    }
                });
            }
        }
        function checkMatchesLegacy() {
            checkRowForThree();
            checkColumnForThree();
            moveDownLegacy();
        }

        /* =======================================================
           ランキング機能:ハイスコア Top5 を保持
           ======================================================= */
        const highScores = [];

        function updateRanking() {
            /* 新スコアを配列に追加し降順ソート */
            highScores.push(score);
            highScores.sort((a, b) => b - a);

            /* 6 件以上なら末尾を削除 */
            if (highScores.length > 5) highScores.pop();

            /* UL タグを再描画 */
            const rankingList = document.getElementById('ranking-list');
            rankingList.innerHTML = '';
            highScores.forEach((highScore, index) => {
                const listItem = document.createElement('li');
                listItem.textContent = `${index + 1}位. ${highScore}点`;
                rankingList.appendChild(listItem);
            });
        }

        /* =======================================================
           タイマー更新:1 秒ごとに呼ばれる
           ======================================================= */
        function updateTimer() {
            timeRemaining -= 1;
            timeDisplay.textContent = timeRemaining;

            if (timeRemaining <= 0) {
                /* タイムアップ → すべての定期処理を停止 */
                clearInterval(timerInterval);
                clearInterval(matchInterval);

                /* ランキング更新 */
                updateRanking();

                /* 結果メッセージ */
                const msg = (score >= 300)
                    ? `🎉 おめでとう! スコア ${score} 点で勝利!`
                    : `🍀 ゲームオーバー… スコア ${score} 点。また挑戦してね!`;
                showMessage(msg);
            }
        }

        /* ちょっとしたメッセージ出力ユーティリティ */
        function showMessage(text){
            messageArea.textContent = text;
        }

        /* =======================================================
           ゲーム開始(Start ボタン)
           ======================================================= */
        function startGame() {
            /* 状態を初期化 */
            score = 0;
            timeRemaining = 60;
            scoreDisplay.textContent = score;
            timeDisplay.textContent = timeRemaining;

            createBoard();          // ボード生成
            grid.style.display = 'grid'; // 盤を表示

            /* 既存インターバルを念のためクリア */
            clearInterval(timerInterval);
            clearInterval(matchInterval);

            /* 1 秒ごとにタイマー更新 */
            timerInterval  = setInterval(updateTimer, 1000);
            /* 0.1 秒ごとにマッチ判定&落下処理 */
            matchInterval  = setInterval(() => {
                checkRowForThree();
                checkColumnForThree();
                moveDown();
            }, 100);
        }

        /* =======================================================
           ゲームリセット(Reset ボタン)
           ======================================================= */
        function resetGame() {
            /* インターバル停止 */
            clearInterval(timerInterval);
            clearInterval(matchInterval);

            /* ステータス初期化 */
            score = 0;
            timeRemaining = 60;
            scoreDisplay.textContent = score;
            timeDisplay.textContent  = timeRemaining;

            /* ボード非表示&クリア */
            grid.innerHTML = '';
            grid.style.display = 'none';
        }

        /* ボタンにイベント登録 */
        startButton.addEventListener('click', startGame);
        resetButton.addEventListener('click', resetGame);
    </script>
</body>
</html>

アルゴリズムの流れ

ステップ関数/命令内容
ボード生成createBoard()8×8 の <div> を生成し、ランダムフルーツを配置。ドラッグ&ドロップのイベントを登録。
ドラッグ&ドロップdragStartdragDropdragEnd交換元・先のフルーツを入れ替え、隣接チェック&マッチ判定後にスコア加算 or 元に戻す。
マッチ判定checkRowForThree()checkColumnForThree()横3連/縦3連を走査し、消去(フェードアニメ付き)→空マス設定。
落下&補充moveDown()下が空いたら上のフルーツを落とし、最上段はランダム補充。落下アニメ付き。
タイマー&マッチループsetInterval(updateTimer,1000)setInterval(...,100)1秒ごとに残り時間更新、0 で終了処理。0.1秒ごとにマッチ&落下処理を実行。
ランキング更新updateRanking()最高5件までスコアを降順で保持し、HTML リストに再描画。

関数の詳細

関数名役割
createBoard()グリッドを初期化し、フルーツ絵文字とドラッグイベントを各マスに設定
dragStart()ドラッグ開始時に選んだフルーツと ID を記録
dragDrop()ドロップ先でフルーツを入れ替える
dragEnd()入れ替え後「隣接か」「3連マッチか」をチェックし、有効ならスコア加算、無効なら元に戻す
checkRowForThree()すべての横3連位置を走査し、連続して同じ絵文字なら消去
checkColumnForThree()列方向の3連判定
moveDown()盤上の空マスに対し上のセルを下げ、最上段をランダム補充
updateTimer()1秒ごとに残り時間を減らし、タイムアップで終了処理とランキング更新
startGame()スコア/時間初期化→ボード生成→インターバル開始→盤面表示
resetGame()インターバル停止→スコア・時間をリセット→盤面非表示

改造のポイント

  • 4連以上の消去判定:3連だけでなく4連・5連用の最適化を追加し、高得点コンボを演出。
  • 特殊アイテム:縦一列全消去・指定フルーツ全消去などのパワーアップブロックを導入。
  • コンボボーナス:連鎖的に消えた回数でスコア倍率を変化させるエフェクトを追加。
  • レベルアップ:時間経過やスコアに応じて盤面サイズを変えたり、フルーツの落下速度を高速化。
  • オンライン対戦:WebSocket でリアルタイム対戦対応し、互いの盤面を見ながらスコアを競う機能。
  • モバイル最適化:タッチスワイプで交換、レスポンシブ対応などスマホ向け UI 改良。
  • サウンド&演出:消去・落下・タイムアップ時の効果音やもっと派手なアニメーションを追加。

 ぜひこの基本構造をベースに、自分だけのオリジナル「フルーツクラッシュ」を作り込んでみてください!