[Unreal Engine] Gameplay Framework 2

Imeamangryang·2025년 7월 27일
post-thumbnail

출처 ㅣ Cedric Neukirchen - Multiplayer Network Compendium


APlayerState

APlayerState는 특정 플레이어에 대한 공유 정보를 저장하는 데 가장 중요한 클래스입니다.
플레이어의 현재 정보를 보관하는 용도로 설계되었으며, 각 플레이어마다 자신의 PlayerState가 존재합니다.

PlayerState는 모든 클라이언트에 복제(Replicated)되므로, 다른 클라이언트에서도 데이터를 조회하고 표시할 수 있습니다. 모든 PlayerState에 쉽게 접근하려면 AGameState 클래스의 PlayerArray를 사용할 수 있습니다.

PlayerState에 저장할 수 있는 예시 정보는 다음과 같습니다:

  • PlayerName - 플레이어의 현재 이름
  • Score - 플레이어의 현재 점수
  • Ping - 플레이어의 현재 핑
  • TeamID - 플레이어가 속한 팀의 ID
  • 그 외 다른 플레이어들이 알아야 할 복제 정보 등

Examples and Usage

제공할 수 있는 대부분의 예시는 매우 제한적일 수 있습니다. 대신 이미 제공되는 속성들과 흥미로운 함수들을 살펴보겠습니다.

Blueprint Examples

블루프린트에 노출된 몇 가지 변수가 있습니다.
이 변수들은 유용할 수도 있고 아닐 수도 있습니다.

아쉽게도 일부 변수는 모든 기능이 노출되어 있지 않으므로, 직접 변수를 만들어 사용하는 것이 더 나을 수 있습니다.

이 변수들은 모두 복제(Replicated)되어 모든 클라이언트에서 동기화됩니다.

아쉽게도 블루프린트에서 쉽게 설정할 수는 없지만, 직접 변수를 만들어 사용하는 데에는 제약이 없습니다.

예를 들어, PlayerName 변수를 설정하는 방법은 GameMode의 “ChangeName” 함수를 호출하고, 해당 플레이어의 PlayerController를 전달하는 것입니다.

PlayerState는 또한 심리스(Seamless) 레벨 변경이나 예기치 않은 연결 문제 발생 시 데이터가 유지되도록 사용됩니다.

PlayerState에는 재접속한 플레이어나 서버와 함께 새로운 맵으로 심리스 이동한 플레이어를 처리하는 전용 함수가 두 개 있습니다.

이 함수들은 원래는 아니었지만, 현재는 블루프린트에서도 사용할 수 있습니다.

PlayerState는 자신이 보유한 정보를 새로운 PlayerState로 복사하는 역할을 합니다. 이는 레벨 변경을 통해 생성되거나, 플레이어가 재접속할 때 생성됩니다.

UE C++ Examples

블루프린트에서 봤던 같은 함수들을 C++로 살펴보겠습니다.

// 현재 PlayerState에서 전달된 PlayerState로 속성을 복사하는 데 사용
virtual void CopyProperties(class APlayerState* PlayerState);

// 전달된 PlayerState의 속성으로 현재 PlayerState를 덮어쓰는 데 사용
virtual void OverrideWith(class APlayerState* PlayerState);

이 함수들은 커스텀 PlayerState 자식 클래스에 구현하여 직접 추가한 데이터를 관리할 수 있습니다.

반드시 “override” 지정자를 끝에 붙이고, “Super::”를 호출하여 원래 구현이 유지되도록 하세요.

구현 예시는 다음과 같습니다.

void ATestPlayerState::CopyProperties(class APlayerState* PlayerState)
{
    Super::CopyProperties(PlayerState);

    if (IsValid(PlayerState))
    {
        ATestPlayerState* TestPlayerState = Cast<ATestPlayerState>(PlayerState);
        if (IsValid(TestPlayerState))
        {
            TestPlayerState->SomeVariable = SomeVariable;
        }
    }
}

void ATestPlayerState::OverrideWith(class APlayerState* PlayerState)
{
    Super::OverrideWith(PlayerState);

    if (IsValid(PlayerState))
    {
        ATestPlayerState* TestPlayerState = Cast<ATestPlayerState>(PlayerState);
        if (IsValid(TestPlayerState))
        {
            SomeVariable = TestPlayerState->SomeVariable;
        }
    }
}

APawn and ACharacter

APawn 클래스는 플레이어가 조종하는 'AActor'입니다. 대부분의 경우 인간형 캐릭터이지만, 고양이, 비행기, 배, 블록 등 어떤 것이든 될 수 있습니다. 플레이어는 한 번에 하나의 Pawn만 소유할 수 있지만, 언포제스(Unpossess) 후 다시 포제스(Possess)하여 쉽게 Pawn을 전환할 수 있습니다.

Pawn은 대부분 모든 클라이언트에 복제(Replicated)됩니다.

Pawn의 자식 클래스인 ACharacter는 이미 네트워크 처리가 적용된 MovementComponent를 제공하므로, 플레이어 캐릭터의 위치, 회전 등 정보를 자동으로 복제해줍니다.

Examples and Usage

멀티플레이어 환경에서는 Pawn의 복제 기능을 주로 활용하여 캐릭터를 표시하고, 일부 정보를 다른 플레이어와 공유합니다.

예를 들어, 캐릭터의 '체력(Health)'을 복제하는 것이 대표적입니다. 단순히 체력을 다른 플레이어에게 보여주기 위해서만 복제하는 것이 아니라, 서버가 체력에 대한 권한을 가지도록 하여 클라이언트가 치트하는 것을 방지하기 위함입니다.

Blueprint Examples

기본적으로 오버라이드 가능한 함수 외에도, Pawn에는 플레이어나 AIController에 의해 un-/possessed될 때 반응할 수 있는 두 가지 함수가 있습니다.

💡 Info
Possessing logic은 서버에서 발생하므로, 이 이벤트들은 Pawn/Character의 서버 버전에서만 호출됩니다.

ReceiveControllerChanged라는 함수도 있는데, 이 함수는 동일한 이벤트에 클라이언트 측에서 반응할 수 있게 해줍니다.

다음 그림은 EventAnyDamage 함수와 복제된 Health 변수를 사용하여 플레이어의 체력을 감소시키는 방법을 보여줍니다.

이 작업은 클라이언트가 아닌 서버에서 발생합니다.

Pawn이 복제되어 있으므로, DestroyActor 노드는 서버에서 호출될 경우 Pawn의 클라이언트 버전도 함께 파괴합니다.
클라이언트 측에서는 복제된 'Health' 변수를 HUD나 모든 캐릭터 머리 위의 Health Bar에 사용할 수 있습니다.

UserWidget을 생성하고 ProgressBar와 Pawn에 대한 참조를 만들면 쉽게 구현할 수 있습니다.

이 문서는 멀티플레이어 컴펜디엄이므로 UserWidget에 대해 이미 알고 있거나, 적어도 별도로 학습할 것을 기대합니다.

'BP_Character' 클래스에 'Health'와 'MaxHealth' 변수가 모두 복제 설정되어 있다고 가정해봅시다. (MaxHealth가 런타임에 변경되지 않는다면 복제하지 않아도 됩니다).

이제 UserWidget 내부에 'BP_Character' 참조 변수와 ProgressBar를 만든 후, ProgressBar의 퍼센트 바인딩을 다음 함수에 연결할 수 있습니다:

그리고 WidgetComponent를 설정한 후 'Widget Class To Use'를 HealthBar UserWidget으로 지정하고, BeginPlay에서 다음을 수행할 수 있습니다.

'BeginPlay'는 Pawn의 모든 인스턴스에서 호출되므로, 서버와 모든 클라이언트에서 실행됩니다.

따라서 모든 인스턴스가 자신을 UserWidget의 Pawn 참조로 설정합니다.

그리고 Pawn과 Health 변수가 복제되어 있으므로, 모든 Pawn 머리 위에 올바른 체력 퍼센트가 표시됩니다.

복제 과정이 아직 명확하지 않더라도, 계속 읽다 보면 왜 이렇게 쉽게 동작하는지 이해할 수 있을 것입니다.

UE C++ Examples

C++ 예제에서는 UserWidget 예제를 재현하지 않습니다. UserWidget을 C++에서 동작시키기 위해서는 너무 많은 사전 준비 코드가 필요하므로 여기서는 다루지 않습니다.

대신 Possess와 Damage 이벤트에 집중하겠습니다. C++에서 두 Possess 이벤트는 다음과 같습니다.

virtual void PossessedBy(AController* NewController);

virtual void UnPossessed();

'UnPossessed' 이벤트는 이전 PlayerController를 전달하지 않습니다.

그리고 Health 예제도 C++로 재현해보겠습니다. 복제 단계가 지금은 이해가 안 되더라도 걱정하지 마세요. 이후 챕터에서 자세히 설명합니다.

복제가 복잡하게 느껴진다면 예제는 건너뛰셔도 괜찮습니다.

'TakeDamage' 함수는 'EventAnyDamage' 노드와 동일합니다. 데미지를 주고 싶을 때는 해당 액터에 'TakeDamage'를 호출하면, 해당 액터가 이 함수를 구현했다면 반응하게 됩니다.

// Replicated Health variable
UPROPERTY(Replicated)
int32 Health;

// Overriding the TakeDamage event
virtual float TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser) override;
// 이 함수는 UPROPERTY 매크로의 Replicated 지정자를 통해 필요하며, 해당 매크로에 의해 선언됩니다.
void ATestPawn::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // 이 변수의 복제를 UE에 알립니다.
    DOREPLIFETIME(ATestPawn, Health);
}

float ATestPawn::TakeDamage(float Damage, struct FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
    const float ActualDamage = Super::TakeDamage(Damage, DamageEvent, EventInstigator, DamageCauser);

    // 플레이어의 체력을 감소시킵니다.
    Health -= ActualDamage;

    // 체력이 0 이하가 되면 파괴합니다.
    if (Health <= 0.f)
    {
        Destroy();
    }

    return ActualDamage;
}

APlayerController

APlayerController 클래스는 우리가 다루게 될 클래스 중에서 가장 흥미롭고 복잡한 클래스일 수 있습니다. 또한 클라이언트가 실제로 '소유'하게 되는 첫 번째 클래스이기 때문에 많은 클라이언트 로직의 중심이 됩니다.

PlayerController는 플레이어의 '입력(Input)' 역할을 한다고 볼 수 있습니다. 즉, 플레이어와 서버를 연결하는 고리입니다. 이 말은 곧, 각 클라이언트마다 하나의 PlayerController가 존재한다는 뜻입니다.
클라이언트의 PlayerController는 오직 해당 클라이언트와 서버에만 존재합니다. 한 클라이언트는 다른 클라이언트의 PlayerController에 접근할 수 없습니다.

즉, 각 클라이언트는 자신의 PlayerController만을 알 수 있습니다!

결과적으로 서버는 모든 클라이언트의 PlayerController에 대한 참조를 가지고 있습니다.

여기서 '입력(Input)'이라는 용어가 실제 입력(버튼 클릭, 마우스 이동, 컨트롤러 축 등)을 모두 PlayerController에 넣어야 한다는 의미는 아닙니다.

차량과 인간형 캐릭터처럼 Pawn/Character별로 동작이 다른 입력은 APawn/ACharacter 클래스에 넣고, 모든 캐릭터에서 공통적으로 동작해야 하거나 캐릭터 오브젝트가 유효하지 않을 때도 동작해야 하는 입력은 PlayerController에 넣는 것이 좋은 설계입니다.

또한 중요한 점은 다음과 같습니다:

올바른 PlayerController를 어떻게 얻을 수 있을까요?

유명한 노드인 'GetPlayerController(0)' 또는 코드 'UGameplayStatics::GetPlayerController(GetWorld(), 0);'는 서버와 클라이언트에서 다르게 동작합니다.

  • 리슨 서버(Listen-Server)에서 호출하면 리슨 서버의 PlayerController를 반환합니다.
  • 클라이언트에서 호출하면 해당 클라이언트의 PlayerController를 반환합니다.
  • 전용 서버(Dedicated Server)에서 호출하면 첫 번째 클라이언트의 PlayerController를 반환합니다.

'0'이 아닌 다른 숫자를 사용해도 클라이언트 입장에서는 다른 클라이언트의 PlayerController를 반환하지 않습니다. 이 인덱스는 로컬 플레이어(스플릿 스크린)용으로 설계된 것이며, 여기서는 다루지 않습니다.

Examples and Usage

APlayerController는 네트워킹에서 가장 중요한 클래스 중 하나이지만, 기본적으로 제공되는 기능은 많지 않습니다.

그래서 간단한 예제를 통해 왜 PlayerController가 필요한지 설명하겠습니다. 소유권(Ownership) 챕터에서 PlayerController가 RPC에 왜 중요한지 더 자세히 다룹니다.

다음 예제는 UserWidget 버튼을 눌러 GameState의 복제 변수 값을 증가시키는 방법을 보여줍니다.

왜 PlayerController가 필요할까요?

UserWidget은 로컬 플레이어(클라이언트 또는 ListenServer)에만 존재하며, 서버에는 인스턴스가 존재하지 않습니다. 즉, UserWidget은 복제되지 않습니다!

따라서 버튼 클릭 이벤트를 서버로 전달할 방법이 필요합니다.

GameState에 직접 RPC를 호출할 수 없는 이유는 무엇일까요?

GameState는 서버가 소유합니다. ServerRPC는 클라이언트가 소유한 오브젝트에서만 호출할 수 있습니다!

Blueprint Examples

우선, 버튼을 누를 수 있는 간단한 UserWidget이 필요합니다.

이미지는 결과부터 역순으로 게시하겠습니다. 이렇게 하면 마지막에 무엇이 호출되는지, 그리고 이전 이미지의 이벤트들이 어떻게 연결되는지 쉽게 볼 수 있습니다.

먼저 목표인 GameState부터 시작하겠습니다. GameState는 복제된 정수형 변수를 증가시키는 일반적인 이벤트를 받습니다.

아래 이벤트는 PlayerController의 ServerRPC 내부에서 서버 측에서 호출됩니다.

마지막으로, 버튼을 누르면 ServerRPC가 호출됩니다:

즉, 버튼을 클릭하면(클라이언트 측) PlayerController의 ServerRPC를 사용해 서버 측으로 이동하고(클라이언트가 PlayerController를 소유하고 있기 때문에 가능), GameState의 'IncreaseVariable' 이벤트를 호출하여 복제된 정수형 변수를 증가시킵니다.

이 정수형 변수는 서버에서 설정되고 복제되므로, 모든 GameState 인스턴스에서 업데이트되어 클라이언트도 변경 사항을 볼 수 있습니다!

UE C++ Examples

이 예제의 C++ 버전에서는 UserWidget 대신 PlayerController의 BeginPlay에서 코드를 실행하도록 하겠습니다.
사실 이 방식이 그리 자연스럽지는 않지만, UserWidget을 C++로 구현하려면 추가 코드가 많이 필요하므로 여기서는 생략합니다.

// APlayerController 자식 클래스의 헤더 파일, 클래스 선언부 내부
// 서버 RPC 함수입니다. RPC 챕터에서 더 자세히 다룹니다.
UFUNCTION(Server, unreliable, WithValidation)
void Server_IncreaseVariable();

// 이 예제를 위해 BeginPlay 함수도 오버라이드합니다.
virtual void BeginPlay() override;
// PlayerController 자식 클래스의 CPP 파일
// GameState 함수에 접근하려면 반드시 include 필요
#include "TestGameState.h"

// 나중에 RPC와 '_Validate'가 왜 필요한지 배우게 됩니다
bool ATestPlayerController::Server_IncreaseVariable_Validate()
{
    return true;
}

// 나중에 RPC와 '_Implementation'이 왜 필요한지 배우게 됩니다
void ATestPlayerController::Server_IncreaseVariable_Implementation()
{
    // 현재 월드의 GameState를 가져와서 캐스팅
    ATestGameState* GameState = Cast<ATestGameState>(UGameplayStatics::GetGameState(GetWorld()));
    GameState->IncreaseVariable();
}

void ATestPlayerController::BeginPlay()
{
    Super::BeginPlay();

    // BeginPlay는 모든 Actor 인스턴스(서버와 클라이언트)에서 호출됩니다.
    // 이 예제에서는 로컬 플레이어만 이 RPC를 호출하도록 하고 싶습니다.
    // 사실 이 조건을 반대로 하면 RPC 없이도 동작하지만, C++에서 위젯을 다루는 예제라서 이렇게 작성했습니다.
    // "IsLocalPlayerController()"를 사용할 수도 있습니다.
    if (Role < ROLE_Authority)
    {
        Server_IncreaseVariable();
    }
}

// AGameState 자식 클래스의 헤더 파일, 클래스 선언부 내부
// 복제(Replicated)되는 정수형 변수
UPROPERTY(Replicated)
int32 OurVariable;

public:
// 변수를 증가시키는 함수
void IncreaseVariable();
// AGameState 자식 클래스의 CPP 파일
// 이 함수는 반드시 필요하며, UPROPERTY의 Replicated 지정자가 선언을 대신해줍니다. 구현만 하면 됩니다.
void ATestGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // 이 변수의 복제를 UE에 알립니다.
    DOREPLIFETIME(ATestGameState, OurVariable);
}

void ATestGameState::IncreaseVariable()
{
    OurVariable++;
}

꽤 많은 코드가 나왔습니다. 아직 일부 함수의 용도와 네이밍이 이해되지 않아도 괜찮습니다. 이후 섹션에서 왜 이렇게 작성하는지 더 잘 이해할 수 있을 것입니다.

profile
언리얼 엔진 주니어(신입) 개발자 | 소설 쓰는 취준 개발자

0개의 댓글