프로젝트/CCFF
미친 선인장과 분노의 버섯 - #12 네트워크 환경에서의 동기화 및 UX(사용자 경험) 개선
2h1824
2025. 4. 10. 20:44
UX 개선
void ABaseCharacter::Attack1(const FInputActionValue& Value)
{
if (bCanAttack==true&&GetCharacterMovement()->IsFalling()==false)
{
UE_LOG(LogTemp,Warning,TEXT("Attack1 Called !!"));
ServerRPCAttack(0,GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
// Play Montage in Owning Client
if (HasAuthority()==false&&IsLocallyControlled()==true)
{
bCanAttack=false;
OnRep_CanAttack();
PlayAttackMontage(0);
}
}
}
void ABaseCharacter::Attack2(const FInputActionValue& Value)
{
if (bCanAttack==true&&GetCharacterMovement()->IsFalling()==false)
{
UE_LOG(LogTemp,Warning,TEXT("Attack2 Called !!"));
ServerRPCAttack(1,GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
if (HasAuthority()==false&&IsLocallyControlled()==true)
{
bCanAttack=false;
OnRep_CanAttack();
PlayAttackMontage(1);
}
}
}
void ABaseCharacter::Attack3(const FInputActionValue& Value)
{
if (bCanAttack==true&&GetCharacterMovement()->IsFalling()==false)
{
UE_LOG(LogTemp,Warning,TEXT("Attack3 Called !!"));
ServerRPCAttack(2,GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
if (HasAuthority()==false&&IsLocallyControlled()==true)
{
bCanAttack=false;
OnRep_CanAttack();
PlayAttackMontage(2);
}
}
}
- 사용자 입장에서는 키 입력에 따라 동작이 바로 실행되는 것이 훨씬 반응성이 좋음
- ServerRPC를 호출하고 캐릭터를 소유한 클라이언트에서는 반응성을 높이기 위해 몽타주 먼저 재생
- 더불어 bCanAttack의 값을 클라에서 미리 바꿔 공격을 바로 할 수 없도록 확실히 방지
- ServerRPCAttack 함수에 두번째 인자로 전달하는 서버기준 현재시각은 아래서 설명
Multicast ㅡ> Client RPC 전환
BaseCharacter.h
UFUNCTION(Client,Unreliable)
void ClientRPCPlayAttackMontage(const int32 Num, ABaseCharacter* InTargetCharacter);
BaseCharacter.cpp
void ABaseCharacter::ServerRPCAttack_Implementation(const int32 Num, float InStartAttackTime)
{
...
PlayAttackMontage(Num);
for (APlayerController* PC:TActorRange<APlayerController>(GetWorld()))
{
if (IsValid(PC)==true&&GetController()!=PC) // Find Other Client
{
if (ABaseCharacter* OtherClientCharacter=Cast<ABaseCharacter>(PC->GetPawn()))
{
OtherClientCharacter->ClientRPCPlayAttackMontage(Num,this);
}
}
}
}
void ABaseCharacter::ClientRPCPlayAttackMontage_Implementation(const int32 Num, ABaseCharacter* InTargetCharacter)
{
if (IsValid(InTargetCharacter)==true)
{
InTargetCharacter->PlayAttackMontage(Num);
}
}
- UX를 개선하는 과정에서 이전에 쓰던 Multicast의 경우 바꿔줘야할 필요성이 생김
ㅡ> Multicast로 몽타주를 재생시켜 줘야 하는 리스트에서 캐릭터를 소유한 클라와 서버가 빠졌기 때문에 그냥 Client RPC로 캐릭터를 소유하지 않은 클라에만 몽타주를 재생하도록 하는 것이 최적 - ServerRPC 내에서 서버는 몽타주를 재생
- 이후 모든 플레이어 컨트롤러 중 현재 RPC를 호출한 캐릭터를 소유하지 않은 플레이어 컨트롤러(Other Client)만 찾아 ClientRPC 호출하여 각 클라에서 몽타주를 재생
Validate를 이용한 치트 방지
BaseCharacter.h
UFUNCTION(Server,Reliable,WithValidation)
void ServerRPCAttack(const int32 Num, float InStartAttackTime);
UPROPERTY()
float LastAttackStartTime;
UPROPERTY()
float ServerDelay;
UPROPERTY()
float PrevMontagePlayTime;
BaseCharacter.cpp
void ABaseCharacter::ServerRPCAttack_Implementation(const int32 Num, float InStartAttackTime)
{
UE_LOG(LogTemp,Warning,TEXT("ServerRPC Called with %d !!"),Num);
ServerDelay=GetWorld()->GetTimeSeconds()-InStartAttackTime;
const float MontagePlayTime=Anim.AttackMontage[Num]->GetPlayLength();
// 0<=ServerDelay<=MontagePlayTime
ServerDelay=FMath::Clamp(ServerDelay,0.f,MontagePlayTime);
PrevMontagePlayTime=MontagePlayTime;
// Consider ServerDelay Timer (Can Attack)
if (KINDA_SMALL_NUMBER<MontagePlayTime-ServerDelay)
{
bCanAttack=false;
OnRep_CanAttack();
FTimerHandle Handle;
GetWorld()->GetTimerManager().SetTimer(Handle,FTimerDelegate::CreateLambda([&]()
{
bCanAttack=true;
OnRep_CanAttack();
}),
MontagePlayTime-ServerDelay,false,-1.f);
}
LastAttackStartTime=InStartAttackTime;
PlayAttackMontage(Num);
...
}
bool ABaseCharacter::ServerRPCAttack_Validate(const int32 Num, float InStartAttackTime)
{
// First Attack input
if (LastAttackStartTime==0.f)
{
return true;
}
const bool bIsValid=(PrevMontagePlayTime-0.1f)<(InStartAttackTime-LastAttackStartTime);
if (!bIsValid)
{
UE_LOG(LogTemp, Warning, TEXT("ServerRPCAttack_Validate failed. InCheckTime: %f, LastTime: %f, MontagePlayTime : %f"), InStartAttackTime, LastAttackStartTime, PrevMontagePlayTime);
}
return bIsValid;
}
- ServerRPC에 매개변수로 Attack이 시작된 시각 추가 (ServerRPC 호출 시점에 서버 기준 시각 전달)
- Validate에서 쓰일 이전 공격이 트리거된 시각, 서버 딜레이, 이전에 재생된 몽타주의 시간 길이 변수들 추가
- '현재 시각 - 공격 시작 시각 = 서버 딜레이'
- '몽타주 시간 길이 - 서버 딜레이' ㅡ> 타이머로 해당 시간 이후에 bCanAttack을 true로 바꾸고 이를 클라에 Replicate 하면 결과적으로 '몽타주 시간 길이 - 서버 딜레이 + 서버 딜레이' 이므로 클라이언트에서 적당한 시간에 공격이 가능
ㅡ> 서버와 클라의 공격 가능 시점 동기화 - 네트워크에서 서버 딜레이가 일관적이지 않기 때문에 아주 정확한 동기화는 아니겠지만 이전보다는 나을 것으로 기대
- ServerRPC가 호출되었을 때, Validate에 쓰일 LastAttackStartTime에 현재 호출된 공격 시작 시각 저장 및 현재 몽타주의 길이를 PrevMontagePlayTime에 저장
- 마지막 공격 시작 시각이 0 ㅡ> 첫 호출이니 그냥 통과
- '현재 공격 시작 시각 - 이전 공격 시작 시각' 보다 '이전 몽타주의 시간 길이-0.1.' 보다 크다면 공격 가능한 시점이라 판단
ㅡ> 0.1초는 약간의 오차를 위한 값
정리
- UX를 개선하기 위해 클라에서 몽타주를 선재생하도록 수정
- 이 과정에서 콜리전에서 오버랩을 감지하는 것까지도 클라에서 하여 서버에서는 데미지 관련 로직만 추가하는 것도 고려
- 분명 서버에서 몽타주를 재생시킬 필요가 사라져 부하는 사라질 것
- 하지만 서버에서 콜리전의 오버랩 감지를 하지 않으므로 클라 단에서 치팅을 할 여지가 생김
- 아래 두가지를 생각했을 때, 1번은 치팅 위험과 더불어 클라들끼리 공격-피격 타이밍 불일치 가능성이 높아 보임
ㅡ> 즉, 보이는 타이밍에 괴리감이 생길 여지가 커보임- '클라에서 오버랩 감지 ㅡ> 서버에서 데미지 및 피격 처리 ㅡ> 다른 클라에 적용'
- '클라에서 입력 감지, 몽타주 재생 ㅡ> 서버에서 몽타주 재생, 데미지 및 피격 관련 판정 ㅡ> 다른 클라들도 몽타주 재생'
- 결과적으로 2번이 몽타주를 선재생함으로써 UX도 개선할 수 있고, 치팅 위험이 적다 판단하여 채택
- 고려해야할 점은 네트워크 지연 상황에 따라 오히려 공격-피격 타이밍 불일치에 대한 괴리감을 느낄 수 있음
- 결과적으로 더 나은 UX 개선 효과를 보려면, 네트워크 동기화 보정 로직이 들어가야 할 수 있음
- Montage_SetPoisition()을 이용해 서버 시점으로 보정 가능
ㅡ> 하지만 이 방법은 보이기에 모션이 뚝뚝 끊어지는 느낌일 수 있음
- 현재 상태에서 UI나 사운드, 이펙트 등으로 체감 반응 속도를 보완하는 방식도 고려 가능
참고자료