Home [게임 프로그래밍 패턴] Design Patterns Revisited: State
Post
Cancel

[게임 프로그래밍 패턴] Design Patterns Revisited: State

State

객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴, 객체는 마치 자신의 클래스를 바꾸는 것처럼 보임.

  • 유한 상태 기계(FSM), 계층형 상태 기계, 푸시다운 오토마타 또한 다룸.

We’ve All Been There

  • 간단한 횡스크롤 플랫포머를 만든다고 가정.
  • 주인공이 사용자 입력에 따라 반응하도록 구현해야한다.
무작정 구현하면 코드는 복잡해진다.
  • B버튼을 누르면 점프하는 코드는 다음과 같다.
1
2
3
4
5
6
7
8
void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    yVelocity_ = JUMP_VELOCITY;
    setGraphics(IMAGE_JUMP);
  }
}
  • 이 코드는 ‘공중 점프’를 허용한다.(계속 공중에 떠 있을 수 있다.)
  • isJumping_ 필드를 추가하면 간단히 고칠 수 있다. (토글할 필요가 있다)
1
2
3
4
5
6
7
8
9
10
11
void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_)
    {
      isJumping_ = true;
      // Jump...
    }
  }
}
  • 주인공이 땅에 있을 때 아래 버튼을 누르면 엎드리고, 버튼을 떼면 다시 일어서는 기능은 다음과 같을 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    // Jump if not jumping...
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    setGraphics(IMAGE_STAND);
  }
}
  • 이 코드의 버그
    • 엎드리기 위해 아래 버튼을 누른 뒤, B버튼을 눌러 엎드린 상태에서 점프하고나서 공중에서 아래버튼을 떼면, 점프 중에 땅에 서 있는 모습으로 보임.
    • 플레그 변수가 더 필요하다.
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
void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // Jump...
    }
  }
  else if (input == PRESS_DOWN)
  {
    if (!isJumping_)
    {
      isDucking_ = true;
      setGraphics(IMAGE_DUCK);
    }
  }
  else if (input == RELEASE_DOWN)
  {
    if (isDucking_)
    {
      isDucking_ = false;
      setGraphics(IMAGE_STAND);
    }
  }
}
  • 점프 중, 아래 버튼을 눌러 내려찍기 공격을 할 수 있게 하는 코드는 다음과 같다.
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
void Heroine::handleInput(Input input)
{
  if (input == PRESS_B)
  {
    if (!isJumping_ && !isDucking_)
    {
      // Jump...
    }
  }
  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_)
    {
      // Stand...
    }
  }
}
  • 이번에도 버그가 생긴다. - 또 플래그 변수를 넣어야한다.
  • 이런 식으로 코드를 건드리면 계속해서 망가진다.

좋은 개발자는 어떤 코드가 버그가 생기기 쉬운지에 대한 감각이 있다. 분기가 복잡하거나 상태가 변경 가능한 코드들은 버그가 쉽게 생긴다.

Finite State Machines to the Rescue

  • 위 에서 다룬 동작들을 플로차트로 그려보면 다음과 같다.

  • 위와 같은 플로차트를 유한 상태 기계(FSM)이라 한다.
  • FSM은 오토마타 이론에서 나왔다.

    오토마타 중 튜링 기계가 유명하다.

  • 핵심은 상태, 입력, 전이

    • 가질 수 있는 ‘상태’가 한정: 서기, 점프, 엎드리기, 내려찍기
    • 한번에 ‘한가지’ 상태만 될 수 있다.: 주인공은 점프와 동시에 서있을 수 없다. 동시에 두가지 상태가 되지 못하도록 막는게 FSM을 쓰는 이유 중 하나.
    • ‘입력’이나 ‘이벤트’가 기계에 전달: 버튼 누르기와 버튼 떼기.
    • 각 상태에는 입력에 따라 다음 상태로 바뀌는 ‘전이’가 있다.: 입력이 들어왔을 때, 현재 상태에 해당하는 전이가 있다면 전이가 가리키는 다음 상태로 변경된다.
  • 서 있는 동안 아래 버튼을 누르면 엎드리기 상태로 전이한다.

  • 현재 상태에서 들어온 입력에 대한 전이가 없을 경우 입력을 무시한다.

Enums and Switches

  • 위에서 다룬 Heroin 클래스의 문제점은, 불리언 변수 값 조합이 유효하지 않을 수 있다는 점이다.

    • 점프와 엎드리기 동시에 참 불가.
  • 여러 플래그 변수 중 하나만 참일 경우 열거형(enum)을 사용하는 것이 좋다.

열거형 사용
1
2
3
4
5
6
7
enum State
{
  STATE_STANDING,
  STATE_JUMPING,
  STATE_DUCKING,
  STATE_DIVING
};
  • 먼저 상태에 따라 분기하게 했다.
    • 이제 플래그 여러개 대신 state_ 필드 하나만 있어도 됨.
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 Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_B)
      {
        state_ = STATE_JUMPING;
        yVelocity_ = JUMP_VELOCITY;
        setGraphics(IMAGE_JUMP);
      }
      else if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        setGraphics(IMAGE_DUCK);
      }
      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;
  }
}
  • 분기 문을 다 없애진 못했지만, 업데이트해야 할 상태변수를 하나로 줄였고, 하나의 상태를 관리하는 코드는 한곳에 있다.

    • 열거형은 상태 기계를 구현하는 가장 간단한 방법이다.
  • 열거형만으로 부족할 수 있다.

  • 이동을 구현하고, 엎드려있으면 기가 모여, 놓는 순간 특수 공격을 쏠 수 있게 만들면, 엎드려서 기를 모으는 시간 또한 기록해야한다.

    • 이와 관련 있는 패턴: 매서드 패턴
chargeTime 추가
  • 이를 위해 Heroin에 chargeTime_필드 추가
1
2
3
4
5
6
7
8
9
10
11
void Heroine::update()
{
  if (state_ == STATE_DUCKING)
  {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      superBomb();
    }
  }
}
  • 엎드릴 때마다 시간을 초기화해야하니 handleInput() 또한 수정해야한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Heroine::handleInput(Input input)
{
  switch (state_)
  {
    case STATE_STANDING:
      if (input == PRESS_DOWN)
      {
        state_ = STATE_DUCKING;
        chargeTime_ = 0;
        setGraphics(IMAGE_DUCK);
      }
      // Handle other inputs...
      break;

      // Other states...
  }
}
  • 기 모으기 공격을 추가하기 위해 함수 두 개를 수정하고 chargeTime_을 추가했다.
  • 이렇게 chargeTime_을 추가하는 것은 좋지 않다.
    • 모든 코드와 데이터를 한곳에 모아둘 수 있는 게 낫다.

The State Pattern

  • 모든 분기문을 동적 디스패치(C++에서는 가상함수)로 바꾸려 하는 것은 과하다.
    • 때로는 if문으로 충분
    • 하지만 위의 예제라면, 객체지향, 상태 패턴을 사용하는게 좋다.

A state interface

  • 다중 선택문에 있던 동작을 인터페이스의 가상 메서드로.
1
2
3
4
5
6
7
8
class HeroineState
{
public:
  virtual ~HeroineState() {}
  virtual void handleInput(Heroine& heroine, Input input) {}
  virtual void update(Heroine& heroine) {}
};

Classes for each state

  • 상태별로 인터페이스를 구현하는 클래스 정의.
  • 메서드: 어떤 행동을 하는지 정의.
  • case 별로 클래스를 만든다.
  • chargeTime_같은 경우 더 분명하게 보여준다.(엎드린 상태에서만 유의미)
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
class DuckingState : public HeroineState
{
public:
  DuckingState()
  : chargeTime_(0)
  {}

  virtual void handleInput(Heroine& heroine, Input input) {
    if (input == RELEASE_DOWN)
    {
      // Change to standing state...
      heroine.setGraphics(IMAGE_STAND);
    }
  }

  virtual void update(Heroine& heroine) {
    chargeTime_++;
    if (chargeTime_ > MAX_CHARGE)
    {
      heroine.superBomb();
    }
  }

private:
  int chargeTime_;
};

Delegate to the state

  • 주인공 클래스에 현재 상태 객체 포인터 추가, 상태 객체에 위임(다중 선택문 제거)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Heroine
{
public:
  virtual void handleInput(Input input)
  {
    state_->handleInput(*this, input);
  }

  virtual void update()
  {
    state_->update(*this);
  }

  // Other methods...
private:
  HeroineState* state_;
};
  • 상태 변경 == state_에 다른 객체 할당

전략패턴, 타입 객체 패턴과 비슷, 하위 객체에 동작을 위임하지만 의도에서 차이.(전략 == 일부 동작으로 부터 디커플링, 타입객체 == 같은 타입 객체 레퍼 공유, 상태 == 동작 변경)

Where Are the State Objects?

  • 상태를 변경: state_에 새로운 상태 객체 할당.
    • 실제 인스턴스가 필요

Static states

  • 상태 객체에 필드가 따로 없으면, 인스턴스는 하나만 있으면됨.
  • 여러 FSM이 동시에 돌더라도 상태 기계는 다 같음.

필드가 없고, 메서드도 하나라면, 정적함수 사용가능. (state_는 함수 포인터)

1
2
3
4
5
6
7
8
9
10
class HeroineState
{
public:
  static StandingState standing;
  static DuckingState ducking;
  static JumpingState jumping;
  static DivingState diving;

  // Other code...
};
1
2
3
4
5
if (input == PRESS_B)
{
  heroine.state_ = &HeroineState::jumping;
  heroine.setGraphics(IMAGE_JUMP);
}

Instantiated states

  • 정적 객체만으로 부족할 때

    • chargeTime_필드가 캐릭마다 다를 경우
  • 전이할 때마다 상태 객체 생성

    • FSM이 상태별로 인스턴스를 가짐.
    • 새로 할당하는 것 == 이전 상태 해제
1
2
3
4
5
6
7
8
9
void Heroine::handleInput(Input input)
{
  HeroineState* state = state_->handleInput(*this, input);
  if (state != NULL)
  {
    delete state_;
    state_ = state;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
HeroineState* StandingState::handleInput(Heroine& heroine,
                                         Input input)
{
  if (input == PRESS_DOWN)
  {
    // Other code...
    return new DuckingState();
  }

  // Stay in this state.
  return NULL;
}
  • 저자는 매번 상태 객체를 할당하기 위해 메모리와 CPU를 낭비하지 않아도 되는 정적 상태를 쓰는 편.
    • 동적할당 메모리 단편화 => 오브젝트 풀 패턴

Enter and Exit Actions

  • 상태 패턴의 목표: 같은 상태에 대한 모든 동작과 데이터를 클래스 하나에 캡슐화하는 것
1
2
3
4
5
6
7
8
9
10
11
HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    heroine.setGraphics(IMAGE_STAND);
    return new StandingState();
  }

  // Other code...
}
  • 입장 기능을 추가하여 그래픽을 제어하도록할 수 있다.
1
2
3
4
5
6
7
8
9
10
class StandingState : public HeroineState
{
public:
  virtual void enter(Heroine& heroine)
  {
    heroine.setGraphics(IMAGE_STAND);
  }

  // Other code...
};
  • 주인공 클래스에서 새로운 상태에 들어 있는 enter함수를 호출하도록 상태 변경 코드를 수정.
1
2
3
4
5
6
7
8
9
10
11
12
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);
  }
}
  • 이제 처음 코드를 다음과 같이 단순화할 수 있다.
1
2
3
4
5
6
7
8
9
10
HeroineState* DuckingState::handleInput(Heroine& heroine,
                                        Input input)
{
  if (input == RELEASE_DOWN)
  {
    return new StandingState();
  }

  // Other code...
}
  • 주인공 클래스에서 상태를 변경하기만 하면 그 상태로 그래픽을 처리할 수 있다.

  • 실제로는, 상태그래프에는 동일한 상태로 여러 번 전환된다.
    • 이 때 이전에는 전이시 모든 곳에 중복코드를 넣지만
    • enter를 통해 그를 최소화한다.
  • 상태가 새로운 상태로 교체되기 직전에 호출되는 퇴장 또한 이렇게 구현 가능하다.

What’s the Catch?

  • FSM의 장점은 동시에 단점

  • 상태 기계는 엄격하게 제한된 구조를 강제함

    • 복잡하고 얽힌 코드 정리
    • FSM에는 미리 정해높은 상태와 현상태만 하드코딩되어 있는 전이만 존재

FSM: 튜링완전하지 않음. 오토마타 이론은 추상 모델을 이용해 더 복잡한 문제를 계산한다.

튜링완전: 시스템이 튜링 기계를 구현할 수 있을 정도로 충분히 강력하다.

Concurrent State Machines

  • 총을 들 수 있게 만든다고 가정.
    • 이 때 구현한 모든 동작이 돌아가게 해야하고 총도 쏠 수 있어야한다.
  • FSM은 이 때 무장, 비무장으로 두 개 만들어야한다.

    • 무기 추가 => 계속 늘어남.
  • 해결: 상태기계를 둘로 나눔.
    • 무엇을 들고 있는가에 대한 상태 기계를 따로 정의.
    • 주인공은 이를 각각 참조.
1
2
3
4
5
6
7
8
class Heroine
{
  // Other code...

private:
  HeroineState* state_;
  HeroineState* equipment_;
};
  • 입력을 상태에 위임할 때 양쪽에 전달
    • 첫 상태에서 입력을 씹어서 다음 상태 기계까지 입력이 가지 않도록 가능
1
2
3
4
5
void Heroine::handleInput(Input input)
{
  state_->handleInput(*this, input);
  equipment_->handleInput(*this, input);
}
  • 각각의 상태가 서로 전혀 연관이 없으면 이 방법은 잘 들어맞는다.
  • 여러 상태기계가 상호작용할 경우 바람직한 방법은 아니지만, 문제 해결가능

Hierarchical State Machines

  • 땅위에 있는 상태 등을 상속받아, 고유 동작 처리
  • 계층형 상태 기계
    • 상속받은 메서드를 오버라이드하는 것과 같음.

      상속: 두 코드가 강하게 커플링

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class OnGroundState : public HeroineState
{
public:
  virtual void handleInput(Heroine& heroine, Input input)
  {
    if (input == PRESS_B)
    {
      // Jump...
    }
    else if (input == PRESS_DOWN)
    {
      // Duck...
    }
  }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
    }
  }
};
  • 메인 클래스에 상태를 하나만 두지 않고 상태 스택을 만들어 명시적으로 현재 상태의 상위 상태 연쇄를 모델링 할 수 있다.
    • 현재 상태가 스택 최상위인 형태.
    • 스택 위에서부터 아래로.

Pushdown Automata

  • 상태 스택을 활용하여 FSM을 확장하는 다른 방법

  • FSM에는 히스토리 개념이 없다.

    • 직전 상태가 무엇인지 몰라 쉽게 돌아갈 수 없음.
  • 어느 상태에서 공격을 했는지?
    • 이전 상태로 되돌아가야함.
  • 공격하기 이전의 상태를 저장하고 불러오는게 좋다.
    • 이 때 푸시다운 오토마타를 이용한다.
  • 푸시다운 오토마타: 상태를 스택으로 관리

    • FSM은 이전 상태를 덮어쓰고 새로운 상태로 전이하는 방식.
  • 부가적인 명령어 두가지
    1. 새로운 상태를 스택에 push: 스택의 최상위 == 현재, 이전 상태는 그 밑에.
    2. 최상위 스택을 pop: 빠진 상태는 제거, 바로 밑 상태가 현재

  • 총소기 상태를 구현할 때.
    • 어떤 상태든 발사버튼 => 총쏘기 스택에 넣
    • 총 쏘기 애니메이션 끝 => 스택에서 뺌
    • 이전 상태가 됨.

So How Useful Are They?

  • FSM의 한계
    • 게임 AI에서는 행동트리(behavior tree)나 계획 시스템(planning system)을 더 많이 쓰는 추세.
  • 사용할만한 곳
    • 내부 상태에 따라 객체 동작이 바뀔 때
    • 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 때
    • 객체가 입력이나 이벤트에 따라 반응할 때
  • 입력 처리, 메뉴 화면 전환, 문자 해석, 네트워크 프로토콜, 비동기 동작을 구현하는데 사용

출처

State

This post is licensed under CC BY 4.0 by the author.

[백준][C++] 11000: 강의실 배정(greedy)

[게임 프로그래밍 패턴] Squencing Patterns: Double Buffer