유한 상태 머신 (FSM) 에 관하여
유한상태머신은 객체를 나타낼 때 상태라는 개념으로 정리해놓은 추상적인 장치입니다.
플레이어라는 오브젝트를 플레이어의 행동이라는 개념으로 나타내기 위해
기본(Idle) , 걷기(Walk), 뛰기(Run) 처럼 나누어보겠습니다.
기본 서 있는 상태에서 걷거나 뛰려고 한다면 미리 구현해둔 키를 통해 동작을 구현할 수 있습니다.
이렇게 wsad로 움직이고 shift로 뛰는 것을 하는 것처럼 키 입력을 트리거 삼아 다른 상태로 넘어가는 것을
유한 상태머신 으로 부를 수 있겠습니다.
사실 우린 이 상태라는 개념을 이미 사용하고 있었습니다.
애니메이션을 출력하기 위해 특정 조건(패러미터)를 설정하여 트랜지션을 연결하는 것이나
인풋 시스템을 사용하여 키 입력을 통해 다음 행동으로 넘어간다거나
이런 것들을 전부 유한 상태머신이라는 개념을 통해 설명이 가능합니다.
AI네비게이션을 처음 다루었을 때 Switch Case 문을 통해 관리하였는데 이 또한 유한 상태머신입니다.
Enum을 통해 상태들을 정의하고 Switch Case 문을 통해 다른 상태로 넘어가는 로직을 만들었었습니다.
해당 로직을 통해 Navmesh Agent를 상태에 따라 다르게 행동하고
또 다른 상태로 넘어갈 수 있는 트리거를 만들어준 셈입니다.
//Enum을 통해 상태를 정의
public enum AIState
{
Idle,
Wandering,
Attacking
}
//스위치 케이스문으로 상태를 변환하는 로직
public void SetState(AIState state)
{
aiState = state;
switch (aiState)
{
case AIState.Idle:
agent.speed = walkSpeed;
agent.isStopped = true;
break;
case AIState.Wandering:
agent.speed = walkSpeed;
agent.isStopped = false;
break;
case AIState.Attacking:
agent.speed = runSpeed;
agent.isStopped = false;
break;
}
animator.speed =agent.speed/walkSpeed;
}
//특정 상태에서 다른 상태로 가기위해선 조건이 필요함
private void PassiveUpdate()
{
if(aiState == AIState.Wandering && agent.remainingDistance < 0.1f)
{
SetState(AIState.Idle);
Invoke("WanderToNewLocation",Random.Range(minWanderWaitTime,maxWanderWaitTime));
}
if (playerDistance < detectDistance)
{
SetState(AIState.Attacking);
}
}
하지만 이렇게 Switch Case 문을 통해 관리를 한다면 상태 수가 적다면 상관은 없겠지만
상태 수가 많아질수록 한 스크립트에서 작성되는 코드가 엄청나게 많아지게 됩니다.
이번 팀 과제로 적을 구현하면서 Switch Case 문으로 제어하려다보니 위 사진처럼 길어지는 코드를 볼 수 있었습니다.
(심지어 기능구현이 전부 된 것도 아니다..)
당연히 이렇게 코드가 몰려있다면 가독성은 물론 유지보수 또한 힘들어지게 됩니다.
객체지향적으로 봐도 이렇게 기능들이 몰려있는 것은 적절하지 않은 방법으로 생각되었습니다.
그래서 이동로직 구현에 앞서 리팩토링이 필요한 시기라고 생각이 되어 검색 중 FSM이라는 키워드를 알게 된 것입니다.
검색해보고 보고나서야 아직 다 못본 강의 제목에 FSM이라는 키워드가 있다는 것을 깨달았습니다.
뒤늦게 강의 영상을 보고 따라하고 질문하면서 이해하는 데 하루 반나절을 소요하였고 구현에 성공하였습니다.
앞서말한 것을 정리하자면 Switch Case 문을 활용한 이 방식도 유한상태머신이라고 하였습니다.
하지만 이 방식은 한 스크립트안에서 모든 동작을 수행하다 보니 가독성과 유지보수성이 떨어지며 객체지향적으로 좋은 방식이 아닙니다.
그래서 이를 클래스로 구현한 방식으로 문제 해결에 접근해봅니다.
상태패턴
유한 상태머신을 클래스로 구현한 디자인 패턴을 상태 패턴이라고 합니다.
상태패턴의 장점은 상태라는 개념으로 묶어 클래스를 만들어 사용하기에 고치는 것이 쉽고 앞선 방식보다 보기 좋다는 것입니다.
상태 패턴의 특징은 각 상태별로 정리하기 전에 추상 클래스를 상속하거나
인터페이스를 상속하여 구현하는 Base가 되는 상태가 있다는 것입니다.
이 곳에서 공통적으로 구현되는 것들을 작성하여 각 상태에 구현을 하도록합니다.
BaseState (상태 도면)
//Abstract 키워드가 있는 부분은 각각의 상태에서 다르게 구현되어야 할 부분
//protected 키워드가 있는 부분은 상태 전부가 공유하는 부분
public abstract class EnemyBaseState
{
protected EnemyStateMachine stateMachine; //실행을 제어해줄 상태머신
protected EnemyBaseState(EnemyStateMachine stateMachine) //다른 상태들에 접근하기 위해서 상속되는 공통부분
{
this.stateMachine = stateMachine; //생성자를 통해 상태들을 상태머신에서 관리하도록 주소를 받아온다.
}
public abstract void Enter(); //처음 호출되었을 때 1회 실행되는 코드
public abstract void Update(); //매프레임 지속적으로 호출되는 코드 (생명주기 Update와 다르다)
public abstract void Exit(); //상태에서 벗어날 때 1회 실행되는 코드
protected void StartAnimation(int hash)
{
stateMachine.butler.animator.SetBool(hash, true);
}
protected void StopAnimation(int hash)
{
stateMachine.butler.animator.SetBool(hash,false);
}
}
BaseState는 상태들의 공통적으로 들어갈 요소들을 작성해준 클래스입니다. 아직 구현되기전인 상태들의 설계도이며
해당 클래스를 각 상태들에 상속하고 멤버변수와 메서드 또한
protected abstract 키워드로 공통적으로 구현될 것과 아닌 것을 나누어줍니다.
() State (특정 상태)
public class IdleState : EnemyBaseState
{
//여기서 보이지는 않지만 protected 키워드의 변수들을 상속받고 있다.
public IdleState(EnemyStateMachine stateMachine) : base(stateMachine) //base 키워드로 stateMachine의 주소 또한 정상적으로 참조되는 중
{
}
public override void Enter()
{
stateMachine.butler.agent.isStopped = true; //들어갈 때 Agent의 이동을 멈추도록함
StartAnimation(stateMachine.butler.animationData.IdleParameterHash); //Idle 애니메이션 출력
}
public override void Exit()
{
stateMachine.butler.agent.isStopped = false; //나갈 때는 거꾸로 Agent의 이동을 다시 풀어줌
StopAnimation(stateMachine.butler.animationData.IdleParameterHash); //마찬가지로 애니메이션을 꺼준다.
}
public override void Update()
{
if(stateMachine.butler.changeWaitTime - Time.time < 0) //일정시간이 지났을 때 순찰 상태로 자동으로 변경되도록 해준다
{
stateMachine.ChangeState(stateMachine.PatrollingState); //소속되어 있는 상태머신에서 상태를 변환해주는 메서드 호출
}
}
}
BaseState를 상속받은 상태들 중 하나의 스크립트입니다.
protected 키워드로 쓰이는 변수와 메서드는 전부 상속받을 때 구현이 되어 있으나
abstract 키워드로 상속받은 메서드들은 구현이 안되어있기에 새로 작성하여 줍니다.
Enter 메서드에서는 상태에 진입하였을 때 1회 수행해줄 로직을 작성하고
Exit에선 반대로 상태에서 벗어날 때 정리해줄 로직을 작성합니다.
Update에선 지속적으로 처리되어야 할 로직을 작성합니다.
StateMachine (상태 기계)
//curState?.Enter(); 처럼 쓰이는 이유는 curState가 비어있을 때 경우를 상정한 것
public class EnemyStateMachine
{
private EnemyBaseState curState; //현재 사용되고 있는 상태 해당 클래스를 상속시킨 클래스들을 여기에 참조시킬 수 있다.
//상태 객체의 저장소
public IdleState IdleState { get; private set; }
public PatrollingState PatrollingState { get; private set; }
public SearchingState SearchingState { get; private set; }
public SuspectState SuspectState { get; private set; }
public ChasingState ChasingState { get; private set; }
public Butler butler { get; private set; } //상태머신이 소속된 적 Npc 클래스
public EnemyStateMachine(Butler butler)
{
this.butler = butler; //생성자를 통해서 이 상태머신이 Butler에 소속됨을 나타냄
SetState(); //밑에 본다면 같은 방식으로 생성자를 통해 상태들을 이 클래스에 소속시키고 있다.
}
public void ChangeState(EnemyBaseState state)
{
curState?.Exit(); //현 상태의 종료로직 수행
curState = state; //현 상태를 입력받은 상태로 교체한다.
curState?.Enter(); //바꾼 상태의 첫 실행 로직 수행
}
public void UpdateState()
{
curState?.Update(); //Butler에서 가져와서 상태들의 Update 메서드를 사용한다.
}
private void SetState() // 처음 호출되었을 때 상태들을 만들어주는 메서드
{
IdleState = new IdleState(this);
PatrollingState = new PatrollingState(this);
SuspectState = new SuspectState(this);
SearchingState = new SearchingState(this);
ChasingState = new ChasingState(this);
}
}
StateMachine 에선 상태들을 새로 생성하고 관리하는 역할을 합니다.
생성자에서는 사용의 주체가 되는 Butler 클래스의 주소를 받아와 캐싱합니다.
SetState 메서드로 각 상태들을 이 상태기계안에서 새로 만들어 주소를 저장해줍니다.
ChangeState 메서드로 실행중인 상태를 종료시키고 다른 상태로 변환시켜줍니다.
UpdateState 메서드는 Butler에서 사용할 지속적인 처리 로직을 실행시켜주는 매개체입니다.
User (사용자)
public class Butler : MonoBehaviour
{
[field: Header("Speed")]
public float walkSpeed;
public float runSpeed;
[field: Header("Idle")]
[field: SerializeField] public float changeWaitTime { get; private set; } = 2f;
[field:Header("Patrol")]
public Transform[] patrolPlace;
[Header("Suspect&Searching")]
private float doubtGauge = 0;
[field:Header("Chase")]
public float attackDistance;
public float detectDistance; //감지범위
public float playerDistance { get; private set; }
public NavMeshAgent agent { get; private set; }
public EnemyStateMachine stateMachine { get; private set; } //상태머신을 참조할 변수
public Animator animator { get; private set; }
[field: SerializeField]public ButlerAnimationData animationData { get; private set; }
private void Awake()
{
agent = GetComponent<NavMeshAgent>();
animator = GetComponentInChildren<Animator>();
animationData.Initialize(); //이건 애니메이션의 패러미터를 미리 string에서 hash로 바꿔놓는 작업
stateMachine = new EnemyStateMachine(this); //새로 상태머신 객체를 생성하며 등록한다.
}
public void Start()
{
stateMachine.ChangeState(stateMachine.IdleState); //Start에서 처음 상태를 지정
}
private void Update()
{
stateMachine.UpdateState(); //stateMachine의 현 상태의 Update를 호출해서 사용한다.
}
}
Butler 클래스는 실행의 주체가 되는 클래스입니다.
Awake 메서드에서 상태머신의 객체를 생성하여 캐싱하고
Start 메서드에서 처음 상태를 지정해주며 이후 자연스럽게 상태가 변환되도록 하는 것은 상태머신 몫이 됩니다.
Update에선 UpdateState로 받아온 지속적인 처리 로직을 호출해줍니다.
마무리
저는 간략한 기능들을 사용하기 위해
BaseState, State, StateMachine, User
로 기능별로 나누어 상태 패턴을 설명하였으며
여기서 좀 더 세부적으로 역할을 나누고 싶다면
BaseState -> GroundState (지상에서 사용하는 상태) -> IdleState (지상에서기본 상태)
StateMachine -> EnemyState or PlayerState
Enemy -> Butler
처럼 상속을 통해 갈래를 나누어 커스텀이 가능합니다.
이러한 객체를 상태패턴으로 나누는 것 뿐아니라 유한 상태머신을 집목시킬 수 있는 모든 대상들에 사용이 가능할 것으로 보입니다.
ex) 자연스러운 저장, 불러오기 or 키워드를 통한 콤보 시스템(ASAASSASA) 등등
유한 상태머신을 이해해보면서 좀 더 객체지향적으로 코드를 작성해보고
또 생성자와 추상 클래스들도 연습해 볼 수 있어 좋은 경험이었던 것 같습니다.
물론 사용함에 익숙하지 않아 미숙한 점이 보일 수도 있지만
피드백받으며 더 좋은 코드를 작성해보는 것이 목표이기에
제가 잘못 알고있는 점에 대해서 이야기해주시면 정말 감사할 것 같습니다!
내일 할일
- 피봇 앵커에 대한 부분 생각 정리해보기
- 적Npc 추격, 경계 게이지 구현해보기
'TIL' 카테고리의 다른 글
2024 11 21 TIL (1) | 2024.11.21 |
---|---|
2024 11 20 TIL (0) | 2024.11.20 |
2024 11 18 TIL (0) | 2024.11.18 |
2024 11 15 TIL (1) | 2024.11.15 |
2024 11 14 TIL (0) | 2024.11.14 |