Unreal Engine에서 로컬 협동 슈팅 게임 프로토타입을 설계하는 방법 (단계별 가이드)
로컬 협동 멀티플레이어 게임의 프로토타입을 제작하는 것은 핵심 게임플레이 루프를 검증하는 가장 빠른 방법 중 하나입니다. 두 명의 플레이어가 같은 소파에 앉아 같은 화면을 공유할 때, 슈팅 메커니즘이 타격감이 있는지, 레벨 디자인이 팀워크를 장려하는지 즉각적으로 알 수 있습니다.
하지만 Unreal Engine에서 로컬 멀티플레이어 슈팅 게임을 구축하는 것은 숨겨진 아키텍처의 함정으로 가득합니다. 입력을 하드코딩하거나, UI를 "Player 0"에 결합하거나, 첫날부터 리플리케이션(replication) 원칙을 무시한다면, 주말 동안 빠르게 만든 프로토타입은 결국 온라인 멀티플레이어로 전환할 때 수백 시간의 리팩토링이 필요한 확장 불가능한 엉망진창이 될 것입니다.
몇 시간 만에 협동 슈팅 게임 프로토타입을 구축하는 최근의 커뮤니티 튜토리얼에서 영감을 받아, 이 가이드는 Unreal Engine에서 견고한 로컬 멀티플레이어 기반을 설계하기 위한 정확한 기술적 단계를 분석합니다. 프로그래밍 방식의 플레이어 스폰, 동적 공유 카메라, 그리고 카우치 협동에서 지속적인 온라인 멀티플레이어로 깔끔하게 확장할 수 있도록 데이터를 구조화하는 방법을 다룰 것입니다.
Step 1: Unreal Engine의 로컬 멀티플레이어 아키텍처 이해하기
코드를 작성하기 전에 Unreal Engine이 단일 머신에서 여러 플레이어를 처리하는 방식을 이해해야 합니다.
표준 싱글플레이어 게임에서는 하나의 UWorld를 보유하는 하나의 UGameInstance가 있으며, 그 안에는 하나의 ULocalPlayer가 포함됩니다. 해당 로컬 플레이어는 APlayerController에 빙의(possess)되며, 이는 다시 캐릭터인 APawn에 빙의합니다.
로컬 멀티플레이어에서는 계층 구조가 변경됩니다. UGameInstance는 싱글톤으로 유지되지만, 이제 ULocalPlayer 객체의 배열을 관리합니다. 각 ULocalPlayer는 고유한 APlayerController를 갖게 됩니다.
개발자들이 저지르는 가장 큰 실수는 GetWorld()->GetFirstPlayerController()가 게임 로직에 작동할 것이라고 가정하는 것입니다. 로컬 협동에서 인덱스 0에 의존한다는 것은 Player 2가 게임 상태, UI 업데이트 및 환경 트리거에서 완전히 무시된다는 것을 의미합니다.
Step 2: 프로그래밍 방식으로 로컬 플레이어 스폰하기
Unreal의 Project Settings에서 분할 화면(split-screen)을 활성화하고 두 번째 게임패드를 연결할 때 엔진이 플레이어를 자동 스폰하도록 할 수 있지만, 이러한 동작에 의존하면 스폰 프로세스, 캐릭터 선택 또는 로드아웃 할당에 대한 제어권이 전혀 없어집니다.
대신 AGameModeBase 내에서 플레이어 인스턴스화를 수동으로 처리해야 합니다.
다음은 두 번째 게임패드에서 "Start" 버튼을 누를 때 두 번째 로컬 플레이어를 동적으로 스폰하기 위한 견고한 C++ 구현입니다:
void ACoopGameMode::SpawnSecondPlayer()
{
// Ensure we are running on the server/authority
if (!HasAuthority())
{
return;
}
UGameInstance* GameInstance = GetWorld()->GetGameInstance();
if (!GameInstance)
{
return;
}
FString ErrorMessage;
// Create a new local player at index 1 (Player 2)
// The 'true' boolean tells the engine to spawn a PlayerController automatically
ULocalPlayer* NewLocalPlayer = GameInstance->CreateLocalPlayer(1, ErrorMessage, true);
if (NewLocalPlayer)
{
UE_LOG(LogTemp, Log, TEXT("Successfully spawned Player 2. Controller ID: %d"), NewLocalPlayer->GetControllerId());
// Optional: Force a specific spawn point for Player 2
APlayerController* PC = NewLocalPlayer->GetPlayerController(GetWorld());
if (PC && PC->GetPawn())
{
FVector P2SpawnLocation = FVector(100.0f, -100.0f, 50.0f);
PC->GetPawn()->SetActorLocation(P2SpawnLocation);
}
}
else
{
UE_LOG(LogTemp, Error, TEXT("Failed to spawn Player 2: %s"), *ErrorMessage);
}
}
CreateLocalPlayer를 통해 인스턴스화를 제어함으로써 스폰 프로세스를 가로채어 캐릭터 선택 화면을 기반으로 고유한 캐릭터 메시나 시작 무기를 할당할 수 있습니다.
Step 3: 공유 화면 카메라 수학 마스터하기
탑다운 또는 아이소메트릭 협동 슈팅 게임의 경우, 분할 화면은 종종 시각적 충실도를 망치고 플레이 영역을 제한합니다. Helldivers나 Diablo와 같은 게임에서 대중화된 동적 공유 카메라는 모든 활성 플레이어의 평균 위치를 계산하고 동적으로 축소하여 모든 플레이어를 단일 화면에 유지합니다.
이를 구축하려면 특정 플레이어에 연결되지 않은 전용 ACameraActor가 필요합니다. 대신 이 카메라는 매 프레임 틱(tick)을 수행하여 모든 활성 플레이어의 경계 상자(bounding box)를 찾습니다.
중심점과 동적 줌 길이를 계산하는 방법은 다음과 같습니다:
void ASharedCameraController::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
FVector AverageLocation = FVector::ZeroVector;
float MaxDistance = 0.0f;
int32 PlayerCount = 0;
// Iterate through all active player controllers
for (FConstPlayerControllerIterator Iterator = GetWorld()->GetPlayerControllerIterator(); Iterator; ++Iterator)
{
APlayerController* PC = Iterator->Get();
if (PC && PC->GetPawn())
{
FVector PlayerLoc = PC->GetPawn()->GetActorLocation();
AverageLocation += PlayerLoc;
PlayerCount++;
// Calculate distance to find the farthest player from the center
// (Requires a second pass in a real scenario, but simplified here for distance from origin)
float DistFromOrigin = PlayerLoc.Size();
if (DistFromOrigin > MaxDistance)
{
MaxDistance = DistFromOrigin;
}
}
}
if (PlayerCount > 0)
{
// Find the midpoint
AverageLocation /= PlayerCount;
// Smoothly interpolate the camera's target location
FVector NewLocation = FMath::VInterpTo(GetActorLocation(), AverageLocation, DeltaTime, 5.0f);
SetActorLocation(NewLocation);
// Dynamically adjust the SpringArm length based on player spread
// Assuming 'CameraSpringArm' is a valid USpringArmComponent pointer
float TargetZoom = FMath::Clamp(MaxDistance * 1.5f, 1000.0f, 3000.0f);
CameraSpringArm->TargetArmLength = FMath::FInterpTo(CameraSpringArm->TargetArmLength, TargetZoom, DeltaTime, 3.0f);
}
}
이 로직은 카메라가 액션을 부드럽게 추적하도록 보장합니다. 여기서 VInterpTo 및 FInterpTo 함수는 매우 중요합니다. 이 함수들이 없으면 플레이어가 사망하거나 리스폰될 때 카메라가 공격적으로 스냅되어 플레이어에게 심각한 멀미를 유발할 수 있습니다.
Step 4: "Player 0" UI 함정에서 살아남기
로컬 멀티플레이어 개발에서 가장 실망스러운 버그 중 하나는 사용자 인터페이스와 관련이 있습니다.
표준 Blueprint 노드인 Create Widget(또는 C++의 CreateWidget<UUserWidget>(GetWorld(), WidgetClass))을 사용하여 위젯을 생성할 때, Unreal은 기본적으로 첫 번째 로컬 플레이어(인덱스 0)에게 소유권을 할당합니다.
만약 Player 2가 탄약을 줍고 UI 로직이 Player 0이 소유한 HUD를 업데이트한다면, 잘못된 탄약 카운터가 깜박일 것입니다. 더 나쁜 것은 AddToViewport()를 사용하면 위젯이 전역적으로 렌더링되어 분할 화면 경계를 무시하거나 겹치는 경우가 많다는 것입니다.
이 문제를 해결하려면 위젯을 생성할 때 항상 특정 Player Controller를 소유 객체로 전달해야 합니다:
// CORRECT: Assigning ownership to the specific player
UUserWidget* PlayerHUD = CreateWidget<UUserWidget>(SpecificPlayerController, HUDWidgetClass);
// Use AddToPlayerScreen instead of AddToViewport for local multiplayer
PlayerHUD->AddToPlayerScreen();
AddToPlayerScreen()은 공유 카메라에서 분할 화면으로 전환하더라도 UI가 모니터의 해당 특정 플레이어 사분면에 올바르게 제한되도록 보장합니다.
Step 5: 페인 포인트 — 로컬 상태를 온라인 지속성으로 확장하기
로컬 멀티플레이어 프로토타입은 믿을 수 없을 정도로 기만적입니다. 두 플레이어가 동일한 머신의 동일한 메모리 공간에 존재하기 때문에 네트워크 지연 시간, 패킷 손실 또는 서버 권한(server authority)에 대해 걱정할 필요가 없습니다. Player 1의 투사체에서 Player 2의 체력을 직접 수정할 수 있습니다.
하지만 이 프로토타입을 온라인으로 전환하기로 결정하거나, 단순히 다른 플레이 세션에 걸쳐 플레이어 진행 상황(잠금 해제된 무기나 최고 점수 등)을 저장하고 싶어지는 순간 아키텍처는 무너집니다.
USaveGame 객체를 사용하여 플레이어 데이터를 로컬에 저장하면 해당 데이터는 물리적 머신에 종속됩니다. Player 2가 집에 가서 게임을 구매하면 진행 상황이 사라집니다. 이 문제를 해결하려면 플레이어 상태를 로컬 머신에서 분리하고 클라우드 백엔드로 이동해야 합니다.
이를 직접 구축하려면 로드 밸런서, 데이터베이스 샤딩 및 SSL 인증서 관리를 설정해야 하며, 안전한 플레이어 로그인 및 인벤토리 시스템을 실행하는 데만 쉽게 4~6주의 작업이 필요합니다. horizOn을 사용하면 이러한 Backend-as-a-Service가 사전 구성되어 제공되므로 인프라가 아닌 게임 출시에 집중할 수 있습니다.
개발 초기 단계에서 백엔드 API를 통해 플레이어 프로필, 로드아웃 및 세션 데이터를 라우팅함으로써 "Player 2"가 단순한 일시적인 로컬 게스트가 아니라 지속적인 데이터를 가진 인증된 사용자임을 보장할 수 있습니다. 온라인 매치메이킹을 구현할 준비가 되면 horizOn은 로컬 협동 플레이어를 더 넓은 온라인 세션으로 원활하게 전환하는 즉시 사용 가능한 로비 시스템을 제공합니다.
협동 프로토타이핑을 위한 모범 사례
프로토타입이 확장 가능하고 성능을 유지하도록 하려면 첫날부터 다음 아키텍처 규칙을 준수하십시오:
- 온라인인 것처럼 가장하기: 로컬 프로토타입만 구축하더라도 항상 Unreal Engine의 리플리케이션 프레임워크(
HasAuthority(),Server_RPC 및UPROPERTY(Replicated))를 사용하십시오. 첫날부터 로컬 머신을 Listen Server로 취급하면 나중에 멀티플레이어 리팩토링 시간을 최대 80%까지 줄일 수 있습니다. - Input Actions 격리하기: Enhanced Input System을 사용하여
UInputAction에셋을 하드웨어 버튼이 아닌 논리적 게임플레이 의도(예: "FireWeapon")에 매핑하십시오. 이를 통해 인덱스를 하드코딩하지 않고도 키보드/마우스를 Player 1에, 게임패드를 Player 2에 동적으로 다시 매핑할 수 있습니다. - 컨트롤러 연결 해제를 우아하게 처리하기: 항상
FCoreDelegates::OnControllerConnectionChange에 바인딩하십시오. Player 2의 컨트롤러가 꺼지면 캐릭터를 총격전 중에 가만히 서 있게 두는 대신 게임이 자동으로 일시 중지되고 재연결을 요청해야 합니다. - 투사체에 Instanced Static Meshes 사용하기: 협동 슈팅 게임에서는 연사력이 높은 무기를 발사하는 두 명의 플레이어가 초당 수백 개의 투사체를 스폰할 수 있습니다. 표준 Actor 기반 투사체를
UInstancedStaticMeshComponent또는 Niagara 파티클 시스템으로 교체하여 격렬한 전투 장면에서 드로우 콜(draw calls)을 ~2000에서 ~400으로 줄이십시오.
로컬 협동 슈팅 게임을 구축하는 것은 믿을 수 없을 정도로 보람 있는 기술적 과제입니다. 처음부터 플레이어 스폰, 카메라 수학 및 데이터 지속성을 올바르게 구조화함으로써 프로토타입이 본격적인 릴리스로 확장될 준비가 되었는지 확인할 수 있습니다.
Source: Community Tutorial: Coop Local Multiplayer Shooter Prototype