GJ2016에서 만든 게임에서 BehaviorTree를 사용해 AI를 탑재한 사연입니다.

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

GJ가 끝나고 넣어본 AI에 관한 말이다.


글로벌 게임 잼(GGJ)에 참가한 니코니코 회의장에서 제작한 게임'Born To Beans'에 AI를 탑재해 외톨이 아이들도 여러 명 대결을 전제로 한 게임을 할 수 있도록 하는 기술에 관한 말이다.

AI용 물건

  • NavMesh(주로 캐릭터 경로 탐색)
  • BehaviorTree(AI의 사고 회로)
  • NavMesh


    NavMesh는 Unity에 탑재된 경로 탐색 시스템이다.
    사용 방법은 매우 간단하다.

    우선, Navigation으로 지형 정보를 태운다.


    도구 모음에서 Window→Navigation을 선택하고 NavMesh 설정 항목을 출력합니다.
    NavMesh는 Static 속성의 Mesh를 태울 수 있기 때문에 MeshRender를 선택하여 Hierrarchy의 내용을 축소합니다
    1.jpg
    객체를 한 번에 선택
    2.jpg
    Navigation Static 설정 후
    3.jpg
    Bake 태그에서 AI 통행 허용 범위 설정
    4.jpg
    그냥 케이크에 구울 뿐이야!
    5.jpg
    아주 간단하죠!
    파란 곳은 판단할 수 있는 곳이다.그 외에는 통상 갈 수 없는 곳이다.
    또 배치Cube는 아이템의 출현 위치와 유저의 출현 위치의 대상이다.

    Navigation 구이에 푹 빠졌어요.


    네, 잘 구워져서 다음 무대를 태우고 싶어요.

    여기 문제는 이거예요.


    6.jpg
    산 위의 대상이 사라져도 산 위의 무대의 베이커 메시지는 아직 남아 있다

    왜 이렇게 됐지?


    Navigation의 규격에 따라 한 장면을 얼마나 태워야 하는지, 하나하나 태워서는 안 된다.
    같은 장면에서 네이비게이션이 타버린 무대는 몇 개 없었다는 것이다.

    해결책


    7.jpg
    무대마다 장면이 있다.
    마침 Unity 5.3.2를 사용해 장면의 추가 읽기가 수월해졌기 때문에 이번 추가 읽기가 대응했다.

    NavMesh를 통한 탐색 경로


    자신의 위치와 가고 싶은 곳의 위치를'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 알고리즘에서 자주 사용하는 사고방식이다.
    Unreal Engine 4는 표준으로 탑재된 것으로 조사됐다.
    이름에 트리가 있다는 걸 상상하면 사고 회로의 흐름은 나무와 같다.
    하나하나의 요소를 노드라고 하는데 하나하나의 순서에 따라 집행되는 절차다.

    노드 정보


    하나둘씩 차례대로 진행하면 AI라고 할 수 없고 일만 되풀이하고 있다.
    노드에도 종류를 두고, 실행과 불실행 등 분기하는 일을 AI로 만든다.
    다음은 대표적인 노드를 하나하나 설명한다.

    ActionNode


    diagram-1402313254329939360.png
    특징.
  • 처리 전용
  • 아이를 데리고 갈 수 없음
  • DecoratorNode


    diagram-4160511130048197331.png
    특징.
  • 평가 진행
  • 사실이라면 하위 노드를 실행하여 하위 상태로 돌아가기
  • 거짓이면 Failure 상태로 돌아갑니다
  • 아이 하나
  • SelectorNode


    diagram-8603140746798229953.png
    특징.
  • 성공한 아이를 찾을 때까지 집행자
  • 검색에 성공하면 후속 처리 없이 Success
  • 로 돌아갑니다.
  • 모두 실패로 끝나면 Failure
  • 로 돌아갑니다.
  • 아이 몇 명 다 들 수 있어
  • SequenceNode


    diagram-2056530729771971868.png
    특징.
  • 순서대로 집행자
  • 자녀가 성공하면 런닝으로 돌아간다.다음 업데이트 때 다음 아이를 실행합니다.
  • 아이가 실패하면 Failure로 돌아갑니다.
  • 모든 하위 프로세스가 완료되면 Success
  • 로 돌아갑니다.
  • 여러 아이를 둘 수 있다
  • 이들 4개 노드를 조합해 AI를 만든다.

    이번 AI.


    PlanetUML로 AI의 흐름을 귀결해보면 이렇게 되는 느낌.
    diagram-7294345731845021443.png
    네, 한 번 보면 잘 모르겠어요.
    내가 설명할게.
  • 항목이 나타나면 먼저 찾기
  • 물건을 찾는 동작이 끝나면 그 자리에서 잠시 생각을 멈춘다
  • 가장 가까운 유저를 선택
  • 이 순서를 반복해서 집행하는 절차.
    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를 설치해봤어요.

    끝맺다


    Aiming 개발자 블로그의 BehaviorTree에 대한 글은 매우 참고 가치가 있다.
    또 BehaviorTree에 대한 이해가 얕아 노드의 처리가 실제와 다를 수 있다.
    다른 실가라면 지정해 주세요.
    BehaviorTree를 활용한'Behave 2 for Unity'도 있는데 그걸 사용할 수 있다.  AssetStore
    AI의 소스 코드를 일부 GiitHub로 높였습니다.BorntoBeans_AI

    좋은 웹페이지 즐겨찾기