[Lost Kingdom] 개발일지 - 12 (마우스 커서, 캐릭터 스탯 - 전투)

조재훈·2024년 7월 12일

개요

거의 1달만에 언리얼이다. 브릿지 기말 발표가 1달도 안 남아서 최근 브릿지 플젝을 달리고 있다가 겨우 틈을 내서 진행할 수 있었다

좀 달라진 게 있다면 로스트아크의 UI 느낌을 살리고 싶어서 커미션을 부탁해 리소스를 받고 있어서 나름 게임의 퀄리티가 올라갈 것 같다

가장 먼저 간단한 마우스 커서를 바꿔보자

마우스 커서

커미션을 부탁할 때 커서도 같이 부탁해서 리소스를 받았다. 커서를 바꿔보자

커서도 똑같이 위젯 블루프린트로 만든다. 커서는 코드 상으로 뭐 건들일게 없기 때문에 간단함

Canvas아래에 Image를 추가하고 Details에서 세팅해준다

크기나 회전 값을 세팅해 원하는 대로 위젯 블루프린트를 만든다

그리고 이제 언리얼 엔진에 에셋을 등록해줘야 한다.


Edit > Project Setting > User Interface에 가서 Software Cursors에 여러 상황에서 쓸 커서들이 있지만 Default로 지정해 애셋을 등록해준다

이렇게만 설정해도 게임을 실행하면 다음과 같이 마우스 커서가 바뀌었다

캐릭터 스탯(공격 등)

저번 포스트에서는 캐릭터의 체력을 위주로 했다면 이번에는 공격을 위주로 스탯을 만들어 보겠다

이제 캐릭터마다 생성자에서 데이터를 입력해주는건 별로 좋지 못한 생각이란 것을 알 것이다. 엑셀로 데이터를 입력해서 엑셀 데이터를 게임 안으로 옮겨보자

먼저 기존에 액터 컴포넌트인 StatComponent에서 캐릭터의 MaxHP 같은 스탯을 관리했다. 기존에는 그냥 모든 캐릭터가 ULKCharacterStatComponent 타입의 변수를 관리하게 했는데 개발해보니까 플레이어 캐릭터와 적 캐릭터 간에 스탯을 구별해야 할 것 같았다

예를 들어 일반몹같은 적 캐릭터는 단순히 체력, 공격력, 방어력의 스탯만 있으면 되는데 플레이어 캐릭터는 로스트아크에 있는 치명, 특화, 신속의 스탯을 갖게 할 것이고 치명타같은 스탯도 있어야 한다

그러므로 ULKCharacterStatComponent를 상속받는 ULKPlayerCharacterStatComponent 클래스를 만들어서 플레이어 캐릭터의 Stat 변수에는 ULKPlayerCharacterStatComponent의 인스턴스를 할당할 것임

일단 상속만 해서 클래스를 만든 후 안에 내용은 뒤에서 구현하겠다

FLKCharacterStat 구조체

DataAsset과 비슷한 역할을 하는 캐릭터의 스탯을 저장하는 구조체를 만들어보자. C++로 구조체를 만드는 방법이 마땅치 않아서 직접 파일을 만들어 보겠음

구조체의 헤더 파일을 선언할 폴더에서 파일을 만들어 이름을 LKCharacterStat.h로 저장하면 헤더 파일이 만들어진다. 완성된 코드를 보면서 분석하자

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataTable.h"
#include "LKCharacterStat.generated.h"

USTRUCT(BlueprintType)
struct FLKCharacterStat : public FTableRowBase
{
	GENERATED_BODY()
	
public:
	FLKCharacterStat() : MaxHP(0.0f), ATK(0.0f), DEF(0.0f), Exp(0.0f) {}

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat")
	float MaxHP;	// 최대 체력

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat")
	float ATK;		// 공격력

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat")
	float DEF;		// 방어력

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stat")
	float Exp;		// 이 캐릭터를 죽였을 때 획득할 경험치 / 플레이어의 경우 레벨업에 필요한 경험치

	FLKCharacterStat operator+(const FLKCharacterStat& Other) const
	{
		const float* const ThisPtr = reinterpret_cast<const float* const>(this);
		const float* const OtherPtr = reinterpret_cast<const float* const>(&Other);

		FLKCharacterStat Result;
		float* ResultPtr = reinterpret_cast<float*>(&Result);

		int32 StatNum = sizeof(FLKCharacterStat) / sizeof(float);
		for (int32 i = 0; i < StatNum; ++i)
		{
			ResultPtr[i] = ThisPtr[i] + OtherPtr[i];
		}

		return Result;
	}
};

데이터 테이블로 이용하기 위해 FTableRowBase를 상속받게 했다. 그리고 캐릭터의 기본 스탯으로 사용할 변수들을 선언한다

데이터 테이블로 사용할 엑셀의 예시는(csv 파일로 저장해야 함)

다음처럼 엑셀의 Name은 제외하고 변수 이름과 엑셀의 열 이름을 동일하게 해야 로드를 할 수 있다

그리고 연산자 오버로딩으로 Stat끼리 덧셈 연산이 가능하게 했다(이유는 뒤에)

이제 DataTable 애셋을 만들어보자. 브라우저에서 우클릭 후 DataTable을 선택한다

그리고 아까 만든 구조체 클래스를 선택

애셋을 만들면 처음에는 데이터가 비어있는데 Reimport를 눌러서 데이터로 사용할 csv 파일을 선택하면 데이터들이 로드된다. 에디터에서 즉석으로 데이터를 변경할 수도 있지만 되도록이면 csv 파일에서 편집 후 Reimport만 누르면 자동으로 로드된다

싱글톤

갑자기 왜 뜬금없이 싱글톤? 이라기에는 우리가 위에서 엑셀 데이터를 불러왔으니 이 데이터를 하나의 클래스에서 관리를 하는 것이 좋으니까 싱글톤 클래스를 만들어 데이터를 불러오고 관리하게 만들자

유니티랑은 다르게 언리얼에서는 자체적으로 싱글톤 클래스가 존재한다
1. GameInstance
2. AssetManager
3. 게임 플레이 관련 액터들(GameMode, GameState)
4. 세팅에서 싱글톤으로 등록한 언리얼 오브젝트

4번 방법으로 싱글톤을 이용할텐데 언리얼 오브젝트를 만들어 여기에 등록시켜주면 된다
ProjectSetting > Engine > General Setting

LKGameSingleton 클래스에 이제 엑셀 데이터를 불러와서 관리하게 하자

// .h
UCLASS()
class LOSTKINGDOM_API ULKGameSingleton : public UObject
{
	GENERATED_BODY()
	
public:
	ULKGameSingleton();
	static ULKGameSingleton& Get();

	// Character Stat
public:
	FORCEINLINE FLKCharacterStat GetPlayerStat(int32 InLevel) const { return PlayerStatTable.IsValidIndex(InLevel - 1) ? PlayerStatTable[InLevel - 1] : FLKCharacterStat(); }
	FORCEINLINE FLKCharacterStat GetEnemyStat(int32 InLevel) const { return EnemyStatTable.IsValidIndex(InLevel - 1) ? EnemyStatTable[InLevel - 1] : FLKCharacterStat(); }

	UPROPERTY()
	int32 CharacterMaxLevel;

private:
	TArray<FLKCharacterStat> PlayerStatTable;	// 플레이어 레벨 별 스탯
	TArray<FLKCharacterStat> EnemyStatTable;	// 일반 적 레벨 별 스탯
};

// .cpp
ULKGameSingleton::ULKGameSingleton()
{
	static ConstructorHelpers::FObjectFinder<UDataTable> EnemyDataTableRef(TEXT("/Script/Engine.DataTable'/Game/LostKingdom/GameData/LKEnemyStatTable.LKEnemyStatTable'"));
	if (nullptr != EnemyDataTableRef.Object)
	{
		const UDataTable* DataTable = EnemyDataTableRef.Object;
		check(DataTable->GetRowMap().Num() > 0);

		TArray<uint8*> ValueArray;
		DataTable->GetRowMap().GenerateValueArray(ValueArray);
		Algo::Transform(ValueArray, EnemyStatTable,
			[](uint8* Value)
			{
				return *reinterpret_cast<FLKCharacterStat*>(Value);
			}
		);
	}

	static ConstructorHelpers::FObjectFinder<UDataTable> PlayerDataTableRef(TEXT("/Script/Engine.DataTable'/Game/LostKingdom/GameData/LKPlayerStatTable.LKPlayerStatTable'"));
	if (nullptr != PlayerDataTableRef.Object)
	{
		const UDataTable* DataTable = PlayerDataTableRef.Object;
		check(DataTable->GetRowMap().Num() > 0);

		TArray<uint8*> ValueArray;
		DataTable->GetRowMap().GenerateValueArray(ValueArray);
		Algo::Transform(ValueArray, PlayerStatTable,
			[](uint8* Value)
			{
				return *reinterpret_cast<FLKCharacterStat*>(Value);
			}
		);
	}
	CharacterMaxLevel = PlayerStatTable.Num();
	ensure(CharacterMaxLevel > 0);
}

ULKGameSingleton& ULKGameSingleton::Get()
{
	ULKGameSingleton* Singleton = CastChecked<ULKGameSingleton>(GEngine->GameSingleton);
	if (Singleton)
	{
		return *Singleton;
	}

	return *NewObject<ULKGameSingleton>();
}

생성자에서 엑셀 데이터를 파싱해서 가져와 배열로 관리한다. DataTable에서 Key-Value의 형식으로 존재하기에 초기화를 저렇게 한다

그리고 외부에서 Get 함수를 통해 싱글톤의 인스턴스를 불러올 수 있게 선언한다(이때 GEngine의 GameSingleton을 불러오는 것을 확인할 수 있음)

캐릭터 스탯 컴포넌트

다시 스탯 컴포넌트 클래스로 돌아가자

이전에는 최대 체력을 스탯 컴포넌트에서 관리했는데 이제 스탯 구조체로 옮겼으니 제외하고 현재 레벨과 Stat 구조체 변수를 두 개 선언해준다

UPROPERTY(VisibleInstanceOnly, Category = Stat)
int32 CurrentLevel;

UPROPERTY(Transient, VisibleInstanceOnly, Category = "Stat", Meta = (AllowPrivateAccess = true))
FLKCharacterStat BaseStat;

UPROPERTY(Transient, VisibleInstanceOnly, Category = "Stat", Meta = (AllowPrivateAccess = true))
FLKCharacterStat ModifierStat;

BaseStat은 말 그대로 캐릭터의 레벨에 해당하는 기본 스탯이고 ModifierStat은 아이템, 장비 등으로 변경한 스탯을 저장하는 변수이다

함수들을 보면

virtual void SetLevelStat(int32 InNewLevel);
FORCEINLINE int32 GetCurrentLevel() const { return CurrentLevel; }
FORCEINLINE void AddBaseStat(const FLKCharacterStat& InAddBaseStat) { BaseStat = BaseStat + InAddBaseStat; }
FORCEINLINE void SetBaseStat(const FLKCharacterStat& InBaseStat) { BaseStat = InBaseStat; }
FORCEINLINE void SetModifierStat(const FLKCharacterStat& InModifierStat) { ModifierStat = InModifierStat; }

FORCEINLINE const FLKCharacterStat& GetBaseStat() { return BaseStat; }
FORCEINLINE const FLKCharacterStat& GetModifierStat() { return ModifierStat; }
FORCEINLINE const FLKCharacterStat	GetFinalStat() { return BaseStat + ModifierStat; }
ULKCharacterStatComponent::ULKCharacterStatComponent()
{
	CurrentLevel = 1;

	bWantsInitializeComponent = true;
}

void ULKCharacterStatComponent::InitializeComponent()
{
	Super::InitializeComponent();

	SetLevelStat(CurrentLevel);
	SetHP(BaseStat.MaxHP);
}

void ULKCharacterStatComponent::SetLevelStat(int32 InNewLevel)
{
	CurrentLevel = FMath::Clamp(InNewLevel, 1, ULKGameSingleton::Get().CharacterMaxLevel);
	SetBaseStat(ULKGameSingleton::Get().GetEnemyStat(CurrentLevel));
	check(BaseStat.MaxHP > 0);
}
  • SetLevelStat은 매개변수로 들어온 레벨에 해당하는 스탯을 BaseStat에 저장할 것이다
  • BaseStat, ModifierStat을 변경하고 불러오는 함수와 최종 스탯을 반환하는 GetFinalStat도 있다

그 다음에 플레이어 캐릭터 스탯 컴포넌트를 만들고 이제 기본 스탯이 아닌 BattleStat 구조체를 선언 후 치명, 특화, 신속 스탯을 만들어 관리하게 하고 비슷하게 나둔다

플레이어 캐릭터 블루프린트로 가서 Stat 컴포넌트를 클릭해 Component Class 변경

이제 게임을 시작하고 캐릭터의 Stat Category를 보면 데이터가 잘 들어갔다

업로드중..

이제 어느정도 스탯을 다 완성했으니 이 스탯을 이용해 경험치 획득해 레벨업이라거나 치명, 특화, 신속을 반영해보는 응용을 해 볼 예정임

점점 글을 간략하게 써가는 것 같은데 은근 힘드네,,,,

profile
나태지옥

0개의 댓글