ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
수정
아래 내용 중 PlayMontageAndWait 태스크를 커스텀, 내부에서 자체적으로 WaitGameplayEvent 태스크까지 실행하는 부분이 있다. 지금 생각해보니 Event를 2개 이상 기다려야 하는 경우는 대응할 수 없는 태스크기 때문에, 그냥 분리하는 게 낫다 싶어서 PlayMontageAndWaitForEvent 라는 커스텀 태스크를 PlayTaggedMontageAndWait으로 변경했다.
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ
현재 수강 중인 강의에서 알려준 방식과 약간 다르게 구현하다 보니 난관..까진 아니고 고민을 조금 해야 하는 일이 잦다. 강의에서 알려준 방식은 '캐릭터가 애니메이션 몽타주와 관련된 정보를 갖고' 있으며, 기본 공격을 담당하는 GameplayAbility는 인터페이스 함수를 통해 이 정보를 가져오고 애니메이션 재생과 데미지를 주는 로직을 호출한다. 난 여기서 조금 마음에 들지 않는 부분이 있었다.
GameplayAbility는 결국 '캐릭터가 어떤 행동을 할 건지'를 담당하는 클래스인데, 기본 공격의 로직 자체는 GA가 담당하지만 그 관련 정보는 캐릭터가 갖고 있는 상태다. 물론 이 방식도 장점은 있다. MeleeAttack이라는 단 하나의 기본 공격 클래스를 만들고, 모든 캐릭터가 이 GA를 할당받으면 모든 캐릭터의 기본 공격 구현이 가능하다.
하지만 나중에 신규 캐릭터에게 '추가 효과가 달린 기본 공격' 같은 기획이 들어온다면?
어차피 MeleeAttack을 상속받는 뭔가를 만들어야 할 거다. 그럼 MeleeAttack이라는 범용 GA를 모든 캐릭터에게 할당해주는 로직에서 이 캐릭터만 제외해야 하는 일도 생긴다. 그럴 바엔 모든 캐릭터마다 하나씩 기본 공격 GA를 할당해주는 게 낫겠다는 생각이 들었다. 그게 더 깔끔하고 보기도 좋으며, 헷갈릴 일도 적다.
그래서 인터페이스가 갖고 있던 구조체 TaggedMontage를 기본 공격 GA 클래스로 옮겼다. 아래는 그 구조체의 선언이다.
USTRUCT(BlueprintType)
struct FTaggedMontage
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
UAnimMontage* Montage = nullptr;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FGameplayTag MontageTag;
/**
* 근접 공격 캐릭터에겐 공격 판정 위치, 원거리 공격 캐릭터에겐 투사체 발사 위치
* 현재 목록
* TipSocket (Weapon에서 사용)
* LeftHandSocket
* RightHandSocket
*/
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
FName SocketName = FName("TipSocket");
};
사용 방식은 이렇다.
위 구조체가 GA에게 있으므로, 변수로 선언해 블루프린트에서 값을 채운다. 그 다음 PlayMontageAndWait노드로 Montage를 가져와 재생한다.
그 다음 MontageTag를 가져와서 이벤트를 기다린다.
몽타주는 재생 도중 AnimNotify로 이 이벤트를 돌려준다. 이벤트를 받은 GA는 다시 구조체에서 SocketName을 통해 공격판정 위치가 어디인지 알아내고, 데미지를 주는 로직을 실행한다. (RangedAttack이라면 Projectile을 스폰한다.) 그런데 여기서 문제가 발생했다.
기본 공격 모션이 여러 개면?
이건 강의에서도 다루는 내용이다. 강의는 TaggedMontage를 캐릭터 클래스가 갖고 있기 때문에 인터페이스 함수 GetRandomMontage를 선언, TaggedMontage를 하나 가져와서 GA의 멤버변수로 할당했다. 나도 처음엔 비슷한 방식으로 해결했다. GA의 배열에서 하나를 랜덤하게 가져오고, 그 인덱스를 CachedMontageIndex라는 이름의 멤버변수로 할당했다가 Notify 이벤트를 받을 때 다시 배열에서 가져왔다.
하지만, 이건 좀 위험하다.
모종의 이유로 GA가 2번 실행됐다면? 혹은 GA를 2번 실행해야만 하는 일이 생겼다면? 내가 원하는 이벤트를 발생시키지 못 할 가능성이 있다.
심지어 또 하나의 문제가 있다. PlayMontageAndWait을 호출한 뒤 WaitGameplayEvent를 통해 이벤트를 기다리는 이 구조 자체가 여러 곳에서 쓰일 가능성이 있다는 거다. 실제로 이미 MeleeAttack과 RangedAttack에서 같은 구조를 반복적으로 구현한 상태다.
해결 방법은 Task
Task를 만들면 GA가 Task 객체를 만들어 TaggedMontage를 보낼 거고, 그걸 Task의 멤버변수에 할당해서 사용한다면? GA 입장에선 마치 로컬변수에 캐싱한 것처럼 사용할 수 있게 된다. 그리고 Task는 MeleeAttack이나 RangedAttack 외에도 다른 모든 곳에서 사용할 수 있을 거다. 그게 Attack이 아니더라도 말이다. Task를 만드는 것으로 이 2개의 문제를 동시에 해결할 수 있다.
엔진 프로그래밍을 할 수준은 아니니까 직접 만들기는 좀 그렇고.. PlayMontageAndWait을 그대로 복사 붙여넣기 한 클래스를 만든 뒤,
void UPlayMontageAndWaitForEventWithSocket::Activate()
{
if (Ability == nullptr)
{
return;
}
...
// 새로운 태스크를 직접 만들어 바인드 및 호출합니다.
if (Ability)
{
UAbilityTask_WaitGameplayEvent* WaitGameplayEvent = UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(Ability, MontageTag);
WaitGameplayEvent->EventReceived.AddDynamic(this, &ThisClass::HandleEventReceived);
WaitGameplayEvent->ReadyForActivation();
}
bPlayedMontage = true;
...
}
Activate 구현부 내에서 적절한 위치를 찾아 WaitGameplayEvent 태스크를 생성, 함수를 붙여줬다.
void UPlayMontageAndWaitForEventWithSocket::HandleEventReceived(FGameplayEventData Payload)
{
if (ShouldBroadcastAbilityTaskDelegates())
{
OnEventReceived.Broadcast(MontageTag, SocketName);
}
}
붙인 함수는 이렇게 간단하게 생겼다.
// 헤더파일
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FMontageEventWithSocketDelegate, FGameplayTag, MontageTag, FName, SocketName);
UPROPERTY(BlueprintAssignable)
FMontageEventWithSocketDelegate OnEventReceived;
UPROPERTY()
FGameplayTag MontageTag;
UPROPERTY()
FName SocketName;
블루프린트에서 노출할 핀, 그리고 이 태스크가 만들어질 때 WaitGameplayEvent에게 넘겨줄 값들을 저장할 수 있도록 멤버변수를 선언했다.
static UPlayMontageAndWaitForEventWithSocket* CreatePlayMontageAndWaitProxy(
UGameplayAbility* OwningAbility,
FName TaskInstanceName,
>>>"FTaggedMontage TaggedMontage"<<<,
float Rate = 1.f,
FName StartSection = NAME_None,
bool bStopWhenAbilityEnds = true,
float AnimRootMotionTranslationScale = 1.f,
float StartTimeSeconds = 0.f
);
마지막으로 함수의 매개변수를 UAnimMontage에서 TaggedMontage로 변경했다.
결과적으로 이런 노드를 얻을 수 있었으며
모든 게 의도대로 정상작동하는 모습도 확인할 수 있었다.