멀티플레이어 프로그래밍 퀵스타트

C++에서 간단한 멀티플레이어 게임을 만듭니다.

Choose your operating system:

Windows

macOS

Linux

필요한 사전지식

이 페이지의 콘텐츠를 이해하고 활용하기 위해 다음 주제를 숙지해 주세요.

Preview.png

멀티플레이어 게임을 위한 게임플레이를 개발하려면 게임의 액터(Actors) 리플리케이션(Replication) 을 구현해야 합니다. 게임 세션의 호스트 역할을 하는 서버 전용 함수 기능과 세션에 연결하는 플레이어를 나타내는 클라이언트 도 설계해야 합니다. 간단한 멀티플레이어 게임플레이 제작 프로세스를 보여드리는 이 가이드에서는 다음을 학습하게 됩니다.

  • 베이스 액터에 리플리케이션을 추가하는 방법

  • 네트워크 게임에서 무브먼트 컴포넌트(Movement Components) 를 활용하는 방법

  • 변수 에 리플리케이션을 추가하는 방법

  • 변수가 변경될 때 RepNotify 를 사용하는 방법

  • C++에서 원격 프로시저 콜(Remote Procedure Calls, RPC) 을 사용하는 방법

  • 함수 내에서 수행된 호출에 필터를 적용하기 위해 액터의 네트워크 역할(Network Role) 을 확인하는 방법

최종 결과물은 플레이어들이 서로에게 폭발하는 발사체를 날릴 수 있는 3인칭 게임입니다. 작업의 대부분은 발사체를 만들고 캐릭터에 대미지 반응을 추가하는 일이 될 것입니다.

시작하기에 앞서 데디케이티드 서버네트워크 개요 페이지의 기본 정보를 살펴보실 것을 적극 권장합니다. 이 가이드와 리플리케이션 콘셉트를 사용하지 않는 일인칭 슈팅 튜토리얼을 비교해 보셔도 좋습니다.

1. 필수 구성

  1. 에디터(Editor) 를 열고 새 프로젝트(New Project) 를 생성합니다. 사용할 세팅은 다음과 같습니다.

    • C++ 프로젝트

    • 삼인칭(Third-Person) 템플릿 사용

    • 시작용 콘텐츠(Starter Content) 포함

    • 데스크톱(Desktop) 타기팅

    이 세팅을 적용한 뒤 프로젝트 이름은 ThirdPersonMP 로 하고 생성(Create) 버튼을 클릭하여 계속합니다. 프로젝트의 C++ 파일이 생성되고 언리얼 에디터ThirdPersonExampleMap 이 자동으로 열릴 것입니다.

  2. 씬에 서 있는 ThirdPersonCharacter삭제 후 맵 안에 플레이어 스타트(PlayerStart) 를 두 개 넣습니다. 플레이어 스타트는 씬에 기본으로 포함되고 수동 배치된 ThirdPersonCharacter 대신 플레이어의 스폰을 처리할 것입니다.

    플레이어 스타트 추가

대부분의 템플릿에서 폰과 캐릭터의 리플리케이션은 디폴트로 활성화되어 있습니다. 이 예시에서 ThirdPersonCharacter는 자동으로 움직임을 리플리케이트하는 캐릭터 무브먼트 컴포넌트(Character Movement Component) 를 이미 가지고 있습니다.

캐릭터 무브먼트 컴포넌트가 리플리케이션을 어떻게 처리하고, 함수 기능을 어떻게 확장하는지에 대해서는 캐릭터 무브먼트 컴포넌트 가이드를 참조하세요.

캐릭터의 스켈레탈 메시(Skeletal Mesh)애니메이션 블루프린트(Animation Blueprint) 등의 외관 컴포넌트는 리플리케이트되지 않습니다. 그러나 캐릭터 속도와 같이 게임플레이 및 무브먼트와 연관성이 있는 변수는 리플리케이트됩니다. 애니메이션 블루프린트는 업데이트될 때 이러한 변수를 읽습니다. 이 방식으로 각 클라이언트의 캐릭터 사본이 비주얼을 업데이트합니다. 이 프로세스는 게임플레이 변수의 정확한 업데이트와 일관성을 갖도록 수행됩니다. 마찬가지로

[**게임플레이 프레임워크**](programming-and-scripting/Framework)
는 플레이어 스타트에 자동으로 캐릭터를 스폰하고 캐릭터에 플레이어 컨트롤러(Player Controllers) 를 할당합니다.

이 프로젝트로 서버를 시작하고 클라이언트가 거기에 참여하기만 해도 멀티플레이어 게임은 작동합니다. 그러나 플레이어는 아바타로 이동과 점프밖에 할 수 없을 것입니다. 따라서 추가적인 멀티플레이어 게임플레이를 만들어야 합니다.

2. RepNotify로 플레이어 체력 리플리케이트하기

플레이어에게는 게임플레이 도중 대미지를 입을 수 있는 체력 값이 필요합니다. 이 값은 리플리케이트해야 하며, 모든 클라이언트는 각 플레이어의 체력에 대한 정보를 동기화했습니다. 플레이어가 대미지를 입을 때는 플레이어에게 피드백을 제공해야 합니다. 이 섹션에서는 RepNotify를 사용하여 RPC에 의존하지 않고 변수에 대한 모든 필수 업데이트를 동기화하는 방법을 보여드립니다.

참고로 '역할'은 각각 'GetLocalRole()'과 'GetRemoteRole()'로 대체되었습니다. 아래의 일부 섹션에는 이전에 사용되었던 '역할'이 남아 있을 수 있으니 변경되었다는 사실을 기억하세요.

  1. ThirdPersonMPCharacter.h 를 엽니다. protected 아래 다음 프로퍼티를 추가합니다.

  2. ThirdPersonMPCharacter.h 를 엽니다. protected 아래 다음 프로퍼티를 추가합니다.

    ThirdPersonMPCharacter.h

    protected:
    
        /** 플레이어의 최대 체력. 체력의 최댓값입니다. 이 값은 스폰 시 시작되는 캐릭터의 체력 값입니다.*/
        UPROPERTY(EditDefaultsOnly, Category = "Health")
        float MaxHealth;
    
        /** 플레이어의 현재 체력. 0이 되면 죽은 것으로 간주됩니다.*/
        UPROPERTY(ReplicatedUsing = OnRep_CurrentHealth)
        float CurrentHealth;
    
        /** 현재 체력에 가해진 변경에 대한 RepNotify*/
        UFUNCTION()
        void OnRep_CurrentHealth();

플레이어의 체력이 변경되는 방식을 엄격하게 제어해야 하므로, 체력 값에 다음과 같은 제약을 넣습니다.

+ `MaxHealth` 는 리플리케이트되지 않으며 디폴트만 편집 가능합니다. 이 값은 모든 플레이어에 대해 사전에 계산되며, 전혀 변경되지 않습니다.
+ `CurrentHealth` 는 리플리케이트되지만 블루프린트에서 편집하거나 액세스할 수 없습니다.
+ `MaxHealth` 와 `CurrentHealth` 는 모두 `protected` 되어 외부 C++ 클래스로부터의 액세스를 방지합니다. `AThirdPersonMPCharacter` 또는 여기서 파생된 다른 클래스 내에서만 수정 가능합니다.

이는 라이브 게임플레이 도중 플레이어의 `CurrentHealth` 또는 `MaxHealth` 에 원치 않는 변경이 발생할 위험성을 최소화합니다. 이후 단계에는 이 값을 구하고 변경하는 다른 퍼블릭 함수를 제공하게 됩니다.

`Replicated` 지정자는 서버에서 액터의 사본을 활성화하여 변수 값이 변경될 때마다 연결된 모든 클라이언트에 해당 변수 값을 리플리케이트합니다. `ReplicatedUsing` 도 똑같은 작업을 수행하지만 **RepNotify** 함수를 설정하도록 지원합니다. 이 함수는 클라이언트가 리플리케이트된 데이터를 성공적으로 수신할 때 트리거됩니다. `OnRep_CurrentHealth` 를 사용하여 이 변수의 변경을 기반으로 각 클라이언트에 업데이트를 수행할 것입니다.
  1. ThirdPersonMPCharacter.cpp 를 엽니다. 상단의 #include "GameFramework/SpringArmComponent.h" 아래 다음 #include 구문을 추가합니다.

    ThirdPersonMPCharacter.cpp

    #include "Net/UnrealNetwork.h"
    #include "Engine/Engine.h"

    이는 GEngine 내에서 AddOnscreenDebugMessage 함수 액세스와 변수 리플리케이션에 필요한 함수 기능을 제공합니다. 이를 사용하여 메시지를 화면에 출력할 것입니다.

  2. ThirdPersonMPCharacter.cpp 에서 AThirdPersonMPCharacter 생성자 하단에 다음 코드를 추가합니다.

    ThirdPersonMPCharacter.cpp

    //플레이어 체력 초기화
    MaxHealth = 100.0f;
    CurrentHealth = MaxHealth;

    이렇게 하면 플레이어의 체력이 초기화됩니다. 이 캐릭터의 새 사본이 생성될 때마다 현재 체력이 최대 체력 값으로 설정될 것입니다.

  3. ThirdPersonMPCharacter.h`에서 다음 퍼블릭 함수 선언을 AThirdPersonMPCharacter` 생성자 바로 뒤에 추가합니다.

    ThirdPersonMPCharacter.h

    /** 프로퍼티 리플리케이션 */
    void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
  4. ThirdPersonMPCharacter.cpp 에서 이 함수에 다음 구현을 추가합니다.

    ThirdPersonMPCharacter.cpp

    //////////////////////////////////////////////////////////////////////////
    // 리플리케이트된 프로퍼티
    
    void AThirdPersonMPCharacter::GetLifetimeReplicatedProps(TArray <FLifetimeProperty>& OutLifetimeProps) const
    {
        Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    
        //현재 체력 리플리케이트
        DOREPLIFETIME(AThirdPersonMPCharacter, CurrentHealth);
    }

    GetLifetimeReplicatedProps 함수는 Replicated 지정자로 지정된 모든 프로퍼티를 리플리케이트하며, 프로퍼티의 리플리케이트 방식을 구성하도록 지원합니다. 여기서는 가장 기본적인 CurrentHealth 구현을 사용합니다. 리플리케이트가 필요한 프로퍼티를 추가할 때는 반드시 이 함수도 추가해야 합니다.

    GetLifetimeReplicatedPropsSuper 버전을 호출해야 합니다. 그러지 않으면 액터의 부모 클래스에서 상속받은 프로퍼티가 부모 클래스에서 리플리케이트하도록 지정되어 있더라도 리플리케이트되지 않습니다.

  5. ThirdPersonMPCharacter.h 에서 Protected 아래 다음의 함수 선언을 추가합니다.

    ThirdPersonMPCharacter.h

    protected:
        /** 업데이트되는 체력에 반응. 서버에서는 수정 즉시 호출, 클라이언트에서는 RepNotify에 반응하여 호출*/
        void OnHealthUpdate();
  6. ThirdPersonMPCharacter.cpp 에 다음 구현을 추가합니다.

    ThirdPersonMPCharacter.cpp

    void AThirdPersonMPCharacter::OnHealthUpdate()
    {
        //클라이언트 전용 함수 기능
        if (IsLocallyControlled())
        {
            FString healthMessage = FString::Printf(TEXT("You now have %f health remaining."), CurrentHealth);
            GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Blue, healthMessage);
    
            if (CurrentHealth <= 0)
            {
                FString deathMessage = FString::Printf(TEXT("You have been killed."));
                GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, deathMessage);
            }
        }
    
        //서버 전용 함수 기능
        if (GetLocalRole() == ROLE_Authority)
        {
            FString healthMessage = FString::Printf(TEXT("%s now has %f health remaining."), *GetFName().ToString(), CurrentHealth);
            GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Blue, healthMessage);
        }
    
        //모든 머신에서 실행되는 함수 
        /*  
            여기에 대미지 또는 사망의 결과로 발생하는 특별 함수 기능 배치 
        */
    }

    이 함수를 사용하여 플레이어의 CurrentHealth 변경에 대응하는 업데이트를 수행할 것입니다. 현재 그 기능은 화면상의 디버그 메시지로 제한되지만 기능이 더 추가될 수 있습니다. 예를 들어 OnDeath 함수는 사망 애니메이션을 트리거하기 위해 모든 머신에서 호출됩니다. OnHealthUpdate 는 리플리케이트되지 않아서 모든 디바이스에서 수동으로 호출해야 한다는 점에 유의하세요.

  7. ThirdPersonMPCharacter.cpp 에서 OnRep_CurrentHealth 에 다음 구현을 추가합니다.

    ThirdPersonMPCharacter.cpp

    void AThirdPersonMPCharacter::OnRep_CurrentHealth()
    {
        OnHealthUpdate();
    }

    변수는 항상 리플리케이트되는 것이 아니라 값이 변경될 때마다 리플리케이트되며, RepNotify 는 리플리케이트된 변수 값을 클라이언트가 성공적으로 받을 때마다 실행됩니다. 그러므로 플레이어의 CurrentHealth 를 서버에서 변경할 때마다 각 연결된 클라이언트에서 OnRep_CurrentHealth 가 실행된다고 예상할 수 있습니다. 따라서 OnRep_CurrentHealth 는 클라이언트의 머신에서 OnHealthUpdate 를 호출하기에 이상적인 위치입니다.

3. 플레이어가 대미지에 반응하게 만들기

플레이어의 체력을 구현했으니, 이 클래스 밖에서 플레이어의 체력을 수정할 방법을 만들어야 합니다.

  1. ThirdPersonMPCharacter.h 에서 Public 아래 다음 함수 선언을 추가합니다.

    ThirdPersonMPCharacter.h

    public: 
        /** 최대 체력 게터*/
        UFUNCTION(BlueprintPure, Category="Health")
        FORCEINLINE float GetMaxHealth() const { return MaxHealth; } 
    
        /** 현재 체력 게터*/
        UFUNCTION(BlueprintPure, Category="Health")
        FORCEINLINE float GetCurrentHealth() const { return CurrentHealth; }
    
        /** 현재 체력 세터. 값을 0과 MaxHealth 사이로 범위제한하고 OnHealthUpdate를 호출합니다. 서버에서만 호출되어야 합니다.*/
        UFUNCTION(BlueprintCallable, Category="Health")
        void SetCurrentHealth(float healthValue);
    
        /** 대미지를 받는 이벤트. APawn에서 오버라이드됩니다.*/
        UFUNCTION(BlueprintCallable, Category = "Health")
        float TakeDamage( float DamageTaken, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser ) override;

    GetMaxHealthGetCurrentHealth 함수는 C++와 블루프린트에서 AThirdPersonMPCharacter 밖의 플레이어 체력 값에 액세스할 수 있는 게터를 제공합니다. const 함수를 사용하면 이 값의 수정을 허용하지 않으면서 안전하게 가져올 수 있습니다. 플레이어의 체력을 설정하고 대미지를 입히기 위한 함수도 선언하겠습니다.

  2. ThirdPersonMPCharacter.cpp 에서 SetCurrentHealth 에 다음 구현을 추가합니다.

    ThirdPersonMPCharacter.cpp

    void AThirdPersonMPCharacter::SetCurrentHealth(float healthValue)
    {
        if (GetLocalRole() == ROLE_Authority)
        {
            CurrentHealth = FMath::Clamp(healthValue, 0.f, MaxHealth);
            OnHealthUpdate();
        }
    }

    SetCurrentHealth 를 사용하면 AThirdPersonMPCharacter 외부에서 통제된 방식으로 플레이어의 CurrentHealth 를 수정할 수 있습니다. 리플리케이트되는 함수는 아니지만, 액터의 네트워크 역할이 ROLE_Authority 임을 확인하여 이 함수가 호스팅된 게임 서버에서 호출될 때만 실행되도록 제한하는 것입니다. 이 제한은 CurrentHealth 를 0과 플레이어의 MaxHealth 사이 값으로 범위제한하여 CurrentHealth 를 유효하지 않은 값으로 설정할 수 없게 합니다. 또한 OnHealthUpdate 를 호출하여 서버와 클라이언트 모두 이 함수에 대해 병렬 호출을 갖게 합니다. 이 부분은 서버가 RepNotify를 수신하지 않기 때문에 필요합니다.

    이러한 '세터' 함수가 모든 변수에 필요한 것은 아니지만, 플레이 도중 빈번하게 변경되는 민감한 게임플레이 변수에는 있는 것이 좋습니다. 여러 소스에 의해 수정될 수 있는 변수의 경우 특히 그렇습니다. 변수에 대한 실시간 변경을 더 일관되고, 디버그하기 쉽고, 새 함수 기능으로 확장하기 쉽게 만들어 주므로 싱글 플레이어 게임과 멀티 플레이어 게임에 모두 권장되는 모범 관행입니다.

  3. ThirdPersonMPCharacter.cpp 에서 TakeDamage 에 다음 구현을 추가합니다.

    ThirdPersonMPCharacter.cpp

    float AThirdPersonMPCharacter::TakeDamage(float DamageTaken, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
    {
        float damageApplied = CurrentHealth - DamageTaken;
        SetCurrentHealth(damageApplied);
        return damageApplied;
    }

    액터에 대미지를 적용하는 내장 함수는 해당 액터에 대한 기본 TakeDamage 함수를 호출합니다. 여기서는 SetCurrentHealth 를 사용하여 간단한 방식으로 체력 감소를 구현하겠습니다.

지금까지 이 섹션을 따라 하셨다면 액터에 대미지를 적용하는 플로는 다음과 같습니다.

  • 외부 액터 또는 함수가 캐릭터에 CauseDamage 를 호출하고, 캐릭터는 TakeDamage 함수를 호출합니다.

  • TakeDamageSetCurrentHealth 를 호출하여 서버에서 플레이어의 현재 체력 값을 변경합니다.

  • SetCurrentHealth 가 서버에서 OnHealthUpdate 를 호출하여 플레이어의 체력 변경에 반응하는 모든 함수 기능을 실행하게 합니다.

  • CurrentHealth 가 해당 캐릭터와 연결된 모든 클라이언트의 사본에 리플리케이트됩니다.

  • 각 클라이언트는 새 CurrentHealth 값을 서버로부터 받으면 OnRep_CurrentHealth 를 호출합니다.

  • OnRep_CurrentHealthOnHealthUpdate 를 호출하여 각 클라이언트가 동일한 방식으로 새 CurrentHealth 값에 반응하게 합니다.

이 구현에는 두 가지 장점이 있습니다. 첫째, 두 핵심 함수 SetCurrentHealthOnHealthUpdate 를 중심으로 새 함수 기능을 추가하여 워크플로를 압축합니다. 이렇게 하면 향후 코드를 관리하고 확장하기가 더 쉬워집니다. 둘째, 이 구현은 서버, 클라이언트, NetMulticast RPC를 사용하지 않고 CurrentHealth 의 리플리케이션에만 의존하여 모든 필수 변경을 트리거하므로 네트워크 전반에 전송되는 정보의 양이 압축됩니다. 어떤 함수를 구현하든 CurrentHealth 는 리플리케이트되어야 하므로, 이는 체력 변경을 리플리케이트하는 가장 효율적인 모델입니다.

4. 발사체와 리플리케이션 생성

  1. 언리얼 에디터 내에서 툴(Tool) 메뉴 또는 콘텐츠 브라우저(Content Browser) 를 사용하여 새로운 C++ 클래스(New C++ Class) 를 생성합니다.

    새 클래스 생성

  2. 부모 클래스 선택(Choose Parent Class) 메뉴에서 액터(Actor) 를 부모 클래스로 선택하고 다음(Next) 을 클릭합니다.

    이미지를 클릭하면 최대 크기로 볼 수 있습니다.

  3. 새 액터 이름(Name Your New Actor) 메뉴에서 클래스를 ThirdPersonMPProjectile 로 명명하고 클래스 생성(Create Class) 을 클릭합니다.

    이미지를 클릭하면 최대 크기로 볼 수 있습니다.

  4. ThirdPersonMPProjectile.h 를 열고 클래스 정의의 public 아래 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.h

    public:
        // 콜리전 테스트에 사용되는 스피어 컴포넌트
        UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
        class USphereComponent* SphereComponent;
    
        // 오브젝트의 비주얼 표현을 제공하는 스태틱 메시
        UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
        class UStaticMeshComponent* StaticMesh;
    
        // 발사체 움직임을 처리하는 무브먼트 컴포넌트
        UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
        class UProjectileMovementComponent* ProjectileMovementComponent;
    
        // 발사체가 다른 오브젝트에 영향을 미치고 폭발할 때 사용되는 파티클
        UPROPERTY(EditAnywhere, Category = "Effects")
        class UParticleSystem* ExplosionEffect;
    
        //이 발사체가 가할 대미지 타입과 대미지
        UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Damage")
        TSubclassOf<class UDamageType> DamageType;
    
        //이 발사체가 가하는 대미지
        UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Damage")
        float Damage;

    이 선언의 각 유형에서는 class 키워드를 먼저 사용해야 합니다. 이렇게 하면 모두 변수 선언일 뿐 아니라 자체 클래스의 전방 선언이 되므로, 클래스가 헤더 파일에서 분명하게 인식됩니다. 다음 단계에 이를 위한 #include 를 CPP 파일에 추가할 것입니다.

    여기서 선언하는 프로퍼티는 다음을 제공할 것입니다.

    • 발사체의 비주얼 표현이 될 스태틱 메시 컴포넌트(Static Mesh Component)

    • 콜리전 테스트에 사용되는 스피어(Sphere) 컴포넌트

    • 발사체를 움직이기 위한 발사체 무브먼트 컴포넌트(Projectile Movement Component)

    • 이후 단계에서 폭발 이펙트를 스폰할 때 사용할 파티클 시스템(Particle System) 레퍼런스

    • 대미지 이벤트에 사용할 대미지 타입

    • 캐릭터가 이 발사체에 맞았을 때 체력을 얼마나 줄일지 나타내는 대미지(Damage) float 값

    그러나 아직 정의된 것은 없습니다.

    캐릭터 무브먼트 컴포넌트와 마찬가지로 발사체 무브먼트 컴포넌트도 bReplicatesTrue 로 설정된 경우 소속된 액터가 움직이면 자동으로 리플리케이션을 처리합니다.

  5. ThirdPersonMPProjectile.cpp 를 열고 파일 상단의 #include 구문 중 #include "ThirdPersonMPProjectile.h" 아래 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    #include "Components/SphereComponent.h"
    #include "Components/StaticMeshComponent.h"
    #include "GameFramework/ProjectileMovementComponent.h"
    #include "GameFramework/DamageType.h"
    #include "Particles/ParticleSystem.h"
    #include "Kismet/GameplayStatics.h"
    #include "UObject/ConstructorHelpers.h"

    이 실습에서 모두 사용해 볼 것입니다. 첫 4개는 사용할 컴포넌트입니다. GamePlayStatics.h 는 기본 게임플레이 함수에 대한 액세스를, ConstructorHelpers.h 는 컴포넌트 구성에 유용한 생성자 함수에 대한 액세스를 제공합니다.

  6. AThirdPersonMPProjectile 생성자 내부에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    bReplicates = true;

    bReplicates 변수는 이 액터가 리플리케이트 대상임을 게임에 알립니다. 기본적으로 액터는 해당 액터를 스폰하는 머신에서만 로컬로 존재합니다. bReplicatesTrue 로 설정하면, 액터의 권위 있는 사본이 서버에 존재하는 한 액터를 연결된 모든 클라이언트에 리플리케이트하려 할 것입니다.

  7. AThirdPersonMPProjectile 생성자 내부에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    //발사체와 콜리전의 루트 컴포넌트 역할을 할 SphereComponent 정의
    SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("RootComponent"));
    SphereComponent->InitSphereRadius(37.5f);
    SphereComponent->SetCollisionProfileName(TEXT("BlockAllDynamic"));
    RootComponent = SphereComponent;

    이 코드는 오브젝트가 생성되었을 때 SphereComponent를 정의하여 발사체 콜리전을 제공합니다.

  8. AThirdPersonMPProjectile 생성자 내부에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    //비주얼 표현을 담당할 메시 정의
    static ConstructorHelpers::FObjectFinder<UStaticMesh> DefaultMesh(TEXT("/Game/StarterContent/Shapes/Shape_Sphere.Shape_Sphere"));
    StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    StaticMesh->SetupAttachment(RootComponent);
    
    //사용할 메시 에셋이 발견되면 스태틱 메시와 위치/스케일 설정
    if (DefaultMesh.Succeeded())
    {
        StaticMesh->SetStaticMesh(DefaultMesh.Object);
        StaticMesh->SetRelativeLocation(FVector(0.0f, 0.0f, -37.5f));
        StaticMesh->SetRelativeScale3D(FVector(0.75f, 0.75f, 0.75f));
    }

    이 코드는 비주얼 표현으로 사용하는 StaticMeshComponent를 정의합니다. 코드는 StarterContent 안의 Shape_Sphere 메시를 자동으로 찾아서 채워 넣으려고 시도합니다. 스피어 또한 스케일 조절되어 SphereComponent의 크기에 맞게 정렬됩니다.

  9. AThirdPersonMPProjectile 생성자 내부에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    static ConstructorHelpers::FObjectFinder<UParticleSystem> DefaultExplosionEffect(TEXT("/Game/StarterContent/Particles/P_Explosion.P_Explosion"));
    if (DefaultExplosionEffect.Succeeded())
    {
        ExplosionEffect = DefaultExplosionEffect.Object;
    }

    ExplosionEffect 에 대한 에셋 레퍼런스를 StarterContent 내의 P_Explosion 에셋으로 설정합니다.

  10. AThirdPersonMPProjectile 생성자 내부에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    //발사체 무브먼트 컴포넌트 정의
    ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovement"));
    ProjectileMovementComponent->SetUpdatedComponent(SphereComponent);
    ProjectileMovementComponent->InitialSpeed = 1500.0f;
    ProjectileMovementComponent->MaxSpeed = 1500.0f;
    ProjectileMovementComponent->bRotationFollowsVelocity = true;
    ProjectileMovementComponent->ProjectileGravityScale = 0.0f;

    이 코드는 발사체에 대해 발사체 무브먼트 컴포넌트를 정의합니다. 이 컴포넌트는 리플리케이트되며, 이 컴포넌트가 서버에서 수행하는 모든 움직임은 클라이언트 측에서 재생성될 것입니다.

  11. AThirdPersonMPProjectile 생성자 내부에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    DamageType = UDamageType::StaticClass();
    Damage = 10.0f;

    이 코드는 발사체가 액터에 가하는 대미지의 크기와 대미지 이벤트에 사용할 대미지 타입을 초기화합니다. 아직 새 대미지 타입을 정의하지 않았으므로 여기서는 베이스 UDamageType 으로 초기화합니다.

5. 발사체가 대미지를 유발하게 만들기

지금까지 따라 하셨다면 서버에서 발사체를 스폰할 수 있고, 이 발사체가 모든 클라이언트에서 나타나고 움직일 것입니다. 발사체가 벽이나 차단 오브젝트에 부딪치면 멈출 것입니다. 이제 발사체가 플레이어에게 대미지를 가하게 해야 하고, 세션에 연결된 모든 클라이언트에게 폭발 이펙트를 보여줘야 합니다.

  1. ThirdPersonMPProjectile.h 에서 Protected 아래 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.h

    protected:  
        virtual void Destroyed() override;
  2. ThirdPersonMPProjectile.cpp 에서 이 함수에 다음 구현을 추가합니다.

    ThirdPersonMPProjectile.cpp

    void AThirdPersonMPProjectile::Destroyed()
    {
        FVector spawnLocation = GetActorLocation();
        UGameplayStatics::SpawnEmitterAtLocation(this, ExplosionEffect, spawnLocation, FRotator::ZeroRotator, true, EPSCPoolMethod::AutoRelease);
    }

    Destroyed 함수는 액터가 소멸될 때마다 호출됩니다. 파티클 이미터 자체는 일반적으로 리플리케이트되지 않지만, 액터 소멸은 리플리케이트되므로 서버에서 이 투사체를 소멸시킵니다. 이 함수는 연결된 각 클라이언트에서 각 사본을 소멸시킬 때 호출됩니다. 그 결과 발사체가 소멸되면 모든 플레이어가 폭발 이펙트를 보게 됩니다.

  3. ThirdPersonMPProjectile.h 에서 Protected 아래 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.h

    UFUNCTION(Category="Projectile")
    void OnProjectileImpact(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);
  4. ThirdPersonMPProjectile.cpp 에서 이 함수에 다음 구현을 추가합니다.

    ThirdPersonMPProjectile.cpp

    void AThirdPersonMPProjectile::OnProjectileImpact(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
    {   
        if ( OtherActor )
        {
            UGameplayStatics::ApplyPointDamage(OtherActor, Damage, NormalImpulse, Hit, GetInstigator()->Controller, this, DamageType);
        }
    
        Destroy();
    }

    발사체가 오브젝트에 부딪칠 때 호출할 함수입니다. 오브젝트가 유효한 액터와 부딪칠 경우 ApplyPointDamage 함수를 호출하여 콜리전이 발생한 위치에 대미지를 가할 것입니다. 한편 모든 콜리전은 어떤 표면에 충돌했든 이 액터를 소멸시키면서 폭발 이펙트를 일으킬 것입니다.

  5. ThirdPersonMPProjectile.cpp 에서 RootComponent = SphereComponent 아래의 AThirdPersonMPProjectile 생성자에 다음 코드를 추가합니다.

    ThirdPersonMPProjectile.cpp

    //발사체 충돌 함수를 히트 이벤트에 등록
    if (GetLocalRole() == ROLE_Authority)
    {
        SphereComponent->OnComponentHit.AddDynamic(this, &AThirdPersonMPProjectile::OnProjectileImpact);
    }

    OnProjectileImpact 함수를 발사체의 주요 콜리전 컴포넌트가 될 스피어 컴포넌트의 OnComponentHit 이벤트에 등록합니다. 서버만 이 게임플레이 로직을 실행하도록 OnProjectileImpact 등록 전에 GetLocalRole() == ROLE_Authority 을 확인합니다.

6. 발사체 발사하기

  1. 언리얼 에디터 를 열고 화면 상단의 편집(Edit) 드롭다운 메뉴에서 프로젝트 세팅(Project Settings) 을 엽니다.

    프로젝트 세팅

  2. 엔진(Engine) 섹션에서 입력(Input) 을 클릭하여 프로젝트의 입력 세팅을 엽니다. 바인딩(Bindings) 섹션을 펼쳐서 새 엔트리를 추가합니다. "Fire "라고 명명한 다음 왼쪽 마우스 버튼(Left Mouse Button) 을 이 액션이 바인딩될 키로 선택합니다.

    이미지를 클릭하면 최대 크기로 볼 수 있습니다.

  3. ThirdPersonMPCharacter.cpp 에서 #include "Engine/Engine.h" 줄 아래 다음 #include 를 추가합니다.

    ThirdPersonMPCharacter.cpp

    #include "ThirdPersonMPProjectile.h"

    이렇게 하면 캐릭터 클래스가 발사체 타입을 인식하고 스폰할 것입니다.

  4. ThirdPersonMPCharacter.h 에서 protected 아래 다음 코드를 추가합니다.

    ThirdPersonMPCharacter.h

    protected:  
        UPROPERTY(EditDefaultsOnly, Category="Gameplay|Projectile")
        TSubclassOf<class AThirdPersonMPProjectile> ProjectileClass;
    
        /** 발사 딜레이, 단위는 초. 테스트 발사체의 발사 속도를 제어하는 데 사용되지만, 서버 함수의 추가분이 SpawnProjectile을 입력에 직접 바인딩하지 않게 하는 역할도 합니다.*/
        UPROPERTY(EditDefaultsOnly, Category="Gameplay")
        float FireRate;
    
        /** true인 경우 발사체를 발사하는 프로세스 도중입니다. */
        bool bIsFiringWeapon;
    
        /** 무기 발사 시작 함수*/
        UFUNCTION(BlueprintCallable, Category="Gameplay")
        void StartFire();
    
        /** 무기 발사 종료 함수. 호출되면 플레이어가 StartFire를 다시 사용할 수 있습니다.*/
        UFUNCTION(BlueprintCallable, Category = "Gameplay")
        void StopFire();  
    
        /** 발사체를 스폰하는 서버 함수*/
        UFUNCTION(Server, Reliable)
        void HandleFire();
    
        /** 스폰 사이에 발사 속도 딜레이를 넣는 타이머 핸들*/
        FTimerHandle FiringTimer;

    발사체 발사에 사용할 변수와 함수입니다. HandleFire 는 이 튜토리얼에서 구현하는 유일한 RPC로, 서버에서 발사체를 스폰합니다. Server 지정자가 있기 때문에 클라이언트에서 이를 호출하려는 모든 시도는 네트워크를 통해 서버의 권위 있는 캐릭터로 전달됩니다.

    HandleFire 에는 Reliable 지정자도 있기 때문에 호출될 때마다 신뢰할 수 있는 RPC의 큐 등록에 배치되며, 서버가 수신에 성공하면 큐 등록에서 제거됩니다. 이는 서버가 이 함수의 호출을 확실하게 수신하도록 보장합니다. 그러나 너무 많은 RPC가 제거 없이 동시에 배치되면 신뢰할 수 있는 RPC의 큐 등록이 추가분을 유발할 수 있으며, 그 경우 사용자가 강제로 연결 해제됩니다. 그러므로 플레이어가 이 함수를 호출하는 빈도에 주의해야 합니다.

  5. ThirdPersonMPCharacter.cpp 에서 AThirdPersonMPCharacter 생성자 하단에 다음 코드를 추가합니다.

    ThirdPersonMPCharacter.cpp

    //발사체 클래스 초기화
    ProjectileClass = AThirdPersonMPProjectile::StaticClass();
    //발사 속도 초기화
    FireRate = 0.25f;
    bIsFiringWeapon = false;

    이 코드는 발사체 발사를 처리하는 데 필요한 변수를 초기화합니다.

  6. ThirdPersonMPCharacter.cpp 에 다음 구현을 추가합니다.

    ThirdPersonMPCharacter.cpp

    void AThirdPersonMPCharacter::StartFire()
    {
        if (!bIsFiringWeapon)
        {
            bIsFiringWeapon = true;
            UWorld* World = GetWorld();
            World->GetTimerManager().SetTimer(FiringTimer, this, &AThirdPersonMPCharacter::StopFire, FireRate, false);
            HandleFire();
        }
    }
    
    void AThirdPersonMPCharacter::StopFire()
    {
        bIsFiringWeapon = false;
    }
    
    void AThirdPersonMPCharacter::HandleFire_Implementation()
    {
        FVector spawnLocation = GetActorLocation() + ( GetActorRotation().Vector()  * 100.0f ) + (GetActorUpVector() * 50.0f);
        FRotator spawnRotation = GetActorRotation();
    
        FActorSpawnParameters spawnParameters;
        spawnParameters.Instigator = GetInstigator();
        spawnParameters.Owner = this;
    
        AThirdPersonMPProjectile* spawnedProjectile = GetWorld()->SpawnActor<AThirdPersonMPProjectile>(spawnLocation, spawnRotation, spawnParameters);
    }

    StartFire 는 플레이어가 발사 프로세스를 개시하기 위해 로컬 머신에서 호출하는 함수입니다. 다음 기준에 따라 사용자가 HandleFire 를 얼마나 자주 호출할 수 있는지 제한합니다.

    • 사용자는 이미 발사체를 발사하는 중일 때 또 발사할 수 없습니다. 이 상태는 StartFire 가 호출될 때 true 로 설정되는 bFiringWeapon 으로 지정됩니다.

    • bFiringWeaponStopFire 가 호출되어야 false 로 설정됩니다.

    • StopFireFireRate 길이 타이머가 종료되면 호출됩니다.

    사용자가 발사체를 발사하면 다시 발사할 수 있기까지 FireRate 초만큼 기다려야 한다는 뜻입니다. 이 함수는 StartFire 가 어떤 입력에 바인딩되어 있든 일관되게 기능합니다. 예를 들어 사용자가 '발사' 명령을 마우스 휠 스크롤 같은 부적절한 입력에 바인딩하거나 버튼을 빠르게 연타하는 경우에도 이 함수는 적절한 간격을 두고 실행되며, HandleFire 호출로 사용자의 신뢰할 수 있는 함수 큐 등록에 추가분을 유발하지 않습니다.

    HandleFire 는 서버 RPC이며 CPP 파일의 구현에서는 반드시 함수 이름에 접미사 _Implementation 이 추가되어야 하기 때문입니다. 여기서의 구현은 캐릭터의 회전 제어를 사용하여 카메라가 향한 방향을 구한 다음 해당 방향으로 발사체를 스폰하여 플레이어가 조준할 수 있게 합니다. 그러면 발사체의 발사체 무브먼트 컴포넌트가 해당 방향을 향한 이동을 처리합니다.

  7. ThirdPersonMPCharacter.cpp 에서 함수 SetupPlayerInputComponent 하단에 다음을 추가합니다.

    ThirdPersonMPCharacter.cpp

    // 발사체 발사 처리
    PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &AThirdPersonMPCharacter::StartFire);

    StartFire 를 이 섹션 첫 단계에 생성한 발사(Fire) 입력 액션과 바인딩하여 사용자가 활성화할 수 있게 합니다.

7. 게임 테스트하기

  1. 에디터에서 프로젝트를 엽니다. 편집(Edit) 드롭다운 메뉴를 클릭하고 에디터 개인설정(Editor Preferences) 을 엽니다.

    레벨 에디터 열기/플레이

  2. 레벨 에디터(Level Editor) 섹션으로 가서 플레이(Play) 메뉴를 클릭합니다. 멀티플레이어 옵션(Multiplayer Options) 을 찾고 플레이 넷 모드(Play Net Mode)Play As Listen Server 로 변경합니다. 또한 클라이언트 플레이 수(Play Number of Clients)2 로 설정합니다.

    이미지를 클릭하면 최대 크기로 볼 수 있습니다.

  3. 플레이(Play) 버튼을 누릅니다. 메인 에디터에서 플레이(PIE) 창이 서버로서 멀티플레이어 세션을 시작하고, 보조 PIE 창이 열려 클라이언트로서 연결될 것입니다.

최종 결과

이미지를 클릭하면 최대 크기로 볼 수 있습니다.

게임 내의 두 플레이어는 서로가 움직이는 것을 볼 수 있고, 서로를 향해 커스텀 발사체를 발사할 수 있습니다. 한 플레이어가 커스텀 발사체에 맞으면 두 플레이어 모두에게 폭발 파티클이 보이고, 맞은 플레이어는 얼마나 대미지를 받았는지, 현재 체력은 얼마인지 알려주는 '히트' 메시지를 받습니다. 세션의 나머지 플레이어에게는 아무것도 보이지 않습니다. 플레이어의 체력이 0으로 줄어들면 사망했다는 메시지를 받습니다.

이 가이드를 완료하셨으니 변수와 컴포넌트 리플리케이션의 개요, 네트워크 역할을 다루는 방법, RPC를 사용하기에 적절한 시기 등 C++에서 멀티플레이어 함수 기능을 만드는 데 필요한 기초를 파악하셨을 것입니다. 이 정보를 바탕으로 언리얼의 서버-클라이언트 모델에서 여러분만의 멀티플레이어 게임을 만들 수 있을 것입니다.

직접 해 보기

네트워크 멀티플레이어 프로그래밍 실력을 더 키우려면 다음을 시도해 보세요.

  • 발사체의 OnHit 함수 기능을 확장하여 발사체가 타깃에 부딪쳤을 때 폭발 반경을 시뮬레이션하는 스피어 트레이스를 생성하는 등의 추가 이펙트 넣기

  • ThirdPersonMPProjectile을 확장하고 ProjectileMovement 컴포넌트를 실험하면서 다르게 행동하는 새 변수 생성하기

  • ThirdPersonMPCharacter의 TakeDamage 함수를 확장하여 플레이어 폰을 처치하고 리스폰하기

  • 로컬 플레이어 컨트롤러에 HUD를 추가하여 리플리케이트된 정보를 표시하거나 클라이언트 함수에 반응하게 하기

  • DamageTypes를 사용하여 플레이어가 처치되었을 때 개인화된 메시지 생성하기

  • 게임 모드, 플레이어 상태, 게임 상태를 활용하여 매치를 조정하는 규칙과 플레이어 통계 및 점수판을 만들기

코드 샘플

ThirdPersonMPProjectile.h

// Copyright 1998-2022 Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ThirdPersonMPProjectile.generated.h"

UCLASS()
class THIRDPERSONMP_API AThirdPersonMPProjectile : public AActor
{
    GENERATED_BODY()

public: 
    // 이 액터 프로퍼티의 디폴트값 설정
    AThirdPersonMPProjectile();

protected:
    // 게임 시작 또는 스폰 시 호출
    virtual void BeginPlay() override;

public: 
    // 프레임마다 호출
    virtual void Tick(float DeltaTime) override;

public:
    // 콜리전 테스트에 사용되는 스피어 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
    class USphereComponent* SphereComponent;

    // 오브젝트의 비주얼 표현을 제공하는 스태틱 메시
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
    class UStaticMeshComponent* StaticMesh;

    // 발사체 움직임을 처리하는 무브먼트 컴포넌트
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Components")
    class UProjectileMovementComponent* ProjectileMovementComponent;

    // 발사체가 다른 오브젝트에 영향을 미치고 폭발할 때 사용되는 파티클
    UPROPERTY(EditAnywhere, Category = "Effects")
    class UParticleSystem* ExplosionEffect;

    //이 발사체가 가할 대미지 타입과 대미지
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Damage")
    TSubclassOf<class UDamageType> DamageType;

    //이 발사체가 가하는 대미지
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Damage")
    float Damage;

protected:

    virtual void Destroyed() override;

    UFUNCTION(Category = "Projectile")
    void OnProjectileImpact(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit);

};

ThirdPersonMPProjectile.cpp

// Copyright 1998-2022 Epic Games, Inc. All Rights Reserved.

#include "ThirdPersonMPProjectile.h"
#include "Components/SphereComponent.h"
#include "Components/StaticMeshComponent.h"
#include "GameFramework/ProjectileMovementComponent.h"
#include "GameFramework/DamageType.h"
#include "Particles/ParticleSystem.h"
#include "Kismet/GameplayStatics.h"
#include "UObject/ConstructorHelpers.h"

// 디폴트값 설정
AThirdPersonMPProjectile::AThirdPersonMPProjectile()
{
    // 이 액터가 프레임마다 Tick()을 호출하도록 설정합니다.  이 설정이 필요 없는 경우 비활성화하면 퍼포먼스가 향상됩니다.
    PrimaryActorTick.bCanEverTick = true;

    bReplicates = true;

    //발사체와 콜리전의 루트 컴포넌트 역할을 할 SphereComponent 정의
    SphereComponent = CreateDefaultSubobject<USphereComponent>(TEXT("RootComponent"));
    SphereComponent->InitSphereRadius(37.5f);
    SphereComponent->SetCollisionProfileName(TEXT("BlockAllDynamic"));
    RootComponent = SphereComponent;

    //발사체 충돌 함수를 히트 이벤트에 등록
    if (GetLocalRole() == ROLE_Authority)
    {
        SphereComponent->OnComponentHit.AddDynamic(this, &AThirdPersonMPProjectile::OnProjectileImpact);
    }

    //비주얼 표현을 담당할 메시 정의
    static ConstructorHelpers::FObjectFinder<UStaticMesh> DefaultMesh(TEXT("/Game/StarterContent/Shapes/Shape_Sphere.Shape_Sphere"));
    StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    StaticMesh->SetupAttachment(RootComponent);

    //사용할 메시 에셋이 발견되면 스태틱 메시와 위치/스케일 설정
    if (DefaultMesh.Succeeded())
    {
        StaticMesh->SetStaticMesh(DefaultMesh.Object);
        StaticMesh->SetRelativeLocation(FVector(0.0f, 0.0f, -37.5f));
        StaticMesh->SetRelativeScale3D(FVector(0.75f, 0.75f, 0.75f));
    }

    static ConstructorHelpers::FObjectFinder<UParticleSystem> DefaultExplosionEffect(TEXT("/Game/StarterContent/Particles/P_Explosion.P_Explosion"));
    if (DefaultExplosionEffect.Succeeded())
    {
        ExplosionEffect = DefaultExplosionEffect.Object;
    }

    //발사체 무브먼트 컴포넌트 정의
    ProjectileMovementComponent = CreateDefaultSubobject<UProjectileMovementComponent>(TEXT("ProjectileMovement"));
    ProjectileMovementComponent->SetUpdatedComponent(SphereComponent);
    ProjectileMovementComponent->InitialSpeed = 1500.0f;
    ProjectileMovementComponent->MaxSpeed = 1500.0f;
    ProjectileMovementComponent->bRotationFollowsVelocity = true;
    ProjectileMovementComponent->ProjectileGravityScale = 0.0f;

    DamageType = UDamageType::StaticClass();
    Damage = 10.0f;
}

// 게임 시작 또는 스폰 시 호출
void AThirdPersonMPProjectile::BeginPlay()
{
    Super::BeginPlay();
}

// 프레임마다 호출
void AThirdPersonMPProjectile::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
}

void AThirdPersonMPProjectile::Destroyed()
{
    FVector spawnLocation = GetActorLocation();
    UGameplayStatics::SpawnEmitterAtLocation(this, ExplosionEffect, spawnLocation, FRotator::ZeroRotator, true, EPSCPoolMethod::AutoRelease);
}

void AThirdPersonMPProjectile::OnProjectileImpact(UPrimitiveComponent* HitComponent, AActor* OtherActor, UPrimitiveComponent* OtherComp, FVector NormalImpulse, const FHitResult& Hit)
{
    if (OtherActor)
    {
        UGameplayStatics::ApplyPointDamage(OtherActor, Damage, NormalImpulse, Hit, GetInstigator()->Controller, this, DamageType);
    }
    Destroy();
}

ThirdPersonMPCharacter.h

// Copyright 1998-2022 Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "ThirdPersonMPCharacter.generated.h"

UCLASS(config=Game)
class AThirdPersonMPCharacter : public ACharacter
{
    GENERATED_BODY()

    /** 캐릭터 뒤에 카메라를 배치하는 카메라 붐 */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
    class USpringArmComponent* CameraBoom;

    /** 카메라 따라가기 */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = Camera, meta = (AllowPrivateAccess = "true"))
    class UCameraComponent* FollowCamera;

public:

    AThirdPersonMPCharacter();

    /** 프로퍼티 리플리케이션 */
    void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    /** 베이스 회전 속도, 단위는 도(º)/초. 다른 스케일 값 조절로 인해 최종 회전 속도가 영향을 받을 수 있습니다. */
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category=Input)
    float TurnRateGamepad;

protected:

    /** 앞뒤 입력으로 호출 */
    void MoveForward(float Value);

    /** 좌우 입력으로 호출 */
    void MoveRight(float Value);

    /** 
    * 입력을 통해 호출되어 지정된 속도로 회전 
    * @param Rate   정규화된 비율이며, 1.0인 경우 지정된 회전 속도의 100%를 의미합니다.
    */
    void TurnAtRate(float Rate);

    /**
    * 입력을 통해 호출되어 지정된 속도로 올려다보기/내려다보기 
    * @param Rate   정규화된 비율이며, 1.0인 경우 지정된 회전 속도의 100%를 의미합니다.
    */
    void LookUpAtRate(float Rate);

    /** 터치 입력 시작 시 핸들러 */
    void TouchStarted(ETouchIndex::Type FingerIndex, FVector Location);

    /** 터치 입력 중지 시 핸들러 */
    void TouchStopped(ETouchIndex::Type FingerIndex, FVector Location);

    // APawn 인터페이스
    virtual void SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent) override;
    // APawn 인터페이스 종료

public:

    /** CameraBoom 서브오브젝트 반환 **/ 
    FORCEINLINE class USpringArmComponent* GetCameraBoom() const { return CameraBoom; }

    /** FollowCamera 서브오브젝트 반환 **/ 
    FORCEINLINE class UCameraComponent* GetFollowCamera() const { return FollowCamera; }

protected:

    /** 플레이어의 최대 체력. 체력의 최댓값입니다. 이 값은 스폰 시 시작되는 캐릭터의 체력 값입니다.*/
    UPROPERTY(EditDefaultsOnly, Category = "Health")
    float MaxHealth;

    /** 플레이어의 현재 체력. 0이 되면 죽은 것으로 간주됩니다.*/
    UPROPERTY(ReplicatedUsing = OnRep_CurrentHealth)
    float CurrentHealth;

    /** 현재 체력에 가해진 변경에 대한 RepNotify*/
    UFUNCTION()
    void OnRep_CurrentHealth();

    /** 업데이트되는 체력에 반응. 서버에서는 수정 즉시 호출, 클라이언트에서는 RepNotify에 반응하여 호출*/
    void OnHealthUpdate();

public:

    /** 최대 체력 게터*/
    UFUNCTION(BlueprintPure, Category = "Health")
    FORCEINLINE float GetMaxHealth() const { return MaxHealth; }

    /** 현재 체력 게터*/
    UFUNCTION(BlueprintPure, Category = "Health")
    FORCEINLINE float GetCurrentHealth() const { return CurrentHealth; }

    /** 현재 체력 세터. 값을 0과 MaxHealth 사이로 범위제한하고 OnHealthUpdate를 호출합니다. 서버에서만 호출되어야 합니다.*/
    UFUNCTION(BlueprintCallable, Category = "Health")
    void SetCurrentHealth(float healthValue);

    /** 대미지를 받는 이벤트. APawn에서 오버라이드됩니다.*/
    UFUNCTION(BlueprintCallable, Category = "Health")
    float TakeDamage(float DamageTaken, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;

protected:

    UPROPERTY(EditDefaultsOnly, Category = "Gameplay|Projectile")
    TSubclassOf<class AThirdPersonMPProjectile> ProjectileClass;

    /** 발사 딜레이, 단위는 초. 테스트 발사체의 발사 속도를 제어하는 데 사용되지만, 서버 함수의 추가분이 SpawnProjectile을 입력에 직접 바인딩하지 않게 하는 역할도 합니다.*/
    UPROPERTY(EditDefaultsOnly, Category = "Gameplay")
    float FireRate;

    /** true인 경우 발사체를 발사하는 프로세스 도중입니다. */
    bool bIsFiringWeapon;

    /** 무기 발사 시작 함수*/
    UFUNCTION(BlueprintCallable, Category = "Gameplay")
    void StartFire();

    /** 무기 발사 종료 함수. 호출되면 플레이어가 StartFire를 다시 사용할 수 있습니다.*/
    UFUNCTION(BlueprintCallable, Category = "Gameplay")
    void StopFire();

    /** 발사체를 스폰하는 서버 함수*/
    UFUNCTION(Server, Reliable)
    void HandleFire();

    /** 스폰 사이에 발사 속도 딜레이를 넣는 타이머 핸들*/
    FTimerHandle FiringTimer;
};

ThirdPersonMPCharacter.cpp

// Copyright 1998-2022 Epic Games, Inc. All Rights Reserved.

#include "ThirdPersonMPCharacter.h"
#include "Camera/CameraComponent.h"
#include "Components/CapsuleComponent.h"
#include "Components/InputComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/Controller.h"
#include "GameFramework/SpringArmComponent.h"
#include "Net/UnrealNetwork.h"
#include "Engine/Engine.h"
#include "ThirdPersonMPProjectile.h"

//////////////////////////////////////////////////////////////////////////
// AThirdPersonMPCharacter

AThirdPersonMPCharacter::AThirdPersonMPCharacter()
{
    // 콜리전 캡슐 크기 설정
    GetCapsuleComponent()->InitCapsuleSize(42.f, 96.0f);

    // 입력에 대한 회전 속도 설정
    TurnRateGamepad = 50.f;

    // 컨트롤러 회전 시 회전하지 않습니다. 카메라에만 영향을 미치도록 합니다.
    bUseControllerRotationPitch = false;
    bUseControllerRotationYaw = false;
    bUseControllerRotationRoll = false;

    // 캐릭터 무브먼트 환경설정
    GetCharacterMovement()->bOrientRotationToMovement = true; // 캐릭터가 입력 방향으로 이동    
    GetCharacterMovement()->RotationRate = FRotator(0.0f, 500.0f, 0.0f); // 위의 캐릭터가 이동하는 회전 속도

    // 참고: 이 변수를 비롯한 많은 변수는 조정하기 위해 다시 컴파일하지 않고도
    // 캐릭터 블루프린트에서 미세조정하여 반복작업 시간을 단축할 수 있습니다.
    GetCharacterMovement()->JumpZVelocity = 700.f;
    GetCharacterMovement()->AirControl = 0.35f;
    GetCharacterMovement()->MaxWalkSpeed = 500.f;
    GetCharacterMovement()->MinAnalogWalkSpeed = 20.f;
    GetCharacterMovement()->BrakingDecelerationWalking = 2000.f;

    // 카메라 붐 생성(콜리전 있을 시 플레이어 쪽으로 들어옴)
    CameraBoom = CreateDefaultSubobject<USpringArmComponent>(TEXT("CameraBoom"));
    CameraBoom->SetupAttachment(RootComponent);
    CameraBoom->TargetArmLength = 400.0f; // 캐릭터 뒤의 카메라가 이 거리에서 따라옴 
    CameraBoom->bUsePawnControlRotation = true; // 컨트롤러 기반으로 암 회전

    // 카메라 따라가기 생성
    FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
    FollowCamera->SetupAttachment(CameraBoom, USpringArmComponent::SocketName); // 카메라를 붐 끝에 어태치하여 붐이 컨트롤러 오리엔테이션에 맞추어 조절되도록 함
    FollowCamera->bUsePawnControlRotation = false; // 카메라가 암 기준으로 회전하지 않음

    // 참고: 캐릭터로부터 상속받는 메시 컴포넌트에 대한 스켈레탈 메시와 애님 블루프린트 레퍼런스는 
    // C++ 직접 콘텐츠 레퍼런스를 방지하기 위해 이름이 ThirdPersonCharacter인 파생 블루프린트 에셋에서 설정됨

    //플레이어 체력 초기화
    MaxHealth = 100.0f;
    CurrentHealth = MaxHealth;

    //발사체 클래스 초기화
    ProjectileClass = AThirdPersonMPProjectile::StaticClass();
    //발사 속도 초기화
    FireRate = 0.25f;
    bIsFiringWeapon = false;
}

//////////////////////////////////////////////////////////////////////////
// 입력

void AThirdPersonMPCharacter::SetupPlayerInputComponent(class UInputComponent* PlayerInputComponent)
{
    // 게임플레이 키 바인딩 설정
    check(PlayerInputComponent);
    PlayerInputComponent->BindAction("Jump", IE_Pressed, this, &ACharacter::Jump);
    PlayerInputComponent->BindAction("Jump", IE_Released, this, &ACharacter::StopJumping);

    PlayerInputComponent->BindAxis("Move Forward / Backward", this, &AThirdPersonMPCharacter::MoveForward);
    PlayerInputComponent->BindAxis("Move Right / Left", this, &AThirdPersonMPCharacter::MoveRight);

    // 2가지 버전의 회전 바인딩이 있어 서로 다른 종류의 디바이스를 다양한 방식으로 처리할 수 있습니다.
    // 'turn'은 마우스와 같은 절대 델타를 제공하는 디바이스를 처리합니다.
    // 'turnrate'는 아날로그 조이스틱과 같이 변화의 속도를 취급할 디바이스에 사용합니다.
    PlayerInputComponent->BindAxis("Turn Right / Left Mouse", this, &APawn::AddControllerYawInput);
    PlayerInputComponent->BindAxis("Turn Right / Left Gamepad", this, &AThirdPersonMPCharacter::TurnAtRate);
    PlayerInputComponent->BindAxis("Look Up / Down Mouse", this, &APawn::AddControllerPitchInput);
    PlayerInputComponent->BindAxis("Look Up / Down Gamepad", this, &AThirdPersonMPCharacter::LookUpAtRate);

    // 터치 디바이스 처리
    PlayerInputComponent->BindTouch(IE_Pressed, this, &AThirdPersonMPCharacter::TouchStarted);
    PlayerInputComponent->BindTouch(IE_Released, this, &AThirdPersonMPCharacter::TouchStopped);

    // 발사체 발사 처리
    PlayerInputComponent->BindAction("Fire", IE_Pressed, this, &AThirdPersonMPCharacter::StartFire);
}

void AThirdPersonMPCharacter::TouchStarted(ETouchIndex::Type FingerIndex, FVector Location)
{
    Jump();
}

void AThirdPersonMPCharacter::TouchStopped(ETouchIndex::Type FingerIndex, FVector Location)
{
    StopJumping();
}

void AThirdPersonMPCharacter::TurnAtRate(float Rate)
{
    // 속도 정보로부터 이 프레임에 대한 델타 계산
    AddControllerYawInput(Rate * TurnRateGamepad * GetWorld()->GetDeltaSeconds());
}

void AThirdPersonMPCharacter::LookUpAtRate(float Rate)
{
    // 속도 정보로부터 이 프레임에 대한 델타 계산
    AddControllerPitchInput(Rate * TurnRateGamepad * GetWorld()->GetDeltaSeconds());
}

void AThirdPersonMPCharacter::MoveForward(float Value)
{
    if ((Controller != nullptr) && (Value != 0.0f))
    {
        // 앞쪽 찾기
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0, Rotation.Yaw, 0);

        // 앞쪽 벡터 구하기
        const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
        AddMovementInput(Direction, Value);
    }
}

void AThirdPersonMPCharacter::MoveRight(float Value)
{
    if ( (Controller != nullptr) && (Value != 0.0f) )
    {
        // 오른쪽 찾기
        const FRotator Rotation = Controller->GetControlRotation();
        const FRotator YawRotation(0, Rotation.Yaw, 0);

        // 오른쪽 벡터 구하기 
        const FVector Direction = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);
        // 해당 방향으로 이동 추가
        AddMovementInput(Direction, Value);
    }
}

//////////////////////////////////////////////////////////////////////////
// 리플리케이트된 프로퍼티

void AThirdPersonMPCharacter::GetLifetimeReplicatedProps(TArray <FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    //현재 체력 리플리케이트
    DOREPLIFETIME(AThirdPersonMPCharacter, CurrentHealth);
}

void AThirdPersonMPCharacter::OnHealthUpdate()
{
    //클라이언트 전용 함수 기능
    if (IsLocallyControlled())
    {
        FString healthMessage = FString::Printf(TEXT("You now have %f health remaining."), CurrentHealth);
        GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Blue, healthMessage);

        if (CurrentHealth <= 0)
        {
            FString deathMessage = FString::Printf(TEXT("You have been killed."));
            GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Red, deathMessage);
        }
    }

    //서버 전용 함수 기능
    if (GetLocalRole() == ROLE_Authority)
    {
        FString healthMessage = FString::Printf(TEXT("%s now has %f health remaining."), *GetFName().ToString(), CurrentHealth);
        GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Blue, healthMessage);
    }

    //모든 머신에서 실행되는 함수 
    /*
        여기에 대미지 또는 사망의 결과로 발생하는 특별 함수 기능 배치
    */
}

void AThirdPersonMPCharacter::OnRep_CurrentHealth()
{
    OnHealthUpdate();
}

void AThirdPersonMPCharacter::SetCurrentHealth(float healthValue)
{
    if (GetLocalRole() == ROLE_Authority)
    {
        CurrentHealth = FMath::Clamp(healthValue, 0.f, MaxHealth);
        OnHealthUpdate();
    }
}

float AThirdPersonMPCharacter::TakeDamage(float DamageTaken, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    float damageApplied = CurrentHealth - DamageTaken;
    SetCurrentHealth(damageApplied);
    return damageApplied;
}

void AThirdPersonMPCharacter::StartFire()
{
    if (!bIsFiringWeapon)
    {
        bIsFiringWeapon = true;
        UWorld* World = GetWorld();
        World->GetTimerManager().SetTimer(FiringTimer, this, &AThirdPersonMPCharacter::StopFire, FireRate, false);
        HandleFire();
    }
}

void AThirdPersonMPCharacter::StopFire()
{
    bIsFiringWeapon = false;
}

void AThirdPersonMPCharacter::HandleFire_Implementation()
{
    FVector spawnLocation = GetActorLocation() + (GetActorRotation().Vector() * 100.0f) + (GetActorUpVector() * 50.0f);
    FRotator spawnRotation = GetActorRotation();

    FActorSpawnParameters spawnParameters;
    spawnParameters.Instigator = GetInstigator();
    spawnParameters.Owner = this;

    AThirdPersonMPProjectile* spawnedProjectile = GetWorld()->SpawnActor<AThirdPersonMPProjectile>(spawnLocation, spawnRotation, spawnParameters);
}