[유니티/C#] FSM을 알아보자
서론
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을 공부하면서 의미있다고 느낀 건 역시 캡슐화, 추상화인 것 같다.
객체 지향 짱.