본문 바로가기

유니티 개발 정보/개념

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

원문보기
1편보기 2편보기


(이어서 계속)

클로저
(역자 주 : 클로저에 대해서 처음들어보시는 분은 밑에 내용을 읽기 전에 링크를 통해서 클로저에 대한 대략적인 이해를 하시고 보시면 좀 더 쉽게 내용을 이해하실 수 있습니다.)

자, 우리는 위에서 클로저를 만들기 위해 델리게이트에 인스턴스의 참조를 어떻게 숨기는지를 봤다. 이런 이유로, 델리게이트를 사용할 때, Action이 인스턴스 맴버 혹은 static 맴버를 가리키는지, 이 메소드가 존재하는 클래스가 무엇인지 등을 알 필요가 없다. 단지 알아야 하는 것은 someAction()를 호출했을 때 그저 잘 작동할 것이라는 것이다.

여기 놀라운 소식이 하나있다 : 클로저 내부에서도 다른 변수를 숨길 수 있다!

방법은 익명 함수(anonymous functions)를 사용하는 것인데, 다시 말해서, 실제 클래스에 존재하지 않고, 이름을 가지지 않는 함수이지만, 마치 함수인 것처럼 작동하는 것을 말한다. 이 함수에 다수의 내부 변수나 매개변수를 정의할 수 있다.

익명 함수를 만들 때 JIT 컴파일(just-in-time compile)이 필요하지 않기 때문에, 모든 유니티 플랫폼에서 작동된다.
우리는 Action이 리턴 값과 매개변수가 없는 함수를 정의하고 있다는 것을 알고 있다.
항상 action을 다음과 같이 호출 한다는 사실도 알고 있을 것이다. : someAction()

자 이제, 코루틴을 사용해서 물체를 움직이는 간단한 스크립트를 만들어 보자.

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
public static IEnumerator MoveThisObject(Transform objectToMove, Vector3 position, float period, Action onComplete)
{
      //선형 보간(lerp)의 값을 표현하기 위해
      //t 변수를 사용할 것이다.
      //매 프레임마다 작동한다.
      var t = 0f;
 
      //객체의 현재 위치를 기록한다.
      var startingPosition = objectToMove.position;
 
      //끝 지점이 아닐 때 까지 계속 반복한다.
      while(t < 1)
      {
            //물체를 향하여 객체를 선형 보간한다.
            objectToMove.position = Vector3.Lerp(startingPosition, position, t);
          
          //Period는 해당 물체를 해당 위치로 
          //옮길 목표시간을 초단위 시간으로 나타낸 것인데
          //이 프레임에서 움직인 양을 얻기 위해
          //Time.deltaTime을 Period로 나눌 것이다.
            t += Time.deltaTime / period;
            
            //다음 프레임까지 yield된다.
            yield return null;
      }
      if(onComplete != null)
          onComplete();
}

이 스크립트는 코루틴으로 실행해야 한다 - 이 스크립트에 있는 onComplete 매개변수를 보자 - 이 함수는 움직임이 끝이 났을 때 호출될 것이다.

이 함수를 호출하는 가장 간단한 방법을 한번 살펴 보자:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//우리는 이 인벤토리 리스트를 가끔 사용할 것인데,
//이는 당신이 주은 아이템에 대한 리스트를 의미한다.
List<SomeInventoryItemClass> myInventory = new List<SomeInventoryItemClass>();
 
void OnTriggerEnter(Collider other)
{
     if(other.tag == "Pickup")
     {
         //물체를 2초 동안 현재위치로 이동시켜라
         StartCoroutine(MoveThisObject(other.transform, transform.position, 2, Finished));
     }
}
 
void Finished()
{
      Debug.Log("Got here");
}

위에 코드는 주은 아이템("Pick up")을 현재 위치로 2초동안 이동시킨다 - 그러나 만약 "Pick Up"아이템을 파괴시키고, 아이템을 인벤토리에 넣고싶다면 그건 조금 어려울 것이다. 우리는 지금 물체를 특정 위치로 이동시키는, 간단한 MoveThisObject함수를 가지고 있다 - 여기서 아이템을 파괴시키고, 그것을 인벤토리에 넣기 위해, Finished함수를 어떻게 작성해야 할까?

혹시 Finished함수에서 사용될 몇가지 변수들을 클래스에 넣어야 한다고 생각하고 있는가? 안타깝지만 그것은 작동하지 않을 것이다. 한 아이템이 Finished()되기전에 두개의 아이템을 주웠다면 어떻게 되겠는가? 위 방법처럼 한다면 아마 막혀버릴 것이다.
(원문 : 
Are you thinking of setting some variables in the class that Finished would use?  That won't work, what happens if you pickup two things before the first one has finished?  You're stuck if you follow that approach.)
(역자 주 : 저도 번역을 하면서 이해가 잘 되지 않는 부분이였는데, 왜 막혀 버릴까요? 위 방법대로 클래스에 몇개의 변수를 추가해서 Finished함수를 만들어 테스트를 해봤는데, 잘 작동하는 것 같습니다. 물론 위 방법보다 다음에 나올 방법이 더 깔끔하긴 합니다만, 왜 작동하지 않는다고 적혀있는지 궁금하네요. 혹시 이 글을 읽고 이해를 하셨다면 댓글을 달아주시면 감사하겠습니다 :) ) 

그럼 이제, 이를 해결해야하는 유일한 방법은 MoveThisObjectDestroyItAndAddItToMyInventory같은 새로운 함수를 만드는 것이라고 생각할지도 모른다 - 각 함수마다 이런식으로 코드를 작성한다면, 매우 비효율적이며, 똑같은 코드가 반복될 것이다.

이런 상황은 익명함수와 클로저를 사용하기에 딱이다. 우리는 MoveThisObject를 계속 유지할 것인데, 그래도 우리가 원하는 모든 행동을 할 수 있다.

우리가 익명 함수를 작성하기 전에, MoveThisObject 코루틴함수를 생각하지말고, 단순히 OnTriggerEnter에서 아이템을 줍는다면 가정하면, 우리가 무엇을 해야하는지 생각해보자.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void OnTriggerEnter(Collider other)
{
    //충돌체가 "Pickup"인지 확인한다.
    if(other.tag == "Pickup")
    {
         //PickupItem에 대한 스크립트를 얻는다.
         var pickupData = other.GetComponent<PickupItem>();
         
         //인벤토리에 아이템을 추가한다.
         myInventory.Add(pickupData.item);
 
         //pickup 객체를 파괴한다.
         Destroy(pickupData.gameObject);
    }
}

이제 각 장점들을 취합하는 시간이다 - C#에서 익명함수를 작성하는 가장 좋은 방법은 람다(lambda)를 사용하는 것이다 - 분명히 말하지만, 시작 부분이 다소 파격적일 것이다. 하지만 인내심을 가지고 보다 보면, 자연스럽게 느껴질 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void OnTriggerEnter(Collider other)
{
    //충돌체가 "Pickup"인지 확인한다.
    if(other.tag == "Pickup")
    {
         //객체를 움직이고,
         //끝이 나면 인벤토리에 추가한 다음, 삭제한다
         StartCoroutine(MoveThisObject(other.transform, transform.position, 2,
             ()=> {
                //PickupItem에 대한 스크립트를 얻는다.
                var pickupData = other.GetComponent<PickupItem>();
                //인벤토리에 아이템을 추가한다.
                myInventory.Add(pickupData.item);
                //pickup 객체를 파괴한다.
                Destroy(pickupData.gameObject);
             }));
    }
}
someAction()이 호출되었던 Action의 내부에 우리가 숨긴 것을 보자.
우리는 Action을 OnTriggerEnter에 매개변수로 전달된 other 충돌체로 둘러 쌌고, myInventory에 접근하기 위해 필요한 this로도 둘러 쌌다. 만약 동시에 1000개의 트리거에 들어간다고 하더라도, 모든 물체는 당신의 위치로 이동할 것이고, 인벤토리에 추가된다음 파괴될 것이다. 당신은 어떻게 생각할지 모르겠지만, 나는 정말 끝내준다고 생각한다.

매개변수를 람다 함수에 전달할 수 있으며, 값을 리턴할 수도 있다.
다음과 같이 델리게이트를 정의했다고 생각해보자:

//2개의 int 입력매개변수와 하나의 int 출력매개변수를 가지
//는 델리게이트를 정의하자.
Func<int, int, int> someDelegate;
 
//그 후에
 
int result = someDelegate(4,4);
이제 우리는 람다 함수를 작성할 수 있다:
//코드 문을 가지지 않는 람다
someDelegate = (x,y) => x * y;
 
//이것은 함수와 똑같이 작동한다.
someDelegate = (x,y) => {
    return x*y;
};
 
//또 다른 사용 예
someDelegate = (a,b) => {
    var t = 0;
    for(var i = 0; i < b; i++)
    {
        t += a - i;
    }
    return t;
};
 
//다른 값에 의해 둘러 싸인 람다
void CreateAFunction(int someValue)
{
     someDelegate = (a,b) => {
           return a * b / someValue;
     };
}

절대 델리게이트를 반복문의 반복자(iterator)로 둘러 싸면 안된다 - 이는 단일의 변수 인스턴스이며, 함수가 호출될 때 항상 마지막 값을 가지고 있을 것이다. 만약 둘러 싸고 싶다면, 반복문의 반복자를 내부 변수로 복사를 해야 한다. 밑에 예문이 올바른 사용 방법을 보여 주고 있다.

List<Action> resetPositions = new List<Action>();
 
void Start()
{
     //이 게임오브젝트의 모든 자식에 반복된다.
     foreach(Transform child in transform)
     {
         //둘러 싸기 위해서 반복자(child)를 복사한다.
         var closeChild = child;
         //자식의 위치를 얻는다.
         var position = child.position;

         //위치를 초기화하기 위한 함수를 만든다.
         //그리고 그 함수를 리스트에 추가한다.
         resetPositions.Add(()=>closeChild.position=position);
     }
}
 
void ResetAllChildPositions()
{
     //resetPositions을 순회하며 Action을 실행한다.
     foreach(Action a in resetPositions)
     {
        //함수를 만들때 들어간 위치값으로 위치를 초기화 한다.
        a();
     }
}

지금 이순간은 클로저에 대한 비밀을 파해친 역사적인 순간이다. 활짝 웃고 있는 자신의 모습을 보고 있을 것이다 - 이로 인해 당신의 코드안에있는 굉장히 많은 곳에서 반복되는 부분을 제거할 수 있을 것이다.

그런데 한편에서는

자, 이제 원래 내용으로 다시 돌아와서, 우리는 FSM 프레임워크를 만들어야 한다. 간단히 말하자면, reflect를 이용해서 함수를 얻어온 다음, 그것을 Closed 델리게이트에 넣어야 한다.

그럼 한번 해보자. 우리는 현재 상태(current state)의 이름과 우리가 실행하고자 하는 함수의 이름으로 델리게이트를 얻을 수 있는 일반적인(generic) 함수를 작성할 것이다.

우리는 항상 열거형으로 상태를 저장하고, 모든 열거형은 System 클래스 Enum을 상속받는다. 그래서 파생된 클래스에서 우리가 원하는 이전의 열거형(old enumeration)을 만들고, 계속 이를 currentState에 설정하기 위해서,
StateMachineBase 클래스는 Enum형 타입 변수인 currentState을 정의한다. 게다가 설정을 위한 부수적인 일들을 처리하기 위한 함수를 만들기 위해서 다음 처럼 currentState를 속성으로 만들 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private Enum _currentState;
 
public Enum currentState
{
    get
    {
        return _currentState;
    }
    set
    {
        _currentState = value;
        ConfigureCurrentState();           
    }
}

currentState가 설정될 때마다, ConfigureCurrentState()가 호출될 것인데, 여기서 델리게이트를 설정할 것이다.

이제, 열거형 값에 대한 이름을 언제나 얻을 수 있게 되었다.
예를 들면, currentState.ToString() - 얼마나 간단한가!

여기 ConfigureCurrentState()가 있다. 여기서 EnterState와 ExitState 함수에 대한 코루틴을 반환하는 델리게이트들을 어떻게 가지고 있는지 주목해서 보자. 
당신이 아마 말할 수 있는 것은, 대부분의 마술같은 것들은 ConfigureDelegate라고 불리는 일반적인 함수 내부에 있다. 우리는 원하는 함수의 이름과 시그니처가 들어맞는 델리게이트를 나타내는 default 값을 전달할 것이다. 만약 configure delegate call이 상태에 들어맞는 것을 찾지 못한다면, 대신에 빈 함수를 사용할 것이다.

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
29
30
31
32
33
34
35
void ConfigureCurrentState()
{
    //만약 exit state를 가지고 있다면, 코루틴으로 실행한다.
    if(ExitState != null)
    {
        StartCoroutine(ExitState());
    }
 
    //모든 함수를 설정해야 한다.
    DoUpdate = ConfigureDelegate<Action>("Update", DoNothing);
    DoOnGUI = ConfigureDelegate<Action>("OnGUI", DoNothing);
    DoLateUpdate = ConfigureDelegate<Action>("LateUpdate", DoNothing);
    DoFixedUpdate = ConfigureDelegate<Action>("FixedUpdate", DoNothing);
    DoOnMouseUp = ConfigureDelegate<Action>("OnMouseUp", DoNothing);
    DoOnMouseDown = ConfigureDelegate<Action>("OnMouseDown", DoNothing);
    DoOnMouseEnter = ConfigureDelegate<Action>("OnMouseEnter", DoNothing);
    DoOnMouseExit = ConfigureDelegate<Action>("OnMouseExit", DoNothing);
    DoOnMouseDrag = ConfigureDelegate<Action>("OnMouseDrag", DoNothing);
    DoOnMouseOver = ConfigureDelegate<Action>("OnMouseOver", DoNothing);
    DoOnTriggerEnter = ConfigureDelegate<Action<Collider>>("OnTriggerEnter", DoNothingCollider);
    DoOnTriggerExit = ConfigureDelegate<Action<Collider>>("OnTriggerExir", DoNothingCollider);
    DoOnTriggerStay = ConfigureDelegate<Action<Collider>>("OnTriggerEnter", DoNothingCollider);
    DoOnCollisionEnter = ConfigureDelegate<Action<Collision>>("OnCollisionEnter", DoNothingCollision);
    DoOnCollisionExit = ConfigureDelegate<Action<Collision>>("OnCollisionExit", DoNothingCollision);
    DoOnCollisionStay = ConfigureDelegate<Action<Collision>>("OnCollisionStay", DoNothingCollision);
    Func<IEnumerator> enterState = ConfigureDelegate<Func<IEnumerator>>("EnterState", DoNothingCoroutine);
    ExitState = ConfigureDelegate<Func<IEnumerator>>("ExitState", DoNothingCoroutine);
            
    //최적화단계, 만약 OnGUI함수를 가지고 있지 않다면 GUI를 끈다.
    EnableGUI();
    
    //현재 상태를 시작한다.
    StartCoroutine(enterState());
 
}

보이는것 처럼, 각 델리게이트에 대한 설정을 가지고 있다. 
어떻게 ConfigureDelegate가 작동하는지 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//델리게이트를 반환하는 일반적인 함수를 정의한다.
//Where절에 주목해라 - 전달된 타입 T는 클래스여야 하고,
//값타입이어서는 안된다. 그렇지 않으면 (As T)형태로의 캐스팅또한 작동하지 않을 것이다.
T ConfigureDelegate<T>(string methodRoot, T Default) where T : class
    {
      //CURRENTSTATE_METHODROOT라고 불리는 함수를 찾는다.
      //함수는 public 또는 private일 수 있다.
        var mtd = GetType().GetMethod(_currentState.ToString() + "_" + methodRoot, System.Reflection.BindingFlags.Instance
            | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.InvokeMethod);
        //만약 함수를 찾았다면
        if(mtd != null)
        {
            //일반적인 인스턴스가 필요로하는 타입의 델리게이트를 만들고
            //이를 T타입으로 캐스팅 한다.                     
            return Delegate.CreateDelegate(typeof(T), this, mtd) as T;
        }
        else
        {
            //만약 함수를 찾지 못했다면, default를 반환한다.
            return Default;
        }
    }


다음 튜토리얼에서는, 발견된 델리게이트를 캐쉬하는 좀 더 효율적인 루틴을 만들어 볼 것인데, 여기에는 충분한 이유가 있다. GetMethod 호출안에서 문자열을 만드는 것은 메모리를 할당하기때문에, 가비지컬렉션을 일으킬 것이다.
하지만 현재로는, 이것은 전적으로 기능적인 예제이며, 어떤 규칙도 바뀌지 않을 것이다.
지금은, Enter와 Exit함수가 반드시 enumerator여야 한다. 나중에 우리는 이 요구사항을 바꿀 것이지만, 그들이 코루틴이 되는것이 싫다고 할지라도, 지금은 반드시 함수내에 yield return null 또는 yield break를 가지고 있어야 한다. 좀 거슬리겠지만, 다음 섹션에서 이 부분을 수정할 것이다.

결론

나는 이제, 당신이 FiniteStateMachine2에있는 코드를 살펴보거나, 나와 함께 밑에 비디오를 보도록 권하고 싶다. 여기에는 애니메이션을 기다리거나, transform같은 공통 변수를 캐쉬하는 StateMachineBase의 다른 특징들이 들어있다.  다음 섹션에서는 상태를 주입했을 때 실질적으로 작동하는 부분이 나오기 때문에 우리는 이 부분들을 다음을 위해 남겨둘 것이다.

나는 이 튜토리얼이 기술적이고, 다소 어려운 부분이라는 것을 알고 있다. 하지만 상태 기계 시스템을 다 만들고 났을 때는 훨씬 쉽게 느껴질 것이다.

Video

아마 당신은 첫 튜토리얼에서 보았던 비디오를 다시 보고 싶을 것이다.