C++로 ASCII RPG 만들기 - 장비 테이블과 몬스터 구조 리팩터링
ASCII 타이틀 화면과 JSON 장비 테이블, 슬롯 기반 장착 및 능력치 표시를 추가하고 일반 몬스터·보스의 공통 구조를 정리한 과정을 기록했습니다.
아이템을 모으는 것에서 장착하는 것으로
지난 작업에서는 아이템, 연금술 레시피, 몬스터와 드롭 정보를 JSON 파일로 분리하고 레드 드래곤 보스 AI를 추가했다. 던전에서 재료를 얻어 포션을 만드는 흐름은 생겼지만, 캐릭터를 지속적으로 강화할 수단은 레벨업뿐이었다.
이번에는 몬스터가 장비를 떨어뜨리고, 인벤토리에서 장비를 선택해 부위별 슬롯에 장착할 수 있는 기반을 만들었다. 이 과정에서 소비 아이템과 재료, 장비를 같은 방식으로 조회할 수 있도록 공통 아이템 구조를 정리했다.
또 레드 드래곤을 추가하며 갈라졌던 일반 몬스터와 보스의 생성 과정을 다시 살펴봤다. 능력치와 피격, 사망, 드롭처럼 두 종류가 공유하는 부분은 Monster로 모으고, 플레이어 근처에서 반복 공격하는 일반 몬스터의 행동만 NormalMonster로 분리했다.
현재 장비는 획득하고 슬롯에 넣은 뒤 능력치 보정값을 계산해 화면에 표시하고, 변경된 능력치를 실제 전투에 사용하는 단계까지 동작한다. JSON에 기록한 최소 레벨 제한처럼 아직 연결하지 않은 규칙도 있지만 장비를 통한 성장 흐름은 플레이에 반영된다.
타이틀 화면 추가하기
게임을 실행하자마자 마을이나 던전 화면이 나타나는 대신, 먼저 게임의 이름을 보여주는 타이틀 화면을 추가했다.

화면에는 ASCII 아트로 만든 DRAGON SLAYER 제목과 Press Any Key 안내를 표시한다. 별도의 TitleDrawer가 Raw String Literal로 작성한 여러 줄 문자열을 출력하고 _getch()로 키 입력을 기다린다.
void TitleDrawer::ProcessTitle()
{
string title = R"(
DRAGON SLAYER
Press Any Key
)";
cout << title;
_getch();
}
main()에서는 게임의 Entity와 GameManager를 만들기 전에 ProcessTitle()을 호출한다. 사용자가 키를 누른 뒤에야 실제 게임 초기화가 이어지므로 간단하지만 시작과 플레이 화면이 명확히 구분된다.
1. 장비도 JSON 테이블로 관리하기
새로운 GearTable.json에는 20개의 장비를 등록했다. 장비 부위는 무기, 머리, 몸, 발, 장신구의 다섯 종류다.
{
"key": 5010,
"name": "Ironbark Armor",
"cost": 320,
"isUsable": false,
"slot": "Body",
"minLevel": 4,
"statModifiers": [
{
"statType": "Defense",
"operatorType": "Add",
"amount": 4.0
},
{
"statType": "MaxHealth",
"operatorType": "Add",
"amount": 15.0
}
]
}
장비 한 행에는 공통 아이템 정보와 장착에 필요한 정보를 함께 저장한다.
key,name,cost: 아이템을 식별하고 표시하기 위한 기본 정보slot: 장비가 들어갈 부위minLevel: 장착에 필요한 최소 레벨statModifiers: 장착 중 적용할 능력치와 연산 방식
GearTable::Init()은 문자열로 작성된 slot, statType, operatorType을 enum으로 변환한다. 지난 작업에서 JSON과 enum을 연결한 방식을 장비 데이터에도 그대로 확장했다.
gearData->slot = item["slot"].get<GearSlotType>();
gearData->minLevel = item["minLevel"].get<int>();
for (const auto& modifier : item["statModifiers"])
{
auto statModifier = make_shared<GearStatModifier>();
statModifier->statType = modifier["statType"].get<StatType>();
statModifier->operatorType = modifier["operatorType"].get<OperatorType>();
statModifier->amount = modifier["amount"].get<float>();
gearData->statModifiers.emplace_back(statModifier);
}
장비 효과를 배열로 둔 이유는 하나의 장비가 여러 능력치를 바꿀 수 있기 때문이다. 무기는 공격력 하나만 올릴 수 있지만 방어구는 방어력과 최대 HP를 동시에 올리고, 장신구는 공격력·방어력·최대 HP를 조합할 수 있다.
2. 공통 아이템 형식과 ItemBank
소비 아이템, 연금술 재료, 장비는 세부 데이터가 서로 다르지만 키, 이름, 가격, 사용 가능 여부는 공통으로 가진다. 이 값들을 IItem에 모으고 각 테이블의 데이터가 이를 상속하도록 구성했다.
class IItem
{
public:
virtual ~IItem() = default;
ItemType itemType;
int key;
string name;
bool isUsable;
int cost;
};
이름은 인터페이스처럼 보이지만 현재 IItem에는 순수 가상 함수가 없다. 공통 데이터를 다형적으로 전달하기 위한 기반 클래스에 더 가깝다. 가상 소멸자를 두었기 때문에 dynamic_pointer_cast로 실제 데이터 형식을 확인할 수 있다.
InventoryItem은 실제 데이터 전체를 복사하지 않고 테이블 키, 아이템 종류, 수량만 보관한다. 이름이나 세부 정보가 필요하면 ItemBank에 두 값을 전달한다.
shared_ptr<IItem> ItemBank::GetItem(
int tableKey, ItemType type) const
{
switch (type)
{
case ItemType::Consumable:
return ConsumableItemTable::GetInstance().GetData(tableKey);
case ItemType::Ingredient:
return IngredientTable::GetInstance().GetData(tableKey);
case ItemType::Gear:
return GearTable::GetInstance().GetData(tableKey);
default:
return nullptr;
}
}
인벤토리 입장에서는 모든 아이템을 InventoryItem으로 다루고, 실제 효과가 필요한 순간에만 구체적인 데이터 형식으로 변환한다. 새 아이템 종류가 생겨도 인벤토리 자체의 저장 구조를 다시 만들 필요가 없다.
3. 인벤토리에서 아이템 종류까지 구분하기
테이블이 여러 개로 나뉘면서 정수 키만으로는 아이템을 완전히 식별할 수 없게 되었다. 그래서 InventoryItem에 ItemType을 추가하고 수량 변경과 검색에도 키와 종류를 함께 사용하도록 인터페이스를 바꿨다.
InventoryItem(
int tableKey,
ItemType itemType,
int quantity);
bool HasItem(
int tableKey,
ItemType itemType,
int* index) const;
연금술 상점도 재료를 검사할 때 ItemType::Ingredient, 결과 포션을 지급할 때 ItemType::Consumable을 명시한다. 같은 숫자 키가 다른 테이블에 존재하더라도 잘못된 종류의 아이템을 재료로 사용하는 일을 막을 수 있다.
인벤토리에서 I 키를 누르면 선택 모드로 들어가고 W, S로 커서를 움직인다. Space를 누르면 선택한 아이템 종류에 따라 소비 아이템은 사용하고 장비는 장착한다.
switch (itemType)
{
case ItemType::Consumable:
playerStat->AddBuff(consumableData);
AddItemQuantity(
consumableData->key,
consumableData->itemType, -1);
break;
case ItemType::Gear:
RemoveItemFromInventory(
gearData->key,
gearData->itemType);
EquipItem(gearData->key, gearData->itemType);
break;
}
4. 부위별 장비 장착과 교체
플레이어는 unordered_map<GearSlotType, InventoryItem>인 equippedGear에 현재 장착한 아이템을 보관한다. 슬롯을 키로 사용하므로 같은 부위에는 하나의 장비만 들어갈 수 있다.
auto found = equippedGear.find(gearData->slot);
if (found != equippedGear.end())
{
auto current = found->second;
AddItemQuantity(
current.GetTableKey(),
current.GetItemType(),
current.GetQuantity());
}
equippedGear.insert_or_assign(gearData->slot, newItem);
선택한 슬롯에 기존 장비가 있으면 먼저 인벤토리로 돌려보낸다. 이후 insert_or_assign()으로 같은 슬롯의 값을 새 장비로 교체한다. 별도의 중복 검사 없이 컨테이너 자체가 슬롯당 하나의 장비만 유지하게 했다.
새 장비는 장착 전에 인벤토리에서 제거된다. 장비 수량은 1로 만들어 equippedGear에 저장하고, 나중에 교체할 때 다시 인벤토리에 한 개를 추가한다.

실행 화면에서는 Green Slime을 처치해 Rusty Iron Sword를 얻은 뒤 인벤토리에서 장착한 결과를 확인할 수 있다. 장비는 인벤토리에서 사라지고 오른쪽의 Gear 영역으로 이동한다. 플레이어 상태에는 기본 공격력 10과 장비로 증가한 값 2가 ATK : 10 +2 형태로 따로 표시된다.
VirtualDisplay는 GetEquippedGear()로 현재 장비 목록을 가져와 이름을 출력한다. 능력치도 기본값과 장비 보정값을 분리해 보여준다.
WriteString(
nextBufferIndex,
PLAYER_STATUS_POSITION.x,
PLAYER_STATUS_POSITION.y + 4,
format("ATK : {0} +{1}",
player->GetAttack(),
player->GetAddedStat(StatType::Attack)));
장비의 보정값은 GetTotalStat()에서 계산한다. 먼저 더하기, 빼기, 곱하기, 나누기의 연산 순서를 정한 뒤 모든 장착 장비의 statModifiers를 순회한다. GetAddedStat()은 계산된 최종값에서 기본 능력치를 빼 화면에 표시할 추가분을 구한다.
float Player::GetAddedStat(StatType statType) const
{
float totalStat = GetTotalStat(statType);
float baseStat = playerStat->GetStat(statType);
return totalStat - baseStat;
}
전투에서도 장비가 포함된 최종 능력치를 사용한다. 플레이어가 몬스터를 공격할 때 GetTotalStat(StatType::Attack)의 결과를 피해 계산에 전달하므로 화면의 ATK : 10 +2는 표시용 숫자에 그치지 않고 실제 공격력 12로 적용된다.
monster->TakeDamage(
GetTotalStat(StatType::Attack));
5. 몬스터 드롭에 아이템 종류 추가하기
장비를 얻으려면 기존 드롭 테이블도 여러 아이템 종류를 표현할 수 있어야 한다. MonsterItemDropData에 itemType을 추가하고 JSON의 각 드롭 행에 Ingredient 또는 Gear를 기록했다.
{
"key": 5001,
"itemType": "Gear",
"minQuantity": 1,
"maxQuantity": 1,
"dropChance": 0.03
}
몬스터를 처치하면 RollDropTable()이 목록의 확률 값을 모두 더한 범위에서 난수를 뽑고, 누적값이 난수를 넘는 첫 번째 항목을 반환한다.
double totalRange = 0.0;
for (auto& element : dropItems)
totalRange += element->dropChance;
double random = Random::GetInstance()
.RandomRange(0.0, totalRange);
double elapsed = 0.0;
for (auto& element : dropItems)
{
elapsed += element->dropChance;
if (random <= elapsed)
return element;
}
이 구현에서 dropChance는 독립적인 확률보다 후보 사이의 가중치로 동작한다. 목록이 비어 있지 않다면 원칙적으로 항목 하나가 선택되고, 실제 선택 확률은 개별 값이 전체 합에서 차지하는 비율이 된다.
장비 항목은 테이블을 불러올 때 GEAR_DROP_CHANCE_MULTIPLIER를 곱한다. 다만 현재 상수는 10000이고 주석에는 20%라고 적혀 있어 의미가 맞지 않는다. 지금의 가중치 방식에서 이 값을 그대로 사용하면 장비가 거의 항상 선택되므로, 테스트용 배율인지 실제 드롭 확률인지 먼저 정리해야 한다.
6. Monster와 NormalMonster 분리하기
기존 Monster는 HP, 공격력, 방어력 같은 데이터와 일반 몬스터의 근접 공격 행동을 모두 가지고 있었다. 레드 드래곤이 별도의 상태 머신을 사용하게 되면서 공통 데이터와 구체적인 행동을 분리할 필요가 생겼다.
이제 Monster는 다음 역할을 담당한다.
Stat을 통한 피해와 사망 처리- 경험치 조회
- 드롭 테이블 보관과 결과 추첨
- 맵 전환 시 Entity 제거
NormalMonster는 Monster를 상속하고 플레이어가 한 칸 안에 들어왔을 때 일정 간격으로 공격하는 행동만 구현한다.
class NormalMonster : public Monster
{
public:
using Monster::Monster;
void Update() override;
private:
bool fistOverlap;
double nextAttackTime;
};
현재 변수명 fistOverlap에는 firstOverlap의 오타가 남아 있다. 동작에는 영향을 주지 않지만 역할이 바로 읽히도록 이름을 수정할 예정이다.
생성 과정도 CreateMonsterBase()와 구체적인 몬스터 생성 함수로 나눴다. Base 함수는 테이블 데이터를 읽어 Position, Renderer, DungeonObjectTag, Stat을 붙인다. 일반 몬스터는 여기에 NormalMonster를, 보스는 Dragon Component를 추가한다.
auto monsterEntity = CreateMonsterBase(position, displayChar);
// 일반 몬스터
monsterEntity->AddComponent<NormalMonster>(params);
// 레드 드래곤
monsterEntity->AddComponent<Dragon>(dragonParams);
이 변경으로 지난 글에서 남은 문제였던 레드 드래곤 능력치 하드코딩도 제거했다. 이제 보스 역시 MonsterTable의 HP, 공격력, 방어력, 공격 간격과 경험치를 사용한다. 일반 몬스터와 보스가 같은 능력치 생성 경로를 공유하므로 JSON을 수정한 결과가 실제 Entity에 반영된다.
이번 작업에서 남은 문제
minLevel도 JSON에서 읽기만 하고 장착 전에 검사하지 않는다. 현재는 레벨 1 플레이어가 최종 장비를 얻으면 바로 장착할 수 있다. 장착 장비 목록은 화면에 표시되지만 슬롯 이름은 나오지 않으며, 장비를 직접 해제하는 입력도 아직 없다.
GearTable::GetRandomItem()은 빈 포인터를 반환하고, ItemBank::GetRandomItem()의 무작위 종류 범위도 소비 아이템부터 재료까지만 포함한다. 필드에서 무작위 장비를 생성하려면 두 부분을 연결해야 한다.
인벤토리에서 장비를 선택한 코드는 InventoryItem& 참조를 잡은 뒤 벡터에서 해당 원소를 삭제하고, 마지막 로그에서 그 참조를 다시 사용한다. vector::erase() 이후의 참조는 유효하지 않으므로 이름을 삭제 전에 복사하거나 장착 로그를 GearData에서 가져오도록 바꿔야 한다.
InventoryItem& selectedItem = inventory[inventoryCursorIndex];
RemoveItemFromInventory(gearData->key, gearData->itemType);
EquipItem(gearData->key, gearData->itemType);
// selectedItem은 erase 이후 더 이상 안전하지 않다.
Logger::LogInfo(format(
"Equipped item: {0}", selectedItem.GetName()));
마지막으로 일부 수량 변경 코드는 키만 비교하고 아이템 종류는 비교하지 않는다. 현재는 테이블마다 키 범위를 다르게 사용해 충돌을 피하고 있지만, (tableKey, itemType)을 아이템의 정식 식별자로 정했다면 모든 검색 함수에서 같은 규칙을 적용하는 편이 안전하다.
이번 작업의 결과
- 20개의 장비를
GearTable.json에 등록했다. - ASCII 아트로 구성한 타이틀 화면을 추가했다.
- 무기, 머리, 몸, 발, 장신구의 다섯 장비 슬롯을 만들었다.
- 하나의 장비가 여러 능력치 보정값을 가질 수 있게 했다.
IItem으로 소비 아이템, 재료, 장비의 공통 데이터를 묶었다.ItemBank가 아이템 종류에 맞는 테이블을 선택한다.- 인벤토리가 키와 아이템 종류를 함께 저장한다.
- 인벤토리에서 Space를 눌러 장비를 장착할 수 있다.
- 같은 슬롯의 기존 장비는 인벤토리로 돌아온다.
- 장착 장비의 이름과 능력치 추가분을 화면에 표시한다.
- 장비가 적용된 최종 공격력을 실제 전투 피해에 사용한다.
- 몬스터 드롭 테이블에 장비와 아이템 종류를 추가했다.
- 몬스터의 공통 로직과 일반 공격 행동을 분리했다.
- 레드 드래곤 능력치를
MonsterTable과 연결했다.
지난 작업에서 외부 JSON 데이터와 보스 AI를 만들었다면, 이번에는 그 데이터 구조를 장비까지 확장하고 일반 몬스터와 보스가 공유할 경계를 다시 정리했다. 이제 던전에서 얻은 장비를 부위별로 교체하면 상태 화면의 보정값이 바뀌고, 변경된 공격력이 실제 전투에도 적용된다.
다음 작업
- 맵 이동: 여러 맵을 만들고, 플레이어가 맵 사이를 이동할 수 있게 만들기
- 여러 층 구조: 점점 아래층으로 내려가는 방식의 던전 만들기
- 마을: NPC와 대화하거나 거래할 수 있는 안전한 공간 만들기
- 연금술 아이템 목록 만들기
- 연금술 상점 만들기
- 보스 몬스터 만들기
- 아이템 테이블화: 하드코딩된 아이템 정보를 테이블에서 읽어오도록 바꾸기
- 아이템 사용 로직 모듈화: 테이블 정보만으로 아이템 효과가 적용되도록 구조 정리하기
- 몬스터 AI: 몬스터가 돌아다니거나 플레이어를 탐지해 공격하도록 만들기
- 몬스터 테이블화: 여러 종류의 몬스터를 데이터 기반으로 생성하기
- 장비 착용: 무기나 방어구를 착용해 플레이어를 강화할 수 있게 만들기
- 타이틀 화면 만들기
- NPC: 거래와 퀘스트 부여가 가능한 NPC 추가하기 -> 연금술사 추가(재료로 표션 만들어 줌)
- 퀘스트: NPC가 플레이어에게 목표를 주고, 완료 상태를 추적할 수 있게 만들기
- 직업 추가하기
다음에는 장비 슬롯과 최소 레벨을 UI에서 더 분명하게 확인할 수 있도록 다듬고, 장비를 직접 해제하는 기능도 추가할 예정이다.
GitHub
전체 소스 코드는 jjr2930/text-rpg-mandatory에서 확인할 수 있다.