[CH4 1번] 숫자 야구 게임

이채원·6일 전

TIL

목록 보기
70/73

1. 게임 기획 및 규칙

  • 게임 목표: 서버가 무작위로 생성한 1~9 사이의 중복 없는 3자리 난수를 3번의 기회 안에 상대방보다 먼저 맞혀야 합니다.
  • 판정 시스템 (S / B / OUT):
  • S (스트라이크): 입력한 숫자와 자리가 정답과 모두 일치할 때.
  • B (볼): 숫자는 정답에 포함되어 있으나, 자리가 다를 때.
  • OUT: 입력한 3개의 숫자가 정답에 단 하나도 포함되지 않았을 때.
  • 예시: 정답이 386일 때 167을 입력하면 0S 1B, 127을 입력하면 OUT.
  • 예외 처리: 숫자 이외의 문자나 중복된 숫자를 입력하면 기회가 차감되지 않으며, 사용자에게 재입력을 요구합니다.
  • 승패 및 종료 조건:
  • 승리: 제한된 3번의 기회 내에 누군가 3S를 가장 먼저 달성하면 승리.
  • 무승부: 게임에 참여한 모든 플레이어가 3번의 기회를 모두 소진할 때까지 정답자가 없으면 무승부로 종료됩니다.
  • 도전 모드 (턴제 추가 규칙): 각 턴마다 입력 제한 시간이 주어지며, 시간 내에 미입력 시 기회가 1회 강제 소진되고 다음 사람에게 차례가 넘어갑니다.

2. 코어 클래스 설계 및 네트워크 통신 구조

멀티플레이어 환경에 맞춰 서버(Authority)와 클라이언트(Proxy)의 역할을 분리한 클래스 설계입니다.

GameModeBase (ABBGGameModeBase)

게임의 마스터 룰을 통제하며 서버에서만 존재하고 실행되는 핵심 클래스입니다.

  • 정답 생성: BeginPlay 시 GenerateRandomNumbers()를 통해 1~9 배열을 섞어 3자리 난수 문자열(SecretAnswer)을 생성합니다.
  • 입력 검증: 클라이언트로부터 전달받은 문자열의 길이 제한(3자리), 숫자 형태 여부, 0 포함 여부, 중복 여부를 IsValidInput()으로 엄격하게 검증합니다.
  • 결과 판정 및 중계: CalculateResult()에서 S/B/OUT을 판별하고, 월드에 접속한 모든 플레이어 컨트롤러를 순회하며 ClientShowMessage()를 호출해 결과를 브로드캐스트합니다.
  • 게임 흐름 제어: 3S 달성 시 승리를 공지하고, 모든 플레이어의 기회가 소진되었는지 확인(CheckGameEndCondition())하여 무승부를 판별한 뒤 게임을 초기화(ResetGame())합니다.

PlayerState (ABBGPlayerState)

플레이어 개인의 상태 데이터를 보관하며 서버와 클라이언트 간 변수를 동기화(Replication)합니다.

  • 데이터 관리: CurrentAttempts 변수를 통해 현재까지 시도한 횟수를 추적하며, 최대 허용 횟수(MaxAttempts = 3)와 비교하여 남은 기회를 확인합니다.
  • 네트워크 동기화: GetLifetimeReplicatedProps 함수 안에서 DOREPLIFETIME 매크로를 사용해 서버의 CurrentAttempts 데이터를 클라이언트 화면에 실시간으로 복제합니다.

PlayerController (ABBGPlayerController)

클라이언트의 입력을 서버로 전달하고, 서버의 피드백을 사용자 UI에 출력하는 RPC(Remote Procedure Call) 통신 창구입니다.

  • Server RPC (ServerSubmitGuess): 클라이언트 위젯에서 입력된 텍스트를 서버(GameMode)로 전송하여 정답 판정을 요청합니다.
  • Client RPC (ClientShowMessage, ClientShowAnnouncement): 서버가 판정한 결과 텍스트나 전체 공지사항(승리/무승부)을 전달받아 클라이언트 화면에 출력합니다.
  • UI 연동: 공지 위젯 전용 텍스트인 NotificationText를 Replicated로 설정하여 값이 변경될 때마다 화면에 동기화되도록 구성합니다.

UserWidget (UBBGChatWidget)

사용자가 직접 숫자를 타이핑하고 제출하는 클라이언트 측 UI 뷰입니다.

  • 이벤트 바인딩: NativeConstruct 시점에 UEditableTextBox 컴포넌트의 엔터키 입력(OnTextCommitted) 이벤트를 감지하도록 바인딩합니다.
  • 입력 데이터 전달: 엔터키가 눌리면 현재 소유권을 가진 ABBGPlayerController를 찾아 ServerSubmitGuess()를 호출해 서버로 데이터를 넘기고, 입력창을 다시 비워 다음 턴을 준비합니다.
// BBGGameModeBase.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "BBGGameModeBase.generated.h"

// 숫자 야구 게임의 전체 규칙과 서버 로직을 총괄하는 클래스
UCLASS()
class BASEBALLGAME_API ABBGGameModeBase : public AGameModeBase
{
    GENERATED_BODY()

public:
    // 게임 시작 시 최초 1회 호출 및 정답 숫자 생성
    virtual void BeginPlay() override;

    // 플레이어의 입력값을 받아 정답 판정
    void ProcessPlayerGuess(class ABBGPlayerController* PC, const FString& GuessStr);

    // 플레이어 서버 접속 시 자동 호출 및 접속 순서에 따른 이름 부여
    virtual void OnPostLogin(AController* NewPlayer) override;

private:
    // 서버 전용 3자리 정답 숫자
    FString SecretAnswer;

    // 1~9 사이의 중복 없는 3자리 난수 생성 및 저장
    void GenerateRandomNumbers();

    // 자릿수, 숫자 구성, 중복 여부 등 입력값 유효성 검사
    bool IsValidInput(const FString& Input);

    // 정답과 비교하여 스트라이크 및 볼 개수 계산 후 문자열 반환
    FString CalculateResult(const FString& Input, int32& OutStrike);

    // 시도 횟수 소진 여부 확인 및 무승부 판정
    void CheckGameEndCondition();

    // 승리 또는 무승부 시 데이터 초기화 및 게임 리셋
    void ResetGame();

    // 모든 플레이어의 화면 중앙에 공지 텍스트 출력
    void AnnounceToAll(const FString& Message);
};
// BBGGameModeBase.cpp
#include "Game/BBGGameModeBase.h"
#include "Player/BBGPlayerController.h"
#include "Player/BBGPlayerState.h"

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

    // 접속 플레이어 상태 정보 획득
    if (ABBGPlayerState* PS = NewPlayer->GetPlayerState<ABBGPlayerState>())
    {
        // 총 접속 인원 기반 이름 조립
        FString NewName = FString::Printf(TEXT("Player%d"), GetNumPlayers());

        // 플레이어 닉네임 설정
        PS->SetPlayerName(NewName);
    }
}

void ABBGGameModeBase::GenerateRandomNumbers()
{
    // 1~9 배열 생성 및 무작위로 해놓기
    TArray<int32> Numbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    Numbers.Sort([](int32 A, int32 B) { return FMath::RandBool(); });

    // 앞 3자리 숫자 추출 후 문자열 조합
    SecretAnswer = FString::Printf(TEXT("%d%d%d"), Numbers[0], Numbers[1], Numbers[2]);

    // 서버 콘솔에 정답 로그 출력
    UE_LOG(LogTemp, Warning, TEXT("[Server] Secret Answer Generated : %s"), *SecretAnswer);
}

bool ABBGGameModeBase::IsValidInput(const FString& Input)
{
    // 길이 3자리 제한 검사
    if (Input.Len() != 3) return false;

    // 숫자로만 구성되었는지 검사
    if (!Input.IsNumeric()) return false;

    // 숫자 0 포함 여부 검사
    if (Input.Contains(TEXT("0"))) return false;

    // 각 자리 숫자 중복 여부 검사
    if (Input[0] == Input[1] || Input[1] == Input[2] || Input[0] == Input[2]) return false;

    return true;
}

FString ABBGGameModeBase::CalculateResult(const FString& Input, int32& OutStrike)
{
    // 스트라이크 및 볼 개수 초기화
    OutStrike = 0;
    int32 Ball = 0;

    // 3자리 숫자 순회 검사
    for (int i = 0; i < 3; i++)
    {
        // 위치와 숫자 모두 일치 시 스트라이크 증가
        if (Input[i] == SecretAnswer[i])
        {
            OutStrike++;
        }
        // 위치는 다르나 숫자가 존재할 시 볼 증가
        else if (SecretAnswer.Contains(FString::Printf(TEXT("%c"), Input[i])))
        {
            Ball++;
        }
    }

    // 일치 숫자 없을 시 OUT 텍스트 반환
    if (OutStrike == 0 && Ball == 0) return TEXT("OUT");

    // 계산된 스트라이크 및 볼 결과 문자열 반환
    return FString::Printf(TEXT("%dS %dB"), OutStrike, Ball);
}

void ABBGGameModeBase::ProcessPlayerGuess(ABBGPlayerController* PC, const FString& GuessStr)
{
    // 컨트롤러 유효성 검사
    if (!PC) return;

    // 플레이어 상태 및 잔여 기회 검사
    ABBGPlayerState* PS = PC->GetPlayerState<ABBGPlayerState>();
    if (!PS || !PS->HasAttemptsLeft()) return;

    // 입력값 검증 실패 시 당사자에게만 에러 출력 후 로직 종료
    if (!IsValidInput(GuessStr))
    {
        PC->ClientShowMessage(TEXT("잘못된 입력입니다. 1~9 사이의 중복 없는 3자리 숫자를 입력하세요."));
        return;
    }

    // 시도 횟수 차감
    PS->IncrementAttempt();

    // 입력값 판정 및 결과 문자열 획득
    int32 Strikes = 0;
    FString ResultStr = CalculateResult(GuessStr, Strikes);

    // 전체 출력용 결과 텍스트 포맷 조립
    FString MsgToClient = FString::Printf(TEXT("[%s] [%d / %d] %s -> %s"),
        *PS->GetPlayerName(), PS->GetCurrentAttempts(), PS->GetMaxAttempts(), *GuessStr, *ResultStr);

    // 접속 중인 전체 클라이언트 화면에 메세지 출력
    for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
    {
        ABBGPlayerController* LoopPC = Cast<ABBGPlayerController>(It->Get());
        if (LoopPC)
        {
            LoopPC->ClientShowMessage(MsgToClient);
        }
    }

    // 3 스트라이크 달성 시 승리 공지 및 게임 초기화
    if (Strikes == 3)
    {
        AnnounceToAll(FString::Printf(TEXT("Winner는 %s 입니다! (정답: %s)"), *PS->GetPlayerName(), *SecretAnswer));
        ResetGame();
        return;
    }

    // 전체 인원 기회 소진 여부 검사 로직 호출
    CheckGameEndCondition();
}

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

    // 서버 시작 직후 정답 난수 최초 생성
    GenerateRandomNumbers();
}

void ABBGGameModeBase::CheckGameEndCondition()
{
    // 무승부 판별 상태 변수
    bool bIsDraw = true;

    // 전체 플레이어 잔여 기회 순회 검사
    for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
    {
        ABBGPlayerController* PC = Cast<ABBGPlayerController>(It->Get());
        if (PC)
        {
            ABBGPlayerState* PS = PC->GetPlayerState<ABBGPlayerState>();

            // 기회 남은 플레이어 발견 시 무승부 상태 해제
            if (PS && PS->HasAttemptsLeft())
            {
                bIsDraw = false;
                break;
            }
        }
    }

    // 전원 기회 소진 상태일 경우 무승부 공지 및 초기화
    if (bIsDraw)
    {
        AnnounceToAll(TEXT("모든 기회 소진. 무승부입니다!"));
        ResetGame();
    }
}

void ABBGGameModeBase::ResetGame()
{
    // 신규 정답 난수 생성
     GenerateRandomNumbers(); 

    // 전체 클라이언트 시도 횟수 데이터 초기화
    for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
    {
        ABBGPlayerController* PC = Cast<ABBGPlayerController>(It->Get());
        if (PC)
        {
            ABBGPlayerState* PS = PC->GetPlayerState<ABBGPlayerState>();
            if (PS)
            {
                // 플레이어 횟수 0으로 되돌리기
                PS->ResetAttempt();
            }
        }
    }
}

void ABBGGameModeBase::AnnounceToAll(const FString& Message)
{
    // 전체 클라이언트 공지용 UI 텍스트 변수 업데이트
    for (FConstPlayerControllerIterator It = GetWorld()->GetPlayerControllerIterator(); It; ++It)
    {
        ABBGPlayerController* PC = Cast<ABBGPlayerController>(It->Get());
        if (PC)
        {
            PC->NotificationText = FText::FromString(Message);
        }
    }
}
// BBGPlayerState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "BBGPlayerState.generated.h"

// 플레이어 고유의 상태 데이터(시도 횟수 등) 보관 및 동기화 클래스
UCLASS()
class BASEBALLGAME_API ABBGPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
    ABBGPlayerState();

    // 서버와 클라이언트 간 네트워크 변수 동기화 설정
    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

    // 현재 시도 횟수 반환
    UFUNCTION(BlueprintCallable)
    int32 GetCurrentAttempts() const { return CurrentAttempts; }

    // 최대 시도 가능 횟수 반환
    UFUNCTION(BlueprintCallable)
    int32 GetMaxAttempts() const { return MaxAttempts; }

    // 숫자 입력 시 현재 시도 횟수 1 증가
    void IncrementAttempt() { CurrentAttempts++; }

    // 현재 시도 횟수 0으로 초기화
    void ResetAttempts() { CurrentAttempts = 0; }

    // 남은 입력 기회 존재 여부
    bool HasAttemptsLeft() const { return CurrentAttempts < MaxAttempts; }

    // 현재 시도 횟수 0으로 초기화 선언
    void ResetAttempt();

protected:
    // 클라이언트에게 실시간 동기화되는 현재 시도 횟수 변수
    UPROPERTY(Replicated)
    int32 CurrentAttempts;

    // 블루프린트에서 편집 가능한 게임 최대 시도 횟수 (기본값 3)
    UPROPERTY(EditDefaultsOnly)
    int32 MaxAttempts = 3;
};
// BBGPlayerState.cpp
#include "Player/BBGPlayerState.h"
#include "Net/UnrealNetwork.h"

// 생성자 및 초기값 설정
ABBGPlayerState::ABBGPlayerState()
{
    // 네트워크 복제 활성화
    bReplicates = true;

    // 초기 시도 횟수 0 설정
    CurrentAttempts = 0;
}

// 서버 및 클라이언트 간 변수 동기화 설정
void ABBGPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // CurrentAttempts 변수를 동기화 대상으로 등록
    DOREPLIFETIME(ABBGPlayerState, CurrentAttempts);
}

// 시도 횟수 초기화 로직
void ABBGPlayerState::ResetAttempt()
{
    // 현재 시도 횟수 0 설정
    CurrentAttempts = 0;
}
// BBGPlayerController.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "BBGPlayerController.generated.h"

UCLASS()
class BASEBALLGAME_API ABBGPlayerController : public APlayerController
{
    GENERATED_BODY()

public:
    // 게임 시작 시 최초 1회 호출 및 공지 위젯 생성
    virtual void BeginPlay() override;

    // 서버와 클라이언트 간 네트워크 변수 동기화 설정
    virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;

    // 클라이언트가 입력한 숫자 값을 서버로 전송
    UFUNCTION(Server, Reliable, WithValidation)
    void ServerSubmitGuess(const FString& GuessStr);

    // 서버의 텍스트 메세지를 특정 클라이언트 화면에 출력
    UFUNCTION(Client, Reliable)
    void ClientShowMessage(const FString& Message);

    // 승리 및 무승부 공지를 클라이언트 화면에 출력
    UFUNCTION(Client, Reliable)
    void ClientShowAnnouncement(const FString& AnnouncementMsg);

    // 서버 변경 시 클라이언트 위젯에 자동 동기화되는 공지 텍스트 변수
    UPROPERTY(Replicated, BlueprintReadOnly)
    FText NotificationText;

protected:
    // 블루프린트에서 지정할 공지 위젯 클래스
    UPROPERTY(EditDefaultsOnly)
    TSubclassOf<class UUserWidget> NotificationTextWidgetClass;

    // 화면에 생성된 공지 위젯 인스턴스 보관
    UPROPERTY()
    TObjectPtr<UUserWidget> NotificationTextWidgetInstance;
};
// BBGPlayerController.cpp
#include "Player/BBGPlayerController.h"
#include "Game/BBGGameModeBase.h"
#include "Net/UnrealNetwork.h"
#include "Blueprint/UserWidget.h"

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

    // 로컬 컨트롤러인 경우에만 UI 생성
    if (!IsLocalController()) return;

    // 블루프린트에 지정된 클래스로 공지 위젯 생성 및 화면 추가
    if (NotificationTextWidgetClass)
    {
        NotificationTextWidgetInstance = CreateWidget<UUserWidget>(this, NotificationTextWidgetClass);
        if (NotificationTextWidgetInstance)
        {
            NotificationTextWidgetInstance->AddToViewport();
        }
    }
}

bool ABBGPlayerController::ServerSubmitGuess_Validate(const FString& GuessStr)
{
    // 입력값 무조건 승인
    return true;
}

void ABBGPlayerController::ServerSubmitGuess_Implementation(const FString& GuessStr)
{
    // 게임모드 인스턴스 획득 후 입력값 판정 로직 실행
    ABBGGameModeBase* GameMode = GetWorld()->GetAuthGameMode<ABBGGameModeBase>();
    if (GameMode)
    {
        GameMode->ProcessPlayerGuess(this, GuessStr);
    }
}

void ABBGPlayerController::ClientShowMessage_Implementation(const FString& Message)
{
    // 5초간 메세지 출력
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Yellow, Message);
    }
}

void ABBGPlayerController::ClientShowAnnouncement_Implementation(const FString& AnnouncementMsg)
{
    // 10초간 공지 출력
    if (GEngine)
    {
        GEngine->AddOnScreenDebugMessage(-1, 10.f, FColor::Red, AnnouncementMsg);
    }
}

void ABBGPlayerController::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // NotificationText 변수를 서버와 클라이언트 간 동기화 대상으로 지정
    DOREPLIFETIME(ABBGPlayerController, NotificationText);
}
// BBGChatWidget.h
#pragma once

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

// 채팅 입력을 처리하는 UI 위젯 클래스
UCLASS()
class BASEBALLGAME_API UBBGChatWidget : public UUserWidget
{
    GENERATED_BODY()

protected:
    // UI 화면 초기 생성 시 자동 호출
    virtual void NativeConstruct() override;

    // 블루프린트 내 동일한 이름의 텍스트 박스 컴포넌트와 자동 연결
    UPROPERTY(meta = (BindWidget))
    class UEditableTextBox* EditableTextBox_ChatInput;

    // 텍스트 박스에서 엔터키 입력 시 실행
    UFUNCTION()
    void OnChatInputCommitted(const FText& Text, ETextCommit::Type CommitMethod);
};
// BBGChatWidget.cpp
#include "BBGChatWidget.h"
#include "Components/EditableTextBox.h"
#include "Player/BBGPlayerController.h"

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

    // 텍스트 박스 엔터키 입력 이벤트와 처리 함수 연결
    if (EditableTextBox_ChatInput)
    {
        EditableTextBox_ChatInput->OnTextCommitted.AddDynamic(this, &UBBGChatWidget::OnChatInputCommitted);
    }
}

void UBBGChatWidget::OnChatInputCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
    // 엔터키 입력 시에만 내부 로직 실행
    if (CommitMethod == ETextCommit::OnEnter)
    {
        // 현재 UI 소유 플레이어 컨트롤러 획득
        ABBGPlayerController* PC = Cast<ABBGPlayerController>(GetOwningPlayer());
        if (PC)
        {
            // 컨트롤러를 통해 서버로 입력 텍스트 전송
            PC->ServerSubmitGuess(Text.ToString());
        }

        // 전송 완료 후 텍스트 박스 내용 비우기
        EditableTextBox_ChatInput->SetText(FText::GetEmpty());
    }
}
profile
초보 개발자

0개의 댓글