1. 서버의 종류
P2P
- 각 컴퓨터가 서버랑 클라이언트를 모두 수행하는 방식
리슨 서버
- HOST 역할을 하는 서버용 컴퓨터가 존재 (클라이언트 역할도 수행)
- GUEST 역할을 하는 클라이언트용 컴퓨터가 존재
데디케이티드 서버
- server - client 구조
- 서버의 역할 "만을" 수행하는 것이 따로 존재하고
- 클라이언트는 모두 클라이언트의 역할만 수행한다.
언리얼에서 볼 수 있는 설정의 종류
2. 데디케이티드 서버의 동작 순서
위에서 본 사진처럼 Play As Client를 선택하면, 자동으로 데디케이티드 서버 프로세스가 켜지게 된다.
(server.exe 를 실행하는 방식도 있음)
- play를 누르게 되면 서버에서는 OpenLevel 명령어와 Listen 명령어가 실행하고 클라이언트를 기다린다.
- OpenLevel을 통해 Level이 생성되고
- Listen 명령어를 통해 Socket을 열고 클라이언트를 기다린다.
- 이후 서버에서는 GameMode와 GameState를 생성한다.
- 여기서 중요한 내용은 "GameMode는 서버에만 존재한다" 라는 점이다.
- 그래서 GameMode는 게임의 중요한 로직을 담당하게 된다.
- 이제 클라이언트가 하나 접속했다고 하자
- 클라이언트는 Open 명령어를 통해 서버의 IP주소를 입력하여 서버에 접속하게 된다.
- 참고)
- 직접 IP 주소를 통해 접속하는 작업은 좋은 방식은 아니다.
- 그래서 세션 서비스라는 것을 통해 안전하게 접속을 하게 해주는 시스템이 존재한다.
- 참고)
- 서버는 클라이언트(클라이언트1)가 접속되면 OpenLevel을 통해 열었던 Level에 대한 정보를 클라이언트로 보내주고 클라이언트는 해당 Level을 열게 된다.
- 클라이언트가 Level을 성공적으로 열었다면, 성공했다는 정보를 서버로 보내주게 된다.
- 서버는 그때 클라이언트1 전용의 플레이어 컨트롤러, 플레이어 스테이트, 빙의할 Pawn 등을 만들고
- 만들어진 것들을 클라이언트로 복제시켜 아래와 같은 모습이 구성된다.
- 클라이언트는 Open 명령어를 통해 서버의 IP주소를 입력하여 서버에 접속하게 된다.
- 새로운 클라이언트(클라이언트2)가 하나더 접속하면 아래와 같은 동작을 하게 된다.
- 클라이언트1이 접속했던 과정과 똑같은 과정을 진행한다.
- 그 결과 서버에는 클라이언트1 과 2의 모든 정보를 가지게 되고, 그 정보를 복제해서 2가 가지게 된다.
- 여기서 기억할 점은 "플레이어 컨트롤러를 제외한" 나머지 정보들은 똑같이 복제되어 다른 클라이언트에게 보내준다.
- 마지막으로 생각할 점
- 데디케이트 서버 구조에서 서버와 클라이언트의 통신은 존재하지만
- 클라이언트와 클라이언트간의 통신은 존재할 수 가 없다! (위의 그림을 보면 전혀 통신할 수 있는 경로가 없다)
3. 언리얼 엔진 데디케이트 서버 세팅
현재 간단한 채팅 구조를 만들어 두었다. (멀티플레이 로직 존재 x)
단순히 아래처럼 Play As Client로 넷모드를 바꾸고, Player의 수를 2명으로 늘리게 되면 원하는 동작을 하지 않는다.
- Client 1에서만 입력을 수행했는데, 두 클라이언트 모두에서 출력이 보이게 된다.
- 서버 로직을 넣지않았는데 모든 클라이언트에서 보이면 안된다!
- 또한 아래와 같은 에러 메세지도 뜨게 된다.
그렇다면 문제는 무엇이고, 어떻게 설정을 해줘야 할까?
먼저 위 에러 메세지부터 뜨지 않도록 수정해본다.
- 에러 메세지가 뜨는 이유는 내가 보는 UI 화면을 다른 플레이어는 볼 필요가 없기 때문이다.
- 지금은 채팅이라 둘 다 떠야한다고 생각되지만, 다른 게임을 생각해보면 만약 내가 죽었을 때 UI가 다른 플레이어의 화면에서 뜬다고 생각하면 그건 잘못된 동작 방식이다.
- 해결 방법은 IsLocalController 함수를 통한 체크를 해주면 된다.
- 이 함수의 자세한 동작은 이후에 나온다.
void ACXPlayerController::BeginPlay()
{
Super::BeginPlay();
if (!IsLocalController())
{
return;
}
...
}
그렇다면, 왜 클라이언트1에서 친 채팅에 클라이언트2에서도 보일까?
- 그건 언리얼 엔진 자체의 설정중에 Run Under One Process가 true로 되어있기 때문이다.
- 하나의 언리얼 엔진 프로세스에서 모든 로직이 동작하니 PrintString() 함수도 모든 화면에서 뜨는 것이다.
- 제대로 된 데디케이티드 서버의 로직을 테스트 하려면 아래처럼 설정을 해야한다.
편의성 세팅
멀티플레이 세팅
- 넷 모드는 Player as Client, Player 수를 2명으로 설정
모든 설정을 마치면 아래처럼 콘솔창 하나가 뜨고, 두개의 클라이언트 창이 뜨게 된다.
4. NetMode, NetConnection, NetDriver
4 - 1. NetMode
이제 넷 모드에 대해서 알아보자
넷 모드라는 언어는 위의 설정 과정에서 "Player As Client"로 설정하면서 본 단어이다.
넷 모드란
- 해당 게임 프로세스가 네트워크 상에서 어떤 역할을 하는지를 의미하는 용어이다.
- 언리얼 엔진에서는 아래와 같이 정의를 해 두었다.
- 넷 모드는 World가 가지는 속성이다.
- 싱글 : NM_StandAlone
- 서버 : NM_ListenServer, NM_DedicatedServer
- 클라이언트 : NM_Client
넷 모드가 왜 필요할까?
- 우리가 작성할 로직이 "어디서 이루어질지(서버인지 클라인지)" 를 체크하기 위해서 사용한다.
- 해킹을 방지하기 위해서라고 생각하면 쉽다.
- 클라이언트와 서버는 socket을 통해서 정보를 주고 받는다.
- 이때 데미지 처리되는 과정을 패킷을 통해 클라에서 서버로 보낸다면 해킹범은 이 패킷의 특징을 찾아서 수정해서 보내버리면, 갑자기 데미지를 100배받는 그런 문제가 발생할 수 있다.
- 아래 그림을 보고 다시 생각해보자
- 클라이언트1의 캐릭터의 BeginPlay를 예를 들면 서버, 클라이언트1, 클라이언트2 세군데에서 돌아가게 된다.
- 만약 중요한 로직 (클라이언트가 가져가면 안되는 로직)의 경우 "서버 캐릭터" 에서만 돌아가야 한다.
- 이때 IsServer와 같은 함수를 통해 체크가 가능하다
4 - 2. NetDriver
넷 드라이버란
- 클라이언트마다 하나씩 가지고 있는 통신에 관련된 것을 관리하는 로우레벨의 클래스이다.
- 멀티 플레이게임의 경우 UWorld::Listen 함수를 통해 UNetDriver 객체가 생성된다.
서버와 클라이언트의 넷 드라이버
- 클라이언트는 클라이언트 당 하나씩 가지게 된다.
- 서버는 클라이언트의 갯수만큼 넷 드라이버를 가지게 된다.
4 - 3. NetConnection
넷 커넥션이란
- 우리가 위에서 보던 그림에서 Socket이라고 써있는 다리 역할을 하는 것을 넷 커넥션이라고 생각하면 쉽다.
- 서버와 클라이언트의 연결이 생기면
- 넷 드라이버가 생성되고, 이 넷 드라이버는 생성된 넷 커넥션 개체를 소유하고 관리하게 된다.
- 그 결과 클라이언트는 하나의 넷 커넥션을 가질 것이고
- 서버는 클라이언트 갯수만큼의 넷 커넥션을 가질 것이다.
- 아래 코드를 참고해 보자
- 서버 커넥션은 포인터 하나고 클라이언트 커넥션은 TArray로 관리되는데
- 이 이유는 위에서 말했던 것 처럼
- 서버와의 커넥션은
- ServerConnection이고 하나밖에 존재할 수 없다.
- 클라이언트가 가지는, 서버와의 커넥션 정보
- 클라이언트와의 커넥션은
- ClientConnection이고 TArray로 관리한다.
- 서버가 가지는, 클라이언트와의 커넥션 정보
- 서버와의 커넥션은
// UNetDriver.h
...
UCLASS(...)
class UNetDriver : public UObject, public FExec
{
...
/** Connection to the server (this net driver is a client) */
UPROPERTY()
TObjectPtr<class UNetConnection> ServerConnection;
/** Array of connections to clients (this net driver is a host) - unsorted, and ordering changes depending on actor replication */
UPROPERTY()
TArray<TObjectPtr<UNetConnection>> ClientConnections;
...
}
4 - 4. 관련 함수 동작 방식
그렇다면 넷모드, 넷 드라이버, 넷 커넥션을 사용하는 함수의 구현부를 보면서 어떤 내용인지 이해해보자
- InternalGetNetMode 함수
- NetDriver의 존재 유무를 보는데, 위에서 말했던 것 처럼 넷 드라이버는 Listen 함수를 통해 생기고,
- 멀티플레이 게임이라면 적어도 하나는 가진다 (서버는 여러개일수도, 클라는 한개)
// World.cpp
...
ENetMode UWorld::InternalGetNetMode() const
{
if ( NetDriver != NULL ) // 넷드라이버가 존재하면(멀티라는 의미)
{
const bool bIsClientOnly = IsRunningClientOnly();
return bIsClientOnly ? NM_Client : NetDriver->GetNetMode(); // 클라이언트 아니면 서버이다.
}
...
}
- GetNetMode 함수
// UNetDriver.cpp
ENetMode UNetDriver::GetNetMode() const
{
// Special case for PIE - forcing dedicated server behavior
#if WITH_EDITOR // 에디터
if (World && World->WorldType == EWorldType::PIE && IsServer()) //서버일때
{
//@todo: world context won't be valid during seamless travel CopyWorldData
FWorldContext* WorldContext = GEngine->GetWorldContextFromWorld(World);
if (WorldContext && WorldContext->RunAsDedicated) // 데디로 실행했다면
{
return NM_DedicatedServer; // 데디 서버 반환.
}
}
#endif
// Normal
return (IsServer() ? (GIsClient ? NM_ListenServer : NM_DedicatedServer) : NM_Client);
// 서버인데, 클라이언트로 참여하고 있다? 리슨서버. 서버인데 참여하고 있지 않다? 데디서버.
}
- IsServer함수
- ServerConnection은 클라이언트가 가지는 서버와의 커넥션을 뜻하고
- 이게 없어야 서버이다. (서버는 ClientConnection을 가지니까)
// UNetDriver.cpp
bool UNetDriver::IsServer() const
{
// Client connections ALWAYS set the server connection object in InitConnect()
// @todo ONLINE improve this with a bool
return ServerConnection == NULL;
}
4 - 5. 실습
위에서 배운 넷드라이버, 넷커넥션, 넷모드에 대한 내용을 가지고 로그를 찍는 코드를 작성해 보자
모듈 파일에서 아래와 같이 현재 NetMode를 출력해주는 함수를 작성했다.
모듈 파일은 우리가 프로젝트 만들면 자동으로 생긴 파일을 말한다.
- 파라메터의 WorldContext는 World는 아니지만, World를 유추할 수 있는 액터를 말한다.
- 예시의 경우 우리는 PlayerController에서 이 함수를 호출하기에 PlayerController가 매개변수로 들어간다.
- 위에서 말한 것 처럼 NetMode는 월드의 속성이므로 InWorldContextActor->GetNetMode() 를 사용할 수 있다.
//모듈명.h
class ChatXFunctionLibrary
{
public:
static void MyPrintString(const AActor* InWorldContextActor, const FString& InString, float InTimeToDisplay = 1.f, FColor InColor = FColor::Cyan)
{
if (IsValid(GEngine) && IsValid(InWorldContextActor))
{
if (InWorldContextActor->GetNetMode() == NM_Client || InWorldContextActor->GetNetMode() == NM_ListenServer)
{
GEngine->AddOnScreenDebugMessage(-1, InTimeToDisplay, InColor, InString);
}
else
{
UE_LOG(LogTemp, Log, TEXT("%s"), *InString);
}
}
}
static FString GetNetModeString(const AActor* InWorldContextActor)
{
FString NetModeString = TEXT("None");
if (IsValid(InWorldContextActor))
{
ENetMode NetMode = InWorldContextActor->GetNetMode();
if (NetMode == NM_Client)
{
NetModeString = TEXT("Client");
}
else
{
if (NetMode == NM_Standalone)
{
NetModeString = TEXT("StandAlone");
}
else
{
NetModeString = TEXT("Server");
}
}
}
return NetModeString;
}
};
5. Role
5 - 1. Role의 개념
멀티플레이 게임의 흐름에 있어서 중요한 점이 "게임 내 중요한 로직" 은 서버에서 처리해야 한다는 내용이다.
그래서 이전까지 NetMode를 통해 해당 로직이 서버에서 동작하는지 클라이언트에서 동작하는지를 파악해 보았다.
그러나 NetMode는 월드에 관련된 정보이고, 우리가 주로 사용하는 것들은 "액터나 컴포넌트" 이기 때문에 NetMode를 통해서 서버인지 클라이언트인지 확인할 수가 없다.
결론적으로 Role은 이 액터가 서버에 스폰되어있는지, 클라이언트에 스폰되어있는지
혹은 액터의 멤버함수가 서버에서 실행되고 있는지, 클라이언트에서 실행되고 있는지 등을 파악하는데 사용하는 것이다.
Role의 종류
- Authority
- 클라이언트-서버 모델에서는 항상 서버에 스폰되어있는 액터를 신뢰하게 된다.
- 즉, 서버에 있는 것을 Authority라고 한다.
- Proxy
- Authority에 반해, 서버에서 복제해 온 허상의 액터를 Proxy라고 한다.
또한 Role에는 Remote Role과 Local Role이 존재한다.
- Remote Role
- 커넥션으로 연결된 컴퓨터에서의 롤
- 내가 클라이언트라면, 서버에서의 롤을 뜻한다.
- Local Role
- 현재 동작하는 컴퓨터에서의 롤
액터 Role의 종류
- None : 액터가 존재하지 않음
- Authority
- 신뢰할 수 있는 Role
- 서버
- Autonomous Proxy
- Authority를 복제한 롤, 복제는 당했지만 서버에 통신이 가능하다.
- 예시) "내" 플레이어 컨트롤러, 폰
- Simulated Proxy
- Authority를 복제한 롤, 복제를 당하기만 함
- 서버로부터 데이터를 수신만 한다.
- 예시) "상대편" 폰
5 - 2. 실습
매크로를 통해서 NetRole을 출력해보기
//모듈.h
...
static FString GetRoleString(const AActor* InActor)
{
FString RoleString = TEXT("None");
if (IsValid(InActor))
{
FString LocalRoleString = UEnum::GetValueAsString(TEXT("Engine.ENetRole"), InActor->GetLocalRole());
FString RemoteRoleString = UEnum::GetValueAsString(TEXT("Engine.ENetRole"), InActor->GetRemoteRole());
RoleString = FString::Printf(TEXT("%s / %s"), *LocalRoleString, *RemoteRoleString);
}
return RoleString;
}
서버에서의 출력
- 위의 출력은 클라이언트1에 대한 출력
- 즉 BeginPlay 상태에서는 Local(서버)에서는 Authority이고, Remote(클라)에서는 Simulated이다.
- 이후 PossessedBy가 되면, Remote(클라)에서 Role이 Autonomous가 된다.
- 즉 PossessedBy가 되어야 Pawn이 Autonomous로 바뀌는 것이다.
- 빙의되기 전까지는 그냥 복제만 된 상태이고, 빙의 해야 그때 승격해주는 느낌
- 아래 출력은 클라이언트2에 대한 출력
- 이것도 마찬가지, 서버에서는 클라이언트1과 2는 같은 동작을 하는 것이 맞다.
클라이언트에서의 출력
- 왼쪽이 클라이언트2 오른쪽을 클라이언트1이라고 생각하자
- 왼쪽을 기준으로 설명하면 (클라2)
- 위의 BeginPlay는 클라1의 복제된 폰에 대한 Local / Remote Role이고
- 아래 BeginPlay는 클라2(자기자신)의 복제된 폰에 대한 Local / Remote Role이다.
결론
- 넷 드라이버는 넷 커넥션을 소유
- 넷 커넥션은 플레이어 컨트롤러를 소유
- 플레이어 컨트롤러는 폰을 소유
5 - 3. 관련 함수 찾아보기
Role을 활용한 중요한 함수의 정의를 찾아보면서 언제 쓰는지 생각해보기
- AActor::HasAuthority 함수
- 현재 Actor의 LocalRole을 체크한다.
- Local Role이 authority라는 뜻은, 서버에 존재하는 액터라는 의미이다.
// AActor.cpp
FORCEINLINE_DEBUGGABLE bool AActor::HasAuthority() const
{
return (GetLocalRole() == ROLE_Authority);
}
- AController::IsLocalController
- if (GetRemoteRole() != ROLE_AutonomousProxy && GetLocalRole() == ROLE_Authority) 의미는 서버에서 스폰되었고, 복제되어서 통신하지 않는 것을 말한다.
- 서버에서 스폰된 캐릭터(폰)나 컨트롤러가 아니라는 의미
// APawn.cpp
bool APawn::IsLocallyControlled() const
{
return ( Controller && Controller->IsLocalController() );
}
// AController.cpp
bool AController::IsLocalController() const
{
const ENetMode NetMode = GetNetMode();
if (NetMode == NM_Standalone)
{
// Not networked.
return true;
}
if (NetMode == NM_Client && GetLocalRole() == ROLE_AutonomousProxy)
{
// Networked client in control.
return true;
}
if (GetRemoteRole() != ROLE_AutonomousProxy && GetLocalRole() == ROLE_Authority)
{
// Local authority in control.
return true;
}
return false;
}
5 - 4. Role 정리
게임에 중대한 영향을 끼치는 데미지나 스폰 같은 것은 서버에서 실행해야함. 그래서 HasAuthority 를 쓴다.
입력 관련 로직이나 UI는 Autonomous Proxy에서 수행. 그래서 IsLocalController나 IsLocallyControlled 함수를 쓴다.
액터에 따른 특징
- 게임 모드
- HasAuthority() 함수 필요가 없음
- 그 이유는 게임모드는 항상 서버에만 존재하는 것이니까
- 폰
- Autonomous와 Simulated Proxy 존재
- UI, 애니메이션
- 클라이언트에서만 수행
로컬롤과 리모트롤 나눈 이유
- 예를 들어, 서버에 접속한 플레이어 A와 서버에서 스폰된 캐릭터 B가 있을 때 둘을 구분하고 싶다.
- A와 B모두 "서버의 입장에서 Local Role"은 authority이다.
- 그러나 A의 경우 Remote Role은 Autonomous 이고, B의 경우 Remote Role이 None이기 때문에 구분할 수 있다.
- 이것이 로컬롤과 리모트롤이 나눠져있는 이유이다.
- 또한 이렇게 구분이 가능해야, 서버에서 호출했지만, 클라이언트에서 실행되는 RPC의 구현이 가능하다.
6. RPC
6 - 1. RPC의 개념
지금까지의 함수들은 호출되는 PC와 실행되는 PC가 같았다.
클라이언트에서 실행된 함수는 클라이언트에서 실행되는 것
RPC는 "호출하는 PC의 함수가 다른 PC에서 실행되도록 하는 통신 기법"을 말한다.
Call 과 Invoke
- Call
- 정적인 의미, 직접적을 함수를 호출 및 실행하는 것
- 컴파일 타임에 어떤 함수인지 호출하는 곳과 실행하는 곳이 정해진다.
- 전역 함수 & 멤버 함수
- Invoke
- 동적인 의미, 간접적으로 함수를 호출 및 실행
- 런타임에 어떤 함수인지, 호출하는 곳, 실행하는 곳이 정해진다.
- 함수 포인터, 동적 바인딩, RPC
언리얼에서 RPC의 용도
- 게임 플레이에 큰 영향을 미치지 않는 것들에 사용된다.
- 사운드나, 파티클 등
- 게임에 큰 영향을 미치는 것들은 "프로퍼티 리플리케이션"을 사용한다.
언리얼에서 Own의 의미
- NetConnection에 의해 소유되고 있는지를 뜻한다.
- 위에 NetDriver 코드에서 봤던 것처럼 NetDriver가 두가지의 NetConnection을 가진다.
- 서버와의 연결을 뜻하는 ServerConnection(TObjectPtr)과
- 클라이언트와의 연결을 뜻하는 ClientConnection(TArray)으로 구성된다.
- Client-Owned Actor : 클라이언트 커넥션이 소유하고 있는 플레이어 컨트롤러
- Owned by Different Client : 클라이언트 커넥션이 소유하고 있는 다른 플레이어의 컨트롤러
- Server-Owned Actor : 서버에서 스폰되고, Owner가 없으며 bReplicates가 true인 액터
- Unowned Actor : 서버의 GameMode, 클라이언트의 환경 액터(나무, 박스 등), 리플리케이티드 되지 않은
NetMulicast, Server, Client 키워드
- UFUNCTION() 매크로와 함께 사용되는 키워드이다. (아래 표의 맨 위 행에 존재하는 키워드)
- NetMulticast : 서버를 포함한 모든 클라이언트에 RPC 실행을 요청
- Server : 서버에서 RPC 실행 요청
- Client : 클라이언트에서 RPC 실행 요청
자주 쓰이는 예시들 (위에서 말한 키워드를 사용)
- RPC가 클라이언트에서 호출하고 서버에서 실행되야 하는 경우
- UFUNCTION(Server)
- 클라이언트 커넥션이 소유하고 있는 액터에서 RPC 호출
- _Validate() 함수를 통해 RPC가 실행될지를 결정
- RPC가 모든 클라이언트에서 실행되야 하는 경우
- UFUNCTION(NetMulticast)
- 서버에서 호출해야 한다.
- 부하가 심하기 때문에 자주 사용해서는 안된다.
- RPC가 서버에서 호출, 클라이언트에서 실행되는 경우
- UFUNCTION(Client)
- 클라이언트 커넥션이 소유하고 있는 액터에서 RPC가 호출되어야 한다.
언리얼에서의 동작 방식
- 서버에서 Invoke된 RPC
- 클라이언트에서 Invoke된 RPC
- 그림1) 클라이언트 Invoke RPC의 두번째 경우 동작 모습
- 이 경우 해당 함수가 UFUNCTION(Server) 라면 Dropped될 것이고
- UFUNCTION(Client) 라면 Runs on invoking client가 될 것이다.
- 그림2) 서버 Invoke RPC의 두번째 경우 동작 모습
- 이 경우는 UFUNCTION(Server), UFUNCTION(Server) 두 경우 모두 Runs on server를 실행할 것이다.
- 별 액터는 서버에서 스폰되었지만, SetOwner를 하지 않아서 owner가 없고, Replicated만 된 상태의 액터
안정성 체크
- WithValidation
- UFUNCTION() 매크로와 함께 사용하는 키워드
- 서버 RPC의 경우 사용된다. UFUNCTION(Server)
- 서버 실행 로직은 무조건 신뢰하게 되기 때문에, 위 변조를 막기 위한 방어막 함수이다.
- 너무 빨리 실행되거나 그런것을 막기
- _Implementation() 함수와 _Validate() 함수로 나뉜다.
- _Validate는 해당 RPC가 실제로 실행될지 말지를 결정하는 함수이다.
- UFUNCTION() 매크로와 함께 사용하는 키워드
- Reliable
- UFUNCTION() 매크로와 함께 사용하는 키워드
- RPC는 기본적으로 Unreliable이다. (Unreliable인 경우 remote PC에서 무조건 실행된다는 보장 없다는 의미)
- Reliable을 사용해야 remote PC에서 무조건 실행된다.
- Unreliable : 이펙트, 사운드 등
- Reliable : 충돌, 데미지 등
- UFUNCTION() 매크로와 함께 사용하는 키워드
6 - 2. 실습 1 - 채팅 내용 공유하기
지금까지 구현한 채팅의 경우 채팅을 친 클라이언트 쪽에서만 내용이 보였다.
멀티플레이(데디케이티드 서버) 에서는 클라1이 친 채팅이 클라2에서도 보여야 할 것이다.
그렇다면 어떤 방식으로 동작해야 할까?
기억할 점은 클라이언트와 클라이언트간의 통신은 안된다는 점이다.
- 클라1에서 채팅을 친 후, 그 내용을 서버로 보내준다. (Server RPC)
- 서버는 받은 내용을 클라1과 클라2에 보내준다. (Client RPC)
//PlayerController.h
UFUNCTION(Client, Reliable)
void ClientRPCPrintChatMessageString(const FString& InChatMessageString);
UFUNCTION(Server, Reliable)
void ServerRPCPrintChatMessageString(const FString& InChatMessageString);
//PlayerController.cpp
#include "EngineUtils.h"
void ACXPlayerController::SetChatMessageString(const FString& InChatMessageString)
{
ChatMessageString = InChatMessageString;
//PringChatMessageString(ChatMessageString);
//if client send message to server
if (IsLocalPlayerController())
{
ServerRPCPrintChatMessageString(InChatMessageString);
}
}
void ACXPlayerController::ClientRPCPrintChatMessageString_Implementation(const FString& InChatMessageString)
{
PringChatMessageString(InChatMessageString);
}
void ACXPlayerController::ServerRPCPrintChatMessageString_Implementation(const FString& InChatMessageString)
{
for (TActorIterator<ACXPlayerController> It(GetWorld()); It; ++It)
{
ACXPlayerController* CXPlayerController = *It;
if (IsValid(CXPlayerController))
{
CXPlayerController->ClientRPCPrintChatMessageString(InChatMessageString);
}
}
}
실행 결과
생각해볼것
- 단순히 생각해서 서버에서 모든 클라이언트로 보내줄 것인데, Multicast RPC 해버리면 안될까 라는 의문이 들 수 있다.
- 그러나 이렇게 하면 채팅을 친 쪽과 서버에서만 보일 것이다.
- 그 이유는 이 로직은 컨트롤러에 작성되어있다;
- 서버는 모든 클라이언트의 컨트롤러를 가지고 있지만,
- 클라이언트는 자신의 컨트롤러 하나만 가지게 되기 때문에 NetMulticast를 하면 아래 그림과 같은 상황이 발생하기에 우리가 원하는 동작을 하지 않는다.
6 - 3. 실습 2 - 서버 접속 알리기
GameState를 만들고, NetMulticast RPC를 통해서 새로운 PC가 접속하면 클라이언트들에게 새로운 PC가 접속되었다는 것을 알려주는 기능을 구현할 것이다.
이때 GameMode에서 이 로직을 구성하면 안된다.
- 그 이유는 GameMode는 서버 PC에만 존재하기 때문이다.
- 복제도 되어있지 않고 단지 서버에만 존재한다.
- 그렇기 때문에 무슨 RPC를 쓰더라도 클라이언트에게 정보가 도달할 수가 없다.
//GameState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameStateBase.h"
#include "CXGameStateBase.generated.h"
UCLASS()
class SCC_SERVERSTUDY_API ACXGameStateBase : public AGameStateBase
{
GENERATED_BODY()
public:
UFUNCTION(NetMulticast, Reliable)
void MulticastRPCBroadcastLoginMessage(const FString& InNameString = FString(TEXT("XXXXXX")));
};
//GameState.cpp
#include "CXGameStateBase.h"
#include "Kismet/GameplayStatics.h"
#include "../Player/CXPlayerController.h"
void ACXGameStateBase::MulticastRPCBroadcastLoginMessage_Implementation(const FString& InNameString)
{
// not server
if (!HasAuthority())
{
APlayerController* PC = UGameplayStatics::GetPlayerController(GetWorld(), 0);
if (IsValid(PC))
{
ACXPlayerController* CXPC = Cast<ACXPlayerController>(PC);
if (IsValid(CXPC))
{
FString NotificationString = InNameString + TEXT(" has joined the game");
CXPC->PringChatMessageString(NotificationString);
}
}
}
}
게임 모드에 OnPostLogin 함수를 추가후, GameState의 함수 call하기
void ACXGameModeBase::OnPostLogin(AController* NewPlayer)
{
Super::OnPostLogin(NewPlayer);
ACXGameStateBase* CXGameState = GetGameState<ACXGameStateBase>();
if (IsValid(CXGameState))
{
CXGameState->MulticastRPCBroadcastLoginMessage();
}
}
생각해 볼 코드
APlayerController* PC = UGameplayStatics::GetPlayerController(GetWorld(), 0);
- 이 부분에 있어서 우리가 0을 쓰는 이유는 각 클라이언트에 있어서 컨트롤러는 하나뿐이기 때문이다.
- 단, 이 코드는 HasAuthority가 false일 때 이루어지는데 (클라이언트일때)
- 만약 이 코드를 서버에서 (HasAuthority 가 true) 돌리면 서버에는 클라이언트의 갯수만큼 컨트롤러가 있기 때문에 우리가 원하지 않는 동작을 할 수도 있다.
실행 결과
- 에디터 창에 있는 클라이언트가 먼저 접속한 것이고, 이후에 아래 창의 클라이언트가 접속했기 때문에, 위의 에디터 창 클라이언트에게 접속했다는 문구가 뜨는 것을 확인할 수 있다.
7. Property Replication
7 - 1. 프로퍼티 리플리케이션 개념
리플리케이션
- 생성된 액터의 정보를 네트워크 내의 다른 클라이언트에게 복제하는 작업
- 클라이언트-서버 모델에서는 "서버에서 클라이언트"로 복제되는 것을 말한다.
- 클라이언트에서 클라이언트 구조는 없다!
- 리플리케이션의 종류
- RPC : 직접적으로 값을 바꾸기
- Property Replocation : 자동으로 바꾸기
Property Replication
- "프로퍼티 리플리케이션은, 서버에서 클라이언트로 가는 방향밖에 없다."
- 클라이언트의 객체의 값이 바뀌더라도 그 값이 서버에 반영되지는 않는다!
- (RPC는 클라이언트에서 서버로 가는 방향 있음)
- 그렇다면 언제 쓸 것인가?
- 액터의 속성 값이 변경되면, 이 변경된 값을 클라이언트 액터에 복제해줘야할 때가 있다.
- 그러나 모든 변경사항을 모든 클라이언트에 복제하는 것은 비효율적이므로,
- 우리가 원하는 속성만을 골라서 다른 클라이언트에 복제할때 프로퍼티 리플리케이션을 사용한다.
사용 방법
- 액터의 Replicates 속성을 true로 설정
- URPOPERTY() 매크로에 Replicated 키워드 추가
- GetLifetimeReplicatedProps() 함수에 복제할 속성을 추가 (최적화를 위함)
- DOREPLIFETIME() 매크로 사용 (#include "Net/UnrealNetwork.h" 필요)
7 - 2. 실습 1 - 야구게임 (난수 생성과 판정 로직)
그렇다면 난수 생성과 판정 로직은 어디에서 이루어져야 할 것인가?
- 이 로직은 매우 게임에 큰 영향을 미치는 로직이기에 서버(GameMode)에서 이루어져야 할 것이다.
- 여기서는 프로퍼티 리플리케이트 쓰지 않음, 단순히 로직 추가를 했다.
- 참고) AllPlayerControllers라는 배열을 통해 현재 서버에 접속된 클라이언트의 컨트롤러를 저장하는 코드를 추가했다.
//gamemode.h
void PrintChatMessageString(ACXPlayerController* InChattingPlayerController, const FString& InChatMessageString);
FString GenerateSecretNumber();
bool IsGuessNumberString(const FString& InNumberString);
FString JudgeResult(const FString& InSecretNumberString, const FString& InGuessNumberString);
protected:
FString SecretNumberString;
TArray<TObjectPtr<ACXPlayerController>> AllPlayerControllers;
//gamemode.cpp
void ACXGameModeBase::BeginPlay()
{
Super::BeginPlay();
SecretNumberString = GenerateSecretNumber();
}
void ACXGameModeBase::OnPostLogin(AController* NewPlayer)
{
Super::OnPostLogin(NewPlayer);
ACXGameStateBase* CXGameState = GetGameState<ACXGameStateBase>();
if (IsValid(CXGameState))
{
CXGameState->MulticastRPCBroadcastLoginMessage();
}
ACXPlayerController* CXPlayerController = Cast<ACXPlayerController>(NewPlayer);
if (IsValid(CXPlayerController))
{
AllPlayerControllers.Add(CXPlayerController);
}
}
void ACXGameModeBase::PrintChatMessageString(ACXPlayerController* InChattingPlayerController, const FString& InChatMessageString)
{
FString ChatMessageString = InChatMessageString;
int32 Index = InChatMessageString.Len() - 3;
FString GuessNumberString = InChatMessageString.RightChop(Index);
//number input
if (IsGuessNumberString(GuessNumberString))
{
FString JudgeResultString = JudgeResult(SecretNumberString, GuessNumberString);
for (TActorIterator<ACXPlayerController>It(GetWorld()); It; ++It)
{
ACXPlayerController* CXPlayerController = *It;
if (IsValid(CXPlayerController))
{
FString CombinedMessageString = InChatMessageString + TEXT(" -> ") + JudgeResultString;
CXPlayerController->ClientRPCPrintChatMessageString(CombinedMessageString);
}
}
}
//other string input
else
{
for (TActorIterator<ACXPlayerController>It(GetWorld()); It; ++It)
{
ACXPlayerController* CXPlayerController = *It;
if (IsValid(CXPlayerController))
{
CXPlayerController->ClientRPCPrintChatMessageString(InChatMessageString);
}
}
}
}
FString ACXGameModeBase::GenerateSecretNumber()
{
TArray<int32> Numbers;
for (int32 i = 1; i <= 9; ++i)
{
Numbers.Add(i);
}
FMath::RandInit(FDateTime::Now().GetTicks());
FString Result;
for (int32 i = 0; i < 3; ++i)
{
int32 Index = FMath::RandRange(0, Numbers.Num() - 1);
Result.Append(FString::FromInt(Numbers[Index]));
Numbers.RemoveAt(Index);
}
return Result;
}
bool ACXGameModeBase::IsGuessNumberString(const FString& InNumberString)
{
bool bCanPlay = false;
do {
if (InNumberString.Len() != 3)
{
break;
}
bool bIsUnique = true;
TSet<TCHAR> UniqueDigits;
for (TCHAR C : InNumberString)
{
if (FChar::IsDigit(C) == false || C == '0')
{
bIsUnique = false;
break;
}
UniqueDigits.Add(C);
}
if (bIsUnique == false)
{
break;
}
bCanPlay = true;
} while (false);
return bCanPlay;
}
FString ACXGameModeBase::JudgeResult(const FString& InSecretNumberString, const FString& InGuessNumberString)
{
int32 StrikeCount = 0, BallCount = 0;
for (int32 i = 0; i < 3; ++i)
{
if (InSecretNumberString[i] == InGuessNumberString[i])
{
StrikeCount++;
}
else
{
FString PlayerGuessChar = FString::Printf(TEXT("%c"), InGuessNumberString[i]);
if (InSecretNumberString.Contains(PlayerGuessChar))
{
BallCount++;
}
}
}
if (StrikeCount == 0 && BallCount == 0)
{
return TEXT("OUT");
}
return FString::Printf(TEXT("%dS %dB"), StrikeCount, BallCount);
}
7 - 3. 실습 2 - 플레이어 구분
현재는 플레이어가 둘 다 클라이언트로만 뜨게 된다.
두 클라이언트를 한번 구분해보기 위해 PlayerState를 사용했다.
- PlayerState에 PlayerNameString이라는 변수를 추가하고,
- GameMode의 OnPostLogin에서 PlayerNameString을 세팅하는 동작 후 Multicast를 통해서 출력을 해주는 코드를 추가했다.
//PlayerState.h
FString PlayerNameString;
//gamemode.cpp
#include "Player/CXPlayerState.h"
void ACXGameModeBase::OnPostLogin(AController* NewPlayer)
{
Super::OnPostLogin(NewPlayer);
ACXPlayerController* CXPlayerController = Cast<ACXPlayerController>(NewPlayer);
if (IsValid(CXPlayerController) == true)
{
AllPlayerControllers.Add(CXPlayerController);
ACXPlayerState* CXPS = CXPlayerController->GetPlayerState<ACXPlayerState>();
if (IsValid(CXPS) == true)
{
CXPS->PlayerNameString = TEXT("Player") + FString::FromInt(AllPlayerControllers.Num());
}
ACXGameStateBase* CXGameStateBase = GetGameState<ACXGameStateBase>();
if (IsValid(CXGameStateBase) == true)
{
CXGameStateBase->MulticastRPCBroadcastLoginMessage(CXPS->PlayerNameString);
}
}
}
//playercontroller.cpp
void ACXPlayerController::SetChatMessageString(const FString& InChatMessageString)
{
ChatMessageString = InChatMessageString;
//PringChatMessageString(ChatMessageString);
//if client send message to server
if (IsLocalPlayerController())
{
//ServerRPCPrintChatMessageString(InChatMessageString);
ACXPlayerState* CXPS = GetPlayerState<ACXPlayerState>();
if (IsValid(CXPS))
{
FString CombinedMessageString = CXPS->PlayerNameString + TEXT(": ") + InChatMessageString;
ServerRPCPrintChatMessageString(CombinedMessageString);
}
}
}
그러나 이 경우 제대로 데디케이티드 서버가 에러를 내면서 꺼지게 된다!!
이런 경우 에러를 잡기 위해서는 log 파일을 확인해 보아야 한다.
로그 파일은 Saved -> Logs -> 우리가 만든 프로젝트 명 txt 파일을 확인하면 된다.
이때 우리는 데디케이티드 서버로 돌렸기 때문에 같은 파일이 세개가 존재한다.
이 세가지를 구분하는 방법은 IsServer 라는 것을 검색하여, 서버인지 클라이언트인지 확인하는 것이다.
우리가 방금 마주친 버그는 서버가 꺼졌으므로, IsServer가 YES인 서버의 로그를 보고 디버깅을 해보면 된다.
- 아래 사진처럼 error 가 발생한 코드와 줄 수가 나오기 때문에 이것을 보면서 잘못된 부분을 찾으면 된다.
- 아래 사진의 맨 위에 줄부터 보면, MulticastRPCBroadcastLoginMessage에서 오류가 발생한 것을 볼 수 있다.
- 문제의 원인은 우리가 PlayerState를 코드에서 썼는데, 실제로 WorldSetting에서 안 넣어줘서 그런 것이다.
그러나 이 경우 Player의 번호가 아직 뜨지 않게 된다.
그 이유는 우리가 만든 GameState의 변수를 Replicate하지 않았기 때문이다!
- 아래와 같이 위에서(7-1 목차) 설명한 방식대로 추가해주면 된다.
//playerstate.h
public:
ACXPlayerState();
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps)const override;
public:
UPROPERTY(Replicated)
FString PlayerNameString;
};
//playerstate.cpp
#include "Net/UnrealNetwork.h"
ACXPlayerState::ACXPlayerState()
:PlayerNameString(TEXT("None"))
{
bReplicates = true;
}
void ACXPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, PlayerNameString);
}
실행 결과
'Unreal Engine' 카테고리의 다른 글
Unreal Engine - Save Game (0) | 2025.04.02 |
---|---|
Unreal Engine - 야구게임(데디케이티드 서버) (0) | 2025.03.29 |
Unreal Engine - 플러그인 만들기 (0) | 2025.03.26 |
Unreal Engine - 야구 게임(리슨 서버) (1) | 2025.03.25 |
Unreal Engine - Standalone을 리슨 서버로 확장하기 (0) | 2025.03.24 |