Attack GA 로직 변경 이유
최근 언리얼 네트워크를 학습하며 기존 프로젝트를 싱글플레이에서
멀티플레이로 조금씩 리팩토링하는 중이다
그 과정에서 기존 Timer 기반 콤보공격 로직은 별도의 내부 변수의 동기화,
Timer 이후 들어오는 입력 처리(입력 버퍼링 구현)등에 단점이 있어
GAS 시스템의 GameplayTag와 AbilityTask를 활용하여 로직을 변경했다
그 결과로 아래와 같이 Attack GA를 네트워크 동기화에 성공했다
제한없음
10프레임, 제한없음
프레임 제한없음, 500 pktLag
30 프레임, 100 pktLag
기존 Attack GA 로직

기존 Attack GA는 위와 같은 흐름으로
- 유저 공격 입력
- 입력에 바인드된 Attack GA 가 실행중인지 체크
- 실행중이면 InputPressed 함수 실행
- 비활성화이면 ActivateAbility 함수 실행
- ActivateAbility 함수 실행
- AbilityTask PlayMontageAndWait 테스크를 사용해 몽타주 생성
- 첫 공격 몽타주 섹션 실행 및 Combo 카운터 변수 증가
- 몽타주 AbilityTask에 종료함수 바인드
- 타이머 생성 및 타이머 종료함수 바인드, 타이머 실행
- InputPressed 함수 실행
- 타이머가 살아 있으면 HasNextAttackInput = ture
- 타이머가 끝나 있으면 HasNextAttackInput = false
- 타이머 종료
- 타이머 종료함수 실행
- HasNextAttackInput = true 이면 다음 몽타주 섹션 실행
타이머 다시 실행
새로운 Attack GA 로직
변경된 로직 흐름도


기존 Attack GA와 다른점은 크게 세가지로 다음과 같다
- AbilityTask WaitGameplayEvent와 AnimNotify를 이용해서
타이머 로직을 변경하였음 - ServerRPC와 MultiCastRPC를 사용하여 네트워크 지원
- 콤보 로직을 역활 분리로 세분화해 함수로 나눔
새로운 로직의 실행순서는 밑에 네트워크 흐름도에 나와있다
네트워크 흐름도

위에 나와있는 AnimNotify 이벤트와 재입력 이벤트는 서로 순서가 바뀔 수 있다
네트워크를 위해 기존 초기화 로직 수정
기존 로직은 PossessedBy 함수에서 초기화를 진행하였는데
멀티플레이로 변경후 테스트 하는데 입력에 따라 GA가 발동하지 않는 현상이 있어
초기화 로직을 새롭게 변경하였다
void APPGASCharacterPlayer::GASInit()
{
APPGASPlayerState* GASPlayerState = GetPlayerState<APPGASPlayerState>();
if (GASPlayerState)
{
ASC = GASPlayerState->GetAbilitySystemComponent();
if (ASC)
{
ASC->InitAbilityActorInfo(GASPlayerState, this);
ASC->SetIsReplicated(true);
const UPPCharacterAttributeSet* AttributeSet = ASC->GetSet<UPPCharacterAttributeSet>();
if (AttributeSet)
{
AttributeSet->ActorIsDead.AddDynamic(this, &APPGASCharacterPlayer::ActorIsDead);
}
//ASC에 특정태그가 생기거나 제거되면 호출하는 델리게이트에 콜백함수 연결
ASC->RegisterGameplayTagEvent(PPTAG_CHARACTER_ISCC, EGameplayTagEventType::NewOrRemoved).AddUObject(this, &APPGASCharacterPlayer::OnCCTagChanged);
if (HasAuthority())
{
for (const TSubclassOf<UGameplayAbility>& StartAbility : StartAbilites)
{
//ASC는 직접적으로 GA를 접근, 관리하는게 아닌
//FGameplayAbilitySpec 구조체를 통해 간접적으로 관리함
FGameplayAbilitySpec Spec(StartAbility);
ASC->GiveAbility(Spec);
}
for (const TPair<EInputAbility, TSubclassOf<class UGameplayAbility>>& StartInputAbility : StartInputAbilites)
{
FGameplayAbilitySpec Spec(StartInputAbility.Value);
Spec.InputID = (int32)StartInputAbility.Key;
ASC->GiveAbility(Spec);
}
}
}
}
}
위와 같이 기존 초기화 로직을 GASInit 함수로 만들었다
로직 변경점은 게임 어빌리티 부여를 서버 HasAuthority를 가진 곳에서 진행한다
클라이언트는 서버에서 부여된 GA를 정보를 자동으로 동기화하여
클라이언트에서는 GA를 부여하지 않았다
GASInit 함수를 PossessedBy(서버), OnRep_PlayerState(클라이언트) 함수에서 실행시켰다
또한 기존 InputMappingContext 초기화 로직을 SetupPlayerInputComponent 함수에 넣었다
게임 어빌리티의 RPC
정책설정
UPPGA_Attack::UPPGA_Attack()
{
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
//클라이언트 예측
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
//리플리케이션 정책
ReplicationPolicy = EGameplayAbilityReplicationPolicy::ReplicateYes;
}
GA를 네트워크 동기화 하려면 위와 같은 정책들을 설정해야한다
해당 GA는 인스턴스를 액터마다 가지고 실행은 로컬에서 서버 순서로 실행
리플리케이션을 진행한다 설정하였다
ActivateAbility

게임 어빌리티를 TryActivateAbility 함수나 어빌리티에 트리거 태그를 부여하여
SendGameplayEventToActor 로 트리거 태그를 통하여 발동하면 따로 RPC를 사용하지않아도
GAS에서 자동적으로 로컬, 서버 순서로 GA가 실행된다
EndAbility

또한 EndAbility도 마찬가지로 GAS에서 자동적으로 로컬, 서버 순서로 실행된다
InputPressed

하지만 ASC->AbilitySpecInputPressed 함수로 발동하는 InputPressed 함수는
자동적으로 RPC가 호출되지 않아 필요한 경우 ServerRPC를 이용해 InputPressed 함수에서
ServerRPC를 호출해 이벤트가 발동했다고 알려줘야 한다
해당 영상은 InputPressed 발동시 ServerRPC를 호출하여 서버에 알리는 로직을 추가하기 전으로
클라이언트에서 콤보를 진행시 서버는 ActivateAbility 함수만 실행되어 클라이언트의 콤보가 진행중
서버의 공격 어빌리티가 종료되면 클라이언트의 어빌리티도 같이 종료되는 모습을 볼 수 있다
MulticastRPC
MulticastRPC 는 GA안에서 발동하면 오류가 발생하여 언리얼 엔진이 크래쉬가 난다
GA는 서버-클라이언트(오너)만 소유하고있어 시뮬레이티드 프록시는 GA가 존재 하지않는다
그래서 MulticastRPC가 필요한 경우 인터페이스를 이용해 오너액터에서 실행해야 한다
해당 영상은 콤보공격 몽타주 재생을 MulticastRPC로 알리는 로직을 추가하기 전으로
캐릭터가 공격시 Simulated Proxy 액터는 몽타주 재생이 동기화가 안되는걸 볼 수 있다.
가끔 동기화 되는 몽타주는 AnimInstance에서 일정주기마다 동기화하는것으로 추정하고 있다
이 문제가 발생전 ServerRPC를 적용해 로컬 클라이언트 → 서버간의 동기화는 잘작동한다
이유는 GA가 로컬에서 예측실행 및 서버에서도 로컬에서 실행된 GA 이벤트를 RPC를 통해
서버에서도 발동시켰다
구현 코드
ActivateAbility
void UPPGA_Attack::ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData)
{
Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
PPNET_SUBLOG(LogGAS, Log, TEXT("Begin"));
PPCharacter = ActorInfo->AvatarActor.Get();
if (PPCharacter)
{
ComboAttackMontage = PPCharacter->GetComboAttackMontage();
ComboActionData = PPCharacter->GetComboActionData();
}
if (IsValid(ComboAttackMontage))
{
CurrentCombo = 1;
HandleCombo();
}
}
- 필요한 변수들을 초기화하고 HandleCombo 함수를 통해 첫번째 공격을 발동한다
EndAbility
void UPPGA_Attack::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{
PPNET_SUBLOG(LogGAS, Log, TEXT("Begin"));
Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
if (MontageTask.IsValid())
{
MontageTask.Get()->EndTask();
}
if (WaitInputOpenTask.IsValid())
{
WaitInputOpenTask.Get()->EndTask();
}
UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo_Checked();
if (ASC)
{
ASC->RemoveLooseGameplayTag(EventInputOpenTag);
ASC->RemoveLooseGameplayTag(EventInputReceiveTag);
}
ComboAttackMontage = nullptr;
ComboActionData = nullptr;
WaitInputOpenTask = nullptr;
MontageTask = nullptr;
PPCharacter = nullptr;
CurrentCombo = 0;
}
- 생성한 테스크들을 종료 변수들을 초기화 해준다
HandleCombo
void UPPGA_Attack::HandleCombo()
{
FName NextSection = GetNextSection();
UAbilityTask_PlayMontageAndWait* PlayMontageTask = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(this, TEXT("PlayAttack"), ComboAttackMontage, 1.0f, NextSection);
PlayMontageTask->OnCompleted.AddDynamic(this, &UPPGA_Attack::OnCompletedCallback);
PlayMontageTask->OnInterrupted.AddDynamic(this, &UPPGA_Attack::OnInterruptedCallback);
MontageTask = PlayMontageTask;
if (CurrentCombo < ComboActionData->MaxComboCount)
{
UAbilityTask_WaitGameplayEvent* WaitInputOpen = UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(this, PPTAG_EVENT_INPUTOPEN);
WaitInputOpen->EventReceived.AddDynamic(this, &UPPGA_Attack::OnInputOpen);
WaitInputOpen->ReadyForActivation();
WaitInputOpenTask = WaitInputOpen;
}
PlayMontageTask->ReadyForActivation();
if (HasAuthority(&CurrentActivationInfo))
{
PPCharacter->Multicast_SendPlayMontage(NextSection);
}
}
FName UPPGA_Attack::GetNextSection()
{
FName NextSection = *FString::Printf(TEXT("%s%d"), *ComboActionData->MontageSectionNamePrefix, CurrentCombo);
return NextSection;
}
- AbilityTask를 이용하여 몽타주 재생, InputOpen 이벤트를 대기하는
콤보공격을 실행하는 주요함수 - GetNextSection 함수로 재생할 몽타주 이름을 가져온다
- UAbilityTask_PlayMontageAndWait 테스크를 NextSection을 통해 생성한 뒤
델리게이트에 콟잭함수들을 연결한다 - MontageTask에 생성한 테스크를 저장한다
- 콤보공격이 마무리가 아니면 UAbilityTask_WaitGameplayEvent 테스크를
생성 및 저장 후 실행시켜 InputOpen 이벤트를 테스크를 통해 대기한다 - 몽타주 테스크를 실행 시키고 서버에서는 Multicast 인터페이스 함수를 호출하여
오너 액터를 통해 몽타주 재생 멀티캐스트 함수를 실행시킨다- 해당 인터페이스 함수는 if문을 통해 오너액터를 소유한 로컬,
서버에서는 실행되지 않는다
- 해당 인터페이스 함수는 if문을 통해 오너액터를 소유한 로컬,
InputPressed
void UPPGA_Attack::InputPressed(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo)
{
if (ActorInfo->IsNetAuthority())
{
PPNET_SUBLOG(LogGAS, Log, TEXT("Listhen Begin"));
HandleInputReceive();
}
else
{
PPNET_SUBLOG(LogGAS, Log, TEXT("Client Begin"));
ServerRPC_InputReceived();
HandleInputReceive();
}
}
void UPPGA_Attack::ServerRPC_InputReceived_Implementation()
{
PPNET_SUBLOG(LogGAS, Log, TEXT("Begin"));
HandleInputReceive();
}
- ASC→AbilitySpecInputPressed 함수로 실행되는 함수
- ActivateAbility와 달리 함수 실행을 RPC로 서버에 알리지 않는다
그래서 별도로 ServerRPC가 필요하다 - IsNetAuthority(리슨서버 액터)에서는 바로 HandleInputReceive 함수를 실행한다
- 클라이언트는 ServerRPC로 서버의 HandleInputReceive 함수를 실행하고
HandleInputReceive 를 호출하여 자기자신의 인풋 이벤트를 실행한다
HandleInputReceive
void UPPGA_Attack::HandleInputReceive()
{
UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo_Checked();
if (ASC)
{
if (!ASC->HasMatchingGameplayTag(EventInputReceiveTag))
{
ASC->AddLooseGameplayTag(EventInputReceiveTag);
}
if (ASC->HasMatchingGameplayTag(EventInputOpenTag))
{
AdvanceComboAttack(ASC);
}
}
}
- 인풋 이벤트 함수
- 함수가 실행시 ASC에 인풋이벤트태그(EventInputReceiveTag)가 없으면
ASC에 인풋이벤트태그를 부착한다 - 만약 인풋이 들어 왔을때 EventInputOpenTag가 존재하면 콤보공격을 진행한다
- ASC에 태그를 부착함으로 입력을 저장하는 함수
OnInputOpen
void UPPGA_Attack::OnInputOpen(FGameplayEventData Payload)
{
if (WaitInputOpenTask.IsValid())
{
WaitInputOpenTask.Get()->EndTask();
}
UAbilitySystemComponent* ASC = GetAbilitySystemComponentFromActorInfo_Checked();
if (ASC)
{
ASC->AddLooseGameplayTag(EventInputOpenTag);
if (ASC->HasMatchingGameplayTag(EventInputReceiveTag))
{
AdvanceComboAttack(ASC);
}
}
}
- HandleCombo 함수에서 생성한 WaitGameplayEvent 어빌리티 태스크의 콜백함수
- AnimNotify를 통해 해당 이벤트를 발동하여 OnInputOpen 함수가 실행된다
- 해당 애님노티파이를 이용해 다음 공격이 히트판정(애님노티파이를 통해 발동) 전에
발동하는 것을 막아주고 다음공격 발동 시점을 지정할 수 있다 - 함수 실행시 존재하고 있는 테스크를 종료시킨다
- EventInputOpenTag를 부착하고 이미 입력(EventInputReceiveTag)이 존재하면
콤보공격을 진행한다
AdvanceComboAttack
void UPPGA_Attack::AdvanceComboAttack(UAbilitySystemComponent* ASC)
{
ASC->RemoveLooseGameplayTag(EventInputOpenTag);
ASC->RemoveLooseGameplayTag(EventInputReceiveTag);
if (MontageTask.IsValid())
{
MontageTask.Get()->EndTask();
}
if (WaitInputOpenTask.IsValid())
{
WaitInputOpenTask.Get()->EndTask();
}
CurrentCombo = FMath::Clamp(CurrentCombo + 1, 1, ComboActionData->MaxComboCount);
HandleCombo();
}
- 다음 콤보공격 진행 함수
- ASC에 부착된 EventTag들을 제거한다
- 실행중인 Task가 있으면 종료한다
- CurrentCombo를 갱신시킨후 HandleCombo함수를 호출하여
다음 콤보를 진행한다
'포트폴리오 제작 > Project_P' 카테고리의 다른 글
| Project_P 플레이어 HP Bar 개선 (어트리뷰트셋 동기화) (0) | 2025.12.19 |
|---|---|
| Project_P 공격판정 GA 개선 (네트워크 지원) (2) | 2025.12.12 |
| Project_P 몬스터 AI 개선(행동트리 테스크를 활용하여 몬스터 회전) (0) | 2025.06.04 |
| Project_P AI Perception Damage Sense 적용 (0) | 2025.05.30 |
| Project_P Damage UI 제작(4) 플레이어 컨트롤러에서 UI 생성 (0) | 2025.03.27 |