Appearance
第08回 敵の物理挙動とライフサイクル
前回: 第07回 CSVによる敵の複数配置 / 次回: 第09回 敵AIとステートマシン
今回の授業内容
- 敵に重力の影響を加え、縦方向の速度操作を行う
- プレイヤーと同じタイル衝突処理を敵の移動にも利用する
- ステージ外に落ちた敵を無効化し、更新、描画、接触判定から除外する
- リトライによる敵のリセット処理を実装する
今回の配布コード
第07回で、CSVから複数の敵を配置する機構を実装しました
敵は同時に複数存在できるようになりました
今回は敵1体ずつに重力と地形衝突を適用し、画面外に落下した敵の無効化処理を実装します
配布ファイル一覧
| ファイル | 内容 |
|---|---|
| Source/Game.h | 敵の縦速度、端数移動、物理処理の宣言 |
| Source/Game.cpp | 敵の地形衝突、重力、落下無効化 |
| Source/Stage.cpp | Rectをタイル境界へ戻す衝突処理 |
| Assets/Maps/Stage01.csv | 複数の足場と穴を持つステージ |
第07回からの更新点
| 第07回から引き続き使う機能 | 第08回で追加する機能 |
|---|---|
Enemy enemies[]による固定配列 | 敵ごとのvyとremainderY |
enemyCountによる使用数の管理 | UpdateEnemyPhysics() |
isActiveによる有効・無効の区別 | 横方向と縦方向の地形衝突 |
float vxとremainderXによる横移動 | ステージ外へ落ちた敵の無効化 |
minXとmaxXの巡回範囲 | 壁や巡回端での端数リセット |
ResetEnemies()による再配置 | リトライ時の速度と有効状態の復元 |
今回は敵が地形上を移動し、穴に落ち、ゲームの処理対象から外れるところまでを実装します
敵へ縦速度と縦方向の端数を追加する
第07回のEnemyは、横速度vxとremainderXを使って左右へ移動していました。
今回は重力による落下を扱うため、縦速度vyと縦方向の端数を残すremainderYを追加します。
Game.hcpp
struct Enemy
{
int x;
int y;
int width;
int height;
float vx;
float vy;
float remainderX;
float remainderY;
int minX;
int maxX;
bool isActive;
};プレイヤーと敵はどちらも、横速度と縦速度をfloatで扱います。
座標xとyはピクセル単位の整数のままにし、座標へ反映していない移動量だけをremainderXとremainderYへ残します。
INFO
これらはやや迂遠な実装です。
このゲームの規模であれば、座標をfloatにして直接加算しても問題ありません。
Gameには、敵1体の物理処理を行う関数を追加します。
Game.hcpp
void UpdateEnemies();
void UpdateEnemyPhysics(Enemy& enemy);
void MoveEnemyHorizontal(Enemy& enemy);
void MoveEnemyVertical(Enemy& enemy);リトライ時に物理状態も戻す
ResetEnemies()は、CSVの敵配置から敵を作り直す関数です。
位置だけでなく、今回追加した縦速度と端数も初期値へ戻します。
Game.cppcpp
enemy.vx = (i % 2 == 0) ? 2.0f : -2.0f;
enemy.vy = 0.0f;
enemy.remainderX = 0.0f;
enemy.remainderY = 0.0f;
enemy.minX = enemy.x - 64;
enemy.maxX = enemy.x + 64;
enemy.isActive = true;isActiveをtrueへ戻すことで、前回のプレイ中に落下して無効になった敵も復元されます。
敵の移動と地形衝突
敵の横移動の処理の際、プレイヤーにも使っているStage::ResolveHorizontal()を使って、壁との衝突を処理します。
これで敵が巡回範囲の端や壁にぶつかったときに反転するようになります。
Game.cppcpp
void Game::MoveEnemyHorizontal(Enemy& enemy)
{
enemy.remainderX += enemy.vx;
const int pixelMove =
static_cast<int>(enemy.remainderX);
enemy.remainderX -= pixelMove;
if (pixelMove == 0) return;
Rect movedRect = EnemyRect(enemy);
if (stage.ResolveHorizontal(movedRect, pixelMove))
{
enemy.vx = -enemy.vx;
enemy.remainderX = 0.0f;
}
enemy.x = movedRect.x;
}ResolveHorizontal()は、整数化したpixelMoveを加えたRectと重なるタイルを調べます。
壁へ重なった場合はRectを壁の手前へ戻し、trueを返します。
衝突したときだけvxの向きを反転し、直前の向きで残ったremainderXを0.0fへ戻します。
またenemy.x = movedRect.x;で壁にめり込まないよう補正された座標を設定します。
ここまで実装が済んだら一度ゲームを起動し、敵が壁へめり込まず、手前で方向転換するかを確認してみましょう。
敵の行動と物理処理
UpdateEnemies()は、固定配列から活動中の敵を1体ずつ取り出します。
左右の巡回を処理したあと、今回追加するUpdateEnemyPhysics()を呼び出します。
Game.cppcpp
void Game::UpdateEnemies()
{
for (int i = 0; i < enemyCount; i++)
{
Enemy& enemy = enemies[i];
if (!enemy.isActive) continue;
MoveEnemyHorizontal(enemy);
if (enemy.x < enemy.minX)
{
enemy.x = enemy.minX;
enemy.vx = -enemy.vx;
enemy.remainderX = 0.0f;
}
else if (enemy.x > enemy.maxX)
{
enemy.x = enemy.maxX;
enemy.vx = -enemy.vx;
enemy.remainderX = 0.0f;
}
UpdateEnemyPhysics(enemy);
}
}巡回方法はUpdateEnemies()、重力や落下条件はUpdateEnemyPhysics()に実装されています。
敵がどの状態にあろうと重力はかかりつづけます。
状態ごとの行動ロジックと物理挙動を混在させると、見通しの悪い複雑な処理になってしまうかもしれません。
ここでは双方の処理を分割して影響を最小限に抑え、簡潔なコードになるよう努めています。
第09回では、巡回部分をさらに敵AIの行動ロジックとして発展させます。
ロジック処理が分割されていれば、更新個所も最小限に抑えることができます。
重力と縦方向の衝突を追加する
UpdateEnemyPhysics()では、重力、縦移動、ステージ外判定を順番に処理します。
Game.cppcpp
void Game::UpdateEnemyPhysics(Enemy& enemy)
{
enemy.vy += enemyGravity;
enemy.vy = Clamp(
enemy.vy,
-enemyMaxFallSpeed,
enemyMaxFallSpeed);
MoveEnemyVertical(enemy);
if (enemy.y > Stage::mapHeight * Stage::tileSize + 120)
enemy.isActive = false;
}enemyGravityは1フレームごとに加える値、enemyMaxFallSpeedは落下速度の上限です。
速度を無制限に増やすと、1フレームの移動量が大きくなり、タイルを通り抜けやすくなります。
MoveEnemyVertical()では、端数を整数移動量へ変換してからRectを移動します。
Game.cppcpp
void Game::MoveEnemyVertical(Enemy& enemy)
{
enemy.remainderY += enemy.vy;
const int pixelMove =
static_cast<int>(enemy.remainderY);
enemy.remainderY -= pixelMove;
if (pixelMove == 0) return;
Rect movedRect = EnemyRect(enemy);
if (stage.ResolveVertical(movedRect, pixelMove))
{
enemy.vy = 0.0f;
enemy.remainderY = 0.0f;
}
enemy.y = movedRect.y;
}床へ衝突した場合は、落下速度を消滅させることとします。
ここではvyとremainderYを0へ戻します。
端数を残すと、停止したあとも小さな移動量が蓄積したままになります。
物理処理を追加したら、次の内容を確認します。
- 足場の上にいる敵が床を通り抜けないことを確認する。
- 穴の近くにいる敵が、そのまま空中を移動せず落下することを確認する。
- 壁と床の角に触れても、敵がタイルへ埋まらないことを確認する。
ステージ外の敵を無効化する
画面外に落ちた敵は、isActiveをfalseにして処理対象から外します。
Game.cppcpp
if (enemy.y > Stage::mapHeight * Stage::tileSize + 120)
enemy.isActive = false;isActiveは次の場所で同じ意味を持ちます。
| 処理 | falseの敵を除外する理由 |
|---|---|
| 更新 | 落下後も座標や速度を変え続けないため |
| 描画 | 画面外の敵を描画しないため |
| プレイヤーとの接触判定 | 見えない敵でゲームオーバーにならないため |
| リトライ | ResetEnemies()でtrueへ戻して再利用するため |
cpp
if (!enemy.isActive) continue;有効フラグは、敵を扱うすべての処理で確認する必要があります。
例えば描画だけキャンセルして接触判定を残してしまうと、見えない敵に触れてゲームオーバーになるといった不具合が発生します。
敵が無効になって戻るまでの流れ
isActiveが表しているのは、敵AIの行動ではなく、ゲームの処理対象かどうかです。
図を準備しています…
今回、敵を配列から削除していません。
1体が無効になっても敵の番号は変わらず、固定配列内の同じ場所を使い続けます。
カスタマイズして違いを見る
次のうち1つを変更し、ゲーム画面で違いを確認します。
すべてを一度に変える必要はありません。
| 変更する場所 | 変更例 | 見える変化 |
|---|---|---|
enemyGravity | 0.55fから0.25f | 敵がゆっくり落下する |
enemyMaxFallSpeed | 12.0fから6.0f | 長く落下したときの速度が遅くなる |
enemy.vx | 2.0fから1.5f | 巡回速度が遅くなる |
minX、maxX | 64を96へ変更 | 巡回範囲が広がる |
CSVの値3 | 穴の近くへ移動 | 敵が落下する場所を変えられる |
変更後に動かなくなった場合は配布コードのGame.hとGame.cppを見比べ、変更した行を戻しましょう。
今回の完成条件
- 第07回のCSV配置と固定配列による複数敵管理が残っている。
- 敵が壁と床を通り抜けない。
- 敵が足場のない場所では落下する。
- 落下した敵が更新、描画、接触判定の対象にならない。
- 1体が無効になっても、ほかの敵は動き続ける。
- リトライすると、すべての敵の位置、速度、移動端数、有効状態が初期値へ戻る。
- 重力、巡回範囲、配置のいずれかを変更し、動きの違いを確認できる。
よくある問題
敵が空中を移動する
UpdateEnemies()の最後でUpdateEnemyPhysics(enemy)を呼んでいるか確認します。
宣言だけを追加しても、関数を呼ばなければ重力は実行されません。
敵が床へ沈む
MoveEnemyVertical()の最後でmovedRect.yをenemy.yへ戻しているか確認します。
また、衝突したときにvyとremainderYを両方0へ戻します。
落下した敵が見えない場所で当たる
描画だけでなく、UpdatePlaying()内の敵との接触判定でもenemy.isActiveを確認します。
リトライ後も敵が消えたままになる
ResetEnemies()で、すべての敵のisActiveをtrueへ戻しているか確認します。
まとめ
- 第07回の複数敵管理を保ったまま、敵へ重力と地形衝突を追加しました。
- プレイヤーと同じ浮動小数点数の速度と端数保持を、敵の横方向と縦方向に使いました。
- 敵の巡回と物理処理を別の関数に分けました。
isActiveによって、固定配列から削除せずに敵を無効化しました。- 無効な敵を更新、描画、接触判定から除外しました。
- リトライ時に、CSVの配置からすべての敵を復元しました。
- 重力、巡回範囲、CSV配置を変更し、自分なりの動きへ調整できるようになりました。