[Unreal_C++_DarkSoul]#21_데이터 변환
Data Table에서 필요한 정보들을 추출하는 '데이터 변환'을 구현합니다.
Overview
- 개요 및 설계
- 코드
- 정리
#1. 개요 및 설계
1. 개요
Data Table -> Character 객체 -> Health Component -> Load Data Table -> FSpecInfo에 저장
Details
- 데이터 변환 : 게임 플레이 시스템 "기획 단계"에서 캐릭터 혹은 무기 객체의 정보들을 Data Table에 표 형식으로 작성한 후, "개발 단계"에서 해당 Data Table로부터 필요한 정보들을 FSpecInfo 혹은 FPowerInfo와 같은 구조체 형식으로 변환하는 작업을 수행합니다. 이 데이터 변환 과정을 통해, 캐릭터 혹은 무기 객체의 여러 Actor Component에서 이들이 필요로 하는 몇 개의 정보들을 선택해 추후 구현에 활용할 수 있습니다. 예를 들면, 기획 단계에서 결정한 캐릭터의 최대 HP를 Data Table에 작성해 캐릭터 객체에게 넘겨주고, 캐릭터의 피격 대미지 혹은 체력 회복 등을 관리하는 Health Comopnent에서 넘겨받은 Data Table로부터 HP 관련 정보들을 선택해 활용이 용이한 FSpecInfo 형식의 구조체에 저장함으로서, 추후 HP 관련 기능 구현 작업에 활용합니다.
- 목표 : '기획 단계'에서 설정한 개발 주기를 맞추기위한 효율적인 데이터 변환 툴 개발. 더불어, '디자인 및 프로토타이핑 단계'에서 작성한 데이터를 'SW 개발 단계'로 안전하게 가져와 기획 의도를 정확히 파악해 요구사항을 만족시키는 것이 목표.
2. 두 가지 문제점
- [ 캐릭터 객체와 액터 컴포넌트 간 커플링 ] : 데이터 변환 작업에서 발생한 첫 번째 문제점은 캐릭터 객체와 해당 객체의 액터 컴포넌트 간의 과도한 커플링입니다. 기획 단계에서 전달받은 Data Table들의 '데이터 변환' 작업을 캐릭터 객체 내부에서 모두 수행할 경우, 이를 활용하기 위해 액터 컴포넌트들은 Owner Character로 부터 가공된 데이터를 가져와, 정말 필요로 하는 정보들을 선별하는 부가적인 작업을 수행해야 했습니다. 결과적으로, Data Table의 Column 이름을 변경하거나, 새로운 항목란을 추가할 경우, 캐릭터 객체 내부에 작성한 '데이터 변환' 함수의 수정뿐만 아니라, 이를 필요로 하는 액터 컴포넌트들의 추가적인 선별 작업을 수행하는 함수들 모두 수정해야 하는 문제가 발생했습니다.
- [ 코드 중복과 유지보수성 저하 ] : 데이터 변환 작업에서 발생한 두 번째 문제점은 위 첫 번째 문제를 해결하기 위해 각 Actor Component에서 '데이터 변환 함수' 작성하는 과정에서 발견한 심각한 코드 중복과 유지보수성 저하입니다. 첫 번째 문제를 해결하기 위해 캐릭터 객체가 갖는 Data Table 목록들을 각 Actor Component로 가져와 필요한 Data Table만 선택해 데이터를 변환하는 코드를 작성했습니다. 이때, 몇몇 Actor Copmonent는 동일한 Data Table을 선택해 변환하는 함수를 작성했고, 이러한 코드 중복은 유지보수성을 저하시켰습니다. 예를 들면, 어떤 Data Table에 변경이 발생하면, 해당 Data Table을 가져가 데이터 가공 함수를 작성한 여러 Actor Component들을 모두 찾아서 수정해야 했습니다.
3. 코드
// #1. UC_HealthComponent : 캐릭터의 HP 관련 동작들을 수행하는 액터 컴포넌트
void UC_HealthComponent::LoadSpecInfoFromDataTable()
{
CheckNull(DataTable_SpecInfo);
TArray<FName>RowNames = DataTable_SpecInfo->GetRowNames();
for (auto RowName : RowNames)
{
FString RowNameStr = RowName.ToString();
FString OwnerName = OwnerObject->GetName();
if (OwnerName.Contains(RowNameStr))
{
const FString ContextString(TEXT("Owner Spec Info"));
FSpecInfo* RowStruct = DataTable_SpecInfo->FindRow<FSpecInfo>(RowName, ContextString, true);
SpecInfo_Struct = *RowStruct;
}
}
}
// #2. UC_PowerComponent : 무기의 공격 관련 동작들을 수행하는 액터 컴포넌트
void UC_PowerComponent::LoadPowerInfoFromDataTable()
{
CheckNull(DataTable_PowerInfo);
TArray<FName>RowNames = DataTable_PowerInfo->GetRowNames();
for (auto RowName : RowNames)
{
FString RowNameStr = RowName.ToString();
FString OwnerName = OwnerWeapon->GetName().Mid(3, OwnerWeapon->GetName().Len() - 7);
if (OwnerName.Contains(RowNameStr))
{
const FString ContextString(TEXT("Weapon Power Info"));
FPowerInfo* RowStruct = DataTable_PowerInfo->FindRow<FPowerInfo>(RowName, ContextString, true);
PowerInfo_Struct = *RowStruct;
}
}
}
Details
- 위 코드는 보기에 문제가 없어 보입니다. 하지만, 캐릭터 객체는 15개 이상의 액터 컴포넌트를 가지고 있으며, 몇몇 액터 컴포넌트는 Health Component와 같이 캐릭터의 Spec 정보(HP, MP, SP... etc)를 담고 있는 Data Table로부터 데이터 변환 함수를 작성하고 있습니다. 예를 들면, Mana Component 혹은 Stamina Component는 동일한 Data Table로 부터 MP 혹은 SP 관련 정보들을 추출합니다.
4. 설계
- 다용도 클래스 CHelpers 클래스 내부에 '데이터 변환'을 수행하는 정적 멤버 함수를 정의합니다.
- 각 Actor Component는 Owner Character로부터 DataAsset(Data Table 목록을 담고 있는 Data)을 가져와 특정 Data Table을 CHelpers가 제공하는 '데이터 변환' 서비스를 활용해 필요한 정보를 추출합니다.
#2. 코드
1. Data Table
Details
- [Data Table의 행] : 각 Data Table은 그 목적에 맞는 정보들을 담고 있으며, Data Table의 각 행은 구조체 형식으로, 각 행은 "행 이름"으로 구분됩니다.
- [FCommonAnimInfo 구조체] : 예를 들면, "Sword_1" 이름의 행은 Sword 무기를 들고 있는 캐릭터 객체가 "피격" 동작을 수행하기 위해 필요한 데이터들을 "FCommonAnimInfo(구조체)" 형식에 담고 있습니다.
2. Data Asset
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "Global/Global.h"
#include "Interface/OwnerWeaponInterface.h"
#include "Perception/AISightTargetInterface.h"
#include "GameObject.generated.h"
UCLASS()
class DARKSOUL_API AGameObject : public ACharacter, public IOwnerWeaponInterface, public IAISightTargetInterface
{
GENERATED_BODY()
//...
// DA
protected:
UPROPERTY(EditDefaultsOnly, Category = "Data")
class UDA_Weapon* DA_Weapon;
//...
};
Details
- 특정 Character 객체에 필요한 모든 Data Table들을 하나의 Data Asset으로 묶어 저장합니다.
- 게임 내 모든 캐릭터 객체는 GameObject 클래스를 상속하며, 공통적으로 "UDA_Weapon" 형식의 데이터 에셋을 멤버로 선언하고 Editor에. Unreal Editor 상에 캐릭터 객체의 블루프린트에서 Data Asset을 지정할 수 있습니다.
3. CHelpers(서비스 클래스)
#pragma once
#include "CoreMinimal.h"
#include "UObject/ConstructorHelpers.h"
#include "Engine/World.h"
#include "Global/Custom_Enums.h"
#include "Global/Custom_Structs.h"
#include "Sound/SoundCue.h"
#include "DataAssets/DA_Weapon.h"
class DARKSOUL_API CHelpers
{
public:
//...
// [23-04-27] : Load 함수
static void LoadCommonAnimInfoFromDataTable(UDataTable** InDataTable, TMultiMap<EWeaponType, FCommonAnimInfo>& InMMap)
{
if (!!(*InDataTable))
{
TArray<FName>RowNames = (*InDataTable)->GetRowNames();
TArray<FString> WeaponTypeAsStrings;
CHelpers::GetEWeaponTypeAsStringArray(WeaponTypeAsStrings);
const FString ContextString(TEXT("Common Anim Info"));
for (auto RowName : RowNames)
{
FString RowNameStr = (RowName.ToString()).LeftChop(2);
for (int32 i = 0; i < WeaponTypeAsStrings.Num(); i++)
{
if (RowNameStr.Equals(WeaponTypeAsStrings[i]))
{
FCommonAnimInfo CommonAnimInfo_Struct;
FCommonAnimInfo* RowStruct = (*InDataTable)->FindRow<FCommonAnimInfo>(RowName, ContextString, true);
CommonAnimInfo_Struct = *RowStruct;
InMMap.Add(static_cast<EWeaponType>(i), CommonAnimInfo_Struct);
break;
}
}
}
}
}
//...
};
Details
- [Load 함수] : CHelpers는 서비스 클래스입니다. 각 종 Data Table로부터 사용자가 필요로 하는 데이터로 변환해 전달받은 형식의 자료구조에 저장합니다. 이때, 서비스 제공 함수는 정적 멤버 함수로 선언합니다. Load 함수는 클래스의 특정 인스턴스에 속하지 않고 클래스 레벨에서 동작하기 때문에 정적 멤버 함수로 정의하며, 객체 생성 없이 호출 가능하도록 하여 메모리를 효율적으로 사용합니다.
- [LoadCommonAnimInfoFromDataTable 함수] : "LoadCommmonAnimInfoFromDataTable" 메서드는 Data Table을 Out Parameter로 전달받고, 데이터를 가공해 TMultiMap<EWeaponType, FCommonAnimInfo> 형식의 Out Parameter에 저장합니다. TMultiMap을 활용한 이유는 무기 별 피격 애니메이션이 1~3개로 다양하기 때문입니다.
4. UC_MontageComponent::BeginPlay()
void UC_MontageComponent::BeginPlay()
{
Super::BeginPlay();
OwnerObject = Cast<AGameObject>(GetOwner());
UC_VaultComponent* VaultComp = OwnerObject->FindComponentByClass<UC_VaultComponent>();
DataAsset = OwnerObject->GetWeaponDataAsset();
CheckTrue(!OwnerObject || !VaultComp || !DataAsset);
LoadHitAnimInfoFromDataTable();
LoadDeathAnimInfoFromDataTable();
LoadKnockDownAnimInfoFromDataTable();
LoadWakeUpAnimInfoFromDataTable();
LoadComboAnimInfoFromDataTable();
LoadAttackSkillAnimInfoFromDataTable();
LoadGuardSkillAnimInfoFromDataTable();
//...
}
Details
- [Montage Component] : Montage Component는 각 캐릭터 객체가 갖는 Actor Component입니다. Montage Component는 캐릭터의 특정 상황에 필요한 적절한 Animation Montage를 재생시키는 동작을 관리합니다.
- [Data Asset 가져오기] : Montage Component의 BeginPlay 메서드는 게임 시작 시점에 호출되며, OwnerObject로부터 Data Asset을 가져옵니다.
5. UC_MontageComponent::LoadHitAnimInfoFromDataTable()
void UC_MontageComponent::LoadHitAnimInfoFromDataTable()
{
if (!!OwnerObject && !!DataAsset)
{
UDataTable* HitInfo = DataAsset->GetHitInfo();
CHelpers::LoadCommonAnimInfoFromDataTable(&HitInfo, MmHitAnims);
}
else
{
PrintLine();
return;
}
}
Details
- [Load 함수 호출] : Owner Object로부터 가져온 Data Asset에서 필요한 Data Table을 가져옵니다. 그리고, CHelpers 서비스 클래스가 제공하는 서비스 함수 "LoadCommonAnimInfoFromDataTable"를 호출해 피격 정보를 담고 있는 Data Table을 코드에서 활용 가능한 형식(TMultiMap<EWeaponType, FCommonAnimInfo>)으로 변환시킵니다.
6. 그 외 액터 컴포넌트의 Load 함수들
// #1. Health Component : 캐릭터의 HP 관련 동작들을 관리하는 액터 컴포넌트
void UC_HealthComponent::LoadSpecInfoFromDataTable()
{
if (!!DataTable_SpecInfo)
{
FString OwnerName = OwnerObject->GetName();
CHelpers::LoadSpecInfoFromDataTable(&DataTable_SpecInfo, SpecInfo_Struct, OwnerName);
}
}
// #2. Power Component : 무기 별 공격력 및 데미지 전달 관련 동작들을 관리하는 액터 컴포넌트
// [23-04-27] : Load 함수
void UC_PowerComponent::LoadPowerInfoFromDataTable()
{
if (!!DataTable_PowerInfo)
{
FString OwnerName = OwnerWeapon->GetName().Mid(3, OwnerWeapon->GetName().Len() - 7);
CHelpers::LoadPowerInfoFromDataTable(&DataTable_PowerInfo, PowerInfo_Struct, OwnerName);
}
}
Details
- [유지 보수성 향상] : 만약, Data Table 혹은 Data Asset에 변경이 발생하면, CHelpers로 찾아가 해당 Data Table 관련 변환 동작을 수행하는 정적 멤버 함수만 수정해 주면 됩니다.
- [코드 중복 방지] : 만약, 동일한 Data Table의 변환 동작을 필요로 하는 Actor Component들에서 똑같은 데이터 변환 함수를 몇 번이고 반복해서 작성하는 작업이 생략됩니다.
#3. 정리
- [문제점] : Actor Component는 각종 Data Table을 변환하는 함수들을 작성하는 과정에서, 심각한 코드 중복과 유지 보수성 저하를 인식했습니다. 뿐만 아니라, 캐릭터 객체와 각 Actor Component 간의 커플링 문제도 발견되었습니다.
- [해결] : Character에 필요한 Data Table들을 하나의 Data Asset으로 묶어 캐릭터 객체의 데이터 멤버로 선언했습니다. 그리고, 각 Data Table의 변환 동작을 수행하는 함수들을 서비스 클래스 내 정적 멤버 함수로 선언했습니다. 그리고, 캐릭터의 각 Actor Component는 단순히 Owenr Character로부터 Data Asset을 가져와서 서비스 클래스의 변환 함수를 호출합니다. 이를 통해, 코드 중복을 방지할 수 있었고, 유지 보수성이 향상되었습니다. Data Table에 변경이 발생할 경우, 서비스 클래스를 찾아가 해당 Data Table의 변환 동작을 수행하는 함수만 수정해 주면 됩니다. 결과적으로, "기획" 단계에서 작성한 Data Table을 "개발" 단계로 가져오는 데이터 파이프라인의 "변환" 과정에서 시간이 단축되어, 최종적으로 기능 구현에 필요한 시간을 효율적으로 단축시킬 수 있었습니다.
'개인프로젝트' 카테고리의 다른 글
[Unreal_C++_DarkSoul]#22_기능 구현, HUD (0) | 2023.12.01 |
---|---|
[Unreal_C++_DarkSoul]#20_기능 구현, Level Sequence (0) | 2023.09.17 |
[Unreal_C++_DarkSoul]#19_기능 구현, Portal (0) | 2023.07.23 |
[Unreal_C++_DarkSoul]#18_기능 구현, Sound (0) | 2023.07.23 |
[Unreal_C++_DarkSoul]#17_기능 구현, Grid 클래스 (0) | 2023.06.03 |