● 프로퍼티 레플리케이션을 이용한 시스템 메세지


새 위젯 블루프린트를 생성한다. 이 블루프린트는 C++코드 없이 만든다. 화면 상단에 띄울 텍스트 하나만 있으면 된다.
이 위젯은 시스템 메세지를 출력하는 역할을 담당한다.
public:
ABaseballPlayerController();
virtual void BeginPlay() override;
virtual void GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const override;
//시스템 메세지의 내용
UPROPERTY(Replicated, BlueprintReadOnly)
FText SystemMessageText;
private:
//시스템 메세지 UI 블루프린트
UPROPERTY(EditDefaultsOnly, Category = "UI", meta = (AllowPrivateAccess = true))
TSubclassOf<UUserWidget> SystemMessageTextWidgetClass;
//화면에 생성된 시스템 메세지 UI
UPROPERTY()
TObjectPtr<UUserWidget> SystemMessageTextWidgetInstance;
이 위젯을 화면에 띄워야 하니 플레이어 컨트롤러에 이 위젯에 대한 클래스 및 인스턴스 변수를 추가한다. 여기에 이 위젯의 텍스트 내용이 될 FText 변수도 추가하는데 Replicated, BlueprintReadOnly 키워드를 사용한 UPROPERTY로 선언한다. 프로퍼티 레플리케이션을 할 테니 GetLifttimeReplicatedProps 함수도 필요하다.
ABaseballPlayerController::ABaseballPlayerController()
{
bReplicates = true;
SystemMessageText = FText::GetEmpty();
}
void ABaseballPlayerController::BeginPlay()
{
Super::BeginPlay();
//이하의 내용은 로컬일때만 동작
if (!IsLocalController())
return;
//UI 입력만 허용
FInputModeUIOnly InputModeUIOnly;
SetInputMode(InputModeUIOnly);
//시스템 메세지 UI 생성
if (IsValid(SystemMessageTextWidgetClass))
{
SystemMessageTextWidgetInstance = CreateWidget<UUserWidget>(this, SystemMessageTextWidgetClass);
if (IsValid(SystemMessageTextWidgetInstance))
{
SystemMessageTextWidgetInstance->AddToViewport();
}
}
//채팅 UI 생성
if (IsValid(ChatInputWidgetClass))
{
ChatInputWidgetInstance = CreateWidget<UChatInput>(this, ChatInputWidgetClass);
if (IsValid(ChatInputWidgetInstance))
{
ChatInputWidgetInstance->AddToViewport();
}
}
}
void ABaseballPlayerController::GetLifetimeReplicatedProps(TArray<class FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ThisClass, SystemMessageText);
}
변수와 함수가 추가됨에 따라 추가 및 변경된 함수의 내용이다.

위젯 블루프린트에서 텍스트의 디테일을 보면 텍스트 내용에 바인딩 생성을 할 수 있는 버튼이 있다.

그러면 텍스트 내용을 직접 입력하지 않고 그 대신 블루프린트 이벤트에 따라 텍스트 내용을 정할 수 있다. 위 그래프의 내용은 플레이어 컨트롤러에 선언한 FText의 내용을 반영하도록 만든 것이다.
이렇게 하면 다음과 같은 순서로 텍스트 내용이 반영된다. 이것이 시스템 메세지를 띄우는 원리다.
- FText 변수에 Replicated 키워드가 있다. 그러면 서버가 이 변수의 내용을 수정하면 클라이언트에서도 변경된 내용이 반영된다.
- 클라이언트에서는 위젯의 텍스트가 이 변수의 내용을 반영한다.
- 따라서 서버에서 텍스트를 변경하면 클라이언트의 UI에 그 내용이 출력된다.
● 게임 진행에 대한 함수 구현하기
A. 정답 판정 함수의 반환 타입 변경
원래 만든 정답 판정 함수는 그 결과를 FString으로 반환하는데 이러면 서버에서 게임 결과를 판정하기 위해 FString내 글자 위치를 찾아야 하는 불편함이 있다. 그래서 정답 판정 결과를 담당할 구조체를 만들 것이다.
//정답 판정용 구조체
struct FJudgeInfo
{
int32 StrikeCount;
int32 BallCount;
FJudgeInfo()
{
StrikeCount = 0;
BallCount = 0;
}
//판정 결과를 FString으로
FString ToString()
{
//완전 오답
if (StrikeCount == 0 && BallCount == 0)
return TEXT("아웃!");
return FString::Printf(TEXT("%d스트라이트 %d볼"), StrikeCount, BallCount);
}
};
구조체에는 스트라이크, 볼 개수를 저장할 변수가 있다 이러면 서버에서 판정시 텍스트 위치가 아니라 변수를 참조하면 된다.
클라이언트의 채팅으로 띄우려면 FString 타입인 값이 필요하니 원래 함수에서 반환하는 형식대로 FString으로 변환하는 함수를 구현했다.
//BaseballGameMode.h
//제출한 답이 정답과 일치하는지 검사
FJudgeInfo JudgeResult(const FString& SubmittedString);
//BaseballGameMode.cpp
FJudgeInfo ABaseballGameMode::JudgeResult(const FString& SubmittedString)
{
FJudgeInfo JudgeInfo = FJudgeInfo();
for (int32 iNum = 0; iNum < 3; ++iNum)
{
//동일 위치에 동일 숫자가 있으면 스트라이크
if (AnswerString[iNum] == SubmittedString[iNum])
{
JudgeInfo.StrikeCount++;
}
else//같은 숫자가 다른 위치에 있다면 볼
{
if (FString PlayerGuessChar = FString::Printf(TEXT("%c"), SubmittedString[iNum]); AnswerString.Contains(PlayerGuessChar))
{
JudgeInfo.BallCount++;
}
}
}
//입력에 대한 결과
return JudgeInfo;
}
구조체를 만들었으니 정답 판정 함수의 반환 타입을 및 구현 내용도 수정한다.
B. 게임 결과 판정
public:
//지정한 플레이어에 대한 게임 결과 판정
void JudgeGame(ABaseballPlayerController* BaseballPlayerController, int32 StrikeCount);
//게임 재시작
void GameRestart();
게임모드에 함수 2개를 선언한다. 하나는 한 플레이어가 답을 제출한 경우 게임 종료 판정을 담당하는 역할이고 다른 하나는 게임 종료시 재시작하는 역할이다.
void ABaseballGameMode::GameRestart()
{
//정답 다시 정하기
AnswerString = GenerateAnswer();
//모든 플레이어의 시도 횟수 초기화
for (const auto& PlayerController : AllPlayerControllers)
{
if (ABaseballPlayerState* PlayerState = PlayerController->GetPlayerState<ABaseballPlayerState>(); IsValid(PlayerState))
{
PlayerState->CurrentGuessCount = 0;
}
}
}
게임 재시작 함수에서는 정답을 다시 생성한다. 그 후 모든 플레이어의 시도 횟수를 초기화한다.
void ABaseballGameMode::JudgeGame(ABaseballPlayerController* BaseballPlayerController, int32 StrikeCount)
{
//해당 플레이어가 정답을 맞춤
if (StrikeCount == 3)
{
//모두에게 알림
if (ABaseballPlayerState* WinnersState = BaseballPlayerController->GetPlayerState<ABaseballPlayerState>(); IsValid(WinnersState))
{
for (const auto& PlayerController : AllPlayerControllers)
{
if (ABaseballPlayerState* BaseballPlayerState = PlayerController->GetPlayerState<ABaseballPlayerState>(); IsValid(BaseballPlayerState))
{
FString ResultMessage = WinnersState->PlayerNameString + TEXT("님이 이겼습니다!");
PlayerController->SystemMessageText = FText::FromString(ResultMessage);
}
}
}
//재시작
GameRestart();
}
게임 결과 판정 함수의 상단 부분이다. 같이 입력한 스트라이크 개수가 3일때 그 플레이어가 승리했다는 메세지를 띄우고 게임을 재시작한다.
else
{
//모든 플레이어가 최대 시도 횟수를 채우면 무승부, 아니면 계속 진행
bool bIsDraw = true;
for (const auto& PlayerController : AllPlayerControllers)
{
if (ABaseballPlayerState* BaseballPlayerState = PlayerController->GetPlayerState<ABaseballPlayerState>(); IsValid(BaseballPlayerState))
{
if (BaseballPlayerState->CurrentGuessCount < ABaseballPlayerState::MAX_GUESS_COUNT)
{
bIsDraw = false;
break;
}
}
}
//무승부일경우
if (bIsDraw)
{
for (const auto& PlayerController : AllPlayerControllers)
{
FString SystemMessage = TEXT("아무도 정답을 맞추지 못했습니다... (정답 : ") + AnswerString + TEXT(")");
PlayerController->SystemMessageText = FText::FromString(SystemMessage);
}
//재시작
GameRestart();
}
}
}
그리고 함수 하단 부분의 내용은 3 스트라이크가 아닌 경우다. 모든 플레이어를 확인하여 기회가 남은 플레이어가 있으면 계속 진행하고 아니면 종료한다.
if (CanGuess(BaseballPlayerController) && IsGuessNumberString(SubmittedString))
{
//이 플레이어의 시도 횟수 증가
IncreaseGuessCount(BaseballPlayerController);
//정답 체크
FJudgeInfo JudgeInfo = JudgeResult(SubmittedString);
const FString JudgeResultString = JudgeInfo.ToString();
//채팅과 결과를 각 플레이어에게 전달
for (TActorIterator<ABaseballPlayerController> Iter(GetWorld()); Iter; ++Iter)
{
if (ABaseballPlayerController* PlayerController = *Iter; IsValid(PlayerController))
{
FString CombinedMessage = GuessCountInfo(BaseballPlayerController) + TEXT(" : ") + ChatMessage + TEXT(" -> ") + JudgeResultString;
PlayerController->ClientRPCPrintChatMessageString(CombinedMessage);
}
}
JudgeGame(BaseballPlayerController, JudgeInfo.StrikeCount);
}
그리고 이 함수를 채팅 출력 함수 중 답 제출 부분에서 호출하도록 한다.
이로서 게임이 완성됐다. 게임 규칙을 다시 적어보면 다음과 같다.
- 서버가 정답을 정한다. 정답은 1 ~ 9 중 3개의 숫자를 중복 없이 고른 조합이다.
- 각 플레이어는 답을 제출한다. 답의 형식은 3개의 숫자다. 답은 최대 3번까지 제출할 수 있다.
- 정답과 제출한 숫자를 비교하여 다음의 결과를 출력한다.
* 동일한 위치에 동일한 숫자라면 스트라이크
* 위치는 다르지만 존재하는 숫자라면 볼
* 0 스트라이크 0 볼, 즉 아예 존재하지 않는 숫자만 적었다면 OUT
- 3 스트라이크, 즉 정답을 먼저 입력하면 승리
- 모든 플레이어가 기회를 사용했지만 정답이 안나왔으면 무승부
- 승리 또는 무승부 발생시 게임 재시작
완성된 게임을 해보니 위 규칙에 따라 잘 구현됐음을 확인했다.
● Logout 함수 구현
하지만 처음부터 버그가 없는 게임이 존재할리가... 문제점을 발견했다. 누군가가 접속을 끊어버리면 게임을 진행할 수 없다.

이 문제를 해결할 방법을 찾아봤는데 게임모드의 원본 코드, 즉 GameModeBase.h을 보니 Logout 함수가 있었다. 주석을 읽어보니 OnPostLogin의 반대 개념으로 보인다.
void ABaseballGameMode::Logout(AController* Exiting)
{
Super::Logout(Exiting);
if (ABaseballPlayerController* BaseballPlayerController = Cast<ABaseballPlayerController>(Exiting); IsValid(BaseballPlayerController))
{
//접속 종료한 플레이어 알림
if (ABaseballGameStateBase* BaseballGameState = GetGameState<ABaseballGameStateBase>(); IsValid(BaseballGameState))
{
if (ABaseballPlayerState* BaseballPlayerState = BaseballPlayerController->GetPlayerState<ABaseballPlayerState>(); IsValid(BaseballPlayerState))
{
BaseballGameState->MulticastRPCBroadcastLoginOrLogoutMessage(BaseballPlayerState->PlayerNameString, false);
}
}
//접속을 종료한 플레이어 컨트롤러를 목록에서 제거
AllPlayerControllers.Remove(BaseballPlayerController);
//남은 플레이어가 답을 입력할 수 있는지 확인
JudgeGame(nullptr, 0);
}
}
그래서 내가 만든 게임모드에 해당 함수를 오버라이딩 했다. 누군가가 접속하면 그 클라이언트의 플레이어 컨트롤러를 목록에 추가한 것과 반대로 누군가가 접속 종료하면 목록에서 해당 플레이어 컨트롤러를 제거한다. 그리고 이를 모든 플레이어에게 알린다.
//BaseballGameStateBase.h
UCLASS()
class BASEBALLGAME_API ABaseballGameStateBase : public AGameStateBase
{
GENERATED_BODY()
public:
//해당 이름의 플레이어가 접속하거나 접속 종료함을 알림
UFUNCTION(NetMulticast, Reliable)
void MulticastRPCBroadcastLoginOrLogoutMessage(const FString& NameString, const bool bIsLogin);
private:
const FString LOGIN_MESSAGE = TEXT("님이 입장하셨습니다.");
const FString LOGOUT_MESSAGE = TEXT("님이 퇴장하셨습니다.");
};
//BaseballGameStateBase.cpp
void ABaseballGameStateBase::MulticastRPCBroadcastLoginOrLogoutMessage_Implementation(const FString& NameString, const bool bIsLogin)
{
if (HasAuthority())//서버가 아닐때만 동작
{
return;
}
//플레이어 접속 또는 접속 종료를 모두에게 알림
if (APlayerController* PlayerController = UGameplayStatics::GetPlayerController(GetWorld(), 0); IsValid(PlayerController))
{
if (ABaseballPlayerController* BaseballPlayerController = Cast<ABaseballPlayerController>(PlayerController); IsValid(BaseballPlayerController))
{
const FString NotificationString = NameString + (bIsLogin ? LOGIN_MESSAGE : LOGOUT_MESSAGE);
BaseballPlayerController->PrintChatMessageString(NotificationString, FColor::Cyan);
}
}
}
플레이어 퇴장 알림은 사실 게임 스테이트에 있던 플레이어 접속 알림 함수를 수정한 것이다. 이 함수가 접속 및 접속 종료 알림 모두 담당하도록 수정했다. 이렇게 수정함에 따라 함수 이름도 변경했다.
void ABaseballGameMode::JudgeGame(ABaseballPlayerController* BaseballPlayerController, int32 StrikeCount)
{
//해당 플레이어가 정답을 맞춤
if (StrikeCount == 3 && IsValid(BaseballPlayerController))
{
...
그리고 누군가가 접속을 끊고 남은 플레이어가 정답 입력이 가능한지 다시 확인하도록 했다. 이는 게임 종료 판정 함수를 다시 실행하는 것으로 구현했다. 이때 플레이어를 지정하지 않았으니 nullptr로 하고 스트라이크 개수를 0으로 하여 실행하도록 했다.
플레이어 컨트롤러가 유효한지 확인하는 내용을 if문에 추가했다. 이러면 스트라이크 개수에 관계없이 플레이어 컨트롤러가 null이면 무조건 무승부 또는 게임 재개만 이루어질 것이다.
'언리얼엔진 학습 및 과제' 카테고리의 다른 글
| 액터 리플리케이션을 이용한 게임 제작 (2) (0) | 2025.12.17 |
|---|---|
| 액터 리플리케이션을 이용한 게임 제작 (1) (0) | 2025.12.12 |
| 네트워크 채팅을 이용한 숫자야구 게임 (2) (0) | 2025.12.01 |
| 네트워크 채팅을 이용한 숫자야구 게임 (1) (0) | 2025.11.27 |
| 언리얼 네트워크 관련 사전 정리 (0) | 2025.11.26 |