📌 클래스 다이어그램
이 게임은 자동으로 미로를 생성하고, 플레이어가 탈출지점에 도착하면 승자가 발표되며 게임이 종료되는 구조이다.
언리얼의 GameMode, PlayerController, PlayerState, GameState, Character, Actor 등의 개념을 활용해서 서버-클라이언트 구조로 설계했다.
📌 미로 생성 시스템 / FMazeCell과 AMazeGenerator
게임을 시작했을 때 맵이 고정되어있다면 플레이어는 금방 구조를 외우게 된다.
그래서 매번 새로운 미로를 자동으로 만들어내는 시스템이 필요했고, 이를 C++코드로 직접 구현했다.
벽을 그리드 형태로 배치한 뒤, 2D배열과 재귀 알고리즘을 사용해서 벽을 랜덤하게 제거해나가는 방식이다.
✅ FMazeCell (미로 셀에 대한 구조체)
USTRUCT(BlueprintType)
struct FMazeCell
{
GENERATED_BODY()
UPROPERTY()
bool bVisited;
UPROPERTY()
bool bWallTop;
UPROPERTY()
bool bWallBottom;
UPROPERTY()
bool bWallLeft;
UPROPERTY()
bool bWallRight;
FMazeCell()
: bVisited(false), bWallTop(true), bWallBottom(true),
bWallLeft(true), bWallRight(true) {}
};
미로는 정사각형 셀로 구성되어 있고, 각 셀은 네 방향(상,하,좌,우)에 벽을 가질 수 있다. 그리고 방문했는지 여부를 체크하는 변수를 선언하였다.
네 방향 모두 벽이 있다고 초기화를 한 뒤, 벽을 하나씩 제거해가며 길을 만들어나간다.
✅ MazeGenerator (미로 생성 액터)
🔹 MazeData: 미로 전체를 담는 2차원 배열
namespace
{
TArray<TArray<FMazeCell>> MazeData;
}
2D 배열을 cpp파일 전역 네임스페이스에 선언하였다.
🔹 InitMazeData( ): 모든 셀 초기화
void AMazeGenerator::InitMazeData()
{
MazeData.Empty();
MazeData.SetNum(MazeHeight);
for (int32 Y = 0; Y < MazeHeight; ++Y)
{
MazeData[Y].SetNum(MazeWidth);
for (int32 X = 0; X < MazeWidth; ++X)
{
MazeData[Y][X] = FMazeCell(); // 방문 안 한 상태, 4방향 벽 있는 셀
}
}
}
모든 셀을 미방문 상태이면서 벽이 4개인 상태로 초기화한다.
🔹 GenerateMaze(int32 X, int32 Y, int32 Depth): DFS를 사용한 미로 생성 알고리즘
void AMazeGenerator::GenerateMaze(int32 X, int32 Y, int32 Depth)
{
MazeData[Y][X].bVisited = true;
if (Depth > MaxDepth)
{
MaxDepth = Depth;
GoalPoint = FIntPoint(X, Y); // 가장 깊은 곳에 골 설정
}
TArray<FIntPoint> Neighbors = GetUnvisitedNeighbors(X, Y);
while (Neighbors.Num() > 0)
{
int32 Index = FMath::RandRange(0, Neighbors.Num() - 1);
FIntPoint Next = Neighbors[Index];
RemoveWall(FIntPoint(X, Y), Next);
GenerateMaze(Next.X, Next.Y, Depth + 1);
Neighbors = GetUnvisitedNeighbors(X, Y);
}
}
1. 현재 방문한 셀을 방문처리
2. GetUnvisitedNeighbors( ): 현재 좌표를 넘겨, 다음으로 진입 가능한 위치를 TArray<FIntPoint> 형태로 반환
3. 진입 가능한 위치 중, 랜덤으로 한 곳을 선택
4. RemoveWall( ): 현재 위치와 다음으로 진입할 위치 사이의 벽 제거
5. 다음 위치로 재귀호출
6. 아직 미방문한 셀이 남아있는데, 현재 위치에서는 주변에 진입 가능한 곳이 없다면 리턴하여 이전 노드에서 다른 방향으로 진입 시도 반복
7. 가장 멀리 도달한 셀을 미로 탈출 지점으로 설정
🔹 GetUnvisitedNeighbors(int32 X, int32 Y): 이동 가능한 방향 찾기 로직
TArray<FIntPoint> AMazeGenerator::GetUnvisitedNeighbors(int32 X, int32 Y)
{
TArray<FIntPoint> Result;
const TArray<FIntPoint> Dirs = { {0,-1}, {0,1}, {-1,0}, {1,0} };
for (const FIntPoint& D : Dirs)
{
int32 NX = X + D.X;
int32 NY = Y + D.Y;
if (NX >= 0 && NX < MazeWidth && NY >= 0 && NY < MazeHeight)
{
if (!MazeData[NY][NX].bVisited)
{
Result.Add({ NX, NY });
}
}
}
return Result;
}
현재 좌표에서 상하좌우 네 방향을 모두 검사하는데, 범위를 벗어나지 않고 아직 방문하지 않은 셀을 TArray에 저장해 반환
🔹 RemoveWall(FIntPoint Current, FIntPoint Next): 벽 제거 로직
void AMazeGenerator::RemoveWall(FIntPoint Current, FIntPoint Next)
{
int32 DX = Next.X - Current.X;
int32 DY = Next.Y - Current.Y;
FMazeCell& CurrentCell = MazeData[Current.Y][Current.X];
FMazeCell& NextCell = MazeData[Next.Y][Next.X];
if (DX == 1) { CurrentCell.bWallRight = false; NextCell.bWallLeft = false; }
else if (DX == -1) { CurrentCell.bWallLeft = false; NextCell.bWallRight = false; }
else if (DY == 1) { CurrentCell.bWallTop = false; NextCell.bWallBottom = false; }
else if (DY == -1) { CurrentCell.bWallBottom = false; NextCell.bWallTop = false; }
}
현재 셀과 다음 이동할 셀의 좌표 차이를 보고 방향을 판단해 벽 제거.
예: 오른쪽으로 이동했다면? DX == 1 조건을 통과
현재 위치 기준 오른쪽 벽, 다음 이동할 셀 기준 왼쪽 벽 모두 false를 저장해 벽이 없는 상태로 만듦.
🔹 SpawnMazeActors( ): MazeData를 기반으로 미로 생성 로직
void AMazeGenerator::SpawnMazeActors()
{
if (!HasAuthority() || !WallActorClass) return;
FVector MazeOrigin = GetActorLocation();
// 외곽 Top/Bottom
for (int32 X = 0; X < MazeWidth; ++X)
{
FVector TopPos = MazeOrigin + FVector(X * CellSize + 0.5f * CellSize, MazeHeight * CellSize, 0.f);
GetWorld()->SpawnActor<AActor>(WallActorClass, TopPos, FRotator(0.f, 0.f, 0.f));
FVector BottomPos = MazeOrigin + FVector(X * CellSize + 0.5f * CellSize, 0.f, 0.f);
GetWorld()->SpawnActor<AActor>(WallActorClass, BottomPos, FRotator(0.f, 0.f, 0.f));
}
// 외곽 Left/Right
for (int32 Y = 0; Y < MazeHeight; ++Y)
{
FVector LeftPos = MazeOrigin + FVector(0.f, Y * CellSize + 0.5f * CellSize, 0.f);
GetWorld()->SpawnActor<AActor>(WallActorClass, LeftPos, FRotator(0.f, 90.f, 0.f));
FVector RightPos = MazeOrigin + FVector(MazeWidth * CellSize, Y * CellSize + 0.5f * CellSize, 0.f);
GetWorld()->SpawnActor<AActor>(WallActorClass, RightPos, FRotator(0.f, 90.f, 0.f));
}
// 셀 내부 벽
for (int32 Y = 0; Y < MazeHeight; ++Y)
{
for (int32 X = 0; X < MazeWidth; ++X)
{
const FMazeCell& Cell = MazeData[Y][X];
FVector Base = MazeOrigin + FVector(X * CellSize + 0.5f * CellSize, Y * CellSize + 0.5f * CellSize, 0.f);
if (Cell.bWallRight && X < MazeWidth - 1)
{
FVector Pos = Base + FVector(CellSize * 0.5f, 0.f, 0.f);
GetWorld()->SpawnActor<AActor>(WallActorClass, Pos, FRotator(0.f, 90.f, 0.f));
}
if (Cell.bWallBottom && Y > 0)
{
FVector Pos = Base + FVector(0.f, -CellSize * 0.5f, 0.f);
GetWorld()->SpawnActor<AActor>(WallActorClass, Pos, FRotator(0.f, 0.f, 0.f));
}
}
}
}
미로 생성 알고리즘을 통해 어디에 벽을 스폰해야할지 TArray<TArray<FMazeCell>> MazeData에 저장되어있다.
이를 가지고 실제로 월드에 벽을 스폰하는데 먼저 외각 벽을 스폰하여 테두리를 만든 뒤에 내부에서 우측과 하단 벽을 채워가며 벽이 중복으로 생성되지 않게 하였다.
FVector TopPos = MazeOrigin + FVector(X * CellSize + 0.5f * CellSize, MazeHeight * CellSize, 0.f);
계산이 다소 복잡해보여 외각 Top벽을 예로 설명하겠다.
X * CellSize(X번째 셀의 시작 위치) + 0.5f * CellSize(벽 액터의 루트가 벽의 중앙이므로 셀 중앙 좌표로 오프셋 추가)
MazeHeight * CellSize는 미로의 제일 윗줄 위치를 의미한다.
🔹 SpawnGoalActor( ): 골인 지점 배치
void AMazeGenerator::SpawnGoalActor()
{
if (!HasAuthority() || !GoalActorClass) return;
FVector MazeOrigin = GetActorLocation();
FVector GoalLocation = MazeOrigin + FVector(
GoalPoint.X * CellSize + 0.5f * CellSize,
GoalPoint.Y * CellSize + 0.5 * CellSize,
10.0f);
GetWorld()->SpawnActor<AActor>(GoalActorClass, GoalLocation, FRotator::ZeroRotator);
}
GenerateMaze 함수에서 DFS로 가장 깊이 탐색된 셀의 좌표를 FIntPoint GoalPoint에 넣어두었었는데,
그 위치에 Overlap을 감지하는 액터를 스폰시켜 주었다.
🔔 미로 생성 결과
📌 게임 전체 흐름에 따른 각 클래스의 역할
서버 시작 시 → 미로 생성기 동작
-------------------------------------
AMazeGenerator::InitMazeData()
→ 미로 셀 초기화 (FMazeCell 2D 배열)
AMazeGenerator::GenerateMaze()
→ 재귀 백트래킹 알고리즘으로 미로 생성
→ 가장 먼 지점을 GoalPoint 위치로 저장
AMazeGenerator::SpawnMazeActors(), SpawnGoalActor()
→ 월드에 벽과 골인 액터 생성
→ 시작 위치를 AEFM_GameState에 저장
플레이어 접속 → 서버 처리
---------------------------
AEscapeFromMazeGameMode::PostLogin()
→ 일정 시간 딜레이 후 AssignStartLocation() 호출
→ GameState의 StartLocation 기반으로 위치 계산
→ AEscapeFromMazeCharacter::SetActorLocation() 또는 Client_MoveToStart() 호출
클라이언트에서 닉네임 입력
----------------------------
UMG 위젯 → OnTextCommitted()
→ AEFM_PlayerController::Server_SetPlayerNickname()
→ AEFM_PlayerState::SetPlayerNickname() → Replicated
→ 클라들에 이름 동기화
골 도달 시 처리
----------------------------
AGoalPoint::NotifyActorBeginOverlap()
→ AEscapeFromMazeGameMode::EndGame() 호출
→ 승자 이름 모든 클라이언트에 전달
→ AEFM_PlayerController::Client_ReachedGoal() → HUD 업데이트
게임 종료
---------
AEscapeFromMazeGameMode::ExitGame()
→ 모든 클라이언트의 AEFM_PlayerController::Client_ExitGame() 호출
→ 레벨 종료 또는 게임 종료 처리
이 흐름을 바탕으로 주요 클래스의 일부 함수를 통해 어떤 흐름으로 동작하는지 살펴보자.
📍GameMode
서버에만 존재하며, 게임 전체 로직을 통제하는 역할을 한다.
1. 플레이어가 접속했을 때 위치를 정해줌
2. 누가 승자인지 판단 후 모든 플레이어에게 게임 결과를 전달
3. 종료 조건에 맞춰 게임을 종료
🔹 PostLogin(APlayerController* NewPlayer)
void AEscapeFromMazeGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
FTimerHandle DelayHandle;
FTimerDelegate DelayDelegate;
DelayDelegate.BindUObject(this, &AEscapeFromMazeGameMode::AssignStartLocation, NewPlayer);
GetWorld()->GetTimerManager().SetTimer(DelayHandle, DelayDelegate, 0.3f, false);
}
플레이어가 들어오면 AssignStartLocation( )을 딜레이 걸고 호출한다.
이 함수는 동일한 위치에 플레이어가 스폰되어 곂치거나 위에 쌓이는 현상을 없애도록 플레이어마다의 위치를 약간의 Offset(기준점을 바탕으로 원형으로 퍼트림)을 준 위치로 이동시킨다.
바로 위치를 잡지 않고 타이머로 약간의 딜레이를 준 이유는 Pawn과 상태가 완전히 초기화될 시간을 주기위해서이다.
딜레이 없이 AssignStartLocation( )를 호출했었는데, 뒤늦게 로그인한 플레이어는 미로 바깥의 엉뚱한 위치로 이동되는 버그가 발생했었다. 이를 해결하기 위해 딜레이를 준 것.
🔹 AssignStartLocation(APlayerController* NewPlayer)
void AEscapeFromMazeGameMode::AssignStartLocation(APlayerController* NewPlayer)
{
static int32 PlayerIndex = 0;
if (AEFM_GameState* MazeGameState = GetGameState<AEFM_GameState>())
{
const FVector BaseLocation = MazeGameState->StartLocation;
const float Radius = 30.0f;
const float AngleStep = 45.0f;
float Angle = FMath::DegreesToRadians(AngleStep * PlayerIndex);
FVector Offset = FVector(FMath::Cos(Angle), FMath::Sin(Angle), 0.0f) * Radius;
FVector FinalLocation = BaseLocation + Offset;
FinalLocation.Z = 50.0f;
if (AEscapeFromMazeCharacter* Player = Cast<AEscapeFromMazeCharacter>(NewPlayer->GetPawn()))
{
Player->SetActorLocation(FinalLocation);
}
PlayerIndex++;
}
}
static int32 PlayerIndex: 함수가 호출될 때마다 접속한 플레이어 수를 누적 저장하기 위함
const FVector BaseLocation: MazeGenerator에서 설정한 시작위치를 GameState에서 가져옴
const float Radius: 플레이어들 간의 간격 반지름(거리), 이 거리만큼 중심에서 퍼뜨려서 배치함
const float AngleStep: 각 플레이어 간의 각도 차
float Angle: 라디안으로 변환해 저장
FVector Offset = FVector(Cos(Angle), Sin(Angle), 0.0f) * Radius: 각 플레이어마다 기준 위치에서 Radius만큼 떨어진 위치를 가리키는 벡터
기준점에 Offset을 더하고, 지면으로 부터 Z축 높이를 증가해 지면에 밖히지 않는 위치를 최종 위치로 결정하고, 그위치에 플레이어를 이동시킨다.
📍PlayerController
1. UI에서 닉네임을 입력받아 서버(PlayerState)에 전달
2. 서버에서 전달된 승자 정보를 받아서 HUD에 업데이트
3. 서버가 종료 명령을 내리면 클라이언트 종료 처리
🔹 OnInputCommitted(const FText& Text, ETextCommit::Type CommitMethod)
void AEFM_PlayerController::BeginPlay()
{
Super::BeginPlay();
if (IsLocalController() && NameInputWidgetClass)
{
NameInputWidgetInstance = CreateWidget<UUserWidget>(this, NameInputWidgetClass);
if (NameInputWidgetInstance)
{
NameInputWidgetInstance->AddToViewport();
bShowMouseCursor = true;
SetInputMode(FInputModeUIOnly());
InputGuideText = Cast<UTextBlock>(NameInputWidgetInstance->GetWidgetFromName(TEXT("InputGuideText")));
InputTextBox = Cast<UEditableTextBox>(NameInputWidgetInstance->GetWidgetFromName(TEXT("InputTextBox")));
if (InputTextBox)
{
InputTextBox->OnTextCommitted.AddDynamic(this, &AEFM_PlayerController::OnInputCommitted);
}
}
}
}
void AEFM_PlayerController::OnInputCommitted(const FText& Text, ETextCommit::Type CommitMethod)
{
if (!IsLocalController()) return;
if (CommitMethod == ETextCommit::OnEnter && !Text.IsEmpty())
{
Server_SetPlayerNickname(Text.ToString());
if (NameInputWidgetInstance)
{
NameInputWidgetInstance->RemoveFromParent();
NameInputWidgetInstance = nullptr;
}
if (HUDWidgetClass)
{
HUDWidgetInstance = CreateWidget<UUserWidget>(this, HUDWidgetClass);
HUDWidgetInstance->AddToViewport();
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
if (HUDWidgetInstance)
{
HUDText = Cast<UTextBlock>(HUDWidgetInstance->GetWidgetFromName(TEXT("HUDText")));
PlayerNameText = Cast<UTextBlock>(HUDWidgetInstance->GetWidgetFromName(TEXT("PlayerNameText")));
if (HUDText)
{
HUDText->SetText(FText::FromString(TEXT("미로를 탈출하려면 트로피를 찾으세요!")));
}
if (PlayerNameText)
{
PlayerNameText->SetText(Text);
}
FTimerHandle HiddenTextTimer;
FTimerDelegate HiddenTextDelegate;
HiddenTextDelegate.BindUObject(this, &AEFM_PlayerController::SetVisibleText, HUDText, false);
GetWorld()->GetTimerManager().SetTimer(HiddenTextTimer, HiddenTextDelegate, 4.0f, false);
}
}
}
}
1. ETextCommit 이 Enter 키를 누른 것인지 확인(Build.cs에 "SlateCore" 모듈 추가해야함)
2. Server_SetPlayerNickname(const FString& NewName)으로 서버에 입력된 닉네임 전달
3. 입력 관련 위젯을 뷰포트에서 제거하고, HUD 관련 위젯으로 뷰포트 전환
🔹 Server_SetPlayerNickname_Implementation(const FString& NewName)
void AEFM_PlayerController::Server_SetPlayerNickname_Implementation(const FString& NewName)
{
if (AEFM_PlayerState* EFM_PlayerState = GetPlayerState<AEFM_PlayerState>())
{
EFM_PlayerState->SetPlayerNickname(NewName);
}
}
Server RPC로 PlayerState의 SetPlayerNickname(NewName)으로 닉네임 전달
📍PlayerState
PlayerState는 서버와 클라이언트 모두 존재한다.
PlayerState는 서버에서 관리되고, 모든 클라이언트에 자동으로 복제(Replication) 된다. 자기 자신 뿐만 아니라 다른 클라이언트의 PlayerState도 복제된다는 얘기이다.
다른 플레이어의 상태(이름, 점수 등)를 모든 클라이언트가 공유해야 팀원 이름을 UI에서 볼 수도 있고 점수판 UI로 팀원들의 점수도 알 수 있기 때문에 게임 내에서 모든 플레이어의 공용 정보를 담는 역할이라고 볼 수 있다.
UCLASS()
class ESCAPEFROMMAZE_API AEFM_PlayerState : public APlayerState
{
GENERATED_BODY()
public:
UPROPERTY(Replicated)
FString PlayerNickname;
void SetPlayerNickname(const FString& NewName);
FString GetPlayerNickname() const { return PlayerNickname; }
protected:
virtual void GetLifetimeReplicatedProps(TArray< FLifetimeProperty >& OutLifetimeProps) const override;
};
#include "EFM_PlayerState.h"
#include "EFM_PlayerController.h"
#include "Net/UnrealNetwork.h"
void AEFM_PlayerState::SetPlayerNickname(const FString& NewName)
{
if (HasAuthority())
{
PlayerNickname = NewName;
}
}
void AEFM_PlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AEFM_PlayerState, PlayerNickname);
}
1. 서버 PlayerController에서 SetPlayerNickname 함수를 호출해 string PlayerNickname에 저장
2. PlayerNickname 변수는 Replicated 선언되어 자동으로 서버와 클라 모두 동기화
3. Replicated된 변수라고 GetLifetimeReplicatedProps 함수에서 DOREPLIFETIME으로 언리얼에게 알림(Net/UnrealNetwork.h 헤더 필요)
4. GetPlayerNickname() 게터 함수로 나중에 승자가 누구인지 이름을 가져올 때 쓸 게터함수
🔔 데디케이티드 서버로 패키징
완성된 미로게임을 데디케이티드 서버로 빌드 및 패키징하였다. 패키징 방법은 아래 링크 참조.
🔎데디케이티드 서버 패키징 방법 링크
언리얼 엔진 패키징(데디케이티드 서버 빌드)
📌 데디케이티드 서버용 빌드 가능한 환경 만들기에픽 게임 런처로 설치한 언리얼 엔진은 기본적으로 데디케이티드 서버 빌드를 지원하지 않는다.에픽 런처 버전은 전체 C++ 소스코드가 포함되
dong-grae.tistory.com
💾Git 링크
GitHub - Dongry-96/EscapeFromMaze
Contribute to Dongry-96/EscapeFromMaze development by creating an account on GitHub.
github.com
'내배캠 > Unreal Engine' 카테고리의 다른 글
언리얼 엔진 패키징(데디케이티드 서버 빌드) (0) | 2025.03.21 |
---|---|
채팅으로 하는 숫자 야구 게임 구현(Listen Server) (0) | 2025.03.19 |
언리얼 네트워크와 객체 통신 (0) | 2025.03.12 |
온라인 게임과 네트워크 구성 (0) | 2025.03.11 |
네트워크 개념 (0) | 2025.03.10 |