C++ 기초부터 마스터까지 — 12화
게임 구조로 정리하는 C++
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
지금까지 배운 문법을 실제 게임 구조에 연결해보자. 문법을 외우는 것보다 어떤 책임을 어느 클래스와 자료구조에 둘지 결정하는 일이 더 어렵다.
책임 나누기
Character는 체력과 공격을 책임지고, Monster는 몬스터 상태를 책임지게 할 수 있다. DungeonGame은 파티와 활성 몬스터, 스폰 대기열, 아이템 데이터베이스를 관리한다.
class DungeonGame
{
public:
void AddCharacter(std::unique_ptr<Character> character)
{
if (character)
{
party.push_back(std::move(character));
}
}
void QueueMonster(std::shared_ptr<Monster> monster)
{
if (monster)
{
spawnQueue.push(std::move(monster));
}
}
private:
std::vector<std::unique_ptr<Character>> party;
std::vector<std::shared_ptr<Monster>> activeMonsters;
std::queue<std::shared_ptr<Monster>> spawnQueue;
std::stack<int> killHistory;
std::unordered_map<int, Item> itemDatabase;
std::weak_ptr<Monster> currentTarget;
};
파티 캐릭터는 DungeonGame이 단독 소유하므로 uniqueptr을 사용했다. 몬스터는 대기열과 활성 목록 사이에서 공유될 수 있으므로 sharedptr을 사용했다. 현재 타깃은 소유하지 않으므로 weak_ptr이다.
타입만 봐도 누가 객체를 살려두는지 어느 정도 알 수 있다.
경계값 처리
게임 코드는 정상적인 입력만 들어온다고 가정하면 금방 망가진다.
bool AttackMonster(std::size_t characterIndex, int monsterId)
{
if (characterIndex >= party.size())
{
return false;
}
auto iter = std::find_if(
activeMonsters.begin(),
activeMonsters.end(),
[monsterId](const auto& monster)
{
return monster && monster->GetId() == monsterId;
}
);
if (iter == activeMonsters.end())
{
return false;
}
(*iter)->TakeDamage(party[characterIndex]->Attack());
return true;
}
잘못된 인덱스, 존재하지 않는 ID, nullptr 가능성을 먼저 검사하고 조기 반환한다. 빈 queue에서 front를 호출하거나 빈 stack에서 top을 호출해서도 안 된다.
체력은 0보다 작아지지 않게 하고, 이미 죽은 객체를 다시 처리하지 않게 만들어야 한다. 안전장치는 화려한 기능은 아니지만 디버깅 시간을 크게 줄여준다.
언리얼과 대응되는 개념
일반 C++의 자료구조와 언리얼 타입은 비슷한 역할을 가진 경우가 많다.
std::vector와TArraystd::unordered_map과TMapstd::unique_ptr과TUniquePtrstd::shared_ptr과TSharedPtrstd::weak_ptr과TWeakPtr
그러나 이름이 비슷하다고 UObject에도 std::shared_ptr을 사용하면 안 된다. 일반 C++ 객체는 RAII와 스마트 포인터로 수명을 관리하지만 UObject는 언리얼의 GC가 관리한다.
AActor는 일반 객체처럼 직접 delete하지 않고 Destroy()를 호출하며, 종료 과정은 EndPlay()에서 처리한다. 일반 F 타입과 UObject 타입은 소유 방식이 다르다는 점을 구분해야 한다.
또한 TArray와 std::vector 모두 재할당이 일어나면 원소를 가리키던 포인터가 무효화될 수 있다.
결국 C++ 문법은 각각 따로 떨어진 기능이 아니다. 클래스가 책임을 묶고, 컨테이너가 상태를 저장하고, 스마트 포인터가 수명을 표현하고, 알고리즘이 동작을 수행한다.
이 구조가 눈에 들어오기 시작하면 문법을 외우는 것보다 코드가 왜 이렇게 생겼는지를 이해할 수 있게 된다.