Appearance
第06回 2Dシューティングゲーム入門 3 / 状態管理とゲームオーバー
前回: 第05回 2Dシューティングゲーム入門 2 / 配列で弾を複数扱う / 次回: 第07回 C++振り返り / オブジェクト指向入門
前回の振り返り
配列で複数の弾を扱えるようになりました
Bullet構造体で、弾1発ぶんの情報をまとめましたBullet bullets[bulletMax]で、複数の弾をまとめて用意しましたfor文で配列全体を順番に確認し、発射、更新、描画を行いました- 弾の更新と描画を関数に分け、処理に名前を付ける練習をしました
shotTimerで、キーを押し続けたときの発射間隔を入れました- 今回は、弾と敵の命中判定、敵の移動、再出現を加えてゲームの基本形を完成させます
今回の目的
勝敗が分かる基本形を完成させる
- 弾と敵、プレイヤーと敵の衝突結果を分けて扱える
enumでゲームの状態を整理できる- 状態に応じて更新処理と表示を切り替えられる
- 弾を撃ち、敵に当て、敵が再出現する流れを完成できる
- スコアが目標に達したらゲームクリアにできる
今回の授業内容
状態管理とゲームオーバーを実装する
- 第04回の衝突判定と第05回の弾処理
- 判定しやすい
GameObjectへの変換 Playing、GameOver、GameClearの状態定義- 状態に応じた更新処理と表示内容の切り替え
- 敵の左右移動と再出現
- 弾と敵、プレイヤーと敵の命中判定
- 長くなった処理を次回整理しやすくするための準備
2Dシューティング ゲーム入門 3
勝ち負けの判定を ゲームに組み込みます
これでだいぶ ゲームになるよ
前回までにできていること
動くものと判定の材料がそろっています
- 第03回: プレイヤーをキー入力で動かしました
- 第04回: 矩形どうしの衝突判定を作りました
- 第05回: 配列で複数の弾を発射、更新、描画し、発射間隔も入れました
- 今回はこれらをつなぎ、ゲームの結果 を作ります
今回追加する要素
Playing 中の処理の流れを作る
- 入力でプレイヤーを動かす
- 敵が左右に動かす
- 弾を発射し、画面上へ移動させる
- 弾が敵に当たったら、スコアを増やして敵を再出現させる
- プレイヤーが敵に当たったら
GameOverにする - スコアが目標に達したら
GameClearにする
Playingの間は、この流れを毎フレーム繰り返します
弾もGameObjectに切り替える
衝突判定に必要な情報を持たせる
- 第05回では、弾専用の
Bullet構造体を 作りましたが、今回は敵との衝突判定を 行うために弾もGameObjectにそろえます - プレイヤー、敵、弾を同じ
GameObject型に そろえることで、同じIsHit関数で衝突を 判定できるようになります isActiveは使用中か否かを表すフラグですfalseのものは更新や判定の対象から外します
cpp
// プレイヤー、敵、弾の位置と大きさを管理する構造体
struct GameObject
{
int x; // 左上のX座標
int y; // 左上のY座標
int width; // 横幅
int height; // 高さ
bool isActive; // アクティブかどうか
};衝突判定の関数を更新する
非アクティブな GameObject を 除外して判定する
- 使っていない弾や消えた敵を衝突判定の 対象にするわけにはいきません
- ここでは if 文で GameObject の isActive を チェックして、アクティブな GameObject 同士でのみ衝突を判定しています
cpp
// 2つの GameObject が衝突しているかを調べる
bool IsHit(GameObject a, GameObject b)
{
// どちらかが使われていないなら判定しない
if (a.isActive == false || b.isActive == false)
return false;
// 4方向の条件を満たしていれば重なっている
return a.x < b.x + b.width &&
b.x < a.x + a.width &&
a.y < b.y + b.height &&
b.y < a.y + a.height;
}enum とは
数値に名前を与える仕組み
enumは数値を名前で扱える機構です 定義した名前には数値が上から順に 割り振られます- この例では
Playingが0GameOverが1GameClearが2です - 数値を名前で表せるので、 状態をわかりやすく表現することができます
cpp
// ゲーム全体の状態を表す列挙型
enum GameState
{
Playing, // プレイ中: 0
GameOver, // ゲームオーバー: 1
GameClear // ゲームクリア: 2
};状態を変数で持つ
今のゲームがどの状態かを記録する
cpp
// 最初はプレイ中から始める
GameState gameState = Playing;- この変数で、ゲームが今どの状態にあるかを管理します
- ゲーム開始時は
Playingにします - 弾と敵が当たったらスコアを増やし、 目標数に達したら
GameClearに変えます - 敵とプレイヤーが当たったら
GameOverに変えます
状態で処理を分ける
Playing の間だけゲームを更新する
- ゲームオーバーやクリア後に、 プレイヤーや弾が動き続けると不自然です
GameStateがPlaying時だけcaseの中でだけ更新処理を行えば、 それ以外ではゲームの更新を停止できます- ここでは入力と移動を先に処理し、その後 弾の発射、弾の更新、衝突判定を行います
- 描画は状態に関係なく行っています
cpp
switch (gameState)
{
case Playing:
// プレイ中だけ更新する
// 入力、移動、弾の発射、弾の移動、命中判定
break;
}結果表示も状態で処理を分ける
GameOver と GameClear の表示
switchは、変数の値によって 処理を切り替える構文ですbreakを書くと、そのcaseの処理が 終わったところでswitch文を抜けますcaseを追加すれば、状態別の処理を 簡単に書き足すことができますenumと組み合わせると 状態ごとの処理を分かりやすく書けます
cpp
switch (gameState)
{
case GameOver:
// ゲームオーバーの表示
DrawString(260, 220, L"GAME OVER",
GetColor(255, 255, 255));
break;
case GameClear:
// ゲームクリアの表示
DrawString(260, 220, L"GAME CLEAR",
GetColor(255, 255, 255));
break;
}敵を再出現させる処理
関数にまとめておく
- 倒した敵を再出現させるようにしてみましょう
RespawnEnemyに再出現の処理をまとめて おけば、弾の命中時やリトライ時に同じ処理を 呼び出せますenemy = RespawnEnemy(enemy);のように書けば 敵を新しい位置に出し直すことができますGetRandは0から指定した数までの ランダムな整数を返すDXライブラリの関数です
cpp
// 敵を画面上部に出し直す
GameObject RespawnEnemy(GameObject enemy)
{
enemy.x = GetRand(640 - enemy.width);
enemy.y = 80;
enemy.isActive = true;
return enemy;
}
// 敵を再出現させる
enemy = RespawnEnemy(enemy);敵に弾が命中したときの処理
弾が命中したらスコアを増やす
- 当たった弾は使っていない状態に戻します
- スコアが目標に達したら
GameClearにします - まだ続く場合は、敵を別の位置に 再出現させます
cpp
// 弾と敵の衝突を判定する
if (IsHit(bullets[i], enemy))
{
// 当たった弾を消す
bullets[i].isActive = false;
score++;
if (score >= clearScore)
{
enemy.isActive = false;
gameState = GameClear;
}
else
{
enemy = RespawnEnemy(enemy);
}
}プレイヤーが敵に当たったとき
衝突したらゲームオーバーにする
- プレイヤーと敵の衝突判定には 第04回の衝突判定を使います
- 衝突が発生した場合、状態を
GameOverに 変えることで、以降の更新処理を止められます
cpp
if (IsHit(player, enemy))
{
// プレイヤーが敵に触れたらゲームオーバー
gameState = GameOver;
}サンプルコード(1/11)
GameObject と状態を定義する
- 第05回の
PlayerとBulletを、 衝突判定しやすいGameObjectに置き換えます - プレイヤー、敵、弾を同じ型にそろえることで
IsHit()を共通で使えるようにします GameStateでゲーム全体の状態を表します
cpp
#include "DxLib.h"
// プレイヤー、敵、弾の位置と大きさを管理する構造体
struct GameObject
{
int x; // 左上のX座標
int y; // 左上のY座標
int width; // 横幅
int height; // 高さ
bool isActive; // 画面上で有効かどうか
};
// ゲーム全体の状態
enum GameState
{
Playing, // プレイ中
GameOver, // ゲームオーバー
GameClear // ゲームクリア
};サンプルコード(2/11)
第05回の共有変数を拡張する
- 第05回と同じく、複数の関数で使う変数は
WinMainの外に置きます - 弾の配列や発射間隔に加えて、 敵、スコア、ゲーム状態を追加します
- 弾は今回から
BulletではなくGameObjectの配列に変更されています
cpp
// 弾数、発射間隔、移動速度、クリア条件
const int bulletMax = 10;
const int shotInterval = 10;
const int moveSpeed = 4;
const int bulletSpeed = 8;
const int clearScore = 3;
// プレイヤーと敵の初期状態
GameObject player = { 320, 400, 32, 32, true };
GameObject enemy = { 320, 80, 48, 48, true };
// 弾の管理配列
GameObject bullets[bulletMax] = {};
// ゲーム中に変化する値
int shotTimer = 0;
int score = 0;
int enemySpeed = 2;
GameState gameState = Playing;サンプルコード(3/11)
判定と再出現の関数を追加する
IsHit()は、2つのGameObjectが 重なっているかを調べます- 使っていない弾や敵は判定しないようにします
RespawnEnemy()は、敵を画面上部の ランダムな位置へ出し直します
cpp
// 2つの GameObject が重なっているか調べる
bool IsHit(GameObject a, GameObject b)
{
// どちらかが無効なら当たっていないことにする
if (a.isActive == false || b.isActive == false)
return false;
// 左右上下の範囲が重なっているかを調べる
return a.x < b.x + b.width &&
b.x < a.x + a.width &&
a.y < b.y + b.height &&
b.y < a.y + a.height;
}
// 敵を画面上部に出し直す
GameObject RespawnEnemy(GameObject enemy)
{
// 画面上部のランダムなX座標に移動する
enemy.x = GetRand(640 - enemy.width);
enemy.y = 80;
enemy.isActive = true;
return enemy;
}サンプルコード(4/11)
発射処理を関数にする
- 第05回で
whileの中に書いていた発射処理をShootBullet()にまとめます - 空いている弾を探して、プレイヤーの位置から発射します
- 発射できたら
shotTimerに待ち時間を入れます
cpp
// 空いている場所を探して弾を1発だけ出す
void ShootBullet()
{
// Zキーが押されていない、または待ち時間中なら発射しない
if (CheckHitKey(KEY_INPUT_Z) == false || shotTimer > 0)
return;
for (int i = 0; i < bulletMax; i++)
{
// 使っていない弾を見つけたら発射する
if (bullets[i].isActive == false)
{
bullets[i].x = player.x + 12;
bullets[i].y = player.y;
bullets[i].width = 8;
bullets[i].height = 16;
bullets[i].isActive = true;
shotTimer = shotInterval;
break;
}
}
}サンプルコード(5/11)
プレイヤーと敵を更新する
- プレイヤー移動は第05回と同じです
- 今回は、画面外へ出ないように移動範囲を 制限しています
- 敵は左右に動き、画面端で向きを反転します
cpp
// プレイヤーと敵を更新する
void UpdatePlayerAndEnemy()
{
// 十字キーでプレイヤーを移動する
if (CheckHitKey(KEY_INPUT_LEFT)) player.x -= moveSpeed;
if (CheckHitKey(KEY_INPUT_RIGHT)) player.x += moveSpeed;
if (CheckHitKey(KEY_INPUT_UP)) player.y -= moveSpeed;
if (CheckHitKey(KEY_INPUT_DOWN)) player.y += moveSpeed;
// 画面の左と上にはみ出さないようにする
if (player.x < 0) player.x = 0;
if (player.y < 0) player.y = 0;
// 画面の右と下にはみ出さないようにする
if (player.x > 640 - player.width)
player.x = 640 - player.width;
if (player.y > 480 - player.height)
player.y = 480 - player.height;
// 敵を左右に動かす
enemy.x += enemySpeed;
// 画面端に当たったら移動方向を反転する
if (enemy.x < 0 || enemy.x + enemy.width > 640)
enemySpeed *= -1;
}サンプルコード(6/11)
弾を移動する
- 第05回の
UpdateBullets()に、 敵との命中判定を追加します - まずは使用中の弾だけを上へ動かし、 画面外に出た弾を未使用に戻します
cpp
// 使用中の弾だけを上に動かし、敵との判定を行う
void UpdateBullets()
{
for (int i = 0; i < bulletMax; i++)
{
// 使用中ではない弾は更新しない
if (bullets[i].isActive == false)
continue;
// 弾を上に動かす
bullets[i].y -= bulletSpeed;
// 画面外に出たら未使用に戻す
if (bullets[i].y < -16)
bullets[i].isActive = false;サンプルコード(7/11)
敵との命中判定を行う
- 弾が敵に当たったら、その弾を未使用に戻し、 スコアを増やします
- スコアが足りなければ敵を再出現させ、 目標に達したら
GameClearにします
cpp
// 弾が敵に当たったらスコアを増やす
if (IsHit(bullets[i], enemy))
{
bullets[i].isActive = false;
score++;
if (score >= clearScore)
{
enemy.isActive = false;
gameState = GameClear;
}
else
{
// まだクリアでなければ敵を再出現させる
enemy = RespawnEnemy(enemy);
}
}
}
}サンプルコード(8/11)
DrawBullets も GameObject 使用に
- 弾は
GameObjectになったため、widthとheightを使って矩形で描画します - 第05回と同じく、描画も関数に分けたままにします
cpp
// 使用中の弾だけを描画する
void DrawBullets()
{
for (int i = 0; i < bulletMax; i++)
{
if (bullets[i].isActive)
{
DrawBox(
bullets[i].x, bullets[i].y,
bullets[i].x + bullets[i].width,
bullets[i].y + bullets[i].height,
GetColor(255, 255, 0), TRUE);
}
}
}サンプルコード(9/11)
初期化処理
- DXライブラリの初期化と画像読み込みは、 第05回と同じ流れです
- 今回は敵画像も読み込みます
cpp
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int)
{
// ウィンドウモードで起動する
ChangeWindowMode(TRUE);
if (DxLib_Init() == -1)
return -1;
SetDrawScreen(DX_SCREEN_BACK);
// プレイヤー画像と敵画像を読み込む
int playerHandle = LoadGraph(L"Images/Player.png");
int enemyHandle = LoadGraph(L"Images/Enemy.png");サンプルコード(10/11)
Playing の間だけ更新する
Playingの間だけ、入力、発射、更新、 命中判定を進めますshotTimerは第05回から変わらず、 発射間隔を制御するために使います- プレイヤーが敵に当たったら
gameStateをGameOverにします
cpp
while (ProcessMessage() == 0 &&
CheckHitKey(KEY_INPUT_ESCAPE) == 0)
{
switch (gameState)
{
case Playing:
// プレイ中だけゲームの更新処理を行う
UpdatePlayerAndEnemy();
// 連射制御用タイマーを減らす
if (shotTimer > 0)
shotTimer--;
ShootBullet();
UpdateBullets();
// プレイヤーが敵に触れたらゲームオーバー
if (IsHit(player, enemy))
{
gameState = GameOver;
}
break;
}サンプルコード(11/11)
描画と結果表示
- プレイヤーと敵を描画し、 弾は
DrawBullets()で描画します - 状態に応じて結果の文字を表示します
- 最後に
ScreenFlip()で画面へ反映します
cpp
// 1フレーム分の描画を行う
ClearDrawScreen();
DrawGraph(player.x, player.y, playerHandle, TRUE);
// 敵が有効なときだけ描画する
if (enemy.isActive)
DrawGraph(enemy.x, enemy.y, enemyHandle, TRUE);
DrawBullets();
DrawFormatString(10, 10, GetColor(255, 255, 255),
L"SCORE %d / %d", score, clearScore);
// 状態に応じて結果を表示する
if (gameState == GameOver)
DrawString(260, 220, L"GAME OVER",
GetColor(255, 255, 255));
if (gameState == GameClear)
DrawString(260, 220, L"GAME CLEAR",
GetColor(255, 255, 255));
ScreenFlip();
}
DxLib_End();
return 0;
}サンプルコードを打ち込む際は インデントがズレてないかに 注意してください
処理のまとまりが わかりやすくなるよ
実習1: 弾と敵の命中判定
当たった弾を消し、敵を出し直す
- 弾を
GameObjectの配列として用意し直す IsHitで弾と敵の重なりを判定する- 弾が敵に当たったら、弾の
isActiveをfalseにする enemy = RespawnEnemy(enemy);で敵を出し直す- 敵を何度も狙えることを確認する
- 弾が当たらない場合は、弾の位置と大きさを
DrawBoxで確認します
実習2: 状態管理を追加する
結果を画面に表示する
enum GameStateを定義するGameState gameState = Playing;を用意する- 弾が敵に当たったら
scoreを増やす score >= clearScoreになったらGameClearにする- プレイヤーが敵に当たったら
GameOverにする - 状態に応じて
GAME CLEARまたはGAME OVERを表示する
- 状態を変えるだけでなく、表示が切り替わるところまで確認します
実習3: 基本形を完成させる
遊べる状態に整える
Playingの間だけ、入力、発射、更新、判定を行う- 第05回の発射間隔を残したまま、状態管理を組み込む
- プレイヤーが画面外に出ないように座標を制限する
- 敵を左右に動かし、画面端で反転させる
- 敵を一定数倒すとクリア、敵に当たるとゲームオーバーになるようにする
- プレイヤー、敵、弾の位置や大きさを調整する
- 余裕があれば、敵や弾の速度を変えて難易度を調整します
余裕課題: 倒すたびに難しくする
敵を少しずつ速くする
cpp
// 敵に弾が当たったタイミングで追加する
if (enemySpeed > 0)
enemySpeed++;
else
enemySpeed--;- 敵を倒すたびに移動速度を少し上げると、 後半ほど狙いにくくなります
- 敵の速度がプラスの時は加算、マイナスの時は減算して 向きを変えずに速度を上げています
よくあるつまずき
状態が切り替わらないときの確認ポイント
gameState = GameClear;やgameState = GameOver;を代入できているか==と=を取り違えていないかIsHitの中でisActive == falseのものを除外できているか- 弾の
widthとheightを設定しているか RespawnEnemy(enemy)を呼ぶ前に、敵のwidthとheightが設定されているかPlayingの外側で更新処理を続けていないか
コードが長くなってきました
いまこそ整理のタイミング
- 今回は、状態管理、敵の移動、弾の更新、衝突判定、描画を 1つのゲームループの中に組み込みました
- その結果、
if文やfor文の中に、さらにif文が入る場面が増えています ゲームの処理が複雑になれば、コードが複雑になるのも避けられません - ですが、このまま機能を足し続けると「どこが何の処理なのか」を 追えなくなってしまいます
- 次回は、処理に名前を付けたり、データと処理を近くに置いたりして、 コードを整理する考え方に進みます
今回のまとめ
今日のポイント
- 衝突判定の判定結果をゲームのルールにあわせて処理することで ゲームの勝ち負けを決定するようにしました
- 敵を動かし、倒したら再出現させることで、 短いゲームループを繰り返し遊べる形にしてみました
- ゲーム状態を
enumを使って定義し、Playing、GameOver、GameClearと分けることで 更新と表示の処理をゲーム状態別で切り替えられるようになりました - 次回は今まで触れてきた C++ の内容を振り返り、 その上で長くなったコードを整理する新しい方法に触れます