UE5 GAS 实战:用 Gameplay Ability System 搭建「赛博修真」境界与技能体系

版权声明 © 2026 梦帮集团(DREAMVFIA)保留所有权利。本文为《三界械堂》项目组原创技术文章,示例代码可自由用于学习与非商业项目;商业转载或引用请注明出处「2026 梦帮集团(DREAMVFIA)」。文中出现的世界观、角色、术语(王天劫、劫灭量子太刀、天道龙门等)均为梦帮集团原创 IP,受著作权保护。


UE5 GAS 实战:用 Gameplay Ability System 搭建「赛博修真」境界与技能体系

关键词:Unreal Engine 5、Gameplay Ability System、GAS、AttributeSet、GameplayEffect、GameplayTag、网络同步、C++

适用引擎:UE 5.2 / 5.3 / 5.4 | 难度:中高级 | 阅读预计:40 分钟

写在前面

在我们正在研发的东方赛博朋克项目《三界械堂》里,主角王天劫要在「械启、灵婴、元婴、合体、炼虚、大乘、渡劫、飞升」八大境界之间成长,靠一门叫「混元灵根」的体质切换金、木、水、火、土等多种灵气属性,释放「劫雷」「混沌」等技能。这套「境界 + 属性 + 技能」的数值骨架,如果用传统的「一个 Character 类里塞满 float 和 if-else」的写法,很快就会变成无法维护的意大利面条。

Unreal 官方的 Gameplay Ability System(下称 GAS) 正是为这类需求而生。它把「角色能做什么(Ability)」「角色的数值状态(Attribute)」「对数值的修改(Effect)」「状态标签(Tag)」「表现层(Cue)」彻底解耦,并且原生支持网络多人同步。本文不讲玄乎的概念,而是从零把一套可运行的赛博修真系统搭出来,所有代码都能直接编译。游戏世界观只作为需求背景点缀,主线是工程实现。

本文覆盖:

  • GAS 五大核心组件的职责与协作关系
  • 模块与插件的正确配置(这一步 90% 的新手会踩坑)
  • AttributeSet 设计灵力、真元、机械契合度等修真属性
  • GameplayTag 树表达八大境界与灵气属性
  • GameplayEffect 实现「突破境界」「持续回灵」「灵力消耗」
  • GameplayAbility 实现「劫雷」(瞬发)与「混沌」(蓄力融合)
  • GameplayCue 把技能表现与逻辑分离
  • 网络复制模式与预测的注意事项
  • DataTable 做数据驱动,让策划不改代码就能调平衡

一、为什么是 GAS,而不是自己撸

先摆结论:只要你的项目里「技能 / Buff / 数值」超过十来种,并且要联机,就应该用 GAS。

自己实现一套技能系统,你迟早要面对这些问题:

  1. 数值来源混乱 :主角的「灵力上限」可能同时受境界、装备、Buff、场景增益影响,手写累加极易出错。GAS 用 Modifier 把每一个来源变成一条可追踪、可回滚的记录。
  2. Buff 的叠加与移除 :一个中毒 Buff 每秒掉血,5 秒后消失,还能叠 3 层------这类逻辑手写全是边界条件。GAS 的 GameplayEffect 用 Duration、Period、Stacking 三个维度描述清楚。
  3. 网络同步:属性变化要从服务器同步到客户端,技能释放要做客户端预测以消除延迟感。这是最难自己写对的部分,GAS 已经帮你处理了绝大多数情况。
  4. 表现与逻辑耦合 :技能命中要播特效、放音效、震屏。如果写在技能逻辑里,联机时无法正确回放。GAS 的 GameplayCue 专门解决这件事。

代价是 GAS 的学习曲线陡峭、概念多、初期配置繁琐。本文的目标就是把这段陡坡铺平。


二、五大核心组件速览

在动手前,先建立整体心智模型。GAS 由五个部分构成,用一句话概括它们在《三界械堂》里的对应:

组件 职责 修真语境类比
AbilitySystemComponent(ASC) 挂在角色身上的「能力中枢」,管理下面一切 修真者的「丹田 / 识海」
AttributeSet 一组数值属性(当前值 + 基础值) 灵力、真元、气血、机械契合度
GameplayAbility 一个可激活的技能 劫雷、混沌、御剑术
GameplayEffect(GE) 对属性的修改(瞬时/持续/无限) 突破丹药、中毒、灵气护盾
GameplayTag 层级化的状态标签 「境界.元婴.中期」「属性.雷」

外加一个表现层:

  • GameplayCue:由 Tag 触发的特效/音效,逻辑与表现的桥梁。

它们的协作链条是这样的:

复制代码
玩家按键 → ASC 尝试激活 GameplayAbility
        → Ability 检查 Cost(一个 GE)和 Cooldown(一个 GE)与 Tag 条件
        → 通过则执行逻辑,对目标施加 GameplayEffect
        → GE 通过 Modifier 改变目标 AttributeSet 里的属性
        → 属性变化触发回调 / 触发 GameplayCue 播放表现
        → 所有这些在服务器计算,结果复制到客户端

把这张图记牢,后面每一段代码都是在填充这条链的某一环。


三、工程配置:先把地基打对

3.1 启用插件

编辑器菜单 Edit → Plugins,搜索 Gameplay Abilities,勾选启用,重启编辑器。这是 GAS 的官方插件,UE5 自带。

3.2 模块依赖

打开你的 <Project>.Build.cs,把 GAS 相关模块加进依赖。注意 GameplayAbilities 必须放在 PublicDependencyModuleNames ,很多人漏了 GameplayTagsGameplayTasks 导致链接错误:

csharp 复制代码
// SanJieXieTang.Build.cs
using UnrealBuildTool;

public class SanJieXieTang : ModuleRules
{
    public SanJieXieTang(ReadOnlyTargetRules Target) : base(Target)
    {
        PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;

        PublicDependencyModuleNames.AddRange(new string[]
        {
            "Core",
            "CoreUObject",
            "Engine",
            "InputCore",
            "EnhancedInput",
            // ---- GAS 三件套,缺一不可 ----
            "GameplayAbilities",
            "GameplayTags",
            "GameplayTasks",
        });

        PrivateDependencyModuleNames.AddRange(new string[] { });
    }
}

改完 Build.cs 一定要关闭编辑器、重新生成工程文件(右键 .uproject → Generate Visual Studio project files)、重新编译,热重载对模块依赖变更无效。

3.3 全局初始化

GAS 需要在模块启动时初始化 UAbilitySystemGlobals。在你的主模块启动处调用一次:

cpp 复制代码
// SanJieXieTang.cpp
#include "SanJieXieTang.h"
#include "Modules/ModuleManager.h"
#include "AbilitySystemGlobals.h"

class FSanJieXieTangModule : public FDefaultGameModuleImpl
{
    virtual void StartupModule() override
    {
        // 必须在使用任何 GAS 功能前调用,否则 TargetData 等会崩
        UAbilitySystemGlobals::Get().InitGlobalData();
    }
};

IMPLEMENT_PRIMARY_GAME_MODULE(FSanJieXieTangModule, SanJieXieTang, "SanJieXieTang");

踩坑提示:InitGlobalData() 如果不调用,单机可能看起来正常,但一旦用到 FGameplayAbilityTargetData 做目标选择就会在运行时崩溃,且报错信息晦涩。养成习惯,一定加上。


四、GameplayTag:用标签树描述八大境界

在写属性之前,我们先把「境界」和「灵气属性」用 GameplayTag 表达出来。GameplayTag 是一种层级化的字符串标签 ,形如 Realm.YuanYing.Middle,父子之间用点号分隔,天然适合表达「境界.元婴.中期」这种有层级的状态。

4.1 定义标签

推荐用 DefaultGameplayTags.ini 或 C++ 原生标签声明。C++ 声明的好处是编译期检查、可重构、有智能提示。新建一个标签集中管理文件:

cpp 复制代码
// SanJieGameplayTags.h
#pragma once
#include "NativeGameplayTags.h"

namespace SanJieTags
{
    // ---- 八大境界 ----
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_XieQi);     // 械启境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_LingYing);  // 灵婴境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_YuanYing);  // 元婴境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_HeTi);      // 合体境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_LianXu);    // 炼虚境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_DaCheng);   // 大乘境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_DuJie);     // 渡劫境
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Realm_FeiSheng);  // 飞升境

    // ---- 灵气属性(混元灵根可切换)----
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Metal);   // 金
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Wood);    // 木
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Water);   // 水
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Fire);    // 火
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Earth);   // 土
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Thunder); // 雷(劫雷专属)
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Element_Chaos);   // 混沌(融合态)

    // ---- 技能标签 ----
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Ability_JieLei);  // 劫雷
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Ability_HunDun);  // 混沌

    // ---- 状态标签 ----
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Casting);       // 施法中
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Stunned);       // 眩晕(禁止施法)
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(State_Breakthrough);  // 突破中

    // ---- 事件标签 ----
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Event_Hit);           // 命中事件
    UE_DECLARE_GAMEPLAY_TAG_EXTERN(Cue_JieLei_Impact);   // 劫雷命中表现
}

对应的 .cppUE_DEFINE_GAMEPLAY_TAG_COMMENT 定义,第二个参数是实际标签字符串

cpp 复制代码
// SanJieGameplayTags.cpp
#include "SanJieGameplayTags.h"

namespace SanJieTags
{
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_XieQi,    "Realm.XieQi",    "械启境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_LingYing, "Realm.LingYing", "灵婴境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_YuanYing, "Realm.YuanYing", "元婴境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_HeTi,     "Realm.HeTi",     "合体境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_LianXu,   "Realm.LianXu",   "炼虚境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_DaCheng,  "Realm.DaCheng",  "大乘境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_DuJie,    "Realm.DuJie",    "渡劫境");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Realm_FeiSheng, "Realm.FeiSheng", "飞升境");

    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Metal,   "Element.Metal",   "金");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Wood,    "Element.Wood",    "木");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Water,   "Element.Water",   "水");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Fire,    "Element.Fire",    "火");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Earth,   "Element.Earth",   "土");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Thunder, "Element.Thunder", "雷");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Element_Chaos,   "Element.Chaos",   "混沌");

    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Ability_JieLei, "Ability.JieLei", "劫雷");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Ability_HunDun, "Ability.HunDun", "混沌");

    UE_DEFINE_GAMEPLAY_TAG_COMMENT(State_Casting,      "State.Casting",      "施法中");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(State_Stunned,      "State.Stunned",      "眩晕");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(State_Breakthrough, "State.Breakthrough", "突破中");

    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Event_Hit,         "Event.Hit",             "命中");
    UE_DEFINE_GAMEPLAY_TAG_COMMENT(Cue_JieLei_Impact, "GameplayCue.JieLei.Impact", "劫雷命中");
}

为什么用层级?因为 GAS 的标签查询支持「父标签匹配子标签」。比如判断「角色是否处于任意境界」,只需查询 Realm,所有 Realm.* 都会命中。判断「是否雷或混沌属性」也能用 Element 做粗筛。这是扁平字符串做不到的。


五、AttributeSet:修真属性的数值骨架

AttributeSet 是 GAS 里存放数值的地方。每个属性是一个 FGameplayAttributeData,内部有 BaseValue(基础值)CurrentValue(当前值) 之分------基础值是「裸数值」,当前值是「叠加了各种 Buff 后的最终值」。这个区分是 GAS 数值系统的精髓。

5.1 声明属性集

GAS 有一套固定的宏样板来生成属性的 getter/setter/initter。先定义一个宏块,再逐个声明属性:

cpp 复制代码
// SanJieAttributeSet.h
#pragma once
#include "AttributeSet.h"
#include "AbilitySystemComponent.h"
#include "SanJieAttributeSet.generated.h"

// GAS 官方推荐的属性访问器样板宏
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
    GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)

UCLASS()
class SANJIEXIETANG_API USanJieAttributeSet : public UAttributeSet
{
    GENERATED_BODY()

public:
    USanJieAttributeSet();

    virtual void GetLifetimeReplicatedProps(
        TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    // 在 GE 修改属性「之前」介入(做 Clamp)
    virtual void PreAttributeChange(
        const FGameplayAttribute& Attribute, float& NewValue) override;

    // 在一次「瞬时」GE 执行「之后」介入(扣血致死判定等)
    virtual void PostGameplayEffectExecute(
        const FGameplayEffectModCallbackData& Data) override;

    // ---------- 气血(生命)----------
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_Health, Category = "Vital")
    FGameplayAttributeData Health;
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, Health);

    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_MaxHealth, Category = "Vital")
    FGameplayAttributeData MaxHealth;
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, MaxHealth);

    // ---------- 灵力(技能资源)----------
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_LingLi, Category = "Cultivation")
    FGameplayAttributeData LingLi;
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, LingLi);

    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_MaxLingLi, Category = "Cultivation")
    FGameplayAttributeData MaxLingLi;
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, MaxLingLi);

    // 灵力每秒自然回复
    UPROPERTY(BlueprintReadOnly, Category = "Cultivation")
    FGameplayAttributeData LingLiRegen;
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, LingLiRegen);

    // ---------- 机械契合度(决定义体承受上限)----------
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing = OnRep_JiXie, Category = "Cultivation")
    FGameplayAttributeData JiXieQiHe; // 机械契合度 0~100
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, JiXieQiHe);

    // ---------- 攻防 ----------
    UPROPERTY(BlueprintReadOnly, Category = "Combat")
    FGameplayAttributeData AttackPower;   // 灵能攻击
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, AttackPower);

    UPROPERTY(BlueprintReadOnly, Category = "Combat")
    FGameplayAttributeData Defense;       // 灵能防御
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, Defense);

    // ---------- Meta 属性:伤害(不复制,仅服务器瞬时用)----------
    UPROPERTY(BlueprintReadOnly, Category = "Meta")
    FGameplayAttributeData IncomingDamage;
    ATTRIBUTE_ACCESSORS(USanJieAttributeSet, IncomingDamage);

protected:
    UFUNCTION() void OnRep_Health(const FGameplayAttributeData& Old);
    UFUNCTION() void OnRep_MaxHealth(const FGameplayAttributeData& Old);
    UFUNCTION() void OnRep_LingLi(const FGameplayAttributeData& Old);
    UFUNCTION() void OnRep_MaxLingLi(const FGameplayAttributeData& Old);
    UFUNCTION() void OnRep_JiXie(const FGameplayAttributeData& Old);
};

这里有几个关键设计决策值得展开:

(1)为什么区分 HealthMaxHealth 因为 GAS 的 Buff 会改「当前上限」。境界突破让 MaxHealth 翻倍,是一个 Infinite GE 加在 MaxHealth 上;而受伤是一个 Instant GE 减 Health。两者分开,逻辑才清晰。

(2)什么是 Meta 属性? IncomingDamage 是一个「中转属性」。技能不直接改 Health,而是把伤害值写进 IncomingDamage,然后在 PostGameplayEffectExecute 里读出它、经过防御减免公式、再扣到 Health。这样伤害计算就集中在一处,且不需要复制(客户端不该知道原始伤害)。

5.2 实现构造与初始化

cpp 复制代码
// SanJieAttributeSet.cpp
#include "SanJieAttributeSet.h"
#include "Net/UnrealNetwork.h"
#include "GameplayEffectExtension.h"

USanJieAttributeSet::USanJieAttributeSet()
{
    // 初始默认值------械启境新手王天劫的裸数值
    InitHealth(100.f);
    InitMaxHealth(100.f);
    InitLingLi(50.f);
    InitMaxLingLi(50.f);
    InitLingLiRegen(3.f);       // 每秒回 3 点灵力
    InitJiXieQiHe(20.f);        // 契合度低,义体承受有限
    InitAttackPower(10.f);
    InitDefense(5.f);
    InitIncomingDamage(0.f);
}

void USanJieAttributeSet::GetLifetimeReplicatedProps(
    TArray<FLifetimeProperty>& Out) const
{
    Super::GetLifetimeReplicatedProps(Out);

    // REPNOTIFY_Always 保证即使值没变也触发 OnRep(UI 刷新更可靠)
    DOREPLIFETIME_CONDITION_NOTIFY(USanJieAttributeSet, Health,    COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(USanJieAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(USanJieAttributeSet, LingLi,    COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(USanJieAttributeSet, MaxLingLi, COND_None, REPNOTIFY_Always);
    DOREPLIFETIME_CONDITION_NOTIFY(USanJieAttributeSet, JiXieQiHe, COND_None, REPNOTIFY_Always);
}

5.3 Clamp:在 PreAttributeChange 里守住边界

无论谁改属性,都要保证「当前值不超过上限、不小于零」。这类夹取逻辑写在 PreAttributeChange

cpp 复制代码
void USanJieAttributeSet::PreAttributeChange(
    const FGameplayAttribute& Attribute, float& NewValue)
{
    Super::PreAttributeChange(Attribute, NewValue);

    if (Attribute == GetHealthAttribute())
    {
        NewValue = FMath::Clamp(NewValue, 0.f, GetMaxHealth());
    }
    else if (Attribute == GetLingLiAttribute())
    {
        NewValue = FMath::Clamp(NewValue, 0.f, GetMaxLingLi());
    }
    else if (Attribute == GetJiXieQiHeAttribute())
    {
        // 机械契合度 0~100,超过 100 会「意识下陷」(另做惩罚逻辑)
        NewValue = FMath::Clamp(NewValue, 0.f, 100.f);
    }
}

注意:PreAttributeChange 里的 Clamp 只保证当前值瞬间被夹 ,不改变 GE 的 Modifier 记录。如果上限(MaxHealth)本身被 Buff 拉低,导致当前值超限,你还需要在 PostGameplayEffectExecuteMaxHealth 的 OnRep 里做二次夹取。这是 GAS 数值系统里非常经典的一个坑。

5.4 PostGameplayEffectExecute:伤害结算与死亡判定

这是整个属性集的「结算中枢」。所有瞬时 GE(比如一次攻击)执行完,都会走到这里:

cpp 复制代码
void USanJieAttributeSet::PostGameplayEffectExecute(
    const FGameplayEffectModCallbackData& Data)
{
    Super::PostGameplayEffectExecute(Data);

    // 只处理 IncomingDamage 这个 Meta 属性
    if (Data.EvaluatedData.Attribute == GetIncomingDamageAttribute())
    {
        const float RawDamage = GetIncomingDamage();
        SetIncomingDamage(0.f); // 中转属性用完清零

        if (RawDamage > 0.f)
        {
            // 简单防御减免:实际伤害 = 原始伤害 * (100 / (100 + 防御))
            const float Mitigated = RawDamage * (100.f / (100.f + GetDefense()));
            const float NewHealth = FMath::Clamp(
                GetHealth() - Mitigated, 0.f, GetMaxHealth());
            SetHealth(NewHealth);

            // 死亡判定
            if (NewHealth <= 0.f)
            {
                if (AActor* Owner = GetOwningActor())
                {
                    // 交给角色处理死亡(播放倒地、解锁复活点等)
                    // ISanJieCombatInterface::Execute_HandleDeath(Owner);
                }
            }
        }
    }
}

到这里,数值骨架就立起来了。接下来让角色真正「拥有」这套系统。


六、把 ASC 挂到角色上

AbilitySystemComponent(ASC)是角色的「能力中枢」。挂载它有两种主流范式:

  • Avatar 即 Owner :ASC 直接放在 Character 上。适合 NPC、小怪------它们不需要在死亡后保留状态。
  • PlayerState 持有 ASC :ASC 放在 PlayerState,Character 只是「化身(Avatar)」。适合玩家角色------即使 Character 被销毁重生,PlayerState 还在,属性和技能不丢,联机也更稳。

《三界械堂》的主角王天劫用后者,小怪用前者。这里演示玩家角色的做法。

6.1 PlayerState 持有 ASC 与 AttributeSet

cpp 复制代码
// SanJiePlayerState.h
#pragma once
#include "GameFramework/PlayerState.h"
#include "AbilitySystemInterface.h"
#include "SanJiePlayerState.generated.h"

UCLASS()
class SANJIEXIETANG_API ASanJiePlayerState : public APlayerState,
                                              public IAbilitySystemInterface
{
    GENERATED_BODY()
public:
    ASanJiePlayerState();

    // IAbilitySystemInterface 要求实现
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;
    class USanJieAttributeSet* GetAttributeSet() const { return AttributeSet; }

protected:
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> ASC;

    UPROPERTY()
    TObjectPtr<USanJieAttributeSet> AttributeSet;
};
cpp 复制代码
// SanJiePlayerState.cpp
#include "SanJiePlayerState.h"
#include "AbilitySystemComponent.h"
#include "SanJieAttributeSet.h"

ASanJiePlayerState::ASanJiePlayerState()
{
    ASC = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("ASC"));
    ASC->SetIsReplicated(true);
    // Mixed 模式:GameplayEffect 只复制给拥有者,Cue/Tag 复制给所有人
    // 这是玩家角色的推荐设置,兼顾带宽与预测
    ASC->SetReplicationMode(EGameplayEffectReplicationMode::Mixed);

    AttributeSet = CreateDefaultSubobject<USanJieAttributeSet>(TEXT("AttributeSet"));

    // PlayerState 默认 NetUpdateFrequency 只有 1,对 GAS 太低,调高
    SetNetUpdateFrequency(100.f);
}

UAbilitySystemComponent* ASanJiePlayerState::GetAbilitySystemComponent() const
{
    return ASC;
}

关键坑:PlayerStateNetUpdateFrequency 默认是 1(每秒同步一次),如果不调高,你会看到血条、灵力条更新有明显的一秒延迟。GAS 项目务必把它设到 100 左右。

6.2 在 Character 里初始化 ASC

ASC 需要知道「谁是 Owner(逻辑归属)」和「谁是 Avatar(物理化身)」。这一步要在服务器和客户端分别正确调用,是新手最容易同步不上的地方:

cpp 复制代码
// SanJieHeroCharacter.cpp(节选)

// 服务器:PossessedBy 时初始化
void ASanJieHeroCharacter::PossessedBy(AController* NewController)
{
    Super::PossessedBy(NewController);
    InitAbilitySystem();
    // 服务器侧:授予默认技能与初始境界 Effect
    if (HasAuthority())
    {
        GrantDefaultAbilities();
        ApplyInitialRealmEffect(); // 施加「械启境」属性加成
    }
}

// 客户端:OnRep_PlayerState 时初始化
void ASanJieHeroCharacter::OnRep_PlayerState()
{
    Super::OnRep_PlayerState();
    InitAbilitySystem();
}

void ASanJieHeroCharacter::InitAbilitySystem()
{
    ASanJiePlayerState* PS = GetPlayerState<ASanJiePlayerState>();
    if (!PS) return;

    UAbilitySystemComponent* PSASC = PS->GetAbilitySystemComponent();
    // Owner = PlayerState, Avatar = 本 Character
    PSASC->InitAbilityActorInfo(PS, this);
    CachedASC = PSASC;
}

InitAbilityActorInfo(Owner, Avatar) 是整个 GAS 初始化的心脏。服务器走 PossessedBy,客户端走 OnRep_PlayerState,两边都要调,缺一边就会出现「服务器能放技能、客户端看不到表现」或反之的诡异 bug。


七、GameplayEffect:突破境界与消耗回复

GameplayEffect(GE)是 GAS 里唯一被允许修改属性的东西。它有三种 Duration 类型:

  • Instant(瞬时):立刻改 BaseValue,比如一次伤害、一颗丹药。
  • Duration(有限时长):改 CurrentValue,到期自动移除,比如一个持续 10 秒的护盾。
  • Infinite(无限):改 CurrentValue,手动移除,比如「境界加成」------只要你还在这个境界,加成就一直在。

GE 通常在编辑器里做成蓝图资产(Blueprint Class → GameplayEffect),但理解其 C++ 本质有助于调试。

7.1 境界突破:一个 Infinite GE

「元婴境」相比「灵婴境」,各项属性大幅提升。我们把每个境界做成一个 Infinite GE,突破时移除旧境界 GE、施加新境界 GE。以下是元婴境 GE 的配置逻辑(用蓝图配置,这里用注释说明每个字段):

复制代码
GE_Realm_YuanYing(元婴境加成)
├─ Duration Policy: Infinite
├─ Modifiers:
│   ├─ MaxHealth   +400  (Add)      # 气血从 100 级别跃升到 500
│   ├─ MaxLingLi   +300  (Add)      # 灵力大幅提升
│   ├─ AttackPower ×3.0  (Multiply) # 灵能攻击三倍
│   ├─ Defense     +40   (Add)
│   └─ LingLiRegen +12   (Add)      # 回灵更快
├─ Granted Tags: Realm.YuanYing     # 打上境界标签
└─ Ongoing Tag Requirements: (无)

在代码里做突破:

cpp 复制代码
void ASanJieHeroCharacter::Breakthrough(TSubclassOf<UGameplayEffect> NewRealmGE,
                                        FGameplayTag OldRealmTag)
{
    if (!HasAuthority() || !CachedASC) return;

    // 1. 移除旧境界的 Infinite GE(按 Granted Tag 查找并移除)
    FGameplayEffectQuery Query;
    Query.OwningTagQuery = FGameplayTagQuery::MakeQuery_MatchAnyTags(
        FGameplayTagContainer(OldRealmTag));
    CachedASC->RemoveActiveEffects(Query);

    // 2. 施加新境界 GE
    FGameplayEffectContextHandle Ctx = CachedASC->MakeEffectContext();
    Ctx.AddSourceObject(this);
    FGameplayEffectSpecHandle Spec =
        CachedASC->MakeOutgoingSpec(NewRealmGE, 1.f, Ctx);
    if (Spec.IsValid())
    {
        CachedASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
    }

    // 3. 突破成功后,把气血/灵力回满(体现「脱胎换骨」)
    CachedASC->SetNumericAttributeBase(
        USanJieAttributeSet::GetHealthAttribute(),
        CachedASC->GetNumericAttribute(USanJieAttributeSet::GetMaxHealthAttribute()));
}

7.2 灵力自然回复:一个 Infinite + Period 的 GE

让灵力每秒回复 LingLiRegen 点,用一个带「周期」的 Infinite GE:

复制代码
GE_LingLi_Regen(灵力回复)
├─ Duration Policy: Infinite
├─ Period: 1.0             # 每 1 秒执行一次
├─ Modifiers:
│   └─ LingLi += LingLiRegen (Add, 用属性做后端)

这里用到 GAS 的一个高级技巧:Modifier 的加数不是常量,而是引用另一个属性LingLiRegen)。这样境界提升 LingLiRegen 后,回复速度自动变快,无需改 GE。配置方式是 Modifier 的 Magnitude 选 AttributeBased,Backing Attribute 选 LingLiRegen

7.3 技能消耗:Cost GE

每个技能有「消耗」。GAS 把消耗也做成一个 GE,挂在 Ability 的 Cost 字段。劫雷消耗 30 灵力:

复制代码
GE_Cost_JieLei
├─ Duration Policy: Instant
└─ Modifiers:
    └─ LingLi -= 30 (Add)

Ability 激活时会先用这个 GE 做一次「能否负担」检查CanApplyAttributeModifiers),灵力不够就激活失败,非常优雅。


八、GameplayAbility 实战:劫雷与混沌

技能是玩家最直接感知的部分。我们实现主角的两个标志性技能:

  • 劫雷:瞬发,锁定前方目标,召唤天雷造成雷属性伤害。
  • 混沌:蓄力,融合当前灵气属性,蓄满后释放范围混沌爆发。

8.1 劫雷:一个瞬发投射技能

cpp 复制代码
// GA_JieLei.h
#pragma once
#include "Abilities/GameplayAbility.h"
#include "GA_JieLei.generated.h"

UCLASS()
class SANJIEXIETANG_API UGA_JieLei : public UGameplayAbility
{
    GENERATED_BODY()
public:
    UGA_JieLei();

    virtual void ActivateAbility(
        const FGameplayAbilitySpecHandle Handle,
        const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo,
        const FGameplayEventData* TriggerEventData) override;

protected:
    // 伤害 GE(在编辑器里指定 GE_Damage_JieLei)
    UPROPERTY(EditDefaultsOnly, Category = "JieLei")
    TSubclassOf<UGameplayEffect> DamageEffect;

    // 基础伤害(会被境界与攻击力加成)
    UPROPERTY(EditDefaultsOnly, Category = "JieLei")
    float BaseDamage = 120.f;

    // 蒙太奇(施法动作)
    UPROPERTY(EditDefaultsOnly, Category = "JieLei")
    TObjectPtr<UAnimMontage> CastMontage;

    UFUNCTION()
    void OnMontageHitEvent(FGameplayEventData Payload);
};
cpp 复制代码
// GA_JieLei.cpp
#include "GA_JieLei.h"
#include "SanJieGameplayTags.h"
#include "SanJieAttributeSet.h"
#include "Abilities/Tasks/AbilityTask_PlayMontageAndWait.h"
#include "Abilities/Tasks/AbilityTask_WaitGameplayEvent.h"

UGA_JieLei::UGA_JieLei()
{
    // 技能实例化策略:每个角色一个实例,性能与状态兼顾
    InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;

    // 本技能自带「劫雷」标签,激活时给角色打「施法中」
    AbilityTags.AddTag(SanJieTags::Ability_JieLei);
    ActivationOwnedTags.AddTag(SanJieTags::State_Casting);

    // 被眩晕时不能施法
    ActivationBlockedTags.AddTag(SanJieTags::State_Stunned);
}

void UGA_JieLei::ActivateAbility(
    const FGameplayAbilitySpecHandle Handle,
    const FGameplayAbilityActorInfo* ActorInfo,
    const FGameplayAbilityActivationInfo ActivationInfo,
    const FGameplayEventData* TriggerEventData)
{
    // 1. 提交消耗与冷却(内部会检查灵力是否足够)
    if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }

    // 2. 播放施法蒙太奇,并等待其中的 AnimNotify 触发命中时机
    auto* Task = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
        this, NAME_None, CastMontage, 1.f);
    Task->OnCompleted.AddDynamic(this, &UGA_JieLei::K2_EndAbilityLocal);
    Task->OnInterrupted.AddDynamic(this, &UGA_JieLei::K2_EndAbilityLocal);

    // 3. 监听蒙太奇里的「命中帧」事件(AnimNotify 发 Event.Hit)
    auto* HitTask = UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
        this, SanJieTags::Event_Hit);
    HitTask->EventReceived.AddDynamic(this, &UGA_JieLei::OnMontageHitEvent);

    Task->ReadyForActivation();
    HitTask->ReadyForActivation();
}

void UGA_JieLei::OnMontageHitEvent(FGameplayEventData Payload)
{
    // 只有服务器计算伤害
    if (!HasAuthorityOrPredictionKey(
        CurrentActorInfo, &CurrentActivationInfo)) return;

    if (!HasAuthority(&CurrentActivationInfo)) return;

    // 从 Payload 里取目标(由蒙太奇 Notify 或射线检测填充)
    AActor* Target = const_cast<AActor*>(Payload.Target.Get());
    if (!Target) return;

    UAbilitySystemComponent* TargetASC =
        UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(Target);
    if (!TargetASC) return;

    // 构造伤害 Spec,用 SetByCaller 把伤害数值传进 GE
    FGameplayEffectContextHandle Ctx =
        GetAbilitySystemComponentFromActorInfo()->MakeEffectContext();
    Ctx.SetAbility(this);
    FGameplayEffectSpecHandle Spec = MakeOutgoingGameplayEffectSpec(
        DamageEffect, GetAbilityLevel());

    // 最终伤害 = 基础 × 攻击力系数(攻击力由境界 GE 加成过)
    const float Atk = GetAbilitySystemComponentFromActorInfo()
        ->GetNumericAttribute(USanJieAttributeSet::GetAttackPowerAttribute());
    const float Final = BaseDamage * (Atk / 10.f);
    Spec.Data->SetSetByCallerMagnitude(
        FGameplayTag::RequestGameplayTag("Data.Damage"), Final);

    // 施加伤害
    GetAbilitySystemComponentFromActorInfo()
        ->ApplyGameplayEffectSpecToTarget(*Spec.Data.Get(), TargetASC);

    // 触发命中表现(GameplayCue)------所有客户端都会看到天雷落下
    FGameplayCueParameters CueParams;
    CueParams.Location = Target->GetActorLocation();
    TargetASC->ExecuteGameplayCue(SanJieTags::Cue_JieLei_Impact, CueParams);
}

这段代码演示了 GAS 技能的标准结构CommitAbility 提交消耗 → 用 AbilityTask 播蒙太奇并异步等待 → 在命中帧计算伤害并施加 GE → 触发 Cue 播表现。伤害数值用 SetByCaller 机制从技能传给 GE,这样同一个伤害 GE 可以被不同技能复用,只是数值不同。

8.2 混沌:蓄力融合技能

混沌技能体现主角「混元灵根融合多属性」的设定:按住蓄力,蓄力时长决定融合的属性数量,松手释放。核心用 AbilityTask_WaitInputRelease 检测松手:

cpp 复制代码
void UGA_HunDun::ActivateAbility(/* 省略签名 */)
{
    if (!CommitAbilityCooldown(Handle, ActorInfo, ActivationInfo, true))
    {
        EndAbility(Handle, ActorInfo, ActivationInfo, true, true);
        return;
    }
    ChargeStartTime = GetWorld()->GetTimeSeconds();

    // 蓄力期间循环消耗灵力(每 0.5 秒扣一次)
    auto* Drain = UAbilityTask_WaitDelay::WaitDelay(this, 0.5f);
    Drain->OnFinish.AddDynamic(this, &UGA_HunDun::OnDrainTick);
    Drain->ReadyForActivation();

    // 监听松手
    auto* Release = UAbilityTask_WaitInputRelease::WaitInputRelease(this, true);
    Release->OnRelease.AddDynamic(this, &UGA_HunDun::OnReleased);
    Release->ReadyForActivation();
}

void UGA_HunDun::OnReleased(float TimeHeld)
{
    // 蓄力时长映射为融合属性数(最多 5 种)
    const int32 FusedElements = FMath::Clamp(
        FMath::FloorToInt(TimeHeld / 0.6f), 1, 5);

    // 融合越多,伤害与范围越大------这就是「混元灵根」的机制表达
    const float Radius = 300.f + FusedElements * 120.f;
    const float Damage = 80.f * FusedElements;

    ApplyChaosBurst(Radius, Damage, FusedElements);

    // 给自己临时挂「混沌」属性标签,供其他系统查询
    GetAbilitySystemComponentFromActorInfo()->AddLooseGameplayTag(
        SanJieTags::Element_Chaos);

    EndAbility(CurrentSpecHandle, CurrentActorInfo,
               CurrentActivationInfo, true, false);
}

蓄力时长 TimeHeld 直接决定融合属性数量、伤害和范围,把「混元灵根可同时运行多种功法」这一世界观设定,落成了一条清晰的数值曲线。


九、GameplayCue:把表现层彻底剥离

前面反复出现 ExecuteGameplayCueGameplayCue 是 GAS 的表现层机制:逻辑代码只发一个 Tag,具体放什么特效、音效由 Cue 资产决定。好处是联机时表现能被正确回放,且美术可以独立迭代特效而不碰 C++。

劫雷命中的 Cue 做成一个 GameplayCueNotify_Burst(一次性爆发型)蓝图,绑定 GameplayCue.JieLei.Impact 标签:

cpp 复制代码
// 也可以用 C++ 做静态 Cue Handler
UCLASS()
class UGC_JieLeiImpact : public UGameplayCueNotify_Static
{
    GENERATED_BODY()
public:
    virtual bool OnExecute_Implementation(
        AActor* Target, const FGameplayCueParameters& Params) const override
    {
        // 在命中位置生成天雷粒子 + 播放雷鸣音效 + 轻微震屏
        UNiagaraFunctionLibrary::SpawnSystemAtLocation(
            Target, ThunderVFX, Params.Location);
        UGameplayStatics::PlaySoundAtLocation(
            Target, ThunderSFX, Params.Location);
        return true;
    }
};

逻辑侧永远只需要 ExecuteGameplayCue(Tag, Params) 一行,表现全在 Cue 里。这种彻底的解耦,是大型项目能长期维护的关键。


十、数据驱动:让策划不改代码调平衡

八大境界、每个境界四个小阶段,如果每个都手 K 一个 GE 蓝图,改一次平衡要点开 32 个资产。更好的做法是用 DataTable + 一个通用境界 GE

定义一行境界数据:

cpp 复制代码
USTRUCT(BlueprintType)
struct FRealmRow : public FTableRowBase
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere) FGameplayTag RealmTag;
    UPROPERTY(EditAnywhere) float MaxHealth = 100.f;
    UPROPERTY(EditAnywhere) float MaxLingLi = 50.f;
    UPROPERTY(EditAnywhere) float AttackPower = 10.f;
    UPROPERTY(EditAnywhere) float Defense = 5.f;
    UPROPERTY(EditAnywhere) float LingLiRegen = 3.f;
    UPROPERTY(EditAnywhere) float JiXieRequire = 20.f; // 该境界所需机械契合度
};

突破时读表,用 SetByCaller 把这一行数值灌进一个通用 GE:

cpp 复制代码
void ASanJieHeroCharacter::BreakthroughByRow(FName RealmRowName)
{
    if (!HasAuthority() || !RealmTable) return;

    const FRealmRow* Row =
        RealmTable->FindRow<FRealmRow>(RealmRowName, TEXT("Breakthrough"));
    if (!Row) return;

    FGameplayEffectSpecHandle Spec = CachedASC->MakeOutgoingSpec(
        GenericRealmEffect, 1.f, CachedASC->MakeEffectContext());

    // 用 SetByCaller 把表里的数值传给通用 GE 的各个 Modifier
    Spec.Data->SetSetByCallerMagnitude(
        FGameplayTag::RequestGameplayTag("Data.MaxHealth"), Row->MaxHealth);
    Spec.Data->SetSetByCallerMagnitude(
        FGameplayTag::RequestGameplayTag("Data.MaxLingLi"), Row->MaxLingLi);
    Spec.Data->SetSetByCallerMagnitude(
        FGameplayTag::RequestGameplayTag("Data.AttackPower"), Row->AttackPower);
    // ...其余属性同理

    CachedASC->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());
}

现在整个境界体系的平衡数值都在一张 DataTable 里,策划用 Excel 导入即可调参,程序员完全不用介入。这是数据驱动设计的典型收益。


十一、网络复制:三个必须理解的点

GAS 的联机能力很强,但有三个概念不理解就会踩坑:

(1)复制模式(Replication Mode)

  • Full:所有 GE 复制给所有人。适合单机或 AI。
  • Mixed:GE 只复制给拥有的客户端,Tag/Cue 复制给所有人。玩家角色用这个。
  • Minimal:GE 不复制,只复制 Tag/Cue。适合 AI 小怪,省带宽。

(2)预测(Prediction)

玩家按下技能时,客户端会先本地执行 (播动画、扣灵力),同时请求服务器。服务器确认后如果结果一致就无缝衔接,不一致就回滚。这套「预测键(Prediction Key)」机制让技能手感无延迟。你不需要自己写预测,但要理解:凡是用 ApplyGameplayEffectToSelf 且在预测窗口内的操作会被预测,而伤害这种「对别人的影响」永远只在服务器算。

(3)属性谁能改

属性的 BaseValue 只能在服务器 通过 GE 修改。客户端看到的是复制过来的值。所以任何「改血、改灵力」的逻辑都要有 HasAuthority() 守卫,这在前面的代码里反复出现。


十二、Enhanced Input 与技能的绑定

UE5 已经全面转向 Enhanced Input,而 GAS 的老式 InputID 绑定是为旧输入系统设计的,两者的衔接需要自己搭一座小桥。我们的做法是给每个技能定义一个「输入标签」,用数据资产把 InputAction 和 GameplayTag 关联起来,输入触发时按标签找技能激活。这个方案在社区已经被 Lyra 项目验证过,是当前的主流实践。

首先定义输入配置资产,让策划可以在编辑器里把「按键 → 技能标签」连起来:

cpp 复制代码
// SanJieInputConfig.h ------ 输入动作与技能标签的映射表
USTRUCT(BlueprintType)
struct FSanJieInputAction
{
    GENERATED_BODY()
    UPROPERTY(EditDefaultsOnly)
    TObjectPtr<const UInputAction> InputAction = nullptr;
    UPROPERTY(EditDefaultsOnly, Meta = (Categories = "Input"))
    FGameplayTag InputTag;   // 例如 Input.Ability.JieLei
};

UCLASS()
class USanJieInputConfig : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly)
    TArray<FSanJieInputAction> AbilityInputActions;

    const UInputAction* FindActionByTag(const FGameplayTag& Tag) const;
};

角色在 SetupPlayerInputComponent 里遍历这张表,把每个 InputAction 的按下/松开都绑到统一的处理函数,参数就是这个动作对应的标签:

cpp 复制代码
void ASanJieHeroCharacter::SetupPlayerInputComponent(UInputComponent* PIC)
{
    Super::SetupPlayerInputComponent(PIC);
    auto* EIC = CastChecked<UEnhancedInputComponent>(PIC);

    for (const FSanJieInputAction& Entry : InputConfig->AbilityInputActions)
    {
        EIC->BindAction(Entry.InputAction, ETriggerEvent::Started, this,
            &ASanJieHeroCharacter::AbilityInputPressed, Entry.InputTag);
        EIC->BindAction(Entry.InputAction, ETriggerEvent::Completed, this,
            &ASanJieHeroCharacter::AbilityInputReleased, Entry.InputTag);
    }
}

void ASanJieHeroCharacter::AbilityInputPressed(FGameplayTag InputTag)
{
    if (!CachedASC) return;
    // 遍历已授予的技能,找到 DynamicAbilityTags 里带这个输入标签的
    for (FGameplayAbilitySpec& Spec : CachedASC->GetActivatableAbilities())
    {
        if (Spec.GetDynamicSpecSourceTags().HasTagExact(InputTag))
        {
            CachedASC->TryActivateAbility(Spec.Handle);
        }
    }
}

void ASanJieHeroCharacter::AbilityInputReleased(FGameplayTag InputTag)
{
    if (!CachedASC) return;
    for (FGameplayAbilitySpec& Spec : CachedASC->GetActivatableAbilities())
    {
        if (Spec.GetDynamicSpecSourceTags().HasTagExact(InputTag)
            && Spec.IsActive())
        {
            // 通知技能「输入已松开」------混沌的蓄力释放靠的就是这条
            CachedASC->AbilitySpecInputReleased(Spec);
        }
    }
}

授予技能时把输入标签塞进 Spec 的动态标签,完成闭环:

cpp 复制代码
void ASanJieHeroCharacter::GrantDefaultAbilities()
{
    for (const FSanJieAbilityGrant& Grant : DefaultAbilities)
    {
        FGameplayAbilitySpec Spec(Grant.AbilityClass, 1);
        Spec.GetDynamicSpecSourceTags().AddTag(Grant.InputTag);
        CachedASC->GiveAbility(Spec);
    }
}

这套桥接的好处:输入配置完全数据化,手柄/键盘改键位只动 InputMappingContext;新技能上线只需要在数据资产里加一行映射,代码零改动。注意 AbilitySpecInputReleased 这一步不能省,否则第八节的混沌蓄力技能在松手时收不到 OnRelease 回调------这正是 FAQ 第 6 条那个坑的根源。


十三、ExecCalc:交叉计算的完整伤害公式

第八节的劫雷用 SetByCaller 传伤害,公式很简单:基础值乘攻击系数。但真实项目的伤害公式往往要同时读攻击方防守方 的多个属性:攻击方的攻击力、暴击率、属性克制加成,防守方的防御、抗性、当前境界压制。这种交叉计算,GAS 的正确工具是 GameplayEffectExecutionCalculation(简称 ExecCalc)。

ExecCalc 的机制是:声明你要「捕获」哪些属性(来自 Source 还是 Target、是否做快照),然后在 Execute_Implementation 里拿到双方数值自由计算,最后把结果作为 Modifier 输出。下面是《三界械堂》的完整伤害公式实现:

cpp 复制代码
// SanJieDamageExecCalc.h
UCLASS()
class USanJieDamageExecCalc : public UGameplayEffectExecutionCalculation
{
    GENERATED_BODY()
public:
    USanJieDamageExecCalc();
    virtual void Execute_Implementation(
        const FGameplayEffectCustomExecutionParameters& Params,
        FGameplayEffectCustomExecutionOutput& Out) const override;
};
cpp 复制代码
// SanJieDamageExecCalc.cpp
#include "SanJieDamageExecCalc.h"
#include "SanJieAttributeSet.h"
#include "SanJieGameplayTags.h"

// 属性捕获声明的标准样板:一个静态结构体集中定义
struct FSanJieDamageStatics
{
    DECLARE_ATTRIBUTE_CAPTUREDEF(AttackPower);
    DECLARE_ATTRIBUTE_CAPTUREDEF(Defense);
    DECLARE_ATTRIBUTE_CAPTUREDEF(JiXieQiHe);
    DECLARE_ATTRIBUTE_CAPTUREDEF(IncomingDamage);

    FSanJieDamageStatics()
    {
        // Source = 攻击方,快照 true(技能出手瞬间的数值,飞行途中不变)
        DEFINE_ATTRIBUTE_CAPTUREDEF(USanJieAttributeSet, AttackPower,
                                    Source, true);
        // Target = 防守方,快照 false(结算瞬间取最新值)
        DEFINE_ATTRIBUTE_CAPTUREDEF(USanJieAttributeSet, Defense,
                                    Target, false);
        DEFINE_ATTRIBUTE_CAPTUREDEF(USanJieAttributeSet, JiXieQiHe,
                                    Target, false);
        DEFINE_ATTRIBUTE_CAPTUREDEF(USanJieAttributeSet, IncomingDamage,
                                    Target, false);
    }
};

static const FSanJieDamageStatics& DamageStatics()
{
    static FSanJieDamageStatics S;
    return S;
}

USanJieDamageExecCalc::USanJieDamageExecCalc()
{
    RelevantAttributesToCapture.Add(DamageStatics().AttackPowerDef);
    RelevantAttributesToCapture.Add(DamageStatics().DefenseDef);
    RelevantAttributesToCapture.Add(DamageStatics().JiXieQiHeDef);
}

void USanJieDamageExecCalc::Execute_Implementation(
    const FGameplayEffectCustomExecutionParameters& Params,
    FGameplayEffectCustomExecutionOutput& Out) const
{
    const FGameplayEffectSpec& Spec = Params.GetOwningSpec();

    // 聚合双方 Tag,供条件判断(例如属性克制)
    FAggregatorEvaluateParameters Eval;
    Eval.SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
    Eval.TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();

    float Atk = 0.f, Def = 0.f, QiHe = 0.f;
    Params.AttemptCalculateCapturedAttributeMagnitude(
        DamageStatics().AttackPowerDef, Eval, Atk);
    Params.AttemptCalculateCapturedAttributeMagnitude(
        DamageStatics().DefenseDef, Eval, Def);
    Params.AttemptCalculateCapturedAttributeMagnitude(
        DamageStatics().JiXieQiHeDef, Eval, QiHe);

    // 技能通过 SetByCaller 传入的基础伤害
    const float Base = Spec.GetSetByCallerMagnitude(
        FGameplayTag::RequestGameplayTag("Data.Damage"), false, 0.f);

    // ---- 完整公式 ----
    // 1. 攻击段:基础 × (1 + 攻击力/100)
    float Damage = Base * (1.f + Atk / 100.f);

    // 2. 属性克制:雷克金(对高机械契合度目标额外伤害)
    //    机械契合度越高(义体化程度越高),越怕劫雷------世界观直接变成数值
    if (Eval.SourceTags->HasTag(SanJieTags::Element_Thunder))
    {
        Damage *= 1.f + (QiHe / 100.f) * 0.5f;   // 满契合度多吃 50%
    }

    // 3. 防御段:经典双曲线减伤
    Damage *= 100.f / (100.f + Def);

    // 4. 境界压制:攻击方境界高于防守方每一大境,伤害 +15%
    //    (境界比较通过双方 Realm.* 标签的层级序号实现,此处略)

    if (Damage > 0.f)
    {
        // 输出到目标的 IncomingDamage Meta 属性
        Out.AddOutputModifier(FGameplayModifierEvaluatedData(
            DamageStatics().IncomingDamageProperty,
            EGameplayModOp::Additive, Damage));
    }
}

几个值得咀嚼的细节。第一,快照(Snapshot)的取舍 :攻击力在「技能出手瞬间」快照,意味着王天劫射出劫雷后立刻被削弱,飞行中的这发雷仍按出手时的攻击力结算------这符合直觉;而防御取结算瞬间的最新值,目标在弹道飞行中开了护盾就应该生效。快照与否没有对错,只有设计意图,但你必须有意识地 做这个选择。第二,属性克制直接读 Tag :攻击方带 Element.Thunder 标签就触发「雷克机械」加成,防守方的机械契合度(一个纯数值属性)反过来成了受击弱点。世界观里「义体化程度越高越怕天劫」的设定,在这二十行代码里变成了可平衡、可调试的数值规则------这就是我们说「游戏设定要能落进系统」的含义。第三,ExecCalc 在服务器执行且只执行一次,所有随机数(暴击 roll 点)都应该在这里面掷,客户端预测层不会重复掷点,避免了「客户端显示暴击、服务器结算没暴击」的撕裂。

用法上,把这个 ExecCalc 填进伤害 GE 的 Executions 数组即可,技能侧的代码完全不用变------依旧是 SetByCaller 传基础值、Apply 到目标。公式的所有复杂度被封装在一个可单独测试的类里。


十四、冷却、硬直与「走火」:状态互斥的工程表达

动作游戏的手感一半来自「什么时候不能做什么」。《三界械堂》里有三类典型约束:技能冷却(劫雷 8 秒一发)、受击硬直(被打断施法)、以及世界观特色的「走火」状态(机械契合度过载后强制进入的失控硬直)。三者在 GAS 里都用同一套语言表达:Tag + GE

冷却是一个 Duration GE,唯一的 Modifier 都没有------它存在的意义就是在生效期间给角色挂一个冷却 Tag:

复制代码
GE_Cooldown_JieLei
├─ Duration Policy: Has Duration
├─ Duration Magnitude: 8.0     # 8 秒冷却,可用 SetByCaller 做境界缩减
└─ Granted Tags: Cooldown.Ability.JieLei

技能资产的 Cooldown Gameplay Effect 字段指向它。CommitAbility 时自动施加;下次激活前 GAS 自动检查角色身上是否还有 Cooldown.Ability.JieLei 标签,有则拒绝激活。UI 想显示冷却读秒,查询这个 GE 的剩余时间即可:

cpp 复制代码
float ASanJieHeroCharacter::GetJieLeiCooldownRemaining() const
{
    FGameplayEffectQuery Q = FGameplayEffectQuery::MakeQuery_MatchAnyOwningTags(
        FGameplayTagContainer(
            FGameplayTag::RequestGameplayTag("Cooldown.Ability.JieLei")));
    TArray<float> Times = CachedASC->GetActiveEffectsTimeRemaining(Q);
    return Times.Num() > 0 ? FMath::Max(Times) : 0.f;
}

受击硬直 反过来利用第八节埋好的伏笔:所有攻击性技能的 ActivationBlockedTags 都包含 State.Stunned。受击时给目标挂一个短时 Duration GE,Granted Tags 带 State.Stunned,并在 GE 里配置 GrantedApplicationImmunityTags 防止硬直无限叠加。硬直期间玩家按技能键,激活会在 Tag 检查这一层就被拦下,不需要任何一行「if 眩晕 return」的业务代码散落在各个技能里。打断正在施法的技能则用 CancelAbilities

cpp 复制代码
// 受击 GE 的 ExecCalc 或角色受击回调里
FGameplayTagContainer CancelTags;
CancelTags.AddTag(SanJieTags::State_Casting);   // 取消一切施法中的技能
TargetASC->CancelAbilities(&CancelTags);

走火 是前两者的组合应用:当 JiXieQiHe 被某个 GE 推过 100(第五节的 Clamp 挡住了直接超限,但我们在 PostGameplayEffectExecute 里检测「试图超限」的行为),服务器给角色施加一个 3 秒的 GE_ZouHuo------挂 State.Stunned 强制硬直、每秒扣 5% 最大气血、同时挂一个 GameplayCue 播放义体电弧失控的特效。整个「过载惩罚」机制没有新代码,全部由已有构件组装而成。这种「新机制 = 旧构件的新组合」的开发体验,会随着项目推进变得越来越明显,也是 GAS 前期投入逐渐回本的过程。


十五、规模化与性能:当场上有一百个修真者

垂直切片阶段场上只有王天劫和几个测试怪,怎么写都流畅;但设计案里幽冥市的帮派混战要求同屏几十上百个单位,每个都带 ASC。这时有几条经验值得提前知道。

复制模式分级是第一杠杆。玩家角色用 Mixed,精英怪用 Minimal,普通小怪甚至可以完全不复制 GE------它们的属性变化只有服务器关心,客户端只需要通过 Cue 看到掉血特效和死亡表现。我们的实测数据:一百个 Minimal 模式的小怪相比 Full 模式,GAS 相关的复制带宽下降了约八成。

周期性 GE 是隐形大户 。每个小怪都挂一个每秒跳一次的回灵 GE,一百个小怪就是每秒一百次 GE 执行加属性写入。对小怪这类生命周期短的单位,干脆去掉自然回复,或者把周期拉长到 5 秒,玩家完全感知不到差异。同理,Infinite GE 的 Ongoing Tag Requirements 每次相关 Tag 变化都会重新求值,小怪身上尽量别挂带复杂条件的常驻 GE。

AttributeSet 不要大而全 。我们初版把主角所有属性塞进一个 Set,小怪也用它------结果每个小怪都背着「机械契合度」「灵力回复」这些它根本用不上的属性参与复制。后来拆成 VitalSet(气血,人人都有)和 CultivationSet(修真属性,只有玩家和精英怪挂载),ASC 支持挂多个 AttributeSet,按需组合即可。

技能实例化策略 同样分级:玩家技能用 InstancedPerActor 保留状态;小怪的普攻这类无状态技能用 NonInstanced(UE5.5 后建议改用 InstancedPerActor 配合无状态写法,NonInstanced 已走向废弃,但其零分配的思想仍值得在设计上借鉴------不要在技能实例里存不必要的成员变量)。

最后一条心态上的建议:不要过早优化,但要过早分级 。上面这些手段的共同点是「按单位重要性分配预算」,这个分级决策在架构期就要做(哪些类型的单位用哪档配置),而具体的数值(周期多长、复制频率多少)留到 Profile 之后再调。用 stat abilitysystem 和 Network Profiler 看真实数据,永远比猜测可靠。


十六、调试利器:三条命令

GAS 的运行时状态不直观,UE 提供了强大的调试工具:

复制代码
showdebug abilitysystem

在游戏里输入这条控制台命令,屏幕上会实时显示当前角色的所有属性值、激活中的 GE、持有的 Tag。用 PageUp/PageDown 切换查看目标。这是排查「为什么我的血没扣」的第一手段。

复制代码
AbilitySystem.DebugBasicHUD 1

显示精简版 HUD。

日志方面,给关键节点加日志分类:

cpp 复制代码
DEFINE_LOG_CATEGORY_STATIC(LogSanJieGAS, Log, All);
// 使用
UE_LOG(LogSanJieGAS, Warning, TEXT("劫雷命中 %s,伤害 %.1f"),
    *Target->GetName(), Final);

十七、完整链路回顾

我们从零搭起了一套可运行的赛博修真 GAS 系统。回顾整条链路,把王天劫放一次「劫雷」发生了什么:

  1. 玩家按键 → Enhanced Input 触发 → ASC TryActivateAbilityByClass(UGA_JieLei)
  2. UGA_JieLei::ActivateAbilityCommitAbility 检查并扣除 30 灵力(Cost GE)、进入冷却(Cooldown GE)、打上 State.Casting 标签。
  3. AbilityTask 播放施法蒙太奇,等待动画里的「命中帧」AnimNotify 发出 Event.Hit
  4. 命中帧到达 → 服务器计算伤害(基础 × 攻击力,攻击力已被元婴境 GE ×3)。
  5. 伤害通过 IncomingDamage Meta 属性传入目标 AttributeSet → PostGameplayEffectExecute 经防御减免扣到 Health → 判定死亡。
  6. ExecuteGameplayCue 发出 GameplayCue.JieLei.Impact → 所有客户端在命中点看到天雷、听到雷鸣、轻微震屏。
  7. 蒙太奇结束 → EndAbility → 移除 State.Casting 标签。

每一环都职责单一、可独立测试、天然联机。这就是 GAS 相比手写系统的根本价值。


十八、实战踩坑清单(FAQ)

以下是我们在《三界械堂》垂直切片开发期间真实遇到、并且每一条都消耗了至少半天排查时间的坑,按出现频率排序,希望能帮你省下这些时间。

Q1:技能激活没反应,日志一片安静。

最常见原因是 ASC 的 InitAbilityActorInfo 没有在正确时机调用。检查顺序:服务器是否在 PossessedBy 里调了?客户端是否在 OnRep_PlayerState 里调了?其次检查技能是否真的被授予(GiveAbility 只能在服务器调用,客户端调用会被静默忽略------这个「静默」非常坑)。最后用 showdebug abilitysystem 确认技能出现在授予列表里。

Q2:属性明明改了,UI 不刷新。

两种可能。一是你改的是 BaseValue 而 UI 监听的是 CurrentValue 的复制回调,此时检查 DOREPLIFETIME_CONDITION_NOTIFY 是否用了 REPNOTIFY_Always;二是 PlayerState 的 NetUpdateFrequency 还是默认的 1,属性同步每秒才来一次。前文提过,这个值要调到 100。

Q3:Duration GE 的 Modifier 不生效。

检查你的 Modifier 是不是想修改一个「只该被 Instant GE 修改」的属性。Duration/Infinite GE 改的是 CurrentValue(聚合层),Instant GE 改的是 BaseValue(持久层)。如果你用 Infinite GE 去「扣血」,血条会在 GE 移除后弹回来------因为你只是临时压低了 CurrentValue。伤害必须走 Instant GE 或 Meta 属性。

Q4:FGameplayAbilityTargetData 相关崩溃,报错在序列化。

九成是 UAbilitySystemGlobals::Get().InitGlobalData() 没调。回看第三节。这个初始化在编辑器里测试时经常「碰巧不崩」,一打包就现形,属于典型的「上线前一晚炸弹」。

Q5:客户端能看到自己放技能,别的玩家看不到特效。

表现走了本地逻辑而不是 GameplayCue。任何「所有人都该看到」的表现,必须通过 ExecuteGameplayCue(一次性)或 AddGameplayCue(持续型)触发,由 GAS 负责广播。直接在技能代码里 SpawnEmitter 只会在执行端出现。

Q6:蓄力技能在网络下松手时机不对。

WaitInputRelease 依赖输入按键与技能的绑定关系(InputID 或 Enhanced Input 的映射)。如果你用自己的输入路由,要确保 AbilityLocalInputPressed/Released 被正确调用,否则服务器收不到松手信号,蓄力会一直持续到超时。

Q7:境界 GE 移除后,当前血量超过了新的上限。

MaxHealth 从 500 掉回 100,而 Health 还是 350。PreAttributeChange 只会在「Health 被改时」夹取,不会在「MaxHealth 被改时」联动。解决:监听 MaxHealth 的变化委托(GetGameplayAttributeValueChangeDelegate),在回调里手动把 Health 夹到新上限内。

Q8:打包后 GameplayTag 丢失。

C++ 原生标签(UE_DEFINE_GAMEPLAY_TAG)不会丢;丢的通常是只写在 DefaultGameplayTags.ini 里、又没被任何资产引用的表标签。项目设置里勾选 Import Tags from Config 并确认打包时 ini 被包含。这也是我们推荐 C++ 声明标签的原因之一。


十九、小结与延伸

本文用《三界械堂》的赛博修真设定作为载体,完整走通了 GAS 的五大组件:ASC 挂载、AttributeSet 数值骨架、GameplayTag 标签树、GameplayEffect 的三种时长类型、GameplayAbility 的瞬发与蓄力两种范式、GameplayCue 的表现剥离,以及数据驱动与网络复制的关键要点。

如果你要继续深入,推荐这几个方向:

  • AbilityTask 自定义 :官方的 Task 不够用时,继承 UAbilityTask 写自己的异步节点(比如「引导类」技能)。
  • Modifier Magnitude Calculation(MMC):把复杂的伤害公式(比如受多个属性影响)封装成独立的计算类,比 SetByCaller 更强大。
  • Gameplay Effect Execution Calculation(ExecCalc):需要「攻击方属性 × 防守方属性」交叉计算时用它,是伤害公式的终极形态。
  • Target Data:网络安全地把「客户端选中的目标 / 命中点」传给服务器。

这些进阶主题,我们会在《三界械堂》技术专栏的后续文章里继续拆解。

从系统设计角度回望:为什么这套架构「扛得住」

抛开具体 API,本文这套架构真正值得借鉴的,是它把一个看似庞杂的需求------「八大境界、五行灵气、多种技能、联机同步、策划可调平衡」------拆成了几组正交的关注点:

数值状态归 AttributeSet 管,它不关心数值从哪来;数值的来源归 GameplayEffect 管,它不关心数值怎么用;能力行为归 GameplayAbility 管,它通过 Effect 间接改数值、通过 Tag 表达约束;状态与分类归 GameplayTag 管,它是所有系统之间沟通的「通用语言」;表现归 GameplayCue 管,它订阅 Tag、与逻辑零耦合。

正交的意义在于:新增一个境界,不用碰技能代码;新增一个技能,不用碰属性代码;换一套特效,不用碰任何 C++。 每个维度都能独立演化。当《三界械堂》从「元婴境」的垂直切片扩展到完整的八境体系时,我们几乎没有重构任何已有代码,只是往 DataTable 里加行、往内容目录里加 GE 和 Cue 资产。这种「加法式扩展」的能力,正是一套架构成熟与否的试金石,也是我们愿意投入前期学习成本拥抱 GAS 的根本原因。

反过来说,如果你的项目只有三五个技能、不联机、也不打算长期迭代,GAS 的前期成本可能确实不划算------工程决策永远要匹配项目规模,没有银弹。但对于一款有成长线、有联机、要长期运营的动作 RPG,GAS 几乎是目前 UE 生态里最稳妥的选择。

这些进阶主题,我们会在《三界械堂》技术专栏的后续文章里继续拆解。


附:本文涉及的类清单

类 / 文件 作用
SanJieXieTang.Build.cs 模块依赖配置
SanJieGameplayTags.h/.cpp 境界、属性、技能、状态标签集中定义
USanJieAttributeSet 气血、灵力、机械契合度等修真属性
ASanJiePlayerState 玩家 ASC 与 AttributeSet 的持有者
ASanJieHeroCharacter 主角化身,负责 ASC 初始化与突破逻辑
UGA_JieLei 劫雷(瞬发投射技能)
UGA_HunDun 混沌(蓄力融合技能)
UGC_JieLeiImpact 劫雷命中表现 Cue
FRealmRow 境界数据表行结构

版权与免责声明

© 2026 梦帮集团(DREAMVFIA)。本文所有原创技术内容、示例代码与配图版权归梦帮集团所有。文中代码基于 Unreal Engine 5 官方 GAS 框架编写,可用于学习与非商业用途;商业使用请遵循 Epic Games 的 UE 许可协议,并注明本文出处。

《三界械堂》世界观、角色(王天劫等)、术语(劫灭量子太刀、天道龙门、混元灵根等)为梦帮集团原创知识产权,未经授权不得用于商业开发。

本文为技术分享,不代表最终游戏实现;实际项目代码可能因版本迭代而调整。

------ 梦帮集团 · 《三界械堂》研发组 2026 年

相关推荐
zhanghongyi_cpp1 小时前
10. 实验书3.4.2 筛选达到预警阈值的病虫害数据
python
tuddy7894641 小时前
Codex++ 安全边界探秘:从模型能力到风险防御
人工智能·python·安全
TeamDev1 小时前
JxBrowser 9.3.0 版本发布啦!
java·后端·c#·混合应用·jxbrowser·浏览器控件·异步媒体设备
zzgnbfd65881 小时前
2026最新vibe coding入门实战:零基础快速落地全流程实测
人工智能·microsoft
2601_956865771 小时前
2026电商内容创作工具推荐:AI生成电商短视频的工具有哪些,哪个最划算?
人工智能·aigc
happyness441 小时前
如何通过其他AI蒸馏出自己的大模型
人工智能
踮起脚看烟花1 小时前
多人聊天室实现v2.0
c++·信息与通信
C++、Java和Python的菜鸟2 小时前
第1章 集合高级
java·jvm·python
2603_955279702 小时前
凝视与遗忘:AI如何定义记忆
人工智能