Game Development, 게임개발/디자인패턴

Update Method, 업데이트 메서드 ** [디자인패턴]

게임이 더 좋아 2021. 10. 9. 18:44
반응형
728x170

 

 

업데이트 메서드의 목적은

컬렉션에 들어있는 객체별로 한 프레임 단위의 작업을 진행하려고 알려줘서 전체를 시뮬레이션한다.

 

 


 

예를 들어 설명해보자

보석을 훔치는 퀘스트를 진행하고 있다.

보석은 오래 전에 죽은 마술사왕의 유골에 놓여 있다.

플레이어는 살금살금 마술사왕의 장엄한 무덤 입구로 다가갔고, 공격을 받..지 않았다.

저주받은 석상은 번개를 쏘지 않았고, 언데드 전사는 입구를 지키고 있지 않았다.

그냥 무덤으로 들어가 보석을 가져왔다. 게임은 끝났고, 우리는 승리했다. 

???? 이게 게임인가 ?????

 

무덤은 무찔러야 하는 경비병이 지키고 있어야 한다.

해골 병사부터 되살려서 문 주위를 순찰하게 만들자.

게임 프로그래밍을 전혀 모른다고 가정했을 때 해골 병사가 비틀거리면서 왔다 갔다 하는 코드를 가장 간단하게 만든다면 다음과 같을 것이다.

 

while (true)
{
  // Patrol right.
  for (double x = 0; x < 100; x++)
  {
    skeleton.setX(x);
  }

  // Patrol left.
  for (double x = 100; x > 0; x--)
  {
    skeleton.setX(x);
  }
}

 

물론 이 코드에는 무한루프가 있어서 해골 병사가 순찰도는 걸 플레이어는 볼 수 없다는 문제가 있다.

이런 건 전혀 재미없다.

우리가 진짜 원하는 것은 해골이 한 프레임에 한 걸음씩 걸어가는 것이다.

어떠한 방법을 이용해서라도

루프를 제거하고 외부 게임 루프를 통해서 반복하도록 고쳐야 한다.

이러면 해골 병사가 순찰하는 동안에도 끊이지 않고 유저 입력에 반응하고 렌더링할 수 있다.

 

Entity skeleton;
bool patrollingLeft = false;
double x = 0;

// Main game loop:
while (true)
{
  if (patrollingLeft)
  {
    x--;
    if (x == 0) patrollingLeft = false;
  }
  else
  {
    x++;
    if (x == 100) patrollingLeft = true;
  }

  skeleton.setX(x);

  // Handle user input and render game...
}

 

코드가 이전과 마찬가지로 그렇게 복잡하진 않지만 간단하지도 않다.

그렇게 된다면 이제는 매 프레임마다 외부 게임 루프로 나갔다가 직전 위치에서 다시 시작해야하기 때문에

patrollingLeft 변수를 써서 방향을 명시적으로 기록해야 한다.

 

어쨌거나 이 코드는 동작을 한다.

조금 더 캐릭터를 추가시켜보자.

// Skeleton variables...
Entity leftStatue;
Entity rightStatue;
int leftStatueFrames = 0;
int rightStatueFrames = 0;

// Main game loop:
while (true)
{
  // Skeleton code...

  if (++leftStatueFrames == 90)
  {
    leftStatueFrames = 0;
    leftStatue.shootLightning();
  }

  if (++rightStatueFrames == 80)
  {
    rightStatueFrames = 0;
    rightStatue.shootLightning();
  }

  // Handle user input and render game...
}

 

음.. 캐릭터 몇명만 추가한 것으로도

코드가 복잡해지고 어지러워지기 시작했다.

메인 루프에는 각자 다르게 처리할 게임 개체용 변수와 실행코드가 가득 들어 있다.

이들 모두를 한 번에 실행하려다 보니 코드를 한 곳에 뭉쳐놔야 한다.

**그냥 봐도 유지 보수에 비효율적이다.

 

이렇게 뭉쳐놓는 대신.. 캡슐화를 시키는 것이 신상에 좋을 것이다.

모든 개체가 자신의 동작을 캡슐화해서 루프를 돌리는 것이 좋을 것이다.

이를 위해 추상 메서드인 update()를 정의해 추상 계층을 더하면 되겠다.

게임 루프는 업데이트가 가능하다는 것만 알 뿐 정확한 자료형은 모르는 채로 객체 컬렉션을 관리한다.

덕분에 각 객체의 동작을 게임 엔진과 다른 객체로부터 분리할 수 있다.

 

게임루프는 매 프레임마다 객체 컬렉션을 쭉 돌면서 update()를 호출한다.

이때 각 객체는 한 프레임만큼 동작을 진행한다.

덕분에 모든 게임 객체가 동시에 동작할 수 있다.

 

**실제로는 한 객체를 업데이트하는 동안 나머지 객체는 가만히 있기 때문에..

진짜로 동시에 동작하는 것은 아니다.

 

게임 루프에는 객체를 관리하는 동적 컬렉션이 있어서

컬렉션에 객체를 추가, 삭제하기만 하면 레벨에 객체를 쉽게 넣었다 뺐다 할 수 있다.

더 이상 아무것도 하드코딩되어 있지 않기 때문에

레벨 디자이너가 원하는 대로 데이터 파일을 이용해 레벨을 찍어낼 수 있다.

 


 

게임 월드는 객체 컬렉션을 관리한다.

각 객체는 한 프레임 단위의 동작을 시뮬레이션하기 위한 업데이트 메서드를 구현한다.

매 프레임마다 게임은 컬렉션에 들어 있는 모든 객체를 업데이트한다.

 

 

게임 루프 패턴이 가장 중요하다고 했지만 update 역시 그만큼 중요하다. 

플레이어와 상호작용하며 살아 움직이는 개체가 많은 게임에서는

업데이트 메서드 패턴을 어떻게든 쓰기 마련이다.

게임에 우주 해병, 용, 화성인, 유령, 운동선수 같은 게 있다면

업데이트 메서드 패턴을 쓰고 있을 가능성이 높다.

 

하지만 게임이 더 추상적이거나 게임에서 움직이는 것들이 살아 있다기보단 체스 말에 가깝다면

업데이트 메서드가 맞지 않을 수도 있다.

**항상 상호작용하는 것이 아니니까.

 

업데이트 메서드는 써야할 상황이 몇가지 있다.

 

-동시에 동작해야 하는 객체나 시스템이 게임에 많다.

 

-각 객체의 동작은 다른 객체와 거의 독립적이다.

 

-객체는 시간의 흐름에 따라 시뮬레이션되어야 한다.

 

 


 

물론 업데이트 메서드는 각 게임엔진에서 구현되어 있지만

실제로 업데이트 메서드를 만들 때의 주의사항을 알아보자

 

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

앞의 예제 코드에서도 2가지 중 두 번째 코드가 게임 루프가 매 프레임마다 호출하도록 제어권을 넘기면서 하듯이 어려워진다.

유저 입력, 렌더링 등을 게임 루프가 처리하려면 거의 두 번째 코드처럼 만들어야 하기 때문에

사실 첫 번째 예제는 현실성이 거의 없는 코드다.

그래도 이런 식으로 동작 코드를 프레임마다 조금씩 실행되도록 쪼개어 넣으려면

코드가 복잡해져서 구현 비용이 더 든다.

 

 

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

첫 번째 예제에서는 해골 병사의 이동 방향을 변수에 따로 저장하지 않아도 어느 코드가 실행 중인지를 보고 유추할 수 있다.

이걸 "한 프레임에 한 번" 실행되는 형태로 바꿨더니 이동 방향을 저장할 patrollingLeft 변수가 필요했다.

코드가 반환하고 나면 이전 실행 위치를 알 수 없기 때문에 다음 프레임에서도 돌아갈 수 있도록 정보를 충분히 따로 저장해야 한다.

이럴 때는 상태 패턴이 좋을 수 있다.

상태 기계가 게임에서 많이 쓰이는 이유 중 하나는

이전에 중단한 곳으로 되돌아갈 때 필요한 상태를 상태 기계가 저장하기 때문이다.

 

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

게임 루프는 컬렉션을 돌면서 모든 객체를 업데이트한다. 

update 함수에서는 다른 게임 월드 상태에 접근할 수 있는데,

특히 업데이트 중인 다른 객체에도 접근할 수 있다.

이러다 보니 객체 업데이트 순서가 중요하다.

 

객체 목록에서 A가 B보다 앞에 있다면 A는 B의 이전 프레임 상태를 본다.

B 차례가 왔을 때 A는 이미 업데이트했기 때문에 A의 현재 프레임 상태를 보게 된다.

플레이어에게는 모두가 동시에 움직이는 것처럼 보일지 몰라도 내부에서는 순서대로 업데이트된다.

 다만 한 프레임 안에 전체를 다 도는 것 뿐이다.

순차적으로 업데이트하면 게임 로직을 작업하기가 편하다.

객체를 병렬로 업데이트하다 보면 꼬일 수 있다.

체스에서 흑과 백이 동시에 이동할 수 있다고 해보자.

둘 다 동시에 같은 위치로 말을 이동하려 든다면 어떻게 해야 할까?

순차 업데이트에서는 이런 문제를 피할 수 있다.

게임 월드가 유효한 상태를 유지하면서 업데이트할 때마다 점진적으로 바꿔나가면 상태가 꼬이지 않아 중재할 게 없다.

 

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

이 패턴에서는 많은 게임 동작이 업데이트 메서드 안에 들어가게 한다.

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

해골 경비병을 죽이면 아이템이 떨어진다고 해보자.

객체가 새로 생기면 보통을 별 문제없이 객체 목록 뒤에 추가하면 된다.

계속 객체 목록을 순회하다 보면 결국에는 새로 만든 객체까지 도달해 그것까지 업데이트하게 될 것이다.

하지만 이렇게 하면 새로 생성된 객체가 스폰된 걸 플레이어가 볼 틈도 없이 해당 프레임에서 작동하게 된다.

(렌더링도 하지 않아서 등장한 지도 모르는 객체가 업데이트된 동작을 플레이어가 보는 것이다.)

이게 싫다면 업데이트 루프를 시작하기 전에 목록에 있는 객체 개수를 미리 저장해놓고 그만큼만 업데이트하면 된다.

**실제로 추가된(렌더링) 이후에 플레이어가 그것을 인식하고 update를 수행하게 끔 한다.

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

 

여기에서 objects_는 게임에서 업데이트 가능한 객체 배열이고 numObjects_는 객체 개수다.

객체가 추가되면 numObjects_ 가 증가한다.

루프 시작 전에 객체 개수를 미리 numObjectsThisTurn에 저장했기 때문에

루프는 이번 프레임에 추가된 객체 앞에서 멈춘다. 

순회 도중에 객체를 삭제하는 것은 더 어렵다. 괴물을 죽였다면 객체 목록에서 빼야 한다.

**그러나 객체를 삭제할 때에는 주의해야 한다.

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

 

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

 

여기 간단한 루프 코드에서는 매번 루프를 돌 때마다 업데이트되는 객체의 인덱스를 증가시킨다.

플레이어를 업데이트할 차례가 되었을 때 배열 내용은 아래 그림과 같다.

 

순회 도중에 샂게해서 PEASANT, 소작농,농부가 삭제되었다.

플레이어를 업데이트할 때(i가 1일 때), 영웅이 괴물을 죽였기 때문에 괴물은 배열에서 빠진다.

 플레이어는 배열에서 0 번째로 이동하고, 농부는  1번째로 올라간다.

영웅을 업데이트한 후에 i 값은 2로 증가한다.

림 오른쪽에서 보는 것처럼 농부는 업데이트 되지 않는다.

 

이를 고려해서 객체를 삭제할 때 순회 변수 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)
  {
    // Handle user input...

    // Update each entity.
    for (int i = 0; i < numEntities_; i++)
    {
      entities_[i]->update();
    }

    // Physics and rendering...
  }
}

 

 


 

개체를 상속받는 것을 알아보자

 

객체별로 다른 동작을 정의하기 위해 Entity 클래스를 상속한다. 

바벨탑처럼 쌓아 올린 클래스 상속 구조는 너무나 높고 거대해서 해를 가릴 지경이었다. 

 

시간이 지나면서 거대한 상속 구조가 형편없다는 것을 알게 되었다.

이를 쪼개지 않고서는 유지보수가 불가능했다.

GoF도 이미 알고 있어서 클래스 상속보다는 객체 조합이 낫다라고 썼다.

 

이런 깨달음이 게임 산업 전반에 퍼져나갈 때쯤 등장한 해결책이 컴포넌트 패턴이다.

컴포넌트 패턴을 사용하면서 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();

      // Reset the timer.
      frames_ = 0;
    }
  }

private:
  int frames_;
  int delay_;

  void shootLightning()
  {
    // Shoot the lightning...
  }
};

 

이 클래스도 게임 루프에 있던 코드 대부분을 가져와 이름만 일부 바꿔서 써놓았다.

이들 변수를 status 클래스로 옮겼기 때문에 석상 인스턴스가 타이머를 각자 관리할 수 있어

석상을 원하는 만큼 많이 만들 수 있다.

이런 것이 업데이트 패턴을 활용하는 진짜 이유다.

객체가 자신이 필요한 모든 것을 직접 들고 관리하기 때문에

게임 월드에서 새로운 개체를 추가하는 것이 쉬워졌다.

 

업데이트 메서드 패턴은 따로 구현하지 않아도 개체를 게임에 추가할 수 있게 해준다.

덕분에 데이터 파일이나 레벨 에디터 같은 것으로 월드에 개체를 유연하게 추가할 수 있다.

 

UML 클래스 다이어그램으로 그린 예시다.

 

 


 

우리가 생각한 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);
    }
  }
}

 

해골 병사의 이동거리는 지난 시간에 따라 늘어난다.

가변 시간 간격에 대한 처리를 해야해서 더 복잡해지긴 했다.

시간 간격이 더 커지면 해골 병사가 범위를 벗어날 수도 있다.

 


 

이제 중요한 것은 업데이트 메서드를 어느 클래스에 두고 사용하느냐? 이다.

 

개체 클래스둬볼까?

이미 개체 클래스가 있다면 다른 클래스를 추가하지 않아도 된다는 점에서 가장 간단한 방법이다.

개체가 별로 없다면 괜찮지만 .. 사실 이렇게 쓰는 경우는 거의 없다.

개체 종류가 많은데.. 새로운 동작을 만들 때마다 개체 클래스를 상속받는 것이 별로다.

 

컴포넌트 클래스에 둘까?

컴포넌트 패턴을 쓰고 있다면 생각할 것도 없다. 컴포넌트는 알아서 자기 자신을 업데이트한다.

업데이트 메서드 패턴이 게임 개체를 게임 월드에 있는 다른 개체들과 디커플링하는 것과 마찬가지로,

컴포넌트 패턴은 한 개체의 일부를 개체의 다른 부분들과 디커플링한다.

++렌더링, 물리, AI는 스스로 잘돌아간다.

 

위임 클래스에 둘까?

클래스의 동작 일부를 다른 객체에 위임하는 것과 관련된 패턴이 있는데.. 상태 패턴은 상태가 위임하는 객체를 바꿈으로써 객체의 동작을 변경할 수 있게 해준다.

타입 객체 패턴은 같은 "종류"의 여러 개체가 동작할 수 있도록 공유할 수 있다.

이들 패턴 중 하나를 쓰고 위임클래스에 update를 쓰는 것이 자연스럽다.

void Entity::update()
{
  // Forward to state object.
  state_->update();
}

 


 

종종 여러가지 이유로 일시적으로나마 업데이트가 필요 없는 객체가 생길 수 있는데

그러한 객체가 많아지면 프레임마다 쓸데 없이 객체를 순회하면서 CPU를 낭비하는 경우가 생긴다.

그래서 몇가지 대안을 내놓는데

업데이트가 필요한 "살아 있는" 객체만 따로 컬렉션에 모아두는 것이다.

객체가 비활성화되면 컬렉션에서 제거하고 활성화되면 컬렉션에 추가하는 것이다.

반응형
그리드형