게시글 제목이 딸깍x5에서 다시 '딸깍'으로 바뀌었다. 그렇다! 딸깍으로 충전형 Ability를 구현하는 데에 성공했으며, 처음 시도했던 방식처럼 메모리 걱정도 별로 없다. 차근차근 설명해보겠다.
합성 패턴
합성 패턴이란 독립적인 객체를 조합해 원하는 기능을 만드는 걸 말한다. 사실 상속도 강력한 기능이지만, 이것만으로는 충분하지 않을 때가 많다. 특히 내가 시도한 '충전형 Abiltiy'를 구현하는 데에 있어서 상속만으로 구현을 시도했다면 큰 문제가 생겼을 거다.
예를 들어 ProjectileAbility가 있고, 이 Ability를 상속받아 구현되는 FireBall과 ThunderBolt가 있다고 하자. 두 클래스는 모두 투사체를 갖고 있으며, 투사체를 발사할 때 GameplayEffectHandle을 그 투사체에 할당해서 날린다. 투사체와 Overlap된 대상에게 그 GameplayEffectHandle을 적용하는 방식으로 구현되어있다. 그런데 만약 FireBall만 충전형 Ability로 구현해 주세요.
라는 기획의 요구가 들어왔다면?
이걸 상속으로 구현하려면 관리가 급격히 어려워진다. 이건 상속으로 구현해선 안 되는 내용이다. 만약 고생고생해서 상속을 이용해 FireBall을 충전형 Ability로 구현했다고 치자. 재미가 없어서 다시 원래대로 돌려야 한다면? 혹은 ThunderBolt도 충전형 Ability로 바꿔달라는 요구가 들어온다면?
나는 그 어디에도 대응하기 어렵다. 코드 재사용이 어려운 상태라서 그렇다. 자유롭게 발동 방식을 전환하는 유연함이 부족하고, 이는 유지보수성과 확장성이 떨어지는 결과로 이어진다.
그래서 이 때 쓰이는 게 합성 패턴이다. Lyra 샘플 프로젝트에도 이 패턴이 사용되었는데, Inventory 시스템을 보면 ItemDefinition을 구성하는 Fragment라는 클래스가 있다. 다른 클래스와의 어댑터 역할을 하는 클래스인데, ItemDefinition 안에 Fragment 배열로 선언되어 사용된다. Fragment는 여러 클래스로 다시 파생된다.
이 Fragment들을 블루프린트 클래스로 선언, 변수들을 초기화한 뒤 원하는 객체만 골라 ItemDefinition을 상속받은 블루프린트 클래스의 Fragment 배열에 할당해준다. 그 뒤 필요할 때마다 Fragment를 꺼내와 사용한다. 이게 합성 패턴이다. PickupIcon을 할당해주면 월드 내에 배치되어 주울 수 있게 되고, EquippableItem을 할당해주면 장착할 수 있게 된다. 그런 기능을 원하지 않는다면 할당해주지 않으면 된다. 이게 합성 패턴의 장점이다. 정말 어마어마한 확장성을 갖고 있으면서도 메모리 낭비는 거의 되지 않는다. 애초에 할당해주지 않으면 메모리가 없으니까.
그래서 이 시스템에 영감을 받아 나도 Ability를 합성 패턴으로 구현했다. 아래부턴 구현부다.
// AuraGameplayAbility.h
// 이 아래로는 스택형 스킬을 구현하기 위한 구문입니다.
// Ability 객체가 충전 로직을 담당하게 되므로, 꼭 Instanced per Actor로 설정해줍니다.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "UsableType")
TArray<UAbilityUsableType*> UsableTypes;
protected:
virtual void OnGiveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec) override;
virtual bool CheckCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, FGameplayTagContainer* OptionalRelevantTags = nullptr) const override;
virtual void ApplyCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const override;
virtual void OnRemoveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec) override;
내가 선언한 Ability 클래스 중 가장 상위 클래스의 헤더 파일 일부다. 보면 Lyra의 Fragment처럼 UsableTypes를 선언해놨으며, 이를 사용하기 위해 함수들을 override한 걸 볼 수 있다.
void UAuraGameplayAbility::OnGiveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
Super::OnGiveAbility(ActorInfo, Spec);
for (auto AbilityUsableType : UsableTypes)
{
AbilityUsableType->OnGivenAbility(ActorInfo, Spec);
}
}
bool UAuraGameplayAbility::CheckCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, FGameplayTagContainer* OptionalRelevantTags) const
{
for (auto AbilityUsableType : UsableTypes)
{
if (!AbilityUsableType->CheckCost(this))
{
return false;
}
}
return Super::CheckCost(Handle, ActorInfo, OptionalRelevantTags);
}
void UAuraGameplayAbility::ApplyCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const
{
for (auto AbilityUsableType : UsableTypes)
{
AbilityUsableType->ApplyCost(this);
}
Super::ApplyCost(Handle, ActorInfo, ActivationInfo);
}
void UAuraGameplayAbility::OnRemoveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
for (auto AbilityUsableType : UsableTypes)
{
AbilityUsableType->OnRemoveAbility(this);
}
Super::OnRemoveAbility(ActorInfo, Spec);
}
함수 정의는 굉장히 간단하다. 그냥 모든 함수에서 반복문으로 UsableType들을 돌며 같은 이름의 함수를 호출해줄 뿐이다. 그럼 이제 UsableType의 구현을 보자.
// AbilityUsableType.h
UCLASS(Abstract, BlueprintType, EditInlineNew, DefaultToInstanced)
class AURA_API UAbilityUsableType : public UObject
{
GENERATED_BODY()
public:
virtual void OnGivenAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec);
virtual bool CheckCost(const UAuraGameplayAbility* OwningAbility);
virtual void ApplyCost(const UAuraGameplayAbility* OwningAbility);
virtual void OnRemoveAbility(UAuraGameplayAbility* OwningAbility);
public:
UPROPERTY()
FGameplayTag AbilityTag;
};
먼저 주목할 부분은 UCLASS 매크로다.
지정자 | 설명 |
---|---|
Abstract | 반드시 자식 클래스에서 상속받아 구현해야 하는 추상 클래스라는 의미 |
BlueprintType | 블루프린트에서 변수 타입으로 사용 가능 |
EditInlineNew | 블루프린트에서 해당 클래스의 인스턴스를 다른 객체의 Property로서 바로 생성 가능 |
DefaultToInstanced | 이 클래스를 UObject 변수로 가질 때, 복사본으로 생성해서 사용 (데이터 공유 문제 방지) |
다음으로 Ability 클래스의 같은 이름으로 선언된 함수를 보자. AbilityUsableType 클래스는 구현부가 텅텅 비어있으니, StackableAbility로 보여주겠다.
void UStackableAbility::OnGivenAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
if (ActorInfo && ActorInfo->IsNetAuthority())
{
if (UStackableAbilityComponent* Component = GetStackableAbilityComponent(ActorInfo))
{
Component->RegisterAbility(Spec.Ability->GetAssetTags().First(), StackData.CurrentStack, StackData.MaxStack, StackData.RechargeTime);
}
}
}
UStackableAbilityComponent* UStackableAbility::GetStackableAbilityComponent(const FGameplayAbilityActorInfo* ActorInfo) const
{
// 이미 StackableAbilityComponent가 있다면 그 컴포넌트에 이 Ability를 등록하고, 없다면 서버일 때만 직접 스폰 후 붙여줍니다.
// 클라이언트는 이후에 Component가 자동으로 복제됩니다.
if (AActor* AvatarActor = ActorInfo ? ActorInfo->AvatarActor.Get() : nullptr)
{
UStackableAbilityComponent* Component = AvatarActor->FindComponentByClass<UStackableAbilityComponent>();
if (Component)
{
return Component;
}
if (ActorInfo->IsNetAuthority())
{
Component = Cast<UStackableAbilityComponent>(AvatarActor->AddComponentByClass(UStackableAbilityComponent::StaticClass(), false, FTransform::Identity, true));
AvatarActor->FinishAddComponent(Component, false, FTransform::Identity);
return Component;
}
}
return nullptr;
}
저번 게시글에 작성했듯, Component는 서버에서만 생성해서 붙인다. 클라이언트는 서버에서 만들어준 Component를 Replicate로 알 수 있으며, 값들은 무조건 복제로 받아온다. RegisterAbility를 통해 충전 로직을 시작한다.
bool UStackableAbility::CheckCost(const UAuraGameplayAbility* OwningAbility)
{
// 충전된 스택이 없다면 false를 반환합니다.
if (const UStackableAbilityComponent* Component = GetStackableAbilityComponent(OwningAbility->GetCurrentActorInfo()))
{
if (!Component->CheckCost(OwningAbility->GetAssetTags().First()))
{
return false;
}
}
return true;
}
void UStackableAbility::ApplyCost(const UAuraGameplayAbility* OwningAbility)
{
// 충전된 스택을 소모합니다.
if (UStackableAbilityComponent* Component = GetStackableAbilityComponent(OwningAbility->GetCurrentActorInfo()))
{
Component->ApplyCost(OwningAbility->GetAssetTags().First());
}
}
void UStackableAbility::OnRemoveAbility(UAuraGameplayAbility* OwningAbility)
{
// 이 Ability가 제거될 때, Component에서 이 Ability의 등록을 해제합니다.
if (UStackableAbilityComponent* Component = GetStackableAbilityComponent(OwningAbility->GetCurrentActorInfo()))
{
Component->UnregisterAbility(OwningAbility->GetAssetTags().First());
}
}
다른 함수들도 Component에게 가서 요청할 뿐이다. 충전 로직은 철저하게 Component에서 관리되며, UsableType인 StackableAbility는 자신이 할당되어있는 Ability와 Component를 이어주는 역할을 수행할 뿐이다.
끝이다! StackableAbilityComponent나 WidgetController에 수정 사항은 없었다. 기존 Ability가 Component에 대해 깊게 알고 있던 것과 비교해보면 구조적으로도 상당히 안정적이게 됐다. 전략 패턴도 가미되어서, Ability는 AbilityUsableType을 상속받는 하위 클래스들을 알 필요도 없이 그저 반복문으로 돌리기만 하면 된다. 그럼 알맞는 Component들이 알아서 Ability와 연결된다. 그럼 이제 결과를 확인~! 해보기 전에! 저번에 클라이언트에서 이벤트가 3번씩 발생하던 버그를 수정했다.
void UAuraAbilitySystemComponent::OnRep_ActivateAbilities()
{
Super::OnRep_ActivateAbilities();
TSet<FGameplayAbilitySpecHandle> NewHandles;
NewHandles.Reserve(GetActivatableAbilities().Num());
for (const FGameplayAbilitySpec& AbilitySpec : GetActivatableAbilities())
{
if (!AbilitySpec.Handle.IsValid())
{
continue;
}
NewHandles.Add(AbilitySpec.Handle);
// 이전에 갖고 있지 않던 Ability만 위젯에 알려줍니다.
if (!CachedAbilityHandles.Contains(AbilitySpec.Handle))
{
AbilitiesGivenDelegate.Broadcast(AbilitySpec);
}
}
// 추후 Ability가 제거되는 로직이 추가되면 여기에 로직을 작성해 위젯이 알 수 있도록 해줍니다.
CachedAbilityHandles = MoveTemp(NewHandles);
}
강의에선 이 함수를 override해 AbilitySpec을 델리게이트에 담아 호출해줬다. 위젯에 부여된 Ability들을 스킬 아이콘으로 표현하기 위함이었다. 그런데 알고 보니 이 함수는 Ability 부여만이 아니라 Ability 발동 시에도 호출되는 함수였다. 그래서 내 위젯들이 Ability 발동만 해도 마치 부여된 것처럼 충전 로직이 시작됐다고 미친듯이 이벤트를 받아왔던 것이다. 그래서 추가된 Ability만 확실하게 가져와 델리게이트를 호출해주는 방식으로 변경했다.
그럼 이제 결과를 확인해보자.
이건 충전형 Ability가 아닌 경우!
GA_FireBolt로 가서 이렇게 할당해주면!
성공!
내가 너무 장점만 있는 것처럼 적어놨는데, 합성 패턴은 확장성에 초점을 맞춘 패턴이기 때문에 위 예시처럼 '다양한 발동 방식' 혹은 Lyra 샘플 프로젝트처럼 '다양한 연결점'이 예상되는 상황에 적합하다. 확장성이 필요하지 않은 단순한 시스템에서는 오버 엔지니어링이 될 수 있으니, 상황에 따라 적절하게 선택하는 신중함도 필요할 것 같다.
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
AvatarActor에 붙이는 Component는 다른 클라이언트들에게도 복제된다는 사실을 알았다. Component의 복제 여부는 개별로 설정할 수 있으나, OwnerOnly 같은 컨디션 설정은 없다. 따라서 매니저 클래스를 Component로 구현하면 불필요하게 네트워크 트래픽을 갖게 된다. 다른 클라이언트의 스킬 쿨타임이나 사용 횟수 상태를 알 필요가 있다 하더라도, 그런 일이 자주 발생하진 않는다. 필요할 때 RPC로 알려주는 방식이 낫다고 생각한다.
따라서 Component가 아닌 Actor 클래스를 상속받도록 변경했으며, Ability를 관리하는 객체기 때문에 ASC가 참조하고 있도록 변경했고, Replicated를 붙여 컨디션은 OwnerOnly로 설정했다.
이렇게 (내 생각엔)좋은 구조와 네트워크 최적화까지 챙긴 모습이 되었다.