版权声明 © 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。
自己实现一套技能系统,你迟早要面对这些问题:
- 数值来源混乱 :主角的「灵力上限」可能同时受境界、装备、Buff、场景增益影响,手写累加极易出错。GAS 用
Modifier把每一个来源变成一条可追踪、可回滚的记录。 - Buff 的叠加与移除 :一个中毒 Buff 每秒掉血,5 秒后消失,还能叠 3 层------这类逻辑手写全是边界条件。GAS 的
GameplayEffect用 Duration、Period、Stacking 三个维度描述清楚。 - 网络同步:属性变化要从服务器同步到客户端,技能释放要做客户端预测以消除延迟感。这是最难自己写对的部分,GAS 已经帮你处理了绝大多数情况。
- 表现与逻辑耦合 :技能命中要播特效、放音效、震屏。如果写在技能逻辑里,联机时无法正确回放。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 ,很多人漏了 GameplayTags 和 GameplayTasks 导致链接错误:
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); // 劫雷命中表现
}
对应的 .cpp 用 UE_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)为什么区分 Health 和 MaxHealth? 因为 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 拉低,导致当前值超限,你还需要在PostGameplayEffectExecute或MaxHealth的 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;
}
关键坑:
PlayerState的NetUpdateFrequency默认是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:把表现层彻底剥离
前面反复出现 ExecuteGameplayCue。GameplayCue 是 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 系统。回顾整条链路,把王天劫放一次「劫雷」发生了什么:
- 玩家按键 → Enhanced Input 触发 → ASC
TryActivateAbilityByClass(UGA_JieLei)。 UGA_JieLei::ActivateAbility→CommitAbility检查并扣除 30 灵力(Cost GE)、进入冷却(Cooldown GE)、打上State.Casting标签。AbilityTask播放施法蒙太奇,等待动画里的「命中帧」AnimNotify 发出Event.Hit。- 命中帧到达 → 服务器计算伤害(基础 × 攻击力,攻击力已被元婴境 GE ×3)。
- 伤害通过
IncomingDamageMeta 属性传入目标 AttributeSet →PostGameplayEffectExecute经防御减免扣到Health→ 判定死亡。 ExecuteGameplayCue发出GameplayCue.JieLei.Impact→ 所有客户端在命中点看到天雷、听到雷鸣、轻微震屏。- 蒙太奇结束 →
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 年