ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
충전형 -> 스택형 / Chargeable -> Stackable / 충전형 == 꾹 눌러서 발동
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
더 개선할 방법은 없을까?
이 의문을 항상 품는 건 좋은 습관이라고 생각한다. 멍청하게 짜놓고 뿌듯해하던 모습을 떠올릴 수 있으니까.
그렇다! '딸깍'으로 스택형 Ability를 구현하는 데에 성공했지만, 다시 생각해보니 좋은 구조는 아닌 것 같다. 왜 그런가 하면..
콤보형 스킬로 바꿔주세요.
범위형 스킬로 바꿔주세요.
충전형 스킬로 바꿔주세요.
요구사항이 있을 때마다 Base Ability에 선언할 건가? 지금 구조라면 그게 맞다. 하지만 당연히 그러면 안 된다. Base Ability는 점점 더 비대해질 거고, 너무 많은 역할을 담당하게 될 거다. 위에서 언급한 내용을 모두 구현하게 된다면 메모리도 부담스러워질 거다.
그럼 어떻게 구현하는 게 좋을까?
어쩔 수 없이 Component를 구현하기로 했다. 다만, 처음 생각했던 것처럼 SummonComponent를 자식 클래스로 두는 StackableAbilityComponent 같은 건 없다. 이 두 개의 컴포넌트는 별개의 클래스가 될 거다. SummonComponent는 단순히 '하수인 관리'를 담당하는 컴포넌트고, StackableAbilityComponent는 스택형 Ability의 스택 관리만 담당할 거다.
그래서 스택형 Ability 하나당 한 개의 StackableAbilityComponent가 붙어야 하나? 그건 아니다. 다시 잘 생각해보니 그럴 필요가 전혀 없었다. AbilityTag가 있으니까, 컴포넌트 하나가 Tag로 관리하면 된다.
USTRUCT(BlueprintType)
struct FAbilityStackData
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stackable")
int32 CurrentStack;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stackable")
int32 MaxStack;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stackable")
float RechargeTime = 0.f;
FTimerHandle RechargeTimerHandle;
/**
* 원한다면 RechargeStackAtOnce, UseStackAtOnce 등을 선언해 한 번에 충전되는 횟수나 사용되는 횟수를 정해줄 수도 있습니다.
* 위 변수의 값들은 Ability에서 결정해 넘겨줍니다.
*/
};
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class AURA_API UStackableAbilityComponent : public UActorComponent
{
GENERATED_BODY()
public:
void RegisterAbility(FGameplayTag AbilityTag, int32 MaxStack, float RechargeTime);
void UnregisterAbility(FGameplayTag AbilityTag);
bool CheckCost(FGameplayTag AbilityTag) const;
void ApplyCost(FGameplayTag AbilityTag);
int32 GetCurrentStack(FGameplayTag AbilityTag) const;
private:
TMap<FGameplayTag, FAbilityStackData> AbilityStacks;
void StartRecharge(FGameplayTag AbilityTag);
void StopRecharge(FGameplayTag AbilityTag);
void Recharge(FGameplayTag AbilityTag);
};
스택 관련 로직을 완전히 분리해 리팩토링된 컴포넌트의 헤더파일이다. 저번 게시글에 작성된 Ability가 갖고 있던 로직과 별 다를 건 없다. 차이점은 TMap 하나뿐이다.
void UStackableAbilityComponent::RegisterAbility(FGameplayTag AbilityTag, int32 MaxStack, float RechargeTime)
{
FAbilityStackData& Data = AbilityStacks.FindOrAdd(AbilityTag);
Data.CurrentStack = 0;
Data.MaxStack = MaxStack;
Data.RechargeTime = RechargeTime;
StartRecharge(AbilityTag);
}
void UStackableAbilityComponent::UnregisterAbility(FGameplayTag AbilityTag)
{
StopRecharge(AbilityTag);
AbilityStacks.Remove(AbilityTag);
}
bool UStackableAbilityComponent::CheckCost(FGameplayTag AbilityTag) const
{
const FAbilityStackData* Data = AbilityStacks.Find(AbilityTag);
return Data && Data->CurrentStack > 0;
}
void UStackableAbilityComponent::ApplyCost(FGameplayTag AbilityTag)
{
FAbilityStackData* Data = AbilityStacks.Find(AbilityTag);
if (Data && Data->CurrentStack > 0)
{
Data->CurrentStack = FMath::Max(0, Data->CurrentStack - 1);
StartRecharge(AbilityTag);
}
}
int32 UStackableAbilityComponent::GetCurrentStack(FGameplayTag AbilityTag) const
{
const FAbilityStackData* Data = AbilityStacks.Find(AbilityTag);
return Data ? Data->CurrentStack : 0;
}
void UStackableAbilityComponent::StartRecharge(FGameplayTag AbilityTag)
{
FAbilityStackData* Data = AbilityStacks.Find(AbilityTag);
if (!Data || Data->CurrentStack >= Data->MaxStack)
{
StopRecharge(AbilityTag);
return;
}
if (UWorld* World = GetWorld())
{
if (!World->GetTimerManager().IsTimerActive(Data->RechargeTimerHandle))
{
World->GetTimerManager().SetTimer(
Data->RechargeTimerHandle,
FTimerDelegate::CreateUObject(this, &ThisClass::Recharge, AbilityTag),
Data->RechargeTime,
true);
}
}
}
void UStackableAbilityComponent::StopRecharge(FGameplayTag AbilityTag)
{
FAbilityStackData* Data = AbilityStacks.Find(AbilityTag);
if (Data)
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(Data->RechargeTimerHandle);
}
}
}
void UStackableAbilityComponent::Recharge(FGameplayTag AbilityTag)
{
FAbilityStackData* Data = AbilityStacks.Find(AbilityTag);
if (Data)
{
Data->CurrentStack++;
if (Data->CurrentStack >= Data->MaxStack)
{
StopRecharge(AbilityTag);
}
}
}
이건 cpp파일인데, 함수 이름이나 동작들이 워낙 직관적이라 주석 다는 걸 생략했다. 그리고 헤더파일에 적었듯이, 한 번에 충전되는 횟수나 사용되는 횟수를 정해주고 싶다면 cpp파일에서도 Recharge 함수와 CheckCost, ApplyCost 함수를 수정해주면 된다.
그럼 이제 대망의 Ability 파일이다.
public:
/**
* 이 아래로는 스택형 스킬을 구현하기 위한 구문 예시입니다.
* Ability 객체가 충전 로직을 담당하게 되므로, 꼭 Instanced per Actor로 설정해줍니다.
*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Stackable")
FAbilityStackData StackData;
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;
UStackableAbilityComponent* GetStackableAbilityComponent(const FGameplayAbilityActorInfo* ActorInfo) const;
이건 보일러 플레이트다. 마치 팔만대장경으로 책을 찍은 것처럼, 스택형 Ability를 구현하기 위해선 이 글자들을 찍어내야 한다. 내가 찾은 해답은 이거다. 더이상 StackableAbilityComponent는 건드릴 필요가 없고, 기존에 구현되어있는 Ability에게 변수 하나 선언하고, 함수 4개 오버라이드 및 함수 1개 선언, 정의해준 뒤 블루프린트 에디터에서 StackData의 값을 초기화해주면 된다.
좀.. 길긴 하다. 그래도 장점이 없는 건 아니다. 이제 스택형 Ability가 아닌데도 스택형 관련 변수를 갖고 있던 문제가 해결된 거다. cpp 파일 마저 보고 얘기하자.
void UAuraGameplayAbility::OnGiveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
Super::OnGiveAbility(ActorInfo, Spec);
// 이 Ability가 부여될 때, 대상에게 StackableAbilityComponent를 부여합니다.
if (UStackableAbilityComponent* Comp = GetStackableAbilityComponent(ActorInfo))
{
// 이 Ability의 충전 타이머를 등록합니다.
Comp->RegisterAbility(AbilityTags.First(), StackData.MaxStack, StackData.RechargeTime);
}
}
bool UAuraGameplayAbility::CheckCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, FGameplayTagContainer* OptionalRelevantTags) const
{
// 충전된 스택이 없다면 false를 반환합니다.
if (UStackableAbilityComponent* Comp = GetStackableAbilityComponent(ActorInfo))
{
if (!Comp->CheckCost(AbilityTags.First()))
{
return false;
}
}
return Super::CheckCost(Handle, ActorInfo, OptionalRelevantTags);
}
void UAuraGameplayAbility::ApplyCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const
{
// 충전된 스택을 소모합니다.
if (UStackableAbilityComponent* Comp = GetStackableAbilityComponent(ActorInfo))
{
Comp->ApplyCost(AbilityTags.First());
}
Super::ApplyCost(Handle, ActorInfo, ActivationInfo);
}
void UAuraGameplayAbility::OnRemoveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
// 이 Ability가 제거될 때, 이 Ability의 충전 타이머를 제거합니다.
if (UStackableAbilityComponent* Comp = GetStackableAbilityComponent(ActorInfo))
{
Comp->UnregisterAbility(AbilityTags.First());
}
Super::OnRemoveAbility(ActorInfo, Spec);
}
UStackableAbilityComponent* UAuraGameplayAbility::GetStackableAbilityComponent(const FGameplayAbilityActorInfo* ActorInfo) const
{
// 이미 StackableAbilityComponent가 있다면 그대로 반환하고, 없다면 직접 스폰 후 붙여준 뒤 반환합니다.
if (AActor* AvatarActor = ActorInfo ? ActorInfo->AvatarActor.Get() : nullptr)
{
UStackableAbilityComponent* Comp = AvatarActor->FindComponentByClass<UStackableAbilityComponent>();
if (Comp)
{
return Comp;
}
Comp = Cast<UStackableAbilityComponent>(AvatarActor->AddComponentByClass(UStackableAbilityComponent::StaticClass(), false, FTransform::Identity, true));
AvatarActor->FinishAddComponent(Comp, false, FTransform::Identity);
return Comp;
}
return nullptr;
}
TMap의 Key로 Ability 태그를 쓰고 있으니, AbilityTags의 첫 번째 태그를 매개변수로 넣는다. 즉, 앞으로 Ability 선언 시 AbilityTags의 첫 태그는 반드시 '해당 Ability 전용 Tag'가 되어야 한다. 이것만 지켜주면 된다. 나중에 AbilityTag를 2개 이상 갖는 Ability가 생긴다면 새로운 로직을 생각해봐야 할 것 같다. 예를 들어 TagContainer 자체를 Key로 사용하는 TMap 같은 것 말이다.
어쨌든 보일러 플레이트에 대해 마저 얘기하면, 물론 충전형 Ability를 구현하기 위해 이 모든 걸 적는 과정은 좀 번거롭다. 하지만, BaseAbility에서 파생되는 StackableAbility를 만든다면 어떨까? 계층 구조가 더욱 깊어져 복잡해지는 건 덤이고, 이미 비스택형 Ability로 구현된 클래스는 스택형으로 자유롭게 전환하지 못 하는 문제가 발생한다.
그에 비해 이 보일러 플레이트로 구현되는 스택형 Ability는 주석처리만 해줘도 비스택형 Ability로 변신한다. 다른 스택형 Ability와 코드가 중복된다는 문제는 당연히 있긴 하지만, 이걸 Component로 생각하면 합당하다.
액터의 기능 구현을 위해 액터 컴포넌트를 선언, 관심사를 분리하는 건 매우 보편적인 일이다. 그리고 액터에선 그 액터 컴포넌트를 객체로 생성해 할당받는 코드가 필요하다. 이것도 보일러 플레이트다. 그럼 이 StackableAbilityComponent를 ActorComponent가 아닌, AbilityComponent라고 생각해 접근하면 어떨까? 그리고 이 코드는 그 AbilityComponent와 스택형 Ability를 연결하기 위한 '연결 코드'다. 이렇게 생각하면, 물론 코드가 조금 길긴 하지만 꽤 보편적인 코드라고 생각해볼 수 있다.
합리화가 아니다. 이 코드는 메모리 면에서도, 확장성 면에서도 분명 나쁘지 않은 코드다.
그리고 미래에 충전형, 콤보형, 범위형 등의 Ability가 기획돼도 얼마든지 스택형 Ability와 조합해 사용할 수 있다.
하지만 이제 제목 값은 못 한다. '딸깍' 으로 구현하는 게 목표였는데, 딸깍딸깍딸깍딸깍딸깍딸깍딸깍딸깍딸깍딸깍으로 구현할 수 있게 됐다. 그래도 이 정도면 만족한다. 충분히 현업에서도 사용 가능한 코드라고 생각.. 은 하는데 실제로도 그럴지는 모르겠다.
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
아닌 것 같다. 다시 생각해보니 이미 모두 구현된 블루프린트의 Ability를 스택형으로 바꿀 수가 없다. 예를 들어 GA_Firebolt를 스택형으로 바꾸고 싶어서 Projectile Ability에 보일러 플레이트를 작성하면, 그 아래 모든 게 스택형 Ability로 바뀌어버린다. 이 간단한 걸 생각하지 못 했다. 좀 더 구조를 고민해봐야겠다.
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
https://velog.io/@jiyunkim/%EB%94%B8%EA%B9%8D%EB%94%B8%EA%B9%8D%EB%94%B8%EA%B9%8D%EB%94%B8%EA%B9%8D%EB%94%B8%EA%B9%8D%EC%9C%BC%EB%A1%9C-%EC%8A%A4%ED%83%9D%ED%98%95-Ability-%EA%B5%AC%ED%98%84-3
세 번째 게시글로 이어집니다.