728x90
반응형
- 정리
- 네트웍 멀티플레이를 클라이언트의 반응성을 높이는 구조로 설계하는 방법의 학습
- 강화된 클라이언트 역할에 맞서 서버쪽 검증 처리를 강화하는 방법의 학습
- 네트웍 통신 전송양을 줄이는 여러 기법의 학습
- 현재 코드의 캐릭터 공격 구현의 문제점
- 클라이언트의 모든 행동은 서버를 거친 후에 수행되도록 설계되어 있음
- 통신 부하가 발생하는 경우 사용자 경험이 나빠짐
- 의도적으로 패킷 렉을 발생 시킨 후 결과 확인
[PacketSimulationSettings]
PktLag = 500
- DefaultEngine.ini
- 기존에 구현한 공격 기능의 리뷰
- 렉이 발생하는 경우 클라이언트의 반응이 많이 느려지는 문제가 발생
- 서버로부터 입력에 대한 응답이 올 때까지 대기하고 있어야한다.
- 서버에서 모든 것을 처리하는 방식이 데이터 관리 측면에서는 안전하지만, 통신 상태가 나쁘다면 사용자는 한 박자 늦은 플레이를 진행할 수 밖에 없어서 정확한 판정을 내기 어렵다.
- 캐릭터 공격 구현의 개선을 위해서
- 기본 원칙
- 클라이언트의 명령은 Server RPC를 사용
- 중요한 게임 플레이 판정은 서버에서 처리
- 게임 플레이에 영향을 주는 중요한 정보는 프로퍼티 리플리케이션을 사용
- 클라이언트의 시각적인 효과(Cosmetic)는 Client RPC와 Multicast RPC를 사용
- 개선점
- 클라이언트에서 처리할 수 있는 기능은 최대한 클라이언트에서 직접 처리하여 반응성 높이기
- 최종 판정은 서버에서 진행하되 다양한 로직을 활용해 자세하게 검증
- 네트웍 데이터 전송을 최소화
- 기본 원칙
- 개선된 공격 기능의 설계
- 클라이언트의 반응성을 개선, 서버에서는 클라이언트의 요청을 검증을 통해 구현
- 서버는 패킷을 받으면 클라이언트와 동일한 작업을 수행, 이 과정에서 렉이 있기 때문에 한박자 늦게 시작할 것임
- 그렇기 때문에 패킷을 전달할 때 클라이언트는 시간 정보를 함께 전달해 서버는 받은 시간과 계산하여 얼마나 렉이 걸렸는지를 측정한다.
- 이를 반영하여 최대한 클라이언트와 동일한 타이밍에 서버에서의 공격이 마치도록 구성
- 판정도 클라이언트에서 판정을 진행한다. 하지만 확정은 x. 판정 결과를 서버로 보내고 서버에서 이를 수용할지말지 판단하도록
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();
// 판정을 위해 서버로 보낼 RPC
// Hit판정 RPC
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyHit(const FHitResult& HitResult, float HitCheckTime);
// Miss판정 RPC
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyMiss(FVector TraceStart, FVector TraceEnd, FVector 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;
- ABCharacterPlayer.h
void AABCharacterPlayer::Attack()
{
//ProcessComboCommand();
if (bCanAttack)
{
// RPC 함수를 바로 실행하는 것이 아니라 / 서버와 클라이언트 부분을 구분해서 애니메이션과 공격을 바로 실행할 수 있도록 구현
// 기존에 MulticastRPC에 있던 애니메이션과 공격 로직을 가져와준다.
// 클라이언트
if (!HasAuthority())
{
// bCanAttack을 변수에서 프로퍼티로 승격시켜 서버에서 해당 값을 바꿀 때 자동으로 모든 클라이언트에 바뀐 값이 전송되도록
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();
}
// 현재 월드의 시간을 RPC를 통해 같이 보내주기
// 주의할 점. 서버와 클라이언트의 월드는 서로 다른 월드이다. 클라이언트는 서버보다 늦게 생성하기 때문에 서버보다 늦게 흘러갈 수밖에 없다. 그렇기 때문에 서버의 시간을 가져와서 넘겨줘야 한다.
ServerRPCAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
}
}
void AABCharacterPlayer::PlayAttackAnimation()
{
UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
AnimInstance->StopAllMontages(0.0f);
AnimInstance->Montage_Play(ComboActionMontage);
}
// 공격에 대한 판정
void AABCharacterPlayer::AttackHitCheck()
{
// 서버에서만 동작하도록
// 소유권을 가진 클라이언트에서 동작하도록 변경
// HasAuthority()에서 IsLocallyControlled()로 변경
if (IsLocallyControlled())
{
AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
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 + Forward * AttackRange;
bool HitDetected = GetWorld()->SweepSingleByChannel(OutHitResult, Start, End, FQuat::Identity, CCHANNEL_ABACTION, FCollisionShape::MakeSphere(AttackRadius), Params);
float HitCheckTime = GetWorld()->GetGameState()->GetServerWorldTimeSeconds();
// 클라 / 서버
// 클라이언트의 경우 서버 쪽으로 보내 검증을 거쳐줘야 한다.
// 서버의 경우 그냥 검증해주면 된다.
// 클라
if (!HasAuthority())
{
if (HitDetected)
{
// Hit 판정
ServerRPCNotifyHit(OutHitResult, HitCheckTime);
}
else
{
// Miss 판정
ServerRPCNotifyMiss(Start, End, Forward, HitCheckTime);
}
}
// 서버
else
{
FColor DebugColor = HitDetected ? FColor::Green : FColor::Red;
DrawDebugAttackRange(DebugColor, Start, End, Forward);
if (HitDetected)
{
AttackHitConfirm(OutHitResult.GetActor());
}
}
}
}
// Attack에 대한 Hit 판정 수용
void AABCharacterPlayer::AttackHitConfirm(AActor* HitActor)
{
AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
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
}
// ServerRPCNotifyHit 검증
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))
{
// Hit한 위치와 Hit된 액터의 히트 박스를 가져와 맞았는지 검증하기
const FVector HitLocation = HitResult.Location;
const FBox HitBox = HitActor->GetComponentsBoundingBox();
const FVector ActorBoxCenter = (HitBox.Min + HitBox.Max) * 0.5f;
if (FVector::DistSquared(HitLocation, ActorBoxCenter) <= AcceptCheckDistance * AcceptCheckDistance)
{
AttackHitConfirm(HitActor);
}
else
{
AB_LOG(LogABNetwork, Warning, TEXT("%s"), TEXT("HitTest Rejected!"));
}
#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());
}
}
// ServerRPCNotifyMiss 검증
bool AABCharacterPlayer::ServerRPCNotifyMiss_Validate(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
{
return (HitCheckTime - LastAttackStartTime) > AcceptMinCheckTime;
}
void AABCharacterPlayer::ServerRPCNotifyMiss_Implementation(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
{
DrawDebugAttackRange(FColor::Red, TraceStart, TraceEnd, TraceDir);
}
// ServerRPCAttack 검증
bool AABCharacterPlayer::ServerRPCAttack_Validate(float AttackStartTime)
{
// 검증 강화
if (LastAttackStartTime == 0.0f)
{
return true;
}
return (AttackStartTime - LastAttackStartTime) > AttackTime;
}
// 신호를 받은 서버는 ServerRPCAttack_Implementation를 실행하게 된다.
void AABCharacterPlayer::ServerRPCAttack_Implementation(float AttackStartTime)
{
AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
// Multicast에서 진행 했던걸 해당 부분에서 진행
//
// bCanAttack을 변수에서 프로퍼티로 승격시켜 서버에서 해당 값을 바꿀 때 자동으로 모든 클라이언트에 바뀐 값이 전송되도록
bCanAttack = false;
// OnRep 이벤트 함수의 경우 서버에서는 실행되지 않고, 클라이언트에서만 실행됨.
// 그렇기 때문에 서버에서는 자동으로 호출되지않기 때문에 명시적으로 호출해줘야 함
// 클라이언트에서는 모션을 재생했기 때문에
// 서버 RPC에서는 서버에서 해야 될 일을 진행해주면 된다.
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();
// 서버는 서버와 모든 클라이언트에게 명령을 보내도록
// MulticastRPC의 경우 게임에 중요한 내용보다는 무관한 어떤 효과들을 재생하는데 사용하는 것이 좋다.
MulticastRPCAttack();
}
// Multicast RPC
// 서버는 로컬로 실행이 되기 때문에 바로 호출, 다른 모든 클라이언트는 네트워크를 통해서 패킷이 전송되면 함수가 호출
void AABCharacterPlayer::MulticastRPCAttack_Implementation()
{
// 다른 클라이언트의 프록시로써 동작하는 캐릭터 플레이어에 대해서만 어택 애니메이션을 구동시키도록
if (!IsLocallyControlled())
{
PlayAttackAnimation();
}
}
- ABCharacterPlayer.cpp
- Attack()
- RPC함수를 바로 실행하지 않고, MulticastRPC함수에 있던 애니메이션과 공격 로직을 가져와준다.
- 서버와 클라이언트를 구분해서 구현해주고, 서버와 클라이언트는 서로 다른 월드이고 클라이언트는 서버보다 늦게 생성되기 때문에 ServerRPCAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds()); 를 통해 ServerRPCAttack에 서버 시간을 넘겨준다.
- PlayAttackAnimation()
- AttackAnimation을 플레이하는 함수
- AttackHitCheck()
- 공격에 대한 판정을 구현
- HasAuthority()에서 IsLocallyControlled()로 변경해줌으로 소유권을 가진 클라이언트에서 동작하도록 변경해준다.
- 클라이언트와 서버 나눠서 구현해준다. 클라이언트의 경우 Hit 판정을 위해 서버 쪽으로 RPC를 보내 검증을 거쳐줘야 한다. 서버의 경우 그냥 검증만 해주면 된다.
- AttackHitConfirm(AActor* HitActor)
- Hit 판정에 대한 검증을 마치고 판정을 수용한다.
- DrawDebugAttackRange(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward)
- DrawDebug기능을 자주 쓰다 보니 따로 함수로 빼줘서 사용
- ServerRPCNotifyHit_Validate(const FHitResult& HitResult, float HitCheckTime)
- ServerRPCNotifyHit에 대한 검증을 한다.
- ServerRPCNotifyHit_Implementation(const FHitResult& HitResult, float HitCheckTime)
- Hit에 대해 서버쪽으로 RPC를 보내는 함수. Hit한 위치와 Hit된 액터의 히트박스를 가져와 맞았는지를 검증한다.
- AccepetCheckDistance변수를 임의로 설정하여 HitLocation에서 ActorBoxCenter까지의 거리가 더 가까우면 맞았다고 판정
- ServerRPCNotifyMiss_Validate(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
- Miss 판정도 검증
- ServerRPCNotifyMiss_Implementation(FVector TraceStart, FVector TraceEnd, FVector TraceDir, float HitCheckTime)
- Miss 판정이면 Color.Red로 DrawDebug
- MulticastRPCAttack_Implementation()
- 다른 클라이언트의 프록시로써 동작하는 캐릭터 플레이어에 대해서만 어택 애니메이션을 구동시켜준다.
- ServerRPCAttack_Implementation()
- MulticastRPC에서 진행했던 부분을 진행한다. 또한, 클라이언트에서는 모션을 재생했기 때문에 서버 RPC는 서버에서 해야 될 일을 진행해준다.
- 서버에서는 공격 시간이 클라이언트와 차이가 있기 때문에 클라이언트로부터 패킷을 받은 시간과 서버의 시간의 차이를 반영해주고 마지막으로 공격한 시간을 저장해준다.
- ServerRPC에서는 중요한 정보를, MulticastRPC의 경우 중요한 정보들보다는 무관한 어떤 효과들을 재생하는데 사용하는 것이 좋다.
- ServerRPCAttack_Validate(float AttackStartTime)
- 공격을 시작한 시간을 인풋으로 받아 검증을 해준다.
- 모든 판정은 서버에서 판정하고, DrawDebug되는 모습. 렉은 걸리지만 판정에 있어서 꽤나 공정해졌다.
- 최적화를 위한 몇 가지 방법
// ...
// Miss판정 RPC
// RPC를 전달할 때 FVector 구조체를 전달하고 있는데, 12바이트씩 소모. 정말 정밀한 경우 아닌 이상 조금 더 작은 사이즈로 전달할 수 있도록 최적화 할 수 있다.
// FVector -> FVector_NetQuantize
// ForwardVector의 경우 Normal값으로
UFUNCTION(Server, Reliable, WithValidation)
void ServerRPCNotifyMiss(FVector_NetQuantize TraceStart, FVector_NetQuantize TraceEnd, FVector_NetQuantizeNormal TraceDir, float HitCheckTime);
// ...
- ABCharacterPlayer.h
- ServerRPCNotifyMiss에서 원래는 FVector 구조체를 RPC로 전달하는데, 이의 경우 floatx3의 총 12바이트를 전송하게 된다. 네트워크의 부담을 줄이기 위해서 언리얼에서는 FVector_NetQuantize라는 구조체를 지원한다.
- 정말 정밀한 경우가 아닌 이상 조금 더 작은 사이즈인 FVector_NetQuantize로 전달하면 네트워크 전달에 대한 최적화를 진행할 수 있다.
// ...
// 신호를 받은 서버는 ServerRPCAttack_Implementation를 실행하게 된다.
void AABCharacterPlayer::ServerRPCAttack_Implementation(float AttackStartTime)
{
AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
// Multicast에서 진행 했던걸 해당 부분에서 진행
// bCanAttack을 변수에서 프로퍼티로 승격시켜 서버에서 해당 값을 바꿀 때 자동으로 모든 클라이언트에 바뀐 값이 전송되도록
bCanAttack = false;
// OnRep 이벤트 함수의 경우 서버에서는 실행되지 않고, 클라이언트에서만 실행됨.
// 그렇기 때문에 서버에서는 자동으로 호출되지않기 때문에 명시적으로 호출해줘야 함
// 클라이언트에서는 모션을 재생했기 때문에
// 서버 RPC에서는 서버에서 해야 될 일을 진행해주면 된다.
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();
// 서버는 서버와 모든 클라이언트에게 명령을 보내도록
// MulticastRPC의 경우 게임에 중요한 내용보다는 무관한 어떤 효과들을 재생하는데 사용하는 것이 좋다.
for (APlayerController* PlayerController : TActorRange<APlayerController>(GetWorld()))
{
// 시뮬레이션 프록시인가?
if (PlayerController && GetController() != PlayerController)
{
// PlayerController가 Local이면 이미 재생했기 때문에 스킵
// 여기서 걸러지는 녀석은 서버도 아니고 공격 명령을 내린 플레이어도 아닌 폰을 재생하는 플레이어 컨트롤러
if (!PlayerController->IsLocalController())
{
AABCharacterPlayer* OtherPlayer = Cast<AABCharacterPlayer>(PlayerController->GetPawn());
if (OtherPlayer)
{
OtherPlayer->ClientRPCPlayAnimation(this);
}
}
}
}
}
// ClientRPC
void AABCharacterPlayer::ClientRPCPlayAnimation_Implementation(AABCharacterPlayer* CharacterToPlay)
{
AB_LOG(LogABNetwork, Log, TEXT("%s"), TEXT("Begin"));
if (CharacterToPlay)
{
CharacterToPlay->PlayAttackAnimation();
}
}
// ...
- ABCharacterPlayer.cpp
- 신호를 받아 서버는 ServerRPCAttack_Implementation를 실행하게 되어 있는데, 이때 다른 클라이언트들에게 애니메이션을 재생하도록 하기 위해 MulticastRPCAttack을 사용했는데, 서버의 경우 이미 재생을 했기 때문에 서버는 필요가 없다.
- 그리고 서버에서 MulticastRPCAttack를 구현하게 되면 서버에서 모든 클라이언트에게로 패킷이 전송될 것이다. 다만 MulticastRPCAttack에서도 IsLocallyControlled를 통해 걸러줬다고 해도, 서버에서 해당 클라이언트쪽으로 패킷이 전송된다.
- 이와 같이 한정된 공간, 한정된 플레이어를 사용하는 경우 클라이언트 RPC를 사용하는 게 좋을 수도 있다.
- Log를 보면 Server에서는 각 Client로 ClientRPC가, Client에서는 ServerRPC와 다른 클라이언트에게는 ClientRPC가 실행되는 모습을 볼 수 있다.
- 만약 MulticastRPC였다면 모든 클라이언트에 MulticastRPC가 전송되었을 것이다.
해당 포스트는 인프런의 <이득우의 언리얼 프로그래밍 Part3 - 네트웍 멀티플레이어 프레임웍의 이해>
강의를 수강하고 정리한 내용입니다.
이득우의 언리얼 프로그래밍 Part3 - 네트웍 멀티플레이 프레임웍의 이해 강의 | 이득우 - 인프런
이득우 | 또 하나의 언리얼 엔진이라고도 불리는 네트웍 멀티플레이어 프레임웍을 학습합니다. 네트웍 멀티플레이어 게임을 제작할 때 반드시 알아야 하는 주요 개념, 내부 동작 원리, 최적화
www.inflearn.com
728x90
'공부 > 이득우의 언리얼 프로그래밍' 카테고리의 다른 글
[Study] Part 3 - 물리 움직임 리플리케이션 (12/15) (0) | 2024.08.29 |
---|---|
[Study] Part 3 - 움직임 리플리케이션 (11/15) (3) | 2024.08.28 |
[Study] Part 3 - 캐릭터 공격 구현 (9/15) (0) | 2024.08.26 |
[Study] Part 3 - RPC 기초 (8/15) (0) | 2024.08.23 |
[Study] Part 3 - 액터 리플리케이션 로우레벨 플로우 (7/15) (0) | 2024.08.21 |