[UE5 C++] 스탯 컴포넌트, Status Bar 구현 - 1

LeeTaes·2024년 5월 21일
0

[UE_Project] MysticMaze

목록 보기
17/17
post-thumbnail

언리얼 엔진을 사용한 RPG 프로젝트 만들기

  • 스탯 컴포넌트 구현
  • StatusBar Widget 구현

기획

플레이어 및 몬스터의 스텟 기획 내용은 다음과 같습니다.

레벨을 올리면 스탯 포인트를 지급받으며, 스탯 분배를 통해 플레이어를 강해지게 만들 생각입니다.

공격력의 경우 직업별로 영향을 주는 스탯을 변경할 것입니다.

추가적으로 레벨에 따른 기본 스탯 부여량이 정해져있으며(BaseStat) 스탯 분배(ModifierStat)를 통해 올린 스탯과 무기를 장착하면 얻는 스텟(WeaponStat)을 합친 최종 스탯(TotalStat)이 적용되도록 할 예정입니다.


스탯 데이터 테이블과 스탯 구조체

  • 플레이어의 스탯을 저장하기 위해 스탯 구조체를 선언합니다.
  • 해당 구조체의 요소들을 바탕으로 레벨별 기본 스탯을 저장하는 데이터 테이블을 생성합니다.

우선 스탯 구조체를 생성해주도록 하겠습니다.

기본 4종 스탯(STR, DEX, CON, INT)을 저장하고, 레벨별 달성해야 하는 경험치를 저장할 수 있는 구조체입니다.

내부적으로 연산자 오버로딩을 통해 쉽게 다른 스탯 구조체와 덧셈 연산을 할 수 있도록 구현하였습니다.

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "MMCharacterStat.generated.h"

USTRUCT(BlueprintType)
struct FMMCharacterStat : public FTableRowBase
{
    GENERATED_BODY()

public:
    FMMCharacterStat() : STR(0), DEX(0), CON(0), INT(0), EXP(0) {}

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
    int32 STR;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
    int32 DEX;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
    int32 CON;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
    int32 INT;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
    int32 EXP;

    FMMCharacterStat operator+(const FMMCharacterStat& other) const
    {
        FMMCharacterStat Result;
      
        Result.STR = this->STR + other.STR;
        Result.DEX = this->DEX + other.DEX;
        Result.CON = this->CON + other.CON;
        Result.INT = this->INT + other.INT;

        return Result;
    }
};

위 구조체의 변수와 동일한 이름의 필드를 가진 CSV 파일을 생성해주도록 하겠습니다.

언리얼에서 데이터 테이블을 생성합니다.


스탯 컴포넌트 - 1 (필요한 변수)

  • 스탯을 저장하고, 업데이트하는 컴포넌트입니다.
  • TotalStat을 기준으로 세부 스탯을 설정하여 스탯의 변경이 있을 때마다 이벤트를 발생시켜 스탯 정보를 플레이어에 반영합니다.

기본적으로 플레이어와 몬스터에 부착시키기 위해 ActorComponent를 상속받아 제작하였습니다.

기본 스탯과 세부 스탯을 저장하기 위한 변수들을 추가합니다.

플레이어의 공격속도, 이동속도, 치명타확률은 최대 범위가 지정되어 있습니다.

해당 스탯들의 범위를 저장하기 위한 변수와 플레이어의 직업을 저장하기 위한 변수를 추가합니다.

스탯이 변경된 경우 발생시킬 이벤트에 대해 델리게이트를 추가로 선언해주도록 하겠습니다.



스탯 컴포넌트 - 2 (초기화 및 업데이트)

  • 스탯 컴포넌트의 생성자 함수와 초기화 함수, 스탯 업그레이드 함수를 추가해주도록 하겠습니다.


Init() 함수는 외부에서 스탯 컴포넌트를 초기화하기 위한 함수입니다.
스텟을 불러와 초기 스텟을 설정하고, 업데이트를 진행합니다.

UMMStatComponent::UMMStatComponent()
{
	bWantsInitializeComponent = true;

	CurrentLevel = 1;
	AvailableStatPoint = 0;

	// 최대 추가 가능한 스탯의 범위
	MaxAdditiveMovementSpeed = 400.0f;
	MaxAdditiveAttackSpeed = 1.5f;
	MaxAdditiveCriticalHitRate = 100.0f;
}

void UMMStatComponent::Init()
{
	IMMPlayerClassInterface* PlayerCharacter = Cast<IMMPlayerClassInterface>(GetOwner());
	if (PlayerCharacter)
	{
		// 플레이어 데이터 초기화
		InitPlayerStatus();

		// 세부 스탯 업데이트
		UpdateDetailStatus();

		// 현재 스탯 초기화
		//SetHp(MaxHp);
		//SetMp(MaxMp);
		//SetExp(CurrentExp);
	}
}

SetLevel() 함수는 몬스터에서 사용할 코드입니다.
스폰될 몬스터의 레벨을 설정해 데이터 테이블의 해당 레벨 스탯으로 지정해줄 예정입니다.

void UMMStatComponent::SetLevel(int32 InLevel)
{
	CurrentLevel = InLevel;

	ClassType = EClassType::CT_None;
    // 추후 데이터 테이블이 추가되면 구현할 예정
	InitMonsterStatus(CurrentLevel);
}

InitPlayerStatus() 함수는 세이브 파일로부터 데이터를 읽어와 스탯에 반영시켜주기 위한 함수입니다.

현재는 테스트 용도로 값을 추가해서 확인해보도록 하겠습니다.

void UMMStatComponent::InitPlayerStatus()
{
	// TODO : 세이브 파일로부터 데이터 읽어오기 (레벨, 초기 스탯 정보(ModifierStat))
	{
		// 레벨 및 스탯 포인트 초기화
		CurrentLevel = 1;
		AvailableStatPoint = 5;

		// ModifierStat 초기화
		FMMCharacterStat LoadModifierStatus;
		LoadModifierStatus.STR = 5;
		LoadModifierStatus.DEX = 5;
		LoadModifierStatus.CON = 5;
		LoadModifierStatus.INT = 5;

		ModifierStat = LoadModifierStatus;

		// WeaponStat 초기화 (무기가 있는 경우라면 나중에 인벤쪽에서 해주자..!)
		//FMMCharacterStat LoadWeaponStatus;
		//
		//LoadWeaponStatus.STR = 10;
		//LoadWeaponStatus.DEX = 10;
		//LoadWeaponStatus.CON = 10;
		//LoadWeaponStatus.INT = 10;
		//
		//WeaponStat = LoadWeaponStatus;

		// 클래스 정보 초기화
		ClassType = EClassType::CT_Warrior;

		// 현재 경험치 초기화
		CurrentExp = 10.0f;
	}
	
	// 레벨별 기본 스탯 적용하기
	UMMGameInstance* GameMode = Cast<UMMGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (GameMode)
	{
		BaseStat = GameMode->GetPlayerStat(CurrentLevel);
		MaxExp = BaseStat.EXP;
	}

	// 플레이어 직업 설정
	IMMPlayerClassInterface* PlayerCharacter = Cast<IMMPlayerClassInterface>(GetOwner());
	if (PlayerCharacter)
	{
		PlayerCharacter->SetClass(ClassType);
	}
}

UpdateDetailStatus() 함수는 변경된 기본 스탯에 맞춰 세부 스탯을 재설정하는 함수입니다.

TotalStat을 업데이트 해주고 각각의 세부 스탯을 업데이트 해주며, 스탯 변경 이벤트를 발생시켜주는 역할을 수행합니다.

void UMMStatComponent::UpdateDetailStatus()
{
	// Total Stat 업데이트
	TotalStat = BaseStat + ModifierStat + WeaponStat;

	// 비율에 맞춰 세부 스탯 업데이트
	// * 최대 체력
	MaxHp = (TotalStat.STR * 0.5f * 100) + (TotalStat.CON * 0.5f * 100);
	// * 최대 마나
	MaxMp = TotalStat.INT * 100;
	// * 공격력
	switch (ClassType)
	{
	case EClassType::CT_None:
	case EClassType::CT_Beginner:
	case EClassType::CT_Warrior :
		AttackDamage = TotalStat.STR * 10;
		break;

	case EClassType::CT_Archer:
		AttackDamage = TotalStat.DEX * 10;
		break;

	case EClassType::CT_Mage:
		AttackDamage = TotalStat.INT * 10;
		break;
	}
	// * 방어력
	Defense = TotalStat.CON * 5;
	// * 이동속도
	MovementSpeed = MaxAdditiveMovementSpeed * (TotalStat.DEX / 250.0f) + 600.0f;
	// * 공격속도
	AttackSpeed = MaxAdditiveAttackSpeed * (TotalStat.DEX / 250.0f) + 1.0f;
	// * 치명타 확률
	CriticalHitRate = MaxAdditiveCriticalHitRate * (TotalStat.DEX / 250.0f);

	// 이벤트 발생
	OnMovementSpeedChanged.Broadcast(MovementSpeed);
	OnStatChanged.Broadcast(TotalStat);
	OnHpChanged.Broadcast(CurrentHp, MaxHp);
	OnMpChanged.Broadcast(CurrentMp, MaxMp);
}

StatusBar Widget

  • StatusBarWidget은 플레이어의 체력, 마나, 경험치량을 표시해주는 위젯입니다.
  • 추가적으로 퀵슬롯까지 표현해주도록 하겠습니다.

우선 UserWidget을 기반으로 위젯을 만들어 디자인을 우선적으로 해주도록 하겠습니다.

Hp, Mp, Exp Bar는 모두 ProgressBar로 설정해 비율에 따라 칸을 채워줄 수 있도록 할 예정입니다.

추가적으로 퀵슬롯의 슬롯은 기존에 만든 슬롯을 그대로 사용하였습니다.

1 ~ 4번 슬롯은 스킬 슬롯으로, 쿨타임을 표현하기 위한 ProgressBar와 Text를 추가해주었습니다.

위젯에서 스탯 컴포넌트의 데이터를 받아와야 하므로 인터페이스를 추가해주도록 하겠습니다.

추가적으로 퀵슬롯과 앞으로 구현할 장비 슬롯의 슬롯 타입을 추가해주도록 하겠습니다.

위젯을 제어하기 위한 MMCustomWidget을 상속받은 "MMPlayerStatusBarWidget" 클래스를 작성해주도록 하겠습니다.

// MMPlayerStatusBarWidget Header
#pragma once

#include "CoreMinimal.h"
#include "UI/MMCustomWidget.h"
#include "GameData/MMEnums.h"
#include "MMPlayerStatusBarWidget.generated.h"

/**
 * 
 */
UCLASS()
class MYSTICMAZE_API UMMPlayerStatusBarWidget : public UMMCustomWidget
{
	GENERATED_BODY()
	
protected:
	virtual void NativeConstruct() override;

public:
	void Init();
	void UpdateSkillSlot();
	void UpdatePotionSlot();
	void UpdateHpBar(float CurrentHp, float MaxHp);
	void UpdateMpBar(float CurrentMp, float MaxMp);
	void UpdateExpBar(float CurrentExp, float MaxExp);

// HpBar
public:
	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UProgressBar> PB_HpBar;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_CurrentHp;
	
	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_MaxHp;
	
	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_HpPercent;

// MpBar
public:
	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UProgressBar> PB_MpBar;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_CurrentMp;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_MaxMp;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_MpPercent;

// ExpBar
public:
	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UProgressBar> PB_ExpBar;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_ExpPercent;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_CurrentExp;

	UPROPERTY(meta = (BindWidget = "true"))
	TObjectPtr<class UTextBlock> TXT_MaxExp;

// Quick Slot (Skill, Potion)
public:
	UPROPERTY(VisibleAnywhere, Category = "SkillSlot")
	TArray<TObjectPtr<class UMMSlot>> SkillSlots;

	UPROPERTY(VisibleAnywhere, Category = "SkillSlot")
	TArray<TObjectPtr<class UTextBlock>> CoolTimeTexts;

	UPROPERTY(VisibleAnywhere, Category = "SkillSlot")
	TArray<TObjectPtr<class UProgressBar>> CoolTimeProgressBars;
	
	UPROPERTY(VisibleAnywhere, Category = "PotionSlot")
	TArray<TObjectPtr<class UMMSlot>> PotionSlots;
};
// MMPlayerStatusBarWidget Cpp

#include "UI/MMPlayerStatusBarWidget Cpp.h"
#include "UI/MMSlot.h
#include "Interface/MMStatusInterface.h"
#include "Player/MMStatComponent.h"

#include "Blueprint/WidgetTree.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"


void UMMPlayerStatusBarWidget::NativeConstruct()
{
	Super::NativeConstruct();

	// 쿨타임 텍스트를 배열에 추가합니다.
	for (int32 i = 0; i < 4; i++)
	{
		FName CoolTimeText = *FString::Printf(TEXT("TXT_CoolTime%d"), i + 1);
		UTextBlock* CoolTimeTextBlockWidget = Cast<UTextBlock>(GetWidgetFromName(CoolTimeText));

		if (CoolTimeTextBlockWidget)
		{
			CoolTimeTexts.Add(CoolTimeTextBlockWidget);
		}

		// 쿨타임 바를 배열에 추가합니다.
		FName CoolTimeProgressBar = *FString::Printf(TEXT("PB_CoolTimeRate%d"), i + 1);
		UProgressBar* CoolTimeProgressBarWidget = Cast<UProgressBar>(GetWidgetFromName(CoolTimeProgressBar));
		if (CoolTimeProgressBarWidget)
		{
			CoolTimeProgressBars.Add(CoolTimeProgressBarWidget);
		}
	}
}

void UMMPlayerStatusBarWidget::Init()
{
	// 슬롯을 초기화합니다.
	SkillSlots.Init(nullptr, 4);
	PotionSlots.Init(nullptr, 2);

	TArray<UWidget*> Widgets;
	WidgetTree->GetAllWidgets(Widgets);

	// 위젯들의 목록으로 부터 퀵슬롯들을 초기화하고 배열에 저장합니다.
	for (UWidget* Widget : Widgets)
	{
		UMMSlot* QuickSlot = Cast<UMMSlot>(Widget);
		if (QuickSlot)
		{
			if (QuickSlot->GetName().Contains(TEXT("SkillSlot")))
			{
				QuickSlot->SetOwningActor(OwningActor);
				QuickSlot->SetType(ESlotType::ST_SkillSlot);
				QuickSlot->Init();
				SkillSlots[QuickSlot->SlotIndex] = QuickSlot;
			}
			else if (QuickSlot->GetName().Contains(TEXT("PotionSlot")))
			{
				QuickSlot->SetOwningActor(OwningActor);
				QuickSlot->SetType(ESlotType::ST_PotionSlot);
				QuickSlot->Init();
				PotionSlots[QuickSlot->SlotIndex] = QuickSlot;
			}
		}
	}

	IMMStatusInterface* PlayerCharacter = Cast<IMMStatusInterface>(OwningActor);

	if (PlayerCharacter)
	{
		// 스탯 컴포넌트를 불러옵니다.
		UMMStatComponent* StatComponent = PlayerCharacter->GetStatComponent();

		// 시작 스텟으로 초기화합니다.
		UpdateHpBar(StatComponent->GetCurrentHp(), StatComponent->GetMaxHp());
		UpdateMpBar(StatComponent->GetCurrentMp(), StatComponent->GetMaxMp());
		UpdateExpBar(StatComponent->GetCurrentExp(), StatComponent->GetMaxExp());
	}
}

void UMMPlayerStatusBarWidget::UpdateSkillSlot()
{
}

void UMMPlayerStatusBarWidget::UpdatePotionSlot()
{
	for (const auto& PotionSlot : PotionSlots)
	{
    	// 포션 타입의 슬롯들을 업데이트 합니다.
		PotionSlot->UpdateSlot();
	}
}

void UMMPlayerStatusBarWidget::UpdateHpBar(float CurrentHp, float MaxHp)
{
	// 외부에서 델리게이트와 연동시킬 함수입니다.
    // * 현재 체력과 최대 체력을 받아와 업데이트 해줍니다.
	TXT_MaxHp->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), MaxHp)));
	TXT_CurrentHp->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), CurrentHp)));
	TXT_HpPercent->SetText(FText::FromString(FString::Printf(TEXT("%.0f%%"), (CurrentHp / MaxHp) * 100)));
	PB_HpBar->SetPercent(CurrentHp / MaxHp);
}

void UMMPlayerStatusBarWidget::UpdateMpBar(float CurrentMp, float MaxMp)
{
	// 외부에서 델리게이트와 연동시킬 함수입니다.
    // * 현재 마나와 최대 마나를 받아와 업데이트 해줍니다.
	TXT_MaxMp->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), MaxMp)));
	TXT_CurrentMp->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), CurrentMp)));
	TXT_MpPercent->SetText(FText::FromString(FString::Printf(TEXT("%.0f%%"), (CurrentMp / MaxMp) * 100)));
	PB_MpBar->SetPercent(CurrentMp / MaxMp);
}

void UMMPlayerStatusBarWidget::UpdateExpBar(float CurrentExp, float MaxExp)
{
	// 외부에서 델리게이트와 연동시킬 함수입니다.
    // * 현재 경험치와 최대 경험치를 받아와 업데이트 해줍니다.
	TXT_MaxExp->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), MaxExp)));
	TXT_CurrentExp->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), CurrentExp)));
	TXT_ExpPercent->SetText(FText::FromString(FString::Printf(TEXT("%.0f"), (CurrentExp / MaxExp) * 100)));
	PB_ExpBar->SetPercent(CurrentExp / MaxExp);
}

MMSlot 클래스에서도 추가된 슬롯 타입에 대한 업데이트 로직을 추가합니다.

void UMMSlot::Init()
{
	for (const auto& ToolTipClass : ToolTipClassMap)
	{
		if (ToolTipClass.Value)
		{
			ToolTipMaps.Add(ToolTipClass.Key, CreateWidget<UMMToolTip>(GetWorld(), ToolTipClass.Value));
		}
	}

	SlotUpdateActions.Add(ESlotType::ST_InventoryEquipment, FUpdateSlotDelegateWrapper(FOnUpdateSlotDelegate::CreateUObject(this, &UMMSlot::UpdateEquipmentSlot)));
	SlotUpdateActions.Add(ESlotType::ST_InventoryConsumable, FUpdateSlotDelegateWrapper(FOnUpdateSlotDelegate::CreateUObject(this, &UMMSlot::UpdateConsumableSlot)));
	SlotUpdateActions.Add(ESlotType::ST_InventoryOther, FUpdateSlotDelegateWrapper(FOnUpdateSlotDelegate::CreateUObject(this, &UMMSlot::UpdateOtherSlot)));
	SlotUpdateActions.Add(ESlotType::ST_SkillSlot, FUpdateSlotDelegateWrapper(FOnUpdateSlotDelegate::CreateUObject(this, &UMMSlot::UpdateSkillSlot)));
	SlotUpdateActions.Add(ESlotType::ST_PotionSlot, FUpdateSlotDelegateWrapper(FOnUpdateSlotDelegate::CreateUObject(this, &UMMSlot::UpdatePotionSlot)));
	SlotUpdateActions.Add(ESlotType::ST_Equipment, FUpdateSlotDelegateWrapper(FOnUpdateSlotDelegate::CreateUObject(this, &UMMSlot::UpdateEquipment)));

	UpdateSlot();
}

void UMMSlot::UpdateSkillSlot()
{
	// TODO : 스킬 슬롯 제작 후 초기화
	IMG_Item->SetBrushFromTexture(DefaultTexture);
	TXT_Quantity->SetText(FText::FromString(TEXT("")));
}

void UMMSlot::UpdatePotionSlot()
{
	IMMInventoryInterface* InvPlayer = Cast<IMMInventoryInterface>(OwningActor);

	if (InvPlayer)
	{
		// 포션 퀵슬롯을 가져옵니다.
		TArray<UMMInventoryItem*>& QuickSlots = InvPlayer->GetInventoryComponent()->GetPotionQuickSlots();

		if (QuickSlots.IsValidIndex(SlotIndex))
		{
			// 해당 슬롯에 아이템이 존재하는지 확인합니다.
			if (IsValid(QuickSlots[SlotIndex]))
			{
				if (QuickSlots[SlotIndex]->ItemQuantity > 0)
				{
					// 존재하는 경우 아이템의 텍스쳐와 수량을 반영해주도록 합니다.
					IMG_Item->SetBrushFromTexture(QuickSlots[SlotIndex]->ItemData->ItemTexture);
					TXT_Quantity->SetText(FText::FromString(FString::Printf(TEXT("%d"), QuickSlots[SlotIndex]->ItemQuantity)));
				}
				else
				{
					// 슬롯을 초기화 합니다.
					QuickSlots[SlotIndex] = nullptr;

					// 슬롯을 빈 칸으로 표시합니다.
					IMG_Item->SetBrushFromTexture(DefaultTexture);
					TXT_Quantity->SetText(FText::FromString(TEXT("")));
				}
				
			}
			else
			{
				// 존재하지 않는 경우 빈 칸으로 표시합니다.
				IMG_Item->SetBrushFromTexture(DefaultTexture);
				TXT_Quantity->SetText(FText::FromString(TEXT("")));
			}
		}
	}
}

void UMMSlot::UpdateEquipment()
{
	// TODO : 장비슬롯 업데이트 추가하기
}

클래스 구현이 완료되면 블루프린트 상의 부모 클래스를 수정해주도록 합니다.

profile
클라이언트 프로그래머 지망생

0개의 댓글