본문 바로가기

유니티 개발 정보/개념

유한 상태 기계(Finite State Machines, FSM) #2 (1/3)

원문보기
간단한 switch기반 FSM에서

강력한 힘을 가진 delegate기반 프레임으로 변경하기



두번째 강좌에 온 것을 환영한다. 사실 첫번째 튜토리얼은 꽤 쉬운 편이였다. 이번 강좌에서는 성능이 좋고, 깔끔한 FSM 프레임워크를 만들어 볼 생각이다.

이 튜토리얼은 FSM 프레임워크를 어떻게 만드는지 보여주는데, 분량이 많다고 너무 걱정하지는 마라. 뒷부분에서는 FSM을 사용해서 어떤식으로 게임을 디자인하는지 보여줄 것이다.

이런 상황은 노트북을 살때, 샐러리맨이 다음과 같이 말하는 것과 같다.

"좋아요, 제가 CPU를 만드는데 사용된 실리콘 동판화 프로세스를 설명해드리죠. 걱정마세요 딱 1시간이면 충분합니다"

첫번째 튜토리얼 이후에, 이 튜토리얼은 많이 발전되었다. 대충 느낌을 파악하기 위해서 비디오를 먼저 보기를 원할지도 모르겠지만, 튜토리얼을 다 읽고 비디오를 보는 것이 더 도움이 될 것이다. 

비디오



이번 튜로리얼 프로젝트는 여기서 다운로드할 수 있다.


목적

이번 튜토리얼의 주 목적은 상태를 구현할 때, 코드의 깔끔함을 유지하면서 강력하고 새로운 방법으로 코드를 작성하는 것이다.

  • 상태가 상태의 시작조건과 종료조건에 영향을 주게하자.

  • 개별적인 루틴을 만들어서, 코드의 들여쓰기와 가독성을 증가시키자.

  • 상태에 대한 모든 코드를 소스파일에 함께 두자.

우리가 더 좋은 방법을 사용한다는 것은 위에 장점들 뿐만 아니라, 끝없이 사용하는 if문 혹은 switch문 보다 더 빠르다는 것을 의미한다.


상태 설정 & 해제

우선, 상태에 대해서 좀 더 생각해보자. 첫번째 튜토리얼에서 switch기반의 코드를 봤다면, 우리는 attack과 hit 상태에 대한 모든 로직을 한 곳에 넣지 못했다는 것을 알 것이다. 
상태의 배치, 설정은 currentState변수를 설정한 곳 내부에서 했어야 했다. 다음 코드는 공격 상태를 좀 더 명확하게 한 것이다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if( distanceSquared < _maximumAttackEffectRangeSquared && _angleToTarget < 60f)
{
    //현재 상태를 설정한다.
    currentState = EnemyStates.attacking;
    //애니메이션을 설정한다.
    _attack.enabled = true;
    _attack.time = 0;
    _attack.wrapMode = WrapMode.ClampForever;
    _attack.weight = 1;
    
//적에게 데미지를 가했는지 여부를 확인하는 변수 설정
    hasStruckTarget = false;
 
    return;
}


공격 상태에서는 애니메이션을 기다려야하는데, 애니메이션을 설정할 수 있는 유일한 곳은, 상태를 설정할 때이다. - 애니메이션은 Update문에 있는 해당 상태로 들어갈 때, 실행되어야 한다. 그러나 우리가 실질적으로 상태를 이용할 때는 이런 것까지 알 필요는 없다 - 이 코드는 공격을 시작하는 모든 곳에서 반복될 것이다. 절대 같은 코드를 반복해서는 안된다.


실제, 상태 루틴은 다음과 비슷할 것이다:

이 다이어그램은 함수 호출 순서를 의미하는 것이 아니다. OnGUI는 한 프레임당 여러번 호출된 다는 것을 명심하자. FixedUpdate는 Physics.fixedStep에 따라 게임이 얼마나 빨리 실행되는지를 기준으로 한 프레임에 한번 이상 호출될 수도 있고, 한번도 호출되지 않을 수 있다.

그래서 이 모델에서 보는 것 처럼, 상태는 특정 상태로 들어갔을때와 다시 나갔을 때 필요한 요구사항들을 설정하는 일을 담당한다. 상태를 떠날 때, 어떤 방법과 장소인지는 상관없이 Exit State 코드는 실행되어야 한다. 하지만 가장 중요한 것 코드를 작성하기 더 쉽게 한다는 것이다.

특히, Enter 함수가 여러 프레임동안 실행되는 코루틴이라면,  몇몇 상태들은 
Enter State에서 원하는 작업을 간단하게 처리할 수 있다.
위와 같은 모델을 사용한 attack 상태 코드는 다음과 같다:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#region Attacking
 
IEnumerator Attacking_EnterState()
{
    //공격 애니메이션을 설정한다.
    _attack.enabled = true;
    _attack.time = 0;
    _attack.weight = 1;
 
    //애니메이션의 절반을 기다리다.
    yield return StartCoroutine(WaitForAnimation(_attack, 0.5f));
    //범위 안에 있는지 확인한다.
    if(target && (target.position - transform.position).sqrMagnitude < _maximumAttackEffectRangeSquared)
    {
        //데미지를 적용한다.
        target.SendMessage("TakeDamage", 1 + Random.value * 5, SendMessageOptions.DontRequireReceiver);
    }
    //애니메이션이 끝날 때 까지 기다린다.
    yield return StartCoroutine(WaitForAnimation(_attack, 1f));
    //애니메이션 효과를 끈다.
    _attack.weight = 0;
 
    //대상으로 삼은 적이 살았는지 죽었는지
    //를 기준으로 다음 상태를 결정한다.
    currentState = target ? EnemyStates.Following : EnemyStates.Sleeping;
}
 
#endregion

위 샘플 코드는 애니메이션을 설정한 다음, 애니메이션을 실행하고, 데미지를 보낸다.
한 페이지만으로 코드를 이해하기 쉬워졌다.

다음 부분은 꽤 기술적인 섹션이다 - 내부 작동원리를 몰라도 손쉽게 FSM 프레임워크를 사용할 수 있을 것이다. 뒷 부분에는 FSM을 사용하여 게임을 만드는데 집중할 것이다. 이 파트가 너무 무겁게 느껴져지더라도 걱정하지는 마라. 단지 이 부분은 다음 튜토리얼에 사용할 기술들을 배우고 싶어하는 사람들을 위한 섹션일 뿐이다.

눈에 보이지 않는 것들

자, 이제 본격적으로 FSM 프레임워크를 만드는 방법을 알아보자. 
첫번째 단서는 바로 델리게이트(delegates)이다.


델리게이트는 C/C++의 함수 포인터와 비슷한 개념이며, 만약 C/C++를 사용했다면, 함수 포인터처럼 생각하면 이해하기 쉬울 것이다.
파트 1 / 씬3
FSM을 제어하는 적에 대해서 약간의 상속이 필요하다 - 우리는 'StateMachineBase'라는 이름의 클래스를 만들 것인데, 이는 실질적으로 상태를 관리하는 근본적인 코드이다.

우리는 기본 클래스에 Update, OnTriggerEnter를 실행하고, 델리게이트에게 프로세스를 전달할 것이다. 그리고 FSM상태가 바뀔때마다 델리게이트를 바꿀 것이다.

예를 들어 StateMachineBase에 있는 Update가 호출이 될 때, 그 프로세스는 현재 상태에 필요한 기능들을 의미하는 델리게이트에 위임된다.
델리게이트는 파생 클래스(enemy)안에서 있는 하나의 루틴이 되거나, 아무 것도 하지 않는 기본 루틴이 될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public Action DoUpdate = DoNothing;
public Action DoLateUpdate = DoNothing;
public Action DoFixedUpdate = DoNothing;
public Action<Collider> DoOnTriggerEnter = DoNothingCollider;
public Action<Collider> DoOnTriggerStay = DoNothingCollider;
public Action<Collider> DoOnTriggerExit = DoNothingCollider;
public Action<Collision> DoOnCollisionEnter = DoNothingCollision;
public Action<Collision> DoOnCollisionStay = DoNothingCollision;
public Action<Collision> DoOnCollisionExit = DoNothingCollision;
public Action DoOnMouseEnter = DoNothing;
public Action DoOnMouseUp = DoNothing;
public Action DoOnMouseDown = DoNothing;
public Action DoOnMouseOver = DoNothing;
public Action DoOnMouseExit = DoNothing;
public Action DoOnMouseDrag = DoNothing;
public Action DoOnGUI = DoNothing;
public Func<IEnumerator> ExitState = DoNothingCoroutine;

여기 상태 기계에 대한 델리게이트가 있다. 이 코드들은 Update함수를 구현하는데 사용데 사용된다.

1
2
3
4
void Update ()
{
    DoUpdate();
}

델리게이트는 근본적으로 함수 포인터이며, 함수의 참조를 저장할 수 있는 변수이다. 변수가 실행되면, 함수는 호출된다. 이 예제에 있는 DoUpdate()는 각 FSM상태에 따라 다른 함수로 바뀔 것이다.

델리게이트는 C# 또는 .NET 언어에서 이벤트 핸들러를 작성하는데 사용될 것이다.  이벤트 핸들러는 델리게이트로 구현이 된다.
여기서 델리게이트를 쓰는 이점을 간단하게 설명해보자면:

  • 델리게이트는 매우 빠르다 - 함수를 호출하는 것과 거의 같은 속도다.
  • switch문을 제거함으로써, 모든 가능성있는 상태에 대한 비교를 할 필요가 없고, 우리가 얼마나 많은 상태를 가졌던간에 동일한 속도로 실행될 것이다.
  • 델리게이트는 상태들의 루틴을 STATE_FUNCTION라고 부르는 규칙에 의해서, 상태의 루틴을 찾기 위해 리플렉션을 사용하는 코드를 작성하게 한다.

규칙에 따라 코딩을 하는 것은 독선적인 소프트웨어다. 당신은 반드시 이런 규칙들에 반드시 익숙해져야 하는데, 왜냐하면 유니티는 이미 꽤 독선적이기 때문이다. 당신은 Update함수에서 'U'를  반드시 대문자로 작성해야 한다. - Update를 다른 이름으로 바꿀 수 없다. 규칙 기반의 코딩은, 모든 것들이 항상 올바른 장소에 위치해야 하고, 이름또한 올바른 방법으로 지어야 하며, 시스템은 정의한 규칙에 따라 기능들을 찾아 낼 것이다.
다음으로는 StateMachineBase를 상속한 클래스에 대해서 reflect를 할 것인데, 다시 말해서, 우리의 함수와 같은 루틴을 찾을 것이다. 이 섹션에서는 상태를 바꿀때 마다 클래스에 대해서 reflect를 할 것이다. 리플렉션(Reflection)은 전혀 빠르지 않다. - 이 튜토리얼의 뒷 부분에서는, 결과를 어떻게 캐쉬하는지 볼 수 있다. - 그러나 우리는 전이되는 동안 반드시 이 작업을 해야하는데, 첫 번째 사용에 대한 벌금(패널티)라고 생각하면 된다.


(다음 글에서 계속)