게임에서 튜토리얼은 플레이어가 게임의 세계에 자연스럽게 녹아들게 하는 첫 번째 관문입니다. 잘 만들어진 튜토리얼은 플레이어에게 긍정적인 첫인상을 심어주고, 게임의 핵심 메카닉을 효과적으로 전달합니다.
이번 포스팅에서는 저희가 개발 중인 Abyss Diver: Underworld의 튜토리얼 시스템을 담당하는 AADTutorialGameMode 클래스의 구조와 설계에 대해 자세히 살펴보겠습니다. 이 클래스는 상태 머신(State Machine) 패턴과 데이터 테이블(Data Table)을 활용하여 유연하고 확장 가능한 튜토리얼 흐름을 어떻게 관리하는지 보여주는 좋은 예시가 될 것입니다.
AADTutorialGameMode는 다음과 같은 핵심 설계 원칙을 기반으로 합니다.
상태 기반 흐름 제어: 튜토리얼의 각 단계를 ETutorialPhase라는 enum으로 정의하고, 현재 어떤 단계를 진행 중인지 GameState에 저장합니다. GameMode는 이 상태를 기반으로 적절한 로직을 호출하는 상태 머신의 역할을 합니다.
데이터 기반(Data-Driven) 설계: 튜토리얼의 각 단계에 필요한 텍스트, 표시 시간, 플레이어 액션 대기 여부 등의 상세 정보를 C++ 코드에 하드코딩하는 대신, DataTable에 정의합니다. 이를 통해 기획자가 코드를 수정하지 않고도 튜토리얼의 내용과 흐름을 쉽게 수정하고 테스트할 수 있습니다.
역할의 명확한 분리:
GameMode: 튜토리얼의 전체적인 흐름과 규칙을 관리합니다. (언제 다음 단계로 넘어갈지 결정)
GameState: 튜토리얼의 현재 상태(CurrentPhase)를 저장합니다. 모든 클라이언트가 이 상태를 공유해야 할 때 중요한 역할을 합니다.
PlayerController: 플레이어의 입력을 처리하고, 튜토리얼 UI를 화면에 표시하는 역할을 위임받습니다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameMode.h"
#include "Tutorial/TutorialEnums.h"
#include "ADTutorialGameMode.generated.h"
class UDataTable;
UCLASS()
class ABYSSDIVERUNDERWORLD_API AADTutorialGameMode : public AGameMode
{
GENERATED_BODY()
public:
AADTutorialGameMode();
// 게임 시작 시 호출
virtual void StartPlay() override;
// 다음 튜토리얼 단계로 진행
void AdvanceTutorialPhase();
protected:
// 현재 단계에 맞는 로직 처리
void HandleCurrentPhase();
// 각 단계별 핸들러 함수들
void HandlePhase_Movement();
void HandlePhase_Sprint();
void HandlePhase_Oxygen();
void HandlePhase_Radar();
void HandlePhase_Looting();
void HandlePhase_Inventory();
void HandlePhase_Drone();
void HandlePhase_LightToggle();
void HandlePhase_Items();
void HandlePhase_OxygenWarning();
void HandlePhase_Revival();
void HandlePhase_Complete();
// 특정 NPC를 스폰하는 함수 (블루프린트에서도 호출 가능)
UFUNCTION(BlueprintCallable)
void SpawnDownedNPC();
// 자주 사용하는 플레이어 컨트롤러 캐싱
UPROPERTY()
TObjectPtr<class AADPlayerController> TutorialPlayerController;
// 다음 단계로 자동 진행을 위한 타이머 핸들
FTimerHandle StepTimerHandle;
// 튜토리얼 데이터를 담고 있는 데이터 테이블
UPROPERTY(EditAnywhere, Category = "Tutorial")
TObjectPtr<UDataTable> TutorialDataTable;
};
헤더 파일에서는 튜토리얼의 각 단계를 처리할 HandlePhase_... 함수들과 전체 흐름을 제어하는 AdvanceTutorialPhase, HandleCurrentPhase 함수가 선언되어 있습니다.
특히 주목할 점은 UPROPERTY로 선언된 TutorialDataTable입니다. 이 변수를 통해 언리얼 에디터에서 튜토리얼 데이터를 담은 데이터 테이블 애셋을 손쉽게 연결할 수 있습니다.
// 다음 튜토리얼 단계로 진행시키는 함수
void AADTutorialGameMode::AdvanceTutorialPhase()
{
if (AADTutorialGameState* TutorialGS = GetGameState<AADTutorialGameState>())
{
ETutorialPhase CurrentPhase = TutorialGS->GetCurrentPhase();
if (CurrentPhase != ETutorialPhase::Complete)
{
// 현재 Phase enum 값을 1 증가시켜 다음 Phase를 계산
ETutorialPhase NextPhase = static_cast<ETutorialPhase>(static_cast<uint8>(CurrentPhase) + 1);
// GameState에 다음 Phase 상태를 저장
TutorialGS->SetCurrentPhase(NextPhase);
// 새로운 Phase에 맞는 핸들러 함수 호출
HandleCurrentPhase();
}
}
}
AdvanceTutorialPhase 함수는 외부(플레이어의 특정 행동, 타이머 만료 등)에서 호출될 때마다 튜토리얼 단계를 하나씩 진행시키는 역할을 합니다. GameState에 저장된 현재 단계를 가져와 다음 단계로 업데이트한 후, HandleCurrentPhase를 호출하여 실제 로직을 실행합니다.
// 현재 튜토리얼 단계에 맞는 로직을 처리하는 핵심 함수
void AADTutorialGameMode::HandleCurrentPhase()
{
// ... 플레이어 컨트롤러 유효성 검사 ...
if (AADTutorialGameState* TutorialGS = GetGameState<AADTutorialGameState>())
{
ETutorialPhase CurrentPhase = TutorialGS->GetCurrentPhase();
// 현재 Phase에 따라 적절한 핸들러 함수 호출 (상태 머신의 분기 처리)
switch (CurrentPhase)
{
case ETutorialPhase::Step1_Movement: HandlePhase_Movement(); break;
// ... 다른 단계들 ...
case ETutorialPhase::Complete: HandlePhase_Complete(); break;
default: break;
}
// 데이터 테이블에서 현재 단계에 맞는 데이터 검색
if (TutorialDataTable)
{
// Enum 값을 FName으로 변환하여 Row 이름으로 사용
const FString EnumString = UEnum::GetValueAsString(CurrentPhase);
const FName RowName = FName(EnumString.RightChop(EnumString.Find(TEXT("::")) + 2));
const FTutorialStepData* StepData = TutorialDataTable->FindRow<FTutorialStepData>(RowName, TEXT(""));
// 데이터가 있고, 플레이어의 입력을 기다리지 않는 단계라면
if (StepData && !StepData->bWaitForPlayerTrigger)
{
// 설정된 시간(DisplayDuration) 후에 자동으로 AdvanceTutorialPhase를 호출하는 타이머 설정
GetWorldTimerManager().SetTimer(
StepTimerHandle,
this,
&AADTutorialGameMode::AdvanceTutorialPhase,
StepData->DisplayDuration,
false);
}
else // 플레이어의 특정 행동을 기다려야 하는 단계라면
{
// 기존 타이머를 제거하여 자동 진행을 막음
GetWorldTimerManager().ClearTimer(StepTimerHandle);
}
}
}
}
HandleCurrentPhase는 이 시스템의 심장입니다.
switch 문을 통해 현재 단계에 맞는 HandlePhase_... 함수를 호출하여 고유 로직(예: 아이템 스폰, UI 활성화 등)을 실행합니다.
TutorialDataTable에서 현재 단계에 해당하는 행(Row)을 찾습니다. (ETutorialPhase 열거형의 이름을 그대로 행 이름으로 사용하는 방식이 인상적입니다.)
찾아낸 데이터의 bWaitForPlayerTrigger 플래그를 확인합니다.
false일 경우: 단순 설명처럼 특정 시간 동안 보여주기만 하면 되는 단계입니다. SetTimer를 이용해 DisplayDuration 시간 후에 자동으로 AdvanceTutorialPhase를 호출합니다.
true일 경우: 플레이어가 'W'키를 누르거나, 특정 아이템을 줍는 등의 행동을 완료해야만 다음으로 진행되는 단계입니다. 이 경우 타이머를 설정하지 않고, 다른 곳에서 플레이어의 행동을 감지하여 AdvanceTutorialPhase를 직접 호출해주기를 기다립니다.
void AADTutorialGameMode::HandlePhase_Movement()
{
// 이동 튜토리얼에 필요한 로직 구현
// 예: 플레이어가 일정 거리 이상 움직였는지 체크하는 로직 바인딩
}
void AADTutorialGameMode::HandlePhase_Looting()
{
// 루팅 튜토리얼에 필요한 로직 구현
// 예: 상호작용 가능한 아이템을 플레이어 근처에 스폰
}
void AADTutorialGameMode::HandlePhase_Revival()
{
// 부활 튜토리얼에 필요한 로직 구현
// 예: SpawnDownedNPC() 함수를 호출하여 쓰러진 NPC를 생성
}
//...
현재 각 HandlePhase_... 함수들은 비어있지만, 이 함수들이 각 튜토리얼 단계의 구체적인 내용을 구현하는 공간이 됩니다. 예를 들어 HandlePhase_Revival에서는 SpawnDownedNPC 함수를 호출하여 부활시켜야 할 대상을 맵에 배치하는 코드가 들어갈 것입니다.
전체적인 흐름 정리
1. 게임이 시작되면 StartPlay에서 첫 튜토리얼 단계를 설정하고 HandleCurrentPhase를 호출합니다.
2.HandleCurrentPhase는 switch문으로 해당 단계의 고유 로직을 실행합니다.
3.DataTable을 조회하여 bWaitForPlayerTrigger 값을 확인합니다.
4.(자동 진행) false이면, 타이머를 설정하고 시간이 되면 AdvanceTutorialPhase를 호출합니다. -> 2번으로 순환
5.(수동 진행) true이면, 시스템은 대기합니다. 플레이어가 미션(예: 아이템 줍기)을 완료하면, 해당 아이템의 로직이 GameMode의 AdvanceTutorialPhase를 직접 호출합니다. -> 2번으로 순환
이 과정은 ETutorialPhase::Complete 단계에 도달할 때까지 반복됩니다.