프로젝트/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번은 치팅 위험과 더불어 클라들끼리 공격-피격 타이밍 불일치 가능성이 높아 보임
      ㅡ> 즉, 보이는 타이밍에 괴리감이 생길 여지가 커보임
      1. '클라에서 오버랩 감지 ㅡ> 서버에서 데미지 및 피격 처리 ㅡ> 다른 클라에 적용' 
      2. '클라에서 입력 감지, 몽타주 재생 ㅡ> 서버에서 몽타주 재생, 데미지 및 피격 관련 판정 ㅡ> 다른 클라들도 몽타주 재생'
  • 결과적으로 2번이 몽타주를 선재생함으로써 UX도 개선할 수 있고, 치팅 위험이 적다 판단하여 채택
  • 고려해야할 점은 네트워크 지연 상황에 따라 오히려 공격-피격 타이밍 불일치에 대한 괴리감을 느낄 수 있음
    • 결과적으로 더 나은 UX 개선 효과를 보려면, 네트워크 동기화 보정 로직이 들어가야 할 수 있음
    • Montage_SetPoisition()을 이용해 서버 시점으로 보정 가능
      ㅡ> 하지만 이 방법은 보이기에 모션이 뚝뚝 끊어지는 느낌일 수 있음
  • 현재 상태에서 UI나 사운드, 이펙트 등으로 체감 반응 속도를 보완하는 방식도 고려 가능

 


참고자료