社内BINGOゲーム大会用にルーレットアプリを開発してみた

# はじめに
全社研修の際に、BINGO大会を行うので、そのためのルーレットを開発しました。

[デモ](http://bingo.wealthy-design.com/)

初期画面：
![スクリーンショット 2024-10-18 13.06.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/682035/5c155ca1-2f45-a350-9ed5-8898484b6eca.png)

会議室の大画面に映すので、左右にBINGOの一覧表を作成しています。

# 仕様
### ボタン
スタートボタン、もしくはスペースキーを押下すると、ルーレットが開始ます。
ストップボタン、もしくはスペースキーを押下すると、ルーレットが停止ます。
リセットボタンを押下すると、初期化されます。
ビンゴボタン、もしくはエンターキーを押下すると、BINGOになった人を祝福します。

### 効果音
効果音は4種類で、htmlと同じ階層に音源ファイルを置いてます。
- rolling.mp3: ルーレットが回っている時
- stop.mp3: ストップボタンが押下された時
- confirm.mp3: 番号が確定した時
- win.mp3: 祝福された時

### 一覧表
1度選択された番号は、いつでも一覧表で確認できます。
直近の番号が赤色で、それよりも前の番号は黄色になります。

一覧表：
![スクリーンショット 2024-10-18 13.07.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/682035/ebd105b2-dfc0-b6e8-fa80-f9dbac20f3e1.png)

祝福：
![スクリーンショット 2024-10-18 13.08.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/682035/9e086e26-6873-c915-82c5-07afc0ca538f.png)


# コード

```html:bingo.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>BINGOルーレット</title>
    <style>
        /* ページ全体の基本スタイル */
        body {
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            height: 100vh;
            margin: 0;
            background: linear-gradient(135deg, #ff9a9e 0%, #fad0c4 99%, #fad0c4 100%);
            overflow: hidden;
            position: relative;
        }

        /* ビンゴ番号表示コンテナ */
        #bingoNumberContainer {
            position: fixed;
            top: 3%; /* 表示位置を上に少し調整 */
            left: 50%;
            transform: translateX(-50%);
            width: 80%;
            height: calc(100% - 170px); /* コントロールボタンの高さを考慮 */
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 1;
            background: none; /* 背景は初期状態では透明 */
        }

        /* ビンゴ番号の表示スタイル */
        #bingoNumber {
            font-size: 50vw; /* 画面の50%の幅に基づいたフォントサイズ */
            color: #fff;
            text-shadow: 4px 4px 0 #000; /* 黒い縁取り */
        }

        /* 点滅アニメーション（数字が選ばれた際の点滅効果） */
        @keyframes blink {
            0%, 100% {
                opacity: 1;
            }
            50% {
                opacity: 0;
            }
        }

        /* テーブルのセルが点滅する際のアニメーション */
        @keyframes tableBlink {
            0%, 100% {
                background-color: #ff0000; /* 赤色 */
                color: #000;
            }
            50% {
                background-color: transparent;
                color: #fff;
            }
        }

        /* コントロールボタンのコンテナ */
        #controls {
            position: fixed;
            bottom: 0;
            left: 0;
            width: 100%;
            display: flex;
            justify-content: center;
            gap: 20px;
            padding: 20px;
            background: rgba(0, 0, 0, 0.5);
            z-index: 2;
        }

        /* ボタンの基本スタイル */
        .button {
            padding: 15px 30px;
            font-size: 1.5em;
            color: #fff;
            background: #ff6f61;
            border: none;
            border-radius: 30px;
            cursor: pointer;
            transition: background 0.3s ease;
            box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.2);
            z-index: 2;
        }

        /* ボタンのホバー時のスタイル */
        .button:hover {
            background: #ff4e42;
        }

        /* ボタンが無効化された時のスタイル */
        .button:disabled {
            background: #cccccc;
            cursor: not-allowed;
        }

        /* 番号リストの共通スタイル */
        .numberList {
            position: absolute;
            top: 10%;
            width: 15%;
            height: 80%;
            overflow: auto;
            z-index: 1;
        }

        /* 左右に配置する番号リスト */
        #leftNumberList {
            left: 0;
        }
        #rightNumberList {
            right: 0;
        }

        /* 番号テーブルのスタイル */
        .numberTable {
            font-size: 1.5em;
            color: #fff;
            background: rgba(0, 0, 0, 0.2);
            padding: 10px;
            border-radius: 10px;
            box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.1);
            text-align: center;
            width: 100%;
            box-sizing: border-box;
        }

        /* テーブルのスタイル */
        table {
            border-collapse: collapse;
            width: 100%;
        }

        /* テーブルのセルやヘッダのスタイル */
        table, th, td {
            border: 1px solid #fff;
        }
        th, td {
            padding: 5px;
            text-align: center;
            color: #fff;
        }

        /* テーブルのヘッダ（BINGO文字）の背景色 */
        th {
            background-color: rgba(0, 0, 0, 0.5);
        }

        /* 番号が選ばれた際に強調されるスタイル */
        .highlight {
            background-color: #ff6f61;
        }

        /* 選ばれた番号を黄色で表示 */
        .marked {
            background-color: #ffff00;
            color: #000;
        }

        /* 最近選ばれた番号を赤で表示 */
        .recently-marked {
            background-color: #ff0000;
            color: #000;
        }

        /* ビンゴ成功時のメッセージ */
        #congratsMessage {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            font-size: 8vw;
            color: #ff0000;
            text-shadow: 2px 2px 0 #000;
            z-index: 3;
            display: none;
        }

        /* 紙吹雪エフェクト用のキャンバス */
        #confettiCanvas {
            position: fixed;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            z-index: 3;
        }
    </style>
</head>
<body>
    <!-- ビンゴ番号表示 -->
    <div id="bingoNumberContainer">
        <div id="bingoNumber">0</div>
    </div>

    <!-- コントロールボタン -->
    <div id="controls">
        <button id="startButton" class="button">スタート</button>
        <button id="resetButton" class="button" style="background: #6fafff;">リセット</button>
        <button id="bingoButton" class="button" style="background: #4caf50;">ビンゴ</button>
    </div>

    <!-- 番号リスト（左右） -->
    <div id="leftNumberList" class="numberList"></div>
    <div id="rightNumberList" class="numberList"></div>

    <!-- ビンゴ成功時のメッセージ -->
    <div id="congratsMessage">おめでとう！</div>

    <!-- 紙吹雪エフェクト用のキャンバス -->
    <canvas id="confettiCanvas"></canvas>

    <!-- 効果音 -->
    <audio id="rollingSound" src="rolling.mp3" loop></audio>
    <audio id="stopSound" src="stop.mp3"></audio>
    <audio id="confirmSound" src="confirm.mp3"></audio>
    <audio id="winSound" src="win.mp3"></audio>

    <script>
        let isRolling = false; // ルーレットが回転中かどうかのフラグ
        let bingoNumbers = Array.from({ length: 75 }, (_, i) => i + 1); // 1から75までのビンゴ番号リスト
        let chosenNumbers = []; // 選ばれた番号を格納する配列
        let interval; // ルーレット回転時のインターバル管理用
        let timeout; // タイマー管理用
        let lastChosenNumber = null; // 最後に選ばれた番号

        // 効果音の要素
        const rollingSound = document.getElementById('rollingSound');
        const stopSound = document.getElementById('stopSound');
        const confirmSound = document.getElementById('confirmSound');
        const winSound = document.getElementById('winSound');

        /**
         * 番号テーブルを生成する関数
         * 各BINGOの列に対応する番号をテーブルとして生成
         */
        const generateBingoTable = () => {
            return `
                <div class="numberTable">
                    <table>
                        <thead>
                            <tr>${'BINGO'.split('').map(v => `<th>${v}</th>`).join('')}</tr>
                        </thead>
                        <tbody>
                            ${Array.from({ length: 15 }, (_, j) =>
                                `<tr>${Array.from({ length: 5 }, (_, i) => `<td>${1 + i * 15 + j}</td>`).join('')}</tr>`
                            ).join('')}
                        </tbody>
                    </table>
                </div>
            `;
        };

        // 左右の番号リストにテーブルを挿入
        document.getElementById('leftNumberList').innerHTML = generateBingoTable();
        document.getElementById('rightNumberList').innerHTML = generateBingoTable();

        /**
         * 番号が選ばれた際にテーブル上の該当セルにハイライトを付ける
         * @param {number} number 選ばれた番号
         */
        const highlightNumber = (number) => {
            document.querySelectorAll('.numberTable td').forEach(cell => {
                cell.classList.remove('highlight');
                if (parseInt(cell.textContent) === number) {
                    cell.classList.add('highlight');
                }
            });
        };

        /**
         * 番号を「marked」状態にする（黄色）
         * @param {number} number マークする番号
         */
        const markNumber = (number) => {
            document.querySelectorAll('.numberTable td').forEach(cell => {
                if (parseInt(cell.textContent) === number) {
                    cell.classList.add('marked');
                }
            });
        };

        /**
         * 番号を「recently-marked」状態にする（赤色）
         * @param {number} number マークする番号
         */
        const markRecentNumber = (number) => {
            document.querySelectorAll('.numberTable td').forEach(cell => {
                if (parseInt(cell.textContent) === number) {
                    cell.classList.add('recently-marked');
                }
            });
        };

        /**
         * 直前に選ばれた番号を通常の「marked」状態に戻す（赤から黄色へ）
         */
        const unmarkRecentNumber = () => {
            if (lastChosenNumber !== null) {
                document.querySelectorAll('.numberTable td').forEach(cell => {
                    if (parseInt(cell.textContent) === lastChosenNumber) {
                        cell.classList.remove('recently-marked');
                        cell.classList.add('marked');
                    }
                });
            }
        };

        /**
         * ルーレットを開始する関数
         * 番号がランダムで高速に表示され、ストップ時に確定される
         */
        const startRoulette = () => {
            const startButton = document.getElementById('startButton');
            const bingoButton = document.getElementById('bingoButton');
            const bingoNumberDisplay = document.getElementById('bingoNumber');
            const bingoNumberContainer = document.getElementById('bingoNumberContainer');

            if (isRolling) {
                // ルーレットを停止
                clearInterval(interval);
                clearTimeout(timeout);
                isRolling = false;
                startButton.textContent = 'スタート';
                startButton.style.backgroundColor = '#ff6f61';
                startButton.disabled = false;
                bingoButton.disabled = false;
                rollingSound.pause();
                rollingSound.currentTime = 0;
            } else {
                // ルーレットを開始
                isRolling = true;
                startButton.textContent = 'ストップ';
                startButton.style.backgroundColor = '#ff4e42';
                bingoButton.disabled = true;
                bingoNumberContainer.style.background = 'none';
                rollingSound.play();
                interval = setInterval(() => {
                    if (bingoNumbers.length === 0) {
                        clearInterval(interval);
                        isRolling = false;
                        startButton.textContent = 'スタート';
                        startButton.style.backgroundColor = '#ff6f61';
                        startButton.disabled = false;
                        bingoButton.disabled = false;
                        rollingSound.pause();
                        rollingSound.currentTime = 0;
                        return;
                    }

                    // ランダムに番号を表示
                    const randomIndex = Math.floor(Math.random() * bingoNumbers.length);
                    const randomNumber = bingoNumbers[randomIndex];
                    bingoNumberDisplay.textContent = randomNumber;
                    highlightNumber(randomNumber);
                }, 100);
            }
        };

        /**
         * ルーレットを停止して番号を確定する関数
         */
        const stopRoulette = () => {
            const startButton = document.getElementById('startButton');
            const bingoButton = document.getElementById('bingoButton');
            const bingoNumberContainer = document.getElementById('bingoNumberContainer');
            startButton.disabled = true;

            stopSound.play(); // ストップボタンの効果音を再生

            timeout = setTimeout(() => {
                clearInterval(interval);
                isRolling = false;
                rollingSound.pause();
                rollingSound.currentTime = 0;
                confirmSound.play(); // 数字確定時の効果音を再生

                const bingoNumberDisplay = document.getElementById('bingoNumber');
                const chosenNumber = parseInt(bingoNumberDisplay.textContent);

                if (!chosenNumbers.includes(chosenNumber)) {
                    unmarkRecentNumber(); // 前回の番号を「marked」に戻す
                    lastChosenNumber = chosenNumber;
                    chosenNumbers.push(chosenNumber);
                    bingoNumbers = bingoNumbers.filter(number => number !== chosenNumber);
                    markRecentNumber(chosenNumber); // 今回の番号を「recently-marked」にする
                }

                // 番号表示とテーブルの該当セルを点滅させる
                bingoNumberDisplay.style.animation = 'blink 0.5s step-start 4';
                document.querySelectorAll('.numberTable td').forEach(cell => {
                    if (parseInt(cell.textContent) === chosenNumber) {
                        cell.style.animation = 'tableBlink 0.5s step-start 4';
                    }
                });

                // ボタンの状態を戻す
                startButton.textContent = 'スタート';
                startButton.style.backgroundColor = '#ff6f61';
                startButton.disabled = false;
                bingoButton.disabled = false;

                // 2秒後にアニメーションを終了
                setTimeout(() => {
                    bingoNumberDisplay.style.animation = 'none';
                    document.querySelectorAll('.numberTable td').forEach(cell => {
                        if (parseInt(cell.textContent) === chosenNumber) {
                            cell.style.animation = 'none';
                        }
                    });
                    bingoNumberContainer.style.background = 'none';
                }, 2000);
            }, 3000);
        };

        /**
         * ルーレットをリセットする関数
         * 状態を初期化し、選ばれた番号もリセット
         */
        const resetRoulette = () => {
            clearInterval(interval);
            clearTimeout(timeout);
            isRolling = false;
            bingoNumbers = Array.from({ length: 75 }, (_, i) => i + 1);
            chosenNumbers = [];
            lastChosenNumber = null;
            document.getElementById('bingoNumber').textContent = '0';
            document.querySelectorAll('.numberTable td').forEach(cell => {
                cell.classList.remove('highlight', 'marked', 'recently-marked');
                cell.style.animation = 'none';
            });
            const startButton = document.getElementById('startButton');
            startButton.textContent = 'スタート';
            startButton.style.backgroundColor = '#ff6f61';
            startButton.disabled = false;
            document.getElementById('bingoNumberContainer').style.background = 'none';
            rollingSound.pause();
            rollingSound.currentTime = 0;
            stopSound.pause();
            stopSound.currentTime = 0;
            confirmSound.pause();
            confirmSound.currentTime = 0;
        };

        /**
         * ビンゴ成功時に紙吹雪エフェクトとメッセージを表示する関数
         */
        const showCongratsMessage = () => {
            const congratsMessage = document.getElementById('congratsMessage');
            const confettiCanvas = document.getElementById('confettiCanvas');
            const confettiContext = confettiCanvas.getContext('2d');
            const winSound = document.getElementById('winSound');
            const bingoButton = document.getElementById('bingoButton');

            confettiCanvas.width = window.innerWidth;
            confettiCanvas.height = window.innerHeight;

            const confettiColors = ['#ff0', '#f00', '#0f0', '#00f', '#ff0', '#f0f', '#0ff'];
            const confetti = [];

            // 紙吹雪のデータを生成
            for (let i = 0; i < 300; i++) {
                confetti.push({
                    x: Math.random() * confettiCanvas.width,
                    y: Math.random() * confettiCanvas.height,
                    color: confettiColors[Math.floor(Math.random() * confettiColors.length)],
                    size: Math.random() * 4 + 1,
                    speed: Math.random() * 2 + 1,
                    angle: Math.random() * 360
                });
            }

            // 紙吹雪をレンダリングする関数
            function renderConfetti() {
                confettiContext.clearRect(0, 0, confettiCanvas.width, confettiCanvas.height);

                confetti.forEach((confetto, index) => {
                    confettiContext.fillStyle = confetto.color;
                    confettiContext.beginPath();
                    confetto.y += confetto.speed;
                    confetto.x += Math.cos(confetto.angle) * confetto.speed * 0.5;

                    confetto.angle += Math.random() * 0.05 - 0.025;
                    confettiContext.arc(confetto.x, confetto.y, confetto.size, 0, 2 * Math.PI);
                    confettiContext.fill();

                    if (confetto.y > confettiCanvas.height) {
                        confetti[index].y = 0;
                    }
                    if (confetto.x > confettiCanvas.width) {
                        confetti[index].x = 0;
                    }
                    if (confetto.x < 0) {
                        confetti[index].x = confettiCanvas.width;
                    }
                });

                requestAnimationFrame(renderConfetti);
            }

            renderConfetti();

            congratsMessage.style.display = 'block';
            winSound.play();
            bingoButton.disabled = true;

            // 5秒後に紙吹雪を停止し、ビンゴボタンを再度有効にする
            setTimeout(() => {
                congratsMessage.style.display = 'none';
                confettiCanvas.width = 0;
                confettiCanvas.height = 0;
                bingoButton.disabled = false;
            }, 5000);
        };

        // 各ボタンに対応するイベントリスナーの設定
        document.getElementById('startButton').addEventListener('click', () => {
            if (isRolling) {
                stopRoulette();
            } else {
                startRoulette();
            }
        });

        document.getElementById('resetButton').addEventListener('click', resetRoulette);
        document.getElementById('bingoButton').addEventListener('click', showCongratsMessage);

        // キーボード操作（スペースでルーレット開始/停止、エンターでビンゴ）
        document.addEventListener('keydown', (event) => {
            if (event.code === 'Space') {
                event.preventDefault();
                if (isRolling) {
                    stopRoulette();
                } else {
                    startRoulette();
                }
            }
            if (event.code === 'Enter') {
                event.preventDefault();
                if (!document.getElementById('bingoButton').disabled) {
                    showCongratsMessage();
                }
            }
        });
    </script>
</body>
</html>
```