애니메이션 동기화
기본 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();
}
공격 동기화
공격 실행 구조
- 공격 시 애니메이션 몽타주를 실행
- 애니메이션 몽타주 내에서 노티파이를 통해서 데미지를 입힐 대상 찾기
- 데미지를 입힐 대상이 있다면
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 Type을 Branching 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);
}
출처 : 팀스파르타 내일배움캠프