Why would you make a custom gameplay effect context
If you followed my damage pipeline post, you passed in FGameplayEffectContext through your ExecCalc. This post covers how to extend it with your own data so you can carry exactly what your game needs.
The default context carries the basics, such as the instigator, causer, or hit result, but nothing specific to your game. Whether it's knowing if a hit was a headshot, which ability triggered the effect, or any other game-specific data you need to pass along, none of that lives on the default context.
How to make a custom gameplay effect context
For the file where you actually store the context, you can just create a regular .h and .cpp. In the header, you should first #pragma once, then #include "GameplayEffectTypes.h" and the .generated.h of your header.
The struct
The struct itself that's storing your custom gameplay effect context is the core body of your header. It's a regular BlueprintType struct inheriting from the base gameplay effect context.
When inheriting this struct, it's mandatory to override the GetScriptStruct function, which just returns the static UStruct.
The second function that must be overridden is the NetSerialize function, which we will use to determine how serialization will be done for the struct, for sending data across the network.
The last function we will override is Duplicate, making sure to return our own context instead of the base. This function allows us to create a copy of the gameplay effect context for any later modifications if needed. In the function, we check if there is a hit result and call AddHitResult if so. This is necessary because the hit result is stored as a shared pointer on the base context, which means a plain copy would leave both contexts pointing to the same hit result in memory, so we explicitly allocate a new one for the copy.
In the same struct body, we can add our custom variables that will be included in the context that can be passed along. In my example, I will utilize a bool for checking if a hit was through a shield and the damage multiplier for shooting through the shield (the same can be done with headshots for example), and also passing along a FGameplayAbilityTargetDataHandle, which I will show in more detail in a future post.
The final struct with all your variables and the overridden functions should look something like this:
USTRUCT(BlueprintType)
struct FComplyGameplayEffectContext : public FGameplayEffectContext
{
GENERATED_BODY()
public:
// Returns the actual struct used for serialization, subclasses must override this
virtual UScriptStruct* GetScriptStruct() const override
{
return StaticStruct();
}
// Custom serialization, subclasses must override this
virtual bool NetSerialize(FArchive& Ar, class UPackageMap* Map, bool& bOutSuccess) override;
virtual FComplyGameplayEffectContext* Duplicate() const override
{
FComplyGameplayEffectContext* NewContext = new FComplyGameplayEffectContext();
*NewContext = *this;
if (GetHitResult())
{
NewContext->AddHitResult(*GetHitResult(), true);
}
return NewContext;
}
public:
UPROPERTY()
bool bHitThroughShield = false;
UPROPERTY()
float ShieldDamageMultiplier = 1.f;
// Used to send the shotgun's trace target data for cue effect purposes
// *Note: this variable is not marked as UPROPERTY(), since it's not a UObject
FGameplayAbilityTargetDataHandle ShotgunTracesTargetData;
};
StructOpsTypeTraits
StructOpsTypeTraits is a template struct which is required to be implemented for the custom gameplay ability context, where we set WithNetSerializer and WithCopy to true. WithNetSerializer tells the engine that this struct has a custom NetSerialize and to use it instead of the default property-based replication. WithCopy tells Unreal's type system that this struct is safely copyable via its copy constructor and assignment operator. Without it, Unreal's reflection system may treat the struct as non-copyable and refuse to copy it by value in certain contexts. You can add this struct in the same header, right below the struct definition.
template<>
struct TStructOpsTypeTraits<FComplyGameplayEffectContext> : TStructOpsTypeTraitsBase2<FComplyGameplayEffectContext>
{
enum
{
WithNetSerializer = true,
WithCopy = true
};
};
NetSerialize
Note: NetSerialize handles serialization of all variables in the struct. The explanation gets technical, but the implementation is largely boilerplate. Feel free to skim to the code if you just need to get it working.
Parameters
NetSerialize is a function that handles serialization of variables in the struct.
The function has 3 parameters. The first parameter is an FArchive, which is a base class for archives used for loading, saving, and garbage collecting in a byte order neutral way, which means it abstracts away endianness differences across platforms. It also overloads the << operator, which works both ways depending on context. If we are saving and we have a bool for example, that bool will be serialized and stored in the archive as a series of bits. If we are loading, that series of bits will be deserialized and stored back into the bool on the right side of the operator.
The second paramater is a UPackageMap, which is a UObject that maps object references to compact integer indices for network communication. Rather than sending full object paths, both client and server maintain this map so only a small index needs to be transmitted, which keeps bandwidth low.
The last parameter is a bool by reference called bOutSuccess, which just returns whether or not serialization was successful.
Function body
*Make sure to include your header in the .cpp file.
The function will first require an unsigned integer variable (bitmask), used for serialization and deserialization by keeping track of which variables on the context are being replicated, with one bit per field. We can give it the variable amount of bits we need, which depends on the amount of variables. In this example, we will use uint16 as there are 7 variables from the base context and 3 additional variables in this context, so we need 10 bits in total.
uint16 RepBits = 0;
Now, we should check on the Ar if we are saving, and if we are, checks are performed on variables in the struct to determine whether or not they should be serialized, and serializing the ones that should be serialized at the end.
if (Ar.IsSaving())
{
if (bReplicateInstigator && Instigator.IsValid()) RepBits |= 1 << 0;
if (bReplicateEffectCauser && EffectCauser.IsValid() ) RepBits |= 1 << 1;
if (AbilityCDO.IsValid()) RepBits |= 1 << 2;
if (bReplicateSourceObject && SourceObject.IsValid()) RepBits |= 1 << 3;
if (Actors.Num() > 0) RepBits |= 1 << 4;
if (HitResult.IsValid()) RepBits |= 1 << 5;
if (bHasWorldOrigin) RepBits |= 1 << 6;
if (bHitThroughShield) RepBits |= 1 << 7;
if (ShieldDamageMultiplier != 1.f) RepBits |= 1 << 8;
if (ShotgunTracesTargetData.IsValid(0)) RepBits |= 1 << 9;
}
Ar.SerializeBits(&RepBits, 10);
Inside the if check, we are setting RepBits equal to itself, (=), then using bitwise OR (|) which compares two integers (before and after shifting) by comparing each bit in each place, and wherever there's two 0s, the result for that spot is 0, and if there's any 1s, the result is 1 and the results for each comparison will be placed at that spot on the new integer. In this case, 1 is being added to RepBits, so it will be added at the rightmost digit (flipping the bit, 0th, if counting from right to left). The shift left (<<) operator decides how much to shift digits to the left, so in this case, the digits won't be shifted because it's shifting by 0, but if they were being shifted, every digit would have to be moved to the left by what's specified, and any digits on the left would be lost, and new 0s will be created on the right. So, at 1 << 1, a new 1 will be created on the right, and we are shifting the bits to the left, so when comparing the bits, we will have 1 on 0th and 1st bit, so there will be 1s on both places. In this function, this is essentially a cheaper way of storing the results of bools. So, inside the if check, each field is tested and either sets its corresponding bit in RepBits to 1 if the condition passes, or leaves it as 0 if it doesn't. The full bitmask at the end of all the checks is a complete picture, as every bit position represents one field, with 1 meaning this field has data and should be serialized, and 0 meaning to skip it.
After all the checks are performed, we call the SerializeBits function on the archive, pass in the bitmask, and the possible number of bits that can be serialized (in this case 10 checks, so 10 bits). On save this writes the bitmask to the archive, and on load it reads it back, so the receiving end knows exactly which variables were sent and need to be deserialized.
After serializing the bits, we are now checking if the 0th digit in RepBits is 1, using the bitwise AND (&). It will only return true if both RepBits and the number being checked have a 1 in that position (in this case the 0th digit, due to << 0, otherwise if it had << 1, we would be checking the 1st digit). If we make it into the if statement, the variable will be serialized or deserialized using the << operator. Object references, hit results, vectors, arrays, and booleans are all fully serialized this way. For fields that weren't sent, we reset them to their default values on load using an else if (Ar.IsLoading()) check, ensuring the receiving end is always in a clean state.
if (RepBits & (1 << 0)) Ar << Instigator;
if (RepBits & (1 << 1)) Ar << EffectCauser;
if (RepBits & (1 << 2)) Ar << AbilityCDO;
if (RepBits & (1 << 3)) Ar << SourceObject;
if (RepBits & (1 << 4)) SafeNetSerializeTArray_Default<31>(Ar, Actors);
if (RepBits & (1 << 5))
{
if (Ar.IsLoading())
{
if (!HitResult.IsValid())
{
HitResult = TSharedPtr<FHitResult>(new FHitResult());
}
}
HitResult->NetSerialize(Ar, Map, bOutSuccess);
}
if (RepBits & (1 << 6))
{
Ar << WorldOrigin;
bHasWorldOrigin = true;
}
else if (Ar.IsLoading())
{
bHasWorldOrigin = false;
}
if (RepBits & (1 << 7))
{
Ar << bHitThroughShield;
}
else if (Ar.IsLoading())
{
bHitThroughShield = false;
}
if (RepBits & (1 << 8))
{
Ar << ShieldDamageMultiplier;
}
else if (Ar.IsLoading())
{
ShieldDamageMultiplier = 1.f;
}
if (RepBits & (1 << 9))
{
ShotgunTracesTargetData.NetSerialize(Ar, Map, bOutSuccess);
}
After all the serialization is done, if we are loading, we call AddInstigator, passing in the Instigator and EffectCauser. The InstigatorAbilitySystemComponent isn't serialized directly over the network, so it needs to be re-derived from the Instigator and EffectCauser once they've been deserialized on the receiving end. We then set bOutSuccess to true and return true.
if (Ar.IsLoading())
{
AddInstigator(Instigator.Get(), EffectCauser.Get());
}
bOutSuccess = true;
return true;
Setting a custom gameplay effect context
There is a default AbilitySystemsGlobals class which specifies some defaults and options to use for the gameplay ability system. If we want to set our own options and defaults, including using our custom gameplay effect context, we need our own AbilitySystemsGlobals class where we set it.
After creating the class, we need to override AllocGameplayEffectContext, which is called whenever a new gameplay effect context is created. In that function, we can return our custom context as an object on the heap using the new keyword. We will also need to include our header where we created our custom context.
#include "AbilitySystem/ComplyAbilitySystemGlobals.h"
#include "AbilitySystem/ComplyAbilityTypes.h"
FGameplayEffectContext* UComplyAbilitySystemGlobals::AllocGameplayEffectContext() const
{
return new FComplyGameplayEffectContext();
}
After this, the custom AbilitySystemGlobals function needs to be set in DefaultGame.ini, so that this overridden function gets called whenever an effect context is created instead of the default one. Add these lines to it, replacing it with your own game's name and class name.
[/Script/GameplayAbilities.AbilitySystemGlobals]
+AbilitySystemGlobalsClassName="/Script/Comply.ComplyAbilitySystemGlobals"
Using a custom gameplay effect context and examples
When using the custom gameplay effect context, static_cast is always used to access it. The cast is safe because AllocGameplayEffectContext guarantees every context allocation returns the custom context type.
The order of operations matters. Data should be packed into the custom context before applying the effect so it's accessible, not after.
Creating and packing a context
In this first code snippet, we are creating a fresh context by first making a regular effect context through the ASC via MakeEffectContext, then creating a variable for our custom type and using static_cast to cast to it. After that, we are creating a variable for target data to pass into the context, which will now be sent along with the context when applying a gameplay effect or just passing it into something else. Anything downstream using the context after we packed this data will be able to access it. In my case, it's being passed into gameplay cue parameters straight away.
FGameplayEffectContextHandle ContextHandle = GetAbilitySystemComponentFromActorInfo()->MakeEffectContext();
FComplyGameplayEffectContext* Context = static_cast<FComplyGameplayEffectContext*>(ContextHandle.Get());
if (Context)
{
FGameplayAbilityTargetDataHandle ShieldTargetData;
for (const FHitResult& Hit : ShieldHits)
{
ShieldTargetData.Add(new FGameplayAbilityTargetData_SingleTargetHit(Hit));
}
Context->ShotgunTracesTargetData = ShieldTargetData;
}
It's also possible to allocate the context directly with new instead of going through the ASC, pack data into it, and immediately pass it to another function as a parameter. In my example, I'm doing this when damage should be dealt (once target data is received). The bPassedThroughShield variable is set by a custom target data class and set in the effect context, and the multiplier float is taken from the CDO.
This calls CauseDamage, which immediately leads into the final part - reading from a custom context.
void URangedWeaponAbilityBase::OnTargetDataReceived(const FGameplayAbilityTargetDataHandle& DataHandle)
{
const FGameplayAbilityActivationInfo ActivationInfo = GetCurrentActivationInfo();
for (const TSharedPtr<FGameplayAbilityTargetData>& Data : DataHandle.Data)
{
if (!Data.IsValid()) continue;
AActor* TargetActor = Data->GetHitResult()->GetActor();
if (TargetActor && HasAuthority(&ActivationInfo))
{
// Context created and immediately packed with data
FComplyGameplayEffectContext* Context = new FComplyGameplayEffectContext();
Context->bHitThroughShield = HitscanTargetDataTask->bPassedThroughShield;
Context->ShieldDamageMultiplier = ShieldShotDamageMultiplier;
float FinalDamage = Damage.GetValueAtLevel(GetAbilityLevel());
// ... damage scaling ...
CauseDamage(TargetActor, FinalDamage, Context);
}
}
}
Whether or not you go through the ASC depends on what you need the context for. Using new means the context isn't tied to a handle, and that makes it convenient to pass around as a raw pointer between functions before it actually gets attached to a spec, as long as it's applied immediately after being passed in. Going through the ASC with MakeEffectContext gives a context handle which is a smart pointer wrapper, which is better when you need the context to stay alive and be managed for longer, like when passing it into gameplay cue parameters or other cases where it will be accessed later. So in short: use new when you're immediately handing off the effect to a function as a raw pointer, and use MakeEffectContext when you need to keep it alive.
Reading from the context
CauseDamage receives the context that was packed in the previous step. An effect spec handle for the damage gameplay effect is created, and we then create a variable for our custom context and cast to it. We then set the variables on that damage effect context to be the variables that were packed into the context we passed in when calling this function. From this point, anything downstream such as an ExecCalc or a gameplay cue can cast to the context and read those values.
You may be wondering why we are not just using the context we passed in directly, but we cannot do that because MakeOutgoingGameplayEffectSpec creates a new spec internally and allocates a fresh context with default values as part of that process. Since we can't just swap that context out, we copy the data from the context we passed in onto it manually, otherwise the spec would just use default values when applied.
void UDamageAbilityBase::CauseDamage(AActor* TargetActor, float ExplicitDamage, FComplyGameplayEffectContext* Context) const
{
FGameplayEffectSpecHandle DamageSpecHandle = MakeOutgoingGameplayEffectSpec(DamageEffectClass, 1.f);
UAbilitySystemBlueprintLibrary::AssignTagSetByCallerMagnitude(DamageSpecHandle, DamageType, ExplicitDamage);
FComplyGameplayEffectContext* EffectContext = static_cast<FComplyGameplayEffectContext*>(
DamageSpecHandle.Data->GetContext().Get());
if (EffectContext && Context)
{
EffectContext->bHitThroughShield = Context->bHitThroughShield;
EffectContext->ShieldDamageMultiplier = Context->ShieldDamageMultiplier;
}
GetAbilitySystemComponentFromActorInfo()->ApplyGameplayEffectSpecToTarget(
*DamageSpecHandle.Data.Get(),
UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(TargetActor));
}
Conclusion
Custom gameplay effect contexts are a powerful and scalable way to carry specific information that the default context doesn't support. Once it's set up, extending it with new fields is easy. You just add the variable, add a bit to the bitmask, and serialize it. For any GAS project beyond the basics, a custom context is worth having from the start.
If you have any questions or feedback, feel free to contact me on LinkedIn, or email me: petric.marko04@gmail.com
Top comments (0)