Multiplayer용 Session 시스템에 대한 감을 잡기 위해서 AdvancedSessionSteam
플러그인을 활용해서 순수 BP로 세션 클론코딩을 진행해보았다.(링크) 이후 계획으로 UE에서 자체 개발한 OnlineSubsystemSteam
플러그인 사용해 C++로 개발을 스위칭 하려고 했고, 이번에 UE 5.5 버전으로 세션 생성, 세션 찾기, 세션 참가등에 대한 로직을 만들어 보았다.
C++ 개발에 앞서 약간 약간 무서웠던 부분은 UE 5 버전 이후, 특히 5.3 버전서부터 AdvancedSessionSteam
플러그인에서 세션 참가가 되지 않는 에러가 났다. 정확한 이유는 모르겠지만, Engine.ini안의 변수를 빌드과정에 오버라이딩, UE의 SteamSocket 문제, clientID가 계속 불필요하게 증가하는 복합적인 이유가 작용했던 것 같다.
https://forums.unrealengine.com/t/ue-5-1-steam-sockets-problem/696726/55
https://issues.unrealengine.com/issue/UE-177030
https://issues.unrealengine.com/issue/UE-174140
https://forums.unrealengine.com/t/ue-5-1-steam-sockets-problem/696726/31
이를 해결하기 위해서 위 링크들과 언리얼 포럼들을 꼼꼼하게 보면서 Engine.ini 세팅과 CreateSession을 만들때 들어가는 FOnlineSessionSettings을 하나하나 변경하는 아주 난잡한 테스팅을 여러번 진행해야 했었다. Steam Multi-play 테스팅을 위해서는 2개의 개별 머신에 각각 패키징된 프로그램이 필요한데, 1번의 테스팅을 위해서 패키징->압축->전송->압축 해제 등의 과정을 수십번을 하면서(사실 이번 C++ Session도 마찬가지...) 없던 탈모가 생길 것 같은 느낌이었다. 다행히, 개발 도중 Sandboxie라는 VM 프로그램을 알게 되어서 1개의 PC에 2개의 steam 계정을 키고 테스팅을 비교적 간편하게 할 수 있었다.
내가 생각하기에 OnlineSubsystemSteam
에서 가장 중요한 부분은 2가지이다. 하나는 Create Session
, Find Session
, Join Session
처럼 Session의 lifecycle에 관여하는 주요 함수들을 사용하는 방법과 각각의 환경변수 세팅에 대한 지식이다. 다른 하나는 OnlineSubsystemSteam
에서 이미 만들어준 Delegate과 Delegate-List의 활용 방법이다. 전자에 대해서는 밑에서 상세하게 다룰 것이니 OnlineSubsystemSteam
의 delegate에 대해서 잠깐 알아보자.
OnlineSubsystem
에서는 Session lifecycle의 주요 단계마다 관련 delegate를 만들어두었고 이들은 각 단계가 실행될때마다 트리거된다. 예를 들어서, CreateSession
함수가 불려지게 되면 세션을 만들게 되는데 이때 자동으로 FOnCreateSessionCompleteDelegate
의 타입을 가지는 delegate가 불려지게 된다. 따라서 우리는 세션 생성이 성공적으로 완료되었는지 확인하고 싶으면 콜백 함수를 하나 만들어서 이 delegate에 바인딩 하면 된다.
// 이외에도 더 있음..!
FOnCreateSessionCompleteDelegate OnCreateSessionCompleteDelegate;
FOnFindSessionsCompleteDelegate OnFindSessionsCompleteDelegate;
FOnJoinSessionCompleteDelegate OnJoinSessionCompleteDelegate;
이런 delegate들이 존재하는 이유는 간단하다. 각각의 세션 과정이 즉각적으로 일어나지 않기 때문이다. 예를 들어서 세션을 찾으려면 먼저 세션이 있어야 되는데 이때, 세션이 있는지 확인하는 방법을 위와 같은 delegate를 사용해서 편하게 체크할 수 있기 때문이다.
이제, 대망의 세션 생성을 해보자. 세션 생성은 OnlineSubsystem
을 사용하면 매우 단순하다. IOnlineSubsystem
에서 static method Get으로 Online subsystem
에 대한 포인터를 얻어 올 수 있고, 이 포인터로 CreateSession
이라는 함수를 불러주면 된다.
세션 생성 설정들은 다음과 같다. 우리는 Steam을 통해 세션을 만들점, listen server로 호스팅을 할점 등등을 고려하면 다음과 같이 세션 설정을 해주면 된다.
만약 세션 생성자가 UE에서 제공하지는 않지만 다른 정보를 세션에 넣어주고 싶으면 TSharedPtr<FOnlineSessionSettings>
변수 타입인 세팅 객체에 Set을 사용하면 된다. UE 5로 넘어오면서 이를 template 함수로 만들어두었는데, key값으로만 FName을 넣고 value에 해당하는 2번째 인자로는 변수 타입을 신경쓰지 않고 넣어줘도 된다.
// Create session settings
// https://dev.epicgames.com/documentation/en-us/unreal-engine/API/Plugins/OnlineSubsystem/FOnlineSessionSettings
const TSharedPtr<FOnlineSessionSettings> SessionSettings = MakeShareable(new FOnlineSessionSettings());
SessionSettings->bIsLANMatch = false;
SessionSettings->NumPublicConnections = 4;
SessionSettings->bAllowJoinInProgress = true;
SessionSettings->bShouldAdvertise = true;
SessionSettings->bUsesPresence = true;
SessionSettings->bUseLobbiesIfAvailable = true;
// Key에 해당하는 값이 Template Type!
SessionSettings->Set(FName("Session Type"), FString("For Testing"), EOnlineDataAdvertisementType::ViaOnlineServiceAndPing);
// SessionSettings.bIsDedicated = false;
// Create the session
if (!OnlineSessionInterface->CreateSession(0, DefaultSessionName, *SessionSettings))
{
GEngine->AddOnScreenDebugMessage(-1, 5, FColor::Red, "Failed to create new session.");
}
한가지 착각하기 쉬운 점은 세션 생성과 맵 이동은 개별적인 과정이라는 것이다. 따라서, FOnCreateSessionCompleteDelegate
에 해당되는 콜백 함수에 ServerTravel
로직을 따로 넣어준다. 필자는 에셋 경로를 바로 에디터에 넣는 것보다 BP상에서 에셋을 지정하는 것을 선호해서 이번에도 전환될 맵 에셋을 BP에서 지정하도록 했다.
//BaseGameInstance.h
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Session")
TSoftObjectPtr<UWorld> TraveledMap;
//BaseGameInstance.cpp
UWorld* LoadedMap = TraveledMap.LoadSynchronous();
...
GetWorld()->ServerTravel(TraveledMap.GetAssetName() + TEXT("?listen"), true);
FindSession
도 설정 부분이 어렵지 부르는 것 자체는 OnlineSubsystem
에서 다 함수로 구현해 놓았다. 앞에서 언급을 못했는데, Create Session
과 Find Session
은 나중 인게임에서 UMG와 연동될 것이기 때문에 편의를 위해서 BlueprintCallable
함수로 만들었다.
void UBaseGameInstance::OnFindSessionButtonClicked()
{
//SessionSearchSettings는 reference로 넘겨져서 SearchResult가 담기기 때문에 member variable로 선언해주어야 함.
SessionSearchSettings = MakeShareable(new FOnlineSessionSearch);
SessionSearchSettings->bIsLanQuery = false;
SessionSearchSettings->MaxSearchResults = 10000;
//https://github.com/EpicGames/UnrealEngine/commit/50d88b68429872ee689edcd0b2d623c60153be91
SessionSearchSettings->QuerySettings.Set(SEARCH_LOBBIES, true, EOnlineComparisonOp::Equals);
// Deprecated 되었음!
// SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals);
OnlineSessionInterface->AddOnFindSessionsCompleteDelegate_Handle(OnFindSessionsCompleteDelegate);
GEngine->AddOnScreenDebugMessage(-1, 15, FColor::Green, TEXT("Start to find session!"));
OnlineSessionInterface->FindSessions(0, SessionSearchSettings.ToSharedRef());
}
세션 검색에서 한가지 신박했던 점은 FindSession
함수의 2번째 인자로 들어가는 값을 레퍼런스로 받아서 만약 검색되는 세션값들이 있다면 받은 레퍼런스 값 안의 TArray에다가 그 값들을 저장한다는 것이었다.
앞에 CreateSession
에서 사용자가 key-value 형태로 커스텀한 값을 넣을 수 있다고 했는데, 세션 검색에서 이 key-value값을 만족하는 결과만 필터링해서 확인할 수 있다.
또, 이전 UE 버전에서는 SessionSearch->QuerySettings.Set을 통해 SEARCH_PRESENCE를 해주어야 했지만 이제는 Deprecated 되어서 SEARCH_LOBBIES를 대신 사용하였다.
/** Search for presence sessions only (value is true/false) */
UE_DEPRECATED(5.5, "SEARCH_PRESENCE (\"PRESENCESEARCH\") is deprecated and will soon stop being a valid UE-defined key. Please consult upgrade notes for more details")
const FName SEARCH_PRESENCE = FName(TEXT("PRESENCESEARCH"));
/** Search for a match with min player availability (value is int) */
#define SEARCH_MINSLOTSAVAILABLE FName(TEXT("MINSLOTSAVAILABLE"))
/** Exclude all matches where any unique ids in a given array are present (value is string of the form "uniqueid1;uniqueid2;uniqueid3") */
#define SEARCH_EXCLUDE_UNIQUEIDS FName(TEXT("EXCLUDEUNIQUEIDS"))
/** User ID to search for session of */
#define SEARCH_USER FName(TEXT("SEARCHUSER"))
/** Keywords to match in session search */
#define SEARCH_KEYWORDS FName(TEXT("SEARCHKEYWORDS"))
/** The matchmaking queue name to matchmake in, e.g. "TeamDeathmatch" (value is string) */
#define SEARCH_MATCHMAKING_QUEUE FName(TEXT("MATCHMAKINGQUEUE"))
/** If set, use the named Xbox Live hopper to find a session via matchmaking (value is a string) */
#define SEARCH_XBOX_LIVE_HOPPER_NAME UE_DEPRECATED_MACRO(5.4, "SEARCH_XBOX_LIVE_HOPPER_NAME has been deprecated. Use SETTING_MATCHING_HOPPER instead.") SETTING_MATCHING_HOPPER
/** Which session template from the service configuration to use.*/
#define SEARCH_XBOX_LIVE_SESSION_TEMPLATE_NAME UE_DEPRECATED_MACRO(5.4, "SEARCH_XBOX_LIVE_SESSION_TEMPLATE_NAME has been deprecated. Use SETTING_SESSION_TEMPLATE_NAME instead.") SETTING_SESSION_TEMPLATE_NAME
/** Selection method used to determine which match to join when multiple are returned (valid only on Switch) */
#define SEARCH_SWITCH_SELECTION_METHOD FName(TEXT("SWITCHSELECTIONMETHOD"))
/** Whether to search for lobbies instead of sessions */
#define SEARCH_LOBBIES FName(TEXT("LOBBYSEARCH"))
Session 주요 함수에 들어가는 세팅을 확인하기 위해서는 Unreal Document를 보는 것보다 코드에서 설정 주석을 읽는 것이 더 도움이 많이 되었다. 위는 SearchSetting 관련 코드인데 OnlineSessionNames.h
-> findingsessionsetting
에서 볼 수 있다.
세션 참가는 세션 검색이 끝난 다음에 실행이 되어야 하므로, FOnFindSessionsCompleteDelegate
를 통해 세션 검색이 끝난후 불렀다. 여기서도, 주의해야 할 점이 세션 검색 함수에서 레퍼런스로 받은 인자가 중간에 변경이 되는 것인지 이를 세션 참가 전에 다시 한번 설정해 주어야 했다.
if (SessionSearchSettings->SearchResults.Num() <= 0)
{
GEngine->AddOnScreenDebugMessage(-1, 15, FColor::Red, FString::Printf(TEXT("0 Session found:(")));
}
for (auto FoundSearchResult : SessionSearchSettings->SearchResults)
{
FString Id = FoundSearchResult.GetSessionIdStr();
FString User = FoundSearchResult.Session.OwningUserName;
FString SessionTypeReturned;
// Key에 해당하는 Value가 있으면 넘긴 2번에 reference parameter에 저장.
FoundSearchResult.Session.SessionSettings.Get(FName("Session Type"), SessionTypeReturned);
GEngine->AddOnScreenDebugMessage(-1, 15, FColor::Green, FString::Printf(TEXT("Session : %s found %s with Session type %s"), *Id, *User, *SessionTypeReturned));
}
if (SessionSearchSettings->SearchResults.Num() > 0)
{
// 이유는 모르지만 setting이 바뀌어진다?!
// JoinSession 전에 FindSession에서 찾은 Session의 SessionSetting 다시 한번 설정
SessionSearchSettings->SearchResults[0].Session.SessionSettings.bUseLobbiesIfAvailable = true;
SessionSearchSettings->SearchResults[0].Session.SessionSettings.bUsesPresence = true;
OnlineSessionInterface->AddOnJoinSessionCompleteDelegate_Handle(OnJoinSessionCompleteDelegate);
OnlineSessionInterface->JoinSession(0, TEXT("GameSession"), SessionSearchSettings->SearchResults[0]);
}
세션을 참가하는 클라이언트는 JoinSession
이후, ClientTravel
로 맵 이동을 하게 된다. ClientTravel
을 하기 위해서 찾은 세션 정보의 IP값과 포트 번호를 알아내야 했다.
Seamless
설정을 하면 클라이언트가 JoinSession
을 한것 만으로도(ClientTravel
부를 필요 x) 맵 이동이 자연스럽게 되지만, 여러번 시도 끝에 아직 성공을 못하였다. 지금까지 시도해본 바로는 Gamemode에 Seamless 변수 True, 사용하는 맵의 GameMode 통일, 설정->Build시 포함되는 맵에 맵추가를 해보았다.
테스트를 하고 많이 하다보니깐 중간에 웃겨서 스샷을 남겼는데 그 이후로도 체감상 2배의 테스트를 위해 패키징을 한 것 같다.
결국 성공!!
해상도 설정에서 마지막으로 기록하고 싶은 부분이 있다. 언리얼 프로젝트를 패키징하면 기본 해상도로 FullScreen이 선택되는데, 이게 마우스로 화면 크기를 바꿀 수 없는 FullScreen이다보니 다른 창을 함께 봐야하는 상황이 있을때 매우 불편하였다.
패키징을 했을때 기본 뷰모드를 FullScreen에서 Windowed로 바꾸는 방법은 다음과 같다.
[/Script/Engine.GameUserSettings]
ResolutionSizeX=1280
ResolutionSizeY=720
FullscreenMode=2
Config 폴더에 들어가 DefaultGameUserSettings.ini
파일을 (없으면) 하나 만들고 다음과 같은 값을 넣자.
DefaultGameUserSettings.ini
을 넣어도 UE가 패키징을 할때 이를 무시하므로 시작 맵의 Level Blueprint에 초기화 과정을 한번 더 넣어주었다.