UE Network Role详解:属性复制、RPC权限与联机排查
多人网络游戏做多了你会发现真正把人折磨到半夜的从来不是渲染或者动画而是那些单机上跑得好好的逻辑一连上服务器就变成两个角色、三份特效、客户端改了血量服务器不认。绕不开的一个东西就是 UE 里每个 Actor 都带着的那对属性——Role和RemoteRole也就是常说的Network Role。它本身不参与任何运算也不会显示在屏幕上但它决定了这一刻这台机器上的这个 Actor 到底是“说了算的那个”还是“照着演的那个”决定了哪些代码该跑、哪些包该发、哪些 RPC 会被引擎悄悄丢掉并且只在日志里留一行警告。这篇内容聊的就是这套机制。我会从枚举值讲到复制条件从 RPC 权限矩阵讲到带宽账本最后给一个能直接跑起来的拾取 Demo以及一份我自己用了很久的排查速查表。适合已经写过一些 UE 联机代码、但总在“为什么客户端不生效”“为什么服务器收不到”这类问题上反复卡住的朋友完全没接触过网络同步的新手也能看前面几节会把概念铺平。1. Role 和 RemoteRole 到底在描述什么1.1 三个枚举值对应三种身份Role的类型是ENetRole实际取值里真正会用到的就三个ROLE_Authority、ROLE_AutonomousProxy、ROLE_SimulatedProxy外加一个表示“不存在或不相关”的ROLE_None。ROLE_Authority的含义不是“我是服务器”而是“我对这个 Actor 的状态有最终解释权”ROLE_AutonomousProxy表示“我不是权威但我跟权威之间有一条专属通道我可以本地预测也能往回发指令”ROLE_SimulatedProxy则是纯粹的表现层只能照着收到的数据演没有发言权。这三者不是并列的三种类型而是一条从强到弱的权限阶梯。同一时刻一个 Actor 在不同机器上的Role完全不同服务端看到的是Authority被它拥有的那个客户端看到的是AutonomousProxy其他所有客户端看到的都是SimulatedProxy。很多人第一次接触时以为Role是 Actor 自身的固有属性其实它描述的是“本机与这个 Actor 的关系”换一台机器看同一个 Actor值就变了。生活化的类比一个群里发公告。群主能改公告普通成员只能看而“公告”这份数据本身是一样的。Role就是告诉本机代码“你在这个群里是什么身份”它跟公告内容无关只跟身份有关。1.2 为什么是两个变量而不是一个如果只需要知道“我在这个 Actor 上是什么身份”一个Role就够了。多出来的RemoteRole描述的是“本机所知道的、对端在这个 Actor 上是什么身份”。两者构成一对镜像在服务端Role Authority、RemoteRole AutonomousProxy该 Actor 被某个客户端拥有时或者SimulatedProxy没有客户端拥有时在拥有它的客户端上Role AutonomousProxy、RemoteRole Authority在旁观客户端上Role SimulatedProxy、RemoteRole Authority。为什么要知道对端身份因为很多逻辑的判断依据是“对面能不能改”。比如一个客户端上的道具本地要不要做预测性的表现、要不要等服务器确认后再播放音效、要不要提前把自己隐藏起来这些决策都需要同时知道“我能做什么”和“对面能做什么”。RemoteRole就是为这个场景存在的它是本机视角下对远端能力的建模。还有一种情况容易被忽略独立运行Standalone时Role Authority而RemoteRole ROLE_None。这个组合在单机调试里完全正常但如果你在代码里写了“RemoteRole ROLE_None就认为是客户端”那单机模式会走进一条你根本没测过的分支。1.3 HasAuthority 最容易骗人的地方HasAuthority()的实现就是一句return Role ROLE_Authority;简单到没人会去怀疑它但它有两个非常经典的坑。第一个坑对于没有开启复制的 Actor在客户端上Role依然是Authority因为客户端根本没有从服务器收到过任何角色信息它就是本地创建的一个普通对象当然“自己说了算”。所以你在客户端上对一个本地生成的特效用HasAuthority()判断结果会是true逻辑直接跑偏。第二个坑HasAuthority()不等于“这是服务器”。它只表示“这台机器对这个 Actor 有权威”。在一个纯客户端的本地 UI Actor 上HasAuthority()同样返回true但这个结论对网络同步毫无意义。真正想判断“我是不是服务端进程”应该用GetNetMode() NM_DedicatedServer或者NM_ListenServer两者语义完全不同混用会导致一堆莫名其妙的表现问题。我的习惯是任何涉及网络分支的代码第一行先明确用哪个判断。要问“这个 Actor 的状态归谁管”用HasAuthority()要问“这段逻辑跑在哪个进程角色上”用GetNetMode()要问“这个 Actor 是不是本地玩家在控制”用IsLocallyControlled()。三个问题三种写法写混了一定会出问题。2. Role 的变化时序什么时候才能信任它2.1 从 Spawn 到 BeginPlay 的时序最容易踩的时机问题是在构造函数或者PostInitializeComponents里读Role。构造函数在 CDO 和每次实例化时都会执行那时的网络信息根本没有读出来的值没有任何参考价值。构造函数里唯一该做的事是设置默认值和组件任何依赖Role的分支都应该往外挪。BeginPlay相对安全但也不是绝对的。客户端收到一个 Actor 的复制数据时Role往往在BeginPlay之后才被最终确定下来。引擎在角色信息复制到达时会调用PostNetReceiveRole()这个虚函数你可以重写它来做“角色确定之后”的初始化。稳妥的做法是表现层的初始化放在BeginPlay但凡是依赖Role且必须准确的逻辑要么放在PostNetReceiveRole()要么在BeginPlay里加一层延迟比如SetTimerForNextTick再执行。还有一点是很多人不知道的客户端上非拥有者的 Actor初始Role可能短暂地是Authority然后被复制数据修正成SimulatedProxy。如果你在BeginPlay里做了一次HasAuthority()判断并缓存了结果这个缓存可能是错的。所以我的原则很硬不要缓存 Role 的判定结果需要的时候现算这个调用开销低到可以忽略。2.2 Possess 会让 Role 当场改变一个 Pawn 在客户端上的Role不是恒定的。在它被本地玩家 Possess 之前它在本地是SimulatedProxyPossess 发生的瞬间它会变成AutonomousProxy。这个切换不会重新触发BeginPlay如果你在BeginPlay里按SimulatedProxy分支关掉了某些组件Possess 之后这些组件就再也不会被打开。正确的做法是把“角色切换时需要重新配置的东西”抽成一个函数在BeginPlay、OnPossess、OnRep_Controller/OnRep_Owner里都调一次。我在一个项目里就是因为瞄准准星的显示逻辑只在BeginPlay里判断了一次角色导致主机玩家自己永远看不到准星而其他客户端看到的都是正常的排查了两个小时才定位到 Possess 时序上。反过来服务端的 Pawn 在Possess前后Role都是Authority不受影响。所以这个坑只在客户端出现而客户端出问题时你看到的往往是“只有某一台机器不对”很容易误判成环境问题而不是代码问题。2.3 Role 变成 None 的几种情况ROLE_None出现得比想象中频繁它是排查时的第一嫌疑人。常见来源有四个Actor 还没被复制到这个客户端比如刚 Spawn 还没到Actor 因为距离超出了网络剔除范围被剔除了Actor 处于休眠状态Dormancy且没有唤醒Actor 的bNetLoadOnClient为 false 导致服务端存在但客户端不生成。网络剔除的默认阈值由NetCullDistanceSquared控制注意它是平方厘米。默认值是 225000000开方是 15000也就是 150 米。超过这个距离服务端就不再向该客户端同步这个 Actor客户端的Role会回到None直到玩家走近才恢复。这个“恢复”不是重新 Spawn而是重新开始复制所以客户端上的 Actor 实例一直存在只是没有数据更新。如果你在代码里看到Role ROLE_None就打日志报警会在这个边界上刷满日志。一个实用的判断顺序先看IsActorBeingDestroyed()再看GetNetMode()最后才看Role。因为Role为None的原因太多光凭它一个值不足以定位问题。3. 复制条件让 Role 语义真正落到带宽上3.1 COND 与 Role 的对应关系GetLifetimeReplicatedProps里的复制条件是把 Role 语义落地的关键手段但很多人只会写COND_None把该省的带宽全省掉了。实际在用的条件有这么几个每一个都对应一种明确的角色关系条件宏发送对象典型用途COND_None所有相关连接所有人可见的状态如血量、是否被拾取COND_OwnerOnly仅拥有该 Actor 的连接自己的背包、自己的技能冷却COND_SkipOwner除拥有者外的所有连接第三人称才需要的表现数据如身体朝向COND_SimulatedOnly仅模拟代理端旁观者需要的特效开关本人不需要COND_AutonomousOnly仅自主代理端只在本地玩家身上生效的提示COND_InitialOnly仅在初始同步时出生时确定、后续不变的数据如队伍 IDCOND_SkipOwner和COND_SimulatedOnly是我用得最多的一对。第三人称游戏里角色的上半身旋转对旁观者需要平滑插值但对本人来说自己的视角旋转是输入直接驱动的再同步一份过来纯属浪费。把这类属性标成COND_SkipOwner一个 20 人的房间里每帧能省掉相当可观的出站流量。3.2 相关性剔除的三个开关真正决定“某个客户端收不收得到这个 Actor”的是相关性系统。三个开关按优先级从高到低是bAlwaysRelevant永远相关无视距离、bOnlyRelevantToOwner只对 Owner 相关、NetCullDistanceSquared距离阈值。bAlwaysRelevant要慎用一个被标成永远相关的 Actor在 64 人的房间里等于每帧给 64 个连接各发一份量级上去了很容易把上行带宽吃满。bOnlyRelevantToOwner用在私密对象上非常合适比如玩家的背包物品、私聊界面背后的数据 Actor。它同时会隐式地让这个 Actor 只对 Owner 触发 RPC配合COND_OwnerOnly使用语义才完整。还有一个经常被忘的参数是NetPriority。它不决定发不发只决定在带宽吃紧时谁先发。默认 1.0把玩家角色调到 2.0、把场景装饰调到 0.5在拥挤场景里的体验差别很明显。这个值不是越大越好所有 Actor 都调高等于都没调高。3.3 ReplicatedUsing 与 OnRep 的执行端ReplicatedUsing指定回调函数这个回调只在客户端执行服务端不执行。这一点是新手最容易搞错的地方属性在服务端被修改服务端不会调用自己的OnRep只有收到这个属性的客户端才会调。所以如果你把“拾取后播放特效”的逻辑写在OnRep里主机玩家listen server 上的服务端永远看不到这个特效因为他那边根本没触发回调。解决办法是在服务端修改属性的同时显式调用一次那个表现函数或者在OnRep函数开头判断一下HasAuthority()手动在服务端也调一遍。我在项目里的做法是把表现逻辑抽成HandleXxxVisuals()这样的无状态函数OnRep和服务端修改处都调它保证两端一致。这条规则值得写进团队的代码规范里因为它造成的 bug 是“部分玩家看不到”测试时如果只用单机模式或者只用一台客户端测根本发现不了。4. RPC 与 Role 的搭配矩阵4.1 三种 RPC 的合法调用方RPC 能不能发出去跟Role的判定是同一套底层规则。ServerRPC 要求调用方是“拥有这个 Actor 的连接”也就是说这个 Actor 在该客户端上必须是AutonomousProxy且Owner指向该客户端的PlayerController不满足条件时引擎直接丢弃只在日志里留一句警告客户端代码不会收到任何异常。这是“服务器收不到 RPC”这类问题的头号原因九成以上跟Owner有关。RPC 类型合法调用方执行方条件Server拥有该 Actor 的客户端服务端必须设置正确的OwnerClient服务端该 Actor 的 Owner 客户端Owner 为空则不发NetMulticast服务端所有相关客户端 服务端本地受相关性剔除影响ClientRPC 有一个反直觉的点在 listen server 上如果这个 Actor 的 Owner 就是主机玩家本人服务端调用ClientRPC 会在本地直接执行。这让“主机玩家和客户端玩家表现不同”成为一类非常隐蔽的 bug因为你在服务端调试时看到一切正常。NetMulticast也不是“广播给所有人”。它只发给“当前对这个 Actor 相关的连接”。如果 Actor 因为距离被剔除那个客户端收不到 Multicast而服务端依然认为发了。所以 Multicast 只适合做本地表现不能用来做状态同步——状态同步必须靠属性复制。4.2 用 Role 做前置校验的正确写法很多人会在ServerRPC 的实现里写一堆if (!HasAuthority()) return;这些判断在服务端永远是true等于没写。真正需要校验的是调用者的身份和参数合法性因为客户端的任何输入都不可信。一个完整的ServerRPC 校验应该包含调用者是否为空、调用者是否真的是这个 Actor 的合法操作者、参数是否在合理范围内、当前状态是否允许这个操作。bool AMyWeapon::ServerFire_Validate(const FVector_NetQuantize AimLocation) { // 只做便宜且确定性的检查这个函数在服务端执行 // 返回 false 会导致该客户端被断开所以不要在这里做业务逻辑判断 return FMath::IsFinite(AimLocation.X); } void AMyWeapon::ServerFire_Implementation(const FVector_NetQuantize AimLocation) { // 业务校验放这里失败就静默返回不要用 Validate 去拒绝玩家 if (!HasAuthority() || !IsValid(GetOwner())) { return; } if (!GetOwner()-IsLocallyControlled()) { return; } const float DistSq FVector::DistSquared(AimLocation, GetOwner()-GetActorLocation()); if (DistSq FMath::Square(MaxFireRange)) { return; } // 通过了才继续 }这里有个细节值得强调_Validate返回false的后果是断开连接所以它只能用来挡“协议层面的非法数据”绝对不能用来挡“玩家操作不合法”。把业务判断写进_Validate里玩家一旦距离超限开了一枪直接被踢下线这个体验事故我见过不止一次。4.3 Reliable 和 Unreliable 不是“重要不重要”的区别Reliable保证到达代价是占用可靠通道的缓冲区。引擎的可靠缓冲区是有上限的一旦堆积溢出连接会被断开日志里是Reliable buffer overflow之类的信息。所以在Tick里发ReliableRPC 是一件非常危险的事网络一抖动包发不出去缓冲区迅速堆满然后玩家掉线而你在服务端完全看不出原因。判断标准应该是频率而不是重要性。低频的、状态变更类的事件拾取、购买、开关门用Reliable高频的、可丢的每帧的瞄准方向、移动输入、语音包用Unreliable。对于高频但偶尔需要可靠的数据正确做法是“状态复制 事件 RPC”组合状态用属性复制保证最终一致事件用Unreliable做即时反馈丢了就靠下一次状态复制补上。5. 属性同步还是 RPC一次带宽账本5.1 判定标准新写一个功能时“该用属性复制还是 RPC”这个问题会反复出现。我的判断标准就三条这个数据需不需要新加入的玩家也知道需不需要在丢包后自动恢复是不是持续变化的状态三个里有任意一个是“是”就用属性复制。反过来如果它是一次性的、不需要重放的、错过了也无所谓的瞬时事件才用 RPC。举个具体的玩家的血量用属性复制因为新加入的玩家要看到正确血量丢包了也要自动纠正玩家开枪这个动作用 RPC 或者 Multicast因为它是一次性事件重放反而会出问题比如重复播放开火音效。判断错方向的典型症状是“后加入的玩家看到所有人的血都是满的”这就是把血量做成了 RPC 而不是复制属性。5.2 一个 20 人房间的实算拿一个具体的场景算一下。假设一个 20 人的房间每个玩家角色同步 3 个属性位置、朝向、血量每个属性经过量化和属性头开销后平均按 12 字节算NetUpdateFrequency设为 30Hz把默认的 100Hz 降下来对体验影响很小对带宽影响很大。单个角色对单个客户端的流量12 × 3 × 30 1080 B/s。每个客户端要接收其他 19 个角色1080 × 19 ≈ 20.5 KB/s折合 164 kbps。服务端的总出站是 20.5 KB/s × 20 ≈ 410 KB/s折合约 3.3 Mbps。这个量级在局域网没问题在公网就需要认真对待了尤其是当角色数量继续上升的时候。如果这 3 个属性里有一个是“第三人称才需要的身体朝向”把它改成COND_SkipOwner每个人少收一份自己的数据看起来不多但如果房间里有大量 NPC 或载具省下来的就是线性叠加的量。如果再把NetUpdateFrequency从 30 降到 20整体再降三分之一而玩家在 60fps 下的实际感知差异很小因为位置插值本身就在做平滑。5.3 客户端预测与服务器校正AutonomousProxy的核心价值就在于预测。玩家按下移动键本地立刻动同时把输入发给服务端服务端算完再回一个校正。这套机制在 CharacterMovement 里已经封装好了你要做的是不要去破坏它不要在客户端直接改Role为Authority的 Actor 的位置不要在Tick里用客户端算出来的值覆盖复制值不要在OnRep里做“跳变式”的赋值而不做插值。对于自己实现的、需要预测的系统比如技能冷却、弹药数标准套路是客户端先本地改记下“待确认”的状态服务端处理后回一个ClientRPC带上权威值和这次操作的序号客户端收到后比对序号如果对得上就接受对不上就回滚。序号这一步很多人会省掉省掉的后果是延迟高的时候会出现“先扣了弹药又被加回来”的抖动。6. 动手一个可验证的拾取 Demo6.1 类结构与关键成员这个 Demo 很小一个可拾取道具加一个玩家角色但把所有关键点都串起来了属性复制、OnRep的服务端不触发、ServerRPC 的 Owner 校验、ClientRPC 的结果回执、Multicast的表现广播、以及距离校验必须在服务端做。以 UE5.x 为准UE4 用户把TObjectPtr换成裸指针即可。// PickupActor.h #pragma once #include CoreMinimal.h #include GameFramework/Actor.h #include PickupActor.generated.h UCLASS() class MYNETDEMO_API APickupActor : public AActor { GENERATED_BODY() public: APickupActor(); virtual void BeginPlay() override; // 客户端发起拾取请求 UFUNCTION(Server, Reliable, WithValidation) void ServerTryPickup(APawn* Picker); // 服务端把结果回执给发起者 UFUNCTION(Client, Reliable) void ClientPickupResult(bool bSuccess); // 所有人播放拾取表现 UFUNCTION(NetMulticast, Unreliable) void MulticastPlayPickupFx(); protected: UPROPERTY(VisibleAnywhere, Category Pickup) TObjectPtrUStaticMeshComponent Mesh; UPROPERTY(ReplicatedUsing OnRep_Claimed, Category Pickup) bool bClaimed false; UPROPERTY(EditDefaultsOnly, Category Pickup) float PickupRadius 300.f; UPROPERTY(EditDefaultsOnly, Category Pickup) float RespawnDelay 10.f; UFUNCTION() void OnRep_Claimed(); void ApplyClaimedVisuals(); virtual void GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const override; };6.2 核心代码// PickupActor.cpp #include PickupActor.h #include Net/UnrealNetwork.h #include Components/StaticMeshComponent.h #include GameFramework/Pawn.h APickupActor::APickupActor() { // 构造函数里只做默认值和组件不碰任何网络判断 PrimaryActorTick.bCanEverTick false; bReplicates true; bAlwaysRelevant false; NetUpdateFrequency 10.f; Mesh CreateDefaultSubobjectUStaticMeshComponent(TEXT(Mesh)); RootComponent Mesh; Mesh-SetIsReplicated(false); } void APickupActor::GetLifetimeReplicatedProps(TArrayFLifetimeProperty OutLifetimeProps) const { Super::GetLifetimeReplicatedProps(OutLifetimeProps); // 拾取状态所有人都要知道用 COND_None DOREPLIFETIME_CONDITION(APickupActor, bClaimed, COND_None); } void APickupActor::BeginPlay() { Super::BeginPlay(); // 客户端此时 Role 可能还未最终确定这里只做不依赖 Role 的事 ApplyClaimedVisuals(); } void APickupActor::OnRep_Claimed() { // 注意这个函数只在客户端执行服务端修改 bClaimed 不会触发 ApplyClaimedVisuals(); } void APickupActor::ApplyClaimedVisuals() { if (Mesh) { Mesh-SetVisibility(!bClaimed); Mesh-SetCollisionEnabled(bClaimed ? ECollisionEnabled::NoCollision : ECollisionEnabled::QueryOnly); } } bool APickupActor::ServerTryPickup_Validate(APawn* Picker) { return Picker ! nullptr; } void APickupActor::ServerTryPickup_Implementation(APawn* Picker) { if (!HasAuthority() || bClaimed) { return; } // 距离校验必须在服务端做客户端传来的参数一律当不可信处理 const float DistSq FVector::DistSquared(Picker-GetActorLocation(), GetActorLocation()); if (DistSq FMath::Square(PickupRadius)) { ClientPickupResult(false); return; } bClaimed true; // 服务端不会自动触发 OnRep这里手动补一次表现 ApplyClaimedVisuals(); MulticastPlayPickupFx(); ClientPickupResult(true); } void APickupActor::ClientPickupResult_Implementation(bool bSuccess) { // 只在发起者的机器上执行可以放 UI 提示、音效等 UE_LOG(LogTemp, Log, TEXT(Pickup result on local client: %s), bSuccess ? TEXT(Success) : TEXT(Rejected)); } void APickupActor::MulticastPlayPickupFx_Implementation() { // 服务端和所有相关客户端都会执行到这里 // 这里做特效、音效不要在这里改任何复制属性 }Demo 里最值得看的其实是ServerTryPickup的调用位置。它必须在客户端上、由玩家输入触发并且这个 Actor 必须SetOwner(PlayerController)否则引擎收不到。调用前建议加一层if (GetLocalRole() ROLE_Authority) return;避免在服务端重复走一遍逻辑——注意这里判断的是ROLE_Authority而不是ROLE_AutonomousProxy因为在 listen server 的主机上玩家自己身上的 Actor 也是Authority。6.3 用控制台命令把问题逼出来光在局域网跑通不算验证必须加延迟和丢包。PIE 的 Play 设置里有 Network Emulation 选项也可以在运行时用控制台NetEmulation.PktLag 150 NetEmulation.PktLoss 5 NetEmulation.PktLagVariance 30 stat net ShowDebug NetworkNetEmulation.PktLag 150模拟 150ms 单向延迟PktLoss 5模拟 5% 丢包。这两个一开很多“本地测没问题”的 bug 立刻现形尤其是ReliableRPC 的堆积和预测回滚的抖动。stat net看进出流量和包数ShowDebug Network看连接层面的 ping 和丢包统计。调试完记得用NetEmulation.PktLag 0和NetEmulation.PktLoss 0关掉不然下一个接手的人会怀疑人生。想看每个 Actor 的 Role最快的办法是在BeginPlay或者Tick里打一行日志把GetLocalRole()、GetRemoteRole()、GetNetMode()、IsLocallyControlled()全打出来。我自己写了一个小函数专门做这件事用#if !UE_BUILD_SHIPPING包起来联调期随时开这行日志救过的次数比我愿意承认的多。7. 排查速查表与踩坑记录7.1 常见现象对照表现象最可能的原因第一步排查Server RPC 没反应日志有 warningActor 没设 Owner或 Owner 不是该玩家打印GetOwner()客户端看到两个角色客户端也执行了 SpawnActor确认 Spawn 只在HasAuthority()分支主机玩家看不到特效表现逻辑只写在OnRep里服务端修改处手动补一次调用客户端改属性后闪回客户端直接改复制属性改为 Server RPCRole一直是None未开复制或被剔除查bReplicates和NetCullDistanceSquared玩家莫名掉线ReliableRPC 在Tick里发改Unreliable或合并发送近距离正常远距离不同步超出网络剔除距离调NetCullDistanceSquared只有部分人看不到某 Actor相关性剔除按距离或 Owner 过滤检查bOnlyRelevantToOwner这张表我基本是照着排查顺序排的从上往下命中率递减。第一行和第二行能解决我遇到的七成问题而且都是五分钟内能确认的。7.2 我踩过的几个坑第一个坑是把“客户端生成特效”写在了HasAuthority()为真的分支外面。结果是每个客户端都生成了一份服务端也生成了一份因为服务端有多个客户端的连接特效在服务端的默认世界里同时存在然后在NetMulticast里又被广播一次画面直接糊掉。这个问题的根源是对“服务端的默认世界只有一个但客户端的默认世界有 N 个”这件事没有建立直觉。第二个坑是SetReplicates的调用时机。在运行时动态创建的 Actor必须在SpawnActor之后立刻SetReplicates(true)如果等到BeginPlay之后才设置这个 Actor 已经在客户端“初始化过一轮”了行为会变得很怪。更稳妥的写法是在SpawnActorDeferred里设置完所有网络相关参数再FinishSpawning。第三个坑跟COND_SkipOwner有关。我把它用在一个第三人称需要、第一人称不需要的属性上结果发现单机模式下这个属性完全不生效——因为单机时没有“其他连接”COND_SkipOwner意味着“不给 Owner 发”而单机只有 Owner 一个视角。这类只在联机下生效的复制条件测试时必须至少开两个客户端PIE 里设成 2 个客户端加 1 个专用服务器是最低配置。第四个坑是NetPriority的误用。我曾经在一个场景里把所有的可交互道具都调到了 3.0结果玩家角色的移动变得很卡。原因是引擎的优先级调度是相对的道具的优先级上去了角色的相对优先级就下来了。带宽紧张时先发的不是“更重要的”而是“累积时间 × 优先级最大的”把次要对象全抬上去等于把主要对象压下去。7.3 一套可以抄走的判断流程遇到网络相关的表现问题我现在固定走这几步先确认这个 Actor 在出问题的客户端上Role是多少是None就查复制和相关性是SimulatedProxy就查“这段逻辑是不是本该在拥有者上跑”是AutonomousProxy就查“有没有被服务端覆盖”。然后确认Owner是谁为空的话所有Server和ClientRPC 都是废的。最后才去看具体的属性复制条件和 RPC 参数因为前两步能过滤掉绝大多数问题后面的排查才有意义。至于我个人的体会这套东西的难点从来不在 API 上Role就三个值DOREPLIFETIME就那么几种条件看一眼文档就懂。难的是脑子里要始终维持“同一时刻有 N 台机器在跑同一份代码每台机器上的这个 Actor 身份都不一样”这个模型。只要这个模型立住了写代码的时候自然会问一句“这行跑在谁的机器上”问题就少了一大半。反过来如果写的时候没问这句话那等到联调阶段就只能靠日志一行行猜效率差十倍不止。