HUD
클린 코드

언리얼 엔진의 Widget Blueprint를 이용한 UI 시스템
다양한 위젯(Text, Button, Image 등)을 사용하여 HUD 제작

UI를 시각적으로 설계할 수 있도록 제공하는 에디터용 블루프린트
우측 상단
좌측 Palette: 배치 가능한 UI 요소들
좌측 하단 Hierarchy: UI 요소들의 계층 구조
Screen Size: 현재 만든 UI가 각 기기의 화면마다 어떻게 보이는지 확인 가능
Widget(UI, HUD)은 플레이어의 입력과 상호작용하며 화면에 표시되는 요소
PlayerController에서 입력을 받고, 처리를 해주므로 HUD도 여기에 배치
// MyPlayerController.h
UCLASS()
class SPARTPROJECT_API AMyPlayerController : public APlayerController
{
// ... //
public:
// 생성할 위젯의 클래스를 가져옴
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
TSubclassOf<UUserWidget> HUDWidgetClass;
// HUD클래스로 생성한 인스턴스
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "HUD")
UUserWidget* HUDWidgetInstance;
// 추후에 GameState에서 HUD를 가져오기 위한 get함수
UFUNCTION(BlueprintCallable, Category = "HUD")
UUserWidget* GetHUDWidget() const;
};
// MyPlayerController.cpp
#include "Blueprint/UserWidget.h" // Widget class
#include "MyGameState.h"
void AMyPlayerController::BeginPlay()
{
// ... //
if (HUDWidgetClass)
{
HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
if (HUDWidgetInstance)
{
HUDWidgetInstance->AddToViewport();
if (AMyGameState* MyGameState = GetWorld()->GetGameState<AMyGameState>())
{
MyGameState->UpdateHUD();
}
}
}
}
create : UI 인스턴스를 생성하는 함수this : 위젯의 소유자, 즉 플레이어 컨트롤러HUDWidgetClass : 해당 클래스로 위젯 생성TSubclassOf를 통해 위젯클래스를 멤버변수로 가져서 이 클래스를 이용하여 동적으로 인스턴스를 생성함AddToViewport : 위젯을 뷰포트에 추가(렌더링). 화면에 보이게UpdateHUD : 생성했으니, HUD 업데이트CreateWidget이 작동하려면 Build.cs 수정해야함
컴파일 시 어떤 모듈을 포함할지 모듈의 규칙을 정의해놓은 파일
// Project.Build.cs
using UnrealBuildTool;
public class SpartProject : ModuleRules
{
public SpartProject(ReadOnlyTargetRules Target) : base(Target)
{
// Public인 종속 모듈들. 이 프로젝트에서 사용하는 필수 기능들
PublicDependencyModuleNames.AddRange(new string[] {
"Core", // 엔진 기본 기능
"CoreUObject", // 리플렉션, 가비지컬렉터 등
"Engine", // 게임 엔진 기능
"InputCore", // 입력 시스템
"EnhancedInput", // 향상된 입력 시스템
"UMG"}); // UMG 모듈을 추가해주기
}
}


편리하고 직관적
하지만 Tick함수를 이용해 계속 갱신하는 것이므로 좋지 않음
따라서 값을 갱신해야 할 때만 동작하게끔 직접 코드로 구현하는 것이 좋음
우린 타이머도 있어서 0.1초 간격으로 갱신할 것
HUD는 현재 플레이어 컨트롤러에 있지만, HUD의 갱신은 GameLoop가 구현되어있는 GameState에서 해줌
// MyGameState.cpp
#include "Blueprint/UserWidget.h" // UserWidget
#include "Components/TextBlock.h" // UI 요소
void AMyGameState::BeginPlay()
{
// ... //
StartLevel();
// 레벨 시작 후 0.1초마다 HUD 업데이트
GetWorldTimerManager().SetTimer(
HUDUpdateTimerHandle,
this,
&AMyGameState::UpdateHUD,
0.1f,
true
);
}
void AMyGameState::UpdateHUD()
{
// 컨트롤러에 존재하는 HUD 가져오기
if (APlayerController* PlayerController = GetWorld()->GetFirstPlayerController()) {
if (AMyPlayerController* MyPlayerController = Cast<AMyPlayerController>(PlayerController)) {
if (UUserWidget* HUDWidget = MyPlayerController->GetHUDWidget()) {
if (UTextBlock* TimeBlock = Cast<UTextBlock>(HUDWidget->GetWidgetFromName(TEXT("Time"))))
{
float RemainingTime = GetWorldTimerManager().GetTimerRemaining(LevelTimerHandle);
TimeBlock->SetText(FText::FromString(FString::Printf(TEXT("Time : %.1f"), RemainingTime)));
}
// 나머지, 레벨과 점수는 비슷하게 구현
// ... //
}
GetWidgetFromName : 이름으로부터 HUD의 위젯 가져오기
GetTimerRemaining : 타이머 남은 시간 가져오기
SetText : 텍스트 블록의 텍스트 설정. FText만 받음
FText::FromString() : FString을 FText로 변환. FString 쓰는 이유는 Printf이용해 formating할 때 편함HUD 갱신해주고 싶을 때 UpdateHUD 호출

비슷한 종류의 데이터는 구조체로 묶어 관리하는 것이 사용과 유지보수에 용이
특히나, 함수 매개변수에서 효율적임
void InitWeapon(FString Name, float Damage, float FireRate, int32 AmmoCount,
float ReloadTime, USkeletalMesh* Mesh, USoundBase* Sound) {}
// 매개인자가 뭐가 뭔지도 헷갈림
InitWeapon("AK47", 42.0f, 0.25f, 30, 2.5f, MeshAsset, FireSound);
매개변수가 많으면 함수를 이해하는 것도, 쓰기도 어려움
비슷한 데이터끼리 구조체로 묶어, 간결하고 이해하고 쓰기도 쉽게 함
USTRUCT(BlueprintType)
struct FWeaponData
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite) // 이후 매크로는 생략
FString Name;
float Damage;
float FireRate;
int32 AmmoCount;
float ReloadTime;
};
USTRUCT(BlueprintType)
struct FWeaponAssets
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite) // 이후 매크로는 생략
USkeletalMesh* Mesh;
USoundBase* Sound;
};
// 깔끔하고 이해도 쉬움
void InitWeapon(const FWeaponData& InData, const FWeaponAssets& InAssets) {}
// 사용은 아래와 같이
FWeaponData WeaponInfo = { "AK47", 42.0f, 0.25f, 30, 2.5f };
FWeaponAssets Assets = { MeshAsset, FireSound };
InitWeapon(WeaponInfo, Assets);
전역변수는 사용 금물. 디버깅, 유지보수가 어려움
꼭 필요하다면, 좀 더 안전한 언리얼의 Subsystem을 사용
언리얼이 제공하는 중앙관리용객체시스템
데이터는 숨겨져있고, 함수로만 접근 가능
언리얼이 자동으로 생성과 초기화를 해주어, 직접 초기화할 필요 없음
// 전역으로 사용하고 싶은 데이터를 캡슐화
UCLASS()
class UScoreSystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
private:
int32 Score; // Score는 private으로
public:
// 함수를 통해서만 접근 가능
void AddScore(int32 Amount)
{
Score += Amount;
// 점수가 변경됐음을 알리는 로직
}
int32 GetScore() const { return Score; }
};
// 사용
void AEnemy::OnDefeated()
{
if (UGameInstance* GI = GetGameInstance())
{
// GetSubsystem<UScoreSystem>() 쓰는 곳만 접근 가능
if (UScoreSystem* ScoreSys = GI->GetSubsystem<UScoreSystem>())
{
ScoreSys->AddScore(50);
}
}
}
단일책임의 원칙을 벗어났을 때 생기는 일
하나의 기능이 여러 곳에 퍼져있거나, 하나의 객체가 다양한 일을 맡았을 때 코드를 수정하기가 너무 어려워짐
// 데미지 관련 로직이 여러 곳에 퍼져있어, 데미지 하나만 수정하려해도 여러군데를 다 수정해야함
class APlayerCharacter : public ACharacter
{
public:
void TakeDamage(float Amount) { // 데미지 로직 1 }
};
class AWeapon : public AActor
{
public:
float CalculateDamage() { // 데미지 로직 2 }
};
class AMyGameMode : public AGameModeBase
{
public:
void UpdateDamageLeaderboard() { // 데미지 로직 3 }
};
// GameManager가 서로 다른 일들도 하고 있어, 기능 수정 시 다른 로직과 엉킬수 있음
class AGameManager : public AActor
{
public:
// (1) 데이터 관련
void LoadPlayerData();
void SavePlayerData();
// (2) 게임플레이 관련
void StartNewGame();
void SpawnEnemies();
};
// 데미지 관련 로직을 하나의 시스템으로 묶기
class UDamageSystem : public UObject
{
public:
float CalculateDamage();
void ApplyDamage();
void UpdateDamageLeaderboard();
};
// GameManager도 기능에 따라 모듈화 하기
class UPlayerDataManager : public UGameInstanceSubsystem
{
public:
void LoadPlayerData();
void SavePlayerData();
};
class UGameplayManager : public UGameInstanceSubsystem
{
public:
void StartNewGame();
void SpawnEnemies();
};

자기 객체보다 남의 객체 기능이나 데이터를 더 많이 사용하는 경우
그 함수를 데이터가 있는 곳으로 옮겨 의존성을 줄인다
class UDamageCalculator : public UObject
{
public:
float CalculateDamageReduction(AMyCharacter* Character, float Damage)
{
// Character의 정보를 많이 사용
float HealthPercent = Character->GetHealth() / Character->GetMaxHealth();
float ArmorFactor = Character->GetArmor() * 0.1f;
// ... //
}
};
class AMyCharacter : public ACharacter
{
public:
float CalculateDamageReduction(float Damage) const
{ // ... //
}
};
class UDamageCalculator : public UObject
{
public:
float CalculateDamageReduction(AMyCharacter* Character, float Damage)
{
return Character->CalculateDamageReduction(Damage);
}
};
복잡한 데이터를 단순한 기본형 (int, string)에 과도하게 의존하는 경향
예를 들어, 주민등록번호를 string형으로만 사용 시, 주민등록번호에 들어있는 정보를 string은 표현하지 못 함
체력의 경우도 최대체력이 있고 음수는 불가능하지만 이를 float형은 표현하지 못 함
매번 유효성 검사를 해주는 것보단, 차라리 클래스를 이용해 해결
// 체력 클래스
class FHealth
{
public:
FHealth(float InCurrent, float InMax)
: Current(FMath::Clamp(InCurrent, 0.f, InMax)), Max(InMax) {}
void ApplyDamage(float Amount)
{
Current = FMath::Max(0.f, Current - Amount);
}
float Get() const { return Current; }
private:
float Current;
float Max;
};
// FHealth 사용
class AMyCharacter : public ACharacter
{
public:
FHealth Health = FHealth(100.f, 100.f);
void TakeHit(float Damage)
{
Health.ApplyDamage(Damage);
}
};
for문, while문은 코드를 보고 바로 이해하기가 쉽지 않음
조건 하나만 바꾸려고 해도 코드를 다 읽고 이해하는 노력이 필요함
// 무거운 아이템의 총 무게에 따라 이펙트 적용시키는 함수
void ProcessHeavyItems()
{
TArray<UItem*> Items = GetAllItems();
TArray<UItem*> HeavyItems;
for (int32 i = 0; i < Items.Num(); i++) {
if (Items[i]->Weight > 10.f)
HeavyItems.Add(Items[i]);
}
float TotalWeight = 0.f;
for (int32 j = 0; j < HeavyItems.Num(); j++) {
TotalWeight += HeavyItems[j]->Weight;
}
if (TotalWeight > 50.f) {
ApplySlowEffect();
}
}
void ProcessHeavyItems()
{
TArray<UItem*> Items = GetAllItems();
TArray<UItem*> HeavyItems = GetHeavyItems(Items);
float TotalWeight = GetTotalWeight(HeavyItems);
if (IsTooHeavy(TotalWeight))
ApplySlowEffect();
}
void APlayer::PlayWeaponSound()
{
if (Inventory
&& Inventory->EquippedWeapon
&& Inventory->EquippedWeapon->SoundData
&& Inventory->EquippedWeapon->SoundData->AttackSound) {
UGameplayStatics::PlaySound2D(this,
Inventory->EquippedWeapon->SoundData->AttackSound);
}
}
객체를 줄줄이 호출하면 내부 구조가 노출돼 결합도 커짐
각 객체의 내부 구조를 다 알아야하는 문제가 생김
의존성을 낮추도록 구현해야 함
void APlayer::PlayWeaponSound()
{
// 플레이어는 인벤토리에게만 요청. 나머지 내부 구조는 몰라도 됨
USoundBase* AttackSound = Inventory ? Inventory->GetAttackSound() : nullptr;
}
// 인벤토리는 무기한테만 요청
USoundBase* UInventoryComponent::GetAttackSound()
{
if (!EquippedWeapon) return nullptr;
return EquippedWeapon->GetAttackSound();
}
// 무기가 사운드를 반환
USoundBase* AWeapon::GetAttackSound()
{
return SoundData ? SoundData->AttackSound : nullptr;
}

class AGameCharacter : public ACharacter
{
public:
// 이동 처리
void MoveForward(float Value);
void MoveRight(float Value);
// 전투 처리
// 인벤토리 처리
// 퀘스트 처리
// ... //
};
항상 최대한 쪼개고 쪼갠 부품들을 조립하자
특히 언리얼은 컴포넌트를 이용하는 것을 추천
class AGameCharacter : public ACharacter
{
public:
AGameCharacter();
// 핵심 동작만 유지, 나머지는 컴포넌트에 맡김
private:
UPROPERTY()
UMovementComponent* MovementComp;
UPROPERTY()
UCombatComponent* CombatComp;
// 인벤토리 컴포넌트, 퀘스트 컴포넌트 ...
};