如果你做过 UE5 多人项目,大概率经历过这种场景:单人模式里一切正常,角色能跑能跳能开枪,怪物的血条也老老实实地往下掉。可一旦联机,问题就全冒出来了——队友那边看不到你开的枪,怪物被打死后血条又弹了回去,主机玩家打得欢,客机玩家却对着空气输出。这不是操作问题,也不是美术资源问题,而是网络同步的基础框架没搭对。
这篇文章就针对 UE5 网络同步和 Coop(合作多人)实现,把最核心的概念、最常用的架构、最容易踩的坑一次性讲清楚。适合正在做合作打怪、四人小队生存、或者任何需要“多个玩家一起打 AI 敌人”的项目的开发者。内容不追求把所有网络底层机制都讲透,只求让你看完后能搭出一个稳定、不闪回、不穿帮的 Coop 网络骨架。
1. 为什么 Coop 项目总是“单机跑得好,联机就翻车”
1.1 一个看似简单的问题:谁拥有“事实”
很多人在开始做多人之前,会默认“代码写在哪个客户端上,哪个客户端就有最终结果”。这是一个致命的误解。在 UE5 的网络模型里,一个 Actor 的状态只有一个权威(Authority)来源,通常就是服务器。客户端能做的只是“提出请求”,能不能生效,由服务器说了算。
举个例子。两个玩家同时开枪打同一个怪物:玩家 A 的客户端算出怪物掉血 50,玩家 B 的客户端也算出它掉血 50。如果每个客户端都直接改自己的本地血量,那么怪物在 A 那里是 50 血,在 B 那里是 50 血,在服务器上还是 100 血。接下来服务器再把它的权威状态广播给所有人,客户端一收到就强制覆盖成 100 血,于是怪物血条“回弹”了。
这个问题的本质是:客户端没有权限决定怪物的状态。正确做法是让客户端把开火意图发给服务器,服务器统一计算血量变化,再把新状态广播给所有客户端。每个客户端只负责显示最终结果,不负责“创造事实”。
1.2 Coop 网络模型速览
UE5 的网络同步主要依赖三个东西:Actor 复制、属性复制、RPC。它们之间的关系可以用一句话概括:Actor 复制决定了某个对象要不要同步,属性复制决定了它的哪些字段要同步,RPC 决定了哪些函数要跨端调用。
我把几个常用术语整理成了一张表,方便你边做边对着查:
| 术语 | 作用 | 使用习惯 |
|---|---|---|
| Actor Replication | 控制该 Actor 是否被复制到客户端 | 游戏性对象基本都要开,纯 UI 对象不用开 |
| Property Replication | 同步属性值,客户端自动覆盖本地值 | 血量、分数、波数这类“状态”用这种方式 |
| RepNotify | 加在复制属性上,客户端收到新值后触发回调 | 血条刷新、死亡 UI、飘字播放都靠它 |
| Server RPC | 客户端调用,最终在服务器上执行 | 开火、换弹、拾取物品等所有“请求” |
| Client RPC | 服务器调用,在指定客户端上执行 | 给某个玩家单独弹提示、单独播放结算画面 |
| NetMulticast RPC | 服务器调用,在所有客户端上执行 | 全局音效、爆炸特效、广播聊天 |
这里要特别注意一个选择:UE5 的网络模型是状态同步,不是帧同步。帧同步要求所有端跑完全一致的逻辑,棋盘类、格斗类游戏常用;而 Coop 这种射击、生存、打怪玩法,天然适合状态同步——服务器维护权威状态,客户端各自渲染表现。不要想着做一个“全球同服锁 60 帧”的 Coop,方向就错了。
1.3 开发自测环境的两种搭建方式
刚开始联调时,很多人直接用 PIE(Play In Editor)单进程模拟。这个模式能快速验证功能,但它有个隐藏问题:同一进程内的“网络”延迟极低,很多时序问题根本暴露不出来。
我建议你按这个顺序做测试:
- 编辑器里 PIE + “启动独立服务器”选项,开两个窗口,一个当服务器,一个当客户端。这一步验证基本同步逻辑有没有通。
- 打包一个客户端版本,在局域网里真机双开。这一步才能看到真实的网络延迟和抖动。
- 有条件的话,让三个人以上一起进游戏测试。Coop 最怕的是“两个人测着没问题,四个人一进就崩”,因为同步数据量和关注列表的复杂度都上去了。
如果你只是做原型验证,PIE 足够;但要做到“能拿给别人试玩”的程度,局域网真机测试是逃不掉的。
2. 搭一个最小可复制的 Coop 网络骨架
2.1 角色、GameMode、GameState、PlayerState 各自管什么
很多新手项目一上来就把所有逻辑堆在角色蓝图里,这在单机里没问题,但多人下会立刻爆炸。关键原因是:UE5 为多人游戏预定义了一套分工体系,你非要把不属于角色的逻辑塞进去,复制关系就会乱。
- GameMode:只在服务器上存在,负责游戏规则。谁出生、什么时候刷怪、波次怎么推进,全在这里。
- GameState:复制给所有客户端,负责存放全局状态。当前波数、全场剩余敌人数量、公共得分这类“所有人都能看到”的数据放这里。
- PlayerState:复制给所有客户端,负责存放单个玩家的持久数据。个人击杀数、个人得分、玩家昵称放这里。
- Pawn / Character:复制给所有客户端,负责“这个玩家当前控制的身体”。血量、位置、动画状态放这里。
- PlayerController:负责输入和视图,每个客户端只有一个。
判断一个数据该放哪,有一个实用标准:问自己一句话,它到底属于谁?如果属于世界规则,放 GameState;如果属于某个玩家的长期记录,放 PlayerState;如果属于当前控制角色的即时状态,放 Pawn。拿不准就多看一眼,放错位置是后期重构最痛苦的事。
2.2 代码骨架:先把“状态”放对地方
用 C++ 写一个最简单的 GameState,把波数同步给所有客户端:
// MyGameState.h UCLASS() class AMyGameState : public AGameStateBase { GENERATED_BODY() public: UPROPERTY(ReplicatedUsing = OnRep_WaveNumber) int32 WaveNumber = 0; UFUNCTION() void OnRep_WaveNumber(); virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; };// MyGameState.cpp void AMyGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMyGameState, WaveNumber); } void AMyGameState::OnRep_WaveNumber() { // 客户端这边收到新波数后,刷新 UI 和提示 OnWaveChanged.Broadcast(WaveNumber); }这个代码看起来简单,但它贯彻了一个重要原则:状态改变只发生在服务器,客户端只响应结果。你在服务器上调GameState->WaveNumber = 2,所有客户端都会收到更新,并触发各自的OnRep_WaveNumber回调。客户端永远不需要自己去改这个值。
2.3 服务器函数和客户端函数的划分
网络同步最容易混乱的地方是“哪个函数在哪个端执行”。我有一个习惯:命名时直接带前缀,看到名字就知道它的执行端。
Server_开头的函数,客户端调用,服务器执行。比如Server_RequestOpenFire。Multicast_开头的函数,服务器调用,所有人执行。比如Multicast_PlayExplosion。Client_开头的函数,服务器调用,指定客户端执行。比如Client_ShowRespawnCountdown。
举个例子,一个最简单的开火请求:
// 客户端按鼠标左键,调用这个函数 UFUNCTION(Server, Reliable) void Server_RequestOpenFire(FVector_NetQuantize100 StartLocation, FRotator ShotDirection);按钮按下时,客户端只做一件事:把瞄准方向和起点发给服务器。之后服务器自己做射线检测、算伤害、扣血,再广播打击特效。这个链路清晰了,后面所有能力扩展都不会乱。
3. 命中反馈链路:从开火到爆头不出现回弹
3.1 武器开火的完整链路
Coop 里最常见的问题是“玩家 A 开枪,玩家 B 看不到怪物掉血”,或者更闹心的是“掉血了,但只有开枪的那个人看得到”。这通常是因为伤害计算在客户端本地做了。正确链路应该是:
- 客户端按下开火键。
- 客户端调用
Server_RequestOpenFire,把开火位置、方向传给服务器。 - 服务器收到请求后,做射线检测,判断命中哪个怪物。
- 服务器修改怪物的
Health(这是一个复制属性)。 - 怪物的
OnRep_Health在所有客户端上触发,各自播放受击表现。
这个链路里,射线检测放服务器会有个小问题:客户端看到的准星位置和服务器用网络延迟后的射线算出的位置可能不一致,打移动目标时尤其明显。但我们的 Coop 射击场景,AI 敌人的移动速度通常不快,纯服务器判定完全够用。等到你确实需要高精度竞技射击,再考虑客户端预测和延迟补偿那套复杂方案。
3.2 Health 的同步与回弹问题
怪物血量的属性声明大概是这样的:
UPROPERTY(ReplicatedUsing = OnRep_Health) float Health;服务器在计算伤害时直接改Health,改完以后每个客户端会自动同步。要注意的是:客户端代码里绝不能有Monster->Health -= 10这样的语句。如果你开了本地预测,就必须考虑服务器回包覆盖时的“回弹”问题。
那回弹问题怎么彻底避免?我的答案是:让“扣血逻辑”和“显示逻辑”解耦。服务器只负责记录和广播真实血量;客户端接到OnRep_Health后,用“血量变化的瞬间”来播放受击反馈,而不是在触发开火请求时立刻播放。这样即使网络延迟,玩家看到的效果也只是反馈晚了一点点,而不是血条先掉又涨回来。
3.3 死亡与复活的时序
血量归零后的处理顺序,比扣血本身更容易出问题。我见过不少项目,怪物死在 A 电脑上,但在 B 电脑上还站着,原因是“死亡动画只在开枪者的客户端上播放了”。
正确做法是:服务器检测到血量归零后,调用一个Multicast_OnDeathRPC,让所有客户端同时播放死亡动画、关闭碰撞体、停止移动组件输入。服务器自己则负责把怪物标记为待销毁,过几秒清掉或者进入下一波刷新池。
复活方面,玩家角色死亡后,服务器该做的是:
- 把玩家的 Pawn 设置为死亡状态,停止输入响应。
- 给该客户端单独弹重生倒计时 UI,这个用
Client_RPC。 - 倒计时结束,服务器调用
RestartPlayer,生成一个新的 Pawn,并让 PlayerController 重新控制它。 - 新 Pawn 身上挂的血量、动画、武器,都会随着 Actor 复制自动同步给所有人。
这里最容易踩的坑是:客户端在等待复活时,临时变成了 Spectator(观察者模式),结果RestartPlayer后视角没有切回来。解决办法是在RestartPlayer之前,用SetViewTarget明确把视角交给新 Pawn,不要在客户端侧自己改视角。
4. 刷怪、计分与 Wave 节奏:Coop 体验的同步设计
4.1 刷怪一定要在服务器
Coop 玩法的核心是刷怪节奏。如果刷怪逻辑写在客户端,那么每个玩家看到的怪物数量就可能不一样——A 觉得已经刷了 5 只,B 那边才 2 只。所以刷怪必须放在服务器端的 GameMode 里。
我一般用 FTimerHandle 做波次控制:
void AMyGameMode::StartWave(int32 WaveIndex) { if (!HasAuthority()) return; // 在服务器上生成怪物 for (int32 i = 0; i < SpawnCountPerWave; i++) { FRotator SpawnRot = ...; FVector SpawnLoc = ...; GetWorld()->SpawnActor<AMyEnemy>(EnemyClass, SpawnLoc, SpawnRot); } // 更新 GameState 上的波次信息,客户端自动收到 MyGameState->WaveNumber = WaveIndex; }有一个细节需要注意:生成怪物的位置如果在某个客户端视野内,这个怪物会因为 Actor 复制而被创建出来;但如果它在离所有玩家都远的地方,客户端可能不会立即创建它。这不算 bug,这叫“关注集合(Relevancy)”机制——UE5 不会把远处无关的 Actor 同步给你。理解这一点后,你就不会因为“服务器生成的东西在客户端看不见”而恐慌了。
4.2 全场共享的状态放 GameState
刷怪完成后,玩家最关心的几个全局数据无非是:当前波数、剩余敌人数、全场总得分。这些数据适合放 GameState,因为 GameState 是复制给所有人的,而且天然只在服务器上被修改。
剩余敌人数量的同步有一个常见的重复计数问题:每个客户端都在自己本地OnEnemyDied事件里减计数,结果同一个怪物死亡,A 客户端减了一次,B 客户端也减了一次,计数就不对了。正确做法是:
- 怪物死亡由服务器判定。
- 服务器在自己的 GameMode 逻辑里更新剩余敌人数量。
- 把这个数量写进 GameState 的复制属性。
- 客户端只响应
OnRep_RemainingEnemies来刷新 UI,绝不在客户端本地做加减法。
做一个经验总结:任何“一个事件被很多人同时响应”的设计,在多人下都要警惕。宁可让服务器集中更新状态,再广播结果,也不要让客户端各自推算。
4.3 个人计分:PlayerState
个人得分、个人击杀数这些数据,放在 PlayerState 里最合适,因为它会跟随玩家跨局存在,而且只属于单个玩家。服务器在某个怪物流血时,判断伤害来源(通常是怪物的 LastHitController 或 LastHitPlayerState),然后给对应玩家的 PlayerState 加分。
这里有一个很隐蔽的坑:怪物被玩家 A 打了一枪,又被玩家 B 补了一刀,最后怪物死了,击杀到底算谁的?如果你的需求是“按最后一击算”,那就让服务器的死亡处理逻辑里拿到最后伤害来源,然后给那个玩家加分。如果你的需求是“按伤害贡献算”,那就需要服务器在怪物收到伤害时累计每个人的伤害值,这设计更复杂,但体验更好。先想清楚你的击杀机制,再去写代码。
5. 投射物、特效和那些容易被忽略的表现层
5.1 发射物同步策略
Coop 里经常有火箭筒、弓箭、法球这类投射物。投射物的同步有两种常见做法。
第一种是“服务器生成、全端复制”。服务端在开枪点生成一个带Replicates的投射物 Actor,它的移动组件开启了复制,所有客户端都能看到同一个飞行轨迹。优点是逻辑统一,缺点是高速移动的投射物在客户端会有一点插值延迟,手感偏“飘”。
第二种是“客户端本地生成特效、服务器独立做碰撞”。客户端开枪时,本地立刻生成一个纯视觉效果物(不参与逻辑),服务器自己也在逻辑世界生成一个不可见的射线或投射物负责检测伤害。优点是手感反馈即时,缺点是需要写两套逻辑,而且容易被误判为“客户端开挂”。
对大多数 Coop 项目,我推荐先上第一种,等手感确实不行再优化成第二种。毕竟 Coop 打 AI 对精确命中要求没那么高,稳定、一致才是第一位的。
5.2 Multicast 的调用时机和“没触发”问题
MulticastRPC 是播放大范围特效的常用工具,但它有一个容易被忽略的前提:这个函数所在的 Actor 必须已经被复制到了客户端,并且该客户端当前正在关注这个 Actor。如果你的爆炸特效函数挂在一个根本不会被复制到某个客户端身上的 Actor 上,那边的玩家可能完全看不到爆炸。
我在实际项目中遇到过这样一次崩溃式的排查:玩家 B 报“看不到玩家 A 扔的手雷爆炸”。后来发现,手雷确实是用Multicast_Explode广播了,但手雷这个 Actor 的复制距离设得太近,客户端 B 离得太远时根本还没有这个 Actor 的实例,广播自然无法送达。
解决方法很简单:把这类特效 RPC 挂在一个必然被所有客户端关注的 Actor 上,比如 GameState、PlayerState,或者玩家自己的 Pawn。如果特效位置是“手雷爆炸点”,那就把这个点在 RPC 参数里传过去,而不是让 RPC 本身依托于那个可能没被复制的 Actor。
5.3 音效和 UI 飘字的本地处理
音效、飘字、粒子特效这些表现层的东西,有一个原则:能本地播就本地播,别全部走网络广播。比如玩家开了枪,枪口音效完全可以在本地直接播放;怪物中弹的喷血特效,由OnRep_Health在本地触发。
这里有一个取舍:如果所有表现都靠 RPC 广播,带宽和 Actor 关注列表都会吃紧,尤其是 Coop 里同时存在大量敌人和特效时,很容易出现“广播拥堵导致动作延迟”。我的习惯是:
- 状态变化(血量、分数、波数)必须网络同步。
- 纯粹的视听表现(音效、粒子、飘字)尽量由状态变化的回调在本地触发。
- 只有那些无法从状态变化推出来的表现(比如“某个玩家把箱子扔起来”),才用 RPC。
6. 带宽、帧率与上线前检查
6.1 同步频率不是越高越好
NetUpdateFrequency控制 Actor 的同步频率,默认值对不同类型对象完全不同。角色类可以高一些,比如 60 到 100,保证移动流畅;但场上的小怪如果数量多,频率还设那么高,带宽立刻吃紧。
我见过一个项目,玩家每人带 3 个宠物,每个宠物的移动同步频率都是 100,结果刚四个人联机,服务器带宽就满了。后来把宠物降到 20,视觉上看不出太大差别,但流畅度马上回来了。记住一句经验:能接受视觉小延迟的对象,频率就调低;绝对不能卡顿的对象,才给高频率。
6.2 条件复制和组件最小化
有些属性不需要同步给所有人,比如“只有当前 Owner 才看得到的准星状态”,用DOREPLIFETIME_CONDITION配合 COND_OwnerOnly 就能限制复制范围。如果不加任何条件,那么所有属性都发给所有人,带宽浪费非常明显。
关于组件,默认情况下 Actor 上所有开启复制的组件都会被同步。那些纯本地表现、不影响逻辑的组件(比如装饰用的烟雾特效、客户端自己的私密设置),直接把组件上的SetIsReplicatedByDefault关掉,或者用可见性控制在本地管理。
想观察带宽消耗,可以打开引擎自带的网络统计面板,看每秒钟的复制字节数。不用追求数字越小越好,但至少要知道哪些 Actor 占了最多的资源,然后针对性优化。
6.3 上线前的验证清单
我把负责过的多人项目上线前要过的测试项整理成了一份检查单,你可以直接拿去做参考:
| 检查项 | 具体场景 | 常见失败表现 |
|---|---|---|
| 基础同步 | 两个客户端同时看一个怪物的血量 | 血条不一致、回弹 |
| 多端操作 | 三个玩家同时开火打同一个怪 | 只有部分人看到效果 |
| 中途加入 | 一个人中途进游戏 | 看不到玩家、刷不出怪、UI 不显示 |
| 死亡率 | 两名玩家同时死亡 | 复活错位、视角卡在观察模式 |
| 主机掉线 | 服务器退出后客户端状态 | 卡死、无限转圈、数据丢失 |
多人项目上线前,至少要做一次全员进房间 30 分钟的稳定性测试。Coop 游戏最好玩的阶段是“乱起来”的时候,也是最容易暴露同步问题的时候。单机模式下不可能模拟出这种混乱。
另外,还有一个忠告:不要依赖日志去判断客户端是否“看到”了某个对象。网络同步的日志只能告诉你数据包发出去了,但客户端有没有成功创建、有没有正常执行,必须在真机上用视觉确认。有条件的话,让一个队友站在远处帮你确认现场表现,比自己盯着日志刷屏有效得多。
我在实际操作中的体会是,UE5 网络同步并不需要你把所有知识点背下来,真正管用的是一套稳定的思维习惯:先问这个数据属于谁,再问这个函数在哪个端跑,最后问这个表现能不能本地解决。Coop 项目里绝大多数翻车现场,都是这三个问题没想清楚导致的。希望这篇内容能帮你少踩几个坑,把精力放在真正有趣的玩法设计上。