[UE5] Session 기반 멀티플레이 Plugin 제작 - OnlineSubsystemSteam 환경에서 ServerTravel 디버깅
이전 포스팅에서 OnlineSubsystem의 Session Interface를 사용하여 멀리 떨어진 플레이어도 같이 게임을 즐길 수 있도록 간단하게 멀티플레이 기능을 만들어 보았다.
다양한 장르의 게임을 만들더라도 세션을 통해 플레이어를 연결하는 코드는 비슷하기 때문에 이를 Plugin으로 관리하는 것이 좋다는 언리얼 엔진 개발자 포럼의 팁들이 있어 이를 플러그인으로 만들어 보고 있다.
다행히 유튜브, 언리얼엔진 포럼, 유데미, 아티클이 많고 언리얼 엔진도 플러그인을 쉽게 만들 수 있도록 지원하기 때문에 플러그인 생성까지는 시간이 오래 걸리지 않았다.
하지만 Steam의 OnlineSubsystem을 사용하도록 DefaultEngine.ini 파일에 세팅을 마치고 정상적으로 OSS Steam 또한 설치하였고 OnlineSubsystem이 정상적으로 로드되는 것도 확인하였으나 CreateSession 이후 ServerTravel 시점에서 버벅거리면서 ServerTravel에 입력한 Level로 이동하지 않던 문제가 있었다.
이를 해결한 과정에 대한 포스팅이다.
IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get();
if (OnlineSubsystem)
{
OnlineSessionInterface = OnlineSubsystem->GetSessionInterface();
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Green, FString::Printf(TEXT("Found Subsystem: %s"), *OnlineSubsystem->GetSubsystemName().ToString()));
}
else
{
UE_LOG(LogTemp, Error, TEXT("No GEngine!"));
}
}
else
{
UE_LOG(LogTemp, Error, TEXT("No OSS!"));
}
///
if (!OnlineSessionInterface.IsValid())
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Cyan, FString::Printf(TEXT("Online Session Interface Is not Valid!")));
}
return;
}
auto ExistingSession = OnlineSessionInterface->GetNamedSession(NAME_GameSession);
if (ExistingSession != nullptr)
{
if (GEngine)
{
GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Cyan, FString::Printf(TEXT("Destroy Session Start!")));
}
OnlineSessionInterface->DestroySession(NAME_GameSession);
}
디버깅을 위해 null check도 거의 모든 포인터에 대해 진행해보았고, 실행 중인 게임 프로세스를 VS 디버거에 연결하여 한 줄씩 확인해 보아도 ServerTravel 시점에 연결이 끊어진 것처럼 이동에 실패하였다.
- 언리얼 엔진 버전 변경
- OSS Null 플러그인 제거
- OSS Steam 플러그인 제거
- 설정 파일의 Steam NetDriver 변경
- Steam OSS 설정 파일 UE4 ~ UE5.4까지 모든 버전으로 테스트
- bIsLANMatch, bUseLobbiesIfAvailable, bUsesPresence 등 SessionSetting 관련 테스트
- ServerTravel 대상 Level이 게임에서 정상적으로 로드되었는지 File 존재 여부 런타임에 확인
- ServerTravel 대상 Level 유효성 체크 (OSS Null, UE Editor에서 환경에서 테스트)
- ServerTravel ETravelType 변경
- Memory 체크
이후에도 문제가 그대로 남아 있어 코드 단에서 디버깅을 시작하였다.
- ServerTravel 코드 확인 후 GameMode, NextUrl 등 함수 인자 유효한지 확인
- ServerTravel 내부에서 GameMode->ProcessServerTravel 함수 호출 확인
- 두 함수 인자 유효성 모두 확인
bool UWorld::ServerTravel(const FString& FURL, bool bAbsolute, bool bShouldSkipGameNotify)
{
AGameModeBase* GameMode = GetAuthGameMode();
if (GameMode != nullptr && !GameMode->CanServerTravel(FURL, bAbsolute))
{
return false;
}
// Set the next travel type to use
NextTravelType = bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative;
// if we're not already in a level change, start one now
// If the bShouldSkipGameNotify is there, then don't worry about seamless travel recursion
// and accept that we really want to travel
if (NextURL.IsEmpty() && (!IsInSeamlessTravel() || bShouldSkipGameNotify))
{
NextURL = FURL;
if (GameMode != NULL)
{
// Skip notifying clients if requested
if (!bShouldSkipGameNotify)
{
GameMode->ProcessServerTravel(FURL, bAbsolute);
}
}
else
{
NextSwitchCountdown = 0;
}
}
return true;
}
void AGameModeBase::ProcessServerTravel(const FString& URL, bool bAbsolute)
{
#if WITH_SERVER_CODE
StartToLeaveMap();
UE_LOG(LogGameMode, Log, TEXT("ProcessServerTravel: %s"), *URL);
UWorld* World = GetWorld();
check(World);
FWorldContext& WorldContext = GEngine->GetWorldContextFromWorldChecked(World);
// Use game mode setting but default to full load screen if the server has been up for a long time so that TimeSeconds doesn't overflow and break everything
bool bSeamless = (bUseSeamlessTravel && GetWorld()->TimeSeconds < 172800.0f); // 172800 seconds == 48 hours
// Compute the next URL, and pull the map out of it. This handles short->long package name conversion
FURL NextURL = FURL(&WorldContext.LastURL, *URL, bAbsolute ? TRAVEL_Absolute : TRAVEL_Relative);
// Override based on URL parameters
if (NextURL.HasOption(TEXT("SeamlessTravel")))
{
bSeamless = true;
}
else if (NextURL.HasOption(TEXT("NoSeamlessTravel")))
{
bSeamless = false;
}
// There are some issues with seamless travel in PIE, so fall back to hard travel unless it is supported
if (World->WorldType == EWorldType::PIE && bSeamless && !FParse::Param(FCommandLine::Get(), TEXT("MultiprocessOSS")))
{
if (!UE::GameModeBase::Private::bAllowPIESeamlessTravel)
{
UE_LOG(LogGameMode, Warning, TEXT("ProcessServerTravel: Seamless travel is disabled in PIE, set net.AllowPIESeamlessTravel=1 to enable."));
bSeamless = false;
}
}
// Notify clients we're switching level and give them time to receive.
FString URLMod = NextURL.ToString();
APlayerController* LocalPlayer = ProcessClientTravel(URLMod, bSeamless, bAbsolute);
World->NextURL = URLMod;
ENetMode NetMode = GetNetMode();
if (bSeamless)
{
World->SeamlessTravel(World->NextURL, bAbsolute);
World->NextURL = TEXT("");
}
else
{
// Switch immediately if not networking.
if (NetMode != NM_DedicatedServer && NetMode != NM_ListenServer)
{
World->NextSwitchCountdown = 0.0f;
}
GEngine->IncrementGlobalNetTravelCount();
GEngine->SaveConfig();
}
#endif // WITH_SERVER_CODE
}
여전히 마찬가지로 ServerTravel을 실질적으로 적용하는 코드를 넘어가는 순간 입력한 Level로 이동하지 않고 다시 원래 Level이 로드 되었다.
SteamOSS에서 ServerTravel 생기는 여러 문제들이 언리얼 엔진 포럼에 생각보다 많이 올라와 있어서 거의 모든 글과 답변을 읽었고 Seamless Travel 사용 + SteamSocketsNetDriver 조정으로 문제를 해결하였다.
DefaultEngine.ini 파일에서 기존 엔진 버전에 맞는 세팅 대신 UE5.4 버전의 세팅을 적용하였고
# DefaultEngine.ini
[/Script/Engine.GameEngine]
+NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemSteam.SteamNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver")
[OnlineSubsystem]
DefaultPlatformService=Steam
[OnlineSubsystemSteam]
bEnabled=true
SteamDevAppId=480
[/Script/OnlineSubsystemSteam.SteamNetDriver]
NetConnectionClassName="OnlineSubsystemSteam.SteamNetConnection"
ServerTravel 이전에 GameMode를 받아와 bUseSeamlessTravel을 true로 변경하여 Seamless Travel을 적용하였다.
TransitionMap은 에디터에서 Blank Level로 임의로 설정하였다.
AMultiPlayPluginGameMode* MPPGameMode = Cast<AMultiPlayPluginGameMode>(UGameplayStatics::GetGameMode(World));
MPPGameMode->bUseSeamlessTravel = true;
World->ServerTravel(FString("/Game/ThirdPerson/Maps/Lobby?listen"));
이후 정상적으로 Steam OSS에서 ServerTravel이 작동하였다.
Reference