2026년 10월 12일 [CH4 1번] 숫자 야구 게임 과제를 제출 했으며 아직 멀티플레이 만드는게 어려운데 다음 과제 [CH4 2번] 분노의 질주 : 원화 채굴 는 재미있어 보인다
숫자 야구의 기본 규칙에 멀티플레이가 더해지면서, 누가 입력할 수 있고 결과를 누구에게 보여줄지 함께 정해야 했다.
이번에는 다음 내용을 기준으로 기능을 정리했다.
NumTimeWidget에서 함께 보여준다.특히 입력 가능 여부를 검사하는 단계와 스트라이크·볼을 판정하는 단계를 나누는 것이 중요했다. 기회를 모두 썼거나 자기 차례가 아니라면 먼저 처리를 끝내고, 모든 검사를 통과한 입력만 판정하도록 구성했다.
이 흐름을 기준으로 서버의 규칙 처리, 상태 저장, 화면 표시를 각 클래스에 나누어 작성했다.

입력 흐름은 다음과 같다.
입력창에서 숫자 입력
↓
PlayerController의 Server RPC
↓
GameMode에서 입력 가능 여부와 숫자 검사
↓
기회 사용 → 스트라이크와 볼 판정
↓
결과 전달 → 다음 턴 또는 새 라운드
위젯은 결과를 직접 판정하지 않는다. 서버에서 결정한 내용을 보여준다.
숫자 모양만 검사하면 끝이라고 생각하기 쉽지만, 먼저 이 플레이어가 입력할 수 있는 상태인지 확인해야 한다.
현재 검사 순서는 다음과 같다.
1~9로 이루어진 세 자리인가?기회를 모두 사용했다면 입력 내용과 관계없이 먼저 처리한다.
예를 들어 기회를 모두 쓴 플레이어가 112를 보내더라도 중복 숫자 안내보다 기회를 모두 사용했습니다.가 우선한다.
아래 코드는 실제 입력 형식 검사 함수다.
bool ANumGameModeBase::IsGuessNumberString(
const FString& InNumberString)
{
// 세 자리가 아니면 더 검사하지 않습니다.
if (InNumberString.Len() != 3)
{
return false;
}
// 각 문자가 1~9 범위인지 검사합니다.
for (TCHAR Digit : InNumberString)
{
if (Digit < TEXT('1') || Digit > TEXT('9'))
{
return false;
}
}
return true;
}
길이를 먼저 확인하므로 이후 세 자리의 문자를 비교할 수 있다.
112처럼 입력 내부에 같은 숫자가 있는지는 호출부에서 별도로 검사한다. 이렇게 나누면 형식 오류와 중복 오류에 서로 다른 안내를 보낼 수 있다.

세 자리 안의 중복은 문자를 서로 비교한다.
라운드 전체의 제출 기록은 언리얼의 TSet<FString>으로 관리한다.
TSet<FString> SubmittedGuesses;
아래는 처리 흐름을 간략화한 예시다.
// 이미 제출된 추측이면 기록과 기회를 변경하지 않습니다.
if (SubmittedGuesses.Contains(InChatMessageString))
{
InChattingPlayerController->ClientRPCPrintChatMessageString(
TEXT("이미 제출된 숫자입니다. 다시 입력하세요"));
return;
}
// 모든 검사를 통과한 입력만 기록합니다.
SubmittedGuesses.Add(InChatMessageString);
TSet은 같은 값이 있는지 확인할 때 사용하기 좋다. 다음 라운드에서는 Reset()으로 제출 기록을 비운다.
잘못된 입력을 했다고 즉시 기회가 줄어들지는 않는다. 다만 제한 시간은 계속 흐르기 때문에 유효한 입력 없이 시간이 끝나면 기회를 한 번 사용한다.
처음 수정해야 했던 부분 중 하나는 잘못된 입력 안내가 다른 플레이어에게도 보이는 것이었다.
메시지마다 받는 대상을 나누었다.

개인 안내는 해당 PlayerController의 Client RPC를 호출한다.
전체 안내는 각 플레이어의 PlayerController를 순회하며 Client RPC를 호출한다.
같은 Client RPC라도 어느 PlayerController에서 호출하는지에 따라 받는 플레이어가 달라진다.
정답은 서버의 Warning 로그에만 출력한다. 승리 문구나 Draw 문구에는 정답을 넣지 않는다.
턴 제한 시간의 기본값은 15초다.
GameMode에서 반복 타이머를 등록한다.
// 서버에서 1초마다 남은 턴 시간을 갱신합니다.
GetWorldTimerManager().SetTimer(
TurnClockHandle,
this,
&ANumGameModeBase::UpdateTurnClock,
1.0f,
true);
FTimerHandle은 남은 시간을 저장하는 변수가 아니다. 등록한 타이머를 식별하는 데 사용한다.
실제 남은 시간과 현재 차례는 GameState가 저장한다.
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Turn Practice")
int32 RemainingTurnSeconds = 0;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Turn Practice")
TObjectPtr<class ANumPlayerState> CurrentTurnPlayerState = nullptr;
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Turn Practice")
bool bTurnInProgress = false;
이 변수들을 GetLifetimeReplicatedProps에서 등록하여 클라이언트로 복제한다.
클라이언트의 위젯은 복제받은 시간을 표시한다. 각 클라이언트가 별도로 시간을 줄이지 않으므로 서버의 상태를 기준으로 게임이 진행된다.
다만 네트워크 지연 때문에 모든 화면이 정확히 같은 순간에 바뀌는 것은 아니다.
턴은 두 가지 경우에 끝난다.
두 경로에서 모두 기회를 사용하게 만들면, 숫자를 입력한 뒤 턴이 끝나는 과정에서 기회가 두 번 줄어들 수 있다.
이를 구분하는 변수가 bSubmittedThisTurn이다.
새 턴 시작
bSubmittedThisTurn = false
유효한 숫자 제출
기회 1회 사용
bSubmittedThisTurn = true
턴 종료
제출하지 않은 경우에만 기회 1회 사용
잘못된 입력은 유효한 제출로 처리하지 않는다.
CurrentGuessCount는 남은 기회가 아니라 사용한 횟수이므로, 기회를 사용할 때 값을 증가시킨다.
남은 기회는 다음과 같이 계산한다.
const int32 RemainingTurns = FMath::Max(
0,
NumPS->MaxGuessCount - NumPS->CurrentGuessCount);
플레이어 목록에는 TArray를 사용한다.
UPROPERTY()
TArray<TObjectPtr<ANumPlayerController>> AllPlayerControllers;
플레이어를 순서대로 선택해야 하므로 인덱스를 사용할 수 있는 배열이 맞았다.
CurrentTurnPlayerIndex =
(CurrentTurnPlayerIndex + 1) % AllPlayerControllers.Num();
마지막 플레이어 다음에는 첫 플레이어로 돌아간다. 실제 코드에서는 배열 길이만큼 도는 반복문 안에서 처리하며, 기회를 모두 사용한 플레이어는 건너뛴다.
이번 구현에서 자료구조를 선택한 기준은 간단했다.
TArrayTSetFStringUPROPERTY와 함께 사용하는 TObjectPtr시간과 턴 표시를 NumTimeWidget으로 합쳤다.
전체 플레이 시간을 누적해 보여주던 기능은 제거했다.

화면에서는 다음처럼 보이도록 구성했다.
남은 시간 00:12
내 턴 / 내 남은 기회 2회
다른 플레이어 차례라면 차례 표시만 달라진다.
남은 시간 00:08
Player2님의 턴 / 내 남은 기회 2회
여기서 남은 기회는 위젯을 보고 있는 자신의 값이다.
BindWidget으로 텍스트 위젯을 연결하고, RefreshTimeText에서 GameState와 자신의 PlayerState를 읽어 SetText로 표시한다.
TSet으로 라운드의 중복 제출을 관리한다.현재 위젯은 NativeTick에서 매 프레임 텍스트를 갱신한다. 구현 흐름은 단순하지만 값이 같아도 갱신 함수를 계속 호출한다.
앞으로는 값이 바뀌었을 때만 화면을 갱신하는 구조를 고려할 수 있다. 이때 복제된 값이 도착한 시점을 처리하는 방법도 함께 정리해야 한다.
또한 현재 타이머는 게임 시작 때 등록한 1초 주기를 계속 사용한다. 턴이 바뀐 직후 다음 타이머 호출이 오면 첫 번째 시간이 빠르게 줄어들 수 있다.
턴 시작에 맞춰 타이머를 다시 설정하거나, 서버의 턴 종료 시각을 기준으로 남은 시간을 계산하는 방식은 이후 개선할 부분이다.
bSubmittedThisTurn으로 기회가 중복 사용되는 것을 막는다.TArray, 중복 확인이 필요하면 TSet을 사용한다.