GAS 预测的回滚-初稿

省流

  1. 客户端生成 FPredictionKey,立即本地执行,同时用 RPC 告知服务器——所有副作用(GE/Cue/属性)都绑定这个 Key
  2. 服务器验证后,把这个 Key 写入 FReplicatedPredictionKeyMap 复制下来——客户端 OnRep 触发 CatchUpTo,清理预测副作用,由服务器权威状态接管
  3. 若服务器拒绝,发 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);
}

ExecuteGameplayEffectExecuteActiveEffectsFromInternalExecuteModApplyModToAttribute

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。

上一篇
下一篇