UE5.8 中 GAS 与动画、特效、输入联动

📑 目录

1. 一个完整技能的视角:按键到伤害判定

上周在动作原型的测试关卡里,我控制角色雷恩站在悬崖边的碎石地上。按住鼠标左键,他双手把大剑举过头顶,剑刃周围开始聚集电弧,地面出现细碎的裂纹。三秒后松开按键,剑身顺势劈下,一道冲击波向前推进,面前的木桩被击飞,屏幕上跳出伤害数字。整套动作没有一处卡顿,也没有出现伤害比特效早或者动画已经结束还能继续蓄力的 bug。

要把这套体验做出来,只靠动画蓝图或者只在角色蓝图里写输入事件都不够。输入、蓄力动画、 Niagara 粒子、伤害判定需要在同一套管线里被调度。Gameplay Ability System(GAS)提供了这种调度能力,但它本身并不播放动画,也不直接生成特效,真正干活的是 Ability Task、GameplayCue、动画通知和 Enhanced Input。本文就以这个蓄力斩技能为线索,拆解 UE5.8 里这些模块的联动方式。之前我尝试过把所有逻辑塞进动画蓝图,结果动画师调一个打击帧,我就要改一遍伤害数值;后来把输入和伤害判定直接绑在角色蓝图上,网络同步又成了噩梦。

architecture-beta
    group input[输入层]
        service key[键盘/手柄]
    group gas[GAS 层]
        service asc[Ability System Component]
        service ga[Gameplay Ability]
        service at[Ability Task]
    group anim[动画层]
        service montage[Montage]
        service notify[Anim Notify]
    group vfx[特效层]
        service cue[GameplayCue]
        service niagara[Niagara System]
    key:B --> T:asc
    asc:B --> T:ga
    ga:B --> T:at
    at:B --> T:montage
    notify:B --> T:at
    at:B --> T:cue
    cue:B --> T:niagara

2. Ability Task:让 Ability 拥有时间轴

Gameplay Ability 的 ActivateAbility 函数一旦执行完,Ability 就会被认为已经结束。如果需要持续三秒的蓄力,显然不能在三秒的阻塞循环里等。Ability Task 解决的就是这个问题。它在 Ability 内部启动一个异步任务,Tick、等待事件或者等待输入都不会阻塞主线程。

可以把 Ability Task 理解成 Ability 内部的轻量级状态机。每个 Task 有自己的生命周期:创建、ReadyForActivation、执行、触发委托、销毁。常用的 Task 包括 PlayMontageAndWait、WaitGameplayEvent、WaitInputRelease、WaitInputPress、WaitTargetData 等。裂地斩这个技能会同时用到前四个。

当 Ability 里启动多个 Task 时,它们彼此独立。比如 PlayMontageAndWait 负责动画,WaitInputRelease 负责监听按键松开,WaitGameplayEvent 负责接收动画通知发过来的事件。任何一个 Task 的委托被触发后,由开发者在回调里决定下一步做什么。

flowchart TD
    A[玩家按下攻击键] --> B{ASC 能否激活技能}
    B -->|可以| C[ActivateAbility 开始执行]
    B -->|被 Tag/冷却阻止| D[技能不触发]
    C --> E[播放蓄力 Montage]
    E --> F[Wait Input Release 监听松键]
    F --> G[动画通知触发 Gameplay Event]
    G --> H[Apply Gameplay Effect 造成伤害]
    H --> I[结束 Ability]

Ability Task 还有一个容易被忽略的优点:自动处理网络复制。像 WaitGameplayEvent 这种任务,事件可以在服务器或者客户端触发,Task 内部会根据 Ability 的复制模式把结果同步到正确的端。对于裂地斩这种需要服务器做伤害判定的技能,这意味着动画通知触发的事件必须上传到服务器,然后服务器再 Apply Gameplay Effect。

Ability Task 的所有权也值得注意。Task 由创建它的 Ability 持有,Ability 结束时所有未完成的 Task 会被自动清理,不需要手动删除指针。如果某个 Task 需要提前终止,可以调用 EndTask,但更常见的做法是通过 EndAbility 统一结束。调试时在控制台输入 showdebug abilitysystem,可以查看当前激活的 Ability 和 Task 列表,快速定位是哪一个任务卡住了或者没有触发委托。

3. 输入绑定:Enhanced Input 触发 ActivateAbility

UE5.8 默认使用 Enhanced Input。输入层不应该直接调用伤害函数,它只负责通知 GAS:玩家想使用某个技能。具体能不能用、用了之后发生什么,交给 Ability 和 ASC 判断。

在 PlayerController 或者 Character 里,我们把 Input Action 的 Started 事件绑定到 TryActivateAbility。Started 只在按键按下时触发一次,适合作为技能的起始信号。如果技能需要持续监听按键是否松开,则交给 Ability 内部的 WaitInputRelease。

void AMyPlayerController::SetupInputComponent(UInputComponent* PlayerInputComponent)
{
    Super::SetupInputComponent(PlayerInputComponent);
    UEnhancedInputComponent* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent);
    if (!EIC || !AbilitySystemComponent) return;

    EIC->BindAction(AttackAction, ETriggerEvent::Started, this, &AMyPlayerController::OnAttackStarted);
    EIC->BindAction(AttackAction, ETriggerEvent::Completed, this, &AMyPlayerController::OnAttackCompleted);
}

void AMyPlayerController::OnAttackStarted()
{
    if (AbilitySystemComponent)
    {
        AbilitySystemComponent->TryActivateAbility(SlashAbilitySpecHandle);
    }
}

这里 Completed 也可以直接交给 ASC,但通常更合适的方式是让 Ability 自己通过 WaitInputRelease 监听。这样输入释放事件就和 Ability 生命周期绑定在一起:Ability 被取消了,监听也自动失效,不会出现输入层还在回调已销毁 Ability 的情况。

除了手动调用 TryActivateAbility,GAS 还提供了 GiveAbilityAndActivateOnce 和 BindAbilityActivationToInputSpec 等辅助接口。裂地斩这种需要精确控制生命周期的技能,手动管理 SpecHandle 更稳妥。输入层还要考虑 UI 冻结、过场动画等情况:在这些状态下,可以通过给 ASC 添加 GameplayTag 如 State.UI.Open 来阻塞技能激活,输入回调里先检查 Tag,避免玩家在菜单界面误放技能。对于手柄玩家,WaitInputRelease 返回的 TimeHeld 可以区分短按和长按,裂地斩可以设计成短按直接释放最小伤害版本,长按进入完整蓄力,同一个 Input Action 通过 Ability 内部逻辑区分行为。

4. 动画层联动:Play Montage and Wait

蓄力斩的动画需要分成几个 Section:起手 Start、蓄力循环 Loop、释放 Release、收招 Recovery。Montage 的 Section 跳转可以用代码控制,也可以用 AnimNotifyState 标记窗口。

PlayMontageAndWait 这个 Task 会在 Ability 激活时播放 Montage,并在动画完成、混合退出或者被中断时触发委托。对于裂地斩,流程大致如下:ActivateAbility 里先跳到 Start,然后进入 Loop。Loop 段循环播放,直到 WaitInputRelease 的委托触发,再跳到 Release,最后进入 Recovery。

void UGA_GroundSlash::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const FGameplayEventData* TriggerEventData)
{
    Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);

    UAbilityTask_PlayMontageAndWait* MontageTask =
        UAbilityTask_PlayMontageAndWait::CreateProxyObjectForPlayMontageAndWait(
            this, ChargeMontage, 1.0f, NAME_None, false);

    MontageTask->OnCompleted.AddDynamic(this, &UGA_GroundSlash::OnMontageFinished);
    MontageTask->OnBlendOut.AddDynamic(this, &UGA_GroundSlash::OnMontageBlendOut);
    MontageTask->OnInterrupted.AddDynamic(this, &UGA_GroundSlash::OnMontageInterrupted);
    MontageTask->ReadyForActivation();

    UAbilityTask_WaitInputRelease* ReleaseTask =
        UAbilityTask_WaitInputRelease::WaitInputRelease(this, false);
    ReleaseTask->OnRelease.AddDynamic(this, &UGA_GroundSlash::OnInputReleased);
    ReleaseTask->ReadyForActivation();
}

OnInputReleased 回调里,我们可以计算蓄力时间:

ChargeRatio=min(HeldTimeMaxChargeTime, 1.0)ChargeRatio = \min\left( \frac{HeldTime}{MaxChargeTime},\ 1.0 \right)

ChargeRatioChargeRatio 落在 [0,1][0,1] 区间,最终伤害 DamageDamage 用下式计算:

Damage=BaseDamage×ChargeRatio×(1ArmorReduction)Damage = BaseDamage \times ChargeRatio \times (1 - ArmorReduction)

得到 ChargeRatioChargeRatio 后,调用 Montage 的 JumpToSection 跳到 Release,动画通知在 Release 段的特定帧发送 Gameplay Event,触发伤害判定。如果玩家一直不松键,可以在 Loop 段播放一段时间后强制释放,避免无限蓄力。

PlayMontageAndWait 的 Rate 参数可以动态改变播放速度。蓄力阶段如果想让时间感更紧张,可以随着 ChargeRatio 提高而稍微加快 Loop 段速度。不过要注意,Rate 改变后动画通知的触发帧也会偏移,所以伤害判定不能依赖固定帧数,而应该依赖 AnimNotify 事件。如果 Montage 包含 Root Motion,这个 Task 会配合 NetworkedRootMotion 自动同步,裂地斩的释放位移可以用 Root Motion 驱动,避免手动设置角色位置带来的网络不同步。

5. 动画通知与 Gameplay Event 解耦

动画通知(Anim Notify)最常见的用法是生成特效或者播放音效,但如果在通知里直接写伤害逻辑,会把动画和战斗系统耦合在一起。更干净的做法是让通知只负责发送一个 Gameplay Event,由 GAS 里的 WaitGameplayEvent 接收并处理。

在 Montage 的 Release 段中,我们在剑刃最低点的那一帧放置一个 AnimNotify_SendGameplayEvent。通知里配置 Event Tag,例如 Ability.GroundSlash.Impact。当动画播放到这一帧时,ASC 会收到这个事件。

Ability 内部创建一个 WaitGameplayEvent 任务:

UAbilityTask_WaitGameplayEvent* EventTask =
    UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
        this, FGameplayTag::RequestGameplayTag(FName("Ability.GroundSlash.Impact")), nullptr);
EventTask->EventReceived.AddDynamic(this, &UGA_GroundSlash::OnImpactEvent);
EventTask->ReadyForActivation();

OnImpactEvent 回调里再做真正的伤害判定:范围检测、ApplyGameplayEffect、生成 GameplayCue。这样做的好处是,即使以后换了 Montage,只要事件 Tag 不变,伤害逻辑就不需要改。动画师也可以自由调整打击帧,不需要程序员重新编译代码。

FGameplayEventData 可以携带 Payload,包括 Instigator、Target、TargetData、EventMagnitude 等。裂地斩的 ChargeRatio 可以通过 EventMagnitude 传给伤害计算,也可以在 Ability 内部用成员变量保存。如果伤害判定需要目标数据,比如从技能指示器里收集到的命中列表,可以把 TargetData 一起塞进 Payload。动画通知发送事件的时机还要考虑网络延迟。权威服务器上角色的 Montage 和客户端可能存在几帧差异,所以伤害判定以服务器为准,客户端只负责预测表现。WaitGameplayEvent 在服务器 Ability 上监听,事件由服务器端的动画通知发出,这样伤害不会提前也不会延后。

6. GameplayCue 与 Niagara 联动

GameplayCue 是 GAS 里专门负责客户端表现的一类机制。它可以是瞬发的 Burst,也可以是持续一段时间的 Loop,还可以是持久附着在角色身上的状态。裂地斩需要的地裂火花和冲击波适合做成 Burst 类型。

创建一个继承自 UGameplayCueNotify_Burst 的 C++ 类或者蓝图类,重载 OnExecute。在这个函数里生成 Niagara System:

bool UGCN_GroundImpact::OnExecute_Implementation(
    AActor* MyTarget,
    const FGameplayCueParameters& Parameters) const
{
    if (!ImpactFX) return false;

    UNiagaraFunctionLibrary::SpawnSystemAtLocation(
        MyTarget->GetWorld(),
        ImpactFX,
        Parameters.Location,
        Parameters.Normal.Rotation());

    return true;
}

GameplayCue 的触发方式有两种:一种是直接在 Ability 里调用 K2_ExecuteGameplayCueWithParams,另一种是给 Gameplay Effect 添加 GameplayCue Tag,让 Effect 在应用时自动触发 Cue。对于裂地斩这种只在命中瞬间出现一次的效果,前者更直观;如果是一个持续燃烧的状态,后者更合适,因为 Effect 被移除时 Loop Cue 会自动停止。

在客户端表现上,GameplayCue 会自动处理网络复制和可见性。服务器触发 Cue 后,拥有该 ASC 的客户端会收到通知并播放 Niagara。如果玩家自己看不到自己的特效,通常是因为 Cue Tag 没有加到 DefaultGameplayTags.ini,或者 Niagara System 的 AutoActivate 没有勾选。

GameplayCue Tag 必须在项目设置里注册。打开 DefaultGameplayTags.ini 或者在编辑器里通过 Project Settings -> GameplayTags 添加 Ability.GroundSlash.Impact.Cue 这类 Tag,否则 Cue 不会被 UGameplayCueManager 识别。Cue Tag 通常和对应的 GameplayEvent 或 GameplayEffect Tag 一一对应,便于维护。Loop 类型的 Cue 要注意结束时机。给目标添加一个持续燃烧的 GameplayEffect,Effect 的 Duration Policy 设为 Has Duration,并在 GameplayCues 列表里加上 Loop Cue Tag。当 Effect 被移除时,Loop Cue 会自动停止,不需要手动调用 RemoveGameplayCue。这个机制在做持续治疗、护盾、燃烧状态时非常好用。

7. 连招与取消窗口

裂地斩本身是一个单发技能,但放到整个战斗系统里,它还要和连招、闪避、格挡配合。动画的取消窗口可以通过 AnimNotifyState 标记。窗口期间,玩家输入下一个技能,当前 Ability 可以主动调用 EndAbility 并激活新 Ability。

graph LR
    A[起手式] -->|窗口 1| B[蓄力斩]
    B -->|取消窗口| C[回旋斩]
    C -->|硬直结束| D[收招]
    B -.->|可取消到闪避| E[闪避]
    C -.->|可取消到格挡| F[格挡]

实现时,当前 Ability 启动一个 WaitGameplayEvent,监听 Combo.Open 这个 Tag。AnimNotifyState 在窗口内持续发送 Combo.Open 事件。如果事件触发,说明玩家输入了下一个动作。此时调用 TryActivateAbilityByTag 尝试激活下一个 Ability,新 Ability 的 ActivationBlockedTags 可以阻止当前 Ability 继续运行,旧 Ability 收到中断后调用 EndAbility 并清理自己的 Task。

这里有一个细节:如果新 Ability 激活失败,比如因为冷却没好,当前 Ability 应该继续播放收招,而不是直接僵住。可以在回调里先判断新 Ability 是否成功激活,再决定是否中断当前 Montage。否则玩家会感觉到按键失效,体验很差。

连招系统的另一个难点是输入缓冲。人眼和手指存在反应时间,玩家往往在上一招还没结束时就已经按下下一招。可以在角色上维护一个 LastInputTag 和 BufferTimer,在取消窗口内检查是否有缓存的输入。缓存时间一般取 0.150.250.15 \sim 0.25 秒,太长会误触发,太短又起不到缓冲作用。

取消窗口的实现也可以反过来:不在旧 Ability 里监听新输入,而是让新 Ability 在 ActivateAbility 时检查当前是否有可被取消的 Ability,然后调用 CancelAbility 强制中断。两种方式都能工作,前一种更像输入驱动,后一种更像状态机驱动。项目里可以根据团队习惯选择。连招的判定条件建议用 GameplayTag 而不是字符串。旧 Ability 在取消窗口内给自己打上 Combo.Open 标签,新 Ability 的 ActivationRequiredTags 要求这个标签存在。代码可读性好,网络同步也稳定。Tag 的添加和移除由 AnimNotifyState 控制,进入窗口时添加,离开窗口时移除。

8. 实战整合:裂地斩的实现流程

把前面的内容串起来,裂地斩的完整执行流程如下。玩家按下攻击键,ASC 检查 Cost、Cooldown 和 ActivationBlockedTags,通过后调用 ActivateAbility。Ability 启动 PlayMontageAndWait 播放蓄力 Montage,同时启动 WaitInputRelease 监听松键。

玩家按住按键,Loop 段循环播放,电弧 Niagara 作为 Loop Cue 附着在武器上。松键后,WaitInputRelease 触发,计算 ChargeRatioChargeRatio,Montage 跳到 Release。Release 段的 AnimNotify_SendGameplayEvent 在合适帧发送 Ability.GroundSlash.Impact。WaitGameplayEvent 收到事件后,做范围检测并 ApplyGameplayEffect,同时 ExecuteGameplayCue 生成地裂 Burst。

sequenceDiagram
    participant P as 玩家输入
    participant ASC as AbilitySystemComponent
    participant GA as 裂地斩 Ability
    participant ATM as PlayMontageAndWait
    participant AN as 动画通知
    participant Cue as GameplayCue
    participant Target as 目标角色
    P->>ASC: 触发 Input Action
    ASC->>GA: TryActivateAbility
    GA->>ATM: CreatePlayMontageAndWait
    ATM->>AN: 启动蓄力 Montage
    Note over AN: 蓄力阶段循环
    P->>GA: WaitInputRelease 捕获松键
    AN->>GA: AnimNotify_SendGameplayEvent
    GA->>Cue: ExecuteGameplayCue
    Cue->>Target: 生成 Niagara 打击特效
    GA->>Target: ApplyGameplayEffect 伤害
    ATM->>GA: OnCompleted 通知结束
    GA->>ASC: EndAbility

动画播放完毕后,PlayMontageAndWait 的 OnCompleted 触发,Ability 调用 EndAbility 清理所有 Task。如果在蓄力过程中被敌人击退,Montage 被打断,OnInterrupted 触发,同样需要 EndAbility 并通知视觉表现中断。

这套结构的关键在于每个系统只负责自己擅长的事。Enhanced Input 只负责触发;Ability Task 负责等待和调度;Montage 只负责视觉表现;AnimNotify 只负责在正确时机发送事件;GameplayCue 只负责客户端特效;GameplayEffect 只负责数值。彼此之间通过 Tag 和事件通信,修改任何一个模块都不会把其他模块的逻辑带崩。

清理是这套系统最容易出错的地方。EndAbility 会结束所有 Task,但 Task 的委托里如果还引用了 Ability 的成员变量,就有可能在 Ability 销毁后访问到悬空指针。建议在 Task 的回调里先用 IsValid 判断 Ability 是否还在,或者把回调写成非 Dynamic 形式并手动移除绑定。另一个常见问题是 Montage 播完了但 Ability 没结束。通常是 PlayMontageAndWait 没有正确接收 OnCompleted,或者 Montage 的 Loop Section 永远不会自然结束。可以在 Montage 里给 Loop 段设置明确的结束条件,或者在代码里设置最大蓄力时间,超时后主动 JumpToSection 到 Release。

9. 性能与同步建议

多人游戏里,裂地斩的每一次攻击都会触发 Montage 播放、GameplayCue 生成和伤害判定。如果所有逻辑都走 RPC,网络负载会很快上去。GAS 的设计本身已经做了不少优化:GameplayEffect 的属性修改通过预测和复制同步,不需要为每一次伤害单独发 RPC。GameplayCue 的触发由服务器权威决定,然后广播给相关客户端,围观玩家不会收到不相关的 Cue。

Montage 的播放要区分授权端和围观端。授权客户端调用 PlayMontageAndWait 后,服务器会自动通过网络化的动画实例把 Montage 同步给其他客户端。所以不要手动在所有客户端上调用 PlayAnimMontage,否则会出现双重播放。Niagara 特效如果是 Loop 类型,还要注意在离开视野或者角色死亡时停止,避免粒子持续消耗 GPU。

Ability 的 Cost 和 Cooldown 检查放在服务器执行,客户端只做预测。这样即使客户端通过外挂绕过本地校验,也无法真正释放技能。服务器在 TryActivateAbility 时会重新检查所有 Activation 条件,任何不满足的请求都会被拒绝。

Tag 的复制频率也会影响手感。如果 Combo.Open 这类标签的 NetUpdateFrequency 太低,客户端的取消窗口会比服务器晚几帧,连招手感就会不一致。可以在项目设置里给关键的战斗 Tag 设置更积极的 NetUpdateFrequency,或者让 Ability 采用更及时的复制策略。客户端预测性播放能让按键响应没有延迟,但预测出错时需要平滑回滚,这通常需要额外的插值或者属性同步修正。调试连招和取消窗口时,可以在角色蓝图里实时绘制 Tag 状态,或者在屏幕上打印当前激活的 GameplayTag 数量,配合 showdebug abilitysystem 能快速定位是 Tag 没加上、Ability 没激活,还是 Montage 的 Section 跳转出了问题。

10. 小结

裂地斩从一个按键到最终伤害判定,经历了输入、Ability 激活、Montage 播放、输入释放、Gameplay Event、GameplayCue、GameplayEffect 多个环节。GAS 的价值并不在于替代动画或者特效系统,而在于给这些系统提供了一套统一的触发和同步框架。

Ability Task 把时间轴引入 Ability,WaitGameplayEvent 和 WaitInputRelease 让动画与输入解耦,PlayMontageAndWait 把 Montage 生命周期和 Ability 生命周期绑定,GameplayCue 让 Niagara 特效天然具备网络复制能力。连招和取消窗口则是在这套框架之上再做一层输入缓冲和 Tag 管理。把这些机制组合起来,就能做出手感连贯、表现稳定、网络同步可靠的技能系统。熟练掌握这些联动点后,做新技能时只需要替换 Montage、Cue 和 Effect,就能把主要精力放在手感打磨上,同时留好异常处理分支。