1. 项目概述:为多人TPS游戏构建菜单会话加入功能
在开发一个基于Unreal Engine 5的C++多人第三人称射击游戏时,一个流畅、直观的菜单系统是连接玩家与游戏世界的桥梁。很多教程会花大量篇幅讲解核心的战斗逻辑和网络复制,但往往在“如何让玩家从主菜单顺利进入一个多人房间”这个环节一笔带过。这正是《P22 从菜单中加入会话(Join Sessions from The Menu)》这一课要解决的核心痛点。它不是一个炫酷的功能,但却是决定你辛苦搭建的多人游戏能否被玩家顺利体验的关键一步。
想象一下,玩家打开你的游戏,点击“加入游戏”,却只能看到一个空荡荡的列表,或者点了加入按钮后游戏毫无反应,这种挫败感会直接导致玩家流失。本节课的内容,就是教你如何利用UE5的Online Subsystem(在线子系统),在游戏菜单中实现在线会话的发现、列表展示与加入功能。我们将深入C++代码层,构建一个从UI点击到成功加入游戏会话的完整数据流和逻辑链。这不仅涉及前端UMG与后端C++的交互,更考验你对UE网络框架中会话接口的理解。对于想要发布一个真正可玩多人游戏的开发者来说,这是必须跨过的一道坎。
2. 核心需求与架构设计解析
2.1 功能需求拆解
“从菜单中加入会话”这个需求,听起来简单,但拆解后包含一系列子任务。首先,我们需要一个途径来“寻找”网络上存在的可用游戏会话。其次,需要将这些找到的会话信息(如房间名、玩家人数、地图、Ping值等)清晰、实时地展示给玩家。最后,当玩家选中某个会话并点击“加入”时,我们需要稳定、可靠地执行加入逻辑,并处理各种可能的结果(成功、失败、会话已满等)。
因此,我们的核心需求可以归纳为三点:
- 会话搜索:调用在线子系统接口,根据特定搜索条件(如特定游戏模式)查询可加入的会话。
- 列表展示:将搜索到的会话数据绑定到UMG列表控件(如
ListView),并动态更新。 - 加入执行:为列表中的每个会话项提供加入按钮,点击后触发加入该特定会话的流程。
2.2 系统架构与数据流设计
为了实现上述功能,我们需要设计一个清晰的数据流。典型的UE5多人游戏菜单架构会采用Model-View-ViewModel (MVVM)或类似的前后端分离思想,尽管UE不严格遵循此模式,但理念相通。
- 数据层(Model):由
Online Session接口返回的FOnlineSessionSearchResult对象构成。它包含了会话的所有核心信息,是我们要处理和展示的原始数据。 - 逻辑层(ViewModel/Controller):这是C++游戏实例(
UGameInstance)或专用的菜单控制器类扮演的角色。它负责发起会话搜索请求、接收搜索结果、管理会话列表数据,并响应UI的加入请求。它持有数据,并提供了UI可以绑定的函数和委托。 - 表现层(View):即UMG用户界面。它包含一个用于展示会话列表的
ListView控件,以及每个列表项(UserWidget)的模板,模板内会有文本块显示会话信息和一个“加入”按钮。
数据流如下:
- 玩家在UMG界面点击“查找游戏”按钮。
- 按钮点击事件调用C++逻辑层(例如
UMenuWidget类)的FindSessions函数。 - 逻辑层通过
Online Subsystem的会话接口发起异步搜索。 - 搜索完成后,在线子系统通过一个委托(Delegate)回调通知逻辑层。
- 逻辑层在回调函数中处理搜索结果,将
FOnlineSessionSearchResult数组转换为UI友好的数据结构(或直接使用),并更新其内部维护的会话列表。 - 逻辑层会话列表的更新,通过
UPROPERTY的OnChanged广播或显式调用刷新函数,通知UMG的ListView。 ListView根据新的数据列表,为每一个会话项创建对应的列表项Widget实例。- 每个列表项Widget在初始化时,从逻辑层获取对应的会话数据并填充到自身的文本块中,同时为“加入”按钮绑定点击事件,该事件会调用逻辑层的
JoinSession函数并传入该会话的特定ID。 - 逻辑层执行加入会话的异步调用,并处理加入成功或失败的回调,引导玩家进入游戏或显示错误信息。
这个流程的关键在于理解异步操作和委托回调。网络操作(搜索、加入)都是非阻塞的,你需要设置好回调函数来等待操作完成的通知。
2.3 在线子系统(Online Subsystem)的选择与配置
UE5的Online Subsystem是一个抽象层,它让你可以用同一套代码对接不同的在线服务,如Steam、Epic Online Services (EOS)、Xbox Live等。在开始编码前,必须在项目中配置它。
对于开发和测试,我们通常使用Null子系统(用于单机)或Steam子系统。在项目名.Build.cs文件中,你需要添加相应的模块依赖,例如“OnlineSubsystem”,“OnlineSubsystemSteam”。更重要的是编辑DefaultEngine.ini配置文件。
一个典型的Steam子系统配置如下:
[/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver", DriverClassName="OnlineSubsystemSteam.IpNetDriverSteam", DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=Steam [OnlineSubsystemSteam] bEnabled=true SteamDevAppId=480 // 注意:这是Spacewar的AppID,仅供测试。正式发行需替换为你自己的AppID。 bInitServerOnClient=true [/Script/OnlineSubsystemSteam.NetDriverSteam] NetConnectionClassName="OnlineSubsystemSteam.IpNetConnectionSteam"注意:使用Steam进行测试时,你必须启动Steam客户端,并且
SteamDevAppId的设置至关重要。使用480可以快速测试,但若涉及Steam会话管理(如我们正在做的),最好在Steamworks后台创建自己的测试AppID并配置,以避免潜在冲突。
3. 核心类与UMG设计实现
3.1 扩展GameInstance作为会话管理器
虽然可以创建独立的会话管理类,但一个常见的、高效的做法是扩展UGameInstance。游戏实例在游戏运行期间始终存在,是存放会话管理逻辑的理想场所。
我们创建一个继承自UGameInstance的C++类,例如UMultiplayerSessionsGameInstance。在这个类中,我们需要声明以下关键成员:
UCLASS() class MULTIPLAYERTPS_API UMultiplayerSessionsGameInstance : public UGameInstance { GENERATED_BODY() public: UMultiplayerSessionsGameInstance(); // 用于查找会话的函数,通常由菜单UI调用 void FindSessions(int32 MaxSearchResults); // 用于加入指定会话的函数 void JoinSession(const FOnlineSessionSearchResult& SessionResult); protected: // 内部函数:实际创建会话搜索对象并开始搜索 void OnFindSessionsComplete(bool bWasSuccessful); // 内部函数:处理加入会话的结果 void OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result); private: // 指向在线会话接口的智能指针 IOnlineSessionPtr SessionInterface; // 会话搜索句柄,用于管理异步搜索 TSharedPtr<FOnlineSessionSearch> SessionSearch; // 用于绑定回调的委托句柄,必须保存以防止委托被垃圾回收 FDelegateHandle OnFindSessionsCompleteDelegateHandle; FDelegateHandle OnJoinSessionCompleteDelegateHandle; };在类的实现中,我们需要在Init()函数或构造函数中获取Online Subsystem的会话接口:
void UMultiplayerSessionsGameInstance::Init() { Super::Init(); IOnlineSubsystem* OnlineSubsystem = IOnlineSubsystem::Get(); if (OnlineSubsystem) { SessionInterface = OnlineSubsystem->GetSessionInterface(); if (SessionInterface.IsValid()) { // 绑定委托 OnFindSessionsCompleteDelegateHandle = SessionInterface->AddOnFindSessionsCompleteDelegate_Handle(...); OnJoinSessionCompleteDelegateHandle = SessionInterface->AddOnJoinSessionCompleteDelegate_Handle(...); } } }3.2 设计会话列表的UMG界面
UMG设计分为两部分:主菜单Widget和会话列表项Widget。
主菜单Widget (WBP_Menu):
- 包含一个
Button,文本为“查找游戏”,点击后调用GameInstance的FindSessions函数。 - 包含一个
ListView控件(命名为SessionListView),用于动态生成和展示会话列表。我们需要在C++中将其绑定到一个会话数据列表。 - 可能还包含刷新按钮、返回按钮等。
会话列表项Widget (WBP_SessionEntry):
- 这是一个简单的UserWidget,作为
ListView的每一项模板。 - 包含几个
TextBlock控件,用于显示会话名称(SessionNameText)、当前玩家数/最大玩家数(PlayerCountText)、Ping值(PingText)等。 - 包含一个
Button(JoinButton),点击后触发加入该会话的操作。
关键步骤是在C++中创建用于ListView的数据源。我们通常不直接将FOnlineSessionSearchResult暴露给UMG,而是创建一个UObject类来包装所需数据。
UCLASS() class MULTIPLAYERTPS_API USessionInfoObject : public UObject { GENERATED_BODY() public: FString SessionName; int32 CurrentPlayers; int32 MaxPlayers; int32 PingInMs; // 保存原始的搜索结果,用于后续加入操作 FOnlineSessionSearchResult SearchResult; };在主菜单Widget的C++类中,我们维护一个TArray<USessionInfoObject*>数组。当收到搜索结果时,我们遍历SessionSearch->SearchResults,为每个结果创建一个USessionInfoObject实例,填充数据,并添加到数组中。然后,将这个数组赋值给ListView的Items属性(或通过SetListItems函数)。
3.3 绑定数据与处理UI交互
在WBP_Menu的C++类(例如UMenuWidget)中,我们需要实现数据绑定和事件处理。
首先,在NativeConstruct(或Initialize)中,获取ListView的引用,并设置其项生成器(OnGenerateRow事件)或直接绑定Item类。更现代和推荐的方式是使用ListView的Entry Widget Class属性指向WBP_SessionEntry,并在C++中通过UUserWidget的OnListItemObjectSet事件来初始化每个项。
在UMenuWidget中:
void UMenuWidget::NativeConstruct() { Super::NativeConstruct(); if (SessionListView) { // 假设我们已经将SessionInfoObjectsArray填充好 SessionListView->ClearListItems(); for (USessionInfoObject* Obj : SessionInfoObjectsArray) { SessionListView->AddItem(Obj); } } if (FindSessionsButton) { FindSessionsButton->OnClicked.AddDynamic(this, &UMenuWidget::OnFindSessionsButtonClicked); } } void UMenuWidget::OnFindSessionsButtonClicked() { UMultiplayerSessionsGameInstance* GI = GetGameInstance<UMultiplayerSessionsGameInstance>(); if (GI) { GI->FindSessions(10); // 例如,最多搜索10个结果 } }在WBP_SessionEntry的C++类中:
void USessionEntryWidget::NativeOnListItemObjectSet(UObject* ListItemObject) { IUserObjectListEntry::NativeOnListItemObjectSet(ListItemObject); USessionInfoObject* SessionInfo = Cast<USessionInfoObject>(ListItemObject); if (SessionInfo && SessionNameText && PlayerCountText && JoinButton) { SessionNameText->SetText(FText::FromString(SessionInfo->SessionName)); PlayerCountText->SetText(FText::FromString(FString::Printf(TEXT("%d/%d"), SessionInfo->CurrentPlayers, SessionInfo->MaxPlayers))); // 绑定加入按钮 JoinButton->OnClicked.Clear(); // 清除之前的绑定,防止重复 JoinButton->OnClicked.AddDynamic(this, &USessionEntryWidget::OnJoinButtonClicked); // 我们需要一种方式将会话信息传递给点击事件,通常是将SessionInfo对象作为成员变量保存,或者使用按钮的Widget Metadata。 CachedSessionInfo = SessionInfo; // 假设有一个成员变量 USessionInfoObject* CachedSessionInfo; } } void USessionEntryWidget::OnJoinButtonClicked() { if (CachedSessionInfo) { UMultiplayerSessionsGameInstance* GI = GetGameInstance<UMultiplayerSessionsGameInstance>(); if (GI) { GI->JoinSession(CachedSessionInfo->SearchResult); } } }实操心得:在
OnJoinButtonClicked中直接传递CachedSessionInfo->SearchResult是关键。SearchResult中包含了会话的唯一标识符(SessionId),这是加入特定会话所必需的。切勿尝试只通过会话名来加入,因为会话名可能不唯一。
4. 会话搜索与加入的C++逻辑实现
4.1 实现会话搜索功能
在UMultiplayerSessionsGameInstance::FindSessions中,我们需要配置搜索参数并发起异步调用。
void UMultiplayerSessionsGameInstance::FindSessions(int32 MaxSearchResults) { if (!SessionInterface.IsValid()) { return; } // 清除之前的搜索 SessionSearch = MakeShareable(new FOnlineSessionSearch()); if (SessionSearch.IsValid()) { // 配置搜索参数 SessionSearch->MaxSearchResults = MaxSearchResults; SessionSearch->QuerySettings.Set(SEARCH_PRESENCE, true, EOnlineComparisonOp::Equals); // 搜索公开会话 // 发起异步搜索 ULocalPlayer* LocalPlayer = GetFirstGamePlayer(); if (LocalPlayer) { SessionInterface->FindSessions(*LocalPlayer->GetPreferredUniqueNetId(), SessionSearch.ToSharedRef()); // 此时开始异步搜索,结果将在 OnFindSessionsComplete 回调中处理 } } } void UMultiplayerSessionsGameInstance::OnFindSessionsComplete(bool bWasSuccessful) { if (bWasSuccessful && SessionSearch.IsValid()) { TArray<USessionInfoObject*> NewSessionList; for (const FOnlineSessionSearchResult& Result : SessionSearch->SearchResults) { // 从搜索结果中提取信息 FString SessionName = TEXT("Unknown Session"); Result.Session.SessionSettings.Get(FName("SESSION_NAME"), SessionName); // 假设创建会话时设置了此键值 int32 CurrentPlayers = Result.Session.NumOpenPublicConnections + Result.Session.NumOpenPrivateConnections; // 注意:这是“空位”数,不是当前玩家数。通常需要从SessionSettings获取自定义的玩家数。 int32 MaxPlayers = Result.Session.SessionSettings.NumPublicConnections; int32 Ping = Result.PingInMs; // 创建数据对象 USessionInfoObject* InfoObj = NewObject<USessionInfoObject>(); InfoObj->SessionName = SessionName; // 更准确的做法:在创建会话时,将当前玩家数存入SessionSettings,这里再取出。 // 例如:Result.Session.SessionSettings.Get(FName("CURRENT_PLAYERS"), InfoObj->CurrentPlayers); InfoObj->CurrentPlayers = MaxPlayers - CurrentPlayers; // 估算当前玩家数 InfoObj->MaxPlayers = MaxPlayers; InfoObj->PingInMs = Ping; InfoObj->SearchResult = Result; NewSessionList.Add(InfoObj); } // 通知菜单UI更新列表。这里通常通过自定义的委托/事件或直接获取MenuWidget引用来实现。 UMenuWidget* Menu = Cast<UMenuWidget>(GetPrimaryPlayerController()->GetCurrentWidget()); if (Menu) { Menu->UpdateSessionList(NewSessionList); } } else { // 处理搜索失败:显示错误信息 UE_LOG(LogTemp, Warning, TEXT("Session search failed!")); } }注意事项:
CurrentPlayers的获取是一个常见的坑点。FOnlineSession结构体本身不直接提供当前玩家数,它只提供NumOpenPublicConnections(剩余公开空位)。通常的做法是,在游戏模式中,当玩家加入或离开时,更新一个自定义的SessionSettings键值(如“CURRENT_PLAYERS”)。在搜索结果的回调中,再从Result.Session.SessionSettings中读取这个值。上面的示例代码使用了估算方法,这在测试中可能可行,但不精确。
4.2 实现会话加入功能
加入会话的逻辑相对直接,但同样需要处理异步回调。
void UMultiplayerSessionsGameInstance::JoinSession(const FOnlineSessionSearchResult& SessionResult) { if (!SessionInterface.IsValid()) { return; } ULocalPlayer* LocalPlayer = GetFirstGamePlayer(); if (LocalPlayer) { // 发起异步加入请求 SessionInterface->JoinSession(*LocalPlayer->GetPreferredUniqueNetId(), NAME_GameSession, SessionResult); // 加入结果将在 OnJoinSessionComplete 回调中处理 } } void UMultiplayerSessionsGameInstance::OnJoinSessionComplete(FName SessionName, EOnJoinSessionCompleteResult::Type Result) { if (Result == EOnJoinSessionCompleteResult::Success && SessionInterface.IsValid()) { // 加入成功,现在需要旅行到服务器的地图 FString ConnectString; if (SessionInterface->GetResolvedConnectString(SessionName, ConnectString)) { APlayerController* PlayerController = GetFirstLocalPlayerController(); if (PlayerController) { // 使用控制台命令进行客户端旅行 PlayerController->ClientTravel(ConnectString, TRAVEL_Absolute); } } } else { // 处理加入失败 FString FailureReason; switch (Result) { case EOnJoinSessionCompleteResult::SessionIsFull: FailureReason = TEXT("Session is full."); break; case EOnJoinSessionCompleteResult::SessionDoesNotExist: FailureReason = TEXT("Session no longer exists."); break; case EOnJoinSessionCompleteResult::CouldNotRetrieveAddress: FailureReason = TEXT("Could not connect to the server."); break; case EOnJoinSessionCompleteResult::AlreadyInSession: FailureReason = TEXT("Already in this session."); break; default: FailureReason = TEXT("Join failed for unknown reason."); break; } // 将FailureReason显示给玩家,例如通过一个UMG提示框 UE_LOG(LogTemp, Warning, TEXT("Join session failed: %s"), *FailureReason); } }核心原理:
ClientTravel是客户端向服务器迁移的函数。GetResolvedConnectString获取的是服务器的网络地址(IP和端口),加入会话成功后,在线子系统已经为我们处理了NAT穿透、Steam中继等复杂网络问题,这个连接字符串就是通往目标服务器的“门票”。使用TRAVEL_Absolute意味着进行绝对路径旅行,完全跳转到目标地址,而不是相对当前地图的旅行。
5. 网络测试与调试技巧
5.1 本地多实例测试
这是测试多人功能最基础也是最重要的方法。在编辑器偏好设置中,启用“Play”设置下的“Run Under One Process”(在单一进程中运行)选项通常更方便调试,但为了模拟真实网络情况,更推荐使用“Separate Process”(独立进程)。
- 在编辑器中,设置玩家数量为2或更多。
- 点击“Play”按钮旁的下拉箭头,选择“Standalone Game”模式。
- 启动第一个实例作为服务器(或监听服务器)。你需要在游戏模式中做好设置,使得第一个玩家可以作为主机。
- 再启动第二个(或多个)客户端实例。这些客户端需要通过你刚刚实现的菜单加入第一个实例创建的会话。
测试要点:
- 会话创建:确保第一个实例能成功创建并注册一个在线会话。
- 会话发现:在第二个实例的菜单中,点击“查找游戏”,确认能搜索到第一个实例创建的会话,并且信息(玩家人数、Ping等)显示正确。
- 会话加入:从第二个实例点击加入,观察是否能成功旅行到第一个实例的地图,并且两个玩家能互相看到。
5.2 常见问题与排查实录
即使代码逻辑正确,在实际测试中你仍会遇到各种问题。以下是一些典型问题及其排查思路:
问题1:点击“查找游戏”后,会话列表始终为空。
- 排查步骤:
- 检查在线子系统配置:确认
DefaultEngine.ini配置正确,并且Steam客户端已登录并运行(如果使用Steam)。 - 检查搜索条件:确认
FindSessions调用时传入的搜索参数(如QuerySettings)与创建会话时设置的参数匹配。例如,如果你创建的是PRESENCE会话,搜索时也必须设置SEARCH_PRESENCE为true。 - 检查网络权限:确保防火墙没有阻止UE4/UE5编辑器或打包后的游戏可执行文件。
- 添加日志输出:在
OnFindSessionsComplete回调中,打印bWasSuccessful和SessionSearch->SearchResults.Num()。如果bWasSuccessful为false,说明搜索请求本身失败。如果为true但数量为0,说明没有匹配的会话。 - 验证会话创建:首先确保主机端成功创建了会话。在创建会话成功的回调中打印日志确认。
- 检查在线子系统配置:确认
问题2:能搜索到会话,但点击“加入”后无反应或失败。
- 排查步骤:
- 检查委托绑定:确保
OnJoinSessionComplete委托被正确绑定,并且委托句柄被妥善保存。 - 检查回调函数:在
OnJoinSessionComplete中,详细打印Result枚举值,根据上述代码中的switch case确定具体失败原因。 - 检查连接字符串:如果加入成功但旅行失败,检查
GetResolvedConnectString是否成功获取了字符串,并打印出来看看是否是有效的IP:Port格式。 - 检查端口和网络:如果是局域网测试,确保没有其他程序占用游戏端口(默认7777)。如果是互联网测试,确保主机有公网IP或正确配置了端口转发/Steam中继。
- 检查委托绑定:确保
问题3:加入后客户端卡在加载界面,或加载后看不到对方。
- 排查步骤:
- 地图一致性:确保所有客户端加载的地图名称和路径完全一致。服务器旅行到的地图,客户端也必须能访问。
- 游戏模式与重生点:检查服务器的游戏模式(GameMode)是否正确设置,并且玩家控制器(PlayerController)和Pawn能正常生成。
- 网络复制:确保需要同步的Actor(如角色、武器)设置了
bReplicates = true,并且相关属性也正确复制。 - 查看网络日志:在输出日志(Output Log)中搜索“Net”、“Replicate”、“RPC”等关键词,查看是否有错误或警告。
问题4:使用打包版本测试时功能不正常,但在编辑器内正常。
- 排查步骤:
- 配置文件:打包后,
DefaultEngine.ini会被打包到Saved/Config/目录下。确保打包版本读取的配置文件内容正确。有时需要手动将正确的.ini文件复制到打包游戏的Config/目录。 - Steam AppID:如果使用Steam,打包版本的
SteamDevAppId必须与Steamworks后台配置的测试AppID一致,并且需要将steam_appid.txt文件放在游戏可执行文件同级目录。 - 模块依赖:确保
项目名.Build.cs中所有需要的在线模块(如OnlineSubsystemSteam)都已正确添加。
- 配置文件:打包后,
5.3 性能与体验优化建议
当基础功能跑通后,可以考虑以下优化点来提升用户体验:
- 搜索过滤与排序:在
FOnlineSessionSearch的QuerySettings中,可以设置更复杂的过滤条件,如只显示未满的房间、按Ping值排序等。你可以在UI上提供筛选下拉菜单。 - 异步加载与超时:会话搜索和加入都是网络操作,可能耗时。在UI上显示一个加载动画或提示(如“正在搜索...”),并设置一个超时时间(例如10秒后自动停止搜索并提示“未找到会话”)。
- 列表项视觉反馈:当鼠标悬停在会话列表项上时,可以高亮显示;当会话已满时,可以将列表项置灰并禁用“加入”按钮。
- 会话信息刷新:可以实现一个定时器,每隔几秒自动刷新会话列表,以获取最新的玩家数量和Ping值。注意不要刷新过于频繁,以免对服务器造成压力。
- Ping值计算显示:
FOnlineSessionSearchResult中的PingInMs是系统估算的。你可以考虑在加入前或加入后,通过发送一个小数据包来手动计算更精确的Ping值,并在UI上以颜色区分(绿色表示延迟低,红色表示延迟高)。
实现一个健壮的会话加入系统,是打磨多人游戏体验的重要一环。它虽然位于游戏核心玩法之外,却是玩家接触你的游戏世界的第一道门。把这扇门做得流畅、稳定、信息清晰,能极大提升游戏的第一印象和留存率。代码的健壮性、对边缘情况的处理(如网络中断、会话突然关闭)、以及清晰的用户反馈,是区分业余Demo和专业作品的关键细节。