상태 패턴, State Pattern
객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴으로, 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보입니다. (GoF의 디자인 패턴 395p)
겉으로 보기에는 상태 패턴을 다루지만 이를 위해서는 좀 더 근본 개념인 유한 상태 기계(FSM)를 언급한다. 그러다보니 계층형 상태 기계와 푸시다운 오토마타까지 이어졌다.
추억의 게임 만들기
간단한 슈퍼마리오 게임을 만든다고 해보자. 게임 월드의 캐릭터가 사용자 입력에 따라 반응하도록 구현해야 한다. B버튼을 누르면 점프하는 것부터 간단하게 만들어 보자
void Heroine::handleInput(Input input) {
if(input == PRESS_B) {
yVelocity = JUMP_VELOCITY;
setGrapthics(IMAGE_JUMP);
}
}
'공중 점프'를 막는 코드가 없다. 이 버그는 Heroine 클래스에 isJumping_ 불리언 필드를 추가해 점프중인지를 검사해야 한다.
void Heroine::handleInput(Input input) {
if(input == PRESS_B) {
if(!isJumping_) {
isJumping_ = true;
// more...
}
}
}
캐릭터가 땅에 있을 때 아래 버튼을 누르면 엎드리고, 버튼을 때면 다시 일어서는 기능을 추가해보자.
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);
}
}
이번에도 버그가 있다.
- 엎드리기 위해 아래 버튼을 누른 뒤
- B 버튼을 눌러 엎드린 상태에서 점프하고 나서
- 공중에서 아래 버튼을 때면
점프 중인데도 땅에 서 있는 모습으로 보인다. 플래그 변수가 더 필요하다.
void Heroine::handleInput(Input input) {
if(input == PRESS_B) {
if(!isJumping_ && !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);
}
}
}
만약 점프 중에 아래 버튼을 눌러 내려 찍기 공격을 추가한다면 또 더 많은 플래그 변수가 필요하게 된다.
아직 걷기 구현은 하지도 않은 상태인데 분명 이러면 문제가 있다.
FSM이 우리를 구원하리라
플로차트를 그려보자. 캐릭터가 할 수 있는 동작(서 있기, 점프, 엎드리기, 내려찍기)을 각각 네모칸에 적어 넣는다. 어떤 버튼을 눌렀을 때 상태가 바뀐다면 이전 상태에서 다음 상태로 도착하는 화살표를 그린 뒤 눌렀던 버튼을 선에 적는다.
이것이 바로 유한 상태 기계(FSM)이다. FSM은 컴퓨터 과학 분야 중의 하나인 오토마타 이론에서 나왔다.
요점은 이렇다.
- 가질 수 있는 '상태'가 한정된다.
- 한 번에 '한 가지' 상태만 될 수 있다.
- '입력'이나 '이벤트'가 기계에 전달된다.
- 각 상태에는 입력에 따라 다음 상태로 바뀌는 '전이'가 있다.
열겨형과 다중 선택문
Heroin 클래스의 문제점 하나는 불리언 변수 값 조합이 유효하지 않을 수 있다는 점이다. 예를 들면 isJumping과 isDucking은 동시에 참이 될 수 없다. 여러 플래그 변수 중에서 하나만 참일 때가 많다면 열거형(enum)이 필요하다는 신호다.
에제에서는 FSM 상태를 열거형으로 정의할 수 있다.
enum State {
STATE_STANDING,
STATE_JUMPING,
STATE_DUCKING,
STATE_DIVING
};
이제 Heroine에는 플래그 변수 여러 개 대신 state_ 필드 하나만 있으면 된다. 이전에는 입력에 따라 먼저 분기한 뒤에 상태에 따라 분기했다. 따라서 하나의 버튼 입력에 대한 코드는 모아둘 수 있었으나 하나의 상태에 대한 코드는 흩어져 있었다. 상태 관련 코드를 한곳에 모아두기 위해 먼저 상태에 따라 분기하게 하자.
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;
}
}
분기문을 다 없애지는 못했지만 업데이트해야 할 상태 변수를 하나로 줄였고, 하나의 상태를 관리하는 코드는 깔끔하게 한곳에 모았다. 열거형은 상태 기계를 구현하는 가장 간단한 방법이다.
열거형만으로는 부족할 수도 있다. 이동을 구현하되, 엎드려 있으면 기가 모여서 놓는 순간에 특수 공격을 쏠 수 있게 만든다고 해보자. 엎드려서 기를 모으는 시간을 기록해야한다.
void Heroine::update()
{
if (state_ == STATE_DUCKING)
{
chargeTime_++;
if (chargeTime_ > MAX_CHARGE)
{
superBomb();
}
}
}
엎드릴 때마다 시간을 초기화해야 하니 handleInput() 을 바꿔보자.
void Heroine::handleInput(Input input)
{
switch (state_)
{
case STATE_STANDING:
if (input == PRESS_DOWN)
{
state_ = STATE_DUCKING;
chargeTime_ = 0;
setGraphics(IMAGE_DUCK);
}
// Ohters...
break;
// More...
}
}
기 모으기 공격을 추가하기 위해 함수 두 개를 수정하고 엎드리기 상태에서만 의미 있는 chargeTime_ 필드를 Heroine에 추가해야 했다. 이것보다는 모든 코드와 데이터를 한곳에 모아둘 수 있는게 낫다.
상태 패턴
상태 인터페이스
상태 인터페이스부터 정의하자. 상태에 의존하는 모든 코드, 즉 다중 선택문에 있던 동작을 인터페이스의 가상 메서드로 만든다. 예제에서는 handleInput() 과 update() 가 해당된다.
class HeroineState {
public:
virtual ~HeroineState() {}
virtual void handleInput(Heroien& heroine, Input input) {}
virtual void update(Heroien& heroine) {}
};
상태별 클래스 만들기
상태별로 인터페이스를 구현하는 클래스도 정의한다. 메서드에는 정해진 상퇴가 되었을 때 캐릭터가 어떤 행동을 할지를 정의한다. 다중 선택문에 있던 case별로 클래스를 만들어 코드를 옮기면 된다.
class DuckingState : public HeroineState {
public:
DuckingState() : chargeTime_(0) {}
virtual void handleInput(Heroine& heroine, Input input) {
if(input == RELEASE_DOWN) {
// 일어선 상태로 바꾼다...
heroine.setGraphics(IMAGE_STAND);
}
}
virtual void update(Heroine& heroine) {
chargeTime_++;
if(chargeTime_ > MAX_CHARGE)
heroine.superBomb();
}
private:
int chargeTime_;
}
chargeTime_ 변수를 Heroine에서 DuckingState 클래스로 옮겼다는 점도 놓치지 말자.
chargeTime_은 엎드리기 상태에서만 의미 있다는 점을 객체 모델링을 통해서 분명하게 보여준다는 점에서 훨씬 개선되었다.
동작을 상태에 위임하기
이번에는 Heroine 클래스에 자신의 현재 상태 객체 포인터를 추가해, 거대한 다중 선택문은 제거하고 대신 상태 객체에 위임한다.
class Heroine {
public:
virtual void handleInput(Input input) {
state_->handleInput(*this, input);
}
virtual void update() { state_->update(*this); }
// 다른 메서드들...
private:
HeroineState* state_;
};
'상태를 바꾸려면' state_ 포인터에 HeroineState를 상속받는 다를 객체를 할당하기만 하면 된다. 이게 상태 패턴의 전부다.
상태 객체는 어디에 둬야 할까?
앞에서 얼버무리고 넘어갓 것이 있다. 상태를 바꾸려면 state_에 새로운 상태 객체를 할당해야 한다. 그렇다면 이 객체는 어디에서 온 것일까? 열거형은 기본 자료형이기 때문에 신경 쓸 게 없지만 상태 패턴은 클래스를 쓰기 때문에 포인터에 저장할 실제 인스턴스가 필요하다. 두 가지 방법을 알아보자.
정적 객체
상태 객체에 필드가 따로 없다면 가상 메서드 호출에 필요한 vtable 포인터만 있는 셈이다. 이럴 경우 모든 인스턴스가 같기 때문에 인스턴스느 하나만 있으면 된다.
이제 정적 인스턴스 하나만 만들면 된다. 여러 FSM이 동시에 돌더라도 상태 기계는 다 같으므로 인스턴스 하나를 같이 사용하면 된다.
정적 인스턴스는 원하는 곳에 두면 된다. 특별히 다른 곳이 없다면 상위 상태 클래스에 두자.
class HeroineState {
public:
static StandingState standing;
static DuckingState ducking;
static JumpingState jumping;
static DivingState diving;
// More...
};
각각의 정적 변수가 게임에서 사용하는 상태 인스턴스다. 서 있는 상태에서 점프하게 하려면 이렇게 한다.
if(input == PRESS_B) {
heroine.state_ = &HeroineState::jumping;
heroine.setGraphcis(IMAGE_JUMP);
}
상태 객체 만들기
정적 객체만으로 부족할 때도 있다. 엎드리기 상태에는 chargeTime_ 필드가 있는데 이 값이 캐릭터마다 다르다 보니 정적 객체로 만들 수 없다. 캐릭터가 하나라면 어떻게든 되겠지만, 협동 플레이 기능을 추가해 두 캐릭터가 한 화면에 보여야 한다면 문제가 된다.
이럴 때는 전이할 때마다 상태 객체를 만들어야 한다. 이러면 FSM이 상태별로 인스턴스를 갖게 된다. 새로 상태를 할당했기 때문에 이전 상태를 해제해야 한다. 상태를 바꾸는 코드가 형재 상태 메서드에 있기 때문에 삭제할 때 this를 스스로 지우지 않도록 주의해야 한다.
이를 위해 handleInput() 에서 상태가 바뀔 때에만 새로운 상태를 반환하고, 밖에서는 반환값에 따라 예전 상태를 삭제하고 새로운 상태를 저장하도록 바꿔보자
void Heroine::handleInput(Input input) {
HeroineState* state = sate_->handleInput(*this, input);
if(state != NULL) {
delete state_;
state_ = state;
}
}
handleInput 메서드가 새로운 상태를 반환하지 않는다면 현재 상태를 삭제하지 않는다. 서 있기 상태에서 엎드리기 상태로 전이하려면 새로운 인스턴스를 생성해 반환한다.
HeroineState* StandingState::handleInput(
Heroine& heroine, Input input) {
if(input == PRESS_DOWN) {
// 다른 코드들...
return new DuckingState();
}
// 지금 상태를 유지한다.
return NULL;
}
입장과 퇴장
상태 패턴의 목표는 같은 상태에 대한 모든 동작과 데이터를 클래스 하나에 캡슐화하는 것이다.
캐릭터는 상태를 변경하면서 캐릭터의 스트라이트도 같이 바꾼다. 지금까지는 이전 상태에서 스프라이트를 변경했다. 예를 들어 엎드리기에서 서기로 넘어갈 때에는 엎드리기 상태에서 주인공 이미지를 변경했다.
HeroineState* DuckingState::handleInput(
Heroine& heroine, Input input) {
if(input == RELEASE_DOWN) {
heroine.setGraphics(IMAGE_STAND);
return new StandingState();
}
// More...
}
이렇게 하는 것보다는 상태에서 그래픽까지 제어하는 게 바람직하다. 이를 위해 입장 기능을 추가하자.
class StandingState : public HeroineState {
public:
virtual void enter(Heroine& heroine) {
heroine.setGraphics(IMAGE_STAND);
}
// 다른 코드들...
};
Heroine 클래스에서는 새로운 상태에서 들어 있는 enter 함수를 호출하도록 상태 변경 코드를 수정한다.
void Heroine::handleInput(Input input) {
HeroineState* state = state_->handleInput(*this, input);
if(state != NULL) {
delete state_;
state_ = state;
// 새로운 상태의 입장 함수를 호출한다.
state_->enter(*this);
}
}
이제 엎드리기 코드를 더 단순하게 만들 수 있다.
HeroineState* DuckingState::handleInput(
Heroine& heroine, Input input) {
if(input == RELEASE_DOWN) {
return new StandingState();
}
// More...
}
Heroine 클래스에서는 서기 상태로 변경하기만 하면 서기 상태가 알아서 그래픽까지 챙긴다.
상태가 새로운 상태로 교체되기 직전에 호출되는 퇴장 코드도 이런 식으로 활용할 수 있다.
병행 상태 기계
캐릭터가 총을 들 수 있게 만든다고 해보자, 총을 장착한 후에도 이전에 할 수 있었던 달리기, 점프, 엎드리기 같은 동작을 모두 할 수 있어야 한다. 그러면서 동시에 총도 쏠 수 있어야 한다.
FSM 방식을 고수하겠다면 모든 상태를 서기, 무장한 채로 서기, 점프, 무장한 채로 점프 같은식으로 무장, 비무장에 맞춰 두 개씩 만들어야 한다.
무기를 추가할수록 상태 조합이 폭발적으로 늘어난다. 상태가 많아지는 것도 문제지만, 무장 상태와 비무장 상태는 총 쏘기 코드 약간 외에는 거의 같아서 중복이 많아진다는 점이 더 문제다.
두 종류의 상태(무엇을 하는가, 무엇을 들고 있는가)를 항 상태 기계에 넣다보니 생긴 문제다. 해결법은 간단하다. 상태 기계를 둘로 나누면 된다.
class Heroine {
// More...
private:
HeroineState* state_;
HeroineState* equipment_;
};
Heroine에서 입력을 상태에 위임할 때에는 입력을 상태 기계 양쪽에 다 전달한다.
void Heroine::handleInput(Input input) {
state_->handleInput(*this, input);
equipment_->handleInput(*this, input);
}
각각의 상태 기계는 입력에 따라 동작을 실행하고 독립적으로 상태를 변경할 수 있다. 두 상태 기계가 서로 전혀 연관이 없다면 이 방법이 잘 들어맞는다.
계층형 상태 기계
캐릭터 동작에 살을 덧붙이다 보면 서기, 걷기, 달리기, 미끄러지기 같이 비슷한 상태가 많이 생기기 마련이다.
단순한 상태 기계 구현에서는 이런 코드를 모든 상태마다 중복해 넣어야 한다. 그보다는 한번만 구현하고 다른 상태에서 재사용하는 게 낫다.
상태 기계가 아니라 객체지향 코드라고 생각해보면, 상속으로 여러 상태가 코드를 공유할 수 있다. 점프와 엎드리기는 '땅 위에 있는' 상태 클래스를 정의해 처리한다. 서기, 걷기, 달리기, 미끄러지기는 '땅 위에 있는' 상태 클래스를 상속받아 고유 동작을 추가하면 된다.
이럴 구조를 '계층형 상태 기계'라고 한다. 어떤 상태는 '상위 상태'를 가질 수 있고, 그 경우 그 상태 자신은 '하위 상태'가 된다. 이벤트가 들어올 때 하위 상태에서 처리하지 않으면 상위 상태로 넘어간다. 말하자면 상속받은 메서드를 오버라이드하는 것과 같다.
예제 FSM을 상태 패턴으로 만든다면 클래스 상속으로 계층을 구현할 수 있다. 상위 상태용 클래스를 하나 정의하자.
class OnGroundState : public HeroineState {
public:
virtual void handleInput(
Heroine& heroine, Input input) {
if(input == PRESS_B) {
// 점프...
}
else if(input == PRESS_DOWN) {
// 엎드리기...
}
}
};
그다음 각각의 하위 상태가 상위 상태를 상속받는다.
class DuckingState : public OnGroundState {
public:
virtual void handleInput(Heroine& heroine, Input input) {
if(input == RELEASE_DOWN) {
// 서기...
}
else {
// 따로 입력을 처리하지 않고, 상위 상태로 보낸다.
OnGroundState::handleInput(heroine, input);
}
}
};
현재 상태가 스택 최상위에 있고 밑에는 바로 위 상위 상태가 있으며, 그 상위 상태 밑에는 그 상위 상태의 상위 상태가 있는 식이다. 상태 관련 동작이 들어오면 어느 상태든 동작을 처리할 때까지 스택 위에서부터 밑으로 전달한다.(아무도 처리하지 않는다면 무시하면 된다.)
푸시다운 오토마타
상태 스택을 활용하여 FSM을 확장하는 다른 방법도 있다. 계층형 FSM에서 봤던 스택과는 상태를 담는 방식도 다르고 해결하려는 문제도 다르다.
FSM에는 history 개념이 없다는 문제가 있다. 현재 상태는 알 수 있지만 직전 상태가 무엇인지를 따로 저장하지 않기 때문에 이전 상태로 쉽게 돌아갈 수 없다.
예를들어 문제가 되는점은 총을 쏜 뒤에 어느 상태로 돌아가야 하는가 하는 점이다. 서기, 달리기, 점프, 엎드리기 상태에서 총을 쏠 수 있는데 총 쏘는 동작이 끝난 후에는 다시 이전 상태로 돌아가야 한다.
총 쏘기 전 상태를 저장해놨다가 나중에 불러와 써먹는 게 났다. 이럴 때 써먹을 만한 것으로 푸시다운 오토마타가 있다.
FSM이 한 개의 상태를 포인터로 관리했다면 푸시다운 오토마타에서는 상태를 스택으로 관리한다. FSM은 이전 상태를 덮어쓰고 새로운 상태로 전이하는 방식이었다. 푸시다운 오토마타 에서는 이외에도 부가적인 명령이 두 가지 더 있다.
- 새로운 상태를 스택에 넣는다. (push)
- 최상위 상태를 스택에서 뺀다. (pop)
이것은 총 쏘기 상태를 구현할 때 딱 좋다.
More
FSM에는 몇가지 확장판이 나와있지만 FSM만으로는 한계가 있다. 요즘 게임 AI는 행동 트리나 계획 시스템을 더 많이 쓰는 추세다.
FSM은 다음 경우에 사용하면 좋다.
- 내부 상태에 따라 객체 동작이 바뀔 때
- 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때
- 객체가 입력이나 이벤트에 따라 반응할 때
게임에서는 FSM이 AI 말고도 입력 처리나 메뉴 화면 전환, 문자 해석, 네트워크 프로토콜, 비동기 동작을 구현하는 데에도 많이 사용되고 있다.
이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/state.html 입니다.
by 소년코딩
추천은 글쓴이에게 큰 도움이 됩니다.
악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.