GameplayEffect 是 GAS 里概念密度最高的类:DurationPolicy、Period、Stacking、Modifier、Execution、Immunity……官方文档把它们平铺罗列,但实战中真正需要决策的只有三个:这个效果改什么(Modifier vs Execution)、改多久(三种 DurationPolicy)、重复应用怎么办(Stacking)。基于 UE5.3 源码(GameplayEffect.h、GameplayEffect.cpp)把这三层语义讲透。
三种 DurationPolicy:差别在"留下什么"
GE 资产是纯数据,运行时应用后生成 FActiveGameplayEffect 实例存进 ASC 的容器。三种策略的区别一句话说清:
- Instant:应用即结算——modifier 直接打到 BaseValue 上,不留下任何 ActiveGameplayEffect 记录。"立即恢复 50 血"就是 Instant:回血瞬间完成,之后没有任何东西可以"移除"(没有东西可移除,这是它被预测回滚坑的原因——7040 篇讲过 instant GE 不可逆)。
- Duration:留下带过期时间的记录,modifier 挂在聚合器上参与 CurrentValue 求值,到期自动移除。"10 秒内攻击 +20"。
- Infinite:留下没有过期时间的记录,只能被显式移除(或 Stacking 规则触发清除)。"眩晕直到被净化"。
从状态机看,三者是同一个生命周期在三个点的截断:
$$\text{Apply} \to \underbrace{\text{Active}}_{\text{Duration 有 } t_{expire}} \to \text{Remove}$$
Instant 在 Apply 处直接终结(Instant 的"Active"期长度为零),Infinite 没有 $t_{expire}$ 这条出边。
Period 是叠加在这条线上的另一条轴:GE 可以配置每隔 $p$ 秒重新执行一次效果(Period 字段,配 ExecutePeriodicEffectOnApplication 决定应用时刻是否立即执行第一跳)。Duration + Period 就是 DoT:"每 1 秒掉 10 血,持续 5 秒"。Period 的触发精度受 ASC 的 tick 节奏影响——DoT 掉血的实际间隔是 $p$ 的量化值,追求精确伤害的要用 Execution 里自己记时间。
Stacking:重复应用的四条规则
同一个 GE 第二次应用到同一目标时,行为由 StackingType 决定。默认 None:两份独立记录各自求值(两个 +50% 就是 ×2)。AggregateBySource / AggregateByTarget:合并成一条记录,堆一个计数器,规则复杂度全在这个合并后的记录上:
- 量级怎么算(
StackDurationExpirationPolicy之外的StackingDurationPolicy/LevelPolicy):堆叠的 level 可以是各份之和、取最大、或取最新一份; - 到期怎么办:单份到期时减少计数还是整栈刷新持续时间(Refresh 策略是"续杯"——刷新药水的标准行为);
- 溢出怎么办(
OverflowEffects):计数到上限时触发另一组 GE("叠满 5 层爆炸"); - 拒绝怎么办(
DenyOverflowApplication):到上限后新应用是直接拒绝还是替换最旧一份。
这些规则的组合覆盖了 buff/debuff 设计的大半需求,但有一个公认的边界:Stacking 规则只该用在"同一来源"的效果上(AggregateBySource)。AggregateByTarget 把不同来源的同种效果合并计数,机制上可行,但 level 来源、归属、净化逻辑都会变得难审计——多人项目里这是属性 bug 的重灾区,能不用就不用。
Modifier vs Execution:声明式与命令式的边界
GE 的效果载体二选一(可共存,但语义要分清):
- Modifier:声明式,配置"哪个属性、什么操作、多少量级"。求值走聚合器的四通道公式(7020 篇),天然支持复制、预测、堆叠、快照。量级可以是固定值,也可以是 MMC(ModifierMagnitudeCalculation)——按当前状态动态算:"伤害 = 施法者攻击力 × 系数 − 目标防御"。
- Execution:命令式 C++ 类(
UGameplayEffectExecutionCalculation),拿到 Source/Target 的完整属性快照,任意代码算出结果再写回属性。"结算伤害并分发给多个属性"(扣血+加怒气+触发装备特效)这种跨属性、条件分支多的逻辑,用 Modifier 表达会拆成十几个 GE,用 Execution 一个类解决。
选择边界很实际:能用 Modifier 就不用 Execution——Modifier 走聚合器路径,自动获得复制、预测、堆叠语义;Execution 的代码在两端各自执行,必须自己保证确定性(同样的输入快照必须算出同样的输出,否则预测和权威发散)。写 Execution 的第一纪律是不读快照之外的任何状态(不查全局表、不读 UI 状态),把输入全显式放进 GE 的 SetByCaller 参数。
SetByCaller 值得单独提:GE 里把某个 modifier 的量级标成"由调用方提供"(FGameplayEffectSpec 上的 tag→float 映射),技能激活时通过 spec 传入具体数值。火球术的技能等级、装备加成在激活瞬间注入 GE——一份"火球术伤害"GE 资产服务所有等级,数值差异全在 SetByCaller。这是 GAS 复用性的核心手法。
等级缩放
GE 的 modifier 量级支持按 GE 自身的 level 缩放(ScalableFloat:基础值 + 每级增量 + 曲线查表):
$$M(L) = M_0 + M_{perLevel}\cdot(L-1) + \text{Curve}(L)$$
GE 的 level 来自应用它的技能(火球术 3 级 → GE level 3)。和 SetByCaller 的区别:等级缩放是"GE 自己带着公式",SetByCaller 是"调用方直接给数"。前者适合"属性随等级成长"这类可资产化的规律,后者适合伤害计算这种技能特异的值。