目录
[0. 全局观:这三个阶段在搭什么](#0. 全局观:这三个阶段在搭什么)
[1. 阶段〇:Build.cs 模块配置](#1. 阶段〇:Build.cs 模块配置)
[2. 阶段一:创建带 GAS 能力系统的角色基类](#2. 阶段一:创建带 GAS 能力系统的角色基类)
[2.1 代码:NexusCharacterBase.h](#2.1 代码:NexusCharacterBase.h)
[2.2 代码:NexusCharacterBase.cpp](#2.2 代码:NexusCharacterBase.cpp)
[2.3 头文件要点解析](#2.3 头文件要点解析)
[2.4 构造函数要点解析](#2.4 构造函数要点解析)
[2.5 GAS 的灵魂:InitAbilityActorInfo 的双时机](#2.5 GAS 的灵魂:InitAbilityActorInfo 的双时机)
[3. 阶段二:添加属性集 AttributeSet](#3. 阶段二:添加属性集 AttributeSet)
[3.1 代码:BasicAttributeSet.h](#3.1 代码:BasicAttributeSet.h)
[3.2 代码:BasicAttributeSet.cpp](#3.2 代码:BasicAttributeSet.cpp)
[3.3 角色类的三处改动](#3.3 角色类的三处改动)
[3.4 知识点解析](#3.4 知识点解析)
[4. 阶段三:数值钳制(Clamping)](#4. 阶段三:数值钳制(Clamping))
[4.1 为什么要钳制](#4.1 为什么要钳制)
[4.2 代码改动](#4.2 代码改动)
[4.3 两个回调对比](#4.3 两个回调对比)
[5. 附:动手验证建议](#5. 附:动手验证建议)
[6. 参考资料](#6. 参考资料)
0. 全局观:这三个阶段在搭什么
一句话:创建一个"自带 GAS 能力系统的角色基类",保证它在单机和联网下都能正确初始化;再给它挂上属性账本,并教会账本"不许记越界的数"。
可以把它想象成一个公司的架构:
ANexusCharacterBase(角色 = 公司大楼)
├─ AbilitySystemComponent(ASC = 管家)
│ ├─ GameplayAbility 技能(员工,以后加)
│ ├─ UBasicAttributeSet 属性集(资产账本,阶段二加入)
│ │ ├─ Health / MaxHealth(生命 / 最大生命)
│ │ └─ Stamina / MaxStamina(耐力 / 最大耐力)
│ └─ GameplayTags 标签(档案标签,以后加)
└─ CharacterMovement(腿:负责移动、跳跃)
ASC 是 GAS 系统的核心管理者,所有技能、效果与属性均归属于它。各阶段职责如下:
| 阶段 | 执行内容 | 一句话理解 |
|---|---|---|
| 〇 | Build.cs 添加模块 |
为项目通电(编译期引入 GAS 依赖) |
| 一 | 定义角色基类并绑定 ASC | 雇佣管家,并明确其服务对象 |
| 二 | 创建属性集(AttributeSet) | 为管家配备资产账本,并打通网络同步通信通道 |
| 三 | 实现属性钳制逻辑 | 规范账本使用:生命值不可为负,不可超出上限 |
最终涉及的文件(均位于 Source/Nexus_Demo_GAS/ 目录下):
| 文件 | 引入阶段 | 功能说明 |
|---|---|---|
Nexus_Demo_GAS.Build.cs |
〇 | 引入 GAS 的三大核心模块 |
NexusCharacterBase.h/.cpp |
一(二补充属性集成员) | 角色基类:负责 ASC 的创建与双端初始化 |
BasicAttributeSet.h/.cpp |
二(三补充钳制逻辑) | 属性集:定义 4 个基础属性,包含复制回调与取值范围约束 |
1. 阶段〇:Build.cs 模块配置
cpp
// Fill out your copyright notice in the Description page of Project Settings.
using UnrealBuildTool;
public class Nexus_Demo_GAS : ModuleRules
{
public Nexus_Demo_GAS(ReadOnlyTargetRules Target) : base(Target)
{
// 预编译头策略:显式或共享 PCH(UE 模板默认值,不用动)
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
// 公开依赖模块:本模块用到、别的模块也能看到的依赖
PublicDependencyModuleNames.AddRange(new string[]
{
"Core", // UE 核心基础库(容器、数学、日志......)
"CoreUObject", // UObject 反射系统(UCLASS/UPROPERTY 的地基)
"Engine", // 引擎核心(AActor、ACharacter 等在这里)
"InputCore", // 传统输入
"EnhancedInput", // 增强输入系统(UE5 新输入)
// ↓↓↓ 启用 GAS 必需的三个模块,缺一不可 ↓↓↓
"GameplayAbilities", // GAS 本体:ASC、GameplayEffect、GameplayAbility、AttributeSet
"GameplayTags", // 标签系统:FGameplayTag / FGameplayTagContainer,GAS 的"神经脉络"
"GameplayTasks" // 任务系统:GameplayAbility 执行期间异步任务的底层依赖
});
// 如果要使用 Slate UI,取消下面注释
// PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
// 如果要使用联网功能,取消下面注释
// PrivateDependencyModuleNames.Add("OnlineSubsystem");
// 若要使用 OnlineSubsystemSteam,需在 uproject 文件的 plugins 段添加并设 Enabled 为 true
}
}
三个 GAS 模块的作用如下:
| 模块 | 里面有什么 |
|---|---|
GameplayAbilities |
GAS 核心模块:包含 ASC(AbilitySystemComponent)、GameplayAbility、GameplayEffect 以及 AttributeSet 等核心组件 |
GameplayTags |
标签系统:为技能、效果等提供唯一标识,GAS 内部的匹配与逻辑判断大量依赖此系统 |
GameplayTasks |
任务系统:支持在技能执行过程中挂载异步任务(如等待输入、选择目标等),是实现复杂行为的底层支撑 |
📌 即使暂未使用标签系统,这三个模块也必须添加 ------
GameplayAbilities模块本身依赖于另外两个。✅ 修改
Build.cs后,请务必:关闭编辑器 → 右键.uproject文件重新生成项目文件 → 重新编译,否则更改不会生效。
2. 阶段一:创建带 GAS 能力系统的角色基类
2.1 代码:NexusCharacterBase.h
cpp
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once // 防止头文件被重复包含
#include "CoreMinimal.h" // UE 基础类型(float、FString、日志宏等)
#include "GameFramework/Character.h" // ACharacter:角色基类(胶囊体、移动组件、跳跃......)
#include "AbilitySystemInterface.h" // IAbilitySystemInterface:GAS 定义的接口(一份"合同")
#include "AbilitySystemComponent.h" // UAbilitySystemComponent:GAS 的核心组件类
#include "NexusCharacterBase.generated.h" // ← UHT 自动生成的反射代码,永远必须是最后一个 include!
UCLASS() // 向反射系统声明:本类参与蓝图、序列化、网络复制,UHT 会据此生成 .generated.h 的内容
class NEXUS_DEMO_GAS_API ANexusCharacterBase : public ACharacter // 继承1:角色(物理身体+移动)
, public IAbilitySystemInterface // 继承2:GAS 接口(签合同)
{
GENERATED_BODY() // 占位宏,编译期展开为 UHT 生成的反射代码;UCLASS 类里必须写
public:
// Sets default values for this character's properties 构造函数
ANexusCharacterBase();
// Ability System Component 能力系统组件(GAS 的大管家:技能/效果/属性/标签都挂在它名下)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AbilitySystem")
class UAbilitySystemComponent* AbilitySystemComponent;
protected:
// ASC 的 GameplayEffect 复制模式:C++ 默认成员初始化语法给了初值,
// 又因为是 EditAnywhere 的 UPROPERTY,蓝图子类可以按角色类型覆盖(玩家=Mixed,AI=Minimal)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "AbilitySystem")
EGameplayEffectReplicationMode AscReplicationMode = EGameplayEffectReplicationMode::Mixed;
protected:
// Called when the game starts or when spawned 游戏开始/生成时调用,只调一次
virtual void BeginPlay() override;
// 【服务器】Controller 占有(Possess)该 Pawn 时调用 ------ 给服务器侧的 ASC 登记身份
virtual void PossessedBy(AController* NewController) override;
// 【客户端】PlayerState 复制到达时调用 ------ 给客户端侧的 ASC 登记身份
virtual void OnRep_PlayerState() override;
public:
// Called every frame 每帧都会调用
virtual void Tick(float DeltaTime) override;
// Called to bind functionality to input 用来把输入和功能绑定起来
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
// 履行接口合同:把 ASC 上交给 GAS(GAS 内部拿到任意 Actor 都走这条通道取 ASC)
virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override;
};
2.2 代码:NexusCharacterBase.cpp
cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "NexusCharacterBase.h"
#include "Components/CapsuleComponent.h" // 胶囊体碰撞组件
#include "GameFramework/CharacterMovementComponent.h" // 角色移动组件
// ---------- 构造函数:设置类默认值(蓝图子类都会继承这里的设置) ----------
ANexusCharacterBase::ANexusCharacterBase()
{
// 让角色每帧调用 Tick()。如果不需要每帧逻辑,改成 false 可以提升性能。
PrimaryActorTick.bCanEverTick = true;
// =====【GAS 第一步:创建 ASC ------ 把"大管家"雇佣进来】=====
// CreateDefaultSubobject 只能在构造函数里用:组件会成为这个类的固定组成部分,
// 就像人生下来自带心脏,所有蓝图子类自动继承。
// TEXT("...") 是子对象名(大纲里显示、用于序列化),创建后不要再改名。
AbilitySystemComponent = CreateDefaultSubobject<UAbilitySystemComponent>(TEXT("AbilitySystemComponent"));
// 组件默认不联网同步;ASC 必须显式打开,否则多人游戏里客户端看不到技能状态
AbilitySystemComponent->SetIsReplicated(true);
// 设置 GameplayEffect 的复制模式(Full / Mixed / Minimal,见 2.4 的表格)
AbilitySystemComponent->SetReplicationMode(AscReplicationMode);
// ---------- 碰撞 ----------
// 胶囊体是角色的碰撞外壳:半径 35cm、半高 90cm
GetCapsuleComponent()->InitCapsuleSize(35.0f, 90.0f);
// 角色不跟随控制器的旋转(俯仰/偏航/翻滚全关):
// 否则转视角时第三人称身体会跟着转。视角交给摄像机,身体朝向交给下面移动组件管。
bUseControllerRotationPitch = false;
bUseControllerRotationYaw = false;
bUseControllerRotationRoll = false;
// ---------- 移动手感(UE 第三人称模板默认手感,日后调手感主要动这几行) ----------
GetCharacterMovement()->bOrientRotationToMovement = true; // 朝移动方向转身
GetCharacterMovement()->RotationRate = FRotator(0.0f, 500.0f, 0.0f); // 转身速度:每秒最多 500°(仅偏航),越大越"跟手"
GetCharacterMovement()->JumpZVelocity = 500.f; // 起跳瞬间 Z 向初速度(cm/s),约跳 140cm 高
GetCharacterMovement()->AirControl = 0.35f; // 空中操控系数 0~1:空中保留 35% 的控制力
GetCharacterMovement()->MaxWalkSpeed = 500.f; // 地面最大速度 500cm/s = 5m/s
GetCharacterMovement()->MinAnalogWalkSpeed = 20.f; // 手柄摇杆轻推时的最低速度,防止慢到爬行
GetCharacterMovement()->BrakingDecelerationWalking = 2000.f; // 地面刹车力度:松开摇杆后停得多快
GetCharacterMovement()->BrakingDecelerationFalling = 1500.0f; // 空中刹车力度:比地面稍弱
}
// Called when the game starts or when spawned 只调用一次
void ANexusCharacterBase::BeginPlay()
{
Super::BeginPlay();
}
// Called every frame 每帧都会调用
void ANexusCharacterBase::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
}
// Called to bind functionality to input 用来把输入和功能绑定起来
void ANexusCharacterBase::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
}
// ---------- GAS 双端初始化 · 服务器侧 ----------
// PossessedBy 只在【服务器】上、Controller 占有(Possess) Pawn 时被调用,客户端永远收不到。
// 另外:UE 4.24 起,这里会自动把 Pawn 的 Owner 设为新 Controller------
// 这正是"ASC 直接挂在 Pawn 上 + Mixed 复制模式"能够开箱即用的原因(见 2.4 的说明)。
void ANexusCharacterBase::PossessedBy(AController* NewController)
{
Super::PossessedBy(NewController); // 永远先调父类,让引擎先完成它的工作
if (AbilitySystemComponent)
{
// 给 ASC 登记两个身份:
// 参数1 OwnerActor = ASC 逻辑上"住"在谁家(组件挂在角色身上 → 角色自己)
// 参数2 AvatarActor = ASC 代表"谁"在世界上战斗、谁身上挂效果和动画(也是角色自己)
AbilitySystemComponent->InitAbilityActorInfo(this, this);
}
}
// ---------- GAS 双端初始化 · 客户端侧 ----------
// OnRep_ 前缀 = 网络复制到达时的自动回调,只在【客户端】执行。
// PlayerState 从服务器复制到客户端的那一刻,客户端就知道"这个角色对应哪个玩家",随即给客户端的 ASC 登记。
// 口诀:服务器走 PossessedBy,客户端走 OnRep_。两处开头的 Super:: 都不能省。
void ANexusCharacterBase::OnRep_PlayerState()
{
Super::OnRep_PlayerState();
if (AbilitySystemComponent)
{
AbilitySystemComponent->InitAbilityActorInfo(this, this);
}
}
// ---------- 履行 IAbilitySystemInterface 的合同 ----------
// GAS 内部拿到任意 Actor 后的标准流程:Cast<IAbilitySystemInterface> → 成功就调这里拿 ASC → 再干活。
// 这就是当初继承接口的意义:GAS 不关心你的类是 Character 还是 Pawn,有这条通道就够了。
UAbilitySystemComponent* ANexusCharacterBase::GetAbilitySystemComponent() const
{
return AbilitySystemComponent;
}
2.3 头文件要点解析
① Include 区 = 取工具箱。
前 4 行分别引入引擎基础、角色基类、GAS 接口与 GAS 组件。NexusCharacterBase.generated.h 是 UE 反射系统(UHT)根据 UCLASS/UPROPERTY 标记自动生成的文件,内含蓝图系统所需的"演员名单"。必须置于最后 include:UHT 生成的代码依赖于此前已知的所有类型定义,若中间插入将导致编译失败。
② 双继承是设计的核心精髓:
ACharacter提供角色的物理形态、移动组件与胶囊体;IAbilitySystemInterface是 GAS 定义的纯接口(契约) ,仅要求实现GetAbilitySystemComponent()。
GAS 不强制继承特定基类,而是通过接口机制------它不关心你的角色是 Character、Pawn 还是 PlayerState,只关心"你能否把 ASC 交给我"。任何签了合同(继承 + 实现)的类皆可接入 GAS,这正是"组合优于继承"的典型体现。
③ UPROPERTY 说明符速查表:
| 说明符 | 含义 |
|---|---|
VisibleAnywhere |
编辑器或蓝图中仅可见(常用于组件指针,防止被误拖拽替换) |
BlueprintReadOnly |
蓝图中可读,不可写 |
EditAnywhere |
编辑器中可编辑(适用于类默认值与实例) |
BlueprintReadWrite |
蓝图中可读可写 |
Category = "..." |
在细节面板中分组,便于查找 |
④ 前向声明:
class UAbilitySystemComponent* 中的 class 关键字为前向声明------告知编译器"这是一个类,稍后定义",避免头文件包含完整定义,提升编译效率(实际定义在 .cpp 内)。本文件已包含 AbilitySystemComponent.h,因此此处 class 可省略,保留亦无害。
⑤ override 是拼写保镖:
它向编译器明确声明:"我正在重写父类的虚函数,请校验签名"。若父类无此函数,或参数不匹配,编译器将直接报错。注意 GetAbilitySystemComponent 后的 const------接口中声明为 const,重写时必须完全一致。
2.4 构造函数要点解析
复制模式(SetReplicationMode)决定同步范围:
| 模式 | GameplayEffect 同步对象 | 标签/Cue 提示同步对象 | 适用场景 |
|---|---|---|---|
Full |
所有人 | 所有人 | 单机或小规模合作游戏 |
Mixed |
仅拥有者的客户端 | 所有人 | 玩家角色(战术数值保密,防作弊) |
Minimal |
不同步 | 所有人 | AI 怪物(玩家无需知晓其详细数值) |
📌 关键细节 :根据 GAS 官方文档(tranek/GASDocumentation),
Mixed模式要求OwnerActor的Owner为Controller。PlayerState 天然满足;而 Character 则非如此,但自 UE 4.24 起,PossessedBy()会自动将 Pawn 的 Owner 设为新控制器。因此在 UE5 环境下,"ASC 挂在 Pawn 上 + Mixed"开箱即用。未来迁移到 PlayerState 时,该规则仍会重现。
2.5 GAS 的灵魂:InitAbilityActorInfo 的双时机
cpp
AbilitySystemComponent->InitAbilityActorInfo(this, this);
这是本文件最核心的一行代码,为 ASC 注册两个身份:
- 参数 1:OwnerActor(拥有者) ------ ASC 在逻辑上"归属"于谁。由于 ASC 是 Character 的子组件,故应设为自身。
- 参数 2:AvatarActor(化身) ------ ASC 代表"谁"在世界中行动、承受效果与动画。
两者何时不同? 当 ASC 挂载于 PlayerState 时(大型项目主流做法):
OwnerActor = PlayerState(数据存储处)AvatarActor = Pawn(表现与战斗实体)
当 ASC 直接挂载于角色身上时,二者合一------合法且常见,适用于中小型项目。
为何需初始化两次? 因服务器与客户端"角色就绪"时机不同:
| 时机 | 执行端 | 触发条件 |
|---|---|---|
PossessedBy |
仅服务器 | Controller 占有(Possess)Pawn 时 |
OnRep_PlayerState |
仅客户端 | PlayerState 从服务器复制到达时 |
口诀:服务器走 PossessedBy,客户端走 OnRep_。
这是 GAS 双端初始化的标准结构。两处开头的 Super::Xxx() 不可遗漏------它们通知父类执行其应有的流程,删除将破坏引擎内部机制。
3. 阶段二:添加属性集 AttributeSet
3.1 代码:BasicAttributeSet.h
cpp
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "AttributeSet.h" // UAttributeSet:所有属性集的基类
#include "AbilitySystemComponent.h" // FGameplayAttributeData、复制/访问器相关宏在这里
#include "BasicAttributeSet.generated.h" // ← 永远是最后一个 include
/**
* 基础属性集:生命 / 耐力
*
* 三个要点:
* 1. 每个属性用 FGameplayAttributeData 存(内含 BaseValue + CurrentValue),
* 而不是普通 float------GameplayEffect 的修改、钳制、网络复制都建立在这个结构上;
* 2. 属性用 BlueprintReadOnly:属性只应通过 GameplayEffect / SetXxx() 修改,
* 不给蓝图"直接改内存"的后门,否则会绕过钳制、复制和变化通知;
* 3. ATTRIBUTE_ACCESSORS_BASIC 宏一行生成本属性的全部访问器(展开见下方注释)。
*/
UCLASS()
class NEXUS_DEMO_GAS_API UBasicAttributeSet : public UAttributeSet
{
GENERATED_BODY()
public:
UBasicAttributeSet();
// ==================== 生命 ====================
// 当前生命值
// ReplicatedUsing = OnRep_Health:该值从服务器复制到客户端时,自动调用下面的 OnRep_Health
UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_Health)
FGameplayAttributeData Health;
// 宏展开后生成三个访问器:
// GetHealthAttribute() ------ 返回 FGameplayAttribute(这个属性的"身份证",钳制/GE 里用它比对身份)
// GetHealth() ------ 读当前值(CurrentValue)
// SetHealth(NewVal) ------ 写入新值(通过 ASC 的属性管线修改)
//
// ⚠️ 版本注意:ATTRIBUTE_ACCESSORS_BASIC 是 UE 5.6 起官方内置在 AttributeSet.h 的组合宏;
// 更旧的版本没有它,编译会报未定义。旧版做法参考 GASDocumentation:
// 在自己的头文件里自行定义组合宏 ATTRIBUTE_ACCESSORS(同样三个访问器 + 额外的 InitHealth() 初始化器)。
ATTRIBUTE_ACCESSORS_BASIC(UBasicAttributeSet, Health);
// 最大生命值("上限"本身也是属性:升级、Buff 都可能改它)
UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_MaxHealth)
FGameplayAttributeData MaxHealth;
ATTRIBUTE_ACCESSORS_BASIC(UBasicAttributeSet, MaxHealth);
// ==================== 耐力 ====================
// 当前耐力值(冲刺等动作消耗的就是它)
UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_Stamina)
FGameplayAttributeData Stamina;
ATTRIBUTE_ACCESSORS_BASIC(UBasicAttributeSet, Stamina);
// 最大耐力值
UPROPERTY(BlueprintReadOnly, Category = "Attributes", ReplicatedUsing = OnRep_MaxStamina)
FGameplayAttributeData MaxStamina;
ATTRIBUTE_ACCESSORS_BASIC(UBasicAttributeSet, MaxStamina);
public:
// ==================== 网络复制回调 ====================
// OnRep_ 只在【客户端】被调用:服务器改了属性 → 复制到客户端 → 触发这里。
// GAMEPLAYATTRIBUTE_REPNOTIFY 宏会通知 ASC"该属性已复制更新",于是 UI / 技能里
// 通过 GetGameplayAttributeValueChangeDelegate(...) 注册的回调就会带着旧值被触发。
UFUNCTION()
void OnRep_Health(const FGameplayAttributeData& OldValue) const
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UBasicAttributeSet, Health, OldValue);
}
UFUNCTION()
void OnRep_MaxHealth(const FGameplayAttributeData& OldValue) const
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UBasicAttributeSet, MaxHealth, OldValue);
}
UFUNCTION()
void OnRep_Stamina(const FGameplayAttributeData& OldValue) const
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UBasicAttributeSet, Stamina, OldValue);
}
UFUNCTION()
void OnRep_MaxStamina(const FGameplayAttributeData& OldValue) const
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UBasicAttributeSet, MaxStamina, OldValue);
}
// 登记哪些属性要复制、复制条件是什么(不 override 这个函数,上面的 OnRep 永远不会被触发)
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
3.2 代码:BasicAttributeSet.cpp
cpp
// Fill out your copyright notice in the Description page of Project Settings.
#include "BasicAttributeSet.h"
#include "Net/UnrealNetwork.h" // DOREPLIFETIME_* 复制登记宏在这里
UBasicAttributeSet::UBasicAttributeSet()
{
// 初始值。
// 注意:直接给属性赋值绕过了 ASC 管线(不触发钳制/委托),只适合做"出生初始值";
// 更标准的 GAS 做法是用一个"初始值 GameplayEffect"来初始化属性。
Health = 100.f; // 初始生命
MaxHealth = 100.f; // 最大生命
Stamina = 100.f; // 初始耐力
MaxStamina = 100.f; // 最大耐力
}
// 登记属性的复制规则:DOREPLIFETIME_CONDITION_NOTIFY(类, 属性, 复制条件, OnRep触发条件)
// COND_None = 无条件复制给所有端(进阶可用 COND_OwnerOnly 等做权限控制)
// REPNOTIFY_Always = 即使新值和旧值相同也触发 OnRep
// (比如"回满血"时数值没变,UI 也需要收到通知去刷新)
void UBasicAttributeSet::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, Health, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, MaxHealth, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, Stamina, COND_None, REPNOTIFY_Always);
DOREPLIFETIME_CONDITION_NOTIFY(UBasicAttributeSet, MaxStamina, COND_None, REPNOTIFY_Always);
}
3.3 角色类的三处改动
属性集不需要新的"接线"流程,只要在角色基类上做三处小改动:
cpp
// ① NexusCharacterBase.h:新增属性集成员
// (class 前缀 = 前向声明,完整定义放 .cpp 里 include)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AbilitySystem")
class UBasicAttributeSet* BasicAttributeSet;
cpp
// ② NexusCharacterBase.cpp:include 完整定义
#include "BasicAttributeSet.h"
cpp
// ③ NexusCharacterBase.cpp 构造函数:创建属性集
// 以默认子对象方式创建,与 ASC 同属一个角色,ASC 会自动注册它,不需要手动 Add
BasicAttributeSet = CreateDefaultSubobject<UBasicAttributeSet>(TEXT("BasicAttributeSet"));
3.4 知识点解析
① FGameplayAttributeData = 底账 + 实付
这是一个包含两个 float 值的结构体:
- BaseValue(基础值 / 底账) :永久性的基础数值,瞬时 GameplayEffect 直接修改的是此值;
- CurrentValue(当前值 / 实付):在 BaseValue 基础上叠加所有持续型 GameplayEffect 修正后的最终结果;
GetXxx()返回的是 CurrentValue ------ UI 展示与逻辑判断均应使用该值。
之所以不使用普通 float,是因为需要支持:
- GameplayEffect 的加/乘/覆盖等修改;
- 钳制(Clamping);
- 网络复制;
- 属性变化委托(Change Notifiers)------这些机制都依赖于该结构体的完整生命周期管理。
② 为何属性设为 BlueprintReadOnly?
属性仅应通过 GameplayEffect 或 SetXxx() 经由 ASC 管线进行修改。这样可确保:
- 钳制逻辑生效;
- 网络复制正常触发;
- 属性变更通知被正确广播。
直接在构造函数中赋值会绕过上述管线,因此仅适用于角色出生时的初始设定。
③ ATTRIBUTE_ACCESSORS_BASIC 宏(UE 5.6+ 内置)
一行代码即可生成:
GetXxxAttribute()(获取属性引用,即"身份证");GetXxx()(读取当前值);SetXxx()(写入新值,触发变更流程)。
旧版本(≤5.5)无此宏,需按 GASDocumentation 推荐方式,在头文件中自定义 ATTRIBUTE_ACCESSORS 组合宏,额外提供一个 InitXxx() 函数用于初始化(直接设置 BaseValue,不触发委托)。
④ 属性网络同步"三件套",缺一不可:
| 组件 | 写在哪 | 作用 |
|---|---|---|
ReplicatedUsing = OnRep_Xxx |
UPROPERTY 声明中 | 指定当属性复制到达客户端时,调用 OnRep_Xxx 函数 |
OnRep_Xxx + GAMEPLAYATTRIBUTE_REPNOTIFY |
属性集内部 | 在客户端执行;触发 ASC 广播属性变更委托(携带旧值),用于驱动 UI 更新和技能响应 |
GetLifetimeReplicatedProps + DOREPLIFETIME_CONDITION_NOTIFY |
属性集内部 | 注册复制规则(未登记则永不复制) |
4. 阶段三:数值钳制(Clamping)
4.1 为什么要钳制
属性的所有修改都应约束在合法区间:一次伤害 GE 把 Health 改成 -100、一次治疗 GE 把 Stamina 改成 99999,都会让 UI、死亡判定、消耗逻辑全线出错。GAS 在属性修改管线上设了两个"检查站",分工有官方定论:
-
PreAttributeChange()------ 负责对当前属性值(CurrentValue)的钳制; -
PostGameplayEffectExecute()------ 负责对来自 GameplayEffect 的基础值(BaseValue)修改进行钳制
4.2 代码改动
① BasicAttributeSet.h:新增两个 override 声明 (置于 GetLifetimeReplicatedProps 声明之后,位于同一 public: 区域内即可)
cpp
// 【阶段三新增】属性值即将改变之前的回调 ------ 钳制 CurrentValue 的最佳位置
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) override;
// 【阶段三新增】瞬时 GameplayEffect 执行完成之后的回调 ------ 钳制/规范 BaseValue、做善后逻辑
virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override
② BasicAttributeSet.cpp:新增 include 与两个实现:
cpp
#include "BasicAttributeSet.h"
#include "GameplayEffectExtension.h" // 【阶段三新增】FGameplayEffectModCallbackData 定义在这里
#include "Net/UnrealNetwork.h"
// ==================== 阶段三:数值钳制 ====================
// 属性值即将改变之前调用(GameplayEffect 等通过 GAS 管线的修改都会经过这里)。
// 参数:Attribute = 这次要改的是哪个属性(用 GetXxxAttribute() 比对"身份证")
// NewValue = 即将写入的新值,是【引用】------直接改写它就能实现钳制
//
// ⚠️ 官方文档明确:这里改写的 NewValue 只影响"这一次变化"(CurrentValue 路径),
// 不会永久修正 BaseValue。所以 BaseValue 的钳制要配合下面的 PostGameplayEffectExecute。
void UBasicAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
Super::PreAttributeChange(Attribute, NewValue);
if (Attribute == GetHealthAttribute())
{
// 生命钳制在 [0, MaxHealth]:不会出现负血,也不会溢出上限
NewValue = FMath::Clamp(NewValue, 0.f, GetMaxHealth());
}
else if (Attribute == GetStaminaAttribute())
{
// 耐力钳制在 [0, MaxStamina]
NewValue = FMath::Clamp(NewValue, 0.f, GetMaxStamina());
}
// MaxHealth / MaxStamina 本身没有在这里钳------它们是"上限",通常由 GE 或成长系统负责
}
// 瞬时(Instant) GameplayEffect 在本 ASC 上执行完成之后调用(只在服务器/权威端)。
// 常见用途:读取刚被 GE 改过的值做善后------钳制 BaseValue、触发死亡、播放受击表现......
// Data.EvaluatedData.Attribute : 本次被修改的是哪个属性
// Data.EvaluatedData.Value : ⚠️ Additive(加减)操作时它是"增量",不是最终值!
void UBasicAttributeSet::PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data)
{
Super::PostGameplayEffectExecute(Data);
if (Data.EvaluatedData.Attribute == GetHealthAttribute())
{
// 经典写法:把刚被 GE 改过的值再通过 Setter 写回一遍。
// 作用:让最终值重新走一遍属性管线(再次触发 PreAttributeChange 的钳制、
// 属性变化委托与复制通知),并把 BaseValue 规范到合法范围内。
// 更稳妥的加强写法(显式双保险):
// SetHealth(FMath::Clamp(GetHealth(), 0.f, GetMaxHealth()));
SetHealth(GetHealth());
}
else if (Data.EvaluatedData.Attribute == GetStaminaAttribute())
{
// 同上:耐力也回写一遍,确保钳制和通知生效
SetStamina(GetStamina());
}
}
4.3 两个回调对比
PreAttributeChange |
PostGameplayEffectExecute |
|
|---|---|---|
| 触发时机 | 属性值即将变更之前(每次变化均触发) | 瞬时 Gameplay Effect 执行完毕之后 |
| 官方分工 | 控制 CurrentValue | 作用于 Gameplay Effect 的 BaseValue |
| 生效方式 | 直接修改传入的 NewValue 参数 |
通过 SetXxx() 将最终结果写回属性 |
| 重要注意 | 仅影响本次变更,不会永久修改 BaseValue |
仅在服务器端(权威端)回调执行 |
| 典型用途 | 限制生命值范围(如 Health ∈ [0, MaxHealth]) |
回写钳制结果、死亡判定、受击反馈表现 |
📌 属性系统还包含
PreAttributeBaseChange、PostAttributeChange等其他回调,初学阶段掌握上述两项即可。
5. 附:动手验证建议
按以下步骤可直观验证钳制机制的生效效果(建议在 Standalone 模式 或 以服务器身份监听 的环境下测试------纯客户端进程无法正确应用 GE):
① 创建一个"扣 200 点血"的 Gameplay Effect:
在内容浏览器中右键 → 选择 GameplayEffect 蓝图类,命名为如 BP_GE_Damage200。打开后进行如下配置:
- Duration Policy =
Instant - Modifiers 添加一项:
- Attribute =
Health(从 BasicAttributeSet 中选取) - Modifier Op =
Additive - Magnitude =
-200
- Attribute =
② 临时测试代码(测试完成后可删除):
在 NexusCharacterBase.h 中添加一个可编辑的 GE 引用:
cpp
UPROPERTY(EditAnywhere, Category = "Debug")
TSubclassOf<UGameplayEffect> TestDamageEffect; // 在编辑器中指定为 BP_GE_Damage200
在 BeginPlay 函数中加入以下逻辑:
cpp
void ANexusCharacterBase::BeginPlay()
{
Super::BeginPlay();
if (TestDamageEffect && AbilitySystemComponent)
{
// 将该 GE(等级 1)应用到自身;瞬时效果会立即同步执行完毕
AbilitySystemComponent->ApplyGameplayEffectToSelf(TestDamageEffect, 1.0f,
AbilitySystemComponent->MakeEffectContext());
}
if (BasicAttributeSet)
{
// 预期输出:Health = 0.0(已被钳制),而非 -100
UE_LOG(LogTemp, Warning, TEXT("[ClampTest] Health = %.1f"), BasicAttributeSet->GetHealth());
}
}
③ 验证上限钳制效果:
另创建一个 Magnitude = +200 的治疗型 GE,重复上述测试流程。
预期结果: Health 仍为 100(受 MaxHealth 钳制),而非 300。
这表明属性值已成功被上限和下限约束,钳制机制正常工作。
6. 参考资料
- tranek/GASDocumentation (GitHub)------ 详述钳制回调的分工逻辑(
PreAttributeChange管理CurrentValue,PostGameplayEffectExecute管理BaseValue)、InitAbilityActorInfo的双时机使用场景、三种复制模式的差异,以及 Mixed 与 Controller 之间的关系。 - Epic 官方教程《Gameplay Ability System - Best Practices for Setup》 (dev.epicgames.com)------ 确认
ATTRIBUTE_ACCESSORS_BASIC从 UE 5.6 起已内置于AttributeSet.h;提供PostGameplayEffectExecute中回写与钳制的官方示例,其设计思路与 Lyra 项目中的ULyraHealthSet一致。 - Epic 官方文档《Gameplay Attributes and Attribute Sets》 (dev.epicgames.com)------ 官方定义属性的双值结构(
BaseValue与CurrentValue)及属性集的可扩展设计点。