← 모든 글

C++로 ASCII RPG 만들기 - 입력, 전투, 인벤토리 붙이기

AsciiRPG 프로젝트에 입력 이벤트 브로드캐스트, 플레이어 공격, 몬스터 공격, 필드 아이템 획득, 인벤토리 모드, 더블 버퍼 렌더링과 로그 표시를 붙인 과정을 정리했습니다.

C++ C++ASCII RPGEntity ComponentInventoryConsole

게임다운 상호작용을 붙였다

오늘도 AsciiRPG 프로젝트를 이어가며 C++ 코드에 조금씩 더 익숙해지고 있다. 어제까지는 맵, 렌더링, 이동처럼 게임이 돌아가기 위한 기반을 만드는 쪽에 가까웠다면, 오늘은 플레이어가 게임 안에서 실제로 할 수 있는 행동을 늘리는 데 집중했다.

가장 크게 바뀐 부분은 입력 처리, 전투, 아이템, 인벤토리, 화면 출력이다. 이제 플레이어는 WASD로 이동할 뿐 아니라, 스페이스바로 주변 몬스터를 공격하고, 필드에 놓인 아이템을 줍고, i 키로 인벤토리 모드에 들어갈 수 있다.

현재 ASCII RPG 실제 게임 화면

실제 실행 화면은 이런 모습이다. 왼쪽에는 ASCII 문자로 구성된 맵과 플레이어, 몬스터, 아이템이 보이고, 오른쪽에는 플레이어 상태와 인벤토리 정보가 표시된다. 아래쪽 로그 영역에서는 공격, 아이템 획득, 포션 사용 같은 이벤트를 바로 확인할 수 있다. 단순히 문자를 움직이는 화면에서, 이제는 상태와 로그를 함께 읽는 게임 화면에 가까워졌다.

입력을 브로드캐스트로 넘기기

이전에는 InputController가 입력을 받으면 곧바로 Position을 움직이는 구조였다. 지금은 입력을 InputEventParameter로 감싼 뒤, ObjectManager가 전체 오브젝트에 브로드캐스트하는 방식으로 바꿨다. 입력을 받는 쪽과 실제 행동을 처리하는 쪽을 분리하기 위한 변경이다.

입력 이벤트 브로드캐스트 흐름

void InputController::Update()
{
    if (!_kbhit())
        return;

    char input = _getch();
    switch (input)
    {
        case 'w':
        case 's':
        case 'a':
        case 'd':
        case ' ':
        case 'i':
            ObjectManager::GetInstance().BroadCastMessage(std::make_shared<InputEventParameter>(input));
            break;

        default:
            break;
    }
}

ObjectManager는 받은 메시지를 모든 Object에 전달한다. 모든 오브젝트를 순회하는 방식이라 효율이 좋은 구조는 아니지만, 지금 단계에서는 구현이 단순하고 컴포넌트 간 결합을 줄일 수 있다는 장점이 있다.

void ObjectManager::BroadCastMessage(shared_ptr<EventParameter> message)
{
    for (auto& pair : createdObjects)
    {
        pair.second->HandleEvent(message);
    }
}

이 구조 덕분에 입력 시스템은 키 입력을 이벤트로 바꾸는 일만 담당한다. 실제로 그 입력을 이동으로 해석할지, 공격으로 해석할지, 인벤토리 조작으로 해석할지는 각 컴포넌트가 맡게 되었다. 나중에 UI나 다른 시스템이 같은 입력 이벤트를 받아야 할 때도 확장하기 쉬워진다.

플레이어 상태와 인벤토리

Player 컴포넌트도 많이 커졌다. 이제 플레이어는 HP, 공격력, 방어력, 레벨, 경험치, 인벤토리를 가진다.

class Player : public Component
{
public:
    void HandleEvent(shared_ptr<EventParameter> message) override;
    void TakeDamage(int damage);
    void AddItem(shared_ptr<FieldItem> fieldItemm);

    int GetLevel() const;
    int GetExp() const;
    int GetHp() const;
    int GetMaxHp() const;
    int GetAttack() const;
    int GetDefense() const;
    int GetInventoryElementCount() const;
    int GetInventoryCursorIndex() const;

    const vector<InventoryItem>& GetInventory() const;
};

입력 처리도 현재 화면 모드에 따라 나뉜다. Ingame 모드에서는 이동, 공격, 인벤토리 열기를 처리하고, Inventory 모드에서는 커서 이동과 아이템 사용을 처리한다.

void Player::HandleEvent(shared_ptr<EventParameter> message)
{
    switch (message->eventType)
    {
        case EventType::KeyPressed:
            {
                InputEventParameter* inputMessage = static_cast<InputEventParameter*>(message.get());
                char inputChar = inputMessage->inputChar;
                switch (currentDisplayMode)
                {
                    case VirtualDisplay::DisplayMode::Ingame:
                        ProcessIngameModeInput(inputChar);
                        break;
                    case VirtualDisplay::DisplayMode::Inventory:
                        ProcessInventoryModeInput(inputChar);
                        break;
                }
            }
            break;
    }
}

인벤토리 모드에서는 w, s로 커서를 움직이고, 스페이스바로 선택된 아이템을 사용한다. 현재는 Potion을 사용하면 HP가 20 회복되고, 수량이 0이 되면 인벤토리에서 제거된다.

if (selectedItem.GetName() == "Potion")
{
    currentHp = min(maxHp, currentHp + 20);
    Logger::LogInfo(format("Restored 20 HP. Current HP: {0}/{1}", currentHp, maxHp));
    selectedItem.AddQuantity(-1);

    if (selectedItem.GetQuantity() > 0)
        break;

    inventory.erase(inventory.begin() + inventoryCursorIndex);
}

아직 아이템별 동작이 문자열 비교에 묶여 있지만, 일단 인벤토리에 아이템이 쌓이고 선택한 아이템을 소비하는 흐름은 만들어졌다. 다음에는 아이템 데이터를 테이블화하고, 사용 효과를 모듈화하면 코드가 훨씬 정리될 것 같다.

공격과 몬스터 처리

플레이어는 스페이스바 입력을 받으면 Attack()을 실행한다. 현재는 플레이어 주변 1칸 안에 있는 몬스터를 찾아 공격한다.

void Player::Attack()
{
    auto components = ObjectManager::GetInstance().GetComponentsWithTypes<Monster, Position>();
    Vector2Int position = playerPosition->GetPosition();
    for (auto& [monster, monsterPosition] : components)
    {
        Vector2Int monsterPos = monsterPosition->GetPosition();
        if (!MathUtility::IsOverlap(position, monsterPos, 1))
            continue;

        Logger::LogInfo("ATTACK!!");

        vector<DropItemData> dropItem = monster->GetDropItems();
        int exp = monster->GetExp();
        monster->TakeDamage(attack);
        if (!monster->IsDead())
            continue;

        AddItems(dropItem);
        AddExp(exp);
    }
}

몬스터도 이제 단순한 벽 같은 존재가 아니다. HP, 공격력, 방어력, 경험치, 드롭 아이템, 공격 딜레이를 가지고, 플레이어가 근처에 있으면 일정 시간 뒤 공격한다.

void Monster::Update()
{
    auto components = ObjectManager::GetInstance().GetComponentsWithTypes<Player, Position>();
    if (components.size() != 1)
    {
        Logger::LogError("There should be exactly one player in the game.");
        return;
    }

    Vector2Int myPosition = monsterPosition->GetPosition();

    auto& [player, playerPosition] = components[0];
    if (!MathUtility::IsOverlap(myPosition, playerPosition->GetPosition(), 1))
    {
        fistOverlap = false;
        return;
    }

    if(!fistOverlap)
    {
        fistOverlap = true;
        nextAttackTime = DateTime::Now();
        nextAttackTime.AddSeconds(attackDelay);
    }

    else if(DateTime::Now() >= nextAttackTime)
    {
        Logger::LogError(format("{} attacks player for {} damage", name, attack));
        player->TakeDamage(attack);
        nextAttackTime = DateTime::Now();
        nextAttackTime.AddSeconds(attackDelay);
    }
}

이제 플레이어가 몬스터 근처에 있으면 공격받고, 플레이어도 직접 공격할 수 있다. 아직 전투 연출이나 턴 시스템은 없지만, ASCII RPG의 전투 기반이 들어가기 시작했다.

필드 아이템과 드롭 아이템

오늘은 필드에 놓인 아이템도 생겼다. FieldItem 컴포넌트는 아이템 이름과 수량을 가지고, 플레이어와 같은 위치에 겹치면 플레이어 인벤토리에 아이템을 추가한 뒤 자기 Entity를 삭제한다.

void FieldItem::Update()
{
    auto players = ObjectManager::GetInstance().GetComponentsWithTypes<Player, Position>();
    auto& [player, position] = players[0];

    if(MathUtility::IsOverlap(myPosition->GetPosition(), position->GetPosition(), 0))
    {
        player->AddItem(make_shared<FieldItem>(*this));

        ObjectManager::GetInstance().DestroyEntity(entity);
    }
}

GameManager에서는 필드 아이템을 직접 몇 개 배치한다.

auto item1 = CreationUtil::CreateFieldItem(Vector2Int(13, 7), "Potion", 10);
auto item2 = CreationUtil::CreateFieldItem(Vector2Int(13, 8), "Gold", 100);
auto item3 = CreationUtil::CreateFieldItem(Vector2Int(5, 8), "Gold", 100);

몬스터도 죽으면 DropItemData를 통해 GoldPotion을 드롭할 수 있게 구성했다. 플레이어는 같은 이름의 아이템이 이미 있으면 수량을 더하고, 없으면 새 인벤토리 항목을 만든다.

화면 출력 개선

VirtualDisplay도 크게 바뀌었다. 이제 매 프레임 system("cls")로 전체 화면을 지우고 다시 그리는 대신, 두 개의 버퍼를 두고 변경된 문자만 콘솔에 찍는 방식으로 바꿨다.

게임 화면과 더블 버퍼 출력 구조

void VirtualDisplay::Render()
{
    RenderIngame();

    FindDiff();

    for (auto& d : diff)
    {
        DrawChar(d.position.x, d.position.y, d.character);
    }

    currentBufferIndex = (currentBufferIndex + 1) % 2;
}

FindDiff()는 현재 버퍼와 다음 버퍼를 비교해 달라진 위치만 diff에 넣는다.

bool VirtualDisplay::FindDiff()
{
    diff.clear();
    int nextIndex = (currentBufferIndex + 1) % 2;

    char** currentBuffer = buffer[currentBufferIndex];
    char** nextBuffer = buffer[nextIndex];

    for (int i = 0; i < HEIGHT; ++i)
    {
        for (int j = 0; j < WIDTH; ++j)
        {
            if (currentBuffer[i][j] != nextBuffer[i][j])
            {
                diff.emplace_back(Vector2Int(j, i), nextBuffer[i][j]);
            }
        }
    }

    return diff.size() > 0;
}

또 화면 오른쪽에는 플레이어 상태와 인벤토리를 출력하고, 아래쪽에는 최근 로그를 보여준다. 게임 화면이 단순히 맵만 보여주는 단계에서 벗어나, 정보를 읽고 판단할 수 있는 콘솔 UI로 조금 확장되었다.

로그 시스템

Logger도 추가했다. 로그는 파일에도 남기고, 최근 로그 목록을 VirtualDisplay에서 가져와 화면에 출력한다.

void Logger::AddToRecentLogs(const string& message)
{
    if (recentLogs.size() >= RECENTLY_LOGGED_COUNT)
    {
        recentLogs.pop_front();
    }

    recentLogs.push_back(message);
}

덕분에 공격, 아이템 획득, 포션 사용, 몬스터 공격 같은 이벤트를 콘솔 화면 아래쪽에서 확인할 수 있게 되었다. ASCII 게임에서는 이런 로그가 플레이어에게 상황을 알려주는 중요한 UI가 된다.

오늘의 결과

오늘 작업으로 AsciiRPG는 단순히 움직이는 문자 데모에서 조금 더 게임에 가까워졌다.

아직 정리할 부분도 있다. GameManager에서 virtualDisplay->Render()를 직접 호출하는 부분은 주석처럼 조금 억지스럽고, 플레이어나 필드 아이템이 GetComponentsWithTypes 결과의 첫 번째 원소를 바로 믿는 부분도 더 안전하게 바꿀 필요가 있다.

그래도 오늘은 눈에 보이는 진전이 많았다. 이제 플레이어가 맵 위를 돌아다니고, 몬스터와 상호작용하고, 아이템을 줍고, 인벤토리에서 포션을 쓰는 흐름이 생겼다. 콘솔 위의 작은 ASCII RPG가 조금씩 게임의 모양을 갖춰가고 있다.

다음 목표

다음에는 지금 하드코딩되어 있는 규칙을 데이터와 시스템으로 분리해보고 싶다. 당장 떠오르는 목표는 다음과 같다.

목표를 적어놓고 보니 아직 갈 길이 꽤 길다. 그래도 오늘 만든 입력, 전투, 아이템, 로그 기반이 앞으로 붙일 기능들의 출발점이 될 것 같다.

GitHub

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