2026. 4. 28. 22:40ㆍUnreal
[UE5] GameState로 레벨 진행 시스템 구현하기
구현 목표
프로젝트 과제 중 멀티 레벨 시스템을 구현해야 했다. 요구사항은 다음과 같다.
- 총 3개의 레벨로 구성
- 각 레벨마다 제한 시간 존재
- 시간이 끝나거나 모든 코인을 먹으면 다음 레벨로 전환
- 마지막 레벨까지 끝내면 게임 오버
이 글에서는 그 중 레벨 진행 흐름을 관리하는 AMyGameState 클래스를 어떻게 구현했는지 정리한다.
핵심 개념
GameState란?
언리얼 엔진의 게임플레이 프레임워크는 역할에 따라 클래스를 분리한다.
클래스 역할
| GameMode | 게임의 규칙을 정의 (어떤 Pawn/Controller를 쓸지 등) |
| GameState | 게임의 진행 상태를 관리 (점수, 시간, 진행도 등) |
| GameInstance | 레벨 전환을 거쳐도 유지되는 데이터 (총점, 현재 레벨 인덱스 등) |
레벨 시스템은 "현재 몇 번째 레벨인가", "코인이 몇 개 남았는가", "타이머가 얼마나 남았는가" 같은 진행 상태를 다루므로 GameState에 구현하는 것이 자연스럽다.
GameInstance와의 역할 분담
레벨이 전환되면 GameState는 새로 생성된다. 따라서 레벨을 넘어가도 유지해야 하는 정보는 GameInstance에 보관해야 한다.
GameState → 현재 레벨 안에서만 유효한 상태 (스폰된 코인 수, 타이머 등)
GameInstance → 레벨 간 유지되는 상태 (현재 레벨 인덱스, 총점)
구현 흐름
1. 멤버 변수 설계
int32 score; // 현재 점수
int32 SpawnedCoin; // 이번 레벨에 스폰된 코인 수
int32 CollectedCoin; // 플레이어가 먹은 코인 수
float LevelDuration; // 한 레벨의 제한 시간
int32 CurrentLevel; // 현재 몇 번째 레벨인지
int32 MaxLevel; // 최대 레벨 수
TArray<FName> LevelMapNames; // 레벨별 맵 이름
FTimerHandle LevelTimer; // 타이머 핸들
2. 게임 시작 → BeginPlay() → StartLevel()
void AMyGameState::BeginPlay()
{
Super::BeginPlay();
StartLevel(); // 게임 시작과 동시에 첫 레벨 시작
}
3. 레벨 시작 로직 - StartLevel()
StartLevel()은 다음 순서로 동작한다.
(1) GameInstance에서 현재 레벨 인덱스 가져오기
if (UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GetGameInstance()))
{
CurrentLevel = MyGameInstance->CurrentLevelIndex;
}
레벨이 전환되면 GameState는 새로 생성되므로, 어느 레벨인지 GameInstance에서 받아와야 한다.
(2) 레벨에 배치된 SpawnVolume 찾기
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(
GetWorld(),
ASpawnVolume::StaticClass(),
FoundVolumes);
GetAllActorsOfClass는 월드에 있는 특정 클래스 액터를 모두 찾아주는 함수다.
(3) 아이템 스폰 + 코인 카운트
const int32 VolumeCount = 40;
for (int32 i = 0; i < VolumeCount; ++i)
{
if (FoundVolumes.Num() > 0)
{
ASpawnVolume* SpawnedVolume = Cast<ASpawnVolume>(FoundVolumes[0]);
if (SpawnedVolume)
{
AActor* SpawnActor = SpawnedVolume->SpawnRandomItem();
if (SpawnActor && SpawnActor->IsA(ABaseCoin::StaticClass()))
{
SpawnedCoin++; // 스폰된 게 코인이면 카운트
}
}
}
}
IsA()를 사용하면 부모 클래스를 기준으로 판별할 수 있어, SmallCoin/BigCoin 모두 한 번에 걸러진다.
(4) 제한 시간 타이머 등록
GetWorldTimerManager().SetTimer(
LevelTimer, // 타이머 핸들
this, // 호출 대상
&AMyGameState::OnLevelTimeUp, // 호출할 함수
LevelDuration, // 시간
false); // 반복 안 함
SetTimer는 일정 시간 후에 함수를 자동 호출해주는 기능이다.
4. 레벨 종료 조건 두 가지
레벨은 두 가지 경우에 종료된다.
(1) 시간이 다 됨 → OnLevelTimeUp()
void AMyGameState::OnLevelTimeUp()
{
EndLevel();
}
(2) 모든 코인을 먹음 → OnCoinCollected()
void AMyGameState::OnCoinCollected()
{
CollectedCoin++;
if (SpawnedCoin > 0 && CollectedCoin >= SpawnedCoin)
{
EndLevel();
}
}
5. 레벨 종료 → 다음 레벨 로드 - EndLevel()
void AMyGameState::EndLevel()
{
GetWorldTimerManager().ClearTimer(LevelTimer); // 타이머 정리
if (UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GetGameInstance()))
{
CurrentLevel++;
MyGameInstance->CurrentLevelIndex = CurrentLevel; // GameInstance에 저장
}
if (CurrentLevel > MaxLevel)
{
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevel))
{
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevel]);
}
}
다음 레벨 인덱스를 GameInstance에 저장한 뒤, OpenLevel로 새 레벨을 로드한다. 새 레벨에서 GameState가 다시 만들어질 때 BeginPlay → StartLevel이 호출되며, 거기서 다시 GameInstance로부터 인덱스를 읽어온다.
핵심 포인트
1. GameState와 GameInstance의 역할 분담
OpenLevel을 호출하면 월드가 새로 만들어지면서 GameState도 함께 사라진다. 이 사실을 모르고 CurrentLevel을 GameState에서만 관리하면 레벨 전환 시 항상 0으로 리셋되어 무한 루프에 빠진다.
레벨을 넘어 유지해야 하는 전역 데이터는 반드시 GameInstance에 보관해야 한다.
2. 타이머 핸들과 ClearTimer
SetTimer로 등록한 타이머는 레벨이 끝나기 전에 자동으로 정리되지 않는다. 코인을 먼저 다 모아서 레벨이 끝났는데 타이머가 살아있으면, 다음 레벨에서 의도치 않게 OnLevelTimeUp이 호출될 수 있다. EndLevel에서 ClearTimer를 명시적으로 호출하는 이유다.
3. IsA를 활용한 부모 클래스 판별
SpawnActor->IsA(ABaseCoin::StaticClass())
이 한 줄로 SmallCoin과 BigCoin을 한 번에 판별할 수 있다. 하위 클래스가 늘어나도 코드를 수정할 필요가 없다.
4. 클래스 간 통신 흐름
[BaseCoin] [GameState] [GameInstance]
│ │ │
│── OnCoinCollected() ────▶│ │
│ │ │
│ │── 모든 코인 먹음? ───────│
│ │ │
│ │── EndLevel() ───────────▶│
│ │ CurrentLevelIndex 저장 │
│ │ │
│ │── OpenLevel() ───────────│
│ │ │
[새 GameState 생성]
│
│── BeginPlay() ───────▶│
│ CurrentLevelIndex 읽기
│
└── StartLevel()
각 클래스가 자신의 책임만 갖고, 인터페이스를 통해 통신하는 구조가 자연스럽게 만들어졌다.
배운 점
- GameState/GameMode/GameInstance의 역할 차이를 코드로 직접 구현하면서 체감했다. 책에서 읽었을 땐 추상적이었는데, 실제로 데이터가 어디서 사라지고 어디서 살아남는지 부딪혀보니 명확해졌다.
- 타이머는 등록만큼 정리(Clear)도 중요하다. 안 그러면 다음 레벨에 잔재가 남는다.
- IsA(), Cast<T>(), GetAllActorsOfClass 같은 언리얼 표준 유틸리티를 적절히 쓰면 코드가 훨씬 깔끔해진다.
- 클래스 간 통신은 누가 누구에게 무엇을 알려줘야 하는가의 관점에서 설계해야 한다는 걸 다시 느꼈다. 코인이 점수를 직접 더하는 게 아니라, GameState에 "내가 먹혔다"고 알려주고 GameState가 판단하는 구조가 훨씬 유연하다.
'Unreal' 카테고리의 다른 글
| <언리얼> 애니메이션 리타겟팅(UE5) (0) | 2026.05.17 |
|---|---|
| <언리얼 C++> RandRange와 VRandCone의 차이 (0) | 2026.05.07 |
| < 언리얼 C++ > 6자유도 비행체 + 중력 / 낙하 + 에어 컨트롤 구현 (0) | 2026.04.22 |
| < 언리얼 C++ > Pawn 클래스 3D캐릭터 만들기 ( 필수 기능 ) (0) | 2026.04.20 |
| 언리얼 엔진 애니메이션 블루프린트 정리 (0) | 2026.04.16 |