LoginSignup
2
1

C++でゲームを作ろう!

Last updated at Posted at 2024-04-04

Visual StudioでC++を動かしてみよう

はじめに

こちらの記事は、C++をある程度理解している人に向けた記事です。ですので、「関数とは?」「クラスとは?」みたいなのは省略しています。
また、分かりやすさの為に説明を端折ったり、一部誤解を招きかねない表現がございます。もしも「この書き方は良くない」「これはこう書くべきでは?」のような指摘がありましたら、教えて頂ければと思います。

事前準備

まずはVisual Studioをインストールしてください。この際、「C++ によるデスクトップ開発」というワークロードも必要です。

プロジェクトの作成

インストールが完了したら、新規プロジェクトを作成します。上記のウェブサイトにも載っていますが、改めてこちらでも解説しておきます。

  1. まずは「空のプロジェクト」を作成します
  2. プロジェクト名を適当に付けます。下の写真では「Direct2D」としています
  3. 「ソースファイル」を右クリック、「追加」、「新しい項目」をクリックします
  4. ダイアログが出てくるので、「C++ファイル」を選択、「source.cpp」という名前でファイルを作成します

C++を書いてみる

C++をコンパイルできるか確認しましょう。以下のコードをコピペしてください。

コピペ出来たら、画面上部の「ローカルWindowsデバッガー」からプログラムを実行します。すると入力待ちになるので、自分の名前を入力してみましょう。すると"Hello, ○○!"と出力されます。

#include <iostream>
#include <string>

int main(void) {
  std::string name;
  std::cin >> name;
  std::cout << "Hello, " << name << "!\n";
}

チェックリスト

  • Visual Studioをインストールしましょう
  • 慣れるまでは「空のプロジェクト」を使ってプロジェクトを作成しましょう
空のプロジェクト以外の選択肢とWinMain関数について 「空のプロジェクト」以外にも「Windows デスクトップ アプリケーション」という選択肢があり、そちらにはひな形コードが付いています。そちらを利用してはいけないのかと言うご指摘をいただきましたので、少し解説します。

私は使っているバージョンでは、「Windows デスクトップ アプリケーション」で作成されるひな形コードにはmain関数がありません。代わりに、WinMain関数がエントリポイントになっています。

int APIENTRY wWinMain(HINSTANCE hInstance,
                     HINSTANCE hPrevInstance,
                     LPWSTR lpCmdLine,
                     int nCmdShow){……}

実は、GUIプログラミングを行う場合、こちらをエントリポイントにするべきです。そうでないと、GUIウィンドウと同時にコマンドプロンプトが立ち上がってしまい、見栄えが悪くなります。

しかしながら、これを使うと普段のようにprintfやstd::coutを使ったデバッグが出来ません。おそらく適切な設定を行えば何とかなると思うのですが、そういった事が面倒なので、Windows API初心者さんに向けて解説するときは、「空のプロジェクト」から開発するのをオススメしています。

GUIを作ろう

概略

C++が使えることを確認できたので、次はGUIを作ってみようと思います。Windowsにおいて、GUIアプリケーションを作るには三つのステップがあります。

  1. ウィンドウが行う処理を定義
    「ウィンドウが生成された」「マウスがクリックされた」「サイズが変更された」などの「メッセージ」が届いた際に、何を行うのか定義する関数を用意します。これをウィンドウプロシージャと言います。
  2. GUIを作りたいとOSに要請
    OSに対して「定義したウィンドウプロシージャを使ってウィンドウを作るので、確認お願いします」と伝える必要があります。これにはRegisterClassEx関数を用います。
  3. ウィンドウを作る
    ここまでの準備ができたら、最後にウィンドウを作ります。これにはCreateWindow関数を用います。

実際のコード

概略を理解したところで、実際のコードを見てみましょう。
まずは必要なライブラリをインクルードします。この二行をファイルの先頭に書いてください。

#define NOMINMAX
#include <Windows.h>

#define NOMINMAXはお守りです。これを書いておかないと後ほど大変なことになります。

ウィンドウプロシージャ

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
  if (msg == WM_DESTROY) {
    PostQuitMessage(0);
    return 0;
  } else {
    return DefWindowProcW(hwnd, msg, wp, lp);
  }
}

ここでは「もしもウィンドウが閉じられたら(if (msg == WM_DESTROY) {......})、アプリを終了してくださいPostQuitMessage(0);」と書いています。なお、DefWindowProcWはデフォルトの挙動を行ってくれる関数です。

「このLRESULT CALLBACKって何?」

msgはメッセージの種類って事みたいだけど、じゃあHWND hwndって何?」

など疑問があるかと思いますが、ひとまず全部「おまじない」だと思って飲み込んでください。

GUIを作りたいとOSに要請

続いてGUIを作りたいとOSに要請する……のですが、設定項目がものすごく多いです。あまりに多いので、ひとまず「ウィンドウのID」と「ウィンドウプロシージャ」だけ設定したら、他はデフォルト値を入れてくれる関数を作りましょう。

BOOL RegisterApplication(const wchar_t* window_id, WNDPROC window_proc) {
  WNDCLASSEXW wc;
  wc.cbSize = sizeof(WNDCLASSEXW);
  wc.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
  wc.lpfnWndProc = window_proc;
  wc.cbClsExtra = 0;
  wc.cbWndExtra = 0;
  wc.hInstance = GetModuleHandleW(NULL);
  wc.hIcon = (HICON)LoadIconW(NULL, IDI_APPLICATION);
  wc.hCursor = (HCURSOR)LoadCursorW(NULL, IDC_ARROW);
  wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
  wc.lpszMenuName = NULL;
  wc.lpszClassName = window_id;
  wc.hIconSm = (HICON)LoadImageW(NULL, IDI_APPLICATION, IMAGE_ICON, 0, 0,
                                 LR_DEFAULTSIZE | LR_SHARED);
  return (RegisterClassExW(&wc));
}

// 後ほどmain関数内で
RegisterApplication(L"ウィンドウID", WndProc);
//のように記述します。

設定項目、多いでしょう? 私は10年程この関数を使っていますが、未だに設定項目を覚えきれてません(笑)

というか、覚えるのは無理でしょう。コピペしちゃってOKです!

ちなみにwchar_t*はUnicode文字列であることを意味します。charは一文字1byteですが、wchar_t2byteになっています。

ウィンドウを作る

メイン関数の中身を以下のように書き換えます。

int main(void) {
  const wchar_t* windowid = L"MyFirstApp";
  if (!RegisterApplication(windowid, WndProc)) return 1;
  HWND hwnd = CreateWindowW(windowid, L"Title", WS_OVERLAPPEDWINDOW, 0, 0, 600,400, NULL, NULL, GetModuleHandleW(NULL), NULL);

  ShowWindow(hwnd, SW_SHOW);
  UpdateWindow(hwnd);

  // 以下、メッセージループ
  MSG msg;
  BOOL value;
  while (true) {
    value = GetMessageW(&msg, NULL, 0, 0);
    if (value == 0) break;
    if (value == -1) return 1;

    TranslateMessage(&msg);
    DispatchMessageW(&msg);
  }

  return msg.wParam;
}

L"MyFirstApp"のように文字列の前にLと書いていますね。これはUnicode文字列であることを意味しています。GUIプログラミングではL"文字列"と書くことを覚えておきましょう。

続いてRegisterApplicationしています。万が一失敗したらreturn 1;してアプリを閉じます。

次にCreateWindowWしていますね。ちなみに、CreateWindow「W」と末尾にWをつけているのは、Unicode文字列を使っていることを意味します。

相変わらず引数が多いですね……。一応軽く紹介しておきますね。

void CreateWindowW(
  WindowID,
  Windowのタイトル,
  Windowスタイル,
  Windowの左上のX座標,
  Windowの左上のY座標,
  Windowの幅,
  Windowの高さ,
  親ウィンドウ。今回はNULL,
  メニューの指定。今回はNULL,
  インスタンス。GetModuleHandleW(NULL)を指定しておけばよい,
  追加情報。基本的にNULLを指定
);

覚えなくて大丈夫です。必要になったときに、調べればよいでしょう。

次にShowWindow(hwnd, SW_SHOW);UpdateWindow(hwnd);しています。これらは「ウィンドウを表示」して、「更新」する関数です。

次に「メッセージループ」というものを書きます。詳しいことは省略、おまじないと思っておいてください。

全ソースコード
#define NOMINMAX
#include <Windows.h>

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
  if (msg == WM_DESTROY) {
    PostQuitMessage(0);
    return 0;
  } else {
    return DefWindowProcW(hwnd, msg, wp, lp);
  }
}

BOOL RegisterApplication(const wchar_t* window_id, WNDPROC window_proc) {
  WNDCLASSEXW wc;
  wc.cbSize = sizeof(WNDCLASSEXW);
  wc.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
  wc.lpfnWndProc = window_proc;
  wc.cbClsExtra = 0;
  wc.cbWndExtra = 0;
  wc.hInstance = GetModuleHandleW(NULL);
  wc.hIcon = (HICON)LoadIconW(NULL, IDI_APPLICATION);
  wc.hCursor = (HCURSOR)LoadCursorW(NULL, IDC_ARROW);
  wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
  wc.lpszMenuName = NULL;
  wc.lpszClassName = window_id;
  wc.hIconSm = (HICON)LoadImageW(NULL, IDI_APPLICATION, IMAGE_ICON, 0, 0,
                                 LR_DEFAULTSIZE | LR_SHARED);
  return (RegisterClassExW(&wc));
}

int main(void) {
  const wchar_t* windowid = L"MyFirstApp";
  if (!RegisterApplication(windowid, WndProc)) return 1;
  HWND hwnd = CreateWindowW(windowid, L"Title", WS_OVERLAPPEDWINDOW, 0, 0, 600,
                            400, NULL, NULL, GetModuleHandleW(NULL), NULL);

  ShowWindow(hwnd, SW_SHOW);
  UpdateWindow(hwnd);

  MSG msg;
  BOOL value;
  while (true) {
    value = GetMessageW(&msg, NULL, 0, 0);
    if (value == 0) break;
    if (value == -1) return 1;

    TranslateMessage(&msg);
    DispatchMessageW(&msg);
  }

  return msg.wParam;
}

なんと、たったの(?)50行でGUIアプリケーションを作れちゃいます!

チェックリスト

  • GUIアプリケーションを作るには3ステップ必要です
  • 1つ目のステップは「 ウィンドウプロシージャの作成」です
  • 2つ目のステップは「 ウィンドウの登録」です
  • 最後にウィンドウを作成します
  • 最後に メッセージループという物が必要です

ゲームを作ろう

続いて、簡単なゲームを作ってみようと思います。表示された物をクリックするだけのシンプル極まりないゲームを作っていきます。

Contextとは?

Contextを日本語訳すると何になりますか? そうです、「文脈」や「状況」という意味になります。この事はGUIプログラミングにおいても同じで、以下のような場面でContextという言葉が用いられます。

WndProc関数「なあ、Paint関数。円を描いてくれない?」

Paint関数「えーっと……。どこに描けばいいんです?」

WndProc関数「ああ、悪りぃ。俺が担当してるウィンドウがあるからさ。そこに円を描いて欲しいんだ」

Paint関数「ああ、そういう事ですか。承知しました!」

とまあこんな感じで、描画関数に対して「どこに描画するか」を教える「文脈」の事をContextと呼称します。

Contextを入手しよう

Contextの意味が分かったところで、それを取得する方法を知りましょう。と言ってもそう難しい事ではなく、BeginPaint関数を呼び出すだけです。

ウィンドウプロシージャを以下のように書き変えてください。

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
  if (msg == WM_PAINT) {
    PAINTSTRUCT ps;
    HDC hdc = BeginPaint(hwnd, &ps);
    // HDCはデバイスコンテキストハンドルという意味です。
    // つまり変数「hdc」がContextです。
    EndPaint(hwnd, &ps);
  }
  else if (msg == WM_DESTROY) {
    PostQuitMessage(0);
    return 0;
  } else {
    return DefWindowProcW(hwnd, msg, wp, lp);
  }
}

msg == WM_PAINTとは「描画が必要になった時」という意味です。そういえば説明してませんでしたが、WMは「ウィンドウメッセージ」の略です。

BeginPaint関数でコンテキストを取得し、EndPaint関数で描画完了を宣言します。EndPaintを忘れるとバグるのでご注意ください。

絵を描こう

では次にPaint関数を書いてみます。以下の関数を宣言してください。

void Paint(HDC hdc) {
  Ellipse(hdc, 0, 0, 100, 100);
  Rectangle(hdc, 50, 50, 150, 150);
}

Ellipse関数は楕円を描画する関数で、Rectangle関数は四角形を描画する関数です。

実行すると以下のような結果が得られるはずです。

枠線が黒、中身が白色の図形が描画されていますね!

MVCを理解しよう!

MVCとはModel-View-Controllerの略で、GUIプログラミングにおける美しいコードの書く方法を示しています。これは「プログラムを三つの成分に分けて作ろう」と言う意味です。

なお、C++におけるMVCとWeb開発におけるMVCは全く別物ですので、ご注意ください。ここではC++におけるMVCを説明します。

  • Model
    ソフトウェアのロジックと言う意味です。もっと分かりやすく言うと「これがWindows向けアプリだろうがMac向けだろうがAndroid向けだろうが変わらないプログラム」です。
  • View
    描画システムです。このプログラムで言う所のPaint関数にあたるでしょう。
  • Controller
    ModelとView、あるいはModelとModelを繋ぐものです。このプログラムで言う所のウィンドウプロシージャにあたるでしょう。

ここで「モデルとモデルは出来る限り互いを見られないようにする」という事を意識しましょう。あれにもこれにもアクセスできるようなプログラムはNGです。

その為に 階層構造を意識しましょう。例えばPlayerクラスとEnemyクラスが相互に値を参照し合うようなコードを書くと、敵の種類が増えたときにどえらい事になります。そうではなく、例えばWorldクラスのようなものを作って、それが仲介役をこなすようにしましょう。
スクリーンショット 2024-04-04 184227.png

今から作るような小規模なゲームではこんな事を意識しなくても良いかもしれません。しかし、大規模なゲームになると、綺麗なコードにしないと大変なことになります!

では、クリックゲームのプログラムを作ってみましょう。

ゲームのプログラム

インクルード

まずはライブラリをいくつか追加でインクルードします。

#include <windowsx.h>
#include <cmath>
#include <cstdlib>
#include <string>

構造体

続いて、クリックする的(Target)の情報を保存する構造体を定義します。的の種類(丸か四角か)、大きさ、場所に関する情報を保管する構造体です。

struct Target {
  // 0=rectangle: 1=ellipse
  int type;
  int size;
  int x;
  int y;
};

これはC++ですので、上記のように構造体を定義できます。C言語だともう少し複雑な書き方が必要だったのですが……。時代の進歩とは良いものですね!

Modelクラスの定義

次にModelを定義します。ここには「このゲームがWindowsだろうがMacだろうがAndroidだろうが変わらない、根本のシステム」を記述するのでしたね。

class Model {
 public:
  // コンストラクタ。的を設置し、スコアを0で初期化する。
  Model() {
    SetTarget();
    score_ = 0;
  }
  // クリックされたときの挙動を定義
  void Clicked(int x, int y) {
    if (target_.type == 0) {
      // 的が四角形の時のコード
      if (target_.x - target_.size < x && x < target_.x + target_.size &&
          target_.y - target_.size < y && y < target_.y + target_.size) {
        ++score_;
        SetTarget();
      } else {
        --score_;
      }
    } else {
      // 的が円形の時のコード
      double dx = static_cast<double>(x - target_.x);
      double dy = static_cast<double>(y - target_.y);
      if (std::sqrt(dx * dx + dy * dy) < target_.size) {
        ++score_;
        SetTarget();
      } else {
        --score_;
      }
    }
  }

  // ゲッター。プライベートな変数へのアクセス用。
  Target target() const { return target_; }
  int score() const { return score_; }

 private:
  // 的の形状、大きさ、場所を設定。std::rand()関数を使っている。
  void SetTarget() {
    target_.type = std::rand() % 2;
    target_.size = std::rand() % 10 + 40;
    target_.x = std::rand() % 500 + 50;
    target_.y = std::rand() % 300 + 50;
  }
  // 変数。クラス内の変数は全てprivateにする事!
  Target target_;
  int score_;
};

Viewクラスの定義

もはやクラスである必要性がないですが……まあせっかくなので(?)クラスにしておきました。

class View {
 public:
  void Paint(HDC hdc, Model& model) const {
    Target target = model.target();
    if (target.type == 0) {
      Rectangle(hdc, target.x - target.size, target.y - target.size,
                target.x + target.size, target.y + target.size);
    } else {
      Ellipse(hdc, target.x - target.size, target.y - target.size,
                target.x + target.size, target.y + target.size);
    }
    std::wstring text = L"Score:" + std::to_wstring(model.score());
    TextOutW(hdc, 0, 0, text.c_str(), text.length());
  }
};

Controllerクラスの定義

内部にModelとViewを持っていて、それらを操作しています。

class Controller {
 public:
  bool ProcessMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
    if (msg == WM_PAINT) {
      PAINTSTRUCT ps;
      HDC hdc = BeginPaint(hwnd, &ps);
      view_.Paint(hdc, model_);
      EndPaint(hwnd, &ps);
      return true;
    } else if (msg == WM_LBUTTONUP) {
      // クリックされたときの挙動
      // GET_X_LPARAM(lp)でクリック場所のX座標が、
      // GET_Y_LPARAM(lp)でクリック場所のY座標が分かります。
      model_.Clicked(GET_X_LPARAM(lp), GET_Y_LPARAM(lp));

      // 画面をリフレッシュします。
      InvalidateRect(hwnd, nullptr, TRUE);
      return true;
    }
    
    // 何もしなかったらfalseを返します。
    return false;
  }
 private:
  Model model_;
  View view_;
};

ウィンドウプロシージャの更新

Controllerを使ったものに書き換えます。

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
  static Controller controller;
  if (controller.ProcessMessage(hwnd, msg, wp, lp)) return 0;
  
  if (msg == WM_DESTROY) {
    PostQuitMessage(0);
    return 0;
  } else {
    return DefWindowProcW(hwnd, msg, wp, lp);
  }
}
全ソースコード
#define NOMINMAX
#include <Windows.h>
#include <windowsx.h>

#include <cmath>
#include <cstdlib>
#include <string>

struct Target {
  // 0=rectangle: 1=ellipse
  int type;
  int size;
  int x;
  int y;
};

class Model {
 public:
  Model() {
    SetTarget();
    score_ = 0;
  }
  void Clicked(int x, int y) {
    if (target_.type == 0) {
      if (target_.x - target_.size < x && x < target_.x + target_.size &&
          target_.y - target_.size < y && y < target_.y + target_.size) {
        ++score_;
        SetTarget();
      } else {
        --score_;
      }
    } else {
      double dx = static_cast<double>(x - target_.x);
      double dy = static_cast<double>(y - target_.y);
      if (std::sqrt(dx * dx + dy * dy) < target_.size) {
        ++score_;
        SetTarget();
      } else {
        --score_;
      }
    }
  }
  Target target() const { return target_; }
  int score() const { return score_; }
 private:
  void SetTarget() {
    target_.type = std::rand() % 2;
    target_.size = std::rand() % 10 + 40;
    target_.x = std::rand() % 500 + 50;
    target_.y = std::rand() % 300 + 50;
  }
  Target target_;
  int score_;
};

class View {
 public:
  void Paint(HDC hdc, Model& model) const {
    Target target = model.target();
    if (target.type == 0) {
      Rectangle(hdc, target.x - target.size, target.y - target.size,
                target.x + target.size, target.y + target.size);
    } else {
      Ellipse(hdc, target.x - target.size, target.y - target.size,
                target.x + target.size, target.y + target.size);
    }
    std::wstring text = L"Score:" + std::to_wstring(model.score());
    TextOutW(hdc, 0, 0, text.c_str(), text.length());
  }
};

class Controller {
 public:
  bool ProcessMessage(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
    if (msg == WM_PAINT) {
      PAINTSTRUCT ps;
      HDC hdc = BeginPaint(hwnd, &ps);
      view_.Paint(hdc, model_);
      EndPaint(hwnd, &ps);
      return true;
    } else if (msg == WM_LBUTTONUP) {
      model_.Clicked(GET_X_LPARAM(lp), GET_Y_LPARAM(lp));
      InvalidateRect(hwnd, nullptr, TRUE);
      return true;
    }
    return false;
  }
 private:
  Model model_;
  View view_;
};

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
  static Controller controller;
  if (controller.ProcessMessage(hwnd, msg, wp, lp)) return 0;
  
  if (msg == WM_DESTROY) {
    PostQuitMessage(0);
    return 0;
  } else {
    return DefWindowProcW(hwnd, msg, wp, lp);
  }
}

BOOL RegisterApplication(const wchar_t* window_id, WNDPROC window_proc) {
  WNDCLASSEXW wc;
  wc.cbSize = sizeof(WNDCLASSEXW);
  wc.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
  wc.lpfnWndProc = window_proc;
  wc.cbClsExtra = 0;
  wc.cbWndExtra = 0;
  wc.hInstance = GetModuleHandleW(NULL);
  wc.hIcon = (HICON)LoadIconW(NULL, IDI_APPLICATION);
  wc.hCursor = (HCURSOR)LoadCursorW(NULL, IDC_ARROW);
  wc.hbrBackground = (HBRUSH)GetStockObject(WHITE_BRUSH);
  wc.lpszMenuName = NULL;
  wc.lpszClassName = window_id;
  wc.hIconSm = (HICON)LoadImageW(NULL, IDI_APPLICATION, IMAGE_ICON, 0, 0,
                                 LR_DEFAULTSIZE | LR_SHARED);
  return (RegisterClassExW(&wc));
}

int main(void) {
  const wchar_t* windowid = L"MyFirstApp";
  if (!RegisterApplication(windowid, WndProc)) return 1;
  HWND hwnd = CreateWindowW(windowid, L"Title", WS_OVERLAPPEDWINDOW, 0, 0, 600,
                            400, NULL, NULL, GetModuleHandleW(NULL), NULL);

  ShowWindow(hwnd, SW_SHOW);
  UpdateWindow(hwnd);

  MSG msg;
  BOOL value;
  while (true) {
    value = GetMessageW(&msg, NULL, 0, 0);
    if (value == 0) break;
    if (value == -1) return 1;

    TranslateMessage(&msg);
    DispatchMessageW(&msg);
  }

  return msg.wParam;
}

実行すると、以下のようにクリックゲームが始まります。なんて楽しいゲームなんだー(棒)
スクリーンショット 2024-04-04 194553.png

チェックリスト

  • グラフィックにおけるContextとは「描画対象」です
  • Model、View、Controllerに分けてプログラムを組むと、綺麗なコードを実現できます
  • Modelはソフトウェアの基本的な概念を、Viewは描画処理を、Controllerはその仲介役です
2
1
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1