업데이트 메서드 패턴, Update Method Pattern
컬렉션에 들어 있는 객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션 한다.
게임 프로그래밍을 전혀 모른다고 가정했을 때 해골 병사가 비틀거리면서 왔다 갔다 하는 코드를 가장 간단하게 만든다면 다음과 같을 것이다.
while(true) {
// 오른쪽으로 간다.
for (double x = 0; x < 100; ++x) { skeleton.setX(x); }
// 왼쪽으로 간다.
for (double x = 100; x > 0; --x) { skeleton.setX(x); }
}
이 코드는 무한루프가 있어서 해골 병사가 순찰도는 걸 플레이어는 볼 수 없다는 문제가 있다. 진짜 원하는 것은 해골이 한 프레임에 한 걸음씩 걸어가는 것이다.
루프를 제거하고 외부 게임 루프를 통해서 반복하도록 고쳐야 한다.
Entity skeleton;
bool patrollingLeft = false;
double x = 0;
// 메인 게임 루프
while(true) {
if(patrollingLeft) {
x--;
if(x == 0) patrollingLeft = false;
}
else {
x++;
if(x == 100) patrollingLeft = true;
}
skeleton.setX(x);
// 유저 입력을 처리하고 게임을 렌더링한다.
}
위 코드는 동작을 제대로 하기는 한다.
이제 마법 석상을 두 개 추가해보자. 이 마법 석상은 번개를 쏜다.
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;
// 메인 게임 루프
while(true) {
// 해골 병사용 코드...
if(++leftStatueFrames == 90) {
leftStatueFrames = 0;
leftStatue.shootLightning();
}
if(++rightStatueFrames == 80) {
rightStatueFrames = 0;
rightStatue.shootLightning();
}
// 유저 입력을 처리하고 게임을 렌더링한다.
}
코드가 점점 유지보수하기 어려워진다. 메인 루프에는 각각 다르게 처리할 게임 개체용 변수와 실행 코드가 가득하다. 이들 모두를 한 번에 실행하려다 보니 코드를 한데 뭉쳐놔야 한다.
해결책은 모든 객체가 자신의 동작을 캡슐화하면된다. 이러면 게임 루프를 어지럽히지 않고도 쉽게 개체를 추가, 삭제할 수 있다.
이를 위해 추상 메서드인 update()를 정희애 추상 계층을 더한다.
게임 루프는 매 프레임마다 객체 컬렉션을 쭉 돌면서 update()를 호출한다. 게임 루프에는 객체를 관리하는 동적 컬렉션이 있어서, 컬렉션에 객체를 추가, 삭제가 가능하다.
패턴
게임 월드는 객체 컬렉션을 관리한다. 각 객체는 한 프레임 단위의 동작을 시물레이션하기 위한 업데이트 메서드를 구현한다. 매 프레임마다 게임은 컬렉션에 들어 있는 모든 객체를 업데이트 한다.
업데이트 메서드 패턴은 이럴 때 쓸 수 있다.
- 동시에 동작해야 하는 객체나 시스템이 게임에 많다.
- 각 객체의 동작은 다른 객체와 거의 독립적이다.
- 객체는 시간의 흐름에 따라 시뮬레이션되어야 한다.
주의사항
모든 객체는 매 프레임마다 시뮬레이션 되지만 진짜로 동시에 되는 건 아니다.
게임 루프는 컬렉션을 돌면서 모든 객체를 업데이트한다. update 함수에서는 다른 게임 월드 상태에 접근할 수 있는데, 특히 업데이트 중인 다른 객체에도 접근할 수 있다. 이러다 보니 객체 업데이트 순서가 중요하다.
객체 목록에서 A가 B보다 앞에 있다면, A는 B의 이전 프레임 상태를 본다. B 차례가 왔을 때 A는 이미 업데이트 했기 때문에 A의 현재 프레임 상태를 보게된다. 플레이어게는 모두가 동시에 움직이는 것처럼 보일지 몰라도 내부에서는 순서대로 업데이트 된다.
순차적으로 업데이트하면 게임 로직을 작업하기가 편하다. 객체를 병렬로 업데이트하다 보면 꼬일 수 있다. 체스에서 흑과 백이 동시에 이동할 수 있다고 해보자. 둘 다 동시에 같은 위치로 말을 이동하려 든다면 어떻게 할까? 순차 업데이트에서는 이런 문제를 피할 수 있다.
업데이트 도중에 객체 목록을 바꾸는 건 조심해야 한다.
업데이트 메서드 패턴에서는 많은 게임 동작이 업데이트 메서드 안에 들어가게 된다. 그중에는 업데이트 가능한 객체를 게임에서 추가, 삭제하는 코드도 포함된다.
해골 경비병을 죽이면 아이템이 떨어진다고 해보자. 객체가 서로 생기면 보통은 별 문제없이 객체 목록 뒤에 추가하면 된다. 계속 객체 목록을 순회하다 보면 결국에는 새로 만든 객체까지 도달해 그것까지 업데이트하게 될 것이다.
하지만 이렇게 하면 새로 생성된 객체가 스폰된 걸 프레이어가 볼 틈도 없이 해당 프레임에서 작동하게 된다. 이게 싫다면 업데이트 루프를 시작하기 전에 목록에 있는 객체 개수를 미리 저장해놓고 그 만큼만 업데이트하면 된다.
int numObjectsThisTurn = numObjects_;
for(int i = 0; i < numObjectsThisTurn; ++i) {
objects_[i]->update();
}
`
루프 시작 전에 객체 개수를 미리 numObjectsThisTurn에 저장했기 때문에 루프는 이번 프레임에 추가된 객체 앞에서 멈춘다.
순회 도중에 객체를 삭제하는 건 더 어렵다. 괴물을 죽였다면 객체 목록에서 빼야 한다. 업데이트하려는 객체 이전에 있는 객체를 삭제할 경우, 의도치 않게 하나를 건너뛸 수 있다.
영웅을 업데이트할 때(i가 1 일 때), 영웅이 괴물을 죽였기 때문에 괴물은 배열에서 빠진다. 영웅은 배열에서 0번째로 이동하고, 농부는 1번째로 올라간다. 영웅을 업데이트한 후에 i 값은 2로 증가한다.
이를 고려해서 객체를 삭제할 때 순회 변수 i 를 업데이트하는 것도 한 방법이다. 목록을 다 순회할 떄까지 삭제를 늦추는 수도 있다. 객체에 '죽었음' 표시를 하되 그대로 둬서, 업데이트 도중에 죽은 객체를 만나면 그냥 넘어간다. 전체 목록을 다 돌고 나면 다시 목록을 돌면서 시체를 제거한다.
예제 코드
해골 병사와 석상을 표현할 Entity 클래스부터 만들어보겠다.
class Entity {
public:
Entity() : x_(0), y_(0) {}
virtual ~Entity() {}
virtual void update() = 0;
double x() const { return x_; }
double y() const { return y_; }
void setX(double x) { x_ = x; }
void setY(double y) { y_ = y; }
private:
double x_;
double y_;
};
게임은 개체 컬렉션을 관리한다. 예제에서는 게임 월드를 대표하는 클래스에 개체 컬렉션 관리를 맡긴다.
class World {
public:
World() : numEntities_(0) {}
void gameLoop()l
private:
Entity* entities_[MAX_ENTITIES];
int numEntities_;
};
매 프레임마다 개체들을 업데이트하면 업데이트 메서드 구현이 끝난다.
void World:gameLoop() {
while(true) {
// 유저 입력 처리...
// 각 개체를 업데이트한다.
for(int i = 0; i < numEntities_; ++i) {
entities_[i]->update();
}
// 물리, 렌더링...
}
}
개체 정의
순찰을 도는 해골 경비병과 번개를 쏘는 마법 석상을 정의해보자.
class Skeleton : public Entity {
public:
Skeleton() : patrollingLeft_(false) {}
virtual void update() {
if(patrollingLeft_) {
setX(x() - 1);
if(x() == 0) patrollingLeft_ = false;
}
else {
setX(x() + 1);
if(x() == 100) patrollingLeft_ = true;
}
}
private:
bool patrollingLeft_;
};
석상도 정의해보자.
class Statue : public Entity {
public:
Statue(int delay) : frames_(0), delay_(delay) {}
virtual void update() {
if(++frames_ == delay_) {
shootLightning();
// 타이머 초기화
frames_ = 0;
}
}
private:
int frames_;
int delay_;
void shootLightning() {
// 번개를 쏜다...
}
};
이들 변수를 Status 클래스로 옮겼기 때문에 석상 인스턴스가 타이머를 각자관리할 수 있어 석상을 원하는 만큼 많이 만들 수 있다. 이런 게 업데이트 패턴을 활용하는 진짜 숨은 동기다. 객체가 자신이 필요한 모든 걸 직접 들고 관리하기 때문에 게임 월드에 새로운 개체를 추가하기가 훨씬 쉬어진다.
업데이트 메서드 패턴은 따로 구현하지 않아도 개체를 게임에 추가할 수 있게 해준다. 덕분에 데이터 파일이나 레벨 에이더 같은 걸로 월드에 개체를 유연하게 추가할 수 있다.
시간 전달
여기까지가 핵심이지만 좀 더 다듬어보자. 지금까지는 update() 를 부를 때마다 게임 월드 상태가 동일한 고정 단위 시간만큼 진행된다고 가정하고 있었다.
가변 시간 간격을 쓰는 게임도 많다. 가변 시간 간격에서는 게임 루프를 돌 때마다 이전 프레임에서 작업 진행과 렌더링에 걸린 시간에 따라 시간 간격을 크게 혹은 짧게 시뮬레이션 한다.
즉, 매번 update 함수는 얼마나 많은 시간이 지났는지를 알아야 하기 때문에 지난 시간을 인수로 받는다. 해골 경비병은 가변 시간 간격을 아래와 같이 처리한다.
void Skeleton::update(double elapsed) {
if(patrollingLeft_) {
x -= elapsed;
if(x <= 0) {
patrollingLeft_ = false;
x = -x;
}
}
else {
x += elapsed;
if(x >= 100) {
patrollingLeft_ = true;
x = 100 - (x - 100);
}
}
}
해골 병사의 이동 거리는 지난 시간에 따라 늘어진다. 가변 시간 간격을 처리하느라 코드가 좀 더 복잡해졌음을 확인할 수 있다.
More
- 업데이트 메서트 패턴은 게임 루프 패턴, 컴포넌트 패턴과 함꼐 게임 코드의 핵심을 이룬다.
- 많은 개체나 컴포넌트를 매 프레임마다 루프에서 업데이트할 때 캐시 성능이 걱정된다면 데이터 지역성 패턴을 확인하자.
이 포스트의 글과 그림의 출처는 http://gameprogrammingpatterns.com/update-method.html 입니다.
by 소년코딩
추천은 글쓴이에게 큰 도움이 됩니다.
악플보다 무서운 무플, 댓글은 블로그 운영에 큰 힘이됩니다.