< UE5 > 레벨 진행 시스템 - GameStateClass

2026. 4. 28. 22:40Unreal

[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가 판단하는 구조가 훨씬 유연하다.