이번 포스팅에서는 언리얼 RPC
에 대해서 알아보자. 멀티플레이 게임을 개발하기 위해서는 이 RPC에 대한 이해를 잘 하고 있어야지 다양한 상황에서 내가 구현하고 싶은 로직을 멀티플레이에 올바르게 올릴 수 있다. 거두절미하고 3개의 상황을 보면서 언리얼 RPC
의 3가지 종류에 대해서 알아보자!
ʕتʔ Health라는 변수가 Replicate하기 ʕتʔ
시작하기에 앞서 Health라는 변수를 Replicate
해서 서버 및 이와 연결된 모든 클라이언트가 동일한 값이 되도록 만들어야 한다. 변수를 Replicate
하는 방법은 다음과 같다.
UPROPERTY
안에 Replicate
처리GetLifetimeReplicatedProps
선언bReplicates
값 true로 설정GetLigetimeReplicatedProps
에 변수 등록Health 변수를 CustomCharacter에 선언하고 이를 Replicate
해보자
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h"
UCLASS()
class YOURGAME_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AMyCharacter();
// Step 1.
// ReplicatedUsing은 해당 변수를 Replicated함과 동시에 변수가 변경되었을때 발동되는 콜백함수를 바인딩 할 수 있다.(여기서는 "OnRep_Health"에 해당)
// 콜백 함수 없이 그냥 변수를 Replicate하고 싶으면 "UPROPERTY(Replicated)"를 써주면 된다.
UPROPERTY(ReplicatedUsing = OnRep_Health)
float Health;
protected:
UFUNCTION()
void OnRep_Health();
// Step 2.
// Replicate한 변수를 등록하기 위해 "GetLifetimeReplicatedProps"라는 함수를 선언해주어야한다.
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
#include "MyCharacter.h"
#include "Net/UnrealNetwork.h"
AMyCharacter::AMyCharacter()
{
Health = 100.0f;
// Step 3.
// 이 클래스 자체가 Replicate 될것인지 설정
bReplicates = true;
}
void AMyCharacter::OnRep_Health()
{
// 원한다면 콜백에 커스텀 로직 추가.
}
// Step 4.
// .h에서 선언했던 "GetLifetimeReplicatedProps"에 Replicate할 변수 등록
// DOREPLIFETIME 혹은 DOREPLIFETIME_CONDITION을 통해 변수의 Replicate 조건 또한 지정할 수 있다.
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyCharacter, Health);
}
자 그러면 이제 Health 변수가 Replicated
처리 되었으므로 바로 Client에서 Health 변수를 변경하면 성공적으로 모두가 같은 Replicated
된 변수값을 업데이트 받을 수 있을까?
사실, 이러면 안된다!! 서버에서 바꾼다면 값이 변경될 Client를 찾아서 Health 값을 업데이트 해주면 그만이지만, Client에서 Health 값을 바꾸려고 한다면 아무리 Health 변수가 Replicate
되어 있다고 해도 이를 서버에서 바꿔 주어야 한다(언리얼의 Client-Server 구조를 잘 생각해보자). 따라서 Client에서 ServerRPC
를 사용해서 서버가 Health 변수를 바꾸도록 만든다. ServerRPC
를 사용하는 방법은 뒤에서 더 알아보기로 하자:)
ʕتʔ Client가 돌아다니는 맵에서 HealthPack을 줍고, Client의 HUD에는 체력 회복 효과 보여주기 ʕتʔ
상황 B에서는 ClientRPC
와 SeverRPC
에 대한 개념이 모두 들어간다! 코드를 잘 읽어보면서 잘 따라와보자:D
구현해야할 상황에서 HealthPack이라는 새로운 아이템이 등장했으니 이에 대한 Class 생성이 필요할 것 같다. 기본적으로 캐릭터가 HealthPack을 먹어야 하므로 Collision Detection을 하는 로직이 필요할 것 같다. 그리고 마지막으로 Collision Detection에 걸린 캐릭터를 찾아서 Health 변경 로직을 수행하면 될 것 같다.
#include "HealthPack.h"
#include "MyCharacter.h"
#include "Components/SphereComponent.h"
#include "GameFramework/Actor.h"
#include "Net/UnrealNetwork.h"
AHealthPack::AHealthPack()
{
// HealthPack을 먹었을때 회복될 양
RestoreAmount = 30.0f;
// Collision component 설정
CollisionComponent = CreateDefaultSubobject<USphereComponent>(TEXT("CollisionComponent"));
CollisionComponent->InitSphereRadius(100.0f);sphere
CollisionComponent->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
CollisionComponent->OnComponentBeginOverlap.AddDynamic(this, &AHealthPack::OnOverlapBegin);
RootComponent = CollisionComponent;
}
void AHealthPack::OnOverlapBegin(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComponent, int32 OtherBodyIndex, bool bFromSweep,
const FHitResult& SweepResult)
{
if (ACharacter* Character = Cast<ACharacter>(OtherActor))
{
if (AMyCharacter* MyCharacter = Cast<AMyCharacter>(Character))
{
// MaxHealth보다 현재 Health가 낮을때에만 회복
if (MyCharacter->Health < MyCharacter->MaxHealth)
{
// 회복 로직 수행
RestoreHealth(MyCharacter);
}
}
}
}
// 회복 함수
void AHealthPack::RestoreHealth(ACharacter* Character)
{
if (AMyCharacter* MyCharacter = Cast<AMyCharacter>(Character))
{
if (MyCharacter->HasAuthority())
{
// 서버에서 회복 로직 수행
// MyCharacter에 존재하는 ServerRPC 부르기
MyCharacter->ServerRestoreHealth(RestoreAmount);
}
else
{
UE_LOG(LogTemp, Warning, TEXT("RestoreHealth should only be called on the server!"));
}
// 사용한 HealthPack은 이후 Destroy
Destroy();
}
HealthPack.cpp의 코드는 다음과 같이 구현해 볼 수 있다. 여기서, Collision을 확인하는 부분인 OnOverlapBegin
부분에서 HasAuthority
를 통해 Server만 Collision 체크를 하는 것을 주목하자(매번 헷갈리지만 OnOverlapBegin
에서 Collision에 걸린 actor의 명칭은 OtherActor이다). HealthPack 아이템은 서버를 포함한 모든 클라이언트의 월드에 존재하지만 이 HasAuthority()
조건을 주면 오직 서버에서만 접근할 수 있다.
자! 그러면 이제 서버에서 접근+오버랩된 Actor까지 알았으니깐 그다음 무엇을 해야할까? 일단 첫번째로 Replicated
처리된 Health의 값을 변경하는 것이 있겠고, 다음으로는 HealthPack을 먹은 client HUD 화면에 변경된 Health 값을 업데이트하는 과정이 있을 것이다.
첫번째로 Replicated
처리된 Health 값을 변경하는 과정에 대해 알아보자. 이미, Collision 체크를 서버쪽에서 하고 있기 때문에, 바로 OtherActor로 구한 Character를 캐스팅하여 Health 값을 더해주면 된다(따로 Health Component를 사용하지 않고 Character 클래스 안에 단순하게 health 값이 들어있다고 가정).
다음으로, Client HUD를 업데이트 하는 과정에 대해 알아보자. HealthPack을 먹은 Client 화면에서만 변화가 일어나면 되기 때문에 서버는 ClientRPC
를 통해 해당 Client에 접근해 HUD를 변경하면 된다. 하지만, 이보다 더 좋은 방법이 있는데 Health 값은 Replicated
되어 있는 변수이므로 Health에 해당하는 RepNotify
함수 로직안에 HUD를 변경하는 로직을 넣는 것이다. RepNotify
함수는 코드상에 OnRep_
prefix가 붙는 함수이다.
#include "MyCharacter.h"
#include "Net/UnrealNetwork.h"
#include "GameFramework/PlayerController.h"
#include "YourGameHUD.h" // Replace with your actual HUD class
AMyCharacter::AMyCharacter()
{
Health = 100.0f;
MaxHealth = 100.0f;
bReplicates = true;
}
// Health가 바뀌면 불려지는 콜백 함수에 HUD 업데이트 로직 추가
void AMyCharacter::OnRep_Health()
{
if (APlayerController* PC = Cast<APlayerController>(GetController()))
{
// 서버에서는 HUD를 업데이트 할 필요가 없으니,
// IsLocalController()를 확인해서 client의 HUD만 업데이트 되게 하자
if (PC->IsLocalController())
{
// Widget은 PlayerController에서 접근한다는 사실 다시 한번 기억!
AYourGameHUD* HUD = Cast<AYourGameHUD>(PC->GetHUD());
if (HUD)
{
HUD->UpdateHealth(Health);
}
}
}
}
// HealthPack에서 불려지는 SercerRPC
void AMyCharacter::ServerRestoreHealth_Implementation(float Amount)
{
Health = FMath::Clamp(Health + Amount, 0.0f, MaxHealth);
UE_LOG(LogTemp, Log, TEXT("Health updated on server: %f"), Health);
}
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyCharacter, Health);
}
눈치챈 사람이 있을 수 있겠지만, 여기서 CustomCharacter.cpp에 존재하는 ServerRestoreHealth를 굳이 ServerRPC
로 만들 필요가 없다. ServerRPC
는 기본적으로 Client가 Server에 존재하는 함수를 부르는 것인데, 이미 OnBeginOverlap
에서 Authority 체크로 Server임을 확인했기 때문이다. 이렇게 되면, 서버가 ServerRPC
를 호출하는 꼴이 되어 버리는 것이다.
하지만, 나중에 HealthPack이 아닌 다른 방법으로 체력을 추가하는 경우가 생기고 그 과정을 Client에서 다루고 싶다면 만들어둔 ServerRestoreHealth를 client에서 호출해 요긴하게 사용할 수 있을 것이다.
ʕتʔ Client A가 Client B에게 총을 쐈다. 이 과정을 A와 B를 제외한 게임에 참여하고 있는 다른 클라이언트들에게도 동일하게 보여주려면? ʕتʔ
이 짧은 과정 속에 Client A의 사격 애니메이션, 총기 VFX와 SFX가 실행될 것이고 마찬가지로 피격을 당하는 B 입장에서도 피격 애니메이션, 피격 VFX와 SFX가 실행된다. 그리고 이 과정은 Client A와 B를 제외한 다른 Client의 시선에서 모두 동일하게 보여주어져야 한다.
이건 어떻게 구현해야 할까? 우리가 배운 지식을 보면 Replicate
될 변수들은 ServerRPC
로 변경하고, 이 상황을 보는 Client들의 HUD변화가 일어나야 한다면 일일이 ClientRPC
하는 방법이 떠오를 수 있다. 하지만, 이는 우리가 아직 다루지 않은 하나의 MulticastRPC
로 한번에 해결할 수 있다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Weapon.generated.h"
UCLASS()
class YOURGAME_API AWeapon : public AActor
{
GENERATED_BODY()
public:
AWeapon();
// Client, Server가 둘다 부를 수 있는 총기 사격 함수
void FireWeapon();
// 사격 ServerRPC(Client -> Server)
UFUNCTION(Server, Reliable, WithValidation)
void ServerFireWeapon();
void ServerFireWeapon_Implementation();
// 사격 MulticastRPC(Server -> 자기 자신(server)를 포함한 연결된 모든 Client)
UFUNCTION(NetMulticast, Reliable)
void MulticastFireWeaponEffect(FVector HitLocation, ACharacter* TargetCharacter);
void MulticastFireWeaponEffect_Implementation(FVector HitLocation, ACharacter* TargetCharacter);
private:
float DamageAmount = 20.0f; // Amount of damage the weapon deals
};
#include "Weapon.h"
#include "Net/UnrealNetwork.h"
#include "Kismet/GameplayStatics.h"
#include "GameFramework/Character.h"
#include "MyCharacter.h" // Replace with your actual character class
AWeapon::AWeapon()
{
bReplicates = true;
bReplicateMovement = true;
}
void AWeapon::FireWeapon()
{
// Server에서 FireWeapon 불렀을때
if (HasAuthority())
{
FVector ShotDirection = GetActorForwardVector();
FVector ShotStart = GetActorLocation();
FVector ShotEnd = ShotStart + ShotDirection * 1000.0f;
FHitResult HitResult;
FCollisionQueryParams CollisionParams;
if (GetWorld()->LineTraceSingleByChannel(HitResult, ShotStart, ShotEnd, ECC_Visibility, CollisionParams))
{
if (ACharacter* HitCharacter = Cast<ACharacter>(HitResult.GetActor()))
{
// ApplyDamage를 통해 데미지 가하기
UGameplayStatics::ApplyDamage(HitCharacter, DamageAmount, GetInstigatorController(), this, UDamageType::StaticClass());
// Multicast 함수로 server포함한 모든 client에게 사격효과 보여주기
MulticastFireWeaponEffect(HitResult.Location, TargetCharacter);
}
}
}
}
// Client에서 FireWeapon 불렀을때
else
{
// ServerRPC 실행
ServerFireWeapon();
}
}
void AWeapon::ServerFireWeapon_Implementation()
{
FireWeapon();
}
void AWeapon::MulticastFireWeaponEffect_Implementation(FVector HitLocation, ACharacter* TargetCharacter)
{
// 모두가 볼 수 있는 총격 효과 로직
// VFX, SFX를 각각 하나씩 담아본다.
// VFX
if (ExplosionEffect)
{
UGameplayStatics::SpawnEmitterAtLocation(GetWorld(), ExplosionEffect, Location);
}
// SFX
if (ExplosionSound)
{
UGameplayStatics::PlaySoundAtLocation(GetWorld(), ExplosionSound, Location);
}
}
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MyCharacter.generated.h"
UCLASS()
class YOURGAME_API AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AMyCharacter();
// ApplyDamage가 불려지면 자동으로 호출되는 TakeDamage
UFUNCTION()
void TakeDamage(float DamageAmount);
UPROPERTY(Replicated)
float Health;
private:
const float MaxHealth = 100.0f;
};
#include "MyCharacter.h"
#include "Net/UnrealNetwork.h"
AMyCharacter::AMyCharacter()
{
Health = MaxHealth;
bReplicates = true;
}
void AMyCharacter::TakeDamage(float DamageAmount)
{
Health -= DamageAmount;
Health = FMath::Clamp(Health, 0.0f, MaxHealth);
}
void AMyCharacter::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AMyCharacter, Health);
}
상황 B처럼 상황 C에서도 시작은 Client가 하는 경우에 ServerRPC -> (ApplyDamage + MulticastRPC)와 함수의 실행 순서가 나오게 된다. 반대로, Server로부터 시작을 하게 된다면 바로 ApplyDamage + MulticastRPC를 실행하면 된다.
상황 C에서는 ApplyDamage
와 TakeDamage
라는 새로운 2개의 함수들이 등장했다. 데미지는 거의 모든 게임에 다 들어가기 때문에 언리얼에서 특별히 관련 함수를 만들었다. ApplyDamage는 데미지를 가하는 쪽에서 부르는 함수인데, 안에 인자로 데미지를 입힐 상대와 가할 데미지 양 등을 넣는다.
TakeDamage
는 데미지가 입혀질 상대에서 불려지는 함수이고, TakeDamage
함수를 구현하고 있어야 한다. 이 함수는 ApplyDamage
로 자신이 지목되면 자동으로 호출되는 함수이기 때문에 따로 부르지 않아도 된다.