役割ごとにスレッドを分けよう
前置き
私のプログラムは、基本的に3つにスレッドに分かれています。
WinMainから始まる「ウインドウメッセージ処理スレッド」
定期的に入力デバイスを監視し続ける「入力スレッド」
1ループするたびにゲームを1フレーム更新する「ゲームスレッド」
これについて少し解説をしたいと思います。
なので今回はスレッドクラスを実装してみました。
また、今後このプログラムを基本として機能を拡張していきます。
なので、今までのクラスも全て含めてstatic link libraryにしておきました。
ウインドウメッセージ処理スレッド
int APIENTRY _tWinMain(略) { !ウインドウ生成 !ゲームスレッドを生成し、スタートさせる while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } !アプリが終了するので、ゲームスレッドを停止させる }
これしかしません。
おとなしくウインドウメッセージのみを処理していてね。
何故これを独立させるかと言いますと、もしここで処理を行おうとすると「ウインドウをドラッグしている間処理ができない」という状況になります。
ツールとかだとそれでも良いのですが、ゲームだと少しおかしいですよね?
入力スレッド
void run() { while(true) { !16.666msが経過するまで待ちます !現在の入力デバイスの情報を取得。入力履歴dequeに貯えます } }
単純ですね。
入力デバイスの監視を、ゲームスレッドと同じスレッドでしているとします。
もしそうしてしまうと、ゲームスレッドが処理落ちした場合に、入力デバイスの監視も一緒に処理落ちしてしまいます。
「処理落ちしたらスローになるゲーム」というのも世の中にはありますが、対戦ゲームは普通フレームスキップ機能で対応しますよね。
というわけで入力スレッドは独立している必要があります。
ゲームスレッド
void run() { while(true) { !入力スレッドに貯えられている入力履歴dequeを全て奪い、自分の入力履歴dequeに追加します !入力スレッド側の入力履歴dequeはクリアされます // 自分の入力履歴dequeに1つも入力がない場合、ちょっと待ってcontinue;します if (m_input.empty()) { // 入力スレッドに履歴が貯まるまで待ちましょう ::Sleep(1); continue; } // 入力データを1つ消費します KeyData key = m_input.front(); m_input.pop_front(); // 入力に基づいて、ゲームを1フレーム分進めます task(key); // 自分の入力履歴dequeにまだ入力がある場合は、描画をスキップします。そうでないなら描画 if (m_input.empty()) { draw(); } } }
ゲームスレッドが外部から受け取る情報は「入力スレッドからの、入力履歴のみ」です。
とてもスッキリしていますよね。
拡張性
入力スレッドを「キーボード版」「ゲームパッド版」「インターネット通信ストリーム入力版」「リプレイファイルストリーム版」と作ることで、お手軽にゲームのリプレイが作れます。
電装天使ヴァルフォースの通信対戦、及びリプレイはこの思想に基づいて作られています。
試しにリプレイ版を考えてみよう
入力スレッドに、少し機能を足してみましょう。
入力履歴に貯えるとともに、ファイルにその取得した内容を次々と出力していくのです。
(もちろん、スレッド終了時にfcloseしましょう)
リプレイファイルの完成です!
開発中バージョンでは、常にこの機能を仕込んでおきましょう。
「遊んでたら強制終了したよ」という報告とともに、リプレイファイルを送ってもらうことができます。
そしてリプレイファイルから、1秒間に60個ほどの履歴を読みだす入力スレッドを作ればOK。
ゲームスレッドは、キーボードからなのかリプレイファイルからなのかは一切気にせずに、ゲーム進行を再現してくれるはずです。
VisualStudioのデバッグ起動上で再現させれば、原因も一発で分かることでしょう。
蛇足?ロードスレッド
残念ながら世の中「フレーム単位での時間経過」のみで済ませられないものがあります。
それが「ロード」です。
ロード中は「NowLoading...」みたいな文字や絵がアニメーションをしますよね?
となると、データのロードは「ロードスレッド」にやってもらう必要があります。
しかしロードスレッドが何秒で終了するかは環境依存です。(PCスペックはみんな違いますからね)
試合のリプレイファイルであれば、ロードが終わってからの履歴のみを記録すれば良いでしょう。
実際、ヴァルフォースの試合リプレイファイルは「ロードが終わって試合が始まった時点から、試合が終わるまで」の入力履歴です。
ですがせっかく「ゲーム全てのリプレイ」をデバッグ用に作ることができたのです。
使いたいですよね。
というわけで、開発中バージョンのロードの長さは固定にしてしまうのをお勧めします。
// ロード演出が終了しているか? if (FRAME_LOADING <= frameCounter) { // ロードスレッドが作業を終了したか? if (!threadLoad->isRunning()) { // 終了しているので、次のシーンに移行 nextScene(); return; } #ifdef __DEVELOPMENT_VERSION__ while(threadLoad->isRunning()) { ::Sleep(100); } nextScene(); return; #endif }
ヴァルフォースはこんな感じです。
弊害として、予想以上にロードが長引いた場合フレームスキップが発生してしまいますが…。
まぁ良いんじゃないでしょうか。
気にしないか、ロード時間を長めにとるか、その辺りはそれぞれにおまかせします。
ヴァルフォースはロード後に「ラウンド1 ファイト!」という演出があるため、スキップが発生しても気にしませんでした。