2024. 6. 12. 14:06ㆍGame Develop/Design Pattern
상태 패턴은 GoF의 세가지 패턴 중 행위 패턴에 속한다.
행위 패턴은 클래스나 객체들이 서로 상호작용하는 방법이나 책임 분배 방법을 정의하는 패턴이다.
상태 패턴의 특징은 다음과 같다.
- 객체 상태에 따라 동일한 동작을 다르게 처리해야 할 때 사용
- 객체 상태를 캡슐화하고 이를 참조하는 방식으로 처리
상태 패턴의 근본 개념인 유한 상태 기계(FSM, finite state machine)와 더 나아가 계층형 상태 기계(HSM, hierarchical state machine)와 푸시다운 오토마타 pushdown automata 까지 알아보자.
보통 상태 기계는 AI나 컴파일러 개발자가 아니라면 익숙한 개념이 아닐 수 있다. 하지만 상태 기계 역시 다른 분야에서도 사용될 수 있다.
간단한 횡스크롤 플랫포머를 만든다고 하자. 캐릭터가 사용자 입력에 따라 반응하도록 구현해야 한다. 스페이스바를 누르면 점프하는 것부터 간단하게 만들어보자.
void Heroine::handleInput(Input input){
if(input == PRESS_SPACEBAR){
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
}
위 코드를 사용하면 캐릭터는 점프를 할 수 있지만 공중 점프를 막는 코드가 없어 캐릭터는 계속해서 공중에 떠있을 수 있다. 따라서 Heroine 클래스에 isJumping_ 불리언 변수를 추가하면 막을 수 있다.
void Heroine::handleInput(Input input){
if(input == PRESS_SPACEBAR){
if(!isJumping_){
isJumping_ = true;
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
}
}
, 버튼을 떼면 다시 일어서는 기능을 추가해보자.
void Heroine::handleInput(Input input){
if(input == PRESS_SPACEBAR){
if(!isJumping_){
isJumping_ = true;
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
} // 점프중이 아니라면 점프
else if(input == PRESS_DOWN){
if(!isJumping_){
setGraphics(IMAGE_DUCK);
}
}
else if(input == RELEASE_DOWN){
setGraphic(IMAGE_STAND);
}
}
위 코드에서 문제는 엎드린 상태에서 점프하고 아래 버튼을 떼면 캐릭터가 공중에서 서있는 모습으로 보인다는 것이다.
플래그 변수를 더 써보자.
void Heroine::handleInput(Input input){
if(input == PRESS_SPACEBAR){
if(!isJumping_){
isJumping_ = true;
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
} // 점프중이 아니라면 점프
else if(input == PRESS_DOWN){
if(!isJumping_){
isDucking_ = true;
setGraphics(IMAGE_DUCK);
}
}
else if(input == RELEASE_DOWN){
if(isDucking_){
isDucking_ = false;
setGraphic(IMAGE_STAND);
}
}
}
이번에는 점프 중에 아래 버튼을 눌러 내려찍기 공격을 할 수 있게 해보자.
void Heroine::handleInput(Input input){
if(input == PRESS_SPACEBAR){
if(!isJumping_){
isJumping_ = true;
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_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_){
isDucking_ = false;
setGraphic(IMAGE_STAND);
}
}
}
이러면 공중 점프를 막기 위해 점프 중인지는 검사하지만, 내려찍기 중인지는 검사하지 않는다.
이러면 또 플래그 변수를 추가해야하지만... 이 짓을 계속 할 수 없기 때문에 우리는 FSM을 사용해볼 것이다.
우선 먼저 플로차트를 그려보자.
위와 같이 특정 행위를 하면 상태가 바뀌는 표를 그렸다. 우리는 방금 유한 상태 기계를 만든것이다.
유한상태 기계의 요점은 이렇다.
- 가질 수 있는 '상태'가 한정된다. (서기, 점프, 엎드리기, 내려찍기)
- 한 번에 '한 가지' 상태만 될 수 있다.(주인공은 점프와 동시에 서 있을 수 없음)
- '입력'이나 '이벤트'가 기계에 전달된다.(버튼 누르기와 버튼 떼기가 해당)
- 각 상태에는 입력에 따라 다음 상태로 바뀌는 '전이(transition)'가 있다.(입력이 들어왔을 현재 상태에 해당하는 전이가 있다면 전이가 가리키는 다음 상태로 변경한다. 즉, 서기 상태에서 스페이스 바 버튼을 누르면 점프 상태로 '전이'됨)
순수하게 형식만 놓고 보면 상태, 입력, 전이가 FSM의 전부다. 상태 패턴이 FSM을 구현하는 한 가지 방법이지만, 간단한 방법부터 알아보자.
일단 Heroine 클래스의 문제점 하나는 불리언 변수 값 조합이 유효하지 않을 수 있다는 점이다. 가령 IsJumping_과 IsDucking_은 동시에 참이 될 수 없다. 여러 플래그 변수 중에서 하나만 참일 때가 많다면 열거형(enum)이 필요하다는 신호다.
enum State{
STATE_STANDING,
STATE_JUMPING,
STATE_DUCKING,
STATE_DIVING
};
이제 Heroine에는 플래그 변수 여러개 대신 state_ 필드 하나만 있으면 된다.이전 코드는 입력에 따라 먼저 분기한 뒤 상태에 따라 분기하여 하나의 버튼 입력에 대한 코드는 모아둘 수 있었으나 하나의 상태에 대한 코드는 흩어져 있었다.
상태 관련 코드를 한곳에 모아두기 위해 먼저 상태에 따라 분기해보자.
void Heroine::hadleInput(Input input){
switch(state_){
case STATE_STANDING:
if(input == PRESS_SPACEBAR){
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;
}
}
코드가 한결 나아졌다. 분기문을 다 없애진 못했지만 업데이트해야 할 상태 변수를 하나로 줄였다. 또한 하나의 상태를 관리하는 코드는 깔끔하게 한곳에 모았다. 열거형은 상태 기계를 구현하는 가장 간단한 방법이고, 열거형으로도 충분할 때도 꽤 있다.
하지만 당연히 열거형 만으로 부족할 수도 있다. 이동을 구현했지만 엎드려 있으면 기가 모여서 놓는 순간 특수 공격을 쓸 수 있게 만든다고 해보자. 먼저 엎드려서 기를 모으는 시간을 기록해야 한다.
이를 위해 Heroin에 chargeTime_ 필드를 추가하자.
void heroine::update() {
if(state_ == STATE_DUCKING){
chargeTie_++;
if(chargeTime_ > MAX_CHARGE){
superBomb();
}
}
}
엎드릴 때마다 시간을 초기화해야 하니 handleImput() 을 바꿔보자.
void heroine::update() {
if(state_ == STATE_DUCKING){
case STATE_STANDING:
if(input == PRESS_DOWN){
state_ = STATE_DUCKING;
chargeTime_ = 0;
setGarapics(IMAGE_DUCK);
}
// 다른 입력 처리
break;
// 다른 상태 처리
}
}
위와 같이 추가는 해 주었지만 이것보단 모든 코드와 데이터를 한곳에 모아 놓는 것이 좋다.
이제 상태 패턴을 사용해보자. 간혹 if문 만으로도 충분하지만 위의 경우는 아무래도 상태 패턴을 사용하는 것이 더 낫다.
먼저 상태 인터페이스부터 정의하자. 즉 다중 선택문에 있던 동작을 인터페이스의 가상 메서드로 만든다.
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) {
// 일어선 상태로 바꾼다
heroine.setGraphics(IMAGE_STAND);
}
}
virtual void update(Heroine& heroine){
chargeTim_++;
if(chargeTime_ > MAX_CHARGE){
heroine.superBomb();
}
}
private:
int chargeTime_;
};
chargeTime_ 변수를 Heroine에서 DuckingState 클래스로 옮겼다는 점도 놓치면 안된다.
chargeTime_은 엎드리기 상태에서만 의미 있다는 점을 객체 모델링을 통해 분명히 보여준다는 점에서 훨씬 낫다.
이번에는 Heronie 클래스에 자신의 현재 상태 객체 포인터를추가해, 거대한 다중 선택문은 제거하는 대신 상태 객체에 위임하는 걸 해보자.
class Heroine{
public:
virtual void handleInput(Input input){
state_->handleInput(*this, input);
}
virtual void update() { state_->update(*this); }
// 다른 메서드들
private:
HeroineState* state_;
}
상태를 바꾸기 위해선 state_ 포인터에 HeroineState를 상속받는 다른 객체를 할당하기만 하면 된다. 이게 상태 패턴의 전부다.
상태 패턴은 클래스를 쓰기 때문에 포인터에 저장할 실제 인스턴스가 필요하다.
따라서 객체를 만들 두 가지 방법이 있다.
첫째는 정적 객체를 이용한 방법이 있다. 상태 객체에 필드가 따로 없다면 모든 인스턴스가 같기 때문에 인스턴스는 하나만 있으면 된다.
여러 FSM이 동시에 돌더라도 상태 기계는 다 같으므로 인스턴스 하나를 같이 사용하면 된다.
정적 인스턴스는 원하는 곳에 두면 된다, 딱히 둘 곳이 없다면 상위 클래스에 두는 것이 정신건강에 이롭다.
class heroineState(){
public:
static StandingState standing;
static DuckingState ducking;
static JumpingState jumping;
static divingState diving;
// 다른 코드들
};
각각의 정적 변수가 게임에서 사용하는 상태 인스턴스다. 서 있는 상태에서 점프하게 하려면 이렇게 한다
if (input == PRESS_B){
heroine.state_ = &HeroineState::jumping;
heroine.setGraphics(IMAGE_JUMP);
}
정적 객체만으로 부족할 때도 있다. 만약 협동 플레이 기능을 추가해 It takes two 같이 두 주인공이 한 화면에 보여야 한다면 문제가 된다.
이럴 때는 전이할 대마다 상태 객체를 만들어야 한다. 이러면 FSM이 상태별로 인스턴스를 갖게 된다. 새로 상태를 할당했기 때문에 이전 상태를 해제해야 한다. 상태를 바꾸는 코드가 현재 상태 메서드에 있기 때문에 삭제할 때 this를 스스로 지우지 않도록 주의해야 한다.
void Heroine::handleinput(Input input){
HeroineState* state = steate_->handleInput(*this, input);
if(state != NULL){
delete state_;
state_ = state;
}
}
handleInput 메서드가 새로운 상태를 반환하지 않는다면 현재 상태를 삭제하지 않는다.
HeroineState* StnadingState::handleINput(Heroine& heroine, inPut input){
if(input == PRESS_DOWN){
// 다른 코드들
return new DuckingState();
}
// 지금 상태를 유지
return NULL;
}
이제 거의 다 왔지만 부족한 부분이 있다. 상태 패턴의 목표는 같은 상태에 대한 모든 동작과 데이터를 클래스 하나에 집약하는 것이다. 이러한 부분에서 부족한 면이 있다는 것이다.
지금까지는 주인공의 스프라이트만 변경했지만 상태에서 그래픽까지 제어하는 것이 바람직 하다. 입장기능을 추가해보자.
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);
}
}
이제 엎드리기 코드를 더 단순하게 만들 수 있다.
HeoineState* DuckingState::handleInput(Heroine& heroine, INput input){
if(input == RELEASE_DOWN){
return new StandingState();
}
// 다른 코드들
}
이제 Heroine 클래스에서는 서기 상태로 변경하기만 하면 서기 상태가 알아서 그래픽까지 변경한다.
상태가 새로운 상태로 교체되기 직전에 호출되는 퇴장 코드도 이런 식으로 활용할 수 있다.
FSM의 장점을 알아봤지만, 역시나도 단점이 없을 리 없다. FSM의 장점은 동시에 단점이기도 하기 때문이다.
FSM에는 미리 정해놓은 여러 상태와 현재 상태 하나, 하드코딩 되어 있는 전이만이 존재한다.
상태 기계를 인공지능 같이 더 복잡한 곳에 적용하면 한계에 부딪히게 된다.
따라서 병행 상태 기계가 존재하는데, 주인공이 총을 들 수 있게 만든다고 가정하자. 총을 장착한 후에도 달리기나 일어서기 앉기 등의 동작도 할 수 있어야 한다. 그리고 총도 쏠 수 있어야 한다.
FSM 방식만 사용한다면 모든 상태를 서기, 달리기, 앉기 와 (무장한채로)서기, 달리기, 앉기 와 같이 두 가지 종류로 만들어야 한다.
무기가 추가될 수록 상태 조합이 배수만큼 늘어나게 된다. 이런 문제는 무엇을 하는가와 들고 있는가의 두 상태를 하나의 기계에 욱여넣다 보니 발생한다. 이를 해결하려면 어떻게 해야할까? 답은 간단하다 두개로 나누면 된다.
무엇을 하는가에 대한 상태 기계는 그대로 두고, 무엇을 들고 있는가에 대한 상태 기계를 따로 정의해준다.
이렇게하고 나서 Heroine 클래스는 이들 상태를 각각 참조하기만 하면 된다.
clasxs Heroine{
// 다른 코드들
private:
HeroineState* state_;
HeroineState* equipment_;
};
Heroine에서 입력을 상태에 위임할 대에는 입력을 상태 기계 양쪽에 다 전달한다.
void Heroine::handleInput(Input input){
state_->handleInput(*this, input);
equipment_->handleInput(*this, input);
}
상태 기계는 입력에 따라 동작을 실핸하고 독립적으로 상태를 변경할 수 있다. 두 상태 기계가 서로 전혀 연관이 없다면 이 방법이 잘 들어맞는다. 하지만 연관이 어느정도 되어있다면, 이를 위해 어떤 상태 코드에서 다른 상태 기계의 상태가 무엇인지를 검사하는 지저분한 코드를 만들 일이 생길 수도 있다.
다음은 계층형 상태 기계가 있다. 단순한 상태 기계 구현에서는 여러가지의 상태에서 다른 상태로 전이할 때 모든 상태마다 코드를 중복해서 넣어야 하는 경우가 생긴다. 서기, 걷기, 달리기 중에 모두 스페이스바를 누르면 점프하고 ctrl 버튼을 누르면 엎드려야 한다. 당연히 이보다는 한 번만 구현하고 다른 상태에서 재사용하는게 훨씬 낫다.
점프와 엎드리기는 땅 위에 있는 상태 클래스를 정의해 처리하고 서기, 걷기, 달리기는 땅 위에 있는 상태 클래스를 상속받아 고유 동작을 추가하면 된다.
이런 구조를 계층형 상태 기계라고 한다. 어떤 상태는 상위 상태를 가지고 그 경우에 그 상태 자신은 하위 상태가 된다.
이벤트가 들어올 때 하위 상태에서 처리하지 않으면 상위 상태로 넘어간다. 말하자면 상속받은 메서드를 오버라이드하는 것과 같다.
위의 코드를 예시로 들어보겠다.
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 ahndleInput(Heroine& heroine, Input input){
if(input == REALEASE_DOWN){
// 서기
}
else{
// 따로 입력 처리하지 않고, 상위 상태로 보낸다.
OnGroundState::handleInput(heroine, input);
}
}
}
계층형을 꼭 이렇게 구현해야 하는 건 아니지만 클래스를 사용하는 GoF식 상태 패턴을 쓰지 않는다면 이런 구현이 불가능할 수 있다. 그럴 땐 주 클래스에 상태를 하나만 두지 않고 상태 스택을 만들어 명시적으로 현재 상태의 상위 상태 연쇄를 모델링할 수도 있다.
마지막으로 푸시다운 오토마타라는 방법도 있다. 상태 스택을 활용해 FSM을 확장하는 방법이다. 위의 계층형 FSM에서 봤던 스택과는 상태를 담는 방식도 다르고 해결하려는 문제도 다르다.
기본적으로 FSM에는 history, 즉 이력 개념이 없다는 문제가 있다. 현재 상태는 알 수 있지만 이전의 상태가 무엇이었는지는 모르기 때문에 이전 상태로 돌아갈 수 없다.
예를 들어 총을 쏠 수 있는 모든 상태에서 발사 버튼을 눌렀는데 전이할 FiringState라는 상태를 대충 만들었다면, 주인공이 총을 쏜 뒤에 어느 상태로 돌아가야 하는지를 모른다는 점이다.
일반적인 FSM에선 알 수 없기에 어쩔 수 없이 돌아갈 상태마다 새로운 상태를 하나씩 더 만들어 하드코딩해야한다.
이럴바에는 총 쏘기 전 상태를 저장해 놨다가 다 쏘고나면 다시 그 상태를 불러와 사용하는게 훨씬 좋다.
이를 가능하게 해주는게 푸시다운 오토마타가 있다.
FSM이 한 개의 상태를 포인터로 관리했다면 푸시다운 오토마타에서는 상태를 스택으로 관리한다. FSM은 이전 상태를 덮어쓰고 새로운 상태로 전이하는 방식이었다. 푸시다운 오토마타에서는 이외에도 부가적인 명령이 두 가지 더 있다.
간단하게 말하면 하나는 새로운 상태를 스택에 넣는 것이고 다른 하나는 빼는 것이다. 스택의 최상위 상태가 당연히 현재 상태이기 때문에 새로 추가된 상태가 현재 상태가 된다. 다만 이전 상태는 버리지 않고 방금 들어온 최신 상태 밑에 있게 된다. 이러다가 가장 위에 있는 상태(현재 상태)를 제거하게 되면, 바로 밑에 있던 상태가 새롭게 현재 상태가 되는 것이다.
먼저 총 쏘기 상태를 하나 만든다. 어떤 상태에서든지 간에 발사 버튼을 누르면 총 쏘기 상태를 스택에 넣는다. 총 쏘기 애니메이션이 끝날 대 총 쏘기 상태를 스택에서 빼면, 푸시다운 오토마타가 알아서 이전 상태로 보내준다.
자 마지막으로 FSM을 적절히 잘 활용할 수 있는 환경에 대해 열거하고 마무리하도록 하겠다.
- 내부 상태에 따라 객체 동작이 바뀔 때
- 위의 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때
- 객체가 입력이나 이벤트에 따라 반응할 때
게임에서는 FSM이 AI에서 사용되는 걸로 가장 잘 알려져 있지만, 입력 처리나 메뉴 화면 전환, 문자 해석, 네트워크 프로토콜, 비동기 동작을 구현하는 데에도 많이 사용되고 있다.
'Game Develop > Design Pattern' 카테고리의 다른 글
Game Loop Pattern(게임 루프 패턴) (0) | 2024.06.17 |
---|---|
Double Buffer Pattern(이중 버퍼 패턴) (1) | 2024.06.15 |
Singleton Pattern(싱글턴 패턴) (1) | 2024.06.10 |
Prototype Pattern(프로토타입 패턴) (0) | 2024.06.07 |
Command Pattern(명령 패턴) (0) | 2024.06.06 |