UE5多人游戏开发:从菜单会话搜索到加入的C++实现与网络调试

UE5多人游戏开发:从菜单会话搜索到加入的C++实现与网络调试 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(DefNameGameNetDriver, DriverClassNameOnlineSubsystemSteam.IpNetDriverSteam, DriverClassNameFallbackOnlineSubsystemUtils.IpNetDriver) [OnlineSubsystem] DefaultPlatformServiceSteam [OnlineSubsystemSteam] bEnabledtrue SteamDevAppId480 // 注意这是Spacewar的AppID仅供测试。正式发行需替换为你自己的AppID。 bInitServerOnClienttrue [/Script/OnlineSubsystemSteam.NetDriverSteam] NetConnectionClassNameOnlineSubsystemSteam.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; // 会话搜索句柄用于管理异步搜索 TSharedPtrFOnlineSessionSearch 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等。包含一个ButtonJoinButton点击后触发加入该会话的操作。关键步骤是在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类中我们维护一个TArrayUSessionInfoObject*数组。当收到搜索结果时我们遍历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 GetGameInstanceUMultiplayerSessionsGameInstance(); if (GI) { GI-FindSessions(10); // 例如最多搜索10个结果 } }在WBP_SessionEntry的C类中void USessionEntryWidget::NativeOnListItemObjectSet(UObject* ListItemObject) { IUserObjectListEntry::NativeOnListItemObjectSet(ListItemObject); USessionInfoObject* SessionInfo CastUSessionInfoObject(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 GetGameInstanceUMultiplayerSessionsGameInstance(); 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()) { TArrayUSessionInfoObject* 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 NewObjectUSessionInfoObject(); 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 CastUMenuWidget(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和专业作品的关键细节。