'딸깍'으로 스택형 Ability 구현

김지윤·2025년 7월 27일
0

UE5_GAS

목록 보기
16/22

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
구조에 문제가 있어 2번째 게시글에서 더 나은 방향으로 수정했습니다.
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ

SummonAbility를 스택형으로 구현하고 싶다는 생각이 들었다. 최대 스폰 가능 하수인은 3마리지만, 마나가 있어도 충전되지 않으면 소환할 수 없는 그런 스킬로.

첫 번째 아이디어

SummonAbility는 SummonComponent와 밀접한 관련이 있다. SummonComponent는 SummonAbility를 전혀 모르지만, SummonAbility는 SummonComponent를 참조해서 현재 스폰 가능한 하수인 수를 계산한다. Ability에서 모든 걸 계산해버리면, Ability는 다른 Ability를 모르기 때문에 '통합 최대 하수인 수' 같은 걸 알 수 없다. 그래서 SummonComponent를 붙였고, 다른 하수인 소환 스킬과 함께 병용될 때 유용하게 쓸 수 있도록 구성했다.

그래서 이 SummonComponent를 자식 클래스로 두는 ChargeableAbilityComponent를 만들까 했다. 사용 가능 횟수를 여기서 계산하고 타이머를 관리할 수 있도록. 그런데 조금 생각해보니, 이렇게 구현하면 'Ability당 Component 하나.' 붙여야 하는 상황이 된다. 좀 과하다. 그리고 Ability가 캐릭터의 동작을 담당하는 데에 있어서 Component를 너무 과하게 의존하게 된다는 문제가 있다.

두 번째 아이디어

ChargeableAbilitiy를 선언해서 사용하는 방식을 고안했다. Ability의 Instancing Policy를 Instanced Per Actor(액터마다 Ability 객체 하나씩 생성)로 두고, 충전 및 사용 로직을 오버라이드해서 관리하는 거다. ChatGPT한테 물어보니 Ability가 충전 로직을 담당하는 게 가장 깔끔하다고 한다.(아니였다.) 실제로 Ability가 멤버변수를 갖고 이를 관리하도록 하기 위해 Intanced Per Actor 같은 게 있는 거다. 이러면 생명주기는 ASC가 파괴될 때까지로 책정되며, 안정적으로 멤버변수에 값을 할당해 사용할 수 있다. EndAbility 같은 함수가 호출되어도 이 객체는 사라지지 않는다.

하지만 이것도 문제가 없는 건 아니였다. 이미 탄탄하게 구성된 Ability 클래스들을 갑자기 스택형으로 바꾸고 싶다면? 아.....

이런 기획이 안 나오리라는 보장이 없다. 현재 구조는 아래와 같다.

BaseAbility -> SummonAbility
BaseAbility -> DamageAbility -> MeleeAttack, ProjectileSpell

BaseAbility가 애니메이션 등을 관리하고, Summon은 데미지가 필요 없으니 이런 구조를 갖고 있다. 그럼 여기서 BaseAbility를 상속받는 ChargeableAbility를 만든다면, 지금까지 만든 모든 Ability는 절대로 스택형으로 구현할 수 없게 된다. 다이아몬드 상속은 절대로 있어선 안 되는 일이니까.

그래서 생각해낸 마지막 방법은

세 번째 아이디어

BaseAbility에 Chargeable 관련 변수 및 함수를 냅다 두드려 박는 거다.

물론! 메모리는 낭비된다. 스택형이 아닌데도 관련 변수를 갖고 있을 거고 사용은 안 하니까 비효율적이라고 생각이 들 수도 있다. 하지만 언리얼 설계 철학은..

효율적인 구조를 포기하고, 효율적인 개발 환경을 구성
정보) 뇌피셜임, 아래 게시글 참조
https://velog.io/@jiyunkim/%EC%96%B8%EB%A6%AC%EC%96%BC-%EC%84%A4%EA%B3%84-%EC%B2%A0%ED%95%99

조금 메모리를 낭비하더라도, '딸깍' 한 번으로 비스택형이었던 스킬을 스택형으로 바꿀 수 있다면? 그리고 껏해봤자 Ability 하나당 수십바이트 수준의 메모리를 더 차지하는 건데, 그 정도 손해를 감수하고 기획 수정에 빠르게 대응할 수 있는 편리함을 가질 수 있다면 그게 훨씬 좋은 개발 아닐까? 난 그렇게 생각한다. 그래서 이미 만들어뒀던 BaseAbility에 충전 관련 변수와 함수를 넣었다.

public:
	/**
	 * 이 아래로는 스택형 스킬을 구현하기 위한 구문들입니다.
	 * Ability 객체가 충전 로직을 담당하게 되므로, 꼭 Instanced per Actor로 설정해줍니다.
	 */
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Chargeable")
	bool bUseCharges = false;

	UPROPERTY(BlueprintReadOnly, Category = "Chargeable", meta = (EditCondition = "bUseCharges"))
	int32 CurrentCharges = 0;
	
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Chargeable", meta = (EditCondition = "bUseCharges"))
	int32 MaxCharges = 3;
	
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Chargeable", meta = (EditCondition = "bUseCharges"))
	float RechargeTime = 5.f;

protected:
	FTimerHandle RechargeTimerHandle;

	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;

	void StartRecharge();
	void StopRecharge();
	void Recharge();

코스트에 GE만이 아닌 CurrentCharges도 포함되므로, Cost 관련 함수도 오버라이드했다. 아래는 구현부다.

void UAuraGameplayAbility::OnGiveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
	Super::OnGiveAbility(ActorInfo, Spec);
	StartRecharge();
}

Ability 부여와 동시에 충전을 시작한다.

bool UAuraGameplayAbility::CheckCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, FGameplayTagContainer* OptionalRelevantTags) const
{
	if (bUseCharges && CurrentCharges <= 0)
	{
		return false;
	}
	
	return Super::CheckCost(Handle, ActorInfo, OptionalRelevantTags);
}

Ability 발동 요청 시 Cost를 확인하는 함수의 재정의 구문이다. 스택형 Ability인지 확인 후, 잔여 횟수가 없으면 false를 반환한다.

void UAuraGameplayAbility::ApplyCost(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo) const
{
	if (bUseCharges)
	{
		// const_cast는 웬만해선 사용하지 말라고 들었지만, 스택 횟수 감소는 무조건 ApplyCost에서 하는 게 맞다고 생각이 들어 여기서 처리합니다.
		// ActivateAbility를 오버라이드해 CurrentCharges를 조작하면 Cost 소모가 제대로 이루어지지 않는 현상도 있습니다.
		auto* MutableThis = const_cast<UAuraGameplayAbility*>(this);
		MutableThis->CurrentCharges = FMath::Max(0, MutableThis->CurrentCharges - 1);
		MutableThis->StartRecharge();
	}
	Super::ApplyCost(Handle, ActorInfo, ActivationInfo);
}

const_cast 사용을 피하기 위해 처음엔 ActivateAbility를 오버라이드했으나, 마나 소모가 되지 않는 현상이 있어서 ApplyCost와 const_cast를 어쩔 수 없이 사용했다. Lyra 샘플 프로젝트에서도 일부 이런 구문이 있는 것으로 알고 있다. 어쨌든 사용 가능 횟수도 Cost니까 ApplyCost에서 구현해주는 게 네이밍상으로도 무조건 맞다.

void UAuraGameplayAbility::OnRemoveAbility(const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilitySpec& Spec)
{
	if (bUseCharges)
	{
		StopRecharge();
	}
	
	Super::OnRemoveAbility(ActorInfo, Spec);
}

Ability 부여 해제 시 타이머 해제를 잊지 않는다.

void UAuraGameplayAbility::StartRecharge()
{
	if (!bUseCharges || CurrentCharges >= MaxCharges)
	{
		StopRecharge();
		return;
	}

	if (UWorld* World = GetWorld())
	{
		// EndAbility 호출 이후 인스턴스의 멤버 함수 바인딩이 더이상 유효하지 않게 됩니다.
		// 따라서 델리게이트를 지역변수로 선언해 멤버변수로 넣어줍니다.
		FTimerDelegate Delegate;
		Delegate.BindLambda([this]()
		{
			this->Recharge();
		});
		
		World->GetTimerManager().SetTimer(
			RechargeTimerHandle,
			Delegate,
			RechargeTime,
			true);
	}
}

void UAuraGameplayAbility::StopRecharge()
{
	if (UWorld* World = GetWorld())
	{
		World->GetTimerManager().ClearTimer(RechargeTimerHandle);
	}
}

void UAuraGameplayAbility::Recharge()
{
	++CurrentCharges;

	if (CurrentCharges >= MaxCharges)
	{
		StopRecharge();
	}
}

여기는 평범한 충전 구문이다.

위 사진은 스킬 사용을 연타한 모습이다. 충전 시간은 2초, 최대 충전 횟수는 2회다. 2회 충전된 스킬을 모두 사용하고, 2초 후 1회 충전된 스킬을 사용했다. 아직 UI에 스킬 쿨타임 표시는 구현 중이라서 눈으로 직접 볼 수는 없지만, 연타하는 걸로 정상 작동을 확인했다.

그래서 결과는?

기존 Ability에서 그냥 딸ㅡ깍으로 충전식 Ability를 구현할 수 있게 됐다. 그리고 이는 '충전'에만 국한되며, Cooldown GE와는 완전히 별개로 작동한다. SummonAbility도 정상 적동을 확인했다. 충전 횟수가 없으면 기본 공격을 하다가, 충전되면 소환한다. 기존의 SummonAbility와 SummonComponent를 해치지 않으면서 새로운 기능을 만들어서 뿌듯하다.

ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
2번째 게시글로 이어집니다.
https://velog.io/@jiyunkim/%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-2

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

0개의 댓글