네트워크 모드, 서버 초기화, 클라이언트의 접속 플로우를 알아보겠다.
네트워크 모드
Standalone : 로컬 머신에서 실행됨. 싱글 플레이 게임, 로컬 멀티플레이 게임에 적합하다.
싱글&멀티 둘 다 가능한 게임은 싱글 플레이 상태에서 서버에 접속하면 Standalone 모드에서 Clinet 모드로 전환된다
DedicatedServer : 서버를 구동할 별도의 컴퓨터가 필요하며 게임에 참여하는 모든 플레이어가 네트워크 연결이 필요함.
따라서 로컬 플레이어가 존재하지 않는다. DedicatedServer은 신뢰성 있는 서버를 제공하기 때문에 데이터 변조가 일어나면 치명적인 온라인 서비스 게임에 적합하다.
ListenServer : 클라이언트가 서버 역할을 맡는다. 따라서 서버를 호스트 하는 로컬 머신도 게임에 참여한다.
ListenServer을 호스트 하는 클라이언트가 곧 서버이기 때문에 게임을 변경할 권한도 있다.
소규모 멀티 플레이어 게임에 적합하다.
Client : Client 모드는 Dedicated 혹은 Listen 서버에 접속한 상태를 의미한다.
Client에 있는 모든 액터는 Proxy(허상)다.
게임 모드 기반 로그인 플로우
리슨 서버를 호스팅하고 클라이언트가 서버에 접속하는 플로우를 설명하겠다.
1. 서버의 초기화
서버 로컬 머신에 월드와 게임 인스턴스가 생성됐지만 게임은 아직 시작되지 않았다.
네트워크 기능도 활성화 되지 않았은 상황이기 때문에 현재 서버의 네트워크 모드는 스탠드얼론 상태이다.
스탠드 얼론 상태인 서버에는 게임모드 액터가 존재한다.
2. 플레이어의 로그인
서버 호스팅 방식은 리슨 서버라고 가정하겠다.
게임에 참여하기 위해서는 플레이어가 존재해야 하고 플레이어를 대표하는 액터는 플레이어 컨트롤러다.
게임모드를 통해 플레이어 컨트롤러를 생성한다.
리슨 서버이기 때문에 서버도 클라이언트로서 게임에 참여한다.
자기 자신이 게임에 참여할 지라도 게임모드가 제공하는 로그인 과정을 거친다.
게임모드의 로그인을 통해 서버 역할을 수행할 컴퓨터에는 게임모드와 플레이어 컨트롤러 액터가 생성됐으며 게임은 아직도 시작되지 않고 준비 중인 상태다.
3. 리슨 서버의 시작
서버의 네트워크 기능을 켜고 게임 서비스 시작된다.
서버 로컬 머신의 게임이 시작되고 네트워크 모드가 스탠드얼론에서 리슨 서버로 변경된다.
4. 클라이언트의 접속 시도
클라이언트가 서버에 접속 요청을 보내고 게임 모드가 이 요청을 처리한다.
게임 모드는 요청을 거부할 기회를 가진다.
이때 시점은 서버의 AGameModeBase::PreLogin 이다.
5. 클라이언트의 접속 허용
서버가 접속 요청을 받아들이면 게임 모드가 서버에 접속할 클라이언트를 담당하는 플레이어 컨트롤러를 생성한다
이때 시점은 서버의 AGameModeBase::Login이다.
6. 클라이언트의 초기화
시점은 여전히 서버의 AGameModeBase::Login이다.
서버에서 클라이언트를 대표하는 액터 플레이어 컨트롤러 1가 클라이언트에 복제된다.
서버의 모든 정보를 클라이언트에 전부 전달하지 않고 보이는데 문제없을 정도로만 컨텐트가 효율적으로 복제한다.
복제를 최소화하는 이유는
1. 서버 보안 문제 : 예를 들어 게임 규칙 같은 가장 중요한 것을 처리하는 게임 모드까지 복제한다면 클라이언트가 게임 데이터를 변조할 가능성이 있다.
2. 네트워크 대역폭 최소화 : 모든 정보, 액터를 리플리케이션 하면 네트워크 대역폭이 커지고 서버의 부담도 커진다.
복제를 최소화했기 때문에 게임 모드, 서버의 플레이어를 대표하는 컨트롤러 0이 복제되지 않았다.
서버에서 게임이 시작됐기 때문에 클라이언트가 접속을 하자마자 게임이 시작된다
따라서 클라이언트 월드에 있는 모든 액터의 BeginPlay가 호출된다.
그림에서 복제될 때 컨트롤러의 인덱스를 보자.
플레이어 컨트롤러는 인덱스 형태로 관리되기 때문에 서버의 플레이어 컨트롤러 1이 클라이언트에 복제됐을 때 클라이언트에서 복제된 플레이어 컨트롤러가 최초의 컨트롤러이기 때문에 플레이어 컨트롤러 0이 된다.
언리얼에서 인덱스로 플레이어 컨트롤러에 접근할 수 있는 GetPlayerController 함수가 있다.
서버에서 PlayerIndex = 0으로 자기 자신의 컨트롤러에 접근할 수 있고, PlayerIndex = 1으로 클라이언트의 컨트롤러에 접근할 수 있다.
클라이언트 로컬 머신에선 클라가 가진 플레이어 컨트롤러는 서버로부터 복제된 플레이어 컨트롤러가 유일하므로 PlayerIndex = 0 클라 자기 자신의 컨트롤러에 접근이 가능하다.
네트워크 멀티플레이어용 로그 매크로 제작
로그인 과정을 직접 확인할 수 있도록 로그를 직접 제작해 보겠다.
제작 중인 게임에서 매크로 카테고리 생성하고 로그 매크로를 지정했다.
// ------------------- 프로젝트명.h-----------------------------------
#pragma once
#include "CoreMinimal.h"
//로그 매크로 지정
#define Grizzly_LOG(LogCat, Vervosity, Format, ...) UE_LOG(LogCat, Vervosity, TEXT("%s"),*FString::Printf(Format,##__VA_ARGS__))
//매크로 카테고리 생성
DECLARE_LOG_CATEGORY_EXTERN(LogGrizzly,Log,All)
// ------------------- 프로젝트명.cpp----------------------------------
#include "ProjectGrizzly.h"
#include "Modules/ModuleManager.h"
DEFINE_LOG_CATEGORY(LogGrizzly)
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, ProjectGrizzly, "ProjectGrizzly" );
플레이어 컨트롤러에서 로그를 테스트해 봤다.
#include "GrizzlyPC.h"
#include "ProjectGrizzly/ProjectGrizzly.h"
void AGrizzlyPC::BeginPlay()
{
Grizzly_LOG(LogGrizzly,Log,TEXT("%s"),TEXT("Begin"))
Super::BeginPlay();
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("End"))
}
지금 로그는 어떤 함수에서 찍히는지 모르기 때문에 로그가 어떤 함수에서 찍히는지 구현해 봤다.
// 프로젝트명.h
#pragma once
#include "CoreMinimal.h"
// 호출한 함수명 Get
#define LOG_CALLINFO ANSI_TO_TCHAR(__FUNCTION__)
//로그 매크로 지정 + 로그가 찍힌 함수명 출력
#define Grizzly_LOG(LogCat, Vervosity, Format, ...) UE_LOG(LogCat, Vervosity, TEXT("%s %s"),LOG_CALLINFO,*FString::Printf(Format,##__VA_ARGS__))
//매크로 카테고리 생성
DECLARE_LOG_CATEGORY_EXTERN(LogGrizzly,Log,All)
게임모드의 로그확인
AGameModeBase를 상속받아 가상함수 PreLogin, Login, PostLogin, StartPlay를 오버라이드 한다.
PreLogin : 클라이언트의 접속 요청을 처리하는 함수. 아직 로그인된 상황이 아니다.
Login : 접속이 허용한 클라이언트에 대응하는 플레이어 컨트롤러를 만드는 함수다.
PostLogin : 플레이어 입장을 위해 플레이어에 필요한 기본 설정을 모두 마무리하는 함수. 컨트롤러가 빙의할 캐릭터 세팅까지 모두 정리해서 보내준다.
StartPlay : 게임의 시작을 지시하는 함수
BeginPlay: 게임 모드의 StartPlay를 통해 게임이 시작될 때 모든 액터에서 호출하는 함수
Super 부모 함수 위아래로 로그를 찍어본다.
// --------------------- GrizzlyGameMode.cpp------------------------
#include "GrizzlyGameMode.h"
#include "ProjectGrizzly/ProjectGrizzly.h"
void AGrizzlyGameMode::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage)
{
Grizzly_LOG(LogGrizzly,Log,TEXT("%s"),TEXT("==========New Client Try to Connet==================="));
Grizzly_LOG(LogGrizzly,Log,TEXT("%s"),TEXT("Begin"));
Super::PreLogin(Options,Address,UniqueId,ErrorMessage);
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("End"));
}
APlayerController* AGrizzlyGameMode::Login(UPlayer* NewPlayer, ENetRole InRemoteRole, const FString& Portal, const FString& Options, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage)
{
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("Begin"));
APlayerController* NewController = Super::Login(NewPlayer,InRemoteRole,Portal,Options,UniqueId,ErrorMessage);
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("End"));
return NewController;
}
void AGrizzlyGameMode::PostLogin(APlayerController* NewPlayer)
{
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("Begin"));
Super::PostLogin(NewPlayer);
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("End"));
}
void AGrizzlyGameMode::StartPlay()
{
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("Begin"));
Super::StartPlay();
Grizzly_LOG(LogGrizzly, Log, TEXT("%s"), TEXT("End"));
}
서버는 PreLogin과정이 없다. 왜냐하면 서버의 접속 요청은 무조건 승인이기 때문이다.
클라이언트를 LateJoin 하면 플레이어 컨트롤러의 BeginPlay가 두 번 호출되는데 이건 서버에서, 클라에서 총 2번 호출되기 때이다.
로그만 보고 이것을 구분하기 어렵기 때문에 서버, 클라 어디서 호출됐는지 확인할 수 있는 매크로 로그를 구현하자.
// ------------------------- 프로젝트명.h -----------------------------
#pragma once
#include "CoreMinimal.h"
// 넷모드 Get
// GPlayInEditorID : 에디터 환경에서 각 클라이언트 세션에 부여되는 고유 ID
// GPlayInEditorID를 이용해 몇 번째 클라이언트인지 확인이 가능하다
#define LOG_NETMODEINFO ((GetNetMode()==NM_Client)?*FString::Printf(TEXT("CLIENT%d"),GPlayInEditorID):(GetNetMode()==NM_Standalone)?TEXT("STANDALONE"):TEXT("SERVER"))
// 호출한 함수명 Get
#define LOG_CALLINFO ANSI_TO_TCHAR(__FUNCTION__)
//로그 매크로 지정 + 로그가 찍힌 함수명 출력
#define Grizzly_LOG(LogCat, Vervosity, Format, ...) UE_LOG(LogCat, Vervosity, TEXT("[%s] %s %s"),LOG_NETMODEINFO,LOG_CALLINFO,*FString::Printf(Format,##__VA_ARGS__))
//매크로 카테고리 생성
DECLARE_LOG_CATEGORY_EXTERN(LogGrizzly,Log,All)
게임의 시작
GameMode 함수 StartPlay가 호출되면 게임이 시작되고 이때 모든 액터의 BeginPlay가 호출된다.
void AGameModeBase::StartPlay()
{
//게임 모드가 직접 게임을 시작하지 않고 GameState를 통해 간접적으로 게임 시작을 명령
GameState->HandleBeginPlay();
}
void AGameStateBase::HandleBeginPlay()
{
bReplicatedHasBegunPlay = true;
//월드에 있는 모든 액터의 BeginPlay 호출을 명령
GetWorldSettings()->NotifyBeginPlay();
GetWorldSettings()->NotifyMatchStarted();
}
이때 클라이언트는 게임모드가 없는데 어떻게 신호를 받고 게임을 시작하는 걸까?
이를 위해서 서버는 GameState라는 게임 정보를 알려주는 특별한 액터를 사용한다.
서버에만 존재하는 게임 모드와 다르게 서버와 클라이언트에 모두 존재한다.
게임모드는 게임을 시작할 때 본인이 직접 시작하지 않고 게임 스테이트에게 명령을 내려서 게임을 시작하도록 간접적으로 지시한다. (위 코드 참고)
게임 모드로부터 명령을 받은 게임 스테이트는 월드에 속한 모든 액터들에게 BeginPlay를 시작하도록 지시한다. (GetWorldSettings()->NotifyBeginPlay())
게임스테이트는 클라이언트에 복제되는데 게임이 시작됐다는 정보가 이 복제된 게임스테이트에 반영될 때 이 타이밍에 클라이언트에 있는 모든 액터에게 BeginPlay를 호출하도록 명령한다.
게임 스테이트에서 로그를 통해 이 과정을 확인해 보자.
APlayerState를 상속받아 가상함수 HandleBeginPlay , OnRep_ReplicatedHasBegunPlay를 오버라이드했다.
// -------------------------- ACPP_GrizzlyGameState.cpp ------------------------------
#include "CPP_GrizzlyGameState.h"
#include "ProjectGrizzly/ProjectGrizzly.h"
//게임모드가 StartPlay를 지시할때 게임스테이트에 명령을 내려서 게임스테이트가 월드에 있는 모든액터에게 BeginPlay를 호출하도록 명령을 내린다
//단 서버에서만 실행되는 로직이기 때문에 클라이언트에서 호출되지 않는다
void ACPP_GrizzlyGameState::HandleBeginPlay()
{
Grizzly_LOG(LogGrizzly, Log, TEXT("Begin"));
Super::HandleBeginPlay();
Grizzly_LOG(LogGrizzly, Log, TEXT("End"));
}
// HandleBeginPlay와 동일한 로직을 수행하지만
// bReplicatedHasBegunPlay 프로퍼티가 true로 변경되고 클라이언트로 복제됐을 때 '클라이언트'에서만 호출된다
void ACPP_GrizzlyGameState::OnRep_ReplicatedHasBegunPlay()
{
Grizzly_LOG(LogGrizzly, Log, TEXT("Begin"));
Super::OnRep_ReplicatedHasBegunPlay();
Grizzly_LOG(LogGrizzly, Log, TEXT("End"));
}
// -------------------------- AGameStateBase.cpp ------------------------------
void AGameStateBase::OnRep_ReplicatedHasBegunPlay()
{
//GetLocalRole() != ROLE_Authority : 클라이언트에서만 로직을 실행한다
if (bReplicatedHasBegunPlay && GetLocalRole() != ROLE_Authority)
{
GetWorldSettings()->NotifyBeginPlay();
GetWorldSettings()->NotifyMatchStarted();
}
}
참고 : 클라이언트-서버 모델 | 언리얼 엔진 4.27 문서 | Epic Developer Community (epicgames.com)
'언리얼 > 이득우 네트워크 멀티플레이' 카테고리의 다른 글
[이득우 네트워크 멀티플레이] 06. 액터 리플리케이션 빈도와 연관성 (0) | 2024.08.19 |
---|---|
[이득우 네트워크 멀티플레이] 05. 액터 리플리케이션 기초 (0) | 2024.08.16 |
[이득우 네트워크 멀티플레이] 04. 액터의 역할 커넥션 핸드셰이킹 (0) | 2024.08.15 |
[이득우 네트워크 멀티플레이] 03. 커넥션과 오너십 (0) | 2024.08.11 |
[이득우 네트워크 멀티플레이] 01. 언리얼 네트워크 프레임워크 개요 (0) | 2024.08.06 |