💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# 简述 本章阐述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显示载入画面。