Replicated

** [Drag Down] Local Prediction으로 인한 리슨 서버 이중 처리 문제 with 캐릭터 밀기 물리 처리, 상태 기반 연속 공격 시스템 ** 본문

언리얼 엔진/Drag Down (캡스톤 디자인)

** [Drag Down] Local Prediction으로 인한 리슨 서버 이중 처리 문제 with 캐릭터 밀기 물리 처리, 상태 기반 연속 공격 시스템 **

라구넹 2025. 3. 29. 20:45

처음엔 공격을 콤보로 사용하게 할까 했는데,

팀원과 협의를 해보니 이게 격투 게임도 아니고 액션에 과하게 치중해서 장르가 너무 불분명할 것 같다는 피드백을 들었다.

 

그래서 고민한 결과, 다음과 같은 시스템을 만들기로 정했다.

공격1을 하면 그 이후엔 몇 초가 지나든 공격2가 발동 (내부적으로 Idx 관리)

각각의 공격은 그리고 강도와 범위가 다르고, 하이킥 공격이 있는데 그건 Z축까지 힘을 준다

 

또한, 별개로 점프 상태에서 공격 시 매우 강력한 공격을 발동한다

이 경우 스테미나 소비가 크다

 

이걸 데이터 에셋을 사용해 관리해보자

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "DDStateDrivenAttackData.generated.h"

/**
 * 
 */
UCLASS()
class DRAGDOWN_API UDDStateDrivenAttackData : public UDataAsset
{
	GENERATED_BODY()
	
public:
	UPROPERTY(EditAnywhere, Category = "Name")
	FString MontageSectionNamePrefix;

	UPROPERTY(EditAnywhere, Category = "Name")
	uint8 MaxStateCount;

	UPROPERTY(EditAnywhere, Category = "ComboData")
	TArray< float > AttackPower;

	UPROPERTY(EditAnywhere, Category = "ComboData")
	TArray< float > ZPower;
};

 

일단 데이터 애셋은 설정 완료

현재 캐릭터 공격 상태는 ActorComponent에서 관리하자

물론 가능하면 GAS는 ASC 내부에서 관리하도록 한다

하지만, 하나의 GA에서 공격1 -> 공격2 -> 공격3.. 관리하는데, 공격1이 끝난 다음엔 시간이 얼마나 지나든, 반드시 다음 공격은 공격2가 발동되는 방식이며 그리고 각 공격은 상대방을 밀 수 있다. 그런데 각 공격마다 미는 힘의 크기가 다르다.

 

이런 상황에서는

 

  • 책임 분리: 공격 콤보 순서와 밀어내는 힘 같은 물리적 파라미터를 전담하는 액터 컴포넌트를 별도로 구현하면, GA는 단순히 콤보 진행 로직에 집중 가능
  • 유연한 파라미터 관리: 각 공격마다 다른 밀어내는 힘 값을 액터 컴포넌트에 정의해두면, GA에서 값을 참조하여 적용하는 방식으로 쉽게 확장하거나 수정 가능
  • 재사용성: 여러 캐릭터나 상황에서 동일한 공격 콤보 로직을 사용할 때, 액터 컴포넌트만 잘 구성해두면 GA의 코드를 변경할 필요 없이 컴포넌트의 설정만 조정하면 됨

이러한 장점들이 있어, 차라리 ASC를 조금 벗어나더라도 액터 컴포넌트를 만드는게 더 구조적으로 이득임.

 

 

캐릭터에 Idx만 두고 관리할 수도 있겠지만, 그건 개발 시간이 굉장히 촉박할 때나 할 것 같다.

 

 

// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "DDAttackStateComponent.generated.h"


UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class DRAGDOWN_API UDDAttackStateComponent : public UActorComponent
{
	GENERATED_BODY()

public:	
	UDDAttackStateComponent();
	
	FORCEINLINE int8 GetAttackState() { return NowAttackState; }
	FORCEINLINE void PlusAttackState() { 
		NowAttackState = (NowAttackState + 1) % MaxAttackState;
		UE_LOG(LogTemp, Log, TEXT("PlusAttackState - %d, %d"), NowAttackState, MaxAttackState);
	}

	FString GetSectionPrefix();
	float GetPower();
	float GetZPower();

private:
	UPROPERTY()
	TObjectPtr < class UDDStateDrivenAttackData > StateDrivenAttackData;

	int8 NowAttackState;
	int8 MaxAttackState;
};

// Fill out your copyright notice in the Description page of Project Settings.


#include "ActorComponent/DDAttackStateComponent.h"
#include "DataAsset/DDStateDrivenAttackData.h"

// Sets default values for this component's properties
UDDAttackStateComponent::UDDAttackStateComponent()
{
	NowAttackState = 0;

	static ConstructorHelpers::FObjectFinder<UDDStateDrivenAttackData> DataAssetRef(TEXT("/Script/DragDown.DDStateDrivenAttackData'/Game/Blueprint/DataAsset/DDDA_DirevenAttackData.DDDA_DirevenAttackData'"));

	if ( DataAssetRef.Succeeded() )
	{
		StateDrivenAttackData = DataAssetRef.Object;
		MaxAttackState = DataAssetRef.Object.Get()->MaxStateCount;
	}
}

FString UDDAttackStateComponent::GetSectionPrefix()
{
	if (StateDrivenAttackData) return StateDrivenAttackData->MontageSectionNamePrefix;
	return FString();
}

float UDDAttackStateComponent::GetPower()
{
	if (StateDrivenAttackData) return StateDrivenAttackData->AttackPower[NowAttackState];
	return 0.0f;
}

float UDDAttackStateComponent::GetZPower()
{
	if (StateDrivenAttackData) return StateDrivenAttackData->ZPower[NowAttackState];
	return 0.0f;
}

NowAttackState 기반으로 DataAsset에서 해당 콤보의 데이터를 관리한다

 

void UDDGA_PushingCharacter::OnTraceResultCallback(const FGameplayAbilityTargetDataHandle& TargetDataHandle)
{
	UE_LOG(LogDD, Log, TEXT("OnTraceResultCallback"));

	int32 Idx = 0;
	while ( UAbilitySystemBlueprintLibrary::TargetDataHasHitResult(TargetDataHandle, Idx) )
	{
		FHitResult HitResult = UAbilitySystemBlueprintLibrary::GetHitResultFromTargetData(TargetDataHandle, Idx);

		ACharacter* Character = Cast<ACharacter>(HitResult.GetActor());
		if (Character)
		{
			if (AttackStateComponent == nullptr) return;

			FVector LaunchDirection = AvatarCharacter->GetController()->GetControlRotation().Vector(); // 내가 바라보는 방향

			FVector LaunchVelocity = LaunchDirection * AttackStateComponent->GetPower();
			LaunchVelocity.Z += AttackStateComponent->GetZPower();

			Character->LaunchCharacter(LaunchVelocity, true, true);
		}

		UE_LOG(LogDD, Log, TEXT("HitResult : %s"), *HitResult.GetActor()->GetName());

		++Idx;
	}
	
	AttackStateComponent->PlusAttackState(); 
}

GA에선 해당 값을 가져와서 캐릭터가 바라보는 방향으로 힘을 가한다

ZPower는 하이킥 같은 공격을 위해 추가해줬다

 

void UDDGA_PushingCharacter::ActivateAbility(const FGameplayAbilitySpecHandle Handle,
	const FGameplayAbilityActorInfo* ActorInfo, 
	const FGameplayAbilityActivationInfo ActivationInfo, 
	const FGameplayEventData* TriggerEventData)
{
	Super::ActivateAbility(Handle, ActorInfo, ActivationInfo, TriggerEventData);
	
	AvatarCharacter = Cast<ACharacter>(ActorInfo->AvatarActor.Get());
	if ( AvatarCharacter )
	{
		AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
		AttackStateComponent = AvatarCharacter->GetComponentByClass<UDDAttackStateComponent>();
	}

	if (AttackStateComponent == nullptr) return;

	FString SectionString = AttackStateComponent->GetSectionPrefix() + FString::FromInt(AttackStateComponent->GetAttackState());
	FName SectionName(*SectionString);

	// Montage task
	UAbilityTask_PlayMontageAndWait* MontageTask = UAbilityTask_PlayMontageAndWait::CreatePlayMontageAndWaitProxy(
		this,
		NAME_None,
		PushingMontage,     // UAnimMontage* 타입
		1.0f,               // 플레이 속도
		SectionName,          // Start Section
		false               // Stop when ability ends
	);

	MontageTask->OnCompleted.AddDynamic(this, &UDDGA_PushingCharacter::OnMontageCompleted);
	MontageTask->ReadyForActivation();

	// Wait task
	UAbilityTask_WaitGameplayEvent* EventTask = UAbilityTask_WaitGameplayEvent::WaitGameplayEvent(
		this,
		FGameplayTag::RequestGameplayTag(FName("Event.PushTrigger"))
	);

	EventTask->EventReceived.AddDynamic(this, &UDDGA_PushingCharacter::OnPushingEventReceived);
	EventTask->ReadyForActivation();
}

void UDDGA_PushingCharacter::OnMontageCompleted()
{
	AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);

	bool bReplicatedEndAbility = true;
	bool bWasCancelled = false;
	EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, bReplicatedEndAbility, bWasCancelled);
}

또한, 공격 중에는 움직이지 못하도록 설정한다

 

근데 이러니까 문제가 하나 발생

공격1(두번째 공격)에서 클라이언트는 AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking); 가 제대로 발생하지 않는 듯 하다

 

void UDDGA_PushingCharacter::OnMontageCompleted()
{
	AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);

	UE_LOG(LogDD, Log, TEXT("[NetMode %d] OnMontageCompleted"), GetWorld()->GetNetMode());

	bool bReplicatedEndAbility = true;
	bool bWasCancelled = false;
	EndAbility(CurrentSpecHandle, CurrentActorInfo, CurrentActivationInfo, bReplicatedEndAbility, bWasCancelled);
}

한 번 로그를 찍어보자

 

공격1에서는 확실히 OnMontageCompleted가 서버에서 호출이 안됐다.

 

찾아보니 LocalPrediction 시 필요 없다고 판단되면 그냥 생략해버리기도 하는 듯

 

void UDDGA_PushingCharacter::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{
	Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);

	AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
}

기억 상에 EndAbility라는게 있던 거 같아서 찾아보고 쓰니까 성공

 

근데 문제가 하나 더 있다

하이킥 같이 높이 차야하는게 뭔가 이상하게 작동한다

 

음수가 나오는 걸 보니, 알 것 같다

 

ZPower 더하기 전 0으로 만들어줘야 한다

 

각각 공격0과 공격1을 클라이언트에서 진행한 것이다

 

공격0~4까지 클라이언트에서 실행한 결과다

클라이언트에서 실행 시 서버에서 두 번 발동한다?

뭘 놓친 걸까?

 

일단 어빌리티 발동은 각각 한 번만 된다

 

 

찾아보니까 리슨 서버 + GAS Local Prediction 문제인 거 같다

호스트가 서버인 동시에 클라이언트라 같은 능력에 대해 두 번 처리되는 경우가 있음..

진짜 어이가 없는 구조긴 한데.. ActivateAbility -> 로직 두번 수행 -> EndAbility가 된다

왜냐면 bIsTraced 변수를 넣어서 해결이 되었기 때문이다

 

void UDDGA_PushingCharacter::OnTraceResultCallback(const FGameplayAbilityTargetDataHandle& TargetDataHandle)
{
	if (bIsTraced) return;
	bIsTraced = true;


void UDDGA_PushingCharacter::EndAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateEndAbility, bool bWasCancelled)
{
	Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
	bIsTraced = false;
	AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
}

 

EndAbility에서 bIsTraced false처리 해줘야 한다

InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor을 해서 계속 돌려 쓰는 듯

아니 근데 이럴 거면 그냥 어빌리티 자체적으로 변수 관리해서 콤보 진행을 저장하는게 낫지 않나 싶기도 한데,

어빌리티는 그냥 개별 공격만 담당하고 공격 상태 관리는 캐릭터에 달린 액터 컴포넌트가 하는게 확장성에는 더 맞는 거 같다

 

 

 

* ScopedPredictionWindow?

어지간한 건 자동으로 Prediction Key 붙어서 알아서 자동 검증 및 롤백이 됨

근데 예측 범위 밖의 것이 있음

예를 들어 어빌리티 외부 함수 쓰는 거..?

void UDDGA_PushingCharacter::OnTraceResultCallback(const FGameplayAbilityTargetDataHandle& TargetDataHandle)
{
	if (bIsTraced) return;
	bIsTraced = true;
	UE_LOG(LogDD, Log, TEXT("OnTraceResultCallback"));

	UAbilitySystemComponent* ASC = CurrentActorInfo->AbilitySystemComponent.Get();
	if (ASC)
	{
		FScopedPredictionWindow ScopedPrediction(ASC, !AvatarCharacter->HasAuthority());
		ProcessPush(TargetDataHandle);
		AttackStateComponent->PlusAttackState();
	}
}

만약 롤백되면

AttackStateComponent->PlusAttackState(); 실행되었던 것도 롤백되어야 해서 명시적으로 범위에 포함시켜 줘야 함

 

FScopedPredictionWindow ScopedPrediction(ASC, !AvatarCharacter->HasAuthority());의 두번째 인자는 롤백 가능하게 할 거냐는 인자.. 클라이언트는 롤백 가능하도록 해줌

 

혹은 그냥 명시적으로 표기하고 싶을 때 써주면 됨

FScopedPredictionWindow는 RAII 패턴을 따르는 객체인데
생성될 때 예측 창(예측 범위)을 열고, 소멸될 때 자동으로 닫아 리소스 관리를 수행해서 그냥 같은 스코프에만 만들어주면 됨.

 

자 이제 롤백 시 CancelAbility가 발동할테니 그거 처리까지 해보자

void UDDGA_PushingCharacter::CancelAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo, const FGameplayAbilityActivationInfo ActivationInfo, bool bReplicateCancel)
{
	if ( MontageTask && MontageTask->IsActive() )
	{
		MontageTask->EndTask();
	}

	if ( EventTask && EventTask->IsActive() )
	{
		EventTask->EndTask();
	}

	// for Local Prediction Role Back
	if (AvatarCharacter)
	{
		AvatarCharacter->GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
	}

	bIsTraced = false;

	Super::CancelAbility(Handle, ActorInfo, ActivationInfo, bReplicateCancel);
}

몽타주 끝내고 이동 가능하게 변동하고, 이건 안해도 상관없긴 한데(액티베이트에서도 해줌) bIsTraced도 해주자