🔖[3CH 3번] 게임 루프 및 UI 재설계하기 (~20260902 pm13)
GameState를 활용한 게임 진행 상태 관리TimerManager를 활용한 Wave 제한 시간 구현SpawnVolume과 DataTable을 활용한 랜덤 아이템 생성GameInstance를 활용한 누적 Score 관리: 이전 단계에서는 SpawnVolume과 DataTable을 활용해 여러 종류의 아이템을 랜덤으로 생성하고, 플레이어가 Coin을 획득하면 Score가 증가하도록 구현했다.
이번 과제에서는 기존 아이템 시스템을 기반으로 하나의 플레이가 반복되는 게임 루프를 만드는 것을 목표로 했다.
전체적인 흐름은 다음과 같이 구성했다.
게임 시작
↓
Wave 1 시작
↓
아이템 랜덤 생성
↓
제한 시간 동안 플레이
↓
시간 종료 또는 Coin 획득 조건 달성
↓
남아 있는 아이템 제거
↓
Wave 2 시작
↓
Wave 3
↓
Game Over
단순히 아이템을 한 번 생성하는 것에서 끝나는 것이 아니라,
Wave 시작 → 플레이 → Wave 종료 → 초기화 → 다음 Wave
가 반복되도록 만드는 것이 이번 구현의 핵심이었다.
: Wave는 특정 아이템이나 플레이어 하나의 상태가 아니라 현재 게임 전체의 진행 상태에 해당한다.
이번 프로젝트에서는 다음과 같은 값을 관리해야 했다.
int32 Score;
int32 SpawnedCoinCount;
int32 CollectedCoinCount;
int32 CurrentWave;
int32 MaxWaves;
float WaveDuration;
| 변수 | 역할 |
|---|---|
Score | 현재 획득한 점수 |
SpawnedCoinCount | 현재 Wave에 생성된 Coin 개수 |
CollectedCoinCount | 플레이어가 획득한 Coin 개수 |
CurrentWave | 현재 진행 중인 Wave |
MaxWaves | 전체 Wave 수 |
WaveDuration | 한 Wave의 제한 시간 |
이번 과제에서는 총 3개의 Wave를 진행하도록 설정했다. ✔️
CurrentWave = 1;
MaxWaves = 3;
WaveDuration = 15.0f;
게임이 시작되면 BeginPlay()에서 첫 번째 Wave를 시작
void ASpartaGameState::BeginPlay()
{
Super::BeginPlay();
Score = 0;
CurrentWave = 1;
StartWave();
}
처음에는 각각의 기능을 따로 구현하는 느낌이 강했는데, Wave 시스템을 만들면서 GameState가 현재 게임이 어느 단계에 있는지를 관리하는 중심 역할을 하게 되었다.
: Wave가 시작되면 StartWave()를 호출한다.
먼저 이전 Wave에서 사용했던 Coin 관련 값을 초기화한다.
SpawnedCoinCount = 0;
CollectedCoinCount = 0;
이후 현재 레벨에 존재하는 SpawnVolume을 찾아 아이템을 생성한다.
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(
GetWorld(),
ASpawnVolume::StaticClass(),
FoundVolumes
);
GetAllActorsOfClass()를 사용하면 현재 World에서 특정 클래스에 해당하는 Actor를 검색할 수 있다.
찾아낸 SpawnVolume을 통해 기존에 구현했던 SpawnRandomItem()을 호출한다.
AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
SpawnRandomItem()에서는 DataTable에 등록된 확률을 기반으로 Coin, Mine, Healing Item 등의 아이템 중 하나를 랜덤하게 생성한다.
즉 이번 Wave 시스템에서 새로운 아이템 생성 로직을 다시 만드는 대신,
GameState
↓
SpawnVolume
↓
DataTable
↓
Random Item Spawn
형태로 기존 시스템을 재사용했다.
이 과정에서 이전 단계에서 각각 따로 구현했던 클래스들이 하나의 게임 진행 시스템 안에서 연결되는 구조를 확인할 수 있었다.
: 모든 Wave가 완전히 동일하게 진행되지 않도록 현재 Wave에 따라 생성되는 아이템의 개수를 다르게 설정했다.
int32 ASpartaGameState::GetSpawnCountForCurrentWave() const
{
switch (CurrentWave)
{
case 1:
return 15;
case 2:
return 25;
case 3:
return 35;
default:
return 15;
}
}
각 Wave에서 생성되는 아이템 수는 다음과 같다.
Wave 1 → 15개
Wave 2 → 25개
Wave 3 → 35개
StartWave()에서는 현재 Wave에 해당하는 값을 받아 반복문을 실행한다.
const int32 ItemToSpawn = GetSpawnCountForCurrentWave();
for (int32 i = 0; i < ItemToSpawn; i++)
{
AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
if (SpawnedActor && SpawnedActor->IsA(ACoinItem::StaticClass()))
{
SpawnedCoinCount++;
}
}
여기서 단순히 아이템을 생성하는 것뿐 아니라, 생성된 Actor가 CoinItem인지 확인해 SpawnedCoinCount도 함께 증가시켰다.
이 값을 통해 현재 Wave에서 획득 가능한 Coin을 모두 획득했는지 판단할 수 있게 된다.
: 각 Wave는 15초의 제한 시간을 가진다.
이를 위해 Unreal의 TimerManager를 사용했다.
GetWorldTimerManager().SetTimer(
WaveTimerHandle,
this,
&ASpartaGameState::OnWaveTimeUp,
WaveDuration,
false
);
Timer가 시작된 뒤 WaveDuration만큼 시간이 지나면 OnWaveTimeUp() 함수가 호출된다.
void ASpartaGameState::OnWaveTimeUp()
{
EndWave();
}
결과적으로 다음과 같은 흐름이 만들어진다.
StartWave()
↓
15초 Timer 시작
↓
15초 경과
↓
OnWaveTimeUp()
↓
EndWave()
처음에는 Timer를 단순히 일정 시간이 지나면 함수를 실행시키는 기능 정도로 이해했는데, 이번 구현에서는 게임의 진행 단계를 전환시키는 장치로 사용할 수 있었다.
: 시간이 끝나는 것 외에도 현재 Wave에서 생성된 Coin을 모두 획득하면 Wave가 종료될 수 있도록 했다.
Coin을 획득할 때마다 다음 함수가 호출된다.
void ASpartaGameState::OnCoinCollected()
{
CollectedCoinCount++;
if (SpawnedCoinCount > 0 &&
CollectedCoinCount >= SpawnedCoinCount)
{
EndWave();
}
}
즉 Wave에는 두 가지 종료 조건이 존재한다.
1. 제한 시간 15초가 종료됨
2. 현재 Wave에서 생성된 Coin을 모두 획득함
이를 통해 제한 시간이 끝날 때까지 무조건 기다리는 방식이 아니라 플레이어의 행동에 따라서도 다음 Wave로 넘어갈 수 있도록 했다.
: Wave가 끝나면 EndWave()가 실행된다.
void ASpartaGameState::EndWave()
{
GetWorldTimerManager().ClearTimer(WaveTimerHandle);
ClearRemainingItems();
if (CurrentWave >= MaxWaves)
{
OnGameOver();
return;
}
CurrentWave++;
StartWave();
}
여기에서 처리되는 순서는 다음과 같다.
현재 Wave Timer 제거
↓
남아 있는 Item 제거
↓
마지막 Wave인지 확인
↙ ↘
마지막 진행 중
↓ ↓
GameOver CurrentWave++
↓
StartWave()
StartWave()와 EndWave()가 서로 다음 게임 상태를 연결하도록 구성하면서 반복되는 게임 루프를 만들 수 있었다.
: Wave가 끝났는데 이전 Wave의 아이템이 그대로 남아 있다면 다음 Wave에서 새롭게 생성된 아이템과 섞이게 된다.
이를 방지하기 위해 ClearRemainingItems()를 구현했다.
void ASpartaGameState::ClearRemainingItems()
{
TArray<AActor*> RemainingItems;
UGameplayStatics::GetAllActorsOfClass(
GetWorld(),
ABaseItem::StaticClass(),
RemainingItems
);
for (AActor* ItemActor : RemainingItems)
{
if (ItemActor)
{
ItemActor->Destroy();
}
}
}
현재 World에 존재하는 BaseItem 계열 Actor를 모두 찾은 뒤 Destroy()를 호출한다.
따라서 새로운 Wave가 시작될 때마다 이전 Wave의 아이템이 정리된 상태에서 다시 생성된다.
Wave 1 종료
→ 남은 아이템 제거
→ Wave 2 아이템 25개 생성
Wave 2 종료
→ 남은 아이템 제거
→ Wave 3 아이템 35개 생성
이 과정을 통해 Wave가 바뀌는 것이 단순히 숫자만 증가하는 것이 아니라, 현재 플레이 환경까지 초기화한 뒤 다음 단계가 시작되도록 구성할 수 있었다.
: Coin을 획득하면 AddScore()를 통해 Score를 증가시킨다.
void ASpartaGameState::AddScore(int32 Amount)
{
Score += Amount;
if (USpartaGameInstance* SpartaGameInstance =
Cast<USpartaGameInstance>(GetGameInstance()))
{
SpartaGameInstance->AddToScore(Amount);
}
UpdateHUD();
}
현재 구현에서는 Coin 종류에 따라 다음과 같이 점수가 증가한다.
SmallCoin → +10
BigCoin → +50
GameState의 현재 Score뿐만 아니라 GameInstance의 TotalScore에도 값을 전달했다.
두 클래스의 역할은 다음과 같이 구분할 수 있었다.
GameState
→ 현재 게임 진행 상태
→ CurrentWave, Timer, Coin Count 등 관리
GameInstance
→ 레벨과 독립적으로 유지할 전역 데이터
→ TotalScore 관리
이전에는 GameState, GameMode, GameInstance의 역할이 비슷하게 느껴졌는데, 실제 게임 진행 구조를 구현하면서 각각 어떤 데이터를 담당해야 하는지 조금 더 구체적으로 이해할 수 있었다.
: 최종적으로 다음과 같은 게임 루프를 구현했다.
Game Start
Wave 1
├─ 15 Items Spawn
└─ 15 sec
↓
Remaining Items Clear
↓
Wave 2
├─ 25 Items Spawn
└─ 15 sec
↓
Remaining Items Clear
↓
Wave 3
├─ 35 Items Spawn
└─ 15 sec
↓
Game Over
또한 Coin을 획득하면 Score가 증가하고, 생성된 Coin을 모두 획득하거나 제한 시간이 끝나면 현재 Wave가 종료되도록 구성했다.
기존에 구현했던 Item, SpawnVolume, DataTable, GameState가 각각 독립적인 기능으로 존재하는 것이 아니라 하나의 게임 루프 안에서 서로 연결되도록 확장했다.
[학습 후 느낀 점과 개선하고 싶은 점]
: 이번 구현에서 가장 크게 이해한 부분은 게임의 기능을 만드는 것과 게임의 흐름을 만드는 것은 조금 다르다는 점이었다.
이전에는 아이템을 생성하거나 Coin을 획득해 점수를 올리는 것처럼 각각의 기능 구현에 집중했다면, 이번에는 해당 기능들이 언제 시작되고, 어떤 조건에서 끝나며, 그다음 무엇을 실행해야 하는지를 관리해야 했다.
특히 다음 구조를 직접 구현하면서 GameState가 게임 전체의 진행 상태를 관리하는 이유를 이전보다 명확하게 이해할 수 있었다.
StartWave()
↓
플레이
↓
종료 조건 확인
↓
EndWave()
↓
상태 초기화
↓
StartWave()
또한 기존에 만들어 둔 SpawnVolume이나 DataTable을 새로 작성하지 않고 GameState에서 호출하여 재사용하면서, 각 클래스가 자신의 역할을 담당하도록 나누어 두는 것이 이후 기능 확장에도 중요하다는 점을 확인할 수 있었다.
이후에는 구현한 게임 루프의 상태를 플레이어에게 보여주기 위해 진행한 UMG HUD와 Score / Wave / Time 데이터 연결, 그리고 MainMenu에서 Gameplay Level로 진입하는 흐름과 구현 과정에서 발생한 문제를 정리하려고 한다.