Hey folks!
Introduction
Recently I’ve taken the deep dive into the GameplayAbility system, in part due to the pioneer efforts of @anonymous_user_843b99c6 in his incredible forum thread. However, my circumstances are a bit different so I thought it’d be worth chronicling them for future reference so that someone might find it useful. The GameplayAbility system is extremely vast, obtuse, difficult to wrap your head around and insanely amazing. We know that Epic uses it internally on both Fortnite and Paragon so it’s already been battle-worn and proven to work. Unfortunately, as there was virtually no information on it some 10-12 months ago, I’ve rolled out my custom system. Just recently, having inspected some of the stuff in the GA system I’ve realized that I’ve inadvertently stumbled down some similar design paradigms and solutions in my own system.So as I was headed for a major refactoring anyway (half-***** some new features due to deadlines) I thought to myself what the hoo-ha, might as well take a dive off the deep end. Long story short, this means that this forum thread will hopefully see me rip out the guts of my original combat system and replace it with GA, hopefully giving you guys a better understanding of the system in the process.
Also, thanks to everyone in the Discord chat for helping me in my research.
IMPORTANT: This will not be a well structured tutorial series. It is, for all intents and purposes a “brain dump”. It is intended as an information archive of my research results to help other nutjobs who are also, like me, trying to unravel this beautiful engine subsystem. There’ll be useful information, but I can’t vouch for any formatting, proofreading or user-friendliness.
Prerequisites
These instructions are not an introductory C++ course. The GA system is obtuse and confusing for even the most decorated code warriors, they will not be good material for your 101 course. With that being said I will not go through how to enable the GA system as it really boils down to a) enable plugin and b) add the module to your PublicDependency list. I guess I did go over how to enable the system…
Attribute Sets
When thinking about how to best approach the transition I thought it’s probably best to port my data holders to GA first, i.e. my combat stats. Once I have those I’ll be able to rebuild some of my combat functionality and combat abilities. Thus, my first stop were Attribute Sets. While at first they seem to be simple data holders, they have proven to be deceptively complex. In my old system stats were simple gameplay tags tied together to a float value. Meaning that if a designer wanted to add a new stat he would simply add a new Stat.Something gameplay tag and that’s it, it could be assigned to any actor owning my CombatComponent and be used as a resource. Compared to that, Attribute Sets are much more rigid. While at first that made me frown quite a bit, I realized that in reality, you define your game’s stats once and be done with it, very rarely, if ever, will you be adding some fancy arbitrary stats like that. Thus, it was an acceptable tradeoff for me, but keep this limitation in mind when considering using this system.
Before creating the attribute sets though we’ll first need a UAttributeSystemComponent. Since I will be extending the functionality I have created my own subclass of the component, let’s call it UBlaAttributeSystemComponent for the purpose of this document. Furthermore, chances are that my player and my AI characters will further subclass it so I need to facilitate those being able to change the class of the parent’s component. To clarify - I have a ABlaCharacter whose parent is ACharacter and who branches out further into AAICharacter and APlayerCharacterBase. This means, in my ABlaCharacter I have the following code:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Abilities, meta = (AllowPrivateAccess = "true"))
class UBlaAbilitySystemComponent* AbilitySystem;
But, equally important, below that in the same class there’s this line:
static FName AbilitySystemName;
If we head over to the matching .cpp file there’s these two lines:
//This is outside of any function, just below my includes
FName ABlaCharacter::AbilitySystemName(TEXT("AbilitySystem"));
//In the constructor
AbilitySystem = CreateDefaultSubobject<UBlaAbilitySystemComponent>(ABlaCharacter::AbilitySystemName);
One might wonder why jump through all these hoops when I could’ve just punched in “AbilitySystem” in the CreateDefaultSubobject argument and be done with it. The reason is that this now allows me to do something fancy, namely this:
AAICharacter::AAICharacter(const FObjectInitializer& OI)
: Super(OI.SetDefaultSubobjectClass<UAIMovementComponent>(ACharacter::CharacterMovementComponentName)
.SetDefaultSubobjectClass<UAIAbilitySystemComponent>(ABlaCharacter::CombatComponentName)) { ..... }
See what happened there? My AICharacter, which subclasses ABlaCharacter has changed the class of a component in its parent, or rather, two components. This allows me to subclass any parent component without adding a new one.
Well, this is a good first step, we have the UAbilitySystemComponent (ASC) set up and subclassed. Now it’s time to define some attributes. It is important to note that an ASC can hold on to multiple attribute sets. There are several and ways that you can split attributes. I personally had a UCoreAttributeSet which only defined health-based attributes and a UCombatAttributeSet which defined ye ol’ traditional RPG stats like strength and such. The reason for this peculiar split was because in my game I might have critters which can’t fight back, but still require a health property.
So, to start off, I’ve set up the following attributes:
Health - Current health
MaxHealth - The “fixed” max health. Most of the max health will come from vitality, this is just a way to set up an arbitrary base.
HealthRegenPerSecond - Self-explanatory.
Vitality - Self-explanatory
VitalityHealthBonus - “How much health am I getting from my current Vitality amount”
HealthRegenPerVitality - How much does vitality effect health regeneration?
The code for these looks like this:
//The current health of the attribute set owner
UPROPERTY()
FGameplayAttributeData Health;
//The maximum health
UPROPERTY()
FGameplayAttributeData MaxHealth;
//How much HP is restored per second
UPROPERTY()
FGameplayAttributeData HealthRegenPerSecond;
//Vitality increases health
UPROPERTY()
FGameplayAttributeData Vitality;
//How much health the owner ASC gains per point of vitality
UPROPERTY()
FGameplayAttributeData VitalityHealthBonus;
//How much health regen the owner ASC gains per point of vitality
UPROPERTY()
FGameplayAttributeData HealthRegenPerVitality;
You’ll notice that all the attributes use FGameplayAttributeData. This is basically just a wrapper struct that holds 2 floats - 1 for the current value and 1 for the base value of a stat. This is used to facilitate things like temporary buffs to a given stat etc.
A UAttributeSet has several useful function, but there are four that are the most important. Below I am pasting the data on them from the UAttributeSet.h header file directly:
/**
* Called just before modifying the value of an attribute. AttributeSet can make additional modifications here. Return true to continue, or false to throw out the modification.
* Note this is only called during an 'execute'. E.g., a modification to the 'base value' of an attribute. It is not called during an application of a GameplayEffect, such as a 5 ssecond +10 movement speed buff.
*/
virtual bool PreGameplayEffectExecute(struct FGameplayEffectModCallbackData &Data) { return true; }
/**
* Called just before a GameplayEffect is executed to modify the base value of an attribute. No more changes can be made.
* Note this is only called during an 'execute'. E.g., a modification to the 'base value' of an attribute. It is not called during an application of a GameplayEffect, such as a 5 ssecond +10 movement speed buff.
*/
virtual void PostGameplayEffectExecute(const struct FGameplayEffectModCallbackData &Data) { }
/**
* Called just before any modification happens to an attribute. This is lower level than PreAttributeModify/PostAttribute modify.
* There is no additional context provided here since anything can trigger this. Executed effects, duration based effects, effects being removed, immunity being applied, stacking rules changing, etc.
* This function is meant to enforce things like "Health = Clamp(Health, 0, MaxHealth)" and NOT things like "trigger this extra thing if damage is applied, etc".
*
* NewValue is a mutable reference so you are able to clamp the newly applied value as well.
*/
virtual void PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue) { }
/**
* This is called just before any modification happens to an attribute's base value when an attribute aggregator exists.
* This function should enforce clamping (presuming you wish to clamp the base value along with the final value in PreAttributeChange)
* This function should NOT invoke gameplay related events or callbacks. Do those in PreAttributeChange() which will be called prior to the
* final value of the attribute actually changing.
*/
virtual void PreAttributeBaseChange(const FGameplayAttribute& Attribute, float& NewValue) const { }
In short, PreAttributeChange and PreAttributeBaseChange can be called whenever an attribute changes no matter what changed it or why. PreGameplayEffectExecute and PostGameplayEffectExecute are called when attributes have been modified via a gameplay effect (more on that later, just pretend it means “a buff or debuff” for now). The simplest use case for these (the PreAttributeBaseChange in particular) is clamping. This essentially looks like this:
void UCoreAttributeSet::PreAttributeBaseChange(const FGameplayAttribute& Attribute, float& NewValue) const
{
if (Attribute == HealthAttribute())
{
NewValue = FMath::Clamp(NewValue, 0.f, MaxHealth.GetCurrentValue());
}
}
Important: The above code is incorrect. Health should be clamped in PreAttributeChange, not in PreAttributeBaseChange. The latter will be called only when the base health value has changed, while the latter will be fired on every health modification (like temporary health buffs etc.)
Notice that the function takes a FGameplayAttribute& (NOT a FGameplayAttributeData) and that it’s being compared to a HealthAttribute() function. This function looks like this:
//.h
static FGameplayAttribute HealthAttribute();
//.cpp
FGameplayAttribute UCoreAttributeSet::HealthAttribute()
{
static UProperty* Property = FindFieldChecked<UProperty>(UCoreAttributeSet::StaticClass(), GET_MEMBER_NAME_CHECKED(UCoreAttributeSet, Health));
return FGameplayAttribute(Property);
}
This is nice and all, but you will have to write this for every single attribute that you want to compare or modify in any shape or form. Obviously this gets cumbersome very fast, and the code is 99% identical sans the difference in names. Meaning that this is a perfect scenario for a custom macro. Or rather, 2 macros in my case, 1 for the header and 1 for the cpp. The DECLARE_ macro will take an attribute and create the header function for it and the DEFINE_ macro will create the implementation. Source and sample usage are as follows:
//Macro source, I've put it in a separate AttributeMacros.h file
#define DECLARE_ATTRIBUTE_FUNCTION(PropertyName) static FGameplayAttribute PropertyName##Attribute();
#define DEFINE_ATTRIBUTE_FUNCTION(PropertyName, ClassName) \
FGameplayAttribute ClassName##::PropertyName##Attribute() \
{ \
static UProperty* Property = FindFieldChecked<UProperty>(ClassName##::StaticClass(), GET_MEMBER_NAME_CHECKED(ClassName, PropertyName)); \
return FGameplayAttribute(Property); \
}
//Usage
//.h
DECLARE_ATTRIBUTE_FUNCTION(Health);
//.cpp (Anywhere outside a function)
DEFINE_ATTRIBUTE_FUNCTION(Health, UCoreAttributeSet); //<----UCoreAttributeSet needs to be the same of your actual attribute set
This will save you quite a bit of boilerplate for setting up all attributes.
One thing to note earlier in the document beginning is that me clamping Health between 0 and MaxHealth is incorrect. This is because I also have the VitalityHealthBonus attribute, so it should clamp between 0 and MaxHealth + VitalityHealthBonus. This is a common scenario for me as I have many “2-part” attributes like this. To speed up access to these I’ve created a third macro that defines a function like e.g. GetMaxHealthIncludingVitalityBonus(). It looks like this:
//The macro
#define DECLARE_NAMED_COMBINED_STAT_GETTER(BaseProperty, BonusProperty, FunctionName)__forceinline float FunctionName##() const \
{ \
return BaseProperty##.GetCurrentValue() + BonusProperty##.GetCurrentValue();\
}
//Example in header file (the function is __forceinline)
DECLARE_NAMED_COMBINED_STAT_GETTER(MaxStamina, MaxStaminaPerEndurance, GetMaxStaminaIncludingEnduranceBonus);
This is of course completely optional and won’t make a difference for the core setup.
Now that we actually have some attributes, let’s tell our ASC that we have them. The ASC itself has a TArrayDefaultStartingData array, but that seems to be deprecated, as it depends on you providing a specific attribute together with a non-optional float curve to initialize it from. We’ll see in a bit why that approach isn’t the best idea when we get to proper ways of initializing attributes. I found that this approach works just fine:
//In BlaCharacter.h
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Bla|Character")
TArray<TSubclassOf<class UAttributeSet>> AttributeSets;
//In ABlaCharacter::BeginPlay()
if (AbilitySystem != nullptr)
{
AbilitySystem->InitAbilityActorInfo(this, this);
for (TSubclassOf<UAttributeSet>& Set : AttributeSets)
{
AbilitySystem->InitStats(Set, nullptr);
}
UAbilitySystemGlobals* ASG = IGameplayAbilitiesModule::Get().GetAbilitySystemGlobals();
FAttributeSetInitter* ASI = ASG->GetAttributeSetInitter();
ASI->InitAttributeSetDefaults(AbilitySystem, UBlaGameplayStatics::GetTagLeafName(AbilitySystem->ClassTag), 1, true);
}
Disregard the last 3 lines for now. We’ll build our way up to those as we explore attribute initialization. Just remember that this is where you actually fire off the entire initialization chain eventually.
You probably noticed, but some of the core attributes listed above depend on each other (both HealthRegenPerVitality and VitalityHealthBonus depend on Vitality) and we’ll eventually get to setting that up, but for now let’s take a look at how attributes are populated at all.
Digging through AttributeSet.h you can find a FAttributeInitter struct and the following comment above:
/**
* Helper struct that facilitates initializing attribute set default values from spread sheets (UCurveTable).
* Projects are free to initialize their attribute sets however they want. This is just want example that is
* useful in some cases.
*
* Basic idea is to have a spreadsheet in this form:
*
* 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
*
* Default.Health.MaxHealth 100 200 300 400 500 600 700 800 900 999 999 999 999 999 999 999 999 999 999 999
* Default.Health.HealthRegenRate 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
* Default.Health.AttackRating 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
* Default.Move.MaxMoveSpeed 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500
* Hero1.Health.MaxHealth 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100
* Hero1.Health.HealthRegenRate 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
* Hero1.Health.AttackRating 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
* Hero1.Move.MaxMoveSpeed 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500 500
*
*
* Where rows are in the form: [GroupName].[AttributeSetName].[Attribute]
* GroupName - arbitrary name to identify the "group"
* AttributeSetName - what UAttributeSet the attributes belong to. (Note that this is a simple partial match on the UClass name. "Health" matches "UMyGameHealthSet").
* Attribute - the name of the actual attribute property (matches full name).
*
* Columns represent "Level".
*
* FAttributeSetInitter::PreloadAttributeSetData(UCurveTable*)
* This transforms the CurveTable into a more efficient format to read in at run time. Should be called from UAbilitySystemGlobals for example.
*
* FAttributeSetInitter::InitAttributeSetDefaults(UAbilitySystemComponent* AbilitySystemComponent, FName GroupName, int32 Level) const;
* This initializes the given AbilitySystemComponent's attribute sets with the specified GroupName and Level. Game code would be expected to call
* this when spawning a new Actor, or leveling up an actor, etc.
*
* Example Game code usage:
*
* IGameplayAbilitiesModule::Get().GetAbilitySystemGlobals()->GetAttributeSetInitter()->InitAttributeSetDefaults(MyCharacter->AbilitySystemComponent, "Hero1", MyLevel);
*
* Notes:
* -This lets system designers specify arbitrary values for attributes. They can be based on any formula they want.
* -Projects with very large level caps may wish to take a simpler "Attributes gained per level" approach.
* -Anything initialized in this method should not be directly modified by gameplay effects. E.g., if MaxMoveSpeed scales with level, anything else that
* modifies MaxMoveSpeed should do so with a non-instant GameplayEffect.
* -"Default" is currently the hardcoded, fallback GroupName. If InitAttributeSetDefaults is called without a valid GroupName, we will fallback to default.
*
*/
That is certainly a useful comment but also quite a bit to digest. In short - set up a curve table and read it into your attributes. Simple enough… except it’s not in my case, because my game has for all intents and purposes infinite levels. But let’s step back for a second. The FAttributeSetInitter declared 4 functions:
virtual void PreloadAttributeSetData(const TArray<UCurveTable*>& CurveData) = 0;
virtual void InitAttributeSetDefaults(UAbilitySystemComponent* AbilitySystemComponent, FName GroupName, int32 Level, bool bInitialInit) const = 0;
virtual void ApplyAttributeDefault(UAbilitySystemComponent* AbilitySystemComponent, FGameplayAttribute& InAttribute, FName GroupName, int32 Level) const = 0;
virtual TArray<float> GetAttributeSetValues(UClass* AttributeSetClass, UProperty* AttributeProperty, FName GroupName) const { return TArray<float>(); }
…but they’re all empty. So let’s take a look at the “example game code usage” line from the instructions comment:
IGameplayAbilitiesModule::Get().GetAbilitySystemGlobals()->GetAttributeSetInitter()->InitAttributeSetDefaults(MyCharacter->AbilitySystemComponent, "Hero1", MyLevel);
Simple enough… let’s see what’s inside UAbilitySystemGlobals::GetAttributeSetInitter():
FAttributeSetInitter* UAbilitySystemGlobals::GetAttributeSetInitter() const
{
check(GlobalAttributeSetInitter.IsValid());
return GlobalAttributeSetInitter.Get();
}
Alright… so where and how is GlobalAttributeSetInitter created? Just above there is:
/** Initialize FAttributeSetInitter. This is virtual so projects can override what class they use */
void UAbilitySystemGlobals::AllocAttributeSetInitter()
{
GlobalAttributeSetInitter = TSharedPtr<FAttributeSetInitter>(new FAttributeSetInitterDiscreteLevels());
}
Well, that’s a jackpot alright. We see that the default implementation uses FAttributeSetInitterDiscreteLevels which is a subclass of the empty FAttributeSetInitter. Unfortunately it does (and is limited to) exactly what the name implies - discrete levels. This is evident from its PreloadAttributeSetData function. I am not going to paste it but you can find it in AttributeSet.cpp. In short, it takes all the rich curves from the UCurveTable array that it’s given and just saves out the level values that are specifically defined. This means bye bye curve data and no infinite levels. But the solution is obvious - create a custom subclass of the FAttributeSetInitter, create an override to UAbilitySystemGlobals::AllocAttributeSetInitter() and provide the aforementioned custom initter subclass.
Of course, this means that we need to subclass UAbilitySystemGlobals too. This bit is a little trickier, as we need to tell the system to use our own UAbilitySystemGlobals subclass. This is done via the DefaultGame.ini config file, namely so:
[/Script/GameplayAbilities.AbilitySystemGlobals]
+AbilitySystemGlobalsClassName=/Script/Bla.BlaAbilitySystemGlobals
Where *Bla *is replaced by your game module name and *BlaAbilitySystemGlobals *by the name of your subclass. So now that we have that it’s trivial to override AllocAttributeSetInitter and provide a custom *FAttributeSetInitter * struct. One important thing to note - the ability system globals need to be manually initialized. This is best done via a game instance, so you will have to subclass UGameInstance and override its Init() function like so:
void UBlaGameInstance::Init()
{
Super::Init();
UAbilitySystemGlobals& ASG = UAbilitySystemGlobals::Get();
if (!ASG.IsAbilitySystemGlobalsInitialized())
{
ASG.InitGlobalData();
}
}
It is important to add that IsAbilitySystemGlobalsInitialized() check since the ASG object persists across PIE runs, so you want to only initialize it once.
So far so good… so let’s take a look at the custom attribute initter.
struct BLA_API FAttributeSetInitterCurveEval : public FAttributeSetInitter
I named it “CurveEval” to clarify that, instead of storing each individual discrete value, it’s actually storing the raw curve data.
Before we can preload any of the data, we need to figure out how to store it. Following the attribute initter instruction comment, we’ll structure the data as follows:
struct FPropertyCurvePair
{
FPropertyCurvePair(UProperty* InProperty, FRichCurve* InCurve)
: Property(InProperty), Curve(InCurve)
{
}
UProperty* Property;
FRichCurve* Curve;
};
This is the lowest building block - a single attribute (UProperty) tied to some raw curve data. This is further contained in a FAttributeDefaultCurveList, which has a TArray and a few utility functions:
struct FAttributeDefaultCurveList
{
struct FPropertyCurvePair
{
FPropertyCurvePair(UProperty* InProperty, FRichCurve* InCurve)
: Property(InProperty), Curve(InCurve)
{
}
UProperty* Property;
FRichCurve* Curve;
};
void AddPair(UProperty* InProperty, FRichCurve* InValue)
{
List.Add(FPropertyCurvePair(InProperty, InValue));
}
TArray<FPropertyCurvePair> List;
};
But knowing the attribute is not enough, since technically two attribute sets can have attributes with identical names. Thus, we need to map the attributes to an attribute set, like this:
struct FAttributeSetDefaultsCurveCollection
{
TMap<TSubclassOf<UAttributeSet>, FAttributeDefaultCurveList> DataMap;
};
Last but not least, all of this is then mapped to a specific “group”, which, as discussed above can be Default, Hero1, Hero2 etc. It is basically the first part of the Hero1.Health.MaxHealth table row identifier:
TMap<FName, FAttributeSetDefaultsCurveCollection> Defaults;
Now that all the supporting data structures are in place (those are all properties of our FAttributeSetInitterCurveEval) we can finally preload some data… almost. We need one more support function. Remember in the initter instruction comment it said that the set (middle tag in the table id string) is a “partial match” of the attribute set name. Well, to check for this partial match, we write the following function:
TSubclassOf<UAttributeSet> BlaFindBestAttributeClass(TArray<TSubclassOf<UAttributeSet> >& ClassList, FString PartialName)
{
for (auto Class : ClassList)
{
if (Class->GetName().Contains(PartialName))
{
return Class;
}
}
return nullptr;
}
Notice that there is no class on the function. This is because it’s written directly in the .cpp file. In AttributeSet.cpp there’s already a FindBestAttributeClass function like this, but since it’s classless it isn’t accessible, we need to duplicate it in our own code.
Now, on to the preloading. Most of the code has been copied from the DiscreteLevels initter but let’s take it step by step, starting with the PreloadAttributeSetData function:
if (!ensure(CurveData.Num() > 0))
{
return;
}
/**
* Get list of AttributeSet classes loaded
*/
TArray<TSubclassOf<UAttributeSet> > ClassList;
for (TObjectIterator<UClass> ClassIt; ClassIt; ++ClassIt)
{
UClass* TestClass = *ClassIt;
if (TestClass->IsChildOf(UAttributeSet::StaticClass()))
{
ClassList.Add(TestClass);
/*#if !(UE_BUILD_SHIPPING || UE_BUILD_TEST)
// This can only work right now on POD attribute sets. If we ever support FStrings or TArrays in AttributeSets
// we will need to update this code to not use memcpy etc.
for (TFieldIterator<UProperty> PropIt(TestClass, EFieldIteratorFlags::IncludeSuper); PropIt; ++PropIt)
{
if (!PropIt->HasAllPropertyFlags(CPF_IsPlainOldData))
{
ABILITY_LOG(Error, TEXT("FAttributeSetInitterDiscreteLevels::PreloadAttributeSetData Unable to Handle AttributeClass %s because it has a non POD property: %s"),
*TestClass->GetName(), *PropIt->GetName());
return;
}
}
#endif*/
}
}
The first ensure is there just to prevent you from passing in empty data. After that, we load ALL UAttributeSet subclasses so that we can check against all their attributes.Note the part that is commented out. It will effectively check if a property is not a float, and just drop everything if it runs into that. It seems that the FGameAttributeData approach is newer and that in the past attribute sets only had floats. This means that the DiscreteLevel initter code WILL NOT WORK if you are using anything but float attributes (which you should, FGameAttributeData is the more robust system), so you’ll have to subclass an initter no matter what.
Once all the attribute set classes are gathered, let’s loop through the curve table array… (I’ve added comments in the code)
//Iterate over the table array...
for (const UCurveTable* CurTable : CurveData)
{
//Iterate over the individual rows in a table...
for (auto It = CurTable->RowMap.CreateConstIterator(); It; ++It)
{
//The entire row name, i.e. Class.Player.MaxHealth
FString RowName = It.Key().ToString();
FString ClassName;
FString SetName;
FString AttributeName;
FString Temp;
//Split the RowName into ClassName (Class) and the put the rest in Temp (Player.MaxHealth)
RowName.Split(TEXT("."), &ClassName, &Temp);
//Split the remainder into the SetName (Player) and the AttributeName (MaxHealth)
Temp.Split(TEXT("."), &SetName, &AttributeName);
//If some of these ended up unpopulated just disregard this row...
if (!ensure(!ClassName.IsEmpty() && !SetName.IsEmpty() && !AttributeName.IsEmpty()))
{
ABILITY_LOG(Verbose, TEXT("FAttributeSetInitterDiscreteLevels::PreloadAttributeSetData Unable to parse row %s in %s"), *RowName, *CurTable->GetName());
continue;
}
// Find the AttributeSet
TSubclassOf<UAttributeSet> Set = BlaFindBestAttributeClass(ClassList, SetName);
if (!Set)
{
// This is ok, we may have rows in here that don't correspond directly to attributes
ABILITY_LOG(Verbose, TEXT("FAttributeSetInitterDiscreteLevels::PreloadAttributeSetData Unable to match AttributeSet from %s (row: %s)"), *SetName, *RowName);
continue;
}
// Find the UProperty
UProperty* Property = FindField<UProperty>(*Set, *AttributeName);
//The IsSupportedProperty() just does: return (Property && (Cast<UNumericProperty>(Property) || FGameplayAttribute::IsGameplayAttributeDataProperty(Property)));
//meaning "is this a number of a FGameplayAttribute?"
if (!IsSupportedProperty(Property))
{
ABILITY_LOG(Verbose, TEXT("FAttributeSetInitterDiscreteLevels::PreloadAttributeSetData Unable to match Attribute from %s (row: %s)"), *AttributeName, *RowName);
continue;
}
FRichCurve* Curve = It.Value();
FName ClassFName = FName(*ClassName);
//Get the rich curve collection corresponding to our ClassName (or create it)
FAttributeSetDefaultsCurveCollection& DefaultCollection = Defaults.FindOrAdd(ClassFName);
//Find the attribute list matching the current UAttributeSet
FAttributeDefaultCurveList* DefaultDataList = DefaultCollection.DataMap.Find(Set);
if (DefaultDataList == nullptr)
{
//If there is no list matching this attribute set... create it.
ABILITY_LOG(Verbose, TEXT("Initializing new default set for %s. PropertySize: %d.. DefaultSize: %d"), *Set->GetName(), Set->GetPropertiesSize(), UAttributeSet::StaticClass()->GetPropertiesSize());
DefaultDataList = &DefaultCollection.DataMap.Add(Set);
}
// Import curve value into default data
//Just add the current property together with its matching curve to the attribute list.
check(DefaultDataList);
DefaultDataList->AddPair(Property, Curve);
}
}
Well, that wasn’t so bad, just some nested iteration. The *InitAttributeSetDefaults *isn’t that complex either. This function exists to initialize all the attributes in a set. Remember earlier that this is the function that we call in our character code, once we supply our ASC with all the attribute sets that it’ll hold. Again, the comments are in the code:
//The profiler counter is commented out here, even though it exists in the Discrete Levels version. It seems that, despite these stats existing in AbilitySystemStats.h,
//they're not exported out of the module so they can't be used in your game module. You'll have to make your own stats if you want to profile this. I haven't gotten around to doing that yet.
//SCOPE_CYCLE_COUNTER(STAT_InitAttributeSetDefaults);
check(AbilitySystemComponent != nullptr);
//This whole block will look if the provided group exists in the preloaded data. If it doesn't it checks for the Default group. If that isn't there either, the whole operation is stopped.
const FAttributeSetDefaultsCurveCollection* Collection = Defaults.Find(GroupName);
if (!Collection)
{
ABILITY_LOG(Warning, TEXT("Unable to find DefaultAttributeSet Group %s. Failing back to Defaults"), *GroupName.ToString());
Collection = Defaults.Find(FName(TEXT("Default")));
if (!Collection)
{
ABILITY_LOG(Error, TEXT("FAttributeSetInitterDiscreteLevels::InitAttributeSetDefaults Default DefaultAttributeSet not found! Skipping Initialization"));
return;
}
}
//Iterate over all the spawned attribute sets of the provided ASC
for (const UAttributeSet* Set : AbilitySystemComponent->SpawnedAttributes)
{
//Check our preloaded data to see if we have any curves for the givenn attribute set...
const FAttributeDefaultCurveList* DefaultDataList = Collection->DataMap.Find(Set->GetClass());
if (DefaultDataList)
{
ABILITY_LOG(Log, TEXT("Initializing Set %s"), *Set->GetName());
//We found data for the given attribute set. Iterate over it and populate the data
for (auto& DataPair : DefaultDataList->List)
{
check(DataPair.Property);
if (Set->ShouldInitProperty(bInitialInit, DataPair.Property))
{
FGameplayAttribute AttributeToModify(DataPair.Property);
AbilitySystemComponent->SetNumericAttributeBase(AttributeToModify, DataPair.Curve->Eval(Level));
}
}
}
}
AbilitySystemComponent->ForceReplication();
The interesting bit here is the ShouldInitProperty() function. The default implementation in AttributeSet.h just returns true, but we want to extend it a bit. Namely… all the attributes of mine that depend on other attributes (*VitalityHealthBonus *etc.) can’t be initialized with the character level. Even moreso since it can happen that *VitalityHealthBonus *is assigned before Vitality. Thus, for the UCoreAttributeSet, the ShouldInitProperty() function looks like this:
bool UCoreAttributeSet::ShouldInitProperty(bool FirstInit, UProperty* PropertyToInit) const
{
if (FirstInit)
{
return PropertyToInit != VitalityHealthBonusAttribute().GetUProperty() &&
PropertyToInit != HealthRegenPerVitalityAttribute().GetUProperty();
}
return true;
}
Finally, our initter has one final function of note: ApplyAttributeDefault. This is identical to the previous function except that it takes a FGameplayAttribute& parameter, i.e. it’s only for a single attribute. So, instead of the ShouldInitProperty() check near the end of the function, there’s a simple if (DataPair.Property == InAttribute.GetUProperty()) check.
Phew… so far so good. We have our initter so now we can finally initialize our attributes… but… initialize them with what? Creating the CSV is simple enough. I’ve attached some sample data [here](CT_GlobalAttributes.zip (959 Bytes)&stc=1&d=1493604812). But how to tell the system to use it?
If we dig around in the Ability System Globals, we’ll see the following:
void UAbilitySystemGlobals::InitAttributeDefaults()
{
bool bLoadedAnyDefaults = false;
// Handle deprecated, single global table name
if (GlobalAttributeSetDefaultsTableName.IsValid())
{
UCurveTable* AttribTable = Cast<UCurveTable>(GlobalAttributeSetDefaultsTableName.TryLoad());
if (AttribTable)
{
GlobalAttributeDefaultsTables.Add(AttribTable);
bLoadedAnyDefaults = true;
}
}
// Handle array of global curve tables for attribute defaults
for (const FStringAssetReference& AttribDefaultTableName : GlobalAttributeSetDefaultsTableNames)
{
if (AttribDefaultTableName.IsValid())
{
UCurveTable* AttribTable = Cast<UCurveTable>(AttribDefaultTableName.TryLoad());
if (AttribTable)
{
GlobalAttributeDefaultsTables.Add(AttribTable);
bLoadedAnyDefaults = true;
}
}
}
if (bLoadedAnyDefaults)
{
// Subscribe for reimports if in the editor
#if WITH_EDITOR
if (GIsEditor && !RegisteredReimportCallback)
{
GEditor->OnObjectReimported().AddUObject(this, &UAbilitySystemGlobals::OnTableReimported);
RegisteredReimportCallback = true;
}
#endif
ReloadAttributeDefaults();
}
}
void UAbilitySystemGlobals::ReloadAttributeDefaults()
{
AllocAttributeSetInitter();
GlobalAttributeSetInitter->PreloadAttributeSetData(GlobalAttributeDefaultsTables);
}
Great! There’s where our initter’s PreloadAttributeSetData is called. It gets provided a curve table array called GlobalAttributeDefaultsTables. This is initialized from a TArray called GlobalAttributeSetDefaultsTableNames. This one’s another config variable and is set in a similar fashion as the class above, in the DefaultGame.ini file:
GlobalAttributeSetDefaultsTableNames=/Game/Path/To/Your/Imported/Curve/Table/CT_GlobalAttributes.CT_GlobalAttributes
If you want multiple tables (since it’s an array property) you’d do:
GlobalAttributeSetDefaultsTableNames=/Game/Path/To/Your/Imported/Curve/Table/CT_GlobalAttributes.CT_GlobalAttributes
+GlobalAttributeSetDefaultsTableNames=/Game/Path/To/Your/Imported/Curve/Table/OtherTable.OtherTable
Important note: It’s NOT CurveTable’/Game/Path/To/Your/Imported/Curve/Table/CT_GlobalAttributes.CT_GlobalAttributes’, all of t he fancy notation normally used in references is omitted and just the path is used.
Finally, with ALL THAT behind us, it’s time to address the attributes-depending-on-other attributes. Thankfully, this ended up being quite simple. As seen earlier, our attribute initter has a function which takes an attribute, a level and assigns a curve value to that attribute for the given level. This is great news, as it means that whenever you change Vitality, I can “level up” my VitalityHealthBonus using Vitality as my level. This is exactly what I do:
void UCoreAttributeSet::PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)
{
UBlaAbilitySystemComponent* Source = Cast<UBlaAbilitySystemComponent>(GetOwningAbilitySystemComponent());
if (VitalityAttribute() == Attribute)
{
float HealthPercent = UBlaMath::Clamp01(Health.GetBaseValue() / GetMaxHealthIncludingVitalityBonus());
FName ClassFName = Source->ClassTag.IsValid() ? UBlaGameplayStatics::GetTagLeafName(Source->ClassTag) : FName(TEXT("Default"));
FGameplayAttribute VHBAttribute = VitalityHealthBonusAttribute();
FGameplayAttribute VitalityHealthRegenAttribute = HealthRegenPerVitalityAttribute();
FAttributeSetInitter* ASI = IGameplayAbilitiesModule::Get().GetAbilitySystemGlobals()->GetAttributeSetInitter();
ASI->ApplyAttributeDefault(Source, VHBAttribute, ClassFName, NewValue);
ASI->ApplyAttributeDefault(Source, VitalityHealthRegenAttribute, ClassFName, NewValue);
Health.SetBaseValue(GetMaxHealthIncludingVitalityBonus() * HealthPercent);
}
}
There is only one new thing here and that is the FName ClassFName = Source->ClassTag.IsValid() ? UBlaGameplayStatics::GetTagLeafName(Source->ClassTag) : FName(TEXT(“Default”)); line. Namely, what I do is, instead of manually typing in those “Hero1”, “Hero2” etc. identifier classes, I have them as a FGameplayTag ClassTag in my custom ASC subclass. Then I just get the leaf portion of the tag (e.g. get Hero1 out of Class.Hero1) via a custom gameplay statics function:
FName UBlaGameplayStatics::GetTagLeafName(const FGameplayTag& Tag)
{
FString TagNameAsString = Tag.ToString();
FString Left;
FString Right;
if (TagNameAsString.Split(FString(TEXT(".")), &Left, &Right, ESearchCase::IgnoreCase, ESearchDir::FromEnd))
{
return FName(*Right);
}
else
{
return Tag.GetTagName();
}
}
Almost done… the dependent attributes are all properly set by our Vitality attribute… But how to store that attribute? To be more precise - if I put a point into Vitality in my game, that means the data in the curve table is no longer correct. I need a way to override it. This is a player-only thing, as my enemies will always get their data out of tables. As you might have noticed by now, I don’t have “player level”, but rather just abilities I put points into.
The first step there is to define which attributes should be saved. For that I have a small struct, like so:
USTRUCT(BlueprintType)
struct BLA_API FSavedAttribute
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Bla")
FGameplayAttribute Attribute;
UPROPERTY(BlueprintReadOnly, Category = "Bla")
float SavedValue;
};
The FGameplayAttribute struct gives you an attribute picker in the details panel, which makes this approach very convenient. My APlayerCharacterBase class now simply has a TArray in which I tell it which attributes should be saved. Furthermore, in my character I got 2 more functions to reading and writing the values of those attributes. I won’t go into how to set up the whole save game side of things as this is already way too long but this is the attribute save / load part:
//Called when saving
bool APlayerCharacterBase::GetSavedAttributesCurrentValues(TArray<FSavedAttribute>& OutAttributes)
{
if (AbilitySystem == nullptr)
{
return false;
}
for (FSavedAttribute& SA : AttributesToSave)
{
if (AbilitySystem->HasAttributeSetForAttribute(SA.Attribute))
{
SA.SavedValue = AbilitySystem->GetNumericAttributeBase(SA.Attribute);
}
}
OutAttributes = AttributesToSave;
return OutAttributes.Num() > 0;
}
//Called when reading loaded data
void APlayerCharacterBase::PopulateSavedAttributes(const TArray<FSavedAttribute>& Attributes)
{
if (AbilitySystem == nullptr)
{
return false;
}
for (const FSavedAttribute& Attr : Attributes)
{
AbilitySystem->SetNumericAttributeBase(Attr.Attribute, Attr.SavedValue);
}
}
The saving part is pretty straightforward… we query our ASC to check if it even has the attribute set for a given attribute, and if it does, just fetch the current value.
The loading part is also quite simple. Just iterate over the given attributes and set them in the ASC. This will call our PreAttributeBaseChange and PreAttributeChange and make sure all the attributes are “leveled up”.
Conclusion
As I said, attribute sets can become deceptively difficult. However, through them we’ve explored the FAttributeSetInitter and half a dozen other things. There is some more work to be done on this. Namely, I need to make sure that the constructor sets Health, Mana and Stamina to -1.f initially, so that I can initialize them properly the first time I set MaxHealth / MaxMana / MaxStamina. But this should be more than enough for now. After all this you should be able to create your own attribute sets, your own attribute set initters to initialize your sets with any data you might need as well as apply all the necessary math to your attributes within the attribute sets themselves. Hope it was useful!
I’ll update this thread once I finish more coherent pieces of the system. If you have any questions let me know.