TrapLevel Implement

정혜창·2025년 3월 4일
0

내일배움캠프

목록 보기
34/43
post-thumbnail

스테이지 진행중 TrapLevel로 이동한 뒤 다시 원래의 레벨로 돌아오는 로직을 구현해보았다.
원래의 레벨로 다시 돌아와야 하므로 Game의 상황을 저장하고 레벨을 왕복해야한다. 우선 기본적인 레벨 오프너인 OpenLevel 을 사용하면 UEngine에서 관리하는 최상위 객체인 GameInstance 외에는 모든 것이 초기화 되기때문에 모든 상황을 Instance에 저장을 했다가 다시 Load 해야되는 불편함이 있다.
따라서 ServerTravel과, SeamLessTravel을 사용해보았다.


🎮 SeamLessTravel과 ServelTravel

우선 기본적으로 SeamLessTravel과 ServerTravel은 멀티플레이환경에서 주로 사용된다.
둘 다 서버가 새로운 맵을 로드하면서 클라이언트와 연결을 유지하는 방식이지만, SeamlessTravel은 클라이언트의 접속을 더 안정적으로 유지하고, 특정 객체를 그대로 보존하는 기능이 추가된 것이다.

✅ OpenLevel은 완전히 서버가 유지가 되지않아서 완전히 새로운 게임 세션이 시작된다.

✅그러나 SeamLessTravel과 ServerTravel은 둘 다 동일한 서버를 유지한다. SeamLessTravel이 false라도 PlayerController는 네트워크 세션을 통해 유지가 되기 때문에 다시 연결될 때 기존 PlayerController을 사용할 수 있는 것이다.

✅멀티플레이에서 SeamLessTravel은 ServerTravel(NewLevel) 을 호출하면 현재 실행중인 서버가 새로운 레벨을 로드하게되고, 서버가 유지되면서 클라이언트를 새로운 맵으로 보낸다.

✅서버가 유지되더라도 GameMode와 GameState는 새로운 맵이 로드될 때 반드시 새로 생성되도록 설계 되어있다.
이는 언리얼 엔진의 게임 구조상 맵마다 서로 다른 게임 규칙을 가질 수 잇도록 하기 위한 설계 철학이다.
👉 이것도 유지할 수 있느 방법이 또 존재한다.

🔥 싱글플레이에서는 플레이어가 클라이언트와 서버의 역할을 혼자 다 한다.
언리얼 엔진에서는 기본적으로 멀티플레이어 지원을 염두에 두고 만들어졌기 때문에, 싱글플레이에서도 서버-클라이언트 구조를 유지하는데, 이것으로 인해 플레이어는 "서버 + 클라이언트" 역할을 동시에 수행한다.

🔥 따라서 기존에 서버를 유지하고 클라이언트를 다음레벨로 넘기는 것은 불가능하다. ServerTravel()을 하면 서버가 다시 생성된다.

🔥 하지만 SeamlessTravel = true 이면 엔진이 PlayerState와 Pawn을 새로운 서버로 복사하여 유지하려고 한다.


🎮 싱글플레이에서 적용

이번 프로젝트를 진행하면서 기존 Stage가 넘어가는 방식은 적을 모두 처치하면 클리어 포탈이 생성되면서 오버랩하면 GameInstance의 TArray<FName> StageMapNames;에 저장되어있는 맵으로 전환하는 방식이였다. 그래서 아직 스테이지가 클리어가 되기전에 트랩 포탈을 들어가서 트랩 레벨로 전환하는건 다른 방식으로 전환하여야 했다. 그래서 ServerTravel을 활용하였다.

📌 GameMode.h

UCLASS()
class GUNFIREPARAGON_API AFPSGameMode : public AGameMode
{
	GENERATED_BODY()

public:
	
	AFPSGameMode();
	virtual void BeginPlay() override;
	void TravelToLevel(FName LevelName);
	void ReturnToPreviousLevel();
	void SpawnTrapPortals();
	void SavePlayerLocation();
	void RestorePlayerLocation();
	
    UFUNCTION()
	void OnStageClear();
	UFUNCTION()
	void SpawnEnemiesForStage(int32 StageNumber);
	UFUNCTION()

	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Clear Portal")
	TSubclassOf<ATrapPortal> TrapPortalClass;
	
	bool bIsInTrapLevel;
	bool bPortalSpawned;
};
📌 GameMode.cpp
void AFPSGameMode::BeginPlay()
{
	Super::BeginPlay();

	UFPSGameInstance* FPSGameInstance = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
	if (FPSGameInstance)
	{
		FString PreviousLevel = FPSGameInstance->GetPreviousLevel();
		bIsInTrapLevel = FPSGameInstance->bIsInTrapLevel;
		if (bIsInTrapLevel)
		{
			UE_LOG(LogTemp, Warning, TEXT("BeginPlay() - Returning from TrapLevel, Restoring Player Location."));
			RestorePlayerLocation();
			FPSGameInstance->bIsInTrapLevel = false;
		}
		else
		{
			FPSGameInstance->bIsInTrapLevel = false;
			UE_LOG(LogTemp, Warning, TEXT("BeginPlay() - Normal level transition, skipping RestorePlayerLocation()."));
			SpawnTrapPortals();
		}

	}
}

void AFPSGameMode::TravelToLevel(FName LevelName)
{
	UWorld* World = GetWorld();
	if (!World) return;

	UFPSGameInstance* FPSGameInstance = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
	if (FPSGameInstance)
	{
		FString CurrentLevel = World->GetMapName();
		CurrentLevel.RemoveFromStart(World->StreamingLevelsPrefix);
		FPSGameInstance->SetPreviousLevel(CurrentLevel);
		FPSGameInstance->SaveMouseSensitivity();
		UE_LOG(LogTemp, Warning, TEXT("Saved Previous Level: %s"), *CurrentLevel);
	}
	SavePlayerLocation();
	World->ServerTravel(LevelName.ToString(), true);
}

void AFPSGameMode::ReturnToPreviousLevel()
{
	UFPSGameInstance* FPSGameInstance = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
	if (FPSGameInstance)
	{
		FString PreviousLevel = FPSGameInstance->GetPreviousLevel();
		if (!PreviousLevel.IsEmpty())
		{
			UE_LOG(LogTemp, Warning, TEXT("Returning to Level: %s"), *PreviousLevel);
			GetWorld()->ServerTravel(PreviousLevel, true);
			bIsInTrapLevel = true;
			FPSGameInstance->bIsInTrapLevel = bIsInTrapLevel;
		}
		else
		{
			UE_LOG(LogTemp, Error, TEXT("PreviousLevel is Empthy"));
		}
	}
}

void AFPSGameMode::SpawnTrapPortals()
{
	UFPSGameInstance* FPSGameInstnace = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
	if (!FPSGameInstnace) return;

	AActor* TrapPortalSpawnPoint = nullptr;
	TArray<AActor*> FoundActors;
	UGameplayStatics::GetAllActorsOfClass(GetWorld(), ATrapPortalPoint::StaticClass(), FoundActors);
	for (AActor* Actor : FoundActors)
	{
		if (Actor && Actor->ActorHasTag(FName("TrapPortalPoint")))
		{
			UE_LOG(LogTemp, Warning, TEXT("Found Actor: %s"), *Actor->GetFName().ToString());
			TrapPortalSpawnPoint = Actor;
			break;
		}
	}

	if (TrapPortalSpawnPoint)
	{
		FActorSpawnParameters SpawnParams;
		ATrapPortal* SpawnedPortal = GetWorld()->SpawnActor<ATrapPortal>(
			TrapPortalClass,
			TrapPortalSpawnPoint->GetActorLocation(),
			FRotator::ZeroRotator,
			SpawnParams
			);

		if (SpawnedPortal)
		{
			if (ATrapPortalPoint* TrapPortalPoint = Cast<ATrapPortalPoint>(TrapPortalSpawnPoint))
			{
				if (TrapPortalPoint->PortalAction == ETrapPortalAction::Entry)
				{
					SpawnedPortal->PortalType = ETrapPortalTypes::TravelToTrap;
				}
				else if (TrapPortalPoint->PortalAction == ETrapPortalAction::Exit)
				{
					SpawnedPortal->PortalType = ETrapPortalTypes::ReturnToStage;
				}
			}
		}
	}
}

void AFPSGameMode::SavePlayerLocation()
{
	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
	if (PlayerController)
	{
		AMyPlayerController* FPSPlayerController = Cast<AMyPlayerController>(PlayerController);
		if (!FPSPlayerController || !FPSPlayerController->GetPawn()) return;

		UFPSGameInstance* FPSGameInstance = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
		
		if (FPSGameInstance)
		{
			FPSGameInstance->StoredPlayerLocation = FPSPlayerController->GetPawn()->GetActorLocation();
			UE_LOG(LogTemp, Warning, TEXT("Saved Player Location: %s"), *FPSGameInstance->StoredPlayerLocation.ToString());
		}	
	}	
}

void AFPSGameMode::RestorePlayerLocation()
{
	APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
	if (PlayerController)
	{
		AMyPlayerController* FPSPlayerController = Cast<AMyPlayerController>(PlayerController);
		if (!FPSPlayerController || !FPSPlayerController->GetPawn()) return;

		UFPSGameInstance* FPSGameInstance = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
		
		if (FPSGameInstance)
		{
			FVector SavedLocation = FPSGameInstance->StoredPlayerLocation;
			FPSPlayerController->GetPawn()->SetActorLocation(SavedLocation, false, nullptr, ETeleportType::TeleportPhysics);
			UE_LOG(LogTemp, Warning, TEXT("Restored Player Location: %s"), *SavedLocation.ToString());
		}	
	}
}
📌 TrapPotalPoint, TrapPortalTypes, TrapPortalAction
UENUM(BlueprintType)
enum class ETrapPortalTypes : uint8
{
	TravelToTrap UMETA(DisplayName = "TravelToTrap"),
	ReturnToStage UMETA(DisplayName = "ReturnToStage")
};

UENUM(BlueprintType)
enum class ETrapPortalAction : uint8
{
	Entry UMETA(DisplayName = "Entry"),
	Exit UMETA(DisplayName = "Exit")
};

UCLASS()
class GUNFIREPARAGON_API ATrapPortalPoint : public ATargetPoint
{
	GENERATED_BODY()

public:
	ATrapPortalPoint();

	// UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Portal")
	// ETrapPortalTypes PortalType;
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Portal")
	ETrapPortalAction PortalAction;
};
📌 TrapPortal.cpp
ATrapPortal::ATrapPortal()
{
	PrimaryActorTick.bCanEverTick = false;

	SceneComponent = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
	SetRootComponent(SceneComponent);

	PortalMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("PortalMesh"));
	PortalMesh->SetupAttachment(SceneComponent);

	CollisionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("CollisionBox"));
	CollisionBox->SetupAttachment(SceneComponent);
	CollisionBox->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
	CollisionBox->SetCollisionResponseToAllChannels(ECR_Ignore);
	CollisionBox->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
	CollisionBox->OnComponentBeginOverlap.AddDynamic(this, &ATrapPortal::OnPortalOverlap);

	PortalEffect = CreateDefaultSubobject<UParticleSystemComponent>(TEXT("PortalEffect"));
	PortalEffect->SetupAttachment(SceneComponent);

	PortalType = ETrapPortalTypes::TravelToTrap;
}

void ATrapPortal::OnPortalOverlap(
	UPrimitiveComponent* OverlappedComp,
	AActor* OtherActor,
	UPrimitiveComponent* OtherComp,
	int32 OtherBodyIndex,
	bool bFromSweep,
	const FHitResult& SweepResult)
{

	APlayerCharacter* FPSCharacter = Cast<APlayerCharacter>(OtherActor);

	if (FPSCharacter && FPSCharacter->IsA(APlayerCharacter::StaticClass()))
	{
		AFPSGameMode* FPSGameMode = Cast<AFPSGameMode>((GetWorld()->GetAuthGameMode()));
		UFPSGameInstance* FPSGameInstance = Cast<UFPSGameInstance>(UGameplayStatics::GetGameInstance(this));
		if (FPSGameMode && FPSGameInstance)
		{
			if (PortalType == ETrapPortalTypes::TravelToTrap)
			{
				FPSGameMode->TravelToLevel("Stage_Trap_01");

			}
			else if (PortalType == ETrapPortalTypes::ReturnToStage)
			{
				FPSGameMode->ReturnToPreviousLevel();
			}
		}
	}
}

✅ 우선 기획자가 편하게 배치할 수 있도록 뷰포트에서 TrapPoint를 이동하면 해당 위치에서 TrapPoint가 스폰이 되도록 설정 하였다.

✅ 원래는 TrapEntryPortal, TrapEntryPoint // TrapExitPortal, TrapExitPoint 이렇게 4개로 만들려고 했으나 하나의 TrapPortal과 TrapPoint가 두가지 역할을 할 수 있도록 Enum을 활용하였다. (뭔가 더 복잡해진듯...)

✅ TrapPoint에서 Entry와 Exit 타입을 지정을 해주면 해당 타입을 참조한 조건문을 통해 TrapPortal 타입인 TravelToTrap, ReturnToStage 포탈을 스폰하도록 해주었다.

✅ 그리고 TrapPortal 클래스에서 오버랩 되면 해당 타입을 타입을 참조해 게임모드의 TravelToLevel(Level), ReturnToPreviousLevel() 을 조건에 맞게 호출하도록 하였다.

🔥 여기까지는 좋았는데 문제는 해당 트랩 레벨을 클리어 하면 들어가기전 자리에서 나와야 되는데 이전레벨의 GameStart 부분에서 다시 시작하는 것이였다. 그래서 TravelToLevel(Level)안에서 ServerTravel하기 전에 SavePlayerLocation을 통해서 GameInstance에 플레이어의 위치를 (FVector)을 저장했다.

🔥 이후 ReturnToStage() 내부의 ServerTravel 이후 RestorePlayerLocation() 호출하려고 했지만 바로 즉시 호출하여 게임모드가 생성이 아직 안되어서 인지 아니면 다른 모종의 이유가 있어선지는 모르지만 정상적으로 작동이 되지않아 GameMode의 BeginOverlap에서 호출해 주었다.

🔥 정상적으로 다시 돌아오는 모습이다.


🎮 회고

게임에서 Level전환은 매우 빈번하게 일어난다. 레벨 전환이 없을 것 같은 오픈월드 조차도 레벨전환이 매우 많이 일어난다. 볼륨이 클 수록 많아지는 것도 당연. 그러나 고작 TrapLevel로 들어갔다와 나와서 들어갔던 위치에서 나오도록 하는 로직일 뿐인데 이렇게 애먹었다는게 조금은 분하였다. 이렇게나 생각할 부분이 많았나? 레벨이 전환되면서 어떤 부분이 유지가 되고 어떤 부분이 새로 생성되는지 알아야 했고, 나왔을 때 포탈이 다시 생성되어있지 않도록 신경써야하며, 위치를 인스턴스에 저장하고 다시 불러오는 것 까지... 쉽지 않았던 것 같다. 그러나 TrapLevel이 생각한대로 정상적으로 구현이 되었을 때는 되게 짜릿했었다.

profile
Unreal 1기

0개의 댓글

관련 채용 정보