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 표시로 전환)
'본캠프 TIL' 카테고리의 다른 글
| 06.25 Unreal 본캠프) 게임 흐름에 맞춘 메뉴 UI 구현하기 (0) | 2026.06.25 |
|---|---|
| 06.25 Unreal 본캠프) UI 위젯 설계와 실시간 데이터 연동하기 (0) | 2026.06.25 |
| 06.24 Unreal 본캠프) 캐릭터 체력 및 점수 관리 시스템 구현하기 (0) | 2026.06.24 |
| 06.23 Unreal 본캠프) 아이템 스폰 및 레벨 데이터 관리하기 (0) | 2026.06.23 |
| 06.23 Unreal 본캠프) 충돌 이벤트로 획득되는 아이템 구현하기 (0) | 2026.06.23 |