省流
- 客户端生成 FPredictionKey,立即本地执行,同时用 RPC 告知服务器——所有副作用(GE/Cue/属性)都绑定这个 Key
- 服务器验证后,把这个 Key 写入 FReplicatedPredictionKeyMap 复制下来——客户端 OnRep 触发 CatchUpTo,清理预测副作用,由服务器权威状态接管
- 若服务器拒绝,发 ClientActivateAbilityFailed RPC——触发 BroadcastRejectedDelegate,所有绑定了该 Key 的回调(移除GE、停止Cue、结束Ability)全部执行。
几个类
FPredictionKey
GameplayAbilities/Public/GameplayPrediction.h
USTRUCT()
struct FPredictionKey
{
typedef int16 KeyType;
UPROPERTY()
int16 Current = 0; // 当前唯一键值(全局递增)
UPROPERTY(NotReplicated)
int16 Base = 0; // 链式依赖中的祖先键(不复制,仅客户端本地使用)
UPROPERTY()
bool bIsServerInitiated = false; // 是否为服务器主动生成的键
private:
FObjectKey PredictiveConnectionObjectKey; // 记录"哪个连接给了我这个Key",用于NetSerialize过滤
};
在序列化中
bool FPredictionKey::NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess)
{
// ...
uint8 ValidKeyForConnection = 0;
if (Ar.IsSaving())
{
// 服务器主动生成的键,所有人都需要看到。服务器主动激活的 ServerOnly 能力
// Key还没有被任何连接关联, 是客户端首次发给服务器 客户端 RPC ServerTryActivateAbility(Key=42)
// 只回传给发起者, 服务器记住了"Key=42 来自玩家A 现在只对玩家A的连接写入真实值
ValidKeyForConnection = (Current > 0) &&
(bIsServerInitiated ||
(PredictiveConnectionObjectKey == FObjectKey()) ||
(PredictiveConnectionObjectKey == FObjectKey(Map)));
}
Ar.SerializeBits(&ValidKeyForConnection, 1);
// ...
if (Ar.IsLoading())
{
if (!bIsServerInitiated)
{
PredictiveConnectionObjectKey = FObjectKey(Map);
}
}
// ...
}
这3种情况。
- 服务器主动激活的能力
- A客户端预测激活能力,发给服务器服务器能收到,第二个或条件
- 服务器广播的时候,只有A客户端能收到合法的key,其他都是0,第三个条件。收到同样key的客户端就自己预测执行过了。(解决Redo问题)
判断是否是自己的预测key是接口 IsLocalClientKey()
FPredictionKeyDelegates
GameplayAbilities/Public/GameplayPrediction.h
struct FPredictionKeyDelegates
{
struct FDelegates
{
TArray<FPredictionKeyEvent> RejectedDelegates; // 被服务器拒绝时回调
TArray<FPredictionKeyEvent> CaughtUpDelegates; // 服务器复制状态追上时回调
};
TMap<FPredictionKey::KeyType, FDelegates> DelegateMap;
};
两个主要触发路径
- FPredictionKeyDelegates::Reject(Key) 广播 RejectedDelegates 回滚
- FPredictionKeyDelegates::CatchUpTo(Key) 广播 CaughtUpDelegates 清理预测副作用
FScopedPredictionWindow
// 客户端版本:自动生成新的 DependentPredictionKey
FScopedPredictionWindow(UAbilitySystemComponent* ASC, bool bCanGenerateNewKey = true);
// 服务器版本:接受客户端传入的 PredictionKey,设置到 ASC->ScopedPredictionKey
FScopedPredictionWindow(UAbilitySystemComponent* ASC, FPredictionKey InPredictionKey, bool bSetReplicatedPredictionKey = true);
不同参数的构造函数
析构的时候,压缩版面压缩了写法
FScopedPredictionWindow::~FScopedPredictionWindow()
{
if (SetReplicatedPredictionKey && OwnerPtr->ScopedPredictionKey.IsValidKey())
{
// 关键:服务器在此将 PredictionKey 写入 ReplicatedPredictionKeyMap,触发属性复制
// 客户端收到后调用 FReplicatedPredictionKeyItem::OnRep → CatchUpTo
OwnerPtr->ReplicatedPredictionKeyMap.ReplicatePredictionKey(OwnerPtr->ScopedPredictionKey);
}
}
FReplicatedPredictionKeyMap(环形缓冲区)
这里触发了ASC的 FReplicatedPredictionKeyMap(fast array)的数据变更,
struct FReplicatedPredictionKeyMap : public FFastArraySerializer
{
TArray<FReplicatedPredictionKeyItem> PredictionKeys; // 大小固定为 32(KeyRingBufferSize)
void ReplicatePredictionKey(FPredictionKey Key)
{
// 用 Key.Current % 32 决定写入哪个槽位,复用旧槽位
int32 Index = (Key.Current % KeyRingBufferSize);
PredictionKeys[Index].PredictionKey = Key;
MarkItemDirty(PredictionKeys[Index]);
}
};
这个dirty触发了 FReplicatedPredictionKeyItem::OnRep
USTRUCT()
struct FReplicatedPredictionKeyItem : public FFastArraySerializerItem
{
void PostReplicatedAdd(const struct FReplicatedPredictionKeyMap &InArray) { OnRep(InArray); }
void PostReplicatedChange(const struct FReplicatedPredictionKeyMap &InArray) { OnRep(InArray); }
}
客户端执行 FPredictionKeyDelegates::CatchUpTo(PredictionKey.Current); 预测没问题,成功析构了,撤销预测。
void FReplicatedPredictionKeyItem::OnRep(const FReplicatedPredictionKeyMap& InArray)
{
FPredictionKeyDelegates::CatchUpTo(PredictionKey.Current);
// ...
}
所以完整的时序是
Client (LocalPredicted) Server
───────────────────── ─────────
TryActivateAbility()
└─ InternalTryActivateAbility()
├─ FScopedPredictionWindow(ASC, true)
│ └─ ScopedPredictionKey.GenerateDependentPredictionKey()
│ 生成新键 Key=N, Base=上一个键
│
├─ ActivationInfo.SetPredicting(ScopedPredictionKey)
│
├─ CallServerTryActivateAbility(Handle, InputPressed, Key=N) ──RPC──►
│ InternalServerTryActivateAbility()
│ ├─ FScopedPredictionWindow(ASC, Key=N)
│ │ (设服务器 ScopedPredictionKey=N)
│ ├─ InternalTryActivateAbility(PredictionKey=N)
│ │ ├─ 验证 CanActivateAbility
│ │ │
│ │ ├─ [成功] CallActivateAbility()
│ │ │ ClientActivateAbilitySucceed(Key=N) ◄──RPC──
│ │ │
│ │ └─ [失败] ClientActivateAbilityFailed(Key=N) ◄──RPC──
│ │
│ ◄────────────────────────── FScopedPredictionWindow 析构 ─────┤
│ ReplicatedPredictionKeyMap.Add(N)
│ (属性复制,帧延迟)
│
├─ ScopedPredictionKey.NewCaughtUpDelegate()
│ 绑定 OnClientActivateAbilityCaughtUp(Handle, N)
│
└─ CallActivateAbility() ← 客户端立即本地执行!
副作用都带有 PredictionKey=N
收到 ClientActivateAbilityFailed(Key=N): 收到 ReplicatedPredictionKeyMap 更新(含N):
FPredictionKeyDelegates::BroadcastRejectedDelegate(N) FReplicatedPredictionKeyItem::OnRep()
↓ └─ FPredictionKeyDelegates::CatchUpTo(N)
所有 RejectedDelegates 执行(回滚)
关键代码
客户端预测段
// AbilitySystemComponent_Abilities.cpp - InternalTryActivateAbility()
// LocalPredicted 分支
else if (Ability->GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalPredicted)
{
// 生成预测键,开启预测窗口
FScopedPredictionWindow ScopedPredictionWindow(this, true);
ActivationInfo.SetPredicting(ScopedPredictionKey); // 标记为预测中
// 立即发出 RPC,不等结果
CallServerTryActivateAbility(Handle, Spec->InputPressed, ScopedPredictionKey);
// 注册"追上"回调:服务器复制确认后调用
ScopedPredictionKey.NewCaughtUpDelegate().BindUObject(
this, &UAbilitySystemComponent::OnClientActivateAbilityCaughtUp, Handle, ScopedPredictionKey.Current);
// 客户端立刻激活!
AbilitySource->CallActivateAbility(Handle, ActorInfo, ActivationInfo, ...);
}
服务器验证段
// AbilitySystemComponent_Abilities.cpp - InternalServerTryActivateAbility()
void UAbilitySystemComponent::InternalServerTryActivateAbility(...)
{
// 设置预测窗口(让 ScopedPredictionKey = 客户端传来的 Key)
FScopedPredictionWindow ScopedPredictionWindow(this, PredictionKey);
if (InternalTryActivateAbility(Handle, PredictionKey, &InstancedAbility, ...))
{
// 成功:ClientActivateAbilitySucceed 在 InternalTryActivateAbility 内部已发送
// FScopedPredictionWindow 析构时写入 ReplicatedPredictionKeyMap
}
else
{
// 失败:明确通知客户端拒绝
ClientActivateAbilityFailed(Handle, PredictionKey.Current);
}
// <- 窗口析构追上来
}
客户端收到技能失败拒绝
// AbilitySystemComponent_Abilities.cpp
void UAbilitySystemComponent::ClientActivateAbilityFailed_Implementation(FGameplayAbilitySpecHandle Handle, int16 PredictionKey)
{
// ① 广播拒绝委托 → 触发所有注册了该 Key 的回滚回调
if (PredictionKey > 0)
{
FPredictionKeyDelegates::BroadcastRejectedDelegate(PredictionKey); // RejectedDelegates static 单例
}
// ② 找到对应实例,标记为已拒绝,强制结束能力
TArray<UGameplayAbility*> Instances = Spec->GetAbilityInstances();
for (UGameplayAbility* Ability : Instances)
{
if (Ability->CurrentActivationInfo.GetActivationPredictionKey().Current == PredictionKey)
{
Ability->CurrentActivationInfo.SetActivationRejected();
Ability->K2_EndAbility(); // 结束能力,触发 EndAbility 清理逻辑
}
}
}
具体是怎么回滚的
GE
GameplayEffect 预测与回滚
// GameplayEffect.cpp - FActiveGameplayEffectsContainer::ApplyGameplayEffectSpec()
if (InPredictionKey.IsLocalClientKey() == false || IsNetAuthority())
{
// 服务器或非预测:正常标记为 Dirty,触发网络复制
MarkItemDirty(*AppliedActiveGE);
}
else
{
// 客户端预测:只标记数组 Dirty(不触发服务器复制)
MarkArrayDirty();
// ★ 核心:注册"一旦追上或被拒绝,就立刻移除这个预测 GE"
InPredictionKey.NewRejectOrCaughtUpDelegate(
FPredictionKeyEvent::CreateUObject(
Owner,
&UAbilitySystemComponent::RemoveActiveGameplayEffect_AllowClientRemoval,
AppliedActiveGE->Handle, -1
)
);
}
NewRejectOrCaughtUpDelegate 无论服务器拒绝,还是追上,都会删除本地预测GE。
Redo 问题
// GameplayEffect.cpp - FActiveGameplayEffect::PostReplicatedAdd()
void FActiveGameplayEffect::PostReplicatedAdd(const FActiveGameplayEffectsContainer& InArray)
{
bool ShouldInvokeGameplayCueEvents = true;
if (PredictionKey.IsLocalClientKey())
{
// 如果本地已有相同 Key 的预测 GE,则不重复播放 GameplayCues(Redo 保护)
if (InArray.HasPredictedEffectWithPredictedKey(PredictionKey))
{
ShouldInvokeGameplayCueEvents = false;
}
}
// 继续添加 Modifiers、Tags,但跳过 Cue 事件
constexpr bool bInvokeGameplayCueEvents = false;
InternalOnActiveGameplayEffectAdded(*this, bInvokeGameplayCueEvents);
}
实现方式: 客户端把即时效果(Instant GE)当作无限期效果(Infinite Duration GE)来处理,这样:
- Modifier 以增量形式存在,不直接修改 BaseValue
- 服务器复制来的属性值作为新 BaseValue,然后重新累加所有激活的 Modifier 得到 CurrentValue
属性最终值 = (服务器 BaseValue) + (所有激活 Modifier 之和,包括预测 Modifier)
- 属性的 RepNotify 必须用
REPNOTIFY_Always(即使值没变化也触发,因为本地已预测改变) - RepNotify 内调用
GAMEPLAYATTRIBUTE_REPNOTIFY→ 触发ActiveGameplayEffects.SetAttributeBaseValue→ 重新聚合
抽象:具体一点
这是非预测正常的GE
// AbilitySystemComponent.cpp L953 — 非预测 Instant 路径
else if (Spec.Def->DurationPolicy == EGameplayEffectDurationType::Instant)
{
// 非预测的 Instant GE 不会加入 ActiveGameplayEffects 容器!
ExecuteGameplayEffect(*OurCopyOfSpec, PredictionKey);
}
ExecuteGameplayEffect → ExecuteActiveEffectsFrom → InternalExecuteMod → ApplyModToAttribute:
bool bTreatAsInfiniteDuration = GetOwnerRole() != ROLE_Authority // 不是服务器
&& PredictionKey.IsLocalClientKey() // 有本地预测键
&& Spec.Def->DurationPolicy == EGameplayEffectDurationType::Instant;// 原本是 Instant
瞬时的GE,改成无限的,不跑GE,直接改BaseValue
// AbilitySystemComponent.cpp L935
if (bTreatAsInfiniteDuration)
{
// ★ 不调用 ExecuteGameplayEffect!只触发 GameplayCue
// (in non predictive case, this will happen inside ::ExecuteGameplayEffect)
if (!bSuppressGameplayCues)
{
UAbilitySystemGlobals::Get().GetGameplayCueManager()->InvokeGameplayCueExecuted_FromSpec(...);
}
}
else if (Spec.Def->DurationPolicy == EGameplayEffectDurationType::Instant)
{
// 只有非预测的 Instant 才走这里(直接改 BaseValue)
ExecuteGameplayEffect(*OurCopyOfSpec, PredictionKey);
}
| 步骤 | 非预测 Instant GE(服务器) | 预测 Instant GE(客户端) |
|---|---|---|
| 加入容器? | ✗ 不加入 ActiveGameplayEffects |
✓ 伪装成 Infinite,加入容器 |
| 修改 BaseValue? | ✓ ApplyModToAttribute 直接改 |
✗ 不改 BaseValue |
| Modifier 如何存在? | 不存在(已合入 BaseValue) | 作为 Aggregator 中的 Mod 存在 |
| 属性计算方式 | CurrentValue = BaseValue(已改) |
CurrentValue = BaseValue + Σ(Modifiers) |
| 如何回滚? | 不需要(服务器权威) | 移除这个伪 Infinite GE → Modifier 消失 → 自动回滚 |
这个 90->100 换成 90 +10 的代码在那。无限的GE,FActiveGameplayEffectsContainer::AddActiveGameplayEffectGrantedTagsAndModifiers 这里面就是写modify而不是合并base的。
===
这时候我们还有一个疑惑。容器中会存在两个GE,它们是如何处理接管的?
① 客户端预测 ApplyGE → GameplayEffects_Internal 中新增一条
PredictionKey = {Current=42, WasReceived=false} ← 本地生成
② 服务器复制的 GE 到达 → PostReplicatedAdd → 又新增一条
PredictionKey = {Current=42, WasReceived=true} ← 通过 NetSerialize 收到
此刻容器里有 两条同 Key 的 GE(一条 WasReceived=false,一条 WasReceived=true)
③ CatchUpTo(42) 触发 → RemoveActiveGameplayEffect_AllowClientRemoval
→ 移除预测版(WasReceived=false 的那条)
容器恢复为只有服务器权威版
这时候要保证cue不重复触发,属性修改和TAG的效果一致。
GameplayCue
ASC上有几个这个容器,应该都有用吧
这是添加Cue的时候,一路跑过来的。AddGameplayCue_Internal,
// GameplayCueInterface.cpp - FActiveGameplayCueContainer::PredictiveAdd()
void FActiveGameplayCueContainer::PredictiveAdd(const FGameplayTag& Tag, FPredictionKey& PredictionKey)
{
Owner->UpdateTagMap(Tag, 1); // 立即更新本地 Tag 计数
// 注册:Key 被拒绝或追上时,调用 OnPredictiveGameplayCueCatchup 移除 Cue,很普通的一个接口
PredictionKey.NewRejectOrCaughtUpDelegate(
FPredictionKeyEvent::CreateUObject(
ToRawPtr(Owner), &UAbilitySystemComponent::OnPredictiveGameplayCueCatchup, Tag
)
);
}
服务器复制到来:跳过重复 Cue(Redo 保护)
// GameplayCueInterface.cpp - FActiveGameplayCue::PostReplicatedAdd()
void FActiveGameplayCue::PostReplicatedAdd(const FActiveGameplayCueContainer& InArray)
{
InArray.Owner->UpdateTagMap(GameplayCueTag, 1);
if (PredictionKey.IsLocalClientKey() == false)
{
// 只有当 Key 不是本客户端预测的,才播放 WhileActive 事件
// 如果是本地预测的,说明已经播放过,跳过(Redo 保护)
InArray.Owner->InvokeGameplayCueEvent(GameplayCueTag, EGameplayCueEvent::WhileActive, Parameters);
}
}
关于重复的处理
① 客户端预测 PredictiveAdd(Tag)
→ Owner->UpdateTagMap(Tag, +1) ← 只是 LooseGameplayTag 计数 +1
→ 不往 GameplayCues 数组里加任何东西!
→ 注册 NewRejectOrCaughtUpDelegate
② 服务器 AddCue(Tag) → 复制到客户端 → PostReplicatedAdd
→ Owner->UpdateTagMap(Tag, +1) ← Tag 计数变为 2
→ PredictionKey.IsLocalClientKey() == true → 跳过 InvokeGameplayCueEvent(WhileActive)
此刻:GameplayCues 数组里只有 1 条(服务器的),但 Tag 计数 = 2
③ CatchUpTo 触发 → OnPredictiveGameplayCueCatchup(Tag)
→ RemoveLooseGameplayTag(Tag) ← Tag 计数 -1,变回 1
→ HasMatchingGameplayTag(Tag) 仍为 true(服务器的那条还在)
→ 不触发 Removed 事件
最终:GameplayCues 数组 1 条(服务器权威版),Tag 计数 = 1,一切正确
蒙太奇
不走 PredictionKey 的 CatchUpTo 路径,使用属性复制 + Rejected 回调。
Abilities/GameplayAbilityRepAnimMontage.h
USTRUCT()
struct FGameplayAbilityRepAnimMontage
{
UPROPERTY()
TObjectPtr<UAnimSequenceBase> Animation; // 蒙太奇/动画序列引用
UPROPERTY()
float PlayRate; // 播放速率
UPROPERTY(NotReplicated)
float Position; // 播放位置(量化后复制)
UPROPERTY()
float BlendTime; // 混合时间
UPROPERTY()
uint8 NextSectionID; // 下一 Section ID
UPROPERTY()
uint8 PlayInstanceId; // 每次播放递增,区分同蒙太奇多次播放
UPROPERTY()
uint8 IsStopped : 1; // 是否已停止
UPROPERTY()
uint8 SkipPositionCorrection : 1; // 不同步位置
UPROPERTY()
FPredictionKey PredictionKey; // 预测键(用于定向复制)
};
这个结构通过 DOREPLIFETIME 复制给 非本地控制的客户端(SimulatedProxy),本地控制的客户端自己播放预测版本。
播放阶段:注册拒绝回调
// AbilitySystemComponent_Abilities.cpp - PlayMontage->PlayMontageInternal (约 L3095-L3107)
// 服务器端:
if (IsOwnerActorAuthoritative())
{
// 更新 RepAnimMontageInfo,通过属性复制给其他客户端
AnimMontage_UpdateReplicatedData();
AbilityActorInfo->AvatarActor->ForceNetUpdate();
}
// 客户端预测:
else
{
FPredictionKey PredictionKey = GetPredictionKeyForNewAction();
if (PredictionKey.IsValidKey())
{
// ★ 关键:如果预测键被拒绝,停止蒙太奇
// 注意 这里只是 Rejected
PredictionKey.NewRejectedDelegate().BindUObject(
this, &UAbilitySystemComponent::OnPredictiveMontageRejected, NewAnimMontage);
}
}
拒绝的时候,以0.25的淡出停止
// AbilitySystemComponent_Abilities.cpp L3227
void UAbilitySystemComponent::OnPredictiveMontageRejected(UAnimMontage* PredictiveMontage)
{
static const float MONTAGE_PREDICTION_REJECT_FADETIME = 0.25f;
UAnimInstance* AnimInstance = AbilityActorInfo.IsValid() ? AbilityActorInfo->GetAnimInstance() : nullptr;
if (AnimInstance && PredictiveMontage)
{
// 如果蒙太奇还在播放,立即以 0.25s 淡出停止
if (AnimInstance->Montage_IsPlaying(PredictiveMontage))
{
AnimInstance->Montage_Stop(MONTAGE_PREDICTION_REJECT_FADETIME, PredictiveMontage);
}
}
}
OnRep_ReplicatedAnimMontage 服务器的矫正
// AbilitySystemComponent_Abilities.cpp L3249 - 用于 SimulatedProxy 和 非预测客户端
void UAbilitySystemComponent::OnRep_ReplicatedAnimMontage()
{
// 只有非本地控制时才处理
if (!AbilityActorInfo->IsLocallyControlled())
{
// 新蒙太奇:PlayMontageSimulated
if (LocalAnimation != RepAnimMontageInfo.Animation || PlayInstanceId 变化)
PlayMontageSimulated(...);
// 播放速率校正
if (Montage_GetPlayRate != RepAnimMontageInfo.PlayRate)
Montage_SetPlayRate(...);
// 停止
if (RepAnimMontageInfo.IsStopped && !bIsStopped)
CurrentMontageStop(BlendTime);
// 位置校正:误差超过阈值时强制跳转
if (FMath::Abs(DeltaPosition) > MONTAGE_REP_POS_ERR_THRESH)
Montage_SetPosition(RepAnimMontageInfo.Position);
// Section 校正
if (NextSectionID != RepNextSectionID)
Montage_SetNextSection(...);
}
}
Client (预测控制) Server
─────────────── ─────────
PlayMontage(M) 本地立即播放
└─ PredictionKey.NewRejectedDelegate ServerTryActivateAbility
→ OnPredictiveMontageRejected(M) │
├─ 验证成功: PlayMontage(M)
│ AnimMontage_UpdateReplicatedData()
│ RepAnimMontageInfo → 其他客户端
│
└─ 验证失败: ClientActivateAbilityFailed
→ BroadcastRejectedDelegate
→ OnPredictiveMontageRejected(M)
→ Montage_Stop(0.25s, M) ← 回滚!
自定义业务
GAS 的预测系统设计了委托注册机制,允许业务代码挂载自定义回滚逻辑。核心 API 有三个:
// FPredictionKey 上的三个方法:
// ① 仅在拒绝时触发
FPredictionKeyEvent& NewRejectedDelegate();
// ② 仅在追上时触发
FPredictionKeyEvent& NewCaughtUpDelegate();
// ③ 拒绝或追上都触发(最常用)
void NewRejectOrCaughtUpDelegate(FPredictionKeyEvent Event);
| 注册位置 | 使用的 API | 绑定的回调 | 用途 |
|---|---|---|---|
GE 应用 (GameplayEffect.cpp) |
NewRejectOrCaughtUpDelegate |
RemoveActiveGameplayEffect_AllowClientRemoval |
移除预测 GE |
Cue 预测添加 (GameplayCueInterface.cpp) |
NewRejectOrCaughtUpDelegate |
OnPredictiveGameplayCueCatchup |
移除预测 Cue Tag |
蒙太奇播放 (ASC_Abilities.cpp) |
NewRejectedDelegate |
OnPredictiveMontageRejected |
停止预测蒙太奇 |
能力激活 (ASC_Abilities.cpp) |
NewCaughtUpDelegate |
OnClientActivateAbilityCaughtUp |
检查能力预测状态 |
依赖链 (GameplayPrediction.cpp) |
NewRejectedDelegate + NewCaughtUpDelegate |
Reject / CatchUpTo |
级联传播 |
FReplicatedPredictionKeyItem::OnRep —— 回滚触发点
// GameplayPrediction.cpp - FReplicatedPredictionKeyItem::OnRep()
void FReplicatedPredictionKeyItem::OnRep(const FReplicatedPredictionKeyMap& InArray)
{
// ① 只处理客户端本地预测的 Key(忽略服务器主动键)
if (PredictionKey.bIsServerInitiated)
return;
// ② 核心:触发"追上",清理所有依赖该 Key 的预测副作用
FPredictionKeyDelegates::CatchUpTo(PredictionKey.Current);
// ③ 处理过期键(网络不良时可能有未被确认的 Key 积压)
// 依据 CVarStaleKeyBehavior:
// 0 = CatchUpTo(旧行为,<=UE5.5)
// 1 = Reject
// 2 = Drop(默认,最安全)
for (FPredictionKey::KeyType Key : StalePredictionKeys)
{
if (CVarStaleKeyBehaviorValue == 0) FPredictionKeyDelegates::CatchUpTo(Key);
else if (CVarStaleKeyBehaviorValue == 1) FPredictionKeyDelegates::Reject(Key);
else FPredictionKeyDelegates::Get().DelegateMap.Remove(Key);
}
}
小结回滚机制
| 副作用类型 | 注册方式 | Rejected (拒绝) | CaughtUp (追上) | 说明 |
|---|---|---|---|---|
| GameplayEffect | NewRejectOrCaughtUpDelegate |
移除预测 GE | 移除预测 GE(真实 GE 接管) | 两种情况都移除 |
| GameplayCue | NewRejectOrCaughtUpDelegate |
移除 Tag + 触发 Removed 事件 | 移除 Tag(复制来的接管) | 两种情况都清理 |
| Montage | NewRejectedDelegate 仅 |
Montage_Stop(0.25s) |
不处理(继续正常播放) | 成功时由 RepAnimMontage 属性同步 |
| Ability 自身 | NewCaughtUpDelegate |
K2_EndAbility(在 Failed RPC 中) |
仅检查状态 | 拒绝是 RPC 驱动的,非委托 |
| 属性(Attribute) | 随 GE 一起 | 预测 GE 移除 → Modifier 消失 → 重聚合 | 同左 + 服务器 BaseValue 覆盖 | Delta 模型 |
| 自定义业务 | 开发者选择 | 根据注册的 API 决定 | 根据注册的 API 决定 | 灵活 |
好像有点问题,接管描述的不好。
链式依赖
// GameplayPrediction.cpp - FPredictionKey::GenerateDependentPredictionKey()
void FPredictionKey::GenerateDependentPredictionKey()
{
KeyType Previous = Current;
if (Base == 0) Base = Current; // 记录祖先键
GenerateNewPredictionKey(); // 生成新的 Current
if (Previous > 0)
{
// 建立依赖:Previous 被拒绝 → Current 也被拒绝
FPredictionKeyDelegates::AddDependency(Current, Previous);
}
}
// FPredictionKeyDelegates::AddDependency()
void FPredictionKeyDelegates::AddDependency(KeyType ThisKey, KeyType DependsOn)
{
// 如果祖先 Key 被拒绝,当前 Key 也自动拒绝
NewRejectedDelegate(DependsOn).BindStatic(&FPredictionKeyDelegates::Reject, ThisKey);
// CVarDependentChainBehavior & 0x1:更新的 Key 被确认,隐含更旧的也被确认
if ((CVarDependentChainBehaviorValue & 1) != 0)
NewCaughtUpDelegate(ThisKey).BindStatic(&FPredictionKeyDelegates::CatchUpTo, DependsOn);
// CVarDependentChainBehavior & 0x2:禁用旧"更旧确认隐含更新确认"的 Legacy 行为
if ((CVarDependentChainBehaviorValue & 2) == 0)
NewCaughtUpDelegate(DependsOn).BindStatic(&FPredictionKeyDelegates::CatchUpTo, ThisKey);
}
注意源码注释有缺陷
client: X->Y->Z
server: X->Y->Z
服务器Y被拒绝,客户端已经偷跑到客户端的Z了,这种情况用依赖。
如果客户端偷跑的Z,RPC给服务器Z。服务器的Y被拒绝了,是不知道Z的情况的,这种情况自己用tag想办法。
额外预测窗口
在 Ability 的 ActivateAbility 返回后,初始预测键已失效。若需要再次预测(如按键释放时),可在 Task 中创建新的预测窗口:
Client Server
OnReleaseCallback()
FScopedPredictionWindow sw(ASC, true) 生成新键 Key=M
ASC->ServerInputRelease(Key=M) ──RPC──► ServerInputRelease_Implementation()
FScopedPredictionWindow sw(ASC, Key=M)
OnReleaseCallback() 执行,副作用绑定 Key=M
析构 → ReplicatedPredictionKeyMap.Add(M)
副作用绑定 Key=M(本地立即执行)
FScopedDiscardPredictions
UE 5.5 新增,用于显式 丢弃/接受/拒绝 一组预测(不发送给服务器的场景,例如本地 Montage):
// 仅本地播放 Montage,不预测到服务器
{
FScopedDiscardPredictions Discard(ASC, EGasPredictionKeyResult::SilentlyDrop);
// 此 scope 内产生的 PredictionKey 不会发给服务器,也不会有回调泄漏
PlayMontage(...);
}
AbilityTask 中的子预测窗口
多个 AbilityTask 在回调触发时创建新的 FScopedPredictionWindow,形成独立的预测-回滚单元:
| AbilityTask | 创建预测窗口的位置 | 场景 |
|---|---|---|
WaitInputRelease |
OnReleaseCallback |
释放按键后立即预测 |
WaitInputPress |
OnPressCallback |
按下按键立即预测 |
WaitTargetData |
OnTargetDataReadyCallback |
瞄准数据就绪时预测 |
WaitConfirmCancel |
OnConfirmCallback / OnCancelCallback |
确认/取消输入时预测 |
NetworkSyncPoint |
OnSyncCallback |
网络同步点 |
WaitCancel |
OnCancelCallback |
取消输入时预测 |
每个 Task 的预测键都是独立的,拒绝/确认不会影响其他 Task 的预测键(除非它们在同一个依赖链上)。
9. 补:Lyra的开火回滚的一个理解失误
void UEqZeroGameplayAbility_RangedWeapon::OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& InData, FGameplayTag ApplicationTag)
{
// ..
if (bIsTargetDataValid && CommitAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo))
{
// 增加武器散布(后坐力影响)
UEqZeroRangedWeaponInstance* WeaponData = GetWeaponInstance();
check(WeaponData);
WeaponData->AddSpread();
// 蓝图挂钩:触发伤害、播放特效等
OnRangedWeaponTargetDataReady(LocalTargetDataHandle);
}
else
{
// 如果无法提交(例如没子弹了),则打印警告并结束技能
UE_LOG(LogEqZeroAbilitySystem, Warning, TEXT("Weapon ability %s failed to commit (bIsTargetDataValid=%d)"), *GetPathName(), bIsTargetDataValid ? 1 : 0);
K2_EndAbility();
}
}
最开始认为服务器阻塞开火 bIsTargetDataValid=false,不Commit 会执行,客户端会回滚【错误】
是否Commit和回滚没关系。应该是下面的K2_EndAbility结束了技能。
其他的GE呢?
【即使服务器 CommitAbility 失败,FScopedPredictionWindow 的析构函数仍然会执行,将 PredictionKey 写入 ReplicatedPredictionKeyMap。客户端收到 OnRep 后触发 CatchUpTo,把预测的 GE 移除。由于服务器没有产生对应的权威 GE,效果就是回滚。】
蒙太奇呢?
这个Lyra技能蒙太奇在前面已经双端执行了。
如果只客户端执行 AbilityTask_PlayMontage 呢,动画播放失败X。