Skip to content

第09回 敵AIとステートマシン

前回: 第08回 敵の物理挙動とライフサイクル / 次回: 第10回 タイトル画面と状態遷移の整理

今回の授業内容

  • 敵の状態を定義し、ステートでの行動と状態遷移を実装する
  • 敵の行動と、第08回で作った重力・地形衝突を組み合わせ
  • 状態を変更する条件と、状態が変わった瞬間の初期化を分ける。
  • 複数の敵が、それぞれ別の状態とタイマーを持つことを確認する。
  • 待機時間、移動速度、追跡距離を変更し、敵の性格を調整する。

今回の配布コード

第08回の敵へ、IdlePatrolChaseDefeatedの4状態を追加します。
敵の重力、地形衝突、落下無効化は第08回のUpdateEnemyPhysics()を引き続き使用します。

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

配布ファイル一覧
ファイル内容
Source/Game.hEnemyState、状態タイマー、調整用の定数
Source/Game.cpp敵の状態別行動、物理処理、状態変更
Assets/Maps/Stage01.csv複数の敵を別々に追跡させられるステージ

配布コードには完成したステートマシンが含まれています。
授業では、状態を一度にすべて打ち込むのではなく、待機、巡回、追跡、やられの順に動作を確認します。

条件だけを増やすと行動が混ざる

第08回までの敵は、左右へ巡回しながら重力で落下するだけでした。
ここへ待機と追跡を追加すると、「今はどの行動だけを実行するのか」を決める必要があります。

状態を持たず、各処理へ個別のifを追加すると、次のような組み合わせが発生しやすくなります。

  • 待機中なのに巡回処理で移動する。
  • 追跡中にも巡回範囲で方向転換する。
  • やられたあともプレイヤーを追跡する。
  • やられた敵との接触でゲームオーバーになる。

待機、巡回、追跡、やられは、同じフレームに同時実行する行動ではありません。
そこで、現在の行動を1つだけEnemyStateへ保存します。

EnemyStateと状態タイマーを追加する

Game.hcpp
enum class EnemyState
{
    Idle,
    Patrol,
    Chase,
    Defeated
};

enum classを使うと、敵の状態をEnemyState::Patrolのように型名と一緒に書けます。
単なる数値より、コード上で意味を読み取りやすくなります。

Enemyには、現在の状態と、その状態になってからの経過フレームを追加します。

Game.hcpp
struct Enemy
{
    // 位置、速度、巡回範囲、有効フラグ
    EnemyState state;
    int stateTimer;
};

状態とタイマーは敵ごとに持ちます。
1体目がプレイヤーを追跡している間も、離れた場所にいる2体目は待機や巡回を続けられます。

変更しやすい数値をまとめる

敵の性格を変える数値は、Game.hの同じ場所へまとめます。

Game.hcpp
static const int enemyIdleFrames = 60;
static constexpr float enemyPatrolSpeed = 2.0f;
static constexpr float enemyChaseSpeed = 3.0f;
static const int enemyChaseStartDistance = 180;
static const int enemyChaseEndDistance = 260;
static constexpr float enemyDefeatedJumpPower = -6.0f;
定数変更される動き
enemyIdleFramesプレイ開始後に待つ長さ
enemyPatrolSpeed通常巡回の速さ
enemyChaseSpeedプレイヤーを追う速さ
enemyChaseStartDistance追跡を始める距離
enemyChaseEndDistance追跡をやめる距離
enemyDefeatedJumpPower踏まれた直後の上向き速度

速度を表すenemyPatrolSpeedenemyChaseSpeedは、プレイヤーや敵のvxvyと同じくfloatで定義します。
距離とフレーム数は整数で数えるため、intのままです。

この回のカスタマイズでは、長い関数を変更せず、まずこれらの数値を変更します。

初期状態を設定する

ResetEnemies()では、位置や速度と一緒に状態も初期化します。

Game.cppcpp
enemy.vx = 0.0f;
enemy.remainderX = 0.0f;
enemy.state = EnemyState::Idle;
enemy.stateTimer = i * 15;

初期状態はIdleです。
待機中は横移動しないため、vx0.0fから始めます。
stateTimeri * 15を入れることで、すべての敵が同じフレームに動き始めないようにしています。

このずらしが不要なら0へ変更できます。
動作が正しいかを確認するときは、まずすべて0にすると比較しやすくなります。

敵1体の行動と物理を順番に呼ぶ

第08回では、巡回と物理処理をUpdateEnemies()内で行っていました。
第09回では、行動をUpdateEnemyBehavior()、重力と落下をUpdateEnemyPhysics()へ分けます。

Game.cppcpp
void Game::UpdateEnemies()
{
    for (int i = 0; i < enemyCount; i++)
    {
        Enemy& enemy = enemies[i];
        if (!enemy.isActive) continue;

        UpdateEnemyBehavior(enemy);
        UpdateEnemyPhysics(enemy);
    }
}

この順序では、現在の状態に応じて横方向の行動を決めたあと、すべての状態に共通する重力を処理します。
Defeatedだけは床との衝突を行わず、そのまま画面下へ落下させます。

状態遷移を図と表で確認する

図を準備しています…

現在の状態実行する行動次の状態へ移る条件
Idleその場で待つ60フレーム経過したらPatrol
Patrol巡回範囲を左右へ移動するプレイヤーとの距離が180未満ならChase
Chaseプレイヤーの方向へ移動する距離が260より大きければPatrol
Defeated横移動と接触を停止するステージ外へ落ちて無効になる

追跡開始と終了を同じ距離にすると、境界付近でPatrolChaseが交互に切り替わることがあります。
開始を180、終了を260として間を空けることで、少し離れただけでは追跡をやめない動きになります。

待機から巡回へ切り替える

UpdateEnemyBehavior()の先頭で、その状態になってからの時間を進めます。

Game.cppcpp
enemy.stateTimer++;

Idleでは移動せず、待機時間が終わったときだけPatrolへ変更します。

Game.cppcpp
case EnemyState::Idle:
    if (enemy.stateTimer >= enemyIdleFrames)
        ChangeEnemyState(enemy, EnemyState::Patrol);
    break;

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

  1. enemyIdleFramesを一度180へ変更する。
  2. 敵が約3秒間その場で待つことを確認する。
  3. 60へ戻し、約1秒後に巡回を始めることを確認する。

フレーム数は60fpsを基準にしています。
60フレームは約1秒ですが、処理落ちなどがある場合は実時間と完全には一致しません。

巡回から追跡へ切り替える

Patrolでは、第08回までの左右移動を実行します。
そのあと、プレイヤーとの横方向の距離を調べます。

Game.cppcpp
case EnemyState::Patrol:
    MoveEnemyHorizontal(enemy);

    // minXとmaxXによる方向転換

    if (AbsInt(player.x - enemy.x) <
        enemyChaseStartDistance)
    {
        ChangeEnemyState(
            enemy,
            EnemyState::Chase);
    }
    break;

player.x - enemy.xは、左右の位置関係によって正または負になります。
AbsInt()で絶対値にすると、プレイヤーが敵の左右どちらにいても距離として比較できます。

追跡開始条件を追加したら、次の内容を確認します。

  1. 敵から離れた場所で待ち、巡回を続けることを確認する。
  2. 敵へ近づき、追跡が始まる位置を確認する。
  3. enemyChaseStartDistance80へ変更し、かなり近づくまで追跡しなくなることを確認する。
  4. 確認後、値を180へ戻す。

Chaseではプレイヤーの方向を選ぶ

Chaseでは、プレイヤーが左にいれば負、右にいれば正の速度を設定します。

Game.cppcpp
case EnemyState::Chase:
    enemy.vx =
        (player.x < enemy.x) ?
        -enemyChaseSpeed : enemyChaseSpeed;
    MoveEnemyHorizontal(enemy);

    if (AbsInt(player.x - enemy.x) >
        enemyChaseEndDistance)
    {
        ChangeEnemyState(
            enemy,
            EnemyState::Patrol);
    }
    break;

enemyChaseSpeedenemyPatrolSpeedより大きくすると、追跡に切り替わったことを動きから判断しやすくなります。

複数の敵を配置し、次の内容を確認します。

複数の敵の間を移動し、近い敵だけが追跡を始めることを確認します。
状態は敵ごとに保存されているため、すべての敵が同時に追跡へ変わる必要はありません。

状態変更時の初期化をまとめる

状態を直接代入する場所が増えると、タイマーや速度を戻し忘れます。
状態変更はChangeEnemyState()を通して行います。

Game.cppcpp
void Game::ChangeEnemyState(
    Enemy& enemy,
    EnemyState nextState)
{
    if (enemy.state == nextState) return;

    enemy.state = nextState;
    enemy.stateTimer = 0;
    enemy.remainderX = 0.0f;

    if (nextState == EnemyState::Idle)
    {
        enemy.vx = 0.0f;
    }
    else if (nextState == EnemyState::Patrol)
    {
        enemy.vx =
            (enemy.x < enemy.maxX) ?
            enemyPatrolSpeed : -enemyPatrolSpeed;
    }
    else if (nextState == EnemyState::Defeated)
    {
        enemy.vx = 0.0f;
        enemy.vy = enemyDefeatedJumpPower;
    }
}

どの状態へ変わる場合も、stateTimer0へ戻します。
横移動の方向や速さが切り替わるため、前の状態で残ったremainderX0.0fへ戻します。
Defeatedへ変わったときは横移動を止め、上向きの速度を与えます。

敵を踏んでDefeatedへ変更する

プレイヤーと敵のRectが重なっただけでは、上から踏んだのか、横から触れたのかを区別できません。
落下中であり、プレイヤーの足元が敵の上端付近にある場合だけ踏みつけと判断します。

Game.cppcpp
const bool stomp = player.vy > 0.0f &&
    playerRect.y + playerRect.height
        <= enemyRect.y + 12;

if (stomp &&
    enemy.state != EnemyState::Defeated)
{
    ChangeEnemyState(
        enemy,
        EnemyState::Defeated);
    player.vy = -7.0f;
    player.remainderY = 0.0f;
    continue;
}

Defeated中の敵は、プレイヤーをゲームオーバーにする接触判定から除外します。
敵は第08回の重力で落下し、ステージ外へ出るとisActivefalseになります。

敵の動きをカスタマイズする

次の設定例から1つを選ぶか、自分で値を決めます。

動き待機巡回速度追跡速度開始距離終了距離
標準602.0f3.0f180260
のんびり1201.25f2.0f120220
すばやい153.0f5.0f240320
近くで急に動く901.5f5.0f100200

最初は1つの値だけを変更し、違いが見えたら別の値を変更します。
動きがおかしくなった場合は、標準の行へ戻せば完成状態へ復帰できます。

今回の完成条件

  • 第08回の重力、地形衝突、落下無効化が残っている。
  • 敵がIdlePatrolChaseDefeatedを切り替える。
  • 状態ごとに対応する行動だけが実行される。
  • 複数の敵が別々の状態とタイマーを持つ。
  • 横から敵へ触れるとゲームオーバーになる。
  • 上から敵を踏むとDefeatedになる。
  • 待機時間、速度、追跡距離のいずれかを変更し、動きの違いを確認できる。

よくある問題

すべての敵が同時に動き始める

これは不具合ではありません。
開始をずらしたい場合は、ResetEnemies()stateTimeri * 15を設定します。

敵が追跡と巡回を細かく繰り返す

enemyChaseStartDistanceよりenemyChaseEndDistanceを大きくします。
標準値は開始180、終了260です。

やられた敵がプレイヤーに当たる

接触判定で、enemy.state == EnemyState::Defeatedの場合を除外しているか確認します。

敵が重力で落ちなくなった

UpdateEnemies()で、UpdateEnemyBehavior(enemy)のあとにUpdateEnemyPhysics(enemy)を呼んでいるか確認します。
敵AIを追加しても、第08回の物理処理は削除しません。

まとめ

  • 敵の行動をIdlePatrolChaseDefeatedへ分けました。
  • 行動処理と、第08回から続く物理処理を別の関数にしました。
  • 状態変更時のタイマーと速度の初期化をChangeEnemyState()へまとめました。
  • 複数の敵が、それぞれ別の状態を持つことを確認しました。
  • 待機時間、移動速度、追跡距離を変更し、敵の動きをカスタマイズしました。