283 Views
December 21, 25
スライド概要
C++breaktime 2025/Winter で発表したものです
Breakoutのソースコードは
https://github.com/Kazaoka
にあります
Windowsのゲームの作り方 初心に帰って作ってみました。
自己紹介 風岡秀明(かざおか ひであき) ● 大手ゲーム会社で C++ を中心に開発 ● 週末はのんびりスケートボード ● たまに元ジャニの推し活に付き合う(嫌々)
C++勉強会なのに・・・ 内容がWindows SDK寄りです。 標準入出力だけでゲームを作るのは、 さすがにムズいっす。 ※C++のコードはちゃんと出てきますのでご安心ください ################ # # # [] [] [] [] [] [] [] [] # # # # # # () # # # # # # === # # # ################
グラフィックス表現について ブロック崩しを作るにあたって、 見た目をどう表現するかが課題でした。 描画ライブラリを使う方法もありますが、 今回はちょっと変わったアプローチを試しました。
ウインドウは形を変えられます SetWindowRgnを使えば、ウインドウの 形を自由に変えられます。
ウインドウは形を変えられます Regionとは、ウインドウの表示領域を定義する図形情報 SetWindowRgn ウインドウの表示領域を指定したリージョンに設定します。リー ジョン外は描画されず、ウインドウの形を変えられます。 CreateRectRgn 矩形のリージョンを作成します。四角いウインドウ形状を作ると きに利用。 CreateRoundRectRgn 角の丸い矩形リージョンを作成します。丸みを帯びたウインドウ 形状を作るときに便利。 CombineRgn 複数のリージョンを結合・差分・交差させて新しいリージョンを 作成します。複雑な形状を作るときに使用。 GetWindowRgn 現在設定されているウインドウリージョンを取得します。
ブロック崩しのためのウインドウ ● ボール ● パドル ● ブロック ● 得点表示 (一桁)
ブロックの形状設定プログラム
class WindowBlocks : public Window {
std::array<uint16_t, num_block.cy> blocks_; uint16_t:1行分の残りブロック
void ApplyBlocks() {
auto rgn = CreateRectRgn(0, 0, 0, 0);
for(int y=0; y<blocks_.size(); y++) {
auto bits = blocks_[y];
for (int x = 0; x < 16; x++, bits>>=1U) {
if (!(bits & 1)) continue;
auto rgn_add = RgnBlock(x, y);
rgn = RgnOr(rgn, rgn_add);
}
}
SetWindowRgn(Handle(), rgn, TRUE);
DeleteObject(rgn);
}
public:
int GetRemain() const {
int count = 0;
std::popcount(bits) は bitsのONになっている
for (auto bits : blocks_) {
count += std::popcount(bits);
ビットの数を数えて返します
}
C++20 で<bit>に含まれます
return count;
}
数字の形状設定プログラム
class WindowNumber : public Window {
int number_ = 0;
void ApplyNumber() {
auto number = number_;
number %= 10;
static uint16_t s_arr_pattern[] = {
0b11111’10001’11111,
0b00000’11111’00000,
0b10111’10101’11101,
0b11111’10101’10101,
0b11111’00100’00111,
0b11101’10101’10111,
0b11101’10101’11111,
0b11111’00001’00001,
0b11111’10101’11111,
0b11111’10101’10111,
};
10111
10101
11111
auto rgn = CreateRectRgn(0, 0, 0, 0);
auto pattern = s_arr_pattern[number];
for (int x = 0; x < 3; x++) {
for(int y=0; y<5; y++) {
if (pattern & 1) {
auto rgn_add = RgnDot(x,
y);
rgn = RgnOr(rgn, rgn_add);
}
pattern >>= 1;
}
}
SetWindowRgn(Handle(), rgn, TRUE);
DeleteObject(rgn);
}
public:
マウス操作の実装 WM_MOUSEMOVEではなく WM_INPUTを使ったマウス操作の入力
RAWINPUTによるマウス操作の実装 RAWINPUTとは 従来のマウス入力の課題 ● ● ● WM_MOUSEMOVE メッセージ:OS側でマウスカーソル位置に変換 カーソルの加速度が適用される ゲーム(特にパドル操作)には不向き RAWINPUTの特徴 ● ● ● ● デバイスからの生データを直接取得 マウス移動の**相対変位(delta)**を獲得 カーソル加速度の影響を受けない 低遅延で高精度な入力が可能
MouseOperateクラスの構造 class MouseOperate { int delta_x_ = 0; // マウスの移動量を蓄積 public: void WmInput(HRAWINPUT hRawInput); // WM_INPUTメッセージ処理 int DeltaXPop(); // 蓄積した移動量を取得してリセット MouseOperate(HWND hwnd); // 初期化:RAWINPUTデバイス登録 }; MouseOperate(HWND hwnd) { RAWINPUTDEVICE rid; rid.usUsagePage = 0x01; // HID汎用ページ rid.usUsage = 0x02; // マウス rid.dwFlags = RIDEV_INPUTSINK; // フォーカス外でも受信 rid.hwndTarget = hwnd; RegisterRawInputDevices(&rid, 1, sizeof(rid)); }
入力処理の流れ
1. WM_INPUTメッセージ受信
(HRAWINPUT)lParamとキャストしてHRAWINPUTを取得する
2. GetRawInputData()でデータ取得
まずデータサイズを取得し、次に実データを取得
GetRawInputData(hRawInput, RID_INPUT, NULL, &dwSize, sizeof(RAWINPUTHEADER));
3. lLastXから移動量を抽出し、delta_x_に累積
raw->data.mouse.lLastXにX軸の移動量(マウスカウント)が格納
RAWINPUT* raw = (RAWINPUT*)lpb;
if (raw->header.dwType == RIM_TYPEMOUSE) delta_x_ += raw->data.mouse.lLastX;
4. ゲームループで一括取得
int DeltaXPop() { auto delta_x = delta_x_; delta_x_ = 0; return delta_x; }
メインフロー C++コルーチンを使った順次処理
コルーチンによるゲームフロー実装 概要 このブロック崩しでは、C++20のコルーチン機能を活用して、ゲーム全体の流れを 自然な順次処理として記述しています。 主な特徴 ● メッセージループからのコールバックを意識させない設計 ● ゲーム要素をローカル変数として管理(new/delete不要) ● ステートマシンを使わずに直感的なフローを実現
従来の問題点 コールバック地獄 // 従来のWindowsプログラミング LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { static int state = STATE_TITLE; static Ball* ball = nullptr; static Paddle* paddle = nullptr; switch(msg) { case WM_TIMER: switch(state) { case STATE_TITLE: if(/* 条件 */) state = STATE_PLAYING; break; case STATE_PLAYING: // ゲームロジック散在 break; case STATE_GAMEOVER: // 後始末 break; } break; } }
コルーチンによる解決
自然な記述
static Coroutine GameMain(Window* window) {
BeepControl beep_control;
// 初期化
WindowMouseOperate window_mouse_operate;
Window window_frame{ /* ... */ };
while(true) {
// タイトル待機
while((GetAsyncKeyState(VK_LBUTTON) & 0x8000)==0) {
if (window_frame.ShouldClose()) co_return; // ゲーム終了
paddle_.Move(mouse_operate->DeltaXPop());
co_await std::suspend_always{}; // 1フレーム待機
}
// ゲームプレイ
for (int cnt_ball = 3; 0 < cnt_ball; cnt_ball--) {
Ball ball{ /* ... */ }; // ローカル変数!
do {
// ゲームループ
ball.Move();
co_await std::suspend_always{};
} while (ball.Pos().y < size_frame.cy * size_pixel.cy);
}
}
}
Coroutine型の実装
struct Coroutine {
struct promise_type {
Coroutine get_return_object() {
return
Coroutine{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle_;
bool resume() {
if (!handle_ || handle_.done()) return false;
handle_.resume();
return !handle_.done();
}
};
WindowWithCoroutineクラス
template<auto coroutine>
class WindowWithCoroutine : public Window {
CoroutinePtr coroutine_;
static LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) {
auto window_with_coroutine = /* ... */;
switch (uMsg) {
case WM_TIMER:
if (window_with_coroutine->Step()) return 0; // まだ続く
KillTimer(hwnd, TIMER_FRAME); // 終了
return 0;
}
return DefWindowProc(hwnd, uMsg, wParam, lParam);
}
public:
bool Step() const {
if (coroutine_) {
if (coroutine_->resume()) return true; // 継続
coroutine_.reset(); // 終了
}
return false;
}
};
ローカル変数管理の利点 GameMain内のオブジェクト static Coroutine GameMain(Window* window) { // すべてローカル変数として宣言 BeepControl beep_control; // スタック上 WindowMouseOperate window_mouse_operate; // スタック上 Window window_frame{ /* ... */ }; // スタック上 WindowBlocks window_blocks(&window_frame); // スタック上 for (int cnt_ball = 3; 0 < cnt_ball; cnt_ball--) { Ball ball{ /* ... */ }; // ループごとに生成 Window window_ball{ /* ... */ }; // 自動破棄 do { // ... } while (/* ... */); // スコープ終了で自動的にballとwindow_ballが破棄 } } 項目 従来の方法 コルーチン方式 メモリ管理 new/delete必要 自動管理 生存期間 手動管理 スコープで自動 初期化 NULLチェック必要 常に有効 エラー処理 メモリリーク危険 RAII保証
可読性の比較
ステートマシン方式
enum GameState { TITLE, PLAYING, GAMEOVER };
static GameState state = TITLE;
static int ball_count = 0;
void OnTimer() {
switch(state) {
case TITLE:
if(input.clicked()) {
state = PLAYING;
ball_count = 3;
}
break;
case PLAYING:
if(ball.out()) {
ball_count--;
if(ball_count == 0) {
state = GAMEOVER;
}
}
break;
}
}
コルーチン方式
while(true) {
while(!input.clicked()) co_await frame();
for(int balls = 3; balls > 0; balls--) {
do {
ball.update();
co_await frame();
} while(!ball.out());
}
}
サウンド 音声ファイルを使わないサウンド出力
BeepControlクラス BeepControlクラスは、ゲーム内の効果音をリアルタイムに生成・再生するシステム です。 主な特徴 ● ● ● ● WAV音源不要:数式から音を生成 低レイテンシ:Wave Audio APIを直接使用 軽量:外部ファイル依存なし 単純明快:100行以下の実装
BeepControlクラス
class BeepControl {
static constexpr int sampleRate = 44000;
// 44kHz
static constexpr int durationMs = 100;
// 100ミリ秒
static constexpr int samples = sampleRate * durationMs / 1000;
static constexpr double frequency = 880.0;
// A5 (ラの音)
std::vector<BYTE> buffer_;
HWAVEOUT hWaveOut_;
WAVEHDR hdr_ = {};
public:
void Play();
BeepControl();
~BeepControl();
};
// 波形データ
// 出力デバイス
// 波形ヘッダ
要素
値
理由
サンプルレ
ート
44kHz
CD音質、標準的
持続時間
100ms
短い効果音に最適
周波数
880Hz (A5)
明瞭で心地よい音
程
ビット深度
8bit
シンプル、軽量
音声波形の生成
BeepControl() : buffer_(samples) {
// サイン波の生成
for (int i = 0; i < samples; ++i) {
double t = static_cast<double>(i) / sampleRate;
// 基本式:sin(2πft)
buffer_[i] = static_cast<BYTE>(
127 * (sin(2 * 3.141592 * frequency * t) * 0.1 + 1)
);
}
// デバイスの初期化 ...
}
Wave Audio APIの初期化
// フォーマット設定
WAVEFORMATEX wfx = {};
wfx.wFormatTag = WAVE_FORMAT_PCM; // 非圧縮PCM
wfx.nChannels = 1;
// モノラル
wfx.nSamplesPerSec = sampleRate; // 44kHz
フィールド
wfx.wBitsPerSample = 8;
// 8bit
wfx.nBlockAlign = 1;
// 1サンプル = 1バイト
wFormatTag
wfx.nAvgBytesPerSec = sampleRate; // 44000バイト/秒
// デバイスを開く
waveOutOpen(&hWaveOut_, WAVE_MAPPER,
&wfx, 0, 0, CALLBACK_NULL);
// ヘッダ準備
hdr_.lpData =
reinterpret_cast<LPSTR>(buffer_.data());
hdr_.dwBufferLength =
static_cast<DWORD>(buffer_.size());
waveOutPrepareHeader(
hWaveOut_, &hdr_, sizeof(hdr_));
値
意味
WAVE_FORMAT_PC
M
非圧縮PCM形式
nChannels
1
モノラル
(ステレオは2)
nSamplesPerSec
44000
サンプリング周波数
wBitsPerSample
8
1サンプルあたり8bit
nBlockAlign
1
1サンプル = 1バイト
nAvgBytesPerSe
c
44000
データレート
再生処理 void Play() { if (hdr_.dwFlags & (WHDR_PREPARED|WHDR_DONE)) { waveOutWrite(hWaveOut_, &hdr_, sizeof(hdr_)); } } フラグの意味 ● ● ● WHDR_PREPARED: バッファが再生準備完了 WHDR_DONE: 前回の再生が完了 どちらかが立っていれば再生可能
後片付け ~BeepControl() { waveOutUnprepareHeader(hWaveOut_, &hdr_, sizeof(hdr_)); waveOutClose(hWaveOut_); } リソース解放の順序 1. UnprepareHeader: バッファの準備を解除 2. Close: デバイスを閉じる
ゲーム内での使用例 static Coroutine GameMain(Window* window) { BeepControl beep_control; // ローカル変数として生成 // ... ゲームループ内 ... // 衝突判定 bool bound = ball.BoundFrame(); if (window_blocks.Collision(&ball)) { bound = true; } if (/* パドルに当たった */) { bound = true; } // 何かに当たったら音を鳴らす if(bound) beep_control.Play(); // ← 効果音再生 }
以上です。ご清聴ありがとうございました
僕のスケボー紹介 ふつうの 32インチ Penny 22インチ フリースケート 6.85インチx2 Penny CASPER 36インチ サーフスケート Carver Greenroom 33インチ LOADED Bhangla 48.5インチ