주요적인 목표
데이터 기반의 캐릭터와 기믹 시스템의 제작을 진행한다.
엑셀 기반의 데이터 테이블과, ini파일 설정에 대해서 다뤄볼 예정임.
게임 데이터를 관리하는 싱글톤 객체를 등록한다
엑셀 데이터 및 INI 파일을 활용하여 게임 데이터를 관리한다.
액터의 초기화를 위한 지연 생성의 기능을 구현한다.
데이터 관리
언리얼 엔진에서는 엑셀파일 csv 형태의 파일을 로드할 수 있게 지원한다.
따라서 각종 아이템 정보나 스탯 정보 등을 csv 파일로 관리하고 이를 로드해서 구조체로 관리하는것이 편하다.
먼저 해당 헤더에서는 데이터 애셋과 유사하게, FTableRowBase를 상속받은 구조체를 선언하는 작업이 필요하다.
(csv에 존재하는 데이터를 가져오게 하기 위함)
액셀의 Name 이라는 컬럼이 들어가야 하는데, 이를 제외하고 나머지를 UPROPERTY로 동일하게 맞춰줘야 한다.
재밌는 사실은 어차피 Name을 제외하고 각 Name마다 고유한 정보를 지니게 하면 된다는 것.
구조체의 경우에 있어서는 다음과 같이 구성을 해야 한다.
USTRUCT(BlueprintType)
struct FABCharacterStat : public FTableRowBase
{
GENERATED_BODY()
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;
}
};
이렇게 코드를 작성하고 만들고 싶은 위치에 DataTable을 눌러 위 헤더를 선택하면 생성할 수 있다.
데이터를 가져오기 위해서는 Reimport를 눌러서 파일을 선택하면 csv 파일에서 규격에 맞는 정보일 경우 이렇게 채워진다.
각 컬럼에 대해서는 수정도 가능하다.
데이터는 하나의 클래스에서 관리하면 좋다. 가령 DataManager와 비슷한 역할을 하게 만들어보자.
싱글톤 클래스 설정
언리얼 엔진에서는 자체적으로 무조건 존재하는 싱글톤 클래스가 존재한다.
- 게임 인스턴스
- 애셋 매니저
- 게임 플레이 관련한 액터들 ( 예를 들어서 게임모드, 게임 스테이트 )
- 프로젝트에 싱글톤으로 등록한 언리얼 오브젝트 (이번에는 싱글톤에 존재해서, 컨텐츠 적으로 관리가 되어야 하는 될 요소들을 관리하게 될 것임)
ProjectSetting에서 Game Singleton Class을 지정해서 사용이 가능하다.
이렇게 하나의 클래스를 생성한 뒤에 Singleton으로 설정해주자.
여기서는 엑셀 데이터에 관해 불러들이는 기능을 생성자에 넣어주고 어디에서든 접근할 수 있게 만들 것이다.
그리고 이제 내부적으로는 스탯 정보에 대한 배열을 따로 보유하고 있다.
UCLASS()
class ARENABATTLE_API UABGameSingleton : public UObject
{
GENERATED_BODY()
public :
UABGameSingleton();
static UABGameSingleton& Get();
//Character Stat Data Section
public :
FORCEINLINE FABCharacterStat GetCharacterStat(int32 InLevel) const { return CharacterStatTable.IsValidIndex(InLevel) ? CharacterStatTable[InLevel] : FABCharacterStat(); }
UPROPERTY()
int32 CharacterMaxLevel;
private :
TArray<FABCharacterStat> CharacterStatTable;
};
UABGameSingleton& UABGameSingleton::Get()
{
UABGameSingleton* Singleton = CastChecked<UABGameSingleton>(GEngine->GameSingleton);
if (Singleton)
{
return *Singleton;
}
}
애초에 우리가 엔진에서 GameSingleton으로 해당 클래스를 지정해 줬기 때문에 가능한 것이다.
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);
}
데이터 테이블에 대한 정보를 가져와서 해당 정보를 파싱해줘야한다.
Map으로 이루어진 DataTable을 배열로 만들어주면 된다.
Stat 변경하기
이제 싱글톤으로 데이터를 설정해 주었으니 이걸 가지고 와서 실제 스탯에 반영해보자.
#include "CharacterStat/ABCharacterStatComponent.h"
#include "GameData/ABGameSingleton.h"
// Sets default values for this component's properties
UABCharacterStatComponent::UABCharacterStatComponent()
{
CurrentLevel = 1;
}
// Called when the game starts
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)
}
float UABCharacterStatComponent::ApplyDamage(float InDamage)
{
const float PrevHp = CurrentHp;
const float ActualDamage = FMath::Clamp<float>(InDamage, 0, InDamage);
SetHp(PrevHp - ActualDamage);
if (CurrentHp <= KINDA_SMALL_NUMBER) {
OnHpZero.Broadcast();
}
return ActualDamage;
}
void UABCharacterStatComponent::SetHp(float NewHp)
{
UE_LOG(LogTemp, Log, TEXT("Damaged"))
CurrentHp = FMath::Clamp<float>(NewHp, 0.0f, BaseStat.MaxHp);
OnHpChanged.Broadcast(CurrentHp);
}
나머지는 그냥 게임의 설계에 맞춰서 진행하면된다.
액터의 생성과 지연생성 프로세스
일반적으로 우리가 폰을 생성하게되면 SpawnActor 다음으로 BeginPlay가 호출되면서 플레이어가 초기화 되는 구조이다.
void AABStageGimmick::OnOpponentSpawn()
{
const FVector SpawnLocation = GetActorLocation() + FVector::UpVector * 88.0f;
AActor* OpponentActor = GetWorld()->SpawnActor(OpponentClass, &SpawnLocation, &FRotator::ZeroRotator);
AABCharacterNonPlayer* ABOpponentCharacter = Cast<AABCharacterNonPlayer>(OpponentActor);
if (ABOpponentCharacter) {
ABOpponentCharacter->OnDestroyed.AddDynamic(this, &AABStageGimmick::OnOpponentDestoryed);
ABOpponentCharacter->SetLevel(CurrentStageNum);
}
}
이러한 상황에서 Actor가 생성되고 바로 BeginPlay가 호출된다.
void UABCharacterStatComponent::BeginPlay()
{
Super::BeginPlay();
SetLevelStat(CurrentLevel);
SetHp(BaseStat.MaxHp);
}
그러면 문제가 HP의 최댓값만 늘어나고 실제 체력은 늘지 않는다.
이미 초기 레벨 값으로 초기화 작업이 수행됐기 때문이다.
이러한 문제를 해결하기 위해 SpawnActorDeferred 라는 함수를 제공한다.
Deferred 방식은 FinishSpawning을 호출해줘야만 BeginPlay 가 호출되기 때문에 우리가 초기화를 전부 다 해준 다음 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::OnOpponentDestoryed);
ABOpponentCharacter->SetLevel(CurrentStageNum);
ABOpponentCharacter->FinishSpawning(SpawnTransform);
}
}
실제적인 호출은 다음과 같은 과정을 거친다.
함수 호출의 개괄적인 플로우는 다음과 같다.
- 액터를 스폰하는데, Transform 정보를 가지고 스폰을 한다.
- 그리고 초기값을 설정을 하게 된 다음
- FinishSpawning을 이용해서 내가 스폰을 마쳤다는 것을 알린다.
- 그러면 BeginPlay가 호출된다.
FinishSpawning 에서 인자로 FTransform을 넣는데 그 이유로는 대략 내가 초기에 지정한 Transform과 맞지 않게 스폰되었다면 해당 스폰 위치를 조정해주겠다는 의미로 해석된다. 모종의 이유로 변경사항이 생겼을 때를 대비하기 위함이다.
결론적으로 초기화를 해서 스폰해야하는 모든 경우에는 지연생성을 무조건적으로 해주는것이 좋다. 만약 지연 생성이 필요 없이도 동작이 가능할지언정 굳이 문제를 일으킬 이유가 없기에 사용하는 편이 좋다.
ini 데이터
ini 데이터를 활용해 텍스트 파일로 관리할 수 있다. 가령 mesh를 변경해주고 싶은데 데이터가 많다면 이렇게 지정해두고 이를 불러와서 이중 하나를 꺼내쓰면 된다.
UCLASS(config=ArenaBattle)
class ARENABATTLE_API AABCharacterNonPlayer : public AABCharacterBase
{
GENERATED_BODY()
public :
AABCharacterNonPlayer();
protected :
void SetDead() override;
UPROPERTY(config)
TArray<FSoftObjectPath> NPCMeshes;
TSharedPtr<FStreamableHandle> NPCMeshHandle;
};
config를 지정해주면 DefaultArenaBattle.ini을 불러오겠다는 의미이다.
일단 UPROPERTY의 config를 이용해서 해당 config 파일에서 가져오는 작업을 수행한다.
그 다음 ini에 설정된 이름과 맞게 이름을 지어준다.
그렇게 된다면 자동으로 로드가 되었을 시에 값이 채워지게 될 것임.
문제는 이 텍스트에 맞는 Mesh들을 비동기적으로 수행을 하는 것이 아~주 바람직함.
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();
}
'Unreal Engine 5 > 강의' 카테고리의 다른 글
[UE5] AI - 행동 트리 모델 (0) | 2024.12.13 |
---|---|
[UE5] 스폰 - 무한 맵 제작 (0) | 2024.12.10 |
[UE5] 아이템 - 여러 종류 아이템 획득하기 (0) | 2024.12.09 |
[UE5] 애니메이션 - 캐릭터 공격 판정 (0) | 2024.12.04 |
[UE5] 애니메이션 - 캐릭터 애니메이션과 몽타주를 이용한 연속 공격 (0) | 2024.12.03 |