[게임 프로그래밍 패턴] Chapter7 상태

Jangmanbo·2023년 9월 21일
0

FSM (유한 상태 기계)

  • 가질 수 있는 '상태'가 한정된다.
  • 한 번에 '한 가지' 상태만 될 수 있다.
  • '입력'이나 '이벤트'가 기계에 전달된다.
  • 각 상태에는 입력에 따라 다음 상태로 바뀌는 '전이'가 있다.

걷기, 점프, 엎드리기 등의 상태가 있고 어떤 이벤트에 따라 상태가 바뀔 때 isJumping과 같은 플래그 변수를 무한히 늘리지 말고 유한 상태 기계를 이용하자.

열거형과 다중 선택문

isJumpingisDucking은 동시에 참이 될 수 없다. 이렇게 플래그 변수 값 조합이 유효하지 않을 수 있다면, enum을 만들 때이다.

enum State {
	STATE_STANDING,
	STATE_JUMPING,
	STATE_DUCKING,
	STATE_DIVING
}

이제 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;
        }
    }
  1. 업데이트할 상태 변수가 하나로 줄었다.
  2. 하나의 상태를 관리하는 코드를 깔끔하게 한 곳에 모았다.

그러나 엎드려 있으면 기를 모아서 놓는 순간 특수 공격을 쏘는 기능을 추가한다면, 엎드려서 기를 모으는 시간을 기록해야 한다.
이를 위해 매 프레임 호출되는 update()handleInput()을 수정하면 되겠지만, 관련된 모든 코드와 데이터는 한 곳에 모아두는 게 낫다. 이럴 때 상태 패턴을 사용하자.

상태 패턴

1. 상태 인터페이스

다중 선택문에 있던 동작을 상태 인터페이스의 가상 메서드로 만든다.

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

2. 상태별 클래스 만들기

상태별로 인터페이스를 구현하는 클래스를 정의한다.

class DuckingState : public HeroineState {
public:
    DuckingState() : chargeTime_(0) {}
    
    virtual void handleInput(Heroine& heroine, Input input) {
    	// ...
    }
    virtual void update(Heroine& heroine) {
    	// ...
    }
    
private:
	int chargeTime_;
};

chargeTime_은 엎드리기 상태에서만 의미가 있었다. 이 변수를 Heroin에서 DuckingState 클래스로 옮길 수 있다.

3. 동작을 상태에 위임하기

Heroin 클래스에 자신의 현재 상태 객체 포인터를 추가해, 거대한 다중 선택문을 제거하고 대신 상태 객체에 위임한다.

class Heroine {
public:
    virtual void handleInput(Heroine& heroine, Input input) {
    	state_->handleInput(*this, input);
    }
    virtual void update(Heroine& heroine) {
    	state_->update(*this);
    }
    
private:
	HeroineState* state_;
};

상태를 바꾸기 위해서는 state_HeroineState을 상속받는 다를 객체를 할당하기만 하면 된다.

상태 객체는 어디에?

정적 객체

상태 객체에 필드가 따로 없다면, vtable 포인터만 있다는 것이다. 이럴 경우 모든 인스턴스가 동일한 상태 객체를 공유해도 되므로 인스턴스는 하나만 있어도 된다.

class HeroineState {
public:
	static StadingState standing;
	static Ducking ducking;
	static Jumping jumping;
	static DivingState diving;
    
    // ...
};

if (input == PRESS_B) {
	heroine.state_ = &HeroineState::jumping;
    heroine.setGraphics(IMAGE_JUMP);
}

상태 객체

엎드리기 상태에서 사용하는 chargeTime_ 필드는 주인공마다 다르다. 이런 경우 하나의 인스턴스를 공유하면 안되기 때문에 정적 객체로 만들 수 없다.

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

  1. handleInput()에서 상태가 바뀔 때만 새로운 상태 반환
  2. 밖에서는 반환 값에 따라 예전 상태를 삭제하고 새로운 상태를 저장
// 1. 상태가 바뀔 때만 새로운 상태 반환
void Heroine::handleInput(Input input) {
	HeroineState* state = state_->handleInput(*this, input);
    if )state != NULL) {
    	delete state_;
        state_ = state;
    }
}

// 2. 밖에서는 반환 값에 따라 예전 상태를 삭제하고 새로운 상태를 저장
HeroineState* StandingState::handleInput(Heroine& heroine, Input input) {
	if (input == PRESS_DOWN) {
    	// ...
        return new DeckingState();	// 엎드리기로 전이
    }
    
    return NULL;	// 지금 상태 유지
}

그러나 매번 상태 객체를 할당해야 하는 문제가 있다.

입장과 퇴장

상태 패턴의 목표는 같은 상태에 대한 모든 동작과 데이터를 클래스 하나에 캡슐화하는 것이다.

상태를 변경하면서 주인공의 스프라이트도 같이 바꾸는 코드를 지금까지는 이전 상태에서 작성했다. (엎드리기->서기라면 엎드리기 상태에서 변경)
이 방법보다는 상태에서 그래픽까지 제어하는 게 바람직하다. 이를 위해 입장 기능을 추가한다.

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

이제 enter()Heroing::handleInput()에서 불러주기만 하면 된다.

이전까지는 점프->착지, 내려찍기->착지 등 착지 상태에 여러 전이가 들어올 수 있기 때문에 전이가 일어나는 모든 곳에 중복 코드를 넣게 되지만, 이렇게 입장/퇴장 기능을 분리하면 입장/퇴장 기능에 모아둘 수 있다.

FSM의 단점

FSM은 업격하게 제한된 구조를 강제하기 때문에, 인공지능 같이 복잡한 곳에 적용하기엔 어렵다. 이를 해결할 수 있는 방법들 소개한다.



병행 상태 기계

주인공은 총을 든 상태에서 달리기/점프/엎드리기 동작을 모두 할 수 있으면서 총도 쏠 수 있다.
FSM을 고수함다면, 모든 상태(달리기/점프/엎드리기)를 무장/비무장에 맞춰 두 개씩 만들어야 한다. (여기에 무기의 종류가 총 만이 아니라면...)

해결법

1. 상태 기계를 둘로 나누어서 기존에 만든 무엇을 하는가에 대한 상태 기계는 그대로 두고, 무엇을 들고 있는가에 대한 상태 기계를 따로 정의한다.

class Heroine {
	// ...
    
private:
	HeroineState* state_;
    HeroineState* equipment_;
};

2. Heroine에서 입력을 상태에 위임할 때 입력을 두 상태 기계에 다 전달한다.

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

이제 각각의 상태 기계는 독립적으로 상태를 변경할 수 있다.
만야 점프 도중에는 총을 쏘지 못하는 등 복수의 상태 기계가 상호작용하는 상황이 있다면, 어떤 상태 코드에서 다른 상태 기계의 상태가 무엇인지 검사하는 지저분한 코드로 해결할 수 있다...


계층형 상태 기계

서기, 걷기, 달리기 등 비슷한 상태들에서는 모두 B 버튼을 누르면 점프하고 싶다.

단순 FSM에서는 이 코드를 모든 비슷한 상태들에 중복해서 넣어야 하지만,
'땅 위에 있는' 상태 클래스를 정의해 서기, 걷기, 달리기가 이를 상속받는다면 한 번으로 해결 가능하다.

이러한 구조를 계층형 상태 기계라 한다. 이때 '땅 위에 있는' 상태는 상위 상태 이고 서기, 걷기, 달리기는 하위 상태다.
하위 상태가 handleInput을 오버라이드하여 어떤 입력을 하위 상태가 처리하지 않는다면, 상위 상태가 처리한다.


푸시다운 오토마타

FSM에는 이력 개념이 없다. (직전 상태가 무엇인지 알 수 없어, 직전 상태로 돌아갈 수 없다.)
이는 상태를 FSM처럼 포인터로 관리하지 않고, 스택으로 관리하여 해결하는 방법이 푸시다운 오토마타이다.

  • 새로운 상태를 스택에 넣는다.
  • 최상위 상태를 스택에서 뺀다.
    • 최상위 상태 바로 밑에 있던 상태가 새롭게 현재 상태가 된다.


FSM을 사용하기 좋은 경우

  • 내부 상태에 따라 객체 동작이 바뀔 때
  • 이런 상태가 그다지 많지 않은 선택지로 분명하게 구분될 수 있을 때
  • 객체가 입력이나 이벤트에 따라 반응할 때

0개의 댓글