본문 바로가기

유니티 개발 정보/개념

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

원문은 이곳에서 보실수 있습니다.
내용이 길어 총 3편으로 나눠서 올리겠습니다.



(2편에 이어서 계속)



우리의 첫번째 실용적인 유한 상태 기계

enemy에 대해서 더 주의깊게 생각하고, 상태를 정의한 다음, enemy들을 한 번에(at a time) 하나의 상태에 넣어보자.

enemy는 다음 중 하나의 상태에 있을 수 있다 : sleeping, following, attacking, being hit, dying.

우리는 실제 다음과 같이 작동하기를 바란다:


Part 1/Scene 2
우리는 FiniteStateMachine1.cs파일을 만든 다음, 파일안에서 가장 먼저 해야할 일은 상태를 정의하는 열거형을 만드는 것이다:

01
02
03
04
05
06
07
08
09
10
11
public enum EnemyStates
{
    sleeping = 0,
    following = 1,
    attacking = 2,
    beingHit = 3,
    dying = 4
}
 
public EnemyStates currentState = EnemyStates.sleeping;

이제, 우리는 오직 한 번에 하나의 상태만을 처리하고, 프로세스를 한 가운데로 모으기위해 모든 로직을 변경해야 한다. 이는 각 상태와 관련된 코드를 더 찾기 쉽게 한다.

이를 하기 위해서, 간단히 복잡한 코루틴을 제거할 것이다. 그래서 우리는 코드 통합에 집중할 수 있을 것이다. Start, Attack, Hit, Dead 코루틴에게 작별인사를 하고, switch문들과 잃어버린 코루틴 기능을 형성하기 위한 약간의 새로운 변수들을 반갑게 맞이 하자:

001
002
003
004
005
006
007
008
009
010
011
012
013
014
015
016
017
018
019
020
021
022
023
024
025
026
027
028
029
030
031
032
033
034
035
036
037
038
039
040
041
042
043
044
045
046
047
048
049
050
051
052
053
054
055
056
057
058
059
060
061
062
063
064
065
066
067
068
069
070
071
072
073
074
075
076
077
078
079
080
081
082
083
084
085
086
087
088
089
090
091
092
093
094
095
096
097
098
099
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
void Update()
{
    // 현재 활성화된 상태를 기반으로 코드는 실행 된다.
    switch(currentState)
    {
    case EnemyStates.sleeping:
        // 적은 sleeping 상태이다.

//Check the current time against the time for the next Zzzzz to appear
        if(timeOfNextZZZZ < Time.time)
        {

//If its time then create the sleeping prefab above the enemies
//head
            var newPrefab = Instantiate(sleepingPrefab, _transform.position + Vector3.up * 3f, Quaternion.identity) as Transform;
            newPrefab.forward = Camera.main.transform.forward;

//Calculate a time between 2 and 8 seconds for the next
//Zzzzz
            timeOfNextZZZZ = Time.time + 2 + (6 * Random.value);
        }

//Now check if the player has appraoched, using sqrMagnitude to avoid
//a performance expensive square root
        if((_transform.position - _player.position).sqrMagnitude < _attackDistanceSquared)
        {
  //Move the enemy into the following state
            currentState = EnemyStates.following;

  //Make the enemy target the player
            target = _player;

            //Where this enemy wants to stand to attack
            _attackRotation = Random.Range(60,310);
        }
        break;
 
    case EnemyStates.following:
        // enemy는 target을 따라가고 있다.
 
        //If the target has been destroyed then we go back to sleep
        //(Destroyed objects return null and false)
        if(!target)
        {
            currentState = EnemyStates.sleeping;
            return;
        }

        //Calculate the vector to the target
        var difference = (target.position - _transform.position);
        //Ignore a big part of the height difference by dividing it
        //by 6
        difference.y /= 6;

//Calculate the square of the distance (avoid expensive square root)
        var distanceSquared = difference.sqrMagnitude;
 
        //Too far away to care?
        if( distanceSquared > _sleepDistanceSquared)
        {
            //If we've got too far away then go to sleep
            currentState = EnemyStates.sleeping;
            return;
        }
 
        //Close enough to attack
        if( distanceSquared < _maximumAttackEffectRangeSquared && _angleToTarget < 60f)
        {
            //Move the enemy into the attacking state
            currentState = EnemyStates.attacking;

            //Enable the attack animation
            _attack.enabled = true;

            //Reset the animation to the start
            _attack.time = 0;

            //Make the animation just play once and hold
            //this is so .normalizedTime will become =1
            _attack.wrapMode = WrapMode.ClampForever;

            //Make the attack animation full power
            _attack.weight = 1;

            //Indicate that we have yet to strike the target
            hasStruckTarget = false;
 
            return;
        }
 
        //Move towards the target
 
//First decide target position based on the angle of attack we decided on waking up
        var targetPosition = target.position + (Quaternion.AngleAxis(_attackRotation, Vector3.up) * target.forward * maximumAttackEffectRange * 0.8f);

        //Calculate the basic movement toward the target
        var basicMovement = (targetPosition - _transform.position).normalized * speed * Time.deltaTime;

        //Zero out the y movement so we can get a bearing angle
        //we will apply gravity later
        basicMovement.y = 0;
 
        //Only move when facing the target - so calculate
        //the angle between the heading of the enemy and the target
        _angleToTarget = Vector3.Angle(basicMovement, _transform.forward);
        //Only move the enemy if it is within 70 degrees
        if( _angleToTarget < 70f)
        {
            //Provide some gravity
            basicMovement.y = -20 * Time.deltaTime;
            _controller.Move(basicMovement);
        }
 
        break;
    case EnemyStates.attacking:
        // enemy는 공격하고 있다.
 
//Work out if we are half way through the animation and haven't
//yet added any damage to the target
        if(!hasStruckTarget && _attack.normalizedTime > 0.5f)
        {
            //Ready to hit, so flag that it is done so we
            //only come in here once
            hasStruckTarget = true;

            //Check if still in range
            if(target && (target.position - _transform.position).sqrMagnitude < _maximumAttackEffectRangeSquared)
            {
                //Apply the damage
                target.SendMessage("TakeDamage", 1 + Random.value * 5, SendMessageOptions.DontRequireReceiver);
            }
 
        }
    //See if the animation is complete yet (normalizedTime will be 1)
        if(_attack.normalizedTime >= 1 - float.Epsilon)
        {

    //Set the state of the enemy based on whether the target is
    //dead or not
            currentState = target ? EnemyStates.following : EnemyStates.sleeping;

            //Make sure the animation has no effect
            _attack.weight = 0;
        }
        break;
 
    case EnemyStates.beingHit:
        // enemy는 현재 맞고 있다.
 
        //Check whether the animation is complete
        if(_hit.normalizedTime >= 1 - float.Epsilon)
        {
            //Animation is complete so set the state based on whether
            //the enemy is still alive (in which case chase it).
            currentState = target ? EnemyStates.following : EnemyStates.sleeping;
        }
        break;
 
    case EnemyStates.dying:

        //Wait for the end of the death animation
        if(_die.normalizedTime >= 1 - float.Epsilon)
        {
            //Animation is complete, destroy the enemy
            Destroy(gameObject);
        }
        break;
    }
 
}

우리의 코드는 더 읽기 쉬워졌다. 우리는 또렷하게 각 상태에 적용되는 로직들을 볼 수 있다. 
또, 코드에 트리거를 추가했다. 

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
void OnTriggerEnter(Collider hit)
{
    // 같은 객체에 있는 다른 충돌체에 영향을 주지 않는지 확인해라.
    if(hit.transform == _transform)
        return; // 상태를 기반으로 무슨 일을 할 것인지 선택한다.
    switch(currentState)
    {
    case EnemyStates.sleeping:
    case EnemyStates.following:

        // hit이 Player인가?
        if(hit.transform == _player)
        {
            //If the player is in the trigger then start following him
            target = _player;
            currentState = EnemyStates.following;
        }
        else
        {
            //See if the thing we hit has an EnemyMood
            var rival = hit.transform.GetComponent<EnemyMood>();
            if(rival)
            {
                //If the item hit has an EnemyMood then it is an
                //enemy
                //Get a random number and see if it is bigger than
                //the mood - so the lower the mood the more likely
                //it is that the subsequent code will run
                if(Random.value > _mood.mood/100)
                {
                    //We are going to attack this other enemy
                    target = hit.transform;
                    currentState = EnemyStates.following;
                }
            }
        }
        break;
    }
 
}

각 상태가 활성화 상태일 때, 우리는 또한 명확하게 상태 전이를 볼 수 있고, 정확하게 게임 루프와 트리거에서 일어나는 일을 알 수 있다.

완전히 작동하는 FFSM은 앞에서 봤던 우리의 뒤죽박죽한 생각들 보다, 훨씬 더 나은 개발의 기반이 될 것이다.

사실 아직 약간의 문제가 있다. 솔직히 말해서, 이 switch문은 꽤 커질 수 있다. 만약 더 많은 상태들과 많은 로직들이 존재한다면, 아마 우리가 어느 루틴에 있는지 잊어버릴 것이고, 또 각각의 새로운 상태들은 열거형 값들을 비교하기위해 약간의 시간이 걸릴 것이다. 각 상태에 대해 코드를 함께 가지는 것이 훨씬 더 나을 것이다.(이미 Sleeping Update 로직에서 Sleeping Trigger 로직으로 가는데 시간이 걸리며, Page Up과 Page Down 키를 여러번 눌러야 된다.)

보다 더 중요한 것은, 당신이 특정 상태의 로직을 보기 위해서 많은 스크롤을 해야할 때, 당신의 머리 속에 그 상태의 로직 전체를 유지하는 것이 어렵다는 것이다.

실제로, 당신의 코드를 다음과 같이 작성하면 어떨까:

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
#region Sleeping
 
IEnumerator Sleeping_EnterState()
{
    while(true)
    {
        yield return new WaitForSeconds(Time.time + 2 + (6 * Random.value));
        var newPrefab = Instantiate(sleepingPrefab, transform.position + Vector3.up * 3f, Quaternion.identity) as Transform;
            newPrefab.forward = Camera.main.transform.forward;
 
    }
}
 
void Sleeping_Update()
{
    if((transform.position - _player.position).sqrMagnitude < _attackDistanceSquared)
    {
        target = _player;
        //Where this enemy wants to stand to attack
        _attackRotation = Random.Range(60,310);
 
        currentState = EnemyStates.Following;
 
    }
}
 
void Sleeping_OnTriggerEnter(Collider hit)
{
    Following_OnTriggerEnter(hit);
}
 
#endregion

그렇게 멋지지는 않다. 모두 다 함께 있으며, 어떠한 스위치문도 존재하지 않는다. 실제로 당신이 지금 어디에 있고, 현재 상태에 적용되는 로직들이 무엇인지를 쉽게 볼수 있다. 우리는 심지어 약간의 코루틴 기법도 사용할 수 있다.

이 연재의 두번째 글에서, 우리가 정확히 어떻게 목적을 이루고 더 성능 기준에 부합하는 프레임워크를 만드는지에 대해서 알게 될 것이다. reflection cahing과 delegate에 대해서 배울준비를 해라. 이것은 그들이 우리의 FSM 프레임워크를 날라다니게 만드는데 필요하기 때문이다.


비디오

당신은 다음의 비디오를 통해서, FSM 프레임워크를 만드는데 더 많은 이해를 얻을 수 있다.