본문 바로가기

유니티 개발 정보/개념

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

원문은 이곳에서 보실수 있습니다.
내용이 길어 총 3편으로 나눠서 올리겠습니다.
*번역을 하면서 이해가 잘되지 않은 부분은 원문과 같이 배치하였습니다.



(1편에 이어서 계속)


유한 상태 기계의 타입

많은 사람들이 알다시피, 유한 상태 기계를 만드는 것을 도와주는 많은 그래픽 툴이 있다. - 이 튜토리얼은 코드 기반의 FSM에 대한 것이다. 만약 그래픽 툴을 사용하고 싶다면, PlayMaker 또는 그외 경쟁사 제품을 확인해봐라. - 하지만 그래픽 툴을 이용하는 것은 스스로 코드를 작성하는 것보다 훨씬 더 많은 제약이 존재한다.

여기에는 2가지 형태의 코드 기반 FSM이 있다.

Discoverable FSM/On Demand FSM

가끔씩, 특별한 목적으로 특정 시간에, 어떤 대상의 상태만 알면 되는 경우가 있다. discoverable FSM은 
상황을 샘플링하고, 후보 상태들의 집합을 알기위해 그들을 분석함으로써, 당신이 상태를 정의할 수 있게 한다.

식기세척기를 가지고 있다고 상상해보자. 당신은 식기세척기를 열거나 닫을 수 있다. 그리고 만약 열었을 때, 당신은 세척기안에 있는 서랍과 관련된 일을 할 수 있다. 세척기를 켜고 끌 수 있는 제어 버튼이 있다.

제어 버튼을 누를 때, 식기세척기의 상태를 알아야 한다. 
- 예를 들어 닫기, 열기, 서랍 열기를 할 수 있다고 하자.

Now you don't really define the state of the washer per se - because in its open state the drawers could be anywhere and the effort of testing whether they're open or fully shut would be a lot of processing each time that they moved.  So this could be a good use for a discoverable state machine.
사실 당신은 세척기 그 자체의 상태를 정의하지 않는다. - 왜냐하면 열려있는 상태에서, 서랍은 어떤 곳이든 있을 수 있고, 그들이 열려있거나 완전하게 닫혀있는지를 검사하는 것은 많은 프로세스가 필요하기 때문이다. 그래서 이는 discoverable 상태 기계의 효율적인 사용이 될 수 있을 것이다.

제어 버튼을 누를 때(그리고 다른 때에 가능성이 있는), 세척기 컴포넌트의 상태 코드 샘플 조각을 가질 수 있을 것이다. 그리고 3가지 상태중 하나를 리턴할 것이다. - 다른 모든 코드는 반환된 상태를 사용한다. 예를 들어, 서랍이 열려있을 때, 닫기 버튼을 누르는 것은, 우선 서랍을 닫고, 다음에 세척기의 문을 닫을 것이다.


이와같이 상태를 사용하는 것은 당신의 코드를 DRY하도록 유지하는데 도움을 줄 것이다.

DRY는 Don't Repeat Yourself(코드 중복 방지)를 나타내며, 당신은 이것을 좌우명으로 삼으려고 노력해야 한다.


만약 2개 혹은 그 이상의 실질적으로 똑같은 코드를 가지고 있다면, 어느 날 당신은 그들 중 하나를 수정할 것이고, 나머지 것들을 수정해야 한다는 사실을 잊어버릴 것이다. 가능한 한 당신의 코드를 DRY하도록 노력하면, 많은 불필요한 시간들을 최소화 시켜줄 것이다.
Discoverable 유한 상태 기계는 매우 간단하고, 코드 규칙에 불과하다. 그들의 특성상, discoverable 상태 기계는 더 쉽게 부호결정을 하기 위해서 사용된 간소화된 실제 모델이다. 이 튜토리얼에서, 우리는 상태 전이, 호출가능한 하위 상태 그리고 상태 주입에 대해 배울 것이다. - 이들의 그 어떤 것도 discoverable 모델에 적용되지 않을 것이다.


상태 기계 형태를 혼합하는 것은 지극히 정상이다. DFSMs(discoverable FSM)은 과도하게 반복되는 상황에서 사용될 수 있는 기법이다. 그러나 객체는 완전한 상태 기계에 대해 너무나 간단하게 후보자가 될 수 있다.

Functional Finite State Machines
실용적인 유한 상태 기계

이 튜토리얼의 핵심은 기능적인 유한 상태 기계를 다루는 것이다. 이들은 DRY보다 더 높은 단계의 프로그래밍 기법이며, 이는 당신이 객체에 대해서 다른 방법으로 생각할 수 있게 하고, 당신의 게임을 훨씬 더 쉽게 확장하도록 놀랄만한 논리적인 이식성을 제공한다.

DFSM과 비교했을 때, FFSM의 가장 큰 차이는 객체를 상태에 집어넣는 것이다. 객체가 다른 상태로 이동하기 전까지 현 상태에 거주할 것이다. FFSM은 해당 상태의 특정 로직을 수행할 뿐만 아니라, 빈번히 상태로 들어오고, 나가는 행동을 취할 것이다.

가장 간단한 FFSM은 상태를 묘사하는 enumeration(열거)형으로 구현이 된다. current state는 변수이며, 프로그램이 각 상태로 들어왔을 때, 많은 양의 switch문은 상태에 맞게 각각의 다른 로직을 수행한다. 우리는 이제 시작하려고 한다. 이것은 단지 시작이 아니라 길고 흥미로운 여정이 될 것이다.

튜토리얼 프로젝트

여기에서 튜토리얼 프로젝트를 다운받을 수 있다.(227MB) 이는 완료된 프로젝트이며, 당신은 앞으로 만나게 될 소스들을 그냥 넘어갈 수도 있다.  이 튜토리얼은 쿼터니언 튜토리얼 전체와 놀랍지 않은 유한 상태 기계라고 불리는 beneath 튜토리얼이 포함되어 있다.

Part 1 폴더는 일련의 의사 결정과 마지막 프레임워크에 이르는 기술들 익히도록 도와주는, 상태 기계 프레임워크를 만드는 방법을 보여줄 것이다. Part 2에서는 데모게임을 보여줄 것이다.

이 연재에서는 현재 사용 중인 관련된 씬을 다음과 같이 구별할 것이다.

Part 1/Scene 1

Where We Came In

Scene 1은 쿼터니언 튜토리얼의 마지막 씬과 동일하다. 그리고 우리는 enemy에 초점을 맞춰 시작할 것이다.

이 씬에서 이 Enemy는 MovementStateMachine3 스크립트에 의해 제어되고 있다.
(역자 주 : Scene 1은 Tutorial -> Finite State Machines -> Part1 -> scene1에서 찾을 수 있다)

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
IEnumerator Start () {
 
    //Redacted for brevity
 
    //이 객체가 살아있는 동안 반복된다.
    while(true)
    {
        //3~9초 사이의 무작위 값 동안 대기한다.
        yield return new WaitForSeconds(Random.value * 6f + 3);
         //적이 현재 잠들어 있는지 확인한다.
        if(sleeping)   
        {
            //새로운 Zzzzz객체를 emeny 머리 위에 생성한다.
            var newPrefab = Instantiate(sleepingPrefab, _transform.position + Vector3.up * 3f, Quaternion.identity) as Transform;
            //새로운 프리팹이 카메라와 같은 방향을 보게 만든다.
            newPrefab.forward = Camera.main.transform.forward;
        }
    }
 
}
Start함수는 enemy의 상태에 대해서 약간 알고 있다 - 이는 잠자고 있는 enemy 머리 위에 Zzzzz 객체를 인스턴스화하고, 계속해서 그들이 잠자고 있다고 간주하고 있는 반복문을 가지는 코루틴을 가지고 있다. 이는 잘 작동하지만, 여기서 로직은 다소 잘 보이지 않는다.

Start함수가 초기화를 끝냈을 때, 다음으로 while(true) 반복문에 들어가고, 이는 객체가 활성화 되어있는 한 계속해서 실행될 것이다. 반복문은 enemy가 자고있는지아닌지를 확인하고, 만약 자고 있다면, prefab을 인스턴스한다.

여기 Update 함수에는 더 많은 상태와  if문들이 있다:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
void Update()
{
    //Check if something else is in control -
    //later we will have coroutines that basically take control
    //of the enemy and in that case we need to ignore this
    //Update loop
    if(_busy)
        return;

    //적들이고있는지 확인한다.
    if(sleeping)
    {
        
//적들이 자고있으면 플레이어로부터 얼마나 멀리 떨어져 있는지 확인한다. 
//비용이 많이드는 제곱근 연산을 줄이기 위해 모든 거리를 제곱할 것이다.
        if((_transform.position - _player.position).sqrMagnitude < _attackDistanceSquared)
        {
//만약 플레이어 근처에있다면 Sleeping을 off한다.
            sleeping = false;

//target을 플레이어로 설정한다, 적은 이제 target을 향해 움직일 것이다.
            target = _player;

//Where this enemy wants to stand to attack, basically make them sneaky
//and not approach the player head on
            _attackRotation = Random.Range(60,310);
        }
    }
    else
    {
        //target이 죽고, 다시 sleep으로 돌아갔는지 확인
        if(!target)
        {
            sleeping = true;
            return;
        }
 
//target과의 거리를 구한다.
        var difference = (target.position - _transform.position);

        //약간의 높이차는 무시한다.
        difference.y /= 6;

        //Get the size of the vector (squared for performance reasons)
        var distanceSquared = difference.sqrMagnitude;
 
//거리가 너무 먼가?
        if( distanceSquared > _sleepDistanceSquared)
        {
            //Put the enemy back to sleep
            sleeping = true;
        }

        //공격할정도로 가까운가?
        else if( distanceSquared < _maximumAttackEffectRangeSquared && _angleToTarget < 40f)
        {
           //어택을 다루는 코루틴을 시작한다.
           //코루틴이 실행되는 Update함수는 비활성화 된다.
StartCoroutine(Attack(target));
        }
        //Otherwise time to move
        else
        {
        //Decide target position by taking where the target is and then
        //projecting a vector in the direction of the attack angle we
        //decided on when we woke up.  Note the length of this vector
        //is 80% of the attacks effective range

            var targetPosition = target.position + (Quaternion.AngleAxis(_attackRotation, Vector3.up) * target.forward * maximumAttackEffectRange * 0.8f);

//target을 향해 기본적인 움직임을 실행한다.
            var basicMovement = (targetPosition - _transform.position).normalized * speed * Time.deltaTime;

      //지형 높이를 수정할 것이기에 y값은 0으로 설정한다.
            basicMovement.y = 0;

     //Only move when facing the target - so get the angle between the
     //movement vector and the current heading of the enemy
            _angleToTarget = Vector3.Angle(basicMovement, _transform.forward);

//target이 70도 안에 있는지 확인
            if( _angleToTarget < 70f)
            {
                //중력을 작용한다.
                basicMovement.y = -20 * Time.deltaTime;
                _controller.Move(basicMovement);
            }
        }
    }
}
우리는 attack에 대한 훌륭한 코루틴 기법을 사용할 것이다. 그래서 Update함수는 이를 시작과 함께 제어할 것인지를 결정해야 한다. 만약 그렇다면, 많은 중첩 if문들은 적들이 일어나야 할 시간인지 아닌지를 알아 낼 것이다; 만약 적이 깨어있다면, 플레이어를 추적할 것인지 공격할 것인지를 결정해야 할 것이다. 그 다음 enemy를 대상으로 삼은 타겟의 방향으로 움직이게 처리해야 할 것이다.

이 코드는 잘 작동하지만, 나는 이 코드를 별로 좋아하지 않는다 - 고전적인 프로토타입으로, 슬프게도 오랫동안 버림받았고, 나중에 당신에게 문제를 안겨줄 것이다. Update에서 나눠진 로직들은 혼란스럽다. - 전체 파일을 읽지 않고 sleeping 상태에서 무슨일이 일어나는지는 명확하지 않다.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
IEnumerator Attack(Transform victim)
{
    //The enemy definitely isn't sleeping now
    sleeping = false;
    //Turn off the Update loop for now
    _busy = true;
    //Make sure the target is the victim we called this function with
    //this allows us to call Attack and specify a new enemy if we want to
    //(not used in the current example)
    target = victim;
    //Enable the attack animation
    _attack.enabled = true;
    //Reset the animation to the start
    _attack.time = 0;
    //Make it have its full weight
    _attack.weight = 1;
    //Wait for half way through the animation
    yield return StartCoroutine(StateMachineBase.WaitForAnimation(_attack, 0.5f));
    //Check if still in range
    if(victim && (victim.position - _transform.position).sqrMagnitude < _maximumAttackEffectRangeSquared)
    {
        //Apply the damage
        victim.SendMessage("TakeDamage", 1 + Random.value * 5, SendMessageOptions.DontRequireReceiver);
    }
    //Wait for the end of the animation
    yield return StartCoroutine(StateMachineBase.WaitForAnimation(_attack, 1f));
    //Stop the attack animation having any impact
    _attack.weight = 0;
    //Re-enable the Update loop
    _busy = false;
}


Attack을 호출하는 것은 객체의 상태를 바꾼다. 왜냐하면 sleeping에 영향을 주기때문이다. 또 Update문에서 상태 기계 처리때문에 호출될 수 도 있을 것이다. - 이는 if문들과 관련된 문제이다 - 이들은 객체를 수정하기 쉽고, 잠재적으로 유효한 상태에 있는 객체를 남겨두지만, 상태를 정의하기는 어렵다. 머리를 긁적이면서 많은 테스트를 하게 될 것이다. 그 다음에는  _bush에 영향을 미친다. Sleeping이 Update와 Start에 영향을 주는 동안 , 마찬가지로 이것또한 Update의 처리에 영향을 미친다. - 아직도 혼란스러운가?

나는 앞으로 계속 나아갈 수도 있지만, 그러지 않을 것이다. 나는 당신 이 완벽하게 기능적인 코드 조각은 실제 문제의 소지가 아주 크다는 것에 동의할 것이라고 생각한다.


(다음 글에서 계속)