TIL#52-Unreal C++(7) UMG HUD와 MainMenu 구현 및 트러블슈팅

ㅎ12·2026년 9월 2일
post-thumbnail
🔖[3CH 3번] 게임 루프 및 UI 재설계하기 (~20260902 pm13)

📚 금일 학습 내용

  • UMG를 활용한 게임 HUD 구성
  • GameState의 데이터를 Widget에 실시간으로 반영
  • PlayerController에서 HUD 생성 및 관리
  • MainMenu Widget 제작 및 Level 전환
  • Input Mode UI Only / Input Mode Game Only의 차이
  • GameModeGameState의 상속 관계
  • Widget 이름과 C++ 참조 이름 불일치 문제 해결
  • Level 전환 후 플레이어 입력이 동작하지 않는 문제 해결

1. 구현 목표

: 이전 구현에서는 GameState를 중심으로 다음과 같은 Multi Wave 게임 루프를 구성했다.

Wave 시작
    ↓
아이템 생성
    ↓
제한 시간 동안 플레이
    ↓
Wave 종료
    ↓
아이템 정리
    ↓
다음 Wave

하지만 내부적으로 Wave와 Score가 정상적으로 변경되더라도 플레이어가 현재 상태를 확인할 수 없다면 실제 게임으로서 진행 상황을 파악하기 어렵다.

따라서 이번에는 기존 게임 루프에 UMG 기반 HUD를 연결해 Score, Wave, Time을 실시간으로 표시하고, 별도의 MainMenu를 만들어 게임 시작부터 플레이까지 하나의 흐름으로 연결했다.

최종적으로 구현하고자 한 구조는 다음과 같다. ✔️

MainMenu
    ↓
START GAME
    ↓
BasicLevel
    ↓
HUD 생성
    ↓
Wave 1 → Wave 2 → Wave 3
    ↓
Score / Wave / Time 실시간 갱신

2. UMG를 활용한 HUD 구성

: 게임 진행 상태를 화면에 표시하기 위해 WBP_HUD Widget Blueprint를 생성했다.

HUD에서는 다음 세 가지 정보를 표시하도록 구성했다.

Widget 이름표시 정보
ScoreText현재 획득한 Score
LevelText현재 진행 중인 Wave
TimeText현재 Wave의 남은 시간

초기 화면에서는 다음과 같이 표시된다.

Score: 0
Wave 1 / 3
Time: 15.0

Wave가 진행되거나 Coin을 획득하면 각각의 값이 실시간으로 변경된다.

ex) SmallCoin을 획득하면 Score가 증가하고,

Score: 0
    ↓
Score: 10

Wave 제한 시간이 종료되면 다음과 같이 Wave 표시가 변경된다.

Wave 1 / 3
    ↓
Wave 2 / 3
    ↓
Wave 3 / 3

단순히 Widget에 고정된 텍스트를 배치하는 것이 아니라, C++에서 관리되는 실제 게임 데이터를 UMG에 전달하는 것이 이번 HUD 구현의 핵심이었다.


3. PlayerController에서 HUD 생성하기

: HUD는 SpartaPlayerControllerBeginPlay()에서 생성하도록 구성했다.

먼저 PlayerController에 생성할 Widget Class와 생성된 Widget Instance를 저장할 변수를 선언했다.

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
TSubclassOf<UUserWidget> HUDWidgetClass;

UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "HUD")
UUserWidget* HUDWidgetInstance;

HUDWidgetClass에는 Blueprint에서 WBP_HUD를 지정했다.

이후 게임이 시작되면 BeginPlay()에서 Widget을 생성한다.

if (HUDWidgetClass)
{
    HUDWidgetInstance = CreateWidget<UUserWidget>(
        this,
        HUDWidgetClass
    );

    if (HUDWidgetInstance)
    {
        HUDWidgetInstance->AddToViewport();
    }
}

구조를 정리하면 다음과 같다.

BP_SpartaPlayerController
        ↓
HUDWidgetClass = WBP_HUD
        ↓
BeginPlay()
        ↓
CreateWidget()
        ↓
HUDWidgetInstance
        ↓
AddToViewport()

HUDWidgetClass어떤 종류의 Widget을 생성할 것인지 지정하고,

HUDWidgetInstance실제로 생성되어 현재 화면에 표시되고 있는 Widget 객체를 저장한다.

이 과정을 통해 Blueprint에서 만든 UMG와 C++의 PlayerController를 연결할 수 있었다.


4. GameState의 데이터를 HUD에 반영하기

: HUD를 화면에 생성한 뒤에는 GameState가 관리하고 있는 Score, Wave, Timer 값을 Widget에 전달해야 했다.

이를 위해 UpdateHUD() 함수를 구현했다.

먼저 현재 PlayerController를 가져온다.

APlayerController* PlayerController =
    GetWorld()->GetFirstPlayerController();

ASpartaPlayerController* SpartaPlayerController =
    Cast<ASpartaPlayerController>(PlayerController);

이후 PlayerController가 가지고 있는 HUD Widget을 가져온다.

UUserWidget* HUDWidget =
    SpartaPlayerController->GetHUDWidget();

그리고 GetWidgetFromName()을 이용해 원하는 Text Widget을 찾는다.

ex) Score는 다음과 같이 갱신한다.

if (UTextBlock* ScoreText =
    Cast<UTextBlock>(
        HUDWidget->GetWidgetFromName(TEXT("ScoreText"))))
{
    ScoreText->SetText(
        FText::FromString(
            FString::Printf(TEXT("Score: %d"), Score)
        )
    );
}

전체적인 데이터 흐름은 다음과 같다.

GameState
  │
  ├─ Score
  ├─ CurrentWave
  └─ WaveTimer
        ↓
    UpdateHUD()
        ↓
PlayerController
        ↓
   WBP_HUD
        ↓
ScoreText / LevelText / TimeText

이를 통해 게임 내부의 데이터와 플레이어가 실제로 보는 UI를 연결할 수 있었다.


5. Timer를 HUD에 실시간으로 표시하기

: 남은 Wave 시간은 TimerManager에서 현재 Timer의 남은 시간을 가져와 표시했다.

const float RemainingTime =
    GetWorldTimerManager().GetTimerRemaining(WaveTimerHandle);

이 값을 TimeText에 전달한다.

TimeText->SetText(
    FText::FromString(
        FString::Printf(
            TEXT("Time: %.1f"),
            FMath::Max(RemainingTime, 0.0f)
        )
    )
);

%.1f를 사용하여 소수점 첫째 자리까지 표시했다.

Time: 15.0
Time: 14.9
Time: 14.8
...

HUD는 일정 주기로 UpdateHUD()를 호출하도록 Timer를 설정했다.

GetWorldTimerManager().SetTimer(
    HUDUpdateTimerHandle,
    this,
    &ASpartaGameState::UpdateHUD,
    0.1f,
    true
);

따라서 0.1초마다 현재 게임 상태를 확인해 HUD가 갱신된다.

이전에는 Timer를 Wave 종료 조건에만 사용했지만, 이번에는 게임 진행용 Timer와 UI 갱신용 Timer를 각각 활용할 수 있었다.


6. MainMenu 구현

: 게임 실행과 동시에 바로 플레이가 시작되는 대신 별도의 MainMenu를 거쳐 게임에 진입하도록 구성했다.

WBP_MainMenu를 생성하고 다음과 같이 UI를 배치했다.

SPARTA WAVE

[ START GAME ]

StartButtonOn Clicked 이벤트에는 Open Level (by Name)을 연결했다.

On Clicked (StartButton)
        ↓
Open Level (by Name)
        ↓
BasicLevel

따라서 START GAME 버튼을 누르면 실제 플레이가 이루어지는 BasicLevel로 이동한다.

Project Settings에서는 Game Default MapMainMenu로 지정했다.

게임 실행
    ↓
MainMenu Map
    ↓
WBP_MainMenu 표시
    ↓
START GAME 클릭
    ↓
BasicLevel

이 과정을 통해 단순히 테스트 Level에서 플레이하는 것이 아니라 게임 시작 화면에서 실제 Gameplay Level로 진입하는 흐름을 만들 수 있었다.


7. MainMenu의 Input Mode 설정

: MainMenu에서는 캐릭터 조작보다 UI 버튼을 클릭할 수 있어야 한다.

따라서 MainMenu Level Blueprint의 BeginPlay에서 다음과 같이 설정했다.

Event BeginPlay
    ↓
Create WBP_MainMenu Widget
    ↓
Add to Viewport
    ↓
Set Show Mouse Cursor = true
    ↓
Set Input Mode UI Only

Set Input Mode UI Only를 사용하면 키보드와 마우스 입력이 게임 캐릭터가 아니라 UI를 대상으로 동작한다.

또한 버튼을 마우스로 클릭해야 하므로 Mouse Cursor도 표시했다.

MainMenu

Input Mode → UI Only
Mouse Cursor → true

이렇게 MainMenu에서는 UI 조작에 집중하도록 입력 상태를 변경했다.


🚨 트러블슈팅

: 이번 구현에서는 각각의 기능 자체보다 서로 다른 Unreal 시스템을 연결하는 과정에서 발생한 문제를 해결하는 데 시간이 많이 소요되었다.

특히 GameMode, GameState, PlayerController, UMG, Input Mode가 서로 연결되어 있어 한 부분의 설정 문제로 여러 기능이 동시에 동작하지 않는 경우가 있었다.


8. GameMode와 GameState의 상속 관계 문제 🚨

: HUD와 Enhanced Input을 연결한 이후 실행했을 때,

  • 캐릭터 입력이 동작하지 않음
  • HUD가 표시되지 않음

문제가 동시에 발생했다.

처음에는 PlayerController 또는 Enhanced Input 설정 문제라고 생각해 BeginPlay()의 실행 여부와 Input Mapping Context 등록 여부 등을 확인했다.

로그를 추가하면서 실행 흐름을 확인했지만, 실제 원인은 다른 곳에 있었다.

실행 로그에서 다음과 같은 호환성 문제를 확인했다.

Mixing AGameState with AGameModeBase is not compatible.

당시 프로젝트의 구조는 다음과 같았다.

SpartaGameMode
    ↓
AGameModeBase

SpartaGameState
    ↓
AGameState

AGameModeBaseAGameState를 함께 사용하는 구조에서 호환 문제가 발생하고 있었다.

해결 ✅

: SpartaGameMode의 부모 클래스를 AGameModeBase에서 AGameMode로 변경했다.

#include "GameFramework/GameMode.h"

UCLASS()
class SPVOD_P_API ASpartaGameMode : public AGameMode
{
    GENERATED_BODY()
};

수정 후에는 다음과 같은 구조가 되었다.

SpartaGameMode
    ↓
AGameMode

SpartaGameState
    ↓
AGameState

이후 게임을 다시 실행하자 HUD와 플레이어 입력이 정상적으로 동작했다.

» 처음에는 입력과 HUD가 동시에 동작하지 않았기 때문에 각각의 기능에 문제가 있다고 생각했다.

하지만 실제로는 그보다 상위에서 게임의 기본 Framework를 구성하는 GameModeGameState의 관계에 문제가 있었다.

여러 기능이 동시에 동작하지 않는 경우 각각의 기능만 확인하기보다 공통적으로 영향을 주는 상위 구조도 확인해야 한다는 점을 알게 되었다.


9. HUD가 생성되지만 값이 갱신되지 않는 문제 🚨

: GameMode 문제를 해결한 뒤 HUD 자체는 화면에 표시되었지만 Score와 Wave, Time 값이 정상적으로 갱신되지 않는 문제가 발생했다.

C++에서는 다음 이름을 이용해 Widget을 검색하고 있었다.

GetWidgetFromName(TEXT("ScoreText"));
GetWidgetFromName(TEXT("LevelText"));
GetWidgetFromName(TEXT("TimeText"));

처음에는 Is Variable 설정이나 Widget 생성 시점의 문제라고 생각했다.

원인 🔎

: 실제 원인은 WBP_HUD의 Hierarchy에 설정된 Widget 이름과 C++에서 찾고 있는 이름이 일치하지 않았기 때문이었다.

C++에서

TEXT("ScoreText")

를 찾는다고 해서 화면에 표시되는 Text 내용이 Score이면 자동으로 연결되는 것이 아니었다.

UMG Hierarchy에서 실제 Widget의 이름 자체가 정확히 ScoreText여야 했다.

해결 ✅

: WBP_HUD의 Hierarchy 이름을 다음과 같이 수정했다.

ScoreText
LevelText
TimeText

C++에서 참조하는 이름과 정확히 일치시킨 뒤 실행하자 Score, Wave, Time이 정상적으로 실시간 갱신되었다.

» UMG에서 보이는 텍스트의 내용과 Widget 객체의 이름은 서로 다른 개념이라는 점을 확인할 수 있었다.

또한 GetWidgetFromName()을 사용할 경우 C++ 문자열과 UMG Hierarchy 이름이 정확하게 일치해야 한다.


10. MainMenu 이후 캐릭터가 움직이지 않는 문제 🚨

: MainMenu 구현 후 START GAME 버튼을 누르면 BasicLevel은 정상적으로 열렸지만 캐릭터를 조작할 수 없는 문제가 발생했다.

이전에는 정상적으로 동작했던 Enhanced Input이 MainMenu를 추가한 이후 갑자기 동작하지 않았다.

원인 🔎

: MainMenu에서 설정한

Set Input Mode UI Only

상태가 Gameplay Level로 진입한 이후에도 플레이어 입력에 영향을 주고 있었다.

MainMenu에서는 UI를 클릭해야 하므로 UI Only가 적절하지만, 실제 게임에서는 캐릭터가 키보드와 마우스 입력을 받아야 한다.

해결 ✅

: BasicLevel의 Level Blueprint에서 BeginPlay 시 입력 모드를 다시 게임으로 변경했다.

Event BeginPlay
    ↓
Get Player Controller
    ↓
Set Input Mode Game Only
    ↓
Set Show Mouse Cursor = false

따라서 Level별 입력 상태는 다음과 같이 구분했다.

MainMenu
 ├─ Input Mode UI Only
 └─ Mouse Cursor true

        ↓ START GAME

BasicLevel
 ├─ Input Mode Game Only
 └─ Mouse Cursor false

수정 후 MainMenu의 버튼 조작과 BasicLevel의 캐릭터 조작이 모두 정상적으로 동작했다.

» Input Mode는 단순히 마우스 커서를 보여주는 설정이 아니라 현재 플레이어의 입력을 UI와 Gameplay 중 어디에서 처리할 것인지 결정하는 설정이었다.

Level을 전환한다고 해서 항상 원하는 입력 상태가 자동으로 설정되는 것은 아니기 때문에, 게임 흐름에 따라 명시적으로 Input Mode를 변경해 주는 것이 필요했다.


11. 최종 게임 흐름

: 이번 구현을 통해 이전 TIL에서 만든 Multi Wave 시스템에 HUD와 MainMenu를 연결했다.

최종적인 게임 진행 구조는 다음과 같다.

Game Start
    ↓
MainMenu
 ├─ WBP_MainMenu
 ├─ Input Mode UI Only
 └─ Mouse Cursor ON
    ↓
START GAME
    ↓
BasicLevel
 ├─ Input Mode Game Only
 └─ Mouse Cursor OFF
    ↓
WBP_HUD 생성
    ↓
Wave 1
 ├─ Score 실시간 갱신
 ├─ Time 실시간 갱신
 └─ Wave 표시
    ↓
Wave 2
    ↓
Wave 3
    ↓
Game Over

이전에는 GameState, PlayerController, UMG Widget 등을 각각 별개의 기능으로 생각했지만, 이번 구현을 통해 각각의 클래스와 시스템이 게임의 전체 흐름 안에서 어떤 역할을 담당하는지 조금 더 명확하게 이해할 수 있었다.


[학습 후 느낀 점과 개선하고 싶은 점]

: 이번 과제에서는 새로운 문법을 배우는 것보다 Unreal Gameplay Framework의 여러 요소를 연결하는 과정에서 배운 점이 많았다.

특히 다음과 같이 역할을 구분해서 생각할 수 있게 되었다.

GameMode
→ 게임의 기본 규칙과 사용할 클래스 구성

GameState
→ Wave, Score, Timer 등 현재 게임 진행 상태 관리

PlayerController
→ 플레이어 입력 및 HUD 생성/관리

GameInstance
→ Level과 독립적으로 유지할 전역 데이터 관리

UMG
→ 게임 내부 데이터를 플레이어에게 시각적으로 전달

Level Blueprint
→ MainMenu / Gameplay Level에 필요한 입력 상태 전환

가장 인상적이었던 부분은 하나의 설정 오류가 예상보다 넓은 범위에 영향을 줄 수 있다는 점이었다.

입력과 HUD가 동시에 동작하지 않았던 문제의 원인이 GameModeGameState의 상속 관계였고, 정상적으로 구현했던 캐릭터 입력이 MainMenu 추가 이후 동작하지 않았던 원인은 Input Mode였다.

처음에는 문제가 발생한 기능의 코드부터 확인하는 경우가 많았지만, 이번 과정을 통해 현재 기능이 어떤 클래스와 시스템을 거쳐 동작하는지 전체 흐름을 먼저 확인하는 것도 중요하다는 점을 배울 수 있었다.

또한 UMG의 Widget 이름처럼 사소해 보이는 설정 하나도 C++과 Blueprint를 연결하는 과정에서는 중요한 연결 지점이 될 수 있다는 점을 확인했다.

최종적으로는

MainMenu → Gameplay Level → HUD → Multi Wave → Score / Timer

까지 이어지는 하나의 게임 흐름을 완성하면서, 개별 기능 구현에서 한 단계 더 나아가 여러 시스템을 연결하여 게임의 전체적인 진행 구조를 구성하는 과정을 경험할 수 있었다.

0개의 댓글