언리얼엔진 학습 및 과제

액터 리플리케이션을 이용한 게임 제작 (12)

park94128 2026. 1. 14. 23:52

● 시작 맵 위젯 제작

여느 게임이 그래왔듯 게임을 하려면 우선 시작 화면을 거친다. 지금은 학습 단계니 대충 시작 화면에서 이름을 지은 다음 게임에 입장하는 것을 만들어본다.

우선 Build.cs 파일을 열어서 Slate와 SlateCore 모듈이 있는지 확인한다. 없으면 추가한다.

private:
    //이름 입력 칸이 빈 칸임을 알리는 텍스트
	UPROPERTY(meta = (BindWidget, AllowPrivateAccess))
	UTextBlock* EmptyAlertText;

	//이름 입력 칸
	UPROPERTY(meta = (BindWidget, AllowPrivateAccess))
	UEditableTextBox* InputNameBox;

	//시작 버튼
	UPROPERTY(meta = (BindWidget, AllowPrivateAccess))
	UButton* StartButton;

	//종료 버튼
	UPROPERTY(meta = (BindWidget, AllowPrivateAccess))
	UButton* ExitButton;

일단 시작 맵에 사용될 UI의 형태가 어떤지 생각해보면서 위젯을 제작한다. 그 위젯에 필요한 것을 생각해보니 코드에서는 이렇게 텍스트 입력 칸, 빈 칸일때 알리기 위한 텍스트, 시작 버튼, 종료 버튼이 필요할 것 같다.

 

그리고 그 생각대로 위젯 블루프린트를 만들었다.

 

시작 화면에 위젯을 사용하기 위해 전용 플레이어 컨트롤러를 만들었다.

private:
	UPROPERTY(EditDefaultsOnly, Category = "Widget", meta = (AllowPrivateAccess = true))
	TSubclassOf<UTitleMapUI> TitleMapUIClass;

	UPROPERTY()
	UTitleMapUI* TitleMapUI;
void ATitlePlayerController::BeginPlay()
{
	Super::BeginPlay();

	//UI 생성
    TitleMapUI = CreateWidget<UTitleMapUI>(this, TitleMapUIClass);
    if (IsValid(TitleMapUI))
    {
        TitleMapUI->AddToViewport();
    }
	
	//UI 입력만 허용
	FInputModeUIOnly InputModeUIOnly;
	SetInputMode(InputModeUIOnly);
}

코드는 그냥 화면에 위젯 추가가 전부라서 별거없다. 시작 화면은 네트워크 환경이 아니라서 로컬 검사 관련은 넣지 않았다. 또한 원활한 이용을 위해 FInputModeUIOnly를 사용했다.

 

블루프린트로 만들고 위젯을 지정한다.

 

플레이어 컨트롤러를 만들었으니 게임 모드도 만든다. 월드 세팅에서 시작 맵용 게임 모드를 세팅해주고 플레이어 컨트롤러를 지정해준다.

 

스탠드얼론으로 테스트해보니 위젯 추가는 잘 되는 것을 확인했다.

 

● 위젯 기능 구현을 위한 밑작업

위젯 기능을 만들기 전에 게임 인스턴스를 만들고 플레이어 컨트롤러에 함수를 하나 추가할 것이다.

class HIDDENTHIEF_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()

public:
	virtual void Init() override;
	
	//클라이언트의 이름 (서버 미사용)
	FString MyName;
};
void UMyGameInstance::Init()
{
	Super::Init();

	MyName = TEXT("");
}

게임 인스턴스는 사실 역할이 하나 뿐이다. 시작 화면에서 입력한 이름을 여기에 임시 저장할 것이다.

 

참고로 네트워크 환경에서는 서버와 클라이언트 모두 게임 인스턴스가 있지만 서로 연동되지 않는다. 서버에게 있다는 것 말고는 위젯처럼 각자 개인이 가지고 있다. 그래서 임시 데이터 저장 목적으로 게임 인스턴스를 사용하는 것이다.

 

게임 인스턴스를 만들었으면 프로젝트 세팅에서 지정하는 것을 잊지 말자

 

void ATitlePlayerController::GameStart(const FString& MyName) const
{
	//입력한 이름을 게임 인스턴스에 사용
	UMyGameInstance* MyGameInstance = Cast<UMyGameInstance>(GetGameInstance());
	if (!IsValid(MyGameInstance))
	{
		UE_LOG(LogHiddenThief, Error, TEXT("%s : 게임 인스턴스를 찾을 수 없습니다."), *GetNameSafe(this));
		return;
	}
	MyGameInstance->MyName = MyName;

	//서버로 이동
	UGameplayStatics::OpenLevel(GetWorld(), TEXT("127.0.0.1:17777"), true);
}

플레이어 컨트롤러에 추가하는 함수는 입력한 이름을 게임 인스턴스에 저장한 뒤 게임 맵으로 이동하는 기능을 가지고 있다.

 

시작 맵은 네트워크 환경이 아니기 때문에 (그래서 테스트 목적으로 스탠드얼론에 맞춰놓은 설정을 그대로 유지할 것이다.) 서버인 게임 맵으로 이동시 맵 이름 대신 URL을 사용하여 이동해야 한다. 이렇게 맵 이름 대신 URL을 사용하면 그 URL에 해당되는 서버에 접속하여 맵을 이동한다. 지금은 서버의 시작 맵으로 게임 맵을 지정했기 때문에 서버 접속 성공시 게임 맵으로 이동할 것이다.

 

에디터에서 테스트 할 경우, 기본적인 서버 포트는 17777이다. 그래서 지금은 URL 127.0.0.1:17777이다. (저 IP주소의 정체가 궁금하다면 직접 검색해보자. 금방 알 수 있을 정도로 유명하다.) 지금은 그냥 학습이니 저렇게 하드 코딩으로 URL을 입력했만 실제로는 저렇게 하지 않는다.

에디터 개인 설정에서 '레벨 에디터 - 플레이'로 들어가 서버를 찾아보자. 아무것도 안건드렸다면 서버 포트가 17777로 되어 있다. 이게 마음에 안들면 언제든지 바꿀 수 있다. 나는 변경하지 않았다.

바로 밑에 있는 '서버 맵 이름 오버라이드 (Server Map Name Override)'에는 게임 맵의 이름을 적어야 한다.

 

● 위젯 기능 구현하기

public:
	virtual void NativeConstruct() override;

private:
	//이름 입력 칸 내용 변화에 대한 함수
	UFUNCTION()
	void OnChatInputTextCommitted(const FText& Text, ETextCommit::Type CommitMethod);

	//게임 시작
	UFUNCTION()
	void StartGame();

	//게임 종료
	UFUNCTION()
	void QuitGame();

위젯에 필요한 함수를 선언한다. 이름 입력 칸에서 엔터 입력시, 시작 버튼을 누를 때, 종료 버튼을 누를 때의 함수가 각각 필요하다. 위젯이니 BeginPlay가 아니라 NativeConstruct함수를 쓰는 것을 잊지 말자.

 

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

	EmptyAlertText->SetVisibility(ESlateVisibility::Collapsed);
	
	InputNameBox->OnTextCommitted.AddDynamic(this, &ThisClass::OnChatInputTextCommitted);

	StartButton->OnClicked.AddDynamic(this, &ThisClass::StartGame);
	ExitButton->OnClicked.AddDynamic(this, &ThisClass::QuitGame);
}

NativeConstruct 함수에서 금방 선언한 함수를 필요한 위젯에 각각 대응시킨다. 그리고 이름 빈칸 알림용 텍스트는 처음에 필요하지 않으니 숨긴다.

 

void UTitleMapUI::OnChatInputTextCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
	//엔터입력시 서버 접속
	if (CommitMethod == ETextCommit::Type::OnEnter)
	{
		StartGame();
	}
}

이름 입력 칸에서 엔터를 입력하는 것은 시작 버튼을 누르는 것과 같다.

 

void UTitleMapUI::StartGame()
{
	//이름 미입력시 시작 불가
	FString InputText = InputNameBox->GetText().ToString();
	if (InputText.IsEmpty())
	{
		EmptyAlertText->SetVisibility(ESlateVisibility::Visible);
		return;
	}

	//입력한 이름을 가지고 게임을 시작한다.
	const ATitlePlayerController* OwningPlayer = GetOwningPlayer<ATitlePlayerController>();
	if (!IsValid(OwningPlayer))
	{
		UE_LOG(LogTemp, Error, TEXT("%s : 플레이어 컨트롤러 못찾음"), *GetNameSafe(this));
		return;
	}
	OwningPlayer->GameStart(InputText);
}

시작 버튼을 누르면 이름 입력 칸이 빈칸인지 검사하고 빈칸이면 숨겼던 텍스트를 보여준다. 빈칸이 아니면 플레이어 컨트롤러에 선언한 게임 시작 함수를 호출한다.

 

void UTitleMapUI::QuitGame()
{
	UKismetSystemLibrary::QuitGame(this, nullptr, EQuitPreference::Quit, false);
}

종료 버튼은 말 그대로 종료하는 기능을 가지고 있다.

 

테스트 하기 전에 에디커 개인 설정에서 개별 서버 실행이 켜진 상태인지 다시 확인한다. 스탠드얼론으로 게임을 시작하기 때문에 저 값이 false일경우 서버 실행이 안된다.

실행해보니 시작 화면에서 정상적으로 게임 맵으로 이동했다. 맵 이동시 서버 로그에 뭔가 출력되는 것을 보니 서버 접속도 잘 되는 것으로 보인다.

 

● 이름을 만들었으니 활용해야지?

A. 게임 위젯에 또다른 텍스트 추가하기

public:
	//경찰이 NPC 죽임
	void PoliceKillNPC(const FString& PoliceName);

	//경찰이 도둑 죽임
	void PoliceKillThief(const FString& PoliceName, const FString& ThiefName);

	//도둑이 물건 훔침
	void ThiefStealItem(const FString& ThiefName);
    
private:
	//화면 중앙에 텍스트를 작게 표시하기
	void SetSubMessageText(const FText& Message);
    
	//화면 중앙에 작게 표시되는 텍스트 제거용 타이머 핸들
	FTimerHandle EraseSubMessageDelayHandle;
    
	//화면 중앙에 작게 표시할 텍스트
	UPROPERTY(EditDefaultsOnly, meta = (AllowPrivateAccess = true, BindWidget))
	UTextBlock* SubMessage = nullptr;
    
	const FString POLICE_KILL_NPC_MESSAGE = TEXT("님이 무고한 NPC를 죽였습니다.");
	const FString POLICE_KILL_THIEF_MESSAGE1 = TEXT("님이 ");
	const FString POLICE_KILL_THIEF_MESSAGE2 = TEXT("님을 죽였습니다.");
	const FString THIEF_STEAL_ITEM_MESSAGE = TEXT("님이 보물을 훔쳤습니다.");

게임 맵에서 사용하는 위젯에 추가 함수 및 변수를 선언한다. 경찰이 누군가를 죽이거나 도둑이 훔치면 화면에 표시하기 위함이다.

 

void UMainGameUI::SetSubMessageText(const FText& Message)
{
	SubMessage->SetText(Message);

	GetWorld()->GetTimerManager().SetTimer(EraseSubMessageDelayHandle, ([&]()
	{
		SubMessage->SetText(FText::GetEmpty());
		
	}), EraseDelayTime, false);
}

추가로 선언한 텍스트 블록은 사용할 때마다 잠시 후 지워지게 만들 것이다. 그래서 함수를 하나 따로 만들었다.

FTimerHandle은 중복으로 사용시 이전에 지정한 타이머가 취소되면서 새로 타이머가 지정된다고 하니 여기서는 짧은 간격으로 호출되어도 상관 없을것이다.

 

void UMainGameUI::PoliceKillNPC(const FString& PoliceName)
{
	SetSubMessageText(FText::FromString(PoliceName + POLICE_KILL_NPC_MESSAGE));
}

void UMainGameUI::PoliceKillThief(const FString& PoliceName, const FString& ThiefName)
{
	SetSubMessageText(FText::FromString(PoliceName + POLICE_KILL_THIEF_MESSAGE1 + ThiefName + POLICE_KILL_THIEF_MESSAGE2));
}

void UMainGameUI::ThiefStealItem(const FString& ThiefName)
{
	SetSubMessageText(FText::FromString(ThiefName + THIEF_STEAL_ITEM_MESSAGE));
}

새로 선언한 함수는 경찰 및 도둑의 이름을 매개변수로 받아서 활용하고 있다.

 

코드에서 새로 선언한 텍스트 블록 변수를 따라 위젯 블루프린트에서도 텍스트 위젯을 추가한다. 그냥 원래 있던 메세지용 텍스트 위젯을 복사한 다음 위치와 폰트 크기를 변경했다.

 

public:
	//경찰이 NPC 죽임을 출력
	UFUNCTION(Client, Reliable)
	void PoliceKillNPC(const FString& PoliceName);

	//경찰이 도둑 죽임을 출력
	UFUNCTION(Client, Reliable)
	void PoliceKillThief(const FString& PoliceName, const FString& ThiefName);

	//도둑이 물건 훔침음 출력
	UFUNCTION(Client, Reliable)
	void ThiefStealItem(const FString& ThiefName);
void AMainGamePlayerController::PoliceKillNPC_Implementation(const FString& PoliceName)
{
	MainGameUI->PoliceKillNPC(PoliceName);
}

void AMainGamePlayerController::PoliceKillThief_Implementation(const FString& PoliceName, const FString& ThiefName)
{
	MainGameUI->PoliceKillThief(PoliceName, ThiefName);
}

void AMainGamePlayerController::ThiefStealItem_Implementation(const FString& ThiefName)
{
	MainGameUI->ThiefStealItem(ThiefName);
}

위젯에 추가된 함수를 따라 게임 모드에서 호출할 수 있도록 플레이어 컨트롤러에서도 클라이언트 RPC 함수를 추가한다.

 

B. 서버에서 활용하기

private:
	//경찰인 플레이어
	UPROPERTY()
	AMainGamePlayerController* PolicePlayer = nullptr;

	//경찰인 플레이어의 이름
	FString PoliceName;

우선 경찰인 플레이어를 따로 지정할 때 이름도 따로 저장하도록 변수를 미리 추가한다.

 

public:
	//서버에게 준비 완료를 알림
	void OnPlayerControllerReady(AMainGamePlayerController* NewPlayer, const FString& PlayerName);
void AMainGameMode::OnPlayerControllerReady(AMainGamePlayerController* NewPlayer, const FString& PlayerName)
{
	//접속한 플레이어 확인
	if (AMainGamePlayerState* NewPlayerState = NewPlayer->GetPlayerState<AMainGamePlayerState>(); IsValid(NewPlayerState))
	{
		NewPlayerState->SetPlayerName(PlayerName);
		UE_LOG(LogHiddenThief, Warning, TEXT("%s님이 입장했습니다."), *NewPlayerState->GetPlayerName());
	}

	//접속한 플레이어의 컨트롤러를 TVector에 보관
	InGamePlayers.Add(NewPlayer);
    
    
    ...

게임 모드에서 플레이어의 준비 완료를 받는 함수의 매개변수에 플레이어 이름을 추가한다. 그리고 함수 앞부분에 준비 완료된 플레이어의 이름을 지정하는 기능을 추가한다. 이 이름 지정 함수는 플레이어 스테이트 기본 함수니 이후 다른 프로젝트에서도 적절하게 이용하자.

private:
	//서버에게 준비 완료를 알림
	UFUNCTION(Server, Reliable)
	void AlertReadyToServer(const FString& MyName);
void AMainGamePlayerController::AlertReadyToServer_Implementation(const FString& MyName)
{
	AMainGameMode* MainGameMode = Cast<AMainGameMode>(GetWorld()->GetAuthGameMode());
	MainGameMode->OnPlayerControllerReady(this, MyName);
}

매개변수 추가에 따라 이 함수를 호출하는 곳인 플레이어 컨트롤러도 수정이 필요하다.

 

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

	//로컬이면 UI 생성
	if (IsLocalPlayerController())
	{
		MainGameUI = CreateWidget<UMainGameUI>(this, MainGameUIClass);
		if (IsValid(MainGameUI))
		{
			MainGameUI->AddToViewport();
			MainGameUI->AfterAddToViewport();

			//게임 인스턴스에서 이름 가져오기
			const FString MyName = Cast<UMyGameInstance>(GetGameInstance())->MyName;
			
			//위젯 준비가 완료되면 서버에게 준비됨을 알린다.
			AlertReadyToServer(MyName);
		}
		else
		{
			UE_LOG(LogHiddenThief, Error, TEXT("'%s' : 위젯을 찾을 수 없습니다."), *GetNameSafe(this));
		}

		//UI 입력에서 게임 입력으로 변경
		FInputModeGameOnly InputModeGameOnly;
		SetInputMode(InputModeGameOnly);
	}
}

클라이언트에서는 시작 화면에서 저장한 이름을 가져와서 활용하도록 한다.

 

생각해보니 시작 화면에서 UI전용 입력을 지정했는데 이쯤에서 게임 전용 입력으로 지정해줄 필요가 있다.

 

if (AMainGamePlayerState* PlayerState = Cast<AMainGamePlayerState>(PlayerChar->GetPlayerState()); IsValid(PlayerState))
{
    PlayerState->bPolice = PoliceIndex == PlayerIndex;
    Player->GameStart(PlayerState->bPolice);
    if (PlayerState->bPolice)
    {
        PolicePlayer = Player;
        Player->SetTargetCount(ThiefCount);

        PoliceName = Player->GetPlayerState<AMainGamePlayerState>()->GetPlayerName();
    }
    else
    {
        Player->SetTargetCount(ThiefTarget);
    }
}

다시 게임 모드로 돌아와서, 게임 시작시 경찰 및 도둑을 지정해줄때 경찰 이름을 미리 선언한 변수에 따로 저장한다.

 

public:
	//도둑이 물건 훔침
	void TargetStolen(const FString& ThiefName);

	//경찰이 도둑 죽임
	void ThiefDeath(const FString& ThiefName);

	//경찰이 NPC 죽임
	void NPCDeath();

경찰이 도둑을 죽일때, 도둑이 물건을 훔칠때 사용하는 함수에 도둑 이름을 받도록 매개변수를 추가한다. 그리고 경찰이 NPC를 죽일 때 사용하는 함수도 추가한다.

 

void AMainGameMode::TargetStolen(const FString& ThiefName)
{
	if (CurrentGameState != EGameState::Playing)
	{
		return;
	}

	//훔칠 대상의 수를 줄이고 게임 종료인지 확인
	bool bGameEnd = false;
	if (--ThiefTarget <= 0)
	{
		CurrentGameState = EGameState::EndOfGame;
		bGameEnd = true;
	}

	for (AMainGamePlayerController* Player : InGamePlayers)
	{
		//경찰이 아니면 남아있는 훔칠 대상을 출력
		if (Player != PolicePlayer)
		{
			Player->SetTargetCount(ThiefTarget);
		}

		//도둑이 물건 훔침을 모두에게 알림
		Player->ThiefStealItem(ThiefName);

		//게임 종료시
		if (bGameEnd)
		{
			Player->GameEnd(false);
		}
	}
}

void AMainGameMode::ThiefDeath(const FString& ThiefName)
{
	if (CurrentGameState != EGameState::Playing)
	{
		return;
	}

	//도둑의 수를 줄임
	--ThiefCount;

	//도둑의 죽음을 모두에게 알림
	CurrentGameState = EGameState::EndOfGame;
	for (AMainGamePlayerController* Player : InGamePlayers)
	{
		Player->PoliceKillThief(PoliceName, ThiefName);
		
		//게임 종료시 추가로 알림
		if (ThiefCount <= 0)
		{
			Player->GameEnd(true);
		}
	}

	//경찰에게 남은 도둑의 수를 출력
	PolicePlayer->SetTargetCount(ThiefCount);
}

void AMainGameMode::NPCDeath()
{
	for (AMainGamePlayerController* Player : InGamePlayers)
	{
		Player->PoliceKillNPC(PoliceName);
	}
}

추가 및 변경된 함수는 플레이어 이름을 활용한다.

 

MainGameMode->TargetStolen(PlayerState->GetPlayerName());

매개변수가 추가된 함수에 대해서 해당 함수를 호출하는 곳에서도 수정이 필요하다. 플레이어 캐릭터와 도둑이 훔칠 대상인 액터에서 호출하고 있다.

 

float AMainGameAIChar::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent, AController* EventInstigator, AActor* DamageCauser)
{
	if (bIsDeath)
	{
		return 0.0f;
	}

	DeathMotion();

	//서버에게 NPC 사망을 알림
	if (AMainGameMode* MainGameMode = Cast<AMainGameMode>(GetWorld()->GetAuthGameMode()); IsValid(MainGameMode))
	{
		MainGameMode->NPCDeath();
	}

	return 1.0f;
}

AI 캐릭터에서도 게임 모드에서 추가된 함수를 호출하도록 한다.

 

죄를 지었으면 벌을 받아야지?

이름을 활용한 UI가 완성됐다.