도전 과제 2번 - 웨이브별 환경의 변화
- 웨이브가 올라갈수록 맵 환경이나 플레이 상황 자체가 달라지게 설계해봅시다. 각 웨이브마다 ‘새로운 장애물’이나 ‘무작위 이벤트’, ‘맵 변형’ 등을 도입해 난이도를 높일 수 있습니다. 예를 들어,
- Wave 1: 기존 로직 그대로
- Wave 2: 맵에 새로운 장애물이 추가됩니다. (예: 바닥에서 일정 간격으로 스파이크가 솟아오르는 지역)
- Wave 3: 맵에 무작위 폭발 지점이 추가되어, 일정 시간마다 플레이어 주변에 충돌 이벤트가 발생합니다.
- “스파이크 함정이 활성화되었습니다!” 등 이벤트 발생을 UI로 알려서 플레이어가 상황을 인지할 수 있어야 합니다.
웨이브1, 2, 3 맵을 만들면서 이미 새로운 장애물을 추가해서 도전과제 3번을 먼저 구현한 후 시간이 남아서 웨이브 2에 랜덤한 위치에 폭발 하기까지 구현을 했다.
ExplosionVolume C++클래스를 새로 생성하고 씬 컴포넌트, 박스 컴포넌트를 생성하고 볼륨 안의 랜덤한 위치를 반환할 함수와 폭발을 스폰할 함수를 선언했다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ExplosionVolume.generated.h"
class UBoxComponent;
UCLASS()
class UNREAL_CPP_STUDY_API AExplosionVolume : public AActor
{
GENERATED_BODY()
public:
AExplosionVolume();
UPROPERTY(VisibleAnywhere)
USceneComponent* Scene;
UPROPERTY(VisibleAnywhere)
UBoxComponent* ExplosionBox;
FVector RandomPointInVolume();
void SpawnExplosioning();
};
#include "ExplosionVolume.h"
#include "Components/BoxComponent.h"
AExplosionVolume::AExplosionVolume()
{
PrimaryActorTick.bCanEverTick = false;
Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
SetRootComponent(Scene);
ExplosionBox = CreateDefaultSubobject<UBoxComponent>(TEXT("ExplosionBox"));
ExplosionBox->SetupAttachment(Scene);
}
볼륨 안의 랜덤한 위치를 반환하는 함수까지 구현을 했다.
FVector AExplosionVolume::RandomPointInVolume()
{
const FVector BoxExtent = ExplosionBox->GetScaledBoxExtent();
const FTransform BoxTransform = ExplosionBox->GetComponentTransform();
FVector Local = FVector(FMath::FRandRange(-BoxExtent.X, BoxExtent.X), FMath::FRandRange(-BoxExtent.Y, BoxExtent.Y), FMath::FRandRange(-BoxExtent.Z, BoxExtent.Z));
return BoxTransform.TransformPositionNoScale(Local);
}
이제 볼륨 안의 랜덤한 위치에 폭발이 일어나야 하므로 폭발을 일으킬 클래스를 생성했다.
폭발을 일으킬 범위와 캐릭터가 겹쳤을 때 데미지를 주기 위한 변수와 함수를 선언했다.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Explosioning.generated.h"
class USphereComponent;
UCLASS()
class UNREAL_CPP_STUDY_API AExplosioning : public AActor
{
GENERATED_BODY()
public:
AExplosioning();
UPROPERTY(VisibleAnywhere)
USceneComponent* Scene;
UPROPERTY(VisibleAnywhere)
USphereComponent* ExplosionRange;
UPROPERTY(EditAnywhere, Category = "Explosion")
float ExplosionRadius;
UPROPERTY(EditAnywhere, Category = "Explosion")
int32 ExplosionDamage;
void Explosion();
};
#include "Explosioning.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
AExplosioning::AExplosioning()
{
ExplosionRadius = 400.0f;
ExplosionDamage = 50.0f;
PrimaryActorTick.bCanEverTick = false;
Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
SetRootComponent(Scene);
ExplosionRange = CreateDefaultSubobject<USphereComponent>(TEXT("ExplosionRange"));
ExplosionRange->SetupAttachment(Scene);
ExplosionRange->InitSphereRadius(ExplosionRadius);
}
void AExplosioning::Explosion()
{
TArray<AActor*> OverlappingActors;
ExplosionRange->GetOverlappingActors(OverlappingActors);
for (AActor* Actor : OverlappingActors)
{
if (Actor && Actor->ActorHasTag("Player"))
{
UGameplayStatics::ApplyDamage(
Actor,
ExplosionDamage,
nullptr,
this,
UDamageType::StaticClass()
);
}
}
}
에디터에서 스태틱 메시를 폭발 범위가 땅에 표시되도록 납작한 원으로 설정하고 Sphere 컴포넌트의 크기에 맞췄다.

다시 ExplosionVolume로 가서 ExplosionVolume 안에서 랜덤한 위치에 Explosioning이 스폰이 되도록 로직을 구현했다.
// ExplosionVolume.h
UPROPERTY(EditDefaultsOnly)
TSubclassOf<AExplosioning> Explosioning;
// ExplosionVolume.cpp
void AExplosionVolume::SpawnExplosioning()
{
GetWorld()->SpawnActor<AActor>(
Explosioning,
RandomPointInVolume(),
ExplosionBox->GetComponentRotation()
);
}
에디터에서 Explosioning에 BP_Explosioning을 넣는다.

이제 스폰하는 함수를 웨이브 2에서 호출이 되도록 JinGameState의 StartLevel 함수에서 로직을 구현했다.
맵에 배치된 ExplosionVolume을 배열에 담아서 원소를 AExplosionVolume으로 캐스팅 한 후 SetTimer를 호출했다.
// JinGameState.h
TArray<AActor*> FoundExplosionVolumes; // 맵에 배치된 ExplosionVolume을 담을 배열
FTimerHandle ExplosionTimerHandle;
void AJinGameState::StartLevel()
{
bEndLevel = false;
bIsTrigger = false;
if (UGameInstance* GameInstance = GetGameInstance())
{
if(UJinGameInstance* JinGameInstance = Cast<UJinGameInstance>(GameInstance))
{
CurrentLevelIndex = JinGameInstance->CurrentLevelIndex;
UE_LOG(LogTemp, Warning, TEXT("Level: %d Start!"), CurrentLevelIndex + 1);
switch (CurrentLevelIndex) // 레벨에 따라 스폰 위치 정하기
{
case 1:
StartLocation = { -5200, 6310, 92 };
UGameplayStatics::GetAllActorsOfClass(GetWorld(), AExplosionVolume::StaticClass(), FoundExplosionVolumes);
if (AExplosionVolume* ExplosionVolume = Cast<AExplosionVolume>(FoundExplosionVolumes[0]))
{
GetWorldTimerManager().SetTimer(
ExplosionTimerHandle,
ExplosionVolume,
&AExplosionVolume::SpawnExplosioning,
3.0f,
true
);
}
break;
이렇게 한 후 테스트를 해보니 웨이브 2에서 빨간 원이 3초마다 생성되는 것이 확인이 됐다.
하지만 현재는 빨간원이 스폰이 된 후 터지지 않고 그대로 남아있다.
스폰이 되고 몇 초 후 폭발을 하며 데미지를 입히고 사라지도록 구현을 해야한다.
데미지를 주는 함수에 Destroy()를 추가했다.
// Explosioning.cpp
void AExplosioning::Explosion()
{
TArray<AActor*> OverlappingActors;
ExplosionRange->GetOverlappingActors(OverlappingActors);
for (AActor* Actor : OverlappingActors)
{
if (Actor && Actor->ActorHasTag("Player"))
{
UGameplayStatics::ApplyDamage(
Actor, // 데미지를 입을 액터
ExplosionDamage, // 입힐 데미지 양
nullptr,
this,
UDamageType::StaticClass()
);
}
}
Destroy();
}
이제 스폰이 되고 몇 초 후에 Explosion 함수가 호출이 되도록 BeginPlay 함수에 SetTimer를 사용해서 구현했다.
// Explosioning.h
FTimerHandle ExplosionTimerHandle;
// Explosioning.cpp
void AExplosioning::BeginPlay()
{
Super::BeginPlay();
GetWorldTimerManager().SetTimer(
ExplosionTimerHandle,
this,
&AExplosioning::Explosion,
1.5f,
false
);
}
이렇게 하고 테스트를 해보니 스폰도 잘 되고 범위 안에 있으면 데미지를 입고 체력이 깎이는 것도 확인이 됐다.
이제 터지는 효과음과 파티클을 넣고 마무리할 예정이다.
Explosioning에 파티클시스템과 사운드베이스를 추가했다.
// Explosioning.h
UPROPERTY(EditAnywhere)
UParticleSystem* ExplosionParticle;
UPROPERTY(EditAnywhere)
USoundBase* ExplosionSound;
void AExplosioning::Explosion()
{
if (ExplosionParticle)
{
UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
ExplosionParticle,
GetActorLocation(),
GetActorRotation(),
FVector(4.0f, 4.0f, 4.0f),
true
);
}
if (ExplosionSound)
{
UGameplayStatics::PlaySoundAtLocation(
GetWorld(),
ExplosionSound,
GetActorLocation()
);
}
TArray<AActor*> OverlappingActors;
ExplosionRange->GetOverlappingActors(OverlappingActors);
for (AActor* Actor : OverlappingActors)
{
if (Actor && Actor->ActorHasTag("Player"))
{
UGameplayStatics::ApplyDamage(
Actor, // 데미지를 입을 액터
ExplosionDamage, // 입힐 데미지 양
nullptr,
this,
UDamageType::StaticClass()
);
}
}
Destroy();
}
테스트를 해봤는데 랜덤 범위가 너무 넓어서 폭발을 맞는게 운이 좋아야 맞는 수준이다.
그래서 한번에 여러 랜덤 위치에서 폭발을 하도록 SpawnExplosioning 함수에 반복문을 사용했다.
// ExplosionVolume.h
UPROPERTY(EditAnywhere)
int32 ExplosionNum;
// ExplosionVolume.cpp
// 생성자
ExplosionNum = 3;
void AExplosionVolume::SpawnExplosioning()
{
for(int i = 0; i < ExplosionNum; ++i)
{
GetWorld()->SpawnActor<AActor>(
Explosioning,
RandomPointInVolume(),
ExplosionBox->GetComponentRotation()
);
}
}
한번에 3개가 터지도록 구현했더니 생각보다 어려워서 함정 아이템의 스폰 확률을 낮추고 힐링포션과 코인의 스폰 확률을 높혔다.

도전 과제 3번 - 고급 UI 연출하기 (애니메이션 & 3D 위젯 활용)
- UI 애니메이션 1개 이상 구현
- Widget Animation을 이용해 HUD나 메뉴의 Fade In/Out, 슬라이드 애니메이션 등을 적용합니다.
- 버튼 hover 상태에 Tween 또는 강조 효과를 주어, 사용자 클릭 욕구를 높이는 연출을 넣어봅니다.
- 3D 위젯 1개 이상 구현
- 캐릭터 머리 위 체력바, 아이템 상호작용 안내 문구 등을 3D 공간에 위젯을 표시해봅니다.
- World Space 모드나 Screen Space 모드 중 선택, 적절한 마스크·배경 처리를 합니다.
- (선택) 마우스로 3D 위젯을 직접 클릭하여 상호작용할 수 있게 만들어봅시다.
- 상호작용 아이템 “Press E to pick up” 등
이미 MainMenu의 게임 제목과 GameOver에 UI 애니메이션 효과를 줬으므로 버튼 hover 상태에 Tween 또는 강조 효과를 주기를 구현해 볼 예정이다.
버튼 hover 상태와 Tween이 어떤 의미인지 몰라서 GPT에게 물어봤더니 hover는 마우스를 버튼 위에 올린상태, Tween은 시작 값에서 끝 값을 부드럽게 보간하는 애니메이션을 뜻한다고 한다.
버튼 위젯마다 애니메이션을 생성하고 모두 크기가 증가하는 애니메이션으로 만들었다.

위젯 블루프린트 이벤트 그래프에서 각 버튼마다 마우스를 올리면 애니메이션을 재생, 마우스를 내리면 애니메이션을 역재생하도록 만들었다.

마지막 3D 위젯을 구현하기 위해 화면 왼쪽에 표시됐던 디버프 아이템 효과의 남은 시간을 캐릭터의 머리 위에 표시할 예정이다.
새로운 위젯 블루프린트를 만들어서 캔버스에 텍스트 블럭을 2개 추가하여 디버프 효과를 적어줬다.

그리고 강의에서 미리 만들었던 캐릭터의 WidgetComponent를 활용해서 만든 WBP를 캐릭터에 붙여준다.
// JinCharacter.h
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI")
UWidgetComponent* DebuffTTimerWidget;
// JinCharacter.cpp
// 생성자
DebuffTimerWidget = CreateDefaultSubobject<UWidgetComponent>(TEXT("DebuffTimerWidget"));
DebuffTimerWidget->SetupAttachment(GetMesh());
DebuffTimerWidget->SetWidgetSpace(EWidgetSpace::Screen);


뷰포트에서 캐릭터의 왼쪽에 오도록 위치를 조절했다.

이제 효과가 지속중일때만 텍스트가 보이고 남은 시간이 줄어들도록 구현해야한다.
// JinCharacter.cpp
void AJinCharacter::UpdateDebuffTimer()
{
if (!DebuffTimerWidget) return;
UUserWidget* DebuffTimerWidgetInstance = DebuffTimerWidget->GetUserWidgetObject();
if (!DebuffTimerWidgetInstance) return;
if (UTextBlock* SlowText = Cast<UTextBlock>(DebuffTimerWidgetInstance->GetWidgetFromName(TEXT("Slow"))))
{
if (bIsSlowing)
{
float RemainingSlowingTimer = GetWorldTimerManager().GetTimerRemaining(SlowingTimerHandle);
SlowText->SetVisibility(ESlateVisibility::Visible);
SlowText->SetText(FText::FromString(FString::Printf(TEXT("Slow : %.1f"), RemainingSlowingTimer)));
}
else
{
SlowText->SetVisibility(ESlateVisibility::Hidden);
}
}
if (UTextBlock* ReverseText = Cast<UTextBlock>(DebuffTimerWidgetInstance->GetWidgetFromName(TEXT("Reverse"))))
{
if (bIsReverseControl)
{
float RemainingReverseTimer = GetWorldTimerManager().GetTimerRemaining(ReverseControlTimerHandle);
ReverseText->SetVisibility(ESlateVisibility::Visible);
ReverseText->SetText(FText::FromString(FString::Printf(TEXT("Reverse : %.1f"), RemainingReverseTimer)));
}
else
{
ReverseText->SetVisibility(ESlateVisibility::Hidden);
}
}
}
디버프 효과가 지속중일때만 텍스트블럭이 보이도록 구현은 했는데 타이머를 사용해서 이 함수를 0.1초마다 호출해서 업데이트를 시켜야 0.1초 단위로 디버프 효과 남은 시간이 줄어드는것이 보일것이다.
// JinCharacter.cpp
void AJinCharacter::ActivateSlowItem()
{
bIsSlowing = true;
GetWorld()->GetTimerManager().SetTimer(
SlowingTimerHandle,
this,
&AJinCharacter::SetbIsSlowingFalse,
2.0f,
false
);
GetWorld()->GetTimerManager().SetTimer(
SlowingTimerHandle,
this,
&AJinCharacter::UpdateDebuffTimer,
0.1f,
true
);
}
void AJinCharacter::ActivateReverseControlItem()
{
bIsReverseControl = true;
GetWorld()->GetTimerManager().SetTimer(
ReverseControlTimerHandle,
this,
&AJinCharacter::SetbIsReverseControlFalse,
2.0f,
false
);
GetWorld()->GetTimerManager().SetTimer(
ReverseControlTimerHandle,
this,
&AJinCharacter::UpdateDebuffTimer,
0.1f,
true
);
}
위처럼 같은 타이머를 0.1초마다 호출하는 타이머에 같이 사용하고 실행을 해보니 0과 0.1만 반복해서 출력하고 원하는대로 출력이 되지 않았다.
하나의 타이머핸들을 두 곳에서 사용하면 전에 사용된 타이머는 덮어씌워지고 나중에 사용된 타이머만 남는다는 것을 깨달았다.
그래서 새로운 업데이트용 타이머 핸들을 Slow, Reverse 각 하나씩 만들어서 적용했다.
// JinCharacter.h
FTimerHandle UpdateSlowTimerHandle;
FTimerHandle UpdateReverseTimerHandle;
// JinCharacter.cpp
void AJinCharacter::ActivateSlowItem()
{
bIsSlowing = true;
GetWorld()->GetTimerManager().SetTimer(
SlowingTimerHandle,
this,
&AJinCharacter::SetbIsSlowingFalse,
2.0f,
false
);
GetWorld()->GetTimerManager().SetTimer(
UpdateSlowTimerHandle,
this,
&AJinCharacter::UpdateDebuffTimer,
0.1f,
true
);
}
void AJinCharacter::ActivateReverseControlItem()
{
bIsReverseControl = true;
GetWorld()->GetTimerManager().SetTimer(
ReverseControlTimerHandle,
this,
&AJinCharacter::SetbIsReverseControlFalse,
2.0f,
false
);
GetWorld()->GetTimerManager().SetTimer(
UpdateReverseTimerHandle,
this,
&AJinCharacter::UpdateDebuffTimer,
0.1f,
true
);
}
이제 제대로 작동이 되었다. 그리고 캐릭터의 위젯컴포넌트를 스크린 모드에서 월드 모드로 바꿨다.
스크린 모드는 유저의 모니터 기준으로 고정되어 있는것이고 월드 모드는 캐릭터 기준으로 고정되어 있는 것이라고 한다.

그리고 화면 왼쪽의 디버프 남은 시간 출력되는 것을 제거했다.

클리어 했을 때 띄울 위젯 블루프린트를 만들었다.
'개인 프로젝트(과제)' 카테고리의 다른 글
| 8번과제 클리어 화면 추가하기 (0) | 2025.10.15 |
|---|---|
| 8번과제 장애물 만들기2, 도전 과제 1번 (0) | 2025.10.13 |
| 8번과제 장애물 만들기 (0) | 2025.10.09 |
| 8번과제 맵 만들기 - 레벨3 (0) | 2025.10.08 |
| 8번과제 레벨 2 맵에 아이템 스폰하기 (0) | 2025.10.07 |