← 모든 글

C++로 ASCII RPG 만들기 - JSON 데이터 테이블과 레드 드래곤 AI

코드에 있던 아이템과 연금술 데이터를 JSON 파일로 분리하고, 층별 몬스터와 드롭 테이블을 추가한 뒤 상태 머신으로 레드 드래곤 보스 AI를 구현한 과정을 기록했습니다.

C++ C++ASCII RPGJSONData DrivenState MachineBoss AI

코드 밖으로 게임 데이터를 꺼내기

지난 작업에서는 아이템과 연금술 레시피를 테이블 형태로 정리했다. 플레이어 코드에 아이템 이름과 효과를 직접 작성하던 구조에서는 벗어났지만, 테이블의 행 자체는 여전히 C++ 코드 안에서 초기화하고 있었다.

아이템이나 레시피가 한두 개일 때는 큰 문제가 아니지만 종류가 늘어나면 데이터를 수정할 때마다 코드를 다시 컴파일해야 한다. 몬스터까지 같은 방식으로 추가하면 능력치, 등장 층, 드롭 아이템 같은 값이 여러 클래스에 흩어질 가능성도 컸다.

이번에는 nlohmann::json을 프로젝트에 추가하고 소비 아이템, 연금술 재료, 레시피, 몬스터, 드롭 정보를 각각 JSON 파일로 분리했다. 그 위에 층별 몬스터 생성 구조를 연결하고, 마지막 던전에 상태 머신으로 동작하는 레드 드래곤 보스도 추가했다.

눈에 보이는 가장 큰 변화는 보스지만, 작업의 중심은 콘텐츠를 코드 수정 없이 늘릴 수 있는 데이터 기반을 만드는 일이었다.

1. JSON 파서 추가

JSON 처리는 nlohmann/json을 사용했다. 현대 C++에서 JSON을 직관적으로 다룰 수 있도록 만든 오픈 소스 라이브러리로, JSON의 객체와 배열을 std::map이나 std::vector와 비슷한 문법으로 읽고 쓸 수 있다. 파일 파싱뿐 아니라 STL 컨테이너 변환과 사용자 정의 자료형의 직렬화·역직렬화도 지원한다.

여러 JSON 라이브러리 가운데 이것을 선택한 가장 큰 이유는 사용에 필요한 코드가 json.hpp 헤더 하나로 제공된다는 점이었다. 별도의 라이브러리를 빌드하거나 링크 설정을 추가할 필요 없이 파일 하나를 프로젝트에 넣고 #include "json.hpp"만 작성하면 바로 사용할 수 있다. 이 단순한 도입 방식이 정말 마음에 들었다.

#include "json.hpp"

using json = nlohmann::json;

각 테이블의 Init()은 파일 스트림을 열고 JSON 루트가 배열인지 확인한 다음, 각 원소를 C++ 데이터 구조로 변환한다.

using json = nlohmann::json;

void MonsterTable::Init()
{
    ifstream stream("MonsterTable.json");
    if (!stream.is_open())
        throw runtime_error("Failed to open MonsterTable.json");

    json j;
    stream >> j;

    if (!j.is_array())
        throw runtime_error("Invalid JSON format: expected an array");

    for (auto& element : j)
    {
        MonsterData data;
        data.key = element["key"].get<int>();
        data.name = element["name"].get<string>();
        data.displayChar = element["displayChar"].get<string>()[0];
        data.minFloor = element["minFloor"].get<int>();
        data.maxFloor = element["maxFloor"].get<int>();
        // ...
        monsterDataList.push_back(make_shared<MonsterData>(data));
    }
}

파일을 열 수 없거나 배열 형식이 아니면 예외를 발생시킨다. 필수 필드가 없거나 자료형이 다를 때도 JSON 라이브러리에서 예외가 발생하므로, 잘못된 데이터를 조용히 사용하기보다 게임 시작 단계에서 문제를 확인할 수 있다.

JSON 파일을 각 테이블로 읽어 게임 시스템에서 사용하는 흐름

테이블에 들어갈 JSON 데이터는 AI Codex에 명령해 생성했다. 먼저 필요한 필드와 키 범위, 아이템 효과, 몬스터의 등장 층과 드롭 규칙을 정리해 전달한 뒤 ConsumableItemTable.json, IngredientTable.json, AlchemyTable.json, MonsterTable.json, MonsterItemDropTable.json의 초안을 만들었다. 비슷한 형식의 행을 여러 개 작성하고 몬스터, 재료, 연금술 레시피 사이의 키를 연결하는 반복 작업을 빠르게 처리할 수 있었다.

다만 AI가 만든 결과를 그대로 사용하지는 않았다. 생성된 키가 다른 테이블에 실제로 존재하는지, 필드 이름이 C++ 로더와 일치하는지, 수치가 의도한 플레이 구간에 적당한지는 직접 확인해야 했다. 이번 작업에서도 재료 JSON의 key, cost와 로더가 읽는 id, price가 일치하지 않는 문제가 발견됐다. Codex는 테이블 설계를 대신하기보다, 내가 정한 규칙에 따라 데이터 초안을 빠르게 채우는 도구로 사용했다.

현재 각 테이블은 비슷한 로딩 코드를 따로 가지고 있다. 중복은 있지만 먼저 데이터 구조마다 필요한 필드를 명확하게 구현한 뒤, 공통 로더가 필요한 시점에 묶는 방향으로 진행했다.

2. 아이템 테이블을 종류별로 분리하기

기존 ItemTable 하나에 모든 아이템을 넣던 구조를 소비 아이템과 연금술 재료로 분리했다.

소비 아이템은 JSON에 효과를 적용할 능력치와 연산 방식까지 저장한다.

{
  "key": 2005,
  "name": "Regeneration Potion",
  "cost": 250,
  "isUsable": true,
  "statType": "CurrentHealth",
  "operatorType": "Add",
  "amount": 10.0,
  "isInstant": false,
  "duration": 10.0,
  "interval": 1.0
}

이 데이터는 10초 동안 1초 간격으로 현재 HP를 10씩 회복하는 포션을 의미한다. statTypeoperatorType은 문자열을 enum으로 변환하므로 JSON에서도 값의 의미를 읽기 쉽다.

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);
        default:
            return nullptr;
    }
}

현재 소비 아이템은 즉시 회복 포션뿐 아니라 지속 회복, 공격력·방어력 증가, 해독제, 횃불, 독 플라스크까지 데이터로 등록했다. 아직 모든 능력치가 실제 플레이에 완전히 연결된 것은 아니지만 새 효과를 시험할 데이터 기반은 마련되었다.

3. 연금술 레시피도 JSON으로 이전하기

지난 글의 AlchemyTable은 소형 포션 레시피를 C++ 코드에서 직접 생성했다. 이제 레시피 키, 결과 아이템 키, 필요한 재료 목록을 AlchemyTable.json에서 읽는다.

{
  "key": 4001,
  "resultKey": 2001,
  "ingredients": [
    { "key": 10000, "quantity": 2 },
    { "key": 10002, "quantity": 2 }
  ]
}

로더는 ingredients 배열을 순회하며 AlchemyDataElement를 만든다.

for (auto& ingredient : iter["ingredients"])
{
    AlchemyDataElement element;
    element.itemKey = ingredient["key"].get<int>();
    element.quantity = ingredient["quantity"].get<int>();
    newData.ingredients.push_back(element);
}

레시피는 기존 한 종류에서 21종으로 늘어났다. 결과와 재료를 모두 테이블 키로 연결했기 때문에 연금술 UI와 제작 로직은 개별 아이템 이름을 알 필요가 없다.

4. 몬스터 테이블과 층별 등장 범위

몬스터 데이터에는 표시 문자, 능력치, 경험치, 공격 간격뿐 아니라 등장 가능한 최소·최대 층을 추가했다.

{
  "key": 3005,
  "name": "Cave Troll",
  "displayChar": "T",
  "dropTableKey": 3005,
  "minFloor": 4,
  "maxFloor": 6,
  "hp": 55,
  "attack": 15,
  "defense": 4,
  "exp": 38,
  "attackDelay": 3
}

랜덤 맵 생성기는 현재 층에서 등장할 수 있는 데이터만 가져온 뒤 그중 하나를 무작위로 선택한다.

auto possibleMonsters =
    MonsterTable::GetInstance().GetMonstersByFloor(floor);

MinMaxInt monsterIndexRange(
    0, static_cast<int>(possibleMonsters.size()) - 1);

shared_ptr<MonsterData> randomMonster =
    possibleMonsters[monsterIndexRange.GetRandomValue()];

outputMap->SetCellData(
    monsterX, monsterY, randomMonster->displayChar);

초반에는 L로 표시되는 Green Slime과 K인 Skeleton Warrior가 등장하고, 내려갈수록 Cave Troll, Stone Golem, Ancient Wyvern처럼 강한 몬스터가 후보에 들어온다. 기존처럼 모든 몬스터를 같은 M으로 만들지 않고 표시 문자 자체가 테이블 데이터를 찾는 키 역할도 한다.

맵 문자에서 Entity를 만들 때 GameManagerMonsterTable::IsExist()로 해당 문자가 몬스터인지 검사한다. CreationUtil::CreateMonster()는 다시 문자로 데이터를 조회해 HP, 공격력, 방어력, 경험치, 공격 간격과 드롭 테이블 키를 Component에 전달한다.

5. 몬스터 드롭을 별도 테이블로 분리하기

몬스터 한 행에 드롭 아이템을 모두 넣는 대신 MonsterItemDropTable.json을 별도로 만들었다. 각 드롭 항목은 아이템 키, 최소·최대 수량, 드롭 확률을 가진다.

{
  "key": 3001,
  "name": "Green Slime",
  "dropItems": [
    {
      "key": 10000,
      "minQuantity": 1,
      "maxQuantity": 3,
      "dropChance": 0.85
    }
  ]
}

몬스터는 생성될 때 dropTableKey로 자신의 드롭 목록을 가져온다. 그 결과 던전 층, 몬스터 종류, 연금술 재료가 하나의 흐름으로 연결되었다. 특정 포션을 만들려면 필요한 재료를 확인하고, 그 재료를 떨어뜨리는 몬스터가 등장하는 층까지 탐험하는 구조를 만들 수 있다.

마지막 층에 레드 드래곤 보스 방 추가

랜덤 던전 뒤에는 별도의 고정 보스 방을 추가했다. GameManager는 마을과 랜덤 던전들을 만든 뒤 CreateDragonRoom()의 결과를 마지막 맵에 넣는다.

보스 방은 일반 던전과 달리 미리 작성한 40×20 문자 맵이다. D 문자를 읽으면 CreationUtil::CreateDragon()이 Position, Renderer, Dragon, Stat, DungeonObjectTag Component를 가진 Entity를 만든다.

일반 몬스터는 플레이어가 가까이 있으면 일정 간격으로 공격하는 단순한 동작을 사용한다. 레드 드래곤은 추적과 두 가지 공격 패턴이 필요했기 때문에 별도의 상태 머신을 구성했다.

1. FSM이란 무엇인가

FSM(Finite State Machine, 유한 상태 머신)은 객체가 가질 수 있는 행동을 유한한 수의 상태로 나누고, 정해진 조건에 따라 현재 상태를 다른 상태로 전환하는 구조다. 한 시점에는 하나의 상태만 활성화되며, 활성 상태가 그 순간 객체가 수행할 행동을 결정한다.

FSM은 크게 세 요소로 구성된다.

예를 들어 레드 드래곤이 대기 중이라면 Idle 상태의 행동만 수행한다. 대기 시간이 끝나고 추적이 선택되면 Idle → Moving 전환이 발생한다. 플레이어 앞까지 도착하면 Moving → Melee Attack으로 이동하고, 플레이어가 공격 범위를 벗어나면 다시 Idle로 돌아간다.

Idle에서 추적과 원거리 공격으로 분기한 뒤 다시 Idle로 돌아오는 FSM 기본 흐름

이 구조의 장점은 보스의 모든 행동을 하나의 Update() 함수 안에 중첩된 조건문으로 작성하지 않아도 된다는 점이다. 이동 상태는 경로 탐색과 이동만, 근접 공격 상태는 공격과 거리 검사만 담당한다. 새로운 행동 패턴을 추가할 때도 기존 상태의 내부 로직을 크게 수정하지 않고 새 State와 Transition을 연결할 수 있다.

다만 상태와 전환이 지나치게 많아지면 전체 흐름을 추적하기 어려워질 수 있다. 이번 레드 드래곤은 행동이 네 종류뿐이고 전환 조건도 단순하기 때문에, 복잡한 의사 결정 구조보다 FSM이 잘 맞는다고 판단했다.

2. 상태 머신으로 보스 행동 나누기

DragonStateMachine은 네 가지 상태를 가진다.

레드 드래곤의 상태와 전환 조건

상태 전환은 Transition에 저장한 콜백이 결정한다. Idle이 끝나면 50% 확률로 플레이어를 추적하고, 나머지 경우에는 원거리 공격을 시작한다.

shared_ptr<Transition> idleToMoving = make_shared<Transition>(
    idleState,
    bind(&DragonStateMachine::RollMove, this, placeholders::_1),
    movingState);

shared_ptr<Transition> idleToRangeAttack = make_shared<Transition>(
    idleState,
    bind(&DragonStateMachine::RollRangeAttack, this, placeholders::_1),
    rangeAttackState);

StateMachine::Update()는 현재 상태를 갱신한 뒤 그 상태에서 출발하는 전환만 검사한다. 조건을 만족하면 기존 상태의 End()를 호출하고 새 상태의 Start()를 실행한다.

이 구조에서는 Dragon 클래스가 매 프레임 거대한 조건문으로 모든 행동을 판단하지 않는다. 각 상태가 자신의 데이터와 동작을 관리하고, 상태 머신은 전환 규칙만 담당한다.

3. BFS로 플레이어 앞까지 이동하기

Moving 상태는 현재 월드의 DungeonObjectTag와 Position을 모아 임시 문자 맵을 만든 뒤 BFS를 실행한다. 부모 좌표를 기록하면서 플레이어까지 탐색하고, 도착한 뒤에는 부모를 역추적해 이동 경로를 복원한다.

while (!q.empty())
{
    Vector2Int now = q.front();
    q.pop();

    if (now.x == endPoint.x && now.y == endPoint.y)
    {
        found = true;
        break;
    }

    for (const auto& dir : directions)
    {
        Vector2Int next(now.x + dir.x, now.y + dir.y);

        if (visited[next.y][next.x])
            continue;
        if (map[next.y][next.x] == Const::Map::WALL)
            continue;

        q.push(next);
        visited[next.y][next.x] = true;
        parentMap[next.y][next.x] = now;
    }
}

복원한 경로에서는 드래곤의 현재 위치와 플레이어 위치를 제외한다. 따라서 보스는 플레이어와 같은 칸까지 들어가지 않고 바로 앞 칸에서 멈춰 근접 공격 상태로 전환한다.

플레이어가 이동하면 경로를 다시 계산하고, 0.25초마다 다음 좌표로 이동한다. 길을 찾지 못해 Moving 상태가 계속 유지되는 상황을 막기 위해 이동 시간 초과 조건도 추가했다.

4. 근접 공격과 원거리 투사체

근접 공격 상태에서는 드래곤과 플레이어의 맨해튼 범위가 1인지 확인한다. 범위 안에 있는 동안 드래곤의 공격력으로 피해를 주고, 플레이어가 벗어나면 Idle로 돌아간다.

원거리 공격 상태에 들어가면 공격 간격과 발사 횟수를 각각 무작위로 정한다. 현재 범위는 0.1~1초 간격, 1~5발이다.

totalAttackCount = attackCountRange.GetRandomValue();
attackDelay = attackDelayRange.GetRandomValue();

if (GameTime::GetTime() - lastAttackTime >= attackDelay)
{
    CreationUtil::CreateDragonBullet(
        dragonPosition->GetPosition(),
        playerPosition->GetPosition(),
        10);

    currentAttackCount++;
    lastAttackTime = GameTime::GetTime();
}

레드 드래곤이 플레이어를 향해 여러 발의 투사체를 발사하는 실행 화면

실행 화면에서 D는 레드 드래곤, P는 플레이어, 여러 위치에 표시된 B는 드래곤이 발사한 투사체다. 원거리 공격 상태가 선택되면 드래곤의 위치에서 플레이어가 있던 방향으로 여러 발이 연속 생성되고, 각 투사체는 서로 독립적으로 이동한다. 화면 아래 로그에서는 RollRangeAttack()의 결과로 원거리 공격이 선택되는 과정과, 충돌하거나 맵 밖으로 나간 투사체가 제거되는 Bullet destroyed 메시지도 확인할 수 있다.

드래곤 투사체의 생성과 이동

원거리 공격은 플레이어에게 즉시 피해를 주는 방식이 아니라 DragonBullet Entity를 월드에 생성하는 방식으로 구현했다. 생성할 때는 발사 위치, 목표 위치, 이동 속도, 피해량을 ConstructionParameter에 담아 전달한다.

class ConstructionParameter
{
public:
    Vector2Int spawnPosition;
    Vector2Int targetPosition;
    float movingSpeed;
    int damage;
};

투사체는 생성 시점의 플레이어 위치를 목표 지점으로 사용한다. 목표 지점에서 발사 지점을 뺀 벡터를 정규화하면 길이가 1인 이동 방향을 얻을 수 있다.

Vector2 spawnPoint(
    static_cast<float>(params->spawnPosition.x),
    static_cast<float>(params->spawnPosition.y));

Vector2 targetPoint(
    static_cast<float>(params->targetPosition.x),
    static_cast<float>(params->targetPosition.y));

virtualPosition = spawnPoint;
virtualDirection = targetPoint - spawnPoint;
virtualDirection.Normalize();

방향은 발사 순간에 한 번만 계산한다. 따라서 플레이어를 계속 따라가는 유도탄이 아니라, 플레이어가 발사 이후 움직이면 피할 수 있는 직선 투사체가 된다.

Vector2 연산자 오버로딩

ASCII 화면은 정수 좌표로 이루어져 있지만, 투사체가 프레임마다 한 칸씩 끊겨 움직이면 속도가 지나치게 빠르고 이동도 부자연스럽게 보였다. 조금 더 그럴듯한 움직임을 만들기 위해 실수형 x, y를 저장하는 Vector2를 새로 만들어 투사체의 내부 좌표로 사용했다.

벡터 계산을 간결하게 표현할 수 있도록 Vector2에는 연산자 오버로딩도 추가했다. 벡터끼리 더하고 빼는 +, -, 벡터에 실수를 곱하는 *, 현재 벡터에 다른 벡터를 누적하는 +=를 지원한다.

Vector2 operator-(const Vector2& other) const
{
    return Vector2(x - other.x, y - other.y);
}

Vector2 operator*(float scalar) const
{
    return Vector2(x * scalar, y * scalar);
}

Vector2& operator+=(const Vector2& other)
{
    x += other.x;
    y += other.y;
    return *this;
}

연산자를 추가하기 전에는 목표 방향과 이동 좌표를 구할 때마다 xy를 따로 계산해야 했다. 오버로딩한 뒤에는 투사체 계산을 수식에 가까운 형태로 작성할 수 있다.

virtualDirection = targetPoint - spawnPoint;
Vector2 velocity = virtualDirection * movingSpeed * deltaTime;
virtualPosition += velocity;

targetPoint - spawnPoint는 발사 지점에서 목표 지점으로 향하는 벡터를 만들고, * movingSpeed * deltaTime은 이번 프레임에 이동할 거리를 계산한다. 마지막 +=는 계산한 이동량을 현재 가상 위치에 누적한다. 벡터 계산의 의도가 코드에 그대로 드러나 투사체 이동 로직도 읽기 쉬워졌다.

투사체의 실제 화면 위치는 정수 좌표지만, 내부 위치는 Vector2의 실수 좌표로 관리한다. 덕분에 콘솔의 격자 구조를 유지하면서도 속도를 더 세밀하게 조절할 수 있다.

double deltaTime = GameTime::GetDeltaTime();
Vector2 velocity = virtualDirection
    * movingSpeed
    * static_cast<float>(deltaTime);

virtualPosition += velocity;

int screenX = static_cast<int>(virtualPosition.x);
int screenY = static_cast<int>(virtualPosition.y);
bulletPosition->SetPosition(screenX, screenY);

매 프레임 이동 방향에 속도와 deltaTime을 곱해 가상 좌표를 갱신하고, 렌더링할 때만 실수 좌표를 정수 화면 좌표로 변환한다. 이렇게 하면 프레임 간격이 달라져도 초당 이동 거리를 일정하게 유지하면서 콘솔 격자 위에 표시할 수 있다.

충돌과 투사체 제거

화면 좌표로 변환한 뒤에는 플레이어와 같은 칸에 도달했는지 검사한다. 충돌하면 투사체가 가진 피해량을 플레이어에게 적용하고 자신의 Entity를 제거한다.

if (MathUtility::IsOverlap(
        screenPosition,
        playerPosition->GetPosition(), 0))
{
    player->TakeDamage(damage);
    DestroyEntity();
    return;
}

플레이어에게 명중하지 않은 투사체가 계속 남아 있지 않도록 화면의 가로 또는 세로 범위를 벗어났을 때도 제거한다. 맵을 전환해 OnMapClearRequested 이벤트가 발생한 경우 역시 남아 있는 투사체를 모두 정리한다.

이동과 투사체를 실시간으로 갱신하면서 기존의 입력 대기형 구조보다 보스가 독립적으로 움직이는 느낌을 만들 수 있었다.

이번 작업의 결과

이번 작업으로 코드에 묶여 있던 콘텐츠가 외부 데이터 파일로 이동했고, 던전의 마지막 목표가 될 보스 전투의 기본 흐름도 생겼다.

지난 작업에서는 마을에서 재료를 사용해 아이템을 만드는 흐름을 만들었다. 이번에는 어떤 몬스터가 어느 층에 등장하고 어떤 재료를 떨어뜨리는지까지 데이터로 연결했다. 마지막에는 서로 다른 행동을 선택하는 보스도 추가하면서 마을, 던전 탐험, 재료 수집, 제작, 보스 전투로 이어지는 게임의 큰 순환이 보이기 시작했다.

다음 작업

테이블의 형태를 만드는 것과 실제 데이터가 끝까지 사용되도록 연결하는 것은 별개의 작업이었다. 다음에는 이번에 발견한 데이터 불일치를 먼저 정리하고, 레드 드래곤 처치가 게임의 명확한 결말로 이어지도록 만들 예정이다.

GitHub

전체 소스 코드는 jjr2930/text-rpg-mandatory에서 확인할 수 있다.