[UE Net] 캐릭터 공격 구현 개선
- 느린 통신 환경에도 대응되는 캐릭터의 공격 구현의 개선
- 네트웍 최적화를 위한 다양한 고려 사항의 이해
인프런 이득우님의 '언리얼 프로그래밍 Part3 - 네트웍 멀티플레이 프레임웍의 이해' 강의를 참고하였습니다.
😎 [이득우의 언리얼 프로그래밍 Part3 - 네트웍 멀티플레이 프레임웍의 이해] 강의 들으러 가기!
목차
캐릭터 공격 구현의 개선
목표
의도적으로 패킷 랙을 유발시켜서 기존에 구현한 공격 기능의 문제점을 살펴보자.
캐릭터의 공격 구현의 개선하자.
캐릭터의 공격 구현의 문제점
- 클라이언트의 모든 행동은 서버를 거친 후에 수행되도록 설계되어 있음
- 통신 부하가 발생하는 경우 사용자 경험이 나빠짐.
- 만약에 통신 랙이 심한 경우에는 사용자가 공격 명령을 입력해도 애니메이션이 늦게 재생되거나 시각적으로 보여지는 타이밍에서의 판정이 클라이언트와 서버가 서로 일치하지 않는 경우가 발생할 수 있다.
- 의도적으로 패킷 랙을 발생시킨 후 이의 결과를 확인.
DefaultEngine.ini
학습을 위해 DefaultEngine.ini에 위의 키워드를 작성하여 의도적으로 패킷 통신 랙을 유발시킨다.
이렇게 지정하게 되면 에디터에서는 0.5초(=500ms)의 딜레이를 가지고 패킷을 전송한다.
기존에 구현한 공격 기능의 리뷰
랙이 발생하는 경우, 클라이언트의 반응이 많이 느려지는 문제가 발생
- 패킷 랙이 발생하면 입력이 서버로 전달되는 과정에서 시간이 오래 소요됨.
- 서버에서 클라이언트로 다시 전달되는 과정에서 시간이 중복으로 소요됨. 이 중복으로 소요되는 기간 동안에 입력을 넣었지만 애니메이션이 재생하지 않고 대기하고 있는 상황이 발생하게 된다.
- 공력을 입력했는데 서버로 응답이 올 때까지 대기하고 있어야 하는 상황이 발생한다.
서버에서 모든 것을 처리하는 방식이 데이터 관리 측면에서는 안전한 방법일 수도 있지만 통신 상태가 나쁜 상황에서 사용자는 한 박자 늦은 플레이를 진행할 수밖에 없어서 공정한 플레이를 진행하기가 어렵고 정확한 판정을 내기가 어렵다.
캐릭터 공격 구현의 개선
[기본 원칙]
- 클라이언트의 명령은 Server RPC를 사용한다.
- 중요한 게임 플레이 판정은 서버에서 처리한다.
- 게임 플레이에 영향을 주는 중요한 정보는 프로퍼티 리플리케이션을 사용한다.
- 클라이언트의 시각적인 효과(Cosmetic)는 Client RPC와 Multicast RPC를 사용한다.
[개선점]
- 클라이언트에서 처리할 수 있는 기능은 최대한 클라이언트에서 직접 처리하여 반응성을 높인다.
- 최종 판정은 서버에서 진행하되 다양한 로직을 활용해 자세하게 검증한다.
- 네트웍 데이터 전송을 최소화한다.
개선된 공격 기능의 설계
클라이언트의 반응성을 개선하고 서버에서는 클라이언트의 요청을 검증을 통해 구현
- 클라이언트에서 입력 명령 → 서버의 RPC 호출 / 애니메이션 재생 (동시에 진행)
- 애니메이션의 타이밍은 살짝 다르게 동작할 수 있다.
- 중요한 정보는 동기화 되도록 설정 할 것임 → 이를 위해 시간 정보를 활용한다.
- 클라이언트가 Server RPC를 보낼 때 현재 클라이언트의 시간 정보를 보냄
- 서버는 패킷을 받은 시간과 Server RPC를 보낸 시간을 비교해 얼마나 렉이 걸렸는지 측정
- 서버에서 판정을 하는 방법이 아닌 클라이언트에서 판정하는 방법으로 변경
- 서로 동일한 타이밍에 진행해야 공정하다.
- 클라이언트에서 판정을 확정하면 위조의 위험성이 있다. (그렇기 때문에 아래의 과정이 필요하다)
- 클라이언트의 판정 결과를 서버로 보내서 서버에서 판정 결과를 수용할지말지 판단하는 과정을 거친다.
- 서버가 판정 결과를 수용한다면 액터에게 데미지를 전달한다.
서버(=GameMode)의 시간을 가져오는 방법
GetWorld()->GetGameState()->GetServerWorldTimeSeconds();
※ 참고) 서버의 월드와 클라이언트의 월드는 엄연히 서로 다른 월드다.
클라이언트의 월드는 서버를 Simulate한 월드다.
우리는 클라이언트는 늦게 생성하기 때문에 클라이언트의 시간은 서버의 시간보다 늦게 흘러갈 수밖에 없다.
실습 예제
예제 코드 - 공격 및 피격 판정
ABCharacterPlayer.h
#pragma once
#include "CoreMinimal.h"
#include "Character/ABCharacterBase.h"
#include "InputActionValue.h"
#include "Interface/ABCharacterHUDInterface.h"
#include "ABCharacterPlayer.generated.h"
UCLASS()
class ARENABATTLE_API AABCharacterPlayer : public AABCharacterBase, public IABCharacterHUDInterface
{
GENERATED_BODY()
public:
AABCharacterPlayer();
protected:
virtual void BeginPlay() override;
virtual void SetDead() override;
virtual void PossessedBy(AController* NewController) override;
virtual void OnRep_Owner() override;
virtual void PostNetInit() override;
public:
virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
// Character Control Section
protected:
void ChangeCharacterControl();
void SetCharacterControl(ECharacterControlType NewCharacterControlType);
virtual void SetCharacterControlData(const class UABCharacterControlData* CharacterControlData) override;
// Camera Section
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class USpringArmComponent> CameraBoom;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UCameraComponent> FollowCamera;
// Input Section
protected:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> JumpAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> ChangeControlAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> ShoulderMoveAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> ShoulderLookAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> QuaterMoveAction;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = Input, Meta = (AllowPrivateAccess = "true"))
TObjectPtr<class UInputAction> AttackAction;
void ShoulderMove(const FInputActionValue& Value);
void ShoulderLook(const FInputActionValue& Value);
void QuaterMove(const FInputActionValue& Value);
ECharacterControlType CurrentCharacterControlType;
protected:
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
void Attack();
void PlayAttackAnimation();
virtual void AttackHitCheck() override;
void AttackHitConfirm(AActor* HitActor);
void DrawDebugAttackRange(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward);
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCAttack(float AttackStartTime);
UFUNCTION(NetMulticast, Unreliable)
void MulticastRPCAttack();
UFUNCTION(Client, Unreliable)
void ClientRPCPlayAnimation(AABCharacterPlayer* CharacterToPlay);
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyHit(const FHitResult& HitResult, float HitCheckTime);
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyMiss(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime);
UPROPERTY(ReplicatedUsing = OnRep_CanAttack)
uint8 bCanAttack : 1;
UFUNCTION()
void OnRep_CanAttack();
float AttackTime = 1.4667f;
float LastAttackStartTime = 0.0f;
float AttackTimeDifference = 0.0f;
float AcceptCheckDistance = 300.0f;
float AcceptMinCheckTime = 0.15f;
// UI Section
protected:
virtual void SetupHUDWidget(class UABHUDWidget* InHUDWidget) override;
};
함수 추가
- void PlayAttackAnimation();
- UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyHit(const FHitResult& HitResult, float HitChecktime); // 클라이언트에서 무언가 액터에 맞았을 때 서버로 판정을 보내는 함수 - UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyMiss(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitChecktime); - void AttackHitConfirm(AActor* HitActor); // 확인 후 데미지 전달 함수
함수 수정
- UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCAttack(float AttackStartTime); - UFUNCTION(NetMulticast, Unreliable)
void MulticastRPCAttack(); - virtual void AttackHitCheck() override; // 공격 판정
변수 추가
- float LastAttackStartTime = 0.0f; // 마지막에 공격한 시간
- float AttackTimeDifference = 0.0f; // 클라이언트와 서버와의 시간 차이
- float AcceptMinCheckTime = 0.15f; // 최소한 이 시간 만큼은 지난 후에 판정을 진행
ABCharacterPlayer.cpp
#include "Character/ABCharacterPlayer.h"
AABCharacterPlayer::AABCharacterPlayer()
{
// Camera
...
// Input
...
CurrentCharacterControlType = ECharacterControlType::Quater;
bCanAttack = true;
}
void AABCharacterPlayer::BeginPlay()
{
Super::BeginPlay();
APlayerController* PlayerController = Cast<APlayerController>(GetController());
if (PlayerController)
{
EnableInput(PlayerController);
}
SetCharacterControl(CurrentCharacterControlType);
}
void AABCharacterPlayer::SetDead()
{
Super::SetDead();
APlayerController* PlayerController = Cast<APlayerController>(GetController());
if (PlayerController)
{
DisableInput(PlayerController);
}
}
void AABCharacterPlayer::PossessedBy(AController* NewController)
{
AActor* OwnerActor = GetOwner();
Super::PossessedBy(NewController);
OwnerActor = GetOwner();
}
void AABCharacterPlayer::OnRep_Owner()
{
Super::OnRep_Owner();
AActor* OwnerActor = GetOwner();
}
void AABCharacterPlayer::PostNetInit()
{
Super::PostNetInit();
}
void AABCharacterPlayer::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
Super::SetupPlayerInputComponent(PlayerInputComponent);
UEnhancedInputComponent* EnhancedInputComponent = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Triggered, this, &ACharacter::Jump);
//...
}
void AABCharacterPlayer::ChangeCharacterControl()
{
if (CurrentCharacterControlType == ECharacterControlType::Quater)
{
SetCharacterControl(ECharacterControlType::Shoulder);
}
else if (CurrentCharacterControlType == ECharacterControlType::Shoulder)
{
SetCharacterControl(ECharacterControlType::Quater);
}
}
void AABCharacterPlayer::SetCharacterControl(ECharacterControlType NewCharacterControlType)
{
if (!IsLocallyControlled())
{
return;
}
UABCharacterControlData* NewCharacterControl = CharacterControlManager[NewCharacterControlType];
check(NewCharacterControl);
SetCharacterControlData(NewCharacterControl);
APlayerController* PlayerController = CastChecked<APlayerController>(GetController());
if (UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer()))
{
Subsystem->ClearAllMappings();
UInputMappingContext* NewMappingContext = NewCharacterControl->InputMappingContext;
if (NewMappingContext)
{
Subsystem->AddMappingContext(NewMappingContext, 0);
}
}
CurrentCharacterControlType = NewCharacterControlType;
}
void AABCharacterPlayer::SetCharacterControlData(const UABCharacterControlData* CharacterControlData) { // ... }
void AABCharacterPlayer::ShoulderMove(const FInputActionValue& Value) { ... }
void AABCharacterPlayer::ShoulderLook(const FInputActionValue& Value) { ... }
void AABCharacterPlayer::QuaterMove(const FInputActionValue& Value) { ... }
void AABCharacterPlayer::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AABCharacterPlayer, bCanAttack);
}
void AABCharacterPlayer::Attack()
{
//ProcessComboCommand();
if (bCanAttack)
{
if (!HasAuthority()) // 클라이언트
{
bCanAttack = false;
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
FTimerHandle Handle;
GetWorld()->GetTimerManager().SetTimer(Handle, FTimerDelegate::CreateLambda([&]
{
bCanAttack = true;
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
}
), AttackTime, false, -1.0f);
PlayAttackAnimation(); // 클라이언트에서 공격 몽타주 재생
}
ServerRPCAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
}
}
void AABCharacterPlayer::PlayAttackAnimation()
{
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
AnimInstance->StopAllMontages(0.0f);
AnimInstance->Montage_Play(ComboActionMontage);
}
void AABCharacterPlayer::AttackHitCheck() // 공격 판정
{
if (IsLocallyControlled()) // 서버를 포함 플레이어에서 피격 체크를 한다(리슨 서버이니 서버도 플레이어가 있다)
{
FHitResult OutHitResult;
FCollisionQueryParams Params(SCENE_QUERY_STAT(Attack), false, this);
const float AttackRange = Stat->GetTotalStat().AttackRange;
const float AttackRadius = Stat->GetAttackRadius();
const float AttackDamage = Stat->GetTotalStat().Attack;
const FVector Forward = GetActorForwardVector();
const FVector Start = GetActorLocation() + Forward * GetCapsuleComponent()->GetScaledCapsuleRadius();
const FVector End = Start + GetActorForwardVector() * AttackRange;
bool HitDetected = GetWorld()->SweepSingleByChannel(OutHitResult, Start, End, FQuat::Identity, CCHANNEL_ABACTION, FCollisionShape::MakeSphere(AttackRadius), Params);
float HitCheckTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds(); // 서버 기준 피격시간
if (!HasAuthority()) // 클라이언트
{
// 클라이언트의 경우, 서버쪽으로 RPC를 보내서 검증을 거쳐줘야 한다.
if (HitDetected) // 맞았다면
{
ServerRPCNotifyHit(OutHitResult, HitCheckTime);
}
else // 안 맞았다면
{
ServerRPCNotifyMiss(Start, End, Forward, HitCheckTime);
}
}
else // 서버
{
// 리슨 서버의 서버쪽 플레이어는 검증 절차가 필요없다.
FColor DebugColor = HitDetected ? FColor::Green : FColor::Red;
DrawDebugAttackRange(DebugColor, Start, End, Forward);
if (HitDetected)
{
AttackHitConfirm(OutHitResult.GetActor());
}
}
}
}
void AABCharacterPlayer::AttackHitConfirm(AActor* HitActor) // 데미지 전달 함수
{
if (HasAuthority()) // 서버에서만 실행
{
const float AttackDamage = Stat->GetTotalStat().Attack;
FDamageEvent DamageEvent;
HitActor->TakeDamage(AttackDamage, DamageEvent, GetController(), this);
}
}
void AABCharacterPlayer::DrawDebugAttackRange(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward)
{
#if ENABLE_DRAW_DEBUG
const float AttackRange = Stat->GetTotalStat().AttackRange;
const float AttackRadius = Stat->GetAttackRadius();
FVector CapsuleOrigin = TraceStart + (TraceEnd - TraceStart) * 0.5f;
float CapsuleHalfHeight = AttackRange * 0.5f;
DrawDebugCapsule(GetWorld(), CapsuleOrigin, CapsuleHalfHeight, AttackRadius, FRotationMatrix::MakeFromZ(Forward).ToQuat(), DrawColor, false, 5.0f);
#endif
}
bool AABCharacterPlayer::ServerRPCAttack_Validate(float AttackStartTime)
{
if (LastAttackStartTime == 0.0f)
{
return true;
}
return (AttackStartTime - LastAttackStartTime) > AttackTime;
}
void AABCharacterPlayer::ServerRPCAttack_Implementation(float AttackStartTime)
{
AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
bCanAttack = false;
OnRep_CanAttack();
AttackTimeDifference = GetWorld()->GetTimeSeconds() - AttackStartTime;
AB_LOG(LogABNetwork, Log, TEXT("LagTime : %f"), AttackTimeDifference);
AttackTimeDifference = FMath::Clamp(AttackTimeDifference, 0.0f, AttackTime - 0.01f);
FTimerHandle Handle;
GetWorld()->GetTimerManager().SetTimer(Handle, FTimerDelegate::CreateLambda([&]
{
bCanAttack = true;
OnRep_CanAttack();
}
), AttackTime - AttackTimeDifference, false, -1.0f);
LastAttackStartTime = AttackStartTime;
PlayAttackAnimation(); // 서버에서 공격 몽타주 재생
//MulticastRPCAttack();
for (APlayerController* PlayerController : TActorRange<APlayerController>(GetWorld()))
{
if (PlayerController && GetController() != PlayerController)
{
if(!PlayerController->IsLocalController())
{
AABCharacterPlayer* OtherPlayer = Cast<AABCharacterPlayer>(PlayerController->GetPawn());
if (OtherPlayer)
{
OtherPlayer->ClientRPCPlayAnimation(this);
}
}
}
}
}
void AABCharacterPlayer::ClientRPCPlayAnimation_Implementation(AABCharacterPlayer* CharacterToPlay)
{
if (CharacterToPlay)
{
CharacterToPlay->PlayAttackAnimation();
}
}
void AABCharacterPlayer::MulticastRPCAttack_Implementation()
{
if (!IsLocallyControlled())
{
PlayAttackAnimation();
}
}
bool AABCharacterPlayer::ServerRPCNotifyHit_Validate(const FHitResult& HitResult, float HitCheckTime)
{
return (HitCheckTime - LastAttackStartTime) > AcceptMinCheckTime;
}
void AABCharacterPlayer::ServerRPCNotifyHit_Implementation(const FHitResult& HitResult, float HitCheckTime)
{
AActor* HitActor = HitResult.GetActor();
if (::IsValid(HitActor))
{
const FVector HitLocation = HitResult.Location;
const FBox HitBox = HitActor->GetComponentsBoundingBox(); // 피격 받은 캐릭터를 전체로 감싸고 있는 BoundingBox 영역.
const FVector ActorBoxCenter = (HitBox.Min + HitBox.Max) * 0.5f; // BoundingBox의 중점값
if (FVector::DistSquared(HitLocation, ActorBoxCenter) <= AcceptCheckDistance * AcceptCheckDistance)
{
AttackHitConfirm(HitActor); // 피격O
}
else
{
AB_LOG(LogABNetwork, Warning, TEXT("%s"), TEXT("HitTest Rejected!")); // 피격X
}
#if ENABLE_DRAW_DEBUG
DrawDebugPoint(GetWorld(), ActorBoxCenter, 50.0f, FColor::Cyan, false, 5.0f); // 캐릭터 영역 중심
DrawDebugPoint(GetWorld(), HitLocation, 50.0f, FColor::Magenta, false, 5.0f); // 맞은 위치
#endif
DrawDebugAttackRange(FColor::Green, HitResult.TraceStart, HitResult.TraceEnd, HitActor->GetActorForwardVector());
}
}
bool AABCharacterPlayer::ServerRPCNotifyMiss_Validate(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime)
{
return (HitCheckTime - LastAttackStartTime) > AcceptMinCheckTime;
}
void AABCharacterPlayer::ServerRPCNotifyMiss_Implementation(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime)
{
DrawDebugAttackRange(FColor::Red, TraceStart, TraceEnd, TraceDir);
}
void AABCharacterPlayer::OnRep_CanAttack()
{
if (!bCanAttack)
{
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_None);
}
else
{
GetCharacterMovement()->SetMovementMode(EMovementMode::MOVE_Walking);
}
}
void AABCharacterPlayer::SetupHUDWidget(UABHUDWidget* InHUDWidget)
{
// ...
}
실행화면
'⭐ Unreal Engine > UE 개념정리 - Network' 카테고리의 다른 글
[UE Net] 캐릭터 공격 구현 (0) | 2024.04.10 |
---|---|
[UE Net] RPC (Remote Procedure Call) (0) | 2024.03.02 |
[UE Net] 액터 리플리케이션 로우레벨 플로우 Actor Replication - Low Level Flow (0) | 2024.02.12 |
[UE Net] 액터 리플리케이션 빈도와 연관성 Actor Replication Frequency & Relevancy + 언리얼 인사이트 Unreal Insight (0) | 2024.02.10 |
[UE Net] 액터 리플리케이션 Actor Replication (1) | 2024.02.10 |
댓글
이 글 공유하기
다른 글
-
[UE Net] 캐릭터 공격 구현
[UE Net] 캐릭터 공격 구현
2024.04.10 -
[UE Net] RPC (Remote Procedure Call)
[UE Net] RPC (Remote Procedure Call)
2024.03.02 -
[UE Net] 액터 리플리케이션 로우레벨 플로우 Actor Replication - Low Level Flow
[UE Net] 액터 리플리케이션 로우레벨 플로우 Actor Replication - Low Level Flow
2024.02.12 -
[UE Net] 액터 리플리케이션 빈도와 연관성 Actor Replication Frequency & Relevancy + 언리얼 인사이트 Unreal Insight
[UE Net] 액터 리플리케이션 빈도와 연관성 Actor Replication Frequency & Relevancy + 언리얼 인사이트 Unreal Insight
2024.02.10