ゲームAIを作るときによく利用されるステートマシンについて、サンプルゲームを使いながら説明していきます。
最終的に出来上がるゲームは以下URLで遊べます。
http://uzutaka.com/Projects/StateMachineSample
UnityのプロジェクトファイルはそのままGithubに置いてあります。Unityフリー版で起動できますので、ぜひcloneして動作をいじってみてください。
https://github.com/takanori/StateMachineSample
自律エージェント
今回作成するゲームでは、赤い戦車は敵で、プレイヤーの青い戦車を見つけると近づいて攻撃してきます。青い戦車は矢印キーかWASDキーで動かし、マウスクリックで弾丸を発車することができます。
敵は、以下の4つの行動を取ることができます。
- 徘徊: フィールド内のランダムな位置に向かって移動する
- 追跡: プレイヤーに近づくように移動する
- 攻撃: プレイヤーに向かって弾丸を発射する
- 爆発: 吹き飛んでから、消滅する
さらに敵は、行動を切り替えるためのセンサとして「聴覚」を持っています。とはいっても「プレイヤーとの距離が小さければエンジン音で気づく」というだけの簡単な仕組みです。
このように「環境を知覚し、自分の目的を達成するため、環境に働きかけを行うもの」を自律エージェントと言います。
以下が自律エージェントの模式図です。「センサ」によって環境を知覚し「エフェクタ」によって環境に働きかけます。
自律エージェントの行動
徘徊行動
徘徊行動として、今回のサンプルでは「フィールド内のランダムな位置を目標地点として設定し、そこに向かって進む。ターゲットに近づいたら、新しい目標地点を設定し、同じことを繰り返す」という行動を実装しています。
public class Enemy : MonoBehaviour { private float speed = 10f; private float rotationSmooth = 1f; private Vector3 targetPosition; private float changeTargetSqrDistance = 40f; private void Start() { targetPosition = GetRandomPositionOnLevel(); } private void Update() { // 目標地点との距離が小さければ、次のランダムな目標地点を設定する float sqrDistanceToTarget = Vector3.SqrMagnitude(transform.position - targetPosition); if (sqrDistanceToTarget < changeTargetSqrDistance) { targetPosition = GetRandomPositionOnLevel(); } // 目標地点の方向を向く Quaternion targetRotation = Quaternion.LookRotation(targetPosition - transform.position); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSmooth); // 前方に進む transform.Translate(Vector3.forward * speed * Time.deltaTime); } public Vector3 GetRandomPositionOnLevel() { float levelSize = 55f; return new Vector3(Random.Range(-levelSize, levelSize), 0, Random.Range(-levelSize, levelSize)); } }
追跡行動
追跡行動は「プレイヤーの方向に向かって進む」という行動です。
public class Enemy : MonoBehaviour { private float speed = 10f; private float rotationSmooth = 1f; private Transform player; private void Start() { // 始めにプレイヤーの位置を取得できるようにする player = GameObject.FindWithTag("Player").transform; } private void Update() { // プレイヤーの方向を向く Quaternion targetRotation = Quaternion.LookRotation(player.position - transform.position); transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, Time.deltaTime * rotationSmooth); // 前方に進む transform.Translate(Vector3.forward * speed * Time.deltaTime); } }
攻撃行動
攻撃行動は「プレイヤーの方向に砲身を動かしつつ、弾丸を発射する」という行動です。
public class Enemy : MonoBehaviour { public Transform turret; public Transform muzzle; public GameObject bulletPrefab; private float attackInterval = 2f; private float turretRotationSmooth = 0.8f; private float lastAttackTime; private Transform player; private void Start() { // 始めにプレイヤーの位置を取得できるようにする player = GameObject.FindWithTag("Player").transform; } private void Update() { // 砲台をプレイヤーの方向に向ける Quaternion targetRotation = Quaternion.LookRotation(player.position - turret.position); turret.rotation = Quaternion.Slerp(turret.rotation, targetRotation, Time.deltaTime * turretRotationSmooth); // 一定間隔で弾丸を発射する if (Time.time > lastAttackTime + attackInterval) { Instantiate(bulletPrefab, muzzle.position, muzzle.rotation); lastAttackTime = Time.time; } } }
爆発行動
爆発行動は「吹き飛ぶような動きをしてから、1秒後に自身を消去する」という行動です。
public class Enemy : MonoBehaviour { private void Start() { // ランダムな吹き飛ぶ力を加える Vector3 force = Vector3.up * 1000f + Random.insideUnitSphere * 300f; rigidbody.AddForce(force); // ランダムに吹き飛ぶ回転力を加える Vector3 torque = new Vector3(Random.Range(-10000f, 10000f), Random.Range(-10000f, 10000f), Random.Range(-10000f, 10000f)); rigidbody.AddTorque(torque); // 1秒後に自身を消去する Destroy(gameObject, 1.0f); } }
ここでは体力という概念をまだ作っていないので、シーンを起動した直後に吹き飛んでしまいます。
この後、最初にライフポイントが3あり、弾丸を食らうごとに1減っていき、0になると爆発行動を起こすようにしていきます。
センサ
続いて、自律エージェントが環境を知覚する部分「センサ」についてです。
追跡行動や攻撃行動の中でプレイヤーの位置を毎フレーム取得していましたが、実はあれもセンサの一種と言えます。 しかし、フィールド内に存在するプレイヤーの位置をどこにいても正確に把握できる、いわば千里眼のようなものになってしまっています。
今後は、プレイヤーが十分に近くにいるときだけ位置を取得できるようにすることで、より自然なセンサ「聴覚」を実装していきます。
また、今回は実装しませんが
- 自分の前方にいるときだけ位置がわかる
- 壁に遮られていないときだけ位置がわかる
といった仕組みにすることで「視覚」のようなセンサを作ることもできます。
自律エージェントのまとめ
ここまでで、環境を知覚し、何らかの目的を達成するために動くことのできる自律エージェントを作ることができました。
しかし、同じ行動をずっと繰り返すだけではAIとして面白みがありません。ステートマシンの仕組みを使って、異なる行動を取る状態へと遷移できるようにしましょう。
ステートマシン
有限ステートマシンとは?
有限ステートマシン (Finite State Machine : FSM、日本語で有限状態機械) とは
「有限個のステート(=状態)を持ち、入力を処理することで、あるステートから別のステートへ遷移したり、出力を引き起こしたりすることができるもの」
です。
ステートマシンは「複数の状態と動作を持つものをわかりやすく抽象化して表現するためのモデル」と考えた方がわかりやすいと思います。
この世の中にある、状態を持つものはたいてい有限ステートマシンとみなすことができます。
例えば、スイッチ付きの電球はオン・オフの2つのステートを持つステートマシンとみなせます。
ステートマシンはAI実装で長らく利用されてきたアーキテクチャです。それには以下の様な理由があります。
- 実装が容易
- 多種多様な実装方法があるが、どれもシンプルである
- デバッグが容易
- コードが行動ごとにまとまりとなるので、不具合が起きた時はそのときのステートが参照する部分のみ見れば良くなる
- 計算のオーバーヘッドが小さい
- 原則的に遷移の規則がハードコードされているため、遷移先を取得するために必要な計算量はとても小さい
- 柔軟性がある
- 新しいステートや遷移を追加するのが容易である
- ニューラルネットワークやBehaviour Treeといった他のAIの仕組みと簡単に組み合わせられる
これらの利便性から、AIに限らず状態を持つもの全般を実装する際に使われます。
ステートマシンの実装
ステートマシンはif-else文やswitch文でも作ることができますが、ステートが少し増えてくるだけで保守するのが辛くなります。
色々と応用できるので、一度フレームワークを作ってしまうのがおすすめです。
ここでは、一つのステートを一つのクラスとして扱う、単純なステートマシンの実装を見てみましょう。
ステートの基底クラス
public class State<T> { // このステートを利用するインスタンス protected T owner; public State(T owner) { this.owner = owner; } // このステートに遷移する時に一度だけ呼ばれる public virtual void Enter() {} // このステートである間、毎フレーム呼ばれる public virtual void Execute() {} // このステートから他のステートに遷移するときに一度だけ呼ばれる public virtual void Exit() {} }
ステートに入ったときと出るときに特定の行動を行わせたいという場面が多いので、あらかじめ呼ばれるメソッドを用意してあります。
ステートマシンクラス
public class StateMachine<T> { private State<T> currentState; public StateMachine() { currentState = null; } public State<T> CurrentState { get { return currentState; } } public void ChangeState(State<T> state) { if (currentState != null) { currentState.Exit(); } currentState = state; currentState.Enter(); } public void Update() { if (currentState != null) { currentState.Execute(); } } }
ChangeStateの中で
- 現在のステートのExit関数を呼び出す
- 現在のステートを新しいステートに変更する
- 現在のステートのEnter関数を呼び出す
という処理を行っています。
また、毎フレーム呼ばれるUpdate関数の中で、Execute関数を読んでいます。
ステートを持つオブジェクトの基底クラス
これらStateクラス、StateMachineクラスをそのまま使うこともできますが、準備のためのコードを毎回繰り返し書くことになります。
そこでステートマシンを使うクラスの基底を先に用意しておきます。
public abstract class StatefulObjectBase<T, TEnum> : MonoBehaviour where T : class where TEnum : System.IConvertible { protected List<State<T>> stateList = new List<State<T>>(); protected StateMachine<T> stateMachine; public virtual void ChangeState(TEnum state) { if (stateMachine == null) { return; } stateMachine.ChangeState(stateList[state.ToInt32(null)]); } public virtual bool IsCurrentState(TEnum state) { if (stateMachine == null) { return false; } return stateMachine.CurrentState == stateList[state.ToInt32(null)]; } protected virtual void Update() { if (stateMachine != null) { stateMachine.Update(); } } }
StatefulObjectBaseクラスは、取り得るステートのリストを持ち、enum型を引数とするChangeState関数で、他のステートに遷移することができます。
戦車のAI
ステートマシンのフレームワークを作ったので、いよいよ戦車ゲームの敵AIを実装していきます。
敵戦車のAIは、自律エージェントのところで作った4つのステートを持ちます。
- 徘徊: フィールド内のランダムな位置に向かって移動する
- 追跡: プレイヤーに近づくように移動する
- 攻撃: プレイヤーに向かって弾丸を発射する
- 爆発: 吹き飛んでから、消滅する
ここからは敵AIクラスの重要な部分のみ取り上げていきます。全体を見たい方は https://github.com/takanori/StateMachineSample を参照してください。
取りうるステートの宣言
まず、敵AIの取りうるステートをenum型で宣言します。
public enum EnemyState { Wander, Pursuit, Attack, Explode, }
ステートマシンの初期化
クラスの初期化関数の中でステートマシンを初期化します。
public void Initialize() { // 始めにプレイヤーの位置を取得できるようにする player = GameObject.FindWithTag("Player").transform; life = maxLife; // ステートマシンの初期設定 stateList.Add(new StateWander(this)); stateList.Add(new statePursuit(this)); stateList.Add(new StateAttack(this)); stateList.Add(new StateExplode(this)); stateMachine = new StateMachine<Enemy>(); ChangeState(EnemyState.Wander); }
ステート実装
Stateを継承した、敵AI用のステートを宣言します。
ここでは、4つのステートの内、例として追跡ステートを挙げています。
/// <summary> /// ステート: 追跡 /// </summary> private class statePursuit : State<Enemy> { public statePursuit(Enemy owner) : base(owner) {} public override void Enter() {} public override void Execute() { // プレイヤーとの距離が小さければ、攻撃ステートに遷移 float sqrDistanceToPlayer = Vector3.SqrMagnitude(owner.transform.position - owner.player.position); if (sqrDistanceToPlayer < owner.attackSqrDistance - owner.margin) { owner.ChangeState(EnemyState.Attack); } // プレイヤーとの距離が大きければ、徘徊ステートに遷移 if (sqrDistanceToPlayer > owner.pursuitSqrDistance + owner.margin) { owner.ChangeState(EnemyState.Wander); } // プレイヤーの方向を向く Quaternion targetRotation = Quaternion.LookRotation(owner.player.position - owner.transform.position); owner.transform.rotation = Quaternion.Slerp(owner.transform.rotation, targetRotation, Time.deltaTime * owner.rotationSmooth); // 前方に進む owner.transform.Translate(Vector3.forward * owner.speed * Time.deltaTime); } public override void Exit() {} }
追跡ステートでは、Enter関数とExit関数は何もしていません。
少し前に自律エージェントのところで書いたコードのUpdate関数と比べて
- プレイヤーとの距離が小さければ、攻撃ステートに遷移
- プレイヤーとの距離が大きければ、徘徊ステートに遷移
という処理が増えていることと、owner変数からメソッドを呼び出すようになっている点が異なります。
ライフと爆発行動
プレイヤーの弾丸が当たるとライフが減り、ライフが0になると爆発行動に遷移する処理は、以下のようになります。
public void TakeDamage() { life--; if (life <= 0) { ChangeState(EnemyState.Explode); } }
爆発行動への遷移は、現在の状態に依らず行われるようになっています。
まとめ
以上で戦車ゲームの敵AIが完成しました。
一見難しそうなAIという領域ですが、案外簡単じゃないかと感じていただけたら嬉しいです。
興味を持たれた方は、以下の参考文献なども参照して、ぜひぜひ面白いAIを使ったゲームを作ってみてください。