[Design Pattern] 게임 디커플링 : 컴포넌트, 이벤트 큐, 서비스 중개자
Table of Contents
게임 프로그래밍 패턴 책을 읽고 공부한 노트입니다.
컴포넌트 #
- 플랫포머 게임을 만들고자 한다. 플레이어는 컨트롤러 입력 값도 처리해야 하고, 지형이나 플랫폼과도 상호작용해야 되니 물리 처리도 필요하다. 또한 화면에 나와야 하니 애니메이션과 렌더링도 필요하다. 소리도 들려야하겠다.
- AI, 물리, 렌더링, 사운드 처럼 서로 다른 분야는 서로 몰라야 하겠지만 이 기능들을 하나의 클래스 안에 다 욱여넣는다면 코드가 방대해지는 것은 물론이고, 서로 실타래처럼 얽히게(커플링) 될 것이다.
- 컴포넌트 패턴
- 각각의 분야를 별도의 컴포넌트로 제작하고, 플레이어는 이 컴포넌트들을 컨테이너처럼 들고만 있으면 어떨까?
- 언제 쓸까?
- 한 클래스에서 여러 분야를 건드리고 있는데 이것들을 서로 디커플링하고 싶다.
- 한 클래스가 거대해져서 작업하기가 어렵다.
- 여러 기능을 공유하는 다양한 객체를 만들고 싶은데 상속으로는 원하는 부분만 재사용할 수가 없다.
- 주의사항
- 코드 규모가 작으면 클래스 하나에 코드를 모아놨을 때보다 더 복잡해질 가능성이 있다.
- 무엇이든 한 단계 포인터를 거쳐 처리되므로 성능이 떨어질 수 있다.
코드 예제 #
- 컴포넌트 패턴을 적용하기 전, 통짜 클래스를 살펴보고 문제점을 파악해보자.
class Mario
{
private:
static const int WALK_ACCELERATION = 1;
int velocity, x, y;
Volume volume;
Sprite spriteStand;
Sprite spriteWalkLeft;
Sprite spriteWalkRight;
public:
Mario() : velocity(0), x(0), y(0) {}
void update(World& world, Graphics& graphics)
{
// (1) 입력에 따라 속도를 조절한다.
switch(Controller::getJoystickDirection())
{
case DIR_LEFT:
velocity -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
velocity += WALK_ACCELERATION;
break;
}
// (2) 속도에 따라 위치를 바꾼다.
x += velocity;
world.resolveCollision(volume, x, y, velocity);
// (3) 알맞은 스프라이트를 그린다.
Sprite* sprite = &spriteStand;
if (velocity < 0) sprite = &spriteWalkLeft;
else if (velocity > 0) sprite = &spriteWalkRight;
graphics.draw(*sprite, x, y);
}
};
- 별 기능이 없는데도
update()
함수에 여러가지 기능이 섞여서 어지럽다. - 각 기능을 클래스별로 나눠보자.
// (1) 입력에 따라 속도를 조절한다.
class InputComponent
{
private:
static const int WALK_ACCELERATION = 1;
public:
void update(Mario& mario)
{
switch(Controller::getJoystickDirection())
{
case DIR_LEFT:
mario.velocity -= WALK_ACCELERATION;
break;
case DIR_RIGHT:
mario.velocity += WALK_ACCELERATION;
break;
}
}
};
// (2) 속도에 따라 위치를 바꾼다.
class PhysicsComponent
{
private:
Volume volume;
public:
void update(Mario& mario, World& world)
{
mario.x += velocity;
world.resolveCollision(volume, mario.x, mario.y, mario.velocity);
}
};
// (3) 알맞은 스프라이트를 그린다.
class GraphicsComponent
{
private:
Sprite spriteStand;
Sprite spriteWalkLeft;
Sprite spriteWalkRight;
public:
void update(Mario& mario, World& world)
{
Sprite* sprite = &spriteStand;
if (velocity < 0) sprite = &spriteWalkLeft;
else if (velocity > 0) sprite = &spriteWalkRight;
graphics.draw(*sprite, mario.x, mario.y);
}
};
class Mario
{
private:
// 각 컴포넌트를 들고 있다.
InputComponent input;
PhysicsComponent physics;
GraphicsComponent graphics;
public:
int velocity, x, y;
void update(World& world, Graphics& graphics)
{
// 간단해졌다.
input.update(*this);
physics.update(*this, world);
graphics.update(*this, graphics);
}
};
- 각 컴포넌트, 플레이어 클래스의 구현부와 인터페이스를 분리해서 추상화시켜보자.
class InputComponent
{
public:
virtual ~InputComponent();
virtual void update(GameObject& obj) = 0;
};
class PhysicsComponent
{
public:
virtual ~PhysicsComponent();
virtual void update(GameObject& obj, World& world) = 0;
};
class GraphicsComponent
{
public:
virtual ~GraphicsComponent();
virtual void update(GameObject& obj, Graphcis& graphics) = 0;
};
class GameObject
{
private:
InputComponent* input;
PhysicsComponent* physics;
GraphicsComponent* graphics;
public:
int velocity, x, y;
GameObject(InputComponent* i, PhysicsComponent* p, GraphicsComponent* g) : input(i), physics(p), graphics(g) {}
void update(World& world, Graphics& graphics)
{
input->update(*this);
physics->update(*this, world);
graphics->update(*this, graphics);
}
};
- 이렇게 하면 새로운 기능의 컴포넌트, 캐릭터를 마음껏 만들어낼 수 있다.
- 예를 들면, 아래 처럼 데모 모드용 플레이어 객체를 생성할 수도 있겠다.
// 데모 모드용 입력 처리 클래스
class DemoInputComponent : public InputComponent
{
public:
virtual void update(GameObject& obj)
{
// AI가 알아서 플레이어를 조정한다.
}
};
class MarioPhysicsComponent : public PhysicsComponent
{
public:
virtual void update(GameObject& obj, World& world)
{
// ...물리 코드
}
};
class MarioGraphicsComponent : public GraphicsComponent
{
public:
virtual void update(GameObject& obj, Graphcis& graphics)
{
// ...그래픽스 코드
}
};
// 데모모드용 플레이어 객체를 다음과 같이 생성할 수 있다.
GameObejct* createDemoMario()
{
return new GameObject(
new DemoInputComponent(),
new MarioPhysicsComponent(),
new MarioGraphicsComponent());
}
디자인 결정에 고려할 사항들 #
- 객체는 컴포넌트를 어떻게 얻는가?
- 객체가 알아서 생성한다.
- 외부 코드에서 컴포넌트를 제공한다.
- 컴포넌트끼리는 어떻게 통신할 것인가?
- 객체의 상태를 변경해서 통신하는 방식
- 컴포넌트가 서로를 참조하는 방식
- 메시지를 전달하는 방식
이벤트 큐 #
- UI 이벤트의 처리는 어떻게 이루어질까?
- 버튼을 클릭하면 운영체제는 이벤트를 만든다.
- 이 이벤트는 큐(queue)에 저장된다.
- 그리고 애플리케이션에서 원할 때 큐에 있는 이벤트를 꺼내서 처리한다.
- 게임에서는 자체적으로 이벤트 큐를 만들어서 통신 시스템으로 활용한다.
- 이벤트 큐 패턴
- 큐는 요청들을 순서대로 저장해놓는다.
- 요청을 보낸 쪽에서는 요청을 큐에 넣은 뒤에 기다리지 않고 리턴한다.
- 요청을 받는 쪽에서는 원하는 때에 큐에 있는 요청을 확인하고 처리한다.
- 요청은 직접 처리될 수도 있고, 다른 여러 곳으로 보내질 수도 있다.
- 요청을 보낸 쪽과 받는 쪽은 코드뿐만 아니라 시간 측면에서도 디커플링한다.
- 언제 쓸까?
- 요청을 보낸 쪽과 받는 쪽을 분리하고 싶을 뿐이라면 옵저버 패턴이나 커맨드 패턴으로 처리할 수 있다.
- 이벤트 큐 패턴은 요청을 보내는 시점과 받는 시점을 분리하고 싶을 때 필요하다.
- 주의사항
- 중앙 이벤트 큐는 전역 변수와 같다. 따라서 온갖 미묘한 상호의존성 문제가 생길 수 있다.
- 요청할 당시와 요청을 처리할 당시의 월드 상태는 언제든 달라질 수 있다. 따라서 주의가 필요하다.
- 서로 계속해서 요청을 보내는 피드백 루프에 빠질 수 있다. 동기 방식이면 스택 오버플로가 나겠지만, 비동기 방식이라 게임은 정상적으로 계속 실행된다. 이벤트를 처리하는 곳에서 이벤트를 보내지 않으면 해결될 수 있다.
코드 예제 #
- 예를 들어, 사운드 시스템을 만들고 싶다고 하자.
class Audio
{
public:
static void playSound(SoundId id, int volume)
{
// 리소스를 로딩하고, 채널을 찾아서 소리를 재생한다.
ResourceId resource = loadSound(id);
int channel = findOpenChannel();
if (channel == -1) return;
startSound(resource, channel, volume);
}
};
class Menu
{
public:
void onSelect(int index)
{
// UI 메뉴를 누르면 소리가 나게한다.
Audio::playSound(SOUND_BLOOP, VOL_MAX);
}
};
- 하지만 위의 코드는 문제가 있다.
playSound()
는 동기적이라 소리가 출력되기 전까지 API가 블록된다.- 요청을 모아서 순서대로 처리할 수 없다. 멀티코어 하드웨어에서 실행된다면
playSound()
에 동기화 처리가 없기 때문에 여러 스레드에서 동시에 사운드가 출력될 수 있다. playSound()
를 호출하면 하던 일을 멈추고 당장 사운드를 출력한다. 하지만 이 때가 늘 적당한 때가 아닐 수 있다.
- 이제 이벤트 큐 패턴을 적용해보자.
- 요청을 큐에 넣는 시점과 요청을 처리하는 시점을 달리 하기 위해 함수를 따로 둔다.
- 원형 버퍼를 사용해본다.
// 요청을 나중에 처리할 때 필요한 정보들을 저장하기 위해 구조체를 선언한다.
struct PlayMessage
{
SoundId id;
int volume;
};
class Audio
{
private:
static const int MAX_PENDING = 16;
static PlayMessage pending[MAX_PENDING];
static int head;
static int tail;
public:
static void init()
{
head = 0;
tail = 0;
}
static void playSound(SoundId id, int volume)
{
// 똑같은 요청이 또 들어온 경우라면
// 소리가 더 큰 값 하나로 합쳐지게 한다.
for (int i = head; i != tail; i = (i + 1) % MAX_PENDING)
{
if (pending[i].id == id)
{
pending[i].volume = max(volume, pending[i].volume);
return;
}
}
// 큐가 가득 찬 경우 덮어쓰지 않기 위해 단언문으로 검사한다.
assert((tail + 1) % MAX_PENDING != head);
// 배열의 맨 뒤에 메시지를 넣는다.
pending[tail].id = id;
pending[tail].volume = volume;
// 만약에 꼬리가 배열의 마지막에 도달했다면,
// 맨 앞으로 가게 만든다. (원형 버퍼)
tail = (tail + 1) % MAX_PENDING;
}
// 이 함수는 적당한 곳(메인 게임. 루프 혹은 별도의 오디오 스레드)에서 호출되면 된다.
static void update()
{
// 요청이 없으면 아무것도 안 한다.
if (head == tail) return;
// 리소스를 로딩하고, 채널을 찾아서 소리를 재생한다.
ResourceId resource = loadSound(pending[head].id);
int channel = findOpenChannel();
if (channel == -1) return;
startSound(resource, channel, pending[head].volume);
// 다음 위치로 간다.
head = (head + 1) % MAX_PENDING;
}
};
- 이 코드에 멀티 스레드에서도 안전하게 만들고 싶다면?
- 큐를 변경하는 코드인
playSound()
와update()
를 스레드 안전하게 만들기만 하면 된다.
- 큐를 변경하는 코드인
디자인 결정에 고려할 사항들 #
- 큐에 무엇을 넣을 것인가?
- 이벤트 : 이미 발생한 사건
- 메시지 : 나중에 실행했으면 하는 행동
- 누가 큐를 읽는가?
- 싱글캐스트 큐
- 브로드캐스트 큐
- 작업 큐
- 누가 큐에 값을 넣는가?
- 넣는 측이 하나
- 넣는 측이 여러 개
- 큐에 들어간 객체의 생명주기는 어떻게 관리할 것인가?
- 소유권을 전달한다
- 소유권을 공유한다
- 큐가 소유권을 가진다