UE5.8 GameplayEffect 堆叠、持续与冷却消耗

📑 目录

UE5.8 GameplayEffect 堆叠、持续与冷却消耗

地下城事件:毒雾与治疗光环重叠

上周三的内部测试里,策划小林在地下城首领战 recorded 了一段诡异视频。法师玩家连续踩进三层毒雾,按理说每秒掉血应该叠加,但屏幕上只跳出单段 2424 点伤害。同一时刻,两个牧师同时给坦克套上治疗光环,血条却以肉眼可见的速度疯涨,最后把一个 55 秒 HOT 刷成了永续回血。

会议室里播放完视频,所有人都在问同一个问题:GameplayEffect 到底怎么叠?

GAS 的 GameplayEffect 看起来只是把数值挂到目标身上,实际上内部有 Duration Policy、Stacking Policy、Period、Modifier 四套齿轮在同时转动。任何一项配置错误,都会出现伤害不叠、BUFF 被顶掉、冷却时有时无、资源消耗被预测性透支。这篇文章把那天晚上拆解的结论整理出来,覆盖 GameplayEffect 的持续时间、堆叠规则、周期性 Tick、冷却与消耗实现、数据表配置、Curve 成长、常见 BUFF/DEBUFF,以及网络同步与预测。任何一项配置错误,都会出现伤害不叠、BUFF 被顶掉、冷却时有时无、资源消耗被预测性透支。这篇文章把那天晚上拆解的结论整理出来,覆盖 GameplayEffect 的持续时间、堆叠规则、周期性 Tick、冷却与消耗实现、数据表配置、Curve 成长、常见 BUFF/DEBUFF,以及网络同步与预测。

GameplayEffect 的 Duration Policy

每个 GameplayEffect 在创建时都要选一个 Duration Policy。这不是简单的持续时间字段,而是决定 Effect 生命周期的核心分类。

  • Instant:立即生效一次,不保留状态。典型用途是造成瞬间伤害、治疗、消耗法力。
  • Duration:持续一段时间,期间 Modifier 持续作用于目标。典型用途是 55 秒加速、1010 秒燃烧。
  • Infinite:永久持续,直到被显式移除。典型用途是装备属性加成、被动技能、某些需要手动清理的 DEBUFF。

Duration Policy 的选择会直接影响 Period 是否有效、Stack 如何计算、网络复制采用哪种策略。Instant Effect 不存 Stack,因为它执行完就消失;Duration 和 Infinite Effect 才可能参与堆叠。

flowchart TD
    A[创建 GameplayEffect] --> B{选择 Duration Policy}
    B -->|Instant| C[立即修改 Attribute]
    B -->|Duration| D[创建活跃 Spec
持续 Duration 秒] B -->|Infinite| E[创建活跃 Spec
永久存在] C --> F[Spec 立即销毁] D --> G[定时检查剩余时间] E --> H[等待显式移除] G -->|时间到| I[移除 Spec 并回调] H -->|收到 Remove| J[移除 Spec 并回调]

在源码层面,三种 Policy 对应 EGameplayEffectDurationType 枚举。蓝图里打开 GameplayEffect 资产,最上方就是 Duration Policy 下拉框。很多初学者在这里踩坑:把本该是 Instant 的 Cost 配成了 Duration,结果法力被持续扣减;或者把被动 BUFF 配成 Infinite 后忘了在 Ability 结束时移除,角色永久带着测试数据。

Duration 的具体数值可以写死,也可以从 SetByCaller 或 Curve Table 读取。SetByCaller 的好处是同一个 GameplayEffect 资产可以被多个技能复用,实际持续时间由 Ability 在运行时注入。Curve Table 则适合等级成长,后文会展开。

Stacking Policy:三种堆叠策略

GameplayEffect 的堆叠是 GAS 里最容易出概念混淆的部分。UE5.8 提供了三种 Stacking Policy:

  • Aggregate by Source:按来源聚合。同一个施法者对同一个目标多次施加同一 Effect,会累加 Stack Count;不同来源互不影响。
  • Refresh Duration:每次施加都会刷新持续时间,但不增加 Stack Count。适合需要维持层数的减速、中毒标记。
  • Aggregate by Target:按目标聚合。所有来源对该目标的同一 Effect 共享一个 Stack Count。多人给同一目标上同一 DEBUFF 时会累加。

Policy 的选择直接改变了战斗体验。如果毒雾用 Aggregate by Source,玩家踩进三层毒雾会分别计算三层伤害;如果用 Refresh Duration,玩家无论踩多少层都只维持一层,只是刷新时间。那天测试里毒雾不叠,根本原因就是策划把 Stacking Policy 设成了 Refresh Duration,还误以为踩越多层伤害越高。后来我们专门在策划文档里加了一张表,把每个技能的 Stacking Policy 和原因写清楚,避免同类错误再次发生。

Stack 的另一个关键参数是 Stack Limit Count。超过上限后,新的施加要么被丢弃,要么按 Overflow Policy 处理。Overflow 可以配置为延长 Duration、应用额外 GameplayEffect、或者触发 Gameplay Cue。很多 MMO 里的层数上限 Debuff,比如最多 1010 层流血,就是靠 Stack Limit + Overflow 实现的。

architecture-beta
    group stack_top(cloud)[GameplayEffect Stacking]
    group source_group(server)[按来源聚合]
    group target_group(server)[按目标聚合]
    group refresh_group(server)[仅刷新]

    service spec(stack_top)[GE Spec]
    service asc_source(source_group)[施法者 A ASC]
    service stack_source(source_group)[Stack Count = 3]
    service asc_target(target_group)[目标 ASC]
    service stack_target(target_group)[Stack Count = 5]
    service timer(refresh_group)[Duration Timer]

    spec:R --> L:asc_source
    asc_source:R --> L:stack_source
    spec:B --> T:asc_target
    asc_target:B --> T:stack_target
    stack_source:R --> L:timer
    stack_target:L --> R:timer

选择 Policy 时还要考虑 Stack Duration Refresh Policy。当新 Stack 进来时,是刷新整层 Duration,还是让每层各自倒计时。Refresh Duration 策略下通常选择刷新整层;Aggregate 策略下如果希望每层独立衰减,就要关掉整层刷新。

Stack 的数值累加方式由 Stacking Modifier Magnitude Calculation 控制。常见选项有 Sum、Multiply、Use Stack Count 等。例如每层燃烧给目标加 55 点每秒伤害,33 层就是 1515 点每秒,这是 Sum;如果每层让伤害变成上一层的 1.21.2 倍,则是 Multiply。

Period 与 Tick

Duration 和 Infinite 类型的 GameplayEffect 可以开启 Periodic。Periodic 会每隔 Period 秒触发一次 OnPeriodicExecute,用来实现 DOT、HOT、护盾衰减等效果。这个机制让 GameplayEffect 不再只是静态 Modifier,而是能随时间产生多次结算的动态效果。

触发间隔由 Period 字段决定。如果 Period 是 1.01.0 秒,Duration 是 5.05.0 秒,那么 Effect 会在 0011223344 秒各触发一次,最后一次触发后不久 Effect 移除。注意第一次 Tick 发生在应用后的第 00 秒还是第一个 Period,取决于 ExecutePeriodicEffectOnApplication 选项。

周期性治疗的总量可以用下式估算:

Htotal=DP×htickH_{\text{total}} = \left\lfloor \frac{D}{P} \right\rfloor \times h_{\text{tick}}

其中 DD 是总持续时间,PP 是触发周期,htickh_{\text{tick}} 是每次治疗量。如果开启首次立即触发,则总次数要加 11

那天晚上牧师 HOT 被刷成永续,问题出在 Period 与 Duration 的联动理解上。两个牧师叠加的是同一个 GameplayEffect,Stacking Policy 设成了 Aggregate by Target,且每次新施加都会刷新整层 Duration。于是只要两个牧师交替释放,目标的 HOT Duration 永远不会降到零,相当于永续回血。修复方式是把 Stacking Policy 改成不刷新 Duration,或者限制 Stack Limit 并减少单跳治疗量。

sequenceDiagram
    participant S as 服务器
    participant T as 目标 ASC
    participant GE as 燃烧 GE

    S->>T: 第 0 秒应用 GE
    T->>GE: 立即触发首次 Tick
    GE-->>T: 扣血 20
    Note over T: Duration = 5.0s, Period = 1.0s
    T->>GE: 第 1 秒 Tick
    GE-->>T: 扣血 20
    T->>GE: 第 2 秒 Tick
    GE-->>T: 扣血 20
    S->>T: 第 2.5 秒刷新 Duration
    Note over T: 计时器重置
    T->>GE: 第 3.5 秒 Tick
    GE-->>T: 扣血 20
    T->>GE: 第 4.5 秒 Tick
    GE-->>T: 扣血 20
    GE-->>T: 时间到移除

Tick 的精准度在网络环境下会有波动。服务器权威 Tick 使用服务器世界时间,客户端预测性 Tick 使用本地时间。如果两端不同步,玩家可能看到 DOT 跳字不均匀。对于高频 Tick,建议把 Period 设得不小于 0.10.1 秒,避免网络复制压力。

用 GameplayEffect 实现 Cooldown 和 Cost

GAS 推荐把 Cooldown 和 Cost 都做成 GameplayEffect,而不是在 Ability 蓝图里硬编码。这样它们能自动参与网络同步、预测和 Tag 系统。

Cooldown

Cooldown 是一个 Duration 类型的 GameplayEffect。Ability 激活时,服务器把 Cooldown GE 应用到施法者身上,并赋予一个 Cooldown Tag,例如 Ability.Cooldown.Fireball。Ability 的 CheckCooldown 检查目标是否带有该 Tag,有则拒绝释放。整个机制的核心是 Tag 的存在与否,而不是一个独立倒计时,这是保证多端一致的关键。

冷却剩余时间可以表示为:

tremain=max(0,Tstart+CTnow)t_{\text{remain}} = \max\left(0, T_{\text{start}} + C - T_{\text{now}}\right)

其中 TstartT_{\text{start}} 是服务器开始冷却的时间戳,CC 是冷却时长,TnowT_{\text{now}} 是当前服务器时间。客户端预测时也会本地模拟这个计时,但权威判定永远以服务器 Tag 为准。

Cooldown 的 Duration 不建议写死在 GE 资产里。更灵活的做法是用 SetByCaller,让 C++ 或蓝图在施法时注入具体秒数。这样同一个火球技能可以按等级改变冷却。我们还把冷却缩减属性也接进来,在 Ability 里先算好最终冷却再注入 GE,避免在 GameplayEffect 内部做复杂运算:

bool UMyGameplayAbility::CommitAbilityCooldown(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const bool ForceCooldown)
{
    if (CooldownGameplayEffect)
    {
        FGameplayEffectSpecHandle SpecHandle = MakeOutgoingGameplayEffectSpec(
            CooldownGameplayEffect->GetClass(), GetAbilityLevel());
        SpecHandle.Data->SetSetByCallerMagnitude(
            FGameplayTag::RequestGameplayTag(FName("Data.Cooldown")),
            CooldownDuration);
        ApplyGameplayEffectSpecToOwner(Handle, ActorInfo, ActivationInfo, SpecHandle);
    }
    return true;
}

这段代码控制在 3030 行以内。核心只有三步:创建 Spec、用 SetByCaller 注入冷却时间、应用到所有者。

Cost

Cost 是一个 Instant GameplayEffect,通常扣减 Mana、Stamina 或弹药。Instant Effect 执行一次 Modifier 就销毁,不会在 ASC 里留下活跃 Spec。Ability 的 CheckCost 会预先读取当前 Attribute,判断资源是否足够。

Cost 也可以走 SetByCaller,按技能等级变化。一个常见错误是把 Cost 配成 Duration,导致法力每 Tick 都被扣。另一个错误是在客户端本地直接改 Mana 变量,不走 GE,这样预测失败时资源无法回滚。

Cooldown 和 Cost 都建议在 CanActivateAbility 里同时校验。CanActivateAbility 在客户端和服务端都会执行,客户端用于预测,服务端用于权威。如果两端逻辑不一致,预测失败率会明显升高。

Data Table 配置技能

技能数量多起来后,直接在 C++ 或蓝图里硬编码数值很难维护。UE5.8 的 GameplayEffect 支持通过 Curve Table 和 Data Table 驱动数值。

Curve Table 适合单值随等级成长,例如火球伤害、治疗量、冷却时间。在 GameplayEffect 的 Modifier 里,把 Magnitude 设成 Scalable Float,然后指定 Curve Table 和 Row Name。运行时 ASC 会根据目标等级自动查表插值。插值方式默认是线性,但也可以在 Curve Table 资产里改成常量或阶梯,适合某些只在特定等级跳变的数值。

Data Table 则适合复杂配置,比如每个技能一行,包含伤害、范围、消耗、冷却、弹道速度等字段。Ability 在激活时读取 Data Table 行,再注入到 GameplayEffect 的 SetByCaller 里。

graph LR
    A[技能 Data Table] -->|行索引 SkillID| B[Ability 激活]
    B -->|读取 Damage / Cost / Cooldown| C[SetByCaller]
    C --> D[GameplayEffect Spec]
    D -->|应用| E[目标 ASC]
    F[Curve Table] -->|按等级插值| C

实际项目里,我通常把 Data Table 定义成 USTRUCT(BlueprintType),字段包括:

  • Damage: float
  • ManaCost: float
  • Cooldown: float
  • Range: float
  • Radius: float
  • EffectClass: TSubclassOf

Ability 里用 UDataTable::FindRow 取到行,再用 SetSetByCallerMagnitude 把数值注入到 GE。策划改表就能调数值,不需要重新编译。

Curve 数值成长

Curve Table 是 GAS 里做数值成长最省事的方案。传统做法是在 C++ 里写 switch case 按等级返回不同数值,维护起来痛苦。Curve Table 把等级映射交给编辑器,策划可以直接在曲线里拉点。

一个伤害成长的 Curve 可能长这样:

等级火球伤害
130
555
10100
15160
20240

运行时 ASC 会根据目标等级在曲线点之间线性插值。1212 级时的伤害大约为 120120 左右,不需要代码里写任何分支。

如果希望高等级收益递减,可以把曲线设成对数形状。伤害随等级成长的近似公式可以写成:

D(L)=D0+kln(1+L)D(L) = D_0 + k \cdot \ln(1 + L)

其中 D0D_011 级基础伤害,kk 是成长系数,LL 是等级。Curve Table 本质上就是对这张函数表的采样。

Cost 和 Cooldown 也可以用 Curve 成长。很多项目里高级技能蓝耗更高、冷却更长,但单次收益也更高。策划需要在 Curve 编辑器里同时调整三条曲线,保证资源循环健康。

常见 BUFF/DEBUFF 实现

理解了 Duration Policy、Stacking、Period 和 Curve,就可以组合出常见的战斗效果。

中毒 DOT

中毒通常用 Duration + Period。每跳伤害走 GameplayEffect 的 ExecutePeriodicEffect。Stacking Policy 用 Aggregate by Source,让玩家身上的每层中毒独立计时和伤害。

持续回血 HOT

HOT 与 DOT 结构相同,只是 Modifier 用 Add 而不是 Subtract。Stacking Policy 需要小心:如果用 Aggregate by Target 且刷新 Duration,多个治疗者会让目标永续回血。多数项目会把 HOT 设成 Stack by Duration 或限制 Stack Limit。

攻击力 BUFF

攻击力 BUFF 通常是 Infinite 或 Duration,Modifier 类型用 Scalable FloatAttribute Based。如果是装备加成用 Infinite,手动管理移除;战斗临时 BUFF 用 Duration。Stacking Policy 常用 Aggregate by Source,不同队友给的攻击 BUFF 可以叠加。

眩晕 DEBUFF

眩晕不是单纯改数值,而是给目标加上 State.Stun 这样的 Gameplay Tag。其他 Ability 的 CanActivateAbility 检查到该 Tag 就拒绝释放。眩晕 Effect 可以是 Duration 类型,也可以带 Infinite 并在受击解除时移除。

护盾

护盾可以用 Duration 或 Infinite,Modifier 把伤害先扣到护盾 Attribute 上。更复杂的做法是在 Execution Calculation 里把护盾作为吸收层,伤害先减护盾,溢出部分再扣 Health。

flowchart LR
    A[伤害结算] --> B{护盾 > 0?}
    B -->|是| C[扣护盾]
    B -->|否| D[扣生命值]
    C --> E{伤害溢出?}
    E -->|是| D
    E -->|否| F[结算结束]
    D --> F

网络同步与预测

GameplayEffect 在网络环境下的行为,是 GAS 里最考验设计的地方。服务器持有权威状态,客户端根据 relevance 复制数据并做预测。

Instant Effect 在网络复制时,服务端执行后会把 Attribute 变化同步到客户端。由于 Instant Effect 不保留 Spec,客户端只收到最终的 Attribute 值。Damage 扣血、Cost 扣蓝都属于这一类。客户端无法通过 Spec 反推伤害来源,只能看到结果。如果需要显示伤害来源或飘字分类,通常要在 Execution Calculation 或 Ability 里记录上下文。

Duration 和 Infinite Effect 会保留 Spec,服务端会复制 Spec 的存在、Duration、Stack Count、Period 等信息。客户端看到的 Effect 列表与服务端保持一致,但由于延迟,开始和移除的时机可能略有偏差。例如服务端在第 1.01.0 秒应用了一个 55 秒 Effect,客户端在 120ms120\,\text{ms} 延迟下大概在第 1.121.12 秒收到,视觉上就是 BUFF 晚出现一瞬。对于大多数 RPG 这种偏差可以接受,但对节奏极快的格斗或射击游戏,就需要在 UI 层做提前量补偿。

预测性 GameplayEffect 在客户端应用时带有 Prediction Key。Prediction Key 相当于一批预测操作的身份证。客户端先应用 Effect 并播放表现,再发 RPC 给服务器。服务器确认后,预测性 Effect 转正;服务器拒绝后,客户端回滚。

cooldown 的预测常见问题是客户端本地 Tag 已消失,服务器 Tag 还在。玩家在延迟 120ms120\,\text{ms} 下连续按键,客户端认为冷却结束,服务器却判定还在冷却中。解决方法是让 CheckCooldown 读取 ASC 当前 Tag,而不是维护独立计时器,同时让 Cooldown GE 由服务器权威应用。

Cost 的预测风险在于资源透支。客户端先扣了蓝,服务器拒绝施法,必须把蓝加回来。如果 Cost 不走 GameplayEffect,而是直接改 Mana 变量,GAS 就无法回滚。所有资源修改都应该通过 Instant GameplayEffect 完成。

深入网络同步,Base Value 与 Current Value 的区分是理解数值同步的关键。Base Value 是原始值,Current Value 是叠加 Modifier 后的结果。服务器只同步 Base Value 和 Modifier 来源,客户端本地计算 Current Value。这个设计让 BUFF 叠加的显示非常自然,也减少了网络流量。

Stack 的同步也依赖这一机制。当服务端增加一层 Stack 时,会复制新的 Stack Count 和 Effect Spec 信息,客户端重新计算 Current Value。如果 Stack Policy 是 Aggregate,多个来源的 Stack 会在客户端累加;如果是 Refresh,客户端只是刷新已有的 Spec 计时。理解这一点,就能解释为什么有时候客户端看到 Stack 数正确,伤害却不正确:问题多半出在 Modifier 的 Magnitude Calculation 没有读取 Stack Count。

网络环境下,DOT 和 HOT 的 Tick 也要注意同步。服务端权威 Tick 使用服务器世界时间,客户端预测 Tick 使用本地时间。如果 Period 太短,网络抖动会让跳字看起来不连贯。建议 DOT 的 Period 不小于 0.250.25 秒,并对 UI 显示做插值平滑。对于高频小伤害的 DOT,比如每秒 1010 跳每跳 22 点,也可以考虑合并成每秒 112020 点,减少网络复制和 UI 刷新次数。

调试与避坑

那天晚上排查毒雾和 HOT 问题时,几个工具帮了大忙。

showdebug abilitysystem 可以在运行时查看 ASC 上所有活跃 GameplayEffect、Tag、Attribute。每个 Effect 会显示 Duration、Stack Count、Prediction Key、来源 Ability。判断一个 Stack 为什么没有叠,看这里最直观。

日志里把 LogAbilitySystemLogGameplayEffects 设成 Verbose,可以看到每个 GE 的应用、移除、Stack 变化、Period 触发。Stack 不叠时,通常能在日志里找到原因:是 Policy 拒绝了新 Stack,还是 Stack Limit 到了上限。

蓝图调试时,注意检查 GameplayEffect 资产里的这几个字段:

  • Duration Policy 是否正确。
  • Stacking Policy 是 Aggregate、Refresh 还是 Stack by Duration。
  • Stack Limit Count 和 Overflow Policy。
  • Period 是否开启,ExecutePeriodicEffectOnApplication 是否勾选。
  • Modifier 的 Magnitude 是 Scalable Float、Attribute Based 还是 SetByCaller。

常见错误是把毒雾这种应该叠层的 Effect 配成 Refresh Duration,或者把 Cost 配成 Duration。还有一个隐藏坑是 Curve Table 的 Row Name 拼写错误,导致运行时取到默认值 00, debug 半天才发现是名字少了后缀。

另一个容易忽略的细节是 GameplayEffect 的 Granted Tags。很多 BUFF 不只是改数值,还会给目标挂上 Tag,例如 State.BuffedState.Shielded。这些 Tag 同样参与网络复制,并且可以被其他 Ability 的 Tag 查询使用。设计时要统一 Tag 命名规范,避免不同系统里同名 Tag 产生歧义。

结语

地下城那波毒雾不叠、HOT 永续的问题,最后被我们拆成了 Stacking Policy 与 Duration Refresh 的组合误配。UE5.8 的 GameplayEffect 把战斗数值抽象得很干净,但干净意味着每个开关都要理解到位。Duration Policy 决定生命周期,Stacking Policy 决定层数行为,Period 决定周期触发,Curve Table 决定成长曲线,Prediction Key 决定网络手感。

如果你也在调 GAS 的战斗数值,建议从 showdebug abilitysystem 开始,把每个 Effect 的 Stack、Duration、Period 都看一遍。很多时候,问题不在公式有多复杂,而在一个 Policy 选错了位置。