KoreanFoodie's Study

이득우의 언리얼 C++ 11 : 게임 데이터와 UI 위젯 본문

Game Dev/Unreal C++ : Tutorial

이득우의 언리얼 C++ 11 : 게임 데이터와 UI 위젯

GoldGiver 2022. 3. 19. 11:57

이득우님의 "이득우의 언리얼 C++ 게임 개발의 정석" 책을 따라가며 실습한 내용을 정리한 포스팅입니다. 실습에 필요한 자료들은 이 링크에서, 제가 작업한 예제 소스 완성본은 여기에서 찾아보실 수 있습니다. (저는 언리얼 4.27.2 버전 기준으로 작업하였습니다)

엑셀 데이터의 활용

엑셀에 저장돼 있는 캐릭터의 스탯 데이터 테이블을 언리얼 엔진에 불러들이는 기능을 구현해본다.

캐릭터 스탯 데이터는 게임의 기반을 이루는 변하지 않는 데이터이므로 보통 게임 앱이 초기화될 때 불러들인다. 언리얼엔진은 게임 앱을 관리하기 위한 용도로 게임 인스턴스라는 언리얼 오브젝트를 제공한다. 이 게임 인스턴스가 캐릭터의 스탯을 관리하도록 설계하면 게임 앱이 초기화될 때 캐릭터 스탯 데이터를 불러들이고, 게임 앱이 종료될 때까지 스탯 데이터는 보존된다.

GameInstance 를 부모 클래스로 하는 새로운 클래스를 만들고 기능을 추가해 보자.

 

이름은 ABGameInstance 로 변경한다
맵 & 모드 설정에서 ABGameInstance 로 변경해준다!

게임 앱이 초기화되면 언리얼 엔진은 GameInstance 의 Init 함수를 호출한다. Init 함수를 오버라이딩하고 로그를 찍어보자.

 

ABGameInstance.h

#include "ArenaBattle.h"
...

UCLASS()
class AREANABATTLE_API UABGameInstance : public UGameInstance
{
    GENERATED_BODY()
    
public:
    UABGameInstance();
    
    virtual void Init() override;
};

 

ABGameInstance.cpp

...

UABGameInstance::UABGameInstance()
{

}

UABGameInstance::Init()
{
    Super::Init();
    ABLOG_S(Warning);
}

 

게임을 시작하는 과정은 다음과 같다 :

  1. 게임 앱의 초기화 - UGameInstance::Init
  2. 레벨에 속한 액터의 초기화 - AActor::PostInitializeComponents
  3. 플레이어의 로그인 - AGameMode::PostLogin
  4. 게임의 시작 - AGameMode::StartPlay, AActor::BeginPlay

 

게임 데이터를 관리할 게임 인스턴스를 설정하고 나면, 이번에는 게임 데이터를 프로젝트에 임포트하고 불러들이는 기능을 구현해 보자. 예제 소스의 Resource->Chapter11 폴더의 ABCharacterData.csv 를 열어보자.

 

우리는 위의 csv 파일을 '쉼표로 분리하여 저장' 한 후, 임포트할 것이다. 이때, 테이블 데이터의 각 열의 이름과 유형이 동일한 구조체를 선언해야 한다. FTableRowBase 구조체를 상속받은 FABCharacterData 라는 이름의 구조체를 게임 인스턴스의 헤더에 선언한다.

구조체를 생성할 때 언리얼이 지정한 규칙에 따라줘야 에디터 인터페이스에서 연동해 사용할 수 있다. 언리얼 오브젝트의 선언과 유사하게 USTRUCT 매크로를 구조체 선언 윗줄에 넣고 구조체 내부에 GENERATED_BODY( ) 매크로를 선언한다. 골격을 완성한 후 첫 번째에 위치한 Name 열 데이터를 제외하고 각 열의 이름과 동일한 멤버 변수를 타입에 맞춰 선언해 준다. Name 은 언리얼에서 자동으로 키 값으로 사용한다.

 

ABGameInstance.h

...
#include "Engine/DataTable.h"
...

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

public:
	FABCharacterData() : Level(1), MaxHP(100.0f), Attack(10.0f), DropExp(10), 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;
};

...

컴파일 후 에디터에서 CSV 데이터를 임포트한다.

 

임포트를 누르고 우리가 아까 생성한 ABCharacterData 를 행 타입으로 선택한다. 이때 엑셀을 종료시켜야 한다.

 

임포팅에 성공했다

이제 ABGameInstance 에서 해당 데이터 테이블을 읽도록 구현한다.

 

ABGameInstance.h

...

UCLASS()
class ARENABATTLE_API UABGameInstance : public UGameInstance
{
    ...
    
public:
    FABCharacterData* GetABCharacterData(int32 Level);
    
private:
    UPROPERTY()
    class UDataTable* ABCharacterTable;
};

 

ABGameInstance.cpp

#include "ABGameInstance.h"

UABGameInstance::UABGameInstance()
{
    FString CharacterDataPath = TEXT("/Game/Book/GameData/ABCharacterData.ABCharacterData");
    static ConstructorHelpers::FObjectFinder<UDataTable> DT_ABCHARACTER(*CharacterDataPath);
    ABCHECK(DT_ABCHARACTER.Succeeded());
    ABCharacterTable = DT_ABCHARACTER.Object;
    ABCHECK(ABCharacterTable->GetRowMap().Num() > 0);
}

void UABGameInstance::Init()
{
	Super::Init();
	ABLOG(Warning, TEXT("DropExp of Level 20 ABCharacter : %d"), GetABCharacterData(20)->DropExp);
}

FABCharacterData* UABGameInstance::GetABCharacterData(int32 Level)
{
	return ABCharacterTable->FindRow<FABCharacterData>(*FString::FromInt(Level), TEXT(""));
}

ABCharacterTable 을 받은 후, 20 레벨의 DropExp 를 로그로 출력하는 코드를 삽입했다.

 

 

액터 컴포넌트의 제작

액터 컴포넌트를 상속받은, 스탯을 관리하는 ABCharacterStatComponent 를 만들고 ABCharacter 액터에 붙여 사용해 보자. ABCharacter 클래스에 새로운 컴포넌트를 멤버 변수로 선언하기만 하면 된다.

 

ABCharacter.h

...

UCLASS()
class ARENABATTLE_API AABCharacter : public ACharacter
{
    ...
    
public:
    UPROPERTY(VisibleAnywhere, Category = Stat)
    class UABCharacterStatComponent* CharacterStat;
    
    ...
}

 

ABCharacter.cpp

...
#include "ABCharacterStatComponent.h"

AABCharacter::AABCharacter()
{
    ...
    
    CharacterStat = CreateDefaultSubobject<UABCharacterStatComponent>(TEXT("CHARACTERSTAT"));
    
    ...
}

 

컴파일을 하면 캐릭터의 컴포넌트 목록 하단에 CharacterStat 이라는 액터 컴포넌트가 추가된다.

 

액터 컴포넌트를 생성하면 자동으로 제공되는 템플릿 코드에는 BeginPlay 와 TickComponent 가 있다. 스탯에서는 Tick 이 필요 없으므로 bWantsInitializeComponent 값을 false 로 설정한다.

액터의 PostInitializeComponents 에 대응하는 함수는 InitializeComponent 함수다. 이 함수는 액터의 PostInitializeComponents 함수가 호출되기 바로 전에 호출된다.

ABCharacterStatComponent 에서는 레벨에 따라 체력과 데미지를 관리할 것이다.

 

ABCharacterStatComponent.h

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

#pragma once

#include "ArenaBattle.h"
#include "Components/ActorComponent.h"
#include "ABCharacterStatComponent.generated.h"

DECLARE_MULTICAST_DELEGATE(FOnHPIsZeroDelegate);
DECLARE_MULTICAST_DELEGATE(FOnHPChangedDelegate);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class ARENABATTLE_API UABCharacterStatComponent : public UActorComponent
{
	GENERATED_BODY()

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

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

public:	
	// Called every frame
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

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

	FOnHPIsZeroDelegate OnHPIsZero;
	FOnHPChangedDelegate OnHPChanged;

private:
	struct FABCharacterData* CurrentStatData = nullptr;

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

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

FOnHPIsZeroDelegate 와 FOnHPChangedDelegate 타입을 선언했다. 이는 추후 멀티캐스트 델리게이트로 사용할 것이다. CurrentStatData 는 ABGameInstance 에서 이전에 정의한 구조체 타입으로 선언했다. HPRatio 등의 값은 추후 체력바 UI 에서 활용한다.

 

ABCharacterStatComponent.cpp

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


#include "ABCharacterStatComponent.h"
#include "ABGameInstance.h"

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

	// ...
	
}

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

void UABCharacterStatComponent::SetNewLevel(int32 NewLevel)
{
	auto ABGameInstance = Cast<UABGameInstance>(UGameplayStatics::GetGameInstance(GetWorld()));

	ABCHECK(nullptr != ABGameInstance);
	CurrentStatData = ABGameInstance->GetABCharacterData(NewLevel);
	if (nullptr != CurrentStatData)
	{
		Level = NewLevel;
		SetHP(CurrentStatData->MaxHP);
	}
	else
	{
		ABLOG(Error, TEXT("Level (%d) data doesn't exist"), NewLevel);
	}
}

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

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

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

float UABCharacterStatComponent::GetHPRatio()
{
	ABCHECK(nullptr != CurrentStatData, 0.0f);

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

// Called every frame
void UABCharacterStatComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
	Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

	// ...
}

InitializeComponent 에서는 SetNewLevel 을 호출해 레벨을 설정한다. 

SetNewLevel 에서는 ABGameInstance 를 받고 레벨에 따른 HP 를 설정한다. 마찬가지로 SetDamage 와 SetHP 를 이용해 체력이 0이 되었을 때 OnHPIsZero 를 Broadcast 한다. GetAttack 함수는 데미지 값을 리턴하고, GetHPRatio 는 남아 있는 체력 비율을 리턴한다.

 

언리얼 오브젝트에는 직렬화(Serialization) 기능이 있어서 오브젝트의 UPROPERTY 속성을 저장하고 로딩할 수 있다. 직렬화란, 현재 오브젝트 상태를 저장하고 이를 다른 컴퓨터 환경에서 불러와 동일한 상황으로 만들어 주는 기법을 의미한다. 즉, 예를 들어 CurrentHP 를 직렬화 한다는 것은 CurrentHP 값을 보관한다는 뜻이다. 

하지만 CurrentHP 의 경우 플레이 시마다 변경되는 값이므로 오브젝트를 저장할 때 필요없는 디스크 공간만 차지한다. 이러한 속성에는 Transient 키워드를 추가해 해당 속성을 직렬화에서 제외시키는 것이 좋다.

게임을 시작하면 레벨에 맞게 CurrentHP 값이 바뀐다!

 

 

FArchive 와 패키지

직렬화와 언리얼의 오브젝트 관리에 대해 좀 더 알아보자. C++ 에서는 디스크와 메모리와 소통을 할 때 cin, cout 을 사용한다. 언리얼의 경우는 멀티 플랫폼에서 동작하는 매체의 규약이 정해져 있는데, 이것이 바로 FArchive 이다.

언리얼 오브젝트에 한해 언리얼 엔진은 시리얼라이제이션 기능을 제공한다. 그리고 이를 위해 패키징이라는 클래스를 제공한다. 패키징 클래스는 저장할 언리얼 오브젝트가 잘 저장되도록 포장해주는 역할을 하는 클래스인데, 언리얼 오브젝트 하나만 저장하지 않고, 언리얼 오브젝트에 속한 계층 구조에 있는 모든 오브젝트를 저장할 수 있다. (월드나 에셋들도 패키지에 저장. 확장자는 umap 이다)

패키징 클래스는 모든 언리얼 오브젝트를 관리하는 최상단 부모 클래스가 되며 패키징된 언리얼 오브젝트는 GetOutermost() 함수를 사용해 자신을 관리하는 패키징 인스턴스에 바로 접근할 수 있다.
 

위 항목에 대한 내용은 이득우님 블로그에서 발췌했다. 예제 코드를 볼 수도 있으니 참고하도록 하자!

 

 

ABCharacterStatComponent 에서 HP 값을 바꾸어 줬으니, 이제 ABCharacter 에서 HP 가 0이 되었을 때 죽는 모션을 재생하도록 만들자.

 

ABCharacter.cpp

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


}


float AABCharacter::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    ...
    
    CharacterStat->SetDamage(FinalDamage);
    
    ...
}

void AABCharacter::AttackCheck()
{
    ...
	
    if (bResult)
    {
        if (HitResult.Actor.IsValid())
        {
            ...
            HitResult.Actor->TakeDamage(CharacterStat->GetAttack(), DamageEvent, GetController(), this);
        }
    }

}

OnHPIsZero 에 SetDeadAnim 을 호출하는 람다 함수를 델리게이트로 달아준다. 이제 HP 가 다 닳으면 죽는 모션이 재생될 것이다.

 

체력바는 아래에서 붙이는 방법을 다룬다

 

 

캐릭터 위젯 UI 제작

위젯 블루프린트 UI_HPBar 를 만들어 체력을 표시하도록 만들어 보자.

 

UI 위젯 블루프린트->디자이너로 들어가, 다음과 같이 계층 구조를 만든다.

 

크기는 Custom / 150 x 50 으로 설정한다.&nbsp;
먼저 Progress Bar 를 추가하고, Vertical Box 가 감싸도록 만든다.
Spacer 는 위 아래로 넣어준다.
채우기 영역은 Spacer, Progress Bar, Spacer 각각 40 : 20 : 40 으로 설정한다.
Progress Bar 색상은 빨강으로 바꾼다.

 

 

모듈과 빌드 설정

이제 UI 를 캐릭터에 부착해보자. 언리얼은 액터에 UI 위젯을 부착할 수 있도록 UWidgetComponent 클래스를 제공한다.

 

ABCharacter.h

    ...

    UPROPERTY(VisibleAnywhere, Category = UI)
    class UWidgetComponent* HPBarWidget;
    
    ...

HPBarWidget 을 선언했지만, 컴파일하면 '확인할 수 없는 외부 참조' 에러 메시지가 나온다. 이는 현재 프로젝트 설정에 UI 에 관련된 엔진 모듈을 지정하지 않았기 때문이다. ArenaBattle.Build.cs 파일을 보면 현재 사용하는 모듈을 확인할 수 있다.

 

ArenaBattle.Build.cs

// Copyright Epic Games, Inc. All Rights Reserved.

using UnrealBuildTool;

public class ArenaBattle : ModuleRules
{
	public ArenaBattle(ReadOnlyTargetRules Target) : base(Target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

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

PublicDependencyModuleNames 에 UMG 라는 모듈을 추가하면 현재 프로젝트에서 위젯 컴포넌트를 사용할 수 있게 된다. UMG 모듈은 UE4 프로젝트 내 Source -> Runtime -> UMG 폴더에 위치해 있다.

UMG 모듈의 Public/Components 폴더에는 현재 사용 중인 WidgetComponent.h 파일이 있는데, 캐릭터의 구현부에서 이 헤더 파일을 추가해 컴포넌트를 생성하는 코드를 생성한다.

 

ABCharacter.cpp

...
#include "ABCharacterWidget.h"

AABCharacter::AABCharacter()
{
    ...

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

HPBarWidget 을 생성하고 머리 위에 부착한다.

 

 

UI 와 데이터의 연동

이제 캐릭터의 스탯이 변경되면 이를 UI 에 전달해 프로그레스바가 변경되도록 기능을 구현해보자. UI 의 로직은 애님 인스턴스와 유사하게 C++ 클래스에서 미리 만들어 제공할 수 있는데, 위젯 블루프린트가 사용하는 기반 C++ 클래스는 UserWidget 이다. UserWidget 을 상속받은 새로운 클래스를 생성하고 이름을 ABCharacterWidget 으로 정한다.

 

델리게이트 로직은 이미 CharacterStatComponent 에서 설정했으므로, UI 에서 캐릭터 컴포넌트에 연결하는 코드를 짜보자. 만약 UI 과 캐릭터가 서로 다른 액터라면 약 포인터를 사용하는 것이 바람직하다.

 

ABCharacterWidget.h

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

#pragma once

#include "ArenaBattle.h"
#include "Blueprint/UserWidget.h"
#include "ABCharacterWidget.generated.h"

/**
 * 
 */
UCLASS()
class ARENABATTLE_API UABCharacterWidget : public UUserWidget
{
	GENERATED_BODY()
	
public:
	void BindCharacterStat(class UABCharacterStatComponent* NewCharacterStat);

protected:
	virtual void NativeConstruct() override;
	void UpdateHPWidget();

private:
	TWeakObjectPtr<class UABCharacterStatComponent> CurrentCharacterStat;

	UPROPERTY()
	class UProgressBar* HPProgressBar;
};

 

ABCharacterWidget.cpp

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


#include "ABCharacterWidget.h"
#include "ABCharacterStatComponent.h"
#include "Components/ProgressBar.h"

void UABCharacterWidget::BindCharacterStat(UABCharacterStatComponent* NewCharacterStat)
{
	ABCHECK(nullptr != NewCharacterStat);

	CurrentCharacterStat = NewCharacterStat;

	NewCharacterStat->OnHPChanged.AddUObject(this, &UABCharacterWidget::UpdateHPWidget);
}

void UABCharacterWidget::NativeConstruct()
{
	Super::NativeConstruct();
	HPProgressBar = Cast<UProgressBar>(GetWidgetFromName(TEXT("PB_HPBar")));
	ABCHECK(nullptr != HPProgressBar);
	UpdateHPWidget();
}

void UABCharacterWidget::UpdateHPWidget()
{
	if (CurrentCharacterStat.IsValid())
	{
		if (nullptr != HPProgressBar)
		{
			HPProgressBar->SetPercent(CurrentCharacterStat->GetHPRatio());
		}
	}
}

HPProgressBar 에 CurrentCharacterStat 의 HPRatio 를 전달하는 기능을 구현했다.

 

ABCharacter.cpp

// Called when the game starts or when spawned
void AABCharacter::BeginPlay()
{
	Super::BeginPlay();
	auto CharacterWidget = Cast<UABCharacterWidget>(HPBarWidget->GetUserWidgetObject());
	if (nullptr != CharacterWidget)
	{
		CharacterWidget->BindCharacterStat(CharacterStat);
	}
}

언리얼 4.21 버전부터 위젯의 초기화 시점이 PostInitializeComponents 에서 BeginPlay 로 변경되었다. 

 

위의 코드를 모두 완성하면 위젯 블루프린트로 이동해 해당 위젯 블루프린트가 우리가 제작한 ABCharacterWidget 클래스를 상속받도록 설정한다(그래프 탭 -> 클래스 세팅).

 

한 가지 고려할 사항은 UI 가 초기화되는 시점이다. UI 시스템이 준비되면 NativeConstruct 함수가 호출되는데, UI 생성은 플레이어 컨트롤러의 BeginPlay 에서 호출되므로 BeginPlay 전에 호출된 PostInitializeComponents 함수에서 발생한 명령은 UI 에 반영되지 않는다. 따라서 NativeConstruct 함수에서 위젯 내용을 업데이트하는 로직을 구현하는 것이 필요하다.

NativeConstruct 는 액터의 BeginPlay 와 비슷한 용도이다.

 

 

 
Comments