Unity

Unity AI 아키텍처: 유한 상태 머신(FSM) 패턴을 이용한 몬스터 AI

유니얼 2024. 4. 23. 22:42
728x90

FSM 패턴이란?

유한 상태 머신(FSM)은 시스템이 가질 수 있는 일련의 상태들과 그 상태들 간의 전환을 정의하는 아키텍처 패턴입니다. AI 개발에서 FSM을 사용하면 NPC(Non-Player Character)나 적 AI의 다양한 행동 상태(예: 순찰, 추적, 공격)를 체계적으로 관리할 수 있습니다. 각 상태는 특정 행동을 실행하고, 주어진 입력에 따라 다른 상태로 전환될 수 있습니다.

 

1. 상태(State) 인터페이스 정의하기

AI의 각 행동 상태를 나타내는 인터페이스를 정의합니다. 이 인터페이스는 상태가 시작될 때, 실행될 때, 종료될 때 호출되는 메서드들을 포함합니다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// IState 인터페이스를 정의합니다.
public interface IState
{
    // EnterState 메서드는 상태가 시작될 때 호출됩니다.
    // 이 메서드는 상태로 전환될 때 초기 설정을 수행하는 데 사용됩니다.
    public void EnterState();

    // UpdateState 메서드는 매 프레임마다 호출됩니다.
    // 이 메서드는 상태가 활성 상태일 때 실행되는 로직을 담당합니다.
    public void UpdateState();

    // ExitState 메서드는 상태가 종료될 때 호출됩니다.
    // 이 메서드는 상태를 빠져나올 때 필요한 정리 작업을 수행하는 데 사용됩니다.
    public void ExitState();
}

2. 구체적인 상태 클래스 구현하기

위 인터페이스를 구현하는 구체적인 상태 클래스를 만듭니다. 예를 들어, AI 캐릭터가 순찰, 추적, 공격하는 상태를 다음과 같이 구현할 수 있습니다.

1, PatrolState

using UnityEngine;
using UnityEngine.AI;

// PatrolState 클래스는 MonoBehaviour와 IState 인터페이스를 상속받아 순찰 상태를 구현합니다.
public class PatrolState : MonoBehaviour, IState
{
    [Header("Enemy Guarding Var")]
    public GameObject[] walkPoints; // 순찰할 위치들을 담은 배열
    int currentEnemyPosition = 0; // 현재 순찰 위치의 인덱스

    float walkingPointRadius = 2; // 순찰 지점 도달 판정에 사용되는 반경

    private Animator animator; // 애니메이션 컴포넌트
    private NavMeshAgent EnemyAgent; // 적 AI의 네비게이션을 위한 컴포넌트
    private Enemy _enemy; // 적 AI 스크립트

    // 상태 진입 시 호출되는 메서드입니다.
    public void EnterState()
    {
        // 필요한 컴포넌트를 캐싱합니다.
        if (!animator) animator = GetComponent<Animator>();
        if (!EnemyAgent) EnemyAgent = GetComponent<NavMeshAgent>();
        if (!_enemy) _enemy = GetComponent<Enemy>();

        // 순찰 상태의 애니메이션 설정
        animator.SetBool("Walk", true);
        animator.SetBool("AimRun", false);
        animator.SetBool("Shoot", false);
        animator.SetBool("Die", false);
    }

    // 매 프레임마다 호출되는 메서드입니다.
    public void UpdateState()
    {
        // 현재 위치와 다음 순찰 지점 간의 거리를 확인
        if (Vector3.Distance(walkPoints[currentEnemyPosition].transform.position, transform.position) < walkingPointRadius)
        {
            // 다음 순찰 지점을 무작위로 선택
            currentEnemyPosition = UnityEngine.Random.Range(0, walkPoints.Length);
            // 배열의 범위를 넘어선 경우 인덱스를 초기화
            if (currentEnemyPosition >= walkPoints.Length)
            {
                currentEnemyPosition = 0;
            }
        }
        // 선택된 지점으로 이동 명령을 내림
        EnemyAgent.SetDestination(walkPoints[currentEnemyPosition].transform.position);
    }

    // 상태에서 벗어날 때 호출되는 메서드입니다.
    public void ExitState()
    {
        // 순찰 상태 종료 시 애니메이션 상태를 초기화합니다.
        animator.SetBool("Walk", false);
        animator.SetBool("AimRun", false);
        animator.SetBool("Shoot", false);
        animator.SetBool("Die", false);
    }
}

2, ChaseState

using UnityEngine;
using UnityEngine.AI;

// ChaseState 클래스는 MonoBehaviour와 IState 인터페이스를 상속받습니다.
public class ChaseState : MonoBehaviour, IState
{
    private Animator animator; // 캐릭터의 애니메이션을 관리합니다.
    private NavMeshAgent EnemyAgent; // NavMeshAgent를 사용하여 경로 찾기와 이동을 관리합니다.

    private Transform target; // 추적 대상의 위치 정보입니다.
    private Enemy _enemy; // Enemy 스크립트를 참조합니다.

    // 상태로 진입할 때 호출되는 메서드입니다.
    public void EnterState()
    {
        // 필요한 컴포넌트가 없을 경우, 해당 컴포넌트를 GameObject에서 가져옵니다.
        if (!animator) animator = GetComponent<Animator>();
        if (!EnemyAgent) EnemyAgent = GetComponent<NavMeshAgent>();
        if (!_enemy) _enemy = GetComponent<Enemy>();
        if (!target) target = _enemy.playerBody;

        // 추적 상태의 애니메이션 설정
        animator.SetBool("Walk", false);
        animator.SetBool("AimRun", true);
        animator.SetBool("Shoot", false);
        animator.SetBool("Die", false);
    }

    // 매 프레임마다 호출되는 메서드입니다.
    public void UpdateState()
    {
        // NavMeshAgent를 사용하여 타겟 위치로 이동을 시도합니다.
        EnemyAgent.SetDestination(target.position);
    }

    // 상태에서 벗어날 때 호출되는 메서드입니다.
    public void ExitState()
    {
        // 추적 상태 종료 시 애니메이션 상태를 초기화합니다.
        animator.SetBool("Walk", false);
        animator.SetBool("AimRun", false);
        animator.SetBool("Shoot", false);
        animator.SetBool("Die", false);
    }
}

3, ShootState

using UnityEngine;
using UnityEngine.AI;

// ShootState 클래스는 MonoBehaviour와 IState 인터페이스를 상속받아 공격 상태를 구현합니다.
public class ShootState : MonoBehaviour, IState
{
    public Transform LookPoint; // 적 AI가 목표로 하는 지점
    public ParticleSystem muzzleSpark; // 총구에서 발생하는 스파크 이펙트

    [Header("Sound And UI")]
    public AudioClip shootingSound; // 총소리 클립
    public AudioSource audioSource; // 오디오 소스 컴포넌트

    [Header("Enemy Shooting var")]
    public float timebtwShoot; // 연속 사격 사이의 간격
    public Camera ShootingRaycastArea; // 사격을 수행할 카메라 위치
    public float Damage = 5; // 총알 한 발당 입힐 수 있는 피해량
    public float shootingRaidus; // 사격이 가능한 범위
    bool previouslyShoot; // 이전에 총을 쏜 적이 있는지 여부

    private Animator animator; // 애니메이션 컴포넌트
    private NavMeshAgent EnemyAgent; // 적 AI의 네비게이션을 위한 컴포넌트
    private Enemy _enemy; // 적 AI 스크립트

    // 상태 진입 시 호출되는 메서드입니다.
    public void EnterState()
    {
        if (!animator) animator = GetComponent<Animator>();
        if (!EnemyAgent) EnemyAgent = GetComponent<NavMeshAgent>();
        if (!audioSource) audioSource = GetComponent<AudioSource>();
        if (!_enemy) _enemy = GetComponent<Enemy>();

        // 사격 상태의 애니메이션 설정
        animator.SetBool("Walk", false);
        animator.SetBool("AimRun", false);
        animator.SetBool("Shoot", true);
        animator.SetBool("Die", false);
    }

    // 매 프레임마다 호출되는 메서드입니다.
    public void UpdateState()
    {
        // 적이 현재 위치에 고정됩니다.
        EnemyAgent.SetDestination(transform.position);

        // 적이 LookPoint를 바라보도록 합니다.
        transform.LookAt(LookPoint);

        // 사격을 처음 할 때에만 수행합니다.
        if (!previouslyShoot)
        {
            // 총구 이펙트를 재생합니다.
            muzzleSpark.Play();
            // 총소리를 재생합니다.
            audioSource.PlayOneShot(shootingSound);
            RaycastHit hit;

            // 사격이 성공했는지를 판별합니다.
            if (Physics.Raycast(ShootingRaycastArea.transform.position, ShootingRaycastArea.transform.forward, out hit, shootingRaidus))
            {
                Debug.Log("Shooting " + hit.transform.name);

                // 적중한 대상이 PlayerController 컴포넌트를 가지고 있으면 피해를 입힙니다.
                PlayerController player = hit.transform.GetComponent<PlayerController>();
                if (player)
                {
                    player.HitDamage(Damage);
                }
            }

            // 연속 사격을 막기 위해 플래그를 설정합니다.
            previouslyShoot = true;
            Invoke(nameof(ActiveShooting), timebtwShoot);
        }
    }

    // 사격 가능 상태로 전환하는 메서드입니다.
    private void ActiveShooting()
    {
        previouslyShoot = false;
    }

    // 상태에서 벗어날 때 호출되는 메서드입니다.
    public void ExitState()
    {
        // 사격 상태 종료 시 애니메이션 상태를 초기화합니다.
        animator.SetBool("Walk", false);
        animator.SetBool("AimRun", false);
        animator.SetBool("Shoot", false);
        animator.SetBool("Die", false);
    }
}

3. 상태 머신(State Machine) 클래스 구현하기

상태 전환 로직을 중앙에서 관리하여, AI가 다양한 상태(예: 순찰, 추적, 사격) 간에 전환할 수 있도록 지원합니다

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

// EState 열거형은 적 AI가 가질 수 있는 가능한 상태들을 정의합니다.
enum EState
{
    Patrol,  // 순찰 상태
    Chase,   // 추적 상태
    Shoot,   // 사격 상태
}

// EnemyStateContext 클래스는 적 AI의 상태 관리를 담당합니다.
public class EnemyStateContext
{
    // CurrentState 프로퍼티는 현재 적 AI가 가지고 있는 상태를 저장합니다.
    public IState CurrentState { get; set; }

    // _controller 필드는 적 AI의 주요 제어 로직을 담은 Enemy 클래스의 인스턴스를 참조합니다.
    private readonly Enemy _controller;

    // 생성자에서는 Enemy 클래스의 인스턴스를 받아 _controller 필드에 할당합니다.
    public EnemyStateContext(Enemy controller)
    {
        _controller = controller;
    }

    // Transition 메서드는 적 AI가 다른 상태로 전환할 때 호출됩니다.
    public void Transition(IState state)
    {
        // 현재 상태가 null이 아니면, 현재 상태의 ExitState 메서드를 호출하여 상태 종료 로직을 수행합니다.
        if(CurrentState != null)
            CurrentState.ExitState();
        
        // 새로운 상태를 CurrentState 프로퍼티에 할당합니다.
        CurrentState = state;
        
        // 새로운 상태의 EnterState 메서드를 호출하여 상태 시작 로직을 수행합니다.
        CurrentState.EnterState();
    }
}

4. 상태 전환 로직 추가하기

AI의 행동에 따라 적절한 상태로 전환하기 위한 조건을 체크하는 로직을 추가합니다.  매 프레임마다 플레이어와의 거리에 따라 순찰, 추적, 사격 중 하나의 상태로 전환합니다.

public class Enemy : MonoBehaviour
{
    [Header("Enemy States")]
    // 각 상태에 해당하는 스크립트 컴포넌트를 SerializeField 어트리뷰트를 사용하여 인스펙터에서 할당받을 수 있도록 설정합니다.
    [SerializeField] private PatrolState patrolState;
    [SerializeField] private ChaseState chaseState;
    [SerializeField] private ShootState shootState;

    // 상태 머신 관리를 위한 컨텍스트 객체
    private EnemyStateContext enemyStateContext;

    // Unity의 Awake 생명주기 이벤트 메서드에서 초기 설정을 수행합니다.
    private void Awake()
    {
        // EnemyStateContext 인스턴스를 생성하고, 이 객체에 이 클래스의 인스턴스를 전달합니다.
        enemyStateContext = new EnemyStateContext(this);

        // 적 AI의 초기 상태를 순찰 상태로 설정합니다.
        enemyStateContext.Transition(patrolState);
    }

    // Unity의 Update 생명주기 이벤트 메서드에서 매 프레임마다 호출됩니다.
    private void Update()
    {
        // 적이 플레이어를 감지하는지 여부를 검사합니다. Physics.CheckSphere 함수를 사용하여 특정 반경 내에 플레이어가 있는지 확인합니다.
        playerInvisionRadius = Physics.CheckSphere(transform.position, visionRadius, PlayerLayer);
        playerInShootingRadius = Physics.CheckSphere(transform.position, shootingRaidus, PlayerLayer);

        // 상태 업데이트 로직: 플레이어와의 거리에 따라 적절한 상태로 전환합니다.
        if (!playerInvisionRadius && !playerInShootingRadius) UpdateState(EState.Patrol);
        if (playerInvisionRadius && !playerInShootingRadius) UpdateState(EState.Chase);
        if (playerInvisionRadius && playerInShootingRadius) UpdateState(EState.Shoot);

        // 현재 상태의 UpdateState 메서드를 호출하여, 상태에 맞는 행동을 수행합니다.
        enemyStateContext.CurrentState.UpdateState();
    }

    // 상태를 업데이트하는 메서드입니다. EState 열거형을 기반으로 적절한 상태로 전환을 요청합니다.
    private void UpdateState(EState state)
    {
        switch (state)
        {
            case EState.Patrol:
                enemyStateContext.Transition(patrolState);
                break;
            case EState.Chase:
                enemyStateContext.Transition(chaseState);
                break;
            case EState.Shoot:
                enemyStateContext.Transition(shootState);
                break;
        }
    }
}

마무리

Unity에서 FSM을 사용하여 AI를 설계하는 방법은 AI 개발을 명확하고 관리하기 쉽게 만들어 줍니다. 각 상태가 명확하게 정의되어 있고, 상태 간의 전환도 체계적으로 관리되므로, 복잡한 AI 로직도 이해하고 디버깅하기가 더 쉬워집니다. 이러한 접근 방식을 통해 더욱 동적이고 반응적인 게임 AI를 구현할 수 있습니다.

반응형