Skip to content

第09回 ゲーム状態

前回: 第08回 画像とリソース管理 / 次回: 第10回 音と演出

今回の授業内容

  • enum classでゲーム状態を定義する
  • タイトル、プレイ中、クリア、ゲームオーバーの遷移をMermaidの状態図で整理する
  • 状態ごとに更新処理と描画処理を分ける
  • 状態が切り替わる瞬間の処理をChangeState()へ集約する
  • リトライ時にゲーム内の値を初期化する
  • 開発用のデバッグ表示を追加する

今回の開始状態

第08回終了時点では、起動するとすぐゲームが始まり、敵や弾が動き続けます
今回の終了時点では、タイトル画面から開始し、結果画面からリトライできるようにします

変更するファイル

ファイル変更内容
Game.hGameState、残り時間、クリアスコア、状態別関数、デバッグ表示フラグを追加する
Game.cppタイトル、プレイ中、結果画面の更新と描画、状態遷移、デバッグ表示を追加する

GameStateを追加する

Game.hに、現在の場面を表す列挙型を追加します

cpp
enum class GameState
{
    Title,
    Playing,
    GameClear,
    GameOver
};

Gameクラスに現在の状態を持たせます

cpp
GameState state = GameState::Title;
static constexpr int clearScore = 30;
static constexpr int timeLimit = 60 * 60;
int remainingTime = timeLimit;

状態遷移を図で確認する

実装に入る前に、4つの状態がどうつながるかを図で確認します

図を準備しています…

タイトルからプレイ中への遷移と、結果画面からプレイ中への遷移は、どちらも同じStartGame()を呼びます
プレイ中から抜ける条件が2つ(ゲームオーバーとゲームクリア)ある一方、抜けたあとの戻り先は常にプレイ中の1つだけという、単純な形にまとまっていることを確認します

状態ごとに更新する

Update()は、現在の状態に応じて処理を振り分けるだけにします

cpp
void Game::Update()
{
    switch (state)
    {
    case GameState::Title:
        UpdateTitle();
        break;
    case GameState::Playing:
        UpdatePlaying();
        break;
    case GameState::GameClear:
    case GameState::GameOver:
        UpdateResult();
        break;
    }
}

GameClearGameOverが同じcaseラベルを共有しているのは、結果画面としての振る舞い(Enterでリトライする、敵や弾を動かさない)が共通しているためです
表示するメッセージの違いは、UpdateResult()ではなくDrawResult()側で扱います

タイトルではEnterでゲームを開始します

cpp
void Game::UpdateTitle()
{
    if (input.IsPressed(KEY_INPUT_RETURN))
        StartGame();
}

StartGameで初期化をまとめる

ゲーム開始時とリトライ時に必要な初期化を、StartGame()へまとめます

cpp
void Game::StartGame()
{
    player.Reset();

    for (Bullet& bullet : bullets)
        bullet.Deactivate();

    for (Enemy& enemy : enemies)
        enemy.Deactivate();

    score = 0;
    frame = 0;
    remainingTime = timeLimit;
    state = GameState::Playing;
}

値を戻す処理を複数箇所へ書かないことが重要です
戻し忘れを防ぐため、開始とリトライの入口を同じ関数にします

状態が切り替わる瞬間をChangeStateへ集約する

クリアとゲームオーバーは、stateへ直接代入するのではなく、ChangeState()という専用の関数を経由させます

cpp
void Game::ChangeState(GameState nextState)
{
    if (state == nextState)
        return;

    state = nextState;
}

現時点では、この関数は「同じ状態への遷移を無視して代入するだけ」の単純な処理です
わざわざ関数にする理由は、次回以降、状態が切り替わった瞬間にだけ実行したい処理(クリア音やゲームオーバー音の再生など)が増えていくためです
遷移のたびに呼び出し側で同じ処理を書き足すのではなく、「状態が変わる場所」を1つの関数に決めておくことで、切り替え時の処理を書く場所に迷わなくなります

プレイ中の更新では、最後に終了条件を確認し、ChangeState()を呼びます

cpp
if (!player.IsAlive())
    ChangeState(GameState::GameOver);
else if (score >= clearScore || remainingTime <= 0)
    ChangeState(GameState::GameClear);

結果画面では、敵や弾の更新を止め、EnterでStartGame()を呼びます

cpp
void Game::UpdateResult()
{
    if (input.IsPressed(KEY_INPUT_RETURN))
        StartGame();
}

結果画面を描画する

DrawResult()では、プレイ中の画面の上に半透明の帯を重ね、クリアかゲームオーバーかに応じたメッセージを表示します

cpp
void Game::DrawResult() const
{
    SetDrawBlendMode(DX_BLENDMODE_ALPHA, 128);
    DrawBox(0, 0, screenWidth, screenHeight, black, TRUE);
    SetDrawBlendMode(DX_BLENDMODE_NOBLEND, 0);

    if (state == GameState::GameClear)
        DrawString(246, 180, L"GAME CLEAR", green);
    else
        DrawString(246, 180, L"GAME OVER", red);

    DrawFormatString(246, 224, white, L"SCORE %02d", score);
    DrawString(210, 288, L"PRESS ENTER TO RETRY", yellow);
}

DXライブラリのDrawBoxは、色引数だけでは半透明になりません
SetDrawBlendMode(DX_BLENDMODE_ALPHA, 128)で「これから描く図形を50%程度の透明度で重ねる」設定を行い、描画後はSetDrawBlendMode(DX_BLENDMODE_NOBLEND, 0)で元の設定へ戻します
戻し忘れると、以降のすべての描画が半透明になってしまうため、有効化と無効化は必ず対で書きます

Draw()本体では、クリアとゲームオーバーのときだけ、プレイ中の描画の上に結果画面を重ねます

cpp
switch (state)
{
case GameState::Title:
    DrawTitle();
    break;
case GameState::Playing:
    DrawPlaying();
    break;
case GameState::GameClear:
case GameState::GameOver:
    DrawPlaying();
    DrawResult();
    break;
}

デバッグ表示を追加する

ここまでの実装で、状態、スコア、残り時間、有効なオブジェクト数など、目に見えない値が増えてきました
不具合を調べるとき、これらの値をコード修正のたびにDrawFormatStringで仮表示しては消す、という作業を繰り返すのは非効率です
そこで、F1キーで表示・非表示を切り替えられるデバッグ表示をここで用意します

cpp
bool debugVisible = false;
cpp
void Game::Update()
{
    if (input.IsPressed(KEY_INPUT_F1))
        debugVisible = !debugVisible;

    switch (state)
    {
    // 状態ごとの更新
    }
}

DrawDebug()は、現在のフレーム数と、有効な弾・敵の数を画面右下にまとめて表示します

cpp
void Game::DrawDebug() const
{
    int activeBullets = 0;
    int activeEnemies = 0;

    for (const Bullet& bullet : bullets)
    {
        if (bullet.IsActive()) ++activeBullets;
    }
    for (const Enemy& enemy : enemies)
    {
        if (enemy.IsActive()) ++activeEnemies;
    }

    DrawBox(432, 340, 632, 462, 0x20242c, TRUE);
    DrawString(448, 356, L"DEBUG", yellow);
    DrawFormatString(448, 384, white, L"Frame   %d", frame);
    DrawFormatString(448, 408, white, L"Bullets %d", activeBullets);
    DrawFormatString(448, 432, white, L"Enemies %d", activeEnemies);
}

Draw()の最後に、debugVisibletrueのときだけ呼び出します

cpp
if (debugVisible)
    DrawDebug();

このデバッグ表示は、第05回・第06回で「上限数を一時的に減らして確認する」といった動作確認の代わりとしても使えます
F1で表示を切り替えながら、弾や敵の有効数が想定どおり増減しているかを確認する習慣をつけておくと、第12回・第13回の独自要素追加やデバッグで役立ちます

動作確認

  1. 起動直後にタイトル画面が表示されることを確認する
  2. Enterでプレイが始まることを確認する
  3. HPが0になるとゲームオーバーになることを確認する
  4. スコアまたは残り時間でゲームクリアになることを確認する
  5. 結果画面でEnterを押すと、スコア、HP、弾、敵が初期化されることを確認する
  6. F1でデバッグ表示が切り替わり、弾や敵の有効数が変化と一致することを確認する

よくある問題

  • リトライ後に古い弾や敵が残る場合は、StartGame()で配列を無効化しているか確認する
  • タイトル中に敵が動く場合は、Playing以外で敵更新を呼んでいないか確認する
  • クリアとゲームオーバーが同時に起きる場合は、条件の順序を確認する
  • 結果画面の半透明の帯がまったく透けない、または以降の描画まで透けて見える場合は、SetDrawBlendModeの有効化と無効化が対になっているか確認する

今回の完成条件

  • GameStateで現在の場面を表せる
  • 状態ごとに更新と描画を分けられる
  • 状態遷移を図で説明できる
  • ChangeState()に処理を集約する理由を説明できる
  • リトライ時に必要な値を初期化できる
  • デバッグ表示の役割を説明できる

まとめ

今回は、ゲーム全体の場面を状態として整理し、デバッグ表示を用意しました
次回は、音とエフェクトを追加し、操作結果を分かりやすく伝えます