동기화

김혁·2025년 8월 29일

애니메이션 동기화

기본 Character Movement 속성 값들

  • 각각의 AnimInstance에서 Character Movement Component의 속성 값들을 통해서 Locomotion에 대해서 구현하는 경우가 많은데, 이는 동기화 설정을 해주지 않아도 동기화가 됨
  • 액터에서 기본적으로 Replicate Movement 속성이 true로 되어 있음
    • 해당 속성은 기본적인 이동 관련 정보(Position, Rotation, Velocity 등)를 자동으로 서버 -> 클라이언트로 동기화시켜줌.
    • ACharacter의 경우 CharacterMovementComponent가 이 동기화를 지원함
      -> Velocity, GroundSpeed, bIsFalling 등
  • 캐릭터 이동/회전 관련한 값들은 수동 복제 코드를 작성하지 않아도 자동으로 동기화됨

애니메이션에서 커스텀 변수를 이용하는 경우

  • 무기 장착 여부나, 에임이 카메라를 따라가게 하고 싶은 경우 등등, 커스텀 변수를 통해서 애니메이션을 동작시킬 때는 직접 동기화를 진행해야 한다.
  • Property Replication을 통해서 변할 때마다 동작을 시켜줘도 되고, RPC를 통해서 변수의 수정을 전해줘도 된다.
  • 무기 장착 여부에는 자주 실행되지 않기 때문에 Property Replication을 통해서 값을 수정해주는 것이 좋다.
  • 에임이 카메라를 따라가게 하고 싶은 경우는 거의 매 Tick마다 수행될 가능성이 높고, 한 두번 정도는 씹혀도 큰 문제가 없기 때문에 RPC를 통해 구현할 수 있다.
// DXPlayerCharacter.h

class ADXPlayerCharacter : public ACharacter
{
	...

	UFUNCTION(Server, Unreliable)
    void ServerRPCUpdateAimValue(const float& InAimPitchValue);
    
    UPROPERTY(Replicated)
    float CurrentAimPitch = 0.f;
    
    float PreviousAimPitch = 0.f;

	...
}
// DXPlayerCharacter.cpp

void ADXPlayerCharacter::Tick(float DeltaTime)
{
	Super::Tick(DeltaTime);
    
    if (IsValid(GetController()))
    {
    	PreviousAimPitch = CurrentAimPitch();
        
        FRotator ControlRotation = GetController()->GetControlRotation();
        float NormalizedPitch = FRotator::NormalizeAxis(ControlRotation.Pitch);
        CurrentAimPitch = FMath::Clamp(NormalizedPitch, -90.f, 90.f);
    }

	if (IsLocallyControlled() && PreviousAimPitch != CurrentAimPitch)
    {
    	ServerRPCUpdateAimValue(CurrentAimPitch);
    }
}

void ADXPlayerCharacter::ServerRPCUpdateAimValue_Implementation(const float& InAimPitchValue)
{
	CurrentAimPitch = InAimPitchValue;
}
// DXAnimInstance.h

class UDXAnimInstance : public UAnimInstance
{
	...
    
	UPROPERTY()
    float AimPitch;

	...
}
// DXAnimInstance.cpp

void UDXAnimInstance::NativeUpdateAnimation(float DeltaSeconds)
{
	...
    AimPitch = OwnerCharacter->GetCurrentAimPitch();
}

공격 동기화

공격 실행 구조

  1. 공격 시 애니메이션 몽타주를 실행
  2. 애니메이션 몽타주 내에서 노티파이를 통해서 데미지를 입힐 대상 찾기
  3. 데미지를 입힐 대상이 있다면 APawn::TakeDamage()를 통해서 데미지 입히기
// DXPlayerCharacter.h

class ADXPlayerCharacter : public ACharacter
{
	...

	virtual float TakeDamage(float DamageAmount, struct FDamageEvent const& DamageEvent, class AController* EventInstigator, AActor* DamageCauser) override;
    
    void CheckMeleeAttackHit();
    
    void DrawDebugMeleeAttack(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd, FVector Forward);
    
    void PlayMeleeAttackMontage();
    
protected:
    bool bCanAttack;
    
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TObjectPtr<UAnimMontage> MeleeAttackMontage;
    
    float MeleeAttackMontagePlayTime;
    
    ...
}
// DXPlayerCharacter.cpp

void ADXPlayerCharacter::BeginPlay()
{
	if (IsValid(MeleeAttackMontage))
    {
    	MeleeAttackMontagePlayTime = MeleeAttackMontage->GetPlayLength();
    }
}

void ADXPlayerCharacter::HandleMeleeAttackInput(const FInputActionValue& InValue)
{
	if (bCanAttack && GetCharacterMovement()->IsFalling == false)
    {
    	bCanAttack = false;
        
        GetCharacterMovement()->SetMovementMode(MOVE_None);
        FTimerHandle TimerHandle;
        GetWorldTimerManager().SetTimer(TimerHandle, FTimerDelegate::CreateLambda([&]()
        {
        	bCanAttack = true;
            GetCharacterMovement()->SetMovementMode(MOVE_Walking);
        }), MeleeAttackMontagePlayTime, false);
        
        PlayMeleeAttackMontage();
    }
}

void ADXPlayerCharacter::CheckMeleeAttackHit()
{
	TArray<FHitResult> OutHitResults;
	TSet<ACharacter*> DamagedCharacters;
	FCollisionQueryParams Params(NAME_None, false, this);

	const float MeleeAttackRange = 50.f;
	const float MeleeAttackRadius = 50.f;
	const float MeleeAttackDamage = 10.f;
	const FVector Forward = GetActorForwardVector();
	const FVector Start = GetActorLocation() + Forward * GetCapsuleComponent()->GetScaledCapsuleRadius();
	const FVector End = Start + GetActorForwardVector() * MeleeAttackRange;

	bool bIsHitDetected = GetWorld()->SweepMultiByChannel(OutHitResults, Start, End, FQuat::Identity, ECC_Camera, FCollisionShape::MakeSphere(MeleeAttackRadius), Params);
	if (bIsHitDetected == true)
	{
		for (auto const& OutHitResult : OutHitResults)
		{
			ACharacter* DamagedCharacter = Cast<ACharacter>(OutHitResult.GetActor());
			if (IsValid(DamagedCharacter) == true)
			{
				DamagedCharacters.Add(DamagedCharacter);
			}
		}

		FDamageEvent DamageEvent;
		for (auto const& DamagedCharacter : DamagedCharacters)
		{
			DamagedCharacter->TakeDamage(MeleeAttackDamage, DamageEvent, GetController(), this);
		}
	}
	
	FColor DrawColor = bIsHitDetected ? FColor::Green : FColor::Red;
	DrawDebugMeleeAttack(DrawColor, Start, End, Forward);
}

void ADXPlayerCharacter::DrawDebugMeleeAttack(const FColor& DrawColor, FVector TraceStart, FVector TraceEnd,
	FVector Forward)
{
	const float MeleeAttackRange = 50.f;
	const float MeleeAttackRadius = 50.f;
	FVector CapsuleOrigin = TraceStart + (TraceEnd - TraceStart) * 0.5f;
	float CapsuleHalfHeight = MeleeAttackRange * 0.5f;
	DrawDebugCapsule(GetWorld(), CapsuleOrigin, CapsuleHalfHeight, MeleeAttackRadius, FRotationMatrix::MakeFromZ(Forward).ToQuat(), DrawColor, false, 5.0f);
}

void ADXPlayerCharacter::PlayMeleeAttackMontage()
{
    UAnimInstance* AnimInstance = GetMesh()->GetAnimInstance();
    if (IsValid(AnimInstance))
    {
    	AnimInstance->StopAllMontages(0.f);
    	AnimInstance->Montage_Play(MeleeAttackMontage);
    }
}
// DXAnimInstance.h

class UDXAnimInstance : public UAnimInstance
{
	...
    
	UFUNCTION()
    void AnimNotify_CheckMeleeAttackHit();

	...
}
// DXAnimInstance.cpp

void UDXAnimInstance::AnimNotify_CheckMeleeAttackHit()
{
	if (IsValid(OwnerCharacter))
    {
    	OwnerCharacter->CheckMeleeAttackHit();
    }
}
  • 종종 애님 노티파이가 두 번 호출되는 경우가 있음
    -> Notify에서 Montage Tick TypeBranching Point으로 설정하면 무조건 바로 전송이 됨

공격 동기화 시키기

  • 먼저 모든 공격 실행을 서버에서 할 경우, 클라이언트의 모든 행동이 서버를 거친 후에 실행됨
  • 통신 부하가 발생하는 경우, 공격이 늦게 나가게 되어 사용자 경험이 나빠짐
  • 클라이언트에서 공격 애니메이션은 즉시 실행시켜주고, 서버에서 딜레이를 계산해서 다음 공격 가능 시간에 공격 가능하게 함
  • 그리고 다른 플레이어들에게 애니메이션 몽타주가 보이게 NetMulticastRPC를 실행
// DXPlayerCharacter.h

class ADXPlayerCharacter : public ACharacter
{
	...

private:
    UFUNCTION(Server, Reliable, WithValidation)
    void ServerRPCMeleeAttack(float InStartMeleeAttackTime);
    
    UFUNCTION(NetMulticast, Unreliable)
    void MulticastRPCMeleeAttack();
    
    UFUNCTION()
    void OnRep_CanAttack();
    
protected:
    UPROPERTY(ReplicatedUsing = OnRep_CanAttack)
    uint8 bCanAttack : 1;
    
    float LastStartMeleeAttackTime;
    
    float MeleeAttackTimeDifference;
    
    ...
}
// DXPlayerCharacter.cpp

void ADXPlayerCharacter::HandleMeleeAttackInput(const FInputActionValue& InValue)
{
	if (bCanAttack && GetCharacterMovement()->IsFalling == false)
    {
    	ServerRPCMeleeAttack(GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
        
        if (!HasAuthority() && IsLocallyControlled())
        {
        	PlayMeleeAttackMontage();
        }
    }
}

void ADXPlayerCharacter::CheckMeleeAttackHit()
{
	if (HasAuthority())
    {
		TArray<FHitResult> OutHitResults;
		TSet<ACharacter*> DamagedCharacters;
		FCollisionQueryParams Params(NAME_None, false, this);
	
		const float MeleeAttackRange = 50.f;
		const float MeleeAttackRadius = 50.f;
		const float MeleeAttackDamage = 10.f;
		const FVector Forward = GetActorForwardVector();
		const FVector Start = GetActorLocation() + Forward * GetCapsuleComponent()->GetScaledCapsuleRadius();
		const FVector End = Start + GetActorForwardVector() * MeleeAttackRange;
	
		bool bIsHitDetected = GetWorld()->SweepMultiByChannel(OutHitResults, Start, End, FQuat::Identity, ECC_Camera, FCollisionShape::MakeSphere(MeleeAttackRadius), Params);
		if (bIsHitDetected == true)
		{
			for (auto const& OutHitResult : OutHitResults)
			{
				ACharacter* DamagedCharacter = Cast<ACharacter>(OutHitResult.GetActor());
				if (IsValid(DamagedCharacter) == true)
				{
					DamagedCharacters.Add(DamagedCharacter);
				}
			}
	
			FDamageEvent DamageEvent;
			for (auto const& DamagedCharacter : DamagedCharacters)
			{
				DamagedCharacter->TakeDamage(MeleeAttackDamage, DamageEvent, GetController(), this);
			}
		}
		
		FColor DrawColor = bIsHitDetected ? FColor::Green : FColor::Red;
		DrawDebugMeleeAttack(DrawColor, Start, End, Forward);
    }
}

void ADXPlayerCharacter::ServerRPCMeleeAttack_Implementation(float InStartMeleeAttackTime)
{
	// 애님 몽타주가 끝나기 전에는 다시 공격하지 못하게 하고 싶음
    // 공격이 입력되었다는 신호의 시간을 받아서 공격 가능 시간을 계산해서 공격 가능하게 함
    
    // 서버 딜레이 = 서버에서의 시간 - 공격 입력이 들어왔을 때 시간
    MeleeAttackTimeDifference = GetWorld()->GetTimeSeconds() - InStartMeleeAttackTime;
    // 서버 딜레이가 너무 커도 공격 몽타주보다 크면 입력이 무시될 수 있어서 제한
    MeleeAttackTimeDifference = FMath::Clamp(MeleeAttackTimeDifference, 0.f, MeleeAttackMontagePlayTime)
    
    if (KINDA_SMALL_NUMBER < MeleeAttackMontagePlayTime - MeleeAttackTimeDifference)
    {
    	bCanAttack = false;
        OnRep_CanAttack();
        
        FTimerHandle Handle;
		GetWorld()->GetTimerManager().SetTimer(Handle, FTimerDelegate::CreateLambda([&]()
		{
			bCanAttack = true;
			OnRep_CanAttack();
		}), MeleeAttackMontagePlayTime - MeleeAttackTimeDifference, false, -1.f);
    }
    
    LastStartMeleeAttackTime = InStartMeleeAttackTime;
    PlayMeleeAttackMontage();

	MulticastRPCMeleeAttack();
}

void ADXPlayerCharacter::ServerRPCMeleeAttack_Validate(float InStartMeleeAttackTime)
{
	// 최초 공격은 일단 통과
	if (LastStartMeleeAttackTime == 0.f)
    {
    	return true;
    }
    
    // 이전 공격 시도 시간보다 너무 짧게 공격 시도한 경우 블록
    return (MeleeAttackMontagePlayTime - 0.1f) < (InStartMeleeAttackTime - LastStartMeleeAttackTime);
}

void ADXPlayerCharacter::MulticastRPCMeleeAttack_Implementation()
{
	if (!HasAuthority() && !IsLocallyControlled())
    {
    	PlayMeleeAttackMontage();
    }
}

void ADXPlayerCharacter::OnRep_CanAttack()
{
	if (bCanAttack)
    {
    	GetCharacterMovement()->SetMovementMode(MOVE_Walking);
    }
    else
    {
    	GetCharacterMovement()->SetMovementMode(MOVE_None);
    }
}

데미지 처리 개선

  • 공격 체크를 하는 과정에서 이를 서버에서 실행하면 렉이 걸릴 경우, 굉장히 먼데도 공격이 들어가거나, 맞는 경우가 발생할 수 있다.
  • 따라서 클라이언트에서 공격 체크를 한 후에, 데미지를 주는 로직만 서버에서 실행하는 것이 좋다.
// DXPlayerCharacter.h

class ADXPlayerCharacter : public ACharacter
{
	...
    
    UFUNCTION(Server, Reliable, WithValidation)
    void ServerRPCPerformMeleeHit(ACharacter* InDamagedCharacter, float InCheckTime);
    
    float MinAllowedTimeForMeleeAttack;
    
    ...
}
// DXPlayerCharacter.cpp

void ADXPlayerCharacter::CheckMeleeAttackHit()
{
	// if (HasAuthority() == true)
	if (IsLocallyControlled() == true)
	{
    	...
    
		bool bIsHitDetected = GetWorld()->SweepMultiByChannel(OutHitResults, Start, End, FQuat::Identity, ECC_Camera, FCollisionShape::MakeSphere(MeleeAttackRadius), Params);
		if (bIsHitDetected == true)
		{
			...

			for (auto const& DamagedCharacter : DamagedCharacters)
			{
				ServerRPCPerformMeleeHit(DamagedCharacter, GetWorld()->GetGameState()->GetServerWorldTimeSeconds());
			}
		}
        
		...
	}
}

void ADXPlayerCharacter::ServerRPCPerformMeleeHit_Implementation(ACharacter* InDamagedCharacter, float InCheckTime)
{
	if (IsValid(InDamagedCharacter))
    {
    	const float MeleeAttackDamage = 10.f;
        FDamageEvent DamageEvent;
        InDamagedCharacter->TakeDamage(MeleeAttackDamage, DamageEvent, GetController(), this);
    }
}

void ADXPlayerCharacter::ServerRPCPerformMeleeHit_Validate(ACharacter* InDamagedCharacter, float InCheckTime)
{
	return MinAllowedTimeForMeleeAttack < (InCheckTime - LastStartMeleeAttackTime);
}

애니메이션 플레이 개선

  • 현재 NetMulticastRPC를 통해서 쓸데없이 Server, OwningClient한테도 몽타주 실행 전송
  • OtherClient에게만 ClientRPC를 실행
// DXPlayerCharacter.h

class ADXPlayerCharacter : public ACharacter
{
	...
    
    UFUNCTION(Client, Unreliable)
    void ClientRPCPlayMeleeAttackMontage(ADXPlayerCharacter* InTargetCharacter);
    
    ...
}
// DXPlayerCharacter.cpp

void ADXPlayerCharacter::ServerRPCMeleeAttack_Implementation(float InStartMeleeAttackTime)
{
	...
    
    // MulticastRPCMeleeAttack();
    for (APlayerController* PlayerController : TActorRange<APlayerController>(GetWorld()))
    {
    	if (IsValid(PlayerController) && GetController() != PlayerController)
        {
        	ADXPlayerCharacter* OtherPlayerCharacter = Cast<ADXPlayerCharacter>(PlayerController->GetPawn());
            if (OtherPlayerCharacter)
            {
            	OtherPlayerCharacter->ClientRPCPlayMeleeAttackMontage(this);
            }
        }
    }
}

void ADXPlayerCharacter::ClientRPCPlayMeleeAttackMontage_Implementation(ADXPlayerCharacter* InTargetCharacter)
{
	if (IsValid(InTargetCharacter))
    {
    	InTargetCharacter->PlayMeleeAttackMontage();
    }
}

액터 컴포넌트 동기화

액터와 컴포넌트의 관계

  • 액터가 컴포넌트를 소유하고 있는 상태라고 가정
    • 액터 입장에서 컴포넌트를 서브 오브젝트(Subobject)라고 부름
    • 컴포넌트 입장에서 액터를 오너(Owner)라고 부름
  • Subobject의 네트워크 디버깅용 로깅
    • 액터는 본인이 직접 로컬 롤과 리모트 롤을 관리하고 있지만, 서브 오브젝트는 오너에 의해 소유
    • 따라서 서브 오브젝트는 오너의 로컬 롤과 리모트 롤을 쫓아감
// DedicatedX.h

#define SUBOBJECT_LOCAL_ROLE_TCHAR *(UEnum::GetValueAsString(TEXT("Engine.ENetRole"), GetOwner()->GetLocalRole()))
#define SUBOBJECT_REMOTE_ROLE_TCHAR *(UEnum::GetValueAsString(TEXT("Engine.ENetRole"), GetOwner()->GetRemoteRole()))
#define DX_LOG_SUBOBJECT_ROLE(LogCat, Verbosity, Format, ...) UE_LOG(LogCat, Verbosity, TEXT("[%s][%s/%s] %s %s"), NETMODE_TCHAR, SUBOBJECT_LOCAL_ROLE_TCHAR, SUBOBJECT_REMOTE_ROLE_TCHAR, FUNCTION_TCHAR, *FString::Printf(Format, ##__VA_ARGS__))

컴포넌트의 이벤트 함수

  • InitializeComponent
    • BeginPlay(), ReadyForReplication() 전에 호출됨
    • 본격적인 게임 로직이 수행되기 전에 준비되어야 할 로직 작성
  • ReadyForReplication
    • BeginPlay() 전에 호출됨
    • 레플리케이션 관련 초기화 작업을 미리 해둘 때 사용
  • BeginPlay()
    • 모든 초기화가 완료되어 중요한 게임 로직이 수행될 준비가 완료된 후에 호출됨

체력 관리하는 액터 컴포넌트

  • 서버에서만 데미지 입히는 로직과 현재 체력을 설정할 수 있도록 설정
  • 클라이언트에서는 UI 업데이트 등을 해야되기 때문에 Property Replication을 통해서 델리게이트 실행
// DXStatusComponent.h

DECLARE_MULTICAST_DELEGATE_OneParam(FOnCurrentHPChangedDelegate, float);
DECLARE_MULTICAST_DELEGATE(FOnOutOfCurrentHPDelegate);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnMaxHPChangedDelegate, float);

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class UDXStatusComponent : public UActorComponent
{
	...
    
	float ApplyDamage(float InDamage);
    
    void SetCurrentHP(float InCurrentHP);
    
protected:
	UFUNCTION()
    void OnRep_CurrentHP();
    
    UFUNCTION()
    void OnRep_MaxHP();
    
public:
	UPROPERTY(ReplicatedUsing = OnRep_CurrentHP)
    float CurrentHP;
    
    UPROPERTY(ReplicatedUsing = OnRep_MaxHP)
    float MaxHP;
    
    FOnCurrentHPChangedDelegate OnCurrentHPChanged;
    FOnOutOfCurrentHPDelegate OnOutOfCurrentHP;
    FOnMaxHPChangedDelegate OnMaxHPChanged;
}
// DXStatusComponent.cpp
    
float UDXStatusComponent::ApplyDamage(float InDamage)
{
	if (!IsValid(GetOwner()) || !GetOwner()->HasAuthority())
    {
    	return 0.f;
    }

	const float PreviousHP = CurrentHP;
    const float ActualDamage = FMath::Clamp(InDamage, 0.f, PreviousHP);
    
    SetCurrentHP(PreviousHP - ActualDamage);
    
    return ActualDamage;
}
    
void UDXStatusComponent::SetCurrentHP(float InCurrentHP)
{
	if (!IsValid(GetOwner()) || !GetOwner()->HasAuthority())
    {
    	return;
    }

	CurrentHP = InCurrentHP;
    if (CurrentHP <= KINDA_SMALL_NUMBER)
    {
    	CurrentHP = 0.f;
        OnOutOfCurrentHP.Broadcast();
    }
    OnCurrentHPChanged.Broadcast(CurrentHP);
}

void UDXStatusComponent::OnRep_CurrentHP()
{
	OnCurrentHPChanged.Broadcast(CurrentHP);
}

void UDXStatusComponent::OnRep_MaxHP()
{
	OnMaxHPChanged.Broadcast(MaxHP);
}


출처 : 팀스파르타 내일배움캠프
profile
게임 개발자를 향해..

0개의 댓글