본문 바로가기
IT/Game

게임 프로그래밍 패턴 11장 ~ 13장 (22. 06. 09)

by YEON-DU 2023. 6. 25.
반응형

5주차 (22. 06. 09)

행동 패턴

게임 내 개체들이 해야 할 일을 알려주는 대본에 해당하는 행동behavior.

Chapter11. 바이트 코드

가상 머신 명령어를 인코딩한 데이터로 행동을 표현할 수 있는 유연함을 제공.

성능과 안정성을 위해 C++ 같은 중량 언어heavyweight language를 사용.

⇒ 하드웨어 성능을 최대한 끌어낼 수 있는 저수준 표현과 버그를 막거나 적어도 가둬두기 위한 풍부한 타입 시스템을 함께 제공.

 

게임 출시 이후에도 업데이트를 통해서 버그를 고치거나 콘텐츠를 추가할 수 있어야 함.

전부 하드코딩되어있다면 변경사항이 있을 때마다 게임 실행 파일을 패치해야 한다.

심지어 모드mod를 지원해야 한다면? 유저가 게임에서 커스텀을 하고 싶다면?

게임을 빌드하기 위해 컴파일러 툴체인toolchain을 다 갖춰야 하고, 소스 코드를 공개해야 한다. 유저의 커스텀으로 인해 버그가 생긴다면 다른 플레이어들에게 크래시가 생길 수도 있다.

행동을 데이터 파일에 정의 → 게임 코드에서 읽어서 실행

 

인터프리터 패턴(해석자 패턴)과의 비교를 통해 바이트코드 패턴의 장단점을 살펴본다.

 

인터프리터 패턴

  • (1+2) * (3-4) ⇒ 언어 문법에 따라 각각 객체로 변환
  • 숫자 리터럴 4개는 각기 객체 (숫자값을 래핑한 객체)가 된다.
  • 연산자도 객체로 바뀌며, 피연산자도 함께 참조한다.
  • 괄호와 우선순위까지 고려하게 되면 객체 트리로 변환된다.

추상 구문 트리로 만든 중첩 표현식

  • 추상 구문 트리를 만드는 데에서 끝나지 않고 이를 실행.
  • 객체지향 방식으로 표현식이 자기 자신을 계산하도록 정의.

단점

  • 코드를 로딩하면서 작은 객체를 엄청 많이 만들고 연결해야 함.
  • 객체와 객체를 잇는 포인터는 많은 메모리를 소모.
  • 포인터를 따라서 하위표현식에 접근해야하므로 데이터 캐시에 치명적. 가상 메서드를 호출하는 것은 명령어 캐시instruction cache에 치명적.
  • 매우 느리다 = 대부분의 프로그래밍 언어는 인터프리터 패턴을 사용하지 않는다.

 

가상 기계어

장점

  • 밀도가 높다.
  • 바이너리 데이터가 연속해서 꽉 차있어서 한 비트도 낭비하지 않음
  • 선형적이다.
  • 명령어가 같이 모여있고 순서대로 실행. 흐름 제어문을 실행하는 경우 외에는 메모리를 넘나들지 않는다.
  • 저수준이다.
  • 각 명령어는 비교적 최소한의 작업만 실행.
  • 빠르다.

장점이 좋지만, 게임에서 실행되는 기계어를 유저에게 제공한다면 해커들에게 해킹 당하기 딱 좋다. 기계어의 성능과 인터프리터 패턴의 안정성 사이에서 절충이 필요.

그렇다면 우리만의 가상 기계어를 정의하고, 실행하는 간단한 에뮬레이터를 만든다면?

실제 기계어의 장점을 갖췄지만 게임에서 완전히 제어하므로 안전하다.

에뮬레이터를 가상 머신virtual machine(VM)이라 부르고, VM이 실행하는 가상 바이너리 기계어는 바이트코드라고 부른다.

 

패턴

명령어 집합은 실행할 수 있는 저수준 작업들을 정의.

명령어는 일련의 바이트로 인코딩.

가상 머신은 중간 값들을 스택에 저장해가면서 명령어 하나씩 실행.

바이트코드는 스택 기반으로 처리되는 경우가 많다.

효율화를 위해 레지스터 기반으로 처리하는 경우도 있음.

바이트코드 패턴은 복잡하고 쉽게 적용하기도 어렵다.

정의할 행동은 많은데 게임 구현에 사용한 언어로는 구현하기 어려울 때 사용.

주로 MMORPG에서는 실제로 바이트코드를 사용함.

바이트 코드는 네이티브 코드보다는 느리므로 성능이 민감한 곳에는 적합하지 않음.

저수준 바이트코드 명령어는 성능 면에서 뛰어나지만, 바이너리 바이트코드 형식은 사용자가 작성할 만한게 아니다.

행동 구현을 코드로 따로 빼낸 이유는 고수준으로 표현하기 위해서이다.

바이트코드에는 보통 프론트엔드가 필요하다.

행동을 미리 정의해두고, 유저가 볼 수 있는 ‘행동 제작을 위한 GUI 툴’을 만들어서 정의하도록 유도하면 사용자가 ‘잘못된’코드를 아예 만들 수 없게 된다.

Data Driven 방식.

 

Chapter12. 하위 클래스 샌드박스

상위 클래스가 제공하는 기능들을 통해서 하위 클래스에서 행동을 정의.

원시명령을 protected 메서드로 만들어, 하위 클래스에서 쉽게 접근할 수 있도록 한다.

원시명령을 protected(비가상 함수)로 만드는 이유는 함수가 하위 클래스용이라는 걸 알려주기 위해서이다. 원시명령이 준비되면, 하위 클래스가 구현해야하는 샌드박스 메서드를 순수 가상 메서드로 만들어 protected에 둔다.

상위 클래스가 제공하는 기능을 최대한 고수준 형태로 만듦으로써, 중복 코드 문제를 해결한다. 수많은 하위 클래스는 상위 클래스와만 커플링될 뿐 다른 코드와 커플링이 일어나지 않는다.

 

패턴

상위 클래스추상 샌드박스 메서드와 여러 제공 기능provided operation을 정의.

제공 기능은 protected로 만들어져 하위 클래스용이라는 것을 분명히한다.

하위 클래스 샌드박스 패턴은 이럴 때 좋다.

  • 클래스 하나에 하위 클래스가 많이 있다.
  • 상위 클래스는 하위 클래스가 필요로 하는 기능을 전부 제공할 수 있다.
  • 하위 클래스 행동 중에 겹치는 게 많아, 이를 하위 클래스끼리 쉽게 공유하고 싶다.
  • 하위 클래스들 사이의 커플링 및 하위 클래스와 나머지 코드와의 커플링을 최소화하고 싶다.

다양한 행동을 정의하는 데 안전하게 사용할 수 있는 기본 기능 목록.

protected로 외부 접근을 막되 하위에서 접근 가능.

 

주의사항

상위 클래스에 코드가 계속 쌓이는 경향(버블업 효과)이 있어서, 상속이 안 좋게 여겨지기도 한다.

하위 클래스 샌드박스 패턴에서는 하위클래스가 상위 클래스를 통해서 나머지 게임 코드에 접근하기 때문에 상위 클래스가 하위 클래스에서 접근해야하는 모든 시스템과 커플링된다.

상위 클래스를 조금만 바꿔도 어딘가 깨지기 쉬운fragile base class 문제에 빠지게 된다.

좋은 점은 커플링 대부분이 상위 클래스에 몰려 있으므로 하위 클래스를 나머지 코드와 깔끔하게 분리가 가능하다. (많은 코드가 격리되어 유지보수가 쉽다)

그럼에도 상위 클래스가 복잡해진다면 컴포넌트 패턴이 용이하다.

 

Chapter13. 타입 객체

클래스 하나를 인스턴스별로 다른 객체형으로 표현 할 수 있게 만들어, 새로운 ‘클래스들’을 유연하게 만들 수 있게 한다.

 

하위 클래스가 너무 많다.

 

게임에 스폰된 모든 몬스터 인스턴스의 타입은 Monster라는 상위 클래스를 상속받는다. 종족이 많아질 수록 클래스 상속 쿠조도 커진다. 종족을 늘릴 때마다 코드를 추가/컴파일해야하는 문제가 생긴다.

 

 

클래스 두 개로 무한대의 종족을 표현한다.

 

다른 방법으로는 몬스터마다 종족에 대한 정보를 두는 방법이 있다.

종족마다 Monster 클래스를 상속받게 하지 않고, Monster 클래스 하나와, Breed 클래스 하나만 만든다.

상속 없이 클래스 두 개만으로 해결할 수 있다. 모든 몬스터를 Monster 클래스의 인스턴스로 표현 가능하다. Breed 클래스에는 종족이 같은 몬스터가 공유하는 정보인 최대 체력과 공격 문구가 있다.

몬스터와 종족을 결합하기 위해 모든 Monster 인스턴스는 종족 Breed 객체를 참조.

몬스터가 공격 문구를 얻을 때 종족 객체 메서드를 호출.

Breed 클래스는 본질적으로 몬스터 ‘타입’을 정의.

각각의 종족 객체는 개념적으로 다른 타입을 의미.

타입 객체 패턴은 코드 수정 없이 새로운 타입을 정의할 수 있다는 것이 장점.

 

패턴

클래스라는 코드적인 형태 → 데이터적인 형태로 변환 작성하는 방법

행동을 정의 → 타입 객체-타입 사용 객체

행위를 잘 정의하여 생산성을 향상 시키는 데에 도움이 된다. (일을 병렬로 처리 가능)

타입 객체type object 클래스와 타입 사용 객체typed object 클래스를 정의.

타입 사용 객체는 자신의 타입을 나타내는 타입 객체를 참조.

 

주의사항

  • 타입 객체를 직접 관리해야한다.타입 객체를 생성하고, 이를 필요로하는 몬스터가 있는 한 메모리에 유지해야 함.
  • 몬스터 인스턴스뿐만 아니라 타입 객체도 직접 관리 해야 함.
  • 타입별로 동작을 표현하기가 더 어렵다.
  • 타입 객체로 타입 종속적인 데이터를 정의하기는 쉽지만 타입 종속적인 동작을 정의하기는 어렵다. ⇒ 미리 동작 코드를 여러 개 정의 해놓은 뒤 타입 객체 데이터에서 이 중 하나를 선택하는 방식으로 해결 가능.

 

예제 코드

class Breed {
public: Monster* newMonster() {
        return new Monster(*this);
    }
    int getHealth() { return health_; }
    const char* getAttack() { return attack_; }

private: 
    int health_; // 최대(초기) 체력
    const char* attack_;
};
class Monster {
    friend class Breed;

public:
    const char* getAttack() { return breed_.getAttack(); }

private: Monster(Breed& breed)
: health_(breed.getHealth()),
    breed_(breed) {
    }

    int health.; // 현재 체력
    Breed& breed_;
};

Monster* monster = someBreed.newMonster()

초기화 작업은 메모리를 할당한 다음에 진행된다.

newMonster 함수를 호출하면 Monster 클래스에 초기화 제어권을 넘겨주기 전에 메
모리 풀이나 커스텀 힙에서 메모리를 가져올 수 있다.

종족을 통해 여러 몬스터가 속성을 공유했던 것처럼 여러 종족이 속성 값을 공유할 수
있게 만들면 좋다.

종족 속성 값이 바뀌지 않는다면 생성 시점에 바로 상속을 적용할 수 있다. 이런 걸 ‘카피다운
copy-down’ 위임이라고 한다.

 

타입 객체를 숨길 것인가? 노출할 것인가?

타입 객체를 캡슐화하면

  • 타입 객체 패턴의 복잡성이 나머지 다른 코드에는 드러나지 않는다.
  • 타입 사용 객체는 타입 객체로부터 동작을 선택적으로 오버라이드할 수 있다.
  • 타입 객체 메서드를 전부 포워딩해야 한다.

타입 객체를 노출하면

  • 타입 사용 클래스 인스턴스를 통하지 않고도 외부에서 타입 객체에 접근할 수 있다.
  • 타입 객체가 공개 API의 일부가 된다.

 

타입 객체를 어떻게 생성할 것인가?

객체를 생성한 뒤에 타입 객체를 넘겨주는 경우

  • 외부 코드에서 메모리 할당을 제어할 수 있다.

타입 객체의 ‘생성자’함수를 호출하는 경우

  • 타입 객체에서 메모리 할당을 제어한다.

 

타입을 바꿀 수 있는가?

객체가 필요하면 타입을 변경하게 할 수도 있다.

타입을 바꿀 수 없다면

  • 코드를 구현하고 이해하기가 더 쉽다.
  • 디버깅하기 쉽다.

타입을 바꿀 수 있다면

  • 객체 생성 횟수가 줄어든다.
  • 가정을 깨지 않도록 주의해야 한다.

 

상속을 어떻게 지원할 것인가?

상속 없음

  • 단순하다.
  • 중복 작업을 해야할 수도 있다.

단일 상속

  • 그나마 단순한 편이다.
  • 속성 값을 얻는 데 오래 걸린다.

다중 상속

  • 거의 모든 데이터 중복을 피할 수 있다.
  • 복잡하다.
반응형

댓글