언리얼과 C++이용한 웨이브가 있는 게임 1 - 웨이브 시스템
GameMode, GameState, 델리게이트를 이용해 제한 시간 안에 아이템을 수집하고 다음 웨이브로 진행하는 Unreal C++ 시스템의 작업 과정을 정리했습니다.
언리얼과 C++이용한 웨이브가 있는 게임 1 - 웨이브 시스템
이번 프로젝트에서는 Unreal C++로 정해진 시간 안에 필드 아이템을 모으는 웨이브 시스템을 구현하고 있다. 각 웨이브에는 제한 시간과 목표 수집 개수가 있고, 플레이어가 목표를 채우면 다음 웨이브로 넘어간다. 마지막 웨이브까지 완료하면 다음 스테이지를 불러오고, 시간이 먼저 끝나면 실패 화면으로 이동한다.
현재 구현은 역할에 따라 세 부분으로 나뉜다.
AIngameGameMode: 웨이브 시작, 목표 달성 판정, 다음 스테이지 또는 실패 레벨로의 이동을 담당한다.AIngameState: 현재 웨이브, 수집 개수, 제한 시간처럼 UI와 공유할 진행 데이터를 보관한다.UIngameWidget:AIngameState의 델리게이트를 구독해 화면을 갱신한다.
게임 규칙과 상태, 화면 표시를 분리하면 웨이브 조건이 바뀌더라도 UI 코드까지 함께 수정하는 일을 줄일 수 있다.
전체 진행 흐름
게임을 시작하면 첫 웨이브를 시작하고 기준 시각과 수집 개수를 초기화한다. 플레이 중에는 남은 시간을 계속 계산하고, 아이템을 획득할 때마다 목표 개수와 비교한다.
게임 시작
→ 첫 웨이브 초기화
→ 아이템 생성 및 수집
→ 목표 개수 달성 여부 확인
→ 미달성: 현재 웨이브 계속
→ 달성: 다음 웨이브 또는 다음 스테이지
→ 제한 시간 종료
→ 실패 레벨 이동
이 구조에서 웨이브 전환은 아이템을 주웠을 때만 검사하고, 시간 초과는 GameMode의 Tick()에서 검사한다.
GameMode에서 웨이브 규칙 관리하기
AIngameGameMode의 생성자에서는 프로젝트 전용 AIngameState를 사용하도록 지정하고 Tick을 활성화했다.
AIngameGameMode::AIngameGameMode()
{
GameStateClass = AIngameState::StaticClass();
PrimaryActorTick.bCanEverTick = true;
}
BeginPlay()에서는 현재 GameState를 가져온 뒤 웨이브 인덱스를 -1로 둔다. 이어서 SetNextWave()가 인덱스를 먼저 증가시키므로 첫 웨이브는 내부적으로 0번 인덱스에서 시작한다.
void AIngameGameMode::BeginPlay()
{
IngameState = Cast<AIngameState>(GetWorld()->GetGameState());
checkf(IsValid(IngameState),
TEXT("Current game state is not IngameState"));
IngameState->SetCurrentWaveIndex(-1);
SetNextWave();
}
배열과 인덱스는 0부터 시작하지만 플레이어에게는 1 웨이브부터 표시해야 한다. 내부 계산은 0 기반으로 유지하고 UI에서만 +1을 적용하면 전환 조건을 단순하게 유지할 수 있다.
웨이브를 시작할 때 초기화할 값
다음 웨이브로 넘어갈 때는 웨이브 번호만 바꾸는 것으로 끝나지 않는다. 제한 시간을 다시 계산할 기준 시각과 현재 수집 개수도 함께 초기화해야 한다.
void AIngameGameMode::SetNextWave()
{
int CurrentWaveIndex = IngameState->GetCurrentWaveIndex();
IngameState->SetCurrentWaveIndex(++CurrentWaveIndex);
IngameState->SetStartTime(GetWorld()->TimeSeconds);
IngameState->SetCurrentPickUpCount(0);
}
SetCurrentWaveIndex()와 SetCurrentPickUpCount() 내부에서는 값이 바뀐 직후 델리게이트를 호출한다. 따라서 웨이브가 시작되는 순간 UI의 웨이브 번호와 수집 진행도도 함께 초기화된다.
경과 시간으로 남은 시간 계산하기
남은 시간은 프레임마다 1씩 빼는 방식이 아니라 월드 시간의 차이로 구했다.
void AIngameGameMode::Tick(float DeltaTime)
{
float StartTime = IngameState->GetStartTime();
float NowTime = static_cast<float>(GetWorld()->TimeSeconds);
float WaveDuration = IngameState->GetWaveDuration();
float RemainTime = WaveDuration - (NowTime - StartTime);
IngameState->SetRemainTime(RemainTime);
if (RemainTime <= 0.0f)
{
UGameplayStatics::OpenLevelBySoftObjectPtr(
GetWorld(), FailedLevel);
}
}
계산식은 다음과 같다.
남은 시간 = 웨이브 제한 시간 - (현재 시각 - 웨이브 시작 시각)
프레임 수가 아니라 실제 경과 시간을 기준으로 하기 때문에 FPS가 달라져도 웨이브 길이는 같다. 계산된 값은 AIngameState::SetRemainTime()에 전달되고, 상태 변경 델리게이트를 통해 UI에 반영된다.
제한 시간이 0 이하가 되면 TSoftObjectPtr<UWorld>로 지정한 실패 레벨을 연다. 레벨을 하드 참조하지 않으면서 Blueprint 파생 GameMode에서 이동할 맵을 설정할 수 있는 구조다.
위 흐름도에서 목표 달성 경로와 별개로 제한 시간 판정이 계속 진행된다. 플레이어가 아이템을 모으는 도중 시간이 먼저 끝나면 현재 수집 상태와 관계없이 실패 경로로 들어간다.
아이템 수집으로 웨이브 완료 판정하기
필드 아이템이 수집되면 AIngameGameMode::PickupFieldItem()이 현재 수집 개수를 증가시킨다.
int CurrentPickupCount = IngameState->GetCurrentPickUpCount();
IngameState->SetCurrentPickUpCount(++CurrentPickupCount);
int TotalPickupCount = IngameState->GetTotalPickUpCount();
if (CurrentPickupCount < TotalPickupCount)
{
return;
}
목표보다 적다면 현재 웨이브를 계속한다. 목표에 도달하면 현재 웨이브가 마지막인지 확인한다.
int CurrentWaveIndex = IngameState->GetCurrentWaveIndex();
int TotalWaveCount = IngameState->GetTotalWaveCount();
if (CurrentWaveIndex == TotalWaveCount - 1)
{
UGameplayStatics::OpenLevelBySoftObjectPtr(
GetWorld(), NextStage);
}
else
{
SetNextWave();
}
마지막 웨이브의 인덱스는 TotalWaveCount - 1이다. 마지막이 아니라면 같은 레벨에서 수집 개수와 시간을 초기화해 다음 웨이브를 시작하고, 마지막이라면 다음 스테이지를 불러온다.
GameState를 진행 데이터의 중심으로 사용하기
AIngameState에는 다음과 같은 값이 들어 있다.
- 현재 수집 개수와 목표 수집 개수
- 현재 웨이브 인덱스와 전체 웨이브 수
- 웨이브 제한 시간, 시작 시각, 남은 시간
값을 변경하는 세터에서는 데이터를 저장한 뒤 해당 델리게이트를 Broadcast()한다.
void AIngameState::SetCurrentPickUpCount(int InValue)
{
CurrentPickUpCount = InValue;
OnPickupCountChanged.Broadcast(
CurrentPickUpCount,
TotalPickupCount);
}
void AIngameState::SetRemainTime(float InValue)
{
RemainTime = InValue;
OnRemainTimeChanged.Broadcast(RemainTime);
}
GameMode는 규칙을 처리한 뒤 GameState의 값만 바꾼다. GameState는 값이 변경됐다는 사실을 알리고, 실제 화면에 표시하는 방법은 위젯이 결정한다. 이 방식은 GameMode가 UTextBlock이나 UProgressBar 같은 UI 컴포넌트를 직접 알지 않아도 된다는 장점이 있다.
델리게이트로 UI 자동 갱신하기
UIngameWidget::NativeOnInitialized()에서는 AIngameState가 제공하는 세 델리게이트에 UI 갱신 함수를 연결한다.
IngameState->OnPickupCountChanged.AddUObject(
this, &UIngameWidget::SetPickupCount);
IngameState->OnRemainTimeChanged.AddUObject(
this, &UIngameWidget::SetWaveDuration);
IngameState->OnWaveIndexChanged.AddUObject(
this, &UIngameWidget::SetWaveText);
수집 개수는 텍스트와 프로그레스 바에 동시에 반영한다.
PickupCount->SetText(FText::Format(
FText::FromString(TEXT("{0}/{1}")),
InPickupCount,
InTargetItemCount));
PickupCountProgressbar->SetPercent(
static_cast<float>(InPickupCount) /
static_cast<float>(InTargetItemCount));
웨이브 텍스트에는 내부 인덱스에 1을 더한 값을 사용하고, 남은 시간은 정수로 변환해 표시한다. 위젯이 생성되기 전에 이미 상태가 정해졌을 수도 있으므로 초기화 마지막에는 Getter로 현재 값을 읽어 한 번 직접 그려 준다. 이후 변경부터는 델리게이트가 처리한다.
수집 개수, 웨이브 번호, 남은 시간은 모두 같은 GameState에서 출발하므로 화면에 표시되는 값과 실제 판정에 사용하는 값이 어긋나는 상황을 줄일 수 있다.
Spawn Volume과 DataTable로 아이템 생성하기
웨이브의 목표가 되는 아이템은 AFieldItemSpawnVolume에서 생성한다. 부모인 ASpawnVolume은 박스 영역 안의 임의 위치를 계산하고, SpawnDelay가 지날 때마다 가상 함수 Spawn()을 호출한다.
if (Now - LastSpawnTime >= SpawnDelay)
{
Spawn();
LastSpawnTime = Now;
}
파생 클래스는 DataTable의 행마다 설정한 DropRate를 모두 더하고, 그 범위에서 난수를 뽑아 생성할 아이템을 선택한다. 드롭 확률의 합을 반드시 1이나 100으로 맞추지 않아도 각 값의 비율대로 선택할 수 있다.
선택한 아이템은 지연 생성으로 만든다.
AFieldItemBase* SpawnedActor =
GetWorld()->SpawnActorDeferred<AFieldItemBase>(
FoundFieldItemClass,
SpawnTransform);
SpawnedActor->SetData(FoundRow);
UGameplayStatics::FinishSpawningActor(
SpawnedActor,
SpawnTransform);
일반 SpawnActor() 대신 SpawnActorDeferred()를 사용하면 Actor의 BeginPlay()가 실행되기 전에 선택된 DataTable 행을 전달할 수 있다. 아이템 종류와 수량 범위, 드롭 비율을 데이터로 관리하면서 실제 동작은 C++ 파생 클래스에 둘 수 있다.
아이템 획득과 웨이브 카운트 연결
코인 아이템의 OnPicked()는 플레이어의 UInventoryComponent를 찾아 DataTable에서 정한 수량을 추가한 뒤 자신을 제거한다.
Inventory->AddItem(
Row->ItemTableKey,
Row->RollAmount());
Destroy();
현재 AFieldItemBase는 EndPlay()의 종료 사유가 Destroyed일 때 GameMode에 아이템 획득을 알린다. 이 호출이 수집 개수 증가와 웨이브 완료 판정으로 이어진다.
if (Reason == EEndPlayReason::Destroyed)
{
AIngameGameMode* IngameMode =
Cast<AIngameGameMode>(GetWorld()->GetAuthGameMode());
IngameMode->PickupFieldItem(*this);
}
아이템이 자신의 수집 처리와 인벤토리 반영을 담당하고, GameMode는 아이템의 구체적인 종류를 몰라도 웨이브 목표만 계산한다는 점은 유지하고 싶은 구조다.
추가 작업
현재 웨이브 시스템을 바탕으로 다음 기능을 추가할 예정이다.
- 웨이브마다 다른 설정 적용하기
- 몬스터 등장시키기
- 몬스터가 아이템을 떨어뜨리도록 구현하기
- 몬스터 3종 추가하기
- 힐링 팩
- 이동 속도 증가 아이템
- 간단한 장비 1~2종
마무리
이번 작업에서는 단순히 아이템을 반복 생성하는 것에서 그치지 않고, 생성된 아이템을 수집 목표와 제한 시간에 연결해 하나의 웨이브 진행 구조를 만들었다.
GameMode는 성공과 실패 규칙을 판단하고, GameState는 여러 객체가 함께 보는 진행 값을 관리하며, Widget은 델리게이트를 통해 변경된 값만 화면에 반영한다. 또한 Spawn Volume과 DataTable을 이용해 아이템 생성 위치와 종류를 코드에서 분리했다.
현재는 기본적인 웨이브 전환이 동작하는 단계다. 이후에는 웨이브별 설정과 몬스터, 아이템을 차례로 추가해 게임의 진행 구조를 확장할 예정이다.