[DAY47] Game Loop & UI Redesign

베리투스·2025년 10월 16일

TIL: Today I Learned

목록 보기
48/93

SpartaGame의 레벨을 순차적으로 클리어하는 방식에서 벗어나, 하나의 레벨 안에서 여러 웨이브(Wave)가 진행되는 구조로 게임 루프를 재설계했다. 이를 위해 GameState에 웨이브 정보와 타이머를 관리하는 로직을 집중시켰다. 또한, 플레이 경험을 향상시키기 위해 기존의 HUD와 메뉴 UI를 전면 리뉴얼하고, PlayerController를 통해 게임 일시정지, 입력 모드 전환 등 UI와 상호작용하는 기능을 C++ 코드로 구현했다. 이번 과제를 통해 게임의 핵심 상태 관리와 UI/UX 설계의 중요성을 깊이 이해할 수 있었다. 🚀


📌 목표

  • 단일 레벨 내에서 멀티 웨이브(Multi-Wave) 시스템 구현하기
  • 웨이브별 제한 시간 및 스폰 아이템 개수 다르게 설정하기
  • 점수, 시간, 체력을 표시하는 HUD UI 리뉴얼하기
  • 메인 메뉴, 게임 오버 메뉴를 만들고 버튼 기능(재시작, 종료 등) C++로 구현하기

💻 코드

SpartaGameState.h

#pragma once

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

UCLASS()
class SPARTAN_API ASpartaGameState : public AGameStateBase
{
	GENERATED_BODY()

public:
	// Wave 관리
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Wave")
	int32 CurrentWaveIndex; // 현재 진행 중인 웨이브 번호

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Wave")
	int32 MaxWaves; // 이번 레벨의 최대 웨이브 수

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Wave")
	float WaveDuration; // 각 웨이브의 제한 시간 (초)

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Wave")
	TArray<int32> ItemsToSpawnPerWave; // 웨이브마다 스폰할 아이템 개수를 담는 배열
};

SpartaGameState.cpp

#include "SpartaGameState.h"
#include "TimerManager.h"
#include "Engine/Engine.h"
#include "Blueprint/UserWidget.h"
#include "Components/TextBlock.h"
#include "Spartan/Volumes/SpawnVolume.h"
#include "Spartan/Items/CoinItem.h"
#include "Spartan/Player/SpartaPlayerController.h"

// 새로운 웨이브를 시작하는 함수
void ASpartaGameState::StartWave()
{
	// 코인 카운트 초기화
	SpawnedCoinCount = 0;
	CollectedCoinCount = 0;

	// 이전 Wave 아이템 제거
	for (AActor* Item : CurrentWaveItems)
	{
		if (Item && Item->IsValidLowLevelFast())
		{
			Item->Destroy();
		}
	}
	CurrentWaveItems.Empty();

	// 이번 Wave에 스폰할 아이템 개수 결정
	int32 ItemToSpawn = (ItemsToSpawnPerWave.IsValidIndex(CurrentWaveIndex)) ? ItemsToSpawnPerWave[CurrentWaveIndex] : 20;

	// SpawnVolume을 이용해 아이템 스폰
	if (ASpawnVolume* SpawnVolume = GetSpawnVolume())
	{
		for (int32 i = 0; i < ItemToSpawn; i++)
		{
			if (AActor* SpawnedActor = SpawnVolume->SpawnRandomItem())
			{
				if (SpawnedActor->IsA(ACoinItem::StaticClass()))
				{
					SpawnedCoinCount++;
				}

				CurrentWaveItems.Add(SpawnedActor);
			}
		}
	}

	// Wave별 환경 변화
	if (CurrentWaveIndex == 1)
	{
		EnableWave2();
	}
	else if (CurrentWaveIndex == 2)
	{
		EnableWave3();
	}
	// HUD 위젯에 웨이브 시작 알림 표시
	if (ASpartaPlayerController* SpartaPlayerController = GetSpartaPlayerController())
	{
		if (UUserWidget* HUDWidget = SpartaPlayerController->GetHUDWidget())
		{
        // 위젯 블루프린트에 있는 애니메이션 실행
			UFunction* PlayAnimFunc = HUDWidget->FindFunction(FName("PlayShowWaveNotifyAnim"));

			if (PlayAnimFunc)
			{
				HUDWidget->ProcessEvent(PlayAnimFunc, nullptr);
			}
            // 위젯의 텍스트 블록을 찾아 현재 웨이브 번호로 업데이트
			if (UTextBlock* WaveNotifyText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName("WaveNotifyText")))
			{
				WaveNotifyText->SetText(FText::FromString(
					FString::Printf(TEXT("Wave %d 발생!"), CurrentWaveIndex + 1)));
			}
		}
	}

	// Wave 타이머 시작 (WaveDuration 후 OnWaveTimeUp 호출)
	GetWorldTimerManager().SetTimer(
		WaveTimerHandle,
		this,
		&ThisClass::OnWaveTimeUp,
		WaveDuration,
		false
	);
}

// Wave 2 시작 시 호출되는 디버그 메시지 함수
void ASpartaGameState::EnableWave2()
{
	const FString Msg = TEXT("Wave 2: Spawning and activating 5 Spike Traps!");
	UE_LOG(LogTemp, Warning, TEXT("%s"), *Msg); // 에디터의 출력 로그에 메시지 표시

	if (GEngine)
	{
		// 게임 화면 좌측 상단에 3초간 디버그 메시지 표시
		GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Red, Msg);
	}
}

// Wave 3 시작 시 호출되는 디버그 메시지 함수
void ASpartaGameState::EnableWave3()
{
	const FString Msg = TEXT("Wave 3: Spawning coins that orbit around!");
	UE_LOG(LogTemp, Warning, TEXT("%s"), *Msg); // 에디터의 출력 로그에 메시지 표시

	if (GEngine)
	{
		// 게임 화면 좌측 상단에 3초간 디버그 메시지 표시
		GEngine->AddOnScreenDebugMessage(-1, 3.f, FColor::Red, Msg);
	}
}

SpartaPlayerController.cpp

#include "SpartaPlayerController.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Blueprint/UserWidget.h"

// 일시정지(Pause) 메뉴를 띄우는 함수
void ASpartaPlayerController::PauseGame()
{
	// Game을 일시정지하면, 일반 타이머/틱이 멈춤
	SetPause(true);

	// 마우스 커서 보이도록, UI모드로 전환
	bShowMouseCursor = true;
	SetInputMode(FInputModeUIOnly());

	// Menu Widget 띄우기
	ShowMainMenu(false);
}

// 게임을 종료하는 함수
void ASpartaPlayerController::QuitGame()
{
	// 게임을 완전히 종료
	UKismetSystemLibrary::QuitGame(
		GetWorld(),
		this,
		EQuitPreference::Quit,
		false
	);
}

⚠️ 실수

  • 입력 모드 전환 후 복구 누락: PauseGame 함수에서 SetInputMode(FInputModeUIOnly())로 UI만 조작 가능하도록 변경한 뒤, 게임으로 복귀(Resume)할 때 다시 SetInputMode(FInputModeGameOnly())로 되돌리는 로직을 빠뜨리는 실수를 했다. 이 때문에 메뉴를 닫아도 캐릭터가 움직이지 않고 마우스 커서가 계속 보이는 버그가 발생했다. UI를 띄울 때와 닫을 때의 입력 모드 설정은 반드시 한 쌍으로 관리해야 한다는 것을 배웠다.
  • 타이머 핸들 관리: SetTimer로 웨이브 타이머를 설정했는데, 특정 조건(예: 플레이어 사망)으로 웨이브가 비정상적으로 종료될 때 타이머를 해제(ClearTimer)하지 않았다. 그 결과, 이미 종료된 웨이브의 타이머가 뒤늦게 만료되면서 다음 웨이브 로직을 호출해 게임 흐름이 엉키는 문제가 발생했다. 타이머는 생성만큼이나 소멸 관리도 중요하다는 점을 깨달았다.

✅ 핵심 요약

개념설명비고
GameState게임의 핵심 상태(점수, 남은 시간, 현재 웨이브 등)를 모든 클라이언트에게 복제하고 관리하는 중앙 관리자 역할.플레이어의 상태가 아닌, '게임 판' 자체의 상태를 저장하기에 적합하다.
FTimerManagerSetTimer 함수를 통해 지정된 시간 후에 특정 함수를 호출하도록 예약하는 시스템.WaveDuration 같은 제한 시간 기능이나 주기적인 이벤트 발생에 매우 유용하다.
PlayerController플레이어의 입력을 받아 처리하고, HUD나 메뉴 같은 UI와의 상호작용을 담당하는 클래스.게임 일시정지, 마우스 커서 표시, 입력 모드 변경 등은 PlayerController에서 처리하는 것이 일반적이다.
Input Mode사용자의 입력(키보드, 마우스)이 게임 월드에 적용될지, UI에 적용될지를 결정하는 모드.FInputModeGameOnly, FInputModeUIOnly, FInputModeGameAndUI 등이 있다.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글