0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

JavaScriptでブロック落下ゲームを作ってみた

Last updated at Posted at 2024-10-27

はじめに

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

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

操作方法

  • 移動: 矢印キー
  • 回転: スペースキー

デモ

プレイ画面:
スクリーンショット 2024-10-27 14.43.05.png

ゲームオーバー画面:
スクリーンショット 2024-10-27 14.44.47.png

テトリスの著作権

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

コード

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>
0
1
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?