과제 소개
- 기존 프로젝트에는 이미 인터페이스 기반 아이템, 충돌 이벤트 (Overlap), 랜덤 스폰, 체력/점수, 웨이브/시간 제어, UI 등 다양한 기능이 구현되어 있습니다.
- 이번 과제에서는 아래 2가지 흐름을 함께 재설계·확장하여, 게임 플레이와 UI/UX 모두 새로운 방식으로 즐길 수 있도록 만들어 봅시다.
- 게임 플레이 측면: 아이템 인터페이스 + 멀티 웨이브 구조를 개선하여 게임의 재미를 개선하기
- UI/UX 측면: HUD/메뉴 등을 전면 리뉴얼해 시각적 완성도와 인터랙션을 높이기
과제 진행 개요
1단계 : 멀티 웨이브 구성하기
- 이미 만들어둔 SpawnVolume, 충돌(Overlap), GameState/GameMode 등을 최대한 재사용하되, 새롭게 추가되는 아이템/웨이브 로직에 맞게 확장해 줍니다.
- 한 레벨 안에서 웨이브 1 → 웨이브 2 → 웨이브 3 … 식으로 단계별 진행이 일어납니다.
- 각 웨이브마다 제한 시간을 설정합니다.
- 각 웨이브마다 스폰되는 아이템의 개수 또한 달라지도록 설정합니다.
2단계 : 현재 UI 분석 및 요구사항 정리
- 기존에 만든 WBP_HUD, WBP_MainMenu 등의 위젯을 구조와 디자인 관점에서 살펴봅니다.
- “HUD에 표시하고 싶은 정보가 무엇인지”, “메인 메뉴/종료 메뉴에 필요한 버튼 및 레이아웃은 어떠한지” 기획합니다.
3단계 : HUD 및 Menu UI 재설계
- HUD 위젯 재디자인
- 캔버스 구조 (CanvasPanel, VerticalBox 등)를 적절히 배치하여, 점수, 타이머, 레벨 등 정보를 보기 좋게 표시합니다.
- 텍스트 스타일 (폰트, 색상, 테두리 등)과 아이콘, 게이지 바 등을 활용해 디자인 완성도를 높입니다.
- 메뉴 UI 재디자인
- 게임 시작, 재시작, 종료 버튼을 재배치하고, 배경 이미지·반투명 블러 등 시각적 요소를 개선합니다.
- 버튼 hover, clicked 등 인터랙션 효과 (색/이미지 변화)를 적용합니다.
필수 과제 (기본 요구 사항)
필수 과제 1번 - 멀티 웨이브 구조 구현
- 현재 프로젝트는 3번의 레벨이 전환이 됩니다.
- 이번에는 한 레벨 안에서 최소 3단계 이상의 웨이브를 만들어봅니다. (Wave 1, Wave 2, Wave 3)
- 각 웨이브는 일정 시간을 가지고, 웨이브가 증가할수록 스폰되는 아이템의 개수가 달라집니다.
- 시작 시점에 UE_LOG나 GEngine->AddOnScreenDebugMessage로 “Wave 1 시작!” 등 알림을 출력합니다.
필수 과제 2번 - HUD & 메뉴 UI 리뉴얼
- HUD에 표시할 정보: 점수, 시간, 체력을 전부 한 화면에서 볼 수 있도록 배치합니다.
- 메인 메뉴 (시작, 종료), 게임 오버 메뉴 (재시작, 메인 메뉴로 돌아가기) 화면을 재설계합니다.
- 폰트, 색상, 배경 등을 적절히 조정하여 일관된 디자인을 적용합니다.
- 개인이 원하는 스타일로 최대한 멋지고 직관적으로 UI를 디자인해봅니다.
- 각 버튼 클릭 시, C++ 혹은 블루프린트로 연결된 함수를 호출해 실제 레벨 이동 또는 게임 종료가 이루어지도록 구현합니다.
이번 과제도 위에서부터 하나씩 해결해나갈 예정이다.
필수 과제 1번 - 멀티 웨이브 구조 구현
현재 3개의 레벨이 전환이 되는 구조에서 한 레벨 안에서 최소 3단계 이상의 웨이브로 변경하기
우선 레벨이 끝날 때 새로운 맵이 오픈되는 로직을 주석처리하고 한 레벨에서 다른 지형으로 순간이동 할 수 있도록 테스트 코드를 작성했다.
// JinGameState.cpp
// EndLevel 함수
if (CurrentLevelIndex >= MaxLevels)
{
OnGameOver();
return;
}
else
{
switch (CurrentLevelIndex) // 레벨에 따라 스폰 위치 정하기(테스트)
{
case 1:
StartLocation = {0, -2500, 97};
StartLevel();
break;
case 2:
StartLocation = { 0, 2600, 97 };
StartLevel();
break;
}
}
// 원래 아래 코드로 레벨이 증가하면 다른 맵이 열리도록 했었다.
/*if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
{
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
}
else
{
OnGameOver();
}*/
// StartLevel 함수
void AJinGameState::StartLevel()
{
if (UGameInstance* GameInstance = GetGameInstance())
{
if(UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(GameInstance))
{
CurrentLevelIndex = JinGameInstance->CurrentLevelIndex;
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
if (AJinPlayerController* JinPlayerController = Cast<AJinPlayerController>(PlayerController))
{
JinPlayerController->ShowGameHUD();
if (AJinCharacter* JinCharacter = Cast<AJinCharacter>(JinPlayerController->GetPawn()))
{
JinCharacter->SetHealth(CurrentLevelIndex == 0 ? JinCharacter->GetMaxHealth() : JinGameInstance->HP);
if (CurrentLevelIndex != 0) // 레벨 인덱스가 0이 아니면 캐릭터 순간이동 시키기
{
JinCharacter->SetActorLocation(StartLocation);
}
}
}
}
}
}
위처럼 하니 같은 맵에서 레벨이 오를 때 마다 순간이동이 되고 체력과 점수가 모두 유지가 됐다.
하지만 전 웨이브(레벨)에서 소환된 아이템이 맵에 남아있는 문제가 발견됐다.(아이템이 계속 40개 씩 누적됨)
이를 해결 하기 위해 소환된 아이템을 담을 배열 TArray를 생성하고 아이템이 스폰될 때 배열에 아이템을 담고 레벨이 넘어갈때(EndLevel 함수 호출 시) 이 배열의 아이템을 Destroy 해주면 될 것 같다.
// JimGameState.h
TArray<AActor*> SpawnedActors; // 스폰된 아이템을 담을 배열
// JinGameState.cpp
// StartLevel 함수
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
const int32 ItemToSpawn = 40;
if (FoundVolumes.Num() > 0)
{
ASpawnVolume* SpawnVolume = Cast<ASpawnVolume>(FoundVolumes[0]);
if (SpawnVolume)
{
for (int32 i = 0; i < ItemToSpawn; ++i)
{
AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
if (SpawnedActor)
{
SpawnedActors.Add(SpawnedActor); // 소환된 아이템 배열에 담기
if (SpawnedActor->IsA(ACoinItem::StaticClass()))
{
++SpawnedCoinCount;
}
}
}
}
}
// EndLevel 함수
if (CurrentLevelIndex >= MaxLevels)
{
OnGameOver();
return;
}
else
{
for (auto& Item : SpawnedActors)
{
if(Item && !Item->IsPendingKillPending()) // 이 Item이 Destroy()가 호출돼서 곧 사라질 상태인지 확인하는 함수
{
Item->Destroy();
}
}
SpawnedActors.Empty(); // 배열 초기화
switch (CurrentLevelIndex) // 레벨에 따라 스폰 위치 정하기
{
case 1:
StartLocation = {0, -2500, 97};
StartLevel();
break;
case 2:
StartLocation = { 0, 2600, 97 };
StartLevel();
break;
}
}
IsPendingKillPending 함수는 Actor가 Destroy 함수를 호출하면 바로 메모리에서 지워지는 것이 아니고 "파괴 예약 상태"가 되는데 현재 Actor가 파괴 예약 상태인지를 판단하는 함수다.
현재 프로젝트에서는 아이템을 먹을 때 아이템이 가진 함수 ActivateItem에서 Destroy()를 호출해놨으므로 위 코드에서는 상태 판단을 해서 파괴 예약 상태가 아닌 것만 Destroy 하도록 한 것이다.
만약 IsPendingKillPending 함수를 사용하지 않고 모두 파괴 한다면 SpawnedActors 배열 안에 있는 아이템 중 이미 파괴 예약 상태인 아이템까지 또 Destroy()를 호출하여 문제가 발생할 수도 있다.
각 웨이브는 일정 시간을 가지고, 웨이브가 증가할수록 스폰되는 아이템의 개수 달라짐
웨이브 사이에 일정 시간 텀을 두기 위해 Wave전용 타이머핸들을 추가해서 5초 후에 웨이브가 시작되도록 구현해볼 예정이다.
헤더에 웨이브 타이머핸들을 추가하고 웨이브가 끝날 때 타이머를 작동해서 5초 후에 StartLevel 함수가 호출되도록 구현한다.
// JinGameState.h
FTimerHandle WaveTimerHandle;
// JinGameState.cpp
// EndLevel 함수
switch (CurrentLevelIndex) // 레벨에 따라 스폰 위치 정하기
{
case 1:
StartLocation = {0, -2500, 97};
GetWorldTimerManager().SetTimer(
WaveTimerHandle,
this,
&AJinGameState::StartLevel,
5.0f,
false
);
break;
case 2:
StartLocation = { 0, 2600, 97 };
GetWorldTimerManager().SetTimer(
WaveTimerHandle,
this,
&AJinGameState::StartLevel,
5.0f,
false
);
break;
}
테스트를 해보니 5초 후에 다음 웨이브가 시작되긴 하는데 EndLevel 함수의 첫 부분에서 CurrentLevelIndex를 증가시키는 로직 때문에 HUD의 Level이 먼저 다음 레벨이 표시가 된다. 그냥 둬도 되지만 좀 거슬려서 EndLevel 함수에서 이 텍스트 박스를 숨겼다가 StartLevel 함수에서 다시 보이도록 하려 했지만 텍스트를 다음 웨이브 시작까지 몇 초 남았는지 출력되도록 바꿨다.
헤더에 레벨이 끝났는지 판단하는 bool 변수를 추가하고 레벨이 종료될 때 웨이브 타이머핸들을 초기화 시켰다.
// JinGameState.h
bool bEndLevel;
// JinGameState.cpp
// 생성자
bEndLevel = false;
// StartLevel 함수 맨 윗줄
bEndLevel = false;
// EndLevel 함수 맨 윗줄
bEndLevel = true;
GetWorldTimerManager().ClearTimer(WaveTimerHandle); // 웨이브 타이머 초기화
// UpdateHUD 함수 Level 텍스트 수정하는 부분
if (UTextBlock* LevelIndexText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Level"))))
{
if(!bEndLevel) // 레벨이 안끝났으면(=레벨 진행중이면)
{
LevelIndexText->SetText(FText::FromString(FString::Printf(TEXT("Level %d"), CurrentLevelIndex + 1)));
}
else // 레벨이 끝났으면
{
float RemainingWave = GetWorldTimerManager().GetTimerRemaining(WaveTimerHandle);
LevelIndexText->SetText(FText::FromString(FString::Printf(TEXT("Wait.. %.1fs"), RemainingWave)));
}
}
이제 자연스럽게 진행이 된다.
레벨에 따라 스폰되는 아이템 개수 변화는 레벨에 따라 더 많이 생성되도록 구현할 것이다.
이 부분은 간단하게 아이템 스폰 개수를 정하는 로직에 현재 레벨 인덱스를 더하거나 곱해서 더 많이 생성하도록 구현했다.
// JinGameState.cpp
// StartLevel 함수의 아이템 스폰하는 부분
TArray<AActor*> FoundVolumes;
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ASpawnVolume::StaticClass(), FoundVolumes);
const int32 ItemToSpawn = 30 + (CurrentLevelIndex * 10); // 여기를 수정했다. 기본 30개에 레벨에 따라 10개 증가
SpawnedActors.Reserve(ItemToSpawn); // 미리 배열 크기 할당해놓기(재할당 안일어나게)
if (FoundVolumes.Num() > 0)
{
ASpawnVolume* SpawnVolume = Cast<ASpawnVolume>(FoundVolumes[0]);
if (SpawnVolume)
{
for (int32 i = 0; i < ItemToSpawn; ++i)
{
AActor* SpawnedActor = SpawnVolume->SpawnRandomItem();
if (SpawnedActor)
{
SpawnedActors.Add(SpawnedActor); // 소환된 아이템 담기
if (SpawnedActor->IsA(ACoinItem::StaticClass()))
{
++SpawnedCoinCount;
}
}
}
}
}
스폰 아이템 개수를 담당하는 변수 ItemToSpawn에 기본 30 + 현재 레벨 인덱스 * 10을 해서 1레벨에 30, 2레벨에 40, 3레벨에 50개가 소환되도록 구현했다. 테스트를 해보니 잘 작동했다.
시작 시점에 UE_LOG로 웨이브 시작 알림 출력하기
이것도 간단하게 StartLevel 함수에서 UE_LOG로 현재 레벨 인덱스 + 1을 출력했다.
// JinGameState.cpp
// StartLevel 함수의 상단부
CurrentLevelIndex = JinGameInstance->CurrentLevelIndex;
UE_LOG(LogTemp, Warning, TEXT("Level: %d Start!"), CurrentLevelIndex + 1);
필수 과제 2번 - HUD & 메뉴 UI 리뉴얼
HUD에 표시할 정보: 점수, 시간, 체력을 전부 한 화면에서 볼 수 있도록 배치합니다.
현재 점수, 시간은 이미 구현돼있으니 체력만 추가해준다.
WBP_HUD에 HP 텍스트를 추가하고 텍스트블럭들을 재배치 했다.

HP 텍스트를 현재 체력과 최대 체력 값에 바인딩해서 실시간으로 갱신되도록 한다.
// JinGameState.cpp
// UpdateHUD 함수의 맨 끝에
if (AJinCharacter* JinCharacter = Cast<AJinCharacter>(JinPlayerController->GetPawn()))
{
if (UTextBlock* HPText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("HP"))))
{
HPText->SetText(FText::FromString(FString::Printf(TEXT("HP : %.f / %.f"), JinCharacter->GetHealth(), JinCharacter->GetMaxHealth())));
}
}
캐릭터의 체력과 최대 체력을 가져오기 위해 JinCharacter를 캐스트해서 가져왔다.
이제 기존에 있던 캐릭터의 머리 위에 체력이 표시되던 위젯 컴포넌트를 비활성화 해야하는데 코드를 모두 삭제하긴 아까워서 에디터에서 위젯 클래스를 None으로 변경해주었다. 코드상 유효하지 않으면 함수가 바로 return 되도록 구현해놨다.
// JinCharacter.cpp
void AJinCharacter::UpdateOverheadHP()
{
if (!OverheadWidget) return; // 유효하지 않으면 바로 종료
UUserWidget* OverheadWidgetInstance = OverheadWidget->GetUserWidgetObject();
if (!OverheadWidgetInstance) return;
if (UTextBlock* HPText = Cast<UTextBlock>(OverheadWidgetInstance->GetWidgetFromName(TEXT("OverHeadHP"))))
{
HPText->SetText(FText::FromString(FString::Printf(TEXT("%.f / %.f"), Health, MaxHealth)));
}
}

메인 메뉴(시작, 종료), 게임 오버 메뉴 (재시작, 메인 메뉴로 돌아가기) 화면을 재설계하기
메인 메뉴에 레벨(웨이브)1,2,3 버튼을 추가하여 시작하는 레벨을 선택할 수 있도록 구현을 해볼것이다.
그러기 위해 EndLevel함수와 StartLevel함수를 조금 수정했다.
EndLevel에 현재 레벨 인덱스에 따라 시작 위치를 정해주는 로직을 StartLevel로 옮기고 EndLevel에는 웨이브 타이머만 남겼다.
// JinGameState.cpp
// EndLevel 함수
if (CurrentLevelIndex >= MaxLevels)
{
OnGameOver();
return;
}
else
{
for (auto& Item : SpawnedActors)
{
if(Item && !Item->IsPendingKillPending()) // 이 Item이 Destroy()가 호출돼서 곧 사라질 상태인지 확인하는 함수
{
Item->Destroy();
}
}
SpawnedActors.Empty(); // 배열 초기화
// 원래 이 아래에 레벨에 따라 시작위치를 정해주고 웨이브타이머 작동했음
GetWorldTimerManager().SetTimer(
WaveTimerHandle,
this,
&AJinGameState::StartLevel,
5.0f,
false
);
}
// StartLevel 함수
void AJinGameState::StartLevel()
{
bEndLevel = false;
if (UGameInstance* GameInstance = GetGameInstance())
{
if(UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(GameInstance))
{
CurrentLevelIndex = JinGameInstance->CurrentLevelIndex;
UE_LOG(LogTemp, Warning, TEXT("Level: %d Start!"), CurrentLevelIndex + 1);
switch (CurrentLevelIndex) // 레벨에 따라 스폰 위치 정하기
{
case 1:
StartLocation = { 0, -2500, 97 };
break;
case 2:
StartLocation = { 0, 2600, 97 };
break;
}
여기까지 하고 테스트 실행을 해보니 전과 똑같이 작동을 했다.
이제 메인메뉴에 버튼을 3개 추가해서 레벨1, 2, 3 버튼과 종료 버튼을 만든다.

종료 버튼의 클릭 이벤트에 Quit Game 노드를 연결해 게임이 종료되도록 했다.

이제 레벨 버튼에 따라 시작되는 레벨을 다르도록 연결을 하기 위해 JinGameState에 SetStartLevel 함수를 새로 만들었다.
이 함수에서 메인 메뉴 Level 버튼의 끝에 있는 숫자를 가져와서 CurrentLevelIndex에 넣는 방식을 구현하려 한다.
버튼안에 있는 텍스트 블럭에서 텍스트 일부를 가져오는 방법을 알아보던 중 meta = (BindWidget) 이라는 아주 간편한 매크로를 배웠다.
meta = (BindWidget)은 C++의 변수와 블루프린트의 위젯을 자동으로 연결해주는 매크로다.
현재 프로젝트에서는 TextBlock을 가져올 때
UTextBlock* RemainCoinText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Coin")))
이런식으로 작성하여 WBP에서 Coin이라는 이름을 가진 텍스트 블럭을 찾아서 캐스트를 한 후 RemainCoinText에 넣는 방식으로 작성을 하고있었다.
하지만 매크로를 사용하면 헤더에 연결할 위젯의 이름(위 코드에서 Coin)을 변수로 선언하고 매크로를 사용하면 자동으로 변수의 이름을 WBP에서 같은 이름, 같은 타입의 위젯을 찾아서 자동으로 연결을 해준다는 것이다.
UPROPERTY(meta = (BindWidget))
UTextBlock* Coin;
그 후 cpp에서 번거롭게 캐스트를 하지 않고 그냥 Coin->필요한 메서드를 호출해서 사용하면 된다.
매크로를 사용하여 SetStartLevel 함수를 구현하려 했는데 생각해보니 레벨 버튼이 3갠데 누른 레벨 버튼에 따라 텍스트 블럭에서 끝의 숫자를 가져오려면 함수에서 내가 누른 버튼이 어떤 건지를 알아야 하고 텍스트에서 숫자를 뽑았다 쳐도 이를 또 JinGameInstance의 CurrentLevelIndex로 값을 넣어줘야하는데 번거로울 것 같아서 다른 방법을 생각했다.
생각한 방법은 블루프린트에서 버튼 안의 텍스트 블럭을 Is Variable로 변수로 만들고 이벤트 그래프에서 텍스트 블럭의 끝 숫자를 뽑아서 이를 바로 JinGameInstance의 CurrentLevelIndex로 넘겨주는 것이였다.
기존 프로젝트에서 start 버튼을 누르면 JinPlayerController의 StartGame가 실행되도록 구현이 됐었는데 StartGame 함수에서 JinGameInstance의 CurrentLevelIndex에 바로 값을 넘겨주는 로직이 이미 구현되어 있어서 파라미터를 추가하고 이벤트그래프에서 뽑은 숫자를 파라미터로 넘겨줬다.
// JinPlayerController.cpp
void AJinPlayerController::StartGame(int32 SetLevel) // 파라미터 추가(기존에는 비어있었음)
{
if (UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(UGameplayStatics::GetGameInstance(this)))
{
JinGameInstance->CurrentLevelIndex = SetLevel - 1; // 현재 레벨 인덱스를 파라미터로 받은값 - 1로 설정
JinGameInstance->TotalScore = 0;
}
UGameplayStatics::OpenLevel(GetWorld(), FName("BasicLevel"));
SetPause(false);
}
이벤트 그래프는 다른 로직은 동일하고 텍스트 블럭 변수만 다르다.

레벨 버튼의 텍스트를 변수로 만들어서 GetText로 FText만 뽑고 이를 FString으로 변환해서 RightChop을 사용해서 필요한 문자열(끝의 숫자)만 뽑아냄. RightChop은 왼쪽의 문자를 Count만큼 제거하는 함수다.("Level " = 6)
현재 숫자는 String이므로 int로 변환하여 StartGame의 파라미터로 넘겨준 것.
위까지 구현한 후 테스트를 해보니 시작 레벨 버튼에 따라 위치가 다르게 시작되는 것과 아이템 개수와 아이템 스폰 확률은 레벨 별로 다르게 정상 작동 되는게 확인 됐지만 레벨 2, 3 버튼을 누르면 현재 체력이 0으로 표시가 되는 문제가 발견됐다.
확인해보니 JinGameState.cpp의 StartGame 함수에서 현재 레벨 인덱스가 0이 아니면 JinGameInstance의 HP를 가져오도록 구현이 돼있어서 그 기초값인 0이 들어가 있는 문제였다.
if (AJinCharacter* JinCharacter = Cast<AJinCharacter>(JinPlayerController->GetPawn()))
{ // 이 부분이다
JinCharacter->SetHealth(CurrentLevelIndex == 0 ? JinCharacter->GetMaxHealth() : JinGameInstance->HP);
if (CurrentLevelIndex != 0)
{
JinCharacter->SetActorLocation(StartLocation);
}
}
그래서 현재 버튼이 눌리면 StartGame 함수가 실행되므로 StartGame 함수에서 JinCharacter의 현재 체력을 JinGameInstance의 HP로 넘겨주는 로직을 구현했다.
void AJinPlayerController::StartGame(int32 SetLevel)
{
if (UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(UGameplayStatics::GetGameInstance(this)))
{
JinGameInstance->CurrentLevelIndex = SetLevel - 1;
JinGameInstance->TotalScore = 0;
if (AJinCharacter* JinCharacter = Cast<AJinCharacter>(this->GetPawn()))
{
JinGameInstance->HP = JinCharacter->GetHealth();
}
}
UGameplayStatics::OpenLevel(GetWorld(), FName("BasicLevel"));
SetPause(false);
}
테스트를 해보니 레벨 2, 3에서도 시작 체력이 100으로 잘 작동이 됐다.
그리고 Game Over 텍스트와 Total Score 텍스트는 삭제하고 게임 제목을 표시했다. 게임제목 : Run!Run!Run!

메인 메뉴는 이렇게 마치고 이제 게임 오버 메뉴를 만든다.
게임 오버 메뉴에는 게임 오버 됐을 때 재시작 버튼과 메인 메뉴로 돌아가는 로직을 구현해야 한다.
새로운 위젯 블루프린트를 만들고 캔버스 패널과 버튼, 텍스트블럭을 알맞게 배치했다.

이제 게임 오버가 되는 상황마다 이 위젯이 화면에 띄워지도록 구현을 해야한다.
JinPlayerController에 ShowGameOverMenu 함수를 만들어서 구현했다.
// JinPlayerController.h
// 새로 만든 WBP 담을 변수 생성
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Menu")
TSubclassOf<UUserWidget> GameOverWidgetClass;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Menu")
UUserWidget* GameOverWidgetInstance;
UFUNCTION(BlueprintCallable, Category = "Menu")
void ShowGameOverMenu();
// JinPlayerController.cpp
void AJinPlayerController::ShowGameOverMenu()
{
if (HUDWidgetInstance)
{
HUDWidgetInstance->RemoveFromParent();
HUDWidgetInstance = nullptr;
}
if (MainMenuWidgetInstance)
{
MainMenuWidgetInstance->RemoveFromParent();
MainMenuWidgetInstance = nullptr;
}
if (GameOverWidgetInstance) // 이 조건문을 모든 메뉴 띄우는 함수에 추가했다.
{
GameOverWidgetInstance->RemoveFromParent();
GameOverWidgetInstance = nullptr;
}
if (GameOverWidgetClass)
{
GameOverWidgetInstance = CreateWidget<UUserWidget>(this, GameOverWidgetClass);
if (GameOverWidgetInstance)
{
GameOverWidgetInstance->AddToViewport();
bShowMouseCursor = true;
SetInputMode(FInputModeUIOnly());
}
UFunction* PlayGameOverAnimFunc = GameOverWidgetInstance->FindFunction(FName("PlayGameOverAnim"));
if (PlayGameOverAnimFunc)
{
GameOverWidgetInstance->ProcessEvent(PlayGameOverAnimFunc, nullptr);
}
}
SetPause(true);
}
빌드 후 BP_JinPlayerController에서 GameOverWidgetClass에 만든 WBP_GameOverMenu를 넣어줬다.

그 후 테스트 실행을 했는데 게임오버가 되면 메인메뉴가 나왔다.
확인해보니 게임오버가 되면 OnGameOver함수가 호출되는데 OnGameOver함수에서 ShowMainMenu함수를 호출하고 있었다.
ShowGameOver 함수를 호출하도록 수정하고 다시 테스트를 하니 잘 작동이 됐다.
void AJinGameState::OnGameOver()
{
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
{
if (AJinPlayerController* JinPlayerController = Cast<AJinPlayerController>(PlayerController))
{
JinPlayerController->ShowGameOverMenu();
}
}
}

WBP_GameOverMenu가 띄워지는걸 확인 했으니 이제 Total Score를 JinGameInstance에서 받아와서 텍스트 블럭에 바인딩 해야한다.
// JinPlayerController.cpp
// ShowGameOverMenu 함수
if (UTextBlock* TotalScoreText = Cast<UTextBlock>(GameOverWidgetInstance->GetWidgetFromName(TEXT("TotalScoreText"))))
{
if(UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(UGameplayStatics::GetGameInstance(this)))
{
TotalScoreText->SetText(FText::FromString(FString::Printf(TEXT("Total Score: %d"), JinGameInstance->TotalScore)));
}
}
테스트를 해보니 Total Score가 잘 적용이 됐다.

이제 버튼만 구현하면 게임오버 메뉴까지 완성이다.
버튼은 Restart 버튼을 누르면 어떤 레벨(웨이브)부터 시작할 지 레벨 버튼이 표시되고 MainMenu 버튼을 누르면 MainMenu 위젯이 띄워지도록 구현할 예정이다.
우선 버튼들을 배치 하고 레벨 버튼들은 Hidden으로 설정 해두고 Restart 버튼이 눌리면 Restart는 Hidden으로, 레벨 버튼들은 Visibale로 바뀌고 MainMenu의 위치도 살짝 아래로 내려가도록 구현했다.

이제 레벨 버튼이 눌리면 메인메뉴와 똑같이 작동하도록 구현한다. 복습한다 생각하고 하나씩 다시 구현했다.
레벨 버튼이 클릭되면 플레이어 컨트롤러를 JinPlayerController로 Cast하고 성공시 StartGame 함수 실행.

StartGame의 SetLevel에 Level 버튼의 끝 숫자를 넣어주기 위해 텍스트 블럭을 변수로 만들고 Text를 가져와서 String형으로 변환 후 RightChop 함수로 왼쪽 문자열 Count만큼 삭제하고 Int형으로 변환 후 SetLevel에 넣기.

위까지 하고 테스트를 해보니 게임이 종료될 때 Restart 버튼을 누르면 레벨 버튼이 나오고 레벨 버튼을 누르면 점수와 시간 레벨은 구현한대로 작동이 되는데 체력이 초기화되지 않고 게임 종료 전 체력이 이어지는 문제를 발견했다.
그래서 StartGame 함수에서 JinGameInsatnce의 HP에 JinCharacter의 현재 체력이 아닌 최대 체력을 넣어줬다.
// JinPlayerController.cpp
void AJinPlayerController::StartGame(int32 SetLevel)
{
if (UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(UGameplayStatics::GetGameInstance(this)))
{
JinGameInstance->CurrentLevelIndex = SetLevel - 1;
JinGameInstance->TotalScore = 0;
if (AJinCharacter* JinCharacter = Cast<AJinCharacter>(this->GetPawn()))
{
JinGameInstance->HP = JinCharacter->GetMaxHealth(); // 최대체력으로 변경
}
}
UGameplayStatics::OpenLevel(GetWorld(), FName("BasicLevel"));
SetPause(false);
}
이렇게 하니 이제 모두 초기화가 되어 작동은 잘 되는데 가끔 크래시가 나는 경우가 생겼다.
확실하진 않지만 게임 종료 전에 아이템을 먹고 아이템의 파티클이 사라지기 전에 게임이 끝나면 크래시가 나는것 같다.

파티클은 시작된 후 4초후에 Destroy가 되도록 구현해놨는데 이게 게임이 새로 시작하면 이미 다 삭제가 되고 난 후 또 Destroy가 되어서 크래시가 발생하는것 같다.
이를 Particle을 약한 참조로 변경해서 4초 후에 Particle이 유효한 상태인지를 판단해서 DestroyComponent 함수를 호출하도록 변경했다.
// BaseItem.h
TWeakObjectPtr<UParticleSystemComponent> Particle; // 약한참조
// BaseItem.cpp
// ActivateItem 함수
if (Particle.IsValid()) // Particle이 유효한지 체크
{
FTimerHandle DestroyParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[this]()
{
if (Particle.IsValid()) // Particle이 유효한지 체크
{
Particle->DestroyComponent();
}
},
4.0f,
false
);
}
아직 약한참조와 람다함수를 잘 몰라서 100% 이해를 하진 못했지만 현재 이해한 바로는 SetTimer로 4초 후에 람다 함수를 호출하고 호출될 때 Particle이 파괴된 상태면 DestroyComponent 함수가 호출되지 않도록 구현하여 중복으로 Destroy되는 문제를 해결한 것 같다.
위처럼 수정하고 테스트를 하니 크래시 문제가 발생하지 않았다.
이제 메인메뉴 버튼을 누를 때 ShowMainMenu()가 호출되도록 구현만 하면 끝이다.
간단하게 WBP_GameOverMenu 이벤트 그래프에서 구현을 했다.

테스트를 해보니 모두 잘 작동이 됐다.
과제에 디자인 관련하여 손보라는 내용이 있었지만 디자인은 도전과제 까지 완료한 후 손보도록 하고 우선 로직 구현에 집중을 하려한다.
이로써 필수 과제는 모두 완료한 것 같다.
도전 과제 기능이지만 MainMenu에서 게임 제목인 RUN! RUN! RUN!에 애니메이션 효과를 넣어놨다. GameOver 텍스트에도 애니메이션 효과를 넣어놨다.
도전 과제를 하기 전에 같은 맵에 다른 지형을 만들건데 레벨 2, 레벨 3을 그 지형으로 순간이동 시킬 예정이다.
'개인 프로젝트(과제)' 카테고리의 다른 글
| 8번과제 맵 만들기 - 레벨2 (0) | 2025.10.06 |
|---|---|
| 8번과제 맵 만들기 - 레벨 1 (0) | 2025.10.02 |
| 7번 과제(Pawn 클래스로 3D 캐릭터 만들기) 도전 과제 (0) | 2025.09.22 |
| 7번 과제(Pawn 클래스로 3D 캐릭터 만들기) 필수 과제 (1) | 2025.09.19 |
| 6번 과제(회전 발판과 움직이는 장애물 퍼즐 스테이지) (1) | 2025.09.17 |