Appearance
第09回 敵AIとステートマシン
前回: 第08回 敵の物理挙動とライフサイクル / 次回: 第10回 タイトル画面と状態遷移の整理
今回の授業内容
- 敵の状態を定義し、ステートでの行動と状態遷移を実装する
- 敵の行動と、第08回で作った重力・地形衝突を組み合わせ
- 状態を変更する条件と、状態が変わった瞬間の初期化を分ける。
- 複数の敵が、それぞれ別の状態とタイマーを持つことを確認する。
- 待機時間、移動速度、追跡距離を変更し、敵の性格を調整する。
今回の配布コード
第08回の敵へ、Idle、Patrol、Chase、Defeatedの4状態を追加します。
敵の重力、地形衝突、落下無効化は第08回のUpdateEnemyPhysics()を引き続き使用します。
配布ファイル一覧
| ファイル | 内容 |
|---|---|
| Source/Game.h | EnemyState、状態タイマー、調整用の定数 |
| 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 | 踏まれた直後の上向き速度 |
速度を表すenemyPatrolSpeedとenemyChaseSpeedは、プレイヤーや敵のvx、vyと同じくfloatで定義します。
距離とフレーム数は整数で数えるため、intのままです。
この回のカスタマイズでは、長い関数を変更せず、まずこれらの数値を変更します。
初期状態を設定する
ResetEnemies()では、位置や速度と一緒に状態も初期化します。
Game.cppcpp
enemy.vx = 0.0f;
enemy.remainderX = 0.0f;
enemy.state = EnemyState::Idle;
enemy.stateTimer = i * 15;初期状態はIdleです。
待機中は横移動しないため、vxも0.0fから始めます。stateTimerへi * 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 | 横移動と接触を停止する | ステージ外へ落ちて無効になる |
追跡開始と終了を同じ距離にすると、境界付近でPatrolとChaseが交互に切り替わることがあります。
開始を180、終了を260として間を空けることで、少し離れただけでは追跡をやめない動きになります。
待機から巡回へ切り替える
UpdateEnemyBehavior()の先頭で、その状態になってからの時間を進めます。
Game.cppcpp
enemy.stateTimer++;Idleでは移動せず、待機時間が終わったときだけPatrolへ変更します。
Game.cppcpp
case EnemyState::Idle:
if (enemy.stateTimer >= enemyIdleFrames)
ChangeEnemyState(enemy, EnemyState::Patrol);
break;待機処理を追加したら、次の内容を確認します。
enemyIdleFramesを一度180へ変更する。- 敵が約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()で絶対値にすると、プレイヤーが敵の左右どちらにいても距離として比較できます。
追跡開始条件を追加したら、次の内容を確認します。
- 敵から離れた場所で待ち、巡回を続けることを確認する。
- 敵へ近づき、追跡が始まる位置を確認する。
enemyChaseStartDistanceを80へ変更し、かなり近づくまで追跡しなくなることを確認する。- 確認後、値を
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;enemyChaseSpeedをenemyPatrolSpeedより大きくすると、追跡に切り替わったことを動きから判断しやすくなります。
複数の敵を配置し、次の内容を確認します。
複数の敵の間を移動し、近い敵だけが追跡を始めることを確認します。
状態は敵ごとに保存されているため、すべての敵が同時に追跡へ変わる必要はありません。
状態変更時の初期化をまとめる
状態を直接代入する場所が増えると、タイマーや速度を戻し忘れます。
状態変更は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;
}
}どの状態へ変わる場合も、stateTimerを0へ戻します。
横移動の方向や速さが切り替わるため、前の状態で残ったremainderXも0.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回の重力で落下し、ステージ外へ出るとisActiveがfalseになります。
敵の動きをカスタマイズする
次の設定例から1つを選ぶか、自分で値を決めます。
| 動き | 待機 | 巡回速度 | 追跡速度 | 開始距離 | 終了距離 |
|---|---|---|---|---|---|
| 標準 | 60 | 2.0f | 3.0f | 180 | 260 |
| のんびり | 120 | 1.25f | 2.0f | 120 | 220 |
| すばやい | 15 | 3.0f | 5.0f | 240 | 320 |
| 近くで急に動く | 90 | 1.5f | 5.0f | 100 | 200 |
最初は1つの値だけを変更し、違いが見えたら別の値を変更します。
動きがおかしくなった場合は、標準の行へ戻せば完成状態へ復帰できます。
今回の完成条件
- 第08回の重力、地形衝突、落下無効化が残っている。
- 敵が
Idle、Patrol、Chase、Defeatedを切り替える。 - 状態ごとに対応する行動だけが実行される。
- 複数の敵が別々の状態とタイマーを持つ。
- 横から敵へ触れるとゲームオーバーになる。
- 上から敵を踏むと
Defeatedになる。 - 待機時間、速度、追跡距離のいずれかを変更し、動きの違いを確認できる。
よくある問題
すべての敵が同時に動き始める
これは不具合ではありません。
開始をずらしたい場合は、ResetEnemies()のstateTimerへi * 15を設定します。
敵が追跡と巡回を細かく繰り返す
enemyChaseStartDistanceよりenemyChaseEndDistanceを大きくします。
標準値は開始180、終了260です。
やられた敵がプレイヤーに当たる
接触判定で、enemy.state == EnemyState::Defeatedの場合を除外しているか確認します。
敵が重力で落ちなくなった
UpdateEnemies()で、UpdateEnemyBehavior(enemy)のあとにUpdateEnemyPhysics(enemy)を呼んでいるか確認します。
敵AIを追加しても、第08回の物理処理は削除しません。
まとめ
- 敵の行動を
Idle、Patrol、Chase、Defeatedへ分けました。 - 行動処理と、第08回から続く物理処理を別の関数にしました。
- 状態変更時のタイマーと速度の初期化を
ChangeEnemyState()へまとめました。 - 複数の敵が、それぞれ別の状態を持つことを確認しました。
- 待機時間、移動速度、追跡距離を変更し、敵の動きをカスタマイズしました。