1. 오늘 구현한 것
오늘은 WaveManager를 중심으로 게임의 웨이브 시스템 기본 구조를 구현했다.
구현한 기능은 다음과 같다.
- SpawnVolume 구현
- WaveData 구조체 구현
- DataTable을 이용한 웨이브 데이터 관리
- 현재 웨이브 데이터 가져오기
- 웨이브별 몬스터 수 계산
- 몬스터 스폰
- SpawnVolume을 이용한 랜덤 스폰 위치 지정
- 튜토리얼 30초 대기
- Wave별 제한시간 설정
- 웨이브 종료 후 45초 대기
- 다음 웨이브 자동 시작
- 최대 웨이브 이후 더 이상 웨이브가 진행되지 않도록 처리
- 빌드 오류 트러블슈팅
현재 기획은 다음과 같다.
구분몬스터 수SpawnVolume제한시간
| Wave 1 | 30마리 | 2개 | 60초 |
| Wave 2 | 45마리 | 3개 | 90초 |
| Wave 3 | 60마리 | 4개 | 90초 |
튜토리얼은 30초이며, 웨이브 사이에는 45초의 대기시간을 둔다.
2. SpawnVolume
SpawnVolume의 역할
SpawnVolume은 몬스터를 실제로 관리하는 객체가 아니라, 몬스터가 스폰될 수 있는 위치를 제공하는 역할을 한다.
즉,
SpawnVolume
→ "이 영역 안에서 랜덤한 위치 하나 줘"
라고 요청하면 랜덤한 위치를 반환한다.
실제 어떤 몬스터를 몇 마리 스폰할지는 WaveManager가 담당한다.
SpawnVolume 생성자
ASpawnVolume::ASpawnVolume()
{
// 스폰 영역 박스 컴포 생성
SpawnBox = CreateDefaultSubobject<UBoxComponent>(TEXT("SpawnBox"));
// 스폰볼륨 액터의 루트컴포를 스폰박스로 지정
RootComponent = SpawnBox;
}
코드 의미
CreateDefaultSubobject<UBoxComponent>
를 이용해 BoxComponent를 생성한다.
이 박스가 실제로 몬스터가 랜덤하게 생성될 영역이 된다.
그리고
RootComponent = SpawnBox;
를 통해 생성한 BoxComponent를 SpawnVolume Actor의 RootComponent로 지정한다.
따라서 레벨에 BP_SpawnVolume을 배치하면 Box의 크기를 조절하여 스폰 영역을 지정할 수 있다.
3. 랜덤 스폰 위치 만들기
FVector ASpawnVolume::GetRandomSpawnLocation() const
{
// SpawnBox의 월드 위치를 가져온다.
const FVector Origin = SpawnBox->GetComponentLocation();
// SpawnBox의 실제 월드 크기(절반)를 가져온다.
const FVector Extent = SpawnBox->GetScaledBoxExtent();
// Box 범위 안에서 X, Y, Z가 각각 랜덤인 위치를 반환한다.
return Origin + FVector(
FMath::FRandRange(-Extent.X, Extent.X),
FMath::FRandRange(-Extent.Y, Extent.Y),
FMath::FRandRange(-Extent.Z, Extent.Z)
);
}
코드 의미
먼저 SpawnBox의 중심 위치를 가져온다.
const FVector Origin = SpawnBox->GetComponentLocation();
그리고 Box의 크기를 가져온다.
const FVector Extent = SpawnBox->GetScaledBoxExtent();
Extent는 박스 전체 크기가 아니라 중심에서 각 방향으로 얼마나 떨어져 있는지를 나타낸다.
예를 들어 X 방향 범위가 -500 ~ +500이라면:
FMath::FRandRange(-Extent.X, Extent.X)
를 이용해 그 사이의 랜덤한 값을 만든다.
X, Y, Z 각각에 랜덤값을 적용하기 때문에 최종적으로 SpawnBox 내부의 랜덤한 위치를 반환한다.
4. WaveManager의 웨이브 데이터 구조
웨이브마다 필요한 정보를 DataTable에서 관리하기 위해 두 개의 구조체를 만들었다.
FWaveMonsterData
USTRUCT(BlueprintType)
struct FWaveMonsterData
{
GENERATED_BODY()
// 스폰할 몬스터 클래스
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TSubclassOf<AActor> MonsterClass;
// 해당 몬스터를 몇 마리 스폰할지
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 SpawnCount = 0;
};
의미
하나의 몬스터 종류에 대한 정보를 담는다.
예를 들어:
MonsterClass = BP_Suicide
SpawnCount = 30
이라고 설정하면
BP_Suicide를 30마리 스폰한다.
는 의미가 된다.
5. FWaveData
USTRUCT(BlueprintType)
struct FWaveData : public FTableRowBase
{
GENERATED_BODY()
// 해당 웨이브에서 사용할 몬스터 목록
UPROPERTY(EditAnywhere, BlueprintReadWrite)
TArray<FWaveMonsterData> Monsters;
// 해당 웨이브에서 사용할 스폰볼륨 개수
UPROPERTY(EditAnywhere, BlueprintReadWrite)
int32 SpawnVolumeCount = 0;
// 해당 웨이브의 제한시간(초)
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float WaveTimeLimit = 60.0f;
};
의미
FWaveData는 하나의 웨이브 전체에 대한 정보를 담는다.
즉 한 행이 하나의 웨이브가 된다.
예:
Wave1
├─ Monsters
│ └─ BP_Suicide / 30
├─ SpawnVolumeCount = 2
└─ WaveTimeLimit = 60
DataTable에서 이 데이터를 관리할 수 있도록
public FTableRowBase
를 상속했다.
6. WaveManager 변수
현재 WaveManager에서는 다음과 같은 변수를 사용한다.
// 현재 진행 중인 웨이브 번호
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Wave")
int32 CurrentWave = 1;
현재 몇 번째 웨이브인지 저장한다.
처음에는 1이므로 Wave 1부터 시작한다.
// 전체 웨이브 개수
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Wave")
int32 MaxWave = 3;
전체 웨이브의 개수를 나타낸다.
현재 프로젝트에서는 총 3웨이브이므로 3이다.
// 현재 웨이브에서 아직 처리해야 할 몬스터 수
// 웨이브 시작 시 해당 웨이브의 몬스터 수를 넣어줌
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Wave")
int32 CurrentMonsterCount = 0;
현재 웨이브의 몬스터 수를 관리하기 위한 변수이다.
현재는 웨이브 시스템 기본 구현 단계이며, 이후 몬스터 사망 처리와 연결할 예정이다.
7. SpawnVolume 배열
// 웨이브에서 사용할 스폰볼륨 목록
// 레벨에 배치된 스폰볼륨들을 웨이브매니저가 참조
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Wave|Spawn")
TArray<TObjectPtr<class ASpawnVolume>> SpawnVolumes;
의미
레벨에 배치된 여러 개의 SpawnVolume을 WaveManager가 가지고 있는 것이다.
예를 들어:
SpawnVolumes[0] → SpawnVolume 1
SpawnVolumes[1] → SpawnVolume 2
SpawnVolumes[2] → SpawnVolume 3
SpawnVolumes[3] → SpawnVolume 4
Wave 1에서 2개만 사용한다면 앞의 2개를 사용하고,
Wave 3에서는 4개를 사용하도록 구성했다.
8. WaveDataTable
// 웨이브 정보를 담고 있는 데이터테이블
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Wave|Data")
TObjectPtr<UDataTable> WaveDataTable;
의미
DT_WaveData를 WaveManager가 참조하기 위한 변수이다.
실제 웨이브의 몬스터 수나 제한시간을 코드에 직접 적지 않고 DataTable에서 관리할 수 있게 했다.
9. 현재 웨이브 데이터 가져오기
const FWaveData* AWaveManager::GetCurrentWaveData() const
{
// 현재 웨이브 번호를 DataTable의 Row 이름으로 만든다.
// 예: CurrentWave = 1 → "Wave1"
const FName RowName = FName(*FString::Printf(TEXT("Wave%d"), CurrentWave));
// DataTable에서 해당 Row를 찾아 반환한다.
return WaveDataTable->FindRow<FWaveData>(RowName, TEXT("GetCurrentWaveData"));
}
동작 과정
현재 웨이브가 1이라면:
CurrentWave = 1
이 값을 이용해
Wave1
이라는 Row 이름을 만든다.
그리고 DataTable에서 해당 Row를 찾는다.
즉:
CurrentWave = 1
↓
"Wave1"
↓
DT_WaveData에서 Wave1 검색
↓
Wave1 데이터 반환
Wave 2라면 "Wave2", Wave 3이라면 "Wave3"가 된다.
10. 현재 웨이브 몬스터 수 계산
void AWaveManager::UpdateCurrentMonsterCount()
{
// 현재 웨이브의 데이터를 가져온다.
const FWaveData* WaveData = GetCurrentWaveData();
// 웨이브 데이터가 없으면 종료
if (!WaveData)
{
return;
}
// 현재 웨이브의 몬스터 수를 0부터 다시 계산
CurrentMonsterCount = 0;
// 몬스터 종류별 SpawnCount를 모두 더한다.
for (const FWaveMonsterData& MonsterData : WaveData->Monsters)
{
CurrentMonsterCount += MonsterData.SpawnCount;
}
}
의미
DataTable에 몬스터 종류가 여러 개 들어갈 수 있기 때문에 Monsters 배열을 순회한다.
예를 들어:
Melee 20
Suicide 10
Ranged 15
라면:
20 + 10 + 15 = 45
가 되어 CurrentMonsterCount는 45가 된다.
현재 프로젝트에서는 한 웨이브에 BP_Suicide만 넣었기 때문에:
Wave 1 → 30
Wave 2 → 45
Wave 3 → 60
이 된다.
11. 몬스터 스폰
void AWaveManager::SpawnCurrentWave()
{
// 현재 웨이브 데이터를 가져온다.
const FWaveData* WaveData = GetCurrentWaveData();
// 웨이브 데이터가 없으면 종료
if (!WaveData)
{
return;
}
// 현재 웨이브의 몬스터 종류를 하나씩 확인한다.
for (const FWaveMonsterData& MonsterData : WaveData->Monsters)
{
// 몬스터 클래스가 지정되지 않았으면 건너뛴다.
if (!MonsterData.MonsterClass)
{
continue;
}
// 설정된 몬스터 수만큼 반복해서 스폰한다.
for (int32 i = 0; i < MonsterData.SpawnCount; ++i)
{
// 사용할 SpawnVolume이 없으면 종료
if (SpawnVolumes.Num() == 0)
{
return;
}
// 현재 웨이브에서 사용할 SpawnVolume에 몬스터를 골고루 분배한다.
ASpawnVolume* SpawnVolume =
SpawnVolumes[i % WaveData->SpawnVolumeCount];
// SpawnVolume이 없으면 해당 스폰을 건너뛴다.
if (!SpawnVolume)
{
continue;
}
// SpawnVolume에서 랜덤한 스폰 위치를 가져온다.
const FVector SpawnLocation =
SpawnVolume->GetRandomSpawnLocation();
// 몬스터를 스폰한다.
GetWorld()->SpawnActor<AActor>(
MonsterData.MonsterClass,
SpawnLocation,
FRotator::ZeroRotator
);
}
}
}
핵심 구조
먼저 현재 웨이브 데이터를 가져온다.
const FWaveData* WaveData = GetCurrentWaveData();
그 다음 웨이브에 등록된 몬스터 종류를 확인한다.
for (const FWaveMonsterData& MonsterData : WaveData->Monsters)
그리고 SpawnCount만큼 반복하여 몬스터를 생성한다.
for (int32 i = 0; i < MonsterData.SpawnCount; ++i)
SpawnVolume 분배
SpawnVolumes[i % WaveData->SpawnVolumeCount]
여기서는 % 연산을 사용했다.
예를 들어 SpawnVolume이 2개이고 몬스터가 6마리라면:
i = 0 → Volume 0
i = 1 → Volume 1
i = 2 → Volume 0
i = 3 → Volume 1
i = 4 → Volume 0
i = 5 → Volume 1
따라서 몬스터가 SpawnVolume에 순서대로 골고루 분배된다.
현재 설정에서는:
Wave 1
30마리 / 2개
→ 15마리씩
Wave 2
45마리 / 3개
→ 15마리씩
Wave 3
60마리 / 4개
→ 15마리씩
이 된다.
12. 웨이브 제한시간
void AWaveManager::StartWaveTimer()
{
// 현재 웨이브의 데이터 가져오기
const FWaveData* WaveData = GetCurrentWaveData();
// 웨이브 데이터가 없으면 종료
if (!WaveData)
{
return;
}
// 현재 웨이브의 제한시간으로 타이머 시작
GetWorldTimerManager().SetTimer(
WaveTimerHandle,
this,
&AWaveManager::OnWaveTimeExpired,
WaveData->WaveTimeLimit,
false
);
}
의미
DataTable에 설정된 WaveTimeLimit을 이용해 타이머를 시작한다.
Wave 1:
WaveTimeLimit = 60
이면 60초 후
OnWaveTimeExpired()
가 호출된다.
Wave 2와 Wave 3은 각각 90초로 설정했다.
13. 튜토리얼 30초 대기
처음에는 게임이 시작되자마자 몬스터가 스폰되었지만, 기획상 튜토리얼 30초 동안은 몬스터를 스폰하지 않도록 수정했다.
void AWaveManager::BeginPlay()
{
Super::BeginPlay();
UE_LOG(LogTemp, Warning, TEXT("===== WaveManager BeginPlay ====="));
UpdateCurrentMonsterCount();
// 튜토리얼 30초 후 Wave 1 시작
GetWorldTimerManager().SetTimer(
TutorialTimerHandle,
this,
&AWaveManager::StartFirstWave,
30.0f,
false
);
}
의미
게임 시작 시 BeginPlay()는 실행되지만 바로 몬스터를 스폰하지 않는다.
대신:
게임 시작
↓
30초 대기
↓
StartFirstWave()
↓
Wave 1 스폰
의 흐름으로 변경했다.
14. Wave 1 시작
void AWaveManager::StartFirstWave()
{
UE_LOG(
LogTemp,
Warning,
TEXT("===== WAVE 1 START =====")
);
// Wave 1 몬스터 스폰
SpawnCurrentWave();
// Wave 1 제한시간 시작
StartWaveTimer();
}
의미
튜토리얼 30초가 끝나면 호출된다.
여기서 두 가지를 실행한다.
1. Wave 1 몬스터 스폰
2. Wave 1 제한시간 시작
실제 PIE 테스트에서
===== WaveManager BeginPlay =====
===== WAVE 1 START =====
로그가 확인되었고, 실제 몬스터가 스폰되는 것도 확인했다.
15. 웨이브 시간이 끝났을 때
void AWaveManager::OnWaveTimeExpired()
{
UE_LOG(
LogTemp,
Warning,
TEXT("===== WAVE TIME EXPIRED ===== Wave: %d"),
CurrentWave
);
// 45초 후 다음 웨이브 시작
GetWorldTimerManager().SetTimer(
NextWaveTimerHandle,
this,
&AWaveManager::StartNextWave,
45.0f,
false
);
}
의미
현재 웨이브의 제한시간이 끝나면 바로 다음 웨이브를 시작하지 않는다.
45초의 대기시간을 가진다.
Wave 1
↓
60초
↓
WaveTimeExpired
↓
45초 대기
↓
Wave 2
이 45초 대기는 NextWaveTimerHandle을 이용해 관리한다.
16. 다음 웨이브 시작
void AWaveManager::StartNextWave()
{
// 다음 웨이브 번호로 변경
CurrentWave++;
// 최대 웨이브를 넘으면 종료
if (CurrentWave > MaxWave)
{
return;
}
UE_LOG(
LogTemp,
Warning,
TEXT("===== WAVE %d START ====="),
CurrentWave
);
// 다음 웨이브 몬스터 스폰
SpawnCurrentWave();
// 다음 웨이브 제한시간 시작
StartWaveTimer();
}
의미
다음 웨이브를 시작하기 전에:
CurrentWave++;
로 웨이브 번호를 증가시킨다.
예:
Wave 1 → CurrentWave = 1
다음 웨이브
→ CurrentWave = 2
그 후 현재 웨이브 번호를 이용해 DataTable에서 Wave2 데이터를 가져오고 몬스터를 스폰한다.
최대 웨이브 제한
if (CurrentWave > MaxWave)
{
return;
}
현재 MaxWave = 3이기 때문에:
Wave 1 → 진행
Wave 2 → 진행
Wave 3 → 진행
Wave 4 → 시작하지 않음
이 조건을 통해 3웨이브 이후 더 이상 새로운 웨이브가 생성되지 않도록 했다.
17. 타이머 핸들
WaveManager에서는 세 개의 타이머를 사용한다.
// 현재 웨이브 제한시간을 관리하는 타이머
FTimerHandle WaveTimerHandle;
// 튜토리얼 종료까지 대기하는 타이머
FTimerHandle TutorialTimerHandle;
// 다음 웨이브 시작까지 대기하는 타이머
FTimerHandle NextWaveTimerHandle;
각각의 역할은 다르다.
WaveTimerHandle
→ 현재 웨이브 제한시간
TutorialTimerHandle
→ 게임 시작 후 30초 대기
NextWaveTimerHandle
→ 웨이브 종료 후 45초 대기
18. 현재까지 완성된 전체 흐름
게임 시작
↓
WaveManager BeginPlay
↓
30초 튜토리얼
↓
StartFirstWave()
↓
Wave 1
30마리 / SpawnVolume 2개 / 60초
↓
시간 종료
↓
45초 대기
↓
Wave 2
45마리 / SpawnVolume 3개 / 90초
↓
시간 종료
↓
45초 대기
↓
Wave 3
60마리 / SpawnVolume 4개 / 90초
↓
Wave 3 종료
↓
더 이상 Wave 4 생성 X
실제 PIE에서도 다음 로그를 확인했다.
===== WaveManager BeginPlay =====
===== WAVE 1 START =====
===== WAVE TIME EXPIRED ===== Wave: 1
===== WAVE 2 START =====
따라서 튜토리얼 → Wave 1 → 제한시간 → 45초 대기 → Wave 2까지 실제 동작을 확인했다.
19. 기획 변경 — 놓친 몬스터 처리
처음에는 웨이브 제한시간 안에 처치하지 못한 몬스터를 다음 웨이브에 추가하는 방식을 생각했다.
예:
Wave 1
30마리 스폰
25마리 처치
5마리 생존
↓
Wave 2
45마리 + 생존 5마리
= 50마리
하지만 기획이 변경되었다.
현재는 생존 몬스터를 다음 웨이브에 이월하지 않는다.
대신 놓친 몬스터 수를 계산하여 보호 대상의 스트레스 지수에 반영한다.
현재 예정된 수치는:
몬스터 1마리 놓침
→ 스트레스 +10
예:
30마리 중 25마리 처치
→ 5마리 놓침
→ 스트레스 +50
따라서 다음 웨이브는 항상 DataTable에 설정된 수량대로 시작한다.
Wave 1 → 30
Wave 2 → 45
Wave 3 → 60
이 기능은 몬스터의 사망 처리와 연결해야 하기 때문에 현재는 구현을 보류했다.
특히 다른 팀원이 BaseMonster를 수정하고 있는 상황이라, 머지 충돌을 피하기 위해 해당 작업은 이후 진행하기로 했다.
20. 트러블슈팅 — WeaponBase 관련 컴파일 오류
오늘 WaveManager 작업 과정에서 WeaponBase.cpp와 관련된 컴파일 오류가 발생했다.
처음에는 최근 수정한 WaveManager 코드의 문제인지 확인했지만, 실제 오류가 WeaponBase 쪽에서 발생하는 것을 확인했다.
오류 원인을 분리해서 확인하기 위해 Unity Build를 끄는 방법도 사용해 보았다.
bUseUnityBuild = false;
이렇게 설정했을 때 해당 오류가 사라지는 것을 확인했다.
이를 통해 최근 작성한 코드만 무조건 원인이라고 생각하기보다 실제 빌드 로그에서 오류가 발생한 파일과 위치를 먼저 확인해야 한다는 것을 다시 확인했다.