FSM(finite-state machine)은 상태(state)를 기반으로 동작을 제어하는 방식을 구현하기 위한 디자인 패턴이다.

FSM의 핵심은 단 하나의 상태만을 가진다는 점이다. 상태를 기준으로 어떤 동작을 수행할지 결정하기 때문에 현재 상태만 알 수 있으면 어떤 동작을 수행하려 하는지 명확히 파악할 수 있고 구현이 쉽다는 장점이 있다.

 

예를 들어 어떤 게임의 몬스터가 한 자리에서 플레이어를 기다리다 플레이어가 근처에 있다면 다가가 공격하는 몬스터 AI를 생각해보자.

이 몬스터의 행동은 다음 3가지 상태로 구분할 수 있다.

 

1. Idle

- 아무런 행동 없이 대기하고 있는 상태

- 플레이어가 인지 가능 범위 안에 있다면 Move상태로 전환

- 플레이어가 공격 가능한 거리에 있다면 Attack상태로 전환

 

2. Move

- 플레이어에게 이동하는 상태

- 플레이어가 인지 가능 범위를 벗어나면 Idle상태로 전환

- 플레이어가 공격 가능한 거리에 있다면 Attack상태로 전환

 

3. Attack

- 플레이어를 공격하는 상태

- 플레이어가 인지 가능 범위를 벗어나면 Idle상태로 전환

- 플레이어가 공격 가능 거리를 벗어나고 인지 가능 범위 안에 있다면 Move상태로 전환

 

앞서 말한 상태와 상태 전이 조건을 그래프로 정리하면 다음과 같다.

 

아래 코드는 Unity에서 가장 단순한 방법으로 FSM을 구현한 예시이다. 열거형으로 상태를 정의하고 Update에서 switch문을 통해 현재 상태에 따라 다른 동작을 수행하도록 하는 것만으로 FSM을 구현할 수 있다.

using UnityEngine;

public class Monster : MonoBehaviour
{
    private enum State
    {
        Idle,
        Move,
        Attack
    }
    private State _state;

    private void Start()
    {
        _state = State.Idle;
    }

    private void Update()
    {
        switch (_state)
        {
            case State.Idle:
                // Idle 행동 구현...
                break;
            case State.Move:
                // Move 행동 구현...
                break;
            case State.Attack:
                // Attack 행동 구현...
                break;
        }
    }
}

이러한 방식은 매우 간단히 구현 가능하다는 장점이 있지만 만약 Monster클래스를 상속받아 다양한 종류의 몬스터를 구현한다고 가정하면 모든 종류의 몬스터가 같은 AI 로직을 사용하지 않는 이상 사용하기 적합한 방식은 아닐 것이다.

 


 

그렇기 때문에 다음은 Monster클래스를 상속받은 Slime 클래스를 구현하면서 이를 해결하기 위한 조금 더 확장된 FSM 방식을 알아보자.

public abstract class BaseState
{
    protected Monster _monster;

    protected BaseState(Monster monster)
    {
        _monster = monster;
    }
    
    public abstract void OnStateEnter();
    public abstract void OnStateUpdate();
    public abstract void OnStateExit();
}

우선 BaseState라는 추상 클래스를 정의한다. 이 클래스는 각 상태를 구현하기 위한 필수적인 내용을 미리 정의하는 추상 클래스이다.

우리는 FSM이 Monster의 행동을 제어하기 위한 용도로만 사용된다는 가정 하에 초기화 시 Monster를 내부 변수로 저장하도록 한다.

- OnStateEnter : 상태에 처음 진입했을 때 한 번만 호출되는 메서드

- OnStateUpdate : 매 프레임마다 호출되어야 하는 메서드

- OnStateExit : 상태가 변경되면 호출되는 메서드

 

public class IdleState : BaseState
{
    public IdleState(Monster monster) : base(monster) { }
    
    public override void OnStateEnter()
    {
    }

    public override void OnStateUpdate()
    {
    }

    public override void OnStateExit()
    {
    }
}

public class MoveState : BaseState
{
    public MoveState(Monster monster) : base(monster) { }
    
    public override void OnStateEnter()
    {
    }

    public override void OnStateUpdate()
    {
    }

    public override void OnStateExit()
    {
    }
}

public class AttackState : BaseState
{
    public AttackState(Monster monster) : base(monster) { }
    
    public override void OnStateEnter()
    {
    }

    public override void OnStateUpdate()
    {
    }

    public override void OnStateExit()
    {
    }
}

각 몬스터 AI가 지닐 수 있는 각 상태들은 BaseState를 상속받은 각각의 클래스로 구현한다.

위 예시에서는 각 메서드의 내용이 구현되어있지는 않지만 상태에 따라 수행해야 할 행동을 이곳에서 구현하면 된다.

이때 주의할 점은 상태 변경에 대한 로직은 작성하면 안 된다.

BaseState를 상속받은 클래스는 다른 BaseState 자식 클래스들에 대해 알 수 없고 오직 어떤 행동을 수행해야 하는지에 대한 내용만을 구현한다. 상태 변경에 대한 책임은 Monster클래스에게 있으며 각 Monster클래스 구현에 따라 서로 가질 수 있는 상태가 다를 수 있기 때문이다.

 

public class FSM
{
    public FSM(BaseState initState)
    {
        _curState = initState;
        ChangeState(_curState);
    }

    private BaseState _curState;
    
    public void ChangeState(BaseState nextState)
    {
        if (nextState == _curState)
            return;
        
        if (_curState != null)
            _curState.OnStateExit();

        _curState = nextState;
        _curState.OnStateEnter();
    }

    public void UpdateState()
    {
        if (_curState != null)
            _curState.OnStateUpdate();
    }
}

FSM 클래스는 BaseState 기반으로 현재 상태 혹은 상태 변경 시 호출되어야 할 메서드를 관리해 주는 클래스이다.

Monster 클래스는 FSM 객체를 멤버 변수로 저장해 해당 객체를 통해 상태 제어를 해야 한다.

 

 

public class Slime : Monster
{
    private enum State
    {
        Idle,
        Move,
        Attack
    }

    private State _curState;
    private FSM _fsm;
    
    private void Start()
    {
        _curState = State.Idle;
        _fsm = new FSM(new IdleState(this));
    }

    private void Update()
    {
        switch (_curState)
        {
            case State.Idle:
                if (CanSeePlayer())
                {
                    if (CanAttackPlayer())
                        ChangeState(State.Attack);
                    else
                        ChangeState(State.Move);
                }
                break;
            case State.Move:
                if (CanSeePlayer())
                {
                    if (CanAttackPlayer())
                    {
                        ChangeState(State.Attack);
                    }
                }
                else
                {
                    ChangeState(State.Idle);
                }
                break;
            case State.Attack:
                if (CanSeePlayer())
                {
                    if (!CanAttackPlayer())
                    {
                        ChangeState(State.Move);
                    }
                }
                else
                {
                    ChangeState(State.Idle);
                }
                break;
        }
        
        _fsm.UpdateState();
    }

    private void ChangeState(State nextState)
    {
        _curState = nextState;
        switch (_curState)
        {
            case State.Idle:
                _fsm.ChangeState(new IdleState(this));
                break;
            case State.Move:
                _fsm.ChangeState(new MoveState(this));
                break;
            case State.Attack:
                _fsm.ChangeState(new AttackState(this));
                break;
        }
    }

    private bool CanSeePlayer()
    {
        // TODO:: 플레이어 탐지 구현
    } 
    
    private bool CanAttackPlayer()
    {
        // TODO:: 사정거리 체크 구현
    } 
}

마지막으로 Monster 클래스를 상속받은 Slime 클래스의 코드이다.

먼저 열거형 State에서 슬라임이 가질 수 있는 상태를 정의한다. 모든 Monster 클래스의 자식들은 가질 수 있는 상태가 모두 다르다. 예를 들어 공격을 할 수 없는 몬스터는 Attack상태가 없을 것이고 비행이 가능한 몬스터를 구현하고자 한다면 Fly상태가 추가될 수 있다.

 

이후 Start에서 FSM 객체 생성과 처음 상태를 정의한 후 Update의 Switch문 안에서 각 상태 전이를 위한 조건을 체크한다.

앞서 구현했던 간단한 FSM과의 가장 큰 차이는 실제 상태 별 행동을 구현할 필요가 없다는 것이다. 오직 상태 전이를 위한 조건만을 체크하기 때문에 코드 가독성을 높일 수 있고 슬라임 외 다른 몬스터를 구현하더라도 상태가 같다면 코드를 다시 작성할 필요가 없다.

 

단 각각의 상태들을 뚜렷하게 정의할 수 없거나 더욱 복잡한 AI를 구현하고자 한다면 상태의 종류가 많아지고 이는 곧 상태 전이 조건을 위한 수많은 if-else로 가득 차게 되고 가독성 저하와 디버깅이 어려워져 버그 가능성을 높이게 된다.

FSM은 상태의 수가 적고 상태별 행동이 명확하게 구별될 때 가장 효율이 좋은 디자인 패턴이기 때문에 이러한 특징을 미리 고민해 보고 사용해야 할 것이다.

'Unity' 카테고리의 다른 글

[Unity] Behavior Tree  (0) 2023.03.20
[Unity] Normal Map  (0) 2023.01.30
[Unity] 텍스처 타입  (0) 2023.01.24
[Unity] 텍스처 압축 포맷  (0) 2023.01.16
[Unity] 텍스처와 텍스처 압축  (2) 2023.01.08

+ Recent posts