[DAY38] Unreal Engine C++ 3D Game Dev(4)

베리투스·2025년 9월 25일

TIL: Today I Learned

목록 보기
39/93

오늘은 UMG (Unreal Motion Graphics)를 사용하여 게임 시작을 위한 메인 메뉴, 실시간 정보를 보여주는 인게임 HUD, 그리고 게임 오버 화면까지 전체 UI 흐름을 구축했다. C++의 데이터를 UI 위젯에 연결하는 방법을 배우고, 캐릭터의 머리 위에는 3D 위젯으로 체력 바를 띄웠다. 마지막으로 아이템 획득이나 지뢰 폭발 시 파티클과 사운드 이펙트를 추가하여, 게임을 훨씬 생동감 있고 완성도 높게 만들었다. ✨


📌 목표

  • UI(User Interface)와 HUD(Head-Up Display)의 개념 이해하기
  • UMG를 사용하여 위젯 블루프린트(Widget Blueprint) 생성 및 디자인하기
  • C++ 데이터를 UI 텍스트에 효율적으로 연동하기 (SetText 방식)
  • 메인 메뉴 → 인게임 HUD → 게임 오버로 이어지는 전체 UI 흐름 구현하기
  • 3D 위젯 컴포넌트를 이용해 캐릭터 머리 위에 체력 바 만들기
  • 아이템 상호작용 시 파티클(Particle)사운드(Sound) 효과 추가하기

📖 이론

1. UMG (Unreal Motion Graphics)와 위젯 블루프린트

언리얼 엔진에서 UI를 만드는 현대적이고 강력한 시스템이다. 위젯 블루프린트라는 에셋을 통해 UI를 만드는데, 내부적으로 두 개의 큰 탭으로 나뉜다.

  • 디자이너(Designer) 탭: 텍스트, 버튼, 이미지, 프로그레스 바 같은 UI 요소들을 드래그 앤 드롭으로 배치하여 시각적인 디자인을 하는 곳이다.
  • 그래프(Graph) 탭: 일반 블루프린트처럼 노드를 연결하여 UI의 로직을 구현하는 곳이다. 예를 들어 '버튼을 클릭하면 C++ 함수를 호출해라' 같은 로직을 여기서 짠다.

2. UI 흐름과 PlayerController

UI를 생성하고, 화면에 띄우고, 없애는 모든 관리는 PlayerController에서 하는 것이 정석이다. PlayerController는 플레이어와 게임 세계를 잇는 다리 역할을 하므로, 플레이어가 보게 될 화면(UI)을 제어하기에 가장 적합한 장소다.

  • CreateWidget: 위젯 블루프린트 클래스로부터 실제 화면에 띄울 위젯 인스턴스를 생성한다.
  • AddToViewport: 생성된 위젯 인스턴스를 화면에 보이게 한다.
  • RemoveFromParent: 화면에 떠 있는 위젯을 제거한다.
  • SetInputMode: 입력 모드를 변경한다. 메뉴를 띄웠을 때는 마우스 커서가 보이고 UI만 조작할 수 있도록 UIOnly 모드로, 게임 중일 때는 캐릭터만 조작할 수 있도록 GameOnly 모드로 설정한다.

3. C++ 데이터와 UI 연동: SetText 방식

UI의 텍스트(점수, 시간 등)는 게임 데이터가 변할 때마다 실시간으로 갱신되어야 한다. 방법은 크게 두 가지가 있는데, 매 프레임마다 UI가 데이터를 '가져오는' 바인딩 방식은 비효율적일 수 있다. 우리는 더 효율적인, 이벤트 기반의 SetText 방식을 사용했다.

  • 흐름: GameState(데이터 소유자)에서 점수가 변경된다 → GameState가 PlayerController를 통해 HUD 위젯의 UpdateScore 같은 함수를 호출한다 → 그 함수 안에서 해당 텍스트 블록의 내용을 SetText 노드로 직접 바꿔준다.
  • 장점: 데이터가 변경될 때만 UI가 업데이트되므로 성능 부하가 훨씬 적다.

4. 3D 위젯 컴포넌트 (3D Widget Component)

일반적인 UI는 화면(스크린) 공간에 2D로 덧씌워지지만, 이 컴포넌트를 사용하면 UI 위젯을 3D 월드 공간 자체에 배치할 수 있다. 캐릭터의 머리 위에 뜨는 체력 바, NPC의 이름표 등이 대표적인 예다. Actor에 컴포넌트로 추가하고, WidgetClass에 보여줄 위젯 블루프린트를 지정해주면 된다.

5. 파티클과 사운드 스폰

게임에 생동감을 더하는 시청각 효과는 주로 GameplayStatics 라이브러리의 함수를 통해 구현한다.

  • SpawnEmitterAtLocation: 지정된 위치에 파티클 이펙트를 생성한다. "Fire and Forget" 방식이라 한번 생성되면 이펙트 스스로의 생명주기에 따라 동작하고 사라지며, 생성한 액터가 사라져도 영향을 받지 않는다.
  • PlaySoundAtLocation: 지정된 위치에 사운드를 재생한다. 이 또한 "Fire and Forget" 방식이다.

💻 코드

SpartaPlayerController.h (UI 관리의 중심)

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "SpartaPlayerController.generated.h"

UCLASS()
class SPARTAPROJECT_API ASpartaPlayerController : public APlayerController
{
	GENERATED_BODY()

protected:
	// 1. 블루프린트에서 설정할 위젯 클래스들 (붕어빵 틀)
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI")
	TSubclassOf<class UUserWidget> HUDWidgetClass;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "UI")
	TSubclassOf<class UUserWidget> MainMenuWidgetClass;

	// 2. 실제 생성된 위젯 인스턴스들 (붕어빵)
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "UI")
	UUserWidget* HUDWidgetInstance;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "UI")
	UUserWidget* MainMenuWidgetInstance;

	bool bIsRestart = false; // 재시작 상태인지 확인하는 변수

public:
	// 3. UI를 띄우거나 숨기는 함수들
	UFUNCTION(BlueprintCallable)
	void ShowMainMenu(bool IsRestart);
	
	UFUNCTION(BlueprintCallable)
	void ShowGameHUD();
	
	UFUNCTION(BlueprintCallable)
	void StartGame();
};

SpartaPlayerController.cpp (메뉴/HUD 전환 로직)

#include "SpartaPlayerController.h"
#include "Blueprint/UserWidget.h"
#include "Kismet/GameplayStatics.h"
#include "SpartaGameInstance.h"

using namespace std;

void ASpartaPlayerController::ShowMainMenu(bool IsRestart)
{
	// 1. 기존 HUD가 있다면 제거
	if (HUDWidgetInstance)
	{
		HUDWidgetInstance->RemoveFromParent();
		HUDWidgetInstance = nullptr;
	}

	// 2. 메인 메뉴 위젯 생성 및 화면에 추가
	if (MainMenuWidgetClass)
	{
		MainMenuWidgetInstance = CreateWidget<UUserWidget>(this, MainMenuWidgetClass);
		MainMenuWidgetInstance->AddToViewport();
	}

	bIsRestart = IsRestart;

	// 3. 입력 모드를 UI 전용으로 바꾸고 마우스 커서 보이기
	FInputModeUIOnly InputMode;
	SetInputMode(InputMode);
	bShowMouseCursor = true;
	SetPause(true); // 게임 일시정지
}

void ASpartaPlayerController::ShowGameHUD()
{
	// 1. 기존 메인 메뉴가 있다면 제거
	if (MainMenuWidgetInstance)
	{
		MainMenuWidgetInstance->RemoveFromParent();
		MainMenuWidgetInstance = nullptr;
	}

	// 2. HUD 위젯 생성 및 화면에 추가
	if (HUDWidgetClass)
	{
		HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
		HUDWidgetInstance->AddToViewport();
	}

	// 3. 입력 모드를 게임 전용으로 바꾸고 마우스 커서 숨기기
	FInputModeGameOnly InputMode;
	SetInputMode(InputMode);
	bShowMouseCursor = false;
	SetPause(false); // 게임 재개
}

void ASpartaPlayerController::StartGame()
{
	// 게임 재시작 시 게임 인스턴스의 데이터 초기화
	if (USpartaGameInstance* GameInstance = Cast<USpartaGameInstance>(GetGameInstance()))
	{
		GameInstance->ResetGameData();
	}
	
	// 게임 레벨(Basic) 로드
	UGameplayStatics::OpenLevel(GetWorld(), FName("Basic"));
}

SpartaCharacter.cpp (3D 체력 바 UI 업데이트)

#include "SpartaCharacter.h"
#include "Components/WidgetComponent.h"
#include "Blueprint/UserWidget.h"
#include "Components/TextBlock.h"

ASpartaCharacter::ASpartaCharacter()
{
	// ... (기존 코드 생략) ...
	// 1. 3D 위젯 컴포넌트 생성 및 설정
	OverheadHPBar = CreateDefaultSubobject<UWidgetComponent>(TEXT("OverheadHPBar"));
	OverheadHPBar->SetupAttachment(GetMesh());
	OverheadHPBar->SetWidgetSpace(EWidgetSpace::Screen); // 항상 화면을 바라보도록 설정
	OverheadHPBar->SetDrawSize(FVector2D(150, 30));
}

void ASpartaCharacter::BeginPlay()
{
	Super::BeginPlay();
	// 2. 게임 시작 시 HP UI 초기화
	UpdateOverheadHP();
}

// 3. 체력이 변경될 때마다(데미지, 힐링) 호출될 함수
void ASpartaCharacter::UpdateOverheadHP()
{
	if (OverheadHPBar)
	{
		// 4. 위젯 컴포넌트로부터 실제 유저 위젯을 가져와서 텍스트 블록을 찾고 내용 업데이트
		UUserWidget* HPWidget = OverheadHPBar->GetUserWidgetObject();
		if (HPWidget)
		{
			UTextBlock* HPText = Cast<UTextBlock>(HPWidget->GetWidgetFromName("OverheadHP"));
			if (HPText)
			{
				FString HPString = FString::Printf(TEXT("%d / %d"), (int32)Health, (int32)MaxHealth);
				HPText->SetText(FText::FromString(HPString));
			}
		}
	}
}

float ASpartaCharacter::TakeDamage(...)
{
	// ... (데미지 처리 로직) ...
	UpdateOverheadHP(); // 5. 데미지 입으면 HP UI 업데이트
	return ActualDamage;
}

MineItem.cpp (파티클 & 사운드 이펙트)

#include "MineItem.h"
#include "Kismet/GameplayStatics.h"

using namespace std;

void AMineItem::Explode()
{
	// 1. 중복 폭발 방지
	if (bHasExploded) return;
	bHasExploded = true;

	// 2. 폭발 파티클 스폰 (위치, 회전, 크기, 자동 소멸)
	if (ExplosionParticle)
	{
		UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ExplosionParticle, GetActorLocation(), GetActorRotation(), FVector(2.f), true);
	}
	
	// 3. 폭발 사운드 재생
	if (ExplosionSound)
	{
		UGameplayStatics::PlaySoundAtLocation(GetWorld(), ExplosionSound, GetActorLocation());
	}
	
	// ... (데미지 처리 로직) ...

	DestroyItem();
}

⚠️ 실수

  • UMG 모듈 추가 안 함: UI 관련 코드를 작성하고 빌드했더니 알 수 없는 링크 에러가 발생했다. 원인은 프로젝트의 [ProjectName].Build.cs 파일에 "UMG" 모듈을 추가하지 않아서였다. UI 기능을 사용하려면 엔진에 알려줘야 한다는 것을 배웠다.
  • UI가 화면 크기에 따라 깨지는 문제: 에디터 뷰포트에서 바로 플레이하니 UI가 찌그러져 보였다. 플레이 버튼 옆 드롭다운 메뉴에서 "New Editor Window"를 선택하고, 해상도를 UI 디자인 시점(1920x1080)과 동일하게 맞춰주니 깨끗하게 나왔다. UI 테스트는 정해진 해상도에서 하는 것이 중요하다.
  • 게임오버 후에도 게임이 계속 돌아가는 문제: 게임오버 UI만 띄우고 끝냈더니, 뒤에서는 캐릭터가 계속 움직일 수 있는 상태였다. PlayerControllerSetPause(true) 함수를 호출하여 게임 월드의 시간을 멈춰야 완벽한 게임오버 상태가 된다는 것을 깨달았다.
  • 루프 파티클이 사라지지 않는 문제: 아이템을 줍고 아이템 액터는 Destroy했는데, 픽업 효과로 스폰한 파티클이 계속 루프 재생되며 화면에 남아있었다. SpawnEmitterAtLocation으로 생성된 파티클은 독립적인 존재라는 것을 이해하고, 타이머와 람다 함수를 이용해 몇 초 뒤에 파티클 컴포넌트를 직접 파괴하는 로직을 추가하여 해결했다.

✅ 핵심 요약

개념설명비고
UMG언리얼 엔진의 UI 디자인 시스템.Widget Blueprint를 통해 시각적으로 디자인한다.
PlayerControllerUI의 생성, 제거, 표시 전환 등 UI 흐름을 관리하는 주체.CreateWidget, AddToViewport 함수가 핵심.
SetText 방식C++에서 데이터가 변경될 때만 UI를 갱신하는 효율적인 이벤트 기반 방식.매 프레임 UI를 업데이트하는 바인딩 방식보다 권장됨.
Input Mode플레이어의 입력을 UI에 집중시킬지(UIOnly), 게임에 집중시킬지(GameOnly) 결정.메뉴 화면과 인게임 화면 전환 시 필수.
3D 위젯 컴포넌트UI를 2D 화면이 아닌 3D 월드 공간에 배치하는 컴포넌트.캐릭터 체력 바, 이름표 등에 사용.
SetPause게임의 시간을 멈추거나 재개하는 함수.게임오버, 일시정지 메뉴 구현에 필수.
SpawnEmitter/Sound"Fire and Forget" 방식으로 시청각 효과를 생성하는 함수.생성 주체와 독립적으로 동작한다.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글