3D避けゲームを作ってみよう
今回は、3本のレーンを左右に移動しながら、前から来る敵を避ける3Dゲームを完成させます。
プログラムのすべてを最初から書くのではなく、用意されているプログラムのTODO部分を順番に完成させます。
1つの課題が終わるたびに、
- プログラムを書く
- ビルドする
- 実行する
- ゲーム画面の変化を確認する
という流れで進めます。
今日完成させるゲーム
ゲームを完成させると、次のことができるようになります。
- プレイヤー、敵、背景が画面に表示される
- READYのカウントダウン後にゲームが始まる
- 左右キーでプレイヤーがレーン移動する
- プレイ時間に応じてスコアが増える
- 敵に当たるとゲームオーバーになる
今日の作業では、ゲームづくりでよく使う次の考え方にも触れます。
- 変数
- 関数呼び出し
Init()、Update()、Draw()- フレームとFPS
deltaTime- 入力処理
- 条件分岐
- 距離・時間・速さ
- 当たり判定
作業を始める前に
Visual Studioでを開いてください。PlayScene_Exercise.PlayScene.cpp
このファイルを開には、今回使用する穴埋め用のプログラムがあらかじめ用意されていたます。
参加者が別のファイルからコピーする作業はありません。
各課題には、でCtrl + FPlayScene.cpp次の文字確認する行番号を検索し「〇〇行目あたり」と書いてあります。
TODOVisual
Studioの左側に表示される行番号を見ながら、その行付近へ移動してください。今回修正する場所には、TODO 01からTODO 06までの行番号が付い少しずれている場合は、近くにあるTODO番号を確認します。
1つのTODOが終わったら、必ずビルドしてから実行してください。
エラーが出た場合は、次のTODOへ進まず、今書いた場所を確認します。
エラーが出たときに確認すること
- 英字の大文字と小文字が合っているか
"を消していないか(と)の数が合っているか- 行の最後に
;が付いているか - 全角文字をコードの中へ入力していないか
1.ゲームは変数と関数で動いている
変数は、ゲームの情報を覚える箱
プログラムは、ゲームに必要な情報を変数に保存します。
変数は、数値や状態に名前を付けて覚えておくための箱です。
このゲームでは、たとえば次の情報を変数で管理しています。
| 変数 | 覚えている情報 |
|---|---|
stateTimer_ |
カウントダウンなどの残り時間 |
score_ |
現在のスコア |
scrollSpeed_ |
道路や敵が流れる速さ |
state_ |
READY、プレイ中、ゲームオーバー中などの状態 |
backgroundHandle_ |
読み込んだ背景画像を使うための番号 |
画像や3Dモデルそのものを、変数の小さな箱へ入れているわけではありません。
画像やモデルを読み込むと、それを使うための番号や情報が作られます。プログラムは、その番号を変数に覚えておき、必要なときに画像やモデルを表示します。
変数の値が変化すると、ゲーム画面にも変化が起こります。
- 位置の値が変わると、キャラクターが移動する
stateTimer_が減ると、カウントダウンが進むscore_が増えると、画面の得点が増える- プレイヤーの状態が変わると、走る・倒れるなどのアニメーションが切り替わる
関数は、処理をまとめた命令
関数は、何らかの処理をまとめて名前を付けたものです。
たとえば、次のような命令を関数として用意できます。
Jump();
このコードは、Jumpという名前の関数を呼び出しています。
関数を呼び出すときは、基本的に次の形で書きます。
関数名();
関数によっては、丸かっこの中へ情報を渡します。
LoadModel("ファイルの場所");
丸かっこの中へ渡す情報を引数と呼びます。
今回のプログラムでも、関数を使って次のような命令を出します。
- モデルを読み込む
- 背景画像を読み込む
- プレイヤーを左へ移動させる
- プレイヤーを右へ移動させる
- 敵との当たり判定を調べる
2.ゲームは処理を繰り返して動いている
ゲームの画面は、1枚の画像を表示して終わりではありません。
入力を調べ、キャラクターの位置やスコアを更新し、新しい画面を描く処理を何度も繰り返しています。
今回使うPlaySceneには、大きく分けて次の3つの処理があります。
| 関数 | 役割 | 実行される回数 |
|---|---|---|
Init() |
モデルや画像などを準備する | 最初に1回 |
Update() |
入力、計算、当たり判定などを行う | ゲーム中に何度も |
Draw() |
現在のゲーム画面を描く | ゲーム中に何度も |
流れを図にすると、次のようになります。
ゲーム開始
↓
Init()
↓
┌──────────────┐
│ Update() │
│ ↓ │
│ Draw() │
└──────┬───────┘
│
└─ 何度も繰り返す
この繰り返しをゲームループと呼びます。
1フレームとは
Update()でゲームの状態を更新し、Draw()で画面を描く1回分を、1フレームと考えます。
1秒間に何フレーム処理したかを表す値がFPSです。
FPSは、Frames Per Secondの略です。
$$
\mathrm{FPS}=\text{FPSは、1秒間に処理するフレーム数}です。
1秒間に処理したフレーム数を$N$、時間を$t$秒とすると、FPSは次の式で表せます。
$$\mathrm{FPS}=\frac{N}{t}$$
たとえば60FPSなら、1秒間に約60回、更新と描画を行います。
ゲームは1フレームごとに少しずつ状態を変えるため、キャラクターが滑らかに動いて見えます。
3.TODO 01:モデルと背景を読み込もう
探す場所:PlayScene_Exercise.cppの157行目あたりにあるTODO 01
この課題で変わること
現在のプログラムでは、モデルと背景のファイル名が仮の名前になっています。
正しいファイル名を指定すると、プレイヤー、敵、背景がゲーム画面に表示されます。
Init()を探そう変更する場所
の157行目あたりを確認してください。TODOPlayScene.cpp
この場所はInit()の中にありまです。
Init()は、ゲームを始める前に必要なものを準備する場所です。
157行目あたりには、次の3つの関数が並んでいます。
LoadPlayerModel("Assets/XXX.mv1");
LoadEnemyModel("Assets/XXXXXXX.mv1");
LoadBackgroundImage("Assets/XXXXXXXX.png");
それぞれの関数名を日本語で考えてみましょう。
| 関数 | 読み込むもの |
|---|---|
LoadPlayerModel |
プレイヤーの3Dモデル |
LoadEnemyModel |
敵の3Dモデル |
LoadBackgroundImage |
背景画像 |
.mv1は3Dモデルのファイル、.pngは画像ファイルです。
作業
Assetsフォルダの中を確認する- プレイヤーとして使うモデルのファイル名を探す
- 敵として使うモデルのファイル名を探す
- 背景として使う画像のファイル名を探す
XXXなどの仮の部分を、見つけたファイル名へ書き換えるAssets/や拡張子まで含めて、正確に入力する
書くときの注意
文字列は"で囲みます。
"Assets/ファイル名.拡張子"
次の部分は消さないようにしてください。
- 最初と最後の
" Assets/- モデルの
.mv1 - 画像の
.png - 行末の
;
動作確認
ビルドして実行します。
次の3つが表示されるか確認してください。
- プレイヤー
- 敵
- 背景
この時点では、READYの表示が止まったままでも問題ありません。次の課題でカウントダウンを動かします。
4.TODO 02:カウントダウンタイマーを動かそう
探す場所:PlayScene_Exercise.cppの395行目あたりにあるTODO 02
この課題で変わること
現在は、READYのカウントダウンが止まっています。
残り時間をフレームごとに少しずつ減らすことで、カウントダウンが進み、ゲームが始まるようになります。
deltaTimeとは
パソコンが1フレームを処理する時間は、いつも完全に同じとは限りません。
前のフレームから今回のフレームまでに経過した時間を、deltaTimeに保存しています。
数式では、1フレームの経過時間を$\Delta t$と表します。
FPSと1フレームの時間には、次の関係があります。
$$
\Delta t=\text{前のフレームから今回までに経過した時間frac{1}{\mathrm{FPS}}
$$
60FPS付近なら、1フレームの時間はおよそ次の値です。
$$
\Delta t \approx \t\approx\frac{1}{60}\approx0.0167$$
単位は秒}
$$です。
30FPS付近なら、次のようになります。
$$
\Delta t \approx \t\approx\frac{1}{30}\approx0.0333$$
単位は秒}
$$です。
| FPS | 1フレームのおよその時間 |
|---|---|
| 60FPS | 約0.0167秒 |
| 30FPS | 約0.0333秒 |
30FPSでは1回に進む時間が大きく、60FPSでは1回に進む時間が小さくなります。しかし、1秒間に積み重ねると、どちらも約1秒になります。
タイマーを減らす考え方
現在の残り時間を$ T_{\mathrm{now}} $、次のフレームの残り時間を $ T_{\mathrm{next}} $とします。
毎フレーム、経過した時間だけ残り時間を減らします。
$$
T_{\mathrm{next}}=T_{\mathrm{now}}-\Delta $t$$
プログラムでは、次の変数が対応しています。
| 数式 | プログラム |
|---|---|
stateTimer_ |
|
deltaTime |
作業
の395行目あたりをTODO 02PlayScene.cpp探確認しますてください。
現在は、stateTimer_へ同じstateTimer_を代入しているため、値が変わりません。
stateTimer_ = stateTimer_;
右辺を、次の意味になる式へ変更してください。
現在の残り時間 - 1フレームの経過時間
使用する変数は次の2つです。
- 残り時間:
stateTimer_ - 1フレームの経過時間:
deltaTime
動作確認
ビルドして実行します。
次の順番で変化するか確認してください。
- READYの数字が減る
- GOが表示される
- プレイヤーが走る
- 道路が動き始める
- 敵が出現する
5.関数を呼ぶと、複数のものが動き始める
カウントダウンが終了すると、プログラムはStartGameという関数を呼びます。
StartGameの中では、プレイヤー、道路、敵へそれぞれ命令を出しています。
StartGame
├─ プレイヤーを走る状態にする
├─ 道路のスクロールを始める
└─ 敵の出現を始める
1つの関数を呼ぶことで、ゲーム開始に必要な複数の処理をまとめて実行できます。
このように、複雑なゲームでも、処理を小さな関数へ分けることで分かりやすく整理できます。
6.TODO 03:左右キーの入力を完成させよう
探す場所:PlayScene_Exercise.cppの105行目あたりにあるTODO 03
この課題で変わること
左矢印キーと右矢印キーを押したとき、プレイヤーへレーン移動の命令を出せるようにします。
入力処理とif
Update()はゲーム中に何度も呼ばれます。
その中でキーの状態を調べると、プレイヤーがキーを押したタイミングに反応できます。
if (条件)
{
条件が成立したときの処理
}
ifは、「もし条件が成立したら、中の処理を実行する」という命令です。
次のコードは、左矢印キーが押されたかを調べています。
if (Input::IsKeyDown(KEY_INPUT_LEFT))
{
// 左へ移動する命令
}
右矢印キーは、else ifで調べています。
else if (Input::IsKeyDown(KEY_INPUT_RIGHT))
{
// 右へ移動する命令
}
作業
PlayScene.cppの105行目あたりを確認してください。
TODO 03-1とTODO 03-2の空いている場所へ、プレイヤーを移動させる関数呼び出しを書きます。
使用する関数名は次のとおりです。
| 方向 | 使用する関数名 |
|---|---|
| 左 | MovePlayerLeft |
| 右 | MovePlayerRight |
関数呼び出しの書き方を思い出してください。
関数名();
左側には左移動の関数、右側には右移動の関数を使います。
動作確認
ビルドして実行します。
この時点では、キー入力の処理は完成しますが、プレイヤーの移動速度が0.0fになっています。
そのため、左右キーを押しても、まだプレイヤーが動かないことがあります。
これは失敗ではありません。
キー入力の命令は完成
+
移動速度はまだ0
↓
移動する距離が0
次の課題で移動速度を計算すると、実際にレーン移動するようになります。
7.TODO 04:レーン移動の速さを計算しよう
探す場所:PlayScene_Exercise.cppの26行目あたりにあるTODO 04
この課題で変わること
現在のプレイヤー移動速度は0.0fです。
レーン間の距離と、移動にかける時間から必要な速さを計算し、左右キーでプレイヤーが動くようにします。
距離・時間・速さの関係
一定の速さで移動するとき、距離、時間、速さには次の関係があります。
$$
d = vt
$d=vt$$
| 記号 | 意味 |
|---|---|
| 距離 | |
| 速さ | |
| 時間 |
今回は速さ(v)$v$を求めたいので、式を次の形にします。
$$
v=\frac{d}{t}
$$
つまり、
$$
\text{「速さ}
\frac{\text{=距離}}{\text{÷時間}}
$$
」です。
ミニワーク:必要な速さを求めよう
このゲームでは、中央レーンから隣のレーンまでの距離が12.0です。
1レーンの移動を0.20秒で終わらせます。
次の式へ数値を入れて、必要な速さを計算してください。
$$
v
v=\frac{12.0}{0.20}
\boxed{\qquad\qquad}
=?$$
計算できたら、次の空欄へ記入してください。
必要な速さ:________
数値ではなく変数を使って式を書く
PlayScene.cppの26行目あたりを確認してください。
プログラムには、距離と時間がすでに変数として用意されています。
constexpr float LANE_DISTANCE = 12.0f;
constexpr float LANE_MOVE_TIME = 0.20f;
| 内容 | 変数名 |
|---|---|
| 1レーン分の距離 | LANE_DISTANCE |
| 1レーンの移動時間 | LANE_MOVE_TIME |
TODO 04では、次の空欄へ変数名を入れます。
constexpr float PLAYER_MOVE_SPEED =
________ / ________;
数式の
$$
v=\frac{d}{t}
$$
と同じ順番になるように考えてください。
計算結果の数字を直接書くのではなく、距離と時間を表す変数を使って式を書きます。
1フレームで進む距離
速さが決まっても、プレイヤーを一度に1レーン分移動させるわけではありません。
実際には、フレームごとに少しずつ位置を変えています。
1フレームで進む距離は、次の式で求められます。
$$
\Delta x=v\Delta t
$t$$
| 記号 | 意味 |
|---|---|
| そのフレームで進む距離 | |
| プレイヤーの速さ | |
| 1フレームの経過時間 |
この細かい位置更新や、レーン中央で止める処理は完成済みです。
今回は、ゲームの仕様から速さを計算することに注目します。
動作確認
ビルドして実行します。
左右矢印キーを押し、次のことを確認してください。
- プレイヤーが左右のレーンへ移動する
- 1回の入力で隣のレーンへ移動する
- 約
0.20秒で移動が終わる - レーンの中央付近で停止する
8.TODO 05:時間に応じてスコアを増やそう
探す場所:PlayScene_Exercise.cppの129行目あたりにあるTODO 05
この課題で変わること
現在は、score_へ同じscore_を代入しているため、スコアが増えません。
1秒あたりの得点とdeltaTimeを使って、プレイ時間に応じてスコアを増やします。
1秒あたりの得点
プログラムには、次の定数が用意されています。
constexpr float SCORE_PER_SECOND = 100.0f;
これは、「1秒間プレイすると100点増える」という意味です。
ただし、Update()は1秒に1回ではなく、1秒間に何度も呼ばれます。
そこで、1フレームで増やす得点を次の式で求めます。
$$
\Delta S=r\Delta t
$t$$
| 記号 | 意味 | プログラム |
|---|---|---|
| 今回増やす得点 | これから計算する値 | |
| 1秒あたりの得点 | SCORE_PER_SECOND |
|
| 1フレームの経過時間 | deltaTime |
現在のスコアを($S_{\mathrm{now}})$、次のスコアを($S_{\mathrm{next}})$とすると、次のようになります。
$$
S_{\mathrm{next}}
=S_{\mathrm{now}}
+
r\Delta t
$t$$
作業
の129行目あたりをTODO 05PlayScene.cpp探確認しますてください。
現在は次のコードになっています。
score_ = score_;
次の形になるように空欄を考えてください。
score_ =
score_ + ________ * ________;
使うものは次の2つです。
- 1秒あたりの得点
- 1フレームの経過時間
変数名は、すぐ上のコードや表から探してください。
動作確認
ビルドして実行します。
次のことを確認してください。
- READY中はスコアが増えない
- ゲーム開始後にスコアが増える
- 約1秒で、およそ100点増える
なぜ毎フレーム1点ではいけないのか
次のように毎フレーム固定で1点増やすとします。
1フレームごとに1点追加
60FPSなら1秒に約60点、30FPSなら1秒に約30点になります。
これではパソコンの処理速度によって得点の増え方が変わります。
deltaTimeを使うことで、FPSが変化しても、実際に経過した時間に合わせて得点を増やせます。
9.TODO 06:敵との当たり判定を有効にしよう
探す場所:PlayScene_Exercise.cppの134行目あたりにあるTODO 06
この課題で変わること
現在は、プレイヤーが敵に触れてもゲームオーバーになりません。
当たり判定を調べる関数を条件に使い、敵に当たったときだけ死亡処理を実行します。
trueとfalse
条件が成立していることをtrue、成立していないことをfalseで表します。
if (false)
{
// この中は実行されない
}
現在のプログラムは条件が常にfalseなので、敵に当たっても中の処理へ入りません。
戻り値
関数は、処理を行ったあとに結果を返すことがあります。
今回使う当たり判定の関数名はIsHitです。
この関数は、当たり判定の結果を次のどちらかで返します。
| 戻り値 | 意味 |
|---|---|
true |
敵に当たっている |
false |
敵に当たっていない |
作業
の134行目あたりをTODO 06PlayScene.cpp探確認しますてください。
if (false)
falseの部分を、当たり判定を調べる関数呼び出しへ変更してください。
使用する関数名は次のとおりです。
IsHit
関数を呼び出すときは、関数名の後ろに丸かっこを付けます。
条件部分には、関数が返したtrueまたはfalseが使われます。
当たったときに実行される処理
条件がtrueになると、次の処理が実行されます。
敵に当たる
↓
死亡アニメーションへ切り替える
↓
道路の動きを止める
↓
敵の出現を止める
↓
ゲームオーバー画面へ移る
動作確認
ビルドして実行します。
次のことを確認してください。
- 敵へプレイヤーを当てる
- プレイヤーのアニメーションが変化する
- 道路が停止する
- 敵の出現が停止する
- ゲームオーバー画面へ移る
10.ゲーム全体の流れを確認しよう
今回完成させた処理を、ゲームループへ当てはめて確認します。
Init()で行ったこと
- プレイヤーモデルを読み込む
- 敵モデルを読み込む
- 背景画像を読み込む
- ゲームに必要なオブジェクトを準備する
Update()で行ったこと
- カウントダウンを減らす
- 左右キーの入力を調べる
- プレイヤーへ移動の命令を出す
- 時間に応じてスコアを増やす
- 敵との当たり判定を調べる
- ゲームの状態を切り替える
Draw()で行っていること
- 背景を描く
このプロジェクトでは、道路、草、木、プレイヤー、敵、スコアなどは、PlayScene::Draw()のあとに別の描画処理が自動的に表示します。
そのため、今回はDraw()のコードを変更していません。
11.完成チェック
次の項目をすべて確認してください。
- プレイヤーが表示される
- 敵が表示される
- 背景が表示される
- READYのカウントダウンが進む
- カウントダウン後にゲームが始まる
- 左矢印キーで左へ移動する
- 右矢印キーで右へ移動する
- プレイヤーが約0.20秒で隣のレーンへ移動する
- ゲーム中にスコアが増える
- 敵に当たるとゲームオーバーになる
12.今回学んだこと
変数
ゲームの位置、時間、得点、状態などを覚えておくために使います。
関数
処理をまとめた命令です。関数を呼ぶことで、モデルの読み込みやプレイヤーの移動などを実行できます。
ゲームループ
最初にInit()を実行し、その後はUpdate()とDraw()を繰り返します。
フレームとFPS
ゲームは1フレームずつ少しずつ状態を変えています。FPSは、1秒間に処理するフレーム数です。
deltaTime
前のフレームから今回のフレームまでに経過した時間です。
タイマー、移動、スコアなどを実際の時間に合わせて動かすために使います。
入力処理
Update()の中でキー入力を調べ、押されたキーに応じて関数を呼びます。
距離・時間・速さ
$$
v=\frac{d}{t}
$$
という関係を使い、ゲームの仕様からプレイヤーの移動速度を求めました。
当たり判定
敵に当たっているかを関数で調べ、その結果がtrueのときだけゲームオーバー処理を実行しました。