TIL_058: 언리얼 4대 Framework

김펭귄·2025년 11월 4일

Today What I Learned (TIL)

목록 보기
58/109

오늘 학습 키워드

  • 언리얼 엔진의 4대 Framework

1. 언리얼 엔진의 4대 Framework

  • 객체지향적을 추구하는 언리얼 엔진은 크게 4가지의 Framework를 제공하여 우리가 객체들의 구조를 짜는데 도움을 줌
  1. GameMode : 게임의 심판관 (규칙, 흐름 제어, 승부 판정 등 게임 흐름을 결정)
  2. GameState : 게임 상황판 (모든 플레이어가 보는 공통 정보만을 저장)
  3. PlayerState : 개인 수첩 (각 플레이어만의 정보, 데이터만을 저장)
  4. GameInstance : 전체 게임 데이터 관리자 (전체 생명주기, 레벨 간 데이터)
  • 이렇게 언리얼은 단일책임원칙을 준수하도록 기본틀을 제공함

2. GameMode

  • 게임모드로는 이전에 공부했듯이, GameModeBaseGameMode 2개가 존재

  • GameModeBase는 게임이 돌아가기 위한 정말 기초적인 규칙들만 존재

  • GameMode는 매칭 시스템, 관전자 등 추가적인 기능들을 제공

    • EMatchState같은 StateMachine으로 매치 상태에 따라 따로 구현할 필요 없이 편하게 사용가능하도록 기능 제공
    enum class EMatchState {
        EnteringMap,       // 맵 진입 중
        WaitingToStart,    // 시작 전 로비 단계
        InProgress,        // 실제 진행 중
        WaitingPostMatch,  // 종료 후 여운 (리플레이, 결과 등)
        LeavingMap         // 맵 전환 중
    };
    • 리스폰 기능을 통해 DefaultPawnClass를 다시 생성하고 컨트롤러에 연결해주는 기능도 제공

    • GameModeBase는 이런 기능들 직접 구현해야함

  • GameModeBaseGameStateBase로 사용해야하고, GameModeGameState를 같이 사용해야함

  • 게임을 관리하는 유일한 관리자이므로 서버에 1개만 존재하고, 클라이언트에는 존재하지 않음

  • 결정하는 곳이므로, 데이터를 저장하지 않음. 결정로직만 구현되어 있고 결정하면서 생긴 데이터는 GameState에 저장

void AMyGameMode::StartMatch()
{
    // 1. GameMode는 "결정"을 내리고
    bMatchInProgress = true;

    // 2. GameState에게 "정보 업데이트"를 위임
    if (AMyGameState* GS = GetGameState<AMyGameState>())
    {
        GS->NotifyMatchStarted(MatchDuration);
    }

    // ... //
}

void AMyGameMode::EndMatch(bool bPlayerWon)
{
    bMatchInProgress = false;

    // GameState에 결과 통보
    if (AMyGameState* GS = GetGameState<AMyGameState>())
    {
        GS->NotifyMatchEnded(bPlayerWon);
    }

    // 다음 단계로 진행 (예: GameInstance로 레벨 전환 요청)
    if (bPlayerWon)
    {
        if (UGameInstance* GI = GetGameInstance())
        {
            UE_LOG(LogTemp, Log, TEXT("GameInstance로 다음 레벨 요청"));
        }
    }
}

3. GameState

  • GameMode에서의 데이터를 기록하는 전광판 역할

  • 서버에 한 개 존재하여 게임 데이터를 관리하고, 각 클라이언트로 복제되어 읽기 접근 허용. 모든 클라이언트가 같은 정보를 볼 수 있도록 공유함

  • 그래서 UI에서는 GameMode가 아니라 GameState에서 정보를 가져와야하고, 클라이언트에서는 GameState를 수정할 수 없고 서버에서만 수정/관리해야함

헤더

UCLASS()
class AMyGameState : public AGameStateBase
{
    GENERATED_BODY()

public:
	// 콜백 등록
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_RemainingTime)
    float RemainingTime = 0.0f;

    // GameMode에서 동작 위임한 함수
    UFUNCTION()
    void NotifyMatchStarted(float Duration);

protected:
    // 서버에서 RemainingTime이 갱신될 때 클라에서 자동 호출됨
    UFUNCTION()
    void OnRep_RemainingTime();

private:
    float InitialDuration = 0.0f;
    FTimerHandle CountdownHandle;
};

ReplicatedUsing=함수이름

  • 해당 변수가 네트워크에서 복제(Replication)될 때, 즉 서버에서 값이 바뀌어서 클라이언트로 값이 전달되는 순간 해당 함수를 클라이언트에서 자동으로 호출

  • 서버에서 로직을 처리하고 변경된 값이 GameState에 반영되면, 이 값을 클라이언트에서 UI 업데이트 등 후속처리를 하게 함

  • 서버에서는 호출 안 하고, 클라이언트에서만 호출하여 UI 업데이트 등을 함

  • 같은 객체에 존재하는 함수에만 사용가능

cpp

// 서버가 타이머를 관리
void AMyGameState::NotifyMatchStarted(float Duration)
{
    if (GetLocalRole() != ROLE_Authority) return; // 서버만 실행
    InitialDuration = Duration;
    RemainingTime   = Duration;
    
    // 1초마다 RemainingTime 감소시키고 자동 복제
    GetWorldTimerManager().SetTimer(
        CountdownHandle,
        [this]()
        {
    		// 모든 클라이언트도 동일한 data 받음
            RemainingTime = FMath::Max(RemainingTime - 1.f, 0.f);
            if (RemainingTime <= 0.f)
                GetWorldTimerManager().ClearTimer(CountdownHandle);
        },
        1.0f, true
    );
}

// Remaining Time 수정될 때마다 자동 콜백됨
void AMyGameState::OnRep_RemainingTime()
{
    // UI 업데이트 신호를 던질 수도 있음
    OnTimeUpdated.Broadcast(RemainingTime);
}
  • 그래서 정리해보자면, 서버에서 게임모드가 게임 흐름을 결정하고, 그에 따라 게임스테이트가 상태를 저장. 모든 핵심로직은 서버에서 담당하여 클라이언트에게는 정보 전달만 해줌

4. PlayerState

  • 플레이어 개개인의 정보를 가지는 객체(닉네임, id, 랭킹..)

  • 레벨 전환될 때마다 사라지고 생기는 GameMode, GameState와 달리 이 객체는 계속 유지되어 사라지지 않음

소유 계층 구조

GameMode (서버만 존재)

PlayerController (각 유저마다 1개, 서버+클라 공존)

PlayerState (서버+모든 클라 복제)

  • 게임모드가 컨르롤러를, 컨트롤러가 PlayerState를 소유

  • 따라서 PlayerState를 가져오려면 지금 내 플레이어컨트롤러가 소유하고 있는 PlayerState를 가져오라고 해야함. Pawn에서 가져오는 것이 아니라.
    Pawn은 컨트롤러의 입력에 따라 동작하는 단순한 마네킹일 뿐이다

// 현재 컨트롤러에서 가져오는 함수
AMyPlayerState* PS = GetPlayerState<AMyPlayerState>();

관리, 수정하는 법

  • 얘도 마찬가지로, 서버의 GameState에서 모든 플레이어의 원본 PlayerState 인스턴스를 가지고 관리함

  • 따라서 서버에서 관리하고 수정해야하지, 클라이언트에서 하면 적용도 안 되고 하면 안 됨

  • 게임 모드에서 접근해서 PlayerState를 수정해야하는 것이 올바른 방법

// ❌ 잘못된 접근 (클라에서 직접 수정)
AMyPlayerState* PS = GetWorld()->GetGameState()->PlayerArray[0];

// ✅ 올바른 방식 (클라이언트에서 서버에게 요청하기)	
MyPlayerState->Server_AddScore(50);

헤더

// MyPlayerState.h
UCLASS()
class AMyPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
	// 값 수정되어 복제되면 콜백함수 호출하여 UI 동기화
    UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Score)
    int32 CurrentScore = 0;

    UPROPERTY(BlueprintReadOnly, ReplicatedUsing=OnRep_Lives)
    int32 Lives = 3;

    // 안전한 데이터 변경 (서버 전용 RPC)
    UFUNCTION(Server, Reliable)
    void Server_AddScore(int32 Points);

    UFUNCTION(Server, Reliable)
    void Server_TakeDamage(int32 Amount);

    UFUNCTION(BlueprintPure)
    bool IsGameOver() const { return Lives <= 0; }

    // UI 업데이트용 Delegate
    DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnScoreChanged,
    											 int32, Old, 
                                                 int32, New);
    UPROPERTY(BlueprintAssignable)
    FOnScoreChanged OnScoreChanged;

protected:
	// 클라이언트에서 자동 호출되는 콜백함수
    UFUNCTION()
    void OnRep_Score();

    UFUNCTION()
    void OnRep_Lives();

private:
    int32 HighScore = 0;
};
  • UFUNCTION(Server, Reliable) : 서버 전용 RPC
    클라이언트가 직접 실행하는게 아니라, 서버에게 요청할 때만 서버에서 실행됨

  • delegate를 내부에 선언하면 해당 클래스의 일부 멤버처럼 취급되어 쉽게 캡슐화되며, 외부에서는 인스턴스를 통해서만 접근할 수 있다.
    또한, public, private, protected로 어떻게 선언했느냐에 따라 허용 접근 범위도 달라짐

  • 외부 선언된 델리게이트는 여러 클래스 또는 모듈 간에 공통으로 사용되는 시그니처를 정의하는 데 주로 쓰이며, 이를 통해 코드 재사용성과 일관성을 높임

cpp

// MyPlayerState.cpp
void AMyPlayerState::Server_AddScore_Implementation(int32 Points)
{
    if (Points <= 0) return;
    int32 Old = CurrentScore;
    CurrentScore += Points;

    if (CurrentScore > HighScore)
        HighScore = CurrentScore;

    // GameMode에 승리 조건 검사 요청
    if (AMyGameMode* GM = GetWorld()->GetAuthGameMode<AMyGameMode>())
        GM->CheckVictoryCondition();
}

void AMyPlayerState::OnRep_Score()
{
	// 클라에서 호출하고, delegate이용하여 broadcast
    OnScoreChanged.Broadcast(CurrentScore, CurrentScore);
}

void AMyPlayerState::Server_TakeDamage_Implementation(int32 Amount)
{
    Lives = FMath::Max(Lives - Amount, 0);
}

void AMyPlayerState::OnRep_Lives()
{
    UE_LOG(LogTemp, Verbose, TEXT("클라: 남은 목숨 %d"), Lives);
}
  • 결국 PlayerState는 각 플레이어의 정보를 저장하면서, 정보바뀌면 UI에게 전달하여 클라이언트 화면에 띄어주는 역할

5. GameInstance

  • 언리얼에 존재하는 모든 시스템 위에 존재하는 중간에 사라지지 않는 관리자

  • GameInstance는 게임이 종료될때까지 유지되어야하는 데이터를 관리하고, GameMode는 각 레벨별로 게임 흐름을 관리

  • 레벨 전환, 세션 관리, 총합 점수 등을 관리

헤더

UCLASS()
class UMyGameInstance : public UGameInstance
{
public:
    virtual void Init() override;
    
    // 영속적 데이터 (레벨 바뀌어도 절대 안 사라짐)
    UPROPERTY(BlueprintReadWrite)
    FString PlayerName = TEXT("Player");
    
    UPROPERTY(BlueprintReadWrite)
    int32 TotalScore = 0;           // 전체 누적 점수
    
    // 레벨 전환 중앙 관리
    UFUNCTION(BlueprintCallable)
    void LoadGameLevel(int32 LevelIndex);
};

cpp

void UMyGameInstance::LoadGameLevel(int32 LevelIndex)
{
    if (bIsChangingLevel) return; // 중복 방지
    
    bIsChangingLevel = true;
    CurrentLevelIndex = LevelIndex;
    
    // 레벨 전환 전 백업하기
    if (APlayerController* PC = GetWorld()->GetFirstPlayerController()) {
        if (AMyPlayerState* PS = PC->GetPlayerState<AMyPlayerState>())
            TotalScore += PS->GetCurrentScore();
    }
    
    // 실제 레벨 로딩
    FString LevelName = FString::Printf(TEXT("Level_%d"), LevelIndex);
    UGameplayStatics::OpenLevel(GetWorld(), FName(*LevelName));
}
  • 레벨 전환, 백업 같이 레벨이 전환되어도 유지되어 필요한 역할을 해줌

6. Framework 로딩 순서

게임 시작

🎮 엔진 부팅 단계
1. UEngine 초기화
2. UGameInstance 생성 → Init() 호출 ⭐ 가장 먼저!
   - 이 시점: World 없음 / GameMode 없음
   - 가능: 글로벌 설정, 서브시스템 초기화, SaveGame 로드
   - 불가: GetWorld(), UI 생성, GameMode 접근

🌍 첫 레벨 로딩 단계(BeginPlay 아님!)
3. World 생성 (Persistent Level 포함)
4. GameModeBase 생성 → InitGame() 호출
5. GameStateBase 생성 (GameMode 내부에서 스폰)
6. PlayerController 생성
7. PlayerState 생성 (Controller 소유)
8. Pawn 생성 → Controller 가 Possess()

9. 모든 BeginPlay() 호출 (순서 보장 ❌)
   - 대상: GameMode, GameState, PlayerController,
            PlayerState, Pawn, 기타 모든 Actor
   - ⚠ 의존성 있는 접근은 주의!

레벨 전환 시

=== 현재 레벨 정리 단계 ===
1. 모든 Actor의 EndPlay() 호출
2. GameMode / GameState 파괴
3. 일반 Actor 파괴
4. PlayerState 데이터 보존 ⭐ (실제 복사 이주)

=== 새 레벨 로딩 단계 ===
5. 새 World 생성
6. PlayerState 데이터가 새 인스턴스로 복사 ⭐ (CopyProperties)
7. 새 GameMode / GameState 생성
8. PlayerController가 새 PlayerState에 재연결
9. 새 Pawn 생성
10. 모든 BeginPlay() 호출
  • PlayerState는 새 인스턴스에 복사되는거라 계속 유지된다고 표현하는 것. 완전히 같은 객체는 아니었음

  • OpenLevel로 새 레벨을 여는 도중에, GameInstance를 제외한 나머지 것들은 새로 생기는 과정이므로, BeginPlay()전까지는 접근하지 않는 것이 좋음. GameInstance에만 접근하기

각 단계에서 가능/불가능 한 것

GameInstance::Init()단계
// ✅ 가능
- 글로벌 설정 로드
- 서브시스템 초기화
- SaveGame 데이터 로드

// ❌ 금지
- GetWorld() 호출 (아직 World 없음)
- GameMode / PlayerController 접근
- UI 생성
GameMode 생성자 단계
// ✅ 가능
- 기본 클래스 설정(DefaultPawn, GameState설정..)
- 게임 룰 변수 초기화

// ❌ 금지
- GameState 접근 (아직 없음)
- PlayerController 접근
GameMode::PostInitializeComponents() 단계
// ✅ 이 시점부터 GameState 접근 가능
// ⚠ PlayerController / PlayerState는 아직 안 생김

if (AMyGameState* GS = GetGameState<AMyGameState>()) {
	if (GS->HasBegunPlay())	{
		GS->....; 	// 접근 가능
	}
}
  • BeginPlay() : 랜덤으로 실행되므로, 의존 객체에 접근 금지

  • PostLogin() : 모든 것이 준비된 상태로 다른 객체에 접근하는데에 문제 없음

위험도함수설명
🔴 높음생성자의존성 객체 아직 없음
🟠 중간PostInitializeComponentsGameState 정도는 존재
⚠ 주의BeginPlay호출 순서 비보장 → 의존성 접근 주의
🟢 안전PostLoginPlayerController / PlayerState 모두 준비됨
🟢 안전StartPlay모든 BeginPlay 이후 완전 준비 상태
profile
반갑습니다

0개의 댓글