3、Replication基础:Actor复制、属性复制(Replicated)、组件复制、复制条件

好,咱们今天来啃网络同步里最基础、也最绕不开的一块硬骨头——Replication。说白了,就是「怎么让服务器和客户端看到的东西保持一致」。我刚开始做多人游戏那会儿,以为只要把角色放上去就能自动同步,结果一跑起来,客户端A看到B在天上飞,B看到A卡在墙里……嗯,从那以后我再也不敢小看这个基础了。

3.1 Actor复制:让对象在网络上「活」起来

先问个问题:一个Actor什么时候会出现在客户端?答案是——只有当它被「复制」过去的时候。Actor复制是网络同步的基石,你想想看,如果连对象本身都不存在,那它的位置、血量、动画就更别提了。

在Unreal里,控制Actor是否复制的开关就一个布尔值:bReplicates。把这个设为true,服务器就会尝试把这个Actor广播到所有相关客户端。我个人习惯在构造函数里就把它设好:

// 在构造函数中
bReplicates = true;

但注意,光设这个还不够。你还需要告诉引擎「谁该看到这个Actor」。这就引出了另一个关键属性——bOnlyRelevantToOwner

核心区别:

  • bReplicates = true:Actor参与复制,但具体复制给谁,由相关性(Relevance)决定。
  • bOnlyRelevantToOwner = true:只复制给拥有者(Owner),其他客户端看不到。典型例子就是玩家身上的UI提示、个人专属的拾取物。

我在项目中遇到过一个问题:一个爆炸特效Actor,我设了bReplicates = true,结果所有客户端都看到了,但爆炸位置在服务器上是对的,在客户端上却偏移了。后来发现是因为我没有设置SetReplicates的时机——在Spawn之后才设,导致第一帧的位置没同步过去。所以我的建议是:在Spawn之前就把复制属性设好,或者用UWorld::SpawnActor<>的模板参数直接指定。

3.2 属性复制(Replicated):让数据自动「跑」到客户端

Actor复制只是第一步。真正让游戏动起来的,是属性复制。比如玩家的血量、子弹数量、开门状态——这些数据需要从服务器同步到客户端。

在Unreal里,标记一个属性为「需要复制」很简单,用UPROPERTY(Replicated)宏就行:

UPROPERTY(Replicated)
float Health;

UPROPERTY(Replicated)
int32 AmmoCount;

但光标记还不够,你还需要实现一个函数:GetLifetimeReplicatedProps。这个函数告诉引擎「哪些属性要复制,以及怎么复制」。我见过不少新手忘了这一步,结果属性死活不同步。

void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    DOREPLIFETIME(AMyCharacter, Health);
    DOREPLIFETIME(AMyCharacter, AmmoCount);
}

我的小技巧:如果你希望某个属性只在变化时才同步(而不是每帧都发),可以用DOREPLIFETIME_CONDITION配合条件枚举。比如血量变化时才通知客户端,能省不少带宽。

属性复制还有一个重要概念——复制通知(RepNotify)。当客户端收到属性更新时,你可以指定一个函数自动被调用。比如血量变了,播放受伤动画:

UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;

UFUNCTION()
void OnRep_Health()
{
    // 客户端上播放受伤特效
    PlayDamageEffect();
}

我曾经踩过一个坑:在OnRep_Health里直接修改了Health的值,结果触发了死循环。因为OnRep是在属性变化后调用的,如果你在里面又改了属性,服务器会再次下发……嗯,所以记住:RepNotify里只做表现,别改数据

3.3 组件复制:让子对象也参与同步

有时候,我们不想复制整个Actor,只想复制它身上的某个组件。比如一个武器组件、一个背包组件。Unreal支持组件级别的复制,但需要手动开启。

首先,在组件的构造函数里设置:

UActorComponent::UActorComponent()
{
    bReplicates = true;
}

然后,在Actor的GetLifetimeReplicatedProps里,你不需要为组件属性单独写DOREPLIFETIME——组件自己的属性复制由组件自己管理。但要注意:组件必须被Actor拥有,且Actor本身也要开启复制

我个人的经验是:组件复制适合「功能模块化」的场景。比如一个UHealthComponent,它管理血量、护盾、回复逻辑。把这个组件复制到客户端,客户端就能直接读取血量数值,而不需要Actor再转发一遍。

注意:组件复制不是万能的。如果组件里持有大量动态数据(比如数组、地图),每次同步都会产生不小的开销。我曾经把一个TArray<FItemData>放在组件里并标记为Replicated,结果每次背包变化都卡顿。后来改成只同步「变化的部分」,用RPC通知客户端增量更新,才解决问题。

3.4 复制条件:控制「谁看到什么」

最后,我们来聊聊复制条件。说白了,就是「什么时候复制、复制给谁」。Unreal提供了几种条件枚举,最常用的有:

条件宏 含义 典型场景
COND_None 无条件复制(默认) 全局状态,如游戏时间
COND_OwnerOnly 只复制给拥有者 玩家自己的弹药、分数
COND_SkipOwner 跳过拥有者,复制给其他人 其他玩家的位置(自己不需要)
COND_InitialOnly 只在初始连接时复制一次 地图名称、固定配置

使用方式很简单:

DOREPLIFETIME_CONDITION(AMyCharacter, Health, COND_OwnerOnly);
DOREPLIFETIME_CONDITION(AMyCharacter, Location, COND_SkipOwner);

这里有个容易忽略的点:COND_OwnerOnlybOnlyRelevantToOwner有什么区别?前者是属性级别的控制,后者是Actor级别的控制。如果你只想让某个属性(比如「当前瞄准的敌人」)只发给拥有者,用COND_OwnerOnly;如果你想让整个Actor只出现在拥有者视野里,用bOnlyRelevantToOwner

避坑指南:我曾经在做一个FPS游戏时,把玩家的「准星位置」属性设成了COND_OwnerOnly,结果其他客户端看不到这个玩家的瞄准方向,导致击杀回放里准星是歪的。后来改成COND_None,但只在回放模式下才同步,平时还是OwnerOnly——用条件分支来平衡性能和功能。

小结

好了,这一章的内容就这些。总结一下:

  • Actor复制是基础,bReplicates决定是否参与复制,bOnlyRelevantToOwner控制可见范围。
  • 属性复制UPROPERTY(Replicated)标记,配合GetLifetimeReplicatedProps注册,RepNotify做表现。
  • 组件复制需要组件自身开启bReplicates,适合模块化功能。
  • 复制条件DOREPLIFETIME_CONDITION精细控制,避免带宽浪费。

下一章我们会深入RPC(远程过程调用),看看怎么让客户端主动告诉服务器「我开枪了」。嗯,那才是真正让游戏互动起来的关键。咱们下章见。