Skip to main content

付録2 DxLibテンプレート

付録2 今後のゲーム制作で使用するDxLibテンプレート

このページでは、これからのゲーム制作で共通して使用するDxLibのテンプレートを用意します。

このテンプレートは、HIT&BLOWだけのために作るものではありません。
次にシューティングゲームやアクションゲームを作るときも、基本的には同じものを使用します。

最初から用意されている処理は、次のとおりです。

  • 800×600の黒いウィンドウを表示する
  • DxLibを初期化する
  • 裏画面へ描画する
  • ゲーム内部を1秒間に60回更新する
  • 実際に経過した時間を計測する
  • GameUpdatedeltaTimeを渡す
  • ESCキーまたはウィンドウの閉じるボタンで終了する
  • ゲーム終了時の後始末を行う

次のチャプターから、このテンプレートへゲームの処理を追加していきます。


1.ゲームは処理を繰り返して動いている

コンソールプログラムでは、上から下へ命令を実行し、最後まで進むとプログラムが終了するものが多くありました。

ゲームは、すぐには終了しません。

入力を調べる
    ↓
ゲームの状態を更新する
    ↓
画面を描画する
    ↓
最初へ戻る

この繰り返しを、ゲームループといいます。

今回のテンプレートでは、主に次の関数へ処理を追加します。

void GameInitialize(void)
{
    // ゲーム開始時に1回だけ行う処理
}

void InputUpdate(void)
{
    // キーボードやマウスの入力を調べる処理
}

void GameUpdate(double deltaTime)
{
    // ゲームの状態を更新する処理
}

void GameDraw(double interpolation)
{
    // ゲーム画面を描画する処理
}

void GameFinalize(void)
{
    // ゲーム終了時に1回だけ行う処理
}

2.ゲーム内部は1秒間に60回更新する

このテンプレートでは、ゲーム内部の更新間隔を次のように決めています。

const int UPDATE_FPS = 60;
const double FIXED_DELTA_TIME = 1.0 / UPDATE_FPS;

FIXED_DELTA_TIMEは、ゲーム内部を1回進める時間です。

1 ÷ 60 = 約0.016666秒

ゲーム内部では、約0.016666秒ずつ時間を進めます。

1回目のUpdate    約0.016666秒進む
2回目のUpdate    約0.016666秒進む
3回目のUpdate    約0.016666秒進む
        :
60回目のUpdate   合計で約1秒進む

このように、ゲーム内部を一定の時間ずつ進める方法を固定タイムステップといいます。


3.このテンプレートで扱う2種類の時間

このテンプレートでは、2種類の時間を扱います。

名前 意味
frameDeltaTime 前回のループから実際に経過した時間
deltaTime ゲーム内部を1回更新するときに進める固定時間

frameDeltaTime

frameDeltaTimeは、コンピュータ上で実際に経過した時間です。

実行するたびに、少しずつ異なります。

0.0162秒
0.0170秒
0.0158秒

実際に経過した時間は、次の関数を使って計測します。

GetNowHiPerformanceCount()

取得した値はマイクロ秒単位なので、1000000.0で割って秒へ変換します。

frameDeltaTime =
    (currentTime - previousTime) / 1000000.0;

deltaTime

GameUpdateへ渡されるdeltaTimeは、固定で60分の1秒です。

void GameUpdate(double deltaTime)
{
}

このテンプレートでは、次の値が渡されます。

deltaTime = 1.0 ÷ 60.0
          = 約0.016666秒

シューティングゲームで自機を移動させる場合は、次のように使用します。

playerX += playerSpeed * deltaTime;

例えば、移動速度が1秒間に240ピクセルなら、1回の更新で進む距離は次のようになります。

240 × 1/60 = 4ピクセル

これを1秒間に60回行うので、1秒間に240ピクセル移動します。

ゲーム内の移動、弾、敵、アニメーションなどには、原則としてGameUpdateへ渡されるdeltaTimeを使用します。


4.固定タイムステップの処理

実際に経過した時間は、accumulatorという変数へためておきます。

実際に経過した時間を測る
        ↓
accumulatorへ加える
        ↓
1/60秒以上たまっているか調べる
        ↓
たまっていればGameUpdateを1回実行する
        ↓
処理した1/60秒をaccumulatorから引く

疑似言語で表すと、次のようになります。

○ 実数型:frameDeltaTime
○ 実数型:accumulator
○ 実数型:deltaTime

・deltaTime ← 1 ÷ 60
・accumulator ← 0

■ ゲームが終了していない間

│ ・実際に経過した時間をframeDeltaTimeへ入れる
│ ・accumulatorへframeDeltaTimeを加える

│ ■ accumulatorがdeltaTime以上の間
│ │
│ │ ・入力状態を更新する
│ │ ・ゲームをdeltaTime秒だけ進める
│ │ ・accumulatorからdeltaTimeを引く
│ │
│ ■

│ ・ゲーム画面を描画する

■

例えば、前回から約0.033秒経過していた場合、約60分の2秒がたまっています。

0.033秒 ÷ 0.016666秒 ≒ 2回

この場合は、画面を描画する前にGameUpdateを2回実行します。


5.描画回数と更新回数は同じとは限らない

このテンプレートが固定するのは、ゲーム内部の更新間隔です。

画面の描画回数を、必ず毎秒60回に固定するわけではありません。

ゲーム内部の更新
1秒間に60回を基準として進める

画面の描画
モニターのリフレッシュレートや処理速度に合わせて行う

例えば、144Hzのモニターでは、画面が1秒間に約144回描画されることがあります。

それでも、ゲーム内部は1秒間に60回を基準として更新されます。

反対に、処理が重くなり、画面を1秒間に30回しか描画できない場合は、1回の描画までにGameUpdateを複数回実行して時間を進めます。

「描画を60FPSへ固定する」のではなく、「ゲーム内部を60分の1秒ずつ進める」テンプレートです。


6.テンプレートのソースコード

次のコードをmain.cppへ記述します。

#include "DxLib.h"

// 画面の大きさ
const int SCREEN_WIDTH = 800;
const int SCREEN_HEIGHT = 600;

// ゲーム内部を1秒間に更新する回数
const int UPDATE_FPS = 60;

// ゲーム内部を1回更新するときに進める時間
const double FIXED_DELTA_TIME = 1.0 / UPDATE_FPS;

// ウィンドウ移動やデバッグ停止などで長時間止まった場合に、
// 一度に処理する経過時間の上限
const double MAX_FRAME_DELTA_TIME = 0.25;

// 1回の描画までに実行するGameUpdateの最大回数
const int MAX_UPDATE_COUNT = 5;

// 実際の1フレームで経過した時間
double g_frameDeltaTime = 0.0;

// ゲーム開始時に1回だけ実行する
void GameInitialize(void)
{
    // ====================================
    // ゲームの初期化処理を書く場所
    // ====================================
}

// キーボードやマウスの入力状態を更新する
void InputUpdate(void)
{
    // ====================================
    // 入力の更新処理を書く場所
    // ====================================
}

// ゲーム内部を更新する
void GameUpdate(double deltaTime)
{
    // ====================================
    // ゲームの更新処理を書く場所
    // ====================================

    /*
    シューティングゲームでの使用例

    playerX += playerSpeed * deltaTime;
    */

    // 現在はdeltaTimeを使用しないため、
    // 未使用の警告を防いでいる
    (void)deltaTime;
}

// ゲーム画面を描画する
void GameDraw(double interpolation)
{
    // ====================================
    // ゲームの描画処理を書く場所
    // ====================================

    /*
    interpolationは、
    前回の更新と次の更新の間の位置を表す。

    0.0以上1.0未満の値になり、
    後で描画位置を滑らかに補間するときに使用できる。
    */

    // 現在はinterpolationを使用しないため、
    // 未使用の警告を防いでいる
    (void)interpolation;
}

// ゲーム終了時に1回だけ実行する
void GameFinalize(void)
{
    // ====================================
    // 画像や音などの後始末を書く場所
    // ====================================
}

// 実際の1フレームで経過した時間を取得する
double GetFrameDeltaTime(void)
{
    return g_frameDeltaTime;
}

int WINAPI WinMain(
    HINSTANCE hInstance,
    HINSTANCE hPrevInstance,
    LPSTR lpCmdLine,
    int nCmdShow)
{
    LONGLONG previousTime;
    double accumulator;
    int exitRequested;

    // ウィンドウモードで起動する
    ChangeWindowMode(TRUE);

    // 画面の大きさと色数を設定する
    SetGraphMode(
        SCREEN_WIDTH,
        SCREEN_HEIGHT,
        32);

    /*
     * ScreenFlipを実行したときに、
     * モニターの垂直同期を待つ。
     *
     * この設定はDxLib_Initより前に行う。
     */
    SetWaitVSyncFlag(TRUE);

    // DxLibを初期化する
    if (DxLib_Init() == -1)
    {
        return -1;
    }

    // 描画先を裏画面にする
    SetDrawScreen(DX_SCREEN_BACK);

    // ゲームを初期化する
    GameInitialize();

    // 時間計測を開始する
    previousTime = GetNowHiPerformanceCount();

    // まだゲーム内部で処理していない経過時間
    accumulator = 0.0;

    // 終了要求
    exitRequested = FALSE;

    // メインループ
    while (ProcessMessage() == 0)
    {
        LONGLONG currentTime;
        int updateCount;
        double interpolation;

        // ESCキーが押されたら終了する
        if (CheckHitKey(KEY_INPUT_ESCAPE) != 0)
        {
            break;
        }

        // 現在時刻を取得する
        currentTime = GetNowHiPerformanceCount();

        /*
         * 前回から実際に経過した時間を秒で求める。
         *
         * GetNowHiPerformanceCountの値は
         * マイクロ秒単位なので、1000000.0で割る。
         */
        g_frameDeltaTime =
            (currentTime - previousTime) / 1000000.0;

        // 次の計測に備えて現在時刻を保存する
        previousTime = currentTime;

        /*
         * ウィンドウ移動やデバッグ停止などで
         * 長時間止まった場合の対策。
         *
         * 0.25秒より長い経過時間は、
         * 0.25秒として処理する。
         */
        if (g_frameDeltaTime > MAX_FRAME_DELTA_TIME)
        {
            g_frameDeltaTime = MAX_FRAME_DELTA_TIME;
        }

        // 実際に経過した時間をためる
        accumulator += g_frameDeltaTime;

        // この描画までに実行したUpdateの回数
        updateCount = 0;

        /*
         * 1/60秒以上たまっている間、
         * ゲーム内部を1/60秒ずつ進める。
         */
        while (
            accumulator >= FIXED_DELTA_TIME &&
            updateCount < MAX_UPDATE_COUNT)
        {
            // 入力状態を更新する
            InputUpdate();

            // ゲーム内部を固定時間だけ進める
            GameUpdate(FIXED_DELTA_TIME);

            // 処理した時間を引く
            accumulator -= FIXED_DELTA_TIME;

            updateCount++;
        }

        /*
         * 処理落ちが大きすぎる場合、
         * 遅れをすべて取り戻そうとすると、
         * GameUpdateを何度も実行し続ける可能性がある。
         *
         * 最大回数まで更新しても遅れている場合は、
         * 残った遅れを破棄する。
         */
        if (
            updateCount == MAX_UPDATE_COUNT &&
            accumulator >= FIXED_DELTA_TIME)
        {
            accumulator = 0.0;
        }

        /*
         * 前回の更新から次の更新までの間を、
         * どこまで進んでいるか求める。
         *
         * 結果は0.0以上1.0未満になる。
         */
        interpolation =
            accumulator / FIXED_DELTA_TIME;

        // 裏画面を黒で消す
        ClearDrawScreen();

        // ゲーム画面を描画する
        GameDraw(interpolation);

        // 裏画面を表画面へ表示する
        if (ScreenFlip() == -1)
        {
            exitRequested = TRUE;
        }

        if (exitRequested == TRUE)
        {
            break;
        }
    }

    // ゲームで使用したデータを後始末する
    GameFinalize();

    // DxLibを終了する
    DxLib_End();

    return 0;
}

7.実行して確認する

プログラムを実行し、次のことを確認してください。

  • 800×600の黒いウィンドウが表示される
  • ウィンドウがすぐに閉じない
  • ESCキーを押すと終了する
  • ウィンドウ右上の閉じるボタンでも終了できる
  • コンパイルエラーが発生していない

黒いウィンドウが表示されれば、ゲーム制作の準備は完了です。


8.最初は変更しない場所

次の処理は、ゲームループと時間管理を行う部分です。

while (ProcessMessage() == 0)
{
    // 時間の計測
    // 固定タイムステップによる更新
    // 画面の描画
}

HIT&BLOWを作る段階では、このメインループの中身を直接変更しません。

主に、次の5つの関数へ処理を追加します。

void GameInitialize(void);
void InputUpdate(void);
void GameUpdate(double deltaTime);
void GameDraw(double interpolation);
void GameFinalize(void);

ゲームが変わっても、メインループはできるだけ変更せずに使用します。


9.deltaTimeの使用例

シューティングゲームで、右キーを押している間、自機を右へ移動させる場合は次のように書きます。

double playerX = 100.0;
const double PLAYER_SPEED = 240.0;

void GameUpdate(double deltaTime)
{
    if (CheckHitKey(KEY_INPUT_RIGHT) != 0)
    {
        playerX += PLAYER_SPEED * deltaTime;
    }
}

PLAYER_SPEEDは、1秒間に移動するピクセル数です。

PLAYER_SPEED = 240.0

ならば、1秒間に約240ピクセル移動します。

移動処理を、

playerX += PLAYER_SPEED;

とは書きません。

これでは、1回の更新ごとに240ピクセル移動してしまいます。

playerX += PLAYER_SPEED * deltaTime;

と書くことで、経過時間に応じた移動量になります。


10.実際の経過時間を確認する

実際の1フレームで経過した時間は、次の関数で取得できます。

double frameDeltaTime;

frameDeltaTime = GetFrameDeltaTime();

実測した描画間隔やFPSを確認するときに使用できます。

例えば、次の処理をGameDrawへ一時的に書くと、実測したFPSを表示できます。

void GameDraw(double interpolation)
{
    double frameDeltaTime;
    double fps;

    frameDeltaTime = GetFrameDeltaTime();

    if (frameDeltaTime > 0.0)
    {
        fps = 1.0 / frameDeltaTime;

        DrawFormatString(
            10,
            10,
            GetColor(255, 255, 255),
            "描画FPS:%.1f",
            fps);
    }

    (void)interpolation;
}

この値は、モニターのリフレッシュレートやコンピュータの処理状況によって変化します。

ゲーム内の移動処理には、実測したframeDeltaTimeではなく、GameUpdateへ渡される固定のdeltaTimeを使用します。


11.次のチャプターから書き足す場所

次のチャプターでは、このテンプレートを使ってHIT&BLOWゲームを作ります。

最初は、GameDrawへ文字を1行追加します。

void GameDraw(double interpolation)
{
    DrawString(
        280,
        250,
        "HIT & BLOW",
        GetColor(255, 255, 255));

    (void)interpolation;
}

実行すると、黒いウィンドウへHIT & BLOWと表示されます。

その後、次の機能を少しずつ追加します。

タイトルを表示する
        ↓
数字の入力枠を描く
        ↓
数字ボタンを描く
        ↓
マウスで数字を入力する
        ↓
HITとBLOWを判定する
        ↓
入力履歴を表示する
        ↓
ゲームを完成させる

テンプレートを実行して黒いウィンドウが表示されれば、次のチャプターへ進んでください。