C応用8 シューティングゲームを作る
C応用8 シューティングゲームを作る
シューティングゲームのプログラミングを通して、ゲーム作りでよく使われるプログラミングテクニック(FPS制御、当たり判定等)を学んでいきます。また、基本アルゴリズムの一つであるバブルソートや、代表的なデータ構造であるリスト構造、代表的なOS機能であるマルチスレッドも取り入れながら、シューティングゲームを完成させていきます。
プログラムが大きくなり、プログラムが読みにくくなってきています。プログラムの設計技法として、オブジェクト指向設計を取り入れる様にします。C言語はオブジェクト指向言語ではないため、オブジェクト指向プログラミングは行えませんが、オブジェクト指向的な設計技法を取り入れたプログラム作成をしていきます。
フィールドの中を、アスキーアートのプレイヤーが動くプログラムを作成します。方向キーを使ってプレイヤーを動かします。ただ、滑らかにプレイヤーを動かすには、工夫が必要です。
また、コンピュータの性能に依存しないキー操作を実現するためのFPS制御についても説明します。
今後、シューティングゲームを作成していきますが、プログラムの量が増えていくため、プログラムはオブジェクト指向的に作成していきます。オブジェクト指向設計についても、簡単に説明します。
・標準入力はリアルタイム処理に使えない
getchar関数やscanf関数のキー入力は、Enterキーが押されるまで待ちが発生するため、リアルタイム処理には使えません。Cランタイム関数やWindowsAPIと呼ばれる関数を使う必要があります。
・構造化設計技法とオブジェクト指向設計技法
今までのプログラムは構造化設計技法を使って作成していました。今回作成するシューティングゲームは、オブジェクト指向設計技法を使って作成していきます。但し、C言語はオブジェクト指向プログラミング言語ではないため、オブジェクトに注目した設計とは、どのようなものかを体感してもらうレベルの、オブジェクト指向的な設計になります。
・FPS制御でコンピュータの性能差を防ぐ
FPS(Frame per second)は、1秒間に画面表示する回数を表わす単位で、ゲームプログラミングでは、60FPSで制御する様にしています。
プレイヤーが移動できるフィールド枠を表示させます。このプログラムを元に、シューティングゲームに成長させていきます。MYconio.h,FIELD.h,FIELD.cppがベースプログラムとして用意してあります(YouTube動画の説明欄にダウンロード用URLが記載)。main.cppを追加して、フィールド枠を表示するプログラムを完成させましょう。
Cランタイムの_kbhit関数と_getch関数を使ったキー入力を行い、方向キーでプレイヤーを移動させます。プレイヤー移動に必要なモジュールはベースプログラムに用意してあります(YouTube動画の説明欄にダウンロード用URLが記載)。練習問題49で作成したmain.cppを追加して、プレイヤーを移動させる処理を追加し、プログラムを完成させましょう。
Cランタイム版のキー入力では、方向キーを押したままの状態にした場合、一瞬、止まってから動き始める動作になります。また、2つの方向キーを押し続けた場合、うまく斜め移動させることができません。
Cランタイム関数ではなく、WindowsAPIを使って、キー打鍵の状態(押した/離した)を調べる方法に変えます。これにより、プレイヤー移動が滑らかな動きになり、2つの方向キーを同時押下することで、斜め移動が出来るようになります。ただ、この方法は、プレイヤーが超高速移動になってしまいます。FPS制御を導入し、コンピュータ性能に依存しないプレイヤー移動ができる様にします。
Windows11(2022Update)から、標準コンソールウィンドウがWindowsコンソールホストからWindowsターミナルに変わりました。上記で紹介した練習問題51のプログラムは、従来からあるWindowsコンソールホストでは動きますが、Windowsターミナルでは、ESCキーがうまく入力することができなくなってしまっています。
上記プログラムを動かす一番単純な方法は、下記の「2022年11月の月例WindowsUpdateへの対応」の動画に従って、VisualStudioのデバッグ実行の画面をWindowsコンソールホストに切り替えることです。
何故Windowsターミナルで動かなくなってしまったのかについては、「C応用特別編 コンソール入力 」を参照してください。ログを採取して、キーが入力されると、OSがどのようなデータを送ってくるのかを調べ、WindowsターミナルとWindowsコンソールホストの動きの違いを明らかにしていきます。Windowsターミナルでも動くようにするための対処方法についても説明しています。
2022年11月の月例WindowsUpdateへの対応
C応用特別編 コンソール入力(概要説明)
C応用特別編 コンソール入力(プログラム説明1)
C応用特別編 コンソール入力(プログラム説明2)
C応用特別編 コンソール入力(プログラム説明3)
C応用特別編 コンソール入力(プログラム説明4)
上記のコンソールからのキー入力で説明していますが、練習問題51のプログラムは、Windowsターミナルで実行するとESCキーを押してもプログラムがなかなか終了しません。ESCキーが直ぐに検知されるようにするためには、MYconio.cppのinport関数を、以下のように修正してください。
36行目のif文の条件に「キーが押されたイベント かつ」を追加
if (key_code <= 0x0FF) → if (input_record.Event.KeyEvent.bKeyDown != 0 && key_code <= 0x0FF)
44行目のreturn文の前に「GetAsyncKeyStateでkeyが離されている状態を検知した場合はKeyTabel[key]をリセット」を挿入
if ((GetAsyncKeyState(key) & 0xff00) == 0) KeyTable[key] = 0;
練習問題49のプログラムのFIELD.cppには、MessageDraw関数が定義されています。この関数は、printf関数のラッパー関数で、可変長引数を使用しています。
右記の動画では、この可変長引数が、コンピュータの中でどのように扱われているか、その仕組みについて説明しています。
スペースキーを押すと、プレイヤーの頭の上の位置から弾が発射するようにします。弾は1発だけ発射でき、フィールドの外に到達すると消えて、次の弾が発射できるようにします。弾のプログラムは、PLAYERモジュールを真似て、BULLETモジュールとして作成します。
弾を発射する時、発射音が鳴る様にします。発射音を鳴らすと、プレイヤーが一瞬止まる現象が発生してしまったため、音を鳴らす関数を別スレッドで動かす対処を行います。
・マルチスレッドとマルチスレッドはプログラムがを並行動作する
複数の関数を、別々のスレッドで動作させることにより高速化することができます。但し、同期などの対応が必要になる場合があり、高度なプログラミング技術が必要になります。マルチスレッドプログラミングと呼ばれる技術をきちんと勉強してからチャレンジしてください。今回、練習問題53でマルチスレッド化していますが、このケースは同期の必要もない簡単なものです。スレッド化することにより、プログラムの動作が速くなることを体験してみてください。
・スレッドは同一メモリ空間、プロセスは別メモリ空間
コンピュータの中のメモリには、仮想メモリと物理メモリがあり、プログラムの中で認識しているのは仮想メモリです(物理メモリはOSだけが認識)。OSによって作り出された仮想メモリ空間は、プロセス毎に用意されます。一つの仮想メモリ空間の中で、複数のスレッドが動作します。
スペースキーで弾が発射するようにします。練習問題51でプレイヤーが方向キーで移動できるようになりましたので、これに以下のプログラムを追加します。
PLAYER.cppとPLAYER.hを参考にして、BULLET.cppとBULLET.hを作成。
PLAYER.cppのPlayerUpdateで、スペースキーを入力した場合に、弾を生成するよう処理を追加。
main.cppで、BULLETモジュールの各関数を呼び出す。
練習問題52で弾が発射できるようになりましたので、弾を発射する時に、発射音を鳴らすようにします。
mciSendCommand関数を使った音楽ファイルのオープン、再生、停止、クローズを行う関数定義が、ベースプログラムとして用意しています(YouTube動画の説明欄にダウンロード用URLが記載)。MYconio.cppに、これらの関数を貼り付け、MYconio.hに関数プロトタイプ宣言を追加、BULLET.cppのBulletCreateで弾を生成した時に、発射音を鳴らすとよいでしょう。なお、音楽ファイルを扱う場合も、普通のファイルを扱う時と同じように、オープンとクローズが必要です。今回追加した関数の中にある、音楽ファイルのオープンとクローズを使ってください。
※ 発射音用のMP3ファイルは、ダウンロードしたプログラムファイルと同梱してあります。
練習問題53で、弾を発射した時に発射音が鳴る様になりましたが、プレイヤーを動かしながらスペースキーを押すと、発射音が鳴った瞬間、プレイヤーが少し止まる、という現象が発生します。音がなった時、FPSも低い値になっています。原因は分りませんが、音を再生するmciSendCommand関数の中で、何らかの待ちが発生している様です。この現象を迂回するため、音の再生関数を別スレッドで動作させてみます。別スレッド化することで、プレイヤーを動かしながらスペースキーを押しても、プレイヤーは滑らかに動く様になります。
ENEMYモジュールを作成し、配列を使って複数のエネミーを登場させます。エネミーは一定間隔で、自動的に次々と登場するようにします。
オブジェクトとオブジェクトが衝突したかどうかの当たり判定は、2つの矩形が重なったかどうかを調べる方法を使います。当ったならば消えるだけでなく、効果音も鳴らすことで、当ったことが分かるようにしていき、ゲームらしさを加えていきます。
・矩形と矩形の当たり判定
今回のシューティングゲームでは、エネミーとプレイヤー、エネミーと弾の当たり判定を行います。当たり判定の方法は色々ありますが、プレイヤー、弾、エネミー共に、文字ポインタ配列を使った矩形データにしているため、矩形同士の当たり判定を使う事にしました。2つの矩形の四隅の座標位置を求め、その大小関係を調べることで重なっているかが分かります。重なっていない場合は当っていないと判定し、重なっている場合は当ったと判定します。
練習問題54のプログラムで、プレイヤーが弾を発射すると発射音が鳴る様になりました。今回は、エネミーを5個登場させ、以下の動きをさせます。
一定間隔で左上端から登場
自動的に横移動
壁にぶつかると、一段下がった位置に移動してスピードを上げて折り返す
最下段の壁にぶつかると消滅
練習問題55のプログラムでは、弾がエネミーに当たっても素通りしてしまいます。弾がエネミーに当たったら弾は消え、エネミーとプレイヤーが衝突したらエネミーが消える様にします。また、プレイヤーはエネミーに衝突すると、エネミーの持っている体力分、プレイヤーの体力が奪われ、プレイヤーの体力が無くなってしまった場合は、ゲームオーバーにして、プレーヤーを消滅させるようにします。
練習問題56のプログラムでは、衝突処理で体力が減った様子が分りません。エネミーやプレイヤーの体力の残量が分るようにします。また、効果音を鳴らして、状態が変化しているのが音で分るようにします。
複数のエネミーの構造をリスト構造にします。データを入れるメモリを動的メモリから調達することで、ほぼ無限にエネミーを増殖させることができるようになります。
練習問題では、得点システムを実装することで、ゲームとしての形態が整います。
・配列構造とリスト構造
同じ構造の複数のデータを扱うためのデータ構造として、配列構造とリスト構造があります。配列構造に比べ、リスト構造はプログラミングが難しくなりますが、データの挿入と削除を高速に行うことができます。
・エンキューとデキューを行うキュー構造
リスト構造にも沢山の種類があり、キュー構造はその代表的なリスト構造の中の一つです。FIFO(First In First Out)と呼ばれる構造で、最初にキューに入れたものが、最初に取り出される構造です。キューに入れる事をエンキュー、キューから取り出すことをデキューと言います。
・プッシュとポップを行うスタック構造
スタック構造は、キュー構造と並ぶ代表的なリスト構造の一つです。LIFO(Last In First Out)と呼ばれる構造で、最後にキューに入れたものが、最初に取り出される構造です。キューに入れる事をプッシュ、キューから取り出すことをポップと言います。
・リスト構造は自己参照構造体で作る
自分の構造をポイントするポインタをメンバに持つ構造体を、自己参照構造体と言います。メンバ宣言では、typedefした型名を使う事ができないため、struectタグ名を使ってメンバ宣言します。
練習問題57のプログラムではエネミーを配列で宣言していますが、これを動的メモリを使ったリスト構造に変更します。この変更により、エネミーを、途切れることなく出現させることができます。
ただ、リスト構造はデバッグが難しいので、ポインタが苦手な人は、今回の場合、配列の要素数を50個ほどに増やすことで、途切れることなくエネミーを出現できます。要素数を増やすだけの修正でも、この後の練習問題に進めることができます。
練習問題58で、途切れることなくエネミーを出現させることができました。今回は、エネミーの出現間隔をランダムにします。また、最初に登場するエネミーの体力も、ランダムに、1~5のいずれかの体力になる様にします。
練習問題59に得点システムを導入、ゲームとしての形態が整います。
ゲームの仕様を以下に示します。ゲームオーバーになるまでの得点を競うゲームになります。
得点は、ゴール(フィールドの左下隅)に到達した時のエネミーの残り体力。
プレーヤーは、エネミーをよけて、エネミーがゴールに到達できる様にする。
エネミーは連続発生するため、プレイヤーは、全てのエネミーを避けることができない。
プレイヤーは、エネミーに衝突すると体力が奪われる。
プレイヤーの体力がゼロになるとゲームオーバー。
プレイヤーは、自分の逃げ道を確保するため、エネミーを撃ってエネミーを消す必要がある。
ゴールに到達させるエネミーを見極めながら、逃げ場所確保のためのエネミーを射撃するゲームになります。
ゴールが分る様にマークを付け、得点を表示します。更に、得点がカウントされたら効果音も鳴らすようにします。
得点履歴をバイナリファイルに記録し、得点のランキング表示をします。得点のランキング表示するには、得点の降順に並び替えします。並び替えのアルゴリズムは色々ありますが、一番単純なバブルソートでソートしてみます。
C標準関数に、クィックソートのアルゴリズムを使ったqsort関数があるので、qsortも使ってみます。
・単純で遅いバブルソート、複雑で速いクィックソート
ソートのアルゴリズムは色々ありますが、その中でも一番アルゴリズムが単純なのがバブルソートです。C標準関数には、クイックソートを実装したqsort関数があります。クイックソートは、複雑なアルゴリズムですが、ソートスピードは一番速いと言われています。
・バブルソートは二重ループの中に入替えロジック
昇順か降順かは、入替え条件の違いだけです。
入替えのために用意する作業用変数のデータ型は、入替える対象のデータ型に揃える必要があります。
・qsortの4つめの引数は関数ポインタ(関数名)
比較関数を自分で作成し、その関数の関数名を4つめの引数に指定します。比較関数は、int型のデータを返す関数で、入替え対象となるデータのアドレス(ポインタ)が引数になっています。どの様なデータを入替えるのかは、その時々で変わってくるため、void*型というポインタ型を使っています。比較関数を作る側が、プログラムのロジックの中で、自分が扱いたいデータ型に強制型変換(キャスト)することになります。
練習問題60で、シューティングゲームの得点を表示する所まで出来上がっています。これに、得点の履歴を日時情報と共にファイルに保存し、今までの記録を表示する様にします。今回の表示は、得点の多い順ではなく、ゲームした日時の新しい順に表示します。
今回、新たに、SCOREモジュールを追加します。YouTube動画の説明にダウンロードのURLが記載されているので、そこからダウンロードしてください。SCOREモジュールのScoreInit関数では、得点履歴が入っているファイルを"rb+"モードでオープンして、ftell関数やfseek関数を使ってファイルサイズを取得し、入力に必要なメモリを動的領域から獲得するようにしています。ftell関数とfseek関数は、ここで初めて使っていますが、これらの関数を使って、ファイルを入出力する位置を変更することができます。
練習問題61では、得点履歴は、ゲームした日時の新しい順に表示しています。これを、バブルソートで、得点の降順になる様に並び替えます。
練習問題63ではバブルソートのロジックを使って、得点の降順にソートしましたが、これをqsort関数を使う様に変更します。また、ゲームオーバーになった時、獲得した得点が、最高得点を更新した場合は、最高得点を更新したメッセージと共に、BGMを鳴らし、ESCキーが押されたら、BGMを止めるようにします。