
몬스터 행동 트리: 추적·스킬·도망 페이즈 전환을 한눈에 설계하기
행동 트리로 몬스터의 추적, 스킬 사용, 도망 페이즈 전환을 구성하는 방법을 다룹니다. 조건의 우선순위와 상태 공유 방식을 Unity/C# 예제로 정리합니다.
행동 트리가 필요한 이유
몬스터 AI는 처음에는 if 문 몇 개로 시작하기 쉽습니다. 하지만 체력이 낮을 때 도망가고 사거리 안에서는 스킬을 쓰며 스킬이 재사용 대기 중이면 추적하는 규칙이 추가되면 조건의 우선순위가 빠르게 복잡해집니다.
행동 트리(Behavior Tree)는 이런 의사 결정을 트리 구조로 분리합니다. 상위 노드는 우선순위를 결정하고 하위 노드는 조건 검사나 실제 행동을 담당합니다. 따라서 새 행동을 넣거나 기존 우선순위를 바꿀 때 전체 로직을 다시 얽어매지 않아도 됩니다.
이번 몬스터의 규칙
예제로 사용할 몬스터는 다음 규칙을 따릅니다.
- 체력이 30% 이하이면 플레이어에게서 멀어집니다.
- 도망 상태가 아니고 스킬 사거리 안이며 재사용 대기 시간이 끝났다면 스킬을 사용합니다.
- 스킬을 쓸 수 없으면 공격 사거리까지 플레이어를 추적합니다.
- 이미 공격 사거리 안이라면 이동하지 않고 대기하거나 기본 공격 행동으로 넘깁니다.
이때 핵심은 도망 행동을 가장 높은 우선순위에 두는 것입니다. 체력이 낮은데도 스킬 조건을 먼저 검사하면 몬스터가 도망쳐야 할 순간에 공격을 시도할 수 있습니다.
flowchart TD
Root[Selector: 우선순위 선택] --> Flee[Sequence: 도망]
Root --> Skill[Sequence: 스킬]
Root --> Chase[Sequence: 추적]
Root --> Idle[대기 또는 기본 공격]
Flee --> LowHp{체력 30% 이하?}
LowHp -->|예| MoveAway[플레이어 반대 방향으로 이동]
Skill --> SafeHp{도망 조건 아님?}
SafeHp --> InSkillRange{스킬 사거리 안?}
InSkillRange --> CooldownReady{재사용 대기 완료?}
CooldownReady --> CastSkill[스킬 시전]
Chase --> OutsideAttackRange{공격 사거리 밖?}
OutsideAttackRange --> MoveToTarget[플레이어 추적]
Selector는 왼쪽부터 자식을 평가하다가 성공하거나 실행 중인 노드를 찾으면 그 결과를 반환합니다. 반대로 Sequence는 모든 자식이 성공해야 성공합니다. 따라서 도망 시퀀스의 체력 조건이 실패하면 다음 스킬 시퀀스로 자연스럽게 넘어갑니다.

블랙보드에 공유할 값 정하기
트리의 각 노드가 몬스터 컴포넌트를 직접 참조하면 재사용성이 떨어집니다. 행동 노드가 공통으로 읽고 쓸 데이터를 블랙보드(Blackboard)에 두면 구조가 단순해집니다.
using UnityEngine;
public sealed class MonsterBlackboard
{
public Transform Target;
public float Health;
public float MaxHealth;
public float AttackRange = 2.0f;
public float SkillRange = 6.0f;
public float FleeHealthRatio = 0.3f;
public float NextSkillTime;
public float SkillCooldown = 4.0f;
public float HealthRatio => MaxHealth <= 0f ? 0f : Health / MaxHealth;
public float DistanceToTarget(Vector3 position)
{
return Target == null
? float.PositiveInfinity
: Vector3.Distance(position, Target.position);
}
}
블랙보드에는 목표, 체력, 사거리, 쿨다운처럼 판단에 필요한 사실을 둡니다. 반면 NavMeshAgent 이동이나 애니메이터 재생처럼 실행 주체에 가까운 기능은 몬스터 컨트롤러에 남기는 편이 좋습니다.
노드 결과와 기본 합성 노드
행동 트리는 보통 Success, Failure, Running 세 결과를 사용합니다. 이동이나 시전처럼 여러 프레임에 걸친 행동은 Running을 반환해야 다음 프레임에도 같은 행동을 이어갈 수 있습니다.
public enum NodeState
{
Success,
Failure,
Running
}
public abstract class BtNode
{
public abstract NodeState Tick();
}
public sealed class SelectorNode : BtNode
{
private readonly BtNode[] children;
public SelectorNode(params BtNode[] children)
{
this.children = children;
}
public override NodeState Tick()
{
foreach (var child in children)
{
NodeState state = child.Tick();
if (state != NodeState.Failure)
return state;
}
return NodeState.Failure;
}
}
public sealed class SequenceNode : BtNode
{
private readonly BtNode[] children;
public SequenceNode(params BtNode[] children)
{
this.children = children;
}
public override NodeState Tick()
{
foreach (var child in children)
{
NodeState state = child.Tick();
if (state != NodeState.Success)
return state;
}
return NodeState.Success;
}
}
이 구현은 이해하기 위한 최소 형태입니다. 실제 프로젝트에서는 Sequence가 실행 중인 자식의 인덱스를 저장해 매 프레임 첫 노드부터 다시 평가하지 않도록 최적화할 수 있습니다. 다만 도망처럼 높은 우선순위의 행동이 즉시 끼어들어야 한다면 매 틱 우선순위를 다시 평가하는 방식이 오히려 의도에 맞습니다.
조건 노드와 행동 노드 분리
조건은 성공 또는 실패만 반환하고 행동은 필요하면 Running을 반환하도록 역할을 나눕니다. 아래 코드는 핵심 조건을 간단히 표현한 예시입니다.
public sealed class IsLowHealthNode : BtNode
{
private readonly MonsterBlackboard blackboard;
public IsLowHealthNode(MonsterBlackboard blackboard)
{
this.blackboard = blackboard;
}
public override NodeState Tick()
{
return blackboard.HealthRatio <= blackboard.FleeHealthRatio
? NodeState.Success
: NodeState.Failure;
}
}
public sealed class IsSkillReadyNode : BtNode
{
private readonly MonsterBlackboard blackboard;
private readonly Transform self;
public IsSkillReadyNode(MonsterBlackboard blackboard, Transform self)
{
this.blackboard = blackboard;
this.self = self;
}
public override NodeState Tick()
{
bool inRange = blackboard.DistanceToTarget(self.position) <= blackboard.SkillRange;
bool cooldownReady = Time.time >= blackboard.NextSkillTime;
return blackboard.Target != null && inRange && cooldownReady
? NodeState.Success
: NodeState.Failure;
}
}
스킬 사거리와 공격 사거리를 같은 값으로 처리하면 패턴의 차이가 사라집니다. 예를 들어 원거리 스킬은 공격 사거리보다 멀리 두고 도망 중에는 스킬 시퀀스가 실행되지 않도록 트리 우선순위로 제어하는 편이 명확합니다.
트리 조립 예시
아래처럼 루트 Selector에 도망, 스킬, 추적 순서로 시퀀스를 넣을 수 있습니다. MoveAwayNode, CastSkillNode, ChaseTargetNode는 각각 이동과 애니메이션, 피해 판정을 담당하는 행동 노드라고 가정합니다.
public BtNode BuildTree(MonsterBlackboard blackboard, Transform self)
{
BtNode flee = new SequenceNode(
new IsLowHealthNode(blackboard),
new MoveAwayNode(blackboard, self, fleeDistance: 8.0f)
);
BtNode skill = new SequenceNode(
new IsNotLowHealthNode(blackboard),
new IsSkillReadyNode(blackboard, self),
new CastSkillNode(blackboard, self)
);
BtNode chase = new SequenceNode(
new IsOutsideAttackRangeNode(blackboard, self),
new ChaseTargetNode(blackboard, self)
);
return new SelectorNode(
flee,
skill,
chase,
new IdleNode()
);
}
CastSkillNode는 시전을 시작할 때 바로 성공시키지 말고 애니메이션 이벤트나 시전 시간이 끝날 때까지 Running을 반환하는 편이 안전합니다. 성공 시점에 NextSkillTime을 갱신하면 같은 프레임이나 다음 틱에서 스킬이 연속 선택되는 문제도 막을 수 있습니다.
페이즈 전환에서 자주 생기는 문제
도망과 추적이 매 프레임 뒤집히는 현상
체력 조건만으로 도망을 끝내면 회복 효과나 조건 변화에 따라 도망과 추적이 흔들릴 수 있습니다. 이 경우 진입 기준과 이탈 기준을 다르게 두는 히스테리시스가 도움이 됩니다. 예를 들어 체력 30%에서 도망을 시작하고 40% 이상일 때만 도망을 해제합니다.
스킬 시전 중 도망 조건이 발생하는 경우
도망이 시전보다 우선이라면 시전을 취소할지 결정해야 합니다. 즉시 취소 가능한 스킬이라면 행동 노드가 취소 처리와 이동 복귀를 지원해야 합니다. 취소할 수 없는 공격이라면 시전 노드를 인터럽트 불가로 설계하고 시전 종료 후 도망을 선택하도록 규칙을 명시해야 합니다.
목표를 잃었는데 이전 행동이 계속되는 경우
대상이 사망하거나 탐지 범위 밖으로 나가면 블랙보드의 Target을 비워야 합니다. 모든 거리 조건은 대상이 있는지 먼저 검사하고 이동 노드는 목표가 없을 때 에이전트를 멈추도록 처리합니다.
디버깅할 때 볼 항목
행동 트리는 결과만 보면 원인을 찾기 어렵습니다. 개발 중에는 현재 실행 중인 노드, 각 조건의 평가 결과, 거리, 체력 비율, 다음 스킬 가능 시간을 화면이나 로그로 확인하는 것이 좋습니다.
특히 도망 페이즈가 기대와 다르게 동작하면 다음 순서로 확인합니다.
- 도망 시퀀스가 루트에서 가장 먼저 평가되는지 확인합니다.
- 체력 비율이
0~1범위인지 확인합니다. - 이동 노드가 플레이어 반대 방향을 올바르게 계산하는지 확인합니다.
- 스킬 노드가 시전 중에 쿨다운을 갱신하는지 확인합니다.
행동 트리는 복잡한 AI를 마법처럼 해결하는 도구가 아닙니다. 대신 우선순위와 행동의 책임을 눈에 보이는 구조로 바꿔 줍니다. 추적, 스킬, 도망처럼 서로 충돌하기 쉬운 몬스터 패턴부터 트리로 옮기면 이후 경계, 엄폐, 소환, 협동 행동도 같은 방식으로 확장할 수 있습니다.


