[DAY56] Shooter Project(6) : Implementing a Dynamic Health Bar

베리투스·2025년 11월 5일
0

Shooter Project

목록 보기
7/10

몬스터 피격 시 머리 위에 체력 바가 나타나는 간단한 기능을 구현하려다 C++과 블루프린트 통신의 온갖 함정에 빠지며 좌절했다. 하지만 이 과정을 통해 이름으로 함수를 찾는 불안정한 방식의 한계와, C++ 부모 클래스를 만들어 상속하는 방식이 왜 가장 확실하고 안정적인 '계약' 관계인지 온몸으로 깨닫게 되었다. 이 포스트는 나의 처절한 실패와 최종적인 성공 기록이다. 😅


📌 목표

  • 몬스터 피격 시 머리 위에 체력 바 UI가 나타나고, 일정 시간 후 사라지게 하기.
  • C++ 클래스와 블루프린트 위젯 간의 가장 안정적인 데이터 통신 방법 학습.
  • C++ 부모 클래스를 만들어 블루프린트가 상속받는 '리페어런팅(Reparenting)' 구조 완벽 이해.

📖 이론

1. C++와 블루프린트 통신, '추측'이 아닌 '계약'으로

C++과 블루프린트가 소통하는 방법은 여러 가지가 있지만, 크게 두 가지 접근법으로 나눌 수 있다.

  • '추측'에 기반한 통신 (내가 실패했던 방식들 💩)

    • C++ 코드에서 FindFunctionByName 이나 FindField 같은 함수를 사용해 블루프린트 내부에 특정 이름의 함수나 변수가 있을 것이라고 '추측'하고 접근하는 방식이다.
    • 장점: 간단한 경우 빠르게 구현할 수 있다.
    • 단점: 이름에 오타가 나거나, 파라미터가 맞지 않거나, 내가 그랬던 것처럼 '이벤트'와 '함수'를 착각해도 컴파일 오류 없이 조용히 실패한다. (값으로 0.0이 넘어가는 등) 이는 디버깅을 지옥으로 만드는 주범이다.
  • '계약'에 기반한 통신 (최종 성공 방식 👍)

    • C++로 기능적 '뼈대(부모 클래스)'를 만들고, 블루프린트가 이를 상속받는 방식이다.
    • C++ 부모 클래스에 UFUNCTION(BlueprintImplementableEvent) 같은 매크로로 함수를 선언하는 것은, "나를 상속받는 자식은 반드시 이 함수를 가지고 있다"'계약'을 맺는 것과 같다.
    • 장점: C++ 코드는 Cast를 통해 위젯이 우리와 '계약'된 놈인지 확인만 하면, 컴파일러가 함수의 존재를 보장해주므로 안전하게 직접 호출할 수 있다. 절대 실패하지 않는다.
    • 단점: 파일을 하나 더 만들어야 하는 약간의 번거로움이 있지만, 안정성이 모든 단점을 씹어먹는다.

💻 코드

MonsterHealthBar.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "MonsterHealthBar.generated.h"

UCLASS()
class SPARTA_TPROJECT_02_API UMonsterHealthBar : public UUserWidget
{
    GENERATED_BODY()

public:
    // C++에서는 호출만, 실제 구현은 블루프린트에서 할 함수
    UFUNCTION(BlueprintImplementableEvent, Category = "Health")
    void UpdateHealthBar(float HealthRatio);
};```

#### AIMonsterBase.h

몬스터 헤더 파일. `UWidgetComponent`와 타이머 관련 멤버들을 선언했다.

```cpp
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Engine/TimerHandle.h" // FTimerHandle을 위해 추가
#include "AIMonsterBase.generated.h"

UCLASS()
class SPARTA_TPROJECT_02_API AAIMonsterBase : public ACharacter
{
    GENERATED_BODY()

public:
    AAIMonsterBase();

protected:
    virtual void BeginPlay() override;

public:
    virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;
    
    // ... (기타 멤버들) ...

protected:
    // 월드에 위젯을 표시할 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "UI")
    class UWidgetComponent* HealthBarWidgetComponent;

    // 체력 바를 숨기기 위한 타이머 핸들
    FTimerHandle HealthBarTimerHandle;

    // 체력 바를 숨기는 함수
    void HideHealthBar();
};

AIMonsterBase.cpp

#include "AIMonsterBase.h"
#include "Components/WidgetComponent.h"
#include "MonsterHealthBar.h" // 우리가 만든 위젯 헤더

AAIMonsterBase::AAIMonsterBase()
{
    // ...
    HealthBarWidgetComponent = CreateDefaultSubobject<UWidgetComponent>(TEXT("HealthBarWidget"));
    HealthBarWidgetComponent->SetupAttachment(GetMesh());
    HealthBarWidgetComponent->SetWidgetSpace(EWidgetSpace::Screen);
    HealthBarWidgetComponent->SetDrawAtDesiredSize(true);
    HealthBarWidgetComponent->SetRelativeLocation(FVector(0.f, 0.f, 200.f));
}

void AAIMonsterBase::BeginPlay()
{
    Super::BeginPlay();
    if (HealthBarWidgetComponent)
    {
        HealthBarWidgetComponent->SetVisibility(false); // 시작 시 숨김
    }
}

void AAIMonsterBase::HideHealthBar()
{
    if (HealthBarWidgetComponent)
    {
        HealthBarWidgetComponent->SetVisibility(false); // 타이머 만료 시 숨김
    }
}

float AAIMonsterBase::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    const float ActualDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);

    if (ActualDamage > 0.0f)
    {
        CurrentHealth -= ActualDamage;
        
        if (HealthBarWidgetComponent)
        {
            HealthBarWidgetComponent->SetVisibility(true); // 피격 시 표시

            // 위젯을 C++ 클래스로 형변환(Cast)
            UMonsterHealthBar* HealthBar = Cast<UMonsterHealthBar>(HealthBarWidgetComponent->GetUserWidgetObject());
            
            // 성공했다면 C++ 함수를 직접 호출 (가장 안전한 방법)
            if (HealthBar)
            {
                float HealthRatio = FMath::Clamp(CurrentHealth / MaxHealth, 0.0f, 1.0f);
                HealthBar->UpdateHealthBar(HealthRatio);
            }

            // 3초 후 숨김 타이머 설정
            GetWorldTimerManager().SetTimer(HealthBarTimerHandle, this, &AAIMonsterBase::HideHealthBar, 3.0f, false);
        }
        // ... (죽음 처리 등) ...
    }
    return ActualDamage;
}

⚠️ 실수

  • ProcessEvent의 오해: C++에서 블루프린트의 '커스텀 이벤트'에 파라미터를 넘기려고 ProcessEvent를 사용했으나 계속 실패했다. ProcessEvent'함수'를 호출할 때 사용하는 것이었고, 파라미터 전달 방식도 내가 생각했던 것보다 훨씬 까다로웠다. 이 잘못된 접근 때문에 며칠을 헤맸다.
  • 헤더 파일 누락: FTimerHandle을 헤더 파일에 선언해놓고, 정작 Engine/TimerHandle.h 헤더를 include하지 않아 컴파일 오류가 발생했다. C++의 기본이지만, 정신없이 코딩하다 보면 놓치기 쉬운 실수다.
  • 잘못된 이름 규칙: MonsterHealthBarWidget.h처럼 클래스 종류를 이름에 또 붙여서 혼란을 자초했다. MonsterHealthBar.h (C++ 뼈대)와 WBP_MonsterHealthBar (블루프린트 결과물)처럼 명확하게 구분하는 것이 훨씬 좋았다.

✅ 핵심 요약

개념설명비고
C++ 위젯 부모 클래스C++로 위젯의 기능적 뼈대를 만들고, 블루프린트는 상속받아 디자인에만 집중하게 하는 방식.가장 안정적이고 확장 가능한, 업계 표준 방식.
BlueprintImplementableEventC++ 헤더에 함수를 선언하고, 실제 구현은 블루프린트의 이벤트 그래프에서 하도록 만드는 UFUNCTION 매크로.C++에서 블루프린트로 단방향 함수 호출이 필요할 때 이상적이다.
리페어런팅(Reparenting)기존 블루프린트의 부모 클래스를 다른 클래스(주로 우리가 만든 C++ 클래스)로 변경하는 작업.위젯 에디터의 그래프 탭 > 클래스 세팅에서 변경할 수 있다. (UE5 기준)
Cast를 통한 안전한 함수 호출GetUserWidgetObject()로 얻은 위젯을 우리가 만든 C++ 클래스 타입으로 Cast한다. 성공하면, 컴파일러가 존재를 보장하는 함수를 직접 호출한다.FindFunctionByName 같은 '이름으로 찾기' 방식보다 100배는 안전하다.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글