몬스터 AI 실행 구조와 FSM 기반 실행 계층 분리 구조

목차

1. 요구 사항

2. 설계 목표

3. 흐름도

4. 구현

        4.1. Monster 클래스 상속

       4.2. 실행 계층 설계 원칙과 접근 제어자 설계 의도

       4.3. 몬스터 초기 설정

       4.4. 거리 체크 및 FSM 루프

       4.5. 애니메이션 동기화

       4.6. TargetSet과 레이드 확장 구조

5. 개발 의도

1. 시스템 요구 사항

몬스터는 베이스 클래스에서 정의된 상태(State)를 실제 행동으로 해석하는 실행 계층을 가져야 한다.

이 실행 계층은 상태를 소비하여 이동, 회전, 애니메이션, 타겟 설정 등을 수행하며, 베이스 클래스의 데이터/상태 정의를 침범하지 않아야 한다.

또한 AI는 프레임 단위로 상태를 평가하고, 플레이어와의 거리 및 시작 위치 기준 거리 정보를 바탕으로 상태 전이를 처리해야 한다.

이 시스템은 다음을 만족해야 한다.

Monster를 상속받아 공통 전투 데이터와 상태 체계를 공유하되, 실행 로직은 자식 클래스에 존재해야 한다.

이동은 경로 탐색 기반으로 안정적으로 처리되어야 하며, Animator와 분리된 로직 상태를 유지해야 하며, 레이드 상황과 같은 특수 모드에서도 동일한 상태 구조를 유지하면서 타겟과 추적 범위만 확장 가능해야 한다.

2. 설계  목표

Mushroom 상속을 통한 FSM 구조 재사용

virtual/override 기반 확장 설계

체력 구간 기반 스킬 활성화 구조

쿨타임 객체 분리 설계

Coroutine 기반 비동기 회복 시스템

전투 진입 시 회복 중단 가드

사망 시 잔여 상태 완전 정리

공격 확장 구조 (교체가 아닌 확장)

3. 흐름도

Monster (데이터/상태 정의)

       ↓ 상속

Mushroom (실행 계층)

       ↓

Awake → 컴포넌트 캐싱

Start → 초기 상태 설정

Update → 상태 소비 (FSM 루프)

       ↓

이동 / 회전 / 애니메이션 동기화

이전 게시글(몬스터 베이스 아키텍처 설계)에서 Monster는 상태를 만드는 클래스였다면, 여기서 Mushroom은 그 상태를 해석하여 실행하는 클래스다.

4. 구현

4.1. Monster 클래스 상속
public class Mushroom : Monster

Mushroom은 Monster를 상속받는다.

이로써 Monster가 정의한 체력, 상태(enum), 데미지 처리 규칙, 체력바 연동 구조를 그대로 공유한다.

상속을 선택한 이유는 모든 몬스터가 동일한 전투 규칙을 가져야 하기 때문이다.

체력 감소 기준, 사망 판정 기준, 상태 체계는 공통이므로 베이스에 두는 것이 일관성을 유지하는 가장 단순한 방법이다.

여기서 중요한 점은 Mushroom이 Monster의 내부 로직을 수정하지 않는다는 것이다.

상태를 소비할 뿐이다. 즉, 상속은 데이터/규칙 공유를 위한 것이며, 실행 확장을 위한 구조다.

4.2. 실행 계층 설계 원칙과 접근 제어자 설계 의도
protected Animator ...
protected void ...
protected virtual void ...

Mushroom 클래스는 단순한 몬스터 구현체가 아니라, Monster가 정의한 상태 체계를 실제 동작으로 해석하는 1차 실행 계층이다.

Monster가 상태와 전투 규칙을 정의하는 데이터 중심 클래스라면, Mushroom은 그 상태를 매 프레임 소비하여 이동, 회전, 애니메이션, 타겟 추적을 수행하는 실행 중심 클래스다.

이 구조는 상태 정의와 실행을 분리하기 위한 의도적인 계층화 설계다.

Monster는 무엇이 상태인가를 정의하고, Mushroom은 그 상태가 실제로 어떻게 동작하는가를 담당한다.

이때 Mushroom은 단일 몬스터 구현체로 끝나는 것이 아니라, 이후 보스(MushroomBoss)와 같은 특수 몬스터가 다시 상속하여 확장할 수 있는 중간 계층이 된다.

즉, 구조는 다음과 같다.

Monster (데이터/상태 정의)

Mushroom (기본 실행 계층)

MushroomBoss (확장 실행 계층)

이 계층 구조를 전제로 접근 제어자와 virtual 키워드를 설계했다.

Mushroom의 필드와 함수는 대부분 private이 아니라 protected로 선언되어 있다.

그 이유는 실행 계층이 여기서 끝나지 않기 때문이다.

protected는 외부 클래스에서는 접근할 수 없지만, 상속받은 하위 클래스에서는 접근 가능하다.

즉, 보스와 같은 확장 몬스터가 실행 로직을 부분적으로 재사용하거나 보완할 수 있도록 내부 실행 정보를 열어둔 것이다.

예를 들어 startPos, patolPos, agent... 이 값들은 외부 시스템이 직접 접근해서는 안 되지만, 하위 몬스터가 행동을 확장할 때는 필요하다.

따라서 public이 아닌 protected가 적절하다.

protected는 외부 시스템과는 결합을 차단하면서, 실행 계층 확장을 위해 자식 클래스에는 개방한다.

또한, 구현 세부사항을 은닉하면서도 확장 가능성을 유지한다.

모든 함수가 virtual은 아니다.

virtual은 확장 포인트에만 사용되었다.

protected virtual이 붙은 함수들은 상태 소비 흐름의 일부이다.

이 함수들은 하위 클래스에서 행동을 재정의할 가능성이 있다.

하위 클래스에서 행동을 재정의할 가능성이 있다.

예를 들어, Update, UpdateAttack...등 이 함수들은 FSM 실행 루프의 일부이거나, 전투 행동의 핵심 분기 지점이다.

보스는 공격 로직을 확장하고, 피격 시 회복을 중단하며, 사망 시 추가 정리를 수행한다.

이 경우 부모 구조를 유지하면서 일부만 변경해야 하므로 override 지점이 필요하다.

virtual이 붙은 함수는 아키텍처 확장 지점이다.

하위 클래스가 동작을 변경할 수 있도록 의도적으로 열어둔 것이다.

구조는 유지하고 실행만 교체 가능하게 만들 수 있다.

4.3. 몬스터 초기 설정
protected void Awake()
{
    anime = GetComponent<Animator>();
    agent = GetComponent<NavMeshAgent>();
    
    target = GameObject.FindWithTag("Player").transform;
    startPos = transform.position;
}

protected virtual void Start()
{
    curHp = maxHp;
    state = State.MOVE;
    SetRandomPatrolPoint();
    CreateHealthBar(1.5f);
}

Awake는 Unity에서 오브젝트가 활성화될 때 가장 먼저 호출되는 초기화 단계다.

여기서는 GetComponent를 통해 Animator와 NavMeshAgent를 캐싱한다.

GetComponent는 런타임 탐색이므로 반복 호출하면 비용이 발생한다.

따라서 Awake에서 1회만 캐싱하는 구조를 택했다.

NavMeshAgent는 Unity의 내비게이션 시스템으로, NavMesh 위에서 경로 탐색과 장애물 회피를 자동 처리한다.

직접 transform.position을 이동시키는 방식보다 안정적이며, 경로 계산을 엔진에 위임함으로써 복잡도를 줄일 수 있다.

target을 태그 기반으로 찾은 이유는 실행 계층이 플레이어 참조를 가져야 하기 때문이다.

다만 FindWithTag는 탐색 비용이 있으므로 Awake 1회 캐싱 구조를 사용했다.

Start에서는 런타임 데이터 초기화를 수행한다.

curHp를 maxHp로 설정해 전투 시작 시 체력을 보장하고, 초기 상태를 MOVE로 설정한다.

초기 상태를 MOVE로 설정한 이유는, 상태 전이 흐름을 자연스럽게 시작하기 위함이며, 정적인 IDLE에서 시작할 경우 월드가 정지된 느낌을 줄 수 있기 때문이다.

이는 정적인 IDLE보다 자연스러운 월드 표현을 위해 기본 순찰 상태로 시작하도록 설계한 것이다.

CreateHealthBar 호출은 UI 계층과 연결되는 지점이다.

자식 클래스가 생성 시점에서 체력바를 만들지만, 체력 계산은 여전히 베이스가 담당한다.

책임 경계는 유지된다.

4.4. 거리 체크 및 FSM 루프
protected Vector3 startPos;
protected Vector3 patrolPos;

protected virtual void Update()
{
    if (isDie) return;

    float distToPlayer = Vector3.Distance(transform.position, target.position);
    float distFromStart = Vector3.Distance(transform.position, startPos);

    HpBarActive(distToPlayer);
    
    switch (state)
    {
        case State.IDLE:
            UpdateIdle(distToPlayer);
            break;
        case State.MOVE:
            UpdatePatrol(distToPlayer);
            break;
        case State.CHASE:
            UpdateChase(distToPlayer, distFromStart);
            break;
        case State.ATTACK:
            UpdateAttack(distToPlayer, distFromStart);
            break;
        case State.RETURN:
            UpdateReturn();
            break;
        case State.DAMAGED:
            UpdateDamaged();
            break;
        case State.DIE:
            Die();
            break;
    }
}

이 Update는 AI의 중앙 상태 소비 루프다.

Update는 Unity 프레임 루프에서 매 프레임 호출되며, 이동, 애니메이션, 카메라와 동일한 타이밍으로 동작한다.

AI는 플레이어 위치 변화에 즉시 반응해야 하므로 FixedUpdate가 아니라 Update가 적절하다.

가장 먼저 isDie를 검사하는 것은 사망 이후 로직을 완전히 차단하기 위한 안전 장치다.

state가 DIE여도 Update는 계속 호출되기 때문에, 조기 return을 두지 않으면 불필요한 연산이 발생할 수 있다.

Vector3.Distance는 두 지점 간 유클리드 거리를 계산한다.

내부적으로 제곱근 연산이 포함되므로 대규모 AI 환경에서는 sqrMagnitude 비교가 더 효율적일 수 있다.

그러나 현재 규모에서는 가독성과 명확성이 우선이다.

switch(state)는 명시적 상태 머신 구현이다.

각 상태는 독립 함수로 위임되며, 상태별 로직이 서로 섞이지 않는다.

이 구조는 확장 시 디버깅과 유지보수에 유리하다.

distToPlayer는 몬스터와 플레이어 간의 유클리드 거리다.

이 값은 추적 시작 여부(chaseDist), 공격 전환 여부(attackDist), 체력바 활성화 여부 등 플레이어와의 상호작용 조건 판단에 사용된다.

즉, 전투 중심 거리 변수다.

distFromStart는 몬스터의 현재 위치와 최초 시작 위치(startPos) 사이의 거리다.

이 값은 몬스터가 활동 영역을 벗어났는지 판단하기 위한 기준으로 사용된다.

추적 중 과도하게 멀어졌을 때 RETURN 상태로 전환하는 가드 조건 역할을 한다.

즉, 영역 제한용 거리 변수다.

두 거리는 역할이 명확히 다르다.

하나는 플레이어와의 관계, 다른 하나는 자기 영역과의 관계를 나타낸다.

이 두 거리를 Update에서 한 번만 계산해 각 상태 함수에 매개변수로 전달하는 이유는, 상태 함수 내부에서 다시 거리 계산을 반복하지 않도록 하여 중복 연산을 방지하기 위함이다.

상태 함수가 외부 필드를 직접 참조하기보다 필요한 판단 정보만을 전달받아 사용하도록 만들어 책임을 명확히 하기 위함이다.

이렇게 하면 각 상태 함수는 환경 정보(dist 값)를 입력받아 판단하고, 그 결과로 상태를 재정의하거나 행동을 수행하는 역할에 집중하게 되며, 구조적으로는 판단과 데이터 계산을 분리한 형태가 된다.

IDLE 상태에서는 distToPlayer를 기준으로 플레이어 인지 여부를 판단하고 필요 시 CHASE로 전환한다.

MOVE 상태에서는 순찰 이동 완료 여부를 확인하면서, 동시에 distToPlayer를 기준으로 플레이어 인지 시 추적으로 넘어간다.

CHASE 상태에서는 distToPlayer로 공격 전환을 판단하고 distFromStart로 최대 추적 거리 초과 여부를 판단해 RETURN으로 전환한다.

ATTACK 상태에서는 공격 범위 유지 여부를 distToPlayer로 계속 확인하며 조건이 깨지면 CHASE 또는 RETURN으로 복귀한다.

결국 이 구조는 상태가 단순히 값으로 존재하는 것이 아니라, 매 프레임 거리 기반 환경 정보를 입력으로 받아 다음 상태를 결정하는 동적인 실행 시스템임을 보여주며, 매개변수 전달 방식은 그 판단 구조를 명확히 드러내기 위한 의도적인 설계다.

4.5. 애니메이션 동기화
protected void Animation(bool idle, bool walk, bool run, bool attack)
{
    anime.SetBool("Idle", idle);
    anime.SetBool("Walk", walk);
    anime.SetBool("Run", run);
    anime.SetBool("Attack", attack);
}

Animation 함수는 로직 상태를 Animator 파라미터에 매핑하는 브리지 역할을 한다.

로직 상태를 직접 Animator에 의존시키지 않고, 상태 소비 결과를 전달하는 방식으로 결합도를 낮췄다.

이를 통해 애니메이션 컨트롤러 구조가 변경되더라도 로직 상태 체계는 수정하지 않아도 된다.

4.6. TargetSet과 레이드 확장 구조
// Mushroom.cs
void TargetSet(GameObject obj)
{
    isRaidMonster = true;
    target = obj.transform;
    chaseDist = 125;
    maxChaseDist = 125;
}
// SpawnManager.cs (레이드 관련 클래스)
IEnumerator Spawn()
{
    int cnt = 0;
    while (cnt++ < maxMonCnt)
    {
        ...
        obj.SendMessage("TargetSet", target, SendMessageOptions.DontRequireReceiver);
        ...
    }
}

TargetSet은 일반 몬스터와 레이드 몬스터를 동일한 상태 구조 안에서 확장하기 위한 진입점이다.

레이드에서는 추적 거리와 최대 이동 범위를 확장해야 하므로, 공통 구조를 유지하면서 일부 파라미터만 수정한다.

TargetSet 함수는 SpawnManager에서는 호출된다.

이때, SendMessage는 문자열 기반 메시지 호출 방식이다.

타입 안정성이 떨어지는 단점이 있지만, 모든 몬스터가 반드시 TargetSet을 구현할 필요는 없기 때문에 DontRequireReceiver 옵션을 사용해 확장성을 확보했다.

즉, 레이드 전용 몬스터만 이 메시지를 처리하면 된다.

이 구조는 강한 인터페이스 결합 대신 느슨한 메시지 기반 확장을 선택한 것이다.

대규모 프로젝트에서는 인터페이스 기반 호출이나 명시적 캐스팅 방식이 더 안전하지만, 여기서는 레이드 몬스터에만 선택적으로 기능을 주기 위해 간결한 메시지 방식을 선택했다.

5. 개발 의도

여기서 핵심은 상태를 정의하는 것과 실행하는 것을 분리하는 데 있다.

Monster 베이스 클래스가 데이터와 상태 전이를 담당했다면, Mushroom은 그 상태를 프레임 단위로 소비해 이동, 회전, 애니메이션을 실행한다.

NavMeshAgent를 활용해 이동 안정성을 확보했고, Animator와 로직 상태를 분리하여 결합도를 낮췄다.

또한 레이드 상황에서도 동일한 상태 머신을 유지하면서 타겟과 추적 거리만 확장할 수 있도록 설계했다.

이는 구조를 재사용하면서 규칙만 확장하는 방식이다.