Skip to content

第08回 敵の物理挙動とライフサイクル

前回: 第07回 CSVによる敵の複数配置 / 次回: 第09回 敵AIとステートマシン

今回の授業内容

  • 敵に重力の影響を加え、縦方向の速度操作を行う
  • プレイヤーと同じタイル衝突処理を敵の移動にも利用する
  • ステージ外に落ちた敵を無効化し、更新、描画、接触判定から除外する
  • リトライによる敵のリセット処理を実装する

今回の配布コード

第07回で、CSVから複数の敵を配置する機構を実装しました
敵は同時に複数存在できるようになりました
今回は敵1体ずつに重力と地形衝突を適用し、画面外に落下した敵の無効化処理を実装します

第08回のコード一式をダウンロード

配布ファイル一覧
ファイル内容
Source/Game.h敵の縦速度、端数移動、物理処理の宣言
Source/Game.cpp敵の地形衝突、重力、落下無効化
Source/Stage.cppRectをタイル境界へ戻す衝突処理
Assets/Maps/Stage01.csv複数の足場と穴を持つステージ

第07回からの更新点

第07回から引き続き使う機能第08回で追加する機能
Enemy enemies[]による固定配列敵ごとのvyremainderY
enemyCountによる使用数の管理UpdateEnemyPhysics()
isActiveによる有効・無効の区別横方向と縦方向の地形衝突
float vxremainderXによる横移動ステージ外へ落ちた敵の無効化
minXmaxXの巡回範囲壁や巡回端での端数リセット
ResetEnemies()による再配置リトライ時の速度と有効状態の復元

今回は敵が地形上を移動し、穴に落ち、ゲームの処理対象から外れるところまでを実装します

敵へ縦速度と縦方向の端数を追加する

第07回のEnemyは、横速度vxremainderXを使って左右へ移動していました。
今回は重力による落下を扱うため、縦速度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で扱います。
座標xyはピクセル単位の整数のままにし、座標へ反映していない移動量だけをremainderXremainderYへ残します。

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;

isActivetrueへ戻すことで、前回のプレイ中に落下して無効になった敵も復元されます。

敵の移動と地形衝突

敵の横移動の処理の際、プレイヤーにも使っている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の向きを反転し、直前の向きで残ったremainderX0.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;
}

床へ衝突した場合は、落下速度を消滅させることとします。
ここではvyremainderY0へ戻します。
端数を残すと、停止したあとも小さな移動量が蓄積したままになります。

物理処理を追加したら、次の内容を確認します。

  1. 足場の上にいる敵が床を通り抜けないことを確認する。
  2. 穴の近くにいる敵が、そのまま空中を移動せず落下することを確認する。
  3. 壁と床の角に触れても、敵がタイルへ埋まらないことを確認する。

ステージ外の敵を無効化する

画面外に落ちた敵は、isActivefalseにして処理対象から外します。

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つを変更し、ゲーム画面で違いを確認します。
すべてを一度に変える必要はありません。

変更する場所変更例見える変化
enemyGravity0.55fから0.25f敵がゆっくり落下する
enemyMaxFallSpeed12.0fから6.0f長く落下したときの速度が遅くなる
enemy.vx2.0fから1.5f巡回速度が遅くなる
minXmaxX6496へ変更巡回範囲が広がる
CSVの値3穴の近くへ移動敵が落下する場所を変えられる

変更後に動かなくなった場合は配布コードのGame.hGame.cppを見比べ、変更した行を戻しましょう。

今回の完成条件

  • 第07回のCSV配置と固定配列による複数敵管理が残っている。
  • 敵が壁と床を通り抜けない。
  • 敵が足場のない場所では落下する。
  • 落下した敵が更新、描画、接触判定の対象にならない。
  • 1体が無効になっても、ほかの敵は動き続ける。
  • リトライすると、すべての敵の位置、速度、移動端数、有効状態が初期値へ戻る。
  • 重力、巡回範囲、配置のいずれかを変更し、動きの違いを確認できる。

よくある問題

敵が空中を移動する

UpdateEnemies()の最後でUpdateEnemyPhysics(enemy)を呼んでいるか確認します。
宣言だけを追加しても、関数を呼ばなければ重力は実行されません。

敵が床へ沈む

MoveEnemyVertical()の最後でmovedRect.yenemy.yへ戻しているか確認します。
また、衝突したときにvyremainderYを両方0へ戻します。

落下した敵が見えない場所で当たる

描画だけでなく、UpdatePlaying()内の敵との接触判定でもenemy.isActiveを確認します。

リトライ後も敵が消えたままになる

ResetEnemies()で、すべての敵のisActivetrueへ戻しているか確認します。

まとめ

  • 第07回の複数敵管理を保ったまま、敵へ重力と地形衝突を追加しました。
  • プレイヤーと同じ浮動小数点数の速度と端数保持を、敵の横方向と縦方向に使いました。
  • 敵の巡回と物理処理を別の関数に分けました。
  • isActiveによって、固定配列から削除せずに敵を無効化しました。
  • 無効な敵を更新、描画、接触判定から除外しました。
  • リトライ時に、CSVの配置からすべての敵を復元しました。
  • 重力、巡回範囲、CSV配置を変更し、自分なりの動きへ調整できるようになりました。