TIL_044: HUD, 클린코드

김펭귄·2025년 10월 13일

Today What I Learned (TIL)

목록 보기
44/88

오늘 학습 키워드

  • HUD

  • 클린 코드

1. 구현 목표

  • 이전 프로젝트에 이어서, 아래의 정보를 HUD로 나타내보도록 함
    1. 획득 점수
    2. 스테이지 레벨
    3. 스테이지 잔여 시간

2. UMG (Unreal Motion Graphics)

  • 언리얼 엔진의 Widget Blueprint를 이용한 UI 시스템

  • 다양한 위젯(Text, Button, Image 등)을 사용하여 HUD 제작

Widget Blueprint

  • UI를 시각적으로 설계할 수 있도록 제공하는 에디터용 블루프린트

  • 우측 상단

    • Designer 탭: UI 배치하는 공간
    • Graph 탭: 블루프린트 이벤트 그래프(로직)을 작성하는 공간
  • 좌측 Palette: 배치 가능한 UI 요소들

  • 좌측 하단 Hierarchy: UI 요소들의 계층 구조

  • Screen Size: 현재 만든 UI가 각 기기의 화면마다 어떻게 보이는지 확인 가능

3. HUD 적용

  • 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 업데이트

4. Build.cs 수정

  • 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 모듈을 추가해주기
			
	}
}

  • 하고 플레이어 컨트롤러에서 위젯 클래스를 연결해주면 HUD가 적용된다

5. HUD와 변수 연동

위젯 블루프린트 이용

  • 편리하고 직관적

  • 하지만 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() : FStringFText로 변환. FString 쓰는 이유는 Printf이용해 formating할 때 편함
  • HUD 갱신해주고 싶을 때 UpdateHUD 호출

6. 클린 코드

데이터 뭉치

  • 비슷한 종류의 데이터는 구조체로 묶어 관리하는 것이 사용과 유지보수에 용이

  • 특히나, 함수 매개변수에서 효율적임

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을 사용

  • 언리얼이 제공하는 중앙관리용객체시스템

  • 데이터는 숨겨져있고, 함수로만 접근 가능

  • 언리얼이 자동으로 생성과 초기화를 해주어, 직접 초기화할 필요 없음

Subsytem 공식 문서

// 전역으로 사용하고 싶은 데이터를 캡슐화
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);
        }
    }
}

Shotgun Surgery / Divergent Change

  • 단일책임의 원칙을 벗어났을 때 생기는 일

  • 하나의 기능이 여러 곳에 퍼져있거나, 하나의 객체가 다양한 일을 맡았을 때 코드를 수정하기가 너무 어려워짐

// 데미지 관련 로직이 여러 곳에 퍼져있어, 데미지 하나만 수정하려해도 여러군데를 다 수정해야함
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;
    // 인벤토리 컴포넌트, 퀘스트 컴포넌트 ...
};
profile
반갑습니다

0개의 댓글