☰
UE5 多人联机同步入门:从服务器权威到 Coop 玩法实现
2026/10/3 5:19:50 网站建设 项目流程

从单机关卡到双人联机,UE5 的网络同步是一道绕不过去的坎。我自己带项目的时候,几乎每个从单机转型多人开发的同事都会先撞一次墙:本地跑得好好的功能,开了双人窗口就各种灵异事件——门开了只有自己看得见,敌人打死后在队友屏幕上还站着,拾取的钥匙各算各的。这些问题的根源其实相当集中,搞懂 UE5 的服务器权威模型之后,Coop 玩法的大部分同步坑都能提前避开。这篇文章我从原理讲到能直接照做的 Coop 骨架,再附上调试三板斧和踩坑记录,希望帮你少走一个月的弯路。


1. 为什么 Coop 的第一课永远是"权威"这两个字

1.1 先忘掉"同步"这个词,记住"谁说了算"

很多人一接触网络同步,脑子里想的是"让所有客户端保持一致"。这个方向没错,但容易让人走进死胡同。真正该想的问题是:当两个玩家对同一件事产生分歧时,以谁为准?UE5 给出的答案非常明确——服务器(Server)说了算。这就是服务器权威(Server Authority)模型。

我用一个生活化的例子解释给你。想象一个记账本,几个合伙人合资开一家店,每个人手机上都有一份流水账副本。如果每个人都可以直接在手机本地修改账目,那么 A 记了一笔收入,B 的手机还是旧数字,月底一核对就乱了。正确做法是:所有账目只允许出纳(服务器)改,其他人想记一笔就向出纳发个申请,出纳改完总账之后,再把新账单分发给所有人的手机副本。UE5 的复制(Replication)本质上就是这个流程:服务器持有权威状态,定期把变化"广播"给客户端,客户端看到的是那份总账的副本。

放在 Coop 里具体化:玩家 A 砍了怪物一刀,伤害算多少、怪物扣多少血、掉不掉装备,这些判定都应该由服务器完成。玩家 A 的客户端可以播挥刀动画、可以播放音效,但"结果"不能由 A 自己说了算。如果客户端各自算伤害,就会出现同一只怪在 A 的屏幕上掉血了、在 B 的屏幕上还是满血的诡异场面。

1.2 网络模型:监听服务器、专用服务器与客户端

在动手建项目之前,先搞清楚 UE5 里有几种"角色"在跑。按 NetMode 划分,常见的有四种:

NetMode 类型说明Coop 场景使用建议
Standalone单机模式,无网络开发初期纯本地测试
Listen Server监听服务器,既是服务器又是玩家小型 Coop 最常见的模式,一个玩家建房,其他人加入
Dedicated Server 专用服务器无画面、只跑逻辑的服务器正式运营、大量玩家时使用
Client客户端,只与服务器通信加入别人房间的玩家

在编辑器里点 Play 并设置 Player Count 为 2,默认跑的就是一个 Listen Server 加一个 Client。这也是开发调试时效率最高的模式,因为你可以直接看到两个"视角"的差异。

代码和蓝图里经常看到Has Authority或者GetLocalRole()节点,判断的就是"当前这段代码是否运行在服务器上"。几乎所有关键逻辑都要包一层Has Authority再执行,这是后面排查问题时的第一道滤网。

1.3 持续的"状态"与一次性的"事件"要分开处理

Coop 同步的第二个基本概念:变量复制(Replicated Property)和 RPC 是两套东西,别混用。

  • 变量复制解决的是"状态"问题。比如门的开关状态、玩家的剩余血量、当前的参赛比分,这类数据是一直有值的,客户端过一段时间就能从服务器拉到最新值。它适合描述"当前处于什么状态"。
  • RPC解决的是"事件"问题。比如开火的闪光、怪物被击飞的动画、玩家按下按钮的瞬间,这类数据发生在一瞬间,如果你用变量去记录,客户端很可能错过那一帧。RPC 是主动通知,不管对方有没有在拉数据,我都要喊它一声。

做 Coop 的时候我习惯先问自己:这个东西是"一直存在的值"还是"发生了一次的动作"?如果是前者,用复制的变量;如果是后者,用 RPC。最忌讳的是把一个重复触发的技能特效写成每帧更新的复制变量,那会让同步流量暴涨。


2. 搭建你的第一个 Coop 联机骨架(能跑起来的门槛其实很低)

2.1 从第三人称模板开始,安装完成后直接双开测试

很多人以为 Coop 要从自定义角色或时间生成系统开始写,其实不用。UE5 自带的第三人称模板(Third Person Template)已经把网络同步的地基打好了:角色 Pawn 默认bReplicates = true,自带CharacterMovementComponent,而CharacterMovementComponent本身就是网络感知的——它在服务器上执行移动逻辑,然后把位置、速度等状态复制给客户端。

创建一个第三人称模板项目,什么代码都不用写,点击 Play 旁边的下拉箭头,把 Number of Players 改成 2,选择 New Window 模式启动。你会看到两个窗口,每个窗口各控制一个角色,角色能互相看见对方在跑动。这说明"什么都没做也能联机"的原因就是引擎自带的移动同步组件在替你干活——你的大部分 Coop 逻辑都要像这套移动系统一样,做到"服务器决策、客户端表现",而不是各跑各的。

如果你的角色是自定义 Pawn,检查一下蓝图里有没有把 Pawn 的Replicates属性设为 true。这个属性在蓝图类默认值里位于 "Replication" 分类下。忘记勾选,客户端看你的角色就会是空气。

2.2 调整碰撞响应:队友能不能互相穿人

第三人称模板的角色碰撞默认会阻挡其他 Pawn,如果两边都是人形角色,近距离会互相推挤,这在某些 Coop 游戏里是允许的,但在很多合作游戏里你想让队友不互相挡路。

要做出"队友可穿透、敌人会碰撞"的效果,得在角色的Capsule Component里调整碰撞响应:把 Pawn 通道(World Dynamic 或者直接设一个自定义通道)设为Ignore,同时让碰撞响应在服务器和客户端保持一致。我一般会专门建一个 Collision Profile,叫CoopCharacter,并把它应用到所有玩家角色上,避免每台客户端因为设置不同出现"你看到他穿模但他看不到你穿模"的问题。

一个容易忽略的细节:如果开启了队友穿透,那么队友之间的"体积阻挡"就不再存在,投掷物、近战检测的射线必须单独做碰撞筛选,免得攻击穿过队友身体直接命中后面的敌人。

2.3 让第二台电脑加入进来:局域网联机测试

本地双开适合验证逻辑,但要验证真实网络延迟环境下的表现,还是得用两台机器或者两个编辑器实例。在项目设置里把地图添加到Maps & Modes的 Game Default Map 里,设置Number of Players为你的目标人数。

局域网联机的操作是:主机在编辑器里运行游戏后,在控制台输入open LevelName(或者直接启动时添加?listen);另一台机器运行打包版本后,控制台输入open 192.168.x.x:7777。默认端口是 7777,如果遇到加入不了的情况,优先检查防火墙和局域网 VLAN 设置。

不过我在调试阶段还是推荐先用本地双开。原因很简单:本地双开时你能同时看到两边的真实渲染画面,判断"对方视角看到什么"最直观。


3. 属性同步与 RPC:让不同客户端看到同一场战斗

3.1 用 Replicated 变量做血量、分数和任务计数

Coop 里最基础的同步需求是"所有人都能看到同一份数据"。例如玩家血量、团队成员分数、怪物击杀数量。在蓝图里,只要在变量的 Details 面板中找到 Replicate 并勾选,这个变量就会在服务器修改后自动同步到所有客户端。

C++ 里则是在 UPROPERTY 里加Replicated宏,然后在GetLifetimeReplicatedProps里登记:

UPROPERTY(Replicated) float Health; void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMyCharacter, Health); }

这里请你牢牢记住一条黄金法则:复制的变量只能在服务器上修改。客户端的本地代码可以直接读它的值来更新 UI,但一定不能直接写。如果你尝试在客户端把血量改成 0,服务器的血量不会变化,接下来其他客户端看到的血量也不会变化,只有你自己这一端变成了"假死亡"。正确姿势是:客户端发起一个"请求掉血"的 RPC,由服务器计算伤害并写入血量变量,再复制给所有人。

3.2 三种 RPC 的选择逻辑:Server、Client、Multicast

RPC 是 UE5 里"主动通知"的机制,分为三种,调用端和执行端完全不同。

RPC 类型调用端执行端典型使用场景
Server RPC客户端(拥有者)服务器发请求:攻击、开门、拾取、互动
Client RPC服务器指定的单个客户端服务器向拥有者下发私密信息:自己的死亡回放、专属 UI 提示
Multicast RPC服务器(或拥有者)服务器 + 所有客户端广播场景事件:怪物爆炸、门打开动画、全队通知

很多人一开始分不清 Client RPC 和 Multicast RPC。记住一句话:如果这个事件"只需要某一个人知道",就用 Client RPC;如果"所有人都要知道",就用 Multicast RPC。一个典型的例子——游戏中玩家按 F 拾取钥匙,拾取结算必须是 Server RPC,因为钥匙归谁这件事必须服务器裁决;钥匙被拾取后,播放一个"钥匙飞向玩家"的小动画,是全队可见的表演,用 Multicast RPC 广播。

蓝图里,函数或事件在 Details 中可以选择 Replicates 选项:Server、Multicast、Client。如果选择 Server,客户端调用时自动将调用请求发往服务器。在选择 Replicates 之前,必须确保函数名已经加上Server_、Multi_、Client_之类的前缀,方便一眼识别它的执行端。

3.3 Coop 的"门":从按下开关到全队动画,一条完整链路

做一个简单的双人协作门,需要四个环节:

  1. 玩家 A 走到门旁的触发区,按互动键。这个动作发生在 A 的客户端,直接调用一个Server_RequestOpenDoor的 Server RPC。
  2. 服务器收到请求,判断权限与条件(比如所有人进入范围才开门),然后执行两件事:把门的bIsOpen复制变量改为 true;调用Multi_PlayOpenAnim。
  3. 门本身设置了bReplicates = true,bIsOpen是复制的属性,服务器一改,所有客户端都会在自己下一次复制更新时看到门状态变化。
  4. Multicast RPC 负责播放动画和音效这类"一次性演出"。Multi_PlayOpenAnim在服务器上执行,并自动广播给所有客户端,于是每个玩家都看到门缓缓打开。

你可能想问:既然bIsOpen都复制了,为什么还要一个 Multicast 播动画?原因是变量复制有帧间隔,你会在门打开后的第 0.1 秒左右才看到bIsOpen变化,直接用它驱动动画会带一点延迟。Multicast 可以把动画触发时刻提前,视觉上更跟手。但如果你的门动画是非常简单的"位置移动 + 音效",也可以完全只靠bIsOpen复制驱动,省掉一个 RPC,减少流量。


4. 敌人、掉落物和任务目标在 Coop 里的同步设计

4.1 刷怪必须由服务器亲手生成

Coop 里常见的第一个逻辑是"按时刷一波怪"。如果你在客户端的 BeginPlay 里直接 Spawn 敌人的蓝图,那就出现了大问题:每个客户端都会在自己的世界里生成一批怪,服务器也生成一批怪,大家看到的是不同平行世界的战斗。

正确姿势是:所有需要全局共享的 Actor 必须由服务器生成。在 GameMode(GameMode 只存在于服务器)里写生成逻辑,比如按波次刷怪:

// GameMode 中 FTimerHandle SpawnTimer; GetWorldTimerManager().SetTimer(SpawnTimer, this, &AMyGameMode::SpawnWave, WaveInterval, true); void AMyGameMode::SpawnWave() { for (int32 i = 0; i < SpawnCount; ++i) { FActorSpawnParameters Params; Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn; AMyEnemy* Enemy = GetWorld()->SpawnActor<AMyEnemy>(EnemyClass, SpawnPoints[i]->GetActorLocation(), FRotator::ZeroRotator, Params); } }

只要敌人蓝图里Replicates = true,服务器生成成功后,所有客户端都会自动被同步看到这个敌人。AI 控制器的所有权也默认跟随生成它的服务器,因此行为树运行在服务器上,移动结果同样通过 CharacterMovementComponent 复制到客户端。

反过来,有些东西需要在客户端本地模拟。比如血条 UI 上的伤害数字飘字,这类"只给自己看的演出"就放在客户端自己的逻辑里,不用同步,减少流量。

4.2 敌人的移动与开火:服务器决策,客户端表演

AI 敌人的移动由服务器驱动,会带来一个现象:当网络延迟较高时,客户端看到的怪物位置会有一点滞后或跳跃。这是正常现象,也是模拟移动(Simulated Proxy)的固有属性。

敌人的开火事件则更微妙。服务器决定目标、决定开火判定,这个决策是服务器行为树的一部分;但"枪口火光、枪声、后坐力动画"这些属于表演,必须通过 Multicast RPC 广播,否则只有服务器视角的记录,客户端们看不到枪口火焰。我遇到过的情况是:调试时发现服务器端怪物开枪了,子弹轨迹的数据也复制过去了,但客户端就是没有播放开火特效,最后定位发现是特效只挂在了服务器的 SkeletalMeshComponent 本地时间线上,没有走 RPC。

4.3 拾取物与任务进度:这两种数据的分工

Coop 里的拾取物可以分两种:"全队共享"和"个人独有"。

  • 全队共享的道具(比如开启下一区域的三把钥匙)必须存在服务器上的一个共享状态中。你可以在 GameState 里复制一个TArray<bool> KeysCollected,每个客户端读取这个数组来决定 UI 显示。
  • 个人私有的道具(比如每个人自己的血量、弹药)可以放在各自角色身上,通过玩家 Pawn 的复制属性同步给对应客户端。

任务进度的同步最推荐放在 GameState 上。GameState 本身就是在所有客户端复制的对象,在它上面挂一个Replicated的整数CollectedKeysCount,服务器每次修改它,所有客户端的 HUD 都能收到更新。不要在客户端本地对"任务进度"累加,会话一断开数据就全乱。

4.4 一个完整的 Coop 小循环示例:踩压板开门

把上面的知识点串起来,做一个可以实际运行的 Coop 门开关机制,步骤拆解如下:

  1. 添加一个 PressurePlate 蓝图,Replicates = true,内部局部变量bTriggered不复制,仅在服务器使用。
  2. 玩家走进触发体积,调用自身的Server_RequestPressingServer RPC,把自身的碰撞体 ID 传到服务器。
  3. 服务器记录有玩家踩在板上,并将PlateDoor的bIsOpen复制变量设为 true。
  4. PlateDoor调用Multi_PlayOpenAnim播放动画,服务器与所有客户端同时看到门动画。
  5. 任务目标检测在 GameMode 或 GameState 的服务器逻辑中完成:如果bIsOpen为 true 且任务还没完成,就把 GameState 中的MissionProgress变量 +1。
  6. 客户端 HUD 的"任务进度 1/3"数量直接绑定 GameState 的MissionProgress变量,变量一变 UI 自己刷新。

注意一个细节:所有"是否达到条件"的判断放在服务器。客户端可以显示进度,但不能直接改。


5. 本地多开测试的调试三板斧:身份打印、延迟模拟、现象对照

5.1 第一板斧:打印身份再判断逻辑

几乎所有多人同步问题都可以用"先打印身份再判断逻辑"来定位。在关键函数的入口加一个 Print String,打印当前端的GetNetMode()或Has Authority。

蓝图里最常用的是这两个节点:

  • Has Authority:是否具备权威权限,等同于"当前是服务器还是客户端"。
  • Get Local Role/Get Remote Role:获取本端角色,可以区分ROLE_Authority(服务器)、ROLE_AutonomousProxy(本地控制的客户端)、ROLE_SimulatedProxy(模拟其他玩家)。

我写多人逻辑的习惯是:写任何函数之前,先问"这个逻辑应该跑在服务器还是客户端",然后在函数开头加打印。例如一个攻击函数,我希望只有服务器执行伤害结算,于是在开头写:

Print String: "Attack executed at role: " + GetLocalRole() if (Has Authority) { // 在这里写伤害结算逻辑 } else { // 这里可以直接调用 Server RPC,或者不做任何事 }

配合双开测试,你会看到服务器窗口打印ROLE_Authority,客户端窗口打印ROLE_AutonomousProxy。如果两个窗口都打印了ROLE_Authority,说明你的函数在两端都在执行,十有八九是逻辑没有收敛到服务器。

5.2 第二板斧:模拟网络延迟与丢包

光看两个窗口都正常不算完,Coop 最怕的是延迟条件下的不一致。在控制台输入以下命令可以模拟网络参数:

Net PktLag=200 Net PktLoss=10

或者在启动参数后追加:

?NetPktLag=100&NetPktDup=5

这两个命令分别模拟 200 毫秒的网络延迟和 10% 的丢包率。加了延迟之后你再看之前写的门开关效果,会立刻发现"为什么我按下按钮后要等 0.2 秒门才打开"——这就是没有做客户端预测的表现,但也符合服务器权威模型:客户端不自己预演,只等服务器下发结果。

如果你在延迟条件下发现门开一半停住了、怪物位置跳变严重,可能是复制更新频率太低。增加 Actor 的NetUpdateFrequency(在 Details 面板的 Replication 分类里,默认值是 100 左右)可以让位置更新更频繁,但也会增加流量,需要平衡。

5.3 第三板斧:常见现象对照表,快速缩小排查范围

调试过程其实就是一个"现象 -> 可能原因 -> 验证方法"的循环。我把 Coop 开发中常见的几个现象整理成一张对照表,遇到问题先对着这张表看:

现象可能原因排查方向
另一台窗口看不到自己的角色Pawn 没有勾选 Replicates检查 Pawn 默认值的 Replicates 是否勾选
能看到角色但打不死伤害判定没有收敛到服务器攻击函数开头检查是否只在服务器执行伤害结算
开门只有自己看得到动画动画播放没有被广播是否用 Multicast RPC,或门状态变量是否复制
A 开火只有 A 自己看到特效特效是本地事件而非 Multicast RPC特效触发节点改为 Multicast,开头加 Has Authority 检查
怪物血量在两个窗口不一致两个客户端都各自扣血,服务器没有裁决伤害计算放在服务器,客户端只做请求
任务进度条每个人都不一样GameState 变量没有复制检查 GameState 变量是否勾选 Replicate
子弹命中不统一服务器和客户端分别做了射线检测射线检测在服务器做,客户端只播动画与音效

6. 我在 Coop 项目里真实踩过的那几个大坑(希望你别再踩一遍)

6.1 坑:每个客户端都把自己的怪"打死"了

这是我带的第一个 Coop 原型出现过的最经典问题。当时用蓝图写了一个简单攻击逻辑,玩家按左键直接对怪物扣血,本地测试一切正常。双开之后,两个玩家同时按左键,怪物的血量在两端疯狂跳变——因为每个客户端每次攻击都在改同一个复制变量,后写入的一方覆盖先写入的一方。

定位思路很简单:在攻击函数开头打印角色,发现两个端的角色都是AutonomousProxy(都没有 Authority),都在执行伤害扣除。修复方案是把 "Damage" 移到一个 Server RPC 里,客户端只调用请求,服务器改血量。注意 Server RPC 必须与函数名匹配、且参数类型要是可复制类型(整数、浮点、枚举都可以;引用类、结构体需要小心)。

6.2 坑:能看见对方角色,但位移总在"瞬移"

这是NetUpdateFrequency太低导致的典型表现。本地双开时网络压力小,可能看不出;一旦换到真实局域网或者加延迟模拟,就发现角色每几百毫秒跳一下。

遇到这种情况先别急着怀疑物理同步,检查三个位置:

  • Pawn 的NetUpdateFrequency是否过小(默认 100,可以从这个值往上调);
  • CharacterMovementComponent的Network Cull Distance是否为 0;
  • ReplicatedMovement是否没有复制——如果从模板改来的角色,这个一般没问题,但如果从零搭建的角色,Movement 组件的复制选项可能漏了。

之前我调一个空投箱子,发现它在客户端几乎每 0.5 秒才跳一帧。把盒子的NetUpdateFrequency从默认 100 调到 20 反而更夸张,后来才意识到它应该用ReplicatedMovement自动同步,而不是自己复制 Transform。

6.3 坑:开门动画只有操控者自己看得到

我写过一次开门互动。按下键后门正常开了,但 A 玩家能看到门慢慢打开,B 玩家面前的门"啪"地一下直接变成开的。

原因很简单:门的打开动画是用PlayAnimation放在本地执行的,不是 Multicast。动画事件默认在调用端的本地播放,其他客户端根本没有收到"开门"这个事件,所以它们只会根据复制过来的bIsOpen变量硬跳到最终姿态。

修复有两种方式:

  • 用变量驱动动画:在门的动画蓝图里读取bIsOpen,根据它播放"开/关"状态,而不是在互动的瞬间调用PlayAnimation。这是一致性最高的方式。
  • 用 Multicast RPC 广播动画播放请求:门调用Multi_PlayAnimation,所有客户端都会收到并播放同一个动画。

我更推荐第一种,因为它自动解决了"客户端中途加入"的问题——新加入的玩家不需要补放动画,直接读取当前门状态即可。

6.4 坑:炸弹计时器在服务器和客户端各走各的

Coop 里做"30 秒后房间爆炸"的机制时,我在蓝图中用Delay节点在服务器运行了倒计时,同时也在刚进入房间的客户端本地放了一个Delay节点显示 UI 倒计时。结果就是:服务器倒计时 30 秒,客户端因为加入时机不同,显示的 30 秒和服务器爆炸瞬间差了好几秒,炸的时候 UI 还在 3 秒。

这类问题统一用"服务器决策 + 复制变量展示"解决:把RemainingTime作为复制的变量,服务器每帧更新它;客户端 UI 只读这个变量做倒计时显示。不要在每个客户端挂独立的 Timer。自己想起来这个坑,是因为发现新加入的玩家显示剩余时间总是错的——客户端本地 Timer 永远从加入时才启动,而服务器的倒计时早就走了一半。

6.5 坑:HUD 数据在"我"身上正确,但看"他人"不正确

写玩家头顶血条时,我一开始把血条 UI 直接绑定到自己的Health变量上,结果每个客户端都只显示自己的血量,别人的头顶没有血条。再换成在血条组件里每帧读取目标 Pawn 的Health变量,才正常。这个坑的核心混淆是"拥有者(Owner)"和"本地控制的 Pawn":

  • 每个客户端都能看到其他玩家的 Pawn,但只有自己控制的 Pawn 是AutonomousProxy。
  • 复制变量(Health)是所有人都能读取的:只要你的血条 UI 绑定的是"目标 Pawn 的 Health 复制变量",那么每个客户端都会从服务器那里收到最新血量并更新头顶血条。
  • 用OwnedBy、bIsLocallyControlled判断的是"这个 Pawn 是不是我控制的",它决定的是输入和相机,而不是"这个角色能不能被看到"。

6.6 额外的提醒:避免把 GameMode 逻辑塞进客户端

GameMode 只在服务器存在,客户端拿不到它的引用。我之前试过在客户端蓝图里直接获取 GameMode 做任务进度查询,结果客户端永远是空引用。后来改成 GameState 同步方案才通畅——GameState 对所有端可见,GameMode 只做服务器决策和生成规则。


最后说一个我的个人习惯:每写一个涉及多人互联的节点,第一件事不是写完整逻辑,而是先放一个身份打印,确认它运行在哪一端。双开测试跑完一遍后,再回头把这些调试打印全删掉。这套"先身份、后逻辑"的做法帮我省下了大量定位时间,也让我后来带团队时特别强调——多人开发不是把单机逻辑搬过来加两个 RPC 就完事,而是从一开始就要把"权威"和"表演"分开写。这就是 UE5 Coop 实现里最值得先建立的心智模型。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询