Unreal 게임 개발 과정 TIL

Today's I Learning

본캠프 TIL

06.24 Unreal 본캠프)게임 루프 설계를 통한 게임 흐름 제어하기

Sunlight__ 2026. 6. 24. 19:05

 

1. GameState를 이용한 게임 루프 구현하기

 1) GameState 와 GameMode

 : 언리얼 엔진에서 게임 루프전역 상태를 관리할 때 대표적으로 고려되는 클래스는 GameState와 GameMode가 있다.

- GameMode를 쓰는 이유는?
 : 서버 전용 로직을 담는 곳이며, 게임 규칙 (팀 배정, 승패 조건, 플레이어 스폰 등)을 서버에서 제어하는 데 사용된다.
   클라이언트는 GameMode에 직접 접근할 수 없다.
   따라서 클라이언트도 알아야 하는 정보 (예 : 남은 시간, 현재 점수 등)를 GameMode에만 두면 복잡해진다.
   보통 멀티플레이를 고려한다면, "중요 규칙 로직" 만 GameMode에 두고, "서버-클라이언트가 공통으로 알아야 하는 상태" 는 GameState에 두는 방식을 많이 사용한다.
- GameState를 쓰는 이유는?
 : 게임 전반에 걸쳐 모든 플레이어가 공유해야 하는 상태를 담는 클래스이다.
   보통 전역 상태가 필요할 경우 GameState를 활용한다.
   GameState 객체는 게임이 시작될 때 서버에서 생성되고, 클라이언트는 이를 복제 받아서 똑같은 정보를 읽을 수 있다.
   즉, "서버와 클라이언트 모두" 동일한 정보를 가지게 된다.

 

 2) SpawnVolume 클래스 스폰 데이터 반환 수정

 : 기존에는 스폰 함수가 Void를 반환 했는데, 스폰된 아이템의 정보 (코인이 맞는지, 혹은 다른 아이템인지)를 추후 GameState에서 카운팅 하려면, 스폰 함수가 스폰된 AActor* 를 반환해야 한다.

#pragma once

#include "CoreMinimal.h"
#include "ItemSpawnRow.h"
#include "GameFramework/Actor.h"
#include "SpawnVolume.generated.h"

class UBoxComponent;

UCLASS()
class SPARTAPROJECT_API ASpawnVolume : public AActor
{
    GENERATED_BODY()

public:
    ASpawnVolume();

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spawning")
    USceneComponent* Scene;
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spawning")
    UBoxComponent* SpawningBox;
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawning")
    UDataTable* ItemDataTable;

    UFUNCTION(BlueprintCallable, Category = "Spawning")
    AActor* SpawnRandomItem();     // 리턴 형식을 AActor* 로 변경

    FItemSpawnRow* GetRandomItem() const;
    AActor* SpawnItem(TSubclassOf<AActor> ItemClass);
    FVector GetRandomPointInVolume() const;
};

SpawnVolume.h

#include "SpawnVolume.h"
#include "Components/BoxComponent.h"

ASpawnVolume::ASpawnVolume()
{
    PrimaryActorTick.bCanEverTick = false;
    
    Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
    SetRootComponent(Scene);

    SpawningBox = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawningBox"));
    SpawningBox->SetupAttachment(Scene);

    ItemDataTable = nullptr;
}

AActor* ASpawnVolume::SpawnRandomItem()
{
    if (FItemSpawnRow* SelectedRow = GetRandomItem())
    {
        if (UClass* ActualClass = SelectedRow->ItemClass.Get())
        {
            // 여기서 SpawnItem()을 호출하고, 스폰된 AActor 포인터를 리턴
            return SpawnItem(ActualClass);
        } 
   }
    return nullptr;
}

FItemSpawnRow* ASpawnVolume::GetRandomItem() const
{
    if (!ItemDataTable) return nullptr;

    TArray<FItemSpawnRow*> AllRows;
    static const FString ContextString(TEXT("ItemSpawnContext"));
    ItemDataTable->GetAllRows(ContextString, AllRows);

    if (AllRows.IsEmpty()) return nullptr;

    float TotalChance = 0.0f;
    for (const FItemSpawnRow* Row : AllRows)
    {
        if (Row)
        {
            TotalChance += Row->SpawnChance;
        }
    }

    const float RandValue = FMath::FRandRange(0.0f, TotalChance);

    float AccumulateChance = 0.0f;

    for (FItemSpawnRow* Row : AllRows)
    {
        AccumulateChance += Row->SpawnChance;
        if (RandValue <= AccumulateChance)
        {
            return Row;
        }
    }

    return nullptr;
}

FVector ASpawnVolume::GetRandomPointInVolume() const
{
    FVector BoxExtent = SpawningBox->GetScaledBoxExtent();
    FVector BoxOrigin = SpawningBox->GetComponentLocation();

    return BoxOrigin + FVector(
        FMath::FRandRange(-BoxExtent.X, BoxExtent.X),
        FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y),
        FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z)
    );
}

AActor* ASpawnVolume::SpawnItem(TSubclassOf<AActor> ItemClass)
{
    if (!ItemClass) return nullptr;

    // SpawnActor가 성공하면 스폰된 액터의 포인터가 반환됨
    AActor* SpawnedActor = GetWorld()->SpawnActor<AActor>(
        ItemClass,
        GetRandomPointInVolume(),
        FRotator::ZeroRotator
    );

    return SpawnedActor;
}

SpawnVolume.cpp

 

 3) GameState 기반의 게임 루프 구현

- GameMode 버전과의 일관성을 위해서 이전에 만들어준 GameStateBase를 상속한 SpartaGameStateBase는 삭제하고 GameState를 상속한 SpartaGameState를 생성한다.
- 게임 흐름 (3레벨, 각 레벨당 30초, 코인 모두 획득 시 즉시 다음 레벨로 이동, 3레벨이 끝나면 Game Over)의 로직을 관리하도록 변경한다.
- 레벨 시작 시 40개 아이템을 소환하면, 그중 코인 아이템이 몇 개 생성되었는지 추적(SpawnCoinCount)하고, 플레이어가 먹은 코인 개수를 추적(CollectedCoinCount) 하여 "모두 먹었다면" 즉시 레벨 종료한다.
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameState.h"
#include "SpartaGameState.generated.h"

UCLASS()
class SPARTAPROJECT_API ASpartaGameState : public AGameState
{
    GENERATED_BODY()

public:
    ASpartaGameState();

    virtual void BeginPlay() override;

    UPROPERTY(VisibleAnyWhere, BlueprintReadWrite, Category = "Score")
    int32 Score;
    // 현재 레벨에서 스폰된 코인 개수
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Coin")
    int32 SpawnedCoinCount;
    // 플레이어가 수집한 코인 개수
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Coin")
    int32 CollectedCoinCount;
    // 각 레벨이 유지되는 시간 (초 단위)
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Level")
    float LevelDuration;
    // 현재 진행 중인 레벨 인덱스
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Level")
    int32 CurrentLevelIndex;
    // 전체 레벨의 개수
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Level")
    int32 MaxLevels;
    // 실제 레벨 맵 이름 배열. 여기 있는 인덱스를 차례대로 연동
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Level")
    TArray<FName> LevelMapNames;

    // 매 레벨이 끝나기 전까지 시간이 흐르도록 관리하는 타이머
    FTimerHandle LevelTimerHandle;

    UFUNCTION(BlueprintPure, Category = "Score")
    int32 GetScore() const;
    UFUNCTION(BlueprintCallable, Category = "Score")
    void AddScore(int32 Amount);
    // 게임이 완전히 끝났을 때 (모든 레벨 종료) 실행되는 함수
    UFUNCTION(BlueprintCallable, Category = "Level")
    void OnGameOver();

    // 레벨을 시작할 때, 아이템 스폰 및 타이머 설정
    void StartLevel();
    // 레벨 제한 시간이 만료되었을 때 호출
    void OnLevelTimeUp();
    // 코인을 주웠을 때 호출
    void OnCoinCollected();
    // 레벨을 강제 종료하고 다음 레벨로 이동
    void EndLevel();
};

SpartaGameState.h

#include "SpartaGameState.h"
#include "Kismet/GameplayStatics.h"
#include "SpawnVolume.h"
#include "CoinItem.h"

ASpartaGameState::ASpartaGameState()
{
    Score = 0;
    SpawnedCoinCount = 0;
    CollectedCoinCount = 0;
    LevelDuration = 30.0f;     // 한 레벨당 30초
    CurrentLevelIndex = 0;
    MaxLevels = 3;
}

void ASpartaGameState::BeginPlay()
{
    Super::BeginPlay();

    // 게임 시작 시 첫 레벨부터 진행
    StartLevel();
}

int32 ASpartaGameState::GetScore() const
{
return Score;
}

void ASpartaGameState::AddScore(int32 Amount)
{
Score += Amount;
UE_LOG(LogTemp, Warning, TEXT("Score: %d"), Score);
}

void ASpartaGameState::StartLevel()
{
    // 레벨 시작 시, 코인 개수 초기화
    SpawnedCoinCount = 0;
    CollectedCoinCount = 0;

    // 현재 맵에 배치된 모든 SpawnVolume을 찾아 아이템 40개를 스폰
    TArray<AActor*> FoundVolumes;
    UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);

    const int32 ItemToSpawn = 40;

    for (int32 i = 0; i < ItemToSpawn; i++)
    {
        if (FoundVolume.Num() > 0)
        {
            ASpawnVolume* SpawnVolume = Cast<ASpawnVolume>(FoundVolumes[0]);
            if (SpawnVolume)
            {
                AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
                // 만약 스폰된 액터가 코인 타입이라면 SpawnedCoinCount 증가
                if (SpawnedActor && SpawnedActor->IsA(ACoinItem::StaticClass()))
                {
                    SpawnedCoinCount++;
                }
            }
      }
}

    // 30초 후에 OnLevelTimeUp()가 호출되도록 타이머 설정
    GetWorldTimerManager().SetTimer(
        LevelTimerHandle,
        this,
        &ASpartaGameState::OnLevelTimeUp,
        LevelDuration,
        false
    );

    UE_LOG(LogTemp, Warning, TEXT("Level %d Start!, Spawned %d coin"),
        CurrentLevelIndex + 1,
        SpawnedCoinCount);
}

void ASpartaGameState::OnLevelTimeUp()
{
    // 시간이 다 되면 레벨을 종료
    EndLevel();
}

void ASpartaGameState::OnCoinCollected()
{
    CollectedCoinCount++;

    UE_LOG(LogTemp, Warning, TEXT("Coin Collected: %d / %d"), 
        CollectedCoinCount,
        SpawnedCoinCount)

    // 현재 레벨에서 스폰된 코인을 전부 주웠다면 즉시 레벨 종료
    if (SpawnedCoinCount > 0 && CollectedCoinCount >= SpawnedCoinCount)
    {
        EndLevel();
    }
}

void ASpartaGameState::EndLevel()
{
    // 타이머 해제
    GetWorldTimerManager().ClearTimer(LevelTimerHandle);
    // 다음 레벨 인덱스로
    CurrentLevelIndex++;

    // 모든 레벨을 다 돌았다면 게임 오버 처리
    if (CurrentLevelIndex >= MaxLevels)
    {
        OnGameOver();
        return;
    }

    // 레벨 맵 이름이 있다면 해당 맵 불러오기
    if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
    {
        UGamePlayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
    }
    else
    {
        // 맵 이름이 없으면 게임오버
        OnGameOver();
    }
}

void ASpartaGameState::OnGameOver()
{
    UE_LOG(LogTemp, Warning, TEXT("Game Over!!"));
    // 여기서 UI를 띄운다거나, 재시작 기능을 넣을 수도 있음
}

SpartaGameState.cpp

- 맵 전환 (OpenLevel) 시 주의
 : UGameplayStatics::OpenLevel을 호출하면 지금 월드가 언로드(제거) 되고, 새로운 맵이 로드되면서 BeginPlay()가 다시 실행된다.
   이떄 GameState도 새로 생성되기 때문에, 이전 레벨에서 유지하던 변수가 모두 초기화될 수 있다.
- 로직 함수
   - BeginPlay()
   : 게임 시작 시 StartLevel() 호출
   - StartLevel()
   1. 코인 개수들 초기화 (SpawnedCoinCount = 0, CollectedCoinCount = 0)
   2. 스폰 볼륨들을 찾아서 40개 아이템 스폰(반복)
     : 만약 SpawnRandomItem()ACoinItem을 반환하면 SpawnedCoinCount++
   3. 30초 타이머 설정 (OverLevelTimerUp 호출)
   - OnLevelTimeUp()
   : 30초가 만료되면 레벨 종료 (EndLevel())
   - OnCoinCollected()
   : 코인 아이템을 먹을 때마다 호출
     - CollectedCoinCount++
     - CollectedCoinCount >= SpawnedCoinCount 이면, 즉시 EndLevel()
   - EndLevel()
   1. 현재 레벨 타이머 정리
   2. CurrentLevelIndex++
   3. 만약 CurrentLevelIndex >= MaxLevels 이면 OnGameOver()
     - 아니면 다음 레벨 StartLevel()
   - OnGameOver()
   : GameOver 로그 출력 (혹은 UI 호출 등)
     추후 RestartGame 등 처리 (블루프린트나 GameMode에서 구현)

 

 4) 코인 아이템 점수 획득 로직 수정

 : CoinItem은 플레이어가 닿았을 때 (ActiveItem) 점수를 획득하고 자기 자신을 제거하는 구조이다.

   여기서 추가로 "코인을 하나 더 먹었다" 고 GameState 에게 알려야한다.

#include "CoinItem.h"
#include "Engine/World.h"
#include "SpartaGameState.h"

ACoinItem::ACoinItem()
{
    PointValue = 0;
    ItemType = "DefaultCoin";
}

void ACoinItem::ActivateItem(AActor* Activator)
{
    if (Activator && Activator->ActorHasTag("Player"))
    {
        if (UWorld* World = GetWorld())
        {
            if (ASpartaGameState* GameState = World->GetGameState<ASpartaGameState>())
            {
                GameState->AddScore(PointValue);
                GameState->OnCoinCollected();
            }
        }

        DestroyItem();
    }
}

CoinItem.cpp

 

2, Game Instance를 활용한 데이터 유지하기

 1) Game Instance란?

 : 레벨 전환 시에는 GameState, GameMode 같은 기본 클래스를 비롯해, 맵 내에서 생성된 대부분의 객체가 처음부터 다시 생성된다.

   하지만 어떤 경우에는 "이전 레벨에서 획득한 점수나 플레이어 상태" 등을 모든 레벨에 걸쳐 유지하고 싶을 수 있다.

   이를 위해서는 보통 두 가지 방법을 활용한다.

1. Game Instance
   - 프로젝트가 시작될 때 (에디터에서 게임 실행을 누른 시점)부터 애플리케이션이 완전히 종료될 때까지 유일하게 계속 살아있는 객체이다.
   - 맵이 전환되어도 파괴되지 않으므로, 여기서 전역 데이터를 유지할 수 있다.
2. Seamless Travel
   - 멀티플레이 환경에서 주로 사용되는 레벨 전환 방식으로, GameState / PlayerController 등을 파괴하지 않고 그대로 다음 맵으로 넘어가는 기능이다.
   - Seamless Travel을 사용하면 대부분 객체를 유지할 수 있지만, 설정과 로직이 조금 더 복잡하므로, 싱글 플레이 전용 간단 프로젝트라면 GameInstance를 사용하기가 쉽다.

 

 2) Game Instance 생성 및 변수 선언

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "SpartaGameInstance.generated.h"

UCLASS()
class SPARTAPROJECT_API USpartaGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
    USpartaGameInstance();

    // 게임 전체 누적 점수
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "GameData")
    int32 TotalScore;
    // 현재 레벨 인덱스 (GameState에서도 관리할 수 있지만, 맵 전환 후에도 살리고 싶다면 GameInstance에 복제할 수 있음)
    UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "GameData")
    int32 CurrentLevelIndex;

    UFUNCTION(BlueprintCallable, Category = "GameData")
    void AddToScore(int32 Amount);
};

SpartaGameInstance.h

#include "SpartaGameInstance.h"

USpartaGameInstance::USpartaGameInstance()
{
    TotalScore = 0;
    CurrentLevelIndex = 0;
}

void USpartaGameInstance::AddToScore(int32 Amount)
{
    TotalScore += Amount;
    UE_LOG(LogTemp, Warning, TEXT("Total Score Updated: %d"), TotalScore);
}

SpartaGameInstance.cpp

 

 3) 전체 게임 루프 요약

1. 게임 실행
 - GameInstance 생성, GameMode / GameState 생성, 첫 레벨 로드
2. BeginPlay()
 - ASpartaGameState::BeginPlay() -> StartLevel()
 - 스폰 볼륨(SpawnVolume) 에서 40개 아이템 스폰
 - 코인 개수 추적 (SpawnedCoinCount)
 - 30초 타이머 시작
3. 플레이어가 코인 획득
 - CoinItem::ActivateItem() 에서 GameState->AddScore(), OnCoinCollected()
 - 모든 코인을 모드면 즉시 EndLevel()
4. 게임 종료
 - EndLevel() 에서 CurrentLevelIndex++
 - 남은 레벨이 있으면 UGamePlayStataic::OpenLevel(. . .) 로 다음 맵 로드
 - 더 이상 레벨이 없으면 OnGameOver()
5. 다음 맵 로드 시
 - 새로운 GameState가 생성 -> 다시 BeginePlay() -> StartLevel()
 - 이전 레벨에서 유지하고 싶은 정보는 GameInstance나 "Seamless Travel" 등을 통해 별도로 관리해야 한다.
6. Game Over
 - 로그 출력 (추후 UI 표시로 전환)