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);
}
}





















