JavaScriptでブロック落下ゲームを作ってみた #テトリス

# はじめに
10年以上前にJavaScriptで作ったブロック落下ゲームを、作り直しました。

:::note warn
たまにブロックがすり抜けたりといったバグがあり、まだまだ改修の余地があります。
:::

操作方法
- 移動: 矢印キー
- 回転: スペースキー

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

プレイ画面：
![スクリーンショット 2024-10-27 14.43.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/682035/39d8e8c4-caf3-ca0d-03ce-ba433ec7b7da.png)

ゲームオーバー画面：
![スクリーンショット 2024-10-27 14.44.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/682035/b0b90f86-578d-dc7a-7b3e-40d2bc24a854.png)


# テトリスの著作権
テトリスの著作権侵害を考慮して、ピットの縦横を変更しています。
また、テトリミノに1x1のブロックを追加しています。

# コード

```html:falling-block.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ブロック落下ゲーム</title>
    <style>
        /* レイアウトの中央寄せと基本的なスタイル設定 */
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            background-color: #f0f0f0;
            background-image: linear-gradient(45deg, #f3f3f3 25%, transparent 25%, transparent 75%, #f3f3f3 75%, #f3f3f3),
                              linear-gradient(45deg, #f3f3f3 25%, transparent 25%, transparent 75%, #f3f3f3 75%, #f3f3f3);
            background-size: 60px 60px;
            background-position: 0 0, 30px 30px;
        }

        /* ゲームコンテナの基本スタイル設定 */
        #game-container {
            display: none;
            text-align: center;
            position: relative;
            background-color: rgba(255, 255, 255, 0.8);
            padding: 20px;
            border-radius: 15px;
            box-shadow: 0 10px 20px rgba(0,0,0,0.1);
        }

        /* スタートボタンのスタイル */
        #start-button {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            padding: 15px 30px;
            font-size: 24px;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 30px;
            cursor: pointer;
            box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
        }

        /* ボタンのホバー・アクティブ時のスタイル */
        #start-button:hover {
            background-color: #2980b9;
            transform: translate(-50%, -50%) translateY(-2px);
            box-shadow: 0 7px 20px rgba(52, 152, 219, 0.6);
        }
        #start-button:active {
            transform: translate(-50%, -50%) translateY(1px);
            box-shadow: 0 3px 10px rgba(52, 152, 219, 0.4);
        }

        /* ゲーム情報の表示エリア */
        #info {
            margin-top: 15px;
            font-size: 20px;
            font-weight: bold;
            color: #333;
            display: flex;
            justify-content: space-between;
            align-items: center;
        }

        /* タイマー表示 */
        #timer {
            font-size: 20px;
            font-weight: bold;
            color: #333;
            margin-left: 20px;
        }

        /* ゲーム盤のスタイル */
        #game-board {
            border-collapse: collapse;
            margin: 0 auto;
            background-color: #fafafa;
            border: 2px solid #333;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
        }

        /* セルの基本スタイル */
        #game-board td {
            width: 25px;
            height: 25px;
            border: 1px solid #e0e0e0;
        }

        /* ゲームオーバー画面のスタイル */
        #game-over {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background-color: rgba(255, 255, 255, 0.95);
            padding: 30px;
            border-radius: 15px;
            text-align: center;
            display: none;
            width: 280px;
            box-shadow: 0 10px 30px rgba(0,0,0,0.2);
        }

        /* ゲームオーバー時のメッセージ */
        #game-over h2 {
            margin-top: 0;
            font-size: 32px;
            white-space: nowrap;
            color: #e74c3c;
            text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
        }

        /* リプレイボタン */
        #replay-button {
            margin-top: 15px;
            padding: 12px 25px;
            font-size: 18px;
            cursor: pointer;
            white-space: nowrap;
            background-color: #3498db;
            color: white;
            border: none;
            border-radius: 25px;
            transition: all 0.3s ease;
            box-shadow: 0 5px 15px rgba(52, 152, 219, 0.4);
        }
        #replay-button:hover {
            background-color: #2980b9;
            transform: translateY(-2px);
            box-shadow: 0 7px 20px rgba(52, 152, 219, 0.6);
        }
        #replay-button:active {
            transform: translateY(1px);
            box-shadow: 0 3px 10px rgba(52, 152, 219, 0.4);
        }
    </style>
</head>
<body>
    <!-- スタートボタン -->
    <button id="start-button">スタート</button>

    <!-- ゲームコンテナ -->
    <div id="game-container">
        <div id="info">
            <div>スコア: <span id="score">0</span></div>
            <div id="timer">プレイ時間: 0秒</div>
        </div>
        <div id="view"></div>
        <div id="game-over">
            <h2>Game Over!!</h2>
            <p id="final-score"></p>
            <p id="final-time"></p>
            <button id="replay-button">リプレイ</button>
        </div>
    </div>

    <!-- 効果音とBGMの追加 -->
    <audio id="game-over-sound" src="game-over.mp3" preload="auto"></audio>
    <audio id="clear-block-sound" src="clear-block.mp3" preload="auto"></audio>
    <audio id="block-fixed-sound" src="block-fixed-sound.mp3" preload="auto"></audio>
    <audio id="bgm" src="bgm.mp3" preload="auto" loop></audio>
    <audio id="rotate-sound" src="rotate-sound.mp3" preload="auto"></audio>

    <script>
    document.addEventListener('DOMContentLoaded', () => {
        // ゲームの基本設定
        const width = 14;
        const height = 23;
        let speed = 20;
        let fills = {};
        let cells;
        let block;
        let top, left, angle, score = 0, tick;
        let gameLoop;
        let startTime;
        let playTimeInterval;
        let speedIncreaseInterval;
        let elapsedTime = 0;
        let clearingRows = false; // 行削除フラグ
        let isGameOver = false; // ゲームオーバーフラグ

        // 効果音の参照
        const gameOverSound = document.getElementById('game-over-sound');
        const clearBlockSound = document.getElementById('clear-block-sound');
        const blockFixedSound = document.getElementById('block-fixed-sound');
        const bgm = document.getElementById('bgm');
        const rotateSound = document.getElementById('rotate-sound');

        // ブロックのデータ
        const blocks = [
            { color: '#FF6B6B', angles: [[-1,1,2], [-width,width,width*2], [-2,-1,1], [-width*2,-width,width]] },
            { color: '#4ECDC4', angles: [[-width-1,-width,-1]] },
            { color: '#45B7D1', angles: [[-width,1-width,-1], [-width,1,width+1], [1,width-1,width], [-width-1,-1,width]] },
            { color: '#F7B731', angles: [[-width-1,-width,1], [1-width,1,width], [-1,width,width+1], [-width,-1,width]] },
            { color: '#7B68EE', angles: [[-width-1,-1,1], [-width,1-width,width], [-1,1,width+1], [-width,width-1,width]] },
            { color: '#5CDB95', angles: [[1-width,-1,1], [-width,width,width+1], [-1,1,width-1], [-width-1,-width,width]] },
            { color: '#FF8C94', angles: [[-width,-1,1], [-width,1,width], [-1,1,width], [-width,-1,width]] },
            { color: '#BADA55', angles: [[0]] }
        ];

        const keys = {};

        // ゲームボードの生成関数
        const generateBoard = () => {
            const html = ['<table id="game-board">'];
            for (let y = 0; y < height; y++) {
                html.push('<tr>');
                for (let x = 0; x < width; x++) {
                    if (x === 0 || x === width - 1 || y === height - 1) {
                        html.push('<td style="background-color:#ddd"></td>');
                        fills[x + y * width] = '#ddd';
                    } else {
                        html.push('<td></td>');
                    }
                }
                html.push('</tr>');
            }
            html.push('</table>');
            document.getElementById('view').innerHTML = html.join('');
            cells = document.getElementsByTagName('td');
        };

        // ゲーム初期化関数
        const initGame = () => {
            isGameOver = false;
            fills = {};
            generateBoard();
            top = 2;
            left = Math.floor(width / 2);
            block = blocks[Math.floor(Math.random() * blocks.length)];
            angle = 0;
            score = 0;
            tick = 0;
            speed = 20;
            clearingRows = false;
            document.getElementById('game-over').style.display = 'none';
            document.getElementById('timer').textContent = "プレイ時間: 0秒";
            document.getElementById('score').textContent = score;
            startTime = Date.now();
            elapsedTime = 0;
            playTimeInterval = setInterval(updateTime, 1000);
            speedIncreaseInterval = setInterval(increaseSpeed, 10000);
            playBGM();
            gameLoop = requestAnimationFrame(update);
        };

        // 時間更新
        const updateTime = () => {
            elapsedTime = Math.floor((Date.now() - startTime) / 1000);
            document.getElementById('timer').textContent = `プレイ時間: ${elapsedTime}秒`;
        };

        // 一定時間ごとのスピード増加
        const increaseSpeed = () => {
            if (speed > 5) {
                speed--;
            }
        };

        // ゲームオーバー処理
        const showGameOver = () => {
            isGameOver = true;
            document.getElementById('game-over').style.display = 'block';
            document.getElementById('final-score').textContent = `最終スコア: ${score}`;
            document.getElementById('final-time').textContent = `プレイ時間: ${elapsedTime}秒`;
            cancelAnimationFrame(gameLoop);
            gameOverSound.play();
            bgm.pause();
            bgm.currentTime = 0;
            clearInterval(playTimeInterval);
            clearInterval(speedIncreaseInterval);
        };

        // BGM再生
        const playBGM = () => {
            const context = new (window.AudioContext || window.webkitAudioContext)();
            const unlock = () => {
                if (context.state === 'suspended') {
                    context.resume().then(() => {
                        bgm.play();
                    });
                } else {
                    bgm.play();
                }
                document.removeEventListener('keydown', unlock);
                document.removeEventListener('click', unlock);
            };
            document.addEventListener('keydown', unlock);
            document.addEventListener('click', unlock);
        };

        // キーボード入力の処理
        document.addEventListener('keydown', (e) => {
            if (isGameOver) return;
            switch(e.key) {
                case 'ArrowLeft': keys.left = true; break;
                case 'ArrowRight': keys.right = true; break;
                case 'ArrowDown': keys.down = true; break;
                case ' ':
                    keys.rotate = true;
                    rotateSound.play();
                    setTimeout(() => {
                        rotateSound.pause();
                        rotateSound.currentTime = 0;
                    }, 100);
                    break;
            }
        });

        // リプレイボタンの処理
        document.getElementById('replay-button').addEventListener('click', initGame);

        // 行削除処理
        const clearRowWithEffect = (y, callback) => {
            let x = 1;
            clearBlockSound.play();
            const clearNextCell = () => {
                if (x < width - 1) {
                    cells[y * width + x].style.backgroundColor = '#fafafa';
                    fills[y * width + x] = null;
                    x++;
                    setTimeout(clearNextCell, 50);
                } else {
                    clearBlockSound.pause();
                    clearBlockSound.currentTime = 0;
                    callback();
                }
            };
            clearNextCell();
        };

        // 行を下にシフトする関数
        const shiftRowsDown = (rowsToClear) => {
            rowsToClear.sort((a, b) => a - b);
            rowsToClear.forEach(clearedRow => {
                for (let y = clearedRow; y >= 1; y--) {
                    for (let x = 1; x < width - 1; x++) {
                        fills[y * width + x] = fills[(y - 1) * width + x];
                    }
                }
                for (let x = 1; x < width - 1; x++) {
                    fills[x] = null;
                }
            });
            for (let y = 0; y < height; y++) {
                for (let x = 0; x < width; x++) {
                    cells[y * width + x].style.backgroundColor = fills[y * width + x] || '';
                }
            }
        };

        // ゲームループ
        const update = () => {
            tick++;

            if (!clearingRows) {
                let [top0, left0, angle0] = [top, left, angle];

                if (tick % speed === 0 && !clearingRows) {
                    top++;
                }

                if (keys.left && !clearingRows) {
                    const newLeft = left - 1;
                    if (!checkCollision(top, newLeft, angle)) {
                        left = newLeft;
                    }
                }
                if (keys.right && !clearingRows) {
                    const newLeft = left + 1;
                    if (!checkCollision(top, newLeft, angle)) {
                        left = newLeft;
                    }
                }
                if (keys.down && !clearingRows) top++;
                if (keys.rotate && !clearingRows) {
                    const newAngle = angle + 1;
                    if (!checkCollision(top, left, newAngle)) {
                        angle = newAngle;
                    }
                }

                keys.left = keys.right = keys.down = keys.rotate = false;

                // 衝突判定関数
                function checkCollision(checkTop, checkLeft, checkAngle) {
                    const parts = block.angles[checkAngle % block.angles.length];
                    for (let i = -1; i < parts.length; i++) {
                        const offset = parts[i] || 0;
                        const cellIndex = checkTop * width + checkLeft + offset;
                        const cellX = (cellIndex % width);
                        if (cellX <= 0 || cellX >= width - 1 || fills[cellIndex]) {
                            return true;
                        }
                    }
                    return false;
                }

                let parts = block.angles[angle % block.angles.length];
                let isBlockOnGround = false;
                for (let i = -1; i < parts.length; i++) {
                    const offset = parts[i] || 0;
                    const cellIndex = top * width + left + offset;
                    if (fills[cellIndex] && cellIndex >= top * width) {
                        isBlockOnGround = true;
                        break;
                    }
                }

                if (isBlockOnGround) {
                    if (tick % speed === 0 && !clearingRows) {
                        blockFixedSound.play();
                        const parts0 = block.angles[angle0 % block.angles.length];
                        for (let j = -1; j < parts0.length; j++) {
                            const offset = parts0[j] || 0;
                            fills[top0 * width + left0 + offset] = block.color;
                        }

                        let cleans = 0;
                        const rowsToClear = [];
                        for (let y = height - 2; y >= 0; y--) {
                            const filled = Array.from({length: width - 2}, (_, x) => fills[y * width + x + 1]).every(Boolean);
                            if (filled) {
                                rowsToClear.push(y);
                                cleans++;
                            }
                        }

                        if (cleans > 0) {
                            clearingRows = true;
                            let clearIndex = 0;
                            const clearRowsSequentially = () => {
                                if (clearIndex < rowsToClear.length) {
                                    clearRowWithEffect(rowsToClear[clearIndex], () => {
                                        clearIndex++;
                                        clearRowsSequentially();
                                    });
                                } else {
                                    setTimeout(() => {
                                        shiftRowsDown(rowsToClear);
                                        clearingRows = false;
                                        block = blocks[Math.floor(Math.random() * blocks.length)];
                                        [left, top, angle] = [Math.floor(width / 2), 2, 0];
                                        if (fills[top * width + left]) {
                                            showGameOver();
                                            return;
                                        }
                                    }, 500);
                                }
                            };
                            clearRowsSequentially();
                            score += Math.pow(10, cleans) * 10;
                            document.getElementById('score').textContent = score;
                        } else {
                            block = blocks[Math.floor(Math.random() * blocks.length)];
                            [left, top, angle] = [Math.floor(width / 2), 2, 0];
                            if (fills[top * width + left]) {
                                showGameOver();
                                return;
                            }
                        }
                    } else {
                        [left, top, angle] = [left0, top0, angle0];
                    }
                }
            }

            // 描画処理
            for (let y = 0; y < height; y++) {
                for (let x = 0; x < width; x++) {
                    cells[y * width + x].style.backgroundColor = fills[y * width + x] || '';
                }
            }

            if (!clearingRows) {
                let parts = block.angles[angle % block.angles.length];
                for (let i = -1; i < parts.length; i++) {
                    const offset = parts[i] || 0;
                    cells[top * width + left + offset].style.backgroundColor = block.color;
                }
            }

            gameLoop = requestAnimationFrame(update);
        };

        // ゲーム開始時の処理
        document.getElementById('start-button').addEventListener('click', () => {
            document.getElementById('start-button').style.display = 'none';
            document.getElementById('game-container').style.display = 'block';
            initGame();
        });
    });
    </script>
</body>
</html>
```