Game Development, 게임개발/디자인패턴

State Pattern, 상태 패턴 [디자인패턴]

게임이 더 좋아 2021. 10. 4. 21:09
반응형
728x170

 

사실 이 상태 패턴이란 것은 

우리도 이미 알 수도 있고 모르더라도 이미 쓰고 있을 수 있다.

[Unity, 유니티/Programming, 응용] - FSM,유한 상태 기계, Finite State Machines [Unity]

위 글과 밀접한 관련이 있다.

알아보자

 


 

간단한 횡스크롤 플랫포머를 만든다고 해보자.

플랫포머는 슈퍼 마리오와 비슷한 게임이라고 생각하면 된다.

게임 월드의 주인공이 사용자 입력에 따라 반응하도록 구현한다.

 

//B를 누르면 점프하게끔 구현하는 코드

void Heroine::handleInput(Input input){
    if(input == PRESS_B) {
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
    }
}

 

위에서 버그가 발생할 수 있는 점은 무엇인가?

바로 공중 점프다.

B 버튼을 연속으로 누르면 계속 점프해서 화면에서 사라지고 말 것이다.

그래서 bool isJumping_ 변수를 통해서 점프 중이면 점프할 수 없도록 막을 수 있다.

물론 다시 땅에 도착하면 false로 바꿔주어 다시 점프할 수 있게 만들어야 한다.

 

이번에는 플레이어가 땅에 있을 때 엎드리는 버튼이다.

void Heroine::handleInput(Input input){
    if(input == PRESS_B){
        //점프중이 아니라면 점프가능
        ...
    }else if(input == PRESS_DOWN){
        if(!isJumping_){
            setGraphics(IMAGE_DUCK);
        }
    }else if(input == RELEASE_DOWN){
        setGraphics(IMAGE_STAND);
    }
}

 

여기에도 버그가 있을까?

여기서는 엎드린 상태로 점프하고 아래 버튼을 때면

점프 중에서도 점프 동작으로 바뀌지 않고 서있는 동작이 실행된다.

즉, 여기서도 flag 변수가 필요하다.

void Heroine::handleInput(Input input){
    if(input == PRESS_B){
        if(!isJuping_ && !isDucking_){
            //점프 수행
            ...
        }
    }else if(input == PRESS_DOWN){
        if(!isJumping_){
            isDucking_ = true;
            setGraphics(IMAGE_DUCK);
        }
    }else if(input == RELEASE_DOWN){
        if(isDucking_){
            isDucking_ = false;
            setGraphics(IMAGE_STAND);
        }
    }
}

 

이번에는 점프 중 아래 버튼을 누르면 찍기 공격이 가능하게 해보자

 

void Heroine::handleInput(Input input){
    if(input == PRESS_B){
        if(!isJuping_ && !isDucking_){
            //점프 수행
            ...
        }
    }else if(input == PRESS_DOWN){
        if(!isJumping_){
            isDucking_ = true;
            setGraphics(IMAGE_DUCK);
        }
        else{
            isJumping_ = false;
            setGraphics(IMAGE_DIVE);
        }
    }else if(input == RELEASE_DOWN){
        if(isDucking_){
            isDucking_ = false;
            setGraphics(IMAGE_STAND);
        }
    }
}

 

이번 코드에도 버그가 있다.

공중 점프를 막기 위해서 점프 중인지는 검사하지만, 내려찍기 중인지는 검사하지 않는다.

또 flag 변수를... 넣어야 한다.

 


 

지금 몇번째 버그가 나오며 몇번째 플래그 변수를 넣는 것인가?

결국 사람들은 이에 대해 해결법을 내놓았다.

우선 캐릭터의 동작에 대한 flow chart를 그려본다거나

해서 우리가 최종적으로 수행해야할 것이 무엇인지 알아보자

 

이 차트가 바로 FSM이다. 

FSM의 중요한 점 몇가지를 알아보자

1. 가질 수 있는 "상태, State"는 한정되어있다. (위 그림에선 4가지)

2. 한 번에 한가지 "상태"만 가능하다.

3. "입력, 이벤트"가 기계에 전달된다. (점프버튼 같은)

4. 각 상태에서는 입력에 따라 "상태"가 바뀌는 "전이"가 있다.

 

응?? 그렇다면

FSM은 상태, 입력, 전이로 구현되는데 상태 패턴도 그냥 그렇게 하면 되는건가?

알아보자


 

Heroin 클래스의 문제점 하나는 bool 값 조합이 유효하지 않을 수 있다는 점이다.

다시 말해서 isJumping과 isDucking이 동시에 참을 가질 수는 없다.

**여기서 여러 플래그 변수 중에서 하나만 참일 때가 있다면 열거형, enum이 필요하다는 신호다.

 

그래서 FSM 을 열거형으로 구현할 수 있다.

enum State{
    STATE_STANDING,
    STATE_JUMPING,
    STATE_DUCKING,
    STATE_DIVING
};

 

이제 앞선 Heroine 클래스에서 어떻게 작동시킬까 생각해보자

플래그 변수는 필요없고 State state_와 같이 state_ 필드만 있으면 된다.

void Heroine::handleInput(Input input){
    switch(state_){
        case STATE_STANDING:
            if(input == PRESS_B){
                state_ = STATE_JUMPING;
                yVelocity_ = JUMP_VELOCITY;
                setGraphics(IMAGE_JUMP);
            }
            break;
            
        case STATE_JUMPING:
            if(input == PRESS_DOWN){
                state_ = STATE_DIVING;
                setGraphics(IMAGE_DIVE);
            }
            break;
        
        case STATE_DUCKING:
            if(input == RELEASE_DOWN){
                state_= STATE_STANDING;
                setGraphics(IMAGE_STAND);
            }
        break;
    }
}

 

우선 가독성이 높아졌다.

플래그 변수도 줄었다.

하나의 상태에 대한 코드는 깔끔하게 정리되어 한 구역에 들어가있다.

 

물론 열거형만으로는 부족할 수 있다.

엎드려 있으면 기가 모여서 놓는 순간 특수공격이 나간다고 해보자

void Heroine::update(){
    if(state_ == STATE_DUCKING){
        chargeTime_++;
        if(chargeTime_ > MAX_CHARGE){
            superBomb();
        }
    }
}

 

엎드릴 때마다 시간을 초기화해준다.

...
case STATE_STANDING:
    if(input == PRESS_DOWN;{
        state_ = STATE_DUCKING;
        chargeTime_ = 0;
        setGraphics(IMAGE_DUCK);
    }
    ...
    break;
    
    }
}

 

기모으기 공격을 추가하기 위해

함수 두 개를 수정하고 엎드리기 상태에서만 의미 있는chargeTime_ 필드를 Heroine에 추가했다.

하지만 그냥 상태에 따른 필요한 코드와 데이터를 한 곳에 모아두고 싶은 마음이 든다.

위의 상황에 상태 패턴을 쓸 수 있을까?

알아보자

 


 

상태 인터페이스부터 정의하자

상태에 의존하는 모든 코드, 즉 다중 선택문에 있던 동작을 인터페이스의 가상메서드로 만든다.

여기서는 handleInput()과 update()가 해당된다.

 

class HeroineState
{
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine, Input input) {}
  virtual void update(Heroine& heroine) {}
};

 

그 후에는

상태별로 클래스를 만든다.

메서드에는 정해진 상태가 되었을 때

플레이어가 어떤 행동을 할 지 정의하고 다중 선택문에 있던 case 별로 클래스를 만들면 된다.

 

class DuckingState : public HeroineState
{
public:
  DuckingState()
  : chargeTime_(0)
  {}

  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == RELEASE_DOWN)
    {
      //해당 input을 받아서 상태를 바꾼다.(여기서는 일어섬)
      heroine.setGraphics(IMAGE_STAND);
    }
  }

  virtual void update(Heroine& heroine) {
  
    //여기서는 충전 공격
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      heroine.superBomb();
    }
  }

private:
  int chargeTime_;
};

 

chargeTime_ 변수를 Heroine에서 DuckingState 클래스로 옮김으로써

필요한 곳에 필요한 것만 들어가있다.

 

동작을 이제는 상태에 위임하면 된다.

Heroin 클래스에서 자신의 현재 상태 객체 포인터를 추가해서

상태마다 다중 선택문에 추가하는 대신

상태 객체에 위임하는 것이다.

class Heroine
{
public:
  virtual void handleInput(Input input)
  {
    //해당 상태 객체에 대해서 input을 받고 그에 따른 동작 수행
    state_->handleInput(*this, input);
  }

  virtual void update()
  { 
   //해당 상태 객체 포인터를 이용해 해당 클래스에서 정의된 동작 수행
    state_->update(*this);
  }

  // Other methods...
private:
  HeroineState* state_;
};

 

상태를 바꾸기 위해서는 state_ 포인터에 HeroinState를 상속받는 다른 객체를 할당하기만 하면 된다.

 


그렇다면 상태를 바꾸기 위해서는 state_에 새로운 상태 객체를 할당해야 한다는 것인데..

이 객체는 어디서 생성한 것일까?

열거형을 숫자처럼 기본 자료형이기 떄문에 신경쓰지 않아도 좋지만

상태 패턴은 클래스를 쓰기 때문에 포인터에 저장할 실제 인스턴스가 필요하다.

 

이를 해결할 2가지 방법이 있다.


1. 정적객체

상태 객체에 필드가 따로 없다면 가상 메서드 호출에 필요한 vtable 포인터만 있는 셈이다.

이럴 경우 모든 인스턴스가 같기 때문에 인스턴스는 하나만 있으면 된다.

 

이제 정적 인스턴스 하나만 만들면 된다.

여러 FSM이 동시에 돌더라도 상태 기계는 같으므로 인스턴스 하나를 같이 사용한다.

 

**상태 클래스에 필드가 없고 가상 메서드도 하나밖에 없다면 상태 클래스를 정적 함수로 바꿀 수도 있다.

그리고 state_필드를 함수 포인터로 바꾸면 된다.

 

정적 인스턴스는 원하는 곳에 두면 된다. 굳이 두자면 상위 클래스에 두자

class HeroineState
{

//이렇게 정적으로 선언해놓고 포인터를 가져다 쓰는 것도 낫뱃
public:
  static StandingState standing;
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;

  // Other code...
};

 

각각의 정적 변수가 게임에서 사용하는 상태 인스턴스다.

아래처럼 사용한다.

 

if (input == PRESS_B)
{
  heroine.state_ = &HeroineState::jumping; // heroine의 상태를 갱신한다.
  heroine.setGraphics(IMAGE_JUMP);
}

 

 

2. 상태 객체 만들기

 

정적 객체만으로 부족할 때가 있다.

즉, 엎드리기 상태에서는 chargeTime_필드가 있는데 이 값이 캐릭터마 주어진다면

캐릭터마다 다르게 적용되어야 하니까 정적객체로는 구현할 수 없다.

 

이럴 때는 전이할 때마다 상태 객체를 만들어야 한다.

이러면 FSM이 상태별로 인스턴스를 갖게 된다.

새로 상태를 할당했기 때문에 이전 상태를 해제해야 한다.

상태를 바꾸는 코드가 현재 상태 메서드에 있기 때문에 삭제할 때 this를 스스로 지우지 않도록 해야 한다.

 

이를 위해 handleInput()에서 상태가 바뀔 때에만 새로운 상태를 반환하고

밖에서는 반환 값에 따라 예전 상태를 삭제하고 새로운 상태를 저장하도록 바꿔보자.

 

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
  }
}

 

handleInput 메서드가 새로운 상태를 반환하지 않는다면 현재 상태를 삭제하지 않는다.

서있기 상태에서 엎드리기 상태로 전이하려면 새로운 인스턴스를 생성해 반환해야 한다.

 

HeroineState* StandingState::handleInput(Heroine& heroine,
                                         Input input)
{
  if (input == PRESS_DOWN)
  {
    // Other code...
    return new DuckingState();
  }

  // 현 상태 유지
  return NULL;
}

 

가능하면 정적상태를 쓰는 것이 이득이기는 하나 뭐 말이 그렇다는 거지 항상 환경이 따라주지 않는다.

 


 

상태 패턴의 목표는

"같은 상태에 대한 모든 동작과 데이터를 클래스 하나에 캡슐화하는 것"

 

위의 예제는 상태 패턴의 전부를 보여주긴 부족했다.

주인공은 상태를 변경하면서 주인공의 스프라이트도 같이 바꾼다.

지금까지는 이전 상태에서 스프라이트를 변경했다.

예를 들어 엎드리기에서 서있기로 넘어갈 때는 엎드리기 상태에서 플레이어 이미지를 바꿨다는 말이다.

 

HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    heroine.setGraphics(IMAGE_STAND);
    return new StandingState();
  }

  // Other code...
}

 

이렇게 하는 것보다는 상태에서 스프라이트까지 관리하는 것이 바람직하다.

 

이를 위해 "입장"을 추가했다.

class StandingState : public HeroineState
{
public:
  virtual void enter(Heroine& heroine)
  {
    heroine.setGraphics(IMAGE_STAND);
  }

  // Other code...
};

 

Heroin 클래스에서는 새로운 상태에 들어있는 enter 함수를 호출하도록 상태 변경 코드를 수정했다.

 

void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;

    // Call the enter action on the new state.
    state_->enter(*this);
  }
}

 

이제 엎드리기 코드가 더욱 단순해졌다.

 

HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    return new StandingState();
  }

  // Other code...
}

 

Heroin 클래스에서 서기 상태로 변경하기만 하면

서기 상태에서 스프라이트까지 바꿔준다.

이제야 상태에 맞는 동작이 정확히 상태클래스에 구현되었다고 말할 수 있다.

 

입장 코드를 만들었다면 "퇴장"도 있을까?

있다. 상태가 교체되기 전에 퇴장 메서드를 만들어 활용할 수 있다.

 


 

과연 상태패턴은 이렇게 구현할 만큼 쓸만한가?

 

우선 앞서 말한 다중 선택보다는 쓸만해보인다. 그것뿐일까?

상태 기계는 엄격하게 제한된 구조를 강제함으로써 복잡하게 얽힌 코드를 정리할 수 있게 해준다.

그러나 FSM에서는 미리 정해놓은 여러 상태와 현재 상태 하나처럼 하드 코딩되어 있는 전이만 존재한다.

상태 기계를 인공지능과 같이 더 복잡한 곳에 적용하다보면 한계가 온다. 

이를 해결할 방법이 몇가지 있는데 그것까지만 알아보자

 


 

병행(Concurrent) 상태 기계

 

플레이어가 총을 들 수 있다고 해보자

총을 장착한 후에도 이전에 할 수 있었던 달리기, 점프, 엎드리기 같은 동작을 모두할 수 있어야 한다.

그러면서도 총도 쏠 수 있어야 한다.

FSM 방식을 고수한다면 위와 같은 상태를 구현하려면

서있기, 총들고 서있기 같이 세밀하게 상태를 구분해야 한다.

 

하지만 무기를 추가할 때마다 무엇인가 동시인 상태를 추가할 때마다 나눌 수 없다.

말도 안되는 코드의 양을 견디기 위해 상태를 나눈다.

동작에 대한 상태와 무엇을 가지고 있는가의 상태.

상태가 동시에 2개가 존재하는 것이다.

총을 가지고 있는 상태이면서 서있는 상태

이전의 코드에서 무엇을 들고 있는가에 대한 상태 기계를 따로 정의하면 된다.

Heroin 클래스는 이들 상태들을 "각각"참조함으로써 작동한다.

class Heroine
{
  // Other code...

private:
  HeroineState* state_;
  HeroineState* equipment_;
};

 

Heroin에서 입력을 상태에 위임할 때에는 입력을 각각의 상태 기계에 전달한다.

void Heroine::handleInput(Input input)
{
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}

 

각각의 상태 기계는 입력에 따라 동작을 실행하고 독립적으로 상태를 변경할 수 있다.

두 상태에 대한 서로 간섭이 없다면 잘 동작이 가능하다.

 

현실적으로는 점프 도중에는 총을 못 쏜다든가 상호작용할 수도 있다. 이를 위해 어떤 상태 코드에서는 다른 상태 기계의 상태를 참조해서 무엇인지 알아내는 코드를 사용해야 할 수 있다.

 


 

계층형(Hierarchical) 상태 기계

 

 

플레이어의 동작에 대해서 작성하다보면

비슷한 상태가 많이 생기기도 하는데 단순한 상태 기계에서는 이런 코드를 모든 상태마다 중복해 넣어야 한다. 그보다는 한 번만 구현하고 다른 상태에서 재사용하는 것이 나을지도 모른다.

상태기계가 아니라 객체지향 코드라고 생각해보면 상속으로 여러 상태가 코드를 공유할 수 있다.

점프와 엎드리기는 "땅 위에 있는 상태" 클래스를 정의해서 처리할 수 있고

서있기, 걷기, 달리기, 미끄러지기는 "땅 위에 있는 상태"클래스를 상속받아서 고유 동작을 수행하면 된다.

 

이런 구조를 계층형 상태 기계라고 하는데 어떤 상태는 상위 상태를 가지고 그 경우 그 상태 자신은 하위 상태가 된다.

다시 말해서 이벤트가 발생했을 때 하위 상태에서 처리하지 않으면 상위 상태에서 처리하는 것이다.

쉽게 보자면 상속받은 메서드를 오버라이드하는 것과 같다.

 

예제의 FSM을 상태 패턴으로 만든다면 클래스 상속으로 계층을 구현할 수 있다.

상위 클래스부터 구현해보자

class OnGroundState : public HeroineState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == PRESS_B)
    {
      // Jump...
    }
    else if (input == PRESS_DOWN)
    {
      // Duck...
    }
  }
};

그 다음은 하위 상태다

class DuckingState : public OnGroundState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == RELEASE_DOWN)
    {
      // Stand up...
    }
    else
    {
      // Didn't handle input, so walk up hierarchy.
      OnGroundState::handleInput(heroine, input);
    }
  }
};

 

꼭 이렇게 구현하는 것은 아니고 이렇게 구현할 수도 있다는 것이다.

상태를 하나만 두지 않고 상태 스택을 만들어 명시적으로 현재 상태의 상위 상태 연쇄를 모델링도 가능하다.

현재 상태가 스택 최상위에 있고 밑에는 바로 상위 상태가 있으며 그 상위 상태 밑에는 그 상위 상태의 상위 상태가 있는 식이다.

다시 말해서 상태 관련 동작이 들어오면 어느 상태든 동작을 처리할 때까지 스택 위에서부터 밑으로 전달한다.

 

 

 


 

이렇게 상태 패턴을 구현해봤다.

하지만 요즘 게임 AI는 FSM 보다는 행동 트리나 계획 시스템을 더 많이 쓰는 추세다.

그렇다면 FSM은 어디에 쓰게 될까?

 

1. 내부 상태에 따라 객체 동작이 바뀔 때 (앞서 말한 플레이어 동작)

2. 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때

3. 객체가 입력이나 이벤트에 따라 반응할 때

 

게임에서는 FSM이 AI에서 사용되는 것으로 알려져 있지만 

FSM의 메커니즘은 입력 처리, 메뉴 화면 전환, 문자 해석, 네트워크 프로토콜, 비동기 동작을 구현하는데에서도 많이 사용된다.

반응형
그리드형