Skip to content

第02回 ゲームループの理解と実装

前回: 第01回 オリエンテーションとベースコード確認 / 次回: 第03回 画像表示の導入とエラー確認

今回の授業内容

  • ゲームループにおける入力、更新、判定、状態更新、描画の役割と処理順を理解する
  • F1 で切り替えるデバッグ表示を使って、位置、速度、接地状態を確認する
  • 現在と前フレームの入力を比較し、キーを押した瞬間を判定する
  • 処理順を変更し、挙動の違いを比較する
  • PlayingGameClearGameOver による状態遷移の基本形を実装する

今回の配布コード

第01回のコードに、F1 で切り替えられるデバッグ表示を追加したスナップショットです
位置、速度、接地状態をDrawDebug()で確認できます

配布ファイル一覧
ファイル内容
Source/Game.hゲームとプレイヤーの宣言
Source/Game.cppゲームループとデバッグ表示
Source/Stage.hステージの宣言
Source/Stage.cpp固定マップと床判定
Source/Common.h共通の型と関数
Source/main.cppDXライブラリの初期化

ゲームループの基礎

ゲームループとは

ゲームとフレーム

ゲーム映像は、一秒間に何十枚、時に何百枚もの映像を連続して表示することで成り立っています
映像を生成するためには映像のために必要な情報、つまり表示されるキャラクターの位置や状態を計算する必要があります
これらのキャラクター位置はプレイヤーの入力やゲーム内の状態に応じて変化します

つまり、ゲームの映像を生成するためには

  1. まずプレイヤーの入力を受け取り
  2. 受け取った情報を元にゲーム内の状態を更新し
  3. 更新された結果を画面に描画する

といった手順が必要になります
この一連の手続きが1フレーム分の処理で、このフレームの生成を繰り返すループがゲームループです

ゲームループの処理手順

  1. 入力を読む
  2. 位置や速度を更新する
  3. 当たり判定を行う
  4. 状態を更新する
  5. 描画する

多くのゲームで、上記の順序で処理が行われます
今回のゲーム制作においても、Game クラスの MainLoop() メソッドはこの手順で処理を行っています

MainLoop の処理を読む

Game.cppMainLoop() の実装を確認しましょう

cpp
void Game::MainLoop()
{
    while (ProcessMessage() == 0 && CheckHitKey(KEY_INPUT_ESCAPE) == 0)
    {
        Update();

        ClearDrawScreen();
        Draw();
        ScreenFlip();
    }
}

このループは、ウィンドウを閉じるか ESC キーを押すまで繰り返されます

処理役割所属
ProcessMessage()Windowsから届くメッセージを処理するDXライブラリの関数
CheckHitKey(KEY_INPUT_ESCAPE)終了操作を確認するDXライブラリの関数
Update()入力、移動、当たり判定、ゲーム状態を更新するGame クラスのメソッド
ClearDrawScreen()これから描く画面を消去するDXライブラリの関数
Draw()更新後のゲーム状態を描画するGame クラスのメソッド
ScreenFlip()裏画面に描いた内容を画面へ反映するDXライブラリの関数

更新を扱う Update() と 描画を行う Draw() で別処理となっているのがポイントです
Update() はゲーム内のデータを変化させ、Draw() はその結果を画面へ表示します

1フレーム処理の分解

1フレームの処理内容

MainLoopから呼ばれる関数を順番に

図を準備しています…

プレイ中の1フレームは、大きくは次の5段階で処理されます

  1. UpdatePlayerInput() でキー入力を読み、プレイヤーの速度を変える
  2. UpdatePlayerPhysics() で重力を加え、プレイヤー位置を更新する
  3. MoveHorizontal()MoveVertical() で床や壁との衝突を解決する
  4. 落下とゴールを調べ、必要ならゲーム状態を変更する
  5. Draw() で更新後の状態を描く

入力で速度を操作する

今回のゲームではプレイヤーの位置を直接変更するのではなく、速度を変化させることで移動を表現します
速度を使うことで摩擦や重力などの物理的な挙動を表現しやすくなり、滑るような動きや、ジャンプ後の落下などを自然に表現できます

UpdatePlayerInput() は横方向の速度 vx を変更します

cpp
if (CheckHitKey(KEY_INPUT_LEFT))
{
    player.vx -= accel;
}
else
if (CheckHitKey(KEY_INPUT_RIGHT))
{
    player.vx += accel;
}
else
{
    if (player.vx > 0)
        player.vx = Clamp(player.vx - friction, 0.0f, maxSpeed);
    else
    if (player.vx < 0)
        player.vx = Clamp(player.vx + friction, -maxSpeed, 0.0f);
}

player.vx = Clamp(player.vx, -maxSpeed, maxSpeed);

入力がないときは摩擦係数である friction によって徐々に減速します
その後、UpdatePlayerPhysics() が速度を使って実際の位置を更新します

調整のヒント

横方向の滑りは常に必要なわけではなく、あくまでゲームの仕様次第です
滑りすぎても操作感が悪くなってしまいます
friction の値を変更して、好みの挙動に調整してみましょう

物理更新の中で当たり判定を行う

cpp
void Game::UpdatePlayerPhysics()
{
    player.vy += gravity;
    player.vy = Clamp(player.vy, -30.0f, maxFallSpeed);

    MoveHorizontal(player.vx);

    MoveVertical(player.vy);

    Rect groundProbe = PlayerRect();
    groundProbe.y += 1;
    player.onGround = stage.IsSolidAtRect(groundProbe);
}

この処理では、プレイヤーの速度に重力を加えてから横方向、縦方向の順で移動します
横と縦の移動処理を分けることで、壁と床との同時衝突を避け、斜め方向のめり込みを回避しています

MoveHorizontal()MoveVertical()は、移動後のRectと床タイルの重なりを調べます
重なった場合は進行方向に応じてタイル境界へ位置を補正し、対応する速度を0にします

つまり、壁に当たった際は横方向の速度が 0 に、床に当たった際は縦方向の速度が 0 になります
縦横を分けて判定することで、壁にぶつかってもジャンプの挙動には影響しないようにしています

cpp
Rect movedRect = PlayerRect();
if (stage.ResolveVertical(movedRect, pixelMove))
{
    if (pixelMove > 0) player.onGround = true;
    player.vy = 0.0f;
    player.remainderY = 0.0f;
}
player.y = movedRect.y;

また、下向きに移動して床へ衝突したときだけ、接地している状態を表す onGroundtrue にします
この結果は次のフレームのジャンプ入力判定で使われます

状態管理と検証

デバッグ表示の追加

画面上では同じように見えても、内部の位置、速度、接地状態は毎フレーム変化しています
意図しない挙動に遭遇した際、ゲームループの内部動作を想像で追跡するのは困難です

ゲーム内の細かな数値を表示し確認できるようにすることで、挙動の原因を特定しやすくなります
操作説明を常時表示するDrawHud()とは分けて、内部状態はDrawDebug()へ表示します
F1 を押したときだけ表示・非表示を切り替えられるため、通常の画面と観察用の画面を使い分けられます

cpp
void Game::DrawDebug() const
{
    if (!debugVisible) return;

    DrawFormatString(14, 100, GetColor(255, 255, 255),
        L"POSITION  x:%d  y:%d", player.x, player.y);
    DrawFormatString(14, 120, GetColor(255, 255, 255),
        L"VELOCITY  vx:%.2f  vy:%.2f", player.vx, player.vy);
    DrawFormatString(14, 140, GetColor(255, 255, 255),
        L"ON GROUND %s", player.onGround ? L"TRUE" : L"FALSE");
}

実行して、次の変化を観察します

  1. 左右キーを押し続けると vx が上限まで変化する
  2. キーを離すと vx0 に近づく
  3. ジャンプ直後は vy が負の値になる
  4. 重力によって vy が増え、やがて正の値になって落下する
  5. 床に着くと vy0onGroundTRUE になる

注意

描画処理の中で player の値を変更すると、挙動が変わってしまいます
デバッグ表示の機能の中では状態を表示するだけにして、値の変更は Update() 内で行うようにしましょう
描画関数は可能な限り const メソッドにして、値を変更できないようにするのも有効です

キーを押して表示を切り替える

DXライブラリの CheckHitKey() は、キーを押している間は毎フレームTrueになります
現在の入力と前フレームの入力を比較することで押した瞬間だけを検出できます

cpp
void Game::UpdateDebugToggle()
{
    const bool currentF1 = CheckHitKey(KEY_INPUT_F1) != 0;

    if (currentF1 && !previousF1)
    {
        debugVisible = !debugVisible;
    }

    previousF1 = currentF1;
}

currentF1truepreviousF1falseの組み合わせは「このフレームで押し始めた」ことを示しています
判定後にcurrentF1previousF1に保存し、次のフレームの比較に使います

この更新はゲーム状態に関係なく受け付けるため、Update()の状態分岐より前に呼びます

cpp
void Game::Update()
{
    UpdateDebugToggle();

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

処理順を変えて比較する

処理の順序はゲームの挙動に影響します

処理がすべて1フレーム内に収まっていても、呼び出す順番を変えると結果が変わります
コード内容を変更して、次の比較を行ってみましょう

比較1: 更新より前に描画する

MainLoop() を一時的に次の順番へ変更します

cpp
while (ProcessMessage() == 0 && CheckHitKey(KEY_INPUT_ESCAPE) == 0)
{
    ClearDrawScreen();

    Draw();

    // 比較用: 描画より前に更新している
    Update();
    ScreenFlip();
}

この場合、画面に表示されるのは更新前の状態です
入力と物理計算の結果は次のフレームで反映されるため、描画が内部状態に対して1フレーム遅れます
わずかな差ですが、プレイヤーの操作感に影響します

比較後に戻す

比較後は、必ず Update() を描画処理より前へ戻しましょう

比較2: 物理更新より前にゴール判定する

UpdatePlaying() のゴール判定を UpdatePlayerPhysics() より前へ移すと、ゴールへ触れた結果の判定は次フレームになってしまいます

cpp
void Game::UpdatePlaying()
{
    UpdatePlayerInput();

    // 比較用: 移動前の位置で判定している
    // 判定結果は1フレーム遅れ、わずかながらも操作感や挙動の整合性に悪影響を及ぼす
    if (IsHitRect(PlayerRect(), stage.GetGoal()))
    {
        gameState = GameClear;
        return;
    }

    UpdatePlayerPhysics();
}

判定に使用する座標が「移動前」か「移動後」かに注目してください
当たり判定は、どの時点の状態を使うかまで含めて設計する必要があります

比較後に戻す

比較後は、ゴール判定を UpdatePlayerPhysics() のあとへ戻しましょう

比較3 接地状態のリセットタイミングに注意する

配布コードでは、縦移動の直前に player.onGround = false を実行します
そのあと下向きの衝突が発生した場合にだけ true に戻します

cpp
// 移動の前に接地状態をリセットする
player.onGround = false;
MoveVertical(player.vy);

この代入を MoveVertical() のあとへ移すと、床への衝突で true になった結果まで消してしまいます
そのため、床に立っていてもジャンプできなくなります

cpp
// 移動の後に接地状態をリセットしており、床に立っていてもジャンプできなくなる
MoveVertical(player.vy);
player.onGround = false;

代入する値だけでなくいつ代入するかのタイミングも重要です
今回は簡単な例ですが、複雑なゲームではフラグや更新のタイミングの指定ミスで厄介なバグが発生することがあります
更新、状態の判定、描画の順序は常に意識して、整合性の取れた処理順で設計するように心がけましょう

ゲーム状態を切り替える

ひとつのループの中で場面を管理する

ゲームクリア後もゲームオーバー後も、MainLoop() 自体は動き続けています
ゲームループはゲーム全体の動作を支える、いわば骨格です
ゲーム状態が変わっても、ゲームループを止める必要はありません

この制作でも、ループを作り直す代わりに現在の状態を GameState で表し、状態に応じて処理を切り替えています

cpp
enum GameState
{
    Playing,
    GameClear,
    GameOver
};

現在の状態は gameState に保持します

cpp
GameState gameState = Playing;
状態意味主な更新処理
Playingゲームをプレイしている入力、物理更新、落下判定、ゴール判定
GameClearゴールへ到達したR キーによるリトライ
GameOverステージ下へ落下したR キーによるリトライ

switchで状態ごとの処理を選ぶ

cpp
void Game::Update()
{
    switch (gameState)
    {
    case Playing:
        UpdatePlaying();
        break;

    case GameClear:
    case GameOver:
        UpdateResult();
        break;
    }
}

GameClearGameOver は、どちらも結果表示中の入力を処理するため UpdateResult() を共有しています
プレイ中の更新と結果画面の更新を分けることで、クリア後にプレイヤーが動き続けることを防いでいます

状態遷移を整理する

図を準備しています…

実際のコードでは、ゴールへ触れたときに状態を変更します

cpp
if (IsHitRect(PlayerRect(), stage.GetGoal()))
{
    gameState = GameClear;
}

ステージ下へ落ちた場合は GameOver へ変更し、return でそのフレームの残りの判定を終了します

cpp
if (player.y > Stage::mapHeight * Stage::tileSize + 120)
{
    gameState = GameOver;
    return;
}

結果表示中に R キーが押されたら、プレイヤーを初期位置へ戻してから Playing へ遷移します

cpp
if (CheckHitKey(KEY_INPUT_R))
{
    StartPlay();
}

状態を変更するだけでは、位置や速度は初期化されません
StartPlay()にプレイ中のデータ初期化とPlayingへの状態変更をまとめることで、ゲーム開始時とリトライ時に同じ処理を行えます

cpp
void Game::StartPlay()
{
    ResetPlay();
    gameState = Playing;
}

プレイ画面に結果表示を重ねる

cpp
void Game::Draw()
{
    DrawBackground();
    DrawPlaying();

    if (gameState != Playing)
    {
        DrawResult();
    }
}

背景、ステージ、プレイヤーを描いたあと、プレイ中でなければ結果画面を重ねます
更新処理を状態ごとに分けても、共通して見せたい描画は共有できます

描画順にも意味があります
DrawResult()DrawPlaying() より前に呼ぶと、結果表示がステージやプレイヤーで上書きされてしまう場合があります

実習

実習1 ゲームループを説明する

配布コードを動かしながら、次の処理がどの関数に書かれているか確認します

  • 入力を読む
  • 速度を更新する
  • 位置を更新する
  • 床や壁との当たり判定を行う
  • ゴールと落下を判定する
  • 画面を描画する

実習2 デバッグ表示で値を観察する

F1 で表示を切り替え、移動、ジャンプ、着地時の位置、速度、接地状態を確認します
キーを押し続けても表示が1回しか切り替わらないことも確認します

実習3 処理順を比較する

次のうちひとつを変更し、変更前と変更後の差を説明します

  1. Update()Draw() の順番
  2. 物理更新とゴール判定の順番
  3. onGroundfalse にする位置
  4. DrawPlaying()DrawResult() の順番

比較が終わったら、処理順を配布コードの状態へ戻して動作確認します

発展 ゲーム状態をHUDへ表示する

GameState は列挙型なので、そのままでは状態名を文字として表示できません
DrawDebug() の末尾へ状態ごとの表示を追加します

cpp
const wchar_t* stateText = L"PLAYING";

switch (gameState)
{
case Playing:
    stateText = L"PLAYING";
    break;
case GameClear:
    stateText = L"GAME CLEAR";
    break;
case GameOver:
    stateText = L"GAME OVER";
    break;
}

DrawFormatString(12, 140, GetColor(255, 255, 0),
    L"STATE %s", stateText);

プレイ中、クリア後、ゲームオーバー後で表示が切り替わることを確認します

今回の完成条件

  • MainLoop() が更新、描画、画面反映を繰り返していると説明できる
  • 入力、物理更新、当たり判定、状態更新、描画の順序をコード上で確認できる
  • デバッグ表示で位置、速度、接地状態を確認できる
  • 前フレームの入力と比較し、F1 を押した瞬間だけ表示を切り替えられる
  • 処理順を変えると挙動が変わる理由を説明できる
  • PlayingGameClearGameOver の状態遷移を説明できる
  • クリア後とゲームオーバー後に R キーで再開できる

よくある問題

ジャンプできない

  • player.onGround = falseMoveVertical() よりあとに置かれていないか確認する
  • 床へ着地したときに onGroundtrue になっているかデバッグ表示で確認する

クリア判定が遅れる

  • ゴール判定が UpdatePlayerPhysics() より前に置かれていないか確認する
  • PlayerRect() が更新後の座標から作られているか確認する

結果表示が見えない

  • DrawResult()DrawPlaying() よりあとに呼ばれているか確認する
  • gameStatePlaying 以外へ変化しているか確認する

リトライ後も落下し続ける

  • StartPlay() から ResetPlay() が呼ばれているか確認する
  • ResetPlay() から ResetPlayer() が呼ばれているか確認する
  • ResetPlayer() で位置だけでなく vxvy0 にしているか確認する

まとめ

  • ゲームループは、1フレーム分の更新と描画を繰り返すゲーム動作の骨格です
  • 更新処理では、入力、物理更新、当たり判定、状態更新を順番に行います
  • 描画は更新後のゲーム状態を参照し、画面へ結果を表示します
  • 同じ処理でも、実行する順番によってゲームの挙動が変わります
  • 見えない値はデバッグ表示を使うと確認しやすくなります
  • ゲーム状態を列挙型で管理すると、場面ごとの更新処理を扱いやすくなります

次回は、図形で描いているプレイヤー、床、ゴールを画像描画へ置き換え、また画像の読み込みに失敗した場合のエラー処理を追加します