C++로 ASCII RPG 만들기 - 게임 상태와 게임 오버 후 재시작
타이틀, 플레이, 게임 오버를 GameManager의 상태로 관리하고 플레이어 사망 뒤 객체를 정리해 다시 타이틀로 돌아가는 흐름을 구현했습니다.
타이틀에서 시작해 게임 오버 뒤 다시 돌아오기
지난 작업에서는 타이틀 화면과 장비 시스템을 추가했다. 당시 타이틀은 main()에서 한 번 출력한 뒤 게임 루프로 들어가는 구조였다. 플레이어가 사망하면 입력만 막았고, 게임 오버 화면이나 처음부터 다시 시작하는 흐름은 없었다.
이번에는 게임 전체를 Title, Ingame, End 세 상태로 나눴다. GameManager가 현재 상태에 맞는 화면과 업데이트를 선택하고, 상태가 바뀔 때 한 번의 플레이에 필요한 Entity를 만들거나 정리한다.
눈에 보이는 가장 큰 변화는 게임 오버 화면이지만, 구현의 중심은 타이틀에서 플레이를 시작하고 사망한 뒤 다시 타이틀로 돌아오는 하나의 완전한 순환을 만드는 일이었다.
GameManager에서 모드를 나눈 이유
게임 전체를 세 가지 모드로 나눈 데에는 구조적인 판단과 현실적인 시간 제약이 함께 있었다.
- Unity의 Scene이나 Unreal Engine의 Level처럼 성격이 다른 게임 단위를 분리하는 방향이 적절해 보였다.
- 다음 날부터 팀 프로젝트를 시작해야 해서 개인 과제의 구조를 장기간 다듬을 시간이 없었다.
- enum과
switch를 사용하는 방식이 현재 코드에서 타이틀과 게임 오버 화면을 가장 빠르게 추가할 수 있는 방법이었다.
따라서 이번 GameState는 범용 Scene 시스템이라기보다 현재 프로젝트를 마무리하기 위해 만든 최소한의 화면 전환 구조다. 게임이 더 커진다면 상태마다 초기화, 업데이트, 종료 함수를 가진 별도의 Scene이나 State 객체로 분리할 수 있지만, 지금 규모에서는 enum과 switch만으로 필요한 역할을 충분히 수행한다.
1. GameManager에 게임 상태 추가하기
게임의 전체 진행 상태를 나타내는 GameState enum을 GameManager에 추가했다.
enum class GameState
{
Title,
Ingame,
End,
};
캐릭터의 Idle이나 Attack처럼 한 Entity의 행동을 나누는 FSM과 달리, 이번 상태는 게임 전체가 무엇을 해야 하는지를 결정한다.
Title: 타이틀 화면을 출력하고 키 입력을 기다린다.Ingame: 모든 Entity를 갱신하고 게임 화면을 렌더링한다.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 아트로 만든 DRAGON SLAYER 타이틀과 Press Any Key 안내가 나타난다. 이 화면이 표시되는 동안 게임 월드는 아직 생성되지 않는다. 사용자가 키를 누르면 TitleDrawer가 SetState(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를 만든다.
초기화가 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 문자 아래에 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()는 createdEntities와 createdObjects를 모두 비운다. 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로 출력된다. 일반 몬스터에게 패배했을 때도 같은 문장이 나오므로, 마지막 공격자에 따라 메시지를 바꾸거나 더 일반적인 패배 문구를 사용하는 편이 자연스럽다.
이번 작업의 결과
- 게임 전체 흐름을
Title,Ingame,End로 분리했다. GameManager::Update()가 현재 상태에 맞는 작업만 실행한다.main()을 반복적인 게임 업데이트 호출만 남도록 단순화했다.- Ingame 진입 시 Player, 맵, 화면, 이벤트 브리지를 생성한다.
- 플레이어 HP가 0이 되면 End 상태로 전환한다.
- ASCII 아트로 구성한 게임 오버 화면을 추가했다.
- 게임 오버 화면에서 키를 누르면 타이틀로 돌아간다.
- Ingame을 벗어날 때
ObjectManager의 객체를 정리한다. - 다시 키를 누르면 새로운 플레이 세션을 시작할 수 있다.
지난 작업까지는 타이틀에서 게임으로 들어가는 한 방향의 흐름만 있었다. 이제는 플레이어가 사망하면 게임 오버 화면을 거쳐 타이틀로 돌아오고 다시 게임을 시작할 수 있다. 기능 하나를 추가한 것보다 한 번의 플레이가 시작되고 끝나는 경계를 코드에 만든 점이 이번 작업의 가장 큰 변화다.
다음 작업
- 맵 이동: 여러 맵을 만들고, 플레이어가 맵 사이를 이동할 수 있게 만들기
- 여러 층 구조: 점점 아래층으로 내려가는 방식의 던전 만들기
- 마을: NPC와 대화하거나 거래할 수 있는 안전한 공간 만들기
- 연금술 아이템 목록 만들기
- 연금술 상점 만들기
- 보스 몬스터 만들기
- 아이템 테이블화: 하드코딩된 아이템 정보를 테이블에서 읽어오도록 바꾸기
- 아이템 사용 로직 모듈화: 테이블 정보만으로 아이템 효과가 적용되도록 구조 정리하기
- 몬스터 AI: 몬스터가 돌아다니거나 플레이어를 탐지해 공격하도록 만들기
- 몬스터 테이블화: 여러 종류의 몬스터를 데이터 기반으로 생성하기
- 장비 착용: 무기나 방어구를 착용해 플레이어를 강화할 수 있게 만들기
- 타이틀 화면 만들기
- NPC: 거래와 퀘스트 부여가 가능한 NPC 추가하기 -> 연금술사 추가(재료로 표션 만들어 줌)
보류
- ~~퀘스트: NPC가 플레이어에게 목표를 주고, 완료 상태를 추적할 수 있게 만들기~~
- ~~직업 추가하기~~
GitHub
전체 소스 코드는 jjr2930/text-rpg-mandatory에서 확인할 수 있다.