UE - 멀티플레이 숫자야구2
UnrealEngine
어제에 이어서 야구게임 프로젝트를 계속했다.
주요 흐름은 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)
가 붙어있기에 서버에서만 실행된다.
BPC
의 ClientReceiveResult
함수는 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
이 호출되는 기준을 아직 잘 몰라서 이 부분도 추가적인 공부가 필요하다.