GameDev #1 ゲームプログラミング [Devlog #014]

Table of Contents

ゲーム開発者の教科書:Game Programming in C++ を読んで理解したことについてを要約します(内容の転載を避け、詳しく説明しすぎないように配慮します)

ゲームプログラミング in C++


環境構築

VisualStudio2022でプロジェクトの作成

  • (新規作成->プロジェクト->)新しいプロジェクトの作成->空のプロジェクト
  • プロジェクト名(ソリューション名)と保存パスを指定
    • “ソリューションとプロジェクトを同じディレクトリに配置する"にチェック
  • 作成
  • SDL等のライブラリを設置($(SolutionDir)\External\フォルダ以下)
  • ソリューションのプロパティを変更(参考サイト
    • インクルードディレクトリの指定
      • 構成プロパティ->C/C++->全般->追加のインクルードディレクトリ(注意点
    • 追加のライブラリディレクトリの指定
      • 構成プロパティ->リンカー->全般->追加のインクルードディレクトリ
    • 追加の依存ファイルの指定
      • 構成プロパティ->リンカー->入力->追加の依存ファイル
    • DLLをビルド時にコピー
      • 構成プロパティ->ビルドイベント->ビルド後のイベント->コマンドライン

※サンプルソースコードではプラットフォームをWin32に設定しているので注意

ゲームプログラミング

ゲームループとゲームクラス

ゲームループ(game loop)は、ゲームプログラム全体の流れを制御するループである

シングルスレッドのゲームループに対して、マルチスレッドのゲームループは複雑になる

フレームの中身

ゲームは各フレームで次の3つのステップを実行する

  1. 入力があれば処理する
    • 例:デバイス入力、インターネット経由で受信したデータ、カメラ映像、GPS情報
  2. ゲームワールドを更新する
    • 例:キャラクター、ユーザインターフェースのパーツ、ゲームに与える全ての要素
  3. 出力するものを生成する
    • 例:グラフィクス、オーディオ、フォースフィードバック(振動)、インターネット経由で送信するデータ

ゲームクラスの骨組みを実装する

Game.h
Gameクラス

Initialize()関数はGameクラスを初期化する

RunLoop()関数はゲームループを実行する

Shutdown()関数はゲームを終わらせる

ProcessInput()関数、UpdateGame()関数、GenerateOutput()関数はゲームループの3つのステップに対応する

mIsRunningは、ゲームループを続行するか否かを示すフラグ

Game.cpp
Game::Initialize()

SDL_Init()関数は、SDLライブラリを初期化する

今のところ、初期化する必要があるのはビデオサブシステムだけ(SDL_INIT_VIDEO

SDL_Log("Unable to initialize SDL: %s", SDL_GetError())は、SDLでメッセージをコンソールに出力する簡単な方法である

SDL_GetError()関数は、エラーメッセージを文字列で返す

SDL_CreateWindow()関数でウィンドウを作る

ウィンドウ作成にはフラグを設定できる(SDL_WINDOW_FULLSCREENでフルスクリーンモードを使う)

Game::RunLoop()

もしmIsRunningがfalseになったら繰り返しをやめる

Game::Shutdown()

SDL_DestroyWindow()関数を使ってSDL_Windowを破棄する

SDL_Quit()でSDLを終わらせる

メイン関数

main.cpp

どんなC++プログラムでも入り口はmain関数である

最初にGameクラスのインスタンスを作る

ゲームを初期化(game.Initialize())出来たら、game.RunLoop()を呼び出してゲームループに入る

ループが終わったらgame.Shutdown()を呼び出してゲームを終わらせる

基本的な入力処理

SDLでは、OSから受け取ったイベントを内部のキューで管理する

フレームごとにイベントがないかキューを調べて、キューに入っている個々のイベントについて処理を行う

ウィンドウを動かす操作などのイベントは、(ただ無視するだけで)SDLが自動的に処理する

Game.cpp
Game::ProcessInput()

イベントも一種の入力なので、イベント処理はGame::ProcessInput()関数の中で実装する

SDL_PollEvent()関数は、キューにイベントがあればtrueを返す(Game::ProcessInput()関数はこのtrueが返される限り、SDL_PollEvent()を呼び出す

SDL_Eventtypeメンバ変数には、受け取ったイベントの種類が入り、ここではSDL_QUITイベントを受け取るとmIsRunningfalseを設定する(RunLoop()関数内部のwhileループを停止させる)


SDL_GetKeyboardState(NULL)関数は、キーボードの現在の状態が格納された配列へのポインタを返す

この配列のインデックス参照で特定のキーを確認できる(ここではSDL_SCANCODE_ESCAPEでエスケープキーが押されたかを確認する)

基本的な2Dグラフィクス

ほとんどのディスプレイはラスターグラフィクスを使っており、最終的にほとんどのコンピュータゲームはピクセルごとにRGBの色を出力している

カラーバッファ

メモリ内のカラーバッファに画面全体の色情報が置かれ、ゲームループの"出力生成"の段階で毎フレーム書き込まれる

一般的な色深度は24ビットで、RGBの各要素に8ビット使われる(アルファも含めると色深度は32ビットになる)

各画素4バイトの場合、1080pで約7.9MB

最近では、RGBの各画素に16ビット使うゲームもある

色成分の値をコードで参照する方法として、各チャンネルの値を0~255の符号なし整数とするか0.0~1.0の小数に正規化するかの2種類がある(SDLライブラリは符号なし整数値を期待する)

ダブルバッファ

画面のティアリングを防止するため、フロントバッファとバックバッファを用いるダブルバッファと呼ばれる方法を利用する

ゲームがフロントバッファとバックバッファを交換する際に、バックバッファへの書き込みが完了するまで待機するアプローチを垂直同期(vsync)と呼ぶ

基本的な2Dグラフィクスの実装

Game.cpp
Game::Initialize()

SDL_CreateRenderer()関数でSDLレンダラーを作成し、SDL_Rendererオブジェクトを参照してレンダラーに描画させるようにする

Game::Shutdown()

SDL_DestroyRenderer()関数を呼び出す


描画の全体的な流れは次の3つのステップとなる

  1. バックバッファを単色でクリアする
  2. ゲームのシーン全体を描画する
  3. フロントバッファとバックバッファを交換する
Game::GenerateOutput()

SDL_SetRenderDrawColor()関数で、バックバッファをクリアする描画色を指定する

SDL_RenderClear()関数で、バックバッファを現在の描画色でクリアする

ゲームシーン全体の描画は後で実装するとして、

最後にSDL_RenderPresent()関数で、フロントバッファとバックバッファを交換する

壁とボールとパドルの描画

Game::GenerateOutput()

SDLで塗りつぶされた長方形を描くには、SDL_RenderFillRect()関数を使う

Game.h

xyの両成分を持つ単純なVector2構造体を宣言し、

Game.cpp
Game::Initialize()

Gameクラスにパドルの位置mPaddlePosとボールの位置mBallPosを追加する

長方形と同様に、SDL_RenderFillRect()関数を使ってパドルを描画する

ゲームの更新

ゲームループでは、1秒間に何度も実行され、離散的な時間のステップでゲームを更新する

実時間とゲーム時間

経過する実時間とゲームの世界で経過するゲーム時間とを区別することが重要

デルタタイムの関数としてのロジック

デルタタイムは、最後のフレームから今までに経過した時間の長さを表す

たとえば、理想的なスピードを毎秒150ピクセルとすると、

enemy.mPosition.x += 150 * deltaTime;

のように、プロセッサの速度に依存しない柔軟性の高いコードがかける

Game.cpp
Game::UpdateGame()

SDLは、SDL_Init()関数の呼び出しから経過した時間をミリ秒単位で返すSDL_GetTicks関数を提供している

次のフレームのために、メンバー変数mTicksCountの時刻を更新する

想定よりも早くフレーム処理が完了すると問題が発生する場合(物理法則で動くゲームなど)において、フレーム制限を実装するためにSDL_TICKS_PASSED()関数を利用する

デバッグ時のブレークポイントで発生する停止時間によって、巨大なデルタタイムが発生するのを防ぐために、デルタタイムの値に最大値を設定する

パドルの位置を更新する

Game.h
Game.cpp
Game::ProcessInput()

メンバ変数mPaddleDirは、パドルが動かないなら0、上に移動するなら-1(負のy)、下に移動するなら1(正のy)をセットする(こうすることで、両方のキーを押したら0になる)

Game::UpdateGame()

デルタタイムに基づいてパドルのy座標位置を更新する(毎秒300.0fピクセル)

パドルを画面内におさめるために、パドルのy座標に境界条件の制約を加える(paddleHはパドルの高さを示す定数)

ボールの位置を更新する

ボールの動きを示すには速度衝突検知が必要

Game.h
Game.cpp
Game.Initialize()

Vector2変数のメンバ変数mBallVelを追加し、初期化する

Game.UpdateGame()

ボールの位置を速度に応じて動かす(mBallPos.xmBallPos.yを更新する)

ボールが壁に衝突する際は、速度成分を反転させる(反転の更新がその場で繰り返されてボールが壁にくっつく問題は、速度方向に状態の制約をかけることで解決する)

ボールがパドルに衝突するかは、x位置とy位置とx速度の条件によって判定する

ゲームプロジェクト

単純化したPong(ポン)ゲームが完成

参考文献

Game Engine Architecture

SDL API Reference Guide

SDL APIのリファレンス