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 RPC (
ServerTryActivateAbility,Reliable) - 激活确认/拒绝 :服务器 → 主控端,走 Client RPC (
ClientActivateAbilitySucceed/ClientActivateAbilityFailed) - GA 已授予列表(GrantedAbilities) :走属性复制 (ASC 里的
ActivatableAbilities是Replicated字段) - GA 实例内部字段 (ReplicateYes 时):走属性复制(DOREPLIFETIME)
- End/Cancel 请求 :走 Server RPC / Client RPC (
ServerEndAbility等)
技能系统是 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 模式下 ActiveGameplayEffects 用 COND_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 时:
- 创建一个
FActiveGameplayEffect实例,内含完整的 GE 规格(FGameplayEffectSpec,包括等级、堆叠数、Modifier 列表等) - 将这个实例添加到 ASC 的
ActiveGameplayEffects容器(本质是一个TArray<FActiveGameplayEffect>) - 调用
MarkItemDirty()通知 UE 的复制系统:"这个数组有新元素了,下次复制时把这条带上"
关键点 :MarkItemDirty() 不会立即发送网络包。它只是标记"脏数据", /等下一次网络 Tick 时,UE 的复制系统才会把变化打包发出去。/ 这意味着如果在同一帧内应用了 3 个 GE,它们会被合并在一次网络包中发送,而非发 3 个独立包。
FFastArraySerializer !!#ff0000 增量复制 !!------ "只告诉客户端变化了什么"
FFastArraySerializer 是 UE 提供的一种高效数组复制机制。它不会每帧全量复制整个数组(那样太浪费带宽),而是:
- 记录上次成功复制后的数组状态
- /对比当前状态,计算出"新增了哪些元素"、"修改了哪些元素"、"删除了哪些元素"/
- 只序列化差异部分发送给客户端
举例:如果角色身上有 20 个活跃 GE,但这帧只新加了 1 个,那网络上只传输这 1 个新 GE 的数据,而非全部 20 个。
② 客户端收到新 GE ------ PostReplicatedAdd()
客户端的 FActiveGameplayEffectsContainer 收到新元素后,自动调用 PostReplicatedAdd() 回调:
-
本地应用 GE Modifier :将 GE 中定义的属性修改器(如 "+50 最大生命值"、"+20% 移动速度")注册到属性的聚合器(Aggregator)中。此时
CurrentValue会改变,但BaseValue不变(Duration/Infinite GE 只影响 CurrentValue)。 -
重新计算属性值:聚合器汇总所有活跃 Modifier,重新计算该属性的最终值:
CurrentValue = BaseValue + Σ(Additive Modifiers) × Π(Multiplicative Modifiers) × Π(Division Modifiers) -
触发 GameplayCue :如果 GE 蓝图中配置了 GameplayCue Tags(如
GameplayCue.Buff.SpeedBoost),此时在客户端触发对应的 Cue(如播放加速光环特效)。这就是为什么 Buff 特效能在所有客户端看到------它跟随 GE 复制自动触发。
③ 服务器移除 GE ------ "白板上擦掉条目"
GE 到期(Duration 计时结束)或被代码手动移除时:
- 从
ActiveGameplayEffects容器中删除该FActiveGameplayEffect FFastArraySerializer检测到元素被删除,在下次复制时通知客户端"这条被移除了"
④ 客户端收到 GE 移除 ------ PostReplicatedRemove()
客户端收到删除通知后,自动调用 PostReplicatedRemove() 回调:
- 移除 Modifier:从属性聚合器中删除该 GE 的所有 属性修改器Modifier。
- 重新计算属性值:少了一个 Modifier 贡献后,属性值自然恢复(如 "+50 最大生命值" 的 Buff 到期 → 最大生命值减少 50)。
- 移除 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.Dead、Status.Stunned、Status.Invincible |
| 控制技能激活条件 | 技能要求角色有 Combat.InCombat 才能放 |
| 阻断其他技能 | Status.Stunned 会阻止所有技能激活 |
| 触发事件 | Event.OnHit、Event.OnKill |
| 标识输入 | InputTag.Attack、InputTag.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 字段的用途有两个:
- 延迟补偿:因为网络有延迟(比如 100ms),当 RepAnimMontageInfo 到达模拟端时,服务器那边已经播到 0.1s 了。模拟端 Seek 到 0.1s 而不是从头播,避免"慢半拍"。
- 漂移校正:播放过程中如果和服务器偏差过大(如模拟端因为丢帧落后了),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 → 预测层与复制层合并 │
└─────────────────────────────────────────────────────────────────────┘
参考资料
- Epic Games 引擎源码:
GameplayPrediction.h(预测系统设计文档注释) - Epic Games 引擎源码:
AbilitySystemComponent.h(ReplicationMode、RepAnimMontageInfo) - GASDocumentation (GitHub: tranek) --- GAS 社区最全面的文档
- UE5 GAS 预测框架解析(博客园) --- FPredictionKey 源码实践
- GAS 网络同步(知乎) --- GameplayPrediction.h 设计思路解读
- GAS 的预测 GameplayPrediction(知乎) --- 可预测操作分类
- GE 网络复制(知乎) --- ActiveGameplayEffects 复制机制
- GE 网络同步(知乎) --- GE 预测与同步流程
- GameplayTag 网络复制(知乎) --- MinimalReplicationTags 机制
- UE5 中的预测键与预测系统(CSDN) --- PredictionKey 深入分析
- GAS 属性复制与 RPC 机制(CSDN) --- Attribute 复制详解
- 虚幻 GAS 底层原理解剖十 - 网络(CSDN) --- 完整网络机制剖析
- GA 简介与配置说明(博客园) --- NetExecutionPolicy 与 NetSecurityPolicy 配合
- Montage 同步问题定位(知乎) --- RepAnimMontageInfo 实战分析