
지난 시간까지는 단순히 말을 전달하는 '채팅 시스템(RPC)'을 만들었다면, 오늘은 진짜 게임의 규칙이 돌아가는 '숫자 야구 게임 로직'을 구현했다.
핵심은 코드를 아무 데나 짜는 게 아니라, 역할에 맞는 클래스(GameMode, PlayerState, PlayerController)에 분배하는 것이다. 마치 웹 개발의 MVC 패턴처럼, 언리얼 네트워크 게임에서도 각자의 역할이 명확히 나뉘어 있다.
언리얼 엔진에서 멀티플레이 게임을 만들 때 가장 고민되는 것은 "이 변수와 함수를 어디에 만들어야 할까?"이다. 오늘 숫자 야구 게임을 만들며 정립한 구조는 다음과 같다.
숫자 야구에서 가장 중요한 건 '정답(SecretNumber)'이다. 만약 이 정답을 클라이언트(PlayerController)가 가지고 있다면? 메모리를 뜯어서 정답을 훔쳐보는 핵(Hack)을 만들 수 있다.
따라서 게임의 규칙, 정답 생성, 판정은 무조건 보안이 보장된 서버에만 존재하는 GameMode에서 수행해야 한다.
GenerateSecretNumber(): 게임 시작 시 1~9 사이의 중복 없는 숫자 3개를 만든다.JudgeGame(): 플레이어의 스트라이크 개수를 확인하고 승패를 결정한다.ResetGame(): 승패가 결정되면 게임을 초기화한다.// [CXGameModeBase.cpp]
// 서버에서 수행되는 심판 로직. 3 스트라이크면 승리를 선언한다.
void ACXGameModeBase::JudgeGame(ACXPlayerController* PC, int32 StrikeCount)
{
if (StrikeCount == 3)
{
// 승리 조건 달성!
// 해당 플레이어의 NotificationText를 변경 -> 클라이언트에 자동 복제되어 UI 뜸
PC->NotificationText = FText::FromString(TEXT("You Won!"));
// 게임 리셋
ResetGame();
}
else
{
// 승리가 아니라면 다른 플레이어들의 상태도 체크(무승부 등)해야 함
}
}
플레이어의 이름, 점수, 현재 시도 횟수(CurrentGuessCount) 같은 데이터는 게임 내내 유지되어야 하고, 모든 사람에게 보여야(동기화) 한다.
처음엔 PlayerController에 변수를 넣을까 했지만, 언리얼은 이런 '플레이어의 상태 정보'를 관리하기 위해 PlayerState라는 전용 클래스를 제공한다.
왜 PlayerController가 아닌가?
PlayerController는 접속이 끊기면 사라지거나 교체될 수 있다.PlayerController는 보안상 다른 플레이어의 PC에 존재하지 않는 경우가 많다(서버와 본인 PC에만 존재).Replication(복제) 설정:
UPROPERTY(Replicated)를 선언하고, cpp 파일에서 DOREPLIFETIME을 설정해야 한다.// [CXPlayerState.h]
public:
// 이 매크로가 있어야 서버의 값이 클라이언트로 전달된다.
UPROPERTY(Replicated)
int32 CurrentGuessCount;
UPROPERTY(Replicated)
int32 MaxGuessCount;
// [CXPlayerState.cpp]
void ACXPlayerState::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
// 여기에 등록된 변수만 네트워크로 동기화됨
DOREPLIFETIME(ThisClass, CurrentGuessCount);
DOREPLIFETIME(ThisClass, MaxGuessCount);
}
서버(GameMode)가 판정을 내렸으면, 그 결과를 플레이어의 화면(Widget)에 띄워줘야 한다. 이번에는 RPC가 아닌 변수 레플리케이션을 통해 UI를 업데이트하는 방식을 사용했다.
PlayerController에 FText NotificationText 변수를 만들고 Replicated로 설정한다.NotificationText를 "You Win!"으로 바꾼다.실제 게임이 돌아가는 순서를 정리하면 다음과 같다. 데이터가 Client -> Server -> All Clients로 흐르는 구조를 잘 봐야 한다.
입력 (Client):
플레이어가 채팅창에 "123"을 입력하면 ServerRPC로 서버에 전송한다.
판정 (Server - GameMode):
서버의 GameMode는 받은 문자열이 숫자인지 확인하고, 정답과 비교해 JudgeResult를 돌린다.
기록 갱신 (Server - PlayerState):
판정이 끝나면 PlayerState의 CurrentGuessCount를 +1 증가시킨다. 이 값은 레플리케이션되어 모든 유저의 채팅창 옆 점수판에 실시간으로 반영된다.
무승부 확인 (Server):
서버는 루프를 돌며 모든 플레이어의 시도 횟수를 확인한다. 만약 모두가 기회를 다 썼는데 정답자가 없다면 "Draw..."를 띄우고 게임을 리셋한다.
| 클래스 | 역할 (MVC 비유) | 특징 및 데이터 |
|---|---|---|
| GameMode | Manager (심판) | [Server Only] 정답( SecretNumber), 판정 함수(JudgeGame)보안이 중요한 로직은 모두 여기! |
| PlayerState | Model (데이터) | [Replicated] 시도 횟수( GuessCount), 이름(PlayerName)모든 클라이언트에 동기화됨. |
| PlayerController | Controller (입력/뷰) | [Local + Server] 공지사항 텍스트( NotificationText)입력을 받고 UI에 데이터를 연결. |
| Replication | Sync (동기화) | UPROPERTY(Replicated)와 DOREPLIFETIMERPC가 '행동'이라면 이건 '상태' 동기화. |