📌 숫자 야구 게임
언리얼의 Listen Server를 사용해서 채팅으로 하는 숫자 야구 게임을 구현하였다.
RPCs, RepNotify를 사용해, 서버와 클라이언트 간의 통신하고 데이터를 동기화 시키는 방법을 익혀보았다.
서버에 대한 이해도가 아직 크지 않아서 GameState, PlayerState 없이 GameMode와 PlayerController로만 구현하였다.
전체적인 설명보다는 네트워크 통신 및 데이터 동기화를 어떻게 사용했는지에 대해 일부분만 서술할 것이고, 전체 코드 드 확인 및 실행은 아래 Git 링크에서 가능하다.
*Git 링크: https://github.com/Dongry-96/NumberBaseballGame
GitHub - Dongry-96/NumberBaseballGame
Contribute to Dongry-96/NumberBaseballGame development by creating an account on GitHub.
github.com
📌 게임 규칙
더보기
- 처음 접속하면 서버가 1~9까지 겹치지 않는 3자리의 숫자를 생성
- “/124”과 같이 “/” 가 있으면 응답 패턴으로 간주
- "/"가 없으면 OUT
- 중복된 숫자가 있으면 OUT
- 숫자가 아니면 OUT
- Host 와 Guest 는 차례대로 “/123”, “/543” 와 같이 겹치지 않는 3자리 숫자를 입력
- 서버가 생성한 랜덤한 숫자와 사용자가 입력한 숫자를 비교하여 결과를 반환
- 자리수와 값이 같으면 스트라이크(S) 숫자를 증가
- 자리수는 다르고 값이 같으면 볼(B) 숫자를 증가
- 예 1. 서버 생성 숫자 386, 사용자 답변 127 ⇒ OUT
- 예 2. 서버 생성 숫자 386, 사용자 답변 167 ⇒ 0S1B
- 예 3. 서버 생성 숫자 386, 사용자 답변 367 ⇒ 1S1B
- 예 4. 서버 생성 숫자 386, 사용자 답변 396 ⇒ 2S0B
- 예 4. 서버 생성 숫자 386, 사용자 답변 386 ⇒ 3S0B ⇒ Win
- 3S 를 먼저 맞추는 플레이어가 Winner 처리
- Winner가 나오면 게임은 리셋 (출력메시지는 “Host Won!! 다시 게임이 시작됐다.” 또는 “Guest Won!! 다시 게임이 시작됐다.”)
- 둘 다 3번만에 못 맞추면 게임은 리셋 (출력메시지는 “무승부군. 다시 게임을 시작하지”)
- 턴 제어 기능(자신의 턴에만 입력 가능, 입력 제한 시간 동안 입력하지 못하면 기회 소진 후 상대 턴으로 변경)
✅ 클라이언트에게 Host와 Guest 역할 부여
Listen Server는 최초 서버를 연 플레이어가 Host가 되며, 그 플레이어의 클라이언트가 Server 역할을 수행한다.
따라서 처음 Server에 PlayerController가 생성될 때, Host Player가 없다면 해당 PlayerController를 Host로 지정하고 이후에 생성된 PlayerController에 대해서는 Guest로 지정하였다.
// GameMode.h
protected:
virtual void PostLogin(APlayerController* NewPlayer) override;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "GameRule")
int32 TotalTries;
// GameMode.cpp
void ANBG_GameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
ANBG_PlayerController* PlayerController = Cast<ANBG_PlayerController>(NewPlayer);
if (PlayerController)
{
if (!HostPlayer) // 첫 번째 플레이어는 Host
{
HostPlayer = PlayerController;
PlayerController->SetPlayerRole(true);
PlayerController->TotalTries = TotalTries; // 이 코드만으론 서버에 있는 PlayerController의 TotalTries만 값이 변경됨.
} // 따라서 PlayerController에서 TotalTries를 Replication 속성을 추가하여 자동 동기화 하였음
else // 두 번째 플레이어는 Guest
{
GuestPlayer = PlayerController;
PlayerController->SetPlayerRole(false);
PlayerController->TotalTries = TotalTries;
}
}
}
🔎AGameModeBase::PostLogin
PostLogin은 서버에서 플레이어가 게임에 접속할 때, 로그인 성공 이후 호출되는 AGameModeBase의 가상함수이다.
PlayerController에서 Replicate되는 함수를 호출하기에 가장 안전한 첫 번째 장소이다.
🔹 TotalTries 변수 복제(Property Replication, RepNotify)
TotalTries 는 각 플레이어가 입력할 수 있는 총 횟수를 의미하는 변수이다.
이 변수의 값을 PlayerController에 동기화하여, 사용자 UI에서 남은 기회를 표시할 수 있도록 Property Replication을 사용했다.
서버에서 PlayerController의 TotalTries 값을 변경하면, 해당 값이 클라이언트 PlayerController에 자동으로 복제(Replicated)된다.
그 후, 클라이언트에서 RepNotify (`OnRep_TotalTries`)가 실행되면서 UI를 업데이트하는 함수가 자동으로 호출되는 흐름이다.
만약 PlayerController의 TotalTries 변수가 Replication 설정되지 않았다면, TotalTries 값은 서버에서만 변경되며,
클라이언트에서는 동기화되지 않아, Host는 올바르게 UI가 업데이트되지만 Guest는 초기 값(디폴트 값)만 표시될 가능성이 크다.
// PlayerController.h
public:
ANBG_PlayerController();
protected:
/***네트워크에서 변수를 복제할 수 있도록 설정하는 메서드***/
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
public:
/***UI 업데이트, Client RPC(서버에서 호출)***/
UFUNCTION(Client, Reliable)
void Client_UpdateTriesText(int32 TriesLeft);
void Client_UpdateTriesText_Implementation(int32 TriesLeft);
/***TotalTries에 대한 동기화 후 실행될 함수, Property Replication***/
UFUNCTION()
void OnRep_TotalTries();
/***Replication 설정 및 OnRep 함수 바인딩***/
// 서버에서 값 변경 시, 자동으로 동기화
UPROPERTY(ReplicatedUsing = OnRep_TotalTries)
int32 TotalTries;
// PlayerController.cpp
/***네트워크에서 변수를 복제할 수 있도록 설정하는 메서드***/
void ANBG_PlayerController::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ANBG_PlayerController, TotalTries);
}
/***TotalTries에 대한 동기화 후 실행될 함수, Property Replication***/
void ANBG_PlayerController::OnRep_TotalTries()
{
Client_UpdateTriesText(0);
}
/***UI 업데이트, Client RPC(서버에서 호출)***/
void ANBG_PlayerController::Client_UpdateTriesText_Implementation(int32 TriesLeft)
{
FString TriesMessage = FString::Printf(TEXT("남은 기회: %d"), TotalTries - TriesLeft);
UpdateText(TriesText, TriesMessage);
}
- GameMode::PostLogin 에서 PlayerController->TotalTries 값을 변경하여, 서버에서 TotalTries 업데이트
- TotalTries 변수가 Replicated로 설정되어 있기 때문에, 클라이언트의 PlayerController->TotalTries에도 자동으로 동기화(Replication)됨
- RepNotify로 OnRep_TotalTries 함수가 바인딩되어 있으므로, 클라이언트에서 해당 함수가 자동으로 실행
- OnRep_TotalTries에서 Client_UpdateTriesText 함수를 호출하여 UI 업데이트
- 이 함수는 Client RPC로 선언되어 있지만, 현재는 클라이언트에서 직접 호출되는 것
✅ 사용자 입력, 서버로 전달하기
사용자가 입력을 하고 Enter를 사용했을 때, 서버에 해당 입력을 자동으로 전달할 수 있도록 하였다.
// PlayerController.h
public:
ANBG_PlayerController();
protected:
virtual void BeginPlay() override;
/***사용자 입력 감지 이벤트에 바인딩되는 메서드(Enter 감지)***/
UFUNCTION()
void OnInputCommitted(const FText& Text, ETextCommit::Type CommitMethod);
public:
/***서버에 클라이언트 입력 전달, Server RPC(클라이언트에서 호출)***/
UFUNCTION(Server, Reliable)
void Server_SendGuessToServer(const FString& Input);
void Server_SendGuessToServer_Implementation(const FString& Input);
private:
/***UMG 관련 변수***/
UPROPERTY(EditDefaultsOnly, Category = "UI")
TSubclassOf<class UUserWidget> PlayerWidgetClass;
UUserWidget* PlayerWidget;
class UEditableTextBox* InputText;
// PlayerController.cpp
void ANBG_PlayerController::BeginPlay()
{
Super::BeginPlay();
if (!IsLocalController()) return; // 클라이언트에서만 실행
if (!PlayerWidgetClass) return;
PlayerWidget = CreateWidget<UUserWidget>(this, PlayerWidgetClass);
if (PlayerWidget)
{
PlayerWidget->AddToViewport();
InputText = Cast<UEditableTextBox>(PlayerWidget->GetWidgetFromName(TEXT("InputText")));
if (InputText)
{
Client_SetInputVisibility(false);
InputText->OnTextCommitted.AddDynamic(this, &ANBG_PlayerController::OnInputCommitted); // 사용자 입력 감지 이벤트 바인딩
}
}
}
/***사용자 입력 감지 이벤트에 바인딩되는 메서드(Enter 감지)***/
void ANBG_PlayerController::OnInputCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
if (!IsLocalController()) return; // 클라이언트에서만 실행
if (CommitMethod == ETextCommit::OnEnter && !Text.IsEmpty())
{
Server_SendGuessToServer(Text.ToString()); // 서버에 클라이언트 입력 전달
InputText->SetText(FText::GetEmpty()); // 입력된 텍스트 삭제
Client_SetInputVisibility(false); // 입력 텍스트 블록 숨김 처리
}
}
/***서버에 클라이언트 입력 전달, Server RPC(클라이언트에서 호출)***/
void ANBG_PlayerController::Server_SendGuessToServer_Implementation(const FString& Input)
{
if (!GameMode)
{
GameMode = Cast<ANBG_GameMode>(GetWorld()->GetAuthGameMode());
}
if (GameMode)
{
GameMode->ProcessPlayerGuess(Input, this);
}
}
- 클라이언트에서 BeginPlay() 실행
- IsLocalController() 체크 후, 클라이언트에서만 실행되도록 보장
- UI 위젯을 생성하고 InputText에 대한 입력 이벤트(OnTextCommitted)를 OnInputCommitted() 함수에 바인딩
- 사용자가 엔터 키를 누르면 OnInputCommitted() 함수 실행
- CommitMethod가 ETextCommit::OnEnter인지 확인
- Server_SendGuessToServer() 를 호출하여 서버로 입력 전달(서버 RPC 실행)
- 서버에서 Server_SendGuessToServer_Implementation() 실행
- 클라이언트에서 전달된 입력을 GameMode의 ProcessPlayerGuess() 함수에 전달하며 호출
- ProcessPlayerGuess() 는 GameMode에서 실행(서버에서 입력을 처리)
✅ 결과 UI에 표시하기
사용자 입력이 GameMode에 전달되면, 게임 규칙에 따라 결과를 도출해 사용자 UI에 노출시켜주었다.
// GameMode.h
public:
/***플레이어 입력 처리***/
void ProcessPlayerGuess(const FString& PlayerGuess, class ANBG_PlayerController* Player);
protected:
/***UI 업데이트***/
void BroadcastMessageToAllPlayers(const FString& Message);
// GameMode.cpp
/***플레이어 입력 처리***/
void ANBG_GameMode::ProcessPlayerGuess(const FString& PlayerGuess, ANBG_PlayerController* Player)
{
GetWorldTimerManager().ClearTimer(TurnTimerHandle);
Player->Client_SetTimerTextVisibility(false);
Player->Client_UpdateTimerText(TurnCountdown);
// 유효하지 않은 입력 처리
FString PlayerType = (Player == HostPlayer) ? TEXT("Host") : TEXT("Guest");
if (!IsValidInput(PlayerGuess))
{
FString OutMessage = FString::Printf(TEXT("[%s] 잘못된 입력 (OUT)"), *PlayerType);
BroadcastMessageToAllPlayers(OutMessage);
CheckGameStatus(PlayerType, 0, 0);
return;
}
// 결과 계산
FString PureGuess = PlayerGuess.Right(3);
int32 Strike = 0, Ball = 0;
for (int32 i = 0; i < 3; ++i)
{
if (SecretNumber[i] == PureGuess[i])
{
Strike++;
}
else if (SecretNumber.Contains(FString(1, &PureGuess[i])))
{
Ball++;
}
}
// 결과 메세지 출력
FString ResultMessage = FString::Printf(TEXT("[%s] 입력: %s -> 결과: %dS / %dB"), *PlayerType, *PureGuess, Strike, Ball);
BroadcastMessageToAllPlayers(ResultMessage);
CheckGameStatus(PlayerType, Strike, Ball);
}
/***UI 업데이트***/
void ANBG_GameMode::BroadcastMessageToAllPlayers(const FString& Message)
{
for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
{
ANBG_PlayerController* PlayerController = Cast<ANBG_PlayerController>(It->Get());
if (PlayerController)
{
PlayerController->Client_UpdateResultText(Message);
PlayerController->Client_SetResultTextVisibility(true);
PlayerController->Client_AddHistoryEntry(Message);
}
}
}
- 전달받은 사용자 입력을 바탕으로 게임 규칙에 맞게 결과를 도출
- BroadcastMessageToAllPlayers() 에 결과 메세지를 전달하며 호출
- 현재 세션에 접속한 모든 플레이어에게 결과 메세지를 UI에 노출
- GetPlayerControllerIterator()를 호출하여 현재 접속한 모든 플레이어의 PlayerController를 가져옴
- FSconstPlayerControllerIterator로 플레이어 컨트롤러들을 하나씩 순회
- Client RPC를 호출하여, 결과 메세지 UI 업데이트
🔎GetWorld( ) -> GetPlayerControllerIterator( );
현재 게임 세션에 접속한 모든 플레이어의 APlayerController를 순회(Iterate)하는 함수이다.
서버에서 실행되며, 모든 플레이어의 PlayerController에 접근하여, 전역적인 플레이어 관리나 점수 업데이트, 모든 플레이어에게 메세지를 전달할 때 등 유용하게 사용된다.
🔔 정리
언리얼 엔진의 멀티플레이 시스템에서 클라이언트와 서버 간의 데이터 흐름을 중심으로 공부하였다.
네트워크 게임을 개발할 때 중요한 것은 "데이터를 어디서 처리할 것인가?"를 항상 고민해야될 것 같고, 단순히 RPC를 호출하는 것을 넘어서,
어떻게 하면 더 효율적으로 최적화된 네트워크 코드를 작성할 수 있을지 추가적인 학습이 필요해 보인다.

'내배캠 > Unreal Engine' 카테고리의 다른 글
미로 게임 구현(Dedicated Server) (0) | 2025.03.25 |
---|---|
언리얼 엔진 패키징(데디케이티드 서버 빌드) (0) | 2025.03.21 |
언리얼 네트워크와 객체 통신 (0) | 2025.03.12 |
온라인 게임과 네트워크 구성 (0) | 2025.03.11 |
네트워크 개념 (0) | 2025.03.10 |