Post

UE - 멀티플레이 숫자야구2

UnrealEngine

UE - 멀티플레이 숫자야구2

어제에 이어서 야구게임 프로젝트를 계속했다.

주요 흐름은 GameMode에서 구현했으며 숫자 생성 및 테스트는 블루프린트 라이브러리에서 처리한다.

BluepinrtFunctionLibrary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
UCLASS()  
class SAMPLECHAT_API UBaseballBlueprintFunctionLibrary : public UBlueprintFunctionLibrary  
{  
    GENERATED_BODY()  
public:  
         
    /**  
     * @brief 1부터 9까지의 숫자 중 무작위로 3개의 숫자를 선택하여 반환.  
     ** @return 키가 숫자이고 값이 위치인 TMap<int32, int32> 형태의 난수 맵  
     */    UFUNCTION(BlueprintCallable, Category="Baseball")  
    static TMap<int32, int32> GetRandomNumbers();  
  
    /**  
     * @brief 플레이어가 입력한 답안과 정답을 비교하여 스트라이크와 볼 개수를 계산.  
     * 아직 예외처리가 부족함. 숫자 3개를 받았는지 확인하거나, 숫자대신 문자가 들어가는 등의 예외처리가 필요  
     ** @param SecretNumbers 정답으로 설정된 숫자 목록 (키: 숫자, 값: 위치)  
     * @param PlayerGuess 플레이어가 입력한 3자리 숫자 문자열  
     * @param Strike 스트라이크 개수 (정확한 숫자+위치 일치)  
     * @param Ball 볼 개수 (숫자는 맞지만 위치 불일치)  
     */    UFUNCTION(BlueprintCallable, Category="Baseball")  
    static void CheckAnswer(const TMap<int32, int32>& SecretNumbers, const FString& PlayerGuess, int32& Strike, int32& Ball);  
};

블루프린트라이브러리는 총 2개의 함수를 가지고 있다.

GetRandomNumbers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
TMap<int32, int32> UBaseballBlueprintFunctionLibrary::GetRandomNumbers()  
{  
    TMap<int32, int32> Results;  
  
    TArray<int32> Numbers;  
    // 1~9의 숫자를 추가  
    for (int32 i = 1; i < 10; i++)  
    {  
       Numbers.Add(i);  
    }  
  
    /*  
     * 랜덤 숫자를 고르는 방법  
     * 1. 1~9까지의 숫자가 들어있는 배열을 준비한다.  
     *     * 2. 무작위 숫자를 고른다.  
     *    - i번째 자리부터 배열의 끝까지 중 무작위로 인덱스를 하나 선택해 i번째 자리와 스왑.  
     *    - 스왑 후 i의 값에 +1을 하여 다음 선택에선 i번째 자리를 제외  
     *    - 이걸 뽑고 싶은 갯수만큼 반복 (여기선 3번)  
     *    - 이를 통해 0~2번째 위치에는 무작위로 선택된 3개의 숫자가 들어감.  
     ** 3. 앞에서 3개 숫자를 선택하여 정답으로 저장.  
     */   
    for (int32 i = 0; i < 3; ++i)  
    {  
       int32 RandomIndex = FMath::RandRange(i, Numbers.Num() - 1);  
       Numbers.Swap(i, RandomIndex);  
    }  
  
    // Answer에 처음 3개 저장 (키: 숫자, 값: 위치)  
    Results.Empty();  
    FString Log;     
	
	for (int32 i = 0; i < 3; i++)  
    {  
       Results.Add(Numbers[i], i);  
       Log += FString::FromInt(Numbers[i]);  
    }  
  
    // 디버깅용 정답 프린트  
    UE_LOG(LogTemp, Warning, TEXT("정답은 %s 입니다."), *Log);  
    return Results;  
}

이 함수는 3개의 중복없는 숫자를 뽑는 함수다.

배열과 인덱스를 이용한 방법으로, 중복 검사를 따로 할 필요도 없고, 단순히 반복 횟수만 변경하면 중복없는 숫자 조합을 생성할 수 있다.

CheckAnswer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void UBaseballBlueprintFunctionLibrary::CheckAnswer(const TMap<int32, int32>& SecretNumbers, const FString& PlayerGuess, int32& Strike, int32& Ball)  
{  
    Strike = 0;  
    Ball = 0;  
  
    // TMap에 해당 키(숫자)가 포함하는지 Contains로 확인.  
    // 있다면 자리까지 체크  
    for (int i = 0; i < PlayerGuess.Len(); i++)  
    {  
       int32 GuessNum = PlayerGuess[i] - '0';  
       if (SecretNumbers.Contains(GuessNum))  
       {  
          if (SecretNumbers[GuessNum] == i) Strike++;  
          else Ball++;  
       }  
    }  
      
    FString Message = FString::Printf(TEXT("Strike: %d, Ball: %d"), Strike, Ball);  
    if (GEngine)  
    {  
       GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Green, Message);  
    }  
}

정답과 비교하는 함수다.
정답은 TMap<int, int> 에 담겨있고, 키가 숫자 값이 위치다.

문자열 길이, 중복 숫자, 숫자외의 문자 비교 등 아직 예외처리가 더 필요하긴 하다.

GameMode

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
#pragma once  
  
#include "CoreMinimal.h"  
#include "GameFramework/GameModeBase.h"  
#include "BaseballGameModeBase.generated.h"  
  
class ABaseballPlayerController;  
  
UENUM(BlueprintType)  
enum class EGameResult : uint8  
{  
    None UMETA(DisplayName = "None"), // 기본 상태 (라운드가 다 끝났는데 None이면 무승부)  
    ThreeStrike UMETA(DisplayName = "ThreeStrike"), // 이김  
    Out UMETA(DisplayName = "Out"), // 짐  
};  
  
UCLASS()  
class SAMPLECHAT_API ABaseballGameModeBase : public AGameModeBase  
{  
    GENERATED_BODY()  
  
public:  
    ABaseballGameModeBase();  
  
    virtual void BeginPlay() override;  
    /**  
     * @brief 플레이어가 숫자를 제출하면 정답과 비교하여 결과를 처리하는 함수.  
     * @param UserID 제출한 플레이어의 ID     * @param Num 플레이어가 제출한 숫자 (문자열 형식)  
     * 예외처리가 아직 부족함. 3개 이상으로 받거나 숫자가 아니여도 딱히 검증하지 않음.  
     */    UFUNCTION(BlueprintCallable)  
    void TryAnswer(FString UserID, FString Num);  
  
    /**  
     * @brief 게임을 시작하는 함수.  
     *        초기화 작업을 수행하고 첫 번째 라운드를 시작함.  
     */    UFUNCTION(BlueprintCallable)  
    void StartGame();  
  
    /**  
     * @brief 게임을 종료하는 함수.  
     *        최종 결과를 정리하고 모든 플레이어의 상태를 업데이트함.  
     */    UFUNCTION(BlueprintCallable)  
    void EndGame();  
    /**  
     * @brief 새로운 라운드를 시작하는 함수.  
     *        최대 라운드 수를 초과하면 게임이 종료됨.  
     */    UFUNCTION(BlueprintCallable)  
    void StartRound();  
    /**  
     * @brief 새로운 턴을 시작하는 함수.  
     *        현재 턴의 플레이어를 결정하고 진행을 알림.  
     */    UFUNCTION(BlueprintCallable)  
    void StartTurn();  
    /**  
     * @brief 현재 턴을 종료하는 함수.  
     *        결과를 저장하고 다음 턴을 준비함.  
     */    UFUNCTION(BlueprintCallable)  
    void EndTurn();  
  
    /**  
     * @brief 현재 게임에 참여 중인 모든 플레이어의 ID를 가져오는 함수.  
     *        블루프린트에서 구현해야 함.  
     */    UFUNCTION(BlueprintImplementableEvent, Category="Baseball")  
    void GetALLUserIDs();  
protected:  
      
    /** 현재 진행 중인 라운드 번호 */    UPROPERTY(EditDefaultsOnly, Category="Baseball")  
    int CurrentRound = 0;  
  
    /** 게임의 최대 라운드 수 */    UPROPERTY(EditDefaultsOnly, Category="Baseball")  
    int MaxRound = 3;  
  
    /** 현재 게임에 참여한 모든 플레이어의 컨트롤러 목록 */    UPROPERTY(BlueprintReadWrite, Category="Baseball")  
    TArray<ABaseballPlayerController*> JoinedUserControllers;  
  
    /** 현재 턴을 진행 중인 플레이어의 인덱스 */    int32 UserIDIndex = 0;  
    /** 현재 턴을 진행 중인 플레이어의 ID */    UPROPERTY()  
    FString CurrentTurnPlayerID;  
  
    /** 현재 게임의 결과 (승리, 패배, 무승부) */  
    EGameResult GameResult;  
    /** 게임 정답 (숫자, 위치 매핑) */  
    UPROPERTY(EditAnywhere, BlueprintReadWrite)  
    TMap<int, int> Answer;  
};

게임 모드에선 라운드 처리와 턴 시스템, 게임 진행을 관리한다.

게임의 흐름은 StartGame -> StartRound -> StartTurn, TryAnser, EndTurn 반복 -> EndGame 순으로 진행된다.

게임 상태를 표현하기 위해 enum을 사용했다.
GameplayTag를 사용할까 고민했지만, 크게 확장할 생각이 없어서 enum을 선택했다.

EndGame이 호출되면 게임의 결과를 GameState에 보내준다.

1
2
3
4
5
6
7
8
9
void ABaseballGameModeBase::EndGame()  
{  
    if (ABaseballGameStateBase* BGameState = GetGameState<ABaseballGameStateBase>())  
    {  
       BGameState->ServerReceiveResult(GameResult, CurrentTurnPlayerID);  
    }  
      
    StartGame();  
}

GameState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
USTRUCT(BlueprintType)  
struct FBaseballGameResult  
{  
    GENERATED_BODY()  
  
    /** @brief 기본 생성자 */    FBaseballGameResult();  
    /**   
* @brief 게임 결과를 설정하는 생성자 * @param GameResult 게임의 결과 (승리, 패배 등)  
     * @param CurrentTurnPlayerID 현재 턴을 진행한 플레이어의 ID     */    FBaseballGameResult(EGameResult GameResult, const FString& CurrentTurnPlayerID);  
  
    /** 게임 결과 (승리, 패배 등) */  
    EGameResult Result;  
    /** 현재 턴을 진행한 플레이어의 ID */    FString CurrentTurnID;  
};  
  
UCLASS()  
class SAMPLECHAT_API ABaseballGameStateBase : public AGameStateBase  
{  
    GENERATED_BODY()  
public:  
    /** @brief 기본 생성자 */    ABaseballGameStateBase();  
    /**  
     * @brief 서버에서 게임 결과를 받아서 저장하는 함수  
     * @param NewResult 새로운 게임 결과 (승리, 패배 등)  
     * @param NewCurrentTurnID 해당 턴을 진행한 플레이어의 ID     * @note 이 함수는 서버에서 실행되며, 클라이언트에서는 이 함수를 서버에 요청할 수 있다고 함. (요청하면 실행은 서버에서)  
     */    UFUNCTION(Server, Reliable)  
    void ServerReceiveResult(EGameResult NewResult, const FString& NewCurrentTurnID);  
    /** 현재 게임의 결과를 저장하는 구조체 */    FBaseballGameResult GameResult;  
};

게임 스테이트에선 모든 PlayerController에게 게임 결과를 알려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void ABaseballGameStateBase::ServerReceiveResult_Implementation(EGameResult NewResult, const FString& NewCurrentTurnID)  
{  
    if (!HasAuthority()) return;  
    GameResult = FBaseballGameResult(NewResult, NewCurrentTurnID);  
  
    //모든 컨트롤러를 가져와서 게임 결과를 보내줌.  
    for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)  
    {  
       ABaseballPlayerController* BPC = Cast<ABaseballPlayerController>(*Iterator);  
       if (BPC)  
       {  
          // ClientReceiveResult 함수는 Client, Reliable이다.  
          // 의도는 서버에서 이 함수를 호출해 각 클라이언트에서 ClientReceiveResult가 호출하는 것.  
          BPC->ClientReceiveResult(GameResult.Result, GameResult.CurrentTurnID);  
       }  
    }  
}

이 함수로 모든 클라이언트에게 해당 함수를 호출하도록 명령한다.
UFUNCTION(Server, Reliable) 가 붙어있기에 서버에서만 실행된다.

BPCClientReceiveResult 함수는 UFUNCTION(Client, Reliable)이 붙어있어서, BPC가 속한 클라이언트에서 실행되도록 의도하고 작성했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void ABaseballPlayerController::ClientReceiveResult_Implementation(EGameResult Result, const FString& CurrentTurnID)  
{  
    FString Message = TEXT("오류가 있음");  
  
    UE_LOG(LogTemp, Display, TEXT("ID %s"), *MyUserID);  
       if (Result == EGameResult::None)  
    {  
       Message = TEXT("무승부입니다.");  
    }  
    else if (Result == EGameResult::ThreeStrike)  
    {  
       Message = (CurrentTurnID == MyUserID) ? TEXT("당신은 이겼습니다!") : TEXT("당신은 졌습니다...");  
    }  
    else if (Result == EGameResult::Out)  
    {  
       Message = (CurrentTurnID == MyUserID) ? TEXT("당신은 졌습니다...") : TEXT("당신은 이겼습니다!");  
    }  
  
    SetResultText(FText::FromString(Message)); // 위젯 텍스트 변경  
}

이렇게 자신의 ID와 비교하여 승패유무를 확인하고 위젯에 적용한다.

추가적으로 개선할 점과 공부할 것들

턴 시스템은 따로 컴포넌트화 해도 될 것 같다.

승패 유무를 확인하기 위해 결과와 ID를 같이 보내는데, 이 방법보다 좋은 방법이 분명 있을 것이라고 생각한다.

코드를 짜면서 예상대로 동작하지 않아 우회한 것들도 있다.

GameMode, ReplicatedUsing, Build

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
enum class EGameResult : uint8;  
  
USTRUCT(BlueprintType)  
struct FBaseballGameResult  
{  
    GENERATED_BODY()  
  
    /** @brief 기본 생성자 */    FBaseballGameResult();  
    /**   
* @brief 게임 결과를 설정하는 생성자 * @param GameResult 게임의 결과 (승리, 패배 등)  
     * @param CurrentTurnPlayerID 현재 턴을 진행한 플레이어의 ID     */    FBaseballGameResult(EGameResult GameResult, const FString& CurrentTurnPlayerID);  
  
    /** 게임 결과 (승리, 패배 등) */  
    EGameResult Result;  
    /** 현재 턴을 진행한 플레이어의 ID */    FString CurrentTurnID;  
};  
  
UCLASS()  
class SAMPLECHAT_API ABaseballGameStateBase : public AGameStateBase  
{  
    GENERATED_BODY()  
public:  
    /** @brief 기본 생성자 */    ABaseballGameStateBase();  
    /**  
     * @brief 서버에서 게임 결과를 받아서 저장하는 함수  
     * @param NewResult 새로운 게임 결과 (승리, 패배 등)  
     * @param NewCurrentTurnID 해당 턴을 진행한 플레이어의 ID     * @note 이 함수는 서버에서 실행되며, 클라이언트에서는 이 함수를 서버에 요청할 수 있다고 함. (요청하면 실행은 서버에서)  
     */   
    UFUNCTION(Server, Reliable)  
    void ServerReceiveResult(EGameResult NewResult, const FString& NewCurrentTurnID);  
    /** 현재 게임의 결과를 저장하는 구조체 */    FBaseballGameResult GameResult;  
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void ABaseballGameModeBase::OnRep_GameResult()  
{  
    APlayerController* PC = GetWorld()->GetFirstPlayerController();  
    ABaseballPlayerController* BPC = Cast<ABaseballPlayerController>(PC);  
  
    if (!BPC) return;  
  
    BPC->MyUserID;  
  
    FString Message;  
    if (GameResult == EBaseballResult::None)  
    {  
       Message = TEXT("무승부입니다.");  
    }  
    else  
    {  
       Message = (CurrentTurnPlayerID == BPC->MyUserID) ? TEXT("당신은 이겼습니다!") : TEXT("당신은 졌습니다...");  
    }  
  
    if (GEngine)  
    {  
       GEngine->AddOnScreenDebugMessage(-1, 5.f, FColor::Cyan, Message);  
    }  
}

게임모드안에 GameResult 변수에 ReplicatedUsing을 사용했더니 내부 컴파일러 오류가 발생한다.

근데 이상하게도 Development Editor로 설정하면 빌드가 잘 되는데, Debug GameMode로 빌드할 때만 이런다.

내부 컴파일러 오류라는 것도 처음 봤기에 공부하기 좋은 문제인 것 같다.

OnRep의 호출 조건

1
2
3
4
5
6
7
8
9
10
11
12
13
14
USTRUCT(BlueprintType)  
struct FBaseballGameResult  
{  
    GENERATED_BODY()  
  
    /** @brief 기본 생성자 */    FBaseballGameResult();  
    /**   
* @brief 게임 결과를 설정하는 생성자 * @param GameResult 게임의 결과 (승리, 패배 등)  
     * @param CurrentTurnPlayerID 현재 턴을 진행한 플레이어의 ID     */    FBaseballGameResult(EGameResult GameResult, const FString& CurrentTurnPlayerID);  
  
    /** 게임 결과 (승리, 패배 등) */  
    EGameResult Result;  
    /** 현재 턴을 진행한 플레이어의 ID */    FString CurrentTurnID;  
};

GameState 부분에서 사용한 구조체다.

원래 이 구조체도 ReplicatedUsing을 사용하여 값이 바뀔 때 OnRep이 호출하는 방식으로 설계했었다. 하지만

1
GameResult = FBaseballGameResult(NewResult, NewCurrentTurnID);

이렇게 값이 바뀌는데도 OnRep이 호출되지 않았다.

OnRep이 호출되는 기준을 아직 잘 몰라서 이 부분도 추가적인 공부가 필요하다.

This post is licensed under CC BY 4.0 by the author.