[UE5] 튜토리얼 & 클리어 퀘스트 구현 - 1

연하·2024년 7월 4일
0

Trapper

목록 보기
17/32

메인루프의 웨이브-정비 다음으로 중요한 퀘스트 단계를 구현하기로 했다.

게임 내 퀘스트가 튜토리얼 퀘스트와 클리어 퀘스트 몇개 정도만 존재하기 때문에, switch-case문을 사용해 하드코딩으로 작업하는게 훨씬 빠를수도 있다는 판단이 들었다.

하지만, 추후에 퀘스트가 추가될 수도 있고, 조금 더 유연한 구조를 만들고싶은 욕심이 생겨서 조금이나마 구조를 고민하게 됐다.

퀘스트 메인로직 설계

고려해야 할 것

  1. 게임모드를 통한 퀘스트 관리
    ㄴ 서버가 퀘스트를 관리하고, 클라이언트에서는 관련된 것들만(UI 등) 변경시켜준다.
  2. 우리 게임은 한번에 하나 이상의 퀘스트를 가지지 않고, 그 안에서도 하나의 목적만 달성하면 된다.
  3. 퀘스트가 순차적으로 발생한다.

퀘스트 데이터 테이블 만들기

USTRUCT()
struct FQuestInfo : public FTableRowBase
{
	GENERATED_USTRUCT_BODY()

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite) EQuestType QuestType;

	UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Title;
	UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Description;
	UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 GoalCount;

	UPROPERTY(EditAnywhere, BlueprintReadWrite) FVector PingPosition;

	UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Memo;

	UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 QuestCode;
};

먼저, 퀘스트를 리스트로 관리하기 위해 데이터 테이블을 만들어주었다.

퀘스트와 퀘스트 매니저 만들기

다양한 액터들이 퀘스트와 관련되어 있기 때문에, 퀘스트는 게임모드에서 관리하는게 맞다고 생각했다. 따라서, 가장 먼저 퀘스트와 퀘스트 매니저를 만들어 주었다.

UENUM(BlueprintType)
enum class EQuestType : uint8
{
	Move				UMETA(DisplayName = "Move"),
	Interact			UMETA(DisplayName = "Interact"),
	Collect				UMETA(DisplayName = "Collect"),
};

USTRUCT()
struct FQuest
{
	GENERATED_USTRUCT_BODY()

public:
	FQuest() {}
	void Initialize(EQuestType InQuestType, FString InTitle, FString InDescription, int32 InQuestCode, int32 InGoalCount, FVector InPingPosition);

	FString Title;
	FString Description;
	int32 QuestCode;
	int32 bIsComplete : 1;

	EQuestType QuestType;

	int32 Count;
	int32 GoalCount;

	FVector PingPosition;
};

지금까지 나온 퀘스트의 종류는 크게 세가지로 나눌 수 있었기에, 유형에 맞추어 Enum class를 만들어 주었다.

퀘스트 구조체 안에는 UI에 표시할 퀘스트 제목, 설명과 방금 만든 퀘스트 유형, 완료했는지의 여부 등 부가적인 정보들이 들어간다.

원래는 구조체가 아니라 클래스로 만들고 유형별로 퀘스트를 상속받은 자식클래스들을 만들어 접근하려고 했는데, 그정도의 관리가 필요한 스케일도 아닐 뿐더러, 구현 중에 계속 가비지 컬렉션이 되버리는 이슈가 발생했기 때문에(시간을 많이 날렸다.....) 그냥 구조체 하나로 축소했다.

언리얼 가비지 컬렉터 관련
Struct는 GC에 해당하지 않고, 어차피 클래스 내에서 멤버변수로 관리하기 때문에 클래스가 소멸되면 사라진다. 데이터만을 사용하는 변수들은 구조체로 관리하는 것을 추천한다고 한다.

UCLASS()
class TRAPPERPROJECT_API AQuestManager : public AActor
{
	GENERATED_BODY()
	
public:	
	// Sets default values for this actor's properties
	AQuestManager();

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

public:	
	// Called every frame
	virtual void Tick(float DeltaTime) override;

public:
	TObjectPtr<class UDataTable> QuestData;
	void AddQuest();

	TArray<struct FQuest> QuestList;
	int32 CurrentQuestIndex = 0;

	void QuestCheck(int32 InQuestCode);
	void QuestComplete();
};
AQuestManager::AQuestManager()
	: QuestList()
{
	// Set this actor to call Tick() every frame.  You can turn this off to improve performance if you don't need it.
	PrimaryActorTick.bCanEverTick = true;

	static ConstructorHelpers::FObjectFinder<UDataTable> QuestTable(TEXT("/Script/Engine.DataTable'/Game/Blueprints/Data/DT_QuestData.DT_QuestData'"));
	if (QuestTable.Succeeded() && QuestTable.Object)
	{
		QuestData = QuestTable.Object;
	}
}

// Called when the game starts or when spawned
void AQuestManager::BeginPlay()
{
	Super::BeginPlay();

	if (QuestData)
	{
		AddQuest();
	}
}

// Called every frame
void AQuestManager::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
}

void AQuestManager::AddQuest()
{
	for (int i = 1; i <= QuestData->GetRowMap().Num(); i++)
	{
		FQuestInfo* Data = QuestData->FindRow<FQuestInfo>(*FString::FromInt(i), FString());

		FQuest Quest;
		Quest.Initialize(Data->QuestType, Data->Title, Data->Description, Data->QuestCode, Data->GoalCount);
		QuestList.Add(Quest);

		UE_LOG(LogTemp, Warning, TEXT("[Add Quest] %s"), *Data->Title);
	}

	UE_LOG(LogTemp, Warning, TEXT("[Quest Add Complete]"));
}

퀘스트가 한번에 하나씩 순차적으로 진행하기 때문에 배열에 퀘스트를 넣어 보관하고, 인덱스를 사용해 접근하도록 했다. 생성자에서 퀘스트 데이터 테이블을 불러와 퀘스트 데이터를 만들어두고, BeginPlay() 함수에서 퀘스트를 리스트에 추가하는 작업을 한다.

델리게이트를 활용한 퀘스트 관리

FOnQuestExecute OnQuestExecute;

TObjectPtr<class AQuestManager> QuestManager;
UFUNCTION() void RequiredQuestCheck(int32 InQuestCode);

// --------------------------------------

void ATrapperGameMode::BeginPlay()
{
	Super::BeginPlay();
	QuestManager = GetWorld()->SpawnActor<AQuestManager>(AQuestManager::StaticClass());
	OnQuestExecute.AddUObject(this, &ATrapperGameMode::RequiredQuestCheck);

	SetGameProgress(EGameProgress::Tutorial);
}

void ATrapperGameMode::RequiredQuestCheck(int32 InQuestCode)
{
	QuestManager->QuestCheck(InQuestCode);
}

게임모드에서 퀘스트 매니저를 만들어주고, 퀘스트 실행을 알리는 델리게이트를 만들어주었다. 퀘스트와 관련된 외부 액터들이 게임모드에 접근해 델리게이트를 트리거하면, 바인딩된 퀘스트 매니저의 QuestCheck() 함수가 실행된다.

퀘스트 완료 판정

퀘스트의 실행 여부는 퀘스트 코드로 판정하게 된다. 예를들어, 어떤 행동을 했을 때 델리게이트를 호출한다고 치자.

MyGameMode->OnQuestExecute.Broadcast(1);

이런식으로 특정 행동에 대한 int 코드를 날려주게 되는데(내가 임의로 지정해둔 코드라, 정리가 따로 필요하긴 할 것 같다),

void AQuestManager::QuestCheck(int32 InQuestCode)
{
	UE_LOG(LogTemp, Warning, TEXT("[Recieve BroadCast] QuestCode : %d"), InQuestCode);

	if (!QuestList.IsValidIndex(CurrentQuestIndex)) return;

	FQuest& CurrentQuest = QuestList[CurrentQuestIndex];
	if (CurrentQuest.QuestCode != InQuestCode) return;

	if (CurrentQuest.QuestType == EQuestType::Move)
	{
		QuestComplete();
	}
}

그럼 퀘스트 매니저의 QuestCheck 함수에서 현재 진행중인 퀘스트에서 요구로 하는 퀘스트 코드와 비교하여 맞다면 완료처리 해주는 식이다.

void AQuestManager::QuestComplete()
{
	CurrentQuestIndex++;

	if (!QuestList.IsValidIndex(CurrentQuestIndex)) return;
	UE_LOG(LogTemp, Warning, TEXT("[Current Quest] %s"), *QuestList[CurrentQuestIndex].Title);
}

퀘스트가 완료처리 되면, 다음 퀘스트로 넘어가게 된다.

아직 이동 관련 퀘스트만 처리해놔서 세부적인 로직은 덜 구현되어 있지만, 일단 퀘스트에 대한 처리 구조(?)는 이런식으로 구성해놨다.

0개의 댓글