언리얼엔진 학습 및 과제

네트워크 채팅을 이용한 숫자야구 게임 (1)

park94128 2025. 11. 27. 23:50

● 게임 규칙

우선 만들어볼 숫자야구의 게임 규칙을 다음과 같이 정리한다. 그리고 이를 구현한다.

 

- 서버가 정답을 정한다. 정답은 1 ~ 9 중 3개의 숫자를 중복 없이 고른 조합이다.

- 각 플레이어는 답을 제출한다. 답의 형식은 3개의 숫자다. 답은 최대 3번까지 제출할 수 있다.

- 정답과 제출한 숫자를 비교하여 다음의 결과를 출력한다.

 * 동일한 위치에 동일한 숫자라면 스트라이크

 * 위치는 다르지만 존재하는 숫자라면 볼

 * 0 스트라이크 0 볼, 즉 아예 존재하지 않는 숫자만 적었다면 OUT

- 3 스트라이크, 즉 정답을 먼저 입력하면 승리

- 모든 플레이어가 기회를 사용했지만 정답이 안나왔으면 무승부

- 승리 또는 무승부 발생시 게임 재시작

 

● 게임모드에 규칙 함수 추가

서버에서 사용할 것이니 서버에만 존재하는 게임모드에서 함수를 만든다.

 

public:
	//정답 생성
	FString GenerateAnswer();

	//올바른 답 형식인지 검사
	bool IsGuessNumberString(const FString& SubmittedString);

	//제출한 답이 정답과 일치하는지 검사
	FString JudgeResult(const FString& SubmittedString);

	//채팅 출력
	void PrintChatMessageString(const FString& ChatMessage);

protected:
	//정답
	FString AnswerString;

	//모든 플레이어 컨트롤러
	TArray<TObjectPtr<class ABaseballPlayerController>> AllPlayerControllers;

게임을 진행하려면 서버가 정답을 생성해야 한다. 그리고 플레이어가 답을 입력하면 그것을 가지고 정답인지 체크할 로직이 필요하다.

이에 더하여, 이제야 서버다운 역할을 할 것이다. 기존 채팅 구현에는 OnPostLogin이 그저 접속한 클리이언트 알림에 그쳤지만 이제 접속한 클라이언트의 플레이어 컨트롤러를 저장할 것이다.

 

void ABaseballGameMode::OnPostLogin(AController* NewPlayer)
{
	Super::OnPostLogin(NewPlayer);

	if (ABaseballGameStateBase* BaseballGameState = GetGameState<ABaseballGameStateBase>(); IsValid(BaseballGameState))
	{
		//접속한 플레이이어의 이름을 "PlayerN"형식으로 정한다.
		const FString NewName = TEXT("Player") + FString::FromInt(PlayerCount++);
		BaseballGameState->MulticastRPCBroadcastLoginMessage(NewName);
	}

	//접속한 플레이어 컨트롤러를 목록에 넣기
	if (ABaseballPlayerController* BaseballPlayerController = Cast<ABaseballPlayerController>(NewPlayer); IsValid(BaseballPlayerController))
	{
		AllPlayerControllers.Add(BaseballPlayerController);
	}
}

언급한대로, 접속한 클라이언트의 플레이어컨트롤러를 저장한다.

 

FString ABaseballGameMode::GenerateAnswer()
{
	//중복 없는 3개의 숫자
	int32 Num1 = FMath::RandRange(1, 9);
	int32 Num2 = FMath::RandRange(1, 9);
	while (Num1 == Num2)
	{
		Num2 = FMath::RandRange(1, 9);
	}

	int32 Num3 = FMath::RandRange(1, 9);
	while (Num1 == Num3 || Num2 == Num3)
	{
		Num3 = FMath::RandRange(1, 9);
	}

	//3개의 숫자를 조합하여 반환
	FString Answer = FString::Printf(TEXT("%d%d%d"), Num1, Num2, Num3);
	return Answer;
}

정답 생성 과정이다. 그냥 단순하게 랜덤 숫자를 뽑고 중복이 있으면 다시 뽑는 방식으로 구현했다. 아마 이것보다 더 좋은 방법이 있겠지만 어파지 중요한 사항은 아니라서 그냥 이정도로 구현했다.

 

이 함수를 BeginPlay에서 실행하여 게임 시작시 정답을 먼저 만든다.

 

bool ABaseballGameMode::IsGuessNumberString(const FString& SubmittedString)
{
	//길이 안맞음
	if (SubmittedString.Len() != 3)
	{
		return false;
	}

	//0 또는 숫자가 아닌 문자 불허
	TSet<TCHAR> UniqueDigits;//중복 검사용 Set
	for (TCHAR C : SubmittedString)
	{
		if (!FChar::IsDigit(C) || C == '0')
		{
			return false;
		}
		
		UniqueDigits.Add(C);
	}

	//중복 문자 존재
	if (UniqueDigits.Num() != 3)
	{
		return false;
	}

	return true;
}

입력한 내용이 올바른 형식이 검사하는 함수다. 당연히 3개의 숫자라서 길이가 3인 문자열이 필요하다. 그리고 이것이 중복이 없는 0이 아닌 숫자인지 검사한다.

중복 검사 방법은 TSet을 이용했다. 언리얼 엔진용 Set인 TSet도 중복이 불가능한 컨테이너라서 중복 검사에 적합하다고 본다.

 

FString ABaseballGameMode::JudgeResult(const FString& SubmittedString)
{
	int32 StrikeCount = 0, BallCount = 0;

	for (int32 iNum = 0; iNum < 3; ++iNum)
	{
		//동일 위치에 동일 숫자가 있으면 스트라이크
		if (AnswerString[iNum] == SubmittedString[iNum])
		{
			StrikeCount++;
		}
		else//같은 숫자가 다른 위치에 있다면 볼
		{
			if (FString PlayerGuessChar = FString::Printf(TEXT("%c"), SubmittedString[iNum]); AnswerString.Contains(PlayerGuessChar))
			{
				BallCount++;				
			}
		}
	}

	//완전 오답
	if (StrikeCount == 0 && BallCount == 0)
	{
		return TEXT("OUT");
	}

	//입력에 대한 결과
	return FString::Printf(TEXT("%dS%dB"), StrikeCount, BallCount);
}

입력한 답이 정답인지 체크한다. 정해진 규칙에 따라 스트라이크, 볼의 개수를 출력한다.

 

void ABaseballGameMode::PrintChatMessageString(const FString& ChatMessage)
{
	int Index = ChatMessage.Len() - 3;

	//채팅 내용 중 마지막 3개의 문자가 0이 아닌 숫자 3개라면 답 입력으로 취급하고 정답 체크
	if (const FString SubmittedString = ChatMessage.RightChop(Index); IsGuessNumberString(SubmittedString))
	{
		const FString JudgeResultString = JudgeResult(SubmittedString);
		for (TActorIterator<ABaseballPlayerController> Iter(GetWorld()); Iter; ++Iter)
		{
			if (ABaseballPlayerController* PlayerController = *Iter; IsValid(PlayerController))
			{
				FString CombinedMessageString = ChatMessage + TEXT(" -> ") + JudgeResultString;
				PlayerController->ClientRPCPrintChatMessageString(CombinedMessageString);
			}
		}
	}
	else//아니면 그냥 채팅으로 인식
	{
		for (TActorIterator<ABaseballPlayerController> Iter(GetWorld()); Iter; ++Iter)
		{
			if (ABaseballPlayerController* PlayerController = *Iter; IsValid(PlayerController))
			{
				PlayerController->ClientRPCPrintChatMessageString(ChatMessage);
			}
		}
	}
}

채팅 구현시 모든 플레이어 컨트롤러에게 채팅 내용을 전달하는 것을 이제 플레이어 컨트롤러가 아닌 게임 모드에서 담당하도록 한다. 이와 함께 입력한 채팅이 답 입력인지 판별하는 기능도 겸한다. 여기서 입력한 채팅의 마지막 3글자를 잘라서 그게 숫자3개 조합이면 답 입력으로 취급한다.

 

void ABaseballPlayerController::ServerRPCPrintChatMessageString_Implementation(const FString& ChatMessage)
{
	//서버는 입력한 채팅이 정답 입력 여부인지 확인한다.
	if (AGameModeBase* GameMode = UGameplayStatics::GetGameMode(this); IsValid(GameMode))
	{
		if (ABaseballGameMode* BaseballGameMode = Cast<ABaseballGameMode>(GameMode); IsValid(BaseballGameMode))
		{
			BaseballGameMode->PrintChatMessageString(ChatMessage);
		}
	}
}

게임 모드에 모든 클라이언트에게 채팅 전달을 구현함에 따라 기존 플레이어 컨트롤러의 해당 부분은 게임 모드의 함수를 실행하는 것으로 바꾼다.