본문 바로가기
IT/Game

게임 프로그래밍 패턴 5장 ~ 7장 (22. 05. 17)

by YEON-DU 2022. 6. 19.
반응형

Chapter5. 프로토타입

프로토타입 디자인 패턴

<건틀릿Gauntlet> 같은 핵앤슬래시 게임을 만든다고 해보자.

몬스터들은 영웅을 잡아먹기 위해 떼지어 다닌다. 몬스터는 ‘스포너spawner’를 통해 게임 레벨에 등장하며, 몬스터 종류마다 스포너가 따로 있다.

게임에 나오는 몬스터마다 Ghost, Demon, Sorcerer 같은 클래스를 만들어보자.

class Monster {
    // 기타 등등..
};
class Ghost : public Monster {};
class Demon : public Monster {};
class Sorcerer : public Monster {};

한 가지 스포너는 한 가지 몬스터 인스턴스만 만든다.

게임에 나오는 모든 몬스터를 지원하기 위해 일단 마구잡이로 몬스터 클래스마다 스포너 클래스를 만든다고 치자.

이렇게 하면 스포너 클래스의 상속 구조가 몬스터 클래스 상속 구조를 따라가게 된다.

동일한 클래스 상속 구조

동일한 클래스 상속 구조

class Spawner {
public:
    virtual ~Spawner() {}
    virtual Monster* spawnMonster() = 0;
};

class GhostSpawner : public Spawner {
public:
    virtual Monster* spawnMonster() {
        return new Ghost();
    }
};

clas DemonSpawner : public Spawner {
public:
    virtual Monster* spawnMonster() {
        return new Demon();
    }
};

...

이 코드는 클래스도 많고, 행사 코드(프로그램의 실행과 직접적으로 관계가 없는 프로그래밍 문법적 서식)도 많고, 반복 코드도 많다.

따라서 이런 것을 프로토타입으로 해결할 수 있다.

핵심은 어떤 객체가 자기와 비슷한 객체를 스폰spawn할 수 있다는 점이다.

유령 객체 하나로 다른 유령 객체를 여럿 만들수 있다.

어떤 몬스터 객체든지 자신과 비슷한 몬스터 객체를 만드는 원형prototypical 객체로 사용할 수 있다.

이를 구현하기 위해 상위 클래스인 Monster에 추상 메서드 clone()을 추가한다.

class Monster {
public:
    virtual ~Monster() {}
    virtual Monster* clone() = 0;

    // 그 외..
};

Monster 하위 클래스에서는 자신의 자료형과 상태가 같은 새로운 객체를 반환하도록 clone()을 구현한다.

class Ghost : public Monster {
public:
    Ghost(int health, int speed)
    : health_(health),
        speed_(speed) {
    }
    virtual Monster* clone() {
        return new Ghost(health_, speed_);
    }
private:
    int health_;
    int speed_;
};

Monster를 상속받는 모든 클래스에 clone 메서드가 있다면, 스포너 클래스를 종류별로 만들 필요 없이 하나만 만들면 된다.

class Spawner {
public:
    Spawner(Monster* prototype) : prototype_(prototype) {}
    Monster* spawnerMonster*() {
        return prototype_->clone();
    }
private:
    Monster* prototype_;
};

Spawner 클래스 내부에는 Monster 객체가 숨어있다.

Spawner 클래스는 자기와 같은 Monster 객체를 도장 찍듯 만들어내는 Spawner 역할만 한다.

프로토타입을 품고 있는 스포너

프로토타입을 품고 있는 스포너

유령 스포너를 만들려면 원형으로 사용할 유령 인스턴스를 만든 후에 스포너에 전달한다.

Monster* ghostPrototype = new Ghost(15, 3);
Spawner* ghostSpawner = new Spawner(ghostPrototype);

또한 프로토타입 패턴의 좋은 점은 프로토타입 클래스 뿐만 아니라 상태도 같이 복제clone한다는 점이다. 즉, 원형으로 사용할 유령 객체를 잘 설정하면 빠른 유령, 약한 유령, 느린 유령용 스포너도 쉽게 만들 수 있다.

얼마나 잘 작동하는가?

이제 몬스터마다 스포너 클래스를 만들지 않아도 된다.

그래도 Monster 클래스마다 clone()을 구현해야 하기 때문에 코드 양은 별반 차이가 없다. 또한 clone()을 만들다보면 객체를 깊은 복사deep clone 해야할 지, 얕은 복사shallow clone를 해야할지 애매하다.

프로토타입 패턴을 사용하더라도 코드 양은 많이 줄어들지 않으며, 예제부터도 현실적이지 않다. 즉, 실제로는 잘 사용하지 않는다.

요즘은 개체 종류 별로 클래스를 만들기보다는 컴포넌트나 타입 객체로 모델링하는 것을 선호한다.

스폰 함수

스포너 클래스 대신에 스폰 함수를 사용해보자.

Monster* spawnGhost() {
    return new Ghost();
}

몬스터 종류마다 클래스를 만드는 것보다는 행사코드가 훨씬 적다.

이제 스포너 클래스에는 함수 포인터 하나만 두면 된다.

typedef Monster* (*SpawnCallback)();

class Spawner {
public:
    Spawner(SpawnCallback spawn) : spawn_(spawn) {}
    Monster* spawnMonster() { return spawn_(); }

private:
    SpawnCallback spawn_;
};

유령을 스폰하는 객체는 이렇게 만들 수 있다.

Spawner* ghostSpawner = new Spawner(spawnGhost);

템플릿

스포너 클래스를 이용해 인스턴스를 생성하고 싶지만 특정 몬스터 클래스를 하드코딩하기 싫다면 몬스터 클래스를 템플릿 타입 매개변수로 전달하면 된다. (Spawner 클래스를 따로 두는 것은 생성하는 몬스터 종류에 상관 없이 Monster 포인터만으로 작업하는 코드에서 쓸 수 있기 때문이다.)

class Spawner {
public:
    virtual ~Spawner() {}
    virtual Monster* spawnerMonster() = 0;
};

template <class T>
class SpawnerFor : public Spawner {
public:
    virtual Monster* spawnMonster() { return new T(); }
};

사용법은 아래와 같다.

Spawner* ghostSpawner = new SpawnerFor<Ghost>();

일급 자료형

C++에서는 자료형이 일급 자료형이 아니기 때문에 위와 같은 방법들을 사용해야 한다. 그러나 동적 자료형 언어에서는 이 문제를 훨씬 직접적으로 풀 수 있다. 스포너를 만들기 위해서는 원하는 몬스터 클래스를 실제 런타임 객체를 그냥 전달하면 된다.

이외에 OOP가 데이터와 코드를 묶어주는 ‘객체’를 직접 정의할 수 있는데, 이와 조금 다르게 기존의 OOP에서 할 수 있는 것은 다할 수 있지만 클래스는 없는 셀프라는 것을 사용해서 프로토타입을 만들어본다.

셀프는 클래스 기반 언어보다 더 객체 지향적이다.

상태와 동작을 같이 묶어놓은 것을 OOP라고 할 때, 클래스 기반 언어는 상태와 동작 사이에 분명한 구별이 있다.

객체 상태를 알기 위해서 해당 인스턴스의 메모리를 들여다봐야한다.

즉, 상태는 인스턴스에 들어 있다.

반대로 메서드를 호출할 때는 인스턴스의 클래스를 찾는다. 즉, 동작은 클래스에 있다. 항상 한 단계를 거쳐서 메서드를 호출한다는 점에서 필드(상태)와 메서드는 다르다.

OOP에서 메서드는 클래스에, 필드는 인스턴스에 저장된다.

OOP에서 메서드는 클래스에, 필드는 인스턴스에 저장된다.

셀프에서는 무엇이든 객체에서 바로 찾을 수 있다.

셀프에서는 무엇이든 객체에서 바로 찾을 수 있다.

클래스 기반에서 상속은 나름 단점도 있지만 다형성을 통해서 코드를 재사용하고 중복 코드를 줄일 수 있다는 장점이 있다.

클래스 없이 이런 일을 수행하기 위해서 셀프에는 위임delegation 개념이 있다.

먼저 해당 객체에서 필드나 메서드를 찾아서, 있다면 그걸 사용하고 없다면 상위parent 객체를 찾아본다. 상위 객체는 다른 객체 레퍼런스이다.

첫 번째 객체에 속성이 없다면 상위 객체를 살펴보고, 그래도 없다면 상위 객체의 상위 객체에서 찾아보는 것을 반복한다.

다시 말해 찾아보고 없으면 상위 객체에 위임한다.

상위에 위임하는 객체

상위에 위임하는 객체

상위 객체를 통해서 동작(과 상태)을 여러 객체가 재 사용할 수 있기 때문에 클래스가 제공하는 기능 대부분을 대신할 수 있다.

클래스의 또 다른 역할은 인스턴스 생성이다. 클래스가 없다면 어떤 식으로 객체를 만들 수 있을까?

프로토타입 패턴에서처럼 복제하면 된다.

셀프에서 모든 객체가 프로토타입 디자인 패턴을 저절로 지원하는 것과 같다. 모든 객체가 복제될 수 있기 때문에 비슷한 객체를 만들기 위해서는 아래와 같이 한다.

  1. 객체 하나를 원하는 상태로 만든다. 시스템에서 제공하는 기본 Object 객체를 복제한 뒤에 필드와 메서드를 채워넣는다.
  2. 원하는 만큼 복제한다.

그러나 프로토타입을 기반한 언어는 구현하기 쉬웠지만 복잡한 부분을 사용자에게 떠넘겼기 때문에 언어에서 제공하지 않는 부분을 라이브러리에서 다시 만들어야 하는 단점이 있었다.

자바스크립트는 어떤가?

프로토타입 기반 언어가 접근성이 낮다면 자바스크립트는 어떨까? 프로토타입 기반이지만 수많은 사람들이 자주 사용하는 언어이다.

많은 자바스크립트 문법이 프로토타입 기반 방식이다.

객체는 아무 속성 값이나 가질 수 있는데, 속성에는 필드나 ‘메서드’(실제로는 필드로서 저장된 함수)가 들어간다. 객체는 ‘프로토타입’이라고 부르는 다른 객체를 지정할 수 있어서 자기 자신이 없는 필드는 프로토타입에 위임할 수 있다.

그렇다고 해도 실제로 자바스크립트는 프로토타입 기반보다 클래스 기반 언어에 더 가까운 부분이 있다. 자바 스크립트에는 프로토타입기반 언어의 핵심인 복제를 찾아볼 수 없다는 것이 셀프와의 차이점을 보여준다.

데이터 모델링을 위한 프로토타입

프로그래밍 언어를 사용하는 이유는 복잡성을 제어할 수 있는 수단을 가지고 있어서다.

같은 코드를 복사-붙여넣기 하는 대신에 하나의 함수로 만들어서 호출한다. 여러 클래스에 같은 메서드를 복사하는 대신 클래스로 만들어 상속박거나 믹스인mix in 한다.

게임 데이터도 규모가 일정 이상이 되면 코드와 비슷한 기능이 필요하다. (데이터 모델링) 이때 많이 사용하는 방법이 JSON이다. JSON은 키/값 구조로 이루어져 있는 데이터 개체로 맵, 또는 속성 목록property bag 등 여러 용어로 불리고 있다.

Chapter6. 싱글턴

GoF의 싱글턴 패턴은 의도와는 달리 득보다 실이 많다.

싱글턴 패턴

  • 오직 한 개의 클래스 인스턴스만 갖도록 보장.
  • 전역 접근점을 제공.

싱글턴을 왜 사용하는가?

싱글턴의 장점

  • 한 번도 사용하지 않는다면 아예 인스턴스를 생성하지 않는다.
  • 싱글턴은 처음 사용될 때 초기화되므로 게임 내에서 전혀 사용되지 않는다면 아예 초기화되지 않는다.
  • 런타임에 초기화된다.
  • 싱글턴을 게으른 초기화로 생성하게 되면, 싱글턴은 최대한 늦게 초기화 되기 때문에 그때쯤에는 클래스가 필요로하는 정보가 준비되어 있다. 순환 의존만 없다면 초기화할 때 다른 싱글턴을 참조해도 괜찮다.
  • 싱글턴을 상속할 수 있다.

싱글턴은 왜 문제일까?

알고보니 전역 변수

  • 전역 변수는 코드를 이해하기 어렵게 한다.
  • (커플링이 심하기 때문에 전체 코드에서 해당 부분을 접근하는 모든 곳을 살펴봐야 상황을 파악할 수 있게 된다.)
  • 전역 변수는 커플링을 조장한다.

전역 변수, 즉 싱글턴에서 생길 수 있는 문제는 위와 같다. 전역 상태 때문에 생길수 있는 문제를 쭉 살펴보면 어느 하나도 싱글턴 패턴으로 해결할 수 없다는 걸 알게 된다. 싱글턴 패턴이 바로 클래스로 캡슐화된 전역 상태이기 때문이다.

그밖에도 싱글턴을 사용하려는 이유인 ‘전역 접근’은 전역적으로 싱글턴 객체를 사용하다보니 해당 객체를 사용하는 모든 코드에 영향을 미치게 되고, 수정을 하려하면 모든 코드를 수정해야하는 단점을 갖고 오게된다.

또한, 게으른 초기화는 제어할 수 없다.

게임에서 소리 재생을 위해서 게으른 초기화를 하게 되면 전투 도중 초기화가 시작되는 바람에 화면 프레임이 떨어지고 게임이 버벅댈 수 있다.

마찬가지로 게임에서는 메모리 단편화를 막기 위해 힙에 메모리를 할당하는 방식을 세밀하에 제어하는 것이 보통이다.

오디오 시스템이 초기화될 때 상당한 메모리를 힙에 할당한다면, 힙 어디에 메모리를 할당할지를 제어할 수 있도록 적절한 초기화 시점을 찾아야 한다.

그래서 이런 두 가지 문제 때문에 대부분의 게임에서는 게으른 초기화를 사용하지 않는다. 대신 싱글턴 패턴을 아래와 같이 구현했다.

class FileSystem {
public:
    static FileSystem& instance() { return instance_; }

private:
    FileSystem() {}

    static FileSystem instance_;
};

이렇게 사용하면 게으른 초기화 문제를 해결할 수 있지만, 싱글턴이 그냥 전역 변수보다 나은 점을 몇 개 포기해야 한다.

이처럼 정적 인스턴스를 사용하면 다형성을 사용할 수 없다.

클래스는 정적 객체 초기화 시점에 생성된다. 인스턴스가 필요 없어도 메모리를 해제할 수 없다.

싱글턴 대신 단순한 정적 클래스를 하나 만든 셈이다.

대안

싱글턴으로 해결하려던 문제가 사라진 것은 아니다.

싱글턴을 안 쓴다면 어떤 대안이 있을까?

어떤 문제냐에 따라 다른 대안이 있지만 다음을 생각해보자.

클래스가 꼭 필요한가?

싱글턴은 보통 다른 객체 관리용으로만 존재하는 ‘관리자manager’가 많다. 관리 클래스가 필요한 경우도 있지만 불필요하게 생성하는 경우가 많다. 서툴게 만든 싱글턴은 다른 클래스에 기능을 더해주는 ‘도우미’인 경우가 많다. 가능하다면 도우미 클래스의 작동코드를 원래 클래스로 옮기자.

오직 한 개의 클래스 인스턴스만 갖도록 보장하기

싱글턴 패턴이 해결하려는 첫 문제이다.

싱글턴을 만들더라도 누구나 어디에서나 인스턴스에서 접근할 수 있는 것이 아니라, 특정 코드에서만 접근할 수 있게 만들거나, 클래스의 private 멤버 변수로 만들고 싶을 수 있다. 이럴 때 전역에서 누구나 접근할 수 있게 만들면 구조가 취약해진다.

따라서 전역 접근 없이 클래스 인스턴스만 한 개로 보장할 수 있는 방법이 몇 가지 있다.

  • 어디서나 클래스를 생성하더라도 인스턴스가 두 개 이상이 될 때 에러를 발생시키는 것이다. (단언문assert 사용)

다만 싱글턴은 클래스 문법을 활용해 컴파일 시간에 단일 인스턴스를 보장하는 데 반해 이 방식은 런타임에 인스턴스 개수를 확인한다는 게 단점이다.

인스턴스에 쉽게 접근하기

싱글턴의 이 장점을 대체할 수 있는 방법을 생각해보자.

  • 넘겨주기
  • 객체를 필요로하는 함수에 인수로 넘겨주는 게 가장 쉬우면서도 최선인 경우가 많다.
  • 상위 클래스로부터 얻기
  • 많은 게임에서 클래스를 대부분 한 단계만 상속할 정도로 상속 구조를 얕고 넓게 가져간다. 게임 내 모든 객체가 상속받는 GameObject라는 상위클래스가 있다면, 해당 상위 클래스에서 원하는 객체에 접근하도록 만드는 아이디어를 생각할 수 있다.
  • 이미 전역인 객체로부터 얻기
  • 전역 상태를 모두 제거하는 것은 어렵다. 즉 기존 전역 객체를 사용하면 전역 클래스 개수를 줄일 수 있다.
  • 서비스 중개자로부터 얻기
  • 여러 객체에 대한 전역 접근을 제공하는 용도로만 사용하는 클래스(서비스 중개자)를 따로 정의하는 방법이다.

싱글턴에 남은 것

진짜로 싱글턴 패턴이 필요할 때는 언제일까?

이 책의 저자는 싱글턴보단 다른 디자인 패턴을 사용하라고 권고하고 있다.

Chapter7. 상태

간단한 횡스크롤 플랫포머(발판을 밟으며 진행하는 러닝 게임)를 만든다고 치자.

이때 B 버튼을 누르면 점프하는 것을 간단하게 구현해보자.

처음 아이디어는 다음과 같다.

→ B를 누르면 점프한다.

그러나 이때의 버그가 존재한다.

B를 누르면 점프할 때, ‘공중 점프’를 막는 코드가 없는 것이다.

따라서 B를 연타하면 계속 공중에 떠있을 수 있다. 따라서 isJumping_ 이라는 Boolean 필드를 추가해 점프중인 지를 검사하면 고칠 수 있게된다.

→ isJumping_이 아닐 때, B를 누르면 점프한다.

또한 이 상황에서 주인공이 땅에 있을 때 아래 버튼을 누르면 엎드리고, 버튼을 떼면 다시 일어서는 기능을 추가한다고 해보자.

→ isJumping_이 아닐 때, B를 누르면 점프한다.

→ 아래 버튼을 눌렀을 때 엎드리고, 버튼을 떼면 일어선다.

이때의 버그는 무엇일까?

  1. 엎드리기 위해서 아래 버튼을 누르고
  2. B 버튼을 눌러 엎드린 상태로 점프를 하고 나서
  3. 공중에서 아래 버튼을 떼면 ‘공중에서 일어선다’

따라서 플래그 변수가 더 필요하게 된다. isDucking_ 이라는 엎드린 상태를 표시하는 변수를 추가하여 코드를 고칠 수 있다.

그러나 어떠한 상태를 또 추가하려고 하면 현재 상태가 무엇인지에 대해 검사하는 변수를 또다시 만들어야 한다. 이동과 관련해서 이런 식으로 구현하게 되면 버그가 잔뜩 생기게 될 것이다.

FSM을 사용하자

상태 기계를 그린 플로차트

상태 기계를 그린 플로차트

주인공이 할 수 있는 동작(서 있기, 점프, 엎드리기, 내려찍기)를 적어놓고, 어떤 버튼을 눌렀을 때 상태가 바뀐다면 이전 상태에서 다음 상태로 도착하는 화살표를 그린 뒤, 눌렀던 버튼을 선에 적는다.

이게 바로 유한 상태 기계(FSM)이다. FSM은 컴퓨터 과학 분야 중의 하나인 오토마타 이론에서 나왔다.

  • 가질 수 있는 ‘상태’가 한정된다.
  • 한 번에 ‘한 가지’ 상태만 될 수 있다.
  • ‘입력’이나 ‘이벤트’가 기계에 전달된다.
  • 각 상태에는 입력에 따라 다음 상태로 바뀌는 ‘전이transition’가 있다. 입력이 들어왔을 때 현재 상태에 해당하는 전이가 있다면 전이가 가리키는 다음 상태로 변경한다.

열거형과 다중 선택문

각각의 상태는 동시에 일어나지 않는다. 한 순간에는 한 상태만을 갖는다.

앞서 예시에서의 Boolean 플래그 변수는 한 순간에 하나만 참이 되는 순간이 많다.

즉 열거형(enum)이 필요하다는 신호다.

FSM 상태를 열거형으로 정의하면 다음과 같다.

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;
        }
    }

상태 변수를 하나로 줄였고, 하나의 상태를 관리하는 코드는 한 곳에 모였다.

이렇게 구현하게 되면 ‘유효하지 않은’ 상태가 될 수 없게 된다.

Boolean 변수 여러 개로 상태를 관리하다 보면 일부 값 조합은 유효하지 않을 수 있다. 그러나 열거형은 그럴 일이 없다.

상태 패턴

상태 인터페이스

상태 인터페이스를 다음과 같이 정의하자.

상태에 의존하는 모든 코드(다중 선택문에 있던 동작)를 인터페이스의 가상 메서드로 만든다.

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

상태 클래스 만들기

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

메서드는 정해진 상태가 되었을 때 주인공이 어떤 행동을 할 지를 정의한다.

다중 선택문에 있던 case 별로 클래스를 만들어 코드를 옮기면 된다.

동작을 상태에 위임하기

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

‘상태를 바꾸려면’ state_ 포인터에 상태 인터페이스를 상속받는 다른 객체를 할당하기만 하면 된다.

상태 객체는 어디에 둬야 할까?

상태를 바꾸려면 state_에 새로운 상태 객체를 할당해야 한다.

그렇다면 이 객체는 어디에서 온 것인가?

정적 객체

상태 객체에 필드가 따로 없다면 가상 메서드 호출에 필요한 vtable 포인터만 있는 셈이다.

이럴 경우 모든 인스턴스가 같기 때문에 인스턴스는 하나만 있으면 된다.

또한 상태 클래스에 필드가 없고, 가상 메서드도 하나밖에 없다면 상태 클래스를 정적 함수로 변경할 수도 있다. 이럴 때에 state_ 필드는 함수 포인터가 된다.

상태 객체 만들기

정적 객체만으로 부족할 때도 있다. 주인공이 둘 이상일 때에는 주인공마다 각각 다른 상태를 갖는다던가, 하는 일들이 있다보니 (공유하는 필드가 생기게 되면 안된다) 따라서 전이할 때마다 상태 객체를 만들어야 한다.

이러면 FSM이 상태별로 인스턴스를 갖게 된다. 새로 상태를 할당하고, 이전 상태를 해제해야 한다.

단점

FSM의 단점은 엄격하게 제한된 구조를 강제함으로써 복잡하게 얽힌 코드를 정리할 수 있게 해준다.

미리 정해놓은 여러 상태와 현재 상태 하나, 하드코딩되어있는 전이만이 존재한다.

이러한 상태 기계를 인공지능같이 더 복잡한 곳에 적용하다보면 한계에 부딪히게 된다.

병행 상태 기계

주인공이 총을 들고, 총 쏘기와 추가적인 동작을 ‘동시에’할 수 있어야 한다고 하자.

FSM 방식을 사용한다면 서기, 무장한 채로 서기, 점프, 무장한 채로 점프와 같은 상태를 ‘두 개씩’ 만들어야 한다.

이에 대한 해결법은 간단하다. 상태 기계를 2개로 만들면 된다.

무엇을 하는 가에 대한 상태 기계는 그대로 두고, 무엇을 들고 있는가에 대한 상태 기계를 따로 정의한다. 또한 주인공 클래스는 이들 상태를 각각 참조한다.

계층형 상태 기계

비슷한 상태들이 존재할 수 있다. 이때에는 상속으로 여러 상태가 코드를 공유할 수 있도록 변경할 수 있다.

점프와 엎드리기는 ‘땅 위에 있는’ 상태 클래스를 정의해 처리할 수 있게 된다.

이런 구조를 계층형 상태 기계라고 한다.

어떤 상태는 상위 상태superstate를 가질 수 있고, 그 경우 그 상태 자신은 하위 상태substate가 된다.

계층형 상태 기계는 상태 스택을 만들어 명시적으로 현재 상태의 상위 상태 연쇄를 모델링할 수 도 있다.

푸시다운 오토마타

상태 스택을 활용하여 FSM을 확장하는 다른 방법이다.

FSM에는 이력history 개념이 없다는 문제가 있다.

현재 상태는 알 수 있지만 직전 상태가 무엇인지 따로 저장하지 않기 때문에 이전 상태로 쉽게 돌아갈 수 없다.

일반적인 FSM에서는 이전 상태를 알 수 없다. 이전 상태를 알기 위해 써먹을만한 것으로 푸시다운 오토마타가 있다.

FSM이 한 개의 상태를 포인터로 관리했다면 푸시다운 오토마타에서는 상태를 스택으로 관리한다.

FSM은 이전 상태를 덮어쓰고 새로운 상태로 전이하는 방식인데 반해, 푸시다운 오토마타는 부가적인 명령이 두 가지 더 있다.

  • 새로운 상태를 스택에 넣는다push.
  • 스택의 최상위 상태가 ‘현재’ 상태이기 때문에, 새로 추가된 상태가 현재 상태가 된다. 다만 이전 상태는 버리지 않고 방금 들어온 최신 상태 밑에 있게 된다.
  • 최상위 상태를 스택에서 뺀다pop.
  • 빠진 상태는 제거되고, 바로 밑에 있던 상태가 새롭게 ‘현재’ 상태가 된다.

넣기와 빼기

넣기와 빼기

얼마나 유용한가?

FSM은 한계가 있다.

요즘 게임 AI는 행동 트리behavior tree나 계획 시스템planning system을 더 많이 쓰는 추세다.

FSM은 다음 경우에 사용하면 좋다.

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

게임에서는 FSM이 AI에서 사용되는 걸로 가장 잘 알려져 있지만, 입력 처리나 메뉴화면 전환, 문자 해석, 네트워크 프로토콜, 비동기 동작을 구현하는 데에도 많이 사용되고 있다.

반응형

댓글