일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 언리얼엔진
- Replication
- Multiplay
- attribute
- 유니티
- 게임 개발
- 메카님
- 게임개발
- gameplay ability system
- gravity direction
- 언리얼 엔진
- CTF
- gameplay tag
- animation
- ret2libc
- UI
- Unreal Engine
- local prediction
- os
- Aegis
- gameplay effect
- MAC
- dirty cow
- gas
- rpc
- map design
- ability task
- unity
- listen server
- photon fusion2
- Today
- Total
Replicated
** [Drag Down] Local Prediction으로 인한 리슨 서버 이중 처리 문제 with 캐릭터 밀기 물리 처리, 상태 기반 연속 공격 시스템 ** 본문
** [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도 해주자
'언리얼 엔진 > Drag Down (캡스톤 디자인)' 카테고리의 다른 글
** [Drag Down] RPC In Local Prediction (feat. Root Motion) / (서버->클라이언트 애니메이션 동기화 문제) ** (0) | 2025.03.31 |
---|---|
[Drag Down] 점프 공격하기 (0) | 2025.03.30 |
** [Drag Down] GAS Prediction 기반 AnimNotify 처리 ** (1) | 2025.03.27 |
** [Drag Down] GAS Local Prediction ** with 밀기 애니메이션 (0) | 2025.03.26 |
[Drag Down] 애니메이션 애셋 (0) | 2025.03.25 |