Ch3 팀 프로젝트(3) - Stamina & Hunger & Thirst 스탯 구현

yys·2026년 5월 12일

TIL

목록 보기
45/86

Stamina & Hunger & Thirst


우리 팀 프로젝트에서는 위 3가지의 스탯 + 부위 별 체력 스탯을 담당하고 있기 때문에 어제 만들었던 마스터 머터리얼 기반으로 디자인을 해보려고 한다.

먼저 위 3가지 스탯은 스크린 중 HUD의 부속품이기 때문에 다음과 같은 관계를 지니게 된다.

  • PDAttributeBar : 속성 바의 기본 클래스. 스탯의 Value를 저장
  • PDCircularAttributeBar : 원형 속성 바의 기본 클래스. 이미지 정보 및 머터리얼 정보를 지니는 특징을 가짐
  • PDActivatableBase : 모든 스크린의 기본 클래스. 스크린 활성화/비활성화 및 포커스 로직의 기본 틀 작성
  • PDHUDWidget : HUD에 대한 정보를 지니는 클래스. 이곳에서 모든 스탯의 바인딩을 수행.

1. PDAttributeBar 정의


// 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는 정의하지 않는다.

2. PDCircularAttributeBarWidget 정의


// 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 파라미터에 넘겨준다고 보면 된다.

3. HUD 바인딩


// 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의 기반은 팀원 분이 작성해주셔서 작성된 속성을 기반으로 델리게이트를 형성만 하면 되었다.

4. 블루프린트 세팅


일단 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 작성을 하지 못했다.

profile
게임 개발 지망생

0개의 댓글