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

연하·2024년 7월 30일
0

Trapper

목록 보기
26/32
post-thumbnail

서브시스템 포기 - 액터로 교체

흐어어엉엉,, 역시는 역시 한번에 잘 풀릴리가 없다.. 네트워크 지원이야 안된다는걸 알고 사용한거긴 하지만 따로 액터를 생성해서 RPC를 날려서 서브시스템의 함수를 호출해도 클라이언트는 받질 못하고.. 동기화도 안되고.............. 잘 모르고 편해보인다고 쓰면 이렇게 되는거다.... 반나절을 넘게 헤맸지만 아직도 왜 안되는건지 잘 모르겠다.. 다시 액터로 교체 들어갑니다,,, 그냥 레벨에 배치해서 써야겠다 ^-^..ㅎ..

(+ 교수님께 여쭤봤더니 서브시스템은 동기화가 안되는거라고 한다.. 서버 관련된건 여기서 안 쓰는게 맞다함)
(+ 뒤늦게 생각해봤는데.. 만들어준 액터에 Owner 설정을 안해줬던 것이 문제였던 것 같다..)

퀘스트 매니저 리뷰

갑자기 왜 리뷰로 넘어왔냐면(..) 퀘스트 매니저를 액터로 바꾸면서 정신없이 구현하는 바람에... 과정을 설명하기가 애매해져버렸다. 우선 지금까지의 진행상황이라도 정리하기로 했다.

생성자 & BeginPlay

AQuestManager::AQuestManager()
{
	// 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;
	}

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

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

생성자에서는 데이터 테이블을 읽어들인다. 튜토리얼의 몬스터 배치때문에 튜토리얼 몬스터 데이터 테이블(배치해야하는 몬스터의 Position / Rotation 값)을 추가해주었다.

void ATrapperPlayerController::BeginPlay()
{
	Super::BeginPlay();

	UEnhancedInputLocalPlayerSubsystem* SubSystem =
		ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetLocalPlayer());
	if (SubSystem && DefaultIMC)
	{
		SubSystem->AddMappingContext(DefaultIMC, 0);
	}

	if (!HasAuthority())
	{
		ServerRPCUpdatebIsClientStart(true);
	}

	InitializeHUD();

	for (AQuestManager* QuestManager : TActorRange<AQuestManager>(GetWorld()))
	{
		QuestManager->SetOwner(this);
		QuestManager->SetQuestUI();
	}
}

플레이어 컨트롤러의 BeginPlay에서 퀘스트 매니저의 Owner를 자신으로 설정해주고, SetUI를 호출해준다. 왜 여기서 UI를 세팅하도록 호출해주었냐면, SetUI 함수 내에서 플레이어 컨트롤러를 호출해 UI를 설정해주고 있기 때문이다. 추후 시퀀스 매니저가 생기면, 오프닝 시퀀스 재생이 끝난 후 거기서 호출해줄 생각이다.

void AQuestManager::BeginPlay()
{
	Super::BeginPlay();

	ATrapperGameState* GameState = GetWorld()->GetGameState<ATrapperGameState>();
	GameState->OnQuestExecute.AddUObject(this, &AQuestManager::QuestCheck);

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

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

	// 튜토리얼 몬스터 세팅
	if (HasAuthority())
	{
		FTimerHandle Handle;
		GetWorldTimerManager().SetTimer(Handle, this, &AQuestManager::TutorialMonsterSetting, 1.f, false);
	}

	// 이펙트 생성
	QuestEffect = GetWorld()->SpawnActor<AQuestEffect>();
	SetQuestEffect();
}

퀘스트 매니저의 BeginPlay 함수가 호출되면, 퀘스트 리스트에 퀘스트를 추가하고, 현재 퀘스트에 관련된 액터를 생성해준다.

튜토리얼 몬스터 세팅의 경우 몬스터를 만든 친구의 로직(Begin Play가 끝난 이후에 호출되어야 함)때문에 일단 임시로 타이머를 호출해놨다.

퀘스트를 추가하는 부분을 제외한 나머지도 마찬가지로 오프닝 시퀀스가 끝난 후 설정하도록 변경할 것이다.

AddQuest()

void AQuestManager::AddQuest()
{
	LastQuestIndex = QuestData->GetRowMap().Num() - 1;

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

		FQuest Quest;
		Quest.Initialize(Data->QuestCode, Data->Title, Data->Description,
			Data->GoalCount1P, Data->GoalCount2P, Data->EffectPosition, Data->QuestActorCode);

		if (Data->bIsAlwaysChecking)
		{
			Quest.bIsAlwaysChecking = true;
		}

		if (Data->bChangeMainQuest)
		{
			Quest.bChangeMainQuest = true;
		}

		if (Data->bMoveToMaintenance)
		{
			Quest.bMoveToMaintenance = true;
		}

		if (Data->ExceptionCode != 0)
		{
			Quest.ExceptionCode = Data->ExceptionCode;
		}

		if (Data->bTutorialEnd)
		{
			TutorialEndIndex = i;
		}

		QuestList.Add(Quest);
	}
}

퀘스트 추가 함수에 예외문이 많이 생겼다. 항상 체크해야하는 퀘스트를 체크하기 위한 불변수, 메인 퀘스트가 바뀌는 경우를 체크하기 위한 불변수(추후에 메인 애니메이션마다 UI 애니메이션을 넣어주어야 하기 때문), 정비시간으로 이동해야하는 경우를 체크하기 위한 불변수, 추가적으로 예외처리를 해줘야 하는 상황을 위한 예외코드, 튜토리얼 스킵에 사용해야 해서 필요한 튜토리얼의 인덱스.. 아마 추가적으로 기획자분들이 얘기하는 것들에 따라 더 생길수도 있는데, 일단은 이정도로 마무리해두었다.

QuestCheck()

void AQuestManager::QuestCheck(int32 InQuestCode, bool bIs1P)
{
	// 항상 체크하는 퀘스트 확인
	AlwaysCheckQuestCheck(InQuestCode, bIs1P);

	FQuest& CurrentQuest = GetCurrentQuest();

	// 퀘스트 코드 안맞으면 Exit
	if (CurrentQuest.QuestCode != InQuestCode)
	{
		return;
	}

	// Count 증가
	if (bIs1P)
	{
		CurrentQuest.Count1P++;
	}
	else
	{
		CurrentQuest.Count2P++;
	}

	if (IsQuestClear())
	{
		UE_LOG(LogQuest, Warning, TEXT("-- Complete --"));

		// 퀘스트 완료 처리
		if (HasAuthority())
		{
			QuestComplete();
		}
		else
		{
			ServerRPCQuestComplete();
		}
	}
	else
	{
		UE_LOG(LogQuest, Warning, TEXT("-- Keep Going --"));

		if (HasAuthority())
		{
			ClientRPCAddCount(CurrentQuestIndex, CurrentQuest.Count1P);
		}
		else
		{
			ServerRPCAddCount(CurrentQuestIndex, CurrentQuest.Count2P);
		}
	}

	// 이펙트 설정
	if (HasAuthority())
	{
		if (GetCurrentQuest().Count1P >= GetCurrentQuest().GoalCount1P)
		{
			QuestEffect->QuestPingEffect->Deactivate();
		}
	}
	else
	{
		if (GetCurrentQuest().Count2P >= GetCurrentQuest().GoalCount2P)
		{
			QuestEffect->QuestPingEffect->Deactivate();
		}
	}

	// UI 변경
	SetQuestUI();

	UE_LOG(LogQuest, Warning, TEXT("[%s] Count 1P : %d / 2P : %d - Current Index %d"),
		*GetCurrentQuest().Title, GetCurrentQuest().Count1P, GetCurrentQuest().Count2P, CurrentQuestIndex);
}

게임 스테이트의 델리게이트에 바인딩되어있는 함수이다. 클라이언트와 서버 모두 호출되고 있고, 들어오는 퀘스트 코드에 따라 진행도만 증가시키거나 퀘스트 완료를 판정해준다.

void AQuestManager::AlwaysCheckQuestCheck(int32 InQuestCode, bool bIs1P)
{
	for (int i = 0; i < QuestList.Num(); i++)
	{
		checkf(QuestList.IsValidIndex(i), TEXT("Always Check List Index Error"));
		FQuest& AlwaysCheckQuest = QuestList[i];

		if (!AlwaysCheckQuest.bIsAlwaysChecking ||
			AlwaysCheckQuest.QuestCode != InQuestCode ||
			i == CurrentQuestIndex)
		{
			continue;
		}

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

		// Count 증가
		if (bIs1P)
		{
			AlwaysCheckQuest.Count1P++;
			ClientRPCAddCount(i, AlwaysCheckQuest.Count1P);
		}
		else if (!bIs1P)
		{
			AlwaysCheckQuest.Count2P++;
			ServerRPCAddCount(i, AlwaysCheckQuest.Count2P);
		}

		UE_LOG(LogQuest, Warning, TEXT("-- Always Check Quest -- Count 1P : %d / 2P : %d"),
			AlwaysCheckQuest.Count1P, AlwaysCheckQuest.Count2P);
	}
}

항상 체크해야 하는 퀘스트를 확인하는 함수. 조건문 처리를 해 해당되지 않는 퀘스트들은 바로바로 continue 시키고, 맞다면 진행도를 증가시킨다.

void AQuestManager::ServerRPCQuestComplete_Implementation()
{
	UE_LOG(LogQuest, Warning, TEXT("Server RPC Quest Complete"));

	QuestComplete();
}

void AQuestManager::ServerRPCAddCount_Implementation(int32 Index, int32 Count)
{
	QuestList[Index].Count2P = Count;
	SetQuestUI();
}

void AQuestManager::ClientRPCAddCount_Implementation(int32 Index, int32 Count)
{
	QuestList[Index].Count1P = Count;
	SetQuestUI();
}

AddCount() 함수는 상대방의 카운트를 받아 동기화해주는 작업을 한다.

QuestComplete()

void AQuestManager::QuestComplete()
{
	UE_LOG(LogQuest, Warning, TEXT("[%s] Quest Complete"), *GetCurrentQuest().Title);

	PlayQuestCompleteSound();

	// 정비시간으로 이동
	if (GetCurrentQuest().bMoveToMaintenance)
	{
		ATrapperGameMode* GameMode = GetWorld()->GetAuthGameMode<ATrapperGameMode>();
		GameMode->SetGameProgress(EGameProgress::Maintenance);
		GameMode->InitialItemSetting();
		
		UE_LOG(LogQuest, Warning, TEXT("Go Maintenance"));
	}

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

	CurrentQuestIndex++;

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

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

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

	// 둘다 서버만 변경함
	// 클라이언트 이펙트는 CurrentIndex의 OnRep 함수에서 변경
	SetQuestUI();
	SetQuestEffect();
}

void AQuestManager::OnRep_ChangeCurrentIndex()
{
	SetQuestEffect();
	SetQuestUI();
}

퀘스트가 완료되었을 때 서버에서 호출하는 함수. 퀘스트 완료음을 재생하고, 조건에 따라 게임 진행 상태를 진척시킨다. CurrentQuestIndex 를 리플리케이트 설정하여 인덱스가 바뀌었을 경우 클라이언트쪽에서 UI와 이펙트를 설정하도록 해주었다.

void AQuestManager::HandleQuestExceptions()
{
	switch (GetCurrentQuest().ExceptionCode)
	{
		// 찰코함정 활성화
		case 1 :
		{
			for (ABearTrap* BearTrap : TActorRange<ABearTrap>(GetWorld()))
			{
				BearTrap->MagneticTriggerControl(true);
			}
			break;
		}
		default:
		{
			break;
		}
	}
	
}

CreateQuestActor() 함수 마지막에 넣어준 예외처리 함수이다. 퀘스트 데이터를 읽어올 때 받아온 예외 코드를 스위치문으로 만들어 필요한 처리를 해줄 수 있도록 만들어두었다.

SkipTutorial()

void AQuestManager::SkipTutorial()
{
	CurrentQuestIndex = TutorialEndIndex;

	SetQuestUI();
	SetQuestEffect();

	DestroyQuestActor();

	for (ATutorialMonster* TutorialMonster : TActorRange<ATutorialMonster>(GetWorld()))
	{
		TutorialMonster->Teleport(TutorialMonster->StartPoint);
	}

	for (ABearTrap* BearTrap : TActorRange<ABearTrap>(GetWorld()))
	{
		BearTrap->DestroyHandle();
	}

	CreateQuestActor();
}

튜토리얼 스킵 함수를 호출했을 때, 현재 퀘스트 인덱스를 튜토리얼의 다음 퀘스트로 바꾸어준 뒤, 튜토리얼 퀘스트와 관련된 액터들을 정리해준다.

Set Effect & UI

void AQuestManager::SetQuestEffect()
{
	if (QuestEffect)
	{
		bool IsActive = false;
		if (GetCurrentQuest().EffectPosition != FVector::Zero())
		{
			IsActive = true;
		}

		QuestEffect->SetQuestEffect(IsActive, GetCurrentQuest().EffectPosition);
	}
}

void AQuestManager::SetQuestUI()
{
	FQuest CurrentQuest = GetCurrentQuest();
	FString Description = CurrentQuest.Description;

	if (CurrentQuest.TotalGoalCount > 1)
	{
		uint8 TotalGoal = CurrentQuest.Count1P + CurrentQuest.Count2P;
		Description += TEXT("(") + FString::FromInt(TotalGoal) + TEXT(" / ") +
			FString::FromInt(CurrentQuest.TotalGoalCount) + TEXT(")");
	}

	for (ATrapperPlayerController* PlayerController : TActorRange<ATrapperPlayerController>(GetWorld()))
	{
		PlayerController->SetQuestInfo(CurrentQuest.Title, Description);
	}
}

이펙트와 UI를 설정해주는 코드이다. 기존의 코드를 거의 그대로 사용한다 :)


이제 둘이서 클리어해야 하는 퀘스트더라도, 각각 완료된 상황을 파악해 이펙트를 꺼주거나 하는 등의 처리가 가능해졌다. 예외처리도 할 수 있게 되었고, 네트워크를 통하지 않고 UI와 이펙트를 로컬로 처리하게 되었다(가장 큰 목표..). 얼마나 효율적으로 코드를 개선했는지는 확실치 않지만, 확장에는 조금 더 유연한 구조가 되었길 기도해본다.

이제 시퀀스 매니저와 스크립트 매니저를 만들고, 게임 모드 - 게임 스테이트 - 퀘스트 매니저 - 시퀀스 매니저 - 스크립트 매니저간에 서로 얽히어 있는 이벤트들을 처리하며 메타루프를 완성해나가기로 했다.

0개의 댓글