Pv_log

8. State pattern 본문

Unity Learn 번역/Game Programming Patterns

8. State pattern

Priv 2023. 11. 20. 12:00


 



 

1. 상태 패턴(State pattern)

플레이어가 조작할  있는 캐릭터(Playable Character) 제작하는 과정을 상상해 봅시다.  캐릭터는  위에  있을 수도 있고, 컨트롤러를 조작해 달리거나 걸어 다닐 수도 있습니다. 점프 버튼을 누르면 캐릭터는 공중으로 뛰어오릅니다.  프레임이 지나면 캐릭터는  위에 서게 되고 대기 상태,   있는 상태로 다시 진입합니다.

 


 

2. 상태와 상태 머신(State Machine)

게임은 상호작용을 기본으로 합니다. 또한 게임은 우리에게 게임을 플레이하는 동안 수없이 변화하는 시스템들을 추적하도록 강제합니다. 만약 여러분의 캐릭터가 지니는 다양한 상태들을 표현하는  그린다고 가정하면, 아래와 유사한 그림을 보게  것입니다.

이는  가지 차이점이 있긴 하지만 플로차트(Flowchart) 상당히 닮아있습니다:

- 다이어그램은 여러 가지 상태(대기 / 있기, 걷기, 달리기, 점프하기 ) 구성되어 있습니다. 또한  번에  가지 상태만활성화될  있습니다.

- 각각의 상태는 게임이 실행되는 동안 변화하는 조건에 따라서 다른 상태로 넘어가게 만드는 트리거를 가질  있습니다.

- 트리거에 의해 A 상태에서 B 상태로 넘어갈 ,   B 상태가 새로 활성화된 상태가 됩니다.

 다이어그램은  묘사하고 있습니다. 게임을 개발할 , 유한 상태 머신은 일반적으로 게임  캐릭터나 소품들의 내부 상태를 추적하기 위해 사용됩니다.

기본적인 FSM 코드로 표현할  아마 여러분은 아래와 같이 enum switch 문을 사용한 초보적인 접근법을 사용할 것입니다.

public enum PlayerControllerState
{
    Idle,
    Walk,
    Jump
}

public class UnrefactoredPlayerController : MonoBehaviour
{
    private PlayerControllerState state;
    
    private void Update() 
    {
        GetInput();

        switch (state)
        {
            case PlayerControllerState.Idle :
                Idle();
                break;
            case PlayerControllerState.Walk :
                Walk();
                break;
            case PlayerControllerState.Jump :
                Jump();
                break;
        }
    }

    private void GetInput()
    {
        // process walk and jump controls
    }

    private void Walk()
    {
        // walk logic
    }

    private void Jump()
    {
        // jump logic
    }
}

 코드는 정상적으로 동작은 하겠지만, PlayerController 스크립트가 순식간에 지저분해질  있습니다. 만약 지금보다  많은 상태와 복잡한 요구사항을 추가하려고 한다면 PlayerController 스크립트의 내부 코드에 매번 접근해야  것입니다.

 


 

3. 예제: 간단한 상태 패턴 구현하기

다행히도,  로직을 재구성하는  도움을   있습니다. 본래의 GoF 패턴에 따르면 상태 패턴은 아래와 같은 가지 문제를 해결할  있습니다:

- 오브젝트는 내부 상태가 바뀌었을  동작도 바뀌어야 합니다.

-  상태에 따른 특정한 동작을 독립적으로 정의됩니다. 새로운 상태를 추가하는 것은 기존에 있는 상태의 동작에 영향을 미치지 않습니다.

위에서 예시로 언급된 UnrefactoredPlayerController 클래스는 상태의 변화를 추적할  있으나,  번째 문제(독립적으로정의된 상태들) 해결하지 못하고 있습니다. 새로운 상태를 추가할  기존에 존재하던 상태에 미치는 영향을 최소화하는  대신, 여기서는 오브젝트의 상태를 캡슐화하는 방법을 다루어 보겠습니다.

 상태의 구조를 이미지로 표현하면 다음과 같습니다: 

 해당 구조를 보면 상태에 진입하고, 조건을 만족할 때까지 프레임 단위로 반복문을 순환합니다. 상태 패턴을 구현하기 위해서는 IState라는 이름의 인터페이스를 생성해야 합니다:

public interface IState
{
    public void Enter()
    {
        // code that runs when we first enter the state
    }
    
    public void Update()
    {
        // per-frame logic, include condition to transition to a new state
    }
    
    public void Exit()
    {
        // code that runs when we exit the state
    }
}

게임 내의 각각의 구체적인 상태는 IState 인터페이스를 구현할 것입니다:

- 진입(Enter): 진입 로직은 상태에 처음 진입할  실행됩니다.

- 업데이트(Update): 업데이트 로직(간혹 실행(Execute) 또는 (Tick)이라 부르기도 합니다) 프레임마다 실행됩니다. MonoBehaviour처럼 Update 메서드를 세분화할  있으며, 물리학을 다룰 때는 FixedUpdate, LateUpdate 등을 대신 사용할  있습니다.

Update 안에 있는 모든 기능은 특정한 조건이 만족되어 트리거에 의해 상태가 바뀌기 전까지 프레임마다 실행됩니다.

- 탈출(Exit): 여기에 있는 코드는 상태를 떠나기 , 새로운 상태로 전환될  실행됩니다.

이제 위에서 살펴본 IState 인터페이스의  상태를 구현하는 클래스를 만들어야 합니다. 예제 프로젝트를 살펴보시면 WalkState, IdleState, JumpState 클래스가 미리 제작되어 있을 것입니다.

이와 다른 클래스인 StateMachine 클래스는 상태의 진입과 탈출의 흐름을 어떻게 제어할 것인지를 관리합니다. 앞서 언급한  가지 상태(WalkState, IdleState, JumpState) 함께 제공되는 StateMachine 클래스는 아래와 같이 생겼습니다.

[Serializable]
public class StateMachine
{
    public IState CurrentState { get; private set; }
    
    public WalkState walkState;
    public JumpState jumpState;
    public IdleState idleState;
    
    public void Initialize(IState startingState)
    {
        CurrentState = startingState;
        startingState.Enter();
    }
    
    public void TransitionTo(IState nextState)
    {
        CurrentState.Exit();
        CurrentState = nextState;
        nextState.Enter();
    }
    
    public void Update()
    {
        if (CurrentState != null)
        {
            CurrentState.Update();
        }
    } 
}

상태 패턴을 다르기 위해서는 StateMachine 클래스가 관리해야 하는  상태(walkState, jumpState, idleState) public 타입의 오브젝트로 참조해야 합니다.  이유는 StateMachine 클래스가 MonoBehaviour 상속하지 않고, 생성자를 사용해  인스턴스를 설정하고 있기 때문입니다.

public StateMachine(PlayerController player) {
    this.walkState = new WalkState(player);
    this.jumpState = new JumpState(player);
    this.idleState = new IdleState(player);
}

어떠한 매개변수든 생성자에게 넘겨줄  있습니다. 샘플 프로젝트를 보면, PlayerController  상태를 참조한다는 것을  있습니다. 여러분은  상태를 프레임마다 업데이트하기 위해 이를 사용할 것입니다. (아래의 IdleState 예제 코드를 살펴보세요)

StateMachine 대한 아래 사항들을 기억해 두세요:

- Serializeable 속성은 StateMachine( StateMachine 안에 있는 public 타입의 필드들) Inspector 창에 노출해 줍니다. 따른 MonoBehaviour 상속하는 다른 클래스들(PlayerController 또는 EnemyController) StateMachine 필드로써 사용할  있습니다.

- CurrentState 속성은 읽기 전용 속성입니다. StateMachine  자체로는 명시적으로 필드를 설정하지 않습니다. PlayeController 같은 외부 오브젝트들은 Initialize 메서드를 깨움으로써(Invoke) 기본 상태를 설정할  있습니다.

- 각각의 상태 오브젝트는 현재 활성화된 상태를 변경하는 TransitionTo 메서드를 호출하여 자기 자신의 상태를 결정합니다. 여러분은 StateMachine 인스턴스를 설정할  (상태 머신  자체를 포함하여)필요한 종속성을  상태에 넘겨줄  있습니다.

예제 프로젝트에는 PlayerController 이미 StateMachine 참조가 포함되어 있기 때문에 여러분은 단순히 player 매개변수를 넘겨주기만 하면 됩니다.

 상태 오브젝트는 각자 가지고 있는 내부 로직을 관리할 것이며, 여러분은 게임 오브젝트나 컴포넌트를 묘사하는  필요 만큼 다양한 상태를 만들어낼  있습니다.  상태는 IState 인터페이스를 구현하는 자신만의 클래스를 가지고 있습니다. SOLID 원칙을 지키면서   이상의 상태를 추가한다면 이전에 만들어  상태에 최소한의 영향만 미칠 것입니다.

IdleState 예시 코드는 다음과 같습니다:

public class IdleState : IState
{
    private PlayerController player;
    
    public IdleState(PlayerController player)
    {
        this.player = player;
    }

    public void Enter()
    {
        // code that runs when we first enter the state
    }

    public void Update()
    {
        // Here we add logic to detect if the conditions exist to
        // transition to another state
        // …
    }

    public void Exit()
    {
        // code that runs when we exit the state
    }
}

다시 말하지만, 생성자를 사용해서 PlayerController 오브젝트를 전달하세요. 예제 코드의 player StateMachine 참조와 Update 로직에 필요한 모든 것들이 포함되어 있습니다. IdleState 캐릭터 컨트롤러의 가속도 또는 점프 상태를 감시하고 StateMachine TransitionTo 메서드 적절한 시점에 깨웁니다(invokes).

WalkState JumpState 제대로 구현되었는지 샘플 프로젝트를 통해 살펴보세요. 동작을 전환하는 거대한 하나의 클래스를 구현하는  대신,  상태가 자신만의 업데이트 로직을 가지고 있는 것이  좋습니다. 그렇게 하면 상태들이 다른 상태와 독립적으로 동작할  있습니다.

 


 

4. 상태 패턴의 장단점

상태 패턴은 오브젝트의 내부 로직을 설정할 , SOLID 원칙을 준수할  있도록 도와줍니다.  상태는 비교적 규모가 작고 다른 상태로 전환되는 상태를 추적합니다. 개방-폐쇄 원칙에 따라 여러분은 기존의 다른 상태에 영향을 미치거나 성가신 switch 또는 if 문을 사용하지 않고도 새로운 상태를 추가할  있습니다.

반면에, 추적해야  상태가 많지 않다면, 추가적인 구조가 불필요할  있습니다.  패턴은 추후 개발을 진행하면서 지금의 상태가  복잡해질 가능성이 있을  유용할  있습니다.

 


 

5. 상태 패턴 개량하기

샘플 프로젝트 안에 있는 캡슐은 색상을 바꾸고 플레이어의 내부 상태에 대한 UI 업데이트합니다. 실제 개발 환경에서는 상태변경을 포함하여 이보다  복잡한 효과를 가질  있습니다.

- 상태 패턴과 애니메이션 결합: 가장 흔히   있는 상태 패턴을 활용하는 애플리케이션은 애니메이션입니다. 플레이어 또는 캐릭터들은 종종 매크로 단계에서 기본적인 형태(캡슐) 표현됩니다. 그런 다음, 내부 상태 변화에 반응하는 애니메이션 기하학을 가질  있으므로, 게임  캐릭터는 달리기, 점프하기, 수영하기, 암벽 오르기 등과 같은 동작을 표현할  있습니다.

만약 Unity 엔진에 내장된 Animator 창을 사용해  적이 있다면, Animator 창의 워크플로(Workflow) 상태 패턴과  어울린다는 것을 알아채셨을 것입니다.  애니메이션 클립은 하나의 상태를 나타내며,  번에 하나의 상태만 활성화될  있습니다.

- 이벤트 추가하기: 상태 변화에 대해 외부 오브젝트와 소통하기 위해서는 이벤트를 추가해 주어야 합니다. (상세한 내용은 옵서버 패턴 부분을 참고하세요) 진입 또는 탈출 상태를 지니고 있는 이벤트는 관계가 있는 리스너들에게 알림을 보낼  있고, 게임이 실행되는 동안 응답을 받을  있습니다.

- 계층 구조 추가하기: 상태 패턴을 통해 더욱 복잡한 기능들을 구현하기 시작한다면, 계층 구조를 지닌 상태 머신을 구현하고 싶으실 겁니다. 필연적으로  가지 상태는 유사한 형태를  것입니다. 예를 들어, 플레이어 또는 게임 캐릭터가 지상에 있다면, 이들은 WalkingState 또는 RunningState 중의 하나가 활성화되었을  몸을 숙이거나 점프를   있습니다.

만약 SuperState 상태를 구현한다면, 일반적인 동작을 함께 유지할  있습니다. 상속 기능을 사용하면, 특정한 다른 하위 상태를 오버라이드할 수도 있습니다. 예를 들자면, 여러분은 아마 GroundedState 상태를 먼저 정의할 것입니다. GroundedState 상태가 만들어졌다면 이를 상속하여 RunningState 또는 WalkingState 상태 만들어   있습니다.

- 간단한 AI 구현하기: 유한 상태 머신은 기초적인 수준의  AI 제작하는 데에도 유용하게 활용할  있습니다. NPC 두뇌를 FSM 접근법을 통해 제작한다면 아래와 같은 그림이 그려질 것입니다:

 그림을 보면 서로 완전히 다른 문맥 속에서 동작하는 상태 패턴이 만들어졌음을   있습니다. 모든 상태는 공격하기, 도망가기, 순찰하기와 같은 다양한 행동을 표현합니다. 또한 다음에 전환될 상태를 결정할  있는  상태는  번에 하나씩만 활성화될  있습니다.

 


 


수고하셨습니다!


'Unity Learn 번역 > Game Programming Patterns' 카테고리의 다른 글

10. Model View Presenter (MVP)  (0) 2023.12.01
9. Observer pattern  (0) 2023.11.23
7. Command pattern  (0) 2023.11.12
6. Singleton pattern  (0) 2023.10.31
5. Object pool  (0) 2023.10.23
0 Comments