이번엔 DataTable을 이용해서 게임 시스템에 필요한 데이터를 관리하고 사용하는 법을 적용해보았다.

엑셀파일을 csv 파일로 저장하여 언리얼에 불러와서 사용할 수 있는데 이때 동일한 속성들을 가진 구조체를 선언해줘야한다.
일단 캐릭터에 필요한 MaxHp,Attack(Damage) 등을 엑셀 표에 작성하고 csv 파일 포맷으로 저장한다.
<ABCharacterStat.h>
public:
FABCharacterStat() : MaxHp(0.0f), Attack(0.0f), AttackRange(0.0f), AttackSpeed(0.0f) {}
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float MaxHp;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float Attack;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float AttackRange;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float AttackSpeed;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = Stat)
float MovementSpeed;
FABCharacterStat operator+(const FABCharacterStat& Other) const
{
const float* const ThisPtr = reinterpret_cast<const float* const>(this);
const float* const OtherPtr = reinterpret_cast<const float* const>(&Other);
FABCharacterStat Result;
float* ResultPtr = reinterpret_cast<float*>(&Result);
int32 StatNum = sizeof(FABCharacterStat) / sizeof(float);
for (int32 i = 0; i < StatNum; i++)
{
ResultPtr[i] = ThisPtr[i] + OtherPtr[i];
}
return Result;
}
테이블을 선언하려면 FTableRowBase를 상속받아야하고 Engine/DataTable.h를 include해준다.
위에서 우리가 만든 표와 동일한 속성들을 정의해주고 Stat간 덧셈을 위한 덧셈연산자를 override해준다.
모든 속성들이 float이기 때문에 이를 이용해서 n번째 속성 끼리 값을 더해준다.

우리가 방금 정의한 CharacterStat을 상속받는 DataTable을 생성한다.

파일을 열고 Reimport 버튼을 눌러서 아까 우리가 생성한 csv파일을 가져오면 엑셀에서 정의한 값들을 에디터에서 사용할 수 있다.
DataTable을 관리하는 클래스는 게임 내에 하나의 인스턴스에서 관리하는 것이 좋기 때문에 싱글톤 클래스로 정의해야한다.
우선 DataTable을 관리하는 c++클래스를 생성한다.

프로젝트 세팅->일반 설정->Default classes->Advanced 탭에 있는 Game Singleton Class에 등록해주면 엔진이 초기화될 때 이것의 싱글톤을 GEngine이라고 하는 전역변수에 자동으로 만들어준다.
<UABGameSingleton.h>
DECLARE_LOG_CATEGORY_EXTERN(LogABGameSingleton, Error, All);
public:
UABGameSingleton();
static UABGameSingleton& Get();
// Character Stat Data Section
public:
FORCEINLINE FABCharacterStat GetCharacterStat(int32 InLevel) const { return CharacterStatTable.IsValidIndex(InLevel-1) ? CharacterStatTable[InLevel-1] : FABCharacterStat(); }
UPROPERTY()
int32 CharacterMaxLevel;
private:
TArray<FABCharacterStat> CharacterStatTable;
Get() : 싱글톤 객체를 가져올 수 있는 함수
CharacterStatTable : 캐릭터 스탯 데이터를 TArray에 저장
GetCharacterStat() : 레벨 정보를 넣어서 해당 레벨의 스탯 데이터를 가져온다.
CharacterMaxLevel : 캐릭터의 최대 레벨
Get함수에서 해당 클래스의 싱글톤을 반환하도록 하는데 castCheck로 확인하지만 이는 엔진에서 제공하게 되있기 때문에 실패한다면 무언가 잘못된 것이다.
<Engine.h>
/** A UObject spawned at initialization time to handle game-specific data */
UPROPERTY()
TObjectPtr<UObject> GameSingleton;
이렇게 Engine.h에 싱글톤의 인스턴스를 만들어서 멤버변수로 할당해준다.
<UABGameSingleton.cpp>
DEFINE_LOG_CATEGORY(LogABGameSingleton);
UABGameSingleton::UABGameSingleton()
{
static ConstructorHelpers::FObjectFinder<UDataTable> DataTableRef(TEXT("/Script/Engine.DataTable'/Game/ArenaBattle/GameData/ABCharacterStatTable.ABCharacterStatTable'"));
if (nullptr != DataTableRef.Object)
{
const UDataTable* DataTable = DataTableRef.Object;
check(DataTable->GetRowMap().Num() > 0);
TArray<uint8*> ValueArray;
DataTable->GetRowMap().GenerateValueArray (ValueArray);
Algo::Transform(ValueArray, CharacterStatTable,
[](uint8* Value)
{
return *reinterpret_cast<FABCharacterStat*>(Value);
}
);
}
CharacterMaxLevel = CharacterStatTable.Num();
ensure(CharacterMaxLevel > 0);
}
UABGameSingleton& UABGameSingleton::Get()
{
UABGameSingleton* Singleton = CastChecked< UABGameSingleton>(GEngine->GameSingleton);
if (Singleton)
{
return *Singleton;
}
// 통과해도 에러
UE_LOG(LogABGameSingleton, Error, TEXT("Invalid Game Singleton"));
// 실제로 return될 일은 없다.
return *NewObject<UABGameSingleton>();
}
생성자에서 DataTable의 레퍼런스를 가져와서 등록해준다.
데이터 테이블은 key, value 쌍으로 이루어져있는데 지금 강의에서는 레벨 순서대로 이루어져있기 때문에 key로 접근하기보다 array로 접근하는 것이 편리하다.
algo의 transform 라이브러리를 이용해서 value값만 가져와서 세팅한다.
Get()에서는 싱글톤을 GEngine에서 가져와서 캐스팅하는데 만약 nullptr이면 에러 로그가 출력된다. 하지만 무슨 문제가 생기지 않는한 GEngine에 정의가 되었을 것이기 때문에 그럴일은 거의 없을 것이다.
<ABCharacterStatComponent.h>
void SetLevelStat(int32 InNewLevel);
FORCEINLINE float GetCurrentLevel() const { return CurrentLevel; }
FORCEINLINE void SetModifierStat(const FABCharacterStat& InModifierStat) { ModifierStat = InModifierStat; }
FORCEINLINE FABCharacterStat GetTotalStat() const { return BaseStat + ModifierStat; }
UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat)
float CurrentLevel;
UPROPERTY(Transient,VisibleInstanceOnly,Category =Stat , MEta = (AllowPrivateAccess = "true"))
FABCharacterStat BaseStat;
UPROPERTY(Transient, VisibleInstanceOnly, Category = Stat, MEta = (AllowPrivateAccess = "true"))
FABCharacterStat ModifierStat;
<UABCharacterStatComponent.cpp>
UABCharacterStatComponent::UABCharacterStatComponent()
{
CurrentLevel = 1;
}
void UABCharacterStatComponent::BeginPlay()
{
Super::BeginPlay();
SetLevelStat(CurrentLevel);
SetHp(BaseStat.MaxHp);
}
void UABCharacterStatComponent::SetLevelStat(int32 InNewLevel)
{
CurrentLevel = FMath::Clamp(InNewLevel, 1, UABGameSingleton::Get().CharacterMaxLevel);
BaseStat = UABGameSingleton::Get().GetCharacterStat(CurrentLevel);
check(BaseStat.MaxHp > 0.0f);
}
앞으로 캐릭터의 기본 스탯인 BaseStat과 무기 같은 부가적인 스탯인 ModifierStat으로 관리한다.
CharacterStatComponent에서 BaseStat에 해당하는 MaxHp부분들을 다 교체하고 레벨을 기반으로 스탯을 가져오도록 한다.
그리고 이제 레벨정보를 가지고 스탯에 접근하기 때문에 현재 레벨을 저장할 CurrentLevel과 Level에 관련된 함수들을 추가한다.
<void AABCharacterBase::AttackHitCheck()>
{
//...
const float AttackRange = Stat->GetTotalStat().AttackRange;
const float AttackRadius = 50.0f;
const float AttackDamage = Stat->GetTotalStat().Attack;
//...
}
<void AABCharacterBase::SetupCharacterWidget(UABUserWidget* InUserWidget)>
{
//...
HpBarWidget->SetMaxHp(Stat->GetTotalStat().MaxHp);
//...
}
CharacterBase에서 GetMaxHp(), AttackHitCheck()의 stat을 CharacterStat을 이용하도록 교체한다.
<ABStageGimmick.h>
public:
FORCEINLINE int32 GetStageNum() const { return CurrentStageNum; }
FORCEINLINE void SetStageNum(int32 NewStageNum) { CurrentStageNum = NewStageNum; }
// Stage Stat
protected:
UPROPERTY(VisibleInstanceOnly, Category = Stat, Meta = (AllowPrivateAccess = "true"))
int32 CurrentStageNum;
<AABStageGimmick.cpp>
void AABStageGimmick::OnGateTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
//...
if (!bResult)
{
FTransform NewTransform(NewLocation);
AABStageGimmick* NewGimmick = GetWorld()->SpawnActorDeferred<AABStageGimmick>(AABStageGimmick::StaticClass(), NewTransform);
if (NewGimmick)
{
NewGimmick->SetStageNum(CurrentStageNum + 1);
NewGimmick->FinishSpawning(NewTransform);
}
}
}
스테이지를 넘을수록 NPC의 레벨이 높아지는 기능을 위해 StageNum을 추가한다.
새로운 스테이지가 생성될 때 마다 StageNum의 값을 1씩 증가시킨다.
<ABCharacterBase.h>
// Stat Section
public:
int32 GetLevel();
void SetLevel(int32 InNewLevel);
<ABCharacterBase.cpp>
int32 AABCharacterBase::GetLevel()
{
return Stat->GetCurrentLevel();
}
void AABCharacterBase::SetLevel(int32 InNewLevel)
{
Stat->SetLevelStat(InNewLevel);
}
<ABStageGimmick.cpp>
void AABStageGimmick::OnOpponentSpawn()
{
//...
ABOpponentCharacter->SetLevel(CurrentStageNum);
}
캐릭터가 레벨을 갖도록 CharacterBase에 함수를 추가하고 스탯에서 받아오도록 정의해준다.
그리고 NPC를 스폰할 때 레벨을 세팅해준다.

세팅 후에 NPC가 레벨이 2임에도 불구하고 currentHp가 제대로 세팅이 안된다.
왜그런가 하면...
SpawnActor()를 이용해서Actor를 소환하면 액터와 소유한 모든 컴포넌트에서BeginPlay()가 호출된다.
그런데CurrentLevel의 값이 생성자에서 1로 초기화되기 때문에 1로 설정될 것이고BeginPlay()에서SetLevel()을 통해 1로 레벨이 세팅이 된다.
Gimmick에서SpawnActor()이후에SetLevel을 통해 레벨을 설정하기 때문에BaseStat만 변경이 된다.
이러한 문제를 해결하기 위해 SpawnActorDeferred라는 함수를 지원한다.
이 함수는 FinishSpawning 함수를 호출해줘야 그때 BeginPlay함수가 호출된다.
void AABStageGimmick::OnOpponentSpawn()
{
const FTransform SpawnTransform(GetActorLocation() + FVector::UpVector * 88.0f);
AABCharacterNonPlayer* ABOpponentCharacter = GetWorld()->SpawnActorDeferred<AABCharacterNonPlayer>(OpponentClass, SpawnTransform);
if (ABOpponentCharacter)
{
ABOpponentCharacter->OnDestroyed.AddDynamic(this, &AABStageGimmick::OnOpponentDestroyed);
ABOpponentCharacter->SetLevel(CurrentStageNum);
ABOpponentCharacter->FinishSpawning(SpawnTransform);
}
}
다음과 같이 SpawnActor()를 SpawnActorDeferred()로 수정하면 된다.

잘 적용된 것을 볼 수 있다.
<void AABStageGimmick::OnGateTriggerBeginOverlap>
if (!bResult)
{
FTransform NewTransform(NewLocation);
AABStageGimmick* NewGimmick = GetWorld()->SpawnActorDeferred<AABStageGimmick>(AABStageGimmick::StaticClass(), NewTransform);
if (NewGimmick)
{
NewGimmick->SetStageNum(CurrentStageNum + 1);
NewGimmick->FinishSpawning(NewTransform);
}
}
지연 생성을 스테이지에도 적용시킨다.
지금 상자가 생성되는 곳에 서있으면 상자는 먹어지지만 Next단계로 넘어가지지 않는다.(다른 상자들도 안사라진다.)
SpawnActor()->BeginPlay()
// ?? 왜 Spawn처음과 끝 둘 다 Transform정보를 넣을까? 하나만 넣어도 되지 않나?
<AABItemBox.cpp>
void AABItemBox::PostInitializeComponents()
{
Super::PostInitializeComponents();
// ...
Trigger->OnComponentBeginOverlap.AddDynamic(this, &AABItemBox::OnOverlapBegin);
}
<AABStageGimmick.cpp>
void AABStageGimmick::SpawnRewardBoxes()
{
for (const auto& RewardBoxLocation : RewardBoxLocations)
{
FTransform SpawnTransform(GetActorLocation() + RewardBoxLocation.Value + FVector(0.0f, 0.0f, 30.0f));
AABItemBox* RewardBoxActor = GetWorld()->SpawnActorDeferred<AABItemBox>(RewardBoxClass, SpawnTransform);
if (RewardBoxActor)
{
RewardBoxActor->Tags.Add(RewardBoxLocation.Key);
RewardBoxActor->GetTrigger()->OnComponentBeginOverlap.AddDynamic(this, &AABStageGimmick::OnRewardTriggerBeginOverlap);
RewardBoxes.Add(RewardBoxActor);
}
}
for (const auto& RewardBox : RewardBoxes)
{
if (RewardBox.IsValid())
{
RewardBox.Get()->FinishSpawning(RewardBox.Get()->GetActorTransform());
}
}
}
상자에 설정된 델리게이트의 타이밍을 뒤로 미루고 기믹의 진행을 위한 델리게이트를 그 전에 설정하도록 순서를 바꾼다.
생성자에 있던 델리게이트 묶는 코드를 PostInitializeComponent()로 옮긴다.(이러면 FinishSpawning() 이후에 호출이 된다.)
UPROPERTY(EditAnywhere,Category = Stat)
FABCharacterStat ModifierStat;
무기에 스탯 데이터를 추가하여 캐릭터가 무기를 획득할 때 부가 스탯을 얻도록 한다.

데이터 애셋에 잘 적용되었다.
void AABCharacterBase::EquipWeapon(UABItemData* InItemData)
{
UABWeaponItemData* WeaponItemData = Cast<UABWeaponItemData>(InItemData);
if (InItemData)
{
if (WeaponItemData->WeaponMesh.IsPending())
{
WeaponItemData->WeaponMesh.LoadSynchronous();
}
Weapon->SetSkeletalMesh(WeaponItemData->WeaponMesh.Get());
Stat->SetModifierStat(WeaponItemData->ModifierStat);
}
}
weapon의 Mesh 뿐만 아니라 스탯도 적용되도록 한다.

잘 적용되었다.
NPC들의 외형을 랜덤으로 스폰하는 코드를 추가한다.

ini 파일을 설정하면 자동으로 불러서 할당되도록 할 수 있다.
[ ] 내부는 파일을 의미하는데 다음 표기는 C++ 코드가 ArenaBattle 모듈의 ABCharacterNonPlayer라는 언리얼 오브젝트를 가리키는 것이다.
내부에 NPCMeshes 라는 TArray 객체가 선언되어 있다면 요소들을 추가한다는 것이다.
<ABCharacternonPlayer.h>
// DefaultArenaBattle.ini를 사용하겠다는 뜻
UCLASS(config = ArenaBattle)
class ARENABATTLE_API AABCharacterNonPlayer : public AABCharacterBase
{
GENERATED_BODY()
public:
AABCharacterNonPlayer();
protected:
virtual void PostInitializeComponents() override;
protected:
void SetDead()override;
void NPCMeshLoadCompleted();
// 프로젝트가 로딩될 때 자동으로 값들이 채워진다.
UPROPERTY(config)
TArray<FSoftObjectPath> NPCMeshes;
TSharedPtr<FStreamableHandle> NPCMeshHandle;
};
UCLASS() 괄호 안에 config = ArenaBattle 라고 적는데 DefaultArenaBattle.ini를 사용하겠다는 뜻이다.
그리고 UPROPERTY() 괄호안에 config를 넣는건 해당 config 파일로부터 데이터를 불러오겠다는 뜻이다.
이렇게하면 프로젝트가 로딩될 때 NPCMeshes에 자동으로 로딩되는데 이를 비동기 방식으로 로드하기위해 FStreamableHandle을 사용한다.
<ABCharacternonPlayer.cpp>
AABCharacterNonPlayer::AABCharacterNonPlayer()
{
GetMesh()->SetHiddenInGame(true);
}
void AABCharacterNonPlayer::PostInitializeComponents()
{
Super::PostInitializeComponents();
ensure(NPCMeshes.Num() > 0);
int32 RandIndex = FMath::RandRange(0, NPCMeshes.Num() - 1);
NPCMeshHandle = UAssetManager::Get().GetStreamableManager().RequestAsyncLoad(NPCMeshes[RandIndex], FStreamableDelegate::CreateUObject(this, &AABCharacterNonPlayer::NPCMeshLoadCompleted));
}
void AABCharacterNonPlayer::NPCMeshLoadCompleted()
{
if (NPCMeshHandle.IsValid())
{
USkeletalMesh* NPCMesh = Cast<USkeletalMesh>(NPCMeshHandle->GetLoadedAsset());
if (NPCMesh)
{
GetMesh()->SetSkeletalMesh(NPCMesh);
// 메시가 로딩이 되면 보이게 함.
GetMesh()->SetHiddenInGame(false);
}
}
NPCMeshHandle->ReleaseHandle();
}
PostInitializeComponents() 내부에서 NPCMeshes 중 랜덤하게 하나를 로드시킨다.
이때 ASyncLoad가 끝나는 함수로 NPCMeshLoadCompleted()를 등록한다.
NPCMeshLoadCompleted()에서는 Handle값이 유효하면 NPCMesh를 로드하고 메시를 세팅 및 보이도록 설정한다. 이는 생성자에서 메시가 보이지 않게 설정했다가 메시가 로드되면 보이도록 하기 위함이다. 다 사용한 Handle은 Release해준다.

다음과 같이 다른 메시를 갖는 NPC가 등장하는 것을 볼 수 있다.