[Unity2D] #4 - Enemy AI-1 | Behavior Tree

qweasfjbv·2024년 3월 12일

Unity2D

목록 보기
4/15
post-thumbnail

맵을 만들었으니 그 위에서 움직이는 적들을 구현해보겠습니다.

Enemy AI를 구현하는 방법은 여러가지가 있습니다.

  • Update문 안에 로직 구현
  • FSM
  • Behavior Tree

적들이 단순한 이동 및 공격만 한다면 Update문 안에 작성하거나 FSM이 좋겠지만,
저는 좀 더 다양한 적들을 만들어보기 위해서 Behavior Tree를 구현해 보겠습니다.

큰 구조는 이 유튜브 영상을 참고했습니다.
영상 보시고 글을 읽으시면 이해가 더 쉬우실 것 같습니다.

개요

Behavior Tree는 복잡한 행동 알고리즘을 Node 라는 작업단위로 잘게 나눕니다.
각 노드는 성공, 실패, 실행 중 상태를 반환할 수 있으며, 트리를 통해 상위노드로 전달되어 전체 트리의 실행 흐름을 결정합니다.

Node의 종류

1. Leaf Node ( Task )

Tree의 가장 끝 부분에서 실제로 동작을 수행하는 노드입니다.
예를 들어, Move 노드에서는 오브젝트의 위치를 조작하고, Search 노드에선 주변을 탐색합니다.

2. Composite Node ( Control Flow Node )

여러 자식노드를 가지며 자식들의 상태에 따라 어떻게 실행할 지 결정합니다.

  • Sequence : 자식 노드들을 순서대로 실행. 모든 자식이 성공해야 성공.
  • Selector : 자식 노드들을 순서대로 실행. 하나라도 성공하면 성공.
  • Parallel : 자식 노드들을 동시에 실행.

3. Decorator Node

하나의 자식 노드를 갖고 실행을 제어합니다.
예를 들면, 실행 회수나 조건에 따라 실행을 건너뛰게 합니다.

밑에 예시가 있으니 이해가 잘 안되시면 참고하기 바랍니다.

구현

Base Class

Node.cs


    public class Node
    {

        public NodeState state;
        public Node parent;
        protected List<Node> children = new();

        private Dictionary<string, object> nodeData = new();

        public Node(){
            parent = null;
        }

        public Node(List<Node> children)
        {
            foreach (var child in children)
                Attach(child);
        }

        /// <summary>
        /// 해당 노드에 인자로 받은 노드를 Child로 입력
        /// </summary>
        private void Attach(Node child)
        {
            child.parent = this;
            children.Add(child);
        }

        
        public virtual NodeState Evaluate () => NodeState.Failure;

        public void SetNodeData(string key, object value)
        {
            nodeData[key] = value;
        }

        public object GetNodeData(string key)
        {

            if (nodeData.TryGetValue(key, out object ret))
            {
                return ret;
            }
            Node cur = this.parent;

            while (cur != null)
            {

                ret = cur.GetNodeData(key);
                if (ret != null) return ret;
                cur = cur.parent;
            }

            return null;
        }

        public bool RemoveNodeData(string key)
        {
            if (nodeData.ContainsKey(key))
            {
                nodeData.Remove(key); return true;
            }

            Node cur = this.parent;

            while (cur.parent != null)
            {
                if (cur.RemoveNodeData(key)) return true;
                cur = cur.parent;
            }

            return false;
        }
    }

Node 클래스 입니다.
각 Node는 NodeState를 갖고 Evalute() 함수에서 자신의 state를 판단합니다.

  • Composite Node는 자식들의 Evalute() 함수를 순서대로 호출합니다.
    Sequence는 Failure나 Running이 나오면 중단하고
    Selector는 Success나 Running이 나오면 중단하며 상태를 반환합니다.

  • Leaf Node 같은 경우에는 각 노드의 기능에 맞게 만들어야 합니다.

Tree.cs

	public abstract class Tree : MonoBehaviour
    {

        private Node root = null;

        private void Start()
        {
            root = SetupRoot();
        }
        private void Update()
        {
            if (root != null) root.Evaluate();
        }


        public abstract Node SetupRoot();

    }

Tree에서 root의 Evaluate() 함수만 호출합니다.
Composite Node가 자식들의 Evaluate() 함수를 전부 호출하기 때문에 괜찮습니다.

위 클래스들을 상속받은 클래스들로 Node들을 구현해 보겠습니다.

Composite Node

Sequence.Evaluate()

		public override NodeState Evaluate()
        {
            foreach (Node child in children)
            {
                switch (child.Evaluate()) {
                    case NodeState.Failure:
                        state = NodeState.Failure;
                        return state;
                    case NodeState.Success:
                        continue;
                    case NodeState.Running:
                        state = NodeState.Running;
                        return state;
                }
            }

            state = NodeState.Success;

            return state;
        }

Selector.Evaluate()

    	public override NodeState Evaluate()
        {

            foreach (Node child in children)
            {
                switch (child.Evaluate())
                {
                    case NodeState.Failure:
                        continue;
                    case NodeState.Success:
                        state = NodeState.Success;
                        return state;
                    case NodeState.Running:
                        state = NodeState.Running;
                        return state;
                    default:
                        continue;
                }
            }

            state = NodeState.Failure;
            return state;
        }

Sequence와 Selector Node는 위에서 말한 그대로 만들면 됩니다.

Leaf Node

이 부분에선 Behavior Tree가 제대로 작동하는지 확인하기 위해서 간단하게 만들었습니다.
다음은 제가 만들 Behavior Tree를 그래프로 시각화한 것입니다.

왼쪽 Leaf노드부터 보겠습니다.

  • Search : 주변에 적이 있는지 찾습니다. 적이 있으면 Failure, 아니면 Success를 반환합니다.
    Failure를 반환하면 Sequence는 중단되고 Selector는 다음 Sequence를 실행시킵니다.
    Success를 반환하면 Move를 실행시킵니다.

  • Move : 오른쪽으로 움직입니다. 항상 Running을 반환합니다.
    ( 테스트를 위해서 오른쪽으로만 가도록 했습니다. )

  • IsAttacking : 현재 공격중인지 확인합니다. 공격중이면 Failure, 아니면 Success를 반환합니다.
    Failure를 반환하면 더 이상 실행하지 않습니다.
    Success를 반환하면 Sequence에 의해 Track을 실행합니다.

  • Track : Search에서 찾은 적을 추적합니다. 공격 사거리 안에 들어오면 Success, 아니면 Failure를 반환합니다.
    Failure를 반환하면 더 이상 실행하지 않습니다.
    Success를 반환하면 Sequence에 의해 Attack을 실행합니다.

  • Attack : 적을 공격합니다.

각 클래스의 Evaluate() 함수만 보겠습니다.

Search.Evaluate()

 		public override NodeState Evaluate()
        {
            var cols = Physics2D.OverlapCircleAll(transform.position, searchRange);
            foreach (var col in cols)
            {
                if (col.CompareTag("Monster"))
                {
                    parent.parent.SetNodeData("BossObject", col.gameObject);
                    return NodeState.Failure;
                }
            }
            return NodeState.Success;
        }

Move.Evaluate()

		public override NodeState Evaluate()
        {
            animator.SetBool("Walk", true);
            animator.SetFloat("X", moveSpeed);
            animator.SetFloat("Y", 0);
            rigid.MovePosition(transform.position + new Vector3(moveSpeed, 0, 0));
            return NodeState.Running;
        }

IsAttacking.Evaluate()

		public override NodeState Evaluate()
        {
            var isAtt= animator.GetBool("Attack");
            if (isAtt) return NodeState.Failure;
            else return NodeState.Success;
        }

Track.Evaluate()

		public override NodeState Evaluate()
        {
            var boss = (GameObject)GetNodeData("BossObject");

            Vector3 dir = boss.transform.position - transform.position;
            float dis2 = dir.x * dir.x + dir.y * dir.y;

            animator.SetFloat("X", dir.x);
            animator.SetFloat("Y", dir.y);

            if (dis2 < attackRange * attackRange) {
                animator.SetBool("Walk", false);
                return NodeState.Success; 
            }

            animator.SetBool("Walk", true);
            dir.Normalize();
            rigid.MovePosition(transform.position + trackSpeed * dir);

            return NodeState.Running;
        }

Attack.Evaluate()

        public override NodeState Evaluate()
        {
            if (!animator.GetBool("Attack"))
                animator.SetTrigger("Attack");

            var tr = (GameObject)GetNodeData("BossObject");
            return NodeState.Success;
        }

애니메이션을 추가해서 코드가 조금 지저분해진 것 같습니다.
추후에 Enemy AI를 구현하면서 재사용이 불가능하면 수정하도록 하겠습니다.

Behavior Tree

실제로 오브젝트에 컴포넌트로 붙이고 사용하는 클래스입니다.
위 그림에서 그래프로 나타낸 대로 만들어줍니다.
특히, 순서는 곧 중요도를 나타내기 때문에 굉장히 중요합니다.

EnemyBT

	public class EnemyBT : Tree
    {
        [SerializeField] private int searchRange;
        [SerializeField] private int attackRange;

        [SerializeField] private float moveSpeed;
        [SerializeField] private float trackSpeed;

        public override Node SetupRoot()
        {
            Node root = new Selector(new List<Node> {
                    new Sequence(new List<Node>
                    {
                        new Search(transform, searchRange),
                        new Move(transform, moveSpeed)
                    }),
                    new Sequence(new List<Node>
                    {
                        new IsAttacking(transform),
                        new Track(transform, attackRange, trackSpeed),
                        new Attack(transform)
                    })
                });


            return root;
        }
    }

아래는 실행화면입니다.

마무리

원래 가야하는 길은 오른쪽이지만, 주변에 몬스터가 있으면 추적해서 공격하는 모습을 볼 수 있습니다.
IsAttacking 클래스를 사용함으로써 공격중에 Track이 실행되지 않도록 했습니다.
또한, 사용한 에셋의 애니메이터가 너무 잘 만들어져 있어서 시간을 절약할 수 있었습니다.

다음은 생성한 맵에서 길찾기를 만들어 보겠습니다.

참고자료

https://en.wikipedia.org/wiki/Behavior_tree_(artificial_intelligence,_robotics_and_control)
https://www.youtube.com/watch?v=aR6wt5BlE-E
https://assetstore.unity.com/packages/2d/environments/minifantasy-dungeon-206693

0개의 댓글