Skip to content

第06回 2Dシューティングゲーム入門 3 / 状態管理とゲームオーバー

前回: 第05回 2Dシューティングゲーム入門 2 / 配列で弾を複数扱う / 次回: 第07回 C++振り返り / オブジェクト指向入門

前回の振り返り

配列で複数の弾を扱えるようになりました

  • Bullet 構造体で、弾1発ぶんの情報をまとめました
  • Bullet bullets[bulletMax] で、複数の弾をまとめて用意しました
  • for 文で配列全体を順番に確認し、発射、更新、描画を行いました
  • 弾の更新と描画を関数に分け、処理に名前を付ける練習をしました
  • shotTimer で、キーを押し続けたときの発射間隔を入れました
  • 今回は、弾と敵の命中判定、敵の移動、再出現を加えてゲームの基本形を完成させます

今回の目的

勝敗が分かる基本形を完成させる

  • 弾と敵、プレイヤーと敵の衝突結果を分けて扱える
  • enum でゲームの状態を整理できる
  • 状態に応じて更新処理と表示を切り替えられる
  • 弾を撃ち、敵に当て、敵が再出現する流れを完成できる
  • スコアが目標に達したらゲームクリアにできる

今回の授業内容

状態管理とゲームオーバーを実装する

  • 第04回の衝突判定と第05回の弾処理
  • 判定しやすい GameObject への変換
  • PlayingGameOverGameClear の状態定義
  • 状態に応じた更新処理と表示内容の切り替え
  • 敵の左右移動と再出現
  • 弾と敵、プレイヤーと敵の命中判定
  • 長くなった処理を次回整理しやすくするための準備

2Dシューティング ゲーム入門 3

勝ち負けの判定を ゲームに組み込みます

これでだいぶ ゲームになるよ

前回までにできていること

動くものと判定の材料がそろっています

  • 第03回: プレイヤーをキー入力で動かしました
  • 第04回: 矩形どうしの衝突判定を作りました
  • 第05回: 配列で複数の弾を発射、更新、描画し、発射間隔も入れました
  • 今回はこれらをつなぎ、ゲームの結果 を作ります

今回追加する要素

Playing 中の処理の流れを作る

  1. 入力でプレイヤーを動かす
  2. 敵が左右に動かす
  3. 弾を発射し、画面上へ移動させる
  4. 弾が敵に当たったら、スコアを増やして敵を再出現させる
  5. プレイヤーが敵に当たったら GameOver にする
  6. スコアが目標に達したら GameClear にする
  • Playing の間は、この流れを毎フレーム繰り返します

弾もGameObjectに切り替える

衝突判定に必要な情報を持たせる

  • 第05回では、弾専用の Bullet 構造体を 作りましたが、今回は敵との衝突判定を 行うために弾も GameObject にそろえます
  • プレイヤー、敵、弾を同じ GameObject 型に そろえることで、同じ IsHit 関数で衝突を 判定できるようになります
  • isActive は使用中か否かを表すフラグです false のものは更新や判定の対象から外します
cpp
// プレイヤー、敵、弾の位置と大きさを管理する構造体
struct GameObject
{
    int x;          // 左上のX座標
    int y;          // 左上のY座標
    int width;      // 横幅
    int height;     // 高さ
    bool isActive;  // アクティブかどうか
};

衝突判定の関数を更新する

非アクティブな GameObject を 除外して判定する

  • 使っていない弾や消えた敵を衝突判定の 対象にするわけにはいきません
  • ここでは if 文で GameObject の isActive を チェックして、アクティブな GameObject 同士でのみ衝突を判定しています
cpp
// 2つの GameObject が衝突しているかを調べる
bool IsHit(GameObject a, GameObject b)
{
    // どちらかが使われていないなら判定しない
    if (a.isActive == false || b.isActive == false)
        return false;

    // 4方向の条件を満たしていれば重なっている
    return a.x < b.x + b.width &&
        b.x < a.x + a.width &&
        a.y < b.y + b.height &&
        b.y < a.y + a.height;
}

enum とは

数値に名前を与える仕組み

  • enum は数値を名前で扱える機構です 定義した名前には数値が上から順に 割り振られます
  • この例では Playing0 GameOver1 GameClear2 です
  • 数値を名前で表せるので、 状態をわかりやすく表現することができます
cpp
// ゲーム全体の状態を表す列挙型
enum GameState
{
    Playing,    // プレイ中: 0
    GameOver,   // ゲームオーバー: 1
    GameClear   // ゲームクリア: 2
};

状態を変数で持つ

今のゲームがどの状態かを記録する

cpp
// 最初はプレイ中から始める
GameState gameState = Playing;
  • この変数で、ゲームが今どの状態にあるかを管理します
  • ゲーム開始時は Playing にします
  • 弾と敵が当たったらスコアを増やし、 目標数に達したら GameClear に変えます
  • 敵とプレイヤーが当たったら GameOver に変えます

状態で処理を分ける

Playing の間だけゲームを更新する

  • ゲームオーバーやクリア後に、 プレイヤーや弾が動き続けると不自然です
  • GameStatePlaying 時だけ case の中でだけ更新処理を行えば、 それ以外ではゲームの更新を停止できます
  • ここでは入力と移動を先に処理し、その後 弾の発射、弾の更新、衝突判定を行います
  • 描画は状態に関係なく行っています
cpp
switch (gameState)
{
case Playing:
    // プレイ中だけ更新する
    // 入力、移動、弾の発射、弾の移動、命中判定
    break;
}

結果表示も状態で処理を分ける

GameOver と GameClear の表示

  • switch は、変数の値によって 処理を切り替える構文です
  • break を書くと、その case の処理が 終わったところで switch 文を抜けます
  • case を追加すれば、状態別の処理を 簡単に書き足すことができます
  • enum と組み合わせると 状態ごとの処理を分かりやすく書けます
cpp
switch (gameState)
{
case GameOver:
    // ゲームオーバーの表示
    DrawString(260, 220, L"GAME OVER",
        GetColor(255, 255, 255));
    break;

case GameClear:
    // ゲームクリアの表示
    DrawString(260, 220, L"GAME CLEAR",
        GetColor(255, 255, 255));
    break;
}

敵を再出現させる処理

関数にまとめておく

  • 倒した敵を再出現させるようにしてみましょう
  • RespawnEnemy に再出現の処理をまとめて おけば、弾の命中時やリトライ時に同じ処理を 呼び出せます
  • enemy = RespawnEnemy(enemy); のように書けば 敵を新しい位置に出し直すことができます
  • GetRand0 から指定した数までの ランダムな整数を返すDXライブラリの関数です
cpp
// 敵を画面上部に出し直す
GameObject RespawnEnemy(GameObject enemy)
{
    enemy.x = GetRand(640 - enemy.width);
    enemy.y = 80;
    enemy.isActive = true;
    return enemy;
}

// 敵を再出現させる
enemy = RespawnEnemy(enemy);

敵に弾が命中したときの処理

弾が命中したらスコアを増やす

  • 当たった弾は使っていない状態に戻します
  • スコアが目標に達したら GameClear にします
  • まだ続く場合は、敵を別の位置に 再出現させます
cpp
// 弾と敵の衝突を判定する
if (IsHit(bullets[i], enemy))
{
    // 当たった弾を消す
    bullets[i].isActive = false;
    score++;

    if (score >= clearScore)
    {
        enemy.isActive = false;
        gameState = GameClear;
    }
    else
    {
        enemy = RespawnEnemy(enemy);
    }
}

プレイヤーが敵に当たったとき

衝突したらゲームオーバーにする

  • プレイヤーと敵の衝突判定には 第04回の衝突判定を使います
  • 衝突が発生した場合、状態を GameOver に 変えることで、以降の更新処理を止められます
cpp
if (IsHit(player, enemy))
{
    // プレイヤーが敵に触れたらゲームオーバー
    gameState = GameOver;
}

サンプルコード(1/11)

GameObject と状態を定義する

  • 第05回の PlayerBullet を、 衝突判定しやすい GameObject に置き換えます
  • プレイヤー、敵、弾を同じ型にそろえることで IsHit() を共通で使えるようにします
  • GameState でゲーム全体の状態を表します
cpp
#include "DxLib.h"

// プレイヤー、敵、弾の位置と大きさを管理する構造体
struct GameObject
{
    int x;          // 左上のX座標
    int y;          // 左上のY座標
    int width;      // 横幅
    int height;     // 高さ
    bool isActive;  // 画面上で有効かどうか
};

// ゲーム全体の状態
enum GameState
{
    Playing,    // プレイ中
    GameOver,   // ゲームオーバー
    GameClear   // ゲームクリア
};

サンプルコード(2/11)

第05回の共有変数を拡張する

  • 第05回と同じく、複数の関数で使う変数は WinMain の外に置きます
  • 弾の配列や発射間隔に加えて、 敵、スコア、ゲーム状態を追加します
  • 弾は今回から Bullet ではなく GameObject の配列に変更されています
cpp
// 弾数、発射間隔、移動速度、クリア条件
const int bulletMax = 10;
const int shotInterval = 10;
const int moveSpeed = 4;
const int bulletSpeed = 8;
const int clearScore = 3;

// プレイヤーと敵の初期状態
GameObject player = { 320, 400, 32, 32, true };
GameObject enemy = { 320, 80, 48, 48, true };

// 弾の管理配列
GameObject bullets[bulletMax] = {};

// ゲーム中に変化する値
int shotTimer = 0;
int score = 0;
int enemySpeed = 2;
GameState gameState = Playing;

サンプルコード(3/11)

判定と再出現の関数を追加する

  • IsHit() は、2つの GameObject が 重なっているかを調べます
  • 使っていない弾や敵は判定しないようにします
  • RespawnEnemy() は、敵を画面上部の ランダムな位置へ出し直します
cpp
// 2つの GameObject が重なっているか調べる
bool IsHit(GameObject a, GameObject b)
{
    // どちらかが無効なら当たっていないことにする
    if (a.isActive == false || b.isActive == false)
        return false;

    // 左右上下の範囲が重なっているかを調べる
    return a.x < b.x + b.width &&
        b.x < a.x + a.width &&
        a.y < b.y + b.height &&
        b.y < a.y + a.height;
}

// 敵を画面上部に出し直す
GameObject RespawnEnemy(GameObject enemy)
{
    // 画面上部のランダムなX座標に移動する
    enemy.x = GetRand(640 - enemy.width);
    enemy.y = 80;
    enemy.isActive = true;
    return enemy;
}

サンプルコード(4/11)

発射処理を関数にする

  • 第05回で while の中に書いていた発射処理を ShootBullet() にまとめます
  • 空いている弾を探して、プレイヤーの位置から発射します
  • 発射できたら shotTimer に待ち時間を入れます
cpp
// 空いている場所を探して弾を1発だけ出す
void ShootBullet()
{
    // Zキーが押されていない、または待ち時間中なら発射しない
    if (CheckHitKey(KEY_INPUT_Z) == false || shotTimer > 0)
        return;

    for (int i = 0; i < bulletMax; i++)
    {
        // 使っていない弾を見つけたら発射する
        if (bullets[i].isActive == false)
        {
            bullets[i].x = player.x + 12;
            bullets[i].y = player.y;
            bullets[i].width = 8;
            bullets[i].height = 16;
            bullets[i].isActive = true;
            shotTimer = shotInterval;
            break;
        }
    }
}

サンプルコード(5/11)

プレイヤーと敵を更新する

  • プレイヤー移動は第05回と同じです
  • 今回は、画面外へ出ないように移動範囲を 制限しています
  • 敵は左右に動き、画面端で向きを反転します
cpp
// プレイヤーと敵を更新する
void UpdatePlayerAndEnemy()
{
    // 十字キーでプレイヤーを移動する
    if (CheckHitKey(KEY_INPUT_LEFT)) player.x -= moveSpeed;
    if (CheckHitKey(KEY_INPUT_RIGHT)) player.x += moveSpeed;
    if (CheckHitKey(KEY_INPUT_UP)) player.y -= moveSpeed;
    if (CheckHitKey(KEY_INPUT_DOWN)) player.y += moveSpeed;

    // 画面の左と上にはみ出さないようにする
    if (player.x < 0) player.x = 0;
    if (player.y < 0) player.y = 0;

    // 画面の右と下にはみ出さないようにする
    if (player.x > 640 - player.width)
        player.x = 640 - player.width;
    if (player.y > 480 - player.height)
        player.y = 480 - player.height;

    // 敵を左右に動かす
    enemy.x += enemySpeed;

    // 画面端に当たったら移動方向を反転する
    if (enemy.x < 0 || enemy.x + enemy.width > 640)
        enemySpeed *= -1;
}

サンプルコード(6/11)

弾を移動する

  • 第05回の UpdateBullets() に、 敵との命中判定を追加します
  • まずは使用中の弾だけを上へ動かし、 画面外に出た弾を未使用に戻します
cpp
// 使用中の弾だけを上に動かし、敵との判定を行う
void UpdateBullets()
{
    for (int i = 0; i < bulletMax; i++)
    {
        // 使用中ではない弾は更新しない
        if (bullets[i].isActive == false)
            continue;

        // 弾を上に動かす
        bullets[i].y -= bulletSpeed;

        // 画面外に出たら未使用に戻す
        if (bullets[i].y < -16)
            bullets[i].isActive = false;

サンプルコード(7/11)

敵との命中判定を行う

  • 弾が敵に当たったら、その弾を未使用に戻し、 スコアを増やします
  • スコアが足りなければ敵を再出現させ、 目標に達したら GameClear にします
cpp
        // 弾が敵に当たったらスコアを増やす
        if (IsHit(bullets[i], enemy))
        {
            bullets[i].isActive = false;
            score++;

            if (score >= clearScore)
            {
                enemy.isActive = false;
                gameState = GameClear;
            }
            else
            {
                // まだクリアでなければ敵を再出現させる
                enemy = RespawnEnemy(enemy);
            }
        }
    }
}

サンプルコード(8/11)

DrawBullets も GameObject 使用に

  • 弾は GameObject になったため、 widthheight を使って矩形で描画します
  • 第05回と同じく、描画も関数に分けたままにします
cpp
// 使用中の弾だけを描画する
void DrawBullets()
{
    for (int i = 0; i < bulletMax; i++)
    {
        if (bullets[i].isActive)
        {
            DrawBox(
                bullets[i].x, bullets[i].y,
                bullets[i].x + bullets[i].width,
                bullets[i].y + bullets[i].height,
                GetColor(255, 255, 0), TRUE);
        }
    }
}

サンプルコード(9/11)

初期化処理

  • DXライブラリの初期化と画像読み込みは、 第05回と同じ流れです
  • 今回は敵画像も読み込みます
cpp
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
{
    // ウィンドウモードで起動する
    ChangeWindowMode(TRUE);

    if (DxLib_Init() == -1)
        return -1;

    SetDrawScreen(DX_SCREEN_BACK);

    // プレイヤー画像と敵画像を読み込む
    int playerHandle = LoadGraph(L"Images/Player.png");
    int enemyHandle = LoadGraph(L"Images/Enemy.png");

サンプルコード(10/11)

Playing の間だけ更新する

  • Playing の間だけ、入力、発射、更新、 命中判定を進めます
  • shotTimer は第05回から変わらず、 発射間隔を制御するために使います
  • プレイヤーが敵に当たったら gameStateGameOver にします
cpp
    while (ProcessMessage() == 0 &&
      CheckHitKey(KEY_INPUT_ESCAPE) == 0)
    {
        switch (gameState)
        {
        case Playing:
            // プレイ中だけゲームの更新処理を行う
            UpdatePlayerAndEnemy();

            // 連射制御用タイマーを減らす
            if (shotTimer > 0)
                shotTimer--;

            ShootBullet();
            UpdateBullets();

            // プレイヤーが敵に触れたらゲームオーバー
            if (IsHit(player, enemy))
            {
                gameState = GameOver;
            }
            break;
        }

サンプルコード(11/11)

描画と結果表示

  • プレイヤーと敵を描画し、 弾は DrawBullets() で描画します
  • 状態に応じて結果の文字を表示します
  • 最後に ScreenFlip() で画面へ反映します
cpp
        // 1フレーム分の描画を行う
        ClearDrawScreen();

        DrawGraph(player.x, player.y, playerHandle, TRUE);

        // 敵が有効なときだけ描画する
        if (enemy.isActive)
            DrawGraph(enemy.x, enemy.y, enemyHandle, TRUE);

        DrawBullets();

        DrawFormatString(10, 10, GetColor(255, 255, 255),
            L"SCORE %d / %d", score, clearScore);

        // 状態に応じて結果を表示する
        if (gameState == GameOver)
            DrawString(260, 220, L"GAME OVER",
                GetColor(255, 255, 255));

        if (gameState == GameClear)
            DrawString(260, 220, L"GAME CLEAR",
                GetColor(255, 255, 255));

        ScreenFlip();
    }

    DxLib_End();
    return 0;
}

サンプルコードを打ち込む際は インデントがズレてないかに 注意してください

処理のまとまりが わかりやすくなるよ

実習1: 弾と敵の命中判定

当たった弾を消し、敵を出し直す

  1. 弾を GameObject の配列として用意し直す
  2. IsHit で弾と敵の重なりを判定する
  3. 弾が敵に当たったら、弾の isActivefalse にする
  4. enemy = RespawnEnemy(enemy); で敵を出し直す
  5. 敵を何度も狙えることを確認する
  • 弾が当たらない場合は、弾の位置と大きさを DrawBox で確認します

実習2: 状態管理を追加する

結果を画面に表示する

  1. enum GameState を定義する
  2. GameState gameState = Playing; を用意する
  3. 弾が敵に当たったら score を増やす
  4. score >= clearScore になったら GameClear にする
  5. プレイヤーが敵に当たったら GameOver にする
  6. 状態に応じて GAME CLEAR または GAME OVER を表示する
  • 状態を変えるだけでなく、表示が切り替わるところまで確認します

実習3: 基本形を完成させる

遊べる状態に整える

  1. Playing の間だけ、入力、発射、更新、判定を行う
  2. 第05回の発射間隔を残したまま、状態管理を組み込む
  3. プレイヤーが画面外に出ないように座標を制限する
  4. 敵を左右に動かし、画面端で反転させる
  5. 敵を一定数倒すとクリア、敵に当たるとゲームオーバーになるようにする
  6. プレイヤー、敵、弾の位置や大きさを調整する
  • 余裕があれば、敵や弾の速度を変えて難易度を調整します

余裕課題: 倒すたびに難しくする

敵を少しずつ速くする

cpp
// 敵に弾が当たったタイミングで追加する
if (enemySpeed > 0)
    enemySpeed++;
else
    enemySpeed--;
  • 敵を倒すたびに移動速度を少し上げると、 後半ほど狙いにくくなります
  • 敵の速度がプラスの時は加算、マイナスの時は減算して 向きを変えずに速度を上げています

よくあるつまずき

状態が切り替わらないときの確認ポイント

  • gameState = GameClear;gameState = GameOver; を代入できているか
  • === を取り違えていないか
  • IsHit の中で isActive == false のものを除外できているか
  • 弾の widthheight を設定しているか
  • RespawnEnemy(enemy) を呼ぶ前に、敵の widthheight が設定されているか
  • Playing の外側で更新処理を続けていないか

コードが長くなってきました

いまこそ整理のタイミング

  • 今回は、状態管理、敵の移動、弾の更新、衝突判定、描画を 1つのゲームループの中に組み込みました
  • その結果、if 文や for 文の中に、さらに if 文が入る場面が増えています ゲームの処理が複雑になれば、コードが複雑になるのも避けられません
  • ですが、このまま機能を足し続けると「どこが何の処理なのか」を 追えなくなってしまいます
  • 次回は、処理に名前を付けたり、データと処理を近くに置いたりして、 コードを整理する考え方に進みます

今回のまとめ

今日のポイント

  • 衝突判定の判定結果をゲームのルールにあわせて処理することで ゲームの勝ち負けを決定するようにしました
  • 敵を動かし、倒したら再出現させることで、 短いゲームループを繰り返し遊べる形にしてみました
  • ゲーム状態を enum を使って定義し、 PlayingGameOverGameClear と分けることで 更新と表示の処理をゲーム状態別で切り替えられるようになりました
  • 次回は今まで触れてきた C++ の内容を振り返り、 その上で長くなったコードを整理する新しい方法に触れます

おつかれさまでした!

次回予告 第07回 C++振り返り / オブジェクト指向入門

大きなゲームを 作るための 技法のお話