[Design Pattern] 게임 행동 : 바이트 코드, 하위 클래스 샌드박스, 타입 객체
Table of Contents
게임 프로그래밍 패턴 책을 읽고 공부한 노트입니다.
바이트 코드 #
- 인터프리터 패턴의 경우 느리고 메모리를 많이 필요로 한다는 단점이 있었다.
- 반면에 기계어의 경우 밀도가 높고, 선형적이며 저수준이고, 빠르다. 하지만 해커에게 취약해진다.
- 이 둘을 절충할 수는 없을까?
- 실제 기계어를 읽어서 바로 실행하는 대신 우리만의 가상 기계어(바이트 코드) 를 정의하면 어떨까?
- 그리고 가상 기계어를 실행하는 에뮬레이터(가상 머신: VM) 도 만드는 거다.
- 바이트 코드 패턴
- 실행할 수 있는 저수준 작업들을 명령어 집합으로 정의한다.
- 그 명령어는 일련의 바이트로 인코딩된다.
- 가상 머신은 중간 값들을 스택에 저장해나가면서 명령어들을 하나씩 실행한다.
- 언제 쓸까?
- 언어가 너무 저수준이라 만드는 데 손이 많이 가거나 오류가 생기기 쉽다.
- 컴파일 시간이나 다른 빌드 환경 때문에 반복 개발하기가 너무 오래 걸린다.
- 새로 만드려는 행동을 나머지 코드로부터 분리해서 보안에 신경쓰고 싶다.
코드 예제 #
- 마법 주문을 구현하는 데 필요한 API들은 다음과 같다.
void setHealth(int wizard, int amount)`
void setWisdom(int wizard, int amount)`
void setAgility(int wizard, int amount)`
int getHealth(int wizard)`
int getWisdom(int wizard)`
int getAgility(int wizard)`
void playSound(int soundId)`
void spawnParticles(int particleType)`
- 이 API들을 단순한 명령어 집합으로 바꿀 수 있겠다.
enum Instruction
{
INST_SET_HEALTH = 0x00,
INST_SET_WISDOM = 0x01,
INST_SET_AGILITY = 0x02,
INST_GET_HEALTH = 0x03,
INST_GET_WISDOM = 0x04,
INST_GET_AGILITY = 0x05,
INST_PLAY_SOUND = 0x06,
INST_SPAWN_PARTICLES = 0x07
};
- 인터프리터 패턴은 중첩 객체 트리 형태로 중첩식을 표현했고, 매우 느리다.
- 트리가 아닌 1차원으로 명령어를 나열해도 순서에 맞게 실행할 수 있다면 속도가 빨라질 것이다.
- CPU처럼 스택을 사용해서 명령어 실행 순서를 제어해보자.
- 스택을 이용해서 마법 전체를 실행하는 VM은 다음과 같이 구현한다.
class VM
{
private:
// 스택으로 명령어 실행 순서를 제어한다.
// 스택 크기를 조절해서 VM의 메모리 사용량을 조절할 수 있다.
static const int MAX_STACK = 128;
int stackSize;
int stack[MAX_STACK];
void push(int value)
{
assert(stackSize < MAX_STACK);
stack[stackSize++] = value;
}
int pop()
{
assert(stackSize > 0);
return stack[--stackSize];
}
public:
VM() : stackSize(0) {}
// VM은 바이트 코드를 읽어서 해당하는 명령에 따라 모두 실행한다.
void interpret(char bytecode[], int size)
{
for (int i = 0; i < size; i++)
{
char instruction = bytecode[i];
switch(instruction)
{
case INST_SET_HEALTH:
{
// 명령어가 매개변수를 받을 때는 다음과 같이 스택에서 꺼내온다.
int amount = pop();
int wizart = pop();
setHealth(wizard, amount);
}
break;
case INST_GET_HEALTH:
{
int wizart = pop();
push(getHealth(wizard));
}
break;
// ....
}
}
};
- 스택에서
pop()
으로 값을 읽어오려면…- 바이트 코드에서 값을 읽어서
push()
해놓아야 한다. - 리터럴 명령어가 필요하겠다.
- 바이트 코드에서 값을 읽어서
// 바이트 코드에서 리터럴 명령어가 나오면,
case INST_LITERAL:
{
// 바이트 코드에서 다음 값을 읽어서 스택에 넣는다.
int value = bytecode[++i];
push(value);
}
break;
- 이제 명령어들을 조합해서 아래와 같은 표현식을 만들어보자.
setHealth(0, getHealth(0) + (getAgility(0) + getWisdom(0)) / 2);
- 표현식은 다음과 같은 순서로 실행된다.
- 체력을 가져와 저장한다.
- 민첩성을 가져와 저장한다.
- 지혜를 가져와 저장한다.
- 민첩성과 지혜를 가져와 더한 뒤에 그 결과를 저장한다.
- 결과를 2로 나눈 뒤 저장한다.
- 체력을 가져와 결과에 더하고 저장한다.
- 결과를 가져와 마법사의 체력으로 세팅한다.
- 덧셈을 하려면 아래와 같은 명령어가 추가되어야 하겠다. 나눗셈도 마찬가지다.
case INST_ADD:
{
int b = pop();
int a = pop();
push(a + b);
}
break;
- 바이트 코드를 다음과 같이 만들어낼 수 있겠다.
명령어 스택 상태 목적 LITERAL 0
[0] 마법사 인덱스 LITERAL 0
[0, 0] 마법사 인덱스 GET_HEALTH
[0, 45] getHealth()
LITERAL 0
[0, 45, 0] `마법사 인덱스 GET_AGILITY
[0, 45, 7] getAgility()
LITERAL 0
[0, 45, 7, 0] 마법사 인덱스 GET_WISDOM
[0, 45, 7, 11] getWisdom()
ADD
[0, 45, 18] 민첩성과 지혜를 더함 DIVIDE
[0, 45, 9] 2로 나눔 ADD
[0, 54] 결과에 현재 체력을 더함 SET_HEALTH
[] 결과를 체력으로 세팅함
- 이렇게 손으로 직접 바이트 코드를 컴파일하는 건 현실성이 없다.
- 이제 사용자가 바이트 코드를 고수준 형식으로 편하게 표현할 수 있어야 하겠다.
- 텍스트 기반 언어를 정의하거나, 명령어들을 조합하는 GUI 툴을 만들면 괜찮겠다.
디자인 결정에 고려할 사항들 #
- VM의 종류
- 스택 기반 VM
- 레지스터 기반 VM
- 명령어의 종류
- 외부 원시명령
- 내부 원시명령
- 흐름 제어
- 추상화
- 값을 표현하는 자료형을 어떻게 저장할 것인가?
- 단일 자료형
- 태그 불은 변수
- 태그가 붙지 않은 공용체
- 인터페이스
- 바이트코드를 어떻게 만들 것인가?
- 텍스트 기반 언어를 정의할 경우
- UI가 있는 저작 툴을 만들 경우
하위 클래스 샌드박스 #
- 하위 클래스 샌드박스 패턴
- 상위 클래스에는 중복되는 코드를 넣어 하위 클래스에서 재사용할 수 있게 한다.
- 이 코드를 사용하는 샌드박스 메서드는 순수 가상 함수로 만들어
protected
에 둔다. - 그런다음 하위 클래스에서 샌드박스 메서드를 구현한다.
- 언제 쓸까?
- 클래스 하나에 하위 클래스가 많이 있다.
- 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.
- 하위 클래스들의 행동 중에 겹치는 게 많아서 이들을 공유하게 만들고 싶다.
- 하위 클래스들 사이의 커플링, 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.
- 주의사항
- 하위 클래스는 상위 클래스에 접근하는 모든 시스템과 커플링된다. 그래서 상위 클래스를 조금만 바꿔도 어딘가가 깨지기 쉽다. (fragile base class 문제)
코드 예제 #
- 슈퍼히어로 게임을 만들고 싶다. 여기에는 수십 개가 넘는 다양한 초능력이 존재한다.
class Superpower
{
public:
virtual ~Superpower();
protected:
// 샌드박스 메서드
virtual void activate() = 0;
// 하위 클래스에서 공통으로 사용되는 코드들
void move(double x, double y, double z);
void playSound(SoundId sound, double volume);
void spwanParticles(ParticleType type, int count);
};
class SkyLaunch : public Superpower
{
protected:
// 하위 클래스에서 샌드박스 메서드를 구현한다.
virtual void activate()
{
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};
디자인 결정에 고려할 사항들 #
- 상위 클래스에서 어떤 공통적인 기능을 제공해야 할까?
- 상위 클래스에서 메서드를 직접 제공할 것인가? 아니면 객체 자체를 제공할 것인가?
- 상위 클래스에서 필요한 객체는 어떻게 얻는가?
- 생성자 매개변수로 받는다.
- 생성자에서 객체를 생성한다.
- 정적 객체로 만든다.
- 중재자 패턴을 사용해서 외부에서 중재자가 객체를 넣어준다.
타입 객체 #
- 게임에는 정말이지 다양한 몬스터가 존재한다. 하나의 최상위 클래스
Monster
를 상속받는Dragon
,Troll
같은 하위 클래스를 종류만큼 많이 만들면 하위 클래스가 정말 많아질 것이다. 그리고 종족을 늘릴 때마다 코드를 추가하고 컴파일해야한다. - 이런 방법은 어떨까? 몬스터마다 종족에 대한 정보를 따로 두고 그것을 참조하는 것이다.
- 그러면 상속 없이도
Monster
,Breed
클래스 두 개만으로 다양한 몬스터를 표현할 수 있겠다. Breed
클래스는 몬스터의 ‘타입’을 정의하므로 이 객체는 ‘타입’ 객체이다.
- 그러면 상속 없이도
- 타입 객체 패턴
- 타입 사용 객체(typed object) 와 타입 객체(type object) 클래스를 정의한다.
- 고유한 코드는 타입 사용 객체에 저장하고, 같은 타입끼리 공유하는 코드는 타입 객체에 저장한다.
- 타입 사용 객체는 타입 객체를 참조한다.
- 같은 타입 객체를 참조하는 타입 사용 객체는 같은 타입인 것처럼 동작한다.
- 언제 쓸까?
- 나중에 어떤 타입이 필요할지 알 수 없다. 새로운 몬스터가 등장하는 DLC(downloadable content)를 제공해야 할지도 모른다.
- 컴파일이나 코드 변경 없이 새로운 타입을 추가하거나 타입을 변경하고 싶다.
- 주의사항
- 타입 객체를 직접 관리해야 한다.
- 타입 객체로 데이터를 정의하기는 쉽지만 동작을 정의하기는 어렵다.
코드 예제 #
// 타입 객체
// 같은 종족이 가지는 공통적인 데이터나 동작을 저장하고 있다.
class Breed
{
private:
int health; // 초기 체력
const char* attack;
public:
Breed(int h, const char* a) : health(h), attack(a) {}
int getHealth() { return health; }
const char* getAttack() { return attack; }
};
// 타입 사용 객체
// 각각의 몬스터는
class Monster
{
private:
Breed& breed; // 타입 객체를 참조한다.
int health; // 현재 체력
public:
Monster(Breed& b) : breed(b), health(b.getHealth()) {}
const char* getAttack() { return breed.getAttack(); }
};
- 팩토리 메서드 패턴을 활용해보자.
// 위의 코드에서는 이렇게 몬스터를 생성하지만
Monster* m = new Monster(someBreed);
// 아래 코드에서는 이렇게 생성할 수 있다.
Monster* m = someBreed.newMonster();
class Breed
{
public:
// 팩토리 메서드. 여기서 객체를 생성한다.
// 이렇게 하면 Monster 클래스가 초기화되기 전에,
// 여기에서 다양한 메모리 관리 기법(메모리 풀 등)을 사용해볼 수 있겠다.
Monster* newMonster()
{
return new Monster(*this);
}
// ...
};
class Monster
{
friend class Breed;
private:
Breed& breed;
int health;
Monster(Breed& b) : breed(b), health(b.getHealth()) {}
public:
const char* getAttack() { return breed.getAttack(); }
}
- 타입 객체도 상속 구조를 사용해서 데이터를 공유하게 만들 수 있다.
class Breed
{
private:
Breed* parent; // 상속받을 종족 객체
int health;
const char* attack;
public:
Breed(Breed* p, int h, const char* a) : parent(p), health(h), attack(a) {}
int getHealth()
{
// 오버라이딩
if (health != 0 || parent == NULL) return health;
// 상속
return parent->getHealth();
}
// ...
};
- 만약에 종족이 바뀌지 않는다면, 굳이
parent
객체 포인터를 들고 있지 않고 생성 시점에 바로 상속을 적용시킬 수도 있겠다.
class Breed
{
private:
int health;
const char* attack;
public:
Breed(Breed* p, int h, const char* a) : parent(p), health(h), attack(a)
{
// 생성자에서 상위 속성을 전부 복사한다.
if (parent != NULL)
{
if (health == 0) health = parent->getHealth();
if (attack == NULL) attack = parent->getAttack();
}
}
int getHealth() { return health; }
const char* getAttack() { return attack; }
};
디자인 결정에 고려할 사항들 #
- 타입 객체를 숨길 것인가? 노출할 것인가?
- 타입 사용 객체를 어떻게 생성할 것인가?
- 타입을 바꿀 수 있는가?
- 상속 없음? 단일 상속? 다중 상속? 어떤 걸 지원할 것인가?