“UE5 网络同步”这个词,我第一次真正理解它,不是看文档看懂的,而是在一个Coop小项目里被现实狠狠教育之后才领悟的。当时我做了一个四人合作生存原型:玩家进入游戏,按E开箱子,箱子里掉资源,大家共享一个仓库。编辑器里开两个客户端窗口联机,结果各自按E,各自都能开箱,仓库数值永远只在自己屏幕上变化,队友那边纹丝不动。这个现象让我意识到,UE5的Coop实现,根本不是“把变量传给别人”,而是从底层就把游戏的运作方式从“本地逻辑”彻底切换成“服务器权威、客户端表现”。这篇文章就围绕UE5网络同步的原理和Coop玩法的落地展开,适合那些已经能跑通单机Demo、准备做合作联机但还没真正理解同步机制的开发者。
1. 你先搞清楚:为什么“单机逻辑能跑”不等于“联机能跑”
这是很多初学者踩进去的第一个泥坑。你打开UE5的第三人称模板,里面的人物能走能跳,是因为输入事件直接在你的本地客户端里被PlayerController捕获,然后立刻驱动CharacterMovementComponent,骨骼动画和胶囊体位置几乎在同一帧里响应。这套链路在单机里是自洽的,你觉得什么都是对的。但一旦你把它当成“多人游戏”的原型,问题就出现了:两个客户端都在控制同一个角色,服务器同时收到两份“我要往左走”的指令,那么角色到底听谁的?两个玩家看到的位置不一致,开始互相穿模,甚至各自把角色拖向不同方向。
问题的本质不是引擎出了Bug,而是你的设计模型不对。你还在按单机的方式,把“每个客户端都当成游戏的主机”在写逻辑。网络同步的第一步,不是写代码,是先理解一个反直觉的结论:在UE5的多人体系里,你的客户端并不是“运行游戏的真身”,而只是一个“显示器加遥控器”。
1.1 服务器权威模型到底权威在哪
UE5默认的多人模型,是经典的服务端权威模型(Server Authoritative)。整个游戏世界里只有一台机器承担权威角色,它通常被称作“服务器”或“Authority”,保存着所有游戏状态的最终答案:每个Actor的位置、血量、Buff、任务进度、敌人的存活状态。而所有客户端机器上运行的实例,本质上只是一份远远跟在服务器后面的本地视图。
客户端在这个模型里只做两件事:第一,把你的按键、视角、交互意图打包成指令发给服务器;第二,接收服务器广播下来的状态更新,然后用这些更新来修正自己屏幕上的世界。客户端不会直接决定“这个角色现在站在哪里”,不会直接扣敌人的血量,也不会直接告诉别的玩家“这个Boss死了”。所有能改变游戏结果的决策,都必须发生在服务器或者经过服务器确认后发生。
你可能会问,为什么一定要搞这么复杂?直接让每个客户端自己算自己的,然后用某种方式把结果同步给别人,不是更流畅吗?答案是:如果每个客户端各自判定,你永远无法保证两个玩家的画面里会出现同一个结局。举个最直的例子,射击游戏里你瞄准了一个敌人,你的客户端判定命中了,他的客户端判定自己躲开了,那么谁来裁决?服务器不出面,两个玩家就会各自看到完全不同的战斗结果。Coop游戏虽然玩家在同一阵营、目标是共同的,但对一致性同样敏感——四个人打同一个Boss,如果每个人看到的Boss血量不同,这个合作就没法玩下去。
在UE5里这个权威模型落地得相当彻底:服务器上运行着完整的游戏逻辑,客户端只是通过网络连接不断把本地输入发上去,再不断接收服务器派发下来的“快照”来更新自己的视图。这个流程不需要你手动写网络发送代码,引擎已经把大部分传输细节封装好了,但前提是你必须把Actor和属性按照正确的方式配置成可复制的。
1.2 了解UE5网络连接的最小骨架
在很多教学里,大家一开始就急着配GameMode、写角色、拖节点,但往往漏掉了最基本的框架知识。我建议你在做任何Coop功能之前,先把理解UE5那套“同步班子”里的角色分工弄清楚。这里的核心类有四个,很多人折腾了很久的网络同步问题,最终都归结为把这几个类用错了地方。
- GameMode:只存在于服务器上,不在客户端上运行。它负责规则,比如玩家进场后出生成哪个Pawn、是否允许中途加入、游戏什么时候结束。因为客户端根本没有GameMode实例,你永远不要尝试在客户端读GameMode上的变量。
- GameState:这个是会复制到所有客户端的存在,负责保存整个游戏共享的大局状态,比如任务进度、全队分数、倒计时、Boss当前血量。由于它能被所有人读到,Coop项目里的“共享仓库”“全队目标”就适合放在这里。
- PlayerState:它和具体玩家绑定,也会复制到所有客户端,负责保存单个玩家的状态,比如昵称、等级、击杀数、个人积分。注意它跟Pawn是解耦的,玩家换Pawn之后PlayerState依旧保留。
- PlayerController:这是玩家在服务器上的“延长手臂”,它在客户端上也有一份实例,用来捕获输入和把命令提交给服务器。常规操作是,客户端输入交互键,PlayerController调用服务器上的函数来执行真正的逻辑。
明白这层分工之后,为什么某些变量“不同步”就有了解释。比如你把任务进度存在了GameMode的自定义变量里,然后试图在客户端UI上读取,结果UI永远是默认值——因为GameMode压根就不在客户端机器上。我做Coop项目时的一个习惯是:开工之前先画一张表格,明确哪一个数据放到GameState,哪一个放到PlayerState,哪一个只属于本地表现层。这一步比写代码更重要。
2. 三类“传输工具”的分工:属性同步、RPC与Actor复制
了解完框架,下一步要知道的是UE5如何把你配置的属性变成“跨机器传输的数据”。UE5的同步工具箱里,最常用的就是三个:Actor复制(Actor Replication)、属性同步(Property Replication)和RPC(远程过程调用)。它们看起来都是在搬数据,但搬的东西和时机完全不同,很多Coop实现里的灵异现象,其实就是用错了工具。
2.1 属性同步:只管定期的状态广播
属性同步是UE5网络同步里最基础、最常用的一张网。它简单来说就是:把某个Actor上的某个变量,从服务器定期广播到所有关注了这个Actor的客户端。所谓“关注”,通常是两个人之间的距离足够近,并且这个Actor被配置为可以复制。
要让一个变量被同步,在C++里要满足三个条件,缺一不可:
- 变量声明处加上
UPROPERTY(Replicated)标记; - 在类的
GetLifetimeReplicatedProps()里用DOREPLIFETIME(类名, 变量名)做注册; - 该Actor自身的
bReplicates必须设置为true。
我见过最典型的翻车现场:某A同学给变量加了Replicated标记,也把Actor的bReplicates选了真,就是没写GetLifetimeReplicatedProps,结果怎么测都不同步。这个注册步骤相当于告诉引擎“这个变量你要管理它”,漏了这一步,引擎根本不会去管它。还有一个进阶选项是ReplicatedUsing,它可以指定一个函数(通常是OnRep_xxx),当客户端收到新值时自动调用,这几乎是做UI刷新和动画反馈的标准姿势。
在蓝图里做属性同步虽然也能实现,但对复杂结构和网同步细节的控制不如C++。我通常用C++写数据层和复制逻辑,用蓝图只做表现和抽象逻辑。如果你纯蓝图做,记得在Class Settings里打开Replicates,变量面板勾上Replicate,然后在蓝图里手动处理OnRep事件。
属性同步的本质是“状态”,它定期同步,不是每帧都保证绝对即时。它对血量、任务进度、仓库物品这类“持续存在的数据”最合适。
2.2 RPC:一条单独的指令,一次立刻的执行
RPC和属性同步的最大区别在于:它同步的不是状态,而是“我要你立刻做一件什么事”。RPC分三种类型,每一种的执行路径都不一样,理解这一点是整个Coop实现中容易出大坑的地方。
- Server RPC:由客户端发起,服务器执行。典型用途是客户端告诉服务器“我想开这个箱子”“我想对那个敌人造成伤害”。调用者必须是这个Actor的拥有者客户端,执行者是服务器。
- Client RPC:由服务器发起,只会发送给这个Actor的拥有者客户端。典型用途是给那个客户端独自下发一条反馈,比如“你的屏幕要震动一下”。
- Multicast RPC:由服务器发起,广播给所有客户端。典型用途是播放全场景都能看到的特效、播报“全队任务完成”。
这里有一个经常被误解的点:在服务器上执行某个函数,并不代表所有客户端都会自动执行。如果你在服务器上直接调用了某个角色蓝图里的“播放死亡动画”函数,那只会发生在服务器上,客户端完全不知道。要让所有客户端看到死亡动画,通常得用Multicast RPC把“播放动画”这个事件广播出去,或者准备好复制的变量让客户端通过控制依赖的响应逻辑自行推导。
RPC是“命令”不是“状态”。命令适合一次性的事件,比如打开门、扔手雷、召唤一个特效;持续存在的状态则适合属性同步。你把开门的布尔值用属性同步去做是对的,但如果你让每个客户端拉一下门把手这种瞬间交互也走属性同步,会有明显的延迟和不一致。反过来,如果某个变量需要在所有客户端长期保持一致,你也不应该用RPC每帧去广播,那是滥用。
2.3 该用谁?一张对比表
我后来给团队做网络同步模块时,会要求所有人先对着这张表做选择题,再动手写代码,省掉了大量返工。
| 维度 | 属性同步 | RPC | Actor复制 |
|---|---|---|---|
| 同步内容 | 状态/数值 | 事件/行为 | 生成与销毁、位置更新 |
| 传播方向 | 服务器到所有关注客户端 | Server上发、Client下收、Multicast广播 | 服务器到关注客户端 |
| 时效性 | 定期更新,有间隔 | 调用即发送,单次执行 | 按复制频率和优先级 |
| 典型场景 | 血条、任务进度 | 开枪、开关交互、播放动画事件 | 敌人出生、物品生成 |
| 失败特征 | 值总是旧值 | 事件没触发或触发错对象 | 那边看不到这个Actor |
这张表不是说某个功能只能固定用某个工具,而是引导你想清楚:我要同步的是“一个状态”还是“一个动作”?如果是一个状态,优先考虑属性同步;如果是一个动作,优先考虑RPC。如果是一个新的对象从无到有地出现在世界里,那你需要保证这个Actor是在服务器上生成并且可以被复制,这就是第三种工具Actor复制。
Actor复制还包含一个容易被忽略的部分:Actor自身移动和目标位置(ReplicatedMovement)的同步。UE5里普通Character的移动不需要你手动同步骨骼位置,因为CharacterMovementComponent会在服务器上结算移动,然后把位置和旋转复制给模拟代理(Simulated Proxy)客户端。换句话说,你在C/S架构下做Coop,想让队友看到这个角色在跑,依赖的其实是这个内置的移动复制,而不是你自己用属性同步去传位置。
3. 搭建Coop项目骨架:从GameMode到共享目标
理论聊完,现在进入实际搭建。这个部分我用一个典型的Coop合作任务项目来说明:四名玩家进入地图,目标是在倒计时结束前收集三个充能核心并塞进终端机,同时抵御不断刷新的怪物。这个玩法能覆盖大多数Coop项目的核心问题:玩家生成、共享任务进度、可复制的角色、敌人生成、伤害同步。
3.1 自定义GameMode与GameState:先定好哪些数据属于所有人
第一步是创建一个自定义GameMode,并设置几个关键类:Default Pawn Class用你的自定义角色蓝图,Player Controller Class用能处理输入和服务器交互的控制器,Game State Class指向自定义GameState。注意,你配置GameMode时,这些类必须在项目设置里被认作有效的网络类,否则进入多人模式后会出现“玩家生成了但角色不对”的怪问题。
自定义GameMode内部真正执行的逻辑,一般是“玩家进入游戏时干什么”。默认情况下,每个客户端连入服务器,服务器上的GameMode会为它创建一个PlayerController,然后在某个PlayerStart出生点生成Pawn。Coop模式下,如果有多名玩家,你需要在地图里放多个PlayerStart,否则所有玩家会叠在同一个出生点。更讲究一点的做法,是生成前根据已有玩家数量或队伍分配做出生点选择,保证队友分开出生、或者根据合作策略靠近出生。
而共享任务进度这种“所有人的世界状态”,一定要放在GameState里。比如充能核心收集了多少个,倒计时还剩多少秒,Boss是否进入狂暴阶段。因为这些变量需要复制到每一个客户端,而GameState天然就是复制给所有人看的。我碰到过有的项目为了省事,把这些变量写在某个玩家的PlayerState里,导致只有那个玩家自己的UI能刷新,队友界面永远显示零。把“大家的”和“我的”分清楚,是Coop项目设计数据结构的第一步。
在GameState里定义这些共享变量时,记得加上Replicated标记并注册。当你把某个值改掉后,引擎会自动把新值推给所有客户端,客户端通过OnRep函数刷新UI。这整套机制下来,你会发现其实没有多少“发送”的代码,你只需要正确修改服务器上的状态,客户端就会自动跟上。
3.2 让角色和玩家状态进入多人模式
接下来是角色。你新建一个继承自Character的类,第一件事就是把它的bReplicates设为true。这一步决定了引擎要不要把它的生成、移动、属性同步纳入网络管理。很多只用单机模板的人会忽略这一步,结果就是客户端本地生成了角色,服务器和另一个客户端完全看不到它。
Character内置的移动组件会在服务器上跑物理模拟,并自动把移动信息复制给客户端,所以队友视角里你的角色自然地走动跳跃,不需要额外做位置同步。但有一点要注意:你如果是靠蓝图里的Tick或者Late Update来手动修改角色位置,那会绕过移动组件的复制路径,最终出现严重的位置抖动和“自己动队友不动”的问题。正确做法是,绝不要直接在服务器或客户端上直接SetActorLocation来移动一个由MovementComponent控制的角色,除非你面临的是传送门这类特殊情况。
然后是PlayerState。Coop项目里一个常见需求是“队友能看到我的血量和状态”。这个问题有两种放法:把血量放在PlayerState上,因为它复制到所有客户端,所有玩家都能读;也可以放在Character的Replicated属性上,但这种情况下只有“关注”了这个角色的客户端才会拿到血量更新。如果你的UI需要显示所有队友的血条,建议放到PlayerState上。
初始玩家状态还有一个坑:如果玩家中途重新生成新Pawn,PlayerState里的数据依然保留,所以它天然适合保存“玩家会话级别”的积分和朋友码。如果只是单纯的角色血量,放在Pawn上没问题,Pawn死了换新Pawn血量自然重置。做Coop时你得想清楚:这个数据是跟着“玩家这个人”走,还是跟着“当前这个角色身体”走?想反了,后面就会出现“死亡重生后数据错乱”的问题。
3.3 服务器上生成敌人与共享任务进度
Coop项目里的敌人生成,必须要在服务器上执行。这个理解的深度大概决定了你后面Debug的时间。很多初学者会在客户端蓝图里调用SpawnActorFromClass生成敌人,然后发现这个敌人只出现在自己的世界里,队友那边一片和平。原因很简单:生成Actor这件事,本质上是往游戏世界塞入一个对象,为了让所有客户端的世界一致,生成权只能属于服务器。
因此在GameMode里写一个SpawnEnemy函数,这个函数只在服务器上被调用。生成完成后,因为敌人Actor已经设置了bReplicates = true,服务器会通过Actor复制机制通知所有客户端“这里多了一个新的敌人Actor”。客户端不需要自己再去生成,引擎会自动创建对应的Actor实例并同步它的初始状态。
这里我建议给每个刷出来的敌人身上加一个唯一的ID字段,并且把ID同步给客户端。为什么要这么做?因为当你要记录“哪个敌人已经被击杀”时,单纯同步一个指向敌人的对象引用在蓝图里不好调试,容易出现引用失效、空指针等问题。我们把ID当作钥匙,把一个容器数组放进GameState,每次服务器成功击杀一个敌人就把这个ID移除或标记为已击杀,客户端看到 GameState 里这个ID变化,就知道该更新任务计数、UI刷“消灭怪物x/10”的进度条。
共享任务进度的另一种常见做法,不是直接同步一个数组对象,而是同步一个“任务节点状态结构”。例如终端的三个充能核心,分别用ABO三个布尔值表示“是否已插入”,或者用一个整数表示“当前已收集数量”。改起来更简单,也很好做UI绑定。不管哪种方式,核心原则都是一样的:共享世界状态只能存在于GameState这类“大家都能看到的、服务器说了算的地方”。
3.4 伤害与击杀结算的网络归属
Coop里的战斗同步,是最容易产生诡异Bug的地带。一个比较典型的设计错误是:客户端检测到自己开枪打中敌人,就直接在本地扣敌人血量、播放死亡动画。这块的逻辑如果放在客户端执行,结果就是射击者看到敌人已经死了,队友那边敌人还活蹦乱跳,甚至继续攻击。服务器必须成为唯一负责伤害结算的存在。
正确的网络链路应该长这样:
- 客户端里,你的枪械系统检测到命中,发起一个Server RPC,把“我打到目标了,命中点在这里,用的武器是什么”发给服务器。
- 服务器收到后执行伤害计算和合法性校验:这个弹道是否可信、命中的目标是否真的在有效范围内、武器伤害数值是多少。这里的“合法性校验”可以一两步完成,不必每帧每棵草都校,但至少得判断够不够打得到。
- 服务器扣除目标的血量,并把这笔新血量通过属性同步广播到所有客户端的OnRep,UI血条随之刷新。
- 服务器再调用一个Multicast RPC,向所有人广播“播放受击特效、喷血、播放音效”这类表现层事件。
你可能会觉得,先扣血再广播表现有点慢,但真实体验上,只要这两步间隔控制在几帧内,玩家根本察觉不到。这种“服务器结算、客户端表现”的分工,是Coop项目所有战斗机制的基础。如果你在Boss身上写了一套被砍中就执行、在客户端本地算伤害的代码,那么几乎100%会看到队友视角下Boss的血量跟你的不同步,而且服务器重启后所有状态都回到初始值。
正确做法还有一个优势:后续如果要加更复杂的机制,比如暴击、伤害减免、护盾吸收,因为所有数值计算都在服务器上,你只需要改服务器这一处逻辑,所有玩家客户端表现就会自然跟随,调试起来轻松太多了。
4. 手感问题:延迟模拟、插值与“看起来正常”
网络同步跑到基本功能都通,你以为完事了吗?随时可以进下一步?不对,Coop项目的另一大主题是手感。玩家在自己的客户端上操作时,如果每一帧操作都要等待服务器返回最新位置才让角色移动,那么任何一点网络延迟都会让游戏变得像踩在泥地里一样难受。这也是为什么UE5的移动组件自带客户端预测。
4.1 客户端预测和权威校验的拉扯
所谓客户端预测,指的是你的角色在你的客户端上先立即响应输入,不需要等待服务器把更新传回来,给人一种“我的操作马上生效”的感觉。服务器还是会接收你的输入、重新模拟移动,然后把权威位置同步回所有客户端。如果你的预测和服务器最终模拟结果有偏差,引擎会把你的角色轻轻“拉”回服务器的正确位置,这个过程就是网校同步里常说的回滚(Reconciliation)。
正因为如此,你常常会看到联机游戏里自己的角色很跟手,但队友的角色总会有半秒左右的延迟——因为队友的位置是经过服务器收指令、模拟、再广播到你这边的。这是网络现实的物理规律,不是Bug。如果你的Coop角色使用了CharacterMovementComponent,这套预测机制是默认开启的,运行时会区分三种角色复制状态:
- Autonomous Proxy:你本地控制的Pawn,输入立刻生效,有预测。
- Simulated Proxy:其他玩家控制的Pawn,在你这边只做插值和模拟,不做预测。
- Authority:服务器上的Pawn实例,拥有最终结算权。
这里我可以提一个新手容易犯的错:你想做自定义交互,比如按E开门,如果直接在客户端蓝图里让门瞬间打开,那你的体验是顺畅的,但服务器和其他客户端不会同步。如果你走Server RPC让服务器把关打开再广播给所有人,那么客户端按下E后要等待往返延迟,门才会动,手感就“钝”了。要兼顾手感和一致,对门这类交互,可以做一个客户端预测开门(本地先立刻表现)、服务器确认(服务器把门状态同步回所有客户端再校正)。这部分做起来比纯移动复杂得多,属于进阶话题,但至少你要意识到:手感和一致性之间存在平衡,没有毫无代价的完美方案。
4.2 用控制台命令强制体验高延迟场景
做Coop联机调试时,最怕的就是两个客户端都在同一台开发机上跑,局域网几乎零延迟,所有问题都被掩盖。等真正部署到公网或复杂网络时,各种延迟引发的怪现象才浮出水面。我建议从第一天开始就习惯使用UE5的延迟模拟控制台命令,强制让自己处于“高延迟环境”里测试。
在客户端控制台里执行:
Net PktLag=100 Net PktLagVariance=50第一个参数让网络包人为延迟100毫秒,第二个参数加一点随机抖动,模拟真实网络的不稳定性。调试时可以逐步加大到200甚至300毫秒,去观察角色移动、交互开门、血条刷新这些表现。很多“同步很正常”的错觉都是因为没加延迟测试,加上之后立刻暴露出OnRep刷新不及时、UI闪烁、位置拉扯的问题。
如果你要测试完整的独立服务器模式,可以用命令行参数启动。服务器实例:
UE5Editor.exe /Game/Maps/YourCoopMap?listen -server -log客户端实例:
UE5Editor.exe /Game/Maps/YourCoopMap -game -log如果只用编辑器,也可以用Play的多人选项创建两个Network Client。但无论如何,网络模拟命令必须设置为测试过程的一部分。我的习惯是每完成一个Coop功能,就加大延迟跑一遍核心流程,确保体验在“有点卡但并不脏乱”的范围内。
4.3 观察复制的几种工具和日志
当你觉得“好像有问题又不知道问题在哪”时,就该用工具而不是猜了。UE5的编辑器里,会话前端(Session Frontend)可以连接到各个游戏实例,Network Profiler能帮你看到每个Actor的复制流量、属性和RPC的发送情况。如果你连不上网络分析器,最简单的是用Log命令过滤角色蓝图里的打印信息,判断某些函数是在服务器上执行还是在客户端上执行。
比如,一个常见的做法是在OnRep_Health里打印Health = X, GetLocalRole() = Y,用GetLocalRole()判断当前执行环境是Authority还是Simulated Proxy。通过网络日志的过滤,你可以粗略看到服务器更新流的快照和客户端收到的复制列表。对一个Coop小项目来说,不一定要上特别重的分析工具,但你至少应该能在任何变量处打印出它的复制状态,这是排查问题的基础能力。
5. Coop联调中的常见现场:现象、原因与排查链路
最后分享几个我在做Coop项目时实际遇到、且大概率你也会遇到的故障。有些人把它们当成“引擎Bug”,其实绝大多数都是同步配置或逻辑归属问题。
5.1 现象A:队友看到的怪不会死
你自己开了几枪,本端特效也播放了,敌人倒下了,但队友那边敌人还站着,甚至AI还在移动。这个现象的根源,几乎可以锁定在“伤害结算没有经由服务器完成”。最常见的原因:你在客户端上直接扣了血量、直接调用了死亡动画、直接销毁了敌人Actor。而这些操作全部只在本地客户端的世界里生效,服务器和其他客户端完全没有收到任何变化。
排查链路是:先在射击命中之后加一条Log,用HasAuthority()打印当前是否在服务器上执行。如果命中回调里面打印出来明显是客户端角色在执行,那么继续检查你调用的函数是不是标了Server标记,如果没有,就去找那条调用链。一般来说,修复方式是:客户端发Server RPC上报伤害,服务器结算后通过Actor复制把血量同步出来,死亡动画和销毁用Multicast或在OnRep里处理。
5.2 现象B:客户端销毁了某个Actor,但其他人还看得到
这个坑跟现象A同根同源,但表现相反。你在一台客户端上调用了DestroyActor,那个物体在你这边消失,队友那边依然栩栩如生地挂在原处。原因是:Actor的销毁也必须由服务器发起。如果你是客户端本地销毁的,服务器上它仍然存在,而服务器又定期把“这个Actor还活着”的事实同步给所有人,于是它在你队友的世界里永远存在。
正确做法是:告诉服务器这个Actor应该被销毁,通常通过Server RPC、或者在服务器上判定条件后销毁。服务器销毁后,引擎会广播给所有关注它的客户端,把它们那边的对应Actor清理掉。有时候你还会碰到一种变体:Actor调了Destroy之后客户端迟迟不消失,这往往是因为它仍然被某个复制的引用对象引用着,比如GameState里某个数组还存着指向它的指针。排查时把数组里的引用清理干净,客户端才会正确移除。
5.3 现象C:UI状态和服务器状态对不上
你做了一个共享仓库界面,A玩家放进去一个武器,B玩家立刻应当看到仓库多了一把,但B的界面刷新不及时,或者要重新进入界面才能看到。这个问题大概率不是“没有同步”,而是同步过来之后UI没有订阅更新。你确实用属性同步把仓库列表推给了客户端,但客户端界面只在你打开仓库的一瞬间读取了一次列表,之后列表变了它根本不知道。
在蓝图里,这通常是因为你没有正确处理OnRep事件来刷新UI。我的习惯是:每一个关键复制的属性,都配上对应的OnRep函数,在函数里调用UI的刷新接口。如果是C++,记得注册ReplicatedUsing指向OnRep函数。如果属性同步正确但UI不刷新,九成是漏了这一步。
5.4 一套通用排查链路
当你遇到任何“两个客户端看到的东西不一样”的同步问题时,建议按这个顺序排查,能省掉大量时间:
- 确认这个Actor的
bReplicates为true。如果它是纯本地生成的对象,它永远不会同步。 - 确认你关心同步的属性在
GetLifetimeReplicatedProps里注册过。漏注册就不会有后续。 - 确认你修改这个值的地方在服务器上,不是在某个客户端里直接改。善用
HasAuthority()打印确认。 - 确认客户端收到新值后有没有正确反应。打了
OnRep断点吗?UI订阅函数被调用了吗? - 翻看网络相关日志和Profiler,确认这个属性/fx是否真的通过网络发出了包。
这套链路在我自己的项目里解决过至少九成的“灵异同步Bug”。剩下那一成,通常是蓝图节点误连导致事件压根没被呼叫。
Coop玩法说白了,就是一群玩家在同一个受信任的世界模型里合作。UE5提供的网络同步机制,本质是为了维持这个世界模型的单一权威。我实际开发中的体会是,不要迷信“先写逻辑后再加联机”,网络同步要求你在设计数据结构的时候就考虑所有权:谁拥有这个属性?谁修改它?谁关心它的变化?把这三个问题想清楚了,一半的问题都不会出现。最后再分享一个小习惯——每次新建一个复制的布尔值或数组,先在它旁边画上一笔注释:“Class: GameState, Owner: Server, Listener: All clients”,这对后来的维护和调试帮助极大。