第23章 · GAS 二阶:预测、连招与大型组织

📑 目录

「远港 Online」第一次压力测试,服务器放在华东,西北玩家的 RTT 普遍在 150ms 以上。当晚反馈群就炸了:「按了冲锋,半秒后人还在原地发呆,然后瞬移出去」。技能确认要等一个完整 RTT 加一个服务器 tick,150ms 的延迟意味着每次施法先沉默将近 200ms。三天,测试玩家流失 41%。问题不在网络——光速不讲价,骨干网也不会为一款游戏改道。问题在于我们把 GAS 当单机框架用:一切都等服务器点头。这一章把预测、回滚、TargetData、连招窗口一次讲透,目标只有一个:150ms 延迟下,技能手感追平单机。

本章地图

mindmap
  root((第23章
GAS 二阶)) 预测是生存线 反馈延迟的账本 预测窗口与 RTT 能预测什么/不能预测什么 PredictionKey 本地激活先斩后奏 服务器确认或拒绝 回滚与重放 TargetData 客户端采集瞄准 带 Key 合法入境 服务器权威校验 连招与取消窗口 输入缓冲 取消仲裁 150ms 为什么变肉 冷却与消耗统一建模 冷却就是 GE 消耗就是 GE 目录宪法 基类/职业/专精三层 命名规范 数据驱动配方表

23.1 按下去 200ms 没反应,玩家就删游戏了:预测是生存线

先把没有预测时,一次技能释放的时间账算清楚。玩家按下冲锋键,请求包上行半个 RTT 到服务器;服务器未必立刻处理,平均再等半个 tick;判定完成后结果包下行半个 RTT;客户端收到权威数据后,还要穿过插值缓冲才会渲染。整条链路的玩家感知反馈时间是:

T_{\text{反馈}} = \underbrace{\frac{RTT}{2}}_{\text{上行}} + T_{\text{tick}} + \underbrace{\frac{RTT}{2}}_{\text{下行}} + T_{\text{缓冲}} \;\ge\; RTT

代入「远港 Online」压测的实际数字:RTT = 150ms,服务器 30Hz tick 平均等待约 16.7ms,客户端插值缓冲约 30ms,合计 ≈ 197ms。也就是说,每按一次技能,角色要先「愣」五分之一秒。本地 60fps 下 200ms 是 12 帧——动作游戏里 12 帧足够死一次了。

预测的思路是把等服务器变成先斩后奏:客户端本地检查通过就立刻激活技能、播动画、放特效、扣冷却,同时把这次激活的凭据发给服务器仲裁。服务器同意后,本地结果转正;不同意,就按凭据把这一切撤掉。反馈时间从 197ms 压到 0ms——代价是客户端必须为「可能猜错」做好回滚的功课。

预测能维持多久,取决于服务器回执的到达时间,这个时长叫预测窗口:

TwindowRTT+Ttick+TmarginT_{\text{window}} \;\ge\; RTT + T_{\text{tick}} + T_{\text{margin}}

其中 TmarginT_{\text{margin}} 是给抖动与丢包重发留的余量,工程上取 30~50ms。RTT 150ms 时窗口约 200ms——窗口内客户端产生的全部副作用(Montage、特效、冷却 GE)都挂在同一个预测凭据下,等服务器那一声「确认」或「拒绝」。

GAS 对预测的支持是分层的,不是所有操作都能先斩后奏。下面这张表建议在团队里打印出来贴在墙上:

操作能否预测说明
Montage 播放本地先播,权威信息到达后对齐进度
GameplayCue 特效/音效本地先放,服务器回执带同一凭据时不重复放
冷却/消耗 GE(对自身)预测应用,被拒时整体剥除
属性修改(对自身)权威值到达后做 catch-up 校正
Tag 添加/移除(对自身)随预测凭据生效与撤销
命中判定与伤害(对他人)不能只能等服务器,否则就是作弊通道
生成新 Actor(抛射物)受限抛射物有专门方案,通用 spawn 不行
死亡结算不能死亡必须由服务器宣布

最后三行是红线,原因在第 20 章已经说过:客户端权威是作弊的温床。预测只动「自己的表现与资源」,不碰「他人的状态与世界的事实」。服务器权威原则一个字都没动摇——预测只是让客户端在等待真相的时候不要傻站着。

flowchart LR
    subgraph 无预测
        A1[按键] --> A2[等服务器
约 200ms] --> A3[才看到起手] end subgraph 有预测 B1[按键] --> B2[本地立即起手
0ms] --> B3[服务器异步仲裁] B3 --> B4{通过?} B4 -- 绝大多数 --> B5[无事发生] B4 -- 极少数 --> B6[回滚纠错] end

预测的代价必须摆上台面。客户端要为每次激活多维护一份 Key 与副作用快照,服务器要为每次激活多走一趟回执 RPC——500 个技能规模的 MMO 里,这是一笔常驻的带宽与内存开销。更深一层的成本在设计侧:只有「可回滚」的技能才适合预测。一个技能如果一放出去就生成 20 个 Actor、改变地形、触发全屏演出,回滚它就等于在客户端引爆它。这类技能的老实做法是本地只播起手表现,实质效果等服务器。

「回响峡谷」这类单机项目完全用不上这一节——没有服务器,预测没有意义,激活直接本地生效。这也是 GAS 设计的厚道之处:同一套技能代码,单机跑是零预测开销,联机跑自动多一层预测管道。从单机到 MMO 的迁移成本,被这套机制吃掉了一大半。

还有两个参数会悄悄改变这条账本的数字。服务器 tick 率从 30Hz 提到 60Hz,TtickT_{\text{tick}} 平均等待从 16.7ms 降到 8.3ms,代价是服务器 CPU 账单接近翻倍;NetUpdateFrequency 决定 ASC 数据发往客户端的节奏,默认值在 5.x 里已经偏高,但带宽敏感的项目往往会主动下调——下调的每一档,都在给预测窗口的长度加码。这两个旋钮的取舍在第 21 章已经算过账,本章只提醒一句:改它们之前,先回来把这条公式重新代入一遍。

23.2 Prediction Key:预测的身份证,也是回滚的地址

客户端凭什么知道服务器回执对应哪一次激活?靠预测键(Prediction Key)。机制一句话说清:客户端打开一个预测窗口,GAS 分配一个 Key;窗口内的一切可预测副作用都挂在这个 Key 下;上报服务器时 Key 随请求走;服务器回执也带同一个 Key。Key 就是这次预测的身份证——服务器认它,本地结果转正;服务器否它,本地按 Key 把副作用一个个摘掉。

下面这张图是本章的灵魂。把每个箭头的时机吃透,GAS 预测就懂了大半:

sequenceDiagram
    autonumber
    participant P as 玩家输入
    participant C as 客户端 ASC
    participant CA as 客户端技能实例
    participant S as 服务器 ASC

    P->>C: t=0ms 按下冲锋
    C->>C: 本地预检(冷却/消耗/Tag)
    C->>CA: 打开 ScopedPredictionWindow,生成 Key=K1
    CA->>CA: 本地激活:播 Montage、放 Cue、应用冷却 GE
    Note over CA: 玩家立即看到起手式,反馈 0ms
    C->>S: t≈5ms ServerTryActivateAbility(K1),上行约 RTT/2
    S->>S: t≈80ms 收到,以 K1 身份做权威检查
    alt 检查通过(绝大多数情况)
        S->>S: 服务器正式激活,权威应用 GE
        S->>C: t≈165ms ClientActivateAbilitySucceed(K1)
        C->>C: K1 转正:预测 GE 保留,不重复播放
    else 检查失败(如已被沉默、目标已死)
        S->>C: t≈165ms ClientActivateAbilityFailed(K1)
        C->>CA: 回滚 K1 窗口内全部副作用
        CA->>CA: 停 Montage、剥冷却 GE、补失败反馈
    end

工程上动手的地方在输入入口:本地先预检,预检通过才交给 ASC 走预测激活。预检存在的全部意义,是把「拒绝率」压到接近零——省流量只是顺手的结果。每一次拒绝都是一次玩家看得见的回滚抖动。

// 客户端输入入口:本地预检 + 预测激活(简化示例,省略参数包装细节)
void UFHO_AbilitySystemComponent::LocalInputPressed(FGameplayTag InputTag)
{
    for (FGameplayAbilitySpec& Spec : ActivatableAbilities.Items)
    {
        if (!Spec.Ability || !Spec.GetDynamicSpecSourceTags().HasTagExact(InputTag))
            continue;

        // 本地预检:冷却 Tag、消耗、激活条件,先在客户端这关拦掉
        if (Spec.Ability->CanActivateAbility(Spec.Handle, AbilityActorInfo.Get()))
        {
            TryActivateAbility(Spec.Handle);   // 内部链路见下方注释
        }
    }
}
// TryActivateAbility 在自治客户端的内部链路(引擎已实现,列出便于对照):
//   FScopedPredictionWindow 打开 → 生成 FPredictionKey
//   → 本地 ActivateAbility:CommitAbility / 播 Montage / 放 Cue,全部挂 Key
//   → ServerTryActivateAbility(Key) 以不可靠 RPC 上报仲裁
// 服务器回执:ClientActivateAbilitySucceed(Key) 或 ClientActivateAbilityFailed(Key)

要点注释:Key 是 16 位循环分配的整数,客户端独占生成权;技能内部派生的新激活(连招下一段、触发的子技能)用派生 Key 挂在同一条预测链上;不可靠 RPC 丢了怎么办——服务器长时间没收到就不回执,Key 会超时作废,客户端按拒绝处理。

GameplayCue 的去重是另一个容易踩的细节。特效在预测时本地放了一遍,服务器确认后会向所有相关客户端广播同一个 Cue——如果不做去重,施法者会看到两遍刀光。GAS 的处理是广播时带上来源 Key,施法者的 ASC 发现「这个 Key 我执行过」就跳过;其他客户端没有这条预测记录,正常播放。原理一句话:Key 不只是回滚的地址,也是多播去重的指纹。自定义 Cue 触发路径时若绕过标准接口自己发 RPC,这个指纹就丢了,双倍特效的 bug 十有八九出在这里。

技能实例在预测体系下的完整生命,用状态机看更清楚:

stateDiagram-v2
    [*] --> 未激活
    未激活 --> 预测激活: 输入 + 本地预检通过,发 K1
    预测激活 --> 已确认: Succeed(K1) 到达
    预测激活 --> 回滚中: Failed(K1) 到达 / 权威属性冲突
    回滚中 --> 已结束: 剥除 K1 副作用,状态对齐权威
    已确认 --> 执行中: 命中与伤害由服务器结算
    执行中 --> 已结束: EndAbility
    已结束 --> [*]
    note right of 回滚中
        玩家能感知的一次纠错:
        动画急停或融合、资源返还、
        补一条失败反馈盖过已播特效
    end note

「预测错了怎么办」拆开是三件事。特效和音效已经播了:不撤销,撤销比错播更扎眼——补一个「哑火」Cue(一小撮灰烟、一声闷响)把观众的注意力盖过去,这是业界通用手法。冷却 GE 已经扣了:回滚时按 Key 整体剥除,冷却条会瞬间回满,玩家看到的是「技能没放出去」,语义正确。属性已经变了:客户端收到权威值时做 catch-up,用权威值覆盖预测值,不播放跳变。Montage 已经播了:急停最诚实但最难看,向 Idle 或下一段动作做 0.1 秒融合更自然。位置类副作用(Root Motion 位移)由移动组件的校正机制兜底,小幅校正肉眼难察觉——这也是为什么高延迟下要优先用 RootMotionSource 而不是裸位移。

回滚与重放的逻辑,引擎已经内置在 ASC 里,但值得按逻辑重写一遍,知道它替你做了什么:

# 伪代码:客户端 ASC 处理服务器仲裁回执(引擎已内置,此处按逻辑重写)
def on_verdict(key, accepted):
    if accepted:
        mark_confirmed(key)                 # 预测 GE/Tag 转正,一切保留
        return
    for effect in reversed(predicted[key].effects):
        remove(effect)                      # 冷却 GE、临时 Buff 按入栈反序剥除
    restore_attributes(predicted[key].snapshot)   # 属性 catch-up 到权威值
    stop_or_blend_montage(predicted[key].montage) # 起手式急停或 0.1s 融合
    fire_cue("Cue.Common.Fizzle")           # 已播特效不撤,补「哑火」盖住
    metrics.log_reject(key)                 # 拒绝率入监控

最后一条不是装饰。拒绝率是这套系统的体检指标:正常运营中应低于 1%。拒绝率升高,几乎总是本地预检规则与服务器权威规则漂了——比如客户端的沉默判断漏了一个新 Tag。出现这个信号,走 bug 流程,别走网络排查。

预测的校正精度还取决于第 21 章讲过的复制粒度。ASC 上的 ActiveEffects 容器有三种复制模式:Full 把每个 GE 的完整信息复制给所有人;Mixed 只给属主发完整信息,旁观者拿到最小集;Minimal 连属主都拿最小集。预测客户端需要完整信息来做 catch-up,所以玩家自己的 ASC 至少要 Mixed。旁观者不需要预测别人,Minimal 足够。一个 200 人同图的战场,全部用 Full 等于把 GE 流量乘以玩家数广播,带宽直接爆炸——这是「远港 Online」早期压测踩过的坑,改成 Mixed 后技能相关流量降了六成以上。

23.3 TargetData:瞄准数据怎么合法地进服务器

技能「打哪」是客户端最诚实的输入,也是最危险的输入。冲锋冲向谁、火球落在哪个坐标、治疗点的是团队框架里哪个人——这些选择发生在客户端,因为准星和鼠标在客户端。但服务器不能照单全收:一个被改过的客户端可以声称「我瞄准了 500 米外掩体后的敌人」。TargetData 体系要解决的就是这件事:让瞄准数据从客户端合法地、可校验地到达服务器。

GAS 的做法是把瞄准做成一个 AbilityTask。技能激活后进入瞄准阶段,UAbilityTask_WaitTargetData 启动一个 TargetActor(AGameplayAbilityTargetActor 的子类,引擎自带直线射线、球形范围等),它每帧在客户端本地做射线检测或范围采集,把预览画给玩家看。玩家确认,数据打包成 FGameplayAbilityTargetDataHandle,随当前 Prediction Key 一起发给服务器;玩家取消,任务结束,一个字节流量都没产生。

flowchart TD
    A[技能激活,进入瞄准阶段] --> B[WaitTargetData 任务启动]
    B --> C[TargetActor 每帧本地采集
射线 / 范围 / 光标落点] C --> D{玩家确认?} D -- 取消 --> E[EndAbility
不产生网络流量] D -- 确认 --> F[打包 TargetDataHandle
带 PredictionKey 发送] F --> G[服务器反序列化] G --> H{服务器权威校验} H -- 通过 --> I[服务器侧任务收到数据
继续命中结算] H -- 失败 --> J[按 Key 拒绝并回滚
写入可疑日志]

服务器侧校验是这套机制的灵魂,清单只有五条,一条都不能省:

校验项具体做法拦的是什么
距离上限目标点与施法者权威位置的距离 ≤ 技能射程 × 容差改包伪造的千里之外命中
视线(LOS)权威世界做一次射线,验证无遮挡穿墙锁定
目标合法性阵营、存活状态、必选 Tag 在权威世界复核锁定尸体、队友、无敌目标
时效性回执时间 ≤ 采集时间 + 预测窗口重放旧数据
频度同一玩家的 TargetData 上报限流宏孩儿与 flood 攻击

容差要算,不能拍脑袋。目标在移动、双方都有延迟,服务器权威位置比客户端采集时看到的位置旧了约 RTT/2RTT/2,容差至少覆盖「目标移速 × RTT」,否则正常玩家会被误杀。更精细的做法是第 20 章讲过的延迟补偿:服务器按客户端采集时刻回滚目标位置再校验。校验失败的日志别丢,直接喂给第 28 章的反作弊系统——单次失败是网络抖动,集中失败是外挂画像。

记住一句话:TargetData 只是「申请」——它从来不是「事实」。客户端负责诚实采集与良好预览,服务器保留用权威世界重算的权力。伤害结算时,TargetData 只提供「谁、在哪」,打多少血的系数从第 27 章的数值表里查,一个数字都不从客户端包里读。

采集本身有三种常用模式,对应三种手感:确认型,玩家瞄准后点左键落锤,适合火球落点、冲锋方向;即时型,技能按下瞬间取当前准星快照,适合开枪、指向性技能,零额外交互;持续型,窗口内持续上报直到超时,适合引导类技能。「远港 Online」的规矩是默认即时型——每多一次确认交互,高延迟下就多一分「我到底放没放出去」的犹疑。

工程细节上,FGameplayAbilityTargetDataHandle 是一个多态数组:一次采集可以带多个命中点、多个 Actor 引用、多个位置,服务器反序列化后按类型取用。需要新采集形状(扇形、链式跳跃、地面绘制区域)就继承 AGameplayAbilityTargetActor 写自定义 TargetActor,采集逻辑只在客户端跑,网络上走的仍然只是一个 Handle。这条边界要守住:TargetActor 里禁止写结算逻辑,它只负责「看」,不负责「判」。

旁观者的画面走的是另一条路。队友和敌人不需要知道你瞄准的过程,他们只需要在服务器确认后看到结果:服务器激活的技能向他们多播 GameplayCue,复制的 GE 驱动他们本地的血条与受击动作。施法者看到的是「预测画面」,旁观者看到的是「权威画面」,两者天然相差一个 RTT——这就是为什么观战视角里,技能总比施法者自己的屏幕慢半拍。不要去对齐它们,对齐了反而说明你把旁观者也拉进了预测流量,带宽白烧。

23.4 连招与取消窗口:150ms 下手感为什么变肉

「远港 Online」战士的三连击:第一击横扫 0.6 秒,窗口内再按接第二击上挑,再接第三击劈斩。本地试玩行云流水,压测一开全是差评:「连招接不上」「明明按了」「我滚了为什么还挨刀」。把三段时间轴画出来,病因一目了然:

战士三连击:输入缓冲 / 取消窗口 / 服务器仲裁(RTT = 150ms)

t=0 按键:本地立即起手(0ms 反馈)

动画



第一击 横扫
第二击 上挑
第三击 劈斩

缓冲



窗口内按键 → 缓冲到下一段(跨段延伸)

取消


回执






K1 确认/拒绝到达(+RTT)
K2 回执
K3 回执

00.30.60.91.21.51.8s输入缓冲窗口(本地预测开启)取消窗口(可被闪避/技能打断)服务器回执(≈ +RTT 到达)

手感变肉有三个独立的病源,对症下药才有效。

病源一:窗口开关依赖服务器。输入缓冲窗口如果在服务器打开、靠 Tag 复制下来,客户端感知到的窗口整体右移了 RTT/2 ≈ 75ms。窗口尾巴被吃掉 75ms——玩家在 0.70 秒按的键,本地窗口显示已关,服务器窗口其实还开着,于是这一按被丢掉。症状是「连招随机断」。

病源二:段间衔接不走预测。第一击结束才向服务器请求第二击,每段起手晚一个 RTT,三连击整体拖长 300ms,节奏彻底散架。用公式对比两种实现下玩家的感知延迟:

\Delta T_{\text{段间}} = \begin{cases} 0\ \text{ms}, & \text{窗口本地开、下一段走预测链} \\[4pt] RTT, & \text{等服务器确认后再起手} \end{cases}

病源三:取消不同步。取消窗口内按闪避,本地立即翻滚,但「是否已被命中」是服务器权威事件——命中包比闪避请求早出发 75ms,服务器判你已挨刀,回滚把你从翻滚里拽回来躺平。玩家看到的是「我明明滚了」。这种情况无解,只能接受:闪避可以预测表现,无敌帧不能预测生效。

落地实现上,窗口用动画 Notify 驱动的 GameplayTag 表达:窗口开启时给 ASC 挂 State.Combo.Window(用 GE 授予,随预测链生效),下一段技能的激活条件要求这个 Tag;窗口外按键直接被预检拦掉。连招本体是一段伪代码就能说清的结构:

# 伪代码:战士三连击的窗口与输入缓冲
def strike(combo_index):
    play_montage(anim[combo_index])                # 本地立即播,走预测
    window = wait_gameplay_event("Event.Combo.Open")   # Notify 在 0.35s 打开
    buffered = wait_input_press(timeout=window.until(0.75s))
    if buffered and combo_index < 3:
        activate_predicted(anim[combo_index + 1])  # 派生 Key 挂同一预测链
        end_current()                              # 当前段进收尾帧
    else:
        end_current()                              # 连招结束,回 Idle

取消的仲裁权在服务器,但表现权在客户端:闪避、格挡这类取消技能本身也走预测激活,本地先动;服务器发现「取消请求晚于命中结算」就按权威结果回滚。窗口开多宽是手感参数,交给策划调——我们的职责是保证「窗口在本地开、仲裁在服务器走」这条管道不因为延迟变形。

取消还要解决「谁有资格打断谁」的秩序问题,GAS 用 Tag 表达取消组:技能声明 CancelAbilitiesWithTag(我激活时打断谁)与 BlockAbilitiesWithTag(我激活期间禁止谁)。「远港 Online」的取消优先级表只有四行,贴在这里当模板:

动作能打断被谁打断
普攻三连无(只被窗口衔接)闪避、格挡、受击
闪避普攻、普通技能受击、控制
普通技能普攻闪避、受击、控制
大招一切非控制动作仅控制(死亡、眩晕、击倒)

受击与控制之所以高居表顶,因为它们是服务器权威事件,客户端想赖也赖不掉。这张表本质是策划文档,但落进引擎就是每个 GA 资产里两行 Tag 容器——机制与设计的分界线,又一次画在了「代码管管道,数据管规则」上。

输入缓冲本身比 GAS 年长——第 5 章讲 Enhanced Input 时的输入缓冲、土狼时间是同一族手感工具。区别在于单机缓冲存在输入组件里,联机缓冲必须存在预测体系里:缓冲的不仅是一个按键事件,还有「下一个激活该挂哪条预测链」的上下文。把缓冲写成通用组件塞进项目基类,全职业三连、五连、蓄力接取消,都复用同一套。

蓄力类技能是连招体系的近亲,预测处理另有一套。按下瞬间本地激活、开始蓄力表现,这没问题;麻烦在「蓄力满没满」这个判定——本地计时与服务器计时会漂出半个 RTT。务实的分工是:蓄力进度本地算、本地画,玩家看到的是零延迟进度条;释放伤害按服务器权威时间算,服务器说蓄了 0.9 秒就是 0.9 秒的档位。玩家松手时本地立即播放释放动作,伤害数字晚一个 RTT 到达——伤害飘字本来就该由服务器驱动,没人指望它跟手,跟手的是动作与动画。

23.5 冷却与消耗:都该是 GE,别在技能里藏浮点数

见过最多的 GAS 反模式,是在技能类里写 float CooldownRemaining,每帧 Tick 往下减。这个写法在单机原型里能跑,进联机项目就是四宗罪:不可复制,UI 要拿到冷却值得专门去摸技能对象;不可预测,客户端扣了服务器没扣,两边的表永远对不上;Buff 无法介入,做个「冷却缩减 20%」的天赋得去改每个技能的代码;Tag 系统查不到,「被沉默时禁止施法」得写特殊分支绕开冷却逻辑。

GAS 的标准答案是把冷却和消耗都建模成 GameplayEffect。冷却 GE:Duration Policy 为 Has Duration,时长就是冷却秒数,Granted Tags 里带一个 Cooldown.Warrior.Strike01 这样的标签;技能声明自己的 Cooldown Tags,CheckCooldown 检查 ASC 上是否存在这些 Tag——存在就拦,消失就放。消耗 GE:Instant 类型,对 Mana 或 Stamina 属性做一个负的 Modifier。CommitAbility 一口气把两者都提交,预测体系内客户端先扣、服务器复核、错了整体回滚,全部走第 23.2 节那条管道,一行新机制都不用写。

两种建模的对比,给团队里还在犹豫的人看:

维度浮点数冷却(技能内部)冷却 GE(GAS 标准)
网络同步自己写复制逻辑随 ActiveEffects 容器自动复制
预测支持无,两端各扣各的随 PredictionKey 应用与回滚
冷却缩减 Buff改技能代码一个 GE 改 Duration,全局生效
沉默/禁用查询特殊分支BlockAbilitiesWithTag 一句话
UI 进度条轮询技能对象监听 ASC 的 Effect 事件

UI 的接法也顺手统一。技能栏进度条不该去问技能对象,而是问 ASC「带这个 Cooldown Tag 的 GE 还剩多久」:

// 技能栏取剩余冷却:问 ASC,不问技能对象(简化示例)
void UFHO_AbilitySystemComponent::GetCooldown(FGameplayTag Tag,
                                              float& Remain, float& Total) const
{
    FGameplayEffectQuery Q;
    Q.OwningTagQuery = FGameplayTagQuery::MakeQuery_MatchAnyTags(FGameplayTagContainer(Tag));
    Q.EffectDefinition = UFHO_GE_CooldownBase::StaticClass();   // 冷却 GE 统一基类
    Remain = 0.f; Total = 0.f;
    GetActiveGameplayEffects().GetActiveEffectsTimeRemainingAndDuration(Q, Remain, Total);
    // 查询接口的具体签名以 5.8 官方文档 ActiveGameplayEffects 词条为准
}

和第 19 章的 MVVM 一接,ViewModel 订阅 ASC 的 Effect 增删事件刷新进度条,技能图标自己就会转。数值的接法同样统一:冷却秒数、消耗量一律从 DataTable 查(第 27 章的数值表是上游),技能资产里只存 Tag 与行号引用。策划改一个数字,不动任何资产,不重开编辑器。

消耗走 GE 还有一个常被忽略的好处:失败原因可表达。CheckCost 失败时,UI 不该只灰掉图标,而要知道缺的是法力还是体力——消耗 GE 的 Modifier 里写着属性名,预检失败时把它带回给 UI,图标上就能准确画出「缺蓝」还是「缺体力」的红色缺口。浮点数时代这种信息要靠人肉传参,GE 时代它是数据自带的。

把视野再拉宽一点:DoT、HoT、护盾、易伤,本质都是 GE 的不同时长与周期配置。冷却 GE 不过是这个大家族里一个挂了 Cooldown Tag 的成员。第 22 章那句「GE 是一切数值变化的统一货币」,在本章落到实处——冷却和消耗都进了这同一种货币之后,技能的激活检查、预测、复制、UI、日志,全部只需要实现一遍。

沉默效果顺手也解决了:一个 Granted Tags 带 State.Debuff.Silence 的 GE 挂上,所有技能的激活条件里加一条「不存在该 Tag」,施法全禁;只禁法系就再细分 State.Debuff.Silence.Magic。浮点数时代要专门写的「沉默系统」,在统一建模后是零行新代码——一个 GE、一个 Tag、一条激活条件,收工。

23.6 500 个技能的目录宪法

50 个技能靠记忆管理,500 个技能靠宪法管理。「远港 Online」规划三个职业、每职业三系专精,技能总量冲着 500 去——没有组织法,第二年光找技能就能耗掉一个人力。

第一层是继承宪法,三层封顶,不许再深:

classDiagram
    class UGameplayAbility
    class UFHO_GameplayAbility {
        +InputTag
        +GetDataRow() FAbilityDataRow
        +ApplyCooldownAndCost()
    }
    class UFHO_MeleeAbility {
        +WeaponSocket
        +PlayComboSection()
    }
    class UFHO_WarriorAbility
    class UFHO_MageAbility
    class GA_Warrior_Strike01
    class GA_Warrior_Charge
    class GA_Mage_FrostBolt

    UGameplayAbility <|-- UFHO_GameplayAbility
    UFHO_GameplayAbility <|-- UFHO_MeleeAbility
    UFHO_MeleeAbility <|-- UFHO_WarriorAbility
    UFHO_GameplayAbility <|-- UFHO_MageAbility
    UFHO_WarriorAbility <|-- GA_Warrior_Strike01
    UFHO_WarriorAbility <|-- GA_Warrior_Charge
    UFHO_MageAbility <|-- GA_Mage_FrostBolt

项目基类放所有技能的公共机制:输入绑定、冷却消耗的提交、日志与监控埋点、数值表查询入口。职业基类放该职业的公共资源:武器挂点、动画集、专属资源条。最末端的具体技能一律是蓝图子类,里面只允许填表——Montage 引用、数据行号、Tag。技能之间禁止直接继承复用「长得像」的功能,公共部分一律上提,这条没得商量。

第二层是命名与目录宪法,违规由 CI 脚本拦截,不进评审环节:

资产类型命名格式例子
技能GA_职业_动作GA_Warrior_Charge
冷却 GEGE_Cooldown_职业_动作GE_Cooldown_Warrior_Charge
消耗 GEGE_Cost_职业_动作GE_Cost_Warrior_Charge
伤害 GEGE_Dmg_类型GE_Dmg_Physical
GameplayCueGC_用途GC_FX_SwordSlash
目录Content/Abilities/职业/专精/Abilities/Warrior/Arms/

GameplayTag 树同样立宪:Ability.职业.名、Cooldown.职业.名、State.(状态)、Event.(事件)、Data.Damage.*(伤害类型)。Tag 实行申请制——新 Tag 先在共享表里登记所有者与用途,再进资产。500 个技能规模下,一个重名 Tag 能污染半个技能库。

第三层是数据驱动配方表,技能的数值肉身全部住进 DataTable:

erDiagram
    ABILITY_TABLE ||--o{ COOLDOWN_TABLE : "引用冷却行"
    ABILITY_TABLE ||--o{ COST_TABLE : "引用消耗行"
    ABILITY_TABLE ||--o{ DAMAGE_TABLE : "引用伤害行"
    ABILITY_TABLE {
        int SkillID
        string GAAssetPath
        tag CooldownTag
        string MontagePath
    }
    COOLDOWN_TABLE {
        tag CooldownTag
        float BaseSeconds
        float MinSeconds
    }
    COST_TABLE {
        int SkillID
        string Attribute
        float Amount
    }
    DAMAGE_TABLE {
        int SkillID
        float BaseDamage
        float Coefficient
        tag DamageType
    }

运行时的流向是:GA 激活 → 按 SkillID 查表 → 数值以 SetByCaller 注入伤害/消耗 GE → 结算。策划在电子表格里维护源头,导入成表,和第 27 章的成长曲线、掉落表共用同一套工作流。程序侧只保证一件事:任何数字出现在 GA 资产里,评审打回。500 个技能的可维护性,说到底就是「机制在代码里,数值在表里,表现引用资产」这句话被无例外地执行了多少次。

回头看第 22 章的五大件,它们在宪法里各有固定席位:ASC 与 AttributeSet 属于角色,全项目共享一套基类;GA 按三层继承分层安放;GE 按用途分目录,通用 DoT、通用护盾这类跨职业技能共用的效果单独建 Shared 目录,谁的技能都不许私有;Tag 全树登记制。目录宪法管的是「谁有权改哪一层」——文件放在哪只是表象。基类层动一行要全组评审,专精层策划自己就能改,权限随层下放,风险随层收敛。

宪法落地靠五条纪律,写进团队 Wiki 首页:

  1. 数值不进 GA 资产。发现一个写死的数字,无论多小,评审打回。
  2. Tag 先登记后使用。共享登记表里有所有者和用途,孤儿 Tag 定期清理。
  3. 基类只放公共机制。职业基类出现某个专精独有的逻辑,就是分层漏了。
  4. 每个技能可单测。一个空关卡、一个木桩,技能必须能独立放出来走完整激活链。
  5. 命名交给 CI。lint 脚本扫资产名与目录,违规直接拒合入,不占用评审时间。

第五条听起来苛刻,实际上是最省钱的。人的注意力应该花在「这个技能的机制对不对」上,文件名叫 GA_warrior_charge 还是 GA_Warrior_Charge 从来是次要问题。

组织法还需要审计兜底。每月跑一次资产扫描:统计 GA 资产里残留写死数值的数量、孤儿 Tag 的数量、越层继承的次数,三个数字画成趋势图贴在群里。数字降,宪法活着;数字涨,说明有人开始绕路——绕路的理由九成是「急」,而「急」出来的技术债,利率比想象中高得多。


小结

预测是生存线——说它是优化就低估了它的分量。RTT 150ms 时无预测反馈约 200ms,相当于本地 12 帧,足以赶走四成测试玩家。PredictionKey 是预测的身份证:窗口内副作用全挂 Key,服务器回执带 Key,转正或回滚都按 Key 执行;拒绝率高于 1% 按 bug 处理。TargetData 只是申请——事实永远由服务器说了算。客户端诚实采集,服务器按距离、视线、合法性、时效、频度五条校验,容差至少覆盖目标移速乘 RTT。连招变肉的三个病源是窗口靠服务器开、段间不走预测、取消无本地表现,解药是窗口 Tag 预测授予加派生 Key 预测链。冷却与消耗一律建模为 GE,换来自动复制、预测回滚、Buff 可介入、UI 可订阅;技能组织靠三层继承、命名宪法与数据配方表撑住 500 个技能。

上手任务

  1. 给冲锋技能接上预测激活。用 Network Emulation 模拟 150ms 延迟加 3% 丢包,验收:按键到起手动画的感知延迟小于 50ms,GAS 预测日志无连续 Reject。
  2. 故意制造一次拒绝:服务器侧给角色挂沉默 Tag,客户端瞒着不放,然后放技能。验收:能看到 Failed 回执,预测冷却 GE 被剥除回满,特效只播了一遍且有失败反馈。
  3. 按 23.4 的伪代码实现三连击输入缓冲与窗口 Tag。验收:150ms 模拟下完整三连击总时长与本机零延迟的差距小于 100ms,窗口外按键不产生任何网络请求。
  4. 把一个浮点数冷却改造成冷却 GE 并接上技能栏进度条。验收:冷却期间 ASC 上存在对应 Cooldown Tag,进度条剩余时间与服务器日志一致,冷却缩减 Buff 无需改动技能即可生效。

下一章

预测让手感追平单机,但真相永远住在服务器里——下一章把这个「真相的住所」从编辑器里拆出来:Dedicated Server 的构建、裁剪、容器化与 Session 开房间。

延伸阅读

  • 官方文档:Unreal Engine → Gameplay Ability System → Gameplay Ability Prediction 词条
  • 源码:Engine/Source/Runtime/GameplayAbilities/Private/AbilitySystemComponent_Prediction.cpp(回执与回滚的实现现场)
  • 源码:Engine/Source/Runtime/GameplayAbilities/Public/GameplayPrediction.h(FPredictionKey 的定义与注释)
  • Lyra Starter Game:Abilities 目录结构、InputTag 组织与 GE 命名方式,可当目录宪法的参照系
  • GDC 2017《Overwatch Gameplay Architecture and Netcode》:预测与回滚在工业级射击游戏里的对照样本