← 모든 글

C++로 ASCII RPG 만들기 - 게임 상태와 게임 오버 후 재시작

타이틀, 플레이, 게임 오버를 GameManager의 상태로 관리하고 플레이어 사망 뒤 객체를 정리해 다시 타이틀로 돌아가는 흐름을 구현했습니다.

C++ C++ASCII RPGGame StateGame LoopLifecycleRefactoring

타이틀에서 시작해 게임 오버 뒤 다시 돌아오기

지난 작업에서는 타이틀 화면과 장비 시스템을 추가했다. 당시 타이틀은 main()에서 한 번 출력한 뒤 게임 루프로 들어가는 구조였다. 플레이어가 사망하면 입력만 막았고, 게임 오버 화면이나 처음부터 다시 시작하는 흐름은 없었다.

이번에는 게임 전체를 Title, Ingame, End 세 상태로 나눴다. GameManager가 현재 상태에 맞는 화면과 업데이트를 선택하고, 상태가 바뀔 때 한 번의 플레이에 필요한 Entity를 만들거나 정리한다.

눈에 보이는 가장 큰 변화는 게임 오버 화면이지만, 구현의 중심은 타이틀에서 플레이를 시작하고 사망한 뒤 다시 타이틀로 돌아오는 하나의 완전한 순환을 만드는 일이었다.

GameManager에서 모드를 나눈 이유

게임 전체를 세 가지 모드로 나눈 데에는 구조적인 판단과 현실적인 시간 제약이 함께 있었다.

따라서 이번 GameState는 범용 Scene 시스템이라기보다 현재 프로젝트를 마무리하기 위해 만든 최소한의 화면 전환 구조다. 게임이 더 커진다면 상태마다 초기화, 업데이트, 종료 함수를 가진 별도의 Scene이나 State 객체로 분리할 수 있지만, 지금 규모에서는 enum과 switch만으로 필요한 역할을 충분히 수행한다.

1. GameManager에 게임 상태 추가하기

게임의 전체 진행 상태를 나타내는 GameState enum을 GameManager에 추가했다.

enum class GameState
{
    Title,
    Ingame,
    End,
};

캐릭터의 Idle이나 Attack처럼 한 Entity의 행동을 나누는 FSM과 달리, 이번 상태는 게임 전체가 무엇을 해야 하는지를 결정한다.

Title, Ingame, End 상태가 순환하는 게임 흐름

GameManager::Update()는 현재 상태에 따라 실행할 작업을 선택한다.

void GameManager::Update()
{
    GameTime::CalculateDeltaTime();

    switch (currentGameState)
    {
        case GameState::Title:
            TitleDrawer().ProcessTitle();
            break;

        case GameState::Ingame:
            UpdateEntities();
            virtualDisplay->Render();
            break;

        case GameState::End:
            EndingDrawer().ProcessEnding();
            break;
    }
}

실제 코드에서는 Ingame의 Entity 순회를 함수로 분리하지 않고 Update() 안에서 실행한다. 위 코드는 세 상태의 역할을 읽기 쉽게 줄여 표현한 것이다.

2. main 함수를 게임 루프만 남기기

이전에는 main()이 타이틀을 출력하고 GameManagerBridge, Player 같은 초기 Entity도 직접 만들었다. 게임을 다시 시작하려면 이 초기화 코드를 또 호출해야 하므로 전체 흐름을 관리하기 어려웠다.

이제 main()GameManager::Update()를 반복 호출하는 일만 담당한다.

void main()
{
    while (true)
    {
        GameManager::GetInstance().Update();
        Sleep(16);
    }
}

ASCII 아트와 Press Any Key 안내를 표시하는 실제 타이틀 화면

실행하면 가장 먼저 ASCII 아트로 만든 DRAGON SLAYER 타이틀과 Press Any Key 안내가 나타난다. 이 화면이 표시되는 동안 게임 월드는 아직 생성되지 않는다. 사용자가 키를 누르면 TitleDrawerSetState(GameState::Ingame)을 호출하고, 그때부터 Player와 맵을 포함한 새 세션을 만든다.

초기 상태는 GameManager::Init()에서 Title로 지정한다. 따라서 프로그램을 처음 실행했을 때와 게임 오버 뒤 다시 돌아왔을 때 모두 같은 타이틀 진입 경로를 사용한다.

이렇게 진입점의 역할을 줄이자 타이틀에서 시작하든, 게임 오버 뒤 타이틀로 돌아왔다가 다시 시작하든 같은 초기화 경로를 사용할 수 있게 되었다.

3. Ingame 진입 시 월드 만들기

SetState(GameState::Ingame)은 한 번의 플레이에 필요한 객체를 생성한다.

if (newState == GameState::Ingame)
{
    CreateGameManagerBridge();
    CreationUtil::CreatePlayer(Vector2Int(3, 3));

    maps.emplace_back(CreationUtil::CreateTownMap());

    for (int i = 1; i < Const::Map::DUNGEON_DEPTH + 1; ++i)
    {
        auto map = make_shared<Map>(
            Const::Map::DEFAULT_WIDTH,
            Const::Map::DEFAULT_HEIGHT);

        rmg.GenerateRandomMap(
            Const::Map::DEFAULT_WIDTH,
            Const::Map::DEFAULT_HEIGHT,
            i, map);

        maps.emplace_back(map);
    }

    maps.emplace_back(CreationUtil::CreateDragonRoom());
    CreateCurrentMapObjects(true);
    virtualDisplay = CreationUtil::CreateVirtualDisplay()
        ->GetComponent<VirtualDisplay>();
}

생성 순서는 다음과 같다.

1. GameManagerBridge와 Player를 만든다. 2. 0번 맵에 고정 마을을 넣는다. 3. 1층부터 10층까지 랜덤 던전을 만든다. 4. 마지막에 레드 드래곤 보스 방을 추가한다. 5. 현재 맵의 문자 데이터를 Entity로 만든다. 6. 게임 화면을 담당하는 VirtualDisplay를 만든다.

Ingame 진입 시 객체를 만들고 사망 시 정리하는 세션 생명주기

초기화가 GameManager 안으로 모이면서 게임 실행에 필요한 객체가 무엇인지 한곳에서 확인할 수 있게 되었다.

4. 플레이어 사망을 End 상태로 연결하기

Player가 피해를 받은 뒤 현재 HP가 0 이하가 되면 자신을 사망 상태로 표시하고 게임 전체 상태를 End로 바꾼다.

playerStat->AddStat(
    StatType::CurrentHealth, -realDamage);

int currentHealth = static_cast<int>(
    playerStat->GetStat(StatType::CurrentHealth));

if (currentHealth <= 0)
{
    amIDead = true;
    GameManager::GetInstance()
        .SetState(GameManager::GameState::End);
}

amIDead가 설정된 Player는 더 이상 입력 이벤트를 처리하지 않는다. 동시에 SetState(End)가 호출되므로 GameManager도 Ingame 업데이트를 중단한다.

Entity를 순회하는 도중 상태가 End로 바뀔 수 있으므로, GameManager는 각 Entity를 갱신하기 전에 현재 상태를 다시 검사한다.

for (auto& entity : *entities)
{
    if (currentGameState == GameState::End)
        return;

    entity->Update();
}

플레이어가 사망한 프레임에 나머지 몬스터와 화면을 계속 갱신하지 않고 즉시 현재 업데이트를 끝낸다.

5. 게임 오버 화면에서 타이틀로 돌아가기

End 상태에서는 EndingDrawer가 콘솔을 지우고 ASCII 아트로 GAME OVER 화면을 출력한다. 그 아래에는 드래곤에게 패배했다는 메시지와 키 입력 안내를 표시한다.

플레이어 사망 뒤 GAME OVER와 Press Any Key를 표시하는 실제 화면

실제 화면에는 큰 GAME OVER 문자 아래에 THE DRAGON HAS DEFEATED YOU가 표시된다. PRESS ANY KEY 안내에 따라 키를 누르면 이 화면을 닫고 Title 상태로 돌아간다. 타이틀에서 다시 키를 누르면 이전 플레이의 객체를 재사용하지 않고 새로운 게임을 시작한다.

void EndingDrawer::ProcessEnding()
{
    system("cls");

    cout << R"(
        GAME OVER

        THE DRAGON HAS DEFEATED YOU
        PRESS ANY KEY
    )";

    _getch();

    GameManager::GetInstance()
        .SetState(GameManager::GameState::Title);
}

키를 누르면 상태를 다시 Title로 바꾼다. 다음 Update에서는 TitleDrawer가 타이틀 화면을 출력하고, 다시 키를 누르면 새로운 Ingame 세션이 시작된다.

타이틀과 엔딩 화면이 모두 입력을 기다린 뒤 스스로 다음 상태를 요청하므로, main()은 화면 전환 규칙을 알 필요가 없다.

6. 상태 전환 시 모든 Entity 정리하기

Ingame이 아닌 상태로 전환할 때는 ObjectManager::Clear()로 현재 세션의 Entity와 Object를 정리한다.

void GameManager::SetState(GameState newState)
{
    currentGameState = newState;

    if (newState == GameState::Ingame)
    {
        CreateGameObjects();
    }
    else
    {
        ObjectManager::GetInstance().Clear();
    }
}

ObjectManager::Clear()createdEntitiescreatedObjects를 모두 비운다. Player, 몬스터, 벽, 입력 처리기, 화면, 이벤트 브리지처럼 이전 플레이에 속한 객체가 다음 게임에 남지 않게 한다.

GameManager는 Component가 아니므로 기존 이벤트 시스템의 메시지를 직접 받을 수 없다. 이를 위해 Ingame을 시작할 때 GameManagerBridge Component도 다시 만든다. 입구, 출구, 귀환 스크롤 이벤트는 Bridge가 받은 뒤 GameManager::HandleEvent()로 전달한다.

이번 작업에서 남은 문제

ObjectManager의 객체는 정리하지만 GameManager::maps는 새 게임을 시작하기 전에 비우지 않는다. 재시작할 때마다 마을, 랜덤 던전 10개, 보스 방이 벡터 뒤에 계속 추가된다. currentMapIndex는 다시 0이 되기 때문에 게임은 이전 맵을 사용할 가능성이 있고, 새로 생성한 맵은 사용하지 않은 채 메모리에 남는다.

// 새 세션을 만들기 전에 필요하다.
maps.clear();
maps.reserve(Const::Map::DUNGEON_DEPTH + 2);

게임 오버 문구는 플레이어를 쓰러뜨린 대상과 관계없이 항상 THE DRAGON HAS DEFEATED YOU로 출력된다. 일반 몬스터에게 패배했을 때도 같은 문장이 나오므로, 마지막 공격자에 따라 메시지를 바꾸거나 더 일반적인 패배 문구를 사용하는 편이 자연스럽다.

이번 작업의 결과

지난 작업까지는 타이틀에서 게임으로 들어가는 한 방향의 흐름만 있었다. 이제는 플레이어가 사망하면 게임 오버 화면을 거쳐 타이틀로 돌아오고 다시 게임을 시작할 수 있다. 기능 하나를 추가한 것보다 한 번의 플레이가 시작되고 끝나는 경계를 코드에 만든 점이 이번 작업의 가장 큰 변화다.

다음 작업

보류

GitHub

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