Unity FSM과 커스텀 트리로 구현하는 보스 AI 2·3단계 페이즈 전환 로직

Unity C#에서 FSM과 커스텀 행동 트리를 결합해 보스 AI의 2·3단계 페이즈 전환을 구현한다. 체력 임계값, 전환 잠금, 패턴 가중치, 애니메이션 완료 신호를 분리해 중복 전환과 상태 충돌을 막는 실전 구조를 정리한다.

✓ 복사 완료!
챕터시스템 아키텍처 & 패턴
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity FSM과 커스텀 트리로 구현하는 보스 AI 2·3단계 페이즈 전환 로직

핵심 요약

보스의 2·3단계 페이즈 전환은 체력만 확인해 즉시 상태를 바꾸면 중복 전환, 공격 취소, 애니메이션 충돌이 발생하기 쉽다. PhaseController가 체력 임계값과 전환 요청을 판정하고 FSM은 전환 연출을 독점하며 커스텀 트리는 현재 페이즈에 맞는 공격 후보만 선택하도록 책임을 분리한다.

핵심은 Phase와 State를 같은 값으로 취급하지 않는 것이다. 페이즈는 장기 전투 규칙이고 상태는 현재 프레임에서 실행 중인 행동이다.

FSM과 커스텀 트리를 함께 쓰는 이유는 무엇인가?

Unity 2022 LTS와 C# 기준으로 보스 AI는 보통 두 종류의 결정을 동시에 요구한다.

구분FSM이 담당할 일커스텀 트리가 담당할 일
시간 축피격, 경직, 사망, 페이즈 전환처럼 서로 배타적인 행동공격 조건 평가와 우선순위 선택
제어 방식Enter, Tick, Exit로 상태 수명 관리Selector, Sequence, Condition 노드 조합
예시PhaseTransitionState 동안 이동·공격 입력 차단거리, 쿨다운, 플레이어 위치에 따른 공격 선택
장점애니메이션과 입력 잠금이 명확함페이즈별 패턴 확장이 쉬움

Phase2를 FSM 상태로만 만들면 페이즈 내의 공격 분기가 비대해진다. 반대로 모든 것을 트리에 넣으면 전환 연출 중 공격을 확실히 막기 어렵다. 따라서 FSM은 현재 무엇을 실행하는지, 트리는 다음 공격으로 무엇을 고를지를 결정하는 구조가 안정적이다.

FSM 상태와 페이즈별 커스텀 트리의 책임 분리를 보여 주는 보스 AI 구조

보스 AI 페이즈 전환은 왜 중복 실행되는가?

가장 흔한 원인은 체력 조건을 매 프레임 직접 검사하는 구현이다.

// 문제가 되는 예시: HP가 70% 이하인 모든 프레임에서 호출될 수 있다.
if (health.CurrentHp / health.MaxHp <= 0.7f)
{
    fsm.ChangeState(new PhaseTransitionState(2));
}

전환 상태에 들어간 뒤에도 체력이 70% 이하이므로 ChangeState가 반복 호출될 수 있다. 공격 도중 큰 피해를 받아 70%와 35% 임계값을 한 번에 넘는 경우에는 2단계와 3단계 요청이 경쟁할 수도 있다.

체력 비율은 다음처럼 계산한다.

H=CurrentHpmax⁡(MaxHp,1)H = \frac{\text{CurrentHp}}{\max(\text{MaxHp}, 1)}

이를 해결하려면 다음 세 가지를 분리한다.

  1. 전환 판정: 아직 진입하지 않은 다음 페이즈의 임계값만 검사한다.
  2. 전환 실행: FSM의 전환 상태에서 한 번만 실행한다.
  3. 전환 확정: 연출이 끝난 시점에 페이즈를 확정하고 새 트리를 활성화한다.

2단계·3단계 페이즈 전환을 어떻게 구현할까?

Step 1. 페이즈 데이터와 전환 요청을 정의한다

임계값과 공격 가중치를 ScriptableObject 또는 직렬화 가능한 데이터로 분리하면 밸런싱 때 코드 변경을 줄일 수 있다.

public enum BossPhase
{
    Phase1 = 1,
    Phase2 = 2,
    Phase3 = 3
}

[System.Serializable]
public sealed class PhaseDefinition
{
    public BossPhase phase;
    [Range(0f, 1f)] public float enterAtHealthRatio;
    public float moveSpeedMultiplier = 1f;
    public float damageMultiplier = 1f;
    public AttackWeight[] attackWeights;
}

[System.Serializable]
public struct AttackWeight
{
    public string attackId;
    [Min(0)] public int weight;
}

임계값은 내림차순으로 관리한다. 예를 들어 2단계는 0.70f, 3단계는 0.35f로 둔다. Phase1 → Phase2 → Phase3 순서를 강제하면 한 번의 피해로 여러 임계값을 넘더라도 규칙을 예측하기 쉽다.

Step 2. PhaseController에서 요청을 한 번만 발행한다

PhaseController는 현재 페이즈보다 높은 단계만 요청하며 전환 중에는 새 요청을 막는다. 여기서 페이즈 값을 즉시 바꾸지 않는 점이 중요하다.

public sealed class PhaseController : MonoBehaviour
{
    [SerializeField] private Health health;
    [SerializeField] private PhaseDefinition[] phases;

    public BossPhase CurrentPhase { get; private set; } = BossPhase.Phase1;
    public bool IsTransitioning { get; private set; }

    public event System.Action<BossPhase> TransitionRequested;

    private void Update()
    {
        if (IsTransitioning || CurrentPhase == BossPhase.Phase3)
            return;

        float ratio = health.CurrentHp / Mathf.Max(health.MaxHp, 1f);
        BossPhase nextPhase = (BossPhase)((int)CurrentPhase + 1);
        PhaseDefinition next = System.Array.Find(phases, x => x.phase == nextPhase);

        if (next != null && ratio <= next.enterAtHealthRatio)
        {
            IsTransitioning = true;
            TransitionRequested?.Invoke(nextPhase);
        }
    }

    public void ConfirmTransition(BossPhase phase)
    {
        CurrentPhase = phase;
        IsTransitioning = false;
    }
}

한 번에 3단계 임계값 아래로 내려갔을 때의 정책은 명시해야 한다. 연출을 모두 보여 주고 싶다면 2단계 전환 완료 후 다음 프레임에 3단계를 다시 판정한다. 속도감을 우선한다면 현재 체력에 해당하는 최고 페이즈로 바로 전환하는 별도 정책을 둔다. 두 정책을 섞으면 테스트가 어려워진다.

flowchart TD
    A[피해 적용 후 HP 갱신] --> B{전환 중인가?}
    B -- 예 --> Z[현재 FSM 상태 유지]
    B -- 아니오 --> C{다음 페이즈 임계값 이하인가?}
    C -- 아니오 --> Z
    C -- 예 --> D[PhaseTransitionState 진입]
    D --> E[공격·이동 중단 및 무적 규칙 적용]
    E --> F[애니메이션 이벤트 또는 타임라인 완료]
    F --> G[페이즈 확정]
    G --> H[스탯·공격 트리 갱신]
    H --> I[CombatState 복귀]

Step 3. FSM 전환 상태가 연출과 잠금을 독점하게 한다

전환 상태는 공격을 선택하지 않는다. 이동, 네비게이션, 히트박스, 애니메이션 이벤트의 소유권을 명확히 처리한 뒤 완료 신호에서만 페이즈를 확정한다.

public sealed class PhaseTransitionState : IBossState
{
    private readonly BossBrain brain;
    private readonly BossPhase targetPhase;
    private bool completed;

    public PhaseTransitionState(BossBrain brain, BossPhase targetPhase)
    {
        this.brain = brain;
        this.targetPhase = targetPhase;
    }

    public void Enter()
    {
        brain.Agent.isStopped = true;
        brain.AttackExecutor.CancelCurrentAttack();
        brain.Animator.SetInteger("Phase", (int)targetPhase);
        brain.Animator.SetTrigger("PhaseTransition");
        brain.SetDamageable(false); // 디자인 규칙에 따라 유지 또는 해제
    }

    public void Tick()
    {
        if (!completed)
            return;

        brain.PhaseController.ConfirmTransition(targetPhase);
        brain.ApplyPhaseStats(targetPhase);
        brain.BehaviorRunner.SetTree(brain.TreeLibrary.GetTree(targetPhase));
        brain.Fsm.ChangeState(new CombatState(brain));
    }

    // Animation Event에서 정확히 한 번 호출한다.
    public void OnTransitionAnimationFinished()
    {
        if (completed)
            return;

        completed = true;
        brain.SetDamageable(true);
    }

    public void Exit() { }
}

애니메이션 길이를 코드의 WaitForSeconds에 고정하지 않는 편이 좋다. Animator Controller의 전환 시간이나 클립 길이가 바뀌면 동기화가 깨지기 때문이다. 가능하면 PhaseTransitionFinished 애니메이션 이벤트, Playable Timeline signal, 혹은 상태 머신 비헤이비어를 완료 신호로 사용한다.

커스텀 행동 트리에서 페이즈별 공격을 선택하는 방법

커스텀 트리는 공통 루트는 유지하고 페이즈별 공격 Selector만 교체하는 방식이 관리하기 쉽다.

public enum NodeStatus { Success, Failure, Running }

public abstract class Node
{
    public abstract NodeStatus Tick();
}

public sealed class PhaseAttackSelector : Node
{
    private readonly System.Func<BossPhase> getPhase;
    private readonly System.Collections.Generic.Dictionary<BossPhase, Node> trees;

    public PhaseAttackSelector(
        System.Func<BossPhase> getPhase,
        System.Collections.Generic.Dictionary<BossPhase, Node> trees)
    {
        this.getPhase = getPhase;
        this.trees = trees;
    }

    public override NodeStatus Tick()
    {
        return trees[getPhase()].Tick();
    }
}

트리 루트는 다음 순서가 실용적이다.

  1. 사망, 전환 중, 경직처럼 공격보다 우선인 차단 조건을 먼저 검사한다.
  2. 거리와 쿨다운을 만족하는 공격 후보를 수집한다.
  3. 현재 페이즈의 가중치로 후보 하나를 뽑아 AttackExecutor에 실행을 요청한다.
  4. 공격이 Running인 동안에는 같은 공격 노드를 유지해 매 프레임 재추첨하지 않는다.

가중치 선택은 누적합 방식으로 충분하다. 유효 후보의 가중치 합을 W라고 할 때, 정수 난수 r을 0 ≤ r < W 범위에서 뽑고 누적 가중치가 r을 처음 넘는 공격을 선택한다.

P(attacki)=wi∑j=1nwjP(\text{attack}_i) = \frac{w_i}{\sum_{j=1}^{n} w_j}

페이즈 3에서 광역 공격 비중을 높이고 싶다면 트리 구조 전체를 복제하기보다 AttackWeight만 바꾸는 편이 낫다. 단, 페이즈 전용 기믹처럼 조건 자체가 달라질 때는 별도 서브트리를 만드는 것이 읽기 쉽다.

2단계와 3단계에서 공격 가중치와 조건 노드가 달라지는 커스텀 행동 트리 예시

페이즈 전환 시 반드시 정해야 할 게임플레이 규칙

항목권장 결정이유
현재 공격즉시 취소 또는 안전 구간 뒤 취소공격 히트박스가 남는 문제를 방지
피격 가능 여부전환 상태에서 명시적으로 설정무적 여부가 우연한 애니메이션 상태에 의존하지 않음
투사체유지 또는 정리 정책을 공격별로 정의화면에 남은 투사체의 판정이 일관됨
쿨다운유지, 초기화, 일부 감소 중 하나를 선택전환 직후 패턴 반복을 제어
체력 급감순차 전환 또는 즉시 최고 페이즈연출과 난이도 정책을 명확히 함
네트워크 동기화서버 또는 권한 주체가 페이즈 확정클라이언트별 페이즈 불일치 방지

특히 전환 중 피격 처리 순서는 문서화할 가치가 있다. ApplyDamage()에서 HP를 먼저 갱신한 뒤 전환 요청을 내고 실제 페이즈 확정은 전환 상태가 끝날 때 처리하면 피해와 연출의 책임이 분리된다.

자주 묻는 질문 (FAQ)

체력이 35% 아래로 바로 떨어지면 2단계와 3단계를 동시에 바꿔도 될까?

가능하지만 정책을 하나로 고정해야 한다. 연출을 보장하려면 2단계 전환을 완료한 뒤 3단계를 다시 판정하고 전투 템포가 더 중요하면 최고 도달 페이즈를 계산해 한 번만 전환한다.

페이즈 전환 로직은 Update()에서 검사해도 괜찮을까?

가능하다. 다만 IsTransitioning, 현재 페이즈, 다음 페이즈만 확인해 멱등성을 보장해야 한다. 피해 이벤트 직후 검사하면 불필요한 프레임 폴링을 줄일 수 있지만 어느 방식이든 전환 요청은 한 번만 발행해야 한다.

행동 트리를 페이즈마다 새로 생성해야 할까?

노드에 실행 중 상태나 쿨다운이 있다면 페이즈별 트리 인스턴스를 분리하는 편이 안전하다. 공통 트리를 재사용한다면 페이즈 전환 시 실행 중 노드를 명시적으로 중단하고 상태를 초기화해야 한다.

마무리

안정적인 보스 페이즈 전환은 체력 조건문 하나가 아니라 명확한 책임 분리에서 나온다. PhaseController는 전환을 요청하고 FSM의 전환 상태는 연출과 입력 잠금을 보장하며 커스텀 트리는 확정된 페이즈에 맞춰 공격만 선택한다. 이 구조를 지키면 2단계와 3단계의 패턴을 늘려도 전환 중복과 상태 충돌을 통제하기 쉬워진다.