1. 项目概述与核心价值
最近几年,独立游戏和中小团队项目里,多人联机玩法越来越常见。很多朋友在学UE4的时候,单机部分玩得挺溜,一到联机就卡壳,感觉像隔着一堵墙。网上的资料要么太零散,要么版本老旧,照着做不是编译报错就是运行不起来,特别劝退。这个教程,就是想解决这个问题,目标是让你从一个完全空白的UE4关卡开始,一步步搭出一个能创建房间、能被其他玩家搜索到并加入的完整联机Demo。这不仅仅是复制粘贴蓝图节点,更重要的是理解UE4网络框架的“规矩”,比如权威服务器、RPC调用、变量复制这些核心概念到底是怎么运作的,以及那些官方文档里没写、但实际开发中一定会踩的坑。
为什么从“空白关卡”开始?因为很多教程基于现成的模板或内容包,里面隐藏的预设可能会干扰你对底层逻辑的理解。从零开始,每一步都自己搭建,虽然前期慢一点,但根基打得牢,以后做复杂功能才不会懵。最终的目标是一个“可搜索房间”的列表,这是大部分联机游戏大厅的基础功能。整个过程会涉及到工程设置、基础Actor网络同步、玩家控制器和游戏模式的职责划分、UMG用户界面的网络交互,以及最关键的Session接口的使用。我会把每个环节的原理、为什么这么做、以及我趟过的雷都讲清楚,让你不仅能做出功能,更能明白背后的道理。
2. 工程准备与网络基础框架搭建
2.1 创建项目与核心类设置
启动UE4(这里以4.27版本为例,原理通用),选择“游戏”模板,更关键的是在下方选择“空白”项目,不要带初学者内容包。项目名称比如叫“MultiplayerDemo”,使用C++还是纯蓝图?我强烈建议选择“C++”项目,即使你主要用蓝图开发。因为后续一些关键的Session接口配置和头文件包含,在纯蓝图项目里会非常麻烦,甚至无法完成。C++项目给了你最大的灵活性,并且不影响你在蓝图中进行绝大部分逻辑开发。
创建好后,我们首先需要定义几个核心的Gameplay框架类。在内容浏览器中右键,选择“蓝图类”进行创建:
- 游戏模式基类(GameModeBase):命名为
BP_MyGameMode。这是整个游戏规则的导演,它决定了使用哪个玩家控制器、哪个Pawn、以及游戏状态。在多人游戏中,游戏模式只存在于服务器端,客户端根本不会生成它的实例,这一点至关重要。 - 玩家控制器(PlayerController):命名为
BP_MyPlayerController。玩家控制器是玩家在游戏中的“大脑”,负责处理玩家的输入命令。每个玩家都有自己的玩家控制器实例。服务器拥有所有玩家的控制器,而客户端只拥有自己控制的那个。 - 游戏状态(GameState):命名为
BP_MyGameState。游戏状态用于存放所有玩家都需要知道的游戏全局信息,比如当前游戏时间、房间玩家列表、比赛分数等。服务器和每个客户端都会有一个游戏状态实例,并且服务器上的数据会自动同步(复制)到所有客户端。 - 玩家状态(PlayerState):命名为
BP_MyPlayerState。玩家状态用于存放单个玩家的信息,如玩家名、个人得分、KDA等。和游戏状态类似,每个玩家的PlayerState会在服务器和所有客户端间复制。
创建好后,打开BP_MyGameMode,在“类默认值”面板中,将“玩家控制器类”、“默认Pawn类”、“游戏状态类”和“玩家状态类”分别设置为我们刚刚创建的蓝图类。这样,游戏运行时就会使用我们自定义的类了。
注意:很多新手会混淆
GameState和PlayerState的用途。一个简单的类比:GameState像是篮球场的记分牌,显示的是两队的总比分和剩余时间,所有人都看同一块;PlayerState像是每个球员的个人技术统计表,记录他个人的得分、篮板。服务器负责更新这些数据,并确保所有客户端看到的“记分牌”和“统计表”是一致的。
2.2 理解网络复制与RPC
UE4的多人游戏核心是“客户端-服务器”模型,其中一个机器作为权威服务器(Server),其他机器作为客户端(Client)。服务器掌握“真理”,客户端主要进行表现和输入采集。
变量复制(Replication):让服务器上变量的变化自动同步到客户端。在蓝图中,编辑一个变量时,勾选“复制(Replication)”。你需要决定复制时机:
- RepNotify:不仅复制,还在值变化时在客户端调用一个指定函数,用于触发更新UI、播放音效等反应。
- 通常,在
GameState和PlayerState中的变量(如房间名、玩家得分)需要设置为复制。
远程过程调用(RPC):允许在一台机器上调用另一台机器上的函数。有三种类型:
- Server(服务器函数):仅在客户端调用,但实际执行在服务器上。函数声明时必须勾选“在服务器上运行”。用于传递客户端的输入或请求,如“请求创建房间”、“玩家发射子弹”。这是客户端向服务器发送指令的主要方式。
- Client(客户端函数):仅在服务器调用,在指定的客户端上执行。函数声明时勾选“在客户端上运行”。用于服务器向特定客户端发送指令,如“更新你的HUD”、“播放一个只有你能看到的特效”。
- Multicast(多播函数):在服务器调用,在服务器和所有客户端上执行。用于播放同步的视觉效果、音效,或者触发一个所有玩家都需要知道的一次性事件,如“炸弹爆炸”、“游戏开始倒计时”。
理解RPC是联机编程的关键。一个黄金法则是:所有重要的游戏逻辑判定(如是否命中、能否开门、物品归属)都必须在服务器函数中进行,客户端只负责发送请求和接收结果进行表现。
3. 构建基础联机场景与移动同步
3.1 创建一个可网络同步的Pawn
我们的玩家需要一个在游戏世界里能控制的实体,这就是Pawn。在内容浏览器创建一个新的蓝图类,父类选择“Character”(它已经内置了移动组件和胶囊体碰撞,非常适合作为玩家角色),命名为BP_MyCharacter。
打开BP_MyCharacter,我们首先确保它的移动是网络同步的。Character自带的移动组件CharacterMovementComponent已经很好地处理了移动同步。你只需要注意:所有驱动角色移动的输入,都应该通过玩家控制器收集,然后调用Pawn上的服务器函数来执行。
例如,处理移动:
- 在
BP_MyPlayerController中,绑定输入轴事件(如“MoveForward”)。 - 在轴事件触发时,获取输入值,然后调用一个自定义的服务器函数,比如
Server_Move,将这个输入值作为参数传递过去。 - 在
BP_MyCharacter中,创建这个Server_Move函数(标记为“在服务器上运行”),在这个函数里,使用AddMovementInput等节点来实际驱动角色移动。
因为移动是在服务器函数中执行的,所以服务器的角色位置发生了变化。CharacterMovementComponent会自动将这个位置变化复制(Replicate)到所有客户端,其他玩家就能看到你的角色在移动了。这就是一个最简单的“输入-服务器处理-复制同步”流程。
3.2 搭建一个简单的测试关卡
新建一个空白关卡,保存为L_Main。简单地拖入一些地板(如Box拉伸成平面)和墙壁,构成一个小房间,防止角色掉出世界。在“世界场景设置”中,将“游戏模式重载”设置为我们的BP_MyGameMode。这样,在这个关卡中运行游戏,就会使用我们自定义的规则。
接下来,我们需要一个“网络启动”的方式。UE4提供了命令行参数。最常用的测试方法是:
- 作为服务器(监听)启动:在编辑器工具栏点击“运行”下拉箭头,选择“高级设置”,在“游戏”标签页的“附加启动参数”中填入:
-server -log。然后点击“独立进程游戏”。这会启动一个没有编辑器的纯游戏窗口,并作为服务器运行。 - 作为客户端连接:再启动一个编辑器实例(或者直接点击编辑器中的“运行”按钮),这个实例默认是客户端。你需要让客户端连接到服务器。我们可以在
BP_MyPlayerController的BeginPlay事件中,添加一个简单的连接逻辑:使用Console Command节点,执行命令open 127.0.0.1。这样客户端启动后就会自动连接到本机的服务器。
现在,你应该能运行一个服务器和一个客户端,并在客户端中控制角色移动,在服务器窗口看到角色的同步移动。这是验证网络底层是否通畅的第一步。
4. 实现游戏会话管理与房间创建
4.1 理解Online Subsystem与Session接口
房间(Room)或大厅(Lobby)在UE4中是通过“会话(Session)”来管理的。一个会话就是一组玩家共同进行的一个游戏实例。UE4通过“在线子系统(Online Subsystem)”来抽象不同平台(Steam、Epic、Xbox Live等)的会话管理接口,让我们用同一套代码对接不同平台。
对于开发和局域网测试,我们使用“NULL”子系统,它提供了基础的局域网会话功能。配置方法是在项目的Config/DefaultEngine.ini文件中添加以下配置:
[/Script/Engine.GameEngine] +NetDriverDefinitions=(DefName="GameNetDriver",DriverClassName="OnlineSubsystemUtils.IpNetDriver",DriverClassNameFallback="OnlineSubsystemUtils.IpNetDriver") [OnlineSubsystem] DefaultPlatformService=NULL [OnlineSubsystemNULL] bEnabled=true [/Script/OnlineSubsystemUtils.IpNetDriver] MaxClientRate=100000 MaxInternetClientRate=100000这段配置告诉引擎使用NULL在线子系统,并配置了网络驱动。这是实现可搜索房间功能的基础,很多教程会漏掉这一步,导致后续的Find Sessions永远找不到房间。
4.2 创建会话管理蓝图库
为了在蓝图中方便地调用Session接口,我们通常会创建一个“蓝图函数库(Blueprint Function Library)”。在内容浏览器右键,选择“蓝图类”,然后搜索“Blueprint Function Library”,命名为BPFL_SessionManager。
在这个库中,我们将封装几个关键的异步操作节点。UE4的Session接口主要是异步的,意味着你调用一个函数(如创建会话)后,需要等待它完成,完成的结果通过一个“委托(Delegate)”回调给你。在蓝图中,这体现为“异步节点”。
我们需要创建的主要函数有:
- 创建会话(Create Session):调用
Create Session节点。关键参数:Public Connections:房间最大公共玩家数(比如4)。Use LAN:局域网测试务必设为True。- 在“高级”中,可以设置
Session Settings。这里可以添加自定义的会话设置,比如房间名RoomName、游戏模式GameMode、地图名MapName等。这些设置是房间的“标签”,后续搜索房间时可以用来过滤。
- 寻找会话(Find Sessions):调用
Find Sessions节点。需要指定一个Search Settings,你可以设置搜索条件,比如只搜索特定游戏模式的房间。搜索结果是Session Results的数组。 - 加入会话(Join Session):调用
Join Session节点,传入一个找到的Session Result。 - 销毁会话(Destroy Session):当房主离开或游戏结束时调用。
在BPFL_SessionManager中创建这些函数时,记得将它们设置为“纯函数”(不依赖对象实例)和“静态”(可以直接通过类名调用),这样在任何蓝图中都可以方便地使用BPFL_SessionManager::CreateSession这样的方式调用。
4.3 在游戏模式中实现房间创建逻辑
房间创建通常由房主(第一个玩家)触发。我们可以在BP_MyGameMode中处理。一种常见的流程是:
- 在游戏模式中提供一个
StartHosting函数(可由UI按钮触发)。 - 在
StartHosting中,调用我们封装好的Create Session函数。 - 创建会话成功后,在回调委托中,使用
Get World->Server Travel到正式的游戏地图(比如我们之前建的L_Main)。Server Travel会让服务器切换关卡,并自动将所有已连接的客户端也带到新关卡。
这里有一个关键避坑点:Session Settings中设置的自定义数据(如房间名),在Find Sessions成功后,存在于Session Results的Session Settings里。但是,这些数据不会自动同步到所有客户端的GameState。你需要手动处理:在创建会话成功后,房主(服务器)将房间名设置到GameState的一个复制变量(如RoomName)上。这样,所有加入的客户端都能通过GameState获取到房间名并显示在UI上。
5. 设计并实现可搜索房间的用户界面
5.1 创建主菜单与房间列表UI
使用UMG(Unreal Motion Graphics)创建用户界面。首先创建主菜单控件蓝图WBP_MainMenu,它应该包含:
- 一个“创建房间”按钮。
- 一个“刷新房间列表”按钮。
- 一个列表控件(如
List View或Vertical Box),用于动态显示搜索到的房间。 - 一个“加入房间”按钮(可以双击列表项直接加入)。
房间列表中的每一项,可以是一个单独的控件蓝图WBP_RoomEntry,里面包含几个文本块(Text Block)来显示房间名、当前玩家数/最大玩家数、房主名、游戏模式等。
5.2 绑定UI与Session逻辑
UI的逻辑应该放在玩家控制器(BP_MyPlayerController)中,因为UI是每个客户端本地的东西。
- 创建主菜单:在玩家控制器的
BeginPlay事件中,创建WBP_MainMenu控件并添加到视口。 - 绑定“创建房间”按钮:点击后,调用玩家控制器上的一个服务器函数
Server_CreateRoom,传入房间名等参数。在这个服务器函数内部,再调用BPFL_SessionManager::CreateSession。为什么要在服务器函数里创建Session?因为Session是和服务器进程绑定的,必须由服务器来创建。客户端按钮点击只是发起一个请求。 - 绑定“刷新列表”按钮:点击后,调用
BPFL_SessionManager::FindSessions。这是一个异步操作,当搜索完成时,会触发一个回调。 - 处理搜索回调:在回调事件中,你会收到一个
Session Results数组。遍历这个数组,为每一个结果创建一个WBP_RoomEntry实例,用从Session Settings中解析出的数据(房间名、人数等)填充它,然后添加到主菜单的列表控件中。 - 绑定“加入房间”:当玩家点击某个房间列表项时,获取该项对应的
Session Result,然后调用BPFL_SessionManager::JoinSession。
5.3 UI的网络数据流与更新
这里有一个常见的复杂点:房间列表的动态更新。当有新房创建或旧房关闭时,其他客户端的列表应该更新。NULL子系统在局域网下,FindSessions的结果是即时的,但没有自动订阅更新的机制。因此,一个简单的实现是让“房间列表”界面定期自动刷新(比如每5秒),或者在玩家手动点击刷新按钮时更新。
对于房间内的信息(如玩家列表),则通过GameState和PlayerState的复制变量来同步。在WBP_InGameHUD(游戏内HUD)中,可以定时(使用Event Tick或一个定时器)获取GameState中的玩家数组,然后更新UI上的玩家列表显示。当PlayerState中的变量(如玩家名)发生变化并设置了RepNotify时,可以在回调事件中直接更新UI,这样效率更高。
6. 完整流程串联与关键避坑点实录
6.1 从启动到游玩的完整步骤
让我们把上面的所有模块串联起来,描述一个玩家从启动游戏到加入房间游玩的完整流程:
- 玩家A(房主)启动游戏:客户端运行,显示主菜单
WBP_MainMenu。 - 玩家A创建房间:点击“创建房间”,输入“我的战斗小屋”,点击确定。
- 客户端
PlayerController调用Server_CreateRoom(“我的战斗小屋”)。 - 服务器执行该函数,调用
SessionManager::CreateSession,并在Session Settings中设置RoomName=”我的战斗小屋”。 - 创建成功后,在回调中,服务器执行
ServerTravel到L_Main地图。此时玩家A的客户端作为第一个连接者进入地图。 - 服务器同时将
GameState中的RoomName变量设置为“我的战斗小屋”。由于该变量已复制,后续加入的客户端都能看到。
- 客户端
- 玩家B加入游戏:玩家B启动另一个客户端实例,看到主菜单。
- 点击“刷新列表”,客户端调用
FindSessions。 - 搜索完成,回调中收到一个
Session Result,其设置中包含RoomName=”我的战斗小屋”。 - UI列表更新,显示这个房间。
- 玩家B双击该房间项,客户端调用
JoinSession,传入这个结果。 - 加入成功后,引擎会自动让客户端连接到服务器地址(127.0.0.1:7777),并加载服务器当前的地图
L_Main。
- 点击“刷新列表”,客户端调用
- 游戏内:两个玩家都在
L_Main地图中,他们各自的PlayerController和Character被生成。他们可以移动,彼此看到对方。房间名显示在所有人的HUD上(从GameState读取)。
6.2 常见问题排查与解决方案实录
在实际操作中,你几乎一定会遇到下面这些问题。我把它们和解决思路整理成了表格,方便你快速对照排查。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
Find Sessions永远返回空数组 | 1.DefaultEngine.ini中在线子系统未正确配置为NULL。2. 创建会话时 Use LAN未设置为True。3. 防火墙或网络设置阻止了UDP广播(局域网发现依赖这个)。 | 1. 检查Config/DefaultEngine.ini文件,确保有[OnlineSubsystem]和[OnlineSubsystemNULL]的配置节。2. 在创建会话的节点上,确认 Use LAN参数已勾选。3. 暂时关闭防火墙测试,或确保UE4程序在防火墙中被允许通过。 |
| 客户端加入会话后卡在加载界面或连接失败 | 1. 服务器地图路径不一致。客户端尝试加载的地图在本地不存在。 2. 网络端口冲突或被占用。 3. 打包后,客户端与服务器的游戏版本或内容不匹配。 | 1. 确保服务器ServerTravel使用的地图名,在所有客户端机器的内容中均存在且路径一致。对于打包版本,地图必须被正确打包。2. 检查服务器启动日志,看是否提示端口绑定失败。默认是7777,可以尝试换用其他端口如 7778。 3. 确保服务器和客户端使用的是完全相同的构建版本。 |
| 角色移动不同步,其他玩家看到的位置卡顿或瞬移 | 1. 移动逻辑没有在服务器端执行。 2. 网络更新频率过低或网络带宽设置不当。 3. CharacterMovementComponent的Net Update Frequency设置过低。 | 1.绝对确保移动输入是通过PlayerController调用Character上的ServerRPC函数来驱动的。2. 在角色蓝图的 Character Movement组件细节面板中,提高Net Update Frequency(如从默认的30改为100)。在BP_MyGameMode的GameSession类设置中,可以调整MinNetUpdateFrequency。3. 对于高速移动的物体,考虑启用 Client-Side Prediction和Server-Side Correction,但这属于进阶话题。 |
| 自定义的会话设置(如房间名)在搜索时读不到 | 1. 设置会话数据时,变量名或类型错误。 2. 搜索时,没有正确遍历 Search Results中的Session Settings。 | 1. 在创建会话设置时,使用Set节点,确保键(Key)的名称是字符串(如“RoomName”),值(Value)的类型匹配(字符串存字符串,整数存整数)。2. 在 Find Sessions完成后的回调中,遍历Session Results,对每个结果,使用Get节点,用相同的键名去尝试读取数据。读取前最好用Has节点检查一下是否存在。 |
| 玩家退出后,房间列表没有及时更新 | NULL子系统在局域网下没有内置的房间状态广播机制。 | 实现一个简单的轮询机制:在房间列表UI打开时,设置一个定时器(如每5秒),自动调用一次Find Sessions来刷新列表。或者,在玩家加入/离开时,由服务器通过GameState广播一个事件,但实现较复杂。对于初级版本,手动刷新或定时刷新是可行方案。 |
| 打包后游戏无法创建或加入房间 | 1. 打包时,在线子系统模块未正确包含。 2. 打包设置中,未包含必要的启动地图或功能。 | 1. 在项目的.Build.cs文件(C++项目)中,确保添加了"OnlineSubsystem"和"OnlineSubsystemUtils"模块的依赖。对于蓝图项目,在项目设置->打包->高级->附加非资产模块中手动添加。2. 在项目设置->地图和模式中,确保设置了正确的“游戏默认地图”和“服务器默认地图”。 |
6.3 性能与扩展性考量
当你的房间功能跑通后,可以考虑一些优化和扩展:
- 减少RPC调用频率:不要每帧都调用RPC。对于连续的状态更新(如血量),使用复制的变量。对于频繁的事件(如射击),可以考虑在客户端预测,然后由服务器校正。
- 会话设置优化:只同步必要的数据。
Session Settings中的数据会在每次搜索时被广播,不宜过大。 - 使用专用的游戏会话接口:对于更复杂的房间管理(如密码、分段、等级匹配),可以考虑使用更高级的在线子系统接口,如Steam、Epic Online Services,它们提供了更完善的大厅服务。
- 错误处理与超时:对所有异步的Session操作(Create, Find, Join)都要实现错误处理的委托回调。网络操作可能失败,给用户明确的提示(如“创建房间失败”、“加入超时”)至关重要。
走到这一步,你已经拥有了一个可运行、可扩展的UE4多人联机基础框架。这个框架虽然简单,但涵盖了从网络框架理解、Session管理到UI交互的核心链路。