C++로 ASCII RPG 만들기 - 지금까지의 진행상황
C++로 진행 중인 AsciiRPG 프로젝트의 현재 구조를 정리했습니다. Entity-Component 구조, ObjectManager, Map, Renderer, VirtualDisplay, 입력과 이동 처리까지 기록합니다.
C++로 ASCII RPG를 만들고 있다
C++로 AsciiRPG라는 콘솔 RPG 프로젝트를 진행하고 있다. 이전에는 텍스트 메뉴 중심으로 RPG 기능을 붙였다면, 이번 프로젝트는 조금 더 게임 엔진 구조에 가깝게 만들어보고 있다.
현재 코드는 C:\Homework\text-rpg-mandatory\AsciiRPG 폴더에 있는 내용 기준이다. 지금까지 구현된 핵심은 맵을 만들고, 오브젝트를 생성하고, Entity에 Component를 붙이고, Renderer Component를 모아 콘솔 화면에 ASCII 문자로 그리는 흐름이다.
아직 전투나 아이템 같은 RPG 규칙이 본격적으로 들어간 단계는 아니다. 대신 화면에 맵과 오브젝트를 그리고, 플레이어를 WASD로 움직이고, 벽이나 몬스터가 있는 칸으로는 이동하지 못하게 막는 기반 구조를 만들고 있다.
실행 흐름
현재 main.cpp는 아주 단순하다. GameManager와 VirtualDisplay를 만들고, 무한 루프 안에서 업데이트와 렌더링을 반복한다.
void main()
{
GameManager gm;
VirtualDisplay vd;
while (true)
{
gm.Update();
vd.Render();
Sleep(100);
}
}
이 구조에서 GameManager는 게임 오브젝트들의 업데이트를 담당하고, VirtualDisplay는 화면 출력을 담당한다. 아직은 간단한 루프지만, 게임 루프의 모양이 생겼다는 점이 중요했다.
ObjectManager와 shared_ptr 기반 오브젝트 관리
프로젝트에는 Object라는 기본 클래스가 있고, 대부분의 게임 객체는 이 클래스를 기반으로 만들어진다. 모든 Object는 id와 name을 가진다.
class Object
{
public:
Object(int64_t id, const std::string& name);
Object(int64_t id, const std::string& name, shared_ptr<IConstructionParameter> params);
virtual ~Object();
int64_t GetId() const;
std::string GetName() const;
void SetName(const std::string& newName);
protected:
int64_t id;
std::string name;
};
오브젝트 생성은 ObjectManager가 담당한다. ObjectManager는 싱글턴으로 만들어져 있고, CreateOne<T>() 템플릿 함수로 Object를 상속한 타입을 생성한다. 생성된 객체는 shared_ptr로 관리되고, 내부의 createdObjects 맵에 저장된다.
template <typename T,
typename = enable_if_t<is_base_of<Object, T>::value>>
shared_ptr<T> CreateOne()
{
int64_t randomNumber = Random::GetInstance().RandomRange(INT64_MIN, INT64_MAX);
shared_ptr<T> newOne = make_shared<T>(randomNumber, string());
createdObjects.insert({ randomNumber, newOne });
return newOne;
}
이 부분에서 C++의 템플릿, enable_if_t, is_base_of, shared_ptr, dynamic_pointer_cast를 함께 사용하고 있다. 단순히 new와 delete로 직접 관리하던 방식보다, 조금 더 C++다운 객체 관리 흐름으로 옮겨가는 중이다.
Entity와 Component 구조
이번 AsciiRPG에서 가장 중요한 구조는 Entity-Component 방식이다.
Entity는 직접 많은 기능을 가지지 않는다. 대신 여러 Component를 들고 있고, 필요한 기능은 Component를 붙여서 구성한다.
class Entity : public Object
{
public:
using Object::Object;
~Entity();
template <typename T,
typename = std::enable_if_t<std::is_base_of<Component, T>::value>>
shared_ptr<T> AddComponent()
{
auto component = ObjectManager::GetInstance().CreateOne<T>();
components.push_back(component);
return component;
}
void Update();
private:
std::vector<std::shared_ptr<Component>> components;
};
Entity::Update()에서는 Component를 순회한다. 아직 시작되지 않은 Component는 Start()를 한 번 호출하고, 이후 매 프레임 Update()를 호출한다.
void Entity::Update()
{
for (auto& component : components)
{
if (!component->GetIsStarted())
{
component->Start();
component->SetIsStarted(true);
}
component->Update();
}
}
이 구조 덕분에 플레이어, 몬스터, 벽, 입구, 출구, 바닥을 모두 Entity로 만들고, 필요한 Component 조합으로 의미를 부여할 수 있게 되었다.
CreationUtil로 오브젝트 조립하기
Entity를 만들 때마다 Component를 직접 붙이면 코드가 금방 길어진다. 그래서 CreationUtil 네임스페이스에 생성 함수를 모아두었다.
예를 들어 플레이어는 Position, Renderer, InputController를 가진 Entity로 만들어진다.
shared_ptr<Entity> CreationUtil::CreatePlayer(Vector2Int position)
{
auto newPlayerEntity = ObjectManager::GetInstance().CreateEntity();
newPlayerEntity->SetName(format("Player {0} {1}", position.x, position.y));
newPlayerEntity->AddComponent<Position>(std::make_shared<Position::ConstructParameter>(position.x, position.y, newPlayerEntity));
newPlayerEntity->AddComponent<Renderer>(std::make_shared<Renderer::ConstructionParameter>('P', newPlayerEntity));
newPlayerEntity->AddComponent<InputController>(std::make_shared<Component::ConstructionParamterBase>(newPlayerEntity));
return newPlayerEntity;
}
몬스터는 Position, Renderer, Monster를 가진다. 벽은 Position, Renderer, Wall을 가진다. 입구와 출구도 각각 Entrance, Exit Component를 붙여서 표현한다.
shared_ptr<Entity> CreationUtil::CreateMonster(Vector2Int position)
{
auto newMonsterEntity = ObjectManager::GetInstance().CreateEntity();
newMonsterEntity->SetName(format("Monster {0} {1}", position.x, position.y));
newMonsterEntity->AddComponent<Position>(std::make_shared<Position::ConstructParameter>(position.x, position.y, newMonsterEntity));
newMonsterEntity->AddComponent<Renderer>(std::make_shared<Renderer::ConstructionParameter>('M', newMonsterEntity));
newMonsterEntity->AddComponent<Monster>(std::make_shared<Component::ConstructionParamterBase>(newMonsterEntity));
return newMonsterEntity;
}
이 방식은 아직 생성 파라미터가 조금 길고 불편하다. 코드 안에도 "인터페이스가 너무 똥인데?"라는 TODO가 남아 있다. 그래도 현재 단계에서는 Entity를 조립하는 위치를 한곳으로 모아둔 점이 좋았다.
맵과 셀 구성
Map은 MapCell 목록을 가진다. 맵 크기는 Const::Map::DEFAULT_WIDTH, Const::Map::DEFAULT_HEIGHT를 사용하고, 현재 값은 40 x 20이다.
맵 생성 시 바깥 테두리는 벽으로 만들고, 내부는 바닥으로 만든다. 이후 고정 위치에 입구와 출구를 추가한다.
for (int i = 0; i < width * height; ++i)
{
int x = i % width;
int y = i / width;
MapCell* newCell = new MapCell(x, y);
cells.push_back(newCell);
if (x > 0 && x < width - 1 && y > 0 && y < height - 1)
{
auto newFloor = CreationUtil::CreateFloor(newCell->GetPosition());
newCell->AddEntity(newFloor);
}
else
{
auto newWall = CreationUtil::CreateWall(newCell->GetPosition());
newCell->AddEntity(newWall);
}
}
MapCell은 해당 위치에 들어간 Entity들을 가진다. 그리고 HasWall, HasFloor, HasMonster 같은 함수로 특정 Component를 가진 Entity가 있는지 확인한다.
bool MapCell::HasMonster()
{
for (auto& entity : entities)
{
std::shared_ptr<Monster> monster;
if (entity->TryGetComponent(monster))
return true;
}
return false;
}
이 구조는 나중에 충돌, 전투, 상호작용을 붙이기 위한 기반이 된다.
콘솔 렌더링
화면 출력은 VirtualDisplay가 담당한다. 내부에 char** buffer를 만들고, 매 프레임 버퍼를 비운 뒤, 모든 Renderer Component를 찾아 해당 위치에 문자를 찍는다.
void VirtualDisplay::Render()
{
system("cls");
vector<shared_ptr<Renderer>> renderer = ObjectManager::GetInstance().GetObjectsByType<Renderer>();
for (int i = 0; i < Const::VirtualDisplay::HEIGHT; ++i)
{
for (int j = 0; j < Const::VirtualDisplay::WIDTH; ++j)
{
buffer[i][j] = ' ';
}
}
for (const auto& r : renderer)
{
Vector2Int pos = r->GetPosition();
buffer[pos.y][pos.x] = r->GetToPrint();
}
}
Renderer는 출력할 문자와 위치를 제공한다. 위치는 같은 Entity에 붙은 Position Component에서 가져온다.
Renderer::Renderer(int64_t id, const std::string& name, std::shared_ptr<IConstructionParameter> params)
:Component(id, name, params)
{
ConstructionParameter* constructParam = static_cast<ConstructionParameter*>(params.get());
this->toPrint = constructParam->toPrint;
this->position = constructParam->entity->GetComponent<Position>();
}
현재 사용 중인 문자는 대략 이런 식이다.
P: 플레이어M: 몬스터#: 벽E: 입구X: 출구- 공백: 바닥
콘솔 화면을 직접 갱신하기 때문에 아직 깜빡임이나 system("cls") 같은 거친 부분은 있다. 그래도 ASCII RPG의 화면 출력 기반은 만들어졌다.
입력과 이동
플레이어 이동은 InputController Component가 처리한다. _kbhit()으로 입력 여부를 확인하고, _getch()로 입력 문자를 받아 WASD 이동을 처리한다.
void InputController::Update()
{
if (!_kbhit())
return;
char input = _getch();
switch (input)
{
case 'w':
playerPositionComponent->TryMoveYOnly(-1);
break;
case 's':
playerPositionComponent->TryMoveYOnly(1);
break;
case 'a':
playerPositionComponent->TryMoveXOnly(-1);
break;
case 'd':
playerPositionComponent->TryMoveXOnly(1);
break;
}
}
이동 가능 여부는 Position Component에서 검사한다. 모든 Position Component를 찾아 목표 위치에 벽이나 몬스터가 있으면 이동하지 않는다.
bool Position::CanMoveTo(shared_ptr<Position> position, int newX, int newY)
{
if (position->GetX() == newX && position->GetY() == newY)
{
if (position->entity->HasComponent<Wall>())
{
return false;
}
else if (position->entity->HasComponent<Monster>())
{
return false;
}
}
return true;
}
코드에 적힌 주석처럼 이 방식은 꽤 비효율적이다. 이동할 때마다 모든 Position을 훑기 때문이다. 하지만 현재 단계에서는 충돌 규칙을 빠르게 붙이는 데 목적이 있었고, 나중에 맵 셀 기반으로 개선할 여지가 보인다.
지금까지 구현된 것
현재 AsciiRPG에서 구현된 것은 다음 정도다.
- 게임 루프:
Update,Render,Sleep - Object 기반 공통 객체 구조
ObjectManager싱글턴과shared_ptr기반 객체 관리- Entity-Component 구조
Position,Renderer,InputController,Monster,Wall,Floor,Entrance,ExitComponent- 40 x 20 맵 생성
- 테두리 벽과 내부 바닥 생성
- 입구
E, 출구X, 플레이어P, 몬스터M출력 - WASD 입력 이동
- 벽과 몬스터 충돌 처리
아직 RPG라고 부르기에는 전투와 성장 요소가 부족하지만, ASCII 화면 위에서 오브젝트가 배치되고 플레이어가 움직이는 기반은 만들어졌다.
다음에 다듬고 싶은 부분
지금 코드에서 가장 먼저 보이는 개선점은 이동 충돌 검사다. 현재는 ObjectManager에서 모든 Position Component를 가져와 비교하는 방식이다. 맵과 셀 구조가 이미 있으니, 나중에는 목표 좌표의 MapCell을 보고 이동 가능 여부를 판단하는 쪽이 더 자연스러울 것 같다.
생성 파라미터 구조도 다듬고 싶다. 지금은 Component마다 IConstructionParameter를 상속한 클래스를 만들고, 내부에서 static_cast로 꺼내 쓰고 있다. 작동은 하지만 코드가 길어지고 실수하기 쉬워 보인다. Component 생성 인터페이스를 조금 더 간단하게 만들 필요가 있다.
렌더링도 개선 여지가 있다. 매 프레임 system("cls")로 화면을 지우고 전체 문자열을 다시 출력하고 있는데, 나중에는 콘솔 커서 위치 제어나 더블 버퍼링에 가까운 방식으로 깜빡임을 줄여보고 싶다.
그 다음에는 드디어 RPG 규칙을 붙일 차례다. 몬스터와 부딪혔을 때 전투를 시작하거나, 출구에 도달하면 다음 맵으로 넘어가는 식의 게임 흐름을 추가하면 지금 만든 Entity-Component 구조를 더 제대로 써볼 수 있을 것 같다.
GitHub
전체 소스 코드는 jjr2930/text-rpg-mandatory에서 확인할 수 있다.