레벨 전환

목표

게임 진행을 위한 레벨 전환을 만들어보자

1) 구조 설계

레벨 구성

Untitled

진행은 3단계, 레벨은 2개로 구성된다. 진행 순서대로 알아보자.

  1. 시작 화면

    플레이어가 게임을 시작하면 처음 마주하는 화면이다. 본인이 세션을 생성하거나 다른 세션을 탐색하여 참가할 수 있다. 로비와 같은 레벨을 공유하지만 이어지지 않고 리슨 서버로 동일 레벨을 호출한다.

  2. 로비

    세션 생성 혹은 세션 참가 이후 플레이어들이 게임 시작 이전까지 대기하는 장소이다. 준비 버튼과 시작화면으로 돌아가기 버튼이 있으며, 모든 플레이어가 준비 버튼을 누른다면 게임이 시작된다.

  3. 메인 게임

    별도의 레벨을 사용하는 메인 게임. 매치 종료 혹은 메뉴에서 나가기 선택 시, 시작화면으로 레벨 이동이 가능하다.

레벨 전환

언리얼 엔진에서 레벨 전환은 크게 Seamlessnon-Seamless 방식, ServerTravelClientTravel로 구분된다.

Seamless는 서버-클라이언트 연결을 유지하며 레벨을 전환하는 방식이고 non-Seamless는 한 번 연결을 끊고 다시 서버에 접속하는 방식이다.

ServerTravel은 서버 전용으로써 서버에 접속한 모든 클라이언트를 새 레벨로 이동시킨다. 모든 클라이언트에 대하여 ClientTravel을 호출하는 방식이다. ClientTravel은 서버와 클라이언트 모두 사용할 수 있으며, 특정 클라이언트를 새 레벨로 이동시키는 방식이다.

위의 레벨 구성 항목을 보면 레벨간 전환은 크게 3번 발생한다.

  • 시작화면↔로비 : 클라이언트 개인 이동이므로 ClientTravel을 사용한다.
  • 메인게임↔시작화면 : 클라이언트 개인 이동이므로 ClientTravel을 사용한다.
  • 로비→메인게임 : 모든 클라이언트의 이동이므로 ServerTravel을 사용한다.

특히 마지막의 ServerTravel은 non-Seamless 방식을 사용했다. 레벨이 전환되면서 게임모드를 비롯한 모든 것을 새로 생성하기 위함이다. 레벨을 전환하더라도 전달되어야 하는 정보는 게임 실행 내내 유지되는 GameInstance에 저장했다.

void AEPLobbyGameMode::StartMainGame()
{
	// 현재 로비에 참가한 총 플레이어 수를 저장하여 다음 레벨에서 사용할 수 있게끔 저장한다.
	UEPGameInstance* EPGameInstance = Cast<UEPGameInstance>(GetGameInstance());
	if (EPGameInstance)
	{
		EPGameInstance->SetPlayerCount(MaxPlayers);
	}

	// 서버트래블
	GetWorld()->ServerTravel(MainGameMapName, true);
}

2) 로비 레벨

Untitled

시작화면 및 로비를 담당하는 로비 레벨이다. 플레이어의 접속에 따른 석상 관리와 세션 생성 및 참가, 모든 참가자의 준비 상태 확인 및 매치 시작을 관리한다.

시작화면에서 세션 참가를 통해 로비로 이동

void UEPSessionBlockWidget::OnJoinSessionButtonClicked()
{
	if (MultiplayerSessionSubsystem)
	{
		JoinSessionButton->SetIsEnabled(false);
		MultiplayerSessionSubsystem->JoinSession(SessionFindResult);
	}
}

void UEPSessionBlockWidget::OnJoinSessionComplete(EOnJoinSessionCompleteResult::Type Result)
{
	IOnlineSessionPtr SessionInterface = *MultiplayerSessionSubsystem->GetSessionInterface();
	if (SessionInterface.IsValid())
	{
		if (Result == EOnJoinSessionCompleteResult::Success)
		{
			// 세션 이름을 사용하여 원하는 세션을 찾고, 그 주소를 가져온다.
			FString Address;
			SessionInterface->GetResolvedConnectString(NAME_GameSession, Address);

			APlayerController* PlayerController = GetGameInstance()->GetFirstLocalPlayerController();
			if (PlayerController)
			{
				PlayerController->ClientTravel(Address, ETravelType::TRAVEL_Absolute);
			}
		}
		// 세션 침가 실패
		else 
		{
			JoinSessionButton->SetIsEnabled(true);
		}
	}
}

로비에서 모든 인원 준비 완료 시 서버 트래블

void AEPLobbyGameMode::UpdateReadyPlayerCount()
{
	ReadyPlayerCount = 0;

	// 전체 인원에 대하여 레디 상태 확인
	auto& PlayerArray = GetGameState<AGameState>()->PlayerArray;
	for(int32 i = 0; i<PlayerArray.Num(); ++i)
	{
		AEPLobbyPlayerState* PlayerState = Cast<AEPLobbyPlayerState>(PlayerArray[i]);
		if (PlayerState)
		{
			if (PlayerState->GetIsReady())
			{
				++ReadyPlayerCount;
			}
		}
	}

	// 세션에서 함께 플레이할 수 있는 최대 인원 수 확인
	if (MultiplayerSessionSubsystem)
	{
		FNamedOnlineSession* Session = MultiplayerSessionSubsystem->GetSession();
		MaxPlayers = Session->SessionSettings.NumPublicConnections;
	}

	// 각 플레이어에게 최대 연결 가능 수 대비 현 인원 수 전달
	for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
	{
		APlayerController* PlayerController = It->Get();
		if (PlayerController)
		{
			AEPLobbyPlayerController* EPLobbyPlayerController = Cast<AEPLobbyPlayerController>(PlayerController);
			if (EPLobbyPlayerController)
			{
				EPLobbyPlayerController->ClientRPC_UpdatePalyerCount(ReadyPlayerCount, MaxPlayers);
			}
		}
	}

	CheckStartMainGame();
}

void AEPLobbyGameMode::CheckStartMainGame()
{
	// 모든 플레이어가 준비되었을 때, 약간의 유예를 두고 메인 게임을 시작한다.
	if (ReadyPlayerCount >= MaxPlayers)
	{
		FTimerHandle StartMainGameTimerHandle;
		GetWorld()->GetTimerManager().SetTimer(StartMainGameTimerHandle, this, &AEPLobbyGameMode::StartMainGame, 1.0f, false);
	}
}

3) 메인 레벨

Untitled

본 게임이 진행되는 메인 레벨이다. 게임이 끝나고 일정 시간이 지나거나 돌아가기 버튼을 눌러서 시작 화면으로 이동할 수 있다. 게임 도중에 나가기 버튼을 눌러서 나갈 수도 있다.

시작 화면으로 복귀

void UEPGameEndWidget::OnReturnToLobbyButtonClicked()
{
	UEPMultiplayerSessionSubsystem* MultiplayerSessionSubsystem = GetGameInstance()->GetSubsystem<UEPMultiplayerSessionSubsystem>();
	if (MultiplayerSessionSubsystem)
	{
		MultiplayerSessionSubsystem->DestroySession();
		UGameplayStatics::OpenLevel(GetWorld(), *LevelPath);
	}
}

세션에서 벗어나 로비 레벨로 이동한다.

4) 주의사항

레벨 전환 직후 콜리전 미작동

각각의 레벨에서 실행할 때는 멀쩡하게 작동하던 콜리전이 레벨 전환 직후에는 작동하지 않는 경우가 있다. 이 경우 캐릭터가 갑자기 바닥으로 떨어지는 등의 문제가 발생한다.

Untitled

이를 방지하기 위해서 로비 레벨을 퍼시스턴트 레벨로 만들고, 서브 레벨로 메인 게임 레벨을 추가했다. 두 맵이 겹쳐서 보이는 모습이다.

각 플레이어는 서버 트래블 이후 도착하는 속도가 다르다

SeamlessTravel의 경우에는 서버와의 연결이 끊기지 않은 상태에서 레벨 이동이 이루어지기에 이런 문제가 발생하지 않는다. 하지만 연결을 끊고 재접속하는 서버 이동이라면 이를 고려해야한다. 로딩 이후 모든 플레이어가 도착할 때까지 대기하는 시간을 가짐으로써 여기서 발생하는 문제들을 방지할 수 있다.

PIE 모드에서 서버 트래블

예전에는 PIE모드에서 서버 트래블을 지원하지 않았다고 한다. 이제는 지원한다!

서버 트래블 경로 검증

서버 트래블을 시도했을 때 실패했다면 경로가 제대로 입력되었는지 확인해야한다. ServerTravel에서 CanServerTravel을 통해 경로를 검증하니 무슨 요소 때문에 진행이 안되는지 알아낼 수 있다.

bool AGameModeBase::CanServerTravel(const FString& FURL, bool bAbsolute)
{
	UWorld* World = GetWorld();

	check(World);

	if (FURL.Contains(TEXT("%")))
	{
		UE_LOG(LogGameMode, Error, TEXT("CanServerTravel: FURL %s Contains illegal character '%%'."), *FURL);
		return false;
	}

	if (FURL.Contains(TEXT(":")) || FURL.Contains(TEXT("\\")))
	{
		UE_LOG(LogGameMode, Error, TEXT("CanServerTravel: FURL %s blocked, contains : or \\"), *FURL);
		return false;
	}

	FString MapName;
	int32 OptionStart = FURL.Find(TEXT("?"));
	if (OptionStart == INDEX_NONE)
	{
		MapName = FURL;
	}
	else
	{
		MapName = FURL.Left(OptionStart);
	}

	// Check for invalid package names.
	FText InvalidPackageError;
	if (MapName.StartsWith(TEXT("/")) && !FPackageName::IsValidLongPackageName(MapName, true, &InvalidPackageError))
	{
		UE_LOG(LogGameMode, Log, TEXT("CanServerTravel: FURL %s blocked (%s)"), *FURL, *InvalidPackageError.ToString());
		return false;
	}
	
	return true;
}

참고자료