[이득우의 언리얼 C++ 게임 개발의 정석] Chapter 11. 게임 데이터와 UI 위젯

수민·2023년 3월 29일
0
post-thumbnail

이득우의 언리얼 C++ 게임 개발의 정석을 읽고 개인 공부 목적으로 요약 정리한 글입니다!


👀 게임 데이터 임포트 & 관리

엑셀 데이터 불러오기

보통 캐릭터 스탯 데이터는 파일로 저장해두고 초기화할 때 불러온다.
게임 중 변하지 않으니까.

그래서 우리는
게임 인스턴스 (게임 앱을 관리하기 위한 용도)에서 관리하도록 해보자

게임 시작 과정


게임 앱을 초기화
월드 생성
월드에 레벨 로딩하여 스테이지 생성
플레이어 로그인
게임 시작

이렇게 되어 있는 엑셀 데이터 파일이 있다고 하면,
CSV (쉼표로 분리) 형식으로 저장한다.
언리얼 엔진이 제공하는 행과 열로 구성된 테이블 데이터를 불러오는 기능은 CSV 파일 형식만 가능하기 때문이당

위와 똑같은 데이터인데 CSV 파일 형식이고 메모장에서 연다면

요렇게 되어 있을 거다.

이 파일을 언리얼에서 불러들이기 위해서
테이블 데이터의 각 열의 이름유형이 동일한 구조체를 선언해야 한다.

언리얼 엔진에서는 FTableRowBase 구조체를 제공한다
이 구조체를 상속받아서 **게임 인스턴스 헤더((에 선언하면 된다.

Name 열 데이터는 언리얼에서 자동으로 키값으로 사용하므로 선언에서 제외한다

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

public:
	FMyCharacterData() : Level(1), MaxHP(100.0f), Attack(10.0f), DropExp(20), NextExp(30) {}

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
		int32 Level;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
		float MaxHP;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
		float Attack;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
		int32 DropExp;

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Data")
		int32 NextExp;
};

에디터 인터페이스에서 연동해서 사용해야 하므로
언리얼오브젝트의 선언과 유사하게 USTRUCT 매크로를 사용하고
구조체 내부에도 GENERATED_BODY() 매크로를 선언하자.

언리얼 에디터로 가서 폴더에서 우클릭하며 이렇게 임포트가 뜬다.
여기서
CSV 파일을 선택하면 된다.

임포트 시 반드시 엑셀파일을 종료하고 임포트 해야 한다!

임포트가 됐으면 이렇게 게임 데이터 에셋이 생성되었을 것이당.

이제 게임인스턴스 클래스에서 직접 읽어오는 코드를 작성해보도록 하장

MyGameInstance.h

class HUNT_PROTOTYPE_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:	
	UMyGameInstance();

	virtual void Init() override;
	FMyCharacterData* GetMyCharacterData(int32 Level);

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

MyGameInstance.cpp

#include "MyGameInstance.h"

UMyGameInstance::UMyGameInstance()
{
	FString CharacterDataPath = TEXT("DataTable'/Game/GameData/Hunt_Prototype_11.Hunt_Prototype_11'");
	static ConstructorHelpers::FObjectFinder<UDataTable> DT_MYCHARACTER(*CharacterDataPath);
	HUNT_CHECK(DT_MYCHARACTER.Succeeded());
	MyCharacterTable = DT_MYCHARACTER.Object;
	HUNT_CHECK(MyCharacterTable->GetRowMap().Num() > 0);
}

void UMyGameInstance::Init()
{
	Super::Init();
	HUNT_LOG(Warning, TEXT("DropExp of Level 20 MyCharacter : %d"), GetMyCharacterData(20)->DropExp);
}

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

액터로 테이블 데이터 관리하기

캐릭터에 액터 컴포넌트를 부착해서
테이블 데이터를 관리하장.

그래서 ActorComponent를 상속받는 클래스를 만들거다

ActorComponent 클래스

자동으로 BeginPlay, TickComponent 함수가 제공된다.
우리는 Tick이 불필요한 컴포넌트이니까 TickComponent를 사용하지 않을거다

Actor의 PostInitializeComponents == ActorComponents의 InitializeComponent
ActorComponent의 InitializeComponent는 Actor의 PostInitializeComponents 함수 호출 직전에 호출된다.

이 함수를 사용해서 컴포넌트의 초기화 로직을 구현할거다.
InitializeComponent 함수가 불리기 위해서는
생성자에서 bWantsInitializeComponent 값을 true로 설정해야 한다 !!

클래스에서 HP 관리하기

클래스를 만들고
임포트한 데이터를 여기서 관리하도록 한다.

공격이 들어오면 HP를 깎걸 여기서 한다

MyCharacterStatComponents.h

#pragma once

#include "Hunt_Prototype.h"
#include "Components/ActorComponent.h"
#include "MyCharacterStatComponent.generated.h"

DECLARE_MULTICAST_DELEGATE(FOnHPIsZeroDelegate);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class HUNT_PROTOTYPE_API UMyCharacterStatComponent : public UActorComponent
{
	GENERATED_BODY()

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

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

public:
	void SetNewLevel(int32 NewLevel);
    void SetDamage(float NewDamage);
	float GetAttack();

	FOnHPIsZeroDelegate OnHPIsZero;
    
private:
	struct FMyCharacterData* CurrentStatData = nullptr;

	UPROPERTY(EditInstanceOnly, Category = Stat, Meta = (AllowPrivateAccess = true))
		int32 Level;

	UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat, Meta = (AllowPrivateAccess = true))
		float CurrentHP;
};

CurrentHP의 UPROPERTY 속성을 보면 Transient가 있다.

Transient 키워드

게임을 시작할 때마다 변경되므로 값을 보관할 의미가 없는 경우,
Transient 키워드를 붙여서 해당 속성을 직렬화에서 제외시킬 수 있다.

MyCharacterStatComponents.cpp

#include "MyCharacterStatComponent.h"
#include "MyGameInstance.h"

// Sets default values for this component's properties
UMyCharacterStatComponent::UMyCharacterStatComponent()
{
	// 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;
}

void UMyCharacterStatComponent::InitializeComponent()
{
	Super::InitializeComponent();
	SetNewLevel(Level);
}

void UMyCharacterStatComponent::SetNewLevel(int32 NewLevel)
{
	auto MyGameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));

	HUNT_CHECK(nullptr != MyGameInstance);
	CurrentStatData = MyGameInstance->GetMyCharacterData(NewLevel);
	if (nullptr != CurrentStatData) {
		Level = NewLevel;
		CurrentHP = CurrentStatData->MaxHP;
	}
	else {
		HUNT_LOG(Error, TEXT("Level (%d) data doesn't exist"), NewLevel);
	}
}

void UMyCharacterStatComponent::SetDamage(float NewDamage)
{
	HUNT_CHECK(nullptr != CurrentStatData);
	CurrentHP = FMath::Clamp<float>(CurrentHP - NewDamage, 0.0f, CurrentStatData->MaxHP);
    if (CurrentHP <= 0.0f) {
    	OnHPIsZero.Broadcast();
    }
}

float UMyCharacterStatComponent::GetAttack()
{
	HUNT_CHECK(nullptr != CurrentStatData, 0.0f);
	return CurrentStatData->Attack;
}

이렇게하면 캐릭터에 액터 컴포넌트가 잘 붙어있고,
Level에 따라 데이터에서 읽어온 CurrentHP가 설정된다.

그니까
캐릭터가 데미지를 받으면 받은 만큼 CurrentHp에서 차감하고
작거나 같으면 죽도록 했따

데미지 처리를 MyCharacter::TakeDamage에서 했다면 MyCharacerStatComponentL::SetDamage에서 하도록 했당.

액터컴포넌트가 할 일

  • 데미지 계산 처리
  • 데미지 <= 0 일 때 캐릭터에게 알려주기

캐릭터에게 알려주기 위해서
델리게이트를 선언하고
캐릭터에서 바인딩하자

MyCharacter.cpp

// Fill out your copyright notice in the Description page of Project Settings.


#include "MyCharacter.h"
#include "MyAnimInstance.h"
#include "MyWeapon.h"
#include "MyCharacterStatComponent.h"
#include "DrawDebugHelpers.h"
#include "Components/WidgetComponent.h"
#include "MyCharacterWidget.h"

// Sets default values
AMyCharacter::AMyCharacter()
{
 	...
    CharacterStat = CreateDefaultSubobject<UMyCharacterStatComponent>(TEXT("CHARACTERSSTAT"));
	...
}

void AMyCharacter::PostInitializeComponents()
{
	...
    
	CharacterStat->OnHPIsZero.AddLambda([this]()->void {
		HUNT_LOG(Warning, TEXT("OnHPIsZero"));
		MyAnim->SetDeadAnim();
		SetActorEnableCollision(false);
		});
}

float AMyCharacter::TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser)
{
	float FinalDamage = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);

	HUNT_LOG(Warning, TEXT("Actor : %s took Damage : %f"), *GetName(), FinalDamage);

	CharacterStat->SetDamage(FinalDamage);

	return FinalDamage;
}
void AMyCharacter::AttackCheck()
{
	
	if (bResult) {
		if (::IsValid(HitResult.GetActor())) {
			HUNT_LOG(Warning, TEXT("Hit Actor Name : %s"), *HitResult.GetActor()->GetName());

			FDamageEvent DamageEvent;
			HitResult.GetActor()->TakeDamage(CharacterStat->GetAttack(), DamageEvent, GetController(), this);
		}
	}
}

결과

이렇게 잘 관리할 수 있고,

잘 뜬다.


👀 캐릭터 위젯 UI 제작

위젯 블루프린트 이용해서 UI 제작하기

위젯 블루프린트를 사용해서 UI 에셋을 생성하자

HP를 표시할 HPBar를 구현할거니까 ProgresBar를 사용할거다.

ProgressBar를 계층구조에 드래그하고
우클릭 > 감싸기 > 세로 박스를 선택한다
그러면 ui를 세로로 정렬할 수 있당.

그리고 이제 위 아래에 Spacer를 추가해서 넣어준다.

Spacer / PB_HPBar / Spacer
이 순으로 정렬되어 있을 건데,
40, 20, 40이 비율로 채워준다.

색도 빨간색으로 채워준다.

그러면 잘 된다.

모듈과 빌드 설정

UI를 캐릭터에 부착해보잣.
UWidgetComponent : 액터에 UI 위젯을 부착할 수 있도록 언리얼에서 제공하는 클래스

이걸 사용하려면
그냥 냅다 선언해버리면 안된다.

현재 프로젝트 설정에 UI와 관련된 엔진 모듈이 지정되어 있지 않을거다.

언리얼엔진 개발 환경...모듈 ...

언리얼 엔진의 소스 = 많은 수의 모듈이 뭉쳐진 집합.
핵심적으로 사용하는 몇 개의 모듈만 개발 환경에 지정해 사용하고 있는 중
프로젝트명.Build.cs에 보면 사용 중인 모듈을 볼 수 있다.

사용하고 싶은 모듈을 저기다 추가해주면 된다.

왜 되는건가욥?

언리얼 빌드 툴 덕분에

언리얼 빌드 툴

언리얼 C++ 프로젝트 관리를 담당하는 툴.
C#으로 제작된 명령행 툴
C# 응용 프로그램의 기능에는 실행 중에 C# 코드를 컴파일하고 사용하는 기능이 있다.
Build.cs 파일에 프로젝트 설정을 코드로 지정하면 언리얼 빌드툴은 실행 중에 이를 분석해서 컴파일하고 우리 프로젝트를 위한 개발 환경 결과를 생성해준다!

암튼...
그래서 이렇게 UMG 모듈을 추가해주면,
이 모듈의 전체 경로를 몰라도
Public 폴더에 있는 헤더파일을 자유롭게 참조할 수 있땅.

여기여기 ...

이제 암튼 잘 될거니까

위젯 컴포넌트의 WidgetClass로 등록하면 된당.

MyCharacter.cpp

...
#include "Components/WidgetComponent.h"

AMyCharacter::AMyCharacter()
{
 	...
	HPBarWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("HPBARWIDGET"));

	SpringArm->SetupAttachment(GetCapsuleComponent());
	Camera->SetupAttachment(SpringArm);
	HPBarWidget->SetupAttachment(GetMesh());


	HPBarWidget->SetRelativeLocation(FVector(0.0f, 0.0f, 180.0f));
	HPBarWidget->SetWidgetSpace(EWidgetSpace::Screen);
	static ConstructorHelpers::FClassFinder<UUserWidget> UI_HUD(TEXT("WidgetBlueprint'/Game/UI/UI_HPBar.UI_HPBar_C'"));
	if (UI_HUD.Succeeded()) {
		HPBarWidget->SetWidgetClass(UI_HUD.Class);
		HPBarWidget->SetDrawSize(FVector2D(150.0f, 50.0f));
	}
}

UI와 데이터 연동

캐릭터의 스탯이 변경되면
UI에게 전달해서
UI의 ProgressBar가 변경되도록 하고 싶다.

그러면
스탯이 변경되었을 때 UI에게 알려줘야 한다.
당 연히
델리게이트로 선언해줄거다.

UI에서 캐릭터 컴포넌트에 연결해서
HP가 변할 때마다 ProgressBar를 업데이트하도록 하자

UE 4.21부터 Widget의 초기화 시점이 PostInitializeComponents에서 BeginPlay로 변경됨

애니메이션의 애님 그래프 == UI의 디자이너
로직은
애니메이션의 애님 인스턴스 == UI의 UserWidget

UI가 초기화 되는 시점
UI 시스템이 준비되면 NativeConstruct 함수가 호출됨
UI 생성 : PlayerController::BeginPlay
BeginPlay 전에 호출된 PostInitializeComponents에서 발생한 명령은 UI에 반영되지 않는다
그래서
NativeConstruct 함수에서 위젯 내용을 업데이트 해야 한다.

코드

MyCharacterStatComponent.h

DECLARE_MULTICAST_DELEGATE(FOnHPIsZeroDelegate);
DECLARE_MULTICAST_DELEGATE(FOnHPChangedDelegate);


UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class HUNT_PROTOTYPE_API UMyCharacterStatComponent : public UActorComponent
{
	...
public:
	void SetNewLevel(int32 NewLevel);
	void SetDamage(float NewDamage);
	void SetHP(float NewHP);
	float GetAttack();
	float GetHPRatio();

	FOnHPIsZeroDelegate OnHPIsZero;
	FOnHPChangedDelegate OnHPChanged;
	...
};

MyCharacterStatComponent.cpp

void UMyCharacterStatComponent::SetNewLevel(int32 NewLevel)
{
	auto MyGameInstance = Cast<UMyGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));

	HUNT_CHECK(nullptr != MyGameInstance);
	CurrentStatData = MyGameInstance->GetMyCharacterData(NewLevel);
	if (nullptr != CurrentStatData) {
		Level = NewLevel;
		SetHP(CurrentStatData->MaxHP);
	}
	else {
		HUNT_LOG(Error, TEXT("Level (%d) data doesn't exist"), NewLevel);
	}
}

void UMyCharacterStatComponent::SetDamage(float NewDamage)
{
	HUNT_CHECK(nullptr != CurrentStatData);
	SetHP(FMath::Clamp<float>(CurrentHP - NewDamage, 0.0f, CurrentStatData->MaxHP));
}

void UMyCharacterStatComponent::SetHP(float NewHP)
{
	CurrentHP = NewHP;
	OnHPChanged.Broadcast();
	if (CurrentHP < KINDA_SMALL_NUMBER) {
		CurrentHP = 0.0f;
		OnHPIsZero.Broadcast();
	}
}

float UMyCharacterStatComponent::GetAttack()
{
	HUNT_CHECK(nullptr != CurrentStatData, 0.0f);
	return CurrentStatData->Attack;
}

float UMyCharacterStatComponent::GetHPRatio()
{
	HUNT_CHECK(nullptr != CurrentStatData, 0.0f);

	return (CurrentStatData->MaxHP < KINDA_SMALL_NUMBER) ? 0.0f : (CurrentHP / CurrentStatData->MaxHP);
}

float 값을 0과 비교할 때, 미세한 오차 범위 내에 있는지 판단해야 하므로
KINDA_SMALL_NUMBER 매크로를 사용하도록하자

profile
우하하

0개의 댓글