FSM 기반 보스 AI 행동 패턴과 페이즈 전환 로직

보스 AI를 유한 상태 기계(FSM)로 설계하고 체력·전투 상황에 따른 페이즈 전환을 안정적으로 구현하는 방법을 정리합니다.

✓ 복사 완료!
챕터시스템 아키텍처 & 패턴
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
FSM 기반 보스 AI 행동 패턴과 페이즈 전환 로직

왜 보스 AI에 FSM을 사용하는가

보스는 일반 적보다 공격 종류와 전투 규칙이 많다. 대기, 추적, 공격, 경직, 페이즈 전환처럼 역할이 분명한 행동을 상태로 나누면 각 행동의 진입 조건과 종료 조건을 관리하기 쉬워진다.

FSM은 한 시점에 하나의 주 상태만 활성화한다. 따라서 여러 행동이 동시에 실행되어 애니메이션과 이동 명령이 충돌하는 문제를 줄일 수 있다. 다만 공격 선택, 쿨다운, 거리 조건까지 모든 규칙을 상태 전이 조건에 직접 쌓으면 전이가 복잡해진다. 상태는 큰 행동 단위로 유지하고 상태 내부에서 세부 공격을 선택하는 방식이 보통 더 관리하기 좋다.

상태와 데이터의 역할을 나누기

먼저 보스의 큰 흐름을 상태로 정의한다.

  • Idle: 플레이어를 찾거나 전투 시작을 기다린다.
  • Chase: 목표 거리를 유지하며 플레이어에게 접근한다.
  • Combat: 공격을 선택하고 실행한다.
  • Stagger: 피격 또는 특정 기믹으로 인해 잠시 행동을 멈춘다.
  • PhaseTransition: 연출과 능력치 변경을 처리한다.
  • Dead: 사망 연출과 전투 종료를 처리한다.

여기서 페이즈는 상태와 분리한 데이터로 두는 편이 좋다. Phase2Combat, Phase3Combat처럼 페이즈마다 상태를 복제하면 동일한 추적·피격·사망 처리가 늘어나고 수정 범위도 커진다. 대신 현재 페이즈 값이 공격 후보, 이동 속도, 쿨다운 보정값을 제공하도록 구성한다.

flowchart TD
    A[Idle] -->|플레이어 감지| B[Chase]
    B -->|공격 거리 진입| C[Combat]
    C -->|목표가 멀어짐| B
    C -->|체력 임계값 도달| D[PhaseTransition]
    D -->|연출 종료| C
    B -->|경직 조건| E[Stagger]
    C -->|경직 조건| E
    E -->|회복| B
    A -->|체력 0| F[Dead]
    B -->|체력 0| F
    C -->|체력 0| F
    D -->|체력 0| F
    E -->|체력 0| F

이 구조에서 Dead 전이는 다른 모든 상태보다 우선한다. 체력이 0이 된 뒤에도 공격 코루틴이나 페이즈 연출이 남아 있는 문제를 막으려면 매 프레임 또는 피해 처리 직후 사망 여부를 먼저 확인해야 한다.

상태 인터페이스 만들기

Unity C# 예제에서는 상태마다 Enter, Tick, Exit를 갖도록 구성할 수 있다. 상태 객체가 보스의 모든 데이터를 직접 보관하기보다 보스 컨트롤러를 통해 필요한 정보와 기능에 접근하게 하면 상태 교체가 단순해진다.

public interface IBossState
{
    void Enter();
    void Tick();
    void Exit();
}

public sealed class BossStateMachine
{
    public IBossState CurrentState { get; private set; }

    public void ChangeState(IBossState nextState)
    {
        if (CurrentState == nextState)
        {
            return;
        }

        CurrentState?.Exit();
        CurrentState = nextState;
        CurrentState.Enter();
    }

    public void Tick()
    {
        CurrentState?.Tick();
    }
}

상태 전환은 한곳에서만 수행한다. 상태 객체가 서로의 내부 구현을 직접 호출하지 않고 ChangeState를 거치면 전환 시 애니메이션 종료나 이동 중지 같은 공통 정리 작업을 넣기 쉽다.

public class BossController : MonoBehaviour
{
    [SerializeField] private float maxHealth = 1000f;
    [SerializeField] private float phase2HealthRatio = 0.6f;
    [SerializeField] private float phase3HealthRatio = 0.25f;

    private readonly BossStateMachine stateMachine = new();
    private float currentHealth;
    private int currentPhase = 1;
    private bool transitionInProgress;

    private void Awake()
    {
        currentHealth = maxHealth;
    }

    private void Update()
    {
        if (currentHealth <= 0f)
        {
            stateMachine.ChangeState(new DeadState(this));
            return;
        }

        stateMachine.Tick();
    }

    public void TakeDamage(float damage)
    {
        currentHealth = Mathf.Max(0f, currentHealth - damage);
        TryStartPhaseTransition();
    }

    public float HealthRatio => currentHealth / maxHealth;
    public int CurrentPhase => currentPhase;
}

실제 프로젝트에서는 new로 상태를 매번 생성하는 대신 상태 인스턴스를 미리 만들어 재사용해도 된다. 중요한 점은 상태 객체가 실행 중인 코루틴, 타이머, 이동 명령을 Exit에서 정리할 수 있어야 한다는 것이다.

페이즈 전환 조건을 한 번만 통과시키기

체력 비율만 확인하면 같은 페이즈 전환이 여러 번 실행될 수 있다. 예를 들어 체력이 60% 아래인 동안 Update에서 조건을 검사하면 페이즈 2 연출을 반복해서 시작할 위험이 있다.

현재 페이즈와 전환 진행 여부를 함께 확인하면 중복 전환을 막을 수 있다.

private void TryStartPhaseTransition()
{
    if (transitionInProgress || currentHealth <= 0f)
    {
        return;
    }

    if (currentPhase == 1 && HealthRatio <= phase2HealthRatio)
    {
        StartPhaseTransition(2);
    }
    else if (currentPhase == 2 && HealthRatio <= phase3HealthRatio)
    {
        StartPhaseTransition(3);
    }
}

private void StartPhaseTransition(int nextPhase)
{
    transitionInProgress = true;
    stateMachine.ChangeState(new PhaseTransitionState(this, nextPhase));
}

public void CompletePhaseTransition(int nextPhase)
{
    currentPhase = nextPhase;
    transitionInProgress = false;
    stateMachine.ChangeState(new CombatState(this));
}

체력 임계값은 0.6f, 0.25f처럼 비율로 저장하면 난이도별 최대 체력이 달라져도 같은 설계를 유지할 수 있다. 체력 비율은 다음 식으로 계산한다.

healthRatio=currentHealthmaxHealthhealthRatio = \frac{currentHealth}{maxHealth}

체력 임계값을 기준으로 1페이즈에서 3페이즈로 전환되는 보스 체력 UI 예시

한 번의 큰 피해로 여러 임계값을 건너뛸 수도 있다. 이때는 게임 디자인을 먼저 정해야 한다. 모든 페이즈 연출을 순서대로 보여줄지, 현재 체력에 맞는 가장 높은 페이즈로 즉시 넘어갈지에 따라 처리 방식이 달라진다. 연출이 중요한 보스라면 전자를 빠른 전투 흐름이 더 중요하다면 후자를 선택할 수 있다.

전환 상태에서 처리할 일

페이즈 전환은 단순히 숫자를 바꾸는 작업이 아니다. 플레이어에게 전투 규칙이 달라졌음을 전달하는 짧은 구간이다. 전환 상태에서는 일반 공격과 이동을 중단하고 다음 요소를 순서대로 처리한다.

  1. 현재 공격과 이동을 취소한다.
  2. 무적 여부, 슈퍼아머, 카메라 연출 같은 전환 규칙을 적용한다.
  3. 애니메이션과 이펙트를 재생한다.
  4. 새 페이즈의 능력치와 공격 테이블을 적용한다.
  5. 연출이 끝나면 전투 상태로 돌아간다.
public sealed class PhaseTransitionState : IBossState
{
    private readonly BossController boss;
    private readonly int nextPhase;
    private float remainingTime;

    public PhaseTransitionState(BossController boss, int nextPhase)
    {
        this.boss = boss;
        this.nextPhase = nextPhase;
    }

    public void Enter()
    {
        boss.StopMovement();
        boss.CancelCurrentAttack();
        boss.SetDamageable(false);
        boss.PlayPhaseTransitionAnimation(nextPhase);
        remainingTime = boss.GetTransitionDuration(nextPhase);
    }

    public void Tick()
    {
        remainingTime -= Time.deltaTime;

        if (remainingTime > 0f)
        {
            return;
        }

        boss.ApplyPhaseSettings(nextPhase);
        boss.SetDamageable(true);
        boss.CompletePhaseTransition(nextPhase);
    }

    public void Exit()
    {
        boss.SetDamageable(true);
    }
}

위 예제처럼 시간으로 전환 종료를 판단할 수 있지만 애니메이션 길이가 바뀌면 코드와 실제 연출이 어긋날 수 있다. 가능하다면 애니메이션 이벤트나 타임라인 신호로 CompletePhaseTransition을 호출하는 편이 안전하다. 다만 이벤트가 누락될 때 보스가 멈추지 않도록 최대 대기 시간을 보조 장치로 두는 것도 좋다.

페이즈별 공격 선택은 데이터로 관리하기

페이즈가 올라갈수록 공격 종류를 추가하거나 가중치를 바꾸는 경우가 많다. 이를 조건문으로 계속 늘리기보다 공격 정의와 페이즈별 후보 목록을 데이터로 분리하면 조정이 쉬워진다.

[System.Serializable]
public class AttackDefinition
{
    public string id;
    public float minDistance;
    public float maxDistance;
    public float cooldown;
    public int weight;
}

public AttackDefinition SelectAttack(IReadOnlyList<AttackDefinition> candidates, float distance)
{
    var available = candidates
        .Where(attack => distance >= attack.minDistance)
        .Where(attack => distance <= attack.maxDistance)
        .Where(attack => IsOffCooldown(attack.id))
        .ToList();

    if (available.Count == 0)
    {
        return null;
    }

    int totalWeight = available.Sum(attack => attack.weight);
    int roll = Random.Range(0, totalWeight);

    foreach (var attack in available)
    {
        roll -= attack.weight;
        if (roll < 0)
        {
            return attack;
        }
    }

    return available[^1];
}

가중치 선택 전에 거리와 쿨다운으로 후보를 걸러야 한다. 그렇지 않으면 가까운 거리에서만 가능한 공격이 먼 거리에서 선택된 뒤 취소되는 일이 생긴다. 선택 실패 시에는 추적 상태로 돌아가거나 짧은 대기 행동을 수행하는 명확한 대체 규칙을 둔다.

페이즈별로 공격 후보와 가중치가 달라지는 보스 공격 테이블 예시

자주 발생하는 문제와 점검 방법

피해 처리 중 상태가 꼬이는 문제

공격 판정이 여러 프레임에 걸쳐 들어오거나 여러 투사체가 동시에 맞을 수 있다. TakeDamage에서 체력을 0 아래로 내리지 않고 페이즈 전환 중에는 새 전환을 시작하지 않도록 막아야 한다. 사망 상태는 항상 페이즈 전환보다 우선해야 한다.

공격 종료 콜백이 이전 상태를 되살리는 문제

공격 애니메이션 이벤트가 늦게 도착하면 이미 경직이나 페이즈 전환으로 바뀐 뒤에도 CombatState로 되돌아갈 수 있다. 공격 시작 시 발급한 실행 ID를 콜백에 전달하고 현재 실행 ID와 일치할 때만 종료 처리를 허용하면 이런 문제를 줄일 수 있다.

private int attackExecutionId;

public int BeginAttack()
{
    attackExecutionId++;
    return attackExecutionId;
}

public void OnAttackFinished(int finishedId)
{
    if (finishedId != attackExecutionId)
    {
        return;
    }

    // 현재 상태가 CombatState일 때만 다음 행동을 선택한다.
}

public void CancelCurrentAttack()
{
    attackExecutionId++;
}

페이즈 전환이 플레이어 행동을 무효화하는 문제

전환 순간 완전 무적을 주면 플레이어의 공격이 사라진 것처럼 느껴질 수 있다. 무적이 꼭 필요하지 않다면 피해는 받되 경직만 무시하는 방식도 고려할 수 있다. 무적이 필요하다면 전환 직전에 들어간 피해를 먼저 반영하고 시각·음향 신호를 분명히 제공해야 한다.

테스트 체크리스트

  • 각 체력 임계값에서 페이즈 전환이 정확히 한 번만 시작되는지 확인한다.
  • 큰 피해로 여러 임계값을 넘겼을 때 의도한 페이즈 처리 방식이 적용되는지 확인한다.
  • 전환 중 사망하면 연출과 공격이 정리되고 사망 상태로 이동하는지 확인한다.
  • 경직, 넉백, 컷신 등 강제 상태가 전환 상태와 충돌하지 않는지 확인한다.
  • 공격 애니메이션 이벤트가 늦게 도착해도 이전 상태로 되돌아가지 않는지 확인한다.
  • 거리 조건과 쿨다운 때문에 공격 후보가 없을 때 보스가 멈추지 않는지 확인한다.

FSM은 보스의 모든 판단을 담는 만능 구조가 아니라 큰 행동 흐름을 안정적으로 제어하는 뼈대다. 페이즈는 별도 데이터로 관리하고 전환은 한 번만 실행되게 보장하면 공격 패턴을 늘려도 구조를 유지하면서 조정할 수 있다.