이번엔 GameplayCue로 사운드와 나이아가라를 재생하는 섹션을 수강했다. 마찬가지로, 내가 생각하는 더 좋은 구조로 변경하면서 수강하다 보니 내 방식에 맞춰서 구현할 필요가 있었다. 이번 문제는 난관 그 자체였다. 강의에서 구현한 로직은 다음과 같다.
굉장히 쉽고 직관적인 로직이다. 하지만 나는 TaggedMontage를 Ability가 갖고 있고, Cue는 Ability를 참조해선 안 된다.
클라이언트에선 댕글링 포인터가 발생할 수 있고, 순환 참조 문제도 존재한다. 그래서 어떻게 구현할 수 있을지 고민 좀 해봤다.
처음 시도해본 건 Context에 TaggedMontage를 담는 거였다. Ability가 자신의 함수인 CauseDamage를 호출할 때 Context를 생성하는데, 여기서 TaggedMontage를 그대로 담아 Context를 반환하도록 했다. Ability는 그걸 Cue의 매개변수로 넣어줬다. 실제로 성공했고, 멀티플레이에서도 정상적으로 작동했다. 하지만 이것도 마음에 안 들었다. TaggedMontage는 클라이언트도 들고 있는 정보고, 굳이 네트워크 트래픽을 먹을 이유가 없다
는 생각이 들었다. '이거 재생해.' 이 정도의 정보만 담아서 보내야지, 재생해야 하는 걸 통째로 보내면 서버비 내느라 회사 망한다.(말이 그렇다는 거지 망하진 않는다.)
결국 생각해낸 건 사운드와 나이아가라도 Tag로 관리
하는 거였다. 정리한 로직은 이렇다.
여기까지 에셋을 로직상으로 가져온 방법이다. Subsystem은 블루프린트로 파생되지 못 하기 때문에 Config를 통해 경로를 할당해줬다. 이제 실제 플레이 로직이다.
FXManagerSubsystem이 에셋 반환만 하고 Cue가 로직을 관리하는 게 더 낫지 않을까 싶었지만, 아까도 적었다시피 Cue는 잠깐 살아있는 객체다. 콜백을 활용하기에 적절한 객체
가 아니다. 애초에 멤버변수를 선언하고 값을 할당하는 것도 제약이 있는 클래스다. 심지어 블루프린트에서 선언한 함수를 OnExecute에서 호출하는 것도 안 된다. 이거 때문에 노드 정리 못 해서 상당히 화났다. 따라서 사운드나 나이아가라를 직접 반환받아 로직을 작성하게 되면 SoftObjectPtr로 선언된 사운드와 에셋을 동기 로드 시켜야 한다. (로드가 끝난 시점에 콜백을 거는 걸 못 하니까.) TObjectPtr로 선언하는 방법도 있겠지만, 그럼 프로젝트가 커졌을 때 메모리를 어마무시하게 잡아먹을 거다.
따라서 Cue는 FXManagerSubsystem에게 에셋 관련 정보만 던진다. FXManagerSubsystem이 이를 받아 어떤 에셋을 재생해야 하는지 판단하고, 에셋 로딩이 끝나는 시점에 콜백을 바인드, 로드 시작까지 직접 한다. 클래스 이름도 FXManager기 때문에 크게 부자연스럽지 않은 로직이다.
구현 중 직면한 또 다른 문제가 있었는데, 나는 Cue를 여러 개 놓길 원했다. 그래서 Cue를 PlayNiagara, PlaySound 이렇게 2개 구현했다. 그 다음 Ability에서 피격 캐릭터마다 PlayNiagara Cue를, 반복문 종료 시 PlaySound Cue를 호출했다. 그랬더니 마치 Ability에게 최대 Cue 호출 제한이 있는 것처럼 작동했다. 로그를 살펴보니 아니나 다를까.
LogAbilitySystem: Warning: Attempted to fire
NetMulticast_InvokeGameplayCueExecuted_WithParams when no more RPCs are allowed
this net update. >>"Max:2"<<Cue:GameplayCue.PlaySound
Instigator:None Component:/Game/Maps/UEDPIE_0_StartupMap.StartupMap:PersistentLevel.
BP_Goblin_Spear_C_1.AbilitySystemComponent
최대 제한이 걸려있으니까, 여러 개 사용하지 말라고 한다. 엔진 뜯어서 최대 수치 늘리는 방법도 있기야 하겠지만.. 패킷을 한 번에 묶어서 보내는 게 네트워크 트래픽을 적게 사용하는 이상적인 방법이라는 건 상식이다. Ability 전용 Cue까진 아니더라도, 어느정도 범용으로 사용할 수 있는 Cue를 만드는 게 좋겠다.
아래는 구현한 코드와 블루프린트 그래프 스크린샷이다. 다 적자니 코드가 워낙 길어서 핵심 아이디어만 작성했으며, 중요하지 않은 부분은 중간중간 생략했다.
// FXManagerSubsystem.h
// 내부적으로 로딩 중인 에셋의 상태를 관리할 구조체
USTRUCT()
struct FSoundAsyncLoadRequest
{
GENERATED_BODY()
TOptional<TSharedPtr<FStreamableHandle>> StreamableHandle;
UPROPERTY()
TArray<FVector> LocationsToPlay;
UPROPERTY()
TArray<FRotator> RotationsToPlay;
...
// 필요하면 변수 추가
FSoundAsyncLoadRequest()
{
}
};
// 나이아가라 생략
UCLASS(Config = Game)
class AURA_API UFXManagerSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
UFUNCTION(BlueprintCallable, Category = "FX")
void AsyncPlaySoundAtLocation(const FGameplayTag SoundTag, const FVector Location, const FRotator Rotation = FRotator::ZeroRotator, float VolumeMultiplier = 1.f, float PitchMultiplier = 1.f);
void OnSoundAsyncLoadComplete(FSoftObjectPath LoadedAssetPath);
private:
// DataTable에 매핑되어있는 Tag와 에셋들은 탐색 효율을 위해 TMap으로 재구성되므로, 메모리 효율을 위해 Soft로 선언합니다.
UPROPERTY(Config)
TSoftObjectPtr<UDataTable> SoundDataTablePath;
UPROPERTY()
TMap<FGameplayTag, TSoftObjectPtr<USoundBase>> SoundInfos;
FStreamableManager* StreamableManager;
// 현재 로딩 중인 SoftObjectPath와 그에 해당하는 로딩 정보 매핑
UPROPERTY()
TMap<FSoftObjectPath, FSoundAsyncLoadRequest> PendingSoundLoadRequests;
FCriticalSection PendingRequestsLock;
};
// FXManagerSubsystem.cpp
void UFXManagerSubsystem::Deinitialize()
{
FScopeLock Lock(&PendingRequestsLock);
PendingSoundLoadRequests.Empty();
Super::Deinitialize();
}
void UFXManagerSubsystem::AsyncPlaySoundAtLocation(const FGameplayTag SoundTag, const FVector Location, const FRotator Rotation, float VolumeMultiplier, float PitchMultiplier)
{
// 에러 핸들링
// 이미 로드되어있는 경우
if (USoundBase* LoadedSound = SoundToLoad.Get())
{
// 콜백 필요 없이 즉시 재생
return;
}
FSoftObjectPath AssetPath = SoundToLoad.ToSoftObjectPath();
// 이미 로딩 중인 경우
if (FSoundAsyncLoadRequest* ExistingRequest = PendingSoundLoadRequests.Find(AssetPath))
{
// 사운드를 재생할 위치에 추가
ExistingRequest->LocationsToPlay.Add(Location);
ExistingRequest->RotationsToPlay.Add(Rotation);
ExistingRequest->VolumeMultiplier.Add(VolumeMultiplier);
ExistingRequest->PitchMultiplier.Add(PitchMultiplier);
}
// 새로 로드를 시작해야 하는 경우
if (!StreamableManager)
{
UE_LOG(LogTemp, Warning, TEXT("StreamableManager가 유효하지 않습니다."))
return;
}
// 에셋 로드가 끝난 뒤 호출될 콜백 바인드
FSoundAsyncLoadRequest NewRequest;
NewRequest.LocationsToPlay.Add(Location);
NewRequest.RotationsToPlay.Add(Rotation);
NewRequest.VolumeMultiplier.Add(VolumeMultiplier);
NewRequest.PitchMultiplier.Add(PitchMultiplier);
FStreamableDelegate StreamableCompleteDelegate = FStreamableDelegate::CreateUObject(this, &ThisClass::OnSoundAsyncLoadComplete, AssetPath);
NewRequest.StreamableHandle.Emplace(StreamableManager->RequestAsyncLoad(AssetPath, StreamableCompleteDelegate));
PendingSoundLoadRequests.Add(AssetPath, NewRequest);
}
void UFXManagerSubsystem::OnSoundAsyncLoadComplete(FSoftObjectPath LoadedAssetPath)
{
FScopeLock Lock(&PendingRequestsLock);
// 로드 완료 후 사운드 재생 시작
USoundBase* LoadedSound = Cast<USoundBase>(LoadedAssetPath.ResolveObject());
if (FSoundAsyncLoadRequest* CompletedRequest = PendingSoundLoadRequests.Find(LoadedAssetPath))
{
if (LoadedSound && GetWorld())
{
for (int32 i = 0; i < CompletedRequest->LocationsToPlay.Num(); ++i)
{
const FVector PlayLocation = CompletedRequest->LocationsToPlay[i];
const FRotator PlayRotation = CompletedRequest->RotationsToPlay[i];
const float PlayVolumeMultiplier = CompletedRequest->VolumeMultiplier[i];
const float PlayPitchMultiplier = CompletedRequest->PitchMultiplier[i];
UGameplayStatics::PlaySoundAtLocation(GetWorld(), LoadedSound, PlayLocation, PlayRotation, PlayVolumeMultiplier, PlayPitchMultiplier);
}
}
else
{
UE_LOG(LogTemp, Warning, TEXT("로딩 후, USoundBase가 유효하지 않거나 월드를 찾을 수 없습니다."));
}
PendingSoundLoadRequests.Remove(LoadedAssetPath);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("로딩 후, 등록된 콜백 요청을 찾을 수 없습니다."));
}
}
여기까지가 새로 선언한 FXManagerSubsystem이다.
// AuraDamageGameplayAbility.cpp
void UAuraDamageGameplayAbility::CauseDamage(AActor* TargetActor)
{
// Context 생성 및 관련 Actor에 추가
if (!EffectContextHandle.Get())
{
EffectContextHandle = GetAbilitySystemComponentFromActorInfo()->MakeEffectContext();
}
AddActorToContext(TargetActor);
...
}
// BlueprintCallable 함수
FGameplayEffectContextHandle UAuraDamageGameplayAbility::GetContext()
{
if (EffectContextHandle.Get())
{
return EffectContextHandle;
}
return FGameplayEffectContextHandle();
}
Ability에 Context 관련 함수들을 추가했고, Context의 Actors는 Weak 포인터기 때문에 블루프린트에서 참조가 불가능하다. 때문에 Cue에서 호출하기 위한 함수가 따로 필요했다.
TArray<AActor*> UAuraAbilitySystemLibrary::GetActorsFromContext(FGameplayEffectContextHandle& EffectContextHandle)
{
TArray<AActor*> Actors;
for (auto Element : EffectContextHandle.GetActors())
{
Actors.Add(Element.Get());
}
return Actors;
}
전역 함수로 선언해서 사용하기 편하게 했다. Context가 가진 Actor를 또 블루프린트 그래프 어딘가에서 사용할 일이 분명 있을 거다.
이제 블루프린트 구현 사진이다.
이전까진 구현이 똑같고, 다른 건 CauseDamage 함수의 내부(Context에 Actor 추가하는 부분)와 Cue를 호출하는 부분이다. TaggedMontage에 있는 SoundTag와 NiagaraTag(순서 중요)를 TagContainer로 묶어서 Cue로 보내줬다. 또, CauseDamage에서 작성된 Context를 GetContext로 열어서 Cue의 매개변수에 넣어줬다.
받아온 Tag로 0번은 사운드, 1번은 나이아가라로 간주해 로직을 실행한다.(반복문 돌려서 MatchTag 쓸 수도 있었는데, 괜히 반복문 돌리기가 싫었다. 원래부터 패킷이란 게 서버와 클라가 변수를 순서대로 정하는 거니까 괜찮지 않을까 싶다.) 사운드 재생은 1번만 해야 하므로(중복되면 증폭되니까) 무기의 소켓 위치로 받아온 Location으로 재생하고, 나이아가라는 Context에 들어있는 Actor를 가져와 해당 Actor들의 Location에서 재생한다. 범위형 공격 Ability에서 모두 쓸 수 있는 범용 Cue로 만들었다.
서버와 클라이언트 모두 잘 작동한다.. FXManagerSubsystem 비동기 로드 설계부터 Cue의 생명 주기로 인한 참조 불가능 문제 같은 난관에 계속해서 부딪혔지만, 결국 해냈다.
ㅡ 추가
PlayNiagaraToTargetAndSound가 너무 긴 것 같아서, Attack_Area 로 변경했다. 범위형 공격을 의미하는 Cue와 태그다.