저번에 UGameplayModMagnitudeCalculation를 활용해 MaxHealth와 MaxMana를 계산했다.
위 사진처럼 게임 시작 즉시 적용되는 기본 GameplayEffect에 Modifier로 직접 추가했다.
MMC_MaxHealth라는 클래스의 GameplayModMagnitudeCalculation으로 MaxHealth를 계산하겠다는 뜻이다.
MaxHealth와 MaxMana는 코드로 구현됐지만, MMC는 블루프린트로도 구현할 수 있다.
먼저 멤버 변수로 FGameplayEffectAttributeCaptureDefinition를 선언하고, CalculateBaseMagnitude_Implementation를 오버라이드해 사용할 수 있다.
// MMC_MaxHealth.h
public:
UMMC_MaxHealth();
virtual float CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const override;
private:
// 어떤 Attribute를, 누구로부터, 언제 가져올지 정의하는 구조체
FGameplayEffectAttributeCaptureDefinition VigorDef;
// MMC_MaxHealth.cpp
UMMC_MaxHealth::UMMC_MaxHealth()
{
// MaxHealth는 Vigor Attribute에 의해 결정되는 스탯
VigorDef.AttributeToCapture = UAuraAttributeSet::GetVigorAttribute();
// 캐릭터 자신의 스탯을 결정하는 중이므로 Source든 Target이든 관계 없음
VigorDef.AttributeSource = EGameplayEffectAttributeCaptureSource::Target;
// true면 적용 즉시 계산 후 이후로는 반영 안 함, false면 관련 Attribute 갱신 시마다 함께 갱신
VigorDef.bSnapshot = false;
// 이 클래스의 계산과 관련된 Attribute로 등록
RelevantAttributesToCapture.Add(VigorDef);
}
float UMMC_MaxHealth::CalculateBaseMagnitude_Implementation(const FGameplayEffectSpec& Spec) const
{
// Effect를 발생시킨 액터와 적용될 액터의 Tags를 가져오기
const FGameplayTagContainer* SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
const FGameplayTagContainer* TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
FAggregatorEvaluateParameters EvaluationParameters;
EvaluationParameters.SourceTags = SourceTags;
EvaluationParameters.TargetTags = TargetTags;
float Vigor = 0.f;
GetCapturedAttributeMagnitude(VigorDef, Spec, EvaluationParameters, Vigor);
Vigor = FMath::Max<float>(Vigor, 0.f);
ICombatInterface* CombatInterface = Cast<ICombatInterface>(Spec.GetContext().GetSourceObject());
const int32 PlayerLevel = CombatInterface->GetPlayerLevel();
return 80.f + 2.5f * Vigor + 10.f * PlayerLevel;
}
AttributeSet의 Vigor와 캐릭터의 Level을 이용해 MaxHealth를 계산하는 로직을 구현했다.
계산 시 다른 Attribute 여러 개를 사용할 수도 있으며, 원한다면 Level뿐 아니라 Source나 Target을 가져와 다른 멤버변수도 활용할 수 있다.
그런데 MMC의 치명적인 단점이 있다.
바로 '단 하나의 Attribute'의 값만 변경할 수 있다는 거다.
따라서 MMC로 여러 Attribute를 변화시키는 GE(특히 버프나 디버프 같은 거)를 구현하기 위해선 GE에서 여러 개의 Modifier를 선언하고 MMC도 여러개 선언해 사용하는 수밖에 없다.
이런 단점을 극복할 수 있는 방법이 있는데, 바로 GameplayEffectExecutionCalculation다.
아래는 Damage를 계산하기 위해 작성된 Exec다.
// ExecCalc_Damage.h
public:
UExecCalc_Damage();
virtual void Execute_Implementation(
const FGameplayEffectCustomExecutionParameters& ExecutionParams,
FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const override;
// ExecCalc_Damage.cpp
// 해당 cpp 파일에서만 사용하는 원시 구조체로, 블루프린트는 물론 다른 클래스에서도 노출되지 않기 때문에 네이밍에 F를 붙이지 않음
struct AuraDamageStatics
{
FGameplayEffectAttributeCaptureDefinition TargetArmorDef;
AuraDamageStatics() :
TargetArmorDef(UAuraAttributeSet::GetArmorAttribute(), EGameplayEffectAttributeCaptureSource::Target, false)
};
static const AuraDamageStatics& DamageStatics()
{
// 함수 내부에 static으로 선언된 변수로, 이 함수에서만 접근 가능한 변수이며 한 번 초기화되면 프로그램 종료 시까지 같은 인스턴스를 반환
static AuraDamageStatics DStatics;
return DStatics;
}
UExecCalc_Damage::UExecCalc_Damage()
{
// 이 클래스의 계산과 관련된 Attribute로 등록
RelevantAttributesToCapture.Add(DamageStatics().TargetArmorDef);
}
void UExecCalc_Damage::Execute_Implementation(
const FGameplayEffectCustomExecutionParameters& ExecutionParams,
FGameplayEffectCustomExecutionOutput& OutExecutionOutput) const
{
// 스킬 시전자와 피격자의 ASC 가져오기
const UAbilitySystemComponent* SourceASC = ExecutionParams.GetSourceAbilitySystemComponent();
const UAbilitySystemComponent* TargetASC = ExecutionParams.GetTargetAbilitySystemComponent();
// 스킬 시전자와 피격자의 AvatarActor 가져오기
AActor* SourceAvatar = SourceASC ? SourceASC->GetAvatarActor() : nullptr;
AActor* TargetAvatar = TargetASC ? TargetASC->GetAvatarActor() : nullptr;
ICombatInterface* SourceCombatInterface = Cast<ICombatInterface>(SourceAvatar);
ICombatInterface* TargetCombatInterface = Cast<ICombatInterface>(TargetAvatar);
// 지금 적용 중인 GE에 대한 정보
const FGameplayEffectSpec& Spec = ExecutionParams.GetOwningSpec();
// 시전자와 피격자의 태그 정보 가져와서 평가 파라미터에 세팅
const FGameplayTagContainer* SourceTags = Spec.CapturedSourceTags.GetAggregatedTags();
const FGameplayTagContainer* TargetTags = Spec.CapturedTargetTags.GetAggregatedTags();
FAggregatorEvaluateParameters EvaluationParameters;
EvaluationParameters.SourceTags = SourceTags;
EvaluationParameters.TargetTags = TargetTags;
// GameplayTag를 통해 Damage 값 가져오기
float Damage = Spec.GetSetByCallerMagnitude(FAuraGameplayTags::Get().Damage);
// DamageStatics 구조체에 정의된 FGameplayEffectAttributeCaptureDefinition들을 통해 Source와 Target의 현재 Attribute 값을 캡쳐
float TargetArmor = 0.f;
ExecutionParams.AttemptCalculateCapturedAttributeMagnitude(DamageStatics().TargetArmorDef, EvaluationParameters, TargetArmor);
TargetArmor = FMath::Max<float>(0.0f, TargetArmor);
// Attribute를 그대로 사용하는 게 아닌, 시전자와 대상자의 레벨 차이에 따라 차등 적용
const int32 LevelGap = FMath::Max<int32>(0, TargetCombatInterface->GetPlayerLevel() - SourceCombatInterface->GetPlayerLevel());
// 어떤 수치로 차등 적용할 것인지 결정하기 위해 Curve Table 및 그 값 가져오기
UCharacterClassInfo* CharacterClassInfo = UAuraAbilitySystemLibrary::GetCharacterClassInfo(SourceAvatar);
const FRealCurve* EffectiveArmorCurve = CharacterClassInfo->DamageCalculationCoefficients->FindCurve(FName("EffectiveArmor"), FString());
const float EffectiveArmorCoefficient = EffectiveArmorCurve->Eval(LevelGap);
Damage *= FMath::Clamp(100 - TargetArmor, 0.f, 100.f) / 100.f;
// IncomingDamage Attribute에 Damage만큼 Additive(더하기) 연산을 적용하라는 Modifier 데이터를 생성
const FGameplayModifierEvaluatedData EvaluatedData(UAuraAttributeSet::GetIncomingDamageAttribute(), EGameplayModOp::Additive, Damage);
// 이번 ExecCalc의 결과로 Modifier를 Output에 추가 (실제 적용은 GAS가 처리하며, 해당 클래스는 계산만 수행)
OutExecutionOutput.AddOutputModifier(EvaluatedData);
}
코드가 너무 길어져서 Armor만 이용하는 코드로 적어놨다.
FGameplayEffectAttributeCaptureDefinition 선언과 초기화를 간편하게 하는 매크로가 있긴 한데, 이걸 사용하려면 선언한 Attribute와 이름이 완벽히 일치해야 하기 때문에 확장성에 문제가 있다.
Target과 Source의 같은 Attribute를 사용하지 못 하기 때문이다.
따라서 매크로를 사용하지 않고 구현했다.
어쨌든 이처럼 Attribute를 가져와 Attribute에 값을 적용하는 것은 MMC와 다를 바가 없다.
하지만 여러 Attribute의 값을 바꿀 수 있다는 결정적인 차이가 있다.
코드상으로 Modifier를 만들어 추가하기 직전에 '이 Attribute의 값에 어떤 방식으로 이 값을 적용하겠다.' 를 적으면 된다.
const FGameplayModifierEvaluatedData EvaluatedData(UAuraAttributeSet::GetIncomingDamageAttribute(), EGameplayModOp::Additive, Damage);
그게 이 구문이다.
그럼 두 클래스의 장단점 및 차이를 표로 적어보겠다.
항목 | MMC (ModMagnitudeCalculation) | Exec (EffectExecutionCalculation) |
---|---|---|
구현 방식 | 블루프린트 및 C++ | C++로만 가능 |
적용 방식 | 하나의 Attribute에만 적용 | 여러 Attribute에 동시 적용 가능 |
계산 시점 | Modifier의 Magnitude 계산 시 | Execution 단계에서 계산 |
반환 값 | 단일 float 값만 반환 | 여러 Modifier 데이터를 생성 가능 |
복잡도 | 비교적 단순한 수식 계산에 적합 | 복잡한 데미지 계산, 치명타, 방어 무시 등 로직 처리에 적합 |
예시 용도 | 기본 공격력 계산, 레벨 기반 스케일링 | 물리 피해, 치명타, 방어력 감소, 여러 스탯 반영 등 |
알아둬야 하는 핵심 공통점은 Replicate되지 않는다는 점이다.
즉 공격 애니메이션, 나이아가라, 사운드 재생 등은 클라 예측으로 진행되어 바로 볼 수 있지만, 데미지 계산 후 진행되는 피격 애니메이션이나 HP 감소 등은 딜레이가 있을 수밖에 없다.