[AbyssDiver] 튜토리얼_TutorialManager

칼든개구리·2025년 7월 26일
0
post-thumbnail

안녕하세요! 지난번 포스팅에서는 AADTutorialGameMode를 통해 튜토리얼의 전체적인 흐름(Flow)로직(Logic)을 어떻게 관리하는지 살펴보았습니다.

하지만 GameMode가 튜토리얼의 흐름 제어뿐만 아니라 UI 표시까지 모두 책임지는 것은 좋은 설계가 아닙니다. 역할이 너무 많아지면 코드가 복잡해지고 유지보수가 어려워지기 때문입니다.

이번 포스팅에서는 관심사의 분리(Separation of Concerns) 원칙에 따라 튜토리얼의 표현(Presentation), 즉 UI를 전담하는 ATutorialManager 클래스를 어떻게 설계했는지 알아보겠습니다.


핵심 설계: 이벤트 기반 아키텍처
ATutorialManager의 가장 중요한 설계는 이벤트 기반(Event-Driven)으로 동작한다는 점입니다.

ATutorialManager는 매 프레임(Tick)마다 튜토리얼 상태가 바뀌었는지 스스로 확인하지 않습니다. (비효율적)

대신, GameState의 상태가 변경될 때 "상태가 바뀌었으니 너도 업데이트해!" 라는 신호(이벤트)를 받아서 동작합니다.

이러한 구조는 언리얼 엔진의 델리게이트(Delegate) 시스템을 통해 구현되며, 시스템의 결합도(Coupling)를 낮추고 성능 부하를 줄여주는 매우 효율적인 방식입니다

1. GameMode         2. GameState         3. TutorialManager      4. UI Widget
   (흐름 제어)          (상태 저장)            (UI 관리)             (표시)
      |                   |                      |                     |
      |-- 단계 변경 요청 -->|                   |                      |
      |                   |-- 상태 업데이트 &     |                      |
      |                   |   델리게이트 호출 --->|                      |
      |                   |                      |-- 신호 수신 &        |
      |                   |                      |   DataTable 조회 -> |
      |                   |                      |                      |-- 텍스트/힌트 업데이트
  1. 헤더 파일 (TutorialManager.h)
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "TutorialStepData.h"
#include "UI/TutorialHintPanel.h"
#include "TutorialManager.generated.h"

UCLASS()
class ABYSSDIVERUNDERWORLD_API ATutorialManager : public AActor
{
    GENERATED_BODY()
    
public:
    ATutorialManager();

protected:
    virtual void BeginPlay() override;

public:
    // GameState의 Phase가 변경될 때 호출될 함수 (델리게이트에 바인딩됨)
    UFUNCTION()
    void OnTutorialPhaseChanged(ETutorialPhase NewPhase);

protected:
    // 튜토리얼 UI 데이터를 담고 있는 데이터 테이블
    UPROPERTY(EditAnywhere, Category = "Tutorial")
    TObjectPtr<UDataTable> TutorialDataTable;

    // 자막과 힌트 패널 위젯을 생성하기 위한 클래스 정보
    UPROPERTY(EditAnywhere, Category = "Tutorial|UI")
    TSubclassOf<class UTutorialSubtitle> TutorialSubtitleClass;

    UPROPERTY()
    TObjectPtr<UTutorialSubtitle> SubtitleWidget;

    UPROPERTY(EditAnywhere, Category = "Tutorial|UI")
    TSubclassOf<UTutorialHintPanel> TutorialHintPanelClass;

    UPROPERTY()
    TObjectPtr<UTutorialHintPanel> TutorialHintPanel;
};

ATutorialManager는 AActor를 상속받아 레벨에 직접 배치할 수 있는 액터입니다. 헤더 파일에는 UI 위젯을 생성하기 위한 TSubclassOf 변수들과, 생성된 위젯 인스턴스를 담을 TObjectPtr, 그리고 GameState의 변화를 감지할 핵심 함수 OnTutorialPhaseChanged가 선언되어 있습니다.

  1. 소스 파일 (TutorialManager.cpp) - 초기화 및 이벤트 바인딩
void ATutorialManager::BeginPlay()
{
    Super::BeginPlay();

    // 1. 자막, 힌트 패널 위젯을 생성하고 뷰포트에 추가 (초기에는 숨김)
    if (TutorialSubtitleClass)
    {
        SubtitleWidget = CreateWidget<UTutorialSubtitle>(GetWorld(), TutorialSubtitleClass);
        if (SubtitleWidget)
        {
            SubtitleWidget->AddToViewport(20); // Z-Order 20
            SubtitleWidget->SetVisibility(ESlateVisibility::Hidden);
        }
    }
    // ... 힌트 패널도 동일하게 생성 ...

    // 2. GameState를 가져와 델리게이트에 함수를 바인딩
    if (AADTutorialGameState* TutorialGS = GetWorld()->GetGameState<AADTutorialGameState>())
    {
        // GameState의 OnPhaseChanged 이벤트가 발생하면,
        // 이 액터(this)의 OnTutorialPhaseChanged 함수를 호출하도록 등록(바인딩)
        TutorialGS->OnPhaseChanged.AddUObject(this, &ATutorialManager::OnTutorialPhaseChanged);

        // 3. 현재 튜토리얼 상태에 맞게 UI를 즉시 한 번 업데이트
        OnTutorialPhaseChanged(TutorialGS->GetCurrentPhase());
    }
}

BeginPlay의 흐름은 명확합니다.

UI 위젯 생성: 에디터에서 설정한 위젯 블루프린트(TSubclassOf)를 기반으로 실제 위젯 객체를 생성하고, 화면에 추가한 뒤 숨겨둡니다. Z-Order를 다르게 주어 UI가 겹칠 때의 순서를 정합니다.

델리게이트 바인딩: GameState의 OnPhaseChanged라는 델리게이트(이벤트 방송국)에 OnTutorialPhaseChanged라는 함수(구독자)를 등록합니다. 이제 GameState에서 OnPhaseChanged.Broadcast()를 호출할 때마다 ATutorialManager의 OnTutorialPhaseChanged 함수가 자동으로 실행됩니다.

초기 상태 동기화: 게임이 시작될 때의 튜토리얼 단계에 맞는 UI를 표시하기 위해, 바인딩 직후 현재 GameState의 단계로 함수를 한 번 직접 호출해줍니다.

  1. 소스 파일 (TutorialManager.cpp) - UI 업데이트 로직
    OnTutorialPhaseChanged 함수는 GameState로부터 신호를 받아 실제 UI를 업데이트하는 역할을 합니다.
void ATutorialManager::OnTutorialPhaseChanged(ETutorialPhase NewPhase)
{
    if (!TutorialDataTable) return;

    // GameState로부터 전달받은 NewPhase enum 값으로 Row 이름을 찾음
    const FString EnumAsString = UEnum::GetValueAsString(NewPhase);
    const FName RowName = FName(*EnumAsString.RightChop(EnumAsString.Find(TEXT("::")) + 2));
    
    // 데이터 테이블에서 해당 Row 검색
    const FTutorialStepData* StepData = TutorialDataTable->FindRow<FTutorialStepData>(RowName, TEXT("TutorialManager"));

    if (StepData) // 해당 단계에 대한 데이터가 있다면
    {
        // 자막 위젯에 텍스트를 설정하고 보이게 함
        if (SubtitleWidget)
        {
            SubtitleWidget->SetSubtitleText(StepData->SubtitleText);
            SubtitleWidget->SetVisibility(ESlateVisibility::Visible);
        }
        // 힌트 패널 위젯에 키 힌트를 설정
        if (TutorialHintPanel)
        {
            TutorialHintPanel->SetHintByKey(StepData->HintKey);
        }
    }
    else // 데이터가 없다면 (예: 튜토리얼 완료 단계)
    {
        // 모든 튜토리얼 UI를 숨김
        if (SubtitleWidget) SubtitleWidget->SetVisibility(ESlateVisibility::Hidden);
        if (TutorialHintPanel) TutorialHintPanel->SetVisibility(ESlateVisibility::Hidden);
    }
}

이 함수는 GameMode의 HandleCurrentPhase와 유사하게 동작하지만, 목적이 다릅니다. GameMode는 타이머를 설정하는 등 흐름 제어가 목적이었지만, TutorialManager는 DataTable에서 UI에 필요한 SubtitleText나 HintKey 같은 데이터를 찾아 위젯에 전달하는 UI 업데이트가 주된 목적입니다.


ATutorialManager를 별도의 액터로 분리함으로써 다음과 같은 이점을 얻었습니다.

  • 높은 응집도, 낮은 결합도: GameMode는 튜토리얼의 '규칙'에, TutorialManager는 '표현'에만 집중하여 각 클래스의 역할이 명확해졌습니다.

  • 유지보수의 용이성: UI 관련 수정이 필요할 때 TutorialManager와 위젯 블루프린트만 보면 되므로 작업이 단순해집니다.

  • 효율적인 동작: Tick을 사용하지 않고 이벤트 기반으로 동작하여 불필요한 성능 소모를 막았습니다.

profile
메타쏭이

0개의 댓글