UE5.8 GameplayEffect 堆叠、持续与冷却消耗
地下城事件:毒雾与治疗光环重叠
上周三的内部测试里,策划小林在地下城首领战 recorded 了一段诡异视频。法师玩家连续踩进三层毒雾,按理说每秒掉血应该叠加,但屏幕上只跳出单段 点伤害。同一时刻,两个牧师同时给坦克套上治疗光环,血条却以肉眼可见的速度疯涨,最后把一个 秒 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 持续作用于目标。典型用途是 秒加速、 秒燃烧。
- 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,比如最多 层流血,就是靠 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 等。例如每层燃烧给目标加 点每秒伤害, 层就是 点每秒,这是 Sum;如果每层让伤害变成上一层的 倍,则是 Multiply。
Period 与 Tick
Duration 和 Infinite 类型的 GameplayEffect 可以开启 Periodic。Periodic 会每隔 Period 秒触发一次 OnPeriodicExecute,用来实现 DOT、HOT、护盾衰减等效果。这个机制让 GameplayEffect 不再只是静态 Modifier,而是能随时间产生多次结算的动态效果。
触发间隔由 Period 字段决定。如果 Period 是 秒,Duration 是 秒,那么 Effect 会在 、、、、 秒各触发一次,最后一次触发后不久 Effect 移除。注意第一次 Tick 发生在应用后的第 秒还是第一个 Period,取决于 ExecutePeriodicEffectOnApplication 选项。
周期性治疗的总量可以用下式估算:
其中 是总持续时间, 是触发周期, 是每次治疗量。如果开启首次立即触发,则总次数要加 。
那天晚上牧师 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 设得不小于 秒,避免网络复制压力。
用 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 的存在与否,而不是一个独立倒计时,这是保证多端一致的关键。
冷却剩余时间可以表示为:
其中 是服务器开始冷却的时间戳, 是冷却时长, 是当前服务器时间。客户端预测时也会本地模拟这个计时,但权威判定永远以服务器 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;
}这段代码控制在 行以内。核心只有三步:创建 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: floatManaCost: floatCooldown: floatRange: floatRadius: floatEffectClass: TSubclassOf
Ability 里用 UDataTable::FindRow 取到行,再用 SetSetByCallerMagnitude 把数值注入到 GE。策划改表就能调数值,不需要重新编译。
Curve 数值成长
Curve Table 是 GAS 里做数值成长最省事的方案。传统做法是在 C++ 里写 switch case 按等级返回不同数值,维护起来痛苦。Curve Table 把等级映射交给编辑器,策划可以直接在曲线里拉点。
一个伤害成长的 Curve 可能长这样:
| 等级 | 火球伤害 |
|---|---|
| 1 | 30 |
| 5 | 55 |
| 10 | 100 |
| 15 | 160 |
| 20 | 240 |
运行时 ASC 会根据目标等级在曲线点之间线性插值。 级时的伤害大约为 左右,不需要代码里写任何分支。
如果希望高等级收益递减,可以把曲线设成对数形状。伤害随等级成长的近似公式可以写成:
其中 是 级基础伤害, 是成长系数, 是等级。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 Float 或 Attribute 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 列表与服务端保持一致,但由于延迟,开始和移除的时机可能略有偏差。例如服务端在第 秒应用了一个 秒 Effect,客户端在 延迟下大概在第 秒收到,视觉上就是 BUFF 晚出现一瞬。对于大多数 RPG 这种偏差可以接受,但对节奏极快的格斗或射击游戏,就需要在 UI 层做提前量补偿。
预测性 GameplayEffect 在客户端应用时带有 Prediction Key。Prediction Key 相当于一批预测操作的身份证。客户端先应用 Effect 并播放表现,再发 RPC 给服务器。服务器确认后,预测性 Effect 转正;服务器拒绝后,客户端回滚。
cooldown 的预测常见问题是客户端本地 Tag 已消失,服务器 Tag 还在。玩家在延迟 下连续按键,客户端认为冷却结束,服务器却判定还在冷却中。解决方法是让 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 不小于 秒,并对 UI 显示做插值平滑。对于高频小伤害的 DOT,比如每秒 跳每跳 点,也可以考虑合并成每秒 跳 点,减少网络复制和 UI 刷新次数。
调试与避坑
那天晚上排查毒雾和 HOT 问题时,几个工具帮了大忙。
showdebug abilitysystem 可以在运行时查看 ASC 上所有活跃 GameplayEffect、Tag、Attribute。每个 Effect 会显示 Duration、Stack Count、Prediction Key、来源 Ability。判断一个 Stack 为什么没有叠,看这里最直观。
日志里把 LogAbilitySystem 和 LogGameplayEffects 设成 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 拼写错误,导致运行时取到默认值 , debug 半天才发现是名字少了后缀。
另一个容易忽略的细节是 GameplayEffect 的 Granted Tags。很多 BUFF 不只是改数值,还会给目标挂上 Tag,例如 State.Buffed 或 State.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 选错了位置。