Behavior Tree 노드 세팅과 확장형 몬스터 AI 제어

Behavior Tree 노드 세팅과 확장형 몬스터 AI 제어

Behavior Tree의 실행 규칙과 노드 설계 원칙을 바탕으로 감지·추적·공격·복귀 행동을 확장 가능하게 구성하는 방법을 정리합니다.

Behavior Tree가 몬스터 AI에 적합한 이유

몬스터 AI는 보통 대기, 순찰, 플레이어 탐지, 추적, 공격, 원래 위치 복귀처럼 우선순위가 있는 행동으로 구성된다. 이를 조건문으로 계속 덧붙이면 행동 간 우선순위가 코드 곳곳에 흩어지고 새로운 상태를 넣을 때 기존 분기를 쉽게 깨뜨릴 수 있다.

Behavior Tree는 행동을 트리로 표현한다. 상위 노드가 어떤 자식 행동을 먼저 실행할지 결정하고 하위 노드는 한 가지 판단 또는 행동에만 집중한다. 따라서 공격 조건을 추가하거나 도망 행동을 넣을 때 기존 노드를 크게 수정하지 않아도 된다.

flowchart TD
    Root[Selector: 최우선 행동 선택] --> Dead{사망했는가?}
    Dead -->|예| Die[Action: 사망 처리]
    Dead -->|아니오| Combat[Sequence: 전투]
    Combat --> Seen{Condition: 목표 감지}
    Seen -->|성공| Range{Condition: 공격 사거리}
    Range -->|성공| Attack[Action: 공격]
    Range -->|실패| Chase[Action: 추적]
    Seen -->|실패| Return[Sequence: 복귀 또는 순찰]

위 구조에서 Selector는 앞선 자식이 성공하거나 실행 중이면 그 결과를 사용한다. Sequence는 모든 자식이 성공해야 성공한다. 그래서 전투 영역에서는 목표 감지와 사거리 확인 같은 조건을 Sequence 앞쪽에 배치하는 편이 자연스럽다.

Selector와 Sequence로 구성한 몬스터 전투 행동 트리 예시

실행 결과를 먼저 통일하기

노드가 반환하는 결과는 보통 세 가지면 충분하다.

  • Success: 노드의 목적을 달성했다.
  • Failure: 현재 조건에서 실행할 수 없거나 목적 달성에 실패했다.
  • Running: 여러 프레임에 걸쳐 계속 실행해야 한다.

예를 들어 공격 애니메이션이 끝날 때까지 기다리는 노드는 첫 프레임에 Running을 반환하고 타격 처리가 끝난 뒤 Success를 반환한다. 반대로 목표를 찾는 조건 노드는 즉시 성공 또는 실패를 반환한다.

public enum NodeState
{
    Success,
    Failure,
    Running
}

public abstract class BTNode
{
    public abstract NodeState Tick();

    public virtual void Reset() { }
}

Tick()은 매 프레임 호출해도 되지만 몬스터 수가 많다면 감지와 경로 탐색을 매 프레임 수행하지 않도록 별도 간격을 둬야 한다. 트리 구조가 깔끔해도 비용이 큰 노드를 무분별하게 실행하면 CPU 사용량이 늘어난다.

복합 노드 구현하기

SelectorSequence는 거의 모든 기본 행동 트리의 뼈대가 된다. 아래 구현은 실행 중인 자식의 인덱스를 보존한다. 매 프레임 처음 자식부터 다시 실행하는 문제를 피할 수 있다.

using System.Collections.Generic;

public sealed class SequenceNode : BTNode
{
    private readonly List<BTNode> children;
    private int currentIndex;

    public SequenceNode(params BTNode[] children)
    {
        this.children = new List<BTNode>(children);
    }

    public override NodeState Tick()
    {
        while (currentIndex < children.Count)
        {
            NodeState result = children[currentIndex].Tick();

            if (result == NodeState.Running)
                return NodeState.Running;

            if (result == NodeState.Failure)
            {
                Reset();
                return NodeState.Failure;
            }

            currentIndex++;
        }

        Reset();
        return NodeState.Success;
    }

    public override void Reset()
    {
        currentIndex = 0;
        foreach (BTNode child in children)
            child.Reset();
    }
}

SelectorNode는 반대로 자식이 Success를 반환하면 즉시 성공한다. 모든 자식이 실패했을 때만 실패한다. 단, 상위 우선순위 조건이 갑자기 참이 된 경우에는 현재 실행 중인 저우선순위 행동을 중단할 수 있어야 한다. 이를 위해 매 틱 처음부터 평가하는 반응형 Selector를 쓰거나 노드 중단 규칙을 별도로 설계한다.

공유 컨텍스트로 노드 결합도 낮추기

각 노드가 MonsterController, NavMeshAgent, Animator를 직접 찾아 쓰면 재사용하기 어렵다. 몬스터가 필요로 하는 런타임 데이터를 하나의 컨텍스트에 모으고 노드는 컨텍스트만 받도록 구성하는 편이 좋다.

using UnityEngine;
using UnityEngine.AI;

public sealed class MonsterContext
{
    public Transform Self { get; }
    public Transform Target { get; set; }
    public Vector3 SpawnPosition { get; }
    public NavMeshAgent Agent { get; }
    public Animator Animator { get; }
    public float AttackRange { get; }

    public MonsterContext(
        Transform self,
        NavMeshAgent agent,
        Animator animator,
        float attackRange)
    {
        Self = self;
        Agent = agent;
        Animator = animator;
        AttackRange = attackRange;
        SpawnPosition = self.position;
    }
}

이 방식은 같은 ChaseTargetNode를 근접 몬스터와 원거리 몬스터가 함께 사용하게 해 준다. 차이는 트리 조립 과정에서 공격 노드만 교체하거나 컨텍스트에 넣는 설정값을 다르게 주는 방식으로 처리할 수 있다.

공유 컨텍스트를 통해 재사용 노드와 몬스터별 설정을 분리한 구조

조건과 행동을 작은 단위로 나누기

조건 노드는 부작용 없이 판단만 수행하는 것이 좋다. 예를 들어 공격 가능 여부는 이동을 멈추거나 애니메이션을 재생하지 않는다. 그런 변경은 행동 노드가 담당한다.

using UnityEngine;

public sealed class IsTargetInAttackRangeNode : BTNode
{
    private readonly MonsterContext context;

    public IsTargetInAttackRangeNode(MonsterContext context)
    {
        this.context = context;
    }

    public override NodeState Tick()
    {
        if (context.Target == null)
            return NodeState.Failure;

        float sqrDistance = (context.Target.position - context.Self.position).sqrMagnitude;
        float sqrRange = context.AttackRange * context.AttackRange;

        return sqrDistance <= sqrRange
            ? NodeState.Success
            : NodeState.Failure;
    }
}

public sealed class ChaseTargetNode : BTNode
{
    private readonly MonsterContext context;

    public ChaseTargetNode(MonsterContext context)
    {
        this.context = context;
    }

    public override NodeState Tick()
    {
        if (context.Target == null)
            return NodeState.Failure;

        context.Agent.isStopped = false;
        context.Agent.SetDestination(context.Target.position);
        context.Animator.SetBool("IsMoving", true);
        return NodeState.Running;
    }

    public override void Reset()
    {
        context.Agent.isStopped = true;
        context.Animator.SetBool("IsMoving", false);
    }
}

거리 비교에는 제곱 거리를 사용했다. 제곱근 계산 없이 범위 판정을 할 수 있으므로 많은 AI가 자주 검사하는 상황에서 불필요한 비용을 줄일 수 있다.

트리 조립과 확장 지점

몬스터별 차이를 상속 클래스의 긴 조건문으로 해결하기보다 공통 노드를 조립해 트리를 만든다. 아래는 가장 단순한 전투 우선 트리의 예다.

BTNode root = new SelectorNode(
    new SequenceNode(
        new HasTargetNode(context),
        new IsTargetInAttackRangeNode(context),
        new AttackNode(context)),
    new SequenceNode(
        new HasTargetNode(context),
        new ChaseTargetNode(context)),
    new ReturnToSpawnNode(context),
    new PatrolNode(context));

확장할 때는 다음 기준을 지키면 트리가 커져도 관리하기 쉽다.

  • 새로운 행동은 가능한 한 독립적인 ActionNode로 만든다.
  • 공통 조건은 중복 구현하지 말고 재사용한다.
  • 쿨다운, 최근 피격 시간, 경계 상태처럼 여러 노드가 참조하는 값은 컨텍스트 또는 블랙보드에 둔다.
  • 공격 도중 피격되면 경직 행동으로 전환해야 하는지처럼 중단이 필요한 규칙은 트리 조립 단계에서 우선순위로 명시한다.
  • 애니메이션 이벤트로 피해 적용 시점을 연결하고 노드는 애니메이션 완료 여부만 관찰한다.

예를 들어 체력이 낮을 때 후퇴시키려면 최상위 Selector의 전투 노드 앞에 Sequence(IsLowHealth, FindCover, Retreat)를 추가하면 된다. 기존 추적과 공격 노드를 수정하지 않아도 행동 우선순위가 바뀐다.

자주 발생하는 문제

추적 노드가 영원히 Running을 반환하는 경우

목표가 공격 사거리에 들어왔는데도 추적 노드가 계속 실행되면 상위 트리가 다시 사거리 조건을 평가하지 못할 수 있다. 반응형 Selector를 사용하거나 추적 노드 안에서 사거리 진입 시 Success를 반환하도록 설계해야 한다.

노드 Reset에서 상태를 정리하지 않는 경우

이동 노드를 중단했는데 NavMeshAgent.isStopped를 되돌리지 않거나 애니메이션 Bool 값을 남겨 두면 다음 행동에 이전 상태가 섞인다. 실행 중 외부 상태를 바꾸는 노드는 Reset()에서 정리할 항목을 함께 정의해야 한다.

감지와 행동을 혼합하는 경우

AttackNode 안에서 목표 탐지부터 거리 검사, 이동 중지, 피해 처리까지 모두 하면 재사용성과 테스트가 떨어진다. 조건은 조건 노드로 실행은 행동 노드로 나누고 트리가 순서를 표현하게 두는 편이 디버깅에도 유리하다.

마무리

확장 가능한 몬스터 AI의 핵심은 복잡한 행동을 한 노드에 몰아넣지 않는 데 있다. Success, Failure, Running의 의미를 일관되게 유지하고 조건·행동·공유 데이터를 분리하면 새로운 몬스터 패턴을 기존 트리의 조합으로 빠르게 추가할 수 있다.

#Unity#AI#Behavior Tree#C##게임 프로그래밍

계속 읽어보기

이런 글은 어떠세요?

< Back to Logs