[UE5] 추출 슈터 1-1. Gameplay Framework 아키텍처 설계

게시:     수정

카테고리:

태그: , ,

📌 EmploymentProj 1단계 Gameplay Framework의 첫 글입니다. 👾 깃허브 · 📋 기획 · 📚 시리즈 목차

개요

게임플레이의 뼈대를 세우는 글이다.

이 단계에서 실제로 결정한 것은 두 가지이다.

  1. 어느 베이스 클래스를 상속할 것인가: AGameModeAGameModeBase
  2. 어느 값을 클라이언트에게 보여줄 것인가: 서버 전용으로 둘 것과 GameState로 올릴 것

두 번째가 이 프로젝트의 성격을 결정한다. 타르코프류 추출 슈터라서 은폐가 기본값이고, 공개는 예외이다.


클래스 구조

---
config:
  layout: elk
  theme: dark
---
flowchart BT
    A3["AEPGameMode"] --> n2["AGameMode"]
    A4["AEPCharacter"] --> n1["ACharacter"]
    A5["AEPPlayerState"] --> n3["APlayerState"]
    A2["AEPPlayerController"] --> n4["APlayerController"]
    A1["AEPGameState"] --> n5["AGameState"]
    n2 --> n6["AGameModeBase"]
    n1 --> n7["APawn"]
    n7 --> n8["AActor"]
    n6 --> n9["AInfo"]
    n9 --> n8
    n3 --> n9
    n4 --> n11["AController"]
    n11 --> n8
    n5 --> n12["AGameStateBase"]
    n12 --> n9

     A3:::Aqua
     A4:::Aqua
     A5:::Aqua
     A2:::Aqua
     A1:::Aqua
    classDef Aqua stroke-width:1px, stroke-dasharray:none, stroke:#46EDC8, fill:#DEFFF8, color:#378E7A

AGameStateBaseAPlayerState가 둘 다 AInfo 계열이라는 게 눈에 띈다. AInfo월드에 위치를 갖지 않는 액터이다. “게임 정보를 들고 있지만 공간에 존재하지 않는 것”이라는 성격이 상속 구조에 그대로 드러난다.

클래스 역할
AEPCharacter 플레이어가 조종하는 존재. 위치·애니메이션
AEPGameMode 서버에만 존재. 게임 규칙, 매치 판정, 스폰 규칙
AEPGameState GameMode의 값 중 클라가 알아도 되는 것만 복제
AEPPlayerState 플레이어별 상태(킬 수, 추출 여부). 나중에 ASC도 여기
AEPPlayerController 입력, HUD, 서버 요청 송신

PlayerController가 어디에 존재하는지: 나중에 중요해진다

  서버 소유 클라 다른 클라
인스턴스 전원 분량 존재 자기 것 1개 없음
하는 일 Server_ RPC 수신, 권한 판정, RestartPlayer 입력, HUD, Server_ 송신 -

“클라이언트 것”이라고 생각하기 쉽지만 서버에도 플레이어 수만큼 있다. 없으면 Server_ RPC를 받을 대상이 없으니 당연하다.

이걸 지금 확실히 해두는 이유가 있다. 나중에 2-6편에서 크로스헤어 위젯을 만들 때 IsLocalController() 검사를 해야 하는데, 그 이유가 정확히 서버에도 PlayerController가 있어서 서버에서도 위젯이 생성되기 때문이다.


설계 결정 1: AGameMode vs AGameModeBase

AGameMode를 골랐다.

  AGameModeBase AGameMode
매치 상태 없음 MatchState + 내장 상태머신
StartPlay 정도 HandleMatchIsWaitingToStart / HandleMatchHasStarted / HandleMatchHasEnded
리스폰·스폰 있음 있음
딸려오는 전제 없음 bDelayedStart, ReadyToStartMatch(), 매치메이킹 흐름

고른 이유: 이 게임은 대기 → 진행 → 종료가 명확한 매치 기반이다. 상태머신을 직접 만들면 결국 엔진이 이미 가진 것을 다시 짜게 된다. 1-3편에서 저 훅 세 개를 전부 오버라이드하게 되는데, 그게 이 선택의 실질적인 회수이다.

대신 포기한 것: AGameMode는 매치메이킹을 전제한 클래스이다. bDelayedStart, ReadyToStartMatch() 같은 것들이 딸려온다. 싱글플레이나 로비 없는 코옵이었다면 AGameModeBase가 더 가볍다.


설계 결정 2: 무엇을 GameState로 승격할 것인가

먼저, “복제 경계”라는 표현은 정확하지 않다

AEPGameModeAlivePlayerCount를 클라이언트가 못 보는 건 내가 결정한 게 아니다. AGameModeBase는 애초에 클라이언트에 스폰되지 않는다. 복제할 방법 자체가 없다.

진짜 결정은 반대 방향이다.

GameMode가 아는 것 중 무엇을 GameState로 올릴 것인가.

올리지 않으면 자동으로 은폐되고, 올리면 전 클라이언트가 본다. 그래서 판단 기준은 하나이다. UI에 그려야 하는가.

어디에 판단
MatchPhase GameState (승격) 매치 상태 UI를 그려야 함
RemainingTime GameState (승격) 타이머 UI를 그려야 함
AlivePlayerCount GameMode (비승격) 남은 인원을 아는 순간 긴장감이 사라짐
스폰 포인트 사용 이력 GameMode (비승격) 클라가 알면 상대 스폰 위치 추론 가능

엔진이 이미 MatchState를 복제하는데 왜 하나 더 두는가

AGameStateBase가 아니라 AGameState를 상속하면 이게 딸려온다.

// Engine/Classes/GameFramework/GameState.h:34
UPROPERTY(ReplicatedUsing=OnRep_MatchState, BlueprintReadOnly, VisibleInstanceOnly, Category = GameState)
FName MatchState;

// Engine/Private/GameState.cpp:193
DOREPLIFETIME( AGameState, MatchState );

그럼에도 EEPMatchPhase MatchPhase를 따로 뒀다.

// EPGameState.h
UPROPERTY(ReplicatedUsing = OnRep_MatchPhase, BlueprintReadOnly, Category="Match")
EEPMatchPhase MatchPhase;
  엔진 FName MatchState 프로젝트 EEPMatchPhase
대역폭 FName (네트워크 GUID 경로) uint8 1바이트
타입 안전성 오타가 런타임에만 드러남 컴파일 타임에 잡힘
상태 수 엔진 6종(EnteringMap, WaitingToStart, InProgress, WaitingPostMatch, LeavingMap, Aborted) 게임에 필요한 3종만
UI 매핑 문자열 비교 switch

엔진의 6단계는 엔진의 관심사이다(레벨 스트리밍, 심리스 트래블). 게임 UI가 알아야 할 건 대기 중이냐 / 하는 중이냐 / 끝났냐 셋뿐이다. 그래서 엔진 상태머신은 그대로 쓰되, 클라에 내보내는 표현만 좁혔다.

전환 자체는 엔진 상태머신이 하고, 우리는 훅에서 열거형을 갱신만 한다. → 1-3편


설계 결정 3: 돈과 생존자 수

결정 A: 돈을 PlayerState의 숫자로 두지 않는다

돈을 인벤토리 아이템으로 둔다.

이유는 사망 규칙이다. 이 게임은 사망 시 소지품 전부 손실이 핵심 규칙인데, 돈이 PlayerStateint32 Money사망 처리 코드에 “돈도 0으로” 한 줄을 따로 써야 한다. 인벤토리 아이템이면 인벤토리가 사라질 때 같이 사라진다. 규칙이 한 곳에만 있다.

자판기 상호작용 가능 여부도 PlayerState.Money >= Price가 아니라 Inventory.HasItem("Cash", Price)로 판단한다.

결정 B: 생존자 수를 승격하지 않는다

배틀로얄은 남은 인원을 크게 띄운다. 추출 슈터는 반대이다. 맵에 누가 남았는지 모르는 것 자체가 긴장의 원천이다.

그래서 AlivePlayerCountAEPGameMode에만 둔다. 서버 전용 클래스에 두는 것만으로 은폐가 성립하므로, 별도 방어 코드가 필요 없다.


폴더 구조

Public/ / Private/ 를 나누고, 그 아래를 기능별로 갈랐다.

Source/EmploymentProj/
├── Public/
│   ├── Core/       # Character, GameMode, GameState, PlayerController, PlayerState
│   ├── Combat/     # CombatComponent, Weapon
│   ├── Movement/   # CharacterMovement
│   ├── Data/       # ItemDefinition, WeaponDefinition
│   └── Types/      # EPTypes.h
└── Private/        # Public 구조를 그대로 미러링

지금은 단일 모듈이라 Public/Private 분리의 실익이 크지 않다. 그럼에도 나눈 이유는 나중에 모듈을 쪼갤 때 헤더 노출 범위를 다시 정리하는 비용이 크기 때문이다. 지금 지키면 공짜고, 나중에 하면 전수 조사가 된다.


코드

EPTypes.h: 공용 열거형

UENUM(BlueprintType)
enum class EEPMatchPhase : uint8 { Waiting, Playing, Ended };

UENUM(BlueprintType)
enum class EEPItemRarity : uint8 { Common, Uncommon, Rare, Legendary };

UENUM(BlueprintType)
enum class EEPFireMode : uint8 { Single, Burst, Auto };

전부 uint8 기반이다. 복제되는 열거형은 밑바탕 타입이 곧 대역폭이다.

소유 클라이언트에게만 복제

// EPPlayerState.cpp
void AEPPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);

    // 킬 수는 공개 정보가 아니다: 본인에게만
    DOREPLIFETIME_CONDITION(AEPPlayerState, KillCount,    COND_OwnerOnly);
    DOREPLIFETIME_CONDITION(AEPPlayerState, bIsExtracted, COND_OwnerOnly);
}

GameMode → GameState 승격

// AEPGameMode::HandleMatchHasStarted()
// GameMode는 클라이언트에 존재하지 않으므로, 보여줄 값은 GameState에 얹는다
EPGameState->SetMatchPhase(EEPMatchPhase::Playing);

이 뼈대 위에 다음 편들이 얹는 것

이 글의 무엇 위에
1-2 입력 AEPPlayerController가 InputAction을 소유, AEPCharacter가 바인딩
1-3 매치 흐름 AGameMode 훅 3개 오버라이드 → EEPMatchPhase 갱신
1-4 스폰 AEPGameMode::ChoosePlayerStart 오버라이드
2-5 복제 설계 여기서 정한 “은폐가 기본” 원칙을 COND_* 선택으로 확장

다음 편

1-2. Enhanced Input으로 FPS 캐릭터 구현

DevLog 카테고리 내 다른 글 보러가기

댓글남기기