第22章 · GAS 一阶:属性、技能与 Buff 的框架

📑 目录

导语:某个周五晚上,策划在群里发了三条新需求:火球术对被冰冻的目标伤害翻倍;点燃 DoT 不吃这条加成;暴击时点燃伤害也要跟着翻倍。用 if-else 写技能的程序盯着屏幕沉默了半分钟——这三条规则要分别插进伤害公式、DoT 结算、暴击判定三个互不相干的函数,而下周还要合入四个新技能。这个晚上,这个项目决定重写技能系统。MMO 的技能数量是以百计的,组合规则是以千计的,本章讲的就是那个把「千级组合」驯服成「五种资产」的框架:游戏能力系统(Gameplay Ability System, GAS)。

本章地图

mindmap
  root((第22章
GAS 一阶)) 系统策划补课 火球术解剖:五个子系统 组合复杂度杀死 if-else SDD 技能描述文档 GAS 五大件 ASC 结算中心 AttributeSet 账本 GameplayAbility 业务流程 GameplayEffect 统一货币 GameplayTag 通用语言 属性结算 Base 与 Current 两本账 Modifier 四种运算符 A["(Base+Add)×Mul 链"] 统一货币 GE 掉血/回血/加速/Buff 全是 GE 四种时长策略 DoT 即 Periodic Tag 三位一体 状态:眩晕中 权限:沉默禁放 标识:火系技能 远港 Online 落地 冲锋:位移+眩晕 顺劈:扇形 AOE 盾墙:减伤 Buff 诚实警告 学习曲线陡峭 什么时候别碰 GAS

本章的顺序是先破后立:先拆一个 WOW 技能看清复杂度从哪来,再引入 GAS 的五个核心概念,最后把「远港 Online」战士的三个技能完整配置一遍。读到一半觉得抽象是正常的——GAS 是 UE 所有系统里概念密度最高的一个,值得来回读两遍。

22.1 补课·系统策划:一发火球术里藏着五个系统

补课:本节补系统策划的建模思维——把技能看作一组规则的交点,比看成一个功能更有用。策划出身的读者可以直接看 22.1 末尾的 SDD 表格。

以《魔兽世界》的火球术为标本。不同资料片数值有出入,机制结构二十年没变过,拆开看是五行描述:

维度数值(示例口径)挂钩的子系统
读条2.25 秒,可被打断施法状态机、打断/沉默规则
消耗法力值 85 点资源系统(蓝条)
直接伤害320 + 法强 × 0.95伤害公式、属性系统
暴击伤害 ×2,概率取自暴击率属性系统、随机判定
点燃DoT 每 2 秒 48 伤,持续 8 秒持续效果(DoT)系统

五行字,每一行都是另一个系统的入口。火球术的生命周期早已超越了"按一下键掉一管血"——它是一条跨越好几秒的流水线:

stateDiagram-v2
    [*] --> 读条中: 按下技能键
    读条中 --> 已取消: 移动/被打断/被沉默
    读条中 --> 出手: 2.25s 读满
    出手 --> 命中判定: 弹道飞行
    命中判定 --> 结算直接伤害: 未抵抗
    命中判定 --> 未命中: 抵抗/免疫
    结算直接伤害 --> 附加点燃: 天赋已点出
    附加点燃 --> DoT跳动: 每 2s 一跳
    DoT跳动 --> DoT跳动: 剩余跳数 > 0
    DoT跳动 --> [*]: 8s 结束或被驱散
    已取消 --> [*]
    未命中 --> [*]

这一发技能要命吗?不——真正作祟的是叠在它上面的修饰规则。回到导语里策划的三条需求:「对被冰冻的目标伤害翻倍」要插进伤害公式;「点燃不吃这条加成」要在 DoT 结算里加一个例外分支;「暴击时点燃也翻倍」要求 DoT 能读到直接伤害的暴击结果。三条规则,落进三个互不相干的函数。

再往上叠:天赋「火焰强化」全局火系 +10%,装备词条「施法速度 +8%」,团队 Buff「奥术光辉」法强 +40,目标身上的 Debuff「元素诅咒」火系易伤 +15%,还有吸收盾要优先扣减……每个来源都想在公式里占一个位置。

还有一类规则连公式都不进,直接横切在状态机上:沉默禁止读条起手,眩晕打断进行中的施法,移动取消吟唱,目标死亡丢失弹道。读条、瞬发、引导三种施法形态,与这些横切规则的交互各不相同——引导类技能(比如暴风雪)被击退后是重新定向而非中断,沉默却只拦截读条的起手瞬间。策划随口一句"这个技能被打断时返还一半蓝耗",程序就要在取消分支里再接一根管子到资源系统。

算一笔规模账。一个中型 MMO 按 12 个职业、每职业 24 个技能估算,约 300 个技能;每个技能平均挂 6 类修饰来源。if-else 写法的维护成本是 O(技能数 × 修饰来源数)——任何一个新需求都要回归 1800 个交点。项目到第二年,改一条规则引出三个线上事故是常态。复杂度藏在哪里?组合里。它几乎从来不被"写错",但一定会在某个转折点被组合撑爆。

出路只有一条:把"效果"从"技能"里抽出来,立成一等公民。技能不再直接改数字,它只负责签发标准化的效果凭证;结算中心统一处理凭证与属性的换算。这正是 GAS 的世界观。

SDD:策划与程序之间的合约

框架落地的第一步不在引擎里,在文档里。技能描述文档(Skill Design Document, SDD)是系统策划的标准产出物,一个技能一页,字段固定:

字段火球术示例说明
ID / 名称Skill_Mage_Fireball全局唯一,程序按 ID 索引
类型主动 / 读条主动、瞬发、被动、光环四类
资源消耗法力 85指向哪个资源属性
冷却无(GCD 1.5s)独立冷却与公共冷却分开写
读条2.25s,可打断移动是否取消要写明
效果列表伤害 320+SP×0.95;点燃 48×4 跳每条效果:数值 / 系数 / 来源属性
标签火系、法术、投射物供免疫、易伤、套装检索
网络需求服务器权威结算命中与伤害以服务器为准

「效果列表」和「标签」两栏是重点——它们直接预告了本章后半的两个主角:GameplayEffect 和 GameplayTag。策划按这个格式写,程序按这个格式建资产,两边不再靠口头约定。「远港 Online」从第一天起就执行一条纪律:技能没进 SDD 表,程序有权不接

22.2 GAS 五大件:一张图先看懂

GAS 的概念多到劝退,但骨架只有五根。先给一组类比,把抽象名词钉在直觉上——把 GAS 想成一家银行的结算体系:

  • AbilitySystemComponent(ASC):总行结算中心。一切业务都经过它,它不知道"火球术"是什么,只认凭证和账本。
  • AttributeSet:账本。血量、法力、攻击力,每个科目一页账,记两栏数字。
  • GameplayAbility:业务流程单。开户、转账、冻结,是"能做的事"的标准描述。
  • GameplayEffect(GE):统一货币,或者说转账凭证。一切数值变化都必须兑换成它才能入账。
  • GameplayTag:全行通用的科目代码与状态代码,所有单据靠它对暗号。

五者的持有与引用关系,这是本章的灵魂图:

classDiagram
    class UAbilitySystemComponent {
        <<结算中心>>
        +TryActivateAbility()
        +ApplyGameplayEffectToTarget()
        +持有 AttributeSet 数组
        +持有 AbilitySpec 列表
    }
    class UAttributeSet {
        <<账本>>
        +Health: FGameplayAttributeData
        +AttackPower: FGameplayAttributeData
        +PostGameplayEffectExecute()
    }
    class UGameplayAbility {
        <<业务流程>>
        +CanActivateAbility()
        +CommitAbility()
        +技能 Tag 容器
    }
    class UGameplayEffect {
        <<数值凭证>>
        +Modifiers 数组
        +DurationPolicy
        +授予/需求/屏蔽 Tag
    }
    class FActiveGameplayEffect {
        <<运行中的效果实例>>
        +剩余时长
        +来源与目标
    }
    class FGameplayTagContainer {
        <<通用语言>>
        +State.Debuff.Stun
        +Ability.School.Fire
    }
    UAbilitySystemComponent "1" *-- "n" UAttributeSet : 挂账
    UAbilitySystemComponent "1" *-- "n" UGameplayAbility : 授予为 Spec
    UAbilitySystemComponent ..> FActiveGameplayEffect : 应用 GE 后生成
    UGameplayEffect "1" *-- "n" FActiveGameplayEffect : 实例化
    UGameplayAbility ..> UGameplayEffect : 签发(伤害/消耗/冷却/Buff)
    UGameplayEffect ..> UAttributeSet : Modifier 改写属性
    UGameplayAbility ..> FGameplayTagContainer : 激活条件与分类
    UGameplayEffect ..> FGameplayTagContainer : 授予状态与权限

读图要点:ASC 是唯一的交通枢纽,Ability 和 Effect 都不直接碰对方,更不直接碰属性——一切经由 ASC 结算。运行时真正"活着"的效果是 FActiveGameplayEffect,它有剩余时长、能被查询、能被移除;UGameplayEffect 只是它的资产模板,好比合同模板与已签署合同的关系。

这里埋着 GAS 最容易拧巴的一层:资产与实例分离,而且是两层实例UGameplayAbility 是资产,授予到 ASC 上变成 FGameplayAbilitySpec,带等级、带输入绑定;UGameplayEffect 是资产,应用时先打包成 FGameplayEffectSpec(带等级、上下文、来源),落到目标身上才成为 FActiveGameplayEffect。调试时在编辑器细节面板里看到的是实例层,改数值改的却是资产层——新手最常见的乌龙,就是对着运行时实例找半天,忘了数据在 .uasset 里。

启用层面只有两步:在 .uproject 里打开 GameplayAbilities 插件,在模块的 Build.cs 里加 GameplayAbilitiesGameplayTagsGameplayTasks 三个依赖(对应官方文档:Unreal Engine → Gameplay Ability System 词条,5.8 的设置细节以文档为准)。

ASC 住在哪,是个有讲究的问题。玩家角色的 ASC 建议挂在 PlayerState 上(见第 3 章):角色死亡换 Pawn,ASC 和属性账本跟着 PlayerState 走,Buff 不掉。AI 的 ASC 直接挂 Character 即可,死了连同 Actor 一起销毁,没有续存需求。「远港 Online」的战士、法师都用前一方案,野怪用后一方案。

输入怎么接到 Ability 上?两条路。原型期走 AbilityLocalInputPressed 绑整型 ID,够用但僵硬;正规做法是按 Tag 绑定——Enhanced Input 的动作(见第 5 章)触发时带着 InputTag.Ability.Charge 去找 Spec 上挂了同一 Tag 的技能激活。技能换键位、一键多技能、手柄映射,全是配置层的事,代码零改动。「远港 Online」从第二个技能起就只用 Tag 绑定。

22.3 属性:Base 是存款,Current 是可用余额

AttributeSet 里每个属性都是 FGameplayAttributeData,里面有两栏账,而非孤零零一个 float:

  • BaseValue:基础值。等级成长、永久加成写在这里,是"存款本金"。
  • CurrentValue:当前值。装备、Buff、Debuff 全部结算完之后玩家实际生效的数字,是"可用余额"。

GE 从不改写 BaseValue(Instant 类除外,后面讲)。它携带的是修饰器(Modifier),挂在账本旁边参与计算。修饰器有四种运算符:Additive(加)、Multiplicitive(乘)、Division(除)、Override(覆盖)。ASC 的聚合器按固定顺序结算:

Final=(Base+iAi)×jMj/kDk\text{Final} = \left(\text{Base} + \sum_{i} A_i\right) \times \prod_{j} M_j \Big/ \prod_{k} D_k

若存在 Override 修饰器,则忽略其余全部,直接取覆盖值。文字版读法:先把所有加法修饰器求和加到基础上,再依次乘上所有乘法修饰器,最后除以除法修饰器。顺序是写死的,所以「先加后乘」和「先乘后加」的歧义在 GAS 里不存在——这是框架消灭的第一类 bug。

来一遍完整演算。「远港 Online」的战士,基础攻击力来自等级成长:Base = 100。身上挂着三个效果:

  • 装备「远航巨斧」:Additive +30
  • Buff「战吼」:Additive +15
  • 装备「潮汐护符」:Multiplicitive ×1.2

代入公式:Final = (100 + 30 + 15) × 1.2 = 174。下面这张层级图把结算管道画出来,注意巨斧和战吼在同一层求和,护符在下一层做乘法——层与层之间不可交换:

攻击力结算管道:Final = (Base + ΣAdd) × ΠMulBase 基础值100加法层 ΣAdd巨斧+30战吼 Buff+15乘法层 ΠMul潮汐护符×1.2Final174(100 + 30 + 15) × 1.2 = 174:加法层先合并,乘法层后作用,顺序固定不可交换卸下巨斧 = 移除对应 GE → 加法层少一项,Current 自动回落到 (100+15)×1.2 = 138Buff 到期 = 移除对应 GE → 同理回落到 120。全程没人直接改 Base,无需手工"还原"这就是两本账的价值:效果可撤销,是 GE 模型的根基。

两本账解决的是「效果可撤销」问题。if-else 时代,脱下装备要人肉把 +30 减回去,Buff 到期要人肉恢复——只要漏一处,玩家的攻击力就永远漂移。GAS 里装备和 Buff 都是 GE,脱下或到期就是移除这个 GE,聚合器重新结算,Current 自动归位。没人改 Base,就没有还原的负担。

血量是个特例:掉血直接改 BaseValue(用 Instant 类型的 GE),因为伤害属于"已经发生的既成事实"——它不是挂着的可撤销效果。CurrentValue 永远从 Base 加修饰器重算,所以回血后当前血量也要被 MaxHealth 钳制——这类收尾逻辑写在 AttributeSet 的 PostGameplayEffectExecute 里。

顺手分清两个回调的工种。PreAttributeChange 在修饰器即将应用时触发,做前置钳制,比如"新血量不许越过上限";PostGameplayEffectExecute 在整个 GE 结算落账之后触发,掉血后的死亡判定、怒气攒满后的技能高亮,都挂在这里。另一点容易想岔:怒气、连击点、能量条这类"资源"同样建模为属性,放技能消耗 30 怒气就是一次 Instant GE 的扣减——资源系统和属性系统同属一本账——只是科目不同而已。

一个最小可用的 AttributeSet 长这样,解决"属性声明 + 网络复制 + 血量钳制"三件事:

// 战士属性集:血量与攻击力。配合 ASC 挂在 PlayerState 上。
UCLASS()
class FHO_API UWarriorAttributeSet : public UAttributeSet
{
    GENERATED_BODY()
public:
    UPROPERTY(ReplicatedUsing = OnRep_Health)
    FGameplayAttributeData Health;
    ATTRIBUTE_ACCESSORS(UWarriorAttributeSet, Health)

    UPROPERTY(ReplicatedUsing = OnRep_MaxHealth)
    FGameplayAttributeData MaxHealth;
    ATTRIBUTE_ACCESSORS(UWarriorAttributeSet, MaxHealth)

    UPROPERTY(ReplicatedUsing = OnRep_AttackPower)
    FGameplayAttributeData AttackPower;
    ATTRIBUTE_ACCESSORS(UWarriorAttributeSet, AttackPower)

    virtual void PostGameplayEffectExecute(
        const FGameplayEffectModCallbackData& Data) override;

    UFUNCTION() void OnRep_Health(const FGameplayAttributeData& OldValue)
    { GAMEPLAYATTRIBUTE_REPNOTIFY(UWarriorAttributeSet, Health, OldValue); }
    UFUNCTION() void OnRep_MaxHealth(const FGameplayAttributeData& OldValue)
    { GAMEPLAYATTRIBUTE_REPNOTIFY(UWarriorAttributeSet, MaxHealth, OldValue); }
    UFUNCTION() void OnRep_AttackPower(const FGameplayAttributeData& OldValue)
    { GAMEPLAYATTRIBUTE_REPNOTIFY(UWarriorAttributeSet, AttackPower, OldValue); }
};

要点只有三行:ATTRIBUTE_ACCESSORS 宏生成 Get/Set 访问器,让 GE 能按名字找到属性;每个属性配一个 OnRep 走复制通道(机制见第 21 章);钳制逻辑放 PostGameplayEffectExecute,比如 SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()))。属性的初始值(1 级战士血量 500 之类)用 DataTable 配合 InitStats 灌入,别硬编码。

22.4 GameplayEffect:一切数值变化的统一货币

现在给 GE 一个砸实的定位:在 GAS 的世界里,任何数值变化都必须先兑换成 GE,不允许私改账本

掉血是 GE(Instant,攻击力 −40)。回血是 GE(Instant,血量 +60)。加速是 GE(Duration,移动速度 ×1.3,持续 6 秒)。上 Buff 是 GE(Duration,授予 Tag + 挂修饰器)。甚至连技能的"消耗"和"冷却"也是 GE——CommitAbility 干的事就是把 Cost GE 和 Cooldown GE 各应用一次。

为什么要这样自缚手脚?类比数据库:GE 就是事务。它带来源、带目标、带撤销语义、带网络同步。全部变化走同一个管道,才谈得上统一的日志、统一的回放、统一的反作弊校验(第 28 章会用到这一点)。谁绕过 GE 直接 SetHealth,谁就破坏了这个闭合循环——代码评审时见到直接改属性的调用,一律打回。

GE 按时长分四种策略,选对策略是配置的一半:

时长策略语义典型用途
Instant立即结算,直接改写 BaseValue掉血、回血、扣蓝
Duration挂修饰器,到期自动移除加速、盾墙减伤
Infinite挂修饰器,直到被显式移除装备词条、被动光环
Periodic在 Duration/Infinite 上按周期执行 Instant点燃 DoT、回血 HoT

一个"点燃"Debuff 拆开看就是:Duration = 8 秒、Period = 2 秒的 Periodic GE,每跳执行一次"血量 −48"的 Instant 结算,到期自动销毁。22.1 里那条让程序沉默的状态机,翻译成资产配置只有五行字段。

一次 GE 从签发到入账的完整管道:

flowchart TD
    A["技能/装备/场景触发"] --> B["MakeOutgoingGameplayEffectSpec
封装 GE 模板 + 等级 + 上下文"] B --> C{"目标 ASC 校验
免疫 Tag / 应用条件"} C -- "不通过" --> D["拒绝入账,记日志"] C -- "通过" --> E["生成 FActiveGameplayEffect"] E --> F{"时长策略?"} F -- "Instant" --> G["立即改写属性 BaseValue"] F -- "Duration / Infinite" --> H["修饰器挂入聚合器
重算 CurrentValue"] F -- "Periodic" --> I["注册周期计时器
每跳执行一次 Instant"] G --> J["广播属性变化 + 复制到客户端"] H --> J I --> J H -- "到期/被移除" --> K["修饰器摘除
Current 自动回落"] I -- "跳数耗尽" --> K

管道里有两个扩展点值得知道名字。Modifier 的数值不想写死,可以换 ModMagnitudeCalculation(MMC)从来源属性算——比如"点燃每跳伤害 = 施法者法强 × 0.3"。整套结算不够用时还有 Execution Calculation 做完全自定义,「远港 Online」的伤害公式(攻防换算、暴击、易伤)就收在一个自定义 Execution 里,这是后话,配置细节以官方文档 GameplayEffect 词条为准。

回头把导语留下的第三道题解掉:暴击时点燃也翻倍。钥匙是 GE 的上下文(Effect Context)——每次应用 GE 都随身带一包信息:来源 ASC、施法者、命中结果(含这次伤害是否暴击)。点燃 GE 的 MMC 从上下文里读到"直接伤害已暴击",系数乘 2,收工。if-else 时代要横跨三个函数人肉传参的活,在框架里只是读一个字段——凭证自带附件,这是统一货币的隐藏福利。

数值来源实际有三种写法,按复用粒度选:固定值写在 GE 资产里,一个效果一份;MMC 从属性实时算,公式归一处;SetByCaller 由调用方按 Tag 名传参——技能激活时把"系数 1.6"塞进 Spec,同一个伤害 GE 模板被冲锋、顺劈、盾击三个技能复用,各自注入不同系数。「远港 Online」的伤害 GE 全走 SetByCaller,这是"第四个技能不造新 GE"承诺的技术底座。

把上面管道落成伪代码,看聚合器怎么工作:

# 伪代码:ASC 对一个属性做聚合结算(对应引擎 FAggregator 的简化模型)
def evaluate_attribute(attr, active_effects):
    adds, muls, divs, override = [], [], [], None
    for ge in active_effects:                     # 遍历挂在 ASC 上的所有活跃 GE
        for mod in ge.modifiers:
            if mod.target != attr:  continue      # 只取关心本属性的修饰器
            mag = mod.get_magnitude(ge)           # 固定值或经 MMC 计算
            if   mod.op == ADD:      adds.append(mag)
            elif mod.op == MULTIPLY: muls.append(mag)
            elif mod.op == DIVIDE:   divs.append(mag)
            elif mod.op == OVERRIDE: override = mag   # 后到的覆盖先到的
    if override is not None:
        return override
    value = attr.base_value + sum(adds)           # 先加
    for m in muls: value *= m                     # 再乘
    for d in divs: value /= d                     # 后除
    return value

# 调用:战士攻击力
# active_effects = [GE_巨斧(+30), GE_战吼(+15), GE_护符(×1.2)]
# → (100 + 45) × 1.2 = 174,与 22.3 的演算一致

这段伪代码同时是调试口诀:线上发现属性不对,先列出目标身上全部活跃 GE,再逐个查修饰器——范围永远收敛在"GE 列表 × Modifier 列表"里,不用翻全工程。

22.5 GameplayTag:状态、权限、标识,三位一体

Tag 是层级化的字符串标签,写成点分路径:State.Debuff.StunDamage.School.FireAbility.Warrior.Charge。层级的意义在匹配:State.Debuff 是父节点,查询"是否中了任意 Debuff"时用父 Tag 一问,眩晕、沉默、减速全部命中。Tag 在 Project Settings → GameplayTags 里集中登记,禁止代码里随手造——散落在代码各处的裸字符串,是 GAS 项目腐化的第一个征兆。

Tag 在 GAS 里同时干三件事,这是它"通用语言"地位的来历:

其一,状态:描述"我现在是什么样"。 被冲锋眩晕,就往目标 ASC 上挂一个授予 State.Debuff.Stun 的 GE,到期 Tag 自动摘除。动画系统、AI 系统、UI 系统都来查这同一个 Tag:动画播僵直、AI 停止决策、头像框亮眩晕图标。一个状态,四处消费,源头只有一处。

其二,权限:描述"我现在能干什么"。 每个技能的 ActivationBlockedTags 配上 State.Debuff.Silence,被沉默的目标所有技能统统点不亮——不用给每个技能单独写"沉默时禁用"。反过来,ActivationRequiredTags 表达"需要某状态才能放",比如战士的处决技要求目标带 State.LowHealth

其三,标识:描述"这是个什么东西"。 火球术挂 Damage.School.Fire,冰甲术给自己挂"受到的 Damage.School.Fire 伤害减半"的 MMC 判断,套装效果检索"装备 4 件后所有 Damage.School.Fire 技能暴击 +10%"。新加一个火系技能,只要打上同样的 Tag,存量规则自动生效——这是 22.1 那个 O(技能×修饰) 灾难的解药。

三种身份拧在同一张关系网里:

erDiagram
    ABILITY ||--o{ GAMEPLAY_EFFECT : "签发(伤害/消耗/冷却/Buff)"
    GAMEPLAY_EFFECT ||--o{ MODIFIER : "携带"
    MODIFIER }o--|| ATTRIBUTE : "改写"
    GAMEPLAY_EFFECT ||--o{ GAMEPLAY_TAG : "授予 GrantedTags"
    GAMEPLAY_EFFECT ||--o{ GAMEPLAY_TAG : "免疫/屏蔽"
    ABILITY ||--o{ GAMEPLAY_TAG : "标识 AbilityTags"
    ABILITY ||--o{ GAMEPLAY_TAG : "激活条件 Required/Blocked"
    ABILITY {
        string 技能ID
        float 冷却
        string 消耗指向属性
    }
    GAMEPLAY_EFFECT {
        string 时长策略
        float 周期
    }
    MODIFIER {
        string 运算符
        float 数值或MMC
    }
    ATTRIBUTE {
        float Base
        float Current
    }
    GAMEPLAY_TAG {
        string 层级路径
    }

读法:ABILITY 与 TAG 之间有两条边——"我是什么"(标识)和"我何时能放"(权限);GE 与 TAG 之间也有两条边——"我给你挂什么状态"和"你免疫我吗"。技能、效果、属性三方的协作全部由 Tag 这个中间语言撮合,谁也不需要 import 谁。

两个工程细节提前打个桩。匹配规则:HasTag 默认父匹配子,查 State.Debuff 能命中 State.Debuff.Stun;引擎也提供精确匹配开关,但九成场景不该用它——精确匹配会悄悄废掉层级设计的意义,新增 State.Debuff.Freeze 时旧代码集体漏判。复制规则:Tag 随 GE 的活跃状态走既有网络通道,客户端查"目标是否被眩晕"拿到的是复制回来的结果,不必自写 RPC,与第 21 章的复制条件联动。

权限校验落到代码里很轻。技能基类的激活检查大致长这样:

// 技能激活前的 Tag 闸门:对应 CanActivateAbility 的核心逻辑
bool UWarriorAbilityBase::CheckActivationTags(
    const UAbilitySystemComponent* ASC) const
{
    // 沉默中:一切技能禁放(策划在 ActivationBlockedTags 配好)
    if (ASC->HasAnyMatchingGameplayTags(ActivationBlockedTags))
        return false;
    // 需要目标处于某状态(如处决要求目标低血)
    if (!ActivationRequiredTags.IsEmpty() &&
        !ASC->HasAllMatchingGameplayTags(ActivationRequiredTags))
        return false;
    return true;
}

void UWarriorAbilityBase::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* Info,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const FGameplayEventData* TriggerData)
{
    if (!CheckActivationTags(Info->AbilitySystemComponent.Get()))
    { EndAbility(Handle, Info, ActivationInfo, true, true); return; }
    // CommitAbility:扣消耗 GE、挂冷却 GE,一次提交
    if (!CommitAbility(Handle, Info, ActivationInfo))
    { EndAbility(Handle, Info, ActivationInfo, true, true); return; }
    ApplySkillEffects(Info);   // 各技能签发自己的伤害/控制 GE
}

注意 CommitAbility 的位置:消耗和冷却在激活成功的同一刻入账。顺序错了(先放技能后扣费),就会出现"没蓝也能出手"的线上事故。

22.6 「远港 Online」:战士三技能落地

理论收拢,上配置。先看一次技能释放在网络上的完整旅程——GAS 天生为服务器权威设计,结算全部发生在服务器,客户端拿复制回来的结果(复制机制见第 21 章;按下去立刻有反馈的本地预测是第 23 章的主菜):

sequenceDiagram
    autonumber
    participant P as 玩家客户端
    participant ASC_C as 客户端 ASC
    participant S as 服务器
    participant ASC_S as 服务器 ASC
    participant ATTR as 目标 AttributeSet
    P->>ASC_C: 按下冲锋(Enhanced Input,见第 5 章)
    ASC_C->>S: RPC: TryActivateAbility(冲锋)
    S->>ASC_S: 服务器侧激活
    ASC_S->>ASC_S: Tag 闸门 + CommitAbility(扣费/冷却)
    ASC_S->>ATTR: ApplyGE: 眩晕 GE 挂到目标
    ATTR-->>ASC_S: 修饰器入账 / Tag 授予
    ASC_S-->>P: 复制:技能进入冷却(GE 复制)
    ASC_S-->>P: 复制:目标属性与 Tag 变化(OnRep)
    P->>P: UI 刷新冷却圈 / 目标头像亮眩晕图标

三个技能,按 22.1 的 SDD 格式配齐。这张表就是交给策划填、程序照着建资产的合约:

字段冲锋顺劈盾墙
AbilityTagsAbility.Warrior.ChargeAbility.Warrior.CleaveAbility.Warrior.ShieldWall
冷却 GE20s(Tag: Cooldown.Charge)8s60s
消耗无,命中回复怒气 20怒气 30
主效果 GEGE_Charge_Stun:Duration 1.5s,授予 State.Debuff.StunGE_Cleave_Damage:Instant,攻强 ×1.6GE_ShieldWall:Duration 8s,承伤系数 ×0.6
目标单体,8–25m 冲锋距离前方 5m、120° 扇形自身
关键 Tag 规则目标带 State.Immune.Stun 时眩晕被拒仅对敌人阵营生效激活时授予 State.Buff.ShieldWall
附加行为位移由冲刺动画的根运动承担扇形内每个目标各结算一次期间格挡动作替换站立

冲锋拆成两个职责:位移归动画与移动(根运动冲过 25m,表现层的事),控制归 GE。眩晕 GE 挂到目标 ASC 后,目标身上所有技能因为 ActivationBlockedTagsState.Debuff.Stun 而瞬间灰掉——"被晕了放不出技能"这条规则一行代码都没有,全是配置。怪物侧加一个 State.Immune.Stun 的常驻 Tag,BOSS 自然免疫,策划想给谁免疫就给谁配。

冲锋还藏着一个设计盲点:它自己也是技能,同样过 Tag 闸门。战士被眩晕时冲锋图标跟着灰掉,天经地义;但被减速 60% 时冲锋距离缩不缩短?这不是程序能拍板的事,SDD 里写死"不缩短,位移表现由根运动保证",程序照抄即可。策划文档与框架配置咬合得越紧,"程序自由发挥"引发的线上争议就越少。

顺劈的关键是"一次激活,多次结算"。技能采集到扇形内三个目标,就对每个目标各签发一份伤害 GE——三份凭证各自独立入账,每个目标身上的减伤、易伤、护盾各自参与结算。伤害数值不写死 160,而是走 MMC 取"来源攻强 ×1.6",这样 22.3 里算出的 174 点攻强自动流进伤害公式,换装备不用改技能。扇形采集本身属于目标数据(TargetData)的范畴,客户端瞄得准不准、服务器认不认,涉及预测与确认,第 23 章专门处理;本章只立一条底线:结算永远发生在服务器。

盾墙演示"减伤"的正确建模:不要做成"血量 +X"的假减伤,而是给属性 DamageTakenMult(承伤系数)挂一个 Multiplicitive ×0.6 的修饰器。伤害 Execution 的最后一步统一乘以这个系数,盾墙期间一切伤害打六折,到期修饰器摘除,系数回落到 1.0。它与治疗、护盾的吸收顺序(先护盾扣减、再系数打折、最后扣血)也写死在同一个 Execution 里——规则只有一处,不会南北两个模块各算一遍。

冷却也归 GE 管,而且是"一个模板走天下"的最佳案例。建一份 GE_Cooldown 模板,时长用 SetByCaller 注入,再授予一个专属冷却 Tag(冲锋给 Cooldown.Charge,顺劈给 Cooldown.Cleave);技能侧只需在自己的 BlockedTags 里声明对应冷却 Tag。CommitAbility 挂冷却的那一瞬间,技能图标自动转灰,冷却结束 Tag 摘除、图标回亮。新增技能时复制三行配置,冷却系统零代码扩张——UI 查冷却进度,查的也是这份 GE 的剩余时间,数据和表现天然同步。

三个技能合计:技能资产 3 个、GE 资产 5 个(3 主效果 + 3 冷却合一通用模板 + 消耗模板)、新属性 2 个(怒气、承伤系数)、新 Tag 约 12 个。策划再加第四个技能,大概率一个新 GE 都不用造——复用既有模板改参数即可。这就是框架的复利。

22.7 诚实警告:什么时候别碰 GAS

GAS 的学习曲线是 UE 所有系统里最陡的,没有之一。官方示例 Lyra 把它和一堆插件缠在一起,新人往往第三周还在跟 Prediction Key 搏斗。团队平均要烧掉三到四周才谈得上顺手,这期间产出几乎为零。这笔学费,只有特定项目交得值。

quadrantChart
    title 该不该上 GAS:技能复杂度 × 联机需求
    x-axis "技能/效果数量少" --> "技能/效果数量多"
    y-axis "单机" --> "强联机"
    quadrant-1 "闭眼上 GAS"
    quadrant-2 "上 GAS(为后续联机铺路)"
    quadrant-3 "别碰:组件+数据表足够"
    quadrant-4 "轻量方案,留好接口"
    "远港Online": [0.9, 0.95]
    "中型ARPG联机": [0.65, 0.7]
    "回响峡谷": [0.15, 0.1]
    "独立小品单机": [0.1, 0.05]
    "卡牌构筑原型": [0.55, 0.2]

判断清单,命中左边三条以上就该用,命中右边任意一条就别碰:

该用 GAS 的信号不该用的信号
技能 + Buff + 装备词条总数会破百单机小项目,技能一只手数得过来
有联机,且结算必须服务器权威没有联机规划
效果需要"挂上/到期/驱散/免疫"语义团队没人愿意读 Lyra 的 GAS 代码
策划要持续加技能,改参数不改代码三个月内要出可玩 Demo,人力紧张
存在 DoT、冷却、消耗、连招战斗只是点缀(步行模拟、解谜)

本书自己的态度就是证据:「回响峡谷」全程没用 GAS,单机竖切的十几个交互用普通组件加 UPROPERTY 就解决了,硬上 GAS 只会拖垮前两卷的进度;「远港 Online」从第一章就确定要用,因为它的技能规模注定破百。框架选型的唯一标准是复杂度匹配——GAS 的学费是前期的重税,回报是后期的红利。项目活得不够久,税就白交了

还有一个折中路线值得记:先按 SDD 格式把技能全写进数据表,自写一个两百行的轻量结算器顶住原型期,但接口严格模仿 GAS 的"技能签发效果凭证"模型。等技能数冲破五十、或者联机需求立项,再平移到 GAS——数据表和配置思路原封保留,沉没成本可控。

决定用了,再记住三个翻车重灾区,全是代码评审的高频打回项:绕过 GE 直接 Set 属性(破坏可撤销性与复制闭合循环);代码里裸写 Tag 字符串(绕过集中登记,重构时搜不全);拿 GE 当技能使——GE 没有激活逻辑,"按下 Q 键要做什么"永远归 Ability 管。一句话口诀:Ability 决定做不做,GE 决定变多少,Tag 决定让不让。

排障别用蛮力。控制台输入 showdebug abilitysystem,屏幕上直接列出目标 ASC 的全部活跃 GE、Tag 与属性当前值,配合 AbilitySystem.Debug.NextTarget 切换观察对象。线上定位"伤害不对"的标准流程就是:showdebug 看 GE 列表 → 对 SDD 查修饰器 → 查 MMC 的来源属性——三步收敛,比断点快得多。

小结

MMO 技能系统的复杂度是组合爆炸:300 技能 × 6 类修饰来源,if-else 的维护成本是乘法。框架的本质是把"效果"抽成一等公民,技能只签发凭证。GAS 五大件一条流水线串联:ASC 是结算中心,AttributeSet 是两栏账本,Ability 是业务流程,GE 是统一货币,Tag 是通用语言。属性结算顺序固定为 (Base + ΣAdd) × ΠMul ÷ ΠDiv,Override 一票否决——效果不碰 Base,移除即回落,可撤销性是白送的。一切数值变化走 GE:掉血、回血、加速、Buff、消耗、冷却,四种时长策略覆盖全部场景,DoT 只是 Periodic GE 的特例。Tag 三位一体:状态(眩晕中)、权限(沉默禁放)、标识(火系技能),新技能打上旧 Tag,存量规则自动生效,这正是组合灾难的解药。GAS 的学费是三到四周的陡峭爬坡,技能规模破百、有联机权威结算需求才交得值;单机小品离它远点。

上手任务

  1. 给「远港 Online」战士建最小属性集。 按 22.3 的代码建 Health/MaxHealth/AttackPower/Rage/DamageTakenMult 五个属性,用 InitStats 灌入 1 级初始值。验收标准:PIE 里用控制台给目标 Apply 一个 +20 攻强的 Duration GE,属性面板实时变 174→194,GE 移除后回落。
  2. 把盾墙配出来。 建 GE_ShieldWall(Duration 8s,DamageTakenMult ×0.6),用一个打 100 血的 Instant GE 验证。验收标准:无盾墙掉 100 血,盾墙期间掉 60 血,8 秒后恢复掉 100,全程没有手写一行减伤代码。
  3. 用 Tag 实现沉默。 给所有战士技能配 ActivationBlockedTags = State.Debuff.Silence,做一个施加该 Tag 的 GE。验收标准:被沉默期间冲锋/顺劈/盾墙全部无法激活,沉默移除后立即可用;新增第四个技能只改配置不改代码即同样受沉默约束。
  4. 写一页顺劈的 SDD。 按 22.1 表格格式补齐顺劈的全部字段,交给一个不懂 GAS 的同事看。验收标准:对方只看文档能复述出冷却、消耗、伤害系数、目标范围,且能指出"扇形内每个目标独立结算伤害"这一条。

下一章

本章所有结算都发生在服务器,客户端要等一个 RTT 才能看到冷却转圈——在 100ms 延迟下,按下去"愣一下"的感觉会杀死动作游戏的手感。第 23 章讲 GAS 的本地预测:Prediction Key、回滚重放、TargetData 的网络之旅,让战士在延迟下依然指哪打哪,顺便立下 500 个技能不打架的目录宪法。

延伸阅读

  • 官方文档:Unreal Engine → Gameplay Ability System(五大件定义与设置步骤,5.8 细节以该词条为准)
  • 官方示例:Lyra Starter Game,重点看 LyraAbilitySystemComponent 与 ALyraPlayerState 上 ASC 的挂法
  • 社区文档:tranek/GASDocumentation(GitHub 上流传最广的 GAS 中文友好教程,概念与引擎源码对得上号)
  • 引擎源码:Engine/Plugins/Runtime/GameplayAbilities/Source/GameplayAbilities/ 下的 AggregatorEvaluateQualification 相关聚合器实现,对照 22.4 伪代码阅读
  • 设计参考:《魔兽世界》技能数据库(任意资料片),用 22.1 的解剖法拆五个技能,体会"效果复用"在真实商业 MMO 里的密度