Unityの新しいパッケージ、Behavior を使用して敵のAIを作る方法について紹介します。
このパッケージは簡単に言えば BehaviorTree 、敵のタスクをモジュール化した上で制御するのを実現する為の機能です。スクリプトでAIを組むよりも、大分楽にこういったフローを作れるのは便利です。
なお今回紹介するUnityのBehaviorパッケージは、出たばかりの機能なのと名前が名前なのでGoogle検索でもあまりヒットしません。また、このパッケージの詳しい使い方については、この記事では深く触れないので、先にマニュアルを読むのをお勧めします。もしくは下のURLが参考になるかもしれません。
(個人的には新機能に一般名詞を使うのはやめてほしいと思います)
最近ではChatGPTにマニュアルを読み込ませて解説してもらうのも便利ですね。
作りたい物
今回のBehaviorTreeで作るAIは、概ね以下のような内容を作っていく予定です。
- ターゲットが無い場合はマップをパトロールする
- ターゲットを発見したらパトロールを停止しターゲットを追跡する
- ターゲットに追いついたらターゲットを攻撃する
- ターゲットに追いつけない状態が5秒間続いたら、追跡をやめてパトロールに戻る
- ただし、ターゲットが目の前にいる場合は追跡を続行する
敵をパトロールさせる
まず最初にやりたいことは、マップを巡回することです。指定のWayPointを巡回し、プレイヤーの安全地帯を脅かす挙動を作ります。
これは Patrol
という最初から用意されているノードを利用することで、簡単に実現することができます。このノードは Self
が WayPoints(GameObjectのList)
を順番に巡回する挙動を実現するノードです。
詳しい機能や設定方法はマニュアルを見るか、フィーリングで理解してください。
なおBehaviorGraph内のInspectorにはSpeed
やAnimatorSpeedParam
など、ノードが動作する際に参照するパラメーターの一覧が表示されていて、必ずちゃんと設定する必要があります。特にPatrol
の Speedの初期値は0 なので、移動させたいなら必ず0より大きい値を設定する必要があります。
詰まりやすい部分として、BlackBoard
のパラメーターを設定する際、Inspectorに露出しているパラメーターを設定を忘れてしまうことがよくあります。例えばSelf
は空の可能性があるので、ちゃんと設定しましょう。またSceneにあるオブジェクトをGraphViewのBlackboardには登録できません。UnityエディターのInspectorで設定しましょう。そして、その内容はGraphViewには反映されません。
せめてSelf
だけでも自動設定されれば便利なのですが…
ターゲットを発見したらパトロールを中断する
BehaviorTreeについて重要な要素の一つはノードが返すStatus
です。 以下のように動作します:
-
Success
:子ノードを即座に実行 -
Running
:現在のノードの処理を継続 -
Failed
:親に失敗したことを通知
何故、今こんなことを言ったのかというと、Patrol
は常にWayPointsの経路を移動しており、ノードの処理が完了する事は無い為です。つまり Running
の状態が続きます。そのためパトロール中に異物を発見した場合、Patrol
の親で Abort
して動作を中断する必要があります。
今回のケースでは、ターゲット(Transform)を事前に取得しておき、一定距離以下ならばAbortを実行します。
Abortで処理を中断した後は、TryInOrder
で代替案を指定します。これで見つけたら〇〇をする挙動を実現します。これは一般的なBehaviorTreeでは Selectorと呼ばれる物で、左から順に子ノードの動作を確認して最初にSuccess
もしくはRunning
を返すツリーだけを実行し、全てがFailed
ならばTryInOrder
自身もFailed
になるというものです。 こんなところでオリジナリティを出してほしくなかった 。
例えば下の画像では、Target
の距離が 4m 以下の場合、 Target
の方を向きつつ「FindPlayer」とログを出します。
TryInOrder
とPatrol
の間にAbort
を追加し、GraphViewのInspectorでCheck Distance
を追加。 このノードでAbortする条件はSelf
と Target
の間が4m以下に指定。これでTargetが4メートルより近づいたらAbort
によりパトロールがキャンセルとなり、TryInOrder
によって次のタスクに切り替わります。
画像ではAbort
した後のタスクは適当にでっち上げています。
ターゲットを発見したらモードの切り替え
今回の敵は概ね「パトロール」と「追跡して攻撃」の2つのモードを持ちます。このモードの切り替えはTryInOrder
を利用する事もできますが、より簡単にEnum
を使用して状態を管理します。
EnemyMode
というEnum
を作成して「パトロール」と「戦闘」を用意します。これはBlackBoard
の機能で作成しても良いですし、コードを書いても良いです。
[BlackboardEnum]
public enum EnemyMode
{
Patrol,
Battle
}
次にSwitch
ノードを用いてステートの状態で動作先を切り替えるようにします。
さらに わざわざAbort If で取り消さなくても、Switch(Flow)は自身が変化した時に子の処理を中断してくれるみたいです。Abort
のVariable Value Change
使用して、EnemeyMode
が切り替わった際に処理をリセットします。
下図の例では、TryInOrder
を使い、ターゲットとの距離が4m未満になった場合、EnemyMode
を Battle
に切り替えるように設定しています。この結果、ターゲットとの距離が近い状態では自動的に戦闘モードに移行します。
あとは画像のようにTryInOrder
の次の処理でEnemeyMode
をBattle
に切り替えれば、ターゲットとの距離が4m未満になった時、EnemeyMode
がBattle
に切り替わり、以降はBattle
以下のノードが動作するようになります。
ターゲットを追跡する
Battle
ではターゲットを追跡し、接近した際に攻撃を行う処理を設定します。これを実現するためにNavigation to target
ノードを使用します。
- 自身(Self)がターゲット(Target)に向かって移動する
- 移動は
NavmeshAgent
を使用して行い、ノードで設定しないパラメーターはコンポーネントの値を使用する - 一定距離(
DistanceThreshold
)に到達したらSuccess
を返す - 到達するまで
Running
を維持
ここではDistanceThreshold
を少し大きめに設定しておくことで、ターゲットの少し前に停止することを期待することができます。
ターゲットを攻撃する
次の処理ではターゲットを攻撃します。攻撃方法はいろいろとありますが、今回は単純にモーションを再生します。モーションを再生したいならAnimator.Play
するのが簡単なのですが、それを実現するノードは無いので、作ります。作るノードは以下内容:
- アニメーションを再生する。再生対象はステートを指定する
- アニメーションの再生中は
Running
を維持 - アニメーションが完了したら
Success
を返す
ノードを作る場合、GraphViewでCreate New
を選択してAction
を指定します。
ノードの名前は「PlayAnimation」、説明文は「Self play animation state StateName」で、Self
とStateName
を引き数にします。
作成したコードを修正します。
ちなみに、NodeDescriptionの story
の項目にて [ ]
で囲っている部分と一致するフィールド名をノードに設定することができます。 story
に設定されていないフィールドはGraphViewのInspectorに表示されるので、無理やり story に入れ込む必要はありません。視認性が低くなります。
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "PlayAnimation",
story: "[Self] play animation state [StateName]",
description:"selfでstateNameのアニメーションを即座に再生する。ノードが変化するまでRunningで、トランジションしたらSuccess",
category: "Action",
id: "8ae8177e3729856778f0a0f2225e6ff9")]
public partial class PlayAnimationAction : Action
{
[SerializeReference] public BlackboardVariable<Animator> self;
[SerializeReference] public BlackboardVariable<string> stateName;
protected override Status OnStart()
{
// 入力に不備があるなら失敗
if( !self.Value || string.IsNullOrEmpty(stateName.Value))
return Status.Failure;
// アニメーションを再生
self.Value.Play(stateName.Value);
return Status.Running;
}
protected override Status OnUpdate()
{
var state = self.Value.GetCurrentAnimatorStateInfo(0);
// ステート名が変わってないならRunningで処理を継続
// 変わったら Success で処理を完了
return state.IsName(stateName.Value) ? Status.Running : Status.Success;
}
}
あとはノードをGraphViewに追加して、Battleの先に配置します。なお攻撃前にターゲットを向くようにLookAt
を追加した方が良いです(意図しない方向を攻撃します)
-
Self
はTarget
まで移動する(移動が完了までRunning) -
Look At
でSelfはTargetの対象の方を向く(即座にSuccess) -
Play Animation
でPunch
ステートを再生(ステートの再生が完了するまでRunning) - すべての処理が完了したのでOnStartから再判定
5秒経ったらターゲットの追跡をやめる
敵がターゲットを発見しても追跡が無限に続くのは好ましくありません。なので、一定時間後に追跡を中断してパトロールに戻る処理を追加します。
この処理には Timeout
ノードを使用します。Timeout
ノードは、指定時間が経過すると Abort
を実行し、親ノードで次の処理に切り替えます。下の画像のように動作します。
ただし、上の画像の通り実装するとタイミングによっては攻撃中にAbortが発動するので、下の画像の様に攻撃と追跡の処理を分け、追跡中のみTimeoutが発生するように修正します。
この実装では以下の条件でパトロールに戻ります
- TargetがNull
- 距離が2メートル以上離れている時にタイムアウトが発生
距離が近ければ追跡を継続する
タイムアウトによって追跡を終了する際、ターゲットが目の前にいる場合でもパトロールに戻ることがないように条件を追加します。
以下の例では、Check Distance
ノードを活用して、ターゲットが一定距離以内にいる場合は追跡を続ける設定を行っています。
ただし上のグラフだと Target
が消滅したときに Check Distance
の Distance
の対象が取得できずにFailed
となり、EnemyMode
がPatrol
へ切り替わりません。
そこでTryInOrder
の中にConditionalGuard
を配置して、その条件がSuccess
である限りEnemyMode
が切り替わらないようにします。
この状態では、パトロールに切り替わる条件は以下の通りです
- TargetがNull
- 距離が9メートル以上離れている時にタイムアウトが発生する
ターゲットの指定を、手動ではなく動的に設定する
最後にターゲットを手動で設定するのではなく動的に設定することで、囮など複数のターゲットにも対応できるようにします。
情報の取得の動作自体は Action
ノード でも実現可能ですが、今回は Flow
を使用して子ノードの動作を監視しながら親ノードで状況を判断できるようにします。Flow
ノードは子ノードを監視しながら親ノードで状況を判断できるため、毎フレーム確認が必要な処理に便利です。
Action
と同じような文脈で Flow
のソースコードを作成して、内容を実装します。なおFlowの場合、子ノードを実行するために初回のみStartNode()
を実行し、Updateでは Child.currentStatus
からステートを取得する必要があります。
[Serializable, GeneratePropertyBag]
[NodeDescription(
name: "Find Target",
story: "[Self] find target : [isFound]",
category: "Flow",
id: "43001a3a74c7e94269fb82b5b7b4ed96")]
public partial class FindTargetModifier : Modifier
{
[SerializeReference] public BlackboardVariable<GameObject> self;
[SerializeReference] public BlackboardVariable<float> angle = new(60f);
[SerializeReference] public BlackboardVariable<float> distance = new(10f);
// TrueならTargetを見つけたらAbort。Falseなら見失ったらAbort
[SerializeReference] public BlackboardVariable<bool> isFound = new (true);
// 対象が見つかった時にセットする
[SerializeReference] public BlackboardVariable<GameObject> foundTarget;
private const string TargetTag = "Player";
protected override Status OnStart()
{
if (!self?.Value || distance.Value < 0 || angle.Value < 0)
return Status.Failure;
return FindTarget() != isFound.Value ?
StartNode(Child) : // 子ノードの処理を開始する。StartNodeはOnStartのみで実行
Status.Failure;
}
protected override Status OnUpdate()
{
// 子ノードが有効な間は毎フレーム実行
return FindTarget() != isFound.Value ?
Child.CurrentStatus : // 子ノードの結果を取得する
Status.Failure;
}
protected override void OnEnd()
{
// 子ノードも含めてツリーから外れた時に呼ばれる
}
private bool FindTarget()
{
// 自身の参照を取得
var selfObject = self.Value;
// 自身のTransform
var selfTransform = selfObject.transform;
// タグを持つオブジェクトを検索
var targets = GameObject.FindGameObjectsWithTag(TargetTag);
foreach (var target in targets)
{
// ターゲットの位置が無効なら他を探す
if (IsValidTarget(selfTransform, target.transform) == false)
continue;
// 一つでも見つけたら完了
SetFoundTarget(target);
return true;
}
// 条件に合致するオブジェクトが見つからなかった
SetFoundTarget(null);
return false;
}
private void SetFoundTarget(GameObject target)
{
if( foundTarget != null)
foundTarget.Value = target;
}
private bool IsValidTarget(Transform selfTransform, Transform playerTransform)
{
var direction = playerTransform.position - selfTransform.position;
if (direction.sqrMagnitude > distance.Value * distance.Value)
return false;
if (Vector3.Angle(selfTransform.forward, direction) > angle.Value / 2f)
return false;
return !NavMesh.Raycast(selfTransform.position, playerTransform.position, out _, NavMesh.AllAreas);
}
}