[UE5] Cpp Multiplay Chat #1

_ dfdeer·2025년 8월 25일

멀티 플레이 채팅

개념알기

P2P Server (Peer to Peer Server)

각각의 컴퓨터가 클라이언트이자 서버인 구조이다. 서로가 서로에게 영향을 끼친다.

Listen Server

각각의 컴퓨터는 클라이언트이지만, 그 중에 단 하나의 컴퓨터만 서버를 담당한다.
즉 클라이언트 1이 Host 라면 나머지 클라이언트 2, 3 등은 Guest 인 것이다.

Dedicated Server

서버를 담당하는 컴퓨터가 따로 존재한다. 이 컴퓨터는 클라이언트로서는 일하지 않는다. 오직 서버를 관리하고 다른 클라이언트와 연결을 하는 역할만 한다.

우선 나는 이 데디케이티드 서버를 구현하기로 했다.

RPC (Remode Procedure Call)

기본적으로 함수는 로컬PC 에서 호출하고 로컬PC 에서 실행이 되는 구조였다.
하지만 RPC 는 호출하는 PC 와 실행하는 PC 가 달라도 가능하게 해주는 통신 기법이다.

1. 채팅 시스템 구현

1. C++ 클래스 생성

우선 아무것도 없는 레벨을 하나 생성해준다.

그리고 C++ 클래스로 GameModeBase, PlayerController, UserWidget 을 만들어주었다.

//ChatX.Build.cs

using UnrealBuildTool;

public class ChatX : ModuleRules
{
	public ChatX(ReadOnlyTargetRules Target) : base(Target)
	{
		PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
	
		PublicDependencyModuleNames.AddRange(new string[] {
			"Core", 
			"CoreUObject", 
			"Engine", 
			"InputCore", 
			"EnhancedInput", 

			// UI
			"UMG",
			"Slate",
			"SlateCore",
		});

		PrivateDependencyModuleNames.AddRange(new string[] {  });

        PublicIncludePaths.AddRange(new string[] { "ChatX" });
    }
}

우선 ChatX.Build.cs 파일을 수정해준다.
이곳에서 모듈에 UMG 를 넣어주어야 UI 를 사용할 수 있고 Slate, SlateCore 를 넣어야 메시지 입력을 구현할 수 있다.

// CXPlayerController.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "CXPlayerController.generated.h"

class UCXChatInput;

UCLASS()
class CHATX_API ACXPlayerController : public APlayerController
{
	GENERATED_BODY()
	
public:
	virtual void BeginPlay() override;

	void SetChatMessageString(const FString& InChatMessageString);

	void PrintChatMessageString(const FString& InChatMessageString);

protected:
	UPROPERTY(EditDefaultsOnly)
	TSubclassOf<UCXChatInput> ChatInputWidgetClass;
	UPROPERTY()
	TObjectPtr<UCXChatInput> ChatInputWidgetInstance;

	FString ChatMessageString;
};

플레이어 컨트롤러 C++ 클래스의 헤더이다.
유저 위젯으로부터 상속받는 변수를 만들어준다. Instance 에서 임시로 Class 에서 받아오는 구조이다.

또한 유저가 입력할 채팅을 ChatMessageString 을 미리 캐싱해두고 Setter 에서 받은 메시지를 캐싱해둔 변수에 넣는다.

그리고 PrintChatMessageString 함수를 통해 입력한 채팅을 화면에 띄워준다.

// CXPlayerController.cpp

#include "CXPlayerController.h"
#include "UI/CXChatInput.h"
#include "Kismet/KismetSystemLibrary.h"

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

	FInputModeUIOnly InputModeUIOnly;
	SetInputMode(InputModeUIOnly);

	if (IsValid(ChatInputWidgetClass))
	{
		ChatInputWidgetInstance = CreateWidget<UCXChatInput>(this, ChatInputWidgetClass);
		if (IsValid(ChatInputWidgetInstance))
		{
			ChatInputWidgetInstance->AddToViewport();
		}
	}
}

void ACXPlayerController::SetChatMessageString(const FString& InChatMessageString)
{
	ChatMessageString = InChatMessageString;
	PrintChatMessageString(ChatMessageString);
}

void ACXPlayerController::PrintChatMessageString(const FString& InChatMessageString)
{
	UKismetSystemLibrary::PrintString(this, InChatMessageString, true, true, FLinearColor::Red, 5.0f);
}

이후 소스 파일에서 BeginPlay 에서 Input 이 UI 에서만 적용되도록 설정했다. 우선은 채팅 프로젝트이기 때문이다.
그리고 만약에 ChatInputWidgetClass 가 존재한다면, CreateWidget 을 통해 생성하여 ChatInputWidgetInstance 에 담아주고 AddToViewport 를 할 수 있게 했다.

또한 Setter 에서 입력한 채팅을 ChatMessageString 에 담아주고 Print 함수를 호출하여 화면에 띄워준다.

//CXChatInput.h

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "CXChatInput.generated.h"

class UEditableTextBox;

UCLASS()
class CHATX_API UCXChatInput : public UUserWidget
{
	GENERATED_BODY()

public:
	virtual void NativeConstruct() override;

	virtual void NativeDestruct() override;

protected:
	UFUNCTION()
	void OnChatInputTextCommitted(const FText& Text, ETextCommit::Type CommitMethod);

public:
	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UEditableTextBox> EditableTextBox_ChatInput;
};

유저 위젯 C++ 클래스의 헤더이다.
채팅을 내보냈을 때 호출되는 함수를 하나 만들었다.
그리고 TObjectPtr 을 통해 EditableTextBox 변수를 만들어주고, UPROPERTY 에서 BindWidget 을 통해 바인딩을 하도록 강제한다.

// CXChatInput.cpp

#include "CXChatInput.h"
#include "Components/EditableTextBox.h"
#include "Player/CXPlayerController.h"

void UCXChatInput::NativeConstruct()
{
	Super::NativeConstruct();

	if (EditableTextBox_ChatInput->OnTextCommitted.IsAlreadyBound(this, &ThisClass::OnChatInputTextCommitted) == false)
	{
		EditableTextBox_ChatInput->OnTextCommitted.AddDynamic(this, &ThisClass::OnChatInputTextCommitted);
	}
}

void UCXChatInput::NativeDestruct()
{
	Super::NativeDestruct();

	if (EditableTextBox_ChatInput->OnTextCommitted.IsAlreadyBound(this, &ThisClass::OnChatInputTextCommitted) == true)
	{
		EditableTextBox_ChatInput->OnTextCommitted.RemoveDynamic(this, &ThisClass::OnChatInputTextCommitted);
	}
}

void UCXChatInput::OnChatInputTextCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
	if (CommitMethod == ETextCommit::OnEnter)
	{
		if (APlayerController* PC = GetOwningPlayer())
		{
			if (ACXPlayerController* CXPC = Cast<ACXPlayerController>(PC))
			{
				CXPC->SetChatMessageString(Text.ToString());
				EditableTextBox_ChatInput->SetText(FText());
			}
		}
	}
}

다음은 소스 파일이다.
생성될 때 만약 OnTextCommitted 에 바인딩이 되어있지 않다면 바인딩해주고, 파괴될 때는 반대로 바인딩이 되어있다면 바인딩을 해제해주는 코드다.

그 아래에 있는 OnChatInputTextCommitted 함수는 유저가 Enter 키를 눌렀을 때 그 유저의 플레이어 컨트롤러를 가져와 Setter 를 호출하여 입력한 채팅을 보내준다. 이후에 채팅을 입력하는 칸은 SetText(FText()); 로 빈칸으로 만들어준다.

2. 블루프린트로 상속

아까 만든 C++ 클래스들을 각각 상속받는 블루프린트 클래스를 생성해주고 레벨의 월드 세팅에 적용시켜준다.

플레이어 컨트롤러의 블루프린트에서 Widget Class 를 우리가 만든 유저 위젯 블루프린트로 설정해준다.

테스트 플레이

우선 채팅창에 dfdeer 라고 입력했다.

이후 Enter 키를 입력하니 화면에 띄워지고, 채팅창은 잘 비워지는 모습이다.

문제점

현재 이 로직은 클라이언트가 나 하나 뿐일 때만 멀쩡하다.
만약에 다른 클라이언트가 있다면, 내 UI 가 다른 클라이언트의 화면에서 띄워지고 알아서 동기화가 될 것이다.
멀티 플레이 게임을 만들 건데 나 혼자 있을 때만 잘 작동하면 의미가 없다.

2. 데디케이티드 서버 구축

문제 해결

이전 로직의 문제점을 해결하기 위해서는 위젯 객체가 Owning Client 에서만, 즉 실행한 클라이언트에서만 실행이 되면 된다. 그런 다음에 서버와 동기화시키면 된다.

1. 데디케이티드 서버 환경 설정

우선 서버와 클라이언트를 구분하여 여러 곳에서 플레이하는 것을 가정하고 만들 것이기 때문에, PIE 환경 또한 멀티 플레이에 맞게 바꾸어 주어야한다.
따라서 PIE 버튼 오른쪽 ... 버튼을 눌러 Number of Players 를 2로 바꿔주고 Net Mode 는 Play As Client 로 설정해준다.

그리고 Editor Preferences 에 가서 Level Editor > Play > Multiplayer Options 에서 Launch Separate Server 는 체크, Play Net Mode 는 Play As Client, Run Under One Process 는 체크 해제를 해 준다.

Launch Separate Server 는 체크 해두어야 서버와 클라이언트가 따로 띄워진다. 만약 해제돼 있으면 에디터 창이 서버이자 클라이언트가 된다.
Play Net Mode 는 조금 전에 설정한 것이랑 똑같은 건데, 클라이언트로서 실행을 하여 플레이하기 위해서 설정하는 것이다.
Run Under One Process 는 만약에 PIE 를 했을 때 클라이언트가 2개여도 하나의 프로세스로 생각하고 자동으로 동기화가 되기 때문에 확실하게 구분하고 개발하기 위해서 이를 체크 해제 해주는 것이다.

다음으로는 General > Experimental > PIE > Allow late joining 을 체크 해준다.
이는 만약에 PIE 도중 새로운 클라이언트를 추가할 수 있게 해주는 설정이다.

2. 코드 수정

// CXPlayerController.cpp

...

void ACXPlayerController::BeginPlay()
{
	Super::BeginPlay();
	
    // Add
	if (IsLocalController == false)
	{
		return;
	}
    
    ...
    
}

플레이어 컨트롤러 소스 파일에 주석으로 Add 라고 적힌 곳에 있는 코드를 추가해준다.
이렇게 하면 로컬 컨트롤러에서만 아래에 있는 코드들이 실행이 되기 때문에, 클라이언트 간 구별이 확실하게 될 것이다.

//ChatX.h

#pragma once

#include "CoreMinimal.h"

class ChatXFucnctionLibrary
{
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;
	}
};

ChatX 의 헤더 파일이다. 내가 원하는 텍스트만 출력하기 위해서 MyPrintString 이라는 함수를 선언해주었다.
월드에 존재하는 액터 중 아무거나 가져와서 만약 존재한다면 GetNetMode 를 통해 클라이언트이거나 리슨 서버라면 화면에 뷰포트를 통해 텍스트를 띄워준다.
만약에 둘다 아니라면, 데디 서버라는 뜻이기 때문에 로그만 띄워준다.

다음으로 Getter 함수이다.
마찬가지로 월드에 존재하는 액터 중 아무거나 가져와서 GetNetMode 를 통해 클라이언트인지 확인한다. 만약에 아니라면, 스탠드 얼론인지 확인한다. 스탠도 얼론도 아니면 서버라는 뜻이므로 서버를 출력해준다.

Getter 함수이기 때문에 판별한 String 을 반환해준다.

// CXPlayerController.cpp

...

// #include "Kismet/KismetSystemLibrary.h"
#include "ChatX.h"

...

void ACXPlayerController::PrintChatMessageString(const FString& InChatMessageString)
{
	// UKismetSystemLibrary::PrintString(this, InChatMessageString, true, true, FLinearColor::Red, 5.0f);

	ChatXFucnctionLibrary::MyPrintString(this, InChatMessageString, 10.f);

	// Debug
    /*
	FString NetModeString = ChatXFucnctionLibrary::GetNetModeString(this);
	FString CombineMessageString = FString::Printf(TEXT("%s: %s"), *NetModeString, *InChatMessageString);
	ChatXFucnctionLibrary::MyPrintString(this, CombineMessageString, 10.f);
    */
}

그리고 플레이어 컨트롤러 소스 파일에서 이전에 출력하던 텍스트는 제거하고, 새롭게 만든 출력 함수를 가져왔다.
또한 추가로 디버그 용 코드도 추가해두었다. 혹시나 나중에 필요할 때 주석을 지우고 확인하기 위해서 남겨두었다.

//CXPlayerController.h

class CHATX_API ACXPlayerController : public APlayerController
{
	...
    
public:

	...
    
	UFUNCTION(Client, Reliable)
	void ClientRPCPrintChatMessageString(const FString& InChatMessageString);
	UFUNCTION(Server, Reliable)
	void ServerRPCPrintChatMessageString(const FString& InChatMessageString);
    
    ...
    
}

우선 플레이어 컨트롤러 헤더 파일에서 클라이언트와 서버의 RPC Invoke 함수를 선언해준다.

//CXPlayerController.cpp

...

#include "EngineUtils.h"

...

void ACXPlayerController::SetChatMessageString(const FString& InChatMessageString)
{
	ChatMessageString = InChatMessageString;

	//PrintChatMessageString(ChatMessageString);
	if (IsLocalController())
	{
		ServerRPCPrintChatMessageString(InChatMessageString);
	}
}

...

void ACXPlayerController::ClientRPCPrintChatMessageString_Implementation(const FString& InChatMessageString)
{
	PrintChatMessageString(InChatMessageString);
}

void ACXPlayerController::ServerRPCPrintChatMessageString_Implementation(const FString& InChatMessageString)
{
	for (TActorIterator<ACXPlayerController> It(GetWorld()); It; ++It)
	{
		ACXPlayerController* CXPC = *It;
		if (IsValid(CXPC))
		{
			CXPC->ClientRPCPrintChatMessageString(InChatMessageString);
		}
	}
}

그리고 소스 파일에서 이전에 출력하던 코드는 제거해주고, 로컬 컨트롤러가 유효하면 Server RPC Invoke 함수를 호출하도록 한다.
그러면 서버 RPC 는 서버에는 모든 플레이어 컨트롤러가 존재하기 때문에 각 컨트롤러를 가져와서 이터레이터를 통해 담아 놓는다.
만약 각 컨트롤러들이 유효하다면 각자의 클라이언트로 입력된 채팅을 보내기 위해 Client RPC Invoke 함수를 호출한다.
그러면 이제 입력된 채팅을 모든 클라이언트에서 출력해준다.

테스트 플레이

왼쪽 클라이언트가 A, 오른쪽 클라이언트는 B 이다.

먼저 A 클라이언트에서 A 라고 입력한다.

양쪽 클라이언트에 모두 잘 출력 된다.

이번엔 B 클라이언트에서 B 라고 입력한다.

마찬가지로 양쪽 클라이언트에 모두 잘 출력 된다.

학습 내용 요약 & 느낀점

채팅 시스템은 멀티가 아니면 필요가 없는 시스템이기에, 이러한 것을 만들고 나니 정말로 멀티 플레이 게임을 구현하는 데에 있어서 한 걸음 나아간 기분이 들어서 뿌듯하다.
잘 써본 적이 없는 기능들도 이번에 처음으로 배우면서 사용해 봤기에 많은 공부가 된 것 같다.

0개의 댓글