C++로 ASCII RPG 만들기 - 마을, 아이템 테이블, 연금술 상점
랜덤 던전 앞에 마을을 추가하고, 아이템과 능력치를 데이터 기반으로 정리한 뒤 NPC 상호작용과 연금술 제작 UI를 구현한 과정을 기록했습니다.
던전 밖에 머물 공간을 만들기
지난 작업에서는 Perlin Noise로 랜덤 던전을 생성하고, 입구와 출구를 통해 여러 층을 오갈 수 있게 만들었다. 던전을 탐험하는 큰 흐름은 생겼지만 게임을 시작하자마자 곧바로 던전에 들어가는 구조였고, 아이템 효과와 NPC 같은 확장 요소도 아직 코드에 직접 묶여 있었다.
이번에는 던전 앞에 마을을 추가하고, 마을의 NPC와 상호작용해 연금술 상점을 이용할 수 있게 만들었다. 이 과정에서 입력을 가상 키로 추상화하고, 아이템과 능력치를 데이터 중심으로 다시 정리했다.
눈에 보이는 결과는 마을과 제작 메뉴지만, 실제 작업의 중심은 여러 종류의 상호작용과 아이템을 계속 추가할 수 있는 기반을 만드는 일이었다.

실행 화면의 왼쪽에는 고정 마을과 NPC N, 플레이어 P, 입구 S, 출구 X가 보인다. NPC 근처에서 E 키를 눌러 상호작용을 시작하면 화면 오른쪽에 별도의 Interaction 영역이 열린다.
현재 화면은 연금술 상점의 레시피 목록 상태다. Small Potion 아래에 제작 재료인 Slime Gell x 10과 Bone Powder x 10이 표시되고, 그 아래에는 방향키로 커서를 움직이고 ESC로 이전 메뉴에 돌아갈 수 있다는 조작 안내가 나온다. 가운데 인벤토리에서는 테스트를 위해 지급한 두 재료의 보유 수량도 함께 확인할 수 있다. 기존 게임 화면을 지우고 별도의 메뉴로 전환하는 대신, 마을·플레이어 상태·인벤토리·상호작용 UI를 한 화면에 유지하도록 구성했다.
0번째 맵을 마을로 사용하기
이전에는 GameManager가 랜덤 던전 10개를 만들어 바로 첫 번째 던전에서 시작했다. 이제 maps의 0번 인덱스에는 고정된 마을을 넣고, 랜덤 던전은 1번 인덱스부터 생성한다.
// 0번 인덱스는 마을 맵으로 사용한다.
maps.reserve(Const::Map::DUNGEON_DEPTH + 1);
shared_ptr<Map> townMap = CreationUtil::CreateTownMap();
maps.emplace_back(townMap);
for (int i = 1; i < Const::Map::DUNGEON_DEPTH + 1; ++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.emplace_back(newMap);
}
마을은 랜덤 생성 대신 문자열 배열로 직접 작성했다. Map::From()으로 40×20 크기의 문자 데이터를 읽어 고정된 공간을 구성한다.
townMapString.emplace_back("########################################");
townMapString.emplace_back("# #");
// ...
townMapString.emplace_back("# # N # #");
townMapString.emplace_back("# # S X # #");
// ...
townMapString.emplace_back("########################################");
shared_ptr<Map> townMap = make_shared<Map>(
Const::Map::DEFAULT_WIDTH,
Const::Map::DEFAULT_HEIGHT);
townMap->From(townMapString);
N은 NPC, S는 마을로 돌아오는 입구, X는 던전으로 내려가는 출구다. 랜덤 던전과 동일한 Map 형식을 사용하므로 기존의 층 이동 로직을 크게 바꾸지 않고 마을을 앞에 끼워 넣을 수 있었다.
NPC를 상호작용 가능한 오브젝트로 만들기
처음 만든 Npc Component는 플레이어가 한 칸 안으로 접근하면 메시지를 출력하는 정도였다. 이번에는 여기서 더 나아가 플레이어가 E 키를 눌러 능동적으로 상호작용할 수 있는 구조를 추가했다.
상호작용의 공통 인터페이스는 InteractableObject가 담당한다.
class InteractableObject : public Component
{
public:
InteractableObjectTags GetTag();
virtual vector<string> GetRenderStrings();
virtual void IncreaseCursorIndex();
virtual void DecreaseCursorIndex();
virtual void ConfirmSelection();
virtual void OnCancel();
virtual void Reset();
virtual void OnDisable();
};
상호작용 대상은 화면에 표시할 문자열, 커서 이동, 선택, 취소 같은 동작을 공통 함수로 제공한다. 현재는 AlchemyShop이 이 클래스를 상속하지만, 나중에는 상점, 대화 NPC, 대장간도 같은 방식으로 연결할 수 있다.
플레이어가 E를 누르면 주변 한 칸 안에 있는 InteractableObject를 찾는다. 대상을 발견하면 플레이어 입력 모드를 Interaction으로 전환하고 OnStartInteraction 이벤트를 브로드캐스트한다.
void Player::Interact()
{
auto components = ObjectManager::GetInstance()
.GetComponentsWithTypes<InteractableObject, Position>();
vector<shared_ptr<InteractableObject>> interactableObjectsInRange;
for (auto& [interactableObject, position] : components)
{
if (!MathUtility::IsOverlap(
playerPosition->GetPosition(),
position->GetPosition(), 1))
continue;
interactableObjectsInRange.emplace_back(interactableObject);
}
if (interactableObjectsInRange.empty())
return;
currentInteractableObject = interactableObjectsInRange[0];
currentInteractableObject->Reset();
currentInputMode = CurrentInputMode::Interaction;
ObjectManager::GetInstance().BroadcastEvent(
make_shared<InteractionStartEventParameter>(
currentInteractableObject));
}
상호작용 중에는 Player가 이동이나 공격 입력을 처리하지 않는다. 현재 상호작용 중인 오브젝트가 방향키, 스페이스바, ESC 같은 입력을 받아 자신의 메뉴 상태를 바꾼다. 상호작용이 끝나면 OnStopInteraction 이벤트를 통해 플레이어를 다시 인게임 모드로 돌린다.
문자 입력을 Virtualkey로 추상화하기
기존 입력 이벤트는 _getch()로 받은 char를 그대로 전달했다. 하지만 연금술 메뉴에는 방향키와 ESC, 백스페이스가 필요하다. 방향키는 일반 문자와 달리 특수 키 접두 코드를 먼저 읽은 뒤 다음 값을 한 번 더 읽어야 하므로 기존 방식으로 처리하기 불편했다.
그래서 입력을 Virtualkey enum으로 변환한 다음 이벤트로 보내도록 InputController를 바꿨다.
char input = _getch();
if (input == Const::Key::SPECIAL_KEY_PREFIX)
{
input = _getch();
virtualKey = specialKeyMap[input];
}
else
{
virtualKey = normalKeyMap[input];
}
ObjectManager::GetInstance().BroadcastEvent(
make_shared<InputEventParameter>(virtualKey));
이제 입력을 받는 쪽은 실제 키 코드 대신 Virtualkey::Up, Virtualkey::Escape, Virtualkey::Space처럼 의미가 드러나는 값을 사용한다. 소문자와 대문자도 같은 행동으로 묶을 수 있고, 콘솔 키 코드에 대한 지식도 InputController 안으로 제한할 수 있게 되었다.
문자열 기반 아이템을 테이블 키로 바꾸기
이전 인벤토리는 "Potion", "Gold" 같은 문자열로 아이템을 구분했다. 아이템 종류가 적을 때는 간단하지만, 효과와 가격, 사용 가능 여부가 늘어나면 여러 코드에서 같은 문자열을 비교해야 한다.
이번에는 ItemData와 ItemTable을 추가하고 아이템을 정수 ID로 참조하도록 바꿨다.
struct ItemData
{
int id;
string name;
int cost;
bool isUsable;
StatType statType;
OperatorType operatorType;
float amount;
bool isInstant;
double duration;
double interval;
};
현재 테이블에는 골드, 소형 포션, 슬라임 젤, 뼛가루가 등록되어 있다.
rows[1] = {
1, "Gold", 10, false,
StatType::None, OperatorType::None,
0.0f, false, 0.0, 0.0
};
rows[2] = {
2, "Small Potion", 20, true,
StatType::CurrentHealth, OperatorType::Add,
10.0f, true, 0.0, 0.0
};
InventoryItem은 이름 자체를 저장하는 대신 tableKey와 수량만 가진다. 화면에 이름을 표시하거나 사용 가능 여부를 확인할 때 ItemTable에서 데이터를 조회한다. 필드 아이템과 몬스터 드롭 데이터도 같은 키를 사용하도록 변경했다.
이 구조 덕분에 Player 안에 있던 if (itemName == "Potion") 같은 하드코딩을 제거할 수 있었다. 새 아이템을 추가할 때 Player의 사용 코드를 수정하는 대신 테이블에 어떤 능력치에 어떤 연산을 적용할지만 기록하면 된다.
능력치를 Stat Component로 분리하기
아이템 효과를 데이터로 표현하려면 HP, 공격력, 방어력에도 공통 접근 방식이 필요하다. 기존에는 Player와 Monster가 능력치를 각각 멤버 변수로 들고 있었지만, 이제 Stat Component의 unordered_map<StatType, float>에 저장한다.
unordered_map<StatType, float> initialStats = {
{ StatType::MaxHealth, 100.0f },
{ StatType::CurrentHealth, 100.0f },
{ StatType::Attack, 10.0f },
{ StatType::Defense, 5.0f }
};
아이템을 사용하면 Player는 아이템 종류를 직접 해석하지 않고 ItemData를 Stat::AddBuff()에 넘긴다.
auto itemData = selectedItem.GetItemDataFromTable();
playerStat->AddBuff(*itemData);
AddItemQuantity(selectedItem.GetTableKey(), -1);
Stat은 OperatorType에 따라 더하기, 빼기, 곱하기, 나누기를 수행한다.
switch (buff.operatorType)
{
case OperatorType::Add:
stats[buff.statType] += buff.value;
break;
case OperatorType::Subtract:
stats[buff.statType] -= buff.value;
break;
case OperatorType::Multiply:
stats[buff.statType] *= buff.value;
break;
case OperatorType::Divide:
stats[buff.statType] /= buff.value;
break;
}
현재 소형 포션은 CurrentHealth에 10을 더하는 즉시 효과로 설정되어 있다. 효과 적용 뒤 현재 HP를 0과 최대 HP 사이로 제한하므로 최대치를 넘겨 회복되지 않는다.
즉시 효과와 지속 효과를 같은 구조로 다루기
ItemData에는 isInstant, duration, interval도 포함했다. 지금 등록된 포션은 즉시 회복만 하지만, Stat은 일정 시간 동안 주기적으로 효과를 적용하는 Buff도 보관할 수 있게 설계했다.
void Stat::Update()
{
double nowTime = GameTime::GetInstance().GetTime();
for (auto iter = buffs.begin(); iter != buffs.end();)
{
if (nowTime - iter->lastAppliedTime >= iter->interval)
{
ProcessBuff(*iter);
iter->lastAppliedTime = nowTime;
}
if (nowTime - iter->startTime >= iter->duration)
{
iter = buffs.erase(iter);
continue;
}
++iter;
}
}
이를 위해 게임 시작 이후 흐른 시간을 초 단위로 반환하는 GameTime 싱글턴도 추가했다. 앞으로 지속 회복 포션, 독, 일정 시간 공격력 증가 같은 효과를 같은 데이터 형식으로 만들 수 있다.
연금술 레시피를 데이터로 구성하기
아이템 테이블 위에는 제작법을 관리하는 AlchemyTable을 만들었다. AlchemyData는 결과 아이템 키와 필요한 재료 목록을 가진다.
void AlchemyTable::Init()
{
auto smallPotion = make_shared<AlchemyData>(0, 2);
smallPotion->AddIngredient(10000, 10); // Slime Gell
smallPotion->AddIngredient(10001, 10); // Bone Powder
alchemyDataList.push_back(smallPotion);
}
현재 레시피는 슬라임 젤 10개와 뼛가루 10개로 소형 포션 1개를 만드는 한 가지다. 레시피가 결과와 재료의 테이블 키만 저장하므로 실제 이름은 ItemTable에서 가져온다.
제작할 때는 Player가 재료를 충분히 가지고 있는지 먼저 검사한다. 모든 조건을 만족하면 결과 아이템을 하나 추가하고 재료 수량을 차감한다.
for (const auto& ingredient : recipe->ingredients)
{
if (!player->HasEnoughItem(
ingredient.itemKey,
ingredient.quantity))
{
Logger::LogInfo("Not enough ingredients");
return;
}
}
player->AddItemQuantity(recipe->resultItemKey, 1);
for (const auto& ingredient : recipe->ingredients)
{
player->AddItemQuantity(
ingredient.itemKey,
-ingredient.quantity);
}
상태 스택으로 연금술 메뉴 구성하기
연금술 상점에는 단순한 한 화면보다 여러 단계의 메뉴가 필요했다. 현재 메뉴는 다음 네 상태로 나뉜다.
MainMenu: 레시피 보기, 재료로 검색, 제작, 종료RecipeList: 전체 레시피와 필요한 재료 표시FindRecipe: 입력한 재료 이름으로 레시피 검색Craft: 현재 가진 재료로 제작 가능한 아이템 선택
AlchemyShop은 stack<AlchemyShopState>로 현재 메뉴 경로를 관리한다. 하위 메뉴에 들어가면 상태를 push()하고 ESC를 누르면 pop()한다. 메인 메뉴에서 ESC를 누르면 상호작용 자체를 종료한다.
void AlchemyShop::OnCancel()
{
switch (stateStack.top())
{
case AlchemyShopState::MainMenu:
ObjectManager::GetInstance().BroadcastEvent(
make_shared<InteractionCanceledEventParameter>());
break;
case AlchemyShopState::RecipeList:
case AlchemyShopState::FindRecipe:
case AlchemyShopState::Craft:
Reset();
stateStack.pop();
break;
}
}
화면 출력도 상점이 직접 콘솔에 그리지 않는다. VirtualDisplay가 OnStartInteraction 이벤트로 현재 대상을 기억한 뒤 GetRenderStrings()의 결과를 기존 더블 버퍼에 쓴다. 입력, 메뉴 상태, 렌더링의 역할을 나누면서 다른 상호작용 UI도 같은 화면 시스템을 사용할 수 있게 되었다.
이번 작업에서 남은 문제
기능의 큰 흐름은 연결되었지만 아직 초안에 가까운 부분도 많다.
먼저 지속 Buff의 startTime과 lastAppliedTime이 생성 시 0으로 초기화된다. 게임을 시작한 지 오래 지난 뒤 Buff를 추가하면 즉시 만료된 것으로 판단될 수 있으므로, Buff를 등록하는 시점의 GameTime으로 초기화해야 한다.
현재 상호작용 범위에 대상이 여러 개 있으면 첫 번째 오브젝트를 선택한다. 어떤 대상과 상호작용할지 고르거나 가장 가까운 대상을 선택하는 규칙이 필요하다.
테이블도 아직 C++ 코드 안에서 직접 초기화한다. 구조는 데이터 중심으로 바뀌었지만, 실제 데이터를 CSV나 JSON 같은 외부 파일에서 읽는 단계까지는 가지 않았다. 아이템과 레시피가 많아지기 전에 로딩과 유효성 검사를 분리할 필요가 있다.
그 밖에도 CurrentInputMode::Inetraction처럼 오타가 남아 있고, 메뉴 선택 번호를 case 0, case 1로 구분하는 코드도 enum으로 바꿀 예정이다. 연금술 재료는 테스트를 위해 Player 인벤토리에 바로 넣고 있으므로 실제 몬스터 드롭과 연결해야 한다.
이번 작업의 결과
이번 작업으로 던전 탐험 앞뒤에 붙일 수 있는 준비 공간과 상호작용 기반이 생겼다.
- 0번째 맵에 고정된 마을을 추가했다.
- 마을의 NPC를
N문자로 배치했다. E키로 주변 오브젝트와 상호작용할 수 있다.- 문자 입력을
Virtualkey로 변환해 일반 키와 특수 키를 함께 처리한다. - 아이템을 문자열이 아닌 테이블 키로 관리한다.
- 아이템 이름, 가격, 사용 효과를
ItemData에 모았다. - Player와 Monster의 능력치를
StatComponent로 분리했다. - 즉시 효과와 지속 Buff가 사용할 공통 구조를 만들었다.
- 연금술 레시피와 재료를
AlchemyTable로 관리한다. - 레시피 보기, 검색, 제작이 가능한 상태 기반 메뉴를 추가했다.
- 상호작용 UI를 기존 더블 버퍼 렌더링에 연결했다.
지난번에는 무작위 던전을 만들고 여러 층을 이동하는 데 집중했다면, 이번에는 던전 밖에서 아이템을 준비하고 NPC와 상호작용하는 흐름을 붙였다. 아직 데이터와 UI가 완전히 정리된 것은 아니지만, 새로운 아이템과 상점 기능을 추가할 때 Player 코드를 계속 늘리지 않아도 되는 방향으로 한 걸음 옮겼다.
다음 작업
- 맵 이동: 여러 맵을 만들고, 플레이어가 맵 사이를 이동할 수 있게 만들기
- 여러 층 구조: 점점 아래층으로 내려가는 방식의 던전 만들기
- 마을: NPC와 대화하거나 거래할 수 있는 안전한 공간 만들기
- 연금술 아이템 목록 만들기
- 연금술 상점 만들기
- 보스 몬스터 만들기
- 장비 착용: 무기나 방어구를 착용해 플레이어를 강화할 수 있게 만들기
- 아이템 테이블화: 하드코딩된 아이템 정보를 테이블에서 읽어오도록 바꾸기
- 아이템 사용 로직 모듈화: 테이블 정보만으로 아이템 효과가 적용되도록 구조 정리하기
- 몬스터 AI: 몬스터가 돌아다니거나 플레이어를 탐지해 공격하도록 만들기
- 몬스터 테이블화: 여러 종류의 몬스터를 데이터 기반으로 생성하기
- NPC: 거래와 퀘스트 부여가 가능한 NPC 추가하기
- 퀘스트: NPC가 플레이어에게 목표를 주고, 완료 상태를 추적할 수 있게 만들기
- 직업 추가하기
- 타이틀 화면 만들기
랜덤 던전만 있을 때는 내려가며 싸우는 게임에 가까웠다. 이제 마을에서 준비하고, 던전에서 재료를 모으고, 돌아와 아이템을 제작하는 순환 구조의 첫 조각이 생겼다.
GitHub
전체 소스 코드는 jjr2930/text-rpg-mandatory에서 확인할 수 있다.