State Pattern

Game, Tool Program 에서 주로 사용하는 상태 패턴들에 대해 알아보자

FSM(Finite State Machine)

"어떤 상태에서 할 수 있는 행동은 제한적이다" 라는 이론을 추상화한 패턴이다. 게임에서는 각 Player 에게 행동을 제한을 거는 방법으로 많이 사용한다.

예를 들어, Player 가 점프를 했다고 하자. "Megaman X" 게임의 경우, 점프 상태에서만 "벽 차기" 액션을 발동 할 수 있다. 그리고, 점프 중에 특정한 애니메이션으로 "버스터 샷" 액션을 취할 수도 있다. 그러면 점프 중인 상황을 하나의 상태로 보고, 그 상태에서만 벽을 차거나, 버스터 샷을 쏘는 행동을 할 수 있게 해야 할 것이다.

아래는 기본적인 FSM 을 효율적으로 구현했을 때의 예제 코드이다.

// Player State base-class
public abstract class PlayerState {
	abstract void HandleInput(Player player, KeyCode key);
	abstract void Update(Player player);
	
	// 정적 객체로 상태를 정의해두면 매번 인스턴스 생성으로 소모되는 CPU 자원 낭비를 줄일 수 있다.
	public static GroundState ground = new GroundState();
	public static RunningState running = new RunningState();
}

public class GroundState : PlayerState {
	override void HandleInput(Player player, KeyCode key) {
		player.command.execute(key);
		return PlayerState::IdleState;
	}
	
	override void Update(Player player) {
		player.animate.idle();
	}
}

// Usage
public class Player : Actor {
	private PlayerState state;
	
	void Update() {
		state.Update(this);
	}
	
	void OnInputHandle() {
		PlayerState newState = state.HandleInput(keyCode);
		
		// 만약 새로운 상태가 반환되면 상태를 교체하고 상태 진입 메서드를 호출해서
		// 애니메이션 변경 등 초기화 동작을 포함할 수 있다.
		if (newState != null) {
			delete state;
			state = newState;
			state.enter();
		}
	}
}

위와 같이, 상태 패턴은 기본적으로 어떤 상태라도 대응되는 동일한 인터페이스를 구현한다. 동일한 인터페이스로 구현되었다는 것은, 사용자 클래스에서는 기본 상태 클래스의 추상 인터페이스만 가지고 변화하는 상태 인스턴스에 대해 다운캐스팅(Down-casting)을 통해 런타임에 메서드를 호출함으로써 효율적으로 구현을 갈아끼울 수 있다.

다른 종류의 상태 패턴에 대해서도 몇가지 알아보자. 단, 이하부터는 특정한 요구사항에 따라 사용하는 영역이기 때문에 게임에서 더 많이 사용할 수도 있다.

HSM(Hierarchical State Machine)

어떤 상태에서 다른 상태를 "중첩" 해야 하는 경우 (ex. 땅 위에 있으면서 총을 쏴야 하는 경우) 계층형 상태 기계 패턴으로 해결할 수 있다. 객체지향 언어에서는 클래스 상속으로 계층형 상태 패턴을 구현할 수도 있다.

public class GroundState : PlayerState {
	override void HandleInput(Player player, KeyCode key) {
		//...
	}
}

public class ShotState : GroundState {
	override void HandleInput(Player player, KeyCode key) {
		if (key == KeyCode.S) {
			player.command = new ShotCommand(player);
		} else {
			// 중첩 상태에서 아무것도 안하면 부모 상태로 처리를 위임한다.
			// 또한, 중첩된 모든 상태를 처리해야 할 경우 처리를 전파할 수도 있다.
			base(player)
		}
	}	
}

패턴은 꽤 단순하다. 중첩할 상태는 선행되어야 하는 상태를 상속받아서 부모 클래스의 상태를 계승한다는 특징을 이용해서 슈퍼 클래스 호출(base, super 등)을 사용해서 쉽게 상태를 전파하거나 건너뛸 수 있다.

하지만 이 구현은 객체지향 언어는 단일 상속만 지원하는 경우가 있어 한계가 있다. 이런 경우 Push-down Automata Pattern 으로 해결할 수 있다.

Push-down Automata

상태를 중첩할 수 있으면서, 언어의 제약을 받지 않도록 구현이 가능한 상태 패턴이다. Stack 자료구조의 특징을 이용해서 구현할 수 있다.

public class Player : Actor {
	Stack<PlayerState> stateStack = new Stack<PlayerState>();
	
	public void PushState(PlayerState playerState) {
		if (playerState.activePrevStates.include(stateStack.top())) {
			stateStack.push(playerState);
		}
	}
	
	public void PopState() {
		stateStack.pop();
	}
	
	void Update() {
		PlayerState currentState = stateStack.top();
		currentState.Update();
	}
	
	void OnInputHandle() {
		PlayerState currentState = stateStack.top();
		currentState.handleInput(key);
	}
}

Stack 은 LIFO 특징을 가지기 때문에, 제일 위에 있는 상태는 top() 으로 호출한 객체이다. 단일 상태 처리를 수행할 경우 가장 위에 있는 상태만 확인해서 처리하면 되고, 처리에 대해 전파가 필요할 경우 ElementAt 같은 함수를 사용해서 Stack 을 탐색해서 이전 상태의 공통 인터페이스를 호출하면 된다. 무엇보다 상속을 사용하지 않고 공통적으로 가지고 있는 자료구조를 사용하기 때문에 구현이 쉽고 유연하다는 것이 특징이다.

Last updated