[Unreal]

[Unreal 4] 충돌과 UI #2 - 스탯 매니저, UI 실습

럭키🍀 2023. 6. 25. 23:13

충돌과 UI #2 - 스탯 매니저

스탯이라는 개념을 둬서 체력을 표시해보자.

만약 엔진을 사용하지 않고 직접 구조를 만들면 전체적으로 싱글톤패턴이나 전역 클래스로 데이터 매니저를 둬서 장점도 있었는데 이미 언리얼 엔진은 구조가 잡혀있기 때문에 어떤 방식으로 만들어야하는지 고민해봐야한다.

유니티처럼 Actor를 만들어서 BeginPlay에 넣고 로드해온 다음에 Update되는 방법이 있고 아니라면 정석적인 방법으로 새로운 Game Instance클래스를 상속받아서 클래스를 만들고 게임이 실행되는 순간에 한 번 초기화되기 때문에 전역 매니저로 사용하기 괜찮다.

 

우리도 GameInstance클래스를 상속받아서 MyGameInstance 클래스를 만들었다. 그런데 스탯은 공격력이나 HP등을 말하는데 이런 데이터는 코드와 분리해 놓아야한다. 보통 코드에서 안하고 기획쪽에서 밸런싱을 해야하므로.. 또 코드에 직접 박으면 바이너리 안에서 실행파일에 묶여가기 때문에 밸런싱을 위해 전체를 다시 빌드하기는 부담스럽다. 따라서 JSON이나 XML같은 걸로 만들어서 쓴다.

 

//MyGameInstance.h

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "Engine/DataTable.h"
#include "MyGameInstance.generated.h"

USTRUCT()
struct FMyCharacterData : public FTableRowBase
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	int32 Level;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	int32 Attack;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	int32 MaxHp;
};

만들은 MyGameInstance클래스 안에 DataTable헤더를 포함하고 언리얼에서 관리한다는 뜻의 USTRUCT()를 써주고 FTableRowBase를 상속받은 구조체 FMyCharacterData타입을 만들었다. 꼭 이 클래스 안에서 정의해야하는 것을 아닌데 우선 이 클래스 안에 선언한 것이며 우선 레벨, 공격력, HP를 표시할 것이다.

또 콘텐츠 하위에 Data디렉토리를 따로 만들고 그 하위에서 우클릭 기타>데이터테이블을 선택하여 행구조선택 할 때 방금 만든 MyChracterData를 선택하여 저장하였다. (가끔 안 뜰 땐 다시 껐다 키면 된다.) 이름은 StatTable로 하였다. 눌러보면 데이터 테이블이 위 Level, Attack, MaxHp컬럼으로 생겼다. 이 파일은 바이너리로 같이 만들어지지 않는다. 값을 저장하고 싶으면 추가 버튼을 눌러서 할 수 있다.

혹은 이런 방법이 아니라 진짜로 JSON,XML로 만들어서 파싱해 사용할수도 잇는데 간단하게는 이렇게 많이 사용한다.

 

 

 

// MyGameInstance.h

// FMyCharacterData 타입 정의.
//...

UCLASS()
class TESTUNREALENGINE_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()

public:
	UMyGameInstance();

	virtual void Init() override;

	FMyCharacterData* GetStatData(int32 Level);

private:
	UPROPERTY()
	class UDataTable* MyStats;
};

위와 같이 UMyGameInstance클래스 안에서는 위에서 만든 데이터 테이블을 꺼내올수 있는 FMyCharacterData타입을 레벨별로 반환하는 GetStatData를 만들고 데이터 매니저처럼 사용한다. 생성자와 Init()을 아래와 같이 구현했다.

// MyGameInstance.cpp
#include "MyGameInstance.h"

UMyGameInstance::UMyGameInstance()
{
	static ConstructorHelpers::FObjectFinder<UDataTable> DATA(TEXT("DataTable'/Game/Data/StatTable.StatTable'"));
	
	MyStats = DATA.Object;
}

void UMyGameInstance::Init()
{
	Super::Init();

	UE_LOG(LogTemp, Warning, TEXT("MyGameInstance %d"), GetStatData(1)->Attack);
}

FMyCharacterData* UMyGameInstance::GetStatData(int32 Level)
{
	return MyStats->FindRow<FMyCharacterData>(*FString::FromInt(Level), TEXT(""));
}

생성자에선 늘 그렇든 ContructorHelpers로 FObjectFinder를 이용해 데이터 테이블타입의 오브젝트를 로드한다. 경로는 콘텐츠/Data에 만들었던 StatTable을 복붙했다. 만약 이 데이터가 없다면 차라리 크래시를 내는게 나아서 if(Data.Succeeded())를 생략했다. 그리고 멤버인 데이터테이블타입의 MyStats에 값을 넣어주었다.

또 GetStatData에서는 FindRow로 데이터테이블의 열을 찾아줄 것인데 반환하는 타입은 FMyCharacterData타입이고 이 함수의 원형을 보면 FName타입의 RowName과 ContextString을 받고 있는데 Level을 이름으로 지어서 Level에서 Int를 추출하게 했고 두번째는 비어두게 했다. (ContextString은 좀 더 자세하게 찾을 때 사용하는 것이라 우선은 비어뒀다.)

 또 MyGameInstance가 실행되었는지, 데이터 테이블이 잘 로드 되었는지 확인위해 로그를 찍어보도록 했다.

 

언리얼엔진으로 돌아가서 실행해 보면 우선 이 로그는 찍히지 않는다..!!

왜냐면 월드세팅에서 설정하지 않았기 때문인데..

프로젝트 세팅>맵&모드에서 게임 인스턴스 클라스를 지정해주었다.

다시 한번 컴파일하고 실행하면 출력로그에 MyGameInstance의 오버라이드되었던 Init이 호출된 걸 볼 수 있다.

 

이렇게 게임인스턴스에서 스탯 관련 데이터가 로드가 되면 이를 활용해야하는데 MyCharacter에서 AttackCheck() 함수에 가면 충돌체크하고 내 HP가 0보다 크면 상대의 공격력만큼 깎이고 깠였을 때 0 보다 작으면 죽는 계산을 해야한다. 

이 때 고민해야할게 A->B를 때렸으면 A클래스에서 접근해 B의 체력을 깎아야하는지 혹은 중간에 있는 매니저에서 처리해야하는지인데 대부분의 경우는 데미지를 받는 B쪽에서 피격받는 함수를 만들어 깎는 처리를 하는게 훨씬 깔끔하다. 도트 데미지나 공격자에 대한 어그로 상황처리 등을 하려면 피격받는자에서 처리하는게 더 좋다.

void AMyCharacter::AttackCheck()
{
	FHitResult HitResult;
	FCollisionQueryParams Params(NAME_None, false, this);

	float AttackRange = 100.f;
	float AttackRadius = 50.f;

	bool bResult = GetWorld()->SweepSingleByChannel(
		OUT HitResult,
		GetActorLocation(),
		GetActorLocation() + GetActorForwardVector() * AttackRange,
		FQuat::Identity,
		ECollisionChannel::ECC_GameTraceChannel2,
		FCollisionShape::MakeSphere(AttackRadius),
		Params);

	FVector Vec = GetActorForwardVector() * AttackRange;
	FVector Center = GetActorLocation() + Vec * 0.5f;
	float HalfHeight = AttackRange * 0.5f + AttackRadius;
	FQuat Rotation = FRotationMatrix::MakeFromZ(Vec).ToQuat();
	FColor DrawColor;
	if (bResult)
		DrawColor = FColor::Green;
	else
		DrawColor = FColor::Red;

	DrawDebugCapsule(GetWorld(), Center, HalfHeight, AttackRadius,
		Rotation, DrawColor, false, 2.f);


	if (bResult && HitResult.Actor.IsValid())
	{
		UE_LOG(LogTemp, Log, TEXT("Hit Actor : %s"), *HitResult.Actor->GetName());

		FDamageEvent DamageEvent;
		//HitResult.Actor->TakeDamage(Stat->GetAttack(), DamageEvent, GetController(), this);
	}
}

if(bResult&& HitResult.Actor.IsValid())안에서 이제까진 충돌 했으면 로그만 찍고 있었다. 

충돌 정보는 HitResult에 담고 있었는데 HitResult에서 Actor가 피격자이다.

HitResult.Actor에서 이미 피해를 받는 함수를 호출하면 되는데 이미 언리얼에서 제공하는 TakeDamage를 제공해 'Apply damage to this actor'라는 설명에 있듯이 이 캐릭터가 받은 데미지를 저장하고 있어서 DamageAmount를 float으로 갖고있기도 하고 그렇다. 혹은 이 방법 외에도 별도의 함수를 만들어도 되기도 한다.

 

 TakeDamage함수를 사용하면 첫번째 인자값이 공격력이라 찾아와야하는데 MyGameInstance에서 가지고 있는 StatDat에는 MaxHp만 고정값으로 있지 실시간으로 바뀌는 HP는 가지고 있지 않다. 그래서 MyCharacter클래스에도 현재의 스탯값 정보를 가지고 있어야한다. MyCharacter에도 Hp 값을 int32로 추가하는 식으로도 가능하지만 일일이 가지고 있기 힘드므로 아예 별도의 스탯끼리 컴포넌트로 만들어서 MyCharacter가 가지고 있도록 하는 것도 하나의 방법이라 이렇게 만들었다. 엔진의 콘텐츠 브라우저에서 C++클래스쪽에서 우클릭으로 부모클래스를 ActorComponent로 하여 MyStatComponent라는 클래스를 만들었다. 여기에서 어떤 캐릭터의 스탯을 다 관리하는 역할을 할 것이다.

 

MyStatComponent클래스엔 기본적으로 상속받은 TickComponent가 있어 매 프레임마다 돌아가는 함수가 하나 있다. 이 스탯컴포넌트의 역할은 사실 hp 값들같은 정보를 들고 있을 뿐이지 나는 특별히 매 프레임마다 호출될 필요가 없어서 날려버리고 생성자에서 bCanEverTick = false;로 하였다.

대신 FMyCharacterData구조체와 같은 값을 가지고 있도록 Level, Attack, MaxHp을 멤버로 가지고 있고.가지고 오는 함수와 미리 값들이 변경될 때 호출할 함수들을 헤더파일에 정의하였다.

// MyStatComponent.h
#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "MyStatComponent.generated.h"


UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class TESTUNREALENGINE_API UMyStatComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	// Sets default values for this component's properties
	UMyStatComponent();

protected:
	// Called when the game starts
	virtual void BeginPlay() override;
	virtual void InitializeComponent() override;

public:
	void SetLevel(int32 Level);
	void OnAttacked(float DamageAmount);

	int32 GetLevel() { return Level; }
	int32 GetHp() { return Hp; }
	int32 GetAttack() { return Attack; }

private:
	UPROPERTY(EditAnywhere, Category=Stat, Meta=(AllowPrivateAccess=true))
	int32 Level;
	UPROPERTY(EditAnywhere, Category = Stat, Meta = (AllowPrivateAccess = true))
	int32 Hp;
	UPROPERTY(EditAnywhere, Category = Stat, Meta = (AllowPrivateAccess = true))
	int32 Attack;
};

레벨이 변경되었을 때 호출할 SetLevel과 피격판정을 했을 때 값을 바꾸기 위해 OnAttacked함수를 만들었다. 여기서 float DamageAmount는 MyCharacter에서 피격함수가 float을 쓰기때문에 맞춰준 것이다. 또 기본으로 있는 초기화 함수로 InitializeComponent()를 오버라이드 할 것인데 컴포넌트가 액터에 붙고 나서 호출된다. 다만, 이 InitializeComponent가 호출되려면 생성자에서 bWantsInitializeComponent = true; 로 해야한다. 

// MyStatComponent.cpp

#include "MyStatComponent.h"
#include "MyGameInstance.h"
#include "Kismet/GameplayStatics.h"

// Sets default values for this component's properties
UMyStatComponent::UMyStatComponent()
{
	// Set this component to be initialized when the game starts, and to be ticked every frame.  You can turn these features
	// off to improve performance if you don't need them.
	PrimaryComponentTick.bCanEverTick = false;

	bWantsInitializeComponent = true;

	Level = 1;
}


// Called when the game starts
void UMyStatComponent::BeginPlay()
{
	Super::BeginPlay();

	// ...
	
}

void UMyStatComponent::InitializeComponent()
{
	Super::InitializeComponent();

	SetLevel(Level);
}

void UMyStatComponent::SetLevel(int32 NewLevel)
{
	auto MyGameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (MyGameInstance)
	{
		auto StatData = MyGameInstance->GetStatData(NewLevel);
		if (StatData)
		{
			Level = StatData->Level;
			Hp = StatData->MaxHp;
			Attack = StatData->Attack;
		}
	}
}

void UMyStatComponent::OnAttacked(float DamageAmount)
{
	Hp -= DamageAmount;
	if (Hp < 0)
		Hp = 0;

	UE_LOG(LogTemp, Warning, TEXT("OnAttacked %d"), Hp);
}

이 내용과 함수의 정의부분을 MyStatComponent.cpp에 위와 같이 작성하였다.

InitializeComponent에서는 처음에 레벨에 따라 공격력과 초기 HP를 MaxHP로 설정하기 위해 SetLevel을 호출하였다. SetLevel은 일종의 데이터 매니저인 GameInstance에서 레벨에 따라 데이터를 긁어오게 하였다. 여기에 필요한 

#include "MyGameInstance.h"

#include "Kismet/GameplayStatics.h"

를 해줬다. SetLevel에서는 싱글톤처럼 사용할 수 있는 UGameplayStatics::GetGameInstance() 를 이용해 캐스팅해 MyGameInstance타입으로 만들어 레벨에 따른 데이터를 가져와 설정하였다.

공격을 받았을 경우는 우선 UI가 없으므로 hp를 깎고 로그를 찍도록 했다.

이렇게 만든 StatComponent에서는 스탯 관련 정보들을 들고있을 뿐만 아니라 공격을 받았을 경우 피격을 받는 쪽에서 호출할 수 있도록 상황에 따라 패시브가 발동한다든지 어그로를 끈다든지, 죽었을때 상대방이 누구인지 처리 등 하는 동작도 OnAttacked에 쓸 수 있다. (MyCharacter에서 HitResult.Actor->TakeDamage에서도 굉장히 많은 것을 받고 있었다.)

 

그리고 마지막으로 MyCharacter 클래스에서 이 컴포넌트를 들고 있도록

UPROPERTY(VisibleAnywhere)
class UMyStatComponent* Stat;

를 private멤버로 AMyCharacter클래스에 추가했다.

또 MyCharcter의 생성자에서 이 컴포넌트를 추가하는

#include "MyStatComponent.h"

AMyCharacter::AMyCharacter()
{
    // ...
    Stat = CreateDefaultSubobject<UMyStatComponent>(TEXT("STAT"));
}

를 그리고 MyCharacter에서 공격받았는지 체크하는 AttackCheck 함수에서

// MyCharacter.cpp

void AMyCharacter::AttackCheck()
{
	FHitResult HitResult;
	FCollisionQueryParams Params(NAME_None, false, this);

	float AttackRange = 100.f;
	float AttackRadius = 50.f;

	bool bResult = GetWorld()->SweepSingleByChannel(
		OUT HitResult,
		GetActorLocation(),
		GetActorLocation() + GetActorForwardVector() * AttackRange,
		FQuat::Identity,
		ECollisionChannel::ECC_GameTraceChannel2,
		FCollisionShape::MakeSphere(AttackRadius),
		Params);

	FVector Vec = GetActorForwardVector() * AttackRange;
	FVector Center = GetActorLocation() + Vec * 0.5f;
	float HalfHeight = AttackRange * 0.5f + AttackRadius;
	FQuat Rotation = FRotationMatrix::MakeFromZ(Vec).ToQuat();
	FColor DrawColor;
	if (bResult)
		DrawColor = FColor::Green;
	else
		DrawColor = FColor::Red;

	DrawDebugCapsule(GetWorld(), Center, HalfHeight, AttackRadius,
		Rotation, DrawColor, false, 2.f);


	if (bResult && HitResult.Actor.IsValid())
	{
		UE_LOG(LogTemp, Log, TEXT("Hit Actor : %s"), *HitResult.Actor->GetName());

		FDamageEvent DamageEvent;
		HitResult.Actor->TakeDamage(Stat->GetAttack(), DamageEvent, GetController(), this);
	}
}

 TakeDamage를 호출할때 공격력을 Stat->GetAttack()으로 꺼내와 쓸 수 있게 되었다.

TakeDamage함수의 다른 인자들을 보면

첫번째로 공격받은 정도, 두번째로는 데미지 이벤트는 그냥 FDamageEvent타입 변수를 만들어 우선 넘겨주었고 세번째로는 공격자인데 내 캐릭터가 HitResult.Actor를 공격한것이므로 this를 넘겨주었다.

 

이 TakeDamage는 Actor에 있는 가상함수인데 이를 필요에 따라 바꿔쓰기 위해 오버라이드하여 재정의하여 써보자. 

// MyCharacter.h

UCLASS()
class TESTUNREALENGINE_API AMyCharacter : public ACharacter
{
    // ...
public:
    virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;
}

// MyCharacter.cpp

float AMyCharacter::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser)
{
	Stat->OnAttacked(DamageAmount);

	return DamageAmount;
}

새로 OnAttacked를 내가 만들었으므로 이 안에서 넘겨주게 하였다.

 

이렇게 스탯을 관리하는 매니저를 만들고 데이터를 관리하도록 해봤는데 실행해보면 로그로 확인할 수 있다. 이제 실제로 UI를 만들어 써보자.

다음시간엔 UI를 만들 예정이다. 솔직히 UI는 유니티에 비해 작업속도가 더디고 좀 구리다..

그다음엔 터레인 툴하고 몬스터도 뿌리고 그 다음엔 언리얼5가 나오면 서버까지 붙여볼 예정..

 

 

충돌과 UI #2 - UI 실습

2D UI가 있고 3D UI가 있다. 2D UI는 클릭했을 때 메뉴가 뜨거나 하는 것이고 3DUI는 카메라가 멀어지면 같이 작아지는 특징이 있다. 또 화면에 고정되어있는 지도나 메뉴는 2D UI이다. 오늘은 2D와 3D의 중간 쯤 되는 캐릭터 머리위에 HP바를 달아보자.

 

컨텐츠쪽에 새로운 UI 폴더를 새로 만들고 우클릭 유저인터페이스>위젯 블루프린트를 선택해서 이름을 WBP_HpBar로 만들었다. 더블클릭해서 보면 뜨는 에디터창에서 조작한다. DX으로 하면 좌표와 이미지를 코드에 넣어서 UI배치하므로 보이지 않아 힘들었겠지만.. UI가 어려운게 특히 모바일은 크기가 다양하기 때문에 비율을 잘 정해야한다.

우선 화면크기를 Custom으로 한다음에 너비를 200 높이를 50으로 했다.

또 팔레트에서 일반>Progress Bar를 계층구조에 드래그드롭한 다음에 이름을 PB_HPBar로 지었다. 이 이름이 코드상에서 가져다 쓸 것이므로 맞춰줘야한다. 크기를 너비를 200으로 맞추고 높이는 대충 맞춘다.

 

오른쪽 디테일에서 보면 퍼센트를 조정해서 차오르고 색을 따로 지정할 수 있다. 색은 에디터에서 지정하고 값은 코드상에서 PB_HPBar.Percent를 조정하면 된다. 또 그래프탭을 누르면 블루프린트코드 같은 것도 볼 수 있고 모두 블루프린트로 하면 느려지므로 이렇게 위젯 하나에 하나의 C++클래스를 만들어 대응하는게 맵핑하기에 수월하고 좋다.

 

엔진으로 돌아가 우클릭>새 클래스 추가로 UserWidger을 부모로 한 MyCharacterWidget으로 이름을 지었다.

애니메이션 블루프린트를 관리할 때는 AnimInstance클래스를 만든 다음에 속도, 움직임 방향 등의 정보를 넣고 관리하고 있었다. 매 틱마다 NativeUpdateAnimation에서 정보들을 긁어가다 쓰고 있었다. 이와 같지는 않지만 비슷한 방식으로 위젯도 관리한다. MyCharacterWiget 클래스를 모든 위젝 클래스들의 부모 클래스로 바꿔줘서 가능하다.

 WBP_HPBar로 돌아가서 그래프탭>클래스 세팅에서 부모 클래스를 MyCharacterWidget으로 바꾸고 컴파일+저장한다. 

애니메이션 블루프린트 BP_MyAnim 도 부모클래스로 MyAnimInstance를 가지고 있던것과 마찬가지이다.

 

UI가 캐릭터 위에 뜨도록 작업해보자.

 MyCharacter클래스에 UWidgetComponent& HpBar를 추가하고  생성자에서 컴포넌트를 CreateDefaultSubObject로 만들고 HPBar를 캐릭터 메쉬에다가 붙여줬다. #include "Components/WidgetComponent.h", #inclue "MyCharacterWidget.h"도 해주었다. 

또 HpBar->SetWidgetSpace로 스크린에 붙여주었다. 월드에 붙이거나 스크린에 UI를 붙일 수 있는데 매쉬처럼 월드에 3D로 붙이거나 2D로 항상 화면에 보이는 스크린 중에 선택 하는 것이다.

// MyCharacter.h

UCLASS()
class TESTUNREALENGINE_API AMyCharacter : public ACharacter
{
     // ...
 public:
	UPROPERTY(VisibleAnywhere)
	class UWidgetComponent* HpBar;
 }
 
 // MyCharacter.cpp
 
// ...
#include "MyStatComponent.h"
#include "Components/WidgetComponent.h"
#include "MyCharacterWidget.h"

AMyCharacter::AMyCharacter()
{
    // ...
    
	HpBar = CreateDefaultSubobject<UWidgetComponent>(TEXT("HPBAR"));
	HpBar->SetupAttachment(GetMesh());
	HpBar->SetRelativeLocation(FVector(0.f, 0.f, 200.f));
	HpBar->SetWidgetSpace(EWidgetSpace::Screen);

	static ConstructorHelpers::FClassFinder<UUserWidget> UW(TEXT("WidgetBlueprint'/Game/UI/WBP_HpBar.WBP_HpBar_C'"));
	if (UW.Succeeded())
	{
		HpBar->SetWidgetClass(UW.Class);
		HpBar->SetDrawSize(FVector2D(200.f, 50.f));
	}
}

void AMyCharacter::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	AnimInstance = Cast<UMyAnimInstance>(GetMesh()->GetAnimInstance());
	if (AnimInstance)
	{
		AnimInstance->OnMontageEnded.AddDynamic(this, &AMyCharacter::OnAttackMontageEnded);
		AnimInstance->OnAttackHit.AddUObject(this, &AMyCharacter::AttackCheck);
	}

	HpBar->InitWidget();

	auto HpWidget = Cast<UMyCharacterWidget>(HpBar->GetUserWidgetObject());
	if (HpWidget)
		HpWidget->BindHp(Stat);
}

그리고 일반 UWidgetComponent가 아닌 직접 블루프린트로 만든 컴포넌트를 가지고 오고싶은 것이므로 

ConstructorHelpers::FClassFinder로  UUWidget타입을 찾을 것인데 그 경로는 복사해서 붙여넣기로 해주었다.

*그리고 블루프린트로 만든 클래스는 _C를 경로 뒤에 붙이는걸 잊지 말자!.

또 받아오는 것을 성공했으면 객체에 붙여주고 크기도 설정했다.

 

PostInitilaizeComponents에서 InitWidget()하는 것도 잊지 않아주고 체력이 닳든지 HP바의 값이 변할일이 생기면 호출할 함수로 일종의 델리게이트처럼 만든 함수를 묶어주는 일도 했다.(소스코드는 뒤에 나온다.)

 

🌟

그리고 이렇게 UI를 사용하기 위해선 [프로젝트이름].Build.cs란 파일이 있는데 가면 언리얼에서 빌드하는 시스템을 설정해놓은 파일이다.

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "UMG" });

그 중에서도 위에 UMG를 추가했다. 안하면 경우에 따라 에러가 난다.ㅠㅠ

 

 

이제 HP바의 게이지가 바뀌는 작업을 해보자.(위 PostInitializeComponent에서 바인당한 부분)

지금까지 캐릭터의 스탯들은 MyStatComponent에서 가지고 관리하고 있었는데 최대체력 MaxHp를 int32타입으로 추가하고 MaxHp값을 return 하는 GetMaxHp와 Hp/MaxHp값을 반환하는 GetHpRatio()를 MyStatComponent.h에 추가해주었다. 그리고 SetHp도 만드는데 여기서 새로운 hp값을 받아서 UI연동을 한다.

// MyStatComponent.h
// ...
DECLARE_MULTICAST_DELEGATE(FOnHpChanged);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class TESTUNREALENGINE_API UMyStatComponent : public UActorComponent
{
// ...
public:
	FOnHpChanged OnHpChanged;
};


// MyStatComponent.cpp
// ...
void UMyStatComponent::InitializeComponent()
{
	Super::InitializeComponent();

	SetLevel(Level);
}

void UMyStatComponent::SetLevel(int32 NewLevel)
{
	auto MyGameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));
	if (MyGameInstance)
	{
		auto StatData = MyGameInstance->GetStatData(NewLevel);
		if (StatData)
		{
			Level = StatData->Level;
			SetHp(StatData->MaxHp);
			MaxHp = StatData->MaxHp;
			Attack = StatData->Attack;
		}
	}
}

void UMyStatComponent::SetHp(int32 NewHp)
{
	Hp = NewHp;
	if (Hp < 0)
		Hp = 0;

	OnHpChanged.Broadcast();
}

void UMyStatComponent::OnAttacked(float DamageAmount)
{
	int32 NewHp = Hp - DamageAmount;
	SetHp(NewHp);
	//UE_LOG(LogTemp, Warning, TEXT("OnAttacked %d"), Hp);
}

FOnHpChanged라는 이름으로 멀티캐스트 델리게이트를 선언한 다음에 그 타입으로 OnHpChanged객체를 UMyStatComponent의 멤버로 갖게했다. 또 SetHp를 위와 같이 NewHp를 받아서 검사를 한 다음에 OnHpChanged에 브로드캐스팅으로 구독하고 있던 함수를 부르도록 하였다. OnHpChange를 구독할 함수는 아래와 같이 넣으면 된다. OnAttacked에서도 hp를 직접 바꾸지 않고 SetHp를 통해 확인하도록 하였고 레벨에 따라 초기화를 하는 SetLevel에서도  UI도 바뀌게 모두 MaxHp를 직접 수정하지 않도 SetHp를 호출하도록 하였다.

아래는 델리게이트 방식으로 콜백으로 호출할 함수이다. UI를 수정하는 것이므로 hp바 ui를 들고있는 MyCharacterWidget클래스에서 구독한다. 이러한 방식 말고도 혹은 싱글톤 방식으로 MyStatComponent를 만들어서 UI에서 접근하게 해도 되고 방법이 여러개이다. 

// MyCharacterWidget.h
#pragma once

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

UCLASS()
class TESTUNREALENGINE_API UMyCharacterWidget : public UUserWidget
{
	GENERATED_BODY()

public:
	void BindHp(class UMyStatComponent* StatComp);

	void UpdateHp();
	
private:
	TWeakObjectPtr<class UMyStatComponent> CurrentStatComp;

	UPROPERTY(meta=(BindWidget))
	class UProgressBar* PB_HpBar;
};


// MyCharacterWidget.cpp

#include "MyCharacterWidget.h"
#include "MyStatComponent.h"
#include "Components/ProgressBar.h"

void UMyCharacterWidget::BindHp(class UMyStatComponent* StatComp)
{
	//PB_HpBar123 = Cast<UProgressBar>(GetWidgetFromName(TEXT("PB_HpBar"));
	CurrentStatComp = StatComp;
	StatComp->OnHpChanged.AddUObject(this, &UMyCharacterWidget::UpdateHp);
}

void UMyCharacterWidget::UpdateHp()
{
	if (CurrentStatComp.IsValid())
		PB_HpBar->SetPercent(CurrentStatComp->GetHpRatio());
}

WBP_HPBar는 위젯블루프린트 클래스인데 부모를 위 MyWidget클래스로 가지고 있는 것이다. 멤버로 가지고 있는 HP바 이름이 PB_HpBar이므로 UProgressBar타입의 객체 이름을 그대로 사용했다. meta= BindWidget을 했는데 블루프린트에서 만든것과 Cpp클래스에서 만든것과 바로 바인드가 같이 되게 하는 UPROPERTY다. 

또 hp가 변하는 부분은 MyStatComponent에 있으므로 이 클래스 타입을 받아서 묶어주는 곳을 BindHp함수에 넣고 BindHp는 이 위젯컴포넌트를 실제로 들고있는 MyCharacter에서 MyCharacter도 PostInitializeComponets에서 아래와 같이 묶도록 하였다.

MyCharacter::PostInitializeComponts에서 호출하는 BindHp에서는 AddUObject라는 델리게이트에서 원래 지원하는 함수에 바인딩할 함수 UpdateHp를 같은 UMyCharacterWidget에 만들어 프로그레스바의 퍼센트를 바꿔준다.

이렇게 BindHp에서 클래스를 넘겨받아서 사용하는게 객체지향의 면에선 더 맞는 방법이지만 싱글톤으로 사용하는것이 남용만 않는다면 개발 속도면에선 훨씬 빠를 수 있다.

void AMyCharacter::PostInitializeComponents()
{
	Super::PostInitializeComponents();

	AnimInstance = Cast<UMyAnimInstance>(GetMesh()->GetAnimInstance());
	if (AnimInstance)
	{
		AnimInstance->OnMontageEnded.AddDynamic(this, &AMyCharacter::OnAttackMontageEnded);
		AnimInstance->OnAttackHit.AddUObject(this, &AMyCharacter::AttackCheck);
	}

	HpBar->InitWidget();

	auto HpWidget = Cast<UMyCharacterWidget>(HpBar->GetUserWidgetObject());
	if (HpWidget)
		HpWidget->BindHp(Stat);
}

 정리하면 MyStatComponet의 SetHp함수에서 값의 변화가 일어나면 이를 구독하고있던 UMyCharacterWidget의 UpdateHp에서 알림을 받고 ui의 값을 바꾸는 것이다. 또 구독한다고 명시를 MySatComponent의 BindHp에서 하는데 MyStatComponent는 MyCharacter가 가지고 있으므로 MyCharacter::PostInitializeComponet에서 부른다.

이외에도 OnAttacked에서 공격을 받거나 처음에 레벨에 따라 hp를 세팅할 때 hp바를 바꾸게 된다.