
해당 글은 https://github.com/tranek/GASDocumentation 의 설명을 한글로 번역 후 첨언 및 요약 해 나 보려고 내 입맛대로 작성한 글이다.
해당 글은 GAS에서 사용하는 Attribute의 Set을 정의하고 사용하는 것에 대한 팁이다.
Attribute Set이라는 것은 GAS에 존재하는 Attributes들을 정의하고 변경 사항에 대해 보유하고 관리하기 위해 존재한다. 개발할 때 UAttributeSet을 기반으로(C++ base에서는 Subclass로 활용) 커스텀한 AttributeSet을 정의하고 OwnerActor의 생성자에서 자동으로 ASC와 함께 정의하는 방식으로 사용한다. 생성자를 통한 작업이기에 반드시 C++에서 사용해야한다.
ASC 하나에서는 Attribute Set이 한개에서 부터 여러개까지 보유가 가능하다. 참고로 AttributeSet는 메모리를 우리가 신경쓸만큼 사용하는 것은 아니기에 그저 개발자가 원하는 만큼 집어넣으면 된다. 즉 편한대로 group화를 해 사용해도 무방하다라는 뜻이 된다.
하나의 거대한 모놀리식한 Attribute Set(하나에 모든 것이 저장되어 있는 AttributeSet을 말함)이 게임 내의 모든 액터에 공유되고 필요에 따라 특정 속성을 무시하는 것 또한 허용된다.
여러개의 속성(Attribute)를 하나 혹은 여러개의 AttributeSet에 각각 속성 타입에 따라 grouping을 하는 경우도 존재할 것이다. 이런 경우에는 필요한 속성들만 Actor에 추가가 가능한데, 체력과 마나 관련 속성이 있다고 가정할 때 MOBA 장르에서 영웅(플레이어)는 마나가 필요하지만, 미니언(AI 졸개)의 경우는 마나가 필요하지 않기 때문에 영웅에게만 AttributeSet에 마나를 추가한 Set을 적용하고 미니언에게는 마나가 없는 AttributeSet을 적용해 관리하는 방법 또한 존재한다는 것이다.
또한 상속해서 사용하는 방법도 가능하다. 어떻게 보면 객체지향에 가장 맞는 방식이기도 한데, 마나를 사용하는 영웅과 기력을 사용하는 영웅, 그리고 또 스킬을 사용하기에 다른 화폐를 사용하는 경우가 나뉘어 질 텐데, 공통점은 모두 다 체력과 공격력이라는 스텟이 필요하다는 것이다. 체력과 공격력을 BaseAttributeSet에, 마나가 필요한 Set은 ManaAttributeSet에, 기력은 StaminaAttributeSet등 으로 BaseAttributeSet이 부모가 되어 자식 클래스에서는 필요한 Attribute를 추가하는 방식으로 하는 방법 또한 존재한다.
다만 주의해야할 부분은 AttributeSet은 하나의 ASC에서 같은 클래스의 AttributeSet을 여러개 가질 수는 없다. 만약 여러개의 클래스로 선언해서 사용한다면 엔진에서는 런타임 때 임의의 하나를 선택해서 사용할 것이다.
위에서 설명한 스킬을 사용하기 위한 비용 화폐가 영웅 별로 다른 경우 그리고 고유한 비용이 필요한 경우도 존재한다. 이런 경우에 대해서는 여러가지 방법이 있지만 가장 권장하는 방법은 하나의 AttributeSet에 고유한 값을 미리 선언해두는 방법이다. 더 예를 들자면 Pawn별로 데미지를 다르게 들어가는 방식이 존재한다면 Health라는 Attribute가 기본으로 존재하지만 상황에 따라 TestCompHealth0, TestCompHealth1 같은 방식으로 하나의 AttributeSet에 필요한 내용을 추가해버리는 것 이다.
이런 것들에 대해서 부담을 어느정도 느낄수는 있지만 실제로 이렇게 한다고 메모리에 문제가 생길정도는 아니다. 그렇기 때문에 너무 부담을 가지고 AttributeSet을 바라보면서 관리할 필요는 없다는 것이 중요한거다. 그냥 Actor에서 많은 속성이 필요하거나, 혹은 이런 개별 속성이 필요한 Actor들이 많다면 굳이 Attribute를 쓰지말고 별도의 float를 사용해도 무방하다.
결론은 이런 고유 속성을 정의하는 설계 방식에 너무 부담을 가지지 말자가 핵심이다.
Attribute Set을 런타임에서 추가하거나 제거하는 것은 가능하다. 하지만 AttributeSet을 제거하는 것은 위험할 수 있다. 예를 들어 기존에 사용하던 AttributeSet을 클라이언트에서 AttributeSet이 제거된 상태인데, 서버에서는 들고있고 이것을 클라이언트에서 Replicated를 하려고 시도하는 경우 올바른 위치를 찾지 못해 Crash가 발생할 가능성이 존재하기에 제거만큼은 하지 않는 것을 권장한다. (사실 메모리 관점에서도 문제가 되는 것은 아니기에 차라리 비슷하지만 다른 AttributeSet들이 필요한 것이라면 별도의 AttributeSet을 만들어서 사용하는 것이 더 나아보인다)
// 특정 Actor의 ASC에 AttributeSet을 추가하는 방법
AbilitySystemComponent->GetSpawnedAttributes_Mutable().AddUnique(WeaponAttributeSetPointer);
AbilitySystemComponent->ForceReplication();
// 특정 Actor의 ASC에 AttributeSet을 제거하는 방법 (권장하지 않음)
AbilitySystemComponent->GetSpawnedAttributes_Mutable().Remove(WeaponAttributeSetPointer);
AbilitySystemComponent->ForceReplication();
위에서 AttributeSet을 설계할 때 너무 많거나 고유한 Attribute가 필요한 경우에 대해서는 굳이 새로 Attribute말고 별도의 float으로 추가하는 것도 좋다라고 한 적이 있다. 지금 설명할 부분이 그 내용을 조금 더 구체적으로 설명하게 된다.
대표적으로 장비 아이템을 예시로 들 수 있다. 장비 아이템에 속성(공격력, 내구도, 탄약 수(총일 경우))을 정의할 때 GAS를 이용하는 방법도 있지만, 어떠한 접근 방식을 사용하든 그 속성 값은 아이템에 저장되는 것을 기억하는게 좋다. 즉 이 아이템이 활성화 되어있는 동안 여러 플레이어 or actor에서 사용해도 동일한 값을 저장하고 있는 것이 중요하다.
GAS에서도 아이템 float을 권장하는 이유는 굳이 간단한 속성들에 대해서도 GAS를 사용하기 보다 직접 관리해도 무방하다는 것을 권장하는 것 처럼 보인다. 복잡한 시스템이 되면 될 수록 AttributeSet을 사용하는게 좋다라고 말하는 것 같으니 간단한 아이템 속성은 그냥 ASC에서 제거해두는게 편할 것이라고 얘기하는 것 같으니 참고하면 좋을 것 같다(뇌피셜)
참고로 포트나이트의 경우는 총알은 그냥 인스턴스 내부에서 float 값을 선언해 제어하곤 한다. 총의 경우 최대 탄창의 크기(총알 보유 수), 현재 총알 수, 예비 총알 수 등의 정보를 네트워크 조건부 property replecation에서 (COND_OwnerOnly)라는 옵션을 이용해 직접 float 값들을 gun instance에 통신시킨다. 예비 총알 수의 경우 모든 무기가 공유하는 경우 또한 존재한다.(배그를 예시로 들어보면 5mm 탄의 경우 사용이 가능한 총의 종류 또한 여러개가 있어 모든 무기끼리 그 예비 탄창 수를 공유한다) 그런 경우에는 float 변수가 아닌 플레이어 기준으로 총 예비 총알 수 Attribute를 플레이어가 사용하는 AttributeSet에 옮겨주는 것이 좋다.
그리고 공유되는 탄창의 수는 Attribute에 있지만 현재 탄창에 있는 총알 수는 Attribute를 사용하지는 않기 때문에 GAS의 Ability나 Effect를 통해서 재장전을 해야하는 경우(예시임) UGameplayAbility에서 몇가지 함수를 override해서 gun instance 안에서 현재 탄약 수인 float 변수의 비용을 확인하고 적용을 해야할 필요가 있는데 gun instance를 GameplayAbilitySpec(추후 다룰 내용)안의 SourceObject에 주입시켜 GameplayAbility에서 해당 인스턴스에 접근해서 처리시키는 것이 가능해진다.
또한 GameplayAbility로 총알이 사용되고 있는데 replicating이 되면서 기존 탄창 수가 이전 값으로 롤백되는 경우를 방지하기 위해서는 GameplayTag랑 PreReplication() 2가지를 이용해서 처리가 가능하다. 만약 특정 GameplayTag가 내장되어 있는 경우 PreReplication() 함수를 선언함으로써 Replicating이 되는 것을 방지하고 로컬 안에서만 해당 변수를 사용하는 방식으로 처리할 수 있다.
void AGSWeapon::PreReplication(IRepChangedPropertyTracker& ChangedPropertyTracker)
{
Super::PreReplication(ChangedPropertyTracker);
DOREPLIFETIME_ACTIVE_OVERRIDE(AGSWeapon, PrimaryClipAmmo, (IsValid(AbilitySystemComponent) && !AbilitySystemComponent->HasMatchingGameplayTag(WeaponIsFiringTag)));
DOREPLIFETIME_ACTIVE_OVERRIDE(AGSWeapon, SecondaryClipAmmo, (IsValid(AbilitySystemComponent) && !AbilitySystemComponent->HasMatchingGameplayTag(WeaponIsFiringTag)));
}
PreReplication를 이용했을 때의 장단점
장점:
1. AttributeSet 사용 제한을 피하는 것이 가능하다. (아래에 더 상세한 내용 첨부)
단점:
1. 기존 GameplayEffect 로직 사용이 제한된다. (AttributeSet 자체가 Replicated가 계속 이뤄지고 있기 때문)
2. 인스턴스 내부의 float 변수에 대한 확인과 재적용이 필요한 경우 UGameplayAbility 에서 주요 함수들을 재정의하는 작업이 필요해진다.
플레이어의 인벤토리에 들어오는 아이템이 플레이어의 ASC에 아이템의 AttributeSet을 추가해 사용하는 것은 가능은 하지만 많은 제약이 존재한다.
해당 글의 예제 프로젝트(GASShooter)에서는 초기 버전에서 무기 탄약에 대해 이 방식을 적용했는데, 무기에 최대 탄창의 크기, 탄창안에 현재 탄약 수, 예비 탄약 수 등과 같은 속성들을 무기 클래스의 AttributeSet에 저장하고 무기가 예비 탄약을 다른 무기와 공유하는 경우 예비 탄약의 속성을 캐릭터 AttributeSet의 공유 탄약으로 이동시키는 작업을 수행한다.
이제 서버에서 플레이어 인벤토리에 무기가 추가된 것이 확인된다면 무기의 AttributeSet은 플레이어의 ASC:SpawnedAttributes에 추가되고, 서버는 이 정보를 클라이언트로 replicated하게 된다. 그리고 무기가 플레이어 인벤토리에서 제거되면 ASC:SpawnAttribues에서 그 AttributeSet이 제거되게 된다.
즉 간단하게 설명하자면 아이템의 AttributeSet과 플레이어의 AttributeSet은 별도로 관리되지만 인벤토리에 들어오게 되면 플레이어의 ASC:SpawnedAttributes에 아이템의 AttributeSet이 들어오고 주기적으로 서버 통신을 통해 replicated되는 것이 핵심이라 할 수 있다.
그렇기에 AttributeSet이 만약 OwnerActor(ex. 현재 무기 액터) 이외에 다른 곳에 존재할 때 (기본적으로 플레이어가 아니라면 OwnerActor와 AvatarActor가 같기에 OwnerActor에서 관리하겠지만 혹시 다른 곳에서 처리하는 것에 대해 말하는 것으로 보임) 처음 런타임에 AttributeSet에서 컴파일 오류가 발생할 수 있기에 생성자(CDO)말고 런타임 시작 때 BeginPlay()에서 AttributeSet을 생성하고 그 액터에 IAbilitySystemInterface를 구현해주면 된다.
void AGSWeapon::BeginPlay()
{
if (!AttributeSet)
{
AttributeSet = NewObject<UGSWeaponAttributeSet>(this);
}
}
장점
1. 무기에도 GameplayAbility 및 GameplayEffect 사용 및 활용이 가능함.
2. 간단한 세팅으로도 간단한 속성이 있는 아이템을 편하게 만들수 있음
제한 사항
1. 모든 무기 Actor마다 새로운 AttributeSet class를 생성해줘야 한다. ASC는 기능적으로 클래스 하나당 AttributeSet 인스턴스를 하나만 가질수 있기에 ASC::SpawnedAttributes 배열에서 첫번째 인스턴스만 반환해서 사용한다. 그렇기에 동일한 AttributeSet을 가진 class는 무시된다라는 것을 알아둬야한다. (즉 동일한 AttributeSet을 여러개 사용할 수 없기에 무기 Actor별로 새로운 AttributeSet을 만들어줘야한다)
2. 위의 이유와 동일하게 플레이어 인벤토리에서는 동일한 유형의 무기가 1개만 보유할 수 있다는 것을 알아야한다. AttributeSet의 클래스와 인스턴스가 1:1 관계여야한다.
3. AttributeSet을 제거하는 작업을 수행하는 것은 더 위험하다. 굳이 아이템이 아니더라도 제거 작업은 위험하지만 예제 게임 케이스를 보면 GASShooter에서 플레이어가 로켓으로 자살하게 되면 플레이어 인벤토리에서 로켓 발사기를 제거하는 작업을 수행했는데, 서버에서 로켓 발사기의 탄약 속성이 변경된 것에 대해 Replicated를 수행했을 때 클라이언트의 ASC에 해당 Attribute가 없어 게임이 충돌하는 문제가 발생했다. (이런 문제 때문에 탄약 같은 케이스는 PreReplication로 서버에서 Replicated하는 것을 방지해주는 것이 좋다)
아이템 각각마다 ASC 선언 이후 그 안에서 관리하는 것은 어찌보면 극단적인 접근 방법인 것 같기도 하다. 이런 방식을 예제 프로젝트에서 구현해보지도 않았지만 실무에서도 사용하지 않는 방식이고 사용하려면 수많은 개발 공수가 들 것으로 보인다.
Item 자체에 ASC를 선언하고 관리하게 된다면 여러가지 문제가 있는데, 첫번째는 모든 OwnerActor에 IAbilitySystemInterface와 IGameplayTagAssetInterface 선언이 필요해진다. 물론 Tag의 경우는 가능하다. ASC에 있는 태그 집계만 하면 되니까. 다만 AbilitySystem 자체를 관리하는 것에 대해서는 여러 액터 중 하나 가 GameplayEffect를 받고싶어할 때 GameplayEffect는 어떤 Actor에서 받고 전체에 전달하거나 관리할 지 에 대한 문제 또한 발생할 수 있다. (즉 유지보수 측면에서도 이러한 문제가 생길수 있다라는 것)
그래도 Pawn과 무기 자체에 각각의 ASC두는 것 자체는 의미가 있을 수 있다. 이 Actor가 캐릭터용 Pawn인지 무기 Actor인지의 태그를 구분함으로써 부여된 태그가 소유자에게만 허가 되는 등의 의미 또한 생길 수 있게 된다. (Attribute와 GameplayEffect는 독립적인 관계이지만 무기 소유자가 무기의 보유한 태그들을 집계하는 것에는 의미가 있음을 설명하는 것 같다.) 그래도 동일한 소유자를 가진 여러 ASC는 위험하기에 조심하는 것이 좋다.
장점
1. 기존 GameplayAbility와 GameplayEffect를 아이템 내부에서 사용이 가능하다.
2. AttributeSet class의 재사용성이 증가한다. (각 무기당 하나씩 ASC를 부여하면 되기 때문)
단점
1. 개발 비용 산정이 어려울 것
2. 애초부터 그러한 방식이 가능한지에 대한 의문이 있음
Attribute들은 AttributeSet에 오직 C++로만 정의하는 것이 가능하다. 선언 할 때 한가지 팁이 있다면 보통 Attribute를 가져올때는 Getter, Setter를 사용해서 선언하거나 정의하는 경우가 많은데 매번마다 함수를 선언해줄 수는 없으니 GAS에서는 Java의 라이브러리 Lombok처럼 변수에 대해서 Getter, Setter를 별도의 함수 작성없이 @Getter, @Setter 어노테이션을 붙이는 방식으로 선언이 가능한데, C++에서는 더 간단하게 하기 위해서 define을 사용해 매크로 하나를 만들어줄 수 있다.
#define ATTRIBUTE_ACCESSORS(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_PROPERTY_GETTER(ClassName, PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_GETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_SETTER(PropertyName) \
GAMEPLAYATTRIBUTE_VALUE_INITTER(PropertyName)
GAMEPLAYATTRIBUTE_VALUE~~ 매크로를 정의함에도 실제로 사용할 때 에러가 발생할 수 있다. #include "AbilitySystemComponent.h"를 선언하지 않아서 생기는 문제로 AbilitySystemComponent.h안에 관련된 매크로들이 있어서 생기는 문제이니 꼭 선언을 하고 define 선언을 해줘야한다.이후에 아래 처럼 사용하면 된다.
UPROPERTY(BlueprintReadOnly, Category = "Health", ReplicatedUsing = OnRep_Health)
FGameplayAttributeData Health;
ATTRIBUTE_ACCESSORS(AttributeSetCustomBaseClass, Health)
또한 Replicated될 때에 대한 반응(ReplicatedUsing를 위함)을 위한 함수 또한 선언해야 한다.
UFUNCTION()
virtual void OnRep_Health(const FGameplayAttributeData& OldHealth);
이후 C++에서는 OnReplicated시에 대해 GAS에서 Replicated시에 대한 반응을 네트워크를 통해서 다른 플레이어와 동기화하고 변경 후 특정 로직을 실행시키게 하기 위해서 등을 위해 GAMEPLAYATTRIBUTE_REPNOTIFY 매크로를 등록시켜줘야한다
void UGDAttributeSetBase::OnRep_Health(const FGameplayAttributeData& OldHealth)
{
GAMEPLAYATTRIBUTE_REPNOTIFY(UGDAttributeSetBase, Health, OldHealth);
}
그리고 마지막으로 서버를 통해 클라이언트에 정보를 전달하기 위한 작업을 수행시켜주면 된다.
void UGDAttributeSetBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME_CONDITION_NOTIFY(UGDAttributeSetBase, Health, COND_None, REPNOTIFY_Always);
}
DOREPLIFETIME_CONDITION_NOTIFY 마지막 property에 붙은 REPNOTIFY_Always이라는 옵션은 OnRep 목적으로 설정한 함수가 항상 호출되도록 설정되는 플래그로 일반적으로는 로컬과 서버 간의 받은 결과 값이 다른 경우에만 호출되게 설정한다. 그렇기에 기본적으로 로컬 값과 서버에서 복제되는 값이 동일하다면 호출되지 않게 된다.
속성이 Meta 속성처럼 Replicated가 안되는 케이스가 된다면 OnRep와 GetLifetimeReplicatedProps 단계를 건너뛸 수 있다.
속성 값(BaseValue, CurrentValue 둘 다)을 초기화 하는 방법에는 여러가지가 있는데, EpicGames 측에서는 Instance GameplayEffect를 통해 초기화하기를 권장한다. 또한 이러한 방법은 SampleProject에서도 같이 사용한다.
위에 있는 ATTRIBUTE_ACCESSORS 매크로를 선언할 때 GAMEPLAYATTRIBUTE_VALUE_INITTER도 같이 선언한 것을 인지하고 있을 것이다. 이 매크로는 초기화 값을 선언해주는 함수도 같이 내장되어 있기에 C++에서 아래와 같은 방법으로 선언해주면 된다.
AttributeSet->InitHealth(300.0f);
참고사항
4.24 버전 전에FAttributeSetInitterDiscreteLevels라는 메소드와FGameplayAttributeData는 함께 작동하지 않는 문제가 있었다. 속성 값이 raw float일 때 발생하는 이슈였으며It was created when Attributes were raw floats and will complain about FGameplayAttributeData not being Plain Old Data라는 오류 메세지가 노출된다. 4.24 이후 해당 문제는 해결되었으니 참고하면 좋다.
PreAttributeChange(const FGameplayAttribute& Attribute, float& NewValue)라는 함수는 AttributeSet class에서 속성의 CurrentValue가 변경되기 전에 호출되는 주요 함수로 NewValue라는 값을 통해서 실제 값이 변경되기 이전에 속성 값이 변경될 때 방어력을 적용하거나 등의 효과를 줄 수 있는 방법이 될 수 있다.
해당 글의 샘플 프로젝트에서는 이동 속도 수정자를 아래와 같은 방법으로 해결하는 것을 선호한다.
// GetMoveSpeedAttribute()는 당연히 위의 매크로를 이용해 가져온 Get 함수다.
if (Attribute == GetMoveSpeedAttribute())
{
// NewValue의 기존 parameter의 값을 150 ~ 1000사이에서만 고정되게 변경한다.
NewValue = FMath::Clamp<float>(NewValue, 150, 1000);
}
PreAttributeChange는 속성 값이 변경되기 전에 호출되는데 이 변경 전의 기준은 GameplayEffect도 당연하지만 GAMEPLAYATTRIBUTE_VALUE_SETTER 매크로로 선언한 Set[속성값]() 또한 사용할 때도 PreAttributeChange가 작동하는 것을 기억해야한다. PreAttributechange 함수의 주 목적은 NewValue를 조절해 속성 값을 설정해주는 역할이다.
PreAttributeChange는 ASC Modifier 자체를 변환시키는 것이 아니라. Modifier를 조회할 때 반환되는 값만 변경하는 것을 알아야한다.
GameplayEffectExecutionCalculations 혹은 ModifierMagnitudeCalculations와 같이 모든 Modifier에서 CurrentValue를 다시 계산하는 역할을 수행하는 곳에서 해당되는 로직은 아니기 때문에 PreAttributeChange에 대한 로직이 동일하게 필요하다면 다시 구현해줘야한다.
UAbilitySystemComponent::GetGameplayAttributeValueChangeDelegate를 이용해 별도의 Delegate를 선언하고 사용하기를 권장한다.Instant GameplayEffect가 적용된 이후에 호출되는 함수로 속성 값에 대한 추가적인 변경이나 계산(ex. 피해량 계산, 시각적 애니메이션 효과, 경험치 및 골드 획득 등)을 수행하는 역할을 한다.
샘플 프로젝트에서는 해당 함수를 주로 피격시에 활용하는데 Shield 속성 값이 존재한다면 기존 데미지에서 Shield Attribute 속성 값 만큼 제외하고 남은 피해를 Health 속성 값에 적용하는 방식으로 사용한다. 그리고 피격에 대한 애니메이션, 데미지 숫자 표시, 경험치 및 골드 부여 역할 또한 수행한다.
참고로 PostGameplayEffectExecute가 호출되는 시점에서는 이미 속성 변경이 완료되었지만 클라이언트로의 복제는 이루어지지 않았기에 만약 해당 함수에서 Clamp를 진행하게 된다고 한들 클라이언트에서는 clamp가 실행된 이후의 값이 전달된다.
OnAttributeAggregatorCreated(const FGameplayAttribute& Attribute, FAggregator NewAggregator)* 함수의 역할은 AttributeSet 내의 특정 속성에 대한 Aggregator(추후 더 알아볼 내용. 직역하면 집계기)가 생성될 때 호출되는 함수다. 해당 함수를 통해서 FAggregatorEvaluateMetaData 정보 수정이 가능하다.
해당 내용은 추후 Aggregator 에 대해 더 파악한 후 작성할 예정