[UE5] 퀘스트 시스템 개편 - 1

연하·2024년 7월 24일
0

Trapper

목록 보기
25/32
post-thumbnail

이제 코어루프가 끝났으니, 기획의 본 의도에 맞게 게임의 스케일을 키워야 한다. 근데 도대체 왜 구조는 고심해서 짜고나서도 돌아보면 마음에 안드는건지..

지금 게임의 진행 단계 & 퀘스트 & 스크립트 & 시네마틱이 맞물려 돌아가고 있어서 너무 서로에 대한 의존도가 높다. 이걸 어떻게 관리해야 할까..

게임의 단계, 시네마틱 영상, 스크립트, 카메라 무빙 등 퀘스트의 진행 단계에 따라 트리거되는 것들이 많다. 따라서, 가장 먼저 퀘스트 시스템을 개편하려고 한다.

지금은 퀘스트 매니저가 게임모드에서 생성되고 있는데, 서브시스템으로 만들어 퀘스트를 관리하도록 해보려고 한다. 이 친구는 각 퀘스트에 맞게끔 액터를 생성해주는 역할도 할 것이고, 시네마틱이나 카메라 연출을 재생시켜주기도 할 것이다.

코드를 짜봐야 알겠지만, 대충 이런 느낌의 구조가 나올 것 같다. 시네마틱과 스크립트, 카메라 이벤트들은 퀘스트 매니저와 게임 모드의 명령을 받고 재생만 하고, 게임 모드와 퀘스트 매니저는 서로의 함수를 호출해야 할 듯.

퀘스트 서브시스템 생성

먼저, 레벨과 같은 생명주기로 돌아가는 UWorldSubsystem 을 상속받아 QuestSubSystem 클래스를 만들어주었다.

DECLARE_MULTICAST_DELEGATE_OneParam(FOnQuestExecute, int32)

UCLASS()
class TRAPPERPROJECT_API UQuestSubsystem : public UWorldSubsystem
{
	GENERATED_BODY()
	
public:
	UQuestSubsystem();

public:
	virtual void OnWorldBeginPlay(UWorld& InWorld) override;

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

	TArray<FQuest> QuestList;
	int32 CurrentQuestIndex = 0;
	FQuest GetCurrentQuest();

	FOnQuestExecute OnQuestExecute;
	void QuestCheck(int32 InQuestCode);
	void QuestComplete();

	TArray<TObjectPtr<class AInteract>> QuestActorBox;
	void ActiveQuestActor(int32 QuestID);

	TObjectPtr<class AQuestEffect> QuestEffect;
	void SetQuestUI(FString Title, FString Contents);
};

기존 게임모드, 퀘스트 매니저에서 관리하던 퀘스트와 관련된 변수와 함수의 선언부를 옮겨주었다. 구현부는 변경해야 하므로, 조금 이따가 이어서 진행하도록 하겠다!

퀘스트 구조체 개선

기존의 퀘스트는 서버가 모두 관리했고, 클라이언트는 퀘스트의 정보를 갖고있지 않은채로 서버에서 데이터를 받아 UI만 갱신하는 식으로 관리했었다. 따라서 한명이 퀘스트를 완료해도 누가 완료했는지 알 수 없는 문제가 있었다. 클라이언트쪽에서 퀘스트를 완료했다면 클라이언트쪽에서 UI나 이펙트 등으로 표시해주기로 했는데, 그러기 위해서는 먼저 퀘스트 구조체를 변경해야 한다.

겸사겸사 아이템 획득처럼 현재 퀘스트가 진행중이 아니더라도 체크해야 하는 것들도 하드코딩 해놨던 것들을 자동화 시켜보기로 했다. 또한, 퀘스트를 진행할 때 퀘스트와 관련된 액터들을 미리 배치해두는 것이 아니라 필요할 때마다 생성하도록 바꾸기 위해 퀘스트 액터 데이터 테이블을 만들고, 그 데이터 테이블을 참조하여 액터를 생성하도록 퀘스트 구조체가 생성해야 할 데이터 테이블의 번호를 갖고있도록 바꿔줄 것이다.

// 변경한 퀘스트 구조체

USTRUCT()
struct FQuest
{
	GENERATED_USTRUCT_BODY()

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

	// 퀘스트 종류
	EQuestType QuestType;
    
    // 퀘스트 코드
    int32 QuestCode;

	// 제목 & 설명
	FString Title;
	FString Description;

	// 해야하는 행동의 횟수
	int32 Count = 0;
	int32 GoalCount = 0;

	// 퀘스트를 진행중이 아닐때도 체크해야할 때
	uint8 bIsAlwaysChecking : 1 = false;

	// 퀘스트 완료 확인
	uint8 bUserComplete : 1 = false;
	uint8 bTeamComplete : 1 = false;

	// 활성화해야 할 액터 코드
	TArray<int32> QuestActorCode;

	// 퀘스트 이펙트 위치
	FVector EffectPosition = FVector();
};
// 데이터 테이블용 구조체

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) uint8 bIsAlwaysChecking : 1;

	UPROPERTY(EditAnywhere, BlueprintReadWrite) TArray<int32> QuestActorCode;
	UPROPERTY(EditAnywhere, BlueprintReadWrite) FVector EffectPosition;

	UPROPERTY(EditAnywhere, BlueprintReadWrite) FString Memo;
};

퀘스트 액터 데이터 테이블 생성

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

public:
	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	TSubclassOf<class AActor> QuestActor;
    
    UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FVector Position;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FRotator Rotation;

	UPROPERTY(EditAnywhere, BlueprintReadWrite)
	FVector Scale;
};

생성할 액터를 넣어두는 테이블을 만들어 준 뒤, 기존 퀘스트에 필요했던 액터들을 테이블에 데이터로 옮겨주었다.

퀘스트에 해당하는 액터 생성

void UQuestSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
	Super::Initialize(Collection);

	FString QuestTablePath = TEXT("/Script/Engine.DataTable'/Game/Blueprints/Data/DT_QuestData.DT_QuestData'");
	UDataTable* QuestTable = LoadObject<UDataTable>(nullptr, *QuestTablePath);
	if (QuestTable)
	{
		QuestData = QuestTable;
	}

	UDataTable* QuestActorTable = LoadObject<UDataTable>(nullptr, TEXT("/Script/Engine.DataTable'/Game/Blueprints/Data/DT_QuestActorData.DT_QuestActorData'"));
	if (QuestActorTable)
	{
		QuestActorData = QuestActorTable;
	}
}

서브시스템의 Initialize 함수를 오버라이드 한 뒤, 퀘스트 테이블과 퀘스트 액터 테이블을 받아왔다.

void UQuestSubsystem::OnWorldBeginPlay(UWorld& InWorld)
{
	OnQuestExecute.AddUObject(this, &UQuestSubsystem::QuestCheck);

	if (QuestData)
	{
		// 리스트에 퀘스트 추가
		AddQuest();
	}

	// 테스트용 -----------------------------------
	if (QuestActorData)
	{
		// 퀘스트 액터 생성
		CreateQuestActor();
	}

}
void UQuestSubsystem::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->QuestActorCode, Data->GoalCount, Data->EffectPosition);
		QuestList.Add(Quest);

		if (Data->bIsAlwaysChecking)
		{
			AlwaysCheckQuestList.Add(&QuestList.Last());
		}
	}
}

AddQuest 함수는 바뀐 퀘스트의 구조체에 맞게 초기화하도록 바꿔주었고, bIsAlwaysChecking 변수가 true일 경우 AlwaysCheckQuestList 에 추가로 저장해주었다. AlwaysCheckQuestList 는, QuestList 안에 들어가 있는 퀘스트 구조체의 주소를 저장하는 배열이다.

void UQuestSubsystem::CreateQuestActor()
{
	// 서버가 아니면 return
	ENetMode NetMode = GetWorld()->GetNetMode();
	if (NetMode != NM_ListenServer)
	{
		return;
	}

	// 현재 액터에서 생성해야 할 액터 코드
	TArray<int32> CurrentQuestActorCode = GetCurrentQuest().QuestActorCode;

	// 액터 생성
	for (auto Code : CurrentQuestActorCode)
	{
		FQuestActorInfo* Data = QuestActorData->FindRow<FQuestActorInfo>(*FString::FromInt(Code), FString());

		FTransform QuestActorTransform;
		QuestActorTransform.SetLocation(Data->Position);
		QuestActorTransform.SetRotation(FQuat::MakeFromRotator(Data->Rotation));
		QuestActorTransform.SetScale3D(Data->Scale);

		QuestActorBox.Add(GetWorldRef().SpawnActor(Data->QuestActor, &QuestActorTransform));
	}

	//UE_LOG(LogTemp, Warning, TEXT("Create Quest Actor"));
}

액터는 서버에서만 생성하도록 해주었다. 현재 퀘스트 데이터의 생성해야 할 액터 코드를 가져온 뒤, 퀘스트 액터 데이터 테이블에서 클래스를 받아와 생성해주는 코드이다. 생성한 뒤에는 따로 선언해준 QuestActorBox에 넣어준다.

테스트를 위해 투명벽에 텍스트 렌더러를 따로 넣어주었다. 서버와 클라이언트 모두 첫번째 퀘스트에서 생성해주어야 하는 1, 2, 7번째 투명벽들을 잘 생성해준 모습을 볼 수 있다.

퀘스트 체크 및 판정처리

서브시스템은 RPC나 리플리케이트가 안되는 것 같다. 따라서, 기존의 퀘스트 매니저를 서브시스템에서 생성해 가지고 있도록 하고, 퀘스트 매니저를 이용해 네트워킹을 진행해보려고 한다.

어차피 게임 인스턴스를 통해 서브시스템을 찾아서 쓰기 때문에, 기존에 사용하던 델리게이트는 삭제해주었다.

void UQuestSubsystem::QuestCheck(int32 InQuestCode)
{
	// 항상 체크하는 퀘스트들
	for (auto AlwaysCheckQuest : AlwaysCheckQuestList)
	{
		// 이미 완료한 상태라면 Exit
		if (AlwaysCheckQuest->bUserComplete)
		{
			break;
		}

		// 퀘스트 코드가 같다면 Count 증가
		if (AlwaysCheckQuest->QuestCode == InQuestCode)
		{
			AlwaysCheckQuest->Count++;
		}

		// 목표 Count보다 높거나 같아졌다면
		if (AlwaysCheckQuest->Count >= AlwaysCheckQuest->GoalCount)
		{
			AlwaysCheckQuest->bUserComplete = true;

			// 퀘스트 완료 서버 처리
		}
	}

	FQuest& CurrentQuest = GetCurrentQuest();

	// 현재 퀘스트를 완료한 상태가 아니고 퀘스트 코드가 같다면 Count 증가
	if (!CurrentQuest.bUserComplete && CurrentQuest.QuestCode == InQuestCode)
	{
		CurrentQuest.Count++;
	}

	// 목표 Count보다 높거나 같아졌다면
	if (CurrentQuest.Count >= CurrentQuest.GoalCount)
	{
		CurrentQuest.bUserComplete = true;
		
		// 퀘스트 완료 처리
		ENetMode NetMode = GetWorld()->GetNetMode();

		// 서버이고, 팀도 클리어 했을 경우 퀘스트 완료판정
		if (NetMode == NM_ListenServer)
		{
			if (CurrentQuest.bTeamComplete)
			{
				QuestComplete();
			}
			else
			{
				// 클라이언트에게 완료 알리기
			}
		}
		else
		{
			// 서버에게 완료 알리기
		}
	}

	// UI 변경
	SetQuestUI();
}

void UQuestSubsystem::QuestComplete()
{
	PlayQuestCompleteSound();

	// 정비시간으로 이동
	if (CurrentQuestIndex == MoveMaintenanceQuestIndex)
	{
		//MyOwner->SetGameProgress(EGameProgress::Maintenance);
		//MyOwner->InitialItemSetting();
		// 서버와 클라이언트 모두 UI / 이펙트 OFF
		return;
	}

	CurrentQuestIndex++;

	// 마지막 퀘스트 클리어일 경우, 게임 클리어 판정
	if (CurrentQuestIndex == LastQuestIndex)
	{
		//MyOwner->SetGameProgress(EGameProgress::GameClear);
		return;
	}

	FQuest& NewQuest = GetCurrentQuest();

	// 이미 클리어 했을 시 2초 뒤에 QuestComplete 호출
	if (NewQuest.bUserComplete && NewQuest.bTeamComplete)
	{
		FTimerHandle TimerHandle;
		GetWorld()->GetTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([&]
			{
				QuestComplete();
			}
		), 1.0f, false, 2.0f);
	}

	// 현재 퀘스트 액터 정리
	DestroyQuestActor();

	// 다음 퀘스트 액터 준비
	CreateQuestActor();

	// 서버와 클라이언트 모두 UI / 이펙트 변경 처리
}

주석으로 예쁘게 설명했으므로 설명은 생략(...) 중간중간 주석이 대체하고 있는 것들은 다른 것들 먼저 구현해놓고 하나씩 채워나갈 예정이다. 옮기다보니 치명적인 버그가 있던걸 확인했는데.... 지금까지 빌드에서는 너무 의도대로 잘 작동해서 몰랐다. 이렇게라도 발견해서 너무 다행이야,, ㅜㅜ

퀘스트 액터 수정

이제 새로 짠 로직에 맞게 기존에 만들어두었던 AInteract 관련 액터들을 수정해주어야 한다.

원래는 Interact 액터에서 두 캐릭터 모두 상호작용을 완료했는지 판정하고 퀘스트 완료처리를 해주었다. 이제는 퀘스트 액터들이 로컬 플레이어의 퀘스트 완료 여부만 처리하고, 퀘스트 서브시스템에서 두 플레이어가 모두 완료했는지 확인 후 퀘스트 완료판정을 내리도록 바꿔줄 것이다.

AInteract

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

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

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

protected:
	uint8 bPlayerActive : 1;
};

void AInteract::CheckActivate(int32 Value)
{
	if(bPlayerActive) return;

	UQuestSubsystem* QuestSubSystem = GetWorld()->GetSubsystem<UQuestSubsystem>();
	QuestSubSystem->QuestCheck(Value);
	bPlayerActive = true;
}

CheckActivate 함수와 bPlayerActive 변수를 제외하고 모두 삭제했다.

void ATrapperPlayer::FClickStarted(const FInputActionValue& Value)
{
	if (!IsLocallyControlled()) return;

	// 라인 트레이싱 부분 생략...

	if (HasHit)
	{
		AInteract* InteractActor = Cast<AInteract>(HitResult.GetActor());
		if (!InteractActor) return;

		if (HasAuthority())
		{
			MulticastRPCPlayInstallAnim();
		}
		else
		{
			InteractActor->CheckActivate(30);
			ServerRPCPlayInstallAnim();
		}
	}
}

플레이어에서 해당 액터에 상호작용 했을 때, 서버일 경우 애니메이션만 재생해주고 클라이언트에서 퀘스트 완료 판정 처리를 해줬다.

AMoveQuestTriggerBox

UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
uint8 bCheckMagneticMoving : 1;

void AMoveQuestTriggerBox::NotifyActorBeginOverlap(AActor* OtherActor)
{
	Super::NotifyActorBeginOverlap(OtherActor);

	ATrapperPlayer* Player = Cast<ATrapperPlayer>(OtherActor);
	if (!Player || !Player->IsLocallyControlled()) return;

	if (!bPlayerInTriggerTutorial)
	{
		// 트리거 발동 확인 코드
		CheckActivate(1);
	}
	else
	{
		// 자성 이동 확인해서 트리거 발동
		if (Player->Movement->GetMagneticMovingState())
		{
			CheckActivate(2);
		}
	}
}

bCheckMagneticMoving 변수만 남겨놓았다. 이 변수를 사용해 자성이동 트리거와 일반 접근 트리거로 나누어줬다.

퀘스트 액터 블루프린트 생성

기존에는 Tower가 AInteractive를 상속받아 동작하도록 했지만, 이제 타워 위에 상호작용 박스를 생성하여 상호작용이 동작하도록 만들어줄 것이다. 자성이동용 트리거 박스도 만들어주어야 한다.

Interact 콜리전 채널만 Block해준 상호작용 박스를 만들고,

기존 이동 트리거 박스를 복제해 자성이동을 체크하도록 만들어주었다.

다 만든뒤 데이터 테이블에 수정된 것들을 모두 반영해줬다.


이틀동안은 구조를 고민하고, 큰 그림을 그려두었다. 아직 많이 부족하긴 하지만, 내일부터는 네트워크부터 해서 점차적으로 디테일한 부분들을 바꿔나가야겠다.

0개의 댓글