UnityのBehaviorパッケージを使って敵のAIを作ってみる

Unityの新しいパッケージ、**Behavior** を使用して敵のAIを作る方法について紹介します。

このパッケージは簡単に言えば **BehaviorTree** 、敵のタスクをモジュール化した上で制御するのを実現する為の機能です。スクリプトでAIを組むよりも、大分楽にこういったフローを作れるのは便利です。

なお今回紹介するUnityのBehaviorパッケージは、出たばかりの機能なのと名前が名前なので**Google検索でもあまりヒットしません**。また、このパッケージの詳しい使い方については、この記事では深く触れないので、先にマニュアルを読むのをお勧めします。もしくは下のURLが参考になるかもしれません。

（個人的には**新機能に一般名詞を使うのはやめてほしい**と思います）


https://giannisakritidis.com/blog/Behavior-Graph-And-Behavior-Trees-Part1/

https://qiita.com/AtsuAtsu0120/items/7adf1c144b669231c9bb

https://giannisakritidis.com/blog/Behavior-Graph-And-Behavior-Trees-Part1/

<iframe width="560" height="315" src="https://www.youtube.com/embed/VWxgTeCtDAs?si=9Ab9dxMNqpTC58XE" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

最近ではChatGPTにマニュアルを読み込ませて解説してもらうのも便利ですね。


# 作りたい物

今回のBehaviorTreeで作るAIは、概ね以下のような内容を作っていく予定です。

-  ターゲットが無い場合はマップを**パトロール**する
-  ターゲットを発見したら**パトロールを停止**しターゲットを**追跡**する
-  ターゲットに追いついたらターゲットを**攻撃**する
-  ターゲットに追いつけない状態が5秒間続いたら、**追跡をやめてパトロールに戻る**
-  ただし、ターゲットが目の前にいる場合は**追跡を続行**する



<iframe width="560" height="315" src="https://www.youtube.com/embed/ybyg3s1mpPk?si=JpDAQSYJK9OJrG6W" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/9e4e33e5-c933-a45f-687d-82ce4e1ac0d3.png)


# 敵をパトロールさせる

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/35017661-fbba-3ac4-c0cc-c6c0589196cb.png)


まず最初にやりたいことは、マップを巡回することです。指定のWayPointを巡回し、プレイヤーの安全地帯を脅かす挙動を作ります。

これは `Patrol` という最初から用意されているノードを利用することで、簡単に実現することができます。このノードは `Self` が `WayPoints(GameObjectのList）`を順番に巡回する挙動を実現するノードです。
詳しい機能や設定方法はマニュアルを見るか、フィーリングで理解してください。

なおBehaviorGraph内のInspectorには`Speed`や`AnimatorSpeedParam`など、ノードが動作する際に参照するパラメーターの一覧が表示されていて、必ずちゃんと設定する必要があります。特に`Patrol`の **Speedの初期値は０** なので、移動させたいなら必ず０より大きい値を設定する必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/2ad3cef1-5a1c-ef15-ac0e-91623b68519e.png)


詰まりやすい部分として、`BlackBoard`のパラメーターを設定する際、Inspectorに露出しているパラメーターを設定を忘れてしまうことがよくあります。例えば`Self` は空の可能性があるので、ちゃんと設定しましょう。またSceneにあるオブジェクトをGraphViewのBlackboardには登録できません。UnityエディターのInspectorで設定しましょう。そして、その内容はGraphViewには反映されません。

せめて`Self`だけでも自動設定されれば便利なのですが…

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/46fbc338-0c0d-02de-bcd2-c0aa3b291cfe.png)


# ターゲットを発見したらパトロールを中断する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/c292529b-087c-47b9-6ed5-8b586f602bfd.png)


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」とログを出します。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/f003e631-76a8-6417-589f-5069ce74209f.png)

`TryInOrder`と`Patrol`の間に`Abort` を追加し、GraphViewのInspectorで`Check Distance`を追加。 このノードでAbortする条件は`Self` と `Target` の間が4m以下に指定。これでTargetが4メートルより近づいたら`Abort`によりパトロールがキャンセルとなり、`TryInOrder`によって次のタスクに切り替わります。
画像では`Abort`した後のタスクは適当にでっち上げています。

# ターゲットを発見したらモードの切り替え

今回の敵は概ね「パトロール」と「追跡して攻撃」の2つのモードを持ちます。このモードの切り替えは`TryInOrder`を利用する事もできますが、より簡単に`Enum`を使用して状態を管理します。

`EnemyMode`という`Enum`を作成して「パトロール」と「戦闘」を用意します。これは`BlackBoard`の機能で作成しても良いですし、コードを書いても良いです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/8e4d9305-2cc4-a026-35d7-4168b4c578f8.png)

```cs
[BlackboardEnum]
public enum EnemyMode
{
    Patrol,
	Battle
}
```

次に`Switch`ノードを用いてステートの状態で動作先を切り替えるようにします。
~~さらに`Abort`の`Variable Value Change`使用して、`EnemeyMode`が切り替わった際に処理をリセットします。~~　わざわざAbort If で取り消さなくても、Switch(Flow)は自身が変化した時に子の処理を中断してくれるみたいです。

下図の例では、`TryInOrder`を使い、ターゲットとの距離が4m未満になった場合、`EnemyMode` を `Battle` に切り替えるように設定しています。この結果、ターゲットとの距離が近い状態では自動的に戦闘モードに移行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/3370cb3f-aa79-1eac-b81f-d4a8df338160.png)

あとは画像のように`TryInOrder`の次の処理で`EnemeyMode`を`Battle`に切り替えれば、ターゲットとの距離が4m未満になった時、`EnemeyMode`が`Battle`に切り替わり、以降は`Battle`以下のノードが動作するようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/559a3857-dcc6-ead2-9a0d-8d9838852ab9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/5b69fd14-3ead-f4e8-7af7-d23862e793b6.png)


# ターゲットを追跡する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/f5259a40-0cdb-8611-1a1d-eaf4b80ff78a.png)


`Battle`ではターゲットを追跡し、接近した際に攻撃を行う処理を設定します。これを実現するために`Navigation to target` ノードを使用します。

-  自身（Self）がターゲット（Target）に向かって移動する
-  移動は`NavmeshAgent`を使用して行い、ノードで設定しないパラメーターはコンポーネントの値を使用する
-  一定距離（`DistanceThreshold`）に到達したら `Success` を返す
-  到達するまで `Running`を維持

ここでは`DistanceThreshold`を少し大きめに設定しておくことで、ターゲットの少し前に停止することを期待することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/61421b8e-8f65-a8bf-c7d2-0705c3ecaab5.png)

# ターゲットを攻撃する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/1259e3a0-d1b7-d403-9b04-b5a95551bb0d.png)


次の処理ではターゲットを攻撃します。攻撃方法はいろいろとありますが、今回は単純にモーションを再生します。モーションを再生したいなら`Animator.Play` するのが簡単なのですが、それを実現するノードは無いので、作ります。作るノードは以下内容：
-  アニメーションを再生する。再生対象はステートを指定する
-  アニメーションの再生中は `Running` を維持
-  アニメーションが完了したら `Success`を返す

ノードを作る場合、GraphViewで`Create New`を選択して`Action`を指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/7f8dd37e-54c7-8553-454a-7f6f02f8e8ee.png)


ノードの名前は「PlayAnimation」、説明文は「**Self** play animation state **StateName**」で、`Self`と`StateName`を引き数にします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/b8781e1e-5c84-345f-4ad7-55bbd31f531a.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/89b022c8-362e-bf74-4f11-beb44ff4193b.png)

作成したコードを修正します。

ちなみに、NodeDescriptionの `story` の項目にて `[ ]` で囲っている部分と一致するフィールド名をノードに設定することができます。 `story` に設定されていないフィールドはGraphViewのInspectorに表示されるので、無理やり story に入れ込む必要はありません。視認性が低くなります。

```cs
[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`を追加した方が良いです（意図しない方向を攻撃します）

1.  `Self`は`Target`まで移動する（移動が完了までRunning）
1.  `Look At` でSelfはTargetの対象の方を向く（即座にSuccess）
1.  `Play Animation` で `Punch` ステートを再生（ステートの再生が完了するまでRunning)
1.  すべての処理が完了したのでOnStartから再判定

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/e91eb4dd-fde4-3924-b226-7959ddab9fe3.png)


# 5秒経ったらターゲットの追跡をやめる

敵がターゲットを発見しても追跡が無限に続くのは好ましくありません。なので、一定時間後に追跡を中断してパトロールに戻る処理を追加します。

この処理には `Timeout` ノードを使用します。`Timeout`ノードは、指定時間が経過すると `Abort` を実行し、親ノードで次の処理に切り替えます。下の画像のように動作します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/4ee825ac-fae2-1181-0638-ce0b4985bf7b.png)

ただし、上の画像の通り実装するとタイミングによっては攻撃中にAbortが発動するので、下の画像の様に攻撃と追跡の処理を分け、追跡中のみTimeoutが発生するように修正します。

この実装では以下の条件でパトロールに戻ります
-  TargetがNull
-  距離が２メートル以上離れている時にタイムアウトが発生

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/dcdfe546-2885-8fbb-580f-b101a3622bc6.png)


# 距離が近ければ追跡を継続する

タイムアウトによって追跡を終了する際、ターゲットが目の前にいる場合でもパトロールに戻ることがないように条件を追加します。

以下の例では、`Check Distance` ノードを活用して、ターゲットが一定距離以内にいる場合は追跡を続ける設定を行っています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/a5f3758f-624c-8717-81b8-1b79dac9d8f6.png)

ただし上のグラフだと `Target` が消滅したときに `Check Distance` の `Distance` の対象が取得できずに`Failed`となり、`EnemyMode`が`Patrol` へ切り替わりません。
そこで`TryInOrder`の中に`ConditionalGuard`を配置して、その条件が`Success`である限り`EnemyMode`が切り替わらないようにします。

この状態では、パトロールに切り替わる条件は以下の通りです
-  TargetがNull
-  距離が9メートル以上離れている時にタイムアウトが発生する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/4e331877-7908-e576-2771-f1753b670172.png)


# ターゲットの指定を、手動ではなく動的に設定する

最後にターゲットを手動で設定するのではなく動的に設定することで、囮など複数のターゲットにも対応できるようにします。

情報の取得の動作自体は `Action`ノード でも実現可能ですが、今回は `Flow` を使用して子ノードの動作を監視しながら親ノードで状況を判断できるようにします。`Flow`ノードは子ノードを監視しながら親ノードで状況を判断できるため、毎フレーム確認が必要な処理に便利です。

`Action` と同じような文脈で `Flow` のソースコードを作成して、内容を実装します。なおFlowの場合、子ノードを実行するために初回のみ`StartNode()` を実行し、Updateでは `Child.currentStatus`からステートを取得する必要があります。


```cs
[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);
    }
}

```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12772/06f3dd71-0018-808d-914d-077e7ce33d3e.png)