見出し画像

テトリスを作る - 6 ゲームオーバー

行クリアのロジックが完成し、ゲームの核となる機能は揃いました。次に、ゲームに区切りをつけるための**「ゲームオーバーとリセット」**の機能を追加します


ステップ 9: ゲームオーバーとリセット機能の実装

テトリスでは、新しいテトリミノが生成される位置に既にブロックがある場合にゲームオーバーとなります。

ゲームの状態管理

ゲームオーバーを検知し、ゲームループを停止できるように、ゲームの状態を管理する変数を用意します。

冒頭に以下を追加します。

let isGameOver = false; // ゲームの状態を保持するフラグ

そしてボードの初期化をしている以下のコードを

let board = [];
for (let y = 0; y < BOARD_HEIGHT; y++) {
     board[y] = new Array(BOARD_WIDTH).fill(0);
}

関数に修正します。そして初期化できるようにします。

function initializeBoard() {
    const newBoard = [];
    for (let y = 0; y < BOARD_HEIGHT; y++) {
        newBoard[y] = new Array(BOARD_WIDTH).fill(0);
    }
    return newBoard;
}

// 最初のボード初期化
let board = initializeBoard(); 


ゲームオーバー処理の組み込み

関数pieceDrop() と hardDrop()で、新しいピースが生成された直後に衝突判定を行う部分にゲームオーバーを判定するコードを追加します。

A. ゲームオーバー判定の修正(pieceDrop ()と hardDrop() の両方)

既存のコード内の以下の部分を探してください。

currentPiece = createPiece();

このコードの下に以下のゲームオーバーを判定するコードを追加。

if (checkCollision(currentPiece, 0, 0)) {
    gameOver(); // ゲームオーバー処理を実行
    return; // 以降の処理を停止
}

B. gameOver 関数の作成

実際にゲームを停止し、画面にメッセージを表示する関数を作成します。

function gameOver() {
    isGameOver = true;
    console.log("ゲームオーバー!スコア:", score);

    // 画面全体を覆うような形で「GAME OVER」を表示
    ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; // 半透明の黒
    ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    ctx.fillStyle = 'white';
    ctx.font = '40px Arial';
    ctx.textAlign = 'center';
    ctx.fillText('GAME OVER', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 20);
    
    ctx.font = '20px Arial';
    ctx.fillText('SCORE: ' + score, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 20);
    ctx.fillText('Press R to Restart', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 60);
}


ゲームループの停止とリスタート

ゲームループの冒頭で isGameOver をチェックするように修正し、キー入力でリスタートできるようにします。

A. gameLoop 関数の修正

追加します。

  if (isGameOver) {
     setInterval(gameLoop, 1000) // アニメーションは続ける
     return;
  }


B. リスタート処理の作成と組み込み

リスタート用の関数を作成し、Rキーを押したときにそれを呼び出すようにします。

function resetGame() {
    isGameOver = false;
    score = 0;
    dropCounter = 0;
    lastTime = 0;
    
    // ボードを空白で初期化
    board = initializeBoard(); 
    
    // スコア表示をリセット
    updateScoreDisplay(); 
    
    // 新しいテトリミノを生成して再開
    currentPiece = createPiece();
    
    // gameLoop は既に動いているので、これで再開
}

// handleKeyInput 関数にリスタートを追加
function handleKeyInput(event) {
    // ... (前略)
    
    // Rキーでリセット
    if (event.key === 'r' || event.key === 'R') {
        resetGame();
    }
    
    // ゲームオーバー中は、リスタートキー以外は無視
    if (isGameOver) {
        return; 
    }
    
    switch (event.key) {
        // ... (ArrowKey や SpaceKey の処理はそのまま) ...
    }
}

これで、テトリスとして一通りの機能が実装できました。ゲームオーバーになっても、Rキーで新しいゲームを始めることができます。

今までの全コード。

<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <title>JavaScript Tetris</title>

    <style>
        /* CSSは次のセクションで定義します */
        body {
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }

        #tetris-canvas {
            border: 5px solid #333;
            /* 枠線をつけてゲームボードを分かりやすくします */
        }

        #game-container {
            display: flex;
            /* canvasとscore-panelを横並びにする */
            align-items: flex-start;
            /* 上端を揃える */
        }
    </style>

</head>

<body>
    <!-- <canvas id="tetris-canvas"></canvas> -->


    <div id="game-container">
        <div id="score-panel" style="margin-left: 20px;">
            <h2>SCORE: <span id="score-value">0</span></h2>
        </div>

        <canvas id="tetris-canvas"></canvas>
    </div>

    <script>
        let isGameOver = false; // ゲームの状態を保持するフラグ

        // ブロック1つあたりのピクセルサイズ
        const BLOCK_SIZE = 30;

        // テトリス盤面のサイズ(縦20マス、横10マスが一般的)
        const BOARD_WIDTH = 10;
        const BOARD_HEIGHT = 20;

        // キャンバスのサイズを決定
        const CANVAS_WIDTH = BLOCK_SIZE * BOARD_WIDTH;
        const CANVAS_HEIGHT = BLOCK_SIZE * BOARD_HEIGHT;

        // キャンバスを取得
        const canvas = document.getElementById('tetris-canvas');
        const ctx = canvas.getContext('2d');

        // キャンバスにサイズを適用
        canvas.width = CANVAS_WIDTH;
        canvas.height = CANVAS_HEIGHT;

        // ゲームボードを2次元配列で作成し、すべて0(空白)で初期化
        // let board = [];
        // for (let y = 0; y < BOARD_HEIGHT; y++) {
        //     // 1行につき BOARD_WIDTH の要素を持つ配列を作成
        //     board[y] = new Array(BOARD_WIDTH).fill(0);
        // }

        // ボードを初期化する関数を汎用化しておくと便利
        function initializeBoard() {
            const newBoard = [];
            for (let y = 0; y < BOARD_HEIGHT; y++) {
                newBoard[y] = new Array(BOARD_WIDTH).fill(0);
            }
            return newBoard;
        }
        // 最初のボード初期化
        let board = initializeBoard();


        function drawBoard() {
            for (let y = 0; y < BOARD_HEIGHT; y++) {
                for (let x = 0; x < BOARD_WIDTH; x++) {
                    if (board[y][x] !== 0) {
                        // ボード上のマスが空白(0)でなければ描画
                        ctx.fillStyle = 'blue'; // 仮の色
                        ctx.fillRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);

                        // ブロックの境界線を描画
                        ctx.strokeStyle = '#000';
                        ctx.strokeRect(x * BLOCK_SIZE, y * BLOCK_SIZE, BLOCK_SIZE, BLOCK_SIZE);
                    }
                }
            }
        }

        // テトリミノの形状と色を定義する配列
        // 1以上の数字はブロックの種類(色)を示す
        const TETROMINOS = [
            // 0: I型 (Cyan)
            {
                matrix: [
                    [0, 0, 0, 0],
                    [1, 1, 1, 1],
                    [0, 0, 0, 0],
                    [0, 0, 0, 0],
                ],
                color: '#00FFFF' // シアン
            },
            // 1: J型 (Blue)
            {
                matrix: [
                    [2, 0, 0],
                    [2, 2, 2],
                    [0, 0, 0],
                ],
                color: '#0000FF' // 青
            },
            // 2: L型 (Orange)
            {
                matrix: [
                    [0, 0, 3],
                    [3, 3, 3],
                    [0, 0, 0],
                ],
                color: '#FFA500' // オレンジ
            },
            // 3: O型 (Yellow)
            {
                matrix: [
                    [4, 4],
                    [4, 4],
                ],
                color: '#FFFF00' // 黄色
            },
            // 4: S型 (Green)
            {
                matrix: [
                    [0, 5, 5],
                    [5, 5, 0],
                    [0, 0, 0],
                ],
                color: '#00FF00' // 緑
            },
            // 5: T型 (Purple)
            {
                matrix: [
                    [0, 6, 0],
                    [6, 6, 6],
                    [0, 0, 0],
                ],
                color: '#800080' // 紫
            },
            // 6: Z型 (Red)
            {
                matrix: [
                    [7, 7, 0],
                    [0, 7, 7],
                    [0, 0, 0],
                ],
                color: '#FF0000' // 赤
            }
        ];

        // 現在操作中のテトリミノを保持するオブジェクト
        let currentPiece = null;

        function createPiece() {
            // 0からTETROMINOSの数-1までの乱数を生成
            const randIndex = Math.floor(Math.random() * TETROMINOS.length);
            const pieceData = TETROMINOS[randIndex];

            // テトリミノの情報をオブジェクトとして返す
            return {
                matrix: pieceData.matrix,
                color: pieceData.color,
                // 開始位置 (ボードの上端中央)
                pos: { x: Math.floor(BOARD_WIDTH / 2) - Math.floor(pieceData.matrix[0].length / 2), y: 0 }
            };
        }

        function drawPiece(piece) {
            // テトリミノのブロックを描画
            piece.matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        // ボード上の座標に変換
                        const drawX = (piece.pos.x + x) * BLOCK_SIZE;
                        const drawY = (piece.pos.y + y) * BLOCK_SIZE;

                        ctx.fillStyle = piece.color;
                        ctx.fillRect(drawX, drawY, BLOCK_SIZE, BLOCK_SIZE);

                        // ブロックの境界線を描画
                        ctx.strokeStyle = '#000';
                        ctx.strokeRect(drawX, drawY, BLOCK_SIZE, BLOCK_SIZE);
                    }
                });
            });
        }


        function gameLoop() {

            // 画面のクリア
            ctx.clearRect(0, 0, canvas.width, canvas.height);

            if (isGameOver) {
                setInterval(gameLoop, 1000) 
                return;
            }

            pieceDrop()

            // 固定されたブロックと落下中のブロックを描画

            if (currentPiece) {
                drawPiece(currentPiece);
            }
            drawBoard();
        }


        /**
         * 指定された位置 (offset) にテトリミノを移動または回転させたときに、
         * ボードの境界や固定されたブロックと衝突するかどうかを判定する。
         * @param {object} piece - 現在のテトリミノオブジェクト (matrix, pos)
         * @param {number} offsetX - X軸方向の移動量 (通常 -1, 0, 1)
         * @param {number} offsetY - Y軸方向の移動量 (通常 0, 1)
         * @returns {boolean} 衝突する場合は true、しない場合は false
         */
        function checkCollision(piece, offsetX, offsetY) {
            const matrix = piece.matrix;
            const currentX = piece.pos.x;
            const currentY = piece.pos.y;

            for (let y = 0; y < matrix.length; y++) {
                for (let x = 0; x < matrix[y].length; x++) {
                    // ブロックのマス(値が0ではない)のみをチェック
                    if (matrix[y][x] !== 0) {
                        // 移動後のボード上の座標を計算
                        const nextX = currentX + x + offsetX;
                        const nextY = currentY + y + offsetY;

                        // 衝突条件 1: ボードの左右の壁、または底を超えてしまう
                        if (nextX < 0 || nextX >= BOARD_WIDTH || nextY >= BOARD_HEIGHT) {
                            return true; // 衝突 (壁または底)
                        }

                        // 衝突条件 2: ボードの上端を超えてしまう (上端に生成されたばかりのピースは除く)
                        // nextY < 0 の場合は、落下中であれば通常衝突とは見なさない。
                        if (nextY < 0) {
                            continue; // 処理をスキップ
                        }

                        // 衝突条件 3: 既に固定されたブロック (board[nextY][nextX] が 0 ではない) と重なる
                        if (board[nextY] && board[nextY][nextX] !== 0) {
                            return true; // 衝突 (固定ブロック)
                        }
                    }
                }
            }
            return false; // 衝突なし
        }

        // テトリミノを1マス下に移動させる関数
        function pieceDrop() {
            if (!currentPiece) return;

            // 衝突判定: 1マス下に移動したら衝突するか? (offsetX=0, offsetY=1)
            if (checkCollision(currentPiece, 0, 1)) {
                // 衝突する場合 (着地)

                //  ブロックをボードに固定する関数を呼び出す(次のセクションで作成)
                mergePieceToBoard(currentPiece);

                checkRowClear();
                updateScoreDisplay()

                //  新しいテトリミノを生成
                currentPiece = createPiece();

                // ゲームオーバー判定
                if (checkCollision(currentPiece, 0, 0)) {
                    gameOver(); // ゲームオーバー処理を実行
                    return; // 以降の処理を停止
                }



            } else {
                // 衝突しない場合
                currentPiece.pos.y++; // 1マス下に移動
            }
        }

        /**
         * 現在のテトリミノをボード(2次元配列)に固定する
         * @param {object} piece - 現在のテトリミノ
         */
        function mergePieceToBoard(piece) {
            piece.matrix.forEach((row, y) => {
                row.forEach((value, x) => {
                    if (value !== 0) {
                        // テトリミノのブロックがあれば、そのボード上の座標を value で上書き
                        const boardY = piece.pos.y + y;
                        const boardX = piece.pos.x + x;

                        // 既に board[y] が存在することを確認(念のため)
                        if (board[boardY]) {
                            // valueはブロックの色を表すTETROMINOS配列のインデックスを使用しても良い
                            board[boardY][boardX] = value;
                        }
                    }
                });
            });
        }

        function handleKeyInput(event) {
            if (!currentPiece) return;

            // Rキーでリセット
            if (event.key === 'r' || event.key === 'R') {
                resetGame();
            }

            // ゲームオーバー中は、リスタートキー以外は無視
            if (isGameOver) {
                return;
            }

            switch (event.key) {
                case 'ArrowLeft':
                    // 左に移動 (offsetX: -1, offsetY: 0)
                    pieceMove(-1, 0);
                    break;
                case 'ArrowRight':
                    // 右に移動 (offsetX: 1, offsetY: 0)
                    pieceMove(1, 0);
                    break;
                case 'ArrowDown':
                    // 落下を速める (ソフトドロップ)
                    pieceMove(0, 1);
                    // 落下を速めたら、ドロップカウンターをリセットしてすぐに次の落下を促す
                    dropCounter = 0;
                    break;
                case ' ': // スペースキー (ハードドロップ)
                    // ハードドロップ(一気に底まで落とす)
                    hardDrop();
                    break;
                case 'ArrowUp':
                case 'x':
                    // 回転処理(次のステップで実装)
                    pieceRotate();
                    break;
            }
        }

        /**
         * テトリミノを指定されたオフセット分移動させる
         * @param {number} offsetX - X軸方向の移動量
         * @param {number} offsetY - Y軸方向の移動量
         */
        function pieceMove(offsetX, offsetY) {
            // 衝突判定を行い、移動可能かチェック
            if (!checkCollision(currentPiece, offsetX, offsetY)) {
                // 衝突しない場合のみ、位置を更新
                currentPiece.pos.x += offsetX;
                currentPiece.pos.y += offsetY;
            } else if (offsetY === 1) {
                // 下への移動(ソフトドロップ)で衝突した場合は、着地したと見なす
                // pieceDrop()が既に衝突判定を持っているので、ここでは単に終了
            }
        }

        function hardDrop() {
            if (!currentPiece) return;

            // 衝突するまで繰り返し1マスずつ下に移動させる
            while (!checkCollision(currentPiece, 0, 1)) {
                currentPiece.pos.y++;
            }

            // 衝突したら、強制的に着地処理(固定と新規生成)を行う
            mergePieceToBoard(currentPiece);

            checkRowClear();
            updateScoreDisplay()

            currentPiece = createPiece();

            // ゲームオーバー判定
            if (checkCollision(currentPiece, 0, 0)) {
                gameOver(); // ゲームオーバー処理を実行
                return; // 以降の処理を停止
            }

        }

        /**
         * 行列を90度右に回転させる
         * (y, x) の要素は (x, size - 1 - y) に移動する
         * @param {Array<Array<number>>} matrix - 回転させたい行列
         * @returns {Array<Array<number>>} 回転後の新しい行列
         */
        function rotateMatrix(matrix) {
            const size = matrix.length;
            // 新しい行列を初期化(すべて0)
            const newMatrix = Array(size).fill(0).map(() => Array(size).fill(0));

            for (let y = 0; y < size; y++) {
                for (let x = 0; x < size; x++) {
                    // 回転のロジック: [y][x] の要素を [x][size - 1 - y] に移動
                    newMatrix[x][size - 1 - y] = matrix[y][x];
                }
            }
            return newMatrix;
        }

        // キー入力処理関数 (handleKeyInput) で 'ArrowUp' または 'x' が押された時に呼び出す
        function pieceRotate() {
            if (!currentPiece) return;

            // 1. 回転後の行列を取得
            const rotatedMatrix = rotateMatrix(currentPiece.matrix);

            // 2. 一時的にテトリミノの行列を回転後のものに置き換え
            const originalMatrix = currentPiece.matrix;
            currentPiece.matrix = rotatedMatrix;

            // 3. 回転後の位置で衝突判定を行う
            if (checkCollision(currentPiece, 0, 0)) {
                // 衝突する場合: 元に戻す (Wall Kickは次のステップで考慮)
                currentPiece.matrix = originalMatrix;
            }

            // 衝突しない場合: currentPiece.matrix は rotatedMatrix のまま確定
        }

        // キーボードイベントの監視を設定
        document.addEventListener('keydown', handleKeyInput);


        // スコアを保持する変数
        let score = 0;
        // 1行クリアあたりの基本スコア (適当な値)
        const POINTS_PER_LINE = 10;

        function checkRowClear() {
            let linesCleared = 0; // 今回クリアした行数

            // ボードの最も下 (BOARD_HEIGHT - 1) から上 (0) に向かってチェックする
            outer: for (let y = BOARD_HEIGHT - 1; y >= 0; y--) {
                // 現在の行 (board[y]) の各マスをチェック
                for (let x = 0; x < BOARD_WIDTH; x++) {
                    // 1つでも空白マス (0) があれば、この行はクリアされていない
                    if (board[y][x] === 0) {
                        continue outer; // 次の行のチェックへ移動
                    }
                }

                // ここに到達した場合、その行 (y) は完全にブロックで埋まっている (クリア対象)

                // 1. 行を削除する
                // board.splice(y, 1) で y行目から1行分を削除
                board.splice(y, 1);

                // 2. 削除した分の新しい空白行をボードの最上部に追加する
                // Array(BOARD_WIDTH).fill(0) で、幅分の空白行を作成
                board.unshift(new Array(BOARD_WIDTH).fill(0));

                // 3. 削除処理をしたので、yのループを再度実行するため y をインクリメント (重要!)
                // spliceで配列の要素数が減り、yの位置が変わったため
                y++;
                linesCleared++;
            }

            // スコア計算
            if (linesCleared > 0) {
                // 例: 複数行同時クリアはボーナス
                score += POINTS_PER_LINE * linesCleared * linesCleared;
                console.log(`クリア: ${linesCleared}行! スコア: ${score}`);
                // 画面のスコア表示を更新する関数を後で追加
            }
        }

        function gameOver() {
            isGameOver = true;
            console.log("ゲームオーバー!スコア:", score);

            // 画面全体を覆うような形で「GAME OVER」を表示
            ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; // 半透明の黒
            ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

            ctx.fillStyle = 'white';
            ctx.font = '40px Arial';
            ctx.textAlign = 'center';
            ctx.fillText('GAME OVER', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 20);

            ctx.font = '20px Arial';
            ctx.fillText('SCORE: ' + score, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 20);
            ctx.fillText('Press R to Restart', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 60);

            // ゲームループ (gameLoop) は isGameOver のチェックで停止させます(次項)
        }



        function resetGame() {
            isGameOver = false;
            score = 0;
            dropCounter = 0;
            lastTime = 0;

            // ボードを空白で初期化
            board = initializeBoard();

            // スコア表示をリセット
            updateScoreDisplay();

            // 新しいテトリミノを生成して再開
            currentPiece = createPiece();

            // gameLoop は既に動いているので、これで再開
        }

        function updateScoreDisplay() {
            document.getElementById('score-value').innerText = score;
        }


        // --- ゲーム開始 ---
        setInterval(gameLoop, 1000)
        currentPiece = createPiece(); // 最初のテトリミノを生成


    </script>
</body>

</html>

いいなと思ったら応援しよう!