상속으로 인한 구조적 문제를 합성 패턴으로 해결하기 / Is, Has 구분

김지윤·2025년 9월 7일
0

UE5_GAS

목록 보기
22/22

강의에선 UAuraGameplayAbility라는 클래스를 만들고, 이걸 상속받는 AuraDamageAbility를 만들었다. 나는 이게 첫 단추부터 잘못 끼운 구조라고 생각한다. 이로 인해 수많은 코드 중복이 발생할 위기에 놓여서, 이를 리팩토링했다.

왜 코드 중복?

우선, 다이아몬드 상속은 그냥 무조건 발생해선 안 되는 일이다. 그 이유부터 예시를 들어 설명해보겠다.

 A
B C
 D

위와 같은 상속 구조를 갖는 클래스들이 있다고 생각해보자. A에서 가상 함수를 선언, B와 C에서 각각 오버라이드, D에서 오버라이드하지 않으면 어떻게 되겠는가? 컴파일러는 B를 호출하라는 거야 C를 호출하라는거야? 하고 헷갈릴 수밖에 없다. 다이아몬드 상속은 빌드 자체는 가능한 경우가 있더라도 필연적으로 의도치 않은 동작을 유발하게 된다.

그래서 이 얘기를 왜 하는가

DamageAbility를 만들었더니, 다이아몬드 상속이 예상되는 구조가 계속해서 발생했다. 강사는 Debuff를 만들 때도 DamageAbility에 DebuffEffect도 선언해서 데미지 계산 시 디버프도 같이 걸었다. 그럼 나중에 디버프만 거는 스킬을 만들고 싶다면? 데미지를 0으로라도 줘야 한다.

그리고 이번엔 BeamAbility라는 채널링 방식의 스킬을 만들고 있는데, 이것도 DamageAbility를 상속받아 구현 중이다. 이것도 나중에 데미지를 주는 채널링 스킬이 아닌 회복시켜주는 채널링 스킬을 만들고 싶다면 어떻게 되겠는가? 다이아몬드 상속을 피해야 하니까, DamageAbility에서 작성한 코드 그대로 복붙해 가져와야 하는, 코드 중복이 발생하기 마련이다.

그래서 나는 애초에 DamageAbility를 선언한 것 자체가 잘못됐다는 의심이 피어오르기 시작했다.

그럼 뭐, 고쳐야죠.

UPROPERTY(EditDefaultsOnly, Instanced, Category = "Effects")
TArray<TObjectPtr<UAbilityEffectPolicy>> EffectPolicies;

아이디어는 StackableType과 같다. BaseAbility가 UObject기반의 AbilityUsableType을 TArray 형태로 멤버 변수로 참조하며, 할당 시 스택형 어빌리티로 즉시 전환되는 그 방식. Lyra 인벤토리 시스템 분석 후 힌트를 얻어 구현한 방식인데, 여기에도 적용해보기로 했다.

위에 보다시피 BaseAbiltiy는 UAbilityEffectPolicy라는 객체를 배열로 참조하고 있다. 이제 이 Ability에 DamageEffectPolicy를 할당하면 그 즉시 데미지를 주는 Ability로 변환되게 할 거다.

UFUNCTION(BlueprintCallable)
void ApplyAllEffect(AActor* TargetActor);

void UAuraGameplayAbility::ApplyAllEffect(AActor* TargetActor)
{
	for (const auto EffectPolicy : EffectPolicies)
	{
		EffectPolicy->ApplyAllEffect(this, TargetActor);
	}
}

먼저 BaseAbility의 함수로 ApplyAllEffect를 선언했다. TargetActor에게 갖고 있는 모든 GE를 적용시키는 함수가 될 거다. 갖고 있는 AbilityEffectPolicy를 순회하며 함수를 호출한다.

UCLASS(Blueprintable, EditInlineNew, DefaultToInstanced)
class AURA_API UAbilityEffectPolicy : public UObject
{
	GENERATED_BODY()

public:
	virtual void EndAbility() PURE_VIRTUAL(...);
    
	virtual void ApplyAllEffect(UGameplayAbility* OwningAbility, AActor* TargetActor) PURE_VIRTUAL(...)
};

실제로 Effect를 적용할 객체가 될 클래스다. 이제 Debuff는 생략하고 Damage만 작성해보겠다.

#pragma once

#include "CoreMinimal.h"
#include "AbilityEffectPolicy.h"
#include "GameplayEffectTypes.h"
#include "ScalableFloat.h"
#include "DamageEffectPolicy.generated.h"

class UGameplayEffect;

UCLASS()
class AURA_API UDamageEffectPolicy : public UAbilityEffectPolicy
{
	GENERATED_BODY()

public:
	virtual void EndAbility() override;
    
	virtual void ApplyAllEffect(UGameplayAbility* OwningAbility, AActor* TargetActor) override;

	TArray<FGameplayEffectSpecHandle> MakeDamageSpecHandle(const UGameplayAbility* OwningAbility);
	void CauseDamage(const UGameplayAbility* OwningAbility, AActor* TargetActor, const TArray<FGameplayEffectSpecHandle>& DamageSpecs);
	FText GetDamageTexts(int32 InLevel);
	
	FGameplayEffectContextHandle DamageEffectContextHandle;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	float DeathImpulseMagnitude = 500.f;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	float KnockbackChance = 0.f;

	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	float KnockbackForceMagnitude = 100.f;

protected:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Damage")
	TSubclassOf<UGameplayEffect> DamageEffectClass;

	// 데미지 타입과 그 속성 데미지
	UPROPERTY(EditDefaultsOnly, Category = "Damage")
	TMap<FGameplayTag, FScalableFloat> DamageTypes;
};

기존 DamageAbility에서 필요한 변수들과 함수들만 가져왔다. GE를 비롯한 Impulse, Knockback처럼 하나만 필요한 변수도 있고, 데미지 타입은 여러 개 소지할 수 있도록 기존과 마찬가지로 TMap으로 선언되어있다.

void UDamageEffectPolicy::ApplyAllEffect(UGameplayAbility* OwningAbility, AActor* TargetActor)
{
	CauseDamage(OwningAbility, TargetActor, MakeDamageSpecHandle(OwningAbility));
}

TArray<FGameplayEffectSpecHandle> UDamageEffectPolicy::MakeDamageSpecHandle(const UGameplayAbility* OwningAbility)
{
	const UAbilitySystemComponent* ASC = OwningAbility->GetAbilitySystemComponentFromActorInfo();
	if (!ASC)
	{
		return TArray<FGameplayEffectSpecHandle>();
	}
	
	if (!DamageEffectContextHandle.Get())
	{
		// EffectContext를 생성 및 할당합니다.
		// MakeEffectContext 함수는 자동으로 OwnerActor를 Instigator로, AvatarActor를 EffectCauser로 할당합니다.
		DamageEffectContextHandle = ASC->MakeEffectContext();
	}
	
	TArray<FGameplayEffectSpecHandle> DamageSpecs;
	for (TPair<FGameplayTag, FScalableFloat>& Pair : DamageTypes)
	{
		const float ScaledDamage = Pair.Value.GetValueAtLevel(OwningAbility->GetAbilityLevel());
		
		// 할당받은 DamageEffectClass를 기반으로 Projectile이 가질 GameplayEffectSpecHandle을 생성합니다.
		FGameplayEffectSpecHandle DamageSpecHandle = ASC->MakeOutgoingSpec(DamageEffectClass, 1.f, DamageEffectContextHandle);
		
		// Spec 안에 SetByCallerMagnitudes라는 이름의 TMap이 있으며, 거기에 Tag를 키, Damage를 밸류로 값을 추가하는 함수입니다.
		// 이 값은 GetSetByCallerMagnitude로 꺼내올 수 있습니다.
		UAbilitySystemBlueprintLibrary::AssignTagSetByCallerMagnitude(DamageSpecHandle, Pair.Key, ScaledDamage);

		DamageSpecs.Add(DamageSpecHandle);
	}
	
	return DamageSpecs;
}

void UDamageEffectPolicy::CauseDamage(const UGameplayAbility* OwningAbility, AActor* TargetActor, const TArray<FGameplayEffectSpecHandle>& DamageSpecs)
{
	if (DamageEffectContextHandle.IsValid())
	{
		// 대상을 관련 액터에 추가합니다.
		TArray<TWeakObjectPtr<AActor>> TargetActors;
		TargetActors.Add(TargetActor);
		DamageEffectContextHandle.AddActors(TargetActors);

		if (const AActor* AvatarActor = OwningAbility->GetAvatarActorFromActorInfo())
		{
			// 여기선 사망 여부를 알 수 없으므로, DeathImpulse를 일단 세팅합니다.
			UAuraAbilitySystemLibrary::SetDeathImpulse(DamageEffectContextHandle,AvatarActor->GetActorForwardVector() * DeathImpulseMagnitude);

			// 넉백은 확률 계산 후 성공 시 세팅합니다.
			if (FMath::FRandRange(0.f, 100.f) < KnockbackChance)
			{
				UAuraAbilitySystemLibrary::SetKnockbackForce(DamageEffectContextHandle, AvatarActor->GetActorForwardVector() * KnockbackForceMagnitude);
			}
		}
	}

	for (auto& Spec : DamageSpecs)
	{
		if (TargetActor->Implements<UCombatInterface>() && ICombatInterface::Execute_IsDead(TargetActor))
		{
			return;
		}
		
		if (UAbilitySystemComponent* ASC = OwningAbility->GetAbilitySystemComponentFromActorInfo())
		{
			ASC->ApplyGameplayEffectSpecToTarget(*Spec.Data.Get(), UAbilitySystemBlueprintLibrary::GetAbilitySystemComponent(TargetActor));
		}
	}
}

ApplyAllEffect가 호출되면 Spec 생성 및 TargetActor에게 적용하는, 기존과 마찬가지인 로직이다.

FText UDamageEffectPolicy::GetDamageTexts(int32 InLevel)
{
	TArray<FText> FormattedTexts;

	for (const auto& Damage : DamageTypes)
	{
		const FGameplayTag& DamageTag = Damage.Key;
		const float DamageValue = Damage.Value.GetValueAtLevel(InLevel);

		// 태그 네임을 String으로 바꿔 그대로 String Table의 Key로 사용합니다.
		// ToString으로 변환될 때 언더바(_)가 아닌 마침표(.)으로 변환되므로, String Table에서도 마침표로 Key를 작성합니다.. (예시: Damage.Fire)
		FString TextKey = DamageTag.GetTagName().ToString();
		// 최대 소수점 1자리까지 표기합니다.
		FNumberFormattingOptions FormattingOptions;
		FormattingOptions.MinimumFractionalDigits = 0;
		FormattingOptions.MaximumFractionalDigits = 1;
		FText DamageTypeText = FAuraTextManager::GetText(EStringTableTextType::UI, TextKey, FText::AsNumber(DamageValue, &FormattingOptions));
		
		FormattedTexts.Add(DamageTypeText);
	}
	
	return FText::Join(FText::FromString(TEXT("\n")), FormattedTexts);
}

Damage 타입과 그 값을 FText로 가져오는 헬퍼 함수도 마찬가지로 이 곳으로 옮겼다.

그럼 다시 BaseAbility로 돌아가서,

	
	UFUNCTION(BlueprintPure)
	FGameplayEffectContextHandle GetDamageContextHandle() const;
	UFUNCTION(BlueprintPure)
	FGameplayEffectContextHandle GetDebuffContextHandle() const;
    
	UFUNCTION(BlueprintPure)
	FText GetDamageTexts(int32 InLevel);
    
    
FGameplayEffectContextHandle UAuraGameplayAbility::GetDamageContextHandle() const
{
	UAbilityEffectPolicy_Damage* DamageEffectPolicy = GetEffectPoliciesOfClass<UAbilityEffectPolicy_Damage>(EffectPolicies);
	check(DamageEffectPolicy);
	return DamageEffectPolicy->DamageEffectContextHandle;
}

FGameplayEffectContextHandle UAuraGameplayAbility::GetDebuffContextHandle() const
{
	UAbilityEffectPolicy_Debuff* DebuffEffectPolicy = GetEffectPoliciesOfClass<UAbilityEffectPolicy_Debuff>(EffectPolicies);
	check(DebuffEffectPolicy);
	return DebuffEffectPolicy->DebuffEffectContextHandle;
}

FText UAuraGameplayAbility::GetDamageTexts(int32 InLevel) const
{
	if (UAbilityEffectPolicy_Damage* DamagePolicy = GetEffectPoliciesOfClass<UAbilityEffectPolicy_Damage>(EffectPolicies))
	{
		return DamagePolicy->GetDamageTexts(InLevel);
	}
	return FText();
}

블루프린트로 파생된 Ability는 AbilityEffectPolicy에 대해 깊게 알 필요가 없다고 생각했다. 따라서 로직 구현 편의성을 위해 BlueprintPure 함수로 열어두면서도 탐색은 C++에서 수행하도록 했다.

template<typename T>
T* GetEffectPoliciesOfClass(const TArray<TObjectPtr<UAbilityEffectPolicy>>& Policies)
{
	static_assert(TIsDerivedFrom<T, UAbilityEffectPolicy>::IsDerived, "T는 반드시 UAbilityEffectPolicy를 상속받아야 합니다.");

	for (UAbilityEffectPolicy* Policy : Policies)
	{
		if (Policy && Policy->IsA<T>())
		{
			return Cast<T>(Policy);
		}
	}

	return nullptr;
}

보이는 탐색 함수는 템플릿을 활용했다. ProjectileSpell 리팩토링 같은 건 사실상 로직이 거의 동일하므로 생략했다.

그럼 이제..

Firebolt에 위처럼 할당한 뒤 결과를 확인해보겠다.

아주 잘 작동한다. 이제 다이아몬드 상속으로 고민할 일은 없을 것 같다. 강사가 구현하려는 채널링 스킬도 BaseAbility를 상속받는 ChannelingAbility로 선언해서 구현하면 될 것 같다.

이렇게 IsA / HasA를 완벽하게 구분했다. 데미지는 Ability가 갖고 있는 거지, Ability 그 자체가 아니다!

profile
공부한 거 시간 날 때 작성하는 곳

0개의 댓글