게임

[유니티/C#] FSM을 알아보자

Hyun-danpung2 2023. 6. 11. 21:30
728x90
반응형

서론

if - else if 지옥과 예외 처리 지옥에 빠진 나를 구원하는 FSM

 

본론

FSM: Finite State Machine(유한 상태 기계)은 상태 패턴의 한계를 해결할 수 있는 방안 중 하나다.

상태 패턴: 행동과 상태를 나누는 디자인 패턴. 행동을 인터페이스로 정의하여 상태에 따라 행동을 분류.

 

먼저 상태 패턴의 구조에는 세 가지 핵심 요소가 있다.

  • Context 클래스: 클라이언트가 객체의 내부 상태를 변경할 수 있도록 요청하는 인터페이스를 정의하고 현재 상태에 대한 포인터를 보유함.
  • IState 인터페이스: 구체적인 상태 클래스로 연결할 수 있도록 설정.
  • ConcreteState 클래스: IState 인터페이스를 구현하고 Context 오브젝트라 상태의 동작을 트리거하기 위한 퍼블릭 메서드를 노출.

 

클라이언트는 객체의 상태를 업데이트 할 때 Context 객체를 활용해 원하는 상태로 설정하거나 새로운 상태로의 전환을 요청한다.

상태패턴은 캡슐화와 유지 및 관리에서 장점을 보이지만 애니메이션 블렌드 방법을 제공하지 않고 상태 간 관계를 정의하지 않으며 이를 정의하고 싶다면 더 많은 코드를 작성해야 한다는 단점이 있다. 이에 대한 대안으로 나온 것으로는 블랙보드/행동 트리, 유한 상태 기계, 메멘토 등이 있다.

 

이 글에서는 유한 상태 기계, FSM에 대해 다뤄보겠다.

 

간단하게 IDLE, CHASE, ATTACK, DEATH 상태를 갖는 몬스터에 대해 정의한다고 가정하자.

 

플레이어와 거리가 멀 때는 IDLE, 플레이어와 가깝지만 공격 사정 거리보다는 멀리 있을 때 CHASE, 플레이어가 공격 사정 거리에 있을 때는 ATTACK, 체력이 0 이하로 떨어지면 DEATH 상태이다.

 

이를 그림으로 표현하면 다음과 같이 표현할 수 있다.

몬스터가 스폰하면 IDEL 상태에서 시작한다. IDEL 상태에서 CHASE 상태가 될 수 있고, CHASE 상태에서는 IDEL, ATTACK 상태로 전환이 가능하다. ATTACK 상태에서는 CHASE, DEATH 상태로의 전환이 가능하다.

 

이를 구현하기 위해, 먼저 FSM 클래스를 정의해보자.

 

public class FSM
{
    private MonsterBaseState _currentState;
    
    public FSM(MonsterBaseState initState)
    {
        _currentState = initState;
        ChangeState(_currentState);
    }

    public void ChangeState(MonsterBaseState nextState)
    {
        if(nextState == _currentState)
            return;

        if (_currentState != null)
        {
            _currentState.OnStateEnd();
        }

        _currentState = nextState;
        _currentState.OnStateStart();
    }

    public void UpdateState()
    {
        _currentState?.OnStateUpdate();
    }
}

 

다음은 몬스터의 상태와 관련된 추상 클래스다.

 

public abstract class MonsterBaseState
{
    private Monster _monster;

    protected MonsterBaseState(Monster monster)
    {
        _monster = monster;
    }

    // 상태에 들어왔을 때 한번 실행
    public abstract void OnStateStart();
    // 상태에 있을 때 계속 실행
    public abstract void OnStateUpdate();
    // 상태를 빠져나갈 때 한번 실행
    public abstract void OnStateEnd();
}

 

다음은 몬스터 상태를 정의한 코드다.

 

public class Monster : MonoBehaviour
{
    private enum MonsterStateEnum
    {
        IDLE,
        CHASE,
        ATTACK,
        DEATH
    }
    private MonsterStateEnum _state;

    private void Start()
    {
        _state = MonsterStateEnum.IDLE;
    }

    private void Update()
    {
        switch (_state)
        {
            case MonsterStateEnum.IDLE:
                break;
            case MonsterStateEnum.CHASE:
                break;
            case MonsterStateEnum.ATTACK:
                break;
            case MonsterStateEnum.DEATH:
                break;
            default:
                break;
        }
    }
}

 

이제 Bot 이라는 몬스터 객체를 정의한다고 가정한다. 이때, Bot의 상태는 몬스터 상태를 상속받는다.

 

public class IdleState: MonsterBaseState
{
    private Bot _bot;

    public IdleState(Bot bot) : base(bot)
    {
        _bot = bot;
    }


    public override void OnStateStart()
    {
        Debug.Log("===== Start Idle =====");

    }

    public override void OnStateUpdate()
    {
        
    }

    public override void OnStateEnd()
    {
        Debug.Log("===== End Idle =====");
    }

}

public class ChaseState: MonsterBaseState
{
    private Bot _bot;

    public ChaseState(Bot bot) : base(bot)
    {
        _bot = bot;
    }


    public override void OnStateStart()
    {
        Debug.Log("===== Start Chase =====");   
    }

    public override void OnStateUpdate()
    {
        Debug.Log("===== In Chase =====");   
        // TEST
        _bot.transform.position = new Vector3(_bot.transform.position.x - 1, 0, 0);
    }

    public override void OnStateEnd()
    {
        Debug.Log("===== End Chase =====");
    }

}

public class AttackState: MonsterBaseState
{
    private Bot _bot;

    public AttackState(Bot bot) : base(bot)
    {
        _bot = bot;
    }


    public override void OnStateStart()
    {
        Debug.Log("===== Start Attack =====");
    }

    public override void OnStateUpdate()
    {
        Debug.Log("===== In Attack =====");
        // TEST
        _bot.hp -= 1;
    }

    public override void OnStateEnd()
    {
        Debug.Log("===== End Attack =====");
    }

}



public class DeathState: MonsterBaseState
{
    private Bot _bot;
    public DeathState(Bot bot) : base(bot)
    {
        _bot = bot;
    }


    public override void OnStateStart()
    {
        Debug.Log("===== Start Death =====");
    }

    public override void OnStateUpdate()
    {
        
    }

    public override void OnStateEnd()
    {
        Debug.Log("===== End Death =====");
    }

}

 

마지막으로 Bot을 정의하는 코드다.

 

public class Bot: Monster
{
    // TEST
    [SerializeField] public Vector3 position = new Vector3(10, 0, 0);
    [SerializeField] public int hp = 5;
    private Vector3 _playerPosition = new Vector3(0, 0, 0);
    private enum BotStateEnum
    {
        IDLE,
        CHASE,
        ATTACK,
        DEATH
    }

    private BotStateEnum _currentState;
    private FSM _fsm;

    private void Start()
    {
        transform.position = position;
        _currentState = BotStateEnum.IDLE;
        _fsm = new FSM(new IdleState(this));
    }

    private void Update()
    {
        switch (_currentState)
        {
            case BotStateEnum.IDLE:
                if (IsHpZero(this) == false)
                {
                    if (CanAttackPlayer(this))
                    {
                        ChangeState(BotStateEnum.ATTACK);
                    }
                    else if (CanSeePlayer(this))
                    {
                        ChangeState(BotStateEnum.CHASE);
                    }
                    // TEST
                    else
                    {
                        _playerPosition = new Vector3(_playerPosition.x + 1, 0, 0);
                    }
                }
                else
                {
                    ChangeState(BotStateEnum.DEATH);
                }
                break;
            case BotStateEnum.CHASE:
                if (IsHpZero(this) == false)
                {
                    if (CanAttackPlayer(this))
                    {
                        ChangeState(BotStateEnum.ATTACK);
                    }
                    else if (CanSeePlayer(this) == false)
                    {
                        ChangeState(BotStateEnum.IDLE);
                    }
                }
                else
                {
                    ChangeState(BotStateEnum.DEATH);
                }
                break;
            case BotStateEnum.ATTACK:
                if (IsHpZero(this) == false)
                {
                    if (CanAttackPlayer(this) == false)
                    {
                        if (CanSeePlayer(this))
                        {
                            ChangeState(BotStateEnum.CHASE);
                        }
                        else
                        {
                            ChangeState(BotStateEnum.IDLE);
                        }
                    }
                }
                else
                {
                    ChangeState(BotStateEnum.DEATH);
                }
                break;
            case BotStateEnum.DEATH:
                // TODO: DEATH 코드 구현
                break;
            default:
                break;
        }
        _fsm.UpdateState();
    }
    
    
    private void ChangeState(BotStateEnum nextState)
    {
        _currentState = nextState;
        switch (_currentState)
        {
            case BotStateEnum.IDLE:
                _fsm.ChangeState(new IdleState(this));
                break;
            case BotStateEnum.CHASE:
                _fsm.ChangeState(new ChaseState(this));
                break;
            case BotStateEnum.ATTACK:
                _fsm.ChangeState(new AttackState(this));
                break;
            case BotStateEnum.DEATH:
                _fsm.ChangeState(new DeathState(this));
                break;
            default:
                break;
        }
    }

    
    private bool CanSeePlayer(Bot bot)
    {
        Debug.Log("[CanSeePlayer] Distance: " + Vector3.Distance(bot.transform.position, _playerPosition));
        if (Vector3.Distance(bot.transform.position, _playerPosition) < 10f)
        {
            return true;
        }

        return false;
    } 
    
    private bool CanAttackPlayer(Bot bot)
    {
        Debug.Log("[CanAttackPlayer] Distance: " + Vector3.Distance(bot.transform.position, _playerPosition));
        if (Vector3.Distance(bot.transform.position, _playerPosition) < 5f)
        {
            return true;
        }

        return false;
    }

    private bool IsHpZero(Bot bot)
    {
        Debug.Log("[IsHpZero] hp: " + bot.hp);
        if (bot.hp <= 0)
        {
            return true;
        }

        return false;
    }
}

 

TEST로 추가한 코드대로라면, 스폰이 되었을 때는 IDLE 상태에 있다가 플레이어가 가까이 다가오면 CHASE를 시작하고, 공격할 수 있는 거리가 되면 ATTACK 상태로 전환이 되며 Bot의 체력이 0 이하가 되면 DEATH 상태로 진입한다.

 

유니티에서 Log를 확인하면 다음과 같다.

 

 

플레이어와의 거리가 10 보다 작아지면 IDLE 상태를 끝내고 CHASE 상태로 진입한다.

 

 

CHASE 상태에서 플레이어와의 거리가 더 가까워지면 CHASE 상태를 끝내고 ATTACK 상태로 진입하게 된다. 이때부터 체력이 깎이도록 TEST를 작성했기 때문에 hp가 감소하는 것을 볼 수 있다.

 

 

hp가 0 이하로 떨어지게 되면, ATTACK 상태를 끝내고 DEATH 상태로 진입한다.

 

결론

FSM을 공부하면서 의미있다고 느낀 건 역시 캡슐화, 추상화인 것 같다.

객체 지향 짱.

728x90
반응형