11. 진정한 웨이브 구현
A. 매 웨이브마다 동전 아이템 증가
지금 코드는 항상 웨이브 마다 동전 아이템 10개씩 등장하는 것이 전부라서 심심하다. 그래서 이를 변화할 필요가 있다. 일단 게임을 진행할수록 다음 웨이브로 넘어가기 어렵게 만들어야 한다.
이를 위한 제일 간단한 방법은 매 웨이브마다 동전 아이템을 증가하는 것이다.
private:
//최대 생성 가능한 동전 아이템 개수
const int MAX_COIN_COUNT = 50;
여러 번 개수를 바꿔가며 테스트해보니 최대 50개 생성하도록 제한하는 것이 좋아보인다.
void ANBC_Task8GameMode::NextWave()
{
//웨이브 증가
++currentWave;
UE_LOG(LogProcess, Warning, TEXT("Wave %d Start"), currentWave);
//동전 아이템 생성 (시작 개수 + (웨이브 - 1) * 2), 최대 개수 준수
int makingValue = START_COIN_COUNT + (currentWave - 1) * 2;
if (makingValue > MAX_COIN_COUNT)
makingValue = MAX_COIN_COUNT;
AItemManager::GetInstance()->SpawnItems(makingValue);
}
게임 모드 코드를 수정하여 진행 웨이브 값에 비례하여 한 번 생성마다 동전 개수가 증가하게 만들었다.
B. 이제 동전 아이템 말고도 사용해보자

동전 아이템 말고 나머지 2종류의 아이템도 추가하도록 만든다.
//아이템 생성
void SpawnItems(int itemID, int spawnCount);
아이템 생성 함수에서 매개 변수를 1개 더 추가한다. 이 매개 변수는 어떤 아이템을 생성할 것인지 구분할 목적으로 사용될 것이다.
void AItemManager::SpawnItems(int itemID, int spawnCount)
{
//1개 이상 생성
if (spawnCount <= 0)
return;
//생성할 아이템 종류
TSubclassOf<AItemActor> makingItem;
switch (itemID)
{
case 1://동전
makingItem = coinItem;
break;
case 2://해골
makingItem = skullItem;
break;
case 3://구급약
makingItem = firstAidItem;
break;
default://제대로된 아이템 번호 필요
return;
}
//현재 캐릭터 위치
FVector curCharPos = playerCharacter->GetActorLocation();
for (int iNum = 0; iNum < spawnCount; ++iNum)
{
//무작위 위치 (캐릭터와 너무 가까운 곳에서 생성하면 안됨)
FVector spawnPoint;
do
{
float x = FMath::RandRange(-MAP_SIZE_HALF, MAP_SIZE_HALF);
float y = FMath::RandRange(-MAP_SIZE_HALF, MAP_SIZE_HALF);
spawnPoint = FVector(x, y, ITEM_FROM_FLOOR);
} while (FVector::DistSquared(spawnPoint, curCharPos) < ITEM_FROM_PLAYER);
//해당 위치에 아이템 생성
GetWorld()->SpawnActor<AItemActor>(makingItem, spawnPoint, FRotator::ZeroRotator);
}
//동전 아이템 추가시 개수 증가
if (itemID == 1)
spawnCoinCount += spawnCount;
}
switch문을 이용해 어떤 아이템을 생성할 것인지 지정하고, 잘못된 아이템 번호에 대한 예외 처리도 한다. 그리고 동전 아이템 개수를 저장하는 변수는 동전 아이템일때만 증가하도록 만들었다.
이제 나머지 두 아이템의 생성 규칙을 정한다. 아무래도 게임 진행의 보조 역할을 하기 때문에 많이 생성되면 안될 것이다. 그래서 몇 개 맵에 직접 배치에서 살펴본 후 정한 규칙은 다음과 같다.
- 처음에는 생성하지 않고 매 3웨이브 마다 1개씩 증가한다.
- 최대 10개 생성한다.
//해당 웨이브마다 해골, 구급약 아이템 개수 증가
const int ADD_SUB_ITEM_WAVE = 3;
//최대 생성 가능한 해골, 구급약 아이템 개수
const int MAX_SUB_ITEM_COUNT = 10;
규칙을 위해 const 변수 2개를 생성하고,
void ANBC_Task8GameMode::NextWave()
{
//웨이브 증가
++currentWave;
UE_LOG(LogProcess, Warning, TEXT("Wave %d Start"), currentWave);
//동전 아이템 생성 (시작 개수 + (웨이브 - 1) * 2), 최대 개수 준수
int makeCoinCount = START_COIN_COUNT + (currentWave - 1) * 2;
if (makeCoinCount > MAX_COIN_COUNT)
makeCoinCount = MAX_COIN_COUNT;
AItemManager::GetInstance()->SpawnItems(1, makeCoinCount);
//해골, 구급약 아이템 생성 (지정한 웨이브당 1 증가), 최대 개수 준수
if (currentWave >= ADD_SUB_ITEM_WAVE)
{
int makeSubItemCount = currentWave / ADD_SUB_ITEM_WAVE;
if (makeSubItemCount > MAX_SUB_ITEM_COUNT)
makeSubItemCount = MAX_SUB_ITEM_COUNT;
AItemManager::GetInstance()->SpawnItems(2, makeSubItemCount);
AItemManager::GetInstance()->SpawnItems(3, makeSubItemCount);
}
}
다음 웨이브로 넘어가는 함수의 내용을 규칙에 맞게 수정한다.

수정 후 컴파일 해보니 완벽해 보였다. 문제가 발견되기 전까지는
C. 새로운 문제 발생

해골 아이템과 구급약 아이템을 획득하지 않으면 사라지지 않고 계속해서 맵에 남는다. 그래서 동전 아이템만 획득하면 해골 아이템과 구급약 아이템만 엄청나게 쌓일 것이다. 당연히 내가 의도한 상황은 아니다.
매 웨이브 시작마다 해골, 구급약 아이템을 삭제하는 기능을 만들면 해결될 것이다.
//아이템 제거
void DeleteItem(AActor* target);
//모든 아이템 제거
void DeleteAllItems();
아이템 매니저의 헤더에 아이템 삭제를 위한 함수를 public으로 선언한다. 아이템 하나를 지정하면 그 아이템을 삭제하는 함수와 모든 아이템을 삭제하는 함수다.
//맵에 존재하는 아이템 목록
UPROPERTY()
TSet<AActor*> allItems;
그 다음 언리얼 엔진용 Set인 TSet을 만든다. 아이템 생성시 여기에 그 아이템을 추가하고 아이템 제거시 여기에서도 제거할 것이다.
void AItemManager::DeleteItem(AActor* target)
{
//올바른 아이템을 지젇해야 함
if (!::IsValid(target))
return;
//아이테 제거
int result = allItems.Remove(target);
if (result != 0)
GetWorld()->DestroyActor(target);
}
void AItemManager::DeleteAllItems()
{
//allItems에 모든 아이템을 맵에서 삭제
for (AActor* item : allItems)
{
if (!::IsValid(item))
continue;
GetWorld()->DestroyActor(item);
}
//마지막으로 allItems도 비운다.
allItems.Empty();
}
앞서 선언했던 두 함수의 내용을 만든다.
.
여기서 잘 보면 두 함수에서 액터 포인터가 유효한지 검사할 때 nullptr인지 확인하지 않고 IsValid 함수를 쓰는 것을 볼 수 있다. 이렇게 확인하는 이유는 언리얼 엔진에서는 더 이상 쓰지 않지만 언어적으로는 null이 아닌 상황이 발생할 수도 있기 때문이다. (언리얼 엔진의 공식 문서 중 IsValid 함수의 내용을 보면 삭제될 예정이지만 null이 아닌 경우에도 유효하지 않다고 판단한다.)
for (int iNum = 0; iNum < spawnCount; ++iNum)
{
//무작위 위치 (캐릭터와 너무 가까운 곳에서 생성하면 안됨)
FVector spawnPoint;
do
{
float x = FMath::RandRange(-MAP_SIZE_HALF, MAP_SIZE_HALF);
float y = FMath::RandRange(-MAP_SIZE_HALF, MAP_SIZE_HALF);
spawnPoint = FVector(x, y, ITEM_FROM_FLOOR);
} while (FVector::DistSquared(spawnPoint, curCharPos) < ITEM_FROM_PLAYER);
//해당 위치에 아이템을 생성하고 allItms에 추가
AActor* spawnedItem = GetWorld()->SpawnActor<AItemActor>(makingItem, spawnPoint, FRotator::ZeroRotator);
allItems.Add(spawnedItem);
}
아이템 생성 함수에서 생성한 아이템을 TSet에 넣도록 수정한다.
//웨이브 증가
++currentWave;
UE_LOG(LogProcess, Warning, TEXT("Wave %d Start"), currentWave);
//맵에 남아있는 모든 아이템 제거
AItemManager::GetInstance()->DeleteAllItems();
//동전 아이템 생성 (시작 개수 + (웨이브 - 1) * 2), 최대 개수 준수
int makeCoinCount = START_COIN_COUNT + (currentWave - 1) * 2;
if (makeCoinCount > MAX_COIN_COUNT)
makeCoinCount = MAX_COIN_COUNT;
모든 아이템을 삭제 함수는 게임 모드에서 다음 웨이브로 넘어가는 함수에서 호출하도록 만든다.
void AItemActor::OnCharacterOverlap(UPrimitiveComponent* overlappedComp, AActor* otherActor,
UPrimitiveComponent* otherComp, int otherBodyIndex, bool fromSweep, const FHitResult& sweepResult)
{
//아이템 효과 적용
AItemManager::GetInstance()->InvokeItemEffect(itemID);
//이 아이템 삭제
AItemManager::GetInstance()->DeleteItem(this);
}
기존에는 획득 아이템 삭제를 아이템 액터에서 그냥 Destroy함수를 호출하는 것으로 구현했는데 이를 수정하여 자신을 아이템 매니저에서 삭제하도록 만든다.

12. 아이템의 기능 구현
지금까지 아이템을 획득하면 로그로 출력하기만 했는데 진짜로 아이템 내용을 구현해보려고 한다.
A. 동전 아이템 : 점수 시스템 구현하기
동전 아이템의 의도는 다음 웨이브로 넘어가기 위한 조건도 있지만 그 아이템 자체는 점수를 위한 것이다.
public:
//점수 추가
void AddScore(int addValue);
private:
//점수
int score = 0;
게임 모드에 점수를 저장하는 변수를 private로 만들고 이 값을 증가시키는 함수를 public으로 선언했다.
void ANBC_Task8GameMode::BeginPlay()
{
Super::BeginPlay();
score = 0;
currentWave = 0;
NextWave();
}
void ANBC_Task8GameMode::AddScore(int addValue)
{
score += addValue;
UE_LOG(LogTemp, Warning, TEXT("Score : %d"), score);
}
게임 모드의 BeginPlay 함수에 점수를 0점으로 초기화 하는 코드를 추가하고 점수 증가 함수의 내용을 만든다.
//ItemManager.h
private:
//동전 아이템의 점수
const int COIN_SCORE = 100;
//ItemManager.cpp
void AItemManager::InvokeItemEffect(int itemID)
{
switch (itemID)
{
case 1:
{
ANBC_Task8GameMode* gameMode = Cast<ANBC_Task8GameMode>(GetWorld()->GetAuthGameMode());
if (gameMode != nullptr)
{
//점수 추가
gameMode->AddScore(COIN_SCORE);
//모든 동전 획득시 다음 웨이브
if (--spawnCoinCount == 0)
gameMode->NextWave();
}
}
break;
case 2:
UE_LOG(LogTemp, Warning, TEXT("Skull"));
break;
case 3:
UE_LOG(LogTemp, Warning, TEXT("First Aid"));
break;
default:
break;
}
}
아이템 매니저의 헤더에 동전 아이템의 점수를 const변수로 선언한다. 그 다음 게임 모드에서 선언한 점수 추가 함수를 동전 아이템 구간에서 사용한다.
참고로 위 코드처럼 어느 한 case 구간에 지역 변수를 선언할 경우 하위 case에 영향을 주는 건지 아닌 건지 알 수 없기 때문에 대괄호를 사용하여 에러를 막아야 한다.

B. 해골, 구급약 아이템 : 디버프 시스템 구현하기
해골 아이템은 딱 봐도 피하라고 만든 아이템이다. 그리고 구급약은 안 좋은 상황일 때 그 상황을 해결해주는 것처럼 보인다.
나는 해골 아이템 획득시 느려지는 디버프를 걸고 이 디버프를 해제하기 위한 조건으로 구급약 아이템 획득으로 생각했다. 이제 이 내용을 구현한다.


미리 제작된 캐릭터 블루프린트를 보면 캐릭터 무브먼트 컴포넌트에 설정된 캐릭터의 이동 속도는 500이다. 해골 아이템 획득시 이 값을 줄이고 구급약 아이템 획득시 이 값을 원래대로 바꾸면 되겠다.

public:
//속도 디버프
void SpeedDebuff();
//속도 복구
void RestoreSpeed();
private:
//기본 이동 속도
const float DEFAULT_MOVE_SPEED = 500.0F;
//디버프시 이동 속도
const float DEBUFF_MOVE_SPEED = 300.0F;
캐릭터 이동속도를 건드리는 함수와 관련 const함수를 정의한다. 캐릭터와 직접 관련된 내용을 건드리기 때문에 미리 작성된 캐릭터 코드에서 정의한다. 당연하지만 미리 작성된 캐릭터 코드는 건드리지 않고 제일 하단에 정의한다.
void ANBC_Task8Character::SpeedDebuff()
{
GetCharacterMovement()->MaxWalkSpeed = DEBUFF_MOVE_SPEED;
}
void ANBC_Task8Character::RestoreSpeed()
{
GetCharacterMovement()->MaxWalkSpeed = DEFAULT_MOVE_SPEED;
}
정의한 함수의 내용을 구현한다. 이 역시 다른 코드를 건드리지 않고 최하단에서 구현한다.
//ItemManager.h
private:
//캐릭터 액터
UPROPERTY()
class ANBC_Task8Character* playerCharacter = nullptr;
//ItemManager.cpp
void AItemManager::BeginPlay()
{
Super::BeginPlay();
playerCharacter = Cast<ANBC_Task8Character>(GetWorld()->GetFirstPlayerController()->GetPawn());
}
위에 정의한 함수는 아이테 매니저에서 캐릭터 함수를 가져와서 사용할 그냥 액터로 지정했던 캐릭터 변수를 정확한 캐릭터 클래스로 변경한다. 그리고 BeginPlay에서 캐릭터 액터를 가져오는 부분은 캐릭터 클래스로 캐스팅하도록 수정한다.
void AItemManager::InvokeItemEffect(int itemID)
{
switch (itemID)
{
case 1:
{
ANBC_Task8GameMode* gameMode = Cast<ANBC_Task8GameMode>(GetWorld()->GetAuthGameMode());
if (gameMode != nullptr)
{
//점수 추가
gameMode->AddScore(COIN_SCORE);
//모든 동전 획득시 다음 웨이브
if (--spawnCoinCount == 0)
gameMode->NextWave();
}
}
break;
case 2:
UE_LOG(LogTemp, Warning, TEXT("Skull"));
playerCharacter->SpeedDebuff();
break;
case 3:
UE_LOG(LogTemp, Warning, TEXT("First Aid"));
playerCharacter->RestoreSpeed();
break;
default:
break;
}
}
아이템 효과 적용 함수에서 각 아이템에 대응되는 위치에서 적절한 함수를 호출한다.
13. UI 제작

진행하기 전에 먼저 확인할 것이 있다. IDE에서 파일 목록을 잘 보면 Build.cs파일이 있는데, 이 파일을 열어본다.

이 파일은 어떤 모듈을 추가로 사용할 것인지를 정하는 코드다. 미리 추가된 모듈 중 UMG가 있는지 확인해야 한다. 만약 없다면 직접 추가해야 한다. (추가할 경우 PrivateDependencyModuleNames에 적는다.) 나 같은 경우 UMGEditor라는 것이 대신 있었는데 이는 에디터에 의존하는 모듈이니 UMG로 바꿨다.
A. 코드와 블루프린트 생성

UI를 만들려면 유저 위젯(User Widget)이 필요하다. 유저 위젯을 다루는 C++코드를 생성하려면 생성 화면에서 상단의 모든 클래스를 눌러 검색을 하면 선택할 수 있다.

그리고 위젯 블루프린트만을 위한 폴더를 생성한다. 이 폴더에서 위젯 블루프린트를 만든다.

이때 코드로 생성한 클래스를 상속받도록 한다.
B. 기본적인 UI 구성

생성한 블루프린트의 하단을 잘 보면 해상도가 1280 x 720으로 설정된 것을 볼 수 있다. 요즘 화면 크기가 Full HD를 기본으로 잡는다는 것을 생각하면 이 상태로 만들면 크게 보일 수 있다.

화면 상단에 화면 크기를 누르면 미리 저장된 세팅이 있는데 여기서 1920 x 1080인 세팅을 고른다.


팔레트 패널에서 캔버스 패널(Canvas Panel)을 찾는다. 그리고 그것을 계층 구조 패널로 드래그 하면 UI를 배치할 수 있는 공간이 생긴다.

같은 방법으로 텍스트(Text)를 생성한다. 이 텍스트는 2개 만든다. 하나는 점수 표시용이고 다른 하나는 웨이브 표시용이다.

생성한 텍스트는 용도에 맞게 이름을 변경한다. 각각 코드와 연동할 것이니 코드상 변수명이라고 생각하고 명명한다.

점수 표기용 텍스트를 오른쪽 상단에 배치할 생각이다. 앵커를 누르면 어디를 기준으로 할지 정할 수 있는데 오른쪽 위쪽을 고른다.

두 텍스트의 크기와 위치를 정한다. X위치, Y위치는 앵커를 기준으로 움직임을 기억하자. 크기 지정시 텍스트에 기본 내용를 넣어본 뒤에 X크기, Y크기를 정하면 편리하게 정할 수 있다. 오른쪽에 배치한 텍스트는 양쪽맞춤 설정을 오른쪽으로 변경한다.

작성을 다 했으면 컴파일하고 저장한다.
C. 화면에 추가하기

유저 위젯은 플레이어 컨트롤러에서 추가한다. 따로 만들어진 플레이어 컨트롤러는 없기 때문에 직접 C++코드로 생성한다.
protected:
virtual void BeginPlay() override;
public:
//위젯 반환
class UMainUserWidget* GetUserWidget();
private:
//위젯
UPROPERTY(EditAnywhere, Category = "User Widget", meta = (AllowPrivateAccess = "true"))
UMainUserWidget* mainUserWidget;
생성한 플레이어 컨트롤러 클래스의 헤더에 BeginPlay 함수와 위젯을 반환하는 함수, 그리고 타입이 위젯인 변수를 선언한다.
void AMyPlayerController::BeginPlay()
{
Super::BeginPlay();
if (mainUserWidget != nullptr)
mainUserWidget->AddToViewport();
}
UMainUserWidget* AMyPlayerController::GetUserWidget()
{
return mainUserWidget;
}
BeginPlay 함수의 내용으로 위젯을 화면에 추가하는 코드를 사용한다. 그리고 위젯을 반환하는 함수도 구현한다.


컴파일을 한 다음 코드로 생성한 플레이어 컨트롤러를 상속받는 블루프린트를 만든다.

생성한 블루프린트를 열고 디테일 패널에서 블루프린트로 생성한 유저 위젯을 할당한다.


그리고 지금까지 코드로만 존재했던 게임모드를 기반으로 블루프린트를 만든다.

생성한 블루프린트의 디테일을 보면 미리 작성된 코드의 영향으로 일부가 자동으로 할당됨을 볼 수 있다. 다만 내가 만든 플레이어 컨트롤러는 그렇지 않으니 직접 지정한다.

월드 세팅 패널을 연다. 여기서 게임 모드 오버라이드 라는 것이 있는데 이것을 블루프린트로 생성한 게임모드로 지정해준다.

여기까지 내용을 저장 및 컴파일하고 실행해보면 화면에 잘 나오는 것을 볼 수 있다.
D. 데이터와 연동하기
private:
//웨이브 표기용 텍스트
UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidget))
class UTextBlock* waveTextBlock;
//점수 표기용 텍스트
UPROPERTY(EditAnywhere, meta = (AllowPrivateAccess = true, BindWidget))
UTextBlock* scoreTextBlock;
유저 위젯 클래스의 헤더에 텍스트에 해당되는 UTextBlock타입의 변수를 선언한다. 변수명은 반드시 텍스트의 이름과 동일해야 하며 UPROPERTY안에 meta = (BindWidget)를 적어야 한다. (나는 private변수로 선언해서 AllowPrivateAccess = true와 같이 적었다.)

바로 컴파일한 다음 위젯 블루프린트를 열어본다. 그리고 위젯 바인드 패널을 열어볼 때 사진과 같이 체크표시가 뜨면 성공이다. 이제 코드에서 해당 변수를 통해 텍스트를 제어할 수 있다.
protected:
void NativeConstruct() override;
public:
void SetWave(int wave);
void SetScore(int score);
위젯 클래스는 BeginPlay 함수가 없고 NativeConstruct 함수가 대신한다. 그리고 점수, 웨이브를 세팅할 수 있도록 public 함수도 만든다.
void UMainUserWidget::NativeConstruct()
{
Super::NativeConstruct();
SetScore(0);
}
void UMainUserWidget::SetWave(int wave)
{
FString waveString = FString::Printf(TEXT("Wave %d"), wave);
waveTextBlock->SetText(FText::FromString(waveString));
}
void UMainUserWidget::SetScore(int score)
{
FString scoreString = FString::Printf(TEXT("%d"), score);
scoreTextBlock->SetText(FText::FromString(scoreString));
}
각 함수를 적절한 내용으로 정의한다.
여기서 참고할 점은, 위젯의 텍스트 내용을 설정하는 SetText함수는 매개변수가 FText타입이다. 그래서 int값을 FString으로 변경한 다음 FText로 변경하는 과정을 거쳤다.
void ANBC_Task8GameMode::AddScore(int addValue)
{
score += addValue;
AMyPlayerController* playerController = Cast<AMyPlayerController>(GetWorld()->GetFirstPlayerController());
if (playerController != nullptr)
{
UMainUserWidget* widget = playerController->GetUserWidget();
widget->SetScore(score);
}
}
게임 모드에서 웨이브 증가, 점수 추가 부분에서 로그로 출력한 부분을 지우고 UI에 반영하도록 코드를 수정한다.
E. 게임이 터진다
그런데 컴파일을 해보니 시작하자마자 튕기는 문제가 생긴다.
//NBC_Task8GameMode.cpp
void ANBC_Task8GameMode::BeginPlay()
{
Super::BeginPlay();
score = 0;
currentWave = 0;
NextWave();
}
//MyPlayerController.cpp
void AMyPlayerController::BeginPlay()
{
Super::BeginPlay();
if (mainUserWidget != nullptr)
mainUserWidget->AddToViewport();
}
문제가 무엇인고 하니 유저 위젯은 플레이어 컨트롤러의 BeginPlay에서 생성하는데 문제는 게임 모드의 BeginPlay에서 위젯에 접근하는 코드가 있기 때문이다. 게임을 시작하자마자 위젯 생성과 접근이 동시에 진행되어 문제가 발생하는 것이다.

여기서 내가 해결한 방법은 이렇다. 게임 시작시 웨이브 1, 점수가 0점일 테니 위젯 블루프린트에서 초기 텍스트를 이렇게 만들었다.
void UMainUserWidget::SetWave(int wave)
{
if (waveTextBlock == nullptr)
return;
FString waveString = FString::Printf(TEXT("Wave %d"), wave);
waveTextBlock->SetText(FText::FromString(waveString));
}
void UMainUserWidget::SetScore(int score)
{
if (scoreTextBlock == nullptr)
return;;
FString scoreString = FString::Printf(TEXT("%d"), score);
scoreTextBlock->SetText(FText::FromString(scoreString));
}
그 다음 각 텍스트 세팅 함수의 앞 부분에 해당 변수가 null인지 체크하는 코드를 추가했다. 위젯 블루프린트에서 초기 텍스트를 설정했으니 NativeConstruct 함수는 지웠다.

컴파일 및 테스트가 잘 되는거 보니 문제는 잘 해결된듯 한다.
'언리얼엔진 학습 및 과제' 카테고리의 다른 글
| 게임 루프 및 UI 재설계 과제 (8) (0) | 2025.10.17 |
|---|---|
| 게임 루프 및 UI 재설계 과제 (7) (0) | 2025.10.16 |
| 게임 루프 및 UI 재설계 과제 (5) (0) | 2025.10.14 |
| 게임 루프 및 UI 재설계 과제 (4) (0) | 2025.10.02 |
| 게임 루프 및 UI 재설계 과제 (3) (0) | 2025.10.01 |