C++標準ライブラリとWindows.hでコマンドプロンプトで動くブロック崩しを作った話


この記事は 超初心者 ゲーム制作 Advent Calendar 2017 (リンク切れ)の24日目の記事です。

動機

カレンダー作成者に「記事を書けない日があるから埋めてほしい」と頼まれ、何か作ってカレンダーに参加することを考えました。しかし、卒業研究が思いのほか忙しくて製作時間を取れなかったため、今年の2・3月頃に学校の課題で作成したものを少し手直ししたもので記事を書くことにしました。

そもそもなぜそんなものを作った?

学校の某科目で (おそらく) C言語に慣れる趣旨の課題を課されました。この成果物の要件は、以下の4つでした。

  • 何かしらのゲーム的なもの (アクションでなくて良い)
  • Windowsのコマンドプロンプトで遊べる
  • 標準的なライブラリのみで作られている
  • C (またはC++) で記述されている

普通じゃなくて少し面白いものを作りたいと思った私は、この縛りの中でブロック崩しを作ってみようと思いました。

成果物

DropBox よりダウンロードしてください。BrockCrusher.exeが本体です。

通常のWindowsの環境であれば、問題なく動作すると思われますが、突貫工事での実装なので当たり判定が残念です。詳細な遊び方については同梱されているREADME.pdfを参照してください。

技術的な話

以下、技術的な話をします。ゲームのメインとなるロジック部の説明は今回は省略して、コマンドプロンプトで遊べるブロック崩しを実現させるために行ったことについて説明していきます。

開発環境は、Windows10 (64bit) 上のVisualStudio2017です。

キー入力の検出

本ゲームでは、すべての操作をキーボードで行うことにしました。したがって、キー入力を検出して処理を行う必要があったため、 GetAsyncKeyState を利用しました。

この関数は、仮想キーコードに対応するキーが押されているかどうかを返すため、監視したいキーについて毎フレーム呼び出すことでキー入力を検出することができます。

SHORT const state { GetAsyncKeyState(VK_UP) }; // 上矢印キーの状態を得る。

if (state & 0x0001 == 0x0001) { hoge(); } // 前回の関数呼び出し以降に押された場合。

if (state & 0x8000 == 0x8000) { hage(); } // 現在押されている場合。

画面表示

ゲーム画面の表示

通常の文字列で表現したスコアなどの情報と、特定の文字で各物体を表現した盤面で構成されたゲーム画面をコマンドプロンプトに約60fpsで表示することにしました。

コマンドプロンプトにゲーム画面を表示する方法として真っ先に思いつくのは、毎フレームsystem("cls")で画面をきれいにしてからprintfなどを使う (std::coutは遅いので避ける) 方法なのですが、実際に行ってみるとちらつきが激しくて見るに堪えない状況となります。これは、毎フレームバッファを消去してすべて書き直すという処理を行っているからなので、画面を消去せずに上書きすればちらつきを起こさないようにできます。そのため、 SetConsoleCursorPositionGetStdHandle を利用しました。

SetConsoleCursorPositionはコンソールスクリーンバッファのカーソル位置を設定し、GetStdHandleは指定した標準入力/出力/エラーハンドルを返す関数です。毎フレームの頭にこれらを使用して標準出力のカーソル位置を (0,0) にすれば画面を上書きすることが可能となります。

SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), COORD { 0, 0 });

実際に画面を上書きするためには様々な方法が考えられますが、ゲーム画面の構築のしやすさ、実装のシンプルさの観点から、空白で埋めた文字列の配列にゲーム画面を書き込んでputsでまとめて出力しました。

カーソルの不可視化

何もいじらない状態だと、コマンドプロンプトの左上にカーソルが表示された状態となりますが、画面の更新と相まってとても目障りになります。そこで、 SetConsoleCursorInfoGetConsoleCursorInfo を使用してカーソルの可視性を制御しました。

SetConsoleCursorInfoはコンソールスクリーンバッファのカーソルサイズと可視性を設定し、GetConsoleCursorInfoはコンソールスクリーンバッファのカーソルサイズと可視性を取得する関数です。これらを使用してゲーム開始時にカーソルを不可視化し、ゲーム終了時にカーソルを元の状態に戻しました。

constexpr CONSOLE_CURSOR_INFO cursor { 1, FALSE };

CONSOLE_CURSOR_INFO init;

GetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &init); // カーソルの初期状態を得る。

SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &cursor); // カーソルを不可視化する。

hoge(); // ゲームの実行。

SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), &init); // カーソルの状態を元に戻す。

ウィンドウサイズの固定

本ゲームはゲーム画面を決まった大きさ (25×20) で表現しているのですが、コマンドプロンプトのウインドウのサイズがこれより小さいと、1画面で表示しきれないのでスクロールを繰り返して画面が振動します。そこで、ウィンドウのサイズを25×20に固定することで画面の振動を防ぎました。このために、 SetConsoleWindowInfo を利用しました。

この関数は、コンソールスクリーンバッファのウィンドウのサイズと位置を設定するため、毎フレーム呼び出すことでウィンドウサイズを固定することができます。

constexpr SMALL_RECT rect { 0, 0, 20, 25 };

SetConsoleWindowInfo(GetStdHandle(STD_OUTPUT_HANDLE), TRUE, &rect);

フレームレート調整

ゲームの状態更新や描画を約60fpsで行うためのフレームレート調整を行いました。基本戦略は以下の通りです。

  1. 以前の更新からの経過時間を計算する。
  2. 規定のフレーム間インターバル (16ms) との差だけ待機する。
  3. 現在時刻を記録してから更新処理を行う。

以上の処理を毎フレーム行うことでフレームレートを60fps付近に維持できます。これを実現するために QueryPerformanceFrequencyQueryPerformanceCounterSleep を利用しました。

QueryPerformanceFrequencyはパフォーマンスカウンタの周波数を取得し、QueryPerformanceCounterはパフォーマンスカウンタの値を取得する関数です。以前の更新時のパフォーマンスカウンタの値と現在のパフォーマンスカウンタの値の差を周波数で割ることで、経過時間を得ることができます。また、Sleepは指定した時間だけスレッドの実行を中断する関数です。

以下のようにすることで、フレームレートを約60fpsに維持することができます。

LARGE_INTEGER freq, updated;

if (!QueryPerformanceFrequency(&freq)) { return; } // カウンタの周波数を得る。

if (!QueryPerformanceCounter(&updated)) { return; } // カウンタの値で初期化する。

while (true)
{
    LARGE_INTEGER now;

    if (!QueryPerformanceCounter(&now)) { break; } // カウンタの値を得る。

    // 以前の更新からの経過時間を計算する。
    auto const duration { (now.QuadPart - updated.QuadPart) * 1000 / freq.QuadPart };

    // 規定のフレーム間インターバル (16ms) との差だけ待機する。
    if (static_cast<DWORD>(duration) < 16) { Sleep(16 - static_cast<DWORD>(duration)); }

    if (!QueryPerformanceCounter(&updated)) { break; } // 現在時刻を記録する。

    hoge(); // フレームの更新。
}

感想

コマンドプロンプトで遊べるアクション系ゲームの実装は、はじめはネタ気分でした。しかし、実際にやってみると案外奥が深くて普通に遊べるものが出来上がって驚きました。

コマンドラインで実装することのメリットはその手軽さだと思います。素材を作る必要がない (というか使えない) ので、ゲームそのものの制作に集中できると思います。逆にそのデメリットはゲーム性やシステムのみで戦わねばならないことと、見栄えしないことでしょうか…

…とはいえ、意外と味のあるゲームができたし結構面白いと思いました。この記事を読んだあなたもいかがでしょうか?