우리 팀 프로젝트에서는 위 3가지의 스탯 + 부위 별 체력 스탯을 담당하고 있기 때문에 어제 만들었던 마스터 머터리얼 기반으로 디자인을 해보려고 한다.
먼저 위 3가지 스탯은 스크린 중 HUD의 부속품이기 때문에 다음과 같은 관계를 지니게 된다.

// PDAttributeBar.h
UCLASS(Abstract, BlueprintType, meta = (DisableNativeTick))
class PROJECTD_API UPDAttributeBarWidget : public UUserWidget
{
GENERATED_BODY()
public:
// 스탯이 변동될 때 델리게이트 콜백 함수
UFUNCTION(BlueprintCallable, Category = "PD|HUD")
void SetValues(float Current, float Max);
protected:
// UI를 업데이트 하는 함수
virtual void OnValuesUpdated(float Current, float Max, float Percent) {}
};
// PDAttributeBar.cpp
void UPDAttributeBarWidget::SetValues(float Current, float Max)
{
const float Percent = Max > 0.f ? FMath::Clamp(Current / Max, 0.f, 1.f) : 0.f;
OnValuesUpdated(Current, Max, Percent);
}
흐름은 다음과 같다.

스탯이 변동되면 델리게이트로 인해 SetValues()가 호출되고, 여기서 수치 계산한 값을 OnValueUpdated()로 넘겨준다. OnValueUpdated()는 UI를 업데이트하는 특징을 지니기 때문에 추상 클래스인 PDAttributeBar는 정의하지 않는다.
// PDCircularAttributeBarWidget.h
UCLASS(Abstract, BlueprintType, meta = (DisableNativeTick))
class PROJECTD_API UPDCircularAttributeBarWidget : public UPDAttributeBarWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual void OnValuesUpdated(float Current, float Max, float Percent) override;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UImage> Image_Ring;
// 혹시 모를 수치 값을 작성할 수 있으므로 Optional로만 제공
UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional))
TObjectPtr<UTextBlock> Text_Value;
// 마스터 머터리얼의 fill 스칼라 파라미터 이름
UPROPERTY(EditDefaultsOnly, Category = "PD|HUD")
FName PercentParamName = TEXT("Percent");
UPROPERTY(Transient)
TObjectPtr<UMaterialInstanceDynamic> RingMID;
};
// PDCircularAttributeBarWidget.cpp
void UPDCircularAttributeBarWidget::NativeConstruct()
{
Super::NativeConstruct();
if (Image_Ring)
{
// 현재 Image_Ring의 MI를 가져옴
RingMID = Image_Ring->GetDynamicMaterial();
}
}
void UPDCircularAttributeBarWidget::OnValuesUpdated(float Current, float Max, float Percent)
{
if (RingMID)
{
// MI의 "Percent" 파라미터를 수정
RingMID->SetScalarParameterValue(PercentParamName, Percent);
}
if (Text_Value)
{
Text_Value->SetText(FText::FromString(
FString::Printf(TEXT("%d / %d"),
FMath::RoundToInt(Current),
FMath::RoundToInt(Max))));
}
}
흐림을 도식화 하면 다음과 같다.

어제 작성한 머터리얼에서 ProgressBar의 진행도를 나타내는 Percent 파리미터를 생성했는데, 코드에서는 변경된 값을 Percent 파라미터에 넘겨준다고 보면 된다.
// PDHUDWidget.h
UCLASS(Abstract, BlueprintType, meta = (DisableNativeTick))
class PROJECTD_API UPDHUDWidget : public UPDActivatableBase
{
GENERATED_BODY()
public:
UPDHUDWidget(const FObjectInitializer& ObjectInitializer);
void RebindToASC(UAbilitySystemComponent* NewASC);
protected:
virtual void NativeOnActivated() override;
virtual void NativeOnDeactivated() override;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_Stamina;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_Hunger;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_Thirst;
private:
struct FBoundAttributeHandle
{
FGameplayAttribute Attribute;
FDelegateHandle Handle;
};
void BindAllAttributes();
void BindAttributeToBar(UPDAttributeBarWidget* Bar,
const FGameplayAttribute& CurrentAttr,
const FGameplayAttribute& MaxAttr);
void RefreshBar(UPDAttributeBarWidget* Bar,
const FGameplayAttribute& CurrentAttr,
const FGameplayAttribute& MaxAttr) const;
void UnbindAllAttributes();
TWeakObjectPtr<UAbilitySystemComponent> CachedASC;
TArray<FBoundAttributeHandle> BoundAttributeHandles;
};
// PDHUDWidget.cpp
void UPDHUDWidget::NativeOnActivated()
{
Super::NativeOnActivated();
APawn* Pawn = GetOwningPlayerPawn();
UAbilitySystemComponent* ASC = Pawn
? UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Pawn)
: nullptr;
RebindToASC(ASC);
}
void UPDHUDWidget::NativeOnDeactivated()
{
RebindToASC(nullptr);
Super::NativeOnDeactivated();
}
// 현재의 ASC를 캐시하기 위한 함수
void UPDHUDWidget::RebindToASC(UAbilitySystemComponent* NewASC)
{
if (CachedASC.Get() == NewASC) return;
UnbindAllAttributes();
CachedASC = NewASC;
if (!NewASC) return;
BindAllAttributes();
}
void UPDHUDWidget::BindAllAttributes()
{
// 세 스탯을 모두 ASC에 속하는 속성과 바인딩
BindAttributeToBar(Bar_Stamina,
UPDAttributeSet::GetStaminaAttribute(),
UPDAttributeSet::GetMaxStaminaAttribute());
BindAttributeToBar(Bar_Hunger,
UPDAttributeSet::GetHungerAttribute(),
UPDAttributeSet::GetMaxHungerAttribute());
BindAttributeToBar(Bar_Thirst,
UPDAttributeSet::GetThirstAttribute(),
UPDAttributeSet::GetMaxThirstAttribute());
}
void UPDHUDWidget::BindAttributeToBar(UPDAttributeBarWidget* Bar,
const FGameplayAttribute& CurrentAttr,
const FGameplayAttribute& MaxAttr)
{
if (!Bar || !CachedASC.IsValid()) return;
// 처음에 초기 세팅 값을 위젯에 반영하기 위해 먼저 Refresh
RefreshBar(Bar, CurrentAttr, MaxAttr);
UAbilitySystemComponent* ASC = CachedASC.Get();
TWeakObjectPtr<UPDAttributeBarWidget> WeakBar = Bar;
TWeakObjectPtr<UPDHUDWidget> WeakSelf = this;
// 속성 변경 시, 수행되는 람다 함수의 포인터를 저장
auto OnChanged =
[WeakBar, WeakSelf, CurrentAttr, MaxAttr](const FOnAttributeChangeData& Data)
{
if (!WeakBar.IsValid() || !WeakSelf.IsValid()) return;
WeakSelf->RefreshBar(WeakBar.Get(), CurrentAttr, MaxAttr);
};
// 현재/최대 값을 델리게이트로 구독
BoundAttributeHandles.Add({ CurrentAttr,
ASC->GetGameplayAttributeValueChangeDelegate(CurrentAttr).AddLambda(OnChanged) });
BoundAttributeHandles.Add({ MaxAttr,
ASC->GetGameplayAttributeValueChangeDelegate(MaxAttr).AddLambda(OnChanged) });
}
void UPDHUDWidget::RefreshBar(UPDAttributeBarWidget* Bar,
const FGameplayAttribute& CurrentAttr,
const FGameplayAttribute& MaxAttr) const
{
if (!Bar || !CachedASC.IsValid()) return;
// 현재 변경된 속성을 기반으로 위의 위젯의 Percent를 수정하기 위한 요청 전송
UAbilitySystemComponent* ASC = CachedASC.Get();
Bar->SetValues(
ASC->GetNumericAttribute(CurrentAttr),
ASC->GetNumericAttribute(MaxAttr));
}
void UPDHUDWidget::UnbindAllAttributes()
{
if (CachedASC.IsValid())
{
UAbilitySystemComponent* ASC = CachedASC.Get();
for (const FBoundAttributeHandle& BH : BoundAttributeHandles)
{
// 델리게이트 해제
ASC->GetGameplayAttributeValueChangeDelegate(BH.Attribute).Remove(BH.Handle);
}
}
BoundAttributeHandles.Reset();
}
코드만 보았을 때 뭔가 많아보이는데, 스탯이 GAS 기반이기도 하고 좀 더 안정적으로 구현하려고 해서 그렇다. 일단 대략적으로 흐름만 설명하면 다음과 같다.

GAS의 기반은 팀원 분이 작성해주셔서 작성된 속성을 기반으로 델리게이트를 형성만 하면 되었다.
일단 PDAttributeBar를 제외한 위 두 가지 클래스를 블루프린트로 파생해주되, PDCircularAttributeBarWidget는 Stamina/Hunger/Thirst 3가지가 있으므로 3개를 블루프린트로 파생해줘야 한다.
Stamina만 따로 보자면, MI의 값을 조절해주고 SizeBox[160, 160] - Overlay - Image_Ring(160, 160), Image(24, 32)로 원형 진행 바와 아이콘을 배치해주었다.

Hunger, Thirst도 MI 값만 조절해주고 나머지는 동일하게 세팅해준다.
이후 HUD에 원하는 위치에 배치해 주었다. 원래는 Template을 만들어주고 배치해주는 게 좀 더 유지보수 측면에서 좋지만 UI/UX 관점에서 어디에 배치해줘야 할 지 감이 안잡히기도 했고 시간이 좀 급박했던지라 Template 작성을 하지 못했다.
