DirectX11/Eternal Return 모작

[DirectX 11 Eternal Return 모작] 18. Behavior Tree ( 행동 트리 )

Vfly 2026. 1. 7. 06:30

전체 코드 : https://github.com/HyangRim/DirectX11-Engine-Client

 

GitHub - HyangRim/DirectX11-Engine-Client

Contribute to HyangRim/DirectX11-Engine-Client development by creating an account on GitHub.

github.com


 

사실 이 프로젝트는 진작에 끝난 내용이긴한데.... 9월 이후로 포트폴리오나 면접준비 이것저것 하면서 피드백을 받았던 부분이 바로 "왜 행동 트리 안쓰고 FSM 썼냐?" 라는 피드백이 있었다.

 

솔직히 말하면 행동 트리가 있는지도 몰랐다 ㅎㅎ

 

TBI 모작을 하면서 써먹었던 FSM 구조를 그대로 적용해서 사용한거긴한데 막상 몬스터 State Machine 만들때 State가 너무 많아져서 힘들어 죽는줄 알았다....

 

그렇기에 2025년 취업도 다 조진김에 기존 프로젝트들에서 아쉬웠던 부분들은 한번 개인적으로 수정해보기로 했다.

 

그 첫번째로는 이터널리턴 모작 프로젝트에서 가장 말이 많았던 몬스터 FSM 구조를 한번 행동 트리로 바꿔보도록 하겠다.

 


 

먼저 FSM 부터 다시 확인해보자.

 

FSM이란? 

위 그림과 같이 FSM은 시스템을 유한한 개수의 상태(State)로 나누고, 특정 조건이나 이벤트가 발생하면 다른 상태로 전이 하는 구조를 말한다.

 

쉽게 말하자면 "지금 내가 무엇을 하고 있는가?" 를 정의하고 , "언제 행동을 바꿀 것인가?""규칙" 으로 정해놓은 구조다.

 

 

FSM 3요소

 

FSM은 총 3가지 요소로 구성되어 있다.

  1. 상태(State) : 객체가 취할 수 있는 구체적인 행동이나 상황
    • ex )  Idle(대기), Trace(추적), Attack(공격), Die(사망)
    • 특징 : 시스템은 한 번에 오직 하나의 상태만 가질 수 있다. ( 공격하면서 죽어있을 수는 없음 )
  2. 전이 (Transition) : 한 상태에서 다른 상태로 넘어가는 '흐름'
    • ex )  Idle → Trace
  3. 조건 / 이벤트 (Condition/Event) : 전이가 발생하기 위한 규칙
    • ex ) "플레이어와의 거리가 10m 이내인가?" ( 조건 충족 시 Idle → Trace )

 

FSM 작동 방식

FSM은 주로 상태 패턴(State Pattern)을 이용해 구현한다. 각 상태를 별도의 클래스(Class)로 만들고, Update() 함수 안에서 자신의 행동과 다음 상태로의 전환 조건을 검사한다.

 

  • 동작 예시
    • 현재 상태: IdleState
    • Update() 실행 : "주변에 플레이어가 있는가?" 확인.
    • 조건 충족 : 플레이어 발견! → ChangeState(Trace) 호출.
    • 상태 변경 : 현재 상태가 IdleState에서 TraceState로 바뀜.
    • 다음 프레임 : 이제 TraceState의 Update()가 실행됨 (플레이어를 쫓아감).

<예시 코드>

bool MonsterStateMachine::CanChangeState(MonsterStateType newState) const
{
    if (!m_currentState)
        return true;

    return m_currentState->CanTransitionTo(newState);
}

 

void WolfAttackState::Enter()
{
	/// State 진입시 할 행동 ///
}

void WolfAttackState::Update()
{
	// 실제 공격 로직들 ... ///
}

void WolfAttackState::Exit()
{
	/// State 종료시 할 행동 ///
    // ex ) 변수 초기화 등등...
}

bool WolfAttackState::CanTransitionTo(MonsterStateType newState)
{
	if (newState == MonsterStateType::Trace || newState==MonsterStateType::Death)
		return true;

	if (m_isAttackComplete)
	{
		switch (newState)
		{
		case MonsterStateType::Wait:
		case MonsterStateType::Death:
			return true;
		default:
			return false;
		}
	}
}

 

FSM의 장단점

  • 장점
    • 구조가 직관적이고 이해하기 쉽다 (도표로 그리기 쉬움).
    • 간단한 AI나 UI 흐름 제어에는 구현이 매우 빠름.
  • 단점
    • 복잡도 증가: 상태가 많아지면 서로 연결되는 전이(화살표)가 너무 많아져 관리하기 힘든 '스파게티 코드'가 된다.
    • 확장성 부족: 새로운 상태를 넣으려면 기존 상태들의 코드를 수정해야 한다.
    • 재사용성 낮음:  "Wolf" 의 Trace 로직을 "Chicken" 에게 그대로 쓰기 어렵다. (연결된 상태가 다르기 때문).(억지로 하면 가능할수는 있다...!)


다른건 몰라도 "복잡도 증가" 이게 참 귀찮고 짜증나는 요소다. 

뭐.. 사실 내가 구조를 잘못짠걸 수도 있지만 한 "타입""상태" 가 추가될때마다 "Enter() / Exit() / CanTransitionTo() / Update()" 함수들을 다 구현해줘야되고, 기존의 상태들에도 새로 추가한 상태한 대한 조건들을 다시 작성해줘야되는 매우 매우 귀찮아지는 구조다.

 

 

그래서 9월에 완성할당시에는 그냥 이미 구현해 둔거 일단 두자 라는 마인드였는데 포트폴리오 작성이나 면접을 준비하면서 이 부분이 걸림돌이 되는 경우가 생각보다 있었다.

 

그래서 그냥 혼자서라도 수정하기로 맘 먹고 이번기회에 수정을 해봤다.

 

자 그럼 행동 트리는 또 뭔지 알아보자.

 


행동 트리 ( Behavior Tree ) 란?

출처 : https://hoems-unity.tistory.com/39

 

행동 트리를 도식화 하면 위의 그림과 같다.

 

행동 트리트리(Tree) 자료구조를 사용하여 AI의 행동 로직을 계층적으로 설계하는 기법이다.
루트(Root) 노드에서 시작하여 자식 노드들을 탐색(Traversal)하며, 조건에 맞는 행동을 찾아 실행하는 방식이다.

핵심은 "어떤 상태인가?" 가 아니라 "무엇을 해야 하는가?" 에 집중한 구조다.

작동 방식은 각 노드는 실행 결과로 성공(Success), 실패(Failure), 실행 중(Running) 중 하나의 상태를 부모에게 반환한다.

 

 

행동 트리의 3요소

첨부한 이미지에 나와 있는 3가지 핵심 노드 타입을 설명하면 아래와 같다.

 

  1. 셀렉터 (Selector) - "선택의 갈림길"
    • 역할: 자식 노드들 중 하나라도 성공하면 즉시 성공을 반환하고 종료. (OR 연산과 유사)
    • 특징: 여러 선택지 중 가장 우선순위가 높은 행동 하나를 고를 때 사용.
    • 이미지 예시: 적의 행동 양식 셀렉터, 공격 방식 셀렉터, 타겟 설정 셀렉터
  2.  시퀀스 (Sequence) - "연속된 행동"
    • 역할: 자식 노드들을 순서대로 모두 실행해야 성공. 도중에 하나라도 실패하면 즉시 실패를 반환. (AND 연산과 유사)
    • 특징: 조건 검사와 행동 실행을 묶을 때 주로 사용. (예: 적이 보이는가? -> 쫓아가라)
    • 이미지 예시: 공격 시퀀스, 탐지 시퀀스
  3.  액션 (Action) / 조건 (Condition) - "실제 행동"
    • 역할: 실제 게임 로직(이동, 공격, 검사 등)을 수행하는 말단 노드(Leaf Node).
    • 특징:
      • 액션: 공격 하기, 추적 하기, 대기 하기 (실제로 무언가를 함)
      • 조건: 공격 범위 체크, 탐지 범위 체크 (검사만 하고 성공/실패 반환)
    • 이미지 예시: 공격 범위 체크 액션, 일반 공격 하기 액션, 추적 하기 액션 등

행동 트리의 작동방식 (실제 작동 예시)

상황: 몬스터가 플레이어를 발견하고 공격하려는 상황

 

  1. [Root] 시작: 트리의 꼭대기에서 탐색을 시작.
  2. [적의 행동 양식 셀렉터] 진입 :
    • 자식들(공격, 탐지, 귀환, 대기) 중 하나를 고르려 한다. "가장 왼쪽"[공격 시퀀스]부터 검사.
  3. [공격 시퀀스] 진입 (우선순위 1):
    • 첫 번째 자식인 [공격 범위 체크 액션]을 실행.
    • 판단: "플레이어가 사거리 안에 있는가?"
      • YES (성공): 시퀀스는 다음 자식인 [공격 방식 셀렉터]로 넘어갑니다.
        • [스킬 공격 하기] 시도 (쿨타임이면 실패) -> [일반 공격 하기] 실행 (성공).
        • 결과적으로 몬스터는 공격을 수행.
      • NO (실패): 사거리가 안 닿으므로 공격 시퀀스 전체가 실패 처리됩니다.
  4. [탐지 시퀀스] 진입 (우선순위 2 - 공격 실패 시):
    • 공격 시퀀스가 실패했으므로, 셀렉터는 두 번째 자식인 [탐지 시퀀스]를 실행.
    • 첫 번째 자식 [탐지 범위 체크 액션] 실행.
    • 판단: "플레이어가 시야(탐지 범위) 안에 있는가?"
      • YES (성공): [타겟 설정 셀렉터] 실행 -> [추적 하기 액션] 실행.
      • 결과적으로 몬스터는 플레이어를 추적.
  5. [귀환/대기] (우선순위 3, 4 - 모두 실패 시):
    • 공격도 안 되고 탐지도 안 되면(플레이어 없음), [귀환하기] [대기하기]를 실행.

 

행동 트리의 장단점

  • 장점
    1. 높은 모듈성: 공격 시퀀스 같은 가지(Branch)를 통째로 떼어내서 다른 몬스터에게 붙여도 잘 작동.
    2. 직관적인 로직: 트리를 보면 "공격이 탐지보다 우선순위가 높구나"를 한눈에 알 수 있음 (왼쪽이 우선).
    3. 확장성: 새로운 행동을 추가할 때, 기존 코드를 건드리지 않고 노드만 하나 끼워 넣으면 됨.
  • 단점
    1. 구현 난이도: FSM보다 초기 구조를 잡는 데 더 많은 코드가 필요.
    2. 탐색 비용: 트리가 매우 깊어지면 매 프레임 루트부터 탐색하는 비용이 발생할 수 있음 (최적화 필요).

실제 구현

void Wolf::InitBehaviorTree()
{
	// 1. BehaviorTree 인스턴스 생성
	m_behaviorTree = make_shared<BehaviorTree>();

	// 2. 루트 노드 (Selector) 생성
	auto rootNode = make_shared<SelectorNode>();

	// -----------------------------------------------------------
	// 3. 하위 시퀀스 조립 (Delegate::Bind 활용)
	// -----------------------------------------------------------

	// [1] 사망 시퀀스 (Sequence: CheckHP -> Die)
	auto deathSeq = make_shared<SequenceNode>();
	deathSeq->AddChild(make_shared<ActionNode>(Delegate::Bind(&Wolf::CheckHP, this)));
	deathSeq->AddChild(make_shared<ActionNode>(Delegate::Bind(&Wolf::Die, this)));

	// [2] 전투 시퀀스 (Sequence: CheckAttackRange -> Attack)
	auto attackSeq = make_shared<SequenceNode>();
	attackSeq->AddChild(make_shared<ActionNode>(Delegate::Bind(&Wolf::CheckAttackRange, this)));
	attackSeq->AddChild(make_shared<ActionNode>(Delegate::Bind(&Wolf::Attack, this)));

	// [3] 추적 시퀀스 (Sequence: CheckDetectRange -> Trace)
	auto traceSeq = make_shared<SequenceNode>();
	traceSeq->AddChild(make_shared<ActionNode>(Delegate::Bind(&Wolf::CheckDetectRange, this)));
	traceSeq->AddChild(make_shared<ActionNode>(Delegate::Bind(&Wolf::Trace, this)));

	// [4] 대기 액션 (Action)
	auto idleNode = make_shared<ActionNode>(Delegate::Bind(&Wolf::Idle, this));

	// 4. 루트에 자식 등록 (우선순위: 사망 > 공격 > 추적 > 대기)
	rootNode->AddChild(deathSeq);
	rootNode->AddChild(attackSeq);
	rootNode->AddChild(traceSeq);
	rootNode->AddChild(idleNode);

	// 5. 완성된 루트 노드를 BehaviorTree에 등록
	m_behaviorTree->SetRootNode(rootNode);
}

 

위와 같은 방식으로 행동 트리 루트에 노드들을 만들어 준 뒤에, 액션 노드에서 수행할 함수들을 직접 구현만 해주면 끝이 나는 구조다.

 

 

1. 사망 로직 ( Die )

NodeState Wolf::Die()
{
    auto animSM = GetAnimationStateMachine();

    // [1] 이미 사망 로직이 진행 중인 경우
    if (m_isDeadMotionStarted)
    {
        // 사체(Dying) 상태라면 계속 유지 (완벽한 죽음)
        if (animSM->IsInState(AnimationStateType::Dying))
            return NodeState::RUNNING;
            
        // Death 애니메이션 종료 후 -> 사체 상태로 전환
        // (안전장치: 타이머 체크 등 생략된 디테일은 주석으로 설명)
        animSM->RequestStateChange(AnimationStateType::Dying);
        return NodeState::RUNNING;
    }

    // [2] 최초 사망 진입 (HP <= 0)
    if (m_navAgent) m_navAgent->Stop(); // 이동 정지

    // 사망 애니메이션 요청 및 플래그 설정
    animSM->RequestStateChange(AnimationStateType::Death);
    m_isDeadMotionStarted = true;
    m_deathTimer = 0.0f;

    return NodeState::RUNNING;
}

 

 

2. 공격 사거리 체크

NodeState Wolf::CheckAttackRange()
{
    auto target = GetTarget();
    if (!target) return NodeState::FAILURE;

    float dist = Vec3::Distance(GetTransform()->GetPosition(), target->GetTransform()->GetPosition());

    // [1] 사거리 내 진입 성공
    if (dist <= m_monsterStatus.hitRange)
    {
        if (m_navAgent) m_navAgent->Stop(); // 공격을 위해 멈춤
        return NodeState::SUCCESS; // -> Attack 노드 실행
    }

    // [2] 사거리 밖 -> 공격 상태 강제 리셋 (재진입 시 꼬임 방지)
    m_isAttacking = false; 
    return NodeState::FAILURE; // -> Trace 노드 실행
}

 

 

3. 공격 행동

NodeState Wolf::Attack()
{
    auto animSM = GetAnimationStateMachine();
    
    // [1] 공격 시작 진입 (First Enter)
    if (!m_isAttacking)
    {
        // 타겟 방향 회전 및 초기화
        /* ... 회전 로직 ... */
        m_attackTimer = 0.0f;
        m_isAttacking = true;

        // FSM에 애니메이션 재생 요청 (Body에게 명령)
        animSM->RequestStateChange(AnimationStateType::BaseAttack);
        return NodeState::RUNNING;
    }

    // [2] 공격 진행 중 (Update)
    m_attackTimer += DT;

    // 공격 애니메이션 시간(Duration)이 끝났을 때 판단
    if (m_attackTimer >= m_attackDuration)
    {
        float dist = /* ... 거리 계산 ... */;

        // A. 여전히 사거리 내 -> 연속 공격 (타이머 리셋)
        if (dist <= 3.0f) 
        {
            m_attackTimer = 0.0f; 
            animSM->RequestStateChange(AnimationStateType::BaseAttack); // 재요청
            return NodeState::RUNNING;
        }
        // B. 사거리 벗어남 -> 공격 종료 (성공 반환하여 Trace로 전환 유도)
        else 
        {
            m_isAttacking = false; 
            return NodeState::SUCCESS; 
        }
    }

    return NodeState::RUNNING; // 아직 때리는 중
}

 

 

4. 추적 및 대기

NodeState Wolf::Trace()
{
    auto target = GetTarget();
    if (!target) return NodeState::FAILURE;

    // [1] 이동 명령 (NavMeshAgent 사용)
    if (m_navAgent)
    {
        m_navAgent->SetDestination(target->GetTransform()->GetPosition());
        m_navAgent->SetSpeed(2.0f);
    }

    // [2] 애니메이션 동기화 (Run/Trace 상태로 전환)
    auto animSM = GetAnimationStateMachine();
    if (!animSM->IsInState(AnimationStateType::Trace))
    {
        animSM->RequestStateChange(AnimationStateType::Trace);
    }

    return NodeState::RUNNING; // 추적은 계속 진행됨
}

NodeState Wolf::Idle()
{
    // 이동 정지 및 대기 애니메이션 재생
    if (m_navAgent) m_navAgent->Stop();

    auto animSM = GetAnimationStateMachine();
    if (!animSM->IsInState(AnimationStateType::Wait))
    {
        animSM->RequestStateChange(AnimationStateType::Wait);
    }
    
    return NodeState::SUCCESS;
}

 


 

근데 여기까지 바꾸고 나서 생각했던건데, 애니메이션 쪽은 사실 FSM 구조를 그대로 유지하고 있다.

 

왜냐면 애니메이션을 행동 트리와 같은 형태로 바꾸게 되면 오히려 관리가 더 어려워진다.

 

"무엇을 할지"를 결정하는 AI와 달리 애니메이션은 "어떻게 자연스럽게 보여줄지(블렌딩 / 전이 / 타이밍)"이 핵심이다.

 

따라서 오히려 행동 트리로 전환하게 되면 트리가 지저분해지고 오히려 더 복잡해지게 된다.

 

왜 애니메이션은 FSM?

FSM은 유한한 상태(Idle/Run/Attack/Death 등)를 정의하고, 조건에 따라 상태를 전이시키는 모델이라 애니메이션처럼 “상태 전환 규칙”이 중요한 영역에 잘 맞는다.

특히 공격 모션처럼 “중간에 캔슬 가능한가?”, “끝나면 어떤 상태로 돌아갈까?”, “Run→Attack 블렌딩을 허용할까?” 같은 전이 제약은 BT 노드로 풀면 노드마다 예외 처리(가드/쿨타임/애니 완료 체크)가 퍼져서 유지보수가 나빠진다.

 

BT와 FSM의 역할 분담

BT는 매 프레임(또는 주기적으로) 현재 상황을 평가해서 “지금은 공격이 최우선”“공격 불가면 추적”“HP<=0이면 사망”처럼 우선순위 기반 의사결정을 담당한다.


반면 애니메이션 FSM은 “공격 상태에서 Run으로 전이가 가능한지”, “Death는 한 번 재생 후 Dying으로 고정”처럼 동작 표현의 규칙을 담당한다.

 

“그럼 FSM이랑 뭐가 달라?”

기존(순수 FSM AI)에서는 TraceState::Update() 같은 곳에서 거리 재고, 타겟 확인하고, 공격으로 전이하고, 애니메이션까지 바꾸는 식으로 “결정 + 실행 + 표현”이 한 덩어리로 뭉치기 쉽다.

하지만 지금 구조에서는 BT가 결정만 하고(CheckAttackRange, CheckHP), 실행/표현은 각 시스템이 맡는다(이동은 NavMeshAgent, 모션은 AnimationStateMachine). 즉, 상태 전이의 복잡도가 “AI 전이 그래프”에서 “애니메이션 전이 그래프”로 축소되고, AI 로직은 트리 구조로 확장 가능해진다.

 


결론 : 두뇌(Brain)와 신체(Body)의 분리"

결국 이번 리팩토링의 핵심은 '의사 결정(Decision Making)'과 '행동 실행(Execution)'의 분리다.

  • Behavior Tree (Brain): 현재 상황을 판단하고 "무엇을 할지(What)" 결정한다. (순수 논리)
  • FSM (Body): 결정된 행동을 "어떻게 보여줄지(How)" 수행한다. (애니메이션, 블렌딩, 전이 제약)

FSM 안에 모든 판단 로직을 섞어놨던 과거의 '스파게티 코드'에서 벗어나, 이제는 BT가 명령을 내리면 FSM은 수행만 하는 계층적 구조(Hierarchical Structure)가 되었다.

 

덕분에 AI 로직을 수정해도 애니메이션 코드는 건드릴 필요가 없고, 반대의 경우도 마찬가지다.

 

사실 아직 Player 쪽도 구조가 개판인 FSM 구조를 그대로 적용중인 상태인데 사실 이 부분도 HFSM( Hierachical FSM ) "계층적 유한 상태 머신" 으로도 개선이 가능하다고 하는데......

 

한번 시간이 된다면 이 부분도 같이 다루어 보도록 하겠다.