C++로 ASCII RPG 만들기 - 랜덤 던전과 층 이동 구현
Perlin Noise로 랜덤 던전을 생성하고, BFS로 모든 목표의 접근 가능성을 검사하며, 10개 층을 오갈 때 맵 상태를 보존하도록 만든 과정을 정리했습니다.
고정 맵에서 랜덤 던전으로
지난 작업에서는 플레이어가 몬스터와 싸우고, 아이템을 줍고, 인벤토리에서 포션을 사용할 수 있게 만들었다. 전투와 아이템이라는 RPG의 기본 상호작용이 생겼지만, 테스트용 고정 맵에서 진행되다 보니 아직 게임다운 느낌은 부족했다.
이번 작업의 핵심은 단순히 벽을 무작위로 배치하는 것이 아니었다. 무작위로 만든 결과가 실제로 플레이 가능한지 검사하고, Entity로 만들어진 현재 월드와 저장된 맵 데이터 사이를 어떻게 왕복할지 정하는 일이 더 중요했다.

실행 화면에서는 #으로 이루어진 불규칙한 동굴 지형과 함께 플레이어 P, 몬스터 M, 아이템 *, 입구 S, 출구 X를 확인할 수 있다. 오른쪽에는 플레이어의 레벨과 능력치, 인벤토리를 표시하고 아래쪽 로그에는 맵 생성과 전투 상태를 남긴다.
Perlin Noise로 동굴 형태 만들기
랜덤 맵 생성은 RandomMapGenerator가 담당한다. 먼저 맵 테두리를 벽으로 채운 뒤, 내부 좌표마다 Perlin Noise 값을 구한다. Noise 값이 임계값보다 높으면 벽이 되고, 그렇지 않으면 이동할 수 있는 빈 공간으로 남는다.
float scale = 0.15f;
for (int y = 1; y < height - 1; y++)
{
for (int x = 1; x < width - 1; x++)
{
float noiseValue = perlinNoise.GetNoise(x * scale, y * scale);
noiseValue = (noiseValue + 1.0f) / 2.0f;
if (noiseValue > threshold)
{
outputMap->SetCellData(x, y, Const::Map::WALL);
}
}
}
임계값은 0.47부터 0.55 사이에서 무작위로 선택한다. 완전히 독립적인 난수로 각 칸을 채우면 벽이 점처럼 흩어지기 쉽지만, Perlin Noise는 인접한 값이 부드럽게 이어져 동굴 같은 덩어리를 만들기 좋다.
벽을 만든 뒤에는 빈 공간을 골라 입구 S, 출구 X, 아이템 *, 몬스터 M을 배치한다. 아이템은 3~10개, 몬스터는 3~7개가 생성된다.
무작위 맵이 플레이 가능한지 BFS로 검사하기
랜덤 생성에서 가장 큰 문제는 결과가 항상 플레이 가능하지는 않다는 점이다. 보기에는 그럴듯해도 출구가 벽 너머에 고립되거나, 아이템과 몬스터가 갈 수 없는 공간에 놓일 수 있다.
그래서 오브젝트 배치가 끝나면 CanReachAllTargets()에서 너비 우선 탐색(BFS)을 실행한다. 입구에서 시작해 상하좌우로 이동하며 방문 가능한 모든 칸을 기록하고, 출구와 모든 아이템, 모든 몬스터의 위치에 도달했는지 확인한다.
q.push({ startX, startY });
visited[startY][startX] = true;
int dx[] = { 0, 0, -1, 1 };
int dy[] = { -1, 1, 0, 0 };
while (!q.empty())
{
auto [x, y] = q.front();
q.pop();
for (int i = 0; i < 4; i++)
{
int nx = x + dx[i];
int ny = y + dy[i];
if (nx >= 0 && nx < width && ny >= 0 && ny < height)
{
if (!visited[ny][nx] &&
outputMap->GetCellData(nx, ny) != Const::Map::WALL)
{
visited[ny][nx] = true;
q.push({ nx, ny });
}
}
}
}
하나라도 도달하지 못하면 생성 결과를 버리고 다시 만든다. 덕분에 모양은 무작위지만, 적어도 입구에서 출구와 모든 주요 오브젝트까지 갈 수 있는 맵만 게임에 사용된다.
랜덤 생성에서는 무엇을 만들지뿐 아니라 무엇을 실패로 볼지도 함께 설계해야 한다는 점을 배웠다. 생성 알고리즘 뒤에 검증 단계를 붙이자 결과를 믿고 게임에 넘길 수 있게 되었다.
한 장의 Map을 가벼운 문자 데이터로 바꾸기
이전 Map은 MapCell과 Entity를 직접 들고 있는 구조였다. 이번에는 width * height 크기의 char 배열을 가지는 단순한 데이터 구조로 바꿨다.
Map::Map(int width, int height)
: rawData(new char[height * width])
, size(width, height)
{
for (int y = 0; y < height; ++y)
{
for (int x = 0; x < width; ++x)
{
rawData[y * width + x] = Const::Map::EMPTY;
}
}
}
각 문자는 맵을 다시 만드는 데 필요한 최소한의 정보가 된다.
#: 벽S: 입구X: 출구*: 아이템M: 몬스터- 공백: 빈 공간
GameManager는 시작할 때 이 Map을 10개 생성해 maps 벡터에 보관한다.
maps.reserve(Const::Map::DUNGEON_DEPTH);
for (int i = 0; i < Const::Map::DUNGEON_DEPTH; ++i)
{
shared_ptr<Map> newMap = make_shared<Map>(
Const::Map::DEFAULT_WIDTH,
Const::Map::DEFAULT_HEIGHT);
rmg.GenerateRandomMap(
Const::Map::DEFAULT_WIDTH,
Const::Map::DEFAULT_HEIGHT,
newMap);
maps.push_back(newMap);
}
Map은 저장용 데이터, Entity와 Component는 현재 플레이 중인 월드라는 역할 구분이 생겼다. 모든 층의 Entity를 계속 살려두지 않아도 되므로 현재 층에 필요한 객체만 생성할 수 있다.
문자 데이터에서 현재 층의 Entity 만들기
CreateCurrentMapObjects()는 현재 층의 모든 좌표를 순회한다. 셀 문자를 보고 벽, 입구, 출구, 아이템, 몬스터 Entity를 생성한다. 입구를 찾으면 플레이어 위치도 그 좌표로 옮긴다.
switch (cellChar)
{
case Const::Map::WALL:
CreationUtil::CreateWall(Vector2Int(x, y));
break;
case Const::Map::START:
CreationUtil::CreateEntrance(Vector2Int(x, y));
playerPosition->SetPosition(x, y);
break;
case Const::Map::EXIT:
CreationUtil::CreateExit(Vector2Int(x, y));
break;
case Const::Map::ITEM:
CreationUtil::CreateFieldItem(Vector2Int(x, y), "Potion", 1);
break;
case Const::Map::MONSTER:
CreationUtil::CreateMonster(Vector2Int(x, y));
break;
}
반대로 현재 월드의 상태를 Map으로 되돌릴 필요도 있다. 이를 위해 던전에 속하는 Entity에는 DungeonObjectTag Component를 붙였다. 태그는 해당 Entity를 저장할 때 어떤 문자로 기록할지 알려준다.
Map::CollectMapData()는 DungeonObjectTag와 Position을 모두 가진 Component 조합을 찾아 현재 배치를 문자 배열에 기록한다.
void Map::CollectMapData()
{
Clear();
auto pairs = ObjectManager::GetInstance()
.GetComponentsWithTypes<DungeonObjectTag, Position>();
for (auto& [tag, position] : pairs)
{
Vector2Int pos = position->GetPosition();
int index = pos.y * size.x + pos.x;
rawData[index] = tag->GetTag();
}
}
이미 주운 아이템이나 처치한 몬스터는 현재 월드에서 사라졌기 때문에 수집 결과에도 기록되지 않는다. 따라서 다른 층에 갔다가 돌아와도 제거된 상태가 유지된다.
이벤트로 층 이동 처리하기
플레이어가 출구에 겹치면 OnPlayerEnteredExit, 입구에 다시 들어가면 OnPlayerEnteredEntrance 이벤트를 브로드캐스트한다. GameManager는 이벤트를 받아 현재 인덱스에 1을 더하거나 뺀다.
void GameManager::HandleEvent(shared_ptr<EventParameter> message)
{
switch (message->eventType)
{
case EventType::OnPlayerEnteredExit:
ChangeMap(currentMapIndex + 1);
break;
case EventType::OnPlayerEnteredEntrance:
ChangeMap(currentMapIndex - 1);
break;
}
}
실제 층 교체 순서는 다음과 같다.
1. 현재 Entity 상태를 현재 Map의 문자 데이터로 수집한다. 2. OnMapClearRequested 이벤트를 보내 현재 층의 Player를 제외한 Entity를 제거한다. 3. currentMapIndex를 새 층으로 바꾼다. 4. 새 Map의 문자 데이터를 읽어 Entity를 다시 생성한다. 5. 플레이어를 새 층의 입구 위치로 이동시킨다.
현재 상태를 수집하는 이유는 플레이어가 만든 변화를 해당 층에 보존하기 위해서다. 아이템을 줍거나 몬스터를 처치할 때마다 Map을 갱신하는 방법도 있지만, 이번에는 층을 떠나는 시점에 한 번만 현재 Entity 배치를 수집했다. 변경이 발생하는 모든 코드에서 맵 데이터까지 함께 관리하지 않아도 되므로 구현을 단순하게 유지할 수 있었다.
마지막 층의 출구에서는 게임 종료 로그를 남기고, 첫 층의 입구에서는 더 이전 층으로 가지 않도록 막았다.

두 번째 실행 화면의 로그에는 새 입구 좌표로 플레이어를 옮긴 뒤 change map, index: 1이 출력되어 있다. 문자 데이터로 보관하던 다음 층을 Entity 월드로 다시 구성하고, 플레이어가 1층에서 다음 층으로 이동한 결과다.
경험치 테이블과 레벨업
몬스터를 처치했을 때 얻는 경험치도 이제 실제 성장으로 이어진다. LevelExpTable 싱글턴에 레벨별 필요 경험치를 저장하고, Player는 다음 레벨에 필요한 경험치를 조회한다.
levelExpMap = {
{2, 10},
{3, 30},
{4, 60},
{5, 100},
{6, 150},
{7, 210},
{8, 280},
{9, 360},
{10, 450}
};
현재 경험치가 요구량 이상이면 레벨이 오르고, 이때마다 플레이어의 능력치는 이전 레벨을 기준으로 10%씩 누적 성장한다.
능력치를 정수형으로 보관한 상태에서 매번 10%를 곱하고 다시 정수로 변환하면 소수점 이하가 계속 버려진다. 특히 값이 작은 능력치는 증가분이 1보다 작아 한동안 전혀 오르지 않을 수도 있다. 이를 줄이기 위해 매 레벨의 값을 직전 능력치에서 계산하지 않고, 초기 능력치를 부동소수점으로 변환한 뒤 1.1^(레벨 - 1)을 곱해 현재 레벨의 값을 구한다. 마지막에만 int로 변환하므로 레벨마다 발생하는 누적 절삭을 피할 수 있다.
float fInitMaxHp = static_cast<float>(Player::INIT_HP);
float fInitAttack = static_cast<float>(Player::INIT_ATTACK);
float fInitDefense = static_cast<float>(Player::INIT_DEFENSE);
float r = 1.1f;
// 초기 능력치 * 1.1^(현재 레벨 - 1)
float factor = pow(r, static_cast<float>(playerLevel - 1));
maxHp = static_cast<int>(fInitMaxHp * factor);
attack = static_cast<int>(fInitAttack * factor);
defense = static_cast<int>(fInitDefense * factor);
객체 수명 구조도 함께 정리했다
층을 바꿀 때 Entity를 반복해서 만들고 지워야 하면서 객체 수명 관리가 더 중요해졌다. Entity는 Component를 shared_ptr로 소유하고 있었고, Component도 자신의 Entity를 shared_ptr로 참조하고 있었다. 이처럼 양쪽이 서로를 강하게 소유하면 참조 횟수가 0이 되지 않는 순환 참조가 생겨 객체가 메모리에서 해제되지 않는다.
이는 shared_ptr을 사용할 때 주의해야 하는 대표적인 문제다. 소유 관계를 다시 정리해 Entity만 Component를 강하게 소유하도록 하고, Component에서 Entity로 향하는 참조는 weak_ptr로 변경했다. Entity가 필요할 때는 lock()으로 유효성을 확인한 뒤 임시 shared_ptr를 얻는다.
if (auto ptr = entity.lock())
{
myPosition = ptr->GetComponent<Position>();
}
또 Object::generatedCount를 추가해 생성과 소멸 시 객체 수를 추적할 수 있게 했다. 지금은 로그가 주석 처리되어 있지만, 층 이동 뒤 객체가 의도대로 정리되는지 확인할 수 있는 디버깅 장치가 생겼다.
이번 작업에서 남은 문제
기능은 연결되었지만 바로 다듬어야 할 부분도 보인다.
먼저 출구 위치를 고르는 반복문의 조건이 의도와 다르게 작성되어 있다.
} while (outputMap->GetCellData(exitX, exitY) != Const::Map::EMPTY
&& simpleDistanceWithEntrance < 40);
의도는 빈 공간이면서 입구와의 맨해튼 거리가 40 이상인 위치를 찾는 것이었다. 하지만 현재 조건은 두 조건을 &&로 묶었기 때문에, 후보가 빈 공간이기만 하거나 거리 조건만 만족해도 반복을 끝낼 수 있다. 두 조건 중 하나라도 만족하지 않으면 다시 위치를 뽑도록 ||로 바꾸는 편이 의도에 맞다.
조건식을 수정하더라도 고정된 거리 40이 모든 맵에서 적절한지는 별개의 문제다. 맵 크기와 실제 이동 경로를 고려해 최소 거리를 정하거나, BFS에서 구한 최단 경로 길이를 기준으로 출구를 선택하는 방식도 검토할 필요가 있다.
이번 작업의 결과
이번 작업으로 AsciiRPG의 플레이 공간과 성장 흐름이 한 단계 확장되었다.
- Perlin Noise 기반의 동굴형 랜덤 맵을 생성한다.
- 입구, 출구, 아이템, 몬스터를 빈 공간에 무작위로 배치한다.
- BFS로 모든 주요 오브젝트에 도달할 수 있는지 검사한다.
- 유효하지 않은 맵은 버리고 다시 생성한다.
- 총 10개 층을 만들고 입구와 출구로 앞뒤 층을 오갈 수 있다.
- 현재 층을 떠날 때 살아 있는 Entity 상태를 문자 데이터로 저장한다.
- 처치한 몬스터와 주운 아이템의 상태가 층 이동 후에도 유지된다.
- 몬스터 경험치가 레벨업과 능력치 성장으로 이어진다.
weak_ptr를 활용해 Entity와 Component 사이의 순환 참조를 줄였다.
지난번까지는 한 화면 안에서 전투와 아이템 사용이 가능한 게임이었다면, 이제는 탐험할 던전과 아래로 내려가는 진행 방향이 생겼다. 데이터로 저장된 Map과 실제로 동작하는 Entity 월드를 분리한 것도 앞으로 기능을 늘릴 때 중요한 기반이 될 것 같다.
작업 목록
- 맵 이동: 여러 맵을 만들고, 플레이어가 맵 사이를 이동할 수 있게 만들기
- 여러 층 구조: 점점 아래층으로 내려가는 방식의 던전 만들기
- 마을: NPC와 대화하거나 거래할 수 있는 안전한 공간 만들기
- 아이템 테이블화: 하드코딩된 아이템 정보를 테이블에서 읽어오도록 바꾸기
- 아이템 사용 로직 모듈화: 테이블 정보만으로 아이템 효과가 적용되도록 구조 정리하기
- 장비 착용: 무기나 방어구를 착용해 플레이어를 강화할 수 있게 만들기
- 몬스터 AI: 몬스터가 돌아다니거나 플레이어를 탐지해 공격하도록 만들기
- 몬스터 테이블화: 여러 종류의 몬스터를 데이터 기반으로 생성하기
- NPC: 거래와 퀘스트 부여가 가능한 NPC 추가하기
- 퀘스트: NPC가 플레이어에게 목표를 주고, 완료 상태를 추적할 수 있게 만들기
랜덤 던전 생성과 층 이동이라는 큰 작업 두 가지를 마쳤다. 다음에는 지금 만든 던전을 안정화하면서 몬스터와 아이템을 데이터 기반으로 확장해 나갈 예정이다.
GitHub
전체 소스 코드는 jjr2930/text-rpg-mandatory에서 확인할 수 있다.