LyraLog20 GA_Fire的预测

本质上是一个远程技能。在 C++里面维护了相关的代理

void UEqZeroGameplayAbility_RangedWeapon::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
    // 绑定 target data 回调
    OnTargetDataReadyCallbackDelegateHandle = MyAbilityComponent->AbilityTargetDataSetDelegate(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).AddUObject(this, &ThisClass::OnTargetDataReadyCallback);

    // 更新武器的开火时间
    WeaponData->UpdateFiringTime();
}

void UEqZeroGameplayAbility_RangedWeapon::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{

    // 能力结束时,消耗目标数据并移除委托
    // 操作的是 ASC 的 这个 FGameplayAbilityReplicatedDataContainer AbilityTargetDataMap;
    MyAbilityComponent->AbilityTargetDataSetDelegate(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).Remove(OnTargetDataReadyCallbackDelegateHandle);
    MyAbilityComponent->ConsumeClientReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey());
}

如果是Local control 的

会在蓝图里面 ULyraGameplayAbility_RangedWeapon::StartRangedWeaponTargeting

=》激活技能,绑定了相关代理,在蓝图里面调用 StartRangedWeaponTargeting

void UEqZeroGameplayAbility_RangedWeapon::StartRangedWeaponTargeting()
{
    // 预测窗口
    // 它告诉系统:接下来的操作(如造成伤害、消耗子弹)是我客户端先“猜”的,请在服务器确认前先暂时这么显示。
    // 如果没有客户端的 CallServerSetReplicatedTargetData 会有问题
    FScopedPredictionWindow ScopedPrediction(MyAbilityComponent, CurrentActivationInfo.GetActivationPredictionKey());

根据他的构造函数,相当于吧这个Key记录到 AbilitySystemComponent->ScopedPredictionKey 上了

RestoreKey 存了一下当前值,会在析构的时候改回去

FScopedPredictionWindow::FScopedPredictionWindow(UAbilitySystemComponent* AbilitySystemComponent, FPredictionKey InPredictionKey, bool InSetReplicatedPredictionKey /*=true*/)
{
    if (AbilitySystemComponent == nullptr)
    {
        return;
    }

    // This is used to set an already generated prediction key as the current scoped prediction key.
    // Should be called on the server for logical scopes where a given key is valid. E.g, "client gave me this key, we both are going to run Foo()".

    if (AbilitySystemComponent->IsNetSimulating() == false)
    {
        Owner = AbilitySystemComponent;
        check(Owner.IsValid());
        RestoreKey = AbilitySystemComponent->ScopedPredictionKey;
        AbilitySystemComponent->ScopedPredictionKey = InPredictionKey;
        ClearScopedPredictionKey = true;
        SetReplicatedPredictionKey = InSetReplicatedPredictionKey;
    }
}

我们在本地客户端 StartRangedWeaponTargeting

void UEqZeroGameplayAbility_RangedWeapon::StartRangedWeaponTargeting()
{
    // 【1】预测窗口
    FScopedPredictionWindow ScopedPrediction(MyAbilityComponent, CurrentActivationInfo.GetActivationPredictionKey());

    // 【2】进行某种规则的 射线检测
    TArray<FHitResult> FoundHits;
    PerformLocalTargeting(FoundHits);

    // 【3】 构建 TargetData
    FGameplayAbilityTargetDataHandle TargetData;
    TargetData.UniqueId = WeaponStateComponent ? WeaponStateComponent->GetUnconfirmedServerSideHitMarkerCount() : 0;

    if (FoundHits.Num() > 0)
    {
        const int32 CartridgeID = FMath::Rand();

        for (const FHitResult& FoundHit : FoundHits)
        {
            // 创建 GAS 标准的单点命中数据结构
            FEqZeroGameplayAbilityTargetData_SingleTargetHit* NewTargetData = new FEqZeroGameplayAbilityTargetData_SingleTargetHit();
            NewTargetData->HitResult = FoundHit;
            NewTargetData->CartridgeID = CartridgeID;

            TargetData.Add(NewTargetData);
        }
    }

中点是第三部 FGameplayAbilityTargetDataHandle

他Add的东西是 FGameplayAbilityTargetData 的子类

FEqZeroGameplayAbilityTargetData_SingleTargetHit 是我们自己的子类

TargetData 中有什么信息呢 GetUnconfirmedServerSideHitMarkerCount 未被确认的命中信息

    // 在本地先记录这次命中,以便在UI上立即显示(比如先画个白色的X)
    // 虽然还没经服务器确认,但为了手感需要即时反馈
    if (WeaponStateComponent != nullptr)
    {
        WeaponStateComponent->AddUnconfirmedServerSideHitMarkers(TargetData, FoundHits);
    }

在 UEqZeroWeaponStateComponent::AddUnconfirmedServerSideHitMarkers

将命中数据暂时存起来。 这样可以在等待服务器确认前就能立即在 UI 上做一些预表现(如果是需要极快响应的设计)。

但是这里似乎只是存一下。等后面服务器确认。

然后是

    OnTargetDataReadyCallback(TargetData, FGameplayTag());
void UEqZeroGameplayAbility_RangedWeapon::OnTargetDataReadyCallback(const FGameplayAbilityTargetDataHandle& InData, FGameplayTag ApplicationTag)
{
    UAbilitySystemComponent* MyAbilityComponent = CurrentActorInfo->AbilitySystemComponent.Get();
    check(MyAbilityComponent);

    if (const FGameplayAbilitySpec* AbilitySpec = MyAbilityComponent->FindAbilitySpecFromHandle(CurrentSpecHandle))
    {
        // 开启预测窗口,用于网络同步时的平滑处理
        FScopedPredictionWindow ScopedPrediction(MyAbilityComponent);

        // 移动语义获取数据,避免拷贝。获取目标数据的所有权,以确保不会有游戏代码的回调在我们不知情的情况下使数据失效。
        FGameplayAbilityTargetDataHandle LocalTargetDataHandle(MoveTemp(const_cast<FGameplayAbilityTargetDataHandle&>(InData)));

        // 如果是客户端,必须调用这个函数 (本地控制,但是又不权威。我们只讨论DS客户端,他这里排除了单机模式和ListenServer的玩家1)
        if (const bool bShouldNotifyServer = CurrentActorInfo->IsLocallyControlled() && !CurrentActorInfo->IsNetAuthority())
        {
            MyAbilityComponent->CallServerSetReplicatedTargetData(CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey(), LocalTargetDataHandle, ApplicationTag, MyAbilityComponent->ScopedPredictionKey);
        }

        // ...
    }
    // ...
}

这里 CallServerSetReplicatedTargetData

绕绕绕最后会在服务器执行 OnTargetDataReadyCallback 这个代理,我们在Activate 注册的

OnTargetDataReadyCallbackDelegateHandle = MyAbilityComponent->AbilityTargetDataSetDelegate(
    CurrentSpecHandle, CurrentActivationInfo.GetActivationPredictionKey()).AddUObject(this,
    &ThisClass::OnTargetDataReadyCallback);

大致流程是这个的

客户端                                              服务器
  │                                                   │
  │  1. ActivateAbility()                              │
  │     ├─ 注册 AbilityTargetDataSetDelegate           │
  │     ├─ 更新 WeaponData->UpdateFiringTime()         │
  │     └─ 调用蓝图 (Super 触发蓝图逻辑)               │
  │                                                   │
  │  2. 蓝图调用 StartRangedWeaponTargeting()          │
  │     ├─ FScopedPredictionWindow 开启预测窗口         │
  │     ├─ PerformLocalTargeting() → 本地射线检测       │
  │     │   ├─ GetTargetingTransform (摄像机→焦点)      │
  │     │   └─ TraceBulletsInCartridge                  │
  │     │       └─ DoSingleBulletTrace × N颗子弹       │
  │     │           ├─ 线检测 (SweepRadius=0)           │
  │     │           └─ 容错检测 (SweepRadius>0)         │
  │     ├─ 构建 FGameplayAbilityTargetDataHandle       │
  │     ├─ WeaponStateComponent                         │
  │     │   ->AddUnconfirmedServerSideHitMarkers()     │
  │     │   (本地暂存命中, UI 先表现)                    │
  │     └─ 调用 OnTargetDataReadyCallback()             │
  │                                                   │
  │  3. OnTargetDataReadyCallback() [客户端]            │
  │     ├─ FScopedPredictionWindow                      │
  │     ├─ CallServerSetReplicatedTargetData ─────────►│ 4. 服务器收到 TargetData
  │     │   (RPC: 发送命中数据到服务器)                  │    ├─ AbilityTargetDataSetDelegate 触发
  │     ├─ CommitAbility (预测性扣弹药/CD)              │    │   OnTargetDataReadyCallback() [服务器]
  │     ├─ WeaponData->AddSpread()                     │    ├─ 服务器验证命中有效性
  │     └─ OnRangedWeaponTargetDataReady               │    ├─ WeaponStateComponent
  │         (蓝图: 播特效/应用GE伤害)                   │    │   ->ClientConfirmTargetData ──────────►
  │                                                   │    │   (Client RPC: 通知客户端确认结果)
  │  6. ClientConfirmTargetData_Implementation         │    ├─ CommitAbility (权威扣弹药/CD)
  │     ├─ 查找 UniqueId 匹配的未确认批次               │    ├─ WeaponData->AddSpread()
  │     ├─ 根据 HitReplaces 过滤无效命中               │    └─ OnRangedWeaponTargetDataReady
  │     ├─ 有效命中 → ActuallyUpdateDamageInstigatedTime│       (蓝图: 权威应用GE伤害)
  │     ├─ 加入 LastWeaponDamageScreenLocations        │
  │     └─ UI 显示确认后的命中标记 (如爆头变色)          │
  │                                                   │
  │  7. EndAbility()                                   │
  │     ├─ 移除 AbilityTargetDataSetDelegate           │
  │     └─ ConsumeClientReplicatedTargetData           │

Q1:Prediction Key 在哪里生成?

答案:客户端在技能激活时由 ASC 自动生成,使用一个全局递增的 int16 计数器。

引擎源码路径:GameplayPrediction.cpp

// 生成新key的核心——一个 static 局部递增计数器
void FPredictionKey::GenerateNewPredictionKey()
{
    static KeyType GKey = 1;    // KeyType = int16
    Current = GKey++;
    if (GKey <= 0) { GKey = 1; }  // 溢出处理
}

// 只在客户端生成(服务器返回无效key)
FPredictionKey FPredictionKey::CreateNewPredictionKey(const UAbilitySystemComponent* OwningComponent)
{
    FPredictionKey NewKey;
    if (OwningComponent->GetOwnerRole() != ROLE_Authority)
    {
        NewKey.GenerateNewPredictionKey();
    }
    return NewKey;
}

生成时机: InternalTryActivateAbility 中,对于 LocalPredicted 策略的技能,客户端会:

// AbilitySystemComponent_Abilities.cpp
else if (Ability->GetNetExecutionPolicy() == EGameplayAbilityNetExecutionPolicy::LocalPredicted)
{
    // ★ 这里的 true 参数表示 "可以生成新Key"
    FScopedPredictionWindow ScopedPredictionWindow(this, true);

    // 将这个Key设为当前技能激活的预测Key
    ActivationInfo.SetPredicting(ScopedPredictionKey);

    // 立即发RPC到服务器,携带这个Key
    CallServerTryActivateAbility(Handle, Spec->InputPressed, ScopedPredictionKey);

    // 本地先激活(预测执行)
    AbilitySource->CallActivateAbility(Handle, ActorInfo, ActivationInfo, ...);
}

所以流程是:

  1. 客户端 TryActivateAbilityInternalTryActivateAbility
  2. FScopedPredictionWindow(ASC, true) 在构造时调用 GenerateDependentPredictionKey()GenerateNewPredictionKey()
  3. Key 被存入 ASC->ScopedPredictionKey,同时设入 ActivationInfo
  4. 通过 CallServerTryActivateAbility RPC 将 Key 发送给服务器
  5. 服务器收到后,用同一个 Key 设入自己的 ActivationInfo

之后我们的代码里用 CurrentActivationInfo.GetActivationPredictionKey() 获取的就是这个 Key。

Q2:FScopedPredictionWindow 具体做了什么?

答案:它是一个 RAII 作用域守卫,在构造时设置当前预测Key到ASC上,在析构时恢复旧Key并(服务器侧)确认该Key。

它有两个构造函数,分别用于客户端和服务器:

客户端构造函数 FScopedPredictionWindow(ASC, bCanGenerateNewKey=true)

// 保存旧Key
RestoreKey = ASC->ScopedPredictionKey;

// 生成新的依赖Key(Base 指向旧Key,Current 是新值)
ASC->ScopedPredictionKey.GenerateDependentPredictionKey();

作用: 在这个作用域内,所有预测操作(如 ApplyGameplayEffectCallServerSetReplicatedTargetData)都会使用新生成的 ScopedPredictionKey,让 GAS 知道"这些操作属于同一个预测上下文"。

服务器构造函数 FScopedPredictionWindow(ASC, InPredictionKey)

// 保存旧Key
RestoreKey = ASC->ScopedPredictionKey;
// 设置客户端传来的Key
ASC->ScopedPredictionKey = InPredictionKey;

析构函数(关键!)

~FScopedPredictionWindow()
{
    // ★ 服务器侧:确认这个PredictionKey,通知客户端"我处理了这个预测"
    if (SetReplicatedPredictionKey && ScopedPredictionKey.IsValidKey())
    {
        ASC->ReplicatedPredictionKeyMap.ReplicatePredictionKey(ScopedPredictionKey);
        // 这会通过 FastArray 复制到客户端,触发 OnRep
        // 客户端收到后知道"服务器已确认这个Key对应的操作",从而保留预测结果
    }

    // 恢复之前的Key
    ASC->ScopedPredictionKey = RestoreKey;
}

总结 FScopedPredictionWindow 的三个职责:

职责 构造时 析构时
管理 Key 作用域 保存旧 Key,设置新 Key 到 ASC 恢复旧 Key
客户端:生成预测 Key GenerateDependentPredictionKey() -
服务器:确认预测 Key - ReplicatePredictionKey() 告知客户端

在我们代码里出现了两次:

// StartRangedWeaponTargeting() 里——客户端入口
FScopedPredictionWindow ScopedPrediction(MyAbilityComponent, 
    CurrentActivationInfo.GetActivationPredictionKey());
// ↑ 这里用的是 "服务器版构造函数"(传入已有Key),不生成新Key
//   目的是把激活时的PredictionKey设为当前作用域的Key

// OnTargetDataReadyCallback() 里——客户端和服务器都走
FScopedPredictionWindow ScopedPrediction(MyAbilityComponent);
// ↑ 客户端:bCanGenerateNewKey 默认 true,会生成一个依赖Key
//   服务器:IsNetSimulating()==false,会设置并在析构时确认Key

Q3 这个预测窗口具体干了什么好像秀操作但是啥都没干

  • High Level Goals / 高层目标

在 GameplayAbility 层面(实现某个 Ability 时),预测是透明的。一个 Ability 只需描述"执行 X→Y→Z",系统会自动对其中可以预测的部分进行预测。

我们希望避免在 Ability 内部出现"如果是权威端:执行 X;否则:执行 X 的预测版本"这样的逻辑。

  1. CommitAbility → 弹药/冷却的预测扣除

CommitAbility 内部调用 ApplyCost + ApplyCooldown,它们都是 ApplyGameplayEffectSpecToSelf。因为此时在 FScopedPredictionWindow 作用域内,这些 GE 被标记了 PredictionKey。

如果没有预测:客户端开枪后弹药数不变,等 100ms 服务器回包才扣弹——玩家会觉得"卡"。

有预测后:客户端开枪瞬间弹药 -1,服务器确认后属性复制下来,GAS 自动做 reconcile:

  • 预测对了 → 无感过渡(删掉预测的 GE,保留服务器的 GE,数值一样)
  • 预测错了(比如服务器判定技能失败)→ 自动回滚弹药
  1. 属性的 REPNOTIFY_Always → 解决 "Override" 问题

EqZeroHealthSet.cpp:

DOREPLIFETIME_CONDITION_NOTIFY(UEqZeroHealthSet, Health, COND_None, REPNOTIFY_Always);

void UEqZeroHealthSet::OnRep_Health(const FGameplayAttributeData& OldValue)
{
    GAMEPLAYATTRIBUTE_REPNOTIFY(UEqZeroHealthSet, Health, OldValue);
}

这不是"写着好看"。REPNOTIFY_Always 让每次服务器推值客户端都触发 OnRep(即使本地值没变)。GAMEPLAYATTRIBUTE_REPNOTIFY 宏做的事:

服务器推下来 Health = 80(BaseValue)
客户端本地有一个预测的 -10 GE(PredictionKey = 5)
→ FinalValue = 80 + (-10) = 70  //显示给玩家的值

当 PredictionKey 5 被确认/拒绝:
  确认 → 删掉预测GE,服务器GE到位,最终还是 70
  拒绝 → 删掉预测GE,回到 80

如果不用 REPNOTIFY_Always:当服务器推下来的值碰巧和客户端本地值一样,OnRep 不触发,属性聚合链就不会重建,预测的增量就永远卡在那里。

  1. Montage 预测 → 动画即时反应
    你的近战和闪避都用了 UAbilityTask_PlayMontageAndWait。这个引擎 Task 内部 自动开了 FScopedPredictionWindow,做了以下事情:

客户端立即播放蒙太奇(不等服务器)
通过 RPC 通知服务器播同一个蒙太奇
服务器确认后,Multicast 给其他客户端看到动画
发起客户端因为有 PredictionKey,收到 Multicast 时跳过(避免"Redo"——动画播两遍)
如果没有预测:按下闪避键,100ms后才开始播动画——MOBA可以接受,FPS不行。

  1. GameplayCue 的去重 → 不重复播特效
    当一个带 GameplayCue 的 GE 被预测应用时:
客户端预测应用 GE → 本地触发 GameplayCue(播枪口火焰、音效)
服务器应用相同 GE → Multicast GameplayCue → 客户端收到

关键:客户端收到 Multicast 时检查 PredictionKey:
  "这个 Cue 和我预测的Key一样?跳过!不重复播。"

如果没有预测:特效要么延迟100ms播放(等服务器),要么播两遍(本地一次+服务器推一次)。

  1. 你的 ApplyCost 里的 ShouldOnlyApplyCostOnHit → 服务器权威

你项目基类里有一段很有意思的设计:

void UEqZeroGameplayAbility::ApplyCost(...) const
{
    // 只有服务器能判断"是否命中"来决定要不要扣特殊Cost
    if (AdditionalCost->ShouldOnlyApplyCostOnHit())
    {
        if (ActorInfo->IsNetAuthority())  // ← 只在服务器检查
        {
            // 查 TargetData 是否有命中
            bAbilityHitTarget = DetermineIfAbilityHitTarget();
        }
        if (!bAbilityHitTarget) continue; // 客户端跳过
    }
    AdditionalCost->ApplyCost(...);
}

这里故意不预测"命中才扣"的 Cost——因为客户端不知道服务器会不会认可命中结果。普通 Cost(弹药)可以安全预测,特殊 Cost(命中才扣的资源)只在服务器扣,避免预测错误导致资源异常。

总结:
更"复杂"的场景在哪?
你文档里提到但项目还没碰到的几个硬骨头:

  • Meta Attribute(伤害/治疗)不能预测 — 你的 EqZeroDamageExecution 包在 WITH_SERVER_CODE 里,只在服务器跑。所以客户端不预测伤害数值(Health 的变化完全等服务器推),只预测"弹药消耗"和"开火特效"。这是故意的——伤害预测错了要回滚血条很丑。

  • GE 移除不能预测 — 如果你做一个"护盾 Buff,被打时移除",客户端不能预测性移除这个 GE。这意味着护盾破碎特效要么等服务器、要么走自定义逻辑。

  • 链式技能依赖 — 你的连招如果是 GA_Attack1 → GA_Attack2,如果 Attack1 被服务器否了,Attack2 不会自动回滚。防御做法是用 Tag:Attack2 要求有 Combo.Stage1 Tag(由 Attack1 的 GE 赋予),服务器否了 Attack1 就没这个 Tag,Attack2 自然激活不了。

  • 百分比 GE 偏差 — 客户端只知道属性最终值(550),不知道组成(500 base + 10% buff)。再预测一个 10% buff 会基于 550 算出 605 而不是正确的 600。

  • GAS 预测 = 引擎自动对 GE/属性/Cue/蒙太奇做的"先执行后确认"机制,基于 PredictionKey

  • TargetData = 你自定义的"客户端算出命中→发给服务器→服务器验证→回 RPC"流程,和预测是独立的

Q4:为什么有的技能开了 LocalPredicted 但没用预测窗口?

因为 ActivateAbility 本身就在一个预测窗口里。引擎在 <font style="color:rgb(209, 154, 102);background-color:rgba(255, 255, 255, 0.1);">InternalTryActivateAbility</font> 里已经开了:

// 引擎代码 AbilitySystemComponent_Abilities.cpp
FScopedPredictionWindow ScopedPredictionWindow(this, true); // ← 引擎自动开的
ActivationInfo.SetPredicting(ScopedPredictionKey);
CallServerTryActivateAbility(...);
AbilitySource->CallActivateAbility(...); // ← 你的 ActivateAbility 在这个窗口内

所以你的 Dash 和 Melee 的 ActivateAbility 里直接调 CommitAbility、创建 PlayMontageAndWait Task,它们全都自动继承了这个预测窗口的 PredictionKey。不需要你手动开。

你需要手动开 FScopedPredictionWindow 的唯一场景:ActivateAbility 的调用栈结束之后,还想做预测操作。比如 RangedWeapon 的 StartRangedWeaponTargeting 是蓝图在后续帧调用的,已经不在激活窗口里了,所以必须手动开。

开发中需要注意的规则清单:

规则 1:预测窗口 = ActivateAbility 的同步调用栈

✅ ActivateAbility() {
    CommitAbility();         // 在窗口内,预测生效
    PlayMontageAndWait();    // Task 创建在窗口内,预测生效
    ApplyGE();               // 在窗口内,预测生效
}

❌ ActivateAbility() {
    WaitDelay(1.0s) → {
        CommitAbility();     // 1秒后,窗口早关了,PredictionKey 无效
    }                        // GAS 会打印警告,GE 不会被预测
}

定时器、Delay、WaitTargetData、WaitInputRelease 等会跨帧,一旦跨帧,激活窗口就关闭了。在回调里要做预测操作,必须手动开 FScopedPredictionWindow 并有配套的 Server RPC。

规则 2:你需要手动开预测窗口的场景

客户端在某个异步回调里想做预测操作
→ 手动开 FScopedPredictionWindow
→ 同时有一个 Server RPC 在服务器侧也开 FScopedPredictionWindow + 同样的逻辑

必须保证:客户端回调 → ServerRPC → 服务器回调 这条链路上执行的代码对称

你的 RangedWeapon 就是这个模式:

  • StartRangedWeaponTargeting → 手动窗口
  • OnTargetDataReadyCallback → CallServerSetReplicatedTargetData RPC → 服务器 ServerSetReplicatedTargetData_Implementation 自动开窗口 → 服务器的 OnTargetDataReadyCallback

规则 3:不要在服务器 RPC 里重复 CommitAbility

// ❌ Dash 当前写法
void ActivateAbility() {
    CommitAbility();  // 客户端预测扣费
    ServerSendInfo(); // RPC
}
void ServerSendInfo_Implementation() {
    CommitAbility();  // 服务器又扣一次 ← 可能双扣
    ExecuteDash();
}

// ✅ 正确写法
void ActivateAbility() {
    CommitAbility();  // 两端都会执行(客户端预测 + 引擎在服务器confirm时执行)
    if (!HasAuthority()) ServerSendInfo(); 
}
void ServerSendInfo_Implementation() {
    // 不再 CommitAbility,只同步执行效果
    ExecuteDash();
}

规则 4:Execution(伤害计算)不预测

你的 EqZeroDamageExecution 在 WITH_SERVER_CODE 里,这是正确的。Execution 类不支持预测(文档里写了)。伤害数值变化(Health 减少)完全由服务器推下来,客户端通过 REPNOTIFY_Always + GAMEPLAYATTRIBUTE_REPNOTIFY 做 reconcile。

如果你想客户端预测伤害数字显示,需要绕过 Execution,用普通 Modifier(Add/Multiply)而不是 Execution。但一般不建议——预测伤害错了回滚血条很丑。

规则 5:GE 移除不可预测

// ❌ 客户端不能预测性移除 GE
ASC->RemoveActiveGameplayEffect(Handle);   // 只在服务器有效

// ✅ 如果需要客户端有即时反馈,用 GameplayCue 做视觉表现
// 逻辑层面等服务器移除 → 属性复制 → OnRep 回调

规则 6:Tag 操作注意 Loose 和 GE Tag 的区别

你 Reload 里的 ASC->AddLooseGameplayTag(TAG_WeaponFireBlocked) 不走预测系统——Loose Tag 是直接加的,没有 PredictionKey。如果客户端加了,服务器不知道。建议改用短时 GE 赋 Tag 的方式,这样 Tag 的添加/移除会走 GAS 预测流程。

Q5 Redo/Undo 有点抽象呀

什么情况用,对于开发有什么影响

客户端预测激活技能 → 服务器判定
 ├─ 服务器拒绝 (ClientActivateAbilityFailed) → 触发 Undo(回滚一切)
 └─ 服务器确认 (Succeed + ReplicatedPredictionKey 追上来)
      → 触发 Redo(去重,不重复执行副作用)
      → 然后删掉预测版本,保留服务器版本

关键认知:确认成功时也有"回滚"——只不过是安静地用服务器版本替换预测版本,玩家无感。

GE(GameplayEffect)的 Undo/Redo

场景:客户端预测给自己加一个 10 秒减速 Debuff

预测阶段(客户端先行):

客户端 ActiveGameplayEffects:
  [0] SlowDebuff (PredictionKey=7, 客户端预测版)
       → Tag: Status.Slow 已生效
       → GameplayCue: GC_Slow 已播放(紫色特效)
       → 移速属性 -30%

Redo 路径(服务器确认成功)

1. 服务器也 Apply 了 SlowDebuff,设了 PredictionKey=7,复制下来
2. 客户端收到复制的 FActiveGameplayEffect (PredictionKey=7)
3. 客户端检查:本地已有 PredictionKey=7 的 GE? → 有!
4. ★ 跳过 "OnApplied" 逻辑(不重复播 GC_Slow 特效)
5. 此时 ActiveGameplayEffects 里有两个 SlowDebuff(预测版 + 服务器版)
6. ReplicatedPredictionKey 追上来 → 删除预测版 SlowDebuff
7. ★ 删除时检查 PredictionKey:这是预测版被清理,跳过 "OnRemoved" 逻辑(不播移除特效)
8. 最终只剩服务器版 SlowDebuff,玩家全程无感

Undo 路径(服务器拒绝)

1. 服务器判定技能激活失败 → ClientActivateAbilityFailed
2. PredictionKey=7 被 Reject
3. 查找所有 PredictionKey=7 的 FActiveGameplayEffect → 找到预测版 SlowDebuff
4. ★ 移除它:
   - Tag Status.Slow 被移除
   - 移速属性恢复(-30% 的 Modifier 被删)
   - GameplayCue GC_Slow 的 OnRemoved 执行(紫色特效消失)
5. 玩家看到:减速特效闪了一下就消失了(这就是预测失败的回滚)

属性(Attribute)的 Undo/Redo

场景:客户端预测扣 10 点蓝(CommitAbility 扣 Cost)

GAS 对即时 GE(Instant)做了特殊处理:预测时将其视为无限持续时间 GE。

预测阶段:

服务器上 Mana = 100(这是复制下来的值)
客户端预测:Apply 一个 "Mana -10" 的即时 GE
→ GAS 不直接改 Mana 为 90
→ 而是创建一个 "无限持续时间的 Mana -10 Modifier" (PredictionKey=7)
→ 客户端显示 Mana = 100(Base) + (-10)(预测Modifier) = 90

Redo 路径(确认成功)

1. 服务器执行了该 GE,Mana 从 100 变成 90
2. 服务器复制 Mana=90 到客户端
3. 客户端 OnRep_Mana 触发(REPNOTIFY_Always 保证一定触发)
4. GAMEPLAYATTRIBUTE_REPNOTIFY 宏执行:
   - 设新的 BaseValue = 90
   - 重新聚合:90(新Base) + (-10)(预测Modifier) = 80 ← 暂时多扣了!
5. 但几乎同时,ReplicatedPredictionKey 追上来
6. PredictionKey=7 confirmed → 删除预测的 -10 Modifier
7. 重新聚合:90(Base) + 0 = 90 ✅
8. 玩家看到:90 → 短暂 80 → 90,但因为几乎同帧,实际无感

Undo 路径(拒绝)

1. 服务器拒绝 → PredictionKey=7 Rejected
2. 删除预测的 -10 Modifier
3. 重新聚合:100(Base) + 0 = 100
4. 玩家看到:蓝从 90 跳回 100(回滚)

为什么伤害(Health)不预测?

如果客户端预测"敌人 Health -50":

预测:敌人血条从 100 → 50(播放受击动画、血条动画)

服务器拒绝:血条从 50 → 100 跳回

→ 玩家看到敌人血条先掉后涨,非常诡异

而弹药/蓝量:

预测:弹药从 30 → 29

服务器拒绝:弹药从 29 → 30 跳回

→ 玩家几乎注意不到(HUD 上一个小数字闪了一下)

代价不对称:属性回滚的视觉冲击越大,越不应该预测。

所以还是开发者决策。

GameplayCue 的 Undo/Redo

独立 Cue(不跟随 GE)

// 在预测窗口内调用
ASC->ExecuteGameplayCue(TAG_GC_MuzzleFlash, CueParams);

客户端(有 PredictionKey):直接播放枪口火焰
服务器:执行 Multicast → 所有客户端收到
发起客户端收到 Multicast 时:检查有 PredictionKey → 跳过(Redo 去重)
其他客户端:正常播放

Undo?

Execute 类型的 Cue 是"一次性"的(爆炸特效、音效),播完就播完了,无法回滚。这是 "Fire and forget"。所以即使技能被拒绝,枪口火焰已经播了——这是可接受的视觉妥协。

跟随 GE 的 Cue(OnAdded/OnRemoved)

GE 预测应用 → Cue OnAdded 播放(头上冒 buff 图标)

GE Redo(服务器版到达)→ 跳过 OnAdded(不重复播)

GE 预测版被清理 → 跳过 OnRemoved(不播移除动画)

GE 被拒绝 → Cue OnRemoved 正常播放(buff 图标消失)

蒙太奇(Montage)的 Undo/Redo

Redo

PlayMontageAndWait Task 创建时在预测窗口内:

客户端:立即播放蒙太奇
服务器:确认后 Multicast 播放同一蒙太奇
客户端收到 Multicast:检查 PredictionKey → 跳过(动画已在播了)

Undo

技能被拒绝时:

1. 技能 EndAbility(bWasCancelled=true)
2. AbilityTask 全部关闭
3. PlayMontageAndWait 的清理逻辑 → StopMontage
4. 玩家看到:动画播了一半突然停止(这是回滚的"丑"之一)

Tag 的 Undo/Redo

来自 GE 的 Tag(GrantedTags)跟随 GE 一起生死:

GE 预测应用 → Tag 生效
GE Redo → Tag 不重复添加(已经有了)
GE 预测版被清理 → 但服务器版 GE 已到位,Tag 仍然保持
GE 被拒绝 → Tag 跟着移除

Loose Tag(AddLooseGameplayTag)不参与预测系统,不会自动 Undo/Redo。

实际开发中的决策原则

应该预测的(回滚代价小):

  • 弹药消耗(数字闪一下无所谓)
  • 冷却(回滚后能再按,玩家不觉得奇怪)
  • 自身 buff 添加(图标闪一下可接受)
  • 枪口火焰/音效(Execute Cue,播了就播了)
  • 自身蒙太奇(开火/挥刀动画要即时)

不应该预测的(回滚代价大):

  • 他人血量变化(血条先掉后涨很丑)
  • 击杀判定(先显示击杀再撤回?灾难)
  • 掉落/拾取(物品先出现再消失?)
  • 永久状态变更(装备升级等)

灰色地带(看项目需求决定):

  • 自身血量变化(被打时预测扣血?回滚会导致血条波动)
  • 移速 buff(预测加速后回滚减速会有 "拉扯感")
  • 护盾(预测消耗护盾后回滚会看到护盾先碎后修复)

上一篇