
위 사진처럼 타르코프의 체력 시스템을 모방하려고 한다.
우리 프로젝트에서는 머리 + 몸통 + 팔 2개 + 다리 2개 총 6개의 파츠로 분류되어 있고 이를 기반으로 6개로 분리된 이미지 소스를 구해야 한다.
먼저 캐릭터는 간단하게, 언리얼 Manny 마네킹을 기반으로 정면 Idle 상태의 이미지를 따왔다.

이를 NanoBanana에게 6개의 마스크로 색상별 분리를 해달라고 했더니 다음과 같이 잘 분리해주었다. 프롬프트의 경우 내가 직접 작성하면 부족할 것 같아서 Gemini에게 부탁했다.

이후 따로 이미지 편집 툴을 가지고 있지 않아서 이미지 편집 웹사이트인 Photopea를 사용해주었다.
(Photopea - https://www.photopea.com/)
이미지를 배치해준 뒤, Masking을 하고 싶은 부분에 w(Magic Wand)를 눌러주고 클릭해준다.

Shift + F5로 채우기 창을 활성화 해서 전경을 흰색으로 전환해주면 선택한 부분이 흰색으로 변한다.

그 다음 Shift + Ctrl + I를 해서 반전을 시켜준 뒤 Delete를 하면 선택한 부분만 남기고 다 삭제했다.

이 상태로 나머지 5개의 부품을 똑같이 나누어주었다.
먼저 추가된 위젯의 구조도는 다음과 같다.

// UPDBodyPartImageWidget.h
/**
* 부위별 신체 마스크 이미지 위젯
*/
UCLASS(Abstract, BlueprintType, meta = (DisableNativeTick))
class PROJECTD_API UPDBodyPartImageWidget : 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_Part;
// 이 인스턴스에서 사용할 MI
UPROPERTY(EditAnywhere, Category = "PD|HUD")
TObjectPtr<UMaterialInterface> PartMaterial;
// 마스터 머터리얼의 fill 스칼라 파라미터 이름
UPROPERTY(EditDefaultsOnly, Category = "PD|HUD")
FName PercentParamName = TEXT("Percent");
UPROPERTY(Transient)
TObjectPtr<UMaterialInstanceDynamic> PartMID;
};
// UPDBodyPartImageWidget.cpp
void UPDBodyPartImageWidget::NativeConstruct()
{
Super::NativeConstruct();
if (Image_Part)
{
if (PartMaterial)
{
// 인스턴스마다 다른 MI 적용
Image_Part->SetBrushFromMaterial(PartMaterial);
}
PartMID = Image_Part->GetDynamicMaterial();
}
}
void UPDBodyPartImageWidget::OnValuesUpdated(float Current, float Max, float Percent)
{
if (PartMID)
{
PartMID->SetScalarParameterValue(PercentParamName, Percent);
}
}
여기서의 PartMaterial은 체력이 임계점을 넘을 때마다 몸의 색상을 변하도록 만들어 주는 역할을 하며 뒤에서 구현할 예정이다.
// PDBodyPartHealthGroupWidget.h
/**
* 6부위(Head/Torso/Arm_L/Arm_R/Leg_L/Leg_R) HP 바를 묶는 합성 위젯
*/
UCLASS(Abstract, BlueprintType, meta = (DisableNativeTick))
class PROJECTD_API UPDBodyPartHealthGroupWidget : public UUserWidget
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "PD|HUD")
UPDAttributeBarWidget* GetBar(EBodyPart Part) const;
protected:
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_Head;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_Torso;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_ArmL;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_ArmR;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_LegL;
UPROPERTY(BlueprintReadOnly, meta = (BindWidget))
TObjectPtr<UPDAttributeBarWidget> Bar_LegR;
};
// PDBodyPartHealthGroupWidget.cpp
UPDAttributeBarWidget* UPDBodyPartHealthGroupWidget::GetBar(EBodyPart Part) const
{
switch (Part)
{
case EBodyPart::Head: return Bar_Head;
case EBodyPart::Torso: return Bar_Torso;
case EBodyPart::Arm_L: return Bar_ArmL;
case EBodyPart::Arm_R: return Bar_ArmR;
case EBodyPart::Leg_L: return Bar_LegL;
case EBodyPart::Leg_R: return Bar_LegR;
case EBodyPart::None:
default:
return nullptr;
}
}
PDBodyPartHealthGroupWidget은 단순히 각 부품을 보관하는 컨테이너 같은 역할이다. 구현된 내용만 보면 구조체의 구성이지만, Widget Blueprint를 만들어야 하므로 UserWidget을 상속하는 클래스의 형태로 구현되었다.
// PDHUDWidget.h
protected:
UPROPERTY(BlueprintReadOnly, meta = (BindWidgetOptional))
TObjectPtr<UPDBodyPartHealthGroupWidget> Bar_BodyParts;
// PDHUDWidget.cpp
void UPDHUDWidget::BindAllAttributes()
{
// 신체 부위별 HP 어트리뷰트 바인딩
if (Bar_BodyParts)
{
BindAttributeToBar(Bar_BodyParts->GetBar(EBodyPart::Head),
UPDAttributeSet::GetHeadHPAttribute(),
UPDAttributeSet::GetMaxHeadHPAttribute());
BindAttributeToBar(Bar_BodyParts->GetBar(EBodyPart::Torso),
UPDAttributeSet::GetTorsoHPAttribute(),
UPDAttributeSet::GetMaxTorsoHPAttribute());
BindAttributeToBar(Bar_BodyParts->GetBar(EBodyPart::Arm_L),
UPDAttributeSet::GetArmLHPAttribute(),
UPDAttributeSet::GetMaxArmLHPAttribute());
BindAttributeToBar(Bar_BodyParts->GetBar(EBodyPart::Arm_R),
UPDAttributeSet::GetArmRHPAttribute(),
UPDAttributeSet::GetMaxArmRHPAttribute());
BindAttributeToBar(Bar_BodyParts->GetBar(EBodyPart::Leg_L),
UPDAttributeSet::GetLegLHPAttribute(),
UPDAttributeSet::GetMaxLegLHPAttribute());
BindAttributeToBar(Bar_BodyParts->GetBar(EBodyPart::Leg_R),
UPDAttributeSet::GetLegRHPAttribute(),
UPDAttributeSet::GetMaxLegRHPAttribute());
}
}
어제 작성한 내용과 같이, HUD에서 속성을 바인딩해주는 것 이외에는 다른 부분은 없다.
일단은 단순하게 체력이 66퍼 이하가 되면 서서히 노란색으로, 44퍼 이하가 되면 서서히 빨간색으로 표현되도록 세팅해주었다.
추후 필요하다면 다르게 세팅해줄 계획이다.
먼저 아래와 같이 66퍼/44퍼가 될 때 바로 색이 변하도록 하지 않기 위해 다음과 같이 SmoothStep을 배치해주었다.

이후 전달 받은 임계값을 기반으로 색이 혼합되도록 Lerp를 구현해준다.

마지막으로 Lerp의 값을 Final Color로, Opcacity Override를 텍스처 파라미터로 세팅해주었다.

이후 각 부품의 텍스처가 다르므로, MI를 6개를 만들어서 텍스처만 변경해주었다.


각 부품은 원본 이미지가 9:16 비율이기 때문에 적절하게 사이즈 박스로 180 : 320으로 스케일링 한 후에 배치해주었다. 여기서 이미지는 바인딩 때문에 Image_Part로 세팅해주었다.

이후 BodyPartHealthWidget에 위에서 정의한 위젯 블루프린트를 각자 바인딩 해주고 이미지의 텍스처를 위에서 정의한 MI로 세팅해주었다. Torso만 보이는 이유는 코드에서 머터리얼을 적용하는 내용이 NativeConstruct()에서 수행하기 때문에 현재 컴파일 타임에서는 확인할 수 없기 때문이다.
이후 HUD의 배치해준 후 실행해주면 다음과 같은 결과가 나온다.

아직 피격 시스템을 테스트 할 수 없는 상황이라 색이 어떻게 변하는 지 잘 모르겠지만, 바인딩이 되고 있다는 점은 확실해서 일단은 디버프 리스트를 옆에 띄워주는 단계로 넘어가면 될 것 같다.