# 简述
本章阐述Shooter Game中的房间相关操作,包括以下内容:
1. 创建房间
2. 加入房间
3. 游戏回放
游戏运行过程中的逻辑,在下一章节《游戏性框架》中做具体阐述。
[TOC]
# 房间的创建
## 主菜单实现
![](https://box.kancloud.cn/72734a2f2fd130f859cb031860371fb9_461x344.png)
如图,这是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,其实我们可以看作是这样一幅图:
![](https://box.kancloud.cn/2ad510935e434058b5ece92a6947b6ad_290x215.png)
即,在父控件中的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绑定,这其实是在创建一个数值上的树。
![](https://box.kancloud.cn/cc4324efe45bd6bd2988ddd3b682c5cd_390x68.png)
请读者务必注意,此时仅仅只有数据,而**没有**真的创建控件!
真正的控件创建是在最后的BuildAndShowMenu函数完成。
换句话说,首先在内存中构建基于FShooterMenuItem的一棵“数值树”,然后菜单控件动态地根据这个树、当前选择的选项等来动态地生成控件。
关于主菜单的实现,暂且讨论到这里。
## 会话创建
当选择Free For All或是Team Death Match后,实际上完成了下图所示的过程(不分析单机模式下代码):
![](https://box.kancloud.cn/5d00d4c8ba66bcfa4dc63387bd82d457_1060x397.png)
这个时候我们就有必要来研究一下两个和网络联机相关的类: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的字面意义是“会话”,从语义上说,其代表了一个开放的、可供加入的服务端。可以想象一个服务器列表,这里面的每一项都是一个会话。
![一个服务器列表案例](https://box.kancloud.cn/5eb7d396dbc5c9377c3dcba0ed8e616f_500x281.jpg)
从使用上说,根据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,实质上调用过程如下:
![](https://box.kancloud.cn/233aa8cab7a396dc8390be938d6800c0_631x303.png)
所以实际上依然是调用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发现
![](https://box.kancloud.cn/0e2acc0980c6dc26d02ea906c5f042b8_836x274.png)
从图中可知,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显示载入画面。