컨텐츠 검색
[UE5 C++] UI 시스템 구축 및 파티클과 사운드 연출 (HUD, 메뉴, 애니메이션)

2026. 1. 28. 23:38Unreal Engine/개념

오늘은 게임의 완성도를 높여주는 필수 요소인 UI(User Interface) 시스템을 전반적으로 구축하고, 게임에 몰입감을 더해주는 파티클사운드 효과를 적용하는 방법을 학습했습니다.

단순히 UI를 화면에 띄우는 것을 넘어, 데이터를 실시간으로 연동하고 애니메이션을 통해 생동감을 불어넣는 과정까지 다루었습니다.

1. HUD (Heads-Up Display) 위젯 구현 및 데이터 연동

플레이어에게 점수, 시간, 현재 스테이지 정보를 실시간으로 알려주는 HUD를 제작했습니다.

1-1. UI 디자인 및 계층 구조 설계

먼저 UMG(Unreal Motion Graphics)를 사용하여 WBP_HUD 위젯 블루프린트를 생성하고 전체적인 레이아웃을 잡았습니다. 단순히 텍스트만 띄우는 것이 아니라, HP와 남은 시간(Time)은 시각적으로 직관적인 Progress Bar(게이지 바) 형태로 구현했습니다.

  • 구역 분리: Canvas Panel 위에 Border 위젯 4개(Border_HP, Border_Score, Border_Time, Border_Stage)를 배치하여 각 정보가 표시될 영역을 확실하게 구분했습니다.
  • 복합 레이아웃 (HP & Time):
    • HP와 Time 정보는 '라벨+수치 텍스트'와 '게이지 바'가 위아래로 배치되는 형태입니다.
    • 이를 위해 Vertical Box를 사용하여 위쪽에는 Horizontal Box(텍스트 정렬용), 아래쪽에는 Progress Bar를 배치하는 구조로 짰습니다.
    • HP Bar: 초록색(Fill Color)으로 설정하여 체력을 직관적으로 표현했습니다.
    • Time Bar: 하늘색 계열로 설정하여 제한 시간을 표시했습니다.
  • 레벨(Level) 관리 규칙: 레벨 에셋이 헷갈리지 않도록 프로젝트 내의 모든 레벨 에셋 이름 앞에는 L_ 접두사를 붙여서 통일성 있게 관리하도록 규칙을 정했습니다.


1-2. 데이터 바인딩과 PlayerController

데이터를 UI에 연동하는 방식은 에디터 바인딩과 C++ 함수 호출 방식 두 가지를 모두 실습해보며 장단점을 비교했습니다. 또한, 이 HUD를 화면에 띄우는 역할은 PlayerController가 담당하도록 구현했습니다.

  • 위젯 생성 및 출력: PlayerController 클래스에서 CreateWidget 함수로 WBP_HUD를 생성하고, AddToViewport를 호출하여 화면에 띄웁니다.
// HighScorePlayerController.h
UPROPERTY(Category = "HUD", EditAnywhere, BlueprintReadWrite)
TSubclassOf<UUserWidget> HUDWidgetClass;
// HighScorePlayerController.cpp
#include "Blueprint/UserWidget.h"

if (HUDWidgetClass)
{
    UUserWidget* HUDWidget = CreateWidget<UUserWidget>(this, HUDWidgetClass);
    if (HUDWidget)
    {
        HUDWidget->AddToViewport();
    }
}
  • 에디터 바인딩 (Property Binding): UMG 디자이너 탭에서 Bind 버튼을 눌러 함수를 생성하는 방식으로, 구현은 쉽지만 매 프레임 호출될 수 있어 성능 이슈가 발생할 수 있습니다.

  • C++ SetText 방식 (Push Model): GameState나 PlayerController에서 데이터가 변할 때만 UI를 갱신해주는 방식입니다.
// HighScoreGameState.cpp
#include "Character/HighScorePlayerController.h"
#include "Components/TextBlock.h"
#include "Blueprint/UserWidget.h"

void AHighScoreGameState::UpdateHUD()
{
    if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController())
    {
        AHighScorePlayerController* HighScorePlayerController = Cast<AHighScorePlayerController>(PlayerController);
        {
            if (UUserWidget* HUDWidget = HighScorePlayerController->GetHUDWidget())
            {
                // 1. 캐릭터 정보를 가져와서 HP 갱신
                AHighScoreCharacter* MyCharacter = Cast<AHighScoreCharacter>(PlayerController->GetPawn());
                if (MyCharacter) 
                {
                    if (UTextBlock* HPText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("HP"))))
                    {
                        HPText->SetText(FText::FromString(FString::Printf(TEXT("%d"), (int32)MyCharacter->GetHealth())));
                    }

                    if (UProgressBar* HPBar = Cast<UProgressBar>(HUDWidget->GetWidgetFromName(TEXT("PB_HP"))))
                    {
                        float MaxHealth = 100.0f;
                        if (MaxHealth > 0.0f)
                        {
                            float HPRatio = MyCharacter->GetHealth() / MaxHealth;
                            HPBar->SetPercent(HPRatio);
                        }
                    }
                }

                // 2. 남은 시간(Timer) 갱신
                if (UTextBlock* TimeText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Time"))))
                {
                    float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
                    TimeText->SetText(FText::FromString(FString::Printf(TEXT("%.1f"), RemainingTime)));
                }

                if (UProgressBar* TimeProgressBar = Cast<UProgressBar>(HUDWidget->GetWidgetFromName(TEXT("PB_Time"))))
                {
                    float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
                    // 남은 시간 비율 계산 (0.0 ~ 1.0)
                    if (LevelDuration > 0.f)
                    {
                        float Percent = RemainingTime / LevelDuration;
                        TimeProgressBar->SetPercent(Percent);
                    }
                }

                // 3. 점수(Score) 갱신 - GameInstance 데이터 활용
                if (UTextBlock* ScoreText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Score"))))
                {
                    if (UGameInstance* GameInstance = GetGameInstance())
                    {
                        UHighScoreGameInstance* HighScoreGameInstance = Cast<UHighScoreGameInstance>(GameInstance);
                        if (HighScoreGameInstance)
                        {
                            ScoreText->SetText(FText::FromString(FString::Printf(TEXT("SCORE: %d"), HighScoreGameInstance->TotalScore)));
                        }
                    }
                }

                // 4. 스테이지(Stage) 갱신
                if (UTextBlock* LevelIndexText = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Stage"))))
                {
                    LevelIndexText->SetText(FText::FromString(FString::Printf(TEXT("STAGE %d"), CurrentLevelIndex + 1)));
                }
            }
        }
    }
}

저는 성능 최적화를 고려하여 UpdateHUD 함수를 만들고, 점수나 시간이 변할 때만 SetText로 텍스트를 업데이트하도록 구현했습니다.


2. 게임 메뉴 시스템 구축

게임 시작 전 대기 화면인 '메인 메뉴'를 구현하고, 레벨 간의 흐름을 제어했습니다.

  • 메뉴 전용 레벨: 게임 플레이 레벨과 분리하여 메뉴만 띄워주는 L_MenuLevel을 생성하고 프로젝트 세팅에서 기본 맵으로 설정했습니다.
  • WBP_MainMenu: 화면 중앙에 Start 버튼 하나를 배치했습니다.
  • 이벤트 바인딩 & 레벨 전환: 버튼의 OnClicked 이벤트에 함수를 바인딩하여, 클릭 시 OpenLevel 함수를 호출해 게임 레벨(L_BasicLevel)로 넘어가도록 로직을 작성했습니다.
  • Input Mode 전환: 메뉴 화면에서는 마우스 커서 사용이 필요하므로 SetInputModeUIOnly를 사용했고, 게임이 시작되면 SetInputModeGameOnly로 전환하는 로직을 Controller에 추가했습니다.


3. UI 애니메이션 효과 (Game Over)

단순히 텍스트가 뜨는 것이 아니라, 극적인 연출을 위해 UI 애니메이션을 적용했습니다.

  • 상황: 캐릭터가 사망했을 때 "Game Over!" 텍스트가 출력되도록 했습니다.
  • Keyframe 설정: 타임라인을 사용하여 Render Opacity(투명도) 값을 조절했습니다. 0(안 보임)에서 시작해 0.2와 1을 오가며 깜빡이는 효과를 주어 긴박감을 더했습니다.
  • 블루프린트 재생 함수: 애니메이션 로직을 매번 구현하지 않고, 블루프린트 내에서 재생 함수로 묶어두어 C++이나 다른 로직에서 쉽게 호출할 수 있게 만들었습니다.
  • 추가로 게임 오버 시 최종 점수(Total Score)도 함께 표시되도록 설정했습니다.


4. 3D 월드 위젯 (HP Bar)

HUD처럼 화면에 고정된 2D UI가 아니라, 게임 월드 내 캐릭터에 떠 있는 3D UI를 구현했습니다.

  • WidgetComponent: 캐릭터 클래스(C++)에 UWidgetComponent를 추가하여 구현했습니다.
  • Space 설정: World 모드가 아닌 Screen 모드로 설정하여, 월드 상의 위치에 존재하지만 항상 카메라를 바라보며 2D처럼 렌더링되도록 조정했습니다.

5. 게임 효과 연출 (Particles & Sound)

게임의 몰입감을 위해 시각 효과와 청각 효과를 추가했습니다.

  • 아이템 획득: 아이템과 상호작용(Overlap) 시 반짝이는 파티클과 획득 효과음을 재생했습니다.
  • 지뢰 폭발: 지뢰를 밟았을 때 폭발 파티클(P_Explosion)과 함께 펑 터지는 사운드를 재생했습니다.
  • 구현 방법: UGameplayStatics 클래스의 유틸리티 함수들을 활용했습니다.
// 파티클 재생
    if (ExplosionParticle)
    {
        Particle = UGameplayStatics::SpawnEmitterAtLocation(
            GetWorld(),
            ExplosionParticle,
            GetActorLocation(),
            GetActorRotation(),
            false
        );
    });

// 사운드 재생
if (ExplosionSound)
{
    UGameplayStatics::PlaySoundAtLocation(
        GetWorld(),
        ExplosionSound,
        GetActorLocation()
    );
}


마무리

오늘은 UI부터 이펙트까지 게임의 "껍데기"와 "알맹이"를 연결하는 중요한 작업들을 수행했습니다. 특히 UI 데이터를 다룰 때 매 프레임 갱신하는 것보다, 이벤트 기반으로 갱신(UpdateHUD)하는 것이 성능상 유리하다는 점을 확실히 익혔습니다. 이제 게임다운 모습이 제대로 갖춰지고 있는 것 같습니다.