7월까지 1차 빌드가 나와야 하므로, 게임이 한바퀴 굴러가는 것을 목표로 하여 코어 루프를 구현하기로 했다.
내가 맡은 부분은 크게 튜토리얼, 정비, 웨이브 단계, 클리어 퀘스트까지 이어지는 커다란 흐름과 함께 튜토리얼 퀘스트, 웨이브 몬스터 소환 구조, 클리어 퀘스트, 관련된 메인 UI, 멀티플레이 작업이다.
UENUM(BlueprintType)
enum class EGameProgress : uint8
{
Lobby UMETA(DisplayName = "Lobby"),
Tutorial UMETA(DisplayName = "Tutorial"),
Maintenance UMETA(DisplayName = "Maintenance"),
Wave UMETA(DisplayName = "Wave"),
Final UMETA(DisplayName = "Final"),
GameClear UMETA(DisplayName = "GameClear"),
GameOver UMETA(DisplayName = "GameOver"),
};
게임의 전체 흐름을 관리해야 하므로, GameMode 클래스를 사용하기로 했다. 게임의 스테이지는 로비, 튜토리얼, 정비 <=> 웨이브, 클리어 퀘스트, 클리어, 게임오버 단계로 나누어 enum class를 사용해 관리하기로 했다.
void ATrapperGameMode::SetGameProgress(EGameProgress Progress)
{
CurrentGameProgress = Progress;
switch (CurrentGameProgress)
{
case EGameProgress::Lobby:
break;
case EGameProgress::Tutorial:
break;
case EGameProgress::Maintenance:
break;
case EGameProgress::Wave:
break;
case EGameProgress::Final:
break;
case EGameProgress::GameClear:
break;
case EGameProgress::GameOver:
break;
default:
break;
}
}
해당 함수를 외부에서 호출하여 단계를 변경하도록 했다.
게임의 중요한 부분인 정비-웨이브 부분을 먼저 구현하기로 했다. 1차 빌드의 웨이브는 총 10단계로, 단계 내에서도 시간에 따라 여러개의 서브 웨이브로 나뉜다.
처음에는 데이터 에셋으로 관리하려고 했으나, 엑셀 등 기획자들이 편리하게 데이터를 관리하고 변경할 수 있도록 하기 위해 데이터 테이블로 관리하기로 했다.
USTRUCT()
struct FWaveInfo : public FTableRowBase
{
GENERATED_USTRUCT_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 UseThisWave;
UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Skeleton;
UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Mummy;
UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Zombie;
UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Debuffer;
UPROPERTY(EditAnywhere, BlueprintReadWrite) float NextWaveLeftTime;
UPROPERTY(EditAnywhere, BlueprintReadWrite) FText Memo;
};
액터를 상속받은 C++ 클래스를 하나 만들고, 그 헤더 파일에 새로운 구조체를 정의해주었다.
한 웨이브의 마지막을 구분하기 위한 플래그인 UseThisWave
, 각 웨이브당 소환되어야 할 종류별 몬스터의 수와 다음 웨이브까지 몇초 남았는지를 관리한다.
데이터 테이블 에셋을 생성하고, 방금 만들어준 구조체를 선택해주면 된다.
그럼 이렇게 데이터 테이블이 생긴다. 사진처럼 직접 테이블을 채워 넣어주면 된다.
이 테이블은 게임모드에서 사용하고 관리하므로, 게임 모드에서 불러와주어야 한다.
public:
TObjectPtr<class UDataTable> WaveData;
// ------------------------------------------------
ATrapperGameMode::ATrapperGameMode()
{
PrimaryActorTick.bCanEverTick = true;
GameStateClass = ATrapperGameState::StaticClass();
static ConstructorHelpers::FObjectFinder<UDataTable> GameDataRef(TEXT("/Script/Engine.DataTable'/Game/Blueprints/Data/DT_WaveData.DT_WaveData'"));
if (GameDataRef.Succeeded() && GameDataRef.Object)
{
WaveData = GameDataRef.Object;
}
}
UDataTable
멤버변수를 선언해주고, 생성자에서 불러와주자.
bool ATrapperGameMode::GetWaveData(FWaveInfo& OutData)
{
FString WaveText = TEXT("Wave") + FString::FromInt(Wave) + TEXT("_") + FString::FromInt(SubWave);
FWaveInfo* Data = WaveData->FindRow<FWaveInfo>(*WaveText, FString());
if(!Data || !Data->UseThisWave) return false;
OutData.Skeleton = Data->Skeleton;
OutData.Mummy = Data->Mummy;
OutData.Zombie = Data->Zombie;
OutData.Debuffer = Data->Debuffer;
OutData.NextWaveLeftTime = Data->NextWaveLeftTime;
return true;
}
GetWaveData()
함수를 통해 데이터 테이블에 작성된 데이터들을 행의 이름을 가지고 가져올 수 있도록 해주었다.
void ATrapperGameMode::BeginPlay()
{
Super::BeginPlay();
SetGameProgress(EGameProgress::Maintenance);
}
테스트를 위해, 첫번째 정비단계부터 시작하도록 BeginPlay()
함수에서 설정해주었다.
void ATrapperGameMode::SetGameProgress(EGameProgress Progress)
{
CurrentGameProgress = Progress;
switch (CurrentGameProgress)
{
case EGameProgress::Lobby:
break;
case EGameProgress::Tutorial:
break;
case EGameProgress::Maintenance:
MaintenanceStart();
break;
case EGameProgress::Wave:
WaveStart();
break;
case EGameProgress::Final:
UE_LOG(LogTemp, Warning, TEXT("-- Final Stage --"));
break;
case EGameProgress::GameClear:
break;
case EGameProgress::GameOver:
break;
default:
break;
}
}
그럼 이 스위치문을 통해 MaintenanceStart()
함수가 실행되게 된다.
void ATrapperGameMode::MaintenanceStart()
{
UE_LOG(LogTemp, Warning, TEXT("-- Maintenance Stage --"), Wave);
// 정비 시간 설정
MaintenanceTimeLeft = MaintenanceTime;
bMaintenanceInProgress = true;
FTimerHandle MaintenanceTimerHandle;
GetWorldTimerManager().SetTimer(MaintenanceTimerHandle, FTimerDelegate::CreateLambda([&]
{
// 다음 웨이브 시작
bMaintenanceInProgress = false;
MaintenanceTimeLeft = 0.f;
SetGameProgress(EGameProgress::Wave);
}
), 1.0f, false, MaintenanceTime);
}
void ATrapperGameMode::Tick(float DeltaTime)
{
if (bMaintenanceInProgress)
{
// 위젯 출력용
MaintenanceTimeLeft -= DeltaTime;
}
}
추후 남은 정비시간을 위젯에 출력하기 위해, MaintenanceTimeLeft
변수와 bMaintenanceInProgress
변수를 만들어주었다. bMaintenanceInProgress
변수가 true
일 경우, Tick 함수 내에서 델타타임을 빼주는 식으로 계산한다.
아무튼 함수가 호출되면 타이머를 실행시키는데, 정해진 정비 시간이 지나고 타이머가 호출될 때 웨이브를 실행시켜주도록 했다.
void ATrapperGameMode::WaveStart()
{
UE_LOG(LogTemp, Warning, TEXT("[%d-%d Wave Start]"), Wave, SubWave);
FWaveInfo CurrentWaveData;
GetWaveData(CurrentWaveData);
SpawnMonster(CurrentWaveData);
FTimerHandle WaveTimerHandle;
GetWorldTimerManager().SetTimer(WaveTimerHandle, FTimerDelegate::CreateLambda([&]
{
SubWave++;
if (!GetWaveData(CurrentWaveData))
{
if (Wave == 5)
{
// 5 웨이브 이후 정비시간으로 넘어감
Wave++;
SubWave = 1;
SetGameProgress(EGameProgress::Maintenance);
}
else if (Wave == 10)
{
// 10 웨이브 이후 클리어 퀘스트로 넘어감
SetGameProgress(EGameProgress::Final);
}
else
{
// 다음 웨이브로 넘어감
Wave++;
SubWave = 1;
SetGameProgress(EGameProgress::Wave);
}
return;
}
// 서브 웨이브 계속 진행
WaveStart();
}
), 1.0f, false, CurrentWaveData.NextWaveLeftTime);
}
게임 단계가 Wave로 바뀌게 되면 WaveStart
함수를 실행한다. GetWaveData
를 통해 테이블 데이터를 받아오고, SpawnMonster
를 실행해 테이블에 해당하는 정보를 보고 알맞은 몬스터를 소환시킨다.
NextWaveLeftTime
에 설정된 시간 후에 타이머를 다시 실행시키는데, 다음 웨이브를 진행시키기 위함이다.
if (!GetWaveData(CurrentWaveData))
이 분기 안쪽에 여러 로직들이 있는데, 이건
이 상황을 의미한다. 지금 세번째 서브 웨이브는 존재하지 않는다. 따라서 두번째 서브 웨이브가 끝나면 두번째 메인 웨이브로 이동해야하는데, 플래그를 검사하여 다음 웨이브로 넘기기 위해 체크한다.
if(!Data || !Data->UseThisWave) return false;
GetWaveData
함수 내에서 검사해서 false
를 리턴하는 것을 확인할 수 있다.
이 조건문이 성립할 경우, 다섯번째 웨이브 후에는 다시 정비로 돌아가야 하고, 열번째 웨이브 이후에는 클리어 퀘스트 단계로 진입해야한다. 따라서 그에 맞추어 예외처리 해주었고, 나머지 상황에서는 다음 웨이브로 넘어가고 서브웨이브를 1로 초기화해주는 식으로 작성하였다.
void ATrapperGameMode::SpawnMonster(struct FWaveInfo& OutData)
{
/// *********** Create Monster ***********
UE_LOG(LogTemp, Warning, TEXT("Create Monster - Skeleton %d / Mummy %d / Zombie %d / Debuffer %d"),
OutData.Skeleton, OutData.Mummy, OutData.Zombie, OutData.Debuffer);
}
SpawnMonster
함수 내부는 몬스터를 구현하는 친구가 작성할 것이므로, 우선 테스트로 로그를 찍는 코드만 넣어두었다. 테이블에 있는 몬스터의 수에 맞게 생성만 하면 되도록 설계해보았다.
정상적으로 잘 작동한다 :)