首页 / 资讯中心 / 文章详情

UE5网络同步与Coop合作模式实战:从权威模型到同步细节

UE5网络同步与Coop合作模式实战:从权威模型到同步细节 ★ FEATURED ARTICLE
UE5的网络同步和Coop合作模式实现是很多从单机转多人开发的同学绕不过去的一道坎。我在帮朋友搭建双人合作刷怪Demo时几乎把UE5复制系统从里到外摸了一遍从最初“以为把变量勾上Replicated就等于同步了”到后来连断线重连、延迟补偿都做了处理中间踩了不少坑。这篇文章不是官方文档的复述而是把我在实际项目里如何设计Coop框架、怎么拆分GameMode的职责、哪些东西放服务器算、哪些特效放客户端播以及联机调试时的完整链路都梳理一遍希望能帮正在做类似需求的人少走点弯路。1. 动工之前先把同步模型想明白谁说了算谁说了不算很多人上手UE5多人项目第一件事就是打开设置找“多人游戏”选项然后想当然地认为网络同步就是把变量复制一下。实际上多人联机最难的部分从来不是“怎么复制”而是“该听谁的”——也就是权威性Authority的归属问题。这个想不明白后面写出来的代码一定是一团乱麻。1.1 服务器权威与客户端预测Coop模式到底选哪种UE5里主要的同步模型有两种思路服务器权威Server Authoritative和客户端权威Client Authoritative。服务器权威的意思是游戏世界里一切关键状态的计算都发生在服务器上客户端只是“发出请求”和“展示结果”。玩家按一下W键客户端不会直接改自己的坐标而是把这个输入发给服务器服务器算出新位置、再广播给所有人。这样做的优点很明显——服务器是唯一的真相来源作弊者没有办法在本地偷偷改血量或坐标因为服务器根本不认。缺点是服务器压力大而且纯粹采用这种方案玩家操作会有可见延迟因为每次移动都要等服务器回包。客户端权威则是反过来客户端自己算好结果告诉服务器“我在哪、我干了什么”服务器只负责转发给大家。这样做操作响应极快但信任成本极高一个恶意客户端可以把自己改成无敌PVP游戏基本不可能接受。对Coop合作打怪这类PVE项目我强烈建议走服务器权威为主、客户端预测为辅的路线。原因很简单怪物AI、掉落、任务进度这类核心数据必须全网一致不能A玩家那边怪死了、B玩家这边怪还在打空气。而玩家的移动和操作手感又需要用客户端预测来保证“按下去就跑”不至于像拖着铅块在走路。这里说的客户端预测具体到实现上就是UE5引擎内置的移动组件已经做了的事情客户端先本地模拟移动并上屏同时把输入同步给服务器服务器重新计算结果如果和客户端模拟的一致就默认通过不一致则以服务器为准做纠正Resimulate。所以我们写Coop代码时玩家Pawn的移动不需要自己处理预测引擎的CharacterMovementComponent已经包办了。我们要操心的是自定义玩法逻辑的权威性——比如谁来决定一个敌人受到了多少伤害。1.2 我遇到过的一个典型误区所有东西都在服务器算就好了这是另一个极端。有些同学看了服务器权威的概念干脆把所有逻辑都放服务器连一个爆炸特效都要服务器算完后通过RPC广播到每个客户端。这样倒是权威了但服务器负载直线上升而且很多效果明明不需要精确同步、只需要“看着像那么回事”纯属浪费带宽。我在设计双人Coop时给自己定了一条原则影响游戏结果的逻辑放服务器影响观感的逻辑放客户端。比如敌人血量、掉落物归属、关卡进度这些必须在服务器算但击中时的火花、命中音效、UI飘字这类表现层东西可以客户端自己播或者由服务器发一个“可以播了”的信号让收到信号的客户端各自处理。性能、带宽、手感和权威性能得到一个很好的平衡。2. 搭一套能跑起来的多人Coop骨架项目设置与启动流程明确完权威模型就该动手搭工程了。这一步看着简单但我在上面栽过跟头不是所有UE5模板默认就带多人配置的很多模板连Online Subsystem都没启用更别说会话Session流程了。2.1 项目配置与联网插件先把基础打好如果你是用Blueprint或C模板新建的第三人称项目第一步要确认两件事项目是否启用了Online Subsystem相关插件以及默认地图和GameMode的映射是否正确。对Coop联机我优先用局域网LAN模式做开发和验收因为局域网环境能保证包传输的最基本可靠性排查问题最干净。UE5默认的OnlineSubsystemLocal就是专为这种本地调试准备的不需要额外服务器。如果你想走Steam或EPIC在线联机需要额外安装对应的OnlineSubsystemSteam插件并配置AppID但这个不是本文重点后面有机会单独写。打开项目的Build.csC工程或.uproject蓝图工程确认有没有以下模块C写法蓝图工程只需要在插件面板里确认OnlineSubsystem和OnlineSubsystemUtils已启用PublicDependencyModuleNames.AddRange(new string[] { Core, CoreUObject, Engine, InputCore, HeadMountedDisplay, OnlineSubsystem, OnlineSubsystemUtils, AdvancedSessions // 如果你装了进阶会话插件 });很多开发者在联机后发现找不到会话或者搜索不到房间八成就是OnlineSubsystem没启用或者DefaultEngine.ini里没指明用哪个在线子系统。在.ini里加上这两行能避免很多玄学问题[/Script/Engine.GameSession] bIsLANMatchTrue [OnlineSubsystem] DefaultPlatformServiceNullDefaultPlatformServiceNull的意思就是走纯本地/局域网不加任何平台SDK。注意这只是开发期最快路径真要打包发布走Steam或EPIC平台时这套配置是要换的。2.2 主菜单到游戏关卡的会话流程Create Session、Find Session和Join SessionCoop游戏最关键的前置流程是一个玩家创建房间另一个玩家搜索并加入。这一步搞不定后面场景同步做得再漂亮也没用。我在项目里用的是蓝图里常见的“会话流程三件套”Create Session、Find Sessions、Join Session。先说Create Session创建会话。当玩家点击“创建房间”时需要先通过CreateSession节点发起请求回调事件OnCreateSessionComplete里判断是否成功。成功之后再用OpenLevel切换目标关卡这里有一个细节目标关卡名要填地图的短包名Short Package Name比如/Game/Maps/CoopMap的短名是CoopMap服务器端用这个方式带人进入正式游戏场景。Find Sessions查找会话是客户端这边的操作。Find Sessions节点会在局域网上广播“有没有可加入的房间”回调OnFindSessionsComplete得到一个结果数组通常用SessionName和PingInMs展示到UI列表里。这里有个容易踩的坑搜索结果数组只在回调执行的那一帧有效如果你把结果存下来打算下一帧再用大概率访问到的是无效数据。正确做法是在回调里直接把UI列表填充好。Join Session加入会话就是点选房间后执行的动作。需要把搜索到的SessionSearchResult当作参数传入JoinSession接口回调成功后再执行ClientTravel客户端旅行而不是OpenLevel。这一步特别重要服务器切关卡是用OpenLevel客户端切关卡必须用ClientTravel否则只是本地场景切了实际上没连上服务器的那个世界。我当时就卡在这里很久两个客户端都各自进入了Coop场景但彼此看不见对方。后来才意识到客户端加入游戏必须通过服务器转跳而不是自己本地开地图。这个坑值得单独提出来因为官方样例里往往不会把这步说得这么直白。3. 游戏流程的同步骨架GameMode、GameState、PlayerState各干各的活UE5里这几个类的名字长得都很像作用却完全不同。很多人写多人项目最常犯的错误就是把游戏分数直接挂在GameMode上供全端读取却发现客户端永远读不到——因为GameMode只在服务器上存在压根不会同步。我在Coop项目里把这三兄弟的职责划分得很清楚这个划分是整个多人架构的地基。3.1 游戏模式GameMode只在服务器上存在的“导演”GameMode是UE5里纯服务器概念客户端世界里没有它的实例。它负责的事情包括游戏规则、玩家出生点分配、关卡开始与结束的流程控制、PlayState的跳转。因为只有服务器上有所以它天然可以当作“绝对真相”来用比如你写文件存档、改全局状态放这里不会被作弊者干预。在我的Coop项目里GameMode主要负责两件事一是管理波次Wave敌人刷新逻辑二是处理游戏结束判定。波次逻辑其实不应该每帧去监听玩家数量而是用Timer或事件驱动的模式从关卡开始的第一波怪到打完所有怪后触发下一波再在GameState上更新波次编号。这里有个常用的写法敌人全部死亡时AIController会自动OnDestroyed触发委托GameMode可以用Actor.OnDestroyed监听所有敌对的AI控制器计数归零就说明这波打完了。这样就不需要每帧轮询GetAllActorsOfClass去数剩余敌人性能也稳得多。3.2 游戏状态GameState所有客户端共享的“记分牌”GameState才是真正会复制给所有客户端的存在适合存放全房共享的游戏数据波次编号、总击杀数、倒计时、游戏是否暂停等。每个客户端都有一份当前GameState的快照服务器修改GameState上的复制变量后所有客户端会自动收到更新。Coop场景里最典型的用法是把“房间进度”放这。比如波次数字我用的是FOnRep机制服务器改ServerWaveNumber变量后各个客户端在OnRep回调里刷新UI和音效提示。这里要提醒一下GameState上的复制变量虽然会在值改变时自动下发但如果你在不改值的情况下调用ForceNetUpdate客户端会觉得“咦这个值没变啊不用刷新”所以如果只是想广播“重新显示当前进度”这种信号还是用Multicast RPC更稳妥。3.3 玩家状态PlayerState每个玩家的私人名片PlayerState是每个玩家一份、只复制给所有人的状态容器专门用来同步单个玩家的私有信息名字、血量百分比、击杀数、是否死亡状态等。注意它和Pawn的区别Pawn会在角色死亡后销毁或重建PlayerState却贯穿整个会话即使玩家换了Pawn分数和名字也不会丢。我在Coop项目里把玩家的“战斗状态”全部放PlayerState当前血量、是否倒地、倒地倒计时、连击数。也踩过一个坑PlayerState的复制变量默认只在NetUpdateFrequency间隔内广播如果有“临死前最后一击”这种高频事件靠复制变量播报是有延迟的。正确方式是变量做状态存储真正紧急的血量归零事件用RPC立刻通知客户端变量只是作为后续数据兜底。说白了这个骨架就是一句话GameMode负责“游戏规则发生什么”GameState负责“全房间进度是什么”PlayerState负责“每个玩家自己是谁”。想通了这一点后面写任何多人逻辑都不容易乱。4. 让变量和事件真正“同步”复制、RPC与可靠性选择到了实际写代码的环节最核心的同步工具无非就两样。第一件是属性复制Property Replication自动把变量从服务器同步到客户端第二件是RPC远程过程调用直接在客户端或服务器之间调用函数。这两件事看似基础但细节深了之后很容易玩脱。下面我把使用场景和我的选型经验拆开聊。4.1 属性复制是选择Replicated还是RepNotify还是ReplicatedInitialOnly在UE5的Actor类里声明一个同步属性通常是这样的C示例蓝图里就是右键变量选择ReplicationUPROPERTY(Replicated) float Health; UPROPERTY(ReplicatedUsing OnRep_Health) int32 KillCount; void OnRep_Health(); void GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); DOREPLIFETIME(AMyPlayerState, Health); DOREPLIFETIME(AMyPlayerState, KillCount); }复制模式有几种None不会同步纯服务器或纯客户端数据。Replicated值变了自动同步到所有客户端。ReplicatedUsingRepNotify同步且值变化时在每个客户端都触发一次对应的OnRep_函数适合用来刷新UI或播放特效。ReplicatedInitialOnly只在Actor首次生成的快照里同步一次之后就不再同步适合放“名字、ID、颜色”这类不变的数据。我自己的经验是会影响显示或逻辑的值一律用RepNotify。因为如果你只用Replicated客户端虽然内部值更新了但没有一个直观的时机去触发动画、音效、UI刷新你还得自己写轮询很麻烦。RepNotify相当于给了你一个“值变了”的现成回调省心很多。4.2 RPC三兄弟Server、Client、Multicast到底怎么选RPC是UE5里最容易被滥用的同步手段。按执行位置区分有三种类型执行位置典型用途Server RPC由客户端调起在服务器上执行玩家请求攻击、使用道具Client RPC由服务器调起在指定客户端上执行给某玩家显示私有提示比如“只有你能捡这个宝物”Multicast RPC多播由服务器调起在所有客户端执行广播全局事件爆炸、怪物刷新、关卡胜利还有一个很容易忽略的点RPC分可靠Reliable和不可靠Unreliable。可靠RPC保证消息一定会送达适合重要事件攻击判定结果、给予道具但带宽占用高不可靠RPC允许丢包适合高频视觉特效、音频、全息投影这类丢了也能恢复观感的东西。我在Coop项目里有一个血的教训把“敌人死亡爆炸”做成了Reliable Multicast结果在高强度波次下大量敌人同时爆炸时服务器带宽被瞬间打满客户端全部卡顿。后来改成Unreliable Multicast表现上几乎没有差异网络占用却降了很多。凡是可以接受丢包的视觉效果就永远不要用可靠RPC这是多人项目性能优化的第一课。4.3 一个具体栗子扣血逻辑的同步设计我拿一个最简单的例子串联一下这些概念玩家A被怪物B攻击血量下降。完整链条是这样的怪物AI在服务器上执行攻击检测命中PlayerA的胶囊体计算出伤害值。服务器修改PlayerA的PlayerState里的Health复制变量被RepNotify监听。所有客户端的OnRep_Health回调同步触发UI血条更新血条闪烁动画普遍播放。命中特效用Unreliable Multicast广播各个客户端自行播放特效不需要等服务器确认。如果这次攻击直接让玩家倒地则用一个Reliable Client RPC唤醒这个玩家的倒地界面因为倒地状态必须百分百送达否则玩家会看到自己死了又没死很扫兴。这套设计的好处是核心数值走复制变量UI变化完全由RepNotify驱动特效走不可靠多播状态突变走可靠RPC。既保证了权威性又照顾了性能和可靠性是我目前觉得最平衡的方案。5. 敌人刷新、拾取和伤害判定Coop模式里最容易同步崩坏的三件事Coop模式跟竞技模式的一个显著区别是所有玩家在对抗同一个世界世界里的一切细节都要全网一致。而这里最容易出bug的三个点恰好就是敌人刷新、道具拾取和伤害判定。我一个个说。5.1 敌人刷新的服务器权威不要在客户端生成怪很多教程让你在游戏开始时直接BeginPlay里SpawnActor怪就行但多人环境里这个做法是灾难。如果每个客户端都在自己本地刷怪那每个客户端看到的怪位置、数量、状态都不一样整个世界分崩离析。正确的做法是敌人一定要由服务器生成。服务器在GameMode的波次流程里调用SpawnActor生成的Actor默认会同步到其他客户端。这里有两个细节一是SpawnActor后需要立即确认它的复制开关最好在敌人的构造函数里就SetReplicates(true)这样Actor一生成就带复制信息二是怪物身上的AI逻辑必须在服务器上跑客户端只需要接收怪物的位置和旋转同步即可。这里还牵扯到一个常见的困惑AI Controller属于服务器端还是客户端答案很简单AI Controller是服务器上跑逻辑的“脑”客户端只有怪物的骨骼网格体和一个“假动作”播放器。客户端不需要具备AI逻辑所以NavMesh寻路也只需要在服务器上烘焙即可。如果你在客户端场景里看到“没有寻路”那是正常的因为客户端的怪本来就是跟着服务器的移动同步走。5.2 道具拾取的同步服务器判定归属客户端做表现Coop项目的拾取逻辑比单机复杂得多因为牵扯到“谁先捡到”的问题。两个玩家同时去捡一个加血球系统得有一个明确的归属判定否则就会出现A捡了B包里也多了的重复刷道具Bug。我的设计逻辑是这样拾取物本身是服务器Actor当玩家靠近时服务器检测碰撞先锁定拾取者的连接Connection再给服务器提交一个“拾取请求”。服务器检查这个拾取物是否还有效有效就扣除它的数量或直接销毁同时在服务器上给对应玩家加血、加弹药随后广播一个不可靠多播来播放拾取动画和音效。这里要特别强调的是“锁定归属”这个步骤。如果不做归属锁定两个客户端同一个时间点发来拾取请求服务器会同时处理道具就会被复制两次。我在自己项目里用的是一个布尔值加上Server_SetPickupOwner先锁定再执行实际效果的流程这样即使两个客户端都有延迟也不会重复给道具。5.3 伤害判定的坑客户端打到了服务器为什么不认Coop里最让人抓狂的Bug是客户端画面上明明打中了敌人血条却不掉或者敌人纹丝不动。原因是攻击判定如果用客户端射线检测那个结果只存在于那个客户端自己的世界里服务器并不了解。所以正确的方式是客户端发出“我要攻击”的请求服务器在权威世界里重新做一条射线。但这里就会有延迟问题客户端看到自己举起枪瞄准了敌人、开枪可服务器可能还没有更新到那一帧的位置射线就会打空。处理这个问题的常用手段有两个一个是基于客户端位置的服务器端验证一个是基于快照的延迟补偿。大多数Coop项目不需要太极端的延迟补偿大家不是打电竞比赛有一个简单可用的实现是客户端把射击起点和方向发给服务器服务器在该帧做一次LineTrace这个射线可以容忍很小的时间误差够用就可以。我在开发时发现另一个细节客户端的射线起点和服务器端的位置不一致。原因是客户端相机上挂着后坐力动画、镜头抖动导致射线起点偏移。后来我在服务器验证时用的起点是“Pawn的武器发射点”而不是玩家屏幕中心点的相机方向问题立刻缓解了。这个经验如果你遇到“打不中”却很困惑时可以优先排查。6. 联机调试链路与把我卡住过的报错多人项目比单机项目难调试一百倍因为你不能只盯着自己的画面。UE5提供了一些非常实用的调试手段我用下面这套流程基本能定位90%的同步问题。6.1 多开与断点调试编辑器PIE的正确打开方式UE5编辑器里点击Play按钮下拉选项里有一个Number of Players。默认是1改成2或3就能在本地模拟多人联机。选好Net Mode如果你把Play Net Mode设为Play As Listen Server那么第一个窗口就是服务器其他窗口是客户端这样不用部署任何远程服务器就能调试局域网和多人逻辑。注意这里的坑PIE多开时不同窗口的World是不同实例有些静态变量和单例模式要在GetWorld()-GetFirstPlayerController()层面去取千万不要用全局静态变量存玩家引用否则你会看到一个客户端改数据所有窗口一起变调试直接乱套。我还会在蓝图或C里写一些“只在服务器上跑的日志”比如if (HasAuthority()) { UE_LOG(LogTemp, Warning, TEXT([SERVER] Player Health: %f), Health); }这样才能区分服务器和客户端各自看到的数值避免对着屏幕抓耳挠腮。6.2 模拟延迟、丢包与带宽观测多人问题很多要到“有网络延迟”的环境才暴露。UE5控制台命令可以很方便地模拟糟糕网络net PktLag300给网络包模拟300毫秒延迟。net PktLoss10模拟百分之十的丢包率。net PktDup5模拟百分之五的重复包。stat net打开网络统计面板观察带宽使用、复制频率和延迟情况。我在开发Coop时就是靠着net PktLag300提前发现了拾取重复的问题客户端A点了拾取因为延迟高服务器还没处理完客户端又发了一次请求导致道具被复制。有了这个模拟我才抱着头部调试出归属锁定的需求。强烈建议每个多人项目在开发早期就养成交互式模拟网络环境的习惯。6.3 让我卡住过的编辑器报错LowLevelFatalError和MSB3073经常有同行问我UE5里这两个报错怎么解。其实它们和网络同步本身关系不大但既然热词里出现了我顺手分享下我的排查思路。LowLevelFatalError那个[File:...RenderCore...]往往指向的是渲染资源问题比如创建的贴图尺寸非法、材质资源缺失或网格体没有有效的Section。在多人项目里这类报错经常出现在客户端加载服务器动态生成的Actor时——服务器生成了一个Actor但它引用了一个客户端没加载出来的资产客户端实例化时就崩了。所以遇到客户端闪退优先检查服务器动态生成的Actor的默认资产是否被正确打包。光影、粒子系统的资源引用也要顺着这个思路排查。MSB3073通常是Windows编译过程中的返回码错误常常和VS的路径权限或工具链不一致有关。遇到它我一般先做三件事清理Intermediate和Binaries目录、关闭正在运行的UE5编辑器、确保VS装了正确的“Game Development with C”工作负载。这个报错在纯净工程上很少见一般是工程迁移或电脑环境切换后才会冒出来。还有那个双指触摸蓝图如果你做的是移动端Coop游戏双指触摸的响应建议直接用UE5的Enhanced Input系统绑定两个Action而不是在BeginPlay里手动监听TouchIndex。因为多人联机时触摸事件会经过输入预处理直接监听触摸事件很容易在玩家切换Pawn或摄像机跟随出问题时丢失焦点。这个经验来自我做移动端裸眼3D UI时踩过的一次坑。7. 我的最终建议把同步设计当成关卡设计的一部分梳理到这里你会发现UE5的网络同步其实没有想象中那么神秘。它无非是服务器计算真相客户端表现结果RPC负责事件通知复制变量负责状态传递而Coop模式在此基础上多了一个“所有人都要对世界有一致认知”的额外要求。我在这次双人Coop项目的开发中最大的体会是不要等到代码写了一半才开始想同步。最好是一张地图、一个玩法机制设计出来时就同步思考“这个机制的权威逻辑放哪、表现放哪、哪个客户端会看到什么”。我在初版设计时偷懒把怪物掉落的拾取逻辑先写成单机版后面再补多人同步结果硬是重构了两天。如果一开始就按照“服务器判定归属、客户端播放表现”的模型去写至少能省下一半的时间。最后再分享一个小技巧在多人项目里尽量给每个同步变量和RPC都加一个统一前缀注释写明“谁在哪个端会改这个值”。我用的格式是[S]表示仅服务器可写、[C]表示客户端表现相关、[M]表示多播事件。项目做大后这比任何代码注释都管用看一眼就能判断一条链路是否传对了位置。希望我趟过的这些坑能帮你把你的Coop项目做得更顺畅。
阅读完成 · 觉得有帮助?
咨询建站