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

연하·2024년 7월 4일
0

Trapper

목록 보기
18/32

퀘스트 구현

이제 세부적인 퀘스트들을 하나씩 구현하면서 튜토리얼 퀘스트의 흐름을 만들어보려고 한다. 이번 포스팅에서는 흐름을 모두 구현해두고, 다음 포스팅에선 퀘스트와 관련된 이펙트와 UI를 처리해줄 것이다.

[Move] 자성 이동 가이드

특정 장소로 이동하는 퀘스트인데, '솔루나 시프트'를 사용해서 이동해야 한다. 이동 퀘스트에서 사용할 수 있는 트리거를 하나 만들어주기로 했다.

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

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 NotifyActorBeginOverlap(AActor* OtherActor) override;
	virtual void NotifyActorEndOverlap(AActor* OtherActor) override;

public:
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Components")
	TObjectPtr<class UBoxComponent> BoxComponent;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Trigger")
	uint8 bRequireAllPlayersInTrigger : 1;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Trigger")
	uint8 bActivateOnEnterOnly : 1;

	uint8 bFirstPlayerInTrigger : 1;
	uint8 bSecondPlayerInTrigger : 1;

	uint8 bFirstPlayerInTriggerTutorial : 1;
	uint8 bSecondPlayerInTriggerTutorial : 1;

	void CheckTriggerActivate();
};

트리거로 동작해야 하므로 BeginOverlap, EndOverlap 함수를 오버라이드 해주었다.

여러 곳에서 다양한 방식으로 작동할 수 있도록 하기 위해, 조건을 검사하는 다양한 변수들을 넣어주었다.

사용한 조건들

bRequireAllPlayersInTrigger 1P, 2P 동시에 트리거에 접근해야 하는지, 혹은 한 사람만 접근해도 되는지
bActivateOnEnterOnly : 접근 후에 트리거 밖으로 나가도 되는지, 트리거 안에 들어가 있어야 하는지를 구분

만약 둘 다 트리거에 접근해야 하고 들어가기만 하면 되는 조건이라면, 위의 두 변수를 모두 true 로 설정해주면 된다. 에디터 상에서 쉽게 변경할 수 있도록 UPROPERTY 를 지정해주었다.

또한, 솔루나 시프트 튜토리얼을 위해 추가로 변수를 선언했다.

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

	if (!OtherActor->HasAuthority()) return;

	ATrapperPlayer* Player = Cast<ATrapperPlayer>(OtherActor);
	if (Player)
	{
		if (Player->IsLocallyControlled())
		{
			bFirstPlayerInTrigger = true;

			// Tutorial
			if (Player->Movement->GetMagneticMovingState())
			{
				bFirstPlayerInTriggerTutorial = true;
			}
		}
		else
		{
			bSecondPlayerInTrigger = true;

			// Tutorial
			if (Player->Movement->GetMagneticMovingState())
			{
				bSecondPlayerInTriggerTutorial = true;
			}
		}
	}

	CheckTriggerActivate();

	UE_LOG(LogTemp, Warning, TEXT("Overlap State %d %d"), bFirstPlayerInTriggerTutorial, bSecondPlayerInTriggerTutorial);
}

플레이어가 트리거에 접근했을 때 호출되는 함수이다. 서버일때만 호출하도록 했고, 만약 플레이어가 로컬일 경우 bFirstPlayerInTrigger 변수를 true 로 설정, 로컬이 아닐 경우 bSecondPlayerInTrigger 변수를 true 로 설정했다.

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

	if (!OtherActor->HasAuthority() || bActivateOnEnterOnly) return;

	ATrapperPlayer* Player = Cast<ATrapperPlayer>(OtherActor);
	if (Player)
	{
		if (Player->IsLocallyControlled())
		{
			bFirstPlayerInTrigger = false;
		}
		else
		{
			bSecondPlayerInTrigger = false;
		}
	}

	UE_LOG(LogTemp, Warning, TEXT("EndOverlap"));

}

bActivateOnEnterOnlyfalse 라면, 트리거 밖으로 나갈 경우 위에서 바꿔준 변수들을 다시 false 로 바꿔주었다. 이 함수를 통해 트리거 내부에 모든 플레이어가 있음을 판정할 수 있다.

void AMoveQuestTriggerBox::CheckTriggerActivate()
{
	if (!HasAuthority()) return;

	AGameModeBase* GameMode = UGameplayStatics::GetGameMode(GetWorld());
	ATrapperGameMode* MyGameMode = Cast<ATrapperGameMode>(GameMode);

	if (bRequireAllPlayersInTrigger)
	{
		if (bFirstPlayerInTrigger && bSecondPlayerInTrigger)
		{
			MyGameMode->OnQuestExecute.Broadcast(2);
		}

		if (bFirstPlayerInTriggerTutorial && bSecondPlayerInTriggerTutorial)
		{
			MyGameMode->OnQuestExecute.Broadcast(1);
		}
	}
	else
	{
		if (bFirstPlayerInTrigger || bSecondPlayerInTrigger)
		{
			MyGameMode->OnQuestExecute.Broadcast(2);
		}
	}

}

퀘스트 매니저에게 퀘스트 진행 판정을 트리거하는 함수이다. 솔루나 시프트를 완료하기 위해서는, 모든 플레이어가 트리거에 접근했어야 하고 자성이동을 사용했어야 한다. 이 경우는 1번 코드로 설정해두었으므로, 1번 코드를 트리거해주면 퀘스트 매니저 측에서 완료처리하여 다음 퀘스트로 넘어간다.

블루프린트로 만들어 레벨 내에서 직접 배치할 수 있도록 했다.

두 플레이어 모두 자성이동을 사용해 타겟 위치에 도달해야 퀘스트가 완료되는 것을 확인할 수 있다.

[Interact] 함정 발동 가이드

void ABearTrap::ActivateTrap()
{
	AGameModeBase* GameMode = UGameplayStatics::GetGameMode(GetWorld());
	ATrapperGameMode* MyGameMode = Cast<ATrapperGameMode>(GameMode);
	MyGameMode->OnQuestExecute.Broadcast(11);
    
// 생략..

찰코 함정이 발동될 때 트리거를 발동하도록 설정해주었다.

로그가 잘 보이지는 않지만.. 로그 마지막줄을 보면 델리게이트가 수신된 것을 확인할 수 있다 :)

[Interact] 몹몰이 가이드

위와 똑같은 코드에 퀘스트 코드만 바꾸어 몹몰이가 활성화되는 부분에 넣어주었다.

[Collect] 재화 가이드

void ATrapperPlayer::AddBoneItem(int32 Count)
{
	// 생략..
    
	if(HasAuthority())
	{
		AGameModeBase* GameMode = UGameplayStatics::GetGameMode(GetWorld());
		ATrapperGameMode* MyGameMode = Cast<ATrapperGameMode>(GameMode);

		for (int i = 0; i < Count; i++)
		{
			MyGameMode->OnQuestExecute.Broadcast(20);
		}

		OnItemChanged.Broadcast(BoneItemBox);
	}
}

아이템을 얻는 코드쪽에서 마찬가지로 위와 같은 코드를 추가해줬다. 한가지 다른게 있다면, 만약 아이템이 한번에 많이 들어올 경우를 대비해 for문을 썼다는 것. 한번에 많은 아이템이 들어오는 경우는 함정을 철거했을 때밖에 없으므로, 우선은 임시방편으로(?) 이렇게 만들어 두었다.

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();
	}
	else if (CurrentQuest.QuestType == EQuestType::Interact)
	{
		QuestComplete();
	}
	else if (CurrentQuest.QuestType == EQuestType::Collect)
	{
		CurrentQuest.Count++;

		if (CurrentQuest.Count >= CurrentQuest.GoalCount)
		{
			QuestComplete();
		}
	}
}

퀘스트 매니저의 QuestCheck 함수에서는 델리게이트를 받을 때마다 현재 퀘스트의 Count값을 올려주고, 목표 갯수에 도달하면 퀘스트를 완료하게 된다.

[Move] 지름길 가이드

지름길 가이드 퀘스트는 목표 지점에 도착만 하면 되므로, 해당 옵션을 활성화 해주고 배치해주면 된다.

⚠️ 구조 개선 및 문제 해결

아무래도 퀘스트 코드로 동작하다보니, 같은 레벨에 이 트리거가 두세개 있을 경우 특정 트리거 위치로 가야하는 상황에서 다른 트리거에 접근해 조건에 만족할 경우 퀘스트가 완료처리 될 수 있는 문제가 있다. (아까 배치해둔 자성이동 트리거에 오버랩 하게 되면 이 퀘스트가 완료되버림)

같은 트리거를 두번 쓸 경우에도 문제가 생긴다. 퀘스트가 발생하고 나서, 상태를 초기화 하고 다시 체크해야 한다.

상호작용 액터에서도 똑같은 문제가 생긴다. 이 두가지 문제를 어떻게 해결해야 할까?

게임모드가 트리거와 인터랙션이 일어나는 액터를 배열로 가지고 있게 하고, 퀘스트가 갱신되는 시점에 맞추어 활성화/비활성화 한 뒤 값을 초기화 해주는 방법은 어떨까?

꽤 괜찮은 방법일 것 같아서 이렇게 구현해보기로 했다.

Interact 클래스 제작

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 Initialize();

	virtual void CheckActivate();

	void FirstPlayerActive();
	void SecondPlayerActive();

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Active")
	uint8 bAllPlayersActive : 1;

	bool CheckSameQuestID(int32 InQuestID);

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Quest")
	TArray<int32> QuestIdBox;

	uint8 bCurrentUsed : 1;
	void SetCurrentQuestUse(bool Value);

protected:
	uint8 bFirstPlayerActive : 1;
	uint8 bSecondPlayerActive : 1;
	
};

먼저 트리거 박스(타겟 위치로 이동 체크)와 블락 박스(투명 벽처럼 상호작용을 막는 용도)의 부모 역할이자 캐릭터가 F키를 눌러 상호작용한 것을 체크할 때 사용하기 위한 Interact 클래스를 만들어주었다.

트리거 박스에서 사용하던 bFirstPlayerInTrigger, bSecondPlayerInTrigger, bRequireAllPlayersInTrigger 변수를 공통으로 사용할 수 있도록 이름을 바꾸어 옮겨줬다.

QuestIdBox 라는 배열 변수가 있는데, 이친구는 퀘스트의 아이디를 설정하여 현재 진행중인 퀘스트와 아이디가 같다면 해당 액터를 활성화 해준다. 같은 트리거를 다른 퀘스트에서 사용할 수도 있기 때문에 배열로 선언해주었음!

이렇게 레벨에 배치했을 때 디테일 패널에서 직접 설정할 수 있도록 했다. 현재 활성화된 퀘스트의 아이디가 1번이라면 활성화된다. 자세한 내용들은 조금 뒤에 설명하겠다!

Trigger Box 자식 클래스로 변경

기존에 만들어둔 트리거 박스를 Interact 클래스를 상속받은 자식 클래스로 변경해주었다.

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

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 NotifyActorBeginOverlap(AActor* OtherActor) override;
	virtual void NotifyActorEndOverlap(AActor* OtherActor) override;

	void ChangeTriggerSetting(bool AllPlayersActive, bool ActivateOnEnterOnly);
public:
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Components")
	TObjectPtr<class UBoxComponent> BoxComponent;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Active")
	uint8 bActivateOnEnterOnly : 1;

	uint8 bFirstPlayerInTriggerTutorial : 1;
	uint8 bSecondPlayerInTriggerTutorial : 1;

	virtual void CheckActivate() override;
};

부모 클래스에서 사용할 수 있는 클래스들은 삭제해주었고, 다르게 구현해야 할 친구들은 가상함수를 오버라이드하여 사용하도록 했다.

게임모드에서 Interact 액터 관리하기

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

게임모드에 퀘스트에 사용할 액터들을 담아두는 배열을 선언해주고, 퀘스트에 따라 액터를 활성화/비활성화 해주는 함수를 만들어주었다.

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

	TArray<AActor*> OutActors;
	UGameplayStatics::GetAllActorsOfClass(GetWorld(), AInteract::StaticClass(), OutActors);
	for (const auto& Actor : OutActors)
	{
		AInteract* QuestActor = Cast<AInteract>(Actor);
		if (QuestActor)
		{
			QuestActorBox.Add(QuestActor);
		}
	}
    
    QuestManager = GetWorld()->SpawnActor<AQuestManager>(AQuestManager::StaticClass());
	QuestManager->MyOwner = this;
	OnQuestExecute.AddUObject(this, &ATrapperGameMode::RequiredQuestCheck);
	ActiveQuestActor(QuestManager->CurrentQuestIndex);
    
    // 생략..

BeginPlay() 함수가 호출되면 Interact 클래스를 가진 액터를 모두 검색하여 배열에 넣고, 퀘스트 매니저를 생성한 뒤 첫번째 퀘스트를 세팅해준다.

void ATrapperGameMode::ActiveQuestActor(int32 QuestID)
{
	UE_LOG(LogTemp, Warning, TEXT("ActiveQuestActor"));

	for (const auto& QuestActor : QuestActorBox)
	{
		QuestActor->Initialize();

		bool Value = QuestActor->CheckSameQuestID(QuestID + 1);

		if (Value)
		{
			QuestActor->SetCurrentQuestUse(true);
			UE_LOG(LogTemp, Warning, TEXT("Active Actor : %s"), *QuestActor->GetName());
		}
		else
		{
			QuestActor->SetCurrentQuestUse(false);
			UE_LOG(LogTemp, Warning, TEXT("UnActive Actor : %s"), *QuestActor->GetName());

		}
	}
}

bool AInteract::CheckSameQuestID(int32 InQuestID)
{
	for (auto& Code : QuestIdBox)
	{
		if (Code == InQuestID)
			return true;
	}
	return false;

퀘스트 진행 상황이 갱신될 때마다 호출하며, 액터 배열을 모두 돌면서 Interact 클래스의 Initialize() 함수를 사용해 변수를 초기화해준다. 그 다음으로 CheckSameQuestID 함수를 사용해 현재 퀘스트 아이디에 해당하는 액터를 검색한다. 만약 해당한다면 활성화, 해당하지 않는다면 비활성화 해준다.

void AInteract::FirstPlayerActive()
{
	if(!bCurrentUsed) return;

	bFirstPlayerActive = true;
	CheckActivate();
}

void AInteract::SecondPlayerActive()
{
	if (!bCurrentUsed) return;

	bSecondPlayerActive = true;
	CheckActivate();
}

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

	if (!OtherActor->HasAuthority() || !bCurrentUsed) return;
    
    // 생략...
}

이 액터가 활성화된 상태일 경우에만 상호작용을 처리한다.

[Interact] 제단 상호작용

우선 Interact 클래스를 상속받은 제단 블루프린트를 만들어줬다.

모든 플레이어가 상호작용 해야하므로 All Players Active를 체크해주고, 6번, 7번 퀘스트에 사용되기 때문에 퀘스트 아이디를 두개 지정해주었다.

void ATrapperPlayer::FClick(const FInputActionValue& Value)
{
	if (!IsLocallyControlled()) return;
	
	FVector Start = GetActorLocation();
	FVector End = Start + GetActorForwardVector() * InteractDistance;

	FCollisionShape Sphere = FCollisionShape::MakeSphere(InteractRadius);
	FHitResult HitResult;
	bool HasHit = GetWorld()->SweepSingleByChannel(
		HitResult,
		Start, End,
		FQuat::Identity,
		ECC_GameTraceChannel9,
		Sphere
	);

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

		if (HasAuthority())
		{
			InteractActor->FirstPlayerActive();
			UE_LOG(LogTemp, Warning, TEXT("First Player Active"));
		}
		else
		{
			ServerRPCInteract(InteractActor);
		}
	}
}

void ATrapperPlayer::ServerRPCInteract_Implementation(AInteract* InteractActor)
{
	InteractActor->SecondPlayerActive();
	UE_LOG(LogTemp, Warning, TEXT("Second Player Active"));
}

해당 상호작용에 사용되는 채널을 만들었고, 캐릭터가 F키를 눌렀을 경우 동작하는 함수를 만들어주었다. Sweep을 사용해 전방의 액터를 상호작용 처리하며, 클라이언트의 경우 Server RPC를 사용해 서버에서 상호작용 처리를 하도록 해줬다.

튜토리얼용 투명 벽 제작

마찬가지로, Interact class를 상속받은 BlockBox class를 만들었다.

튜토리얼 퀘스트를 진행하는 중 이동하지 못하게 막는 용도이며, 기획서에 맞게 하나씩 사라지게 만들었다.

void ATrapperGameMode::ActiveQuestActor(int32 QuestID)
{
	UE_LOG(LogTemp, Warning, TEXT("ActiveQuestActor"));

	TArray<ABlockBox*> DeleteBlockBox;

	for (const auto& QuestActor : QuestActorBox)
	{
		if(!QuestActor) continue;

		QuestActor->Initialize();

		bool Value = QuestActor->CheckSameQuestID(QuestID + 1);

		if (Value)
		{
			QuestActor->SetCurrentQuestUse(true);
			UE_LOG(LogTemp, Warning, TEXT("Active Actor : %s"), *QuestActor->GetName());
		}
		else
		{
			QuestActor->SetCurrentQuestUse(false);
			UE_LOG(LogTemp, Warning, TEXT("UnActive Actor : %s"), *QuestActor->GetName());

			// 투명벽 처리
			ABlockBox* BlockBox = Cast<ABlockBox>(QuestActor);
			if (BlockBox)
			{
				DeleteBlockBox.Add(BlockBox);
			}
		}
	}

	for (int i = 0; i < DeleteBlockBox.Num(); i++)
	{
		QuestActorBox.Remove(DeleteBlockBox[i]);
		DeleteBlockBox[i]->Destroy();
	}
}

원래는 Interact 클래스에 새로운 bool 변수를 하나 만들어서 끌 때 지워주는 처리를 하려고 했는데, 지금 빌드가 좀 급한 상황이라(...) 임시방편으로 캐스트하여 투명벽을 지워주는 쪽으로 했다. 추후에 더 유연하게 사용할 수 있도록 변경할 예정이다.

드디어 튜토리얼~게임 클리어까지 흐름이 이어진다. 이제 안내창 UI를 만들어 튜토리얼의 진행상황을 파악할 수 있도록 해보겠다!

0개의 댓글