GAS下的网络同步的全面分析【超级全面】

GAS的网络同步

一、前言

GAS这套技能系统,对三种客户端(DS客户端、本地客户端、模拟客户端)进行了不同程度的处理,对各类不同的机制(从数值计算、buff、GameplayAbility、蒙太奇)都有不同程度的针对网络同步的适配策略,是一套很好的现成的网络同步架构的实现。


二、全文核心速览

GA的执行方式

策略 谁能激活 客户端表现 适用场景
LocalPredicted 客户端预测 + 服务器验证 客户端立即执行,服务器确认/拒绝 玩家主动技能(攻击、翻滚、格挡)
LocalOnly 仅本地 只在本地执行 纯客户端表现(打开菜单、UI)
ServerInitiated 仅服务器 服务器执行后复制给客户端 AI 技能、环境触发效果
ServerOnly 仅服务器 客户端完全不知道 后台逻辑计算
服务器和客户端不会同步GA实例,客户端会自己根据PredictionKey 在本地创建自己的 GA 实例

GE的复制模式

模式 GE 复制目标 Tag 复制 Cue 复制 适用角色
Full 所有客户端 所有客户端 所有客户端 单机 / 极少量角色
Mixed 仅 Owner 客户端 所有客户端(MinimalRepl) 所有客户端(Multicast) 玩家角色
Minimal 不复制 所有客户端(MinimalRepl) 所有客户端(Multicast) AI 敌人 / NPC

GE的具体同步实现

即时的GE直接修改BaseValue,通过属性复制实现。

持续性的GE,通过同步增量的GE来实现,(这些GE本质也是走属性复制实现的)

周期性的GE,是通过服务器内部有个定时器,然后一段时间内就去BaseValue的属性同步。

而堆叠型的GE,

GameplayTag的同步

tag在Full模式下会跟随GE去进行同步,

但是在Mixed和Minimal模式下,这些Tag需要额外处理,也去做同步。

GameplayCue的同步

在Full 模式下 Cue 跟随 GE 复制;Full 模式 → Cue 搭 GE 的便车

Mixed/Minimal 模式下,Cue 走独立的走 Multicast RPC

动画蒙太奇的同步

蒙太奇会在服务器去做播放,然后会将这个Info复制给所有人,

主控客户端不会对蒙太奇数据做对比,它只会受到GA的执行结果通过与否来判断。

而模拟客户端会对比服务器下发的具体动画数据,去做动画的播放模拟。


三、三种客户端各自的职责

我们知道,UE里有三种客户端,他们各自的职责不同。

复制代码
┌─────────────────────────────────────────────────────────────────┐
│  Authority(服务器端实例)                                         │
│  ├── 拥有数据的最终裁决权                                         │
│  ├── 可以直接激活任何技能                                         │
│  ├── GE/Attribute 的修改是权威的                                  │
│  └── 通过属性复制向客户端推送数据                                  │
│                                                                 │
│  AutonomousProxy(主控客户端 ------ 玩家自己操控的角色)                │
│  ├── 可以预测性激活技能(LocalPredicted)                          │
│  ├── 接收来自服务器的属性复制                                     │
│  ├── 持有预测键,可以发送 RPC 到服务器                            │
│  └── 收到服务器确认/拒绝后处理回滚                                │
│                                                                 │
│  SimulatedProxy(模拟端 ------ 其他玩家在本地的镜像)                  │
│  ├── 不能主动激活技能                                            │
│  ├── 只接收服务器复制来的数据                                     │
│  ├── 通过 RepAnimMontageInfo 播放动画                            │
│  └── GameplayCue 通过 Multicast/复制到达                         │
└─────────────────────────────────────────────────────────────────┘

关键区别

  • AutonomousProxy 能发 RPC(ServerTryActivateAbility),SimulatedProxy 不能
  • Authority 上直接修改属性值,属性变化通过复制系统推送给客户端

四、GA的网络同步

GA是GAS系统的核心,下面来看看它的网络同步相关的。

4.1 技能在哪执行的策略

GAS 中每个 UGameplayAbility 都有一个 NetExecutionPolicy 属性,决定了技能在网络中的执行方式:

cpp 复制代码
// 引擎源码:GameplayAbility.h
UENUM(BlueprintType)
namespace EGameplayAbilityNetExecutionPolicy
{
    enum Type
    {
        LocalPredicted,   // 客户端预测执行 + 服务器验证(默认值)
        LocalOnly,        // 仅本地执行,不涉及网络
        ServerInitiated,  // 服务器发起,复制到客户端
        ServerOnly        // 仅服务器执行,客户端不知道
    };
}
策略 谁能激活 客户端表现 适用场景
LocalPredicted 客户端预测 + 服务器验证 客户端立即执行,服务器确认/拒绝 玩家主动技能(攻击、翻滚、格挡)
LocalOnly 仅本地 只在本地执行 纯客户端表现(打开菜单、UI)
ServerInitiated 仅服务器 服务器执行后复制给客户端 AI 技能、环境触发效果
ServerOnly 仅服务器 客户端完全不知道 后台逻辑计算

具体如下:

复制代码
┌──────────────────────────────────────────────────────────────┐
│  TryActivateAbility() 被调用                                   │
├──────────────────────────────────────────────────────────────┤
│  LocalPredicted:                                             │
│    ├─ AutonomousProxy → 本地预测 + RPC到服务器                 │
│    ├─ Authority       → 服务器直接执行 + 复制到客户端           │
│    └─ SimulatedProxy  → 不执行(等待服务器复制)                │
│                                                              │
│  ServerInitiated:                                            │
│    ├─ Authority       → 服务器执行 + 复制到客户端              │
│    └─ 非Authority     → 不执行(只能由服务器发起)              │
│                                                              │
│  LocalOnly:  → 本地直接执行,无网络通信                        │
│                                                              │
│  ServerOnly:                                                 │
│    ├─ Authority       → 服务器执行                           │
│    └─ 非Authority     → 转发到服务器执行                      │
└──────────────────────────────────────────────────────────────┘

4.2 复制策略

除了 NetExecutionPolicy(控制"在哪里执行"),每个 UGameplayAbility 还有一个 ReplicationPolicy

cpp 复制代码
// 引擎源码:GameplayAbility.h
UENUM(BlueprintType)
namespace EGameplayAbilityReplicationPolicy
{
    enum Type
    {
        ReplicateNo,   // 不复制 GA 实例状态(默认)
        ReplicateYes   // 复制 GA 实例状态到客户端
    };
}

它控制的是技能执行过程中 "GA的实例本身要不要复制"------也就是某次,GA 对象自身的字段是否走复制通道。

4.3 ReplicateNo(默认)------ 大多数技能用这个

服务器创建 GA 实例 → 不复制实例数据给客户端

/客户端通过 PredictionKey 在本地创建自己的 GA 实例 /
两端独立运行各自的 GA 逻辑

复制代码
状态一致性靠什么保证?
  ├── GE 复制:技能里应用的 GE 通过 ASC 的 ReplicationMode 同步
  ├── 属性复制:技能修改的 BaseValue 通过 DOREPLIFETIME 同步
  ├── Cue 复制:技能触发的视觉/音效通过 GameplayCue 同步
  └── Montage 复制:技能播的动画通过 RepAnimMontageInfo 同步

优点

  • 带宽占用最低,GA 实例数据完全不走网络
  • 性能好,不需要为每个技能维护复制状态

缺点

  • 客户端无法直接读取服务器 GA 的内部状态
  • 如果技能内部有重要的中间数据需要展示,需要通过别的机制(如 GE、GameplayCue 参数)传递

4.4 ReplicateYes ------ 特殊需求场景

复制代码
服务器创建 GA 实例 → GA 上标记为 UPROPERTY(Replicated) 的字段会同步给客户端
客户端可以直接读取服务器 GA 的实时状态

典型适用场景

① 蓄力进度

应用场景:UI 需要实时显示蓄力进度条,主控端预测的进度可能因为延迟和服务器有偏差,需要服务器权威值校正。

cpp 复制代码
UCLASS()
class UGA_ChargedAttack : public UGameplayAbility
{
    GENERATED_BODY()
    
public:
    UGA_ChargedAttack()
    {
        NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
        ReplicationPolicy = EGameplayAbilityReplicationPolicy::ReplicateYes;  // ← 关键
        bReplicateInputDirectly = true;
    }
    
    UPROPERTY(Replicated, BlueprintReadOnly)
    float CurrentChargeAmount = 0.0f;  // 这个字段会自动同步到客户端
};
② 技能内部的状态机

应用场景:技能有多阶段的内部状态,客户端 UI 需要展示"现在处于第几阶段"。

cpp 复制代码
UPROPERTY(Replicated)
EChannelState ChannelState;  // 引导阶段(准备/释放/收尾)

UPROPERTY(Replicated)
int32 RemainingCharges;      // 剩余可用次数
③ 持续目标锁定状态

应用场景:技能持续期间需要客户端知道当前锁定了谁。

cpp 复制代码
UPROPERTY(Replicated)
AActor* CurrentLockedTarget;  // 当前锁定的目标

UPROPERTY(Replicated)
float LockDuration;            // 已锁定时间

4.5 执行策略和复制策略的区别

NetExecutionPolicy vs ReplicationPolicy 的区别

这两个概念名字相似,非常容易混淆,先把它们的分工讲清楚:

维度 NetExecutionPolicy ReplicationPolicy
控制什么 技能在哪里执行(服务器/客户端/两边) GA 实例自身的成员变量是否走网络复制
影响什么 技能逻辑的运行位置 GA 实例的内部数据是否能在客户端读到
典型选项 LocalPredicted / ServerInitiated / ServerOnly / LocalOnly ReplicateNo / ReplicateYes
关注点 "这个技能谁来跑?" "这个技能跑的过程中产生的中间数据要不要让客户端看到?"

举例对比

复制代码
技能 A:玩家攻击
  NetExecutionPolicy = LocalPredicted(客户端预测执行)
  ReplicationPolicy  = ReplicateNo
  → 客户端和服务器都各自跑这个技能的逻辑
  → 两边各自维护自己的 GA 实例,不互相同步
  → 通过"技能里应用的 GE"和"修改的属性"间接保持状态一致
  
技能 B:蓄力射击(需要客户端实时看到蓄力进度条)
  NetExecutionPolicy = LocalPredicted
  ReplicationPolicy  = ReplicateYes
  → 服务器 GA 实例上的"当前蓄力值"通过网络复制到客户端
  → 客户端读取这个值显示进度条

4.6 网络安全策略(NetSecurityPolicy)

除了 NetExecutionPolicy 控制"在哪里执行",GAS 还有 NetSecurityPolicy 控制"是否信任客户端":

cpp 复制代码
UENUM(BlueprintType)
namespace EGameplayAbilityNetSecurityPolicy
{
    enum Type
    {
        ClientOrServer,        // 客户端和服务器都可以自由触发(默认)
        ServerOnlyExecution,   // 仅服务器可执行
        ServerOnlyTermination, // 仅服务器可终止技能
        ServerOnly             // 仅服务器可执行和终止
    };
}

典型配置组合

技能类型 NetExecutionPolicy NetSecurityPolicy
玩家攻击 LocalPredicted ClientOrServer
被动 Buff ServerOnly ServerOnly
AI 攻击 ServerInitiated ServerOnlyExecution
眩晕效果 ServerInitiated ServerOnlyTermination
打开菜单 LocalOnly ClientOrServer
GM 命令 ServerOnly ServerOnly

4.7 底层传输方式速览

GA 相关的网络同步,在底层用到了两类通道:

  • 技能激活请求 :客户端 → 服务器,走 Server RPCServerTryActivateAbility,Reliable)
  • 激活确认/拒绝 :服务器 → 主控端,走 Client RPCClientActivateAbilitySucceed / ClientActivateAbilityFailed
  • GA 已授予列表(GrantedAbilities) :走属性复制 (ASC 里的 ActivatableAbilitiesReplicated 字段)
  • GA 实例内部字段 (ReplicateYes 时):走属性复制(DOREPLIFETIME)
  • End/Cancel 请求 :走 Server RPC / Client RPCServerEndAbility 等)

技能系统是 GAS 里唯一大量使用 RPC 的子系统------因为"激活/结束"天然是事件,不是状态。


五、 GE 复制模式(ReplicationMode)

5.1 三种复制模式

ASC 有一个 EGameplayEffectReplicationMode 枚举,控制 GameplayEffect 和 GameplayTag 的复制策略

cpp 复制代码
UENUM()
enum class EGameplayEffectReplicationMode : uint8
{
    Minimal,    // 只复制最小 GameplayCue 和 Tag 信息,不复制 GE
    Mixed,      // 只向 Owner 复制完整 GE,其他客户端只收到 Cue 和 Tag
    Full        // 向所有客户端复制完整 GE 信息
};

5.2 各模式详解

模式 GE 复制目标 Tag 复制 Cue 复制 适用角色
Full 所有客户端 所有客户端 所有客户端 单机 / 极少量角色
Mixed 仅 Owner 客户端 所有客户端(MinimalRepl) 所有客户端(Multicast) 玩家角色
Minimal 不复制 所有客户端(MinimalRepl) 所有客户端(Multicast) AI 敌人 / NPC

5.3 实际项目中的推荐配置

cpp 复制代码
// 玩家角色:Mixed
AbilitySystemComponent->SetIsReplicated(true);
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

// AI 角色:Minimal
AbilitySystemComponent->SetIsReplicated(true);
AbilitySystemComponent->SetReplicationMode(EGameplayEffectReplicationMode::Minimal);

而其具体内部的实现,GE 的增量复制走的是属性复制通道ActiveGameplayEffects 是 ASC 里一个普通的 UPROPERTY(Replicated) 字段,但它的类型 FActiveGameplayEffectsContainer 继承自 FFastArraySerializer,通过自定义的 NetDeltaSerialize 实现了"只传变化的元素"的增量复制优化。本质上是属性复制,但是增量的

5.4 底层传输方式速览

ReplicationMode 本质上是一个"流量分配开关"------它控制 ASC 的几个 Replicated 字段复制给谁,并不改变"用什么通道"。所有底层传输都是走 UE 的属性复制系统:

  • ActiveGameplayEffects(GE 列表):属性复制(FFastArraySerializer 增量)
  • MinimalReplicationTags(关键 Tag Map):属性复制
  • RepAnimMontageInfo(蒙太奇快照):属性复制
  • GameplayCue Execute(一次性特效):NetMulticast RPC(这一条不受 ReplicationMode 影响,一直走 Multicast)

不同模式下的区别只是"用 COND_ 条件过滤了某些字段的目标客户端"------比如 Mixed 模式下 ActiveGameplayEffectsCOND_ReplayOrOwner 条件,只复制给 Owner。


六、不同类型 GameplayEffect 的网络同步差异

6.1 GE 三种持续类型的网络行为对比

持续类型 是否有活跃实例 是否参与 GE 复制 属性影响方式
Instant(瞬时) ❌ 应用后立即销毁 ❌ 不复制 GE 本身 直接修改 BaseValue,通过属性复制同步
Duration(持续) ✅ 存在于 ActiveGE 容器 ✅ FFastArraySerializer 增量复制 Modifier 叠加在 CurrentValue
Infinite(永久) ✅ 存在于 ActiveGE 容器 ✅ FFastArraySerializer 增量复制 Modifier 叠加在 CurrentValue

6.2 Instant GE 的网络特殊性

Instant一般用在对于BaseValue的修改,属于一次性的修改,修改即生效。

所以可以直接通过属性复制,修改基础值生效。

Instant GE 不参与 ActiveGameplayEffects 的复制,因为它应用后立即生效并销毁:

复制代码
Instant GE 的网络流程:
  ① 服务器应用 Instant GE → 直接修改 Attribute 的 BaseValue
  ② Attribute 通过 DOREPLIFETIME 复制到客户端
  ③ 客户端收到新的 Attribute 值(通过 RepNotify)
  
  注意:客户端收到的不是"GE",而是"属性值的变化"

6.3 Duration/Infinite GE 的复制流程

持续性的GE,一般通过增量的复制和增量的移除,服务器让你增加,你本地就应用这个GE,服务器让你移除,你就移除这个GE。

具体来说,Duration(有时限的 Buff/Debuff)和 Infinite(永久存在直到手动移除的被动效果)GE,与 Instant GE 完全不同------它们会作为"活跃实例"存在于服务器的 ActiveGameplayEffects 容器中,并通过 增量复制到客户端。

当服务器添加新的GE的时候,会将其添加到数组中,然后将这个数据标记为脏,下一次同步的时候就会把这个数据同步过去,

复制代码
服务器端                              客户端
    │                                    │
    │ ① ApplyGameplayEffect()            │
    │    → 添加到 ActiveGameplayEffects   │
    │    → MarkItemDirty()               │
    │                                    │
    │ ── FFastArraySerializer 增量复制 ──→ │
    │                                    │ ② PostReplicatedAdd()
    │                                    │    → 本地应用 GE Modifier
    │                                    │    → 重新计算属性值
    │                                    │    → 触发 GameplayCue
    │                                    │
    │ ③ GE 到期/被移除                    │
    │    → 从 ActiveGE 容器删除           │
    │    → MarkItemDirty() / 标记删除     │
    │                                    │
    │ ── FFastArraySerializer 增量复制 ──→ │
    │                                    │ ④ PostReplicatedRemove()
    │                                    │    → 移除 Modifier
    │                                    │    → 重新计算属性值
    │                                    │    → 移除 GameplayCue
每一步详细解读:

① 服务器应用 GE ------ "白板上写新条目"

当服务器调用 ApplyGameplayEffectSpecToSelf() 应用一个 Duration/Infinite GE 时:

  1. 创建一个 FActiveGameplayEffect 实例,内含完整的 GE 规格(FGameplayEffectSpec,包括等级、堆叠数、Modifier 列表等)
  2. 将这个实例添加到 ASC 的 ActiveGameplayEffects 容器(本质是一个 TArray<FActiveGameplayEffect>
  3. 调用 MarkItemDirty() 通知 UE 的复制系统:"这个数组有新元素了,下次复制时把这条带上"

关键点MarkItemDirty() 不会立即发送网络包。它只是标记"脏数据", /等下一次网络 Tick 时,UE 的复制系统才会把变化打包发出去。/ 这意味着如果在同一帧内应用了 3 个 GE,它们会被合并在一次网络包中发送,而非发 3 个独立包。

FFastArraySerializer !!#ff0000 增量复制 !!------ "只告诉客户端变化了什么"

FFastArraySerializer 是 UE 提供的一种高效数组复制机制。它不会每帧全量复制整个数组(那样太浪费带宽),而是:

  • 记录上次成功复制后的数组状态
  • /对比当前状态,计算出"新增了哪些元素"、"修改了哪些元素"、"删除了哪些元素"/
  • 只序列化差异部分发送给客户端

举例:如果角色身上有 20 个活跃 GE,但这帧只新加了 1 个,那网络上只传输这 1 个新 GE 的数据,而非全部 20 个。

② 客户端收到新 GE ------ PostReplicatedAdd()

客户端的 FActiveGameplayEffectsContainer 收到新元素后,自动调用 PostReplicatedAdd() 回调:

  1. 本地应用 GE Modifier :将 GE 中定义的属性修改器(如 "+50 最大生命值"、"+20% 移动速度")注册到属性的聚合器(Aggregator)中。此时 CurrentValue 会改变,但 BaseValue 不变(Duration/Infinite GE 只影响 CurrentValue)。

  2. 重新计算属性值:聚合器汇总所有活跃 Modifier,重新计算该属性的最终值:

    复制代码
    CurrentValue = BaseValue 
                 + Σ(Additive Modifiers) 
                 × Π(Multiplicative Modifiers) 
                 × Π(Division Modifiers)
  3. 触发 GameplayCue :如果 GE 蓝图中配置了 GameplayCue Tags(如 GameplayCue.Buff.SpeedBoost),此时在客户端触发对应的 Cue(如播放加速光环特效)。这就是为什么 Buff 特效能在所有客户端看到------它跟随 GE 复制自动触发。

③ 服务器移除 GE ------ "白板上擦掉条目"

GE 到期(Duration 计时结束)或被代码手动移除时:

  1. ActiveGameplayEffects 容器中删除该 FActiveGameplayEffect
  2. FFastArraySerializer 检测到元素被删除,在下次复制时通知客户端"这条被移除了"

④ 客户端收到 GE 移除 ------ PostReplicatedRemove()

客户端收到删除通知后,自动调用 PostReplicatedRemove() 回调:

  1. 移除 Modifier:从属性聚合器中删除该 GE 的所有 属性修改器Modifier。
  2. 重新计算属性值:少了一个 Modifier 贡献后,属性值自然恢复(如 "+50 最大生命值" 的 Buff 到期 → 最大生命值减少 50)。
  3. 移除 GameplayCue :调用 Cue 的 OnRemove(),停止 Buff 特效(如加速光环消失)。
为什么 Duration/Infinite GE 不直接修改 BaseValue?

因为它的修改是增量式的修改,而不是修改的基础数值,比如直接给当前血量+30。因此移除的时候也是增量式的移除。

FFastArraySerializer vs 普通 TArray 复制的区别
对比项 普通 TArray 复制 FFastArraySerializer
复制粒度 整个数组全量复制 只复制变化的元素
带宽消耗 高(数组越大越耗带宽) 低(只传差异)
客户端回调 无(只是覆盖数据) 有(Add/Change/Remove 回调)
适用场景 小数组、不频繁变化 大数组、频繁增删改
GAS 中的用途 --- ActiveGameplayEffects

6.4 Periodic GE(周期性效果)的网络同步

周期性 GE(如每秒扣血的 DOT、每 2 秒回血的 HOT)本质上是 Duration/Infinite GE + 周期执行器。它的网络同步涉及两个层面。

【核心前置】每一跳按 Instant 方式执行,改的是 BaseValue

GAS 文档中有一句很关键的话:"Periodic Effects 被视为 Instant GameplayEffect"

这意味着周期性 GE 的每一跳不是"挂一个新 Modifier 到 Aggregator",而是走 Instant 的执行路径,直接修改 BaseValue 。改完 BaseValue 后,CurrentValue 会被 GAS 自动重新计算(CurrentValue = BaseValue + 所有活跃 Modifier),所以 CurrentValue 也会跟着变------但变化是"刻"在 BaseValue 上的,GE 到期后不会恢复

复制代码
为什么要这样设计?用一个中毒的例子说明:

如果每一跳用 Modifier 改 CurrentValue:
  T=1s: 挂 Modifier "-10",T=2s: 再挂一个 "-10"...
  中毒结束 → 所有 Modifier 删掉 → 血全回来了 → 这不是中毒,这是按摩

如果每一跳按 Instant 改 BaseValue:
  T=1s: BaseValue -= 10,T=2s: BaseValue -= 10...
  中毒结束 → GE 移除 → 但 BaseValue 已经被扣了 → 血不会回来 ✅

但这不是唯一选择 。如果策划需要"效果结束后恢复"(如"诅咒期间每 2 秒降低 20 攻击力,诅咒结束后全部恢复"),那就不该用周期执行,而是用 Duration GE 的持续 Modifier 或堆叠 GE 来实现。改 BaseValue 还是 CurrentValue,取决于游戏设计语义------"结束后该不该恢复"。

一个 Duration GE 可以同时做两种事

这是最容易搞混的点。一个 Duration GE 可以同时配置持续 Modifier 和周期执行:

复制代码
例:一个"中毒" Duration GE,持续 10 秒,Period = 1s

  持续 Modifier:移速 -30%(挂 Aggregator,改 CurrentValue,到期自动恢复)
  周期执行:每秒扣 10 血(按 Instant 路径,改 BaseValue,不恢复)

效果:
  中毒期间:走得慢 + 每秒掉血
  中毒结束:移速恢复正常 ← Modifier 删了
            但掉的血不会回来 ← BaseValue 已经改了
同一个 GE 中的不同效果 改什么 GE 移除后恢复吗
持续 Modifier(非周期) CurrentValue(通过 Aggregator) ✅ 恢复
周期执行(每一跳) BaseValue(按 Instant 方式) ❌ 不恢复
GE 本身的网络复制("你中了什么 Debuff")

周期性 GE 作为 Duration/Infinite GE,它本身通过 FFastArraySerializer 复制给客户端(只复制一次)。客户端收到后可以在 UI 上显示 Debuff 图标、剩余时间、堆叠层数。

每一跳属性变化的网络同步("每秒扣了多少血")

每一跳走的是属性复制通道 (改 BaseValue → 通过 DOREPLIFETIME 同步),不是再复制一次 GE。GE 在容器里自始至终只有那一个实例。

复制代码
服务器端的周期执行流程:

  Duration GE(持续 10s,Period = 1.0s)被应用
    ↓
  服务器维护计时器(基于服务器世界时间 StartServerWorldTime)
    ↓
  每隔 1.0s 触发一次 ExecutePeriodicEffect()
    ├── 按 Instant 路径执行 Modifier/Execution → 修改 BaseValue
    ├── BaseValue 变化通过 DOREPLIFETIME 复制到客户端
    └── 如果 GE 配置了 Execute 类型的 GameplayCue,触发一次性 Cue

  关键点:
  ├── 计时完全由服务器控制(防止客户端篡改 DOT 频率)
  ├── 客户端不需要自己计时和执行(只被动接收属性变化)
  └── 客户端知道 GE 存在(通过 GE 复制),但不主动执行周期逻辑

通俗理解:服务器说"你中了 10 秒的毒,每秒掉 10 血",然后:

  • GE 复制告诉客户端"你中毒了"(显示图标)
  • 每秒服务器扣 10 血(改 BaseValue)→ 属性复制告诉客户端"你现在 90 血了"..."80 血了"...
  • 客户端不需要自己数秒,只管显示服务器告诉它的血量
三种 GE 的网络流量对比
复制代码
普通 Duration GE(如 "+50攻击力 持续30秒"):
  GE 复制 2 次(加入 + 移除),中间无额外流量

周期性 GE(如 "每秒扣10血 持续10秒"):
  GE 复制 2 次 + 属性复制 10 次(每跳一次)= 共 12 次

Instant GE(如 "受到50伤害"):
  属性复制 1 次,GE 不进容器 = 共 1 次
周期性 GE 与 GameplayCue 的配合

周期性 GE 可以同时配置两种 Cue:

  • WhileActive Cue(持续性 Cue):中毒光环特效,跟随 GE 生命周期

  • Execute Cue(每跳触发的 Cue):每秒扣血时弹出的伤害数字

    ┌─── GE 存在期间 ───────────────────────────┐
    │ WhileActive Cue: 中毒绿色光环持续显示 │
    │ │
    │ T=0s T=1s T=2s T=3s ... T=10s │
    │ ↓ ↓ ↓ ↓ ↓ │
    │ Execute Execute Execute GE移除 │
    │ Cue Cue Cue Cue停止 │
    │ (-10) (-10) (-10) │
    └────────────────────────────────────────────┘

6.5 Stacking(堆叠)GE 的网络同步

堆叠 GE 是多人游戏中非常常见的机制(如"中毒可叠 5 层"、"攻速 Buff 可叠 3 层")。它的网络同步比普通 GE 更复杂。

堆叠类型
cpp 复制代码
// 引擎源码
UENUM(BlueprintType)
namespace EGameplayEffectStackingType
{
    enum Type
    {
        None,              // 不堆叠:每次施加都创建独立 GE 实例
        AggregateBySource, // 按施加者堆叠:同一施加者的多次施加叠加在同一个 GE 实例
        AggregateByTarget  // 按目标堆叠:无论谁施加,同种 GE 只有一个实例(层数叠加)
    };
}

通俗理解

  • None:A 对 B 放 3 次毒 → B 身上有 3 个独立的毒 GE(各自计时、各自生效)
  • AggregateBySource:A 对 B 放 3 次毒 → B 身上只有 1 个来自 A 的毒 GE(3 层);C 对 B 放毒 → 另一个 1 层的毒 GE
  • AggregateByTarget:A 和 C 对 B 各放 1 次毒 → B 身上只有 1 个毒 GE(2 层,不区分来源)
堆叠 GE 的网络复制结构
cpp 复制代码
// FActiveGameplayEffect 中与堆叠相关的复制字段
struct FActiveGameplayEffect : public FFastArraySerializerItem
{
    FGameplayEffectSpec Spec;           // GE 规格
    float StartServerWorldTime;         // GE 开始时间(服务器世界时间)
    float CachedStartServerWorldTime;   // 缓存的开始时间
    float StartWorldTime;               // 本地开始时间
    
    // 【堆叠核心】
    int32 ClientCachedStackCount;       // 客户端缓存的堆叠数(用于检测变化)
    // StackCount 存储在 Spec.StackCount 中
};
堆叠变化时的网络同步流程
复制代码
场景:玩家 A 对 Boss 施加"灼烧" Debuff(AggregateByTarget,最大 5 层)

第 1 次施加:
  服务器:创建 FActiveGameplayEffect(StackCount = 1)
  → MarkItemDirty() → FFastArraySerializer 复制
  客户端:PostReplicatedAdd() → 显示灼烧图标,1 层

第 2 次施加:
  服务器:找到已有的灼烧 GE → StackCount 从 1 变为 2
  → MarkItemDirty() → FFastArraySerializer 增量复制
  客户端:PostReplicatedChange() → 更新 UI 显示 2 层

第 3 次施加(同时配置了 StackDurationRefreshPolicy = RefreshOnSuccessfulApplication):
  服务器:StackCount 从 2 变为 3 + 重置持续时间
  → MarkItemDirty() → 复制新的 StackCount + 新的 StartServerWorldTime
  客户端:PostReplicatedChange() → 更新层数 + 重置持续时间显示

层数减少(1 层到期或被移除):
  服务器:StackCount 从 3 变为 2
  → MarkItemDirty() → 增量复制
  客户端:PostReplicatedChange() → 更新 UI 显示 2 层

所有层数移除(StackCount → 0):
  服务器:从 ActiveGE 容器删除整个 GE
  → FFastArraySerializer 标记删除
  客户端:PostReplicatedRemove() → 移除图标、停止特效
堆叠相关的策略配置(影响网络行为)
策略 选项 对网络的影响
StackDurationRefreshPolicy RefreshOnSuccessfulApplication / NeverRefresh 刷新时需要复制新的 StartServerWorldTime
StackPeriodResetPolicy ResetOnSuccessfulApplication / NeverReset 重置时需要复制新的周期计时器状态
StackExpirationPolicy ClearEntireStack / RemoveSingleStackAndRefreshDuration / RefreshDuration 决定到期时删除整个 GE 还是减一层(影响 Remove vs Change 回调)
StackLimitCount 最大层数 达到上限时不再产生新的复制变化
堆叠溢出(Overflow)

当堆叠达到上限后再次施加,可以配置溢出效果(OverflowEffects):

复制代码
场景:最大 5 层,当前已 5 层,再次施加

  服务器:检测到溢出
  → 不增加层数(已达上限,不产生 StackCount 的复制变化)
  → 但可以触发 OverflowEffects(如施加一个"引爆"GE)
  → 引爆 GE 是一个新的 GE,走正常的复制/同步流程

6.6 底层传输方式速览

不同类型的 GE 走完全不同的网络通道

GE 类型 底层通道 具体机制
Instant GE 属性复制(DOREPLIFETIME) GE 本身不过网络,只有修改后的 Attribute 走属性复制
Duration/Infinite GE 属性复制(FFastArraySerializer 增量) GE 实例存在 ActiveGameplayEffects 里,作为 Replicated 字段增量同步
Periodic GE 每一跳 属性复制(DOREPLIFETIME) 每跳改 BaseValue,靠属性本身走网络(GE 不重新复制)
Stacking GE 的 StackCount 变化 属性复制(FFastArraySerializer 的 PostReplicatedChange) StackCount 字段变了 → MarkItemDirty → 增量复制

注意 :GE 完全不使用 RPC。所有 GE 相关的数据流都是"状态"型的,走属性复制通道。


七、Attribute(属性)的网络复制机制

前面讲了 GE 的复制、Tag 的复制,这一章专门讲 Attribute(属性本身)的复制。因为有些属性变化是不走 GE 路径的,必须靠属性复制单独同步。

7.1 为什么 Attribute 需要单独复制?

前面讨论过,GAS 里改属性有两条路径:

复制代码
路径 1:Instant GE / 周期性 GE 的每一跳 → 直接改 BaseValue → GE 改完就销毁
路径 2:Duration/Infinite GE 的持续 Modifier → 挂在 Aggregator 里 → 通过 GE 复制同步

路径 2 的属性变化靠 GE 复制就够了------客户端收到 GE,本地挂上 Modifier,自己算出 CurrentValue,两边自然一致。

但路径 1 呢? Instant GE 应用完就销毁了,根本不会被网络复制传过去;周期性 GE 每一跳改的也是 BaseValue,BaseValue 的变化同样不会通过 GE 复制传达。这些情况下客户端怎么知道属性变了?

答案是:Attribute 本身必须标记为 Replicated,走独立的属性复制通道。

而事实上, 它走的逻辑就是UE那套原生的网络同步的逻辑。

7.2 三个关键要素

联网项目中,AttributeSet 里的每个属性都需要同时做这三件事:

ReplicatedUsing 声明
cpp 复制代码
UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health, Category = "Health")
FGameplayAttributeData Health;
  • ReplicatedUsing = OnRep_Health 告诉 UE:"这个属性要复制,值变化时调用 OnRep_Health 回调"
  • 这是标记属性需要走网络的基础声明
② 在 GetLifetimeReplicatedProps 中注册
cpp 复制代码
void UMyAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME_CONDITION_NOTIFY(UMyAttributeSet, Health, COND_None, REPNOTIFY_Always);
}
  • DOREPLIFETIME_CONDITION_NOTIFY 宏完成"注册复制 + 配置复制条件 + 绑定 OnRep"三件事
  • COND_None:不附加任何条件,所有相关客户端都能收到
  • REPNOTIFY_Always 非常关键:默认情况下如果新值和旧值相同就不触发 OnRep,但 GAS 需要每次都触发(因为可能有连续相同的数值变化,如连续被扣 10 血两次,最终值没变但需要两次触发)
OnRep 回调中调用 GAS 专用宏
cpp 复制代码
void UMyAttributeSet::OnRep_Health(const FGameplayAttributeData& OldHealth)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(UMyAttributeSet, Health, OldHealth);
}
  • GAMEPLAYATTRIBUTE_REPNOTIFY 是 GAS 提供的专用宏
  • 它不仅完成"通知 UI 值变了"的基础功能,还会通知 Aggregator 这个属性是从服务器同步来的,确保客户端的预测/回滚逻辑能正确处理这次变化

少了任何一个,属性都同步不对

  • 少①→ 属性根本不复制
  • 少②→ UE 不知道要复制哪个字段
  • 少③→ 客户端 Aggregator 状态混乱,预测回滚失效

7.3 复制时机与触发方式

属性复制不是每帧都推,而是在值发生变化时由 UE 的网络系统自动处理:

复制代码
服务器端:
  ① Instant GE 改了 BaseValue
  ② UE 网络系统检测到 Health 字段变化 → 标记为脏数据
  ③ 下次网络 Tick 时,把新值打包发送给所有相关客户端
  
客户端端:
  ④ 收到新值,写入本地的 Health
  ⑤ 触发 OnRep_Health 回调
  ⑥ GAMEPLAYATTRIBUTE_REPNOTIFY 宏通知 Aggregator 并广播变化事件
  ⑦ UI 收到事件,更新血条

7.4 Attribute 复制 vs GE 复制:分工明确

变化类型 靠什么同步 举例
BaseValue 变化 属性复制(DOREPLIFETIME) 受到 50 点伤害、升级加力量、DOT 每秒扣血
Modifier 增减导致的 CurrentValue 变化 GE 复制(FFastArraySerializer),客户端收到 GE 后自己挂 Modifier 自己算 加速 Buff、装备加攻

换句话说

  • BaseValue 就在属性字段里,它的变化只能靠属性本身走网络
  • CurrentValue 是算出来的,客户端根据本地的 BaseValue + 本地的 Modifier 列表自己算,所以不需要单独复制

7.5 底层传输方式速览

Attribute 的网络同步完全走 UE 原生的属性复制系统,不使用任何 RPC:

  • 传输通道DOREPLIFETIME_CONDITION_NOTIFY 注册的标准属性复制
  • 触发时机:服务器值变化 → 标记脏数据 → 下次网络 Tick 打包发送
  • 回调机制 :客户端通过 ReplicatedUsing = OnRep_XXX 的 RepNotify 触发回调
  • GAS 专用包装GAMEPLAYATTRIBUTE_REPNOTIFY 宏在 OnRep 里多做一步------通知 Aggregator 重算 CurrentValue、广播 AttributeValueChangeDelegate 给 UI

这是 GAS 里最"标准"的一条复制通道------和你自己写 UPROPERTY(Replicated) float Health 走的是同一套引擎机制,只是 GAS 用宏包装了一下。


八、GameplayTag 的网络复制机制

GameplayTag 是 GAS 的"状态标识系统"------用一个层级化的字符串标签来标记角色处于什么状态(死亡、眩晕、无敌、蓄力中...)。它在联网游戏中必须正确同步,否则会出现"服务器说你死了,客户端还觉得你活着"这种严重不一致。

8.1 Tag 在 GAS 中的作用与类型

先理解 Tag 是干什么的:

Tag 的用途 举例
标记角色状态 Status.DeadStatus.StunnedStatus.Invincible
控制技能激活条件 技能要求角色有 Combat.InCombat 才能放
阻断其他技能 Status.Stunned 会阻止所有技能激活
触发事件 Event.OnHitEvent.OnKill
标识输入 InputTag.AttackInputTag.Dodge(WarriorRPG 在用这个)

ASC 里维护了 Tag 的两种主要存储:

cpp 复制代码
// ASC 内部维护的 Tag 容器(简化示意)

1. GameplayTagCountContainer(引用计数容器)
   → 存储所有活跃 Tag,每个 Tag 带一个引用计数
   → 多个 GE 同时授予相同 Tag 时,计数叠加(如 3 个 Buff 都给 "Status.Hasted",计数=3)
   → 只有计数变为 0 时才真正视为"Tag 移除"

2. MinimalReplicationTags(最小复制 Tag Map)
   → 一个 FMinimalReplicationTagCountMap 类型的复制字段
   → 专门用于在 Mixed/Minimal 模式下"把关键 Tag 同步给所有客户端"

8.2 Tag 复制的两条路径详解

路径 1:跟随 GE 复制(Full 模式下的主要方式)
复制代码
服务器应用一个 Duration GE,GE 配置了 GrantedTags = "Buff.Hasted"
    ↓
GE 通过 FFastArraySerializer 复制到客户端(Full 模式下所有客户端都收得到)
    ↓
客户端 PostReplicatedAdd() 触发 → 读取 GE 的 GrantedTags → 本地 TagCountContainer 计数 +1
    ↓
GE 移除时 → 客户端 PostReplicatedRemove() → TagCountContainer 计数 -1

优点:不额外消耗带宽(Tag 搭 GE 复制的便车)
缺点:只有复制到的客户端才能看到 Tag(Mixed 模式下非 Owner 客户端看不到 GE,自然也看不到 Tag)
路径 2:MinimalReplicationTags(独立复制通道)

Mixed 或 Minimal 模式下 ,GE 本身不复制给所有客户端,但有些关键 Tag(比如"死亡"、"眩晕")必须让所有玩家都看到 ------否则就会出现"别的玩家看到你还活着在走动,但实际上你已经死了"的诡异情况。

所以此时就会使用MinimalReplicationTags实现。

cpp 复制代码
// ASC 里的这个字段专门干这事
UPROPERTY(Replicated)
FMinimalReplicationTagCountMap MinimalReplicationTags;

我们知道,在Mixed或者Minmal的模式下应用GE的时候,它只会选择性的复制一部分。

** /但是为了能把tag也传播,此时会单独的将Tag放入到MinimalReplicationTags去复制。/ **

这样就保证其他没收到GE的客户端也能收到tag,不过这个tag只会保留最基础的信息。

复制代码
服务器在 Mixed/Minimal 模式下应用 GE:
  ① 服务器本地 TagCountContainer 加 Tag(完整信息)
  ② 同时把该 Tag 推入 MinimalReplicationTags(简化信息)
    ↓
  GE 本身只复制给 Owner 客户端(按 Mixed 规则)
  MinimalReplicationTags 作为独立字段复制给所有客户端
    ↓
客户端:
  Owner 客户端:两条路径都能收到,信息最完整
  其他客户端:只收到 MinimalReplicationTags,知道你有 "Status.Dead" 但不知道是哪个 GE 给的

8.3 Tag 复制的性能优化机制

Tag 的本质是 FName(字符串),直接复制字符串开销很大。GAS 做了两层优化:

优化 1:FGameplayTagNetIndex(索引复制)

存一个全局的tag和index的map,只需要传index即可。

复制代码
Tag 的名字(如 "Status.Combat.Hasted")很长,直接传几十字节字符串太浪费
→ GAS 维护一个 Tag 的全局索引表(所有客户端和服务器共享)
→ Tag 第一次复制时,双方通过表建立索引映射
→ 后续只传一个 uint16 的索引(2 字节)

效果:一个 Tag 的网络开销从几十字节降到 2 字节
优化 2:TagCountContainer(引用计数去重)

玩家身上的tag,会通过引用计数来记录次数。但是同步的时候,我们只关心tag的存在与否,所以我们只需要处理会让tag存在与否的那次同步即可。

复制代码
场景:角色身上同时挂了 3 个 Buff,都授予 "Status.Hasted" Tag

不优化:
  Buff A 加 Tag → 复制(1次)
  Buff B 加 Tag → 复制(1次)  ← 浪费,客户端本来就知道了
  Buff C 加 Tag → 复制(1次)  ← 浪费
  Buff A 移除 → 复制(1次)   ← 浪费,还有其他 Buff 在给这个 Tag
  Buff B 移除 → 复制(1次)   ← 浪费
  Buff C 移除 → 复制(1次)   ← 这次才该复制(Tag 真的没了)

优化后(TagCountContainer 引用计数):
  Buff A 加 Tag → Count 0→1 → 复制(1次)
  Buff B 加 Tag → Count 1→2 → 不复制(计数变化但 Tag 存在状态没变)
  Buff C 加 Tag → Count 2→3 → 不复制
  Buff A 移除 → Count 3→2 → 不复制
  Buff B 移除 → Count 2→1 → 不复制
  Buff C 移除 → Count 1→0 → 复制(Tag 真的没了)

  → 总共只复制 2 次(添加和最终移除),减少 66% 的复制流量

核心思想 :客户端只关心"Tag 在不在",不关心"有多少个 GE 在给这个 Tag"。所以只在 0 ↔ 非零 的边界触发复制。

8.4 Tag 对技能激活的影响(网络同步的重要性)

为什么 Tag 的同步这么重要?因为很多 GA 的激活条件都依赖 Tag:

cpp 复制代码
// GA 蓝图可以配置:
ActivationRequiredTags: ["Combat.InCombat"]     // 必须有这些 Tag 才能激活
ActivationBlockedTags:  ["Status.Stunned"]      // 有这些 Tag 就不能激活
复制代码
联网场景:
  服务器:玩家被眩晕 → 加 "Status.Stunned" Tag
    ↓
  服务器上玩家想释放技能 → Tag 检查失败 → 不能释放 ✅

  但如果这个 Tag 没同步到客户端:
    客户端:玩家不知道自己被眩晕 → 以为可以释放 → 本地预测激活技能
    服务器:拒绝激活 → 客户端回滚 → 玩家看到"技能释放失败"的画面闪动
    → 用户体验很差

所以正确配置 Tag 复制(选对 ReplicationMode + 用对 LooseTag vs GE 授予方式)对多人游戏手感至关重要。

8.5 底层传输方式速览

Tag 走两条独立的属性复制通道,都不用 RPC:

路径 底层通道 对应字段
路径 1:跟 GE 走 属性复制(FFastArraySerializer) ActiveGameplayEffects 的便车,Tag 信息嵌在 GE 的 FGameplayEffectSpec.GrantedTags
路径 2:独立复制 属性复制(自定义 NetDeltaSerialize) MinimalReplicationTags 是 ASC 上一个 Replicated 字段,类型 FMinimalReplicationTagCountMap 有自己的增量序列化逻辑

优化机制也都在属性复制层:

  • FGameplayTagNetIndex:序列化时把 Tag 的 FName 转成 uint16 索引再写入网络包
  • TagCountContainer 引用计数:只在 0↔非零 边界才真正触发 MarkDirty

九、GameplayCue 的网络同步机制

GameplayCue是一些粒子特效的效果,一般通过 Multicast RPC​同步到全部的客户端,或者通过GE顺带去执行。

9.1 三种执行类型与网络行为

类型 用途 网络行为
Execute 受击特效、粒子爆发 Multicast RPC(一次性)
Add (OnActive/WhileActive) 持续 Buff 特效 跟随 GE 复制
Remove 停止持续特效 跟随 GE 移除复制

9.2 控制 GameplayCue 是否走网络

ASC有几种同步模式,

在Full 模式下 Cue 跟随 GE 复制;Full 模式 → Cue 搭 GE 的便车

Mixed/Minimal 模式下,Cue 走独立的走 Multicast RPC

复制代码
Mixed 模式下:GE 只发给 Owner 客户端,其他客户端收不到 GE
  问题:但所有人都该看到火焰特效啊!
  解决:服务器单独发一个 NetMulticast RPC 专门通知所有客户端"触发这个 Cue"

9.3 底层传输方式速览

GameplayCue 是 GAS 里唯一大量走 RPC 的表现系统(因为特效是"事件"不是"状态"):

Cue 类型 底层通道 具体机制
Execute(一次性) NetMulticast RPC(Unreliable) NetMulticastInvokeGameplayCueExecuted 系列函数,服务器一触发就广播给所有相关客户端
Add (OnActive/WhileActive) ------ Full 模式 属性复制(跟 GE 走 FFastArraySerializer) 客户端在 PostReplicatedAdd 里读 GE 的 GameplayCues 字段触发
Add (OnActive/WhileActive) ------ Mixed/Minimal 模式 NetMulticast RPC 因为 GE 不复制(或不全复制),改走 NetMulticastInvokeGameplayCueAdded 广播
Remove 同 Add 的对应模式 Full 下跟 GE 移除走;Mixed/Minimal 下走 NetMulticast...Removed RPC

为什么 Execute 一定走 Multicast 而不走属性复制:Execute 是"瞬时一击"(比如受击特效),没有"状态"可言,如果用属性复制会遇到"新加入的客户端拿不到历史事件"的问题。Multicast RPC 天然适合这种"广播一次性事件"。


十、Montage(动画蒙太奇)的网络同步

UE 原生有 CharacterMovementComponent 的动画同步,但那套是和在动画蓝图里播放动画的,蒙太奇的同步需要一套新的机制。
GAS 自己搞了一套独立的 Montage 同步机制,专门用来同步技能释放的动画。

多人动作游戏里,你看到其他玩家挥剑、翻滚、释放技能的动作,背后都靠这套机制。

10.1 为什么动画需要专门的网络同步?

通俗理解 :技能释放时服务器上播了一个"挥剑"动画,这个动画的信息(哪个蒙太奇、播到哪一帧、用什么速度、是不是被打断了)必须同步到所有客户端,才能有表现效果。

10.2 RepAnimMontageInfo ------ 动画信息的"网络快照"

GAS 在 ASC 里维护一个叫 FGameplayAbilityRepAnimMontage 的结构,作为"当前正在播放的动画快照"复制给客户端:

cpp 复制代码
// 引擎源码(简化)
USTRUCT()
struct FGameplayAbilityRepAnimMontage
{
    UAnimMontage* AnimMontage;     // 正在播放的蒙太奇(哪个动画)
    float PlayRate;                 // 播放速率(正常=1.0,加速=2.0)
    float Position;                 // 当前播放位置(播到第几秒)
    uint8 SectionIdToPlay;          // 当前播放的 Section(蒙太奇内可以分段)
    FPredictionKey PredictionKey;   // 关联的预测键(用于回滚)
    uint8 PlayInstanceId;           // 播放实例 ID(区分同一动画的多次播放)
};

每个字段的意义

字段 作用 举例
SectionIdToPlay 选择蒙太奇的哪一段 连击攻击:Section1=轻击1、Section2=轻击2
PredictionKey 识别这是哪次预测产生的 被拒绝时好找到对应的动画停掉
PlayInstanceId 防止重复播放同一个动画时的识别混乱 连续按两次攻击,两次播放要能区分

这个结构体作为 UPROPERTY(ReplicatedUsing = OnRep_ReplicatedAnimMontage) 复制,当服务器设置新值时,所有客户端会触发 OnRep_ReplicatedAnimMontage() 回调。

10.3 三种网络角色的完整同步流程

动画同步在不同网络角色上行为完全不同,必须分开讲:

服务器(Authority)------ 权威播放
复制代码
① GA 激活 → 调用 PlayMontageAndWait(AbilityTask)
    ↓
② AbilityTask 内部调用 ASC->PlayMontage()
    ↓
③ 服务器本地播放 Montage(SkeletalMesh 开始动画)
    ↓
④ 同时更新 RepAnimMontageInfo 结构:
    - AnimMontage = 当前蒙太奇
    - PlayRate = 播放速率
    - Position = 0.0(刚开始)
    - ...
    ↓
⑤ MarkDirty → UE 网络系统下次 Tick 时复制给所有客户端

服务器的角色是"权威裁判"------它记录当前"正在播什么动画",然后同步这个事实。

主控客户端(AutonomousProxy)------ 本地预测 + 预测键驱动

重要前提 :主控端不会 收到服务器的 RepAnimMontageInfo(因为 COND_SimulatedOnly)。主控端根本不需要"对比服务器数据"------它自己早就预测播了,服务器也不会再把这份信息传回来"打扰"它。

主控端靠的是 PredictionKey 的 Accepted/Rejected 回调来决定"保留动画还是停掉":

复制代码
① 玩家按下攻击键 → 本地 ASC 激活 LocalPredicted 技能
    ↓
② 生成 PredictionKey #42(关联这次激活)
    ↓
③ PlayMontageAndWait AbilityTask 启动,向 ASC 注册 PredictionKey 的 Rejected 回调
    ↓
④ 本地直接播放 Montage(写入 LocalAnimMontageInfo,关联 PredictionKey #42)
    ↓ 玩家立即看到自己挥剑(无延迟感)
    ↓
⑤ 向服务器发 ServerTryActivateAbility(#42) RPC,等待裁决
    ↓
⑥ 服务器响应:
    ├── Accepted → 对主控端完全透明,本地动画继续播完
    │              (服务器的 RepAnimMontageInfo 不会复制给主控端,主控端无感知)
    │
    └── Rejected → 触发之前注册的 PredictionKey #42 Rejected 委托
                   → AbilityTask_PlayMontageAndWait 在回调里
                     调用 StopMontage() 停止本地预测播放
                   → 动画中断(需要 Blend Out 避免硬切违和)

关键点总结

  • 主控端不做"对比 Montage 数据"的校验,这种校验是模拟端的事
  • 主控端的"是否回滚动画"完全由 PredictionKey 的生命周期回调 驱动
  • 预测成功 = 什么都不用做,动画已经在本地播得好好的
  • 预测失败 = PredictionKey 的 Rejected 回调触发 → AbilityTask 停止 Montage
模拟客户端(SimulatedProxy)------ 对比 RepAnimMontageInfo 实时校准

模拟端才是真正"对比服务器数据并校准本地动画"的角色。它的 OnRep_ReplicatedAnimMontage() 回调里有完整的对比逻辑:

复制代码
① 完全不知道服务器什么时候会播动画(被动等待)
    ↓
② 收到服务器的 RepAnimMontageInfo 复制
    ↓
③ OnRep_ReplicatedAnimMontage() 触发,在里面逐字段对比:
    ├── PlayInstanceId 变了?
    │   → 说明是新一次播放,从头开始播 + Seek 到 Position
    ├── AnimMontage 资源变了?
    │   → 停止当前 Montage,播服务器指定的新 Montage
    ├── SectionIdToPlay 变了?
    │   → 跳到新 Section
    ├── Position 偏差超过阈值?
    │   → Seek 到服务器的位置(延迟补偿 / 网络抖动修正)
    ├── PlayRate 变了?
    │   → 更新本地播放速率
    └── 停止标志变了?
        → 停止本地 Montage

关键点 :模拟端做的是状态同步 ------服务器说"我现在正在播这个动画,播到 0.5s 了",模拟端就把自己也调整到这个状态。Position 字段的用途有两个:

  1. 延迟补偿:因为网络有延迟(比如 100ms),当 RepAnimMontageInfo 到达模拟端时,服务器那边已经播到 0.1s 了。模拟端 Seek 到 0.1s 而不是从头播,避免"慢半拍"。
  2. 漂移校正:播放过程中如果和服务器偏差过大(如模拟端因为丢帧落后了),Seek 到服务器位置强制对齐。

10.4 预测失败时的动画处理

预测失败是动作游戏里最容易出现违和感的地方。来看一个典型场景:

复制代码
客户端按下"重攻击"键(技能需要满愤怒槽)
    ↓
客户端本地:立即播放"重攻击"动画(预测执行)
    ↓
服务器收到 RPC,检查愤怒槽 → 发现实际上愤怒值不够(延迟中被别的消耗掉了)
    ↓
服务器拒绝激活技能
    ↓
客户端收到 PredictionKey Rejected
    ↓
客户端需要停止那个正在播的"重攻击"动画

如果处理得好 :动画用 Blend Out(淡出)到普通状态,玩家几乎看不出异常。
如果处理得不好:动画硬切中断,玩家看到自己挥到一半突然"弹回"原姿,非常出戏。

GAS 的做法是通过 FPredictionKey 绑定------每个预测播放的 Montage 都关联一个 PredictionKey,预测失败时根据 PredictionKey 找到对应的 Montage 并停掉。

10.5 ForceReplication ------ 紧急情况下的强制同步

网络复制默认是"下次 Tick 时打包发送",有一定延迟。某些紧急场景希望立即同步:

cpp 复制代码
AbilitySystemComponent->CurrentMontageStop();  // 停止当前 Montage
AbilitySystemComponent->ForceReplication();    // 强制立即复制给所有客户端

典型场景

  • 角色死亡时,要立即让所有人看到死亡动画,不能等下次 Tick
  • 被强控打断技能时,要立即中断当前动画
  • 场景切换时,需要立刻同步最终状态

10.6 底层传输方式速览

Montage 同步纯走属性复制,不使用 RPC:

  • 传输通道 :ASC 里的 RepAnimMontageInfo 是一个 UPROPERTY(ReplicatedUsing = OnRep_ReplicatedAnimMontage) 字段
  • 复制条件 :带 COND_SimulatedOnly ------ 只复制给模拟端,主控端收不到
  • 触发回调 :模拟端通过 RepNotify(OnRep_ReplicatedAnimMontage)在回调里逐字段对比并校准本地动画
  • 紧急强制同步ForceReplication() 强制让 UE 网络系统在本次 Tick 立即发送,而不等下次常规 Tick

为什么不用 RPC :Montage 是"当前正在播什么动画"这种状态,新加入的客户端也需要立刻看到"这家伙正挥剑挥到一半"。属性复制天然保证"最终一致性",RPC 做不到。


十一、GAS 底层传输方式汇总表

把第四章到第十章的所有机制,按"走属性复制"还是"走 RPC"归类汇总。看完这张表就能整体把握 GAS 到底是怎么用 UE 网络系统的。

走属性复制(Replicated 字段 + DOREPLIFETIME)

数据 所在位置 特殊机制
ActiveGameplayEffects ASC 字段 FFastArraySerializer 增量复制
MinimalReplicationTags ASC 字段 自定义 NetDeltaSerialize,FName→NetIndex 索引优化
RepAnimMontageInfo ASC 字段 COND_SimulatedOnly 只给模拟端
AttributeSet 的每个属性 AttributeSet 字段 REPNOTIFY_Always + GAMEPLAYATTRIBUTE_REPNOTIFY
ActivatableAbilities(已授予的技能列表) ASC 字段 FFastArraySerializer 增量复制
GA 实例的 UPROPERTY 字段(ReplicateYes 时) GA 实例 标准 DOREPLIFETIME
GE 附带的 GameplayCue(Add/Remove,Full 模式) 搭 GE 便车 客户端读 GE 的 GameplayCues 字段触发

走 RPC

RPC 类型 函数名 方向 用途
Server RPC ServerTryActivateAbility 主控端 → 服务器 请求激活技能
Server RPC ServerSetReplicatedTargetData 主控端 → 服务器 上报目标数据
Server RPC ServerEndAbility 主控端 → 服务器 请求结束技能
Client RPC ClientActivateAbilitySucceed 服务器 → 主控端 通知预测成功(确认 PredictionKey)
Client RPC ClientActivateAbilityFailed 服务器 → 主控端 通知预测失败(拒绝 PredictionKey,触发回滚)
Client RPC ClientEndAbility 服务器 → 主控端 服务器主动结束技能
NetMulticast RPC NetMulticastInvokeGameplayCueExecuted 服务器 → 所有客户端 一次性 Cue 广播
NetMulticast RPC NetMulticastInvokeGameplayCueAdded/Removed 服务器 → 所有客户端 Mixed/Minimal 模式下的持续 Cue 广播

通道选择的底层逻辑

为什么 GAS 要这样分:

复制代码
状态型数据 → 属性复制
  ├── "角色身上挂着什么 Buff"(ActiveGameplayEffects)
  ├── "角色现在血量多少"(AttributeSet)
  ├── "角色正在播什么动画"(RepAnimMontageInfo)
  ├── "角色现在有哪些状态 Tag"(MinimalReplicationTags)
  └── 特点:最终一致性、新加入客户端能拿到完整状态、丢包也会对齐

事件型数据 → RPC
  ├── "我按键想放这个技能"(ServerTryActivateAbility)
  ├── "你那次预测我拒绝了"(ClientActivateAbilityFailed)
  ├── "播一下这个受击特效"(NetMulticastInvokeGameplayCueExecuted)
  └── 特点:一次性触发、错过就错过、不需要保留状态

这就是 GAS 底层通道选择的核心原则------不是看"重要不重要",而是看"有没有状态"


十二、TargetData(目标数据)的网络同步

12.1 TargetData 的作用

FGameplayAbilityTargetData 用于从客户端向服务器传递瞄准/命中信息

  • 玩家瞄准了哪个敌人
  • 技能释放时鼠标点击的世界坐标
  • AOE 范围内选中的目标列表

12.2 同步流程

复制代码
客户端创建 TargetData → ServerSetReplicatedTargetData() RPC →
服务器验证合法性 → 服务器使用 TargetData 执行逻辑

十三、GAS 网络同步完整架构图

复制代码
┌─────────────────────────────────────────────────────────────────────┐
│                        服务器(Authority)                            │
│  ┌─── AbilitySystemComponent ───┐                                   │
│  │  ActiveGameplayEffects       │ ── FFastArraySerializer ─────────→│
│  │  GrantedAbilities            │ ── 复制 ────────────────────────→│
│  │  GameplayTags (Minimal Repl) │ ── 最小复制 ───────────────────→│
│  │  AttributeSet                │ ── DOREPLIFETIME ──────────────→│
│  │  RepAnimMontageInfo          │ ── 属性复制 ───────────────────→│
│  └──────────────────────────────┘                                   │
│  ┌─── RPC 通道 ───┐                                                │
│  │  ServerTryActivateAbility()    ← 客户端激活请求                  │
│  │  ServerSetReplicatedTargetData ← 目标数据上报                    │
│  │  ClientActivateAbilityFailed() → 通知预测失败                    │
│  │  NetMulticast_InvokeGameplayCue→ Cue Execute广播                │
│  └─────────────────┘                                                │
└─────────────────────────────────────────────────────────────────────┘
                              ↕ 网络传输
┌─────────────────────────────────────────────────────────────────────┐
│                     客户端(AutonomousProxy)                         │
│  ┌─── AbilitySystemComponent ───┐                                   │
│  │  本地预测层:PredictionKey、预测 GE、预测 Montage、预测 Cue       │
│  │  服务器复制层:权威 GE、权威属性值、权威 Tags                     │
│  └──────────────────────────────┘                                   │
│  回滚:PredictionKey Rejected → 移除预测层 → 属性自然回滚           │
│  确认:PredictionKey Accepted → 预测层与复制层合并                   │
└─────────────────────────────────────────────────────────────────────┘

参考资料

相关推荐
田鸡_5 小时前
Unity新输入系统(Input System)教学篇
unity·游戏引擎·游戏程序
EQ-雪梨蛋花汤5 小时前
【Unity笔记】Unity 音游模板与免费资源:高效构建节奏游戏开发全指南
笔记·unity·游戏引擎
微莱羽墨5 小时前
零、0基础入门Unity 安装详细教程(2026最新版教程,安装Unity看这一篇就够了!)
unity·游戏引擎·unity安装
nnsix6 小时前
Unity 刚体的 默认力、瞬时力 区别
unity·游戏引擎
nnsix6 小时前
Unity Sprite的 Generate Physics Shape 参数解释
unity·游戏引擎
魔士于安6 小时前
Unity完整小球迷宫项目
前端·unity·游戏引擎·贴图·模型
め.6 小时前
Unity协程的原理
unity·游戏引擎
天人合一peng1 天前
unity 生成标记根据背景色标记变色
unity·游戏引擎
天人合一peng1 天前
unity 生成标记根据背景色变色为明显的颜色
unity·游戏引擎