기록공간

9장. 게임을 위한 순차 패턴 - 업데이트 매서드 본문

Game Design Pattern, Logic

9장. 게임을 위한 순차 패턴 - 업데이트 매서드

입코딩 2020. 4. 5. 11:55
반응형

 


"객체별로 한 프레임 단위의 작업을 진행하라고 알려줘서 전체를 시뮬레이션한다."

 


우리는 플레이어가 되어 보석을 훔치는 퀘스트를 진행하고 있다 가정해보자. 보석은 오래전에 죽은 왕의 유골에 놓여 있다. 플레이어는 왕의 장엄한 무덤 입구로 다가갔고, 함정이 있었지만 공격을 받지 않았다. 또한 언데드 전사는 입구를 지키고 있지 않았다. 그냥 무덤으로 들어가 보석을 가져왔다. 게임은 끝났고, 플레이어는 퀘스트를 완료했다.

 

뭔가 이상하지 않은가?

 

무덤에는 무찔러야 하는 경비병이 지키고 있어야 한다. 해골 병사를 되살려 문 주위를 순찰하게 만들자. 코드를 간단하게 만들면 다음과 같을 것이다.

while(true)
{
    // 오른쪽으로 간다.
    for(double x = 0; x < 100; ++x) { skeleton.setX(x); }
    // 왼쪽으로 간다.
    for(double x = 100; x > 0; --x) { skeleton.setY(y); }
}

이 코드는 무한루프가 있어 해골 병사가 순찰도는 걸 볼 수 없다는 문제가 있다. 우리가 원하는 것은 해골이 한 프레임에 한 걸음씩 걸어가는 것이다. 루프를 제거하고 외부 게임 루프를 통해서 반복하도록 고쳐야 한다. 이러면 순찰 도중에도 끊이지 않고 유저 입력과 렌더링을 할 수 있게 된다.

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);
    
    // 유저 입력을 처리하고 렌더링한다.
}

이전 코드에 비해서 조금 복잡해졌다. 이제는 매 프레임마다 외부 게임 루프로 나갔다가 직전 위치에서 다시 시작해야 하기 때문에 patrollingLeft 변수를 써서 방향을 명시적으로 기록해야 한다.

 

이제 마법 석상을 두 개 정도 추가해보자. 석상은 자주 번개를 쏜다고 가정한다. 코드는 다음과 같다.

// 해골 병사용 변수들...
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 함수에서는 다른 게임 월드 상태에 접근할 수 있는데, 특히 업데이트 중인 다른 객체에도 접근할 수 있다. 이러다 보니 업데이트 순서가 중요하다. (왜 제목에 순차 패턴이 들어가는지 생각해보면 된다)

순차적으로 업데이트하면 게임 로직을 작업하기가 편하다. 객체를 병렬로 동시에 업데이트하다 보면 꼬일 수 있다. 예를 들어 체스에서 흑과 백이 동시에 같은 위치로 이동하려 든다면 문제가 생길 것이다. 순차 업데이트는 이런 문제를 회피할 수 있다.  

 

업데이트 도중 객체 목록을 바꾸는 건 조심해야 한다

이 패턴에는 업데이트 가능한 객체를 게임에 추가, 삭제하는 코드도 포함된다. 

해골 경비병을 죽이면 아이템이 떨어진다고 해보자. 객체가 새로 생기면 보통은 객체 목록에 그냥 추가하면 된다. 객체 목록을 순회하다 보면 결국 새로 만든 객체도 업데이트하게 될 것이다.

하지만 이렇게 하면 스폰된 걸 볼 틈도 없이 해당 프레임에서 작동하게 된다. 그래서 업데이트 루프를 시작하기 전에 목록에 있는 객체 개수를 미리 저장해놓고 그만큼만 업데이트하면 된다.

int numObjectsThisTurn = numObjects_;
for(int i = 0; i < numObjectThisTurn; ++i)
{
    objects_[i]->update();
}

순회 도중 객체를 삭제하는 건 더 어렵다. 만약 몬스터를 죽였다면 객체 목록에서 빼야 한다. 업데이트하려는 객체 이전에 있는 객체를 삭제할 경우 의도치 않게 객체 하나를 건너뛸 수 있다.

for(int i = 0; i < numObjects_; ++i)
{
    objects_[i]->update();
}

다음 루프 코드에서는 루프를 돌 때마다 업데이트되는 객체의 인덱스를 증가시킨다. 그림은 다음과 같다.

 

플레이어가 몬스터를 죽였기 때문에 몬스터는 배열에서 빠진다. 플레이어는 배열 0번째로 이동하고, NPC는 1번째로 올라간다. 플레이어를 업데이트한 후에 i값은 2로 증가한다. 그림 오른쪽에서 보는 것처럼 NPC는 건너뛰었기 때문에 업데이트되지 않는다.

이를 고려해서 객체를 삭제할 때 순회 변수 i를 업데이트하는 것도 한 방법이다. 목록을 다 순회할 때까지 삭제를 늦추는 수도 있다. 삭제할 객체를 죽었다고 표시해놓고 업데이트가 끝나면 지우는 방식으로 말이다. 


예제 코드

간단하게 구현해 보도록 하자. 해골 병사와 석상을 표현할 Enitity 클래스부터 만들어보자.

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

가장 중요한 부분은 추상 메서드인 update이다.

 

게임은 개체 컬렉션을 관리한다. 예제에서는 게임 월드를 대표하는 클래스에 개체 컬렉션 관리를 맡긴다.

class World
{
public:
    World() : numEntities_(0) {}
    void gameLoop();

private:
    Entity* entities_[MAX_ENTITIES];
    int numEntities_;
};

이제 매 프레임마다 개체들을 업데이트하면 업데이트 메서드 구현이 끝난다.

void World::gameLoop() 
{
    while(true)
    {
        // 유저 입력 처리
        
        // 각 개체를 업데이트
        for(int i = 0; i < numEntities_; ++i)
        {
            entities_[i]->update();
        }
        // 물리 렌더링 등..
    }
}

 

개체 정의

이제 순찰을 도는 해골 경비병과 번개를 쏘는 마법 석상을 정의해보자. 해골 경비병의 순찰을 정의하기 위해, 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_;
};

앞에서 본 순찰 코드를 update에 거의 그대로 가져왔다. 또한 patrollingLeft를 멤버 변수로 두어 update() 호출 후에도 값을 유지할 수 있게 되었다.

 

석상도 정의해보자.

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()
    { // 번개를 쏜다. }
};

이 코드 역시 앞에 있는 내용을 그대로 가져와 이름만 바꿨다. 사실 코드가 더 단순해졌다. 이제 프레임 카운터와 발사 주기를 지역변수로 따로 관리할 필요가 없어졌다.

 

이들 변수를 Statue 클래스로 옮겼기 때문에 더 많은 석상 인스턴스를 만들 수 있게 되었다. 이런 게 업데이트 패턴을 활용하는 진짜 숨은 동기이다. 

 

업데이트 메서드 패턴은 따로 구현하지 않아도 개체를 게임에 추가할 수 있게 해 준다. 덕분에 데이터 파일이나 레벨 에디터 같은 걸로 월드에 개체를 유연하게 추가할 수 있다.

 

 

시간 전달

대략적인 패턴 설명은 여기서 끝나지만, 좀 더 다듬어보자. 지금까지는 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);
        }
    }    
}

이렇게 하면 해골 병사의 순찰 이동 거리는 지난 시간에 따라 늘어난다.


자료의 출처는 http://gameprogrammingpatterns.com/update-method.html입니다.

반응형
Comments