[게임 프로그래밍 패턴] Chapter10 업데이트 메서드

Jangmanbo·2023년 10월 3일
0

패턴

게임에서 보석 주위를 경비하는 해골 병사를 배치해보자.

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

    // 유저 입력을 처리하고 게임을 렌더링한다.
}

이번에는 주기적으로 번개를 쏘는 2개의 마법 석상을 구현해보자.

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()를 호출한다.
이때 각 객체는 한 프레임만큼 동작을 진행하기 때문에, 모든 게임 객체가 동시에 동작한다.

업데이트 메서드 패턴
1. 게임 월드는 객체 컬렉션을 관리한다.
2. 각 객체는 한 프레임 단위의 동작을 시뮬레이션하기 위한 업데이트 메서드를 구현한다.
3. 매 프레임마다 게임은 컬렉션에 들어있는 모든 객체를 업데이트한다.



언제 쓸 것인가?

게임에서 용, 화성인, 유령 등의 객체는 업데이트 메서드 패턴을 쓰고 있을 가능성이 높다.

그러나 체스 말의 경우에는 업데이트 메서드가 맞지 않을 수 있다.
모든 말을 동시에 시뮬레이션하지 않아도 되고, 매 프레임미다 모든 말을 업데이트하지 않아도 되기 때문이다.

업데이트 메서드를 사용하는 경우
1. 동시에 동작해야 하는 객체나 시스템이 게임에 많다.
2. 각 객체의 동작은 다른 객체와 거의 독립적이다.
3. 객체는 시간의 흐름에 따라 시뮬레이션되어야 한다.


주의사항

코드를 한 프레임 단위로 끊어서 실행하는 게 더 복잡하다

동작 코드를 프레임마다 조금씩 실행되도록 쪼개면 코드가 복잡해진다.
하지만 유저입력, 렌더링 등을 게임루프가 처리하려면 어쩔 수 없다.

다음 프레임에서 다시 시작할 수 있도록 현재 상태를 저장해야 한다

해골 병사 코드에서 patrollingLeft변수로 어느 쪽으로 이동하고 있는지를 저장하고 있다.
이런 경우 이전에 중단한 곳으로 되돌아갈 때 필요한 상태를 저장하는 상태 기계를 사용하는 것도 적합하다. (=상태 패턴)

코드 객체는 매 프레임마다 시뮬레이션되지만 진짜로 동시에 되는 건 아니다

업데이트 메서드 패턴은 게임 루프가 객체 컬렉션을 돌면서 모든 객체를 업데이트한다.
즉, 순차적으로 업데이트하는 것이므로 객체의 업데이트 순서가 중요하다.

반대로 객체를 병렬로 업데이트하면 체스 말이 동시에 같은 위치로 이동하는 등 상태가 꼬일 수 있다.
따라서 순차 업데이트가 게임 로직을 작업하기 편리하다.

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

업데이트 메서드 패턴은 많은 게임 동작이 업데이트 메서드 안에 들어가게 된다.
그중에는 업데이트 가능한 객체를 게임에서 추가/삭제하는 코드도 포함이다.

객체 추가

객체가 새로 생기면 객체 목록 뒤에 추가한다.

그러나 새로 생성된 객체가 스폰된 걸 플레이어가 볼 틈도 없이 해당 프레임에서 업데이트를 하게 되는 문제가 있다.
이를 방지하고 싶다면, 미리 객체 개수를 저장하여 업데이트하면 된다.

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

객체 삭제

업데이트하려는 객체 이전에 있는 객체를 삭제할 경우, 객체 하나의 업데이트를 건너뛸 수 있다.

이를 고려해 순회 변수 i를 업데이트하거나, 목록을 다 순회할 때까지 삭제를 늦추는 방법이 있다.
삭제 예정인 객체는 업데이트하지 않고 객체 목록을 다 돌고 난 후 다시 목록을 돌면서 삭제 예정인 객체를 실제로 제거한다.



예제

해골 병사와 석상을 나타내는 클래스

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();

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

매 프레임마다 개체들을 업데이트하는 업데이트 메서드 구현

void World:gameLoop() {
    while(true) {
        // 유저 입력 처리...

        // 각 개체를 업데이트한다.
        for(int i = 0; i < numEntities_; ++i) {
            entities_[i]->update();
        }

        // 물리, 렌더링...
    }
}

클래스 상속에 관하여..

객체별로 다른 동작을 정의하기 위해 Entity 클래스를 상속하는 위의 예시에는 문제가 있다.
게임 월드에 존재하는 모든 업데이트가 필요한 객체가 Entity 클래스를 상속한다면, Entity 클래스의 작은 수정이 모든 객체에 영향을 미치게 된다.

이를 원하지 않는다면 클래스 상속보다는 객체 조합이 낫다.
14장의 컴포넌트 패턴을 사용하면 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() {
        // 번개를 쏜다...
    }
};

해골 병사가 자신이 이동하는 방향을, 석상은 번개를 쏘는 주기와 현재 해당 주기까지 몇 프레임 지났는지에 대한 정보, 즉 자신이 필요한 모든 정보를 직접 들고 관리하고 있다.
이로 인해 게임 월드에 새로운 개체를 추가할 때 게임 루프의 수정이 불필요하여 편리하다.

이것이 바로 업데이트 패턴을 활용하는 이유이다.


시간 전달

지금까지 예시에서는 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);
        }
    }
}


디자인 결정

업데이트 메서드를 어느 클래스에 둘 것인가?

udpate()의 위치는?!

개체 클래스

컴포넌트 클래스

위임 클래스

휴면 객체 처리

0개의 댓글