🔖

GGJ2016で作ったゲームにBehaviorTreeを使ってAIを搭載した話

2021/10/27に公開
この記事はQiitaからエクスポートしたままの記事です。 
https://qiita.com/kakunpc/items/6717433ca058bb789c18
最終更新日(2016年02月12日)から5年以上が経過しています。

GGJが終わった後に試しで入れてみたAIについての話です。

グローバルゲームジャム(GGJ)のニコニコ会場に参加し作ったゲーム「Born To Beans」にAIを搭載し、複数人対戦が前提なゲームをぼっちな子でもできるように手を加えた時に使った技術に関しての話です。

AIに使ったもの

  • NavMesh(主にキャラクターの経路探索)
  • BehaviorTree(AIの思考回路)

NavMesh

NavMeshとは、Unityに搭載されている経路探索システムです。

使い方はとっても簡単。

まずは、Navigationで地形情報を焼く

ツールバーから「Window」→「Navigation」と選択し、NavMesh設定項目を出します。

NavMeshは、Static属性のMeshに対して焼くことができるので、MeshRenderを選択してHierarchyの中身を絞り込み
1.jpg

焼きたいオブジェクトを一気に選択して
2.jpg

Navigation Staticを設定したら
3.jpg

BakeタブからAIの通行の許容範囲を設定して
4.jpg

Bakeで焼くだけ!
5.jpg

とっても簡単でしょう!
青くなっているところが歩けると判断させた箇所です。それ以外は通常は行けない所となります。
また、 Cube が置かれている箇所はアイテムの出現位置とプレイヤーの出現位置のためのオブジェクトです。

はい無事に焼くことはできたので、次のステージを焼こうと思います。

ここで問題になるのがこれ・・・

6.jpg
山のオブジェクト消しても山ステージのベイク情報が残ってるやんけ

どうしてこうなった?

Navigationの仕様で、1シーンに対して焼ける数が決まっており、1つしか焼くこができないようになっています。
つまり同じシーンで何個もステージNavigationは焼けないのです。

解決方法

7.jpg
ステージごとにシーンを作りました。

ちょうどUnity 5.3.2を使用していたので、シーンの追加読み込みが楽にできるようになっていたので今回は追加読み込みで対応。

自分の位置と、行きたい場所の位置を「NavMesh.CalculatePath」に渡すと、行くための順路が取得できます。

サンプルソースNavMesh.CalculatePathのソースです。

ShowGoldenPath.cs
using UnityEngine;
using System.Collections;
public class ShowGoldenPath : MonoBehaviour {
	public Transform target;
	private NavMeshPath path;
	private float elapsed = 0.0f; 
	void Start () {
		path = new NavMeshPath();
		elapsed = 0.0f;
	}
	void Update () {
		// Update the way to the goal every second.
		elapsed += Time.deltaTime;
		if (elapsed > 1.0f) {
			elapsed -= 1.0f;
			NavMesh.CalculatePath(transform.position, target.position, NavMesh.AllAreas, path);
		}
		for (int i = 0; i < path.corners.Length-1; i++)
			Debug.DrawLine(path.corners[i], path.corners[i+1], Color.red);		
	}
}

これで、 path.corners に始点から終点までの経路が取得できます。
もしうまく取得できていなかったら、 NavMesh.CalculatePath の戻り値に false が返ってきています。
よーするにその場所から歩いて行こうってのは不可能と判断されたわけです。

あとはこれをAIのコントローラ部分に組み込めば、行きたいところに行くAIが出来上がります。

BehaviorTree

BehaviorTreeとは、AIのアルゴリズムの1つでよく使われている考え方です。
調べた所Unreal Engine4に標準搭載されているらしい?

名前にTreeがついてる事から想像がつくかと思いますが、思考回路の流れが木のようになっています。
一つ一つの要素をNodeと言い、1つ1つ順番に実行していく流れとなっています。

Nodeについて

ただ一つ一つ順番にやっていくだけではAIとは呼べません、ただ作業を繰り返しているだけです。
Nodeにも種類をもたせ、実行する・しないなど分岐をさせていくことでAIになっていきます。

代表的なNodeについて1つ1つ説明していきます。

ActionNode

diagram-1402313254329939360.png

特徴

  • 処理を実行するだけ
  • 子は持てない

DecoratorNode

diagram-4160511130048197331.png

特徴

  • 評価を行う
    • 真なら子のNodeを実行し、子のステータスを返す
    • 偽ならFailureステータスを返す
  • 子は1つだけ

SelectorNode

diagram-8603140746798229953.png

特徴

  • 成功する子が見つかるまで子を実行する
    • 成功するのが見つかったらこれ以降の処理は行わず Successを返す
    • すべて実行しすべてが失敗に終わったら Failureを返す
  • 子は何個でも持てる

SequenceNode

diagram-2056530729771971868.png

特徴

  • 順番に子を実行する
    • 子が成功したら Running を返す。次の更新時に次の子を実行させる。
    • 子が失敗したら Failure を返す。
    • すべての子の処理が終わったら Success を返す
  • 子は複数個持てる

これら4つのNodeを組み合わせることで、AIを作っています。

今回のAI

PlantUMLでAIの流れをまとめてみるとこんな感じになっています。

diagram-7294345731845021443.png

はい、ぱっと見た感じだけだとよくわからないですね。

解説をします。

  1. アイテムが出現してるなら、優先的に取り行って
  2. アイテムを取り行く動作が終わったらとりあえずちょっとだけその場で思考停止して
  3. 一番近くにいるプレイヤーを選択して

この順番の流れを繰り返し実行しています。

AIの考え方はこのような感じです。
ではこちらが実際に使用したソースとなっております。

AI_Strong.cs

using GGJ.AI.BehaviorTree;
using GGJ.GameManager;
using GGJ.Player;
using UniRx;
using UniRx.Triggers;
using UnityEngine;

namespace GGJ.AI
{
    public class AI_Strong : MonoBehaviour, IPlayerInput
    {
        private const float LevelMaxGettingUpToTime = 30f; // 最大のレベルになるまでの時間
        private const float AttackDistance = 3f;  // 攻撃開始までの距離
        private const float StopDistance = 1.5f;  // 止まるまでの距離
        private const float GetItemDistance = 10f;  // アイテムを取り行く距離


        private Subject<bool> onAttackButtonSubject = new Subject<bool>();

        /// <summary>
        /// 攻撃ボタンが押されているかどうか
        /// </summary>
        public IObservable<bool> OnAttackButtonObservable
        {
            get { return onAttackButtonSubject.AsObservable(); }
        }

        private Subject<Vector3> moveDirectionSubject = new Subject<Vector3>();

        /// <summary>
        /// プレイヤの移動方向
        /// </summary>
        public ReadOnlyReactiveProperty<Vector3> MoveDirection
        {
            get { return moveDirectionSubject.ToReadOnlyReactiveProperty(); }
        }

        // Use this for initialization
        private void Start()
        {
            var waitPositonTime = 0f;
            GameObject attackPlayer = null;
            var isMoveTerget = false;
            var moveTerget = Vector3.zero;
            GameObject itemObject = null;
            var coolTime = 0f;

            // 行き先に向かって移動する
            this.UpdateAsObservable()
                .Where(_ => isMoveTerget)
                .Select(_ =>
                {
                    var path = new NavMeshPath();
                    isMoveTerget = NavMesh.CalculatePath(transform.position, moveTerget, NavMesh.AllAreas, path);
                    return path;
                })
                .Where(x => x.corners.Length >= 2)
                .Do(_ => waitPositonTime = 0f) // 行き先を見つけた
                .Select(x => (x.corners[1] - x.corners[0]).normalized)
                .Select(x => AI_Extension.CalcDoc(x))
                .Subscribe(moveDirectionSubject);

            // 止まっている時間のカウント
            this.UpdateAsObservable()
                .Where(_ => isMoveTerget == false)
                .Subscribe(_ => { waitPositonTime += Time.deltaTime; });

            // 止まっている時間が一定数立ってしまったらとりあえずターゲットに向かって歩く
            this.ObserveEveryValueChanged(_ => waitPositonTime)
                .Where(_ => isMoveTerget == false)
                .Where(x => x > 1f)
                .Where(_ => (Vector3.Distance(moveTerget, transform.position) > StopDistance * (1f - LevelMaxGettingUpToTimeRate)))  // 近くにすでにいるなら動かない
                .Select(_ => (moveTerget - transform.position).normalized)
                .Select(x => AI_Extension.CalcDoc(x))
                .Subscribe(moveDirectionSubject);

            // 行きたい位置についたら移動処理を停止させる
            this.UpdateAsObservable()
                .Where(_ => isMoveTerget)
                .Select(_ => Mathf.Abs(Vector3.Distance(moveTerget, transform.position)))
                .Where(x => x < StopDistance * (1f - LevelMaxGettingUpToTimeRate))
                .Subscribe(_ => isMoveTerget = false);

            // 敵の近くによったら攻撃する
            this.UpdateAsObservable()
                .Where(_ => attackPlayer != null)
                .Where(_ => (Vector3.Distance(attackPlayer.transform.position, this.transform.position) <= AttackDistance))
                .Subscribe(_ =>
                {
                    onAttackButtonSubject.OnNext(true);
                    onAttackButtonSubject.OnNext(false);
                });

            // アイテム生成通知
            StageSpawner.Instance.OnSpowenItemAsObservable()
                .Where(x => Vector3.Distance(x.transform.position, this.transform.position) < GetItemDistance) // 自分の近くに生成された
                .Subscribe(x =>
                {
                    // アイテムが存在していることを入れる
                    itemObject = x;
                    // 交戦中のプレイヤーを消しておく(取りに行くため)
                    attackPlayer = null;
                });
// - ここからBehaviorTree ------------------------------------------------------

            // アイテム存在チェック
            var checkNearItemState =
                new ActionNode("CheckItem", _ =>
                {
                    if (itemObject != null) return NodeStatusEnum.Success;
                    return NodeStatusEnum.Failure;
                });

            // アイテムに近づくAI
            var goItemObject = new DecoratorNode("GoItemCheck",
                new ActionNode("GoItem", _ =>
                {
                    // 取れた取れてないにかかわらず終了
                    if (itemObject == null)
                    {
                        isMoveTerget = false;
                        return NodeStatusEnum.Success;
                    }
                    // ターゲットを設定
                    moveTerget = itemObject.transform.position;
                    moveTerget.y = transform.position.y;
                    isMoveTerget = true;
                    return NodeStatusEnum.Running;
                })
                , () => (itemObject != null));

            // 近くにアイテムがスポーンしたなら優先的に取り行くAI
            var checkItem = new SequenceNode("SearchItem",
                checkNearItemState // 生成してる
                , goItemObject // 取りに行く
                );

            // 近くのプレイヤーを選択するAI
            var nearPlayer = new ActionNode("SearchPlayer",
                _ =>
                {
                    // 生きているプレイヤー取得
                    var players = PlayerManager.Instance.GetAlivePlayers();
                    if (players.Count <= 1)
                    {
                        isMoveTerget = false;
                        return NodeStatusEnum.Failure;
                    }

                    // ソート(近い順)
                    players.Sort((x, y) =>
                    {
                        var xDist = Vector3.Distance(x.transform.position, this.transform.position);
                        var yDist = Vector3.Distance(y.transform.position, this.transform.position);
                        if (xDist == yDist)
                            return 0;
                        if (xDist > yDist)
                            return 1;
                        else
                            return -1;
                    });
                    // 一番近いプレイヤーを選択
                    attackPlayer = players[1].gameObject;
                    moveTerget = AI_Extension.RandomPosition(attackPlayer.transform.position,
                        (1f - LevelMaxGettingUpToTimeRate)); // 時間経過で性格射撃になる
                    moveTerget.y = transform.position.y;
                    isMoveTerget = true;
                    // 思考を停止するクールタイムを設定
                    coolTime = Random.Range(0.5f, 2f);
                    coolTime *= (1f - LevelMaxGettingUpToTimeRate);// 時間経過で思考停止クールタイムがなくなる
                    return NodeStatusEnum.Success;
                }
                );

            var aiCoolTime = new ActionNode("CoolTime", _ =>
            {
                coolTime -= Time.deltaTime;
                if (coolTime < 0f)
                {
                    // ここでSuccessを返すとNearPlayerが呼ばれなくなってしまいので無理やり失敗にさせる
                    return NodeStatusEnum.Failure;
                }
                return NodeStatusEnum.Running;
            });

            // AI初期化
            BehaviorTreeComponent.RegsterComponent(this.gameObject, new SelectorNode("name",
                checkItem,
                aiCoolTime,
                nearPlayer));
// - ここまでBehaviorTree ------------------------------------------------------
        }

        /// <summary>
        /// 時間経過によるレベルの割合
        /// </summary>
        /// <returns>0~1 1Max</returns>
        private float LevelMaxGettingUpToTimeRate
        {
            get
            {
                if (LevelMaxGettingUpToTime <= 0f) return 1f;
                return TimerManager.Instance.OnGameTimer.Value / LevelMaxGettingUpToTime;
            }
        }
    }
}


このような感じでAIを実装してみました。

終わりに

BehaviorTreeに関してですが、Aiming 開発者ブログの記事がとても参考になりました。
また、BehaviorTreeに関しては理解が浅く、Nodeの処理が実際のものと異なっている可能性があります。
もし違っている実装でしたらご指定いただければと思います。

BehaviorTreeを利用した「Behave 2 for Unity」というのもありますので、そちらを利用しても良さそうです。 AssetStore

AIのソースコードを一部GitHubに上げました。BorntoBeans_AI

Discussion