# 簡述
本章闡述Shooter Game中的房間相關操作,包括以下內容:
1. 創建房間
2. 加入房間
3. 游戲回放
游戲運行過程中的邏輯,在下一章節《游戲性框架》中做具體闡述。
[TOC]
# 房間的創建
## 主菜單實現

如圖,這是Shooter Game的房間創建主界面。
該界面對應了FShooterMainMenu類。由于本文不希望太多涉及Slate編程,故只解析這部分代碼大概含義。
再次重復:Slate編程的復雜度遠遠高于UMG,而UMG能夠實現的界面的復雜程度也許能夠超過你的想象。不到萬不得已,請不要輕易決定使用Slate。
整個界面實際上是一個控件:一個SShooterMenuWidget。該Widget分兩步創建這個界面:
1. 界面實例化:
~~~
SAssignNew(MenuWidget, SShooterMenuWidget)
.Cursor(EMouseCursor::Default)
.PlayerOwner(GetPlayerOwner())
.IsGameMenu(false);
~~~
2. 填充Menu內容。
復雜度較高的主要是后者。在解析之前首先簡單闡述一下Menu的原理:
> Menu實際上是一組根據父控件狀態動態生成子控件的大型控件集合
也就是說,如果我們直觀地繪制一個Menu,其實我們可以看作是這樣一幅圖:

即,在父控件中的OnClick函數掛一個響應,如果父控件被按下,那就動態生成子控件。
最容易實現的思路就是,用這樣的偽代碼完成:
~~~
父控件=生成父控件();
父控件.OnClicked([&](){生成子控件();});
~~~
但是如果每個菜單的子控件都不相同,這就會有一大堆的類要出現:每個子菜單是一個單獨的類。
為了避免這樣的復雜度,從設計上將“數據”與“實例化控件”的操作分離。
數據是說的FShooterMenuItem類,其包含了以下內容
* 一系列的響應代理,如OnConfirmMenuItem等。用于指定選擇特定菜單選項時執行的函數。
* 這些相應代理通過MenuHelper類來完成綁定
* 一系列的引用:
* 指向子菜單的FShooterMenuItem數組的引用
* 指向當前生成的Slate控件的引用
隨后,創建了MenuHelper類,提供了MenuHelper::AddMenuItemSP函數用于實現菜單的添加:
~~~
MenuHelper::AddMenuItemSP(MenuItem, LOCTEXT("FFALong", "FREE FOR ALL"), this, &FShooterMainMenu::OnUIHostFreeForAll);
MenuHelper::AddMenuItemSP(MenuItem, LOCTEXT("TDMLong", "TEAM DEATHMATCH"), this, &FShooterMainMenu::OnUIHostTeamDeathMatch);
~~~
此處即添加了如下圖所示的兩個按鈕的**數據表示**,并與當前對象的OnUIHostFreeForAll綁定,這其實是在創建一個數值上的樹。

請讀者務必注意,此時僅僅只有數據,而**沒有**真的創建控件!
真正的控件創建是在最后的BuildAndShowMenu函數完成。
換句話說,首先在內存中構建基于FShooterMenuItem的一棵“數值樹”,然后菜單控件動態地根據這個樹、當前選擇的選項等來動態地生成控件。
關于主菜單的實現,暫且討論到這里。
## 會話創建
當選擇Free For All或是Team Death Match后,實際上完成了下圖所示的過程(不分析單機模式下代碼):

這個時候我們就有必要來研究一下兩個和網絡聯機相關的類:AGameSession和OnlineSubsystem。
OnlineSubsystem簡稱OSS,是對一系列網絡聯機子系統的抽象,例如Steam、Xbox Live等。關于這部分的更多介紹,請參考官方文檔:[OnlineSubSystem概述](https://docs.unrealengine.com/latest/CHN/Programming/Online/index.html)
1. 在線子系統并不是一個完整的服務器框架,對于這個問題的理解,可以參考Steam。Steam可以提供聯機匹配、搜索服務器等操作,但是Steam本身并不是承載服務器實例運行的程序。
2. 在線子系統實質上不需要直接訪問,其可以借助AGameSession來訪問。
為了抽象對于OnlineSystem的訪問, 提供了一個AGameSession類。GameSession的字面意義是“會話”,從語義上說,其代表了一個開放的、可供加入的服務端。可以想象一個服務器列表,這里面的每一項都是一個會話。

從使用上說,根據Shooter Game的范例,開啟一個服務端的步驟并不復雜:
1. 從UWorld獲取當前的GameMode實例,然后從GameMode中獲取當前的會話Session:
~~~
AShooterGameSession* UShooterGameInstance::GetGameSession() const
{
UWorld* const World = GetWorld();
if (World)
{
AGameModeBase* const Game = World->GetAuthGameMode();
if (Game)
{
return Cast<AShooterGameSession>(Game->GameSession);
}
}
return nullptr;
}
~~~
2. 準備房間創建用的信息,然后調用AShooterGameSession::HostSession以創建一個會話
~~~
TravelURL = InTravelURL;
bool const bIsLanMatch = InTravelURL.Contains(TEXT("?bIsLanMatch"));
//地圖描述
const FString& MapNameSubStr = "/Game/Maps/";
const FString& ChoppedMapName = TravelURL.RightChop(MapNameSubStr.Len());
const FString& MapName = ChoppedMapName.LeftChop(ChoppedMapName.Len() - ChoppedMapName.Find("?game"));
if (GameSession->HostSession(LocalPlayer->GetPreferredUniqueNetId(), GameSessionName, GameType, MapName, bIsLanMatch, true, AShooterGameSession::DEFAULT_NUM_PLAYERS))
~~~
3. AShooterGameSession::HostSession中,通過調用OnlineSubSystem來完成Session創建
~~~
IOnlineSubsystem* const OnlineSub = IOnlineSubsystem::Get();
if (OnlineSub)
{
CurrentSessionParams.SessionName = InSessionName;
CurrentSessionParams.bIsLAN = bIsLAN;
CurrentSessionParams.bIsPresence = bIsPresence;
CurrentSessionParams.UserId = UserId;
MaxPlayers = MaxNumPlayers;
IOnlineSessionPtr Sessions = OnlineSub->GetSessionInterface();
if (Sessions.IsValid() && CurrentSessionParams.UserId.IsValid())
{
HostSettings = MakeShareable(new FShooterOnlineSessionSettings(bIsLAN, bIsPresence, MaxPlayers));
HostSettings->Set(SETTING_GAMEMODE, GameType, EOnlineDataAdvertisementType::ViaOnlineService);
HostSettings->Set(SETTING_MAPNAME, MapName, EOnlineDataAdvertisementType::ViaOnlineService);
HostSettings->Set(SETTING_MATCHING_HOPPER, FString("TeamDeathmatch"), EOnlineDataAdvertisementType::DontAdvertise);
HostSettings->Set(SETTING_MATCHING_TIMEOUT, 120.0f, EOnlineDataAdvertisementType::ViaOnlineService);
HostSettings->Set(SETTING_SESSION_TEMPLATE_NAME, FString("GameSession"), EOnlineDataAdvertisementType::DontAdvertise);
HostSettings->Set(SEARCH_KEYWORDS, CustomMatchKeyword, EOnlineDataAdvertisementType::ViaOnlineService);
OnCreateSessionCompleteDelegateHandle = Sessions->AddOnCreateSessionCompleteDelegate_Handle(OnCreateSessionCompleteDelegate);
return Sessions->CreateSession(*CurrentSessionParams.UserId, CurrentSessionParams.SessionName, *HostSettings);
}
}
~~~
下面需要回答幾個問題:
1. 創建房間的管理代碼應當放在哪里?
* 當前世界在切換到真正的地圖前就會被銷毀,因此需要交給整個游戲的主管——GameInstance類
2. AShooterGameSession類對OnlineSubsystem的抽象是否是無意義的?
* 并不是無意義的。OnlineSubSystem提供的大量異步調用和異常處理,是通過代理實現。AShooterGameSession將會負責向這些代理掛載處理函數,并處理這些代理中的一部分。
3. 一定需要自行實現ShooterGameSession類嗎?什么時候實現?
* 在需要“搜索房間”這樣的操作時,考慮實現這樣的功能。如果是定向地進行網絡同步(每次只是往同一個ip對應的服務端進行鏈接),也許并不需要實現。Shooter Game默認的實現并不包含網絡數據包傳輸等功能。
4. 沒有Steam這些東西怎么辦?
* UE提供了FOnlineSubsystemNull作為實現。Null依然提供了基礎的Session創建等功能。
# 加入房間
理解了房間的創建,那么加入房間就相對來說比較容易了。
房間的加入分為兩個部分:房間的搜索和會話的連接。
Shooter Game的房間搜索同樣是基于封裝過OnlineSubSystem的AShooter,其創建了一個專門的服務器瀏覽器控件SShooterServerList。
## 開始搜索
開始搜索的代碼位于SShooterServerList::BeginServerSearch,實質上調用過程如下:

所以實際上依然是調用OnlineSubSystem的接口IOnlineSessionPtr完成。
IOnlineSession定義了OnlineSubsystem的可用接口函數,粗略來說,有以下值得一看的:
1. 開房間
* CreateSession函數:添加一個新的Session會話,通過傳入的參數設置各種狀態
2. 加入房間
* JoinSession函數
3. 設置房間
* UpdateSession函數
4. 關閉房間
* DestroySession函數
更多的函數請參考[OnlineSubSystem會話與玩家匹配接口](https://docs.unrealengine.com/latest/CHN/Programming/Online/Interfaces/Session/index.html)
## 更新搜索結果
核心代碼位于SShooterServerList::UpdateSearchStatus,整理代碼后如下:
~~~
void SShooterServerList::UpdateSearchStatus()
{
//獲取GameSession
AShooterGameSession* ShooterSession = GetGameSession();
if (ShooterSession)
{
int32 CurrentSearchIdx, NumSearchResults;
EOnlineAsyncTaskState::Type SearchState = ShooterSession->GetSearchResultStatus(CurrentSearchIdx, NumSearchResults);
switch(SearchState)
{
case EOnlineAsyncTaskState::InProgress:
StatusText = LOCTEXT("Searching","SEARCHING...");
bFinishSearch = false;
break;
case EOnlineAsyncTaskState::Done:
// 結束了搜索
//..省略填充代碼
break;
case EOnlineAsyncTaskState::Failed:
// intended fall-through
case EOnlineAsyncTaskState::NotStarted:
StatusText = FText::GetEmpty();
// intended fall-through
default:
break;
}
}
if (bFinishSearch)
{
OnServerSearchFinished();
}
}
~~~
可以看出,這里其實是根據GetSearchResultStatus的結果,做出是顯示等待字符串還是顯示服務器列表的處理。
## 加入房間
加入房間同樣是通過IOnlineSubsystem實現。故不再重復闡述
## 總結
總體而言,房間管理系統實際上已經被OnlineSubSystem實現得差不多了。Shooter Game通過一個比較完整的案例,闡述了如何基于已有的OnlineSubsystem來實現自己的基于房間的架設和游戲方案。
# 游戲回放
虛幻引擎將游戲回放系統稱為Demo。關于這個系統的介紹文檔:[虛幻引擎的回放系統](https://docs.unrealengine.com/latest/CHN/Engine/Replay/index.html)
大致原理如下:
1. 錄制的游戲必須是網絡聯機游戲(或是支持聯機),典型的測試方式是,一個客戶端連入后,看到的世界與單機運行時一致。這意味著游戲本身已經設定了數據的同步。
2. 錄制的內容實質上是將從服務端到客戶端的數據包進行記錄,在下一次回放時,重新發送數據包,從而實現指定時間段內狀態的還原。
3. 開啟錄制的方式是在控制臺中使用DemoRec命令。
當使用DemoRec錄制Demo后,Shooter Game可以在Demo菜單中看到已經錄制的Demo并請求回放,具體實現方式如下:
## Demo發現

從圖中可知,SShooterDemoList首先請求FNetworkReplayStreaming類的單例(Get()),然后通過GetFactory().CreateReplayStreamer()來創建INetworkReplayStreamer實例。這個實例可以在開始時創建,然后在生命周期內一直持有。
在需要查詢當前有哪些Demo的時候,需要通過調用INetworkReplayStreamer的EnumerateStreams來獲取所有可以讀取的Demo,調用如下
~~~
ReplayStreamer->EnumerateStreams(
EnumerateStreamsVersion,
FString(),
FString(),
FOnEnumerateStreamsComplete::CreateSP(
this,
&SShooterDemoList::OnEnumerateStreamsComplete
)
);
~~~
注意,這里由于查詢Demo是一個耗時較長的過程,故采用了回調的模式。當完成查詢后,會調用該回調。函數原型如下:
~~~
void SShooterDemoList::OnEnumerateStreamsComplete(const TArray<FNetworkReplayStreamInfo>& Streams)
~~~
通過遍歷返回的Streams可以查詢到總共有哪些回放文件可以使用
## Demo播放
在控制臺中,可以使用Demoplay命令來播放指定Demo。而C++中的播放,Shooter Game給出的范例如下:
~~~
UShooterGameInstance* const GI = Cast<UShooterGameInstance>(PlayerOwner->GetGameInstance());
if ( GI != NULL )
{
FString DemoName = SelectedItem->StreamInfo.Name;
// Play the demo
GI->PlayDemo( PlayerOwner.Get(), DemoName );
}
~~~
而最終調用的是UGameInstance的PlayReplay函數,參數為前文Demo發現中的FNetworkReplayStreamInfo中的名字。
Shooter Game自己封裝的原因是為了在播放前先調用ShowLoadingScreen顯示載入畫面。