상태 (State)
객체의 내부 상태에 따라 스스로 행동을 변경할 수 있게 허가하는 패턴으로, 이렇게 하면 객체는 마치 자신의 클래스를 바꾸는 것처럼 보입니다.
예제
플랫포머 게임에서 점프를 구현한다고 해보자.
void Heroine::handlerInput(Input input)
{
if (input == PRESS_B) {
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
}
구현: B버튼을 누르면 점프를 한다.
문제점: 공중점프를 무한하게 할 수 있다.
void Heroine::handlerInput(Input input)
{
if (input == PRESS_B) {
if (!isJumping_)
isJumping_ = true;
yVelocity_ = JUMP_VELOCITY;
setGraphics(IMAGE_JUMP);
}
}
}
개선: 플래그 변수를 추가해서 점프 상태를 판별하여 공중점프를 막는다.
이번에는 캐릭터가 땅에 있을 때 아래 버튼을 누르면 엎드리고 버튼을 떼면 일어나는것을 구현한다고 해보자.
void Heroine::handlerInput(Input input)
{
if (input == PRESS_B) {
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) {
setGraphics(IMAGE_STAND);
}
}
구현: 점프 상태가 아닐 때, 아래키를 누르면 엎드리고 키를 떼면 일어선다.
문제점: 엎드린 상태에서 점프한 뒤, 공중에서 아래 버튼을 떼면 땅에 서있는 모습으로 보인다.
void Heroine::handlerInput(Input input)
{
if (input == PRESS_B) {
if (!isJumping_ && !isDucking_) {
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) {
isDucking_ = false;
setGraphics(IMAGE_STAND);
}
}
개선: 플래그 변수를 또 추가해서 상태를 판별하여 막는다.
코드가 하나씩 추가될때마다 구조가 크게 망가진다. 이런식으로 구현하다가는 끝이 없다.
유한 상태 기계(Finite State Machine)
캐릭터가 할 수 있는 동작과 조건을 플로 차트로 그리면 위와 같다. 이것이 유한 상태 기계이다.
유한 상태 기계의 요점은 다음과 같다.
◾ 가질 수 있는 '상태'가 한정된다.
◾ 한 번에 '한 가지' 상태만 될 수 있다.
◾ '입력'이나 '이벤트'가 기계에 전달된다.
◾ 각 상태에는 입력에 따라 다음 상태로 바뀌는 '전이'가 있다.
FSM의 상태는 주로 열거형으로 정의되고 다중 조건(switch-case)문으로 조건을 검사하여 상태를 변경한다.
enum State {
STATE_STANDING,
STATE_JUMPING,
STATE_DUCKING,
STATE_DIVING
};
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);
}
...
}
};
계속 추가되는 플래그 변수 대신 상태를 나타내는 열거형 하나로 줄어들어서 코드 가독성이 조금 더 좋아진다.
만약 엎드린 상태라면 기를 모으고 특수공격을 할 수 있다고 해보자.
void Heroine::update()
{
if (state_ == STATE_DUCKING) {
chargeTime_++;
if (chargeTime_ > MAX_CHARGE) {
superBomb();
}
}
}
이 때는 열거형 뿐만 아니라 다른 변수를 추가로 사용하면 된다.
상태 패턴
◾ 상태 인터페이스
class HeroineState {
public:
virtual ~heroineState() {}
virtual void handleInput(Heroine& heroine, Input input) {}
virtual void update(Heroine& heroine) {}
};
상태에 의존하는 모든 동작을 인터페이스의 가상 메서드로 만든다.
◾ 상태별 클래스 만들기
class DuckingState : public HeroineState {
public:
DuckingState() : chargeTime_(0) {}
virtual void handleInput(Heroint& heroint, 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_;
};
switch-case문에서 case별로 존재하던 상태를 별도의 클래스로 만든다.
◾ 동작을 상태에 위임하기
class Heroine {
public:
virtual void handleInput(Input input) { state_->handleInput(*this, input); }
virtual void update() { state_->update(*this); }
...
private:
HeroineState* state_;
};
선택문을 모두 제거하고 자신의 현재 상태는 HeroineState 객체 포인터에게 위임한다.
상태 객체는 어디에 둬야 할까?
열거형과 다르게 상태 패턴은 클래스를 사용하기 때문에 포인터에 담을 실제 인스턴스가 필요하다.
◾ 정적 객체
상태 객체에 chargeTime_ 같은 필드가 따로 없다면 인스턴스가 모두 동일하기 때문에 정적 인스턴스 하나만 만들어도 충분하다.(=경량 패턴)
만약 필드도 없고 가상 메서드도 하나밖에 없으면 상태 클래스 대신 정적 함수로 바꿔도 무관하다.
◾ 상태 객체 만들기
상태 객체에 필드가 존재한다면 각 캐릭터마다 필드의 값을 다르게 유지해야 하기 때문에 정적 객체만으로는 해결할 수 없다.
이때는 상태를 전이할때마다 새로운 상태 객체를 만들고 기존의 상태 객체는 해제한다.
void Heroine::handleInput(Input input)
{
HeroineState* state = state_->handleInput(*this, input);
if (state != nullptr) {
delete state_;
state_ = state;
}
}
HeroineState* StandingState::handleInput(Heroine& heroine, Input input)
{
if (input == PRESS_DOWN) {
...
return new DuckingState();
}
...
return nullptr;
}
상태 전이가 일어날 때마다 할당과 해제가 이루어지므로 가능하면 정적 상태를 사용하는것이 좋을 것이다.
메모리 단편화가 걱정된다면 차후에 다룰 메모리 풀을 고려해본다.
상태 전이: 입장과 퇴장
HeroineState* DuckingState::handleInput(Heroint& heroint, Input input) {
if (input == RELEASE_DOWN) {
heroine.setGraphics(IMAGE_STAND);
return new StandingState();
}
...
}
상태가 변화할 때, 스프라이트는 새로운 상태가 아닌 이전 상태에서 변경되기 때문에 시기가 올바르지 않는다.
때문에 상태에서 스프라이트까지 제어하는 것이 더 바람직한 흐름 및 구조를 가지게 될 것이다.
class StandingState : public HeroineState {
public:
virtual void enter(Heroine& heroine) { heroine.setGraphics(IMAGE_STAND); }
virtual Heroine* handleInput(Heroine& heroine, Input input) { ... }
...
};
void Heroine::handleInput(Input input)
{
HeroineState* state = state_->handleInput(*this, input);
if (state != nullptr) {
delete state_;
state_ = state;
state_->enter(*this); // 새로운 상태로 전이할 때 enter 호출
}
}
상태가 전이할 때 전이되는 상태 객체에서 스프라이트를 변경시키기 때문에 이전 상태와 상관없는 코드로 작성이 되었다.
만약 상태가 전이되기 전에 퇴장 함수를 호출해서 변경해야 할 사항들이 있다면 객체를 삭제하기 전에 호출하면 된다.
단점
엄격하게 제한된 구조를 강제한다.
미리 정의해둔 여러 개의 상태와 현재 상태 하나, 그리고 하드코딩되어있는 전이만이 존재한다.
하지만 이 구조가 단점이면서 장점이 될 수도 있다.
상태 기계의 단점 극복: 병행 상태 기계
FSM은 오로지 하나의 상태만 가져야 하기 때문에 두 가지 상태가 병행해야 하는 경우라면 두 가지 상태를 병행하는 한가지 새로운 상태를 정의해야 한다.
그런데 이렇게 구현하는 경우에는 행위에 대한 상태가 N개, 소유에 대한 상태가 M개 있다고 했을 때, N*M개의 상태를 정의해야 한다.
상태의 종류가 무수히 많아지는것 뿐만 아니라 중복 코드도 무수히 많아지는 문제가 발생한다.
이럴때는 상태 기계를 둘로 나누면 된다.
class Heroine {
public:
void handleInput(Input input) {
state_->handleInput(*this, input);
equipment_->handleInput(*this, input);
}
private:
HeroineState* state_;
HeroineState* equipment_;
};
개념이 심플한 만큼 사용하는것도 심플하다.
대신 각각의 상태기계가 독립적이고 연관성이 없다는 보장이 있어야만 문제가 생기지 않을것이다.
점프 상태에서는 칼을 휘두를수 있지만, 엎드린 상태에서는 칼을 휘두를수 없다고 한다면 서로의 상태를 검사해야 하는 지저분한 코드가 작성될 여지가 있다.
상태 기계의 단점 극복: 계층형 상태 기계
서기, 걷기, 달리기, 미끄러지기 이런 행동에서는 공통적으로 점프나 엎드리기 같은 상태로 전이하는 공통된 코드가 작성되기 마련이다.
공통되는 코드를 묶어서 상위 상태로 만들고, 하위 상태는 상속받아서 고유 동작을 추가해서 상위 상태를 호출하면 된다.
class OnGroundState : public HeroineState {
public:
virtual void handleInputHGeroine& heroine, Input input) {
if (input == PRESS_B) { /* 점프 */ }
else if (input == PRESS_DOWN) { /* 엎드리기 */ }
}
};
class DuckingState : public OnGroundState {
public:
virtual void handleInputHGeroine& heroine, Input input) {
if (input == RELEASE_DOWN) { /* 서기 */ }
else {
OnGroundState::handleInput(heroine, input); // 상위 상태 호출
// 필요시 추가 동작 구현
}
}
};
만약 상속을 통한 계층 구조 설계가 불가능하다면 주 클래스에 단일 상태 대신 상태 스택을 만들어서 연쇄적으로 모델링 할 수 있다.
상태 기계의 단점 극복: 푸시다운 오토마타
계층형 상태 기계에서 마지막에 언급한 상태 스택과는 다른 방식으로 상태 스택을 활용하여 FSM을 확장시킨다.
FSM은 현재 상태는 알 수 있지만, 직전 상태가 무엇인지 저장하지 않기 때문에 이력 개념이 없다.
이게 문제가 되는 경우를 예를 들어보자면, 캐릭터가 총을 발사하면 발사 애니메이션 재생과 총알 및 이펙트를 생성하는 상태를 새로 만들어서 전이하게 된다.
이 때, 총을 발사할 수 있는 상태가 한 개라면 문제가 되지 않지만 두 개 이상이면 이력이 저장되어 있지 않아서 사격이 끝나고 어떤 상태로 돌아가는지 알 수 없는 문제가 발생한다.
일반적인 FSM에서는 각각의 새로운 상태를 만들고 사격이 끝났을 때 되돌아갈 상태를 하드코딩 해야한다.
하지만 푸시다운 오토마타는 상태를 한 개의 포인터로 관리하는 것이 아니라 스택으로 관리하기 때문에 사격이 끝나면 현재 상태를 스택에서 제거하고 바로 다음 상태로 전이하면 된다.
계층형 상태 기계에서의 상태 스택은 상태 관련 동작을 최상위 스택부터 전달해서 처리될때까지 하위 단계로 내려보내는 방식이고 푸시다운 오토마타에서의 상태 스택은 이력 저장을 위해 사용한다는 차이가 있다.
상태 기계의 유용성
FSM을 개선한다고 해도 한계가 존재하기 때문에 요즘 AI는 행동 트리 또는 계획 시스템을 더 많이 사용한다.
그렇다고 상태 기계가 쓸모없다는 것은 아니고 간단한 상태에서는 유용하게 사용할 수 있다.
◾ 내부 상태에 따라 객체 동작이 바뀔 때
◾ 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때
◾ 객체가 입력이나 이벤트에 따라 반응할 때
위와 같은 경우에 사용하면 좋다.
'도서 > 게임 프로그래밍 패턴' 카테고리의 다른 글
순서 패턴 : 게임 루프 (0) | 2023.02.22 |
---|---|
순서 패턴 : 이중 버퍼 (0) | 2023.02.22 |
디자인 패턴 다시 보기 : 싱글턴 (0) | 2022.12.14 |
디자인 패턴 다시 보기 : 프로토타입 (0) | 2022.12.14 |
디자인 패턴 다시 보기 : 관찰자 (0) | 2022.12.12 |