파티클 시각 효과 추가하기
언리얼에는 캐스케이드와 나이아가라 두 가지의 파티클 시스템이 있다. 나이아가라가 더 고급스러운 효과를 낼 수 있지만, 우선 캐스케이드로 실습을 진행한다.
BaseItem.h
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
UParticleSystem* PickupParticle;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
USoundBase* PickupSound;
UParticleSystem과 USoundBase를 추가해준다.
BaseItem.cpp
void ABaseItem::ActivateItem(AActor* Activator)
{
// 파티클과 사운드 재생
if (PickupParticle)
{
UGameplayStatics::SpawnEmitterAtLocation( // 파티클 생성
GetWorld(), // 파티클을 생성할 월드
PickupParticle, // 파티클 에셋
GetActorLocation(), // 파티클 생성 위치
GetActorRotation(), // 파티클 생성 시 각도
true // 파티클 효과가 끝나면 메모리에서 자동제거
);
}
if (PickupSound)
{
UGameplayStatics::PlaySoundAtLocation( // 사운드 재생
GetWorld(), // 사운드를 생성할 월드
PickupSound, // 사운드 에셋
GetActorLocation() // 사운드 재생 위치
);
}
}





각 아이템의 ActivateItem에서 부모 생성자를 호출해준다.
void AMineItem::ActivateItem(AActor* Activator)
{
Super::ActivateItem(Activator);
// 게임 월드 -> 타이머 매니저
// 5초 뒤를 계산하는 타이머 핸들러
GetWorld()->GetTimerManager().SetTimer(ExplosionTimeHandle, this, &AMineItem::Explode, 5.0f, false); // 타이머
void AHealingItem::ActivateItem(AActor* Activator)
{
Super::ActivateItem(Activator);
if (Activator && Activator->ActorHasTag("Player"))
{
if (ASpartaCharacter* PlayerCharacter = Cast<ASpartaCharacter>(Activator)) // 캐릭터 타입 확인
{
PlayerCharacter->AddHealth(HealAmount);
}
// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player Gained %d HP!"), HealAmount));
DestroyItem();
}
}
void ACoinItem::ActivateItem(AActor* Activator)
{
Super::ActivateItem(Activator);
// 액터가 존재하며 플레이어인지 확인
if (Activator && Activator->ActorHasTag("Player"))
{
if (UWorld* World = GetWorld())
{
if (ASpartaGameState* GameState = World->GetGameState<ASpartaGameState>())
{
GameState->AddScore(PointValue);
GameState->OnCoinCollected();
}
}
// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player Gained %d points!"), PointValue));
DestroyItem();
}
}

폭발 사운드와 파티클 추가하기
MineItem.h에 파티클과 사운드 변수를 추가한다.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "BaseItem.h"
#include "MineItem.generated.h"
/**
*
*/
UCLASS()
class SPARTAPROJECT_API AMineItem : public ABaseItem
{
GENERATED_BODY()
public:
AMineItem();
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
USphereComponent* ExplosionCollision;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
float ExplosionDelay; // 폭발 지연시간
// 콜리전 컴포넌트를 이용할 것이므로 삭제
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
float ExplosionRadius; // 폭발 반경
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
int32 ExplosionDamage; // 폭발 대미지
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
UParticleSystem* ExplosionParticle; // 파티클 변수
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effect")
USoundBase* ExplosionSound; // 사운드 변수
// 폭발 시간을 조정하는 타이머 핸들
FTimerHandle ExplosionTimeHandle;
virtual void ActivateItem(AActor* Activator) override;
void Explode(); // 폭발하는 함수
};
#include "MineItem.h"
#include "Kismet/GameplayStatics.h"
#include "Components/SphereComponent.h"
#include "Particles/ParticleSystemComponent.h"
void AMineItem::Explode()
{
UParticleSystemComponent* Particle = nullptr; // 초기화 안 해주면 에러남
// 폭발 파티클 생성
if (ExplosionParticle)
{
Particle = UGameplayStatics::SpawnEmitterAtLocation( // 월드에 파티클 스폰
GetWorld(),
ExplosionParticle,
GetActorLocation(),
GetActorRotation(),
false // true면 파티클의 라이프 사이클 이후 자동으로 사라지지만, Loop 파티클이나 3초 뒤에 사라지는 파티클같은 경우는 조정할 수 없다.
);
}
// 폭발 사운드 생성
if (ExplosionSound)
{
UGameplayStatics::PlaySoundAtLocation(
GetWorld(),
ExplosionSound,
GetActorLocation()
);
}
TArray<AActor*> OverlappingActors;
ExplosionCollision->GetOverlappingActors(OverlappingActors);
for (AActor* Actor : OverlappingActors)
{
if (Actor && Actor->ActorHasTag("Player")) // 액터인지와 플레이어 태그 확인
{
// 데미지 구현
UGameplayStatics::ApplyDamage(Actor, ExplosionDamage, nullptr, this, UDamageType::StaticClass());
// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player damaged %d by MineItem"), ExplosionDamage));
}
}
DestroyItem();
if (Particle)
{
FTimerHandle DestoryParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestoryParticleTimerHandle,
[Particle]() {Particle->DestroyComponent();},
2.0f,
false
);
}
}
BaseItem.cpp
void ABaseItem::ActivateItem(AActor* Activator)
{
UParticleSystemComponent* Particle = nullptr;
// 파티클과 사운드 재생
if (PickupParticle)
{
Particle = UGameplayStatics::SpawnEmitterAtLocation( // 파티클 생성
GetWorld(), // 파티클을 생성할 월드
PickupParticle, // 파티클 에셋
GetActorLocation(), // 파티클 생성 위치
GetActorRotation(), // 파티클 생성 시 각도
true // 파티클 효과가 끝나면 메모리에서 자동제거
);
}
if (PickupSound)
{
UGameplayStatics::PlaySoundAtLocation( // 사운드 재생
GetWorld(), // 사운드를 생성할 월드
PickupSound, // 사운드 에셋
GetActorLocation() // 사운드 재생 위치
);
}
// 파티클은 2초 뒤에 사라진다.
if (Particle)
{
FTimerHandle DestoryParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestoryParticleTimerHandle,
[Particle]() {Particle->DestroyComponent(); },
2.0f,
false
);
}
}

지뢰의 상태를 구분하기 위해 MineItem.h에 bHasExploded라는 bool 변수를 추가한다.
bool bHasExploded;
cpp에 분기를 넣어준다.
void AMineItem::ActivateItem(AActor* Activator)
{
if (bHasExploded) return;
Super::ActivateItem(Activator);
// 게임 월드 -> 타이머 매니저
// 5초 뒤를 계산하는 타이머 핸들러
GetWorld()->GetTimerManager().SetTimer(ExplosionTimeHandle, this, &AMineItem::Explode, 5.0f, false); // 타이머
// Explode로 이동
//TArray<AActor*> OverlappingActors;
//ExplosionCollision->GetOverlappingActors(OverlappingActors);
//for (AActor* Actor : OverlappingActors)
//{
// if (Actor && Actor->ActorHasTag("Player")) // 액터인지와 플레이어 태그 확인
// {
// // 데미지 구현
// GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Player damaged %f by MineItem"), ExplosionDamage));
// }
//}
//DestroyItem();
bHasExploded = true;
}
USoundBase
USoundeBase는 언리얼 엔진의 모든 사운드 에셋이 공유하는 기본 클래스다. SoundWave와 SoundCue 모두 SoundBase를 상속받는다.

트러블 슈팅
1. 마지막까지 코드를 작성하고 나니 레벨이 바뀔 때 파티클이 남아있으면 크래시가 나면서 언리얼 엔진이 멈췄다.
“레벨 전환 + 파티클 + 타이머” 충돌이 일어난 것이다.

if (Particle)
{
FTimerHandle DestoryParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestoryParticleTimerHandle,
[Particle]() {Particle->DestroyComponent(); },
2.0f,
false
);
}
제미나이의 설명
언리얼 엔진에서 흔히 발생하는 '댕글링 포인터(Dangling Pointer)' 혹은 '유효하지 않은 월드 참조' 문제입니다. 레벨 전환이라는 특수한 상황과 람다(Lambda) 캡처의 특성이 맞물려 발생하는 이 현상을 구조적으로 분석해 보겠습니다.
1. 문제의 근본 원인: 지연 실행과 생존 주기(Lifecycle)의 불일치
이 에러의 핵심은 Particle 컴포넌트의 수명보다 람다 함수의 실행 시점이 더 길게 설정되어 있다는 점입니다.
작동 원리 단계별 분석
- 람다 캡처 ([Particle]): SetTimer가 호출될 때, Particle 포인터 변수의 주소값이 람다 내부로 복사됩니다.
- 레벨 전환 (Level Transition): 2초가 지나기 전 레벨이 바뀌면, 언리얼 엔진은 현재 월드의 모든 액터와 컴포넌트를 소멸(Destroy)시킵니다. 이때 Particle 객체도 메모리에서 해제되거나 무효화됩니다.
- 타이머 실행: 2초 뒤, 타이머 시스템은 등록된 람다를 실행하려 합니다.
- 유효하지 않은 참조: 람다 내부의 Particle은 이미 파괴된 객체의 메모리 주소를 가리키고 있습니다. 이 상태에서 ->DestroyComponent()를 호출하면 Access Violation(부조적 메모리 접근) 에러가 발생합니다.
2. 구조적 결함 및 간극 식별 (Reframing)
질문하신 코드에는 세 가지 잠재적 위험 요소가 숨어 있습니다.
- 가정의 오류: "2초 뒤에도 월드와 컴포넌트가 온전할 것"이라는 가정하에 코드가 작성되었습니다.
- 캡처 방식의 한계: 일반 포인터로 캡처하면 해당 객체가 가비지 컬렉션(GC)에 의해 사라졌는지 알 방법이 없습니다.
- 월드 소멸 처리 미비: GetWorld()를 통해 타이머를 걸었지만, 월드 자체가 사라질 때 해당 타이머 핸들이 안전하게 정리되지 않을 수 있습니다.
3. 해결을 위한 복수 경로 및 리스크 관리
방법 A: 약포인터(TWeakObjectPtr) 사용 (권장)
객체가 살아있는지 확인하고 실행하는 가장 안전한 방법입니다.
if (Particle)
{
// 약포인터로 변환하여 캡처
TWeakObjectPtr<UPrimitiveComponent> ParticlePtr(Particle);
FTimerHandle DestroyParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[ParticlePtr]() {
// 실행 시점에 객체가 여전히 유효한지 체크
if (ParticlePtr.IsValid())
{
ParticlePtr->DestroyComponent();
}
},
2.0f,
false
);
}
1. TWeakObjectPtr가 해결사인 이유: "안부 확인"
가장 큰 차이는 객체를 붙잡고 있느냐, 아니면 지켜보고 있느냐의 차이입니다.
- 일반 포인터 ([Particle]): 포인터는 단순히 메모리 주소(예: 0x123)를 복사합니다. 레벨이 넘어가서 해당 주소의 실제 데이터가 삭제되어도, 포인터는 여전히 0x123을 가리킵니다. 이를 댕글링 포인터라고 하며, 여기에 ->Destroy()를 시도하면 크래시가 납니다.
- 약포인터 ([WeakParticle]): 언리얼의 가비지 컬렉터(GC)와 통신하는 특수 스마트 포인터입니다. 객체가 메모리에서 해제되면 내부적으로 자동으로 nullptr 처리가 됩니다.
- if (WeakParticle.IsValid()) 단계에서 "너 아직 살아있니?"라고 엔진에 물어보는 과정이 추가된 것입니다. 레벨 전환으로 이미 파괴되었다면 false가 되어 함수를 그냥 종료하므로 에러가 나지 않습니다.
2. IsValid()의 핵심 역할: "메모리 주소 vs 객체의 수명"
단순히 포인터가 nullptr인지 확인하는 것과 IsValid()를 사용하는 것은 큰 차이가 있습니다.
- 일반 if (Pointer): "이 변수에 메모리 주소값이 들어 있는가?"만 확인합니다.
- IsValid(): "이 메모리 주소에 있는 객체가 실제로 유효(Active)하며, 삭제 대기 중(Pending Kill)이 아닌가?"를 확인합니다.
작동 단계 (내부 로직)
- 포인터 체크: 변수가 비어있는지(nullptr) 먼저 봅니다.
- 플래그 체크: 객체가 존재하더라도, 언리얼 엔진이 Destroy()를 호출하여 "이제 곧 삭제할 것"이라고 표시해둔 상태(RF_BeginDestroyed 등)인지 확인합니다.
- 결과 반환: 주소도 있고 삭제 대기 상태도 아닐 때만 true를 반환합니다.
만약 UObject가 아닌 일반 C++ 클래스를 다룬다면 IsValid()를 쓸 수 없고 nullptr 체크만 가능합니다. 하지만 언리얼의 모든 컴포넌트(UActorComponent)와 액터(AActor)는 UObject를 상속받으므로, 반드시 IsValid()나 TWeakObjectPtr를 사용하는 습관을 들이는 것이 좋습니다
결론: 비동기 실행(Timer, Delegate) 시점에는 반드시 대상의 생존을 재확인해야 한다.
2. 메뉴에서 Start Game을 누르지 않아도 LevelDuration이 지나면 게임이 자동으로 시작되는 현상
SpartaGameState.cpp에서 StartLevel() 호출을 없애준다.
void ASpartaGameState::BeginPlay()
{
Super::BeginPlay();
// UpdateHUD();
// StartLevel(); // 레벨 시작
GetWorldTimerManager().SetTimer(
HUDUpdateTimerHandle,
this,
&ASpartaGameState::UpdateHUD,
0.1f,
true
);
}
그리고 플레이어 컨트롤러의 BeginPlay를 아래와 같이 수정해준다.
void ASpartaPlayerController::BeginPlay()
{
Super::BeginPlay();
// 1. 입력 매핑 설정 (이 부분은 유지)
if (ULocalPlayer* LocalPlayer = GetLocalPlayer())
{
if (UEnhancedInputLocalPlayerSubsystem* Subsystem =
LocalPlayer->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
{
if (InputMappingContext)
{
Subsystem->AddMappingContext(InputMappingContext, 0);
}
}
}
// 2. 일시정지 해제 (이전 레벨의 영향 제거)
SetPause(false);
// 3. 맵 이름에 따른 분기 (중복 제거)
FString CurrentMapName = GetWorld()->GetMapName();
if (CurrentMapName.Contains("MenuLevel"))
{
// 메뉴 맵일 때
ShowMainMenu(false);
}
else
{
// 게임 플레이 맵일 때 (BasicLevel, Level2 등 모두 포함)
// 마우스 커서 숨기고 게임 입력 모드로 변경
bShowMouseCursor = false;
SetInputMode(FInputModeGameOnly());
// GameState를 가져와서 게임 시작!
ASpartaGameState* SpartaGameState = GetWorld() ? GetWorld()->GetGameState<ASpartaGameState>() : nullptr;
if (SpartaGameState)
{
// 여기서 HUD 생성(ShowGameHUD)과 타이머가 동시에 시작.
SpartaGameState->StartLevel();
}
}
}'게임 개발 > 학습 일지' 카테고리의 다른 글
| 언리얼 내배캠 마스터반 수업 과제 - 샌드박스 패턴으로 기존 과제 구조 변경 / 우클릭 조준 구현 (0) | 2026.05.07 |
|---|---|
| 언리얼 심화 - 템플릿 패턴, 샌드박스 패턴 (0) | 2026.05.01 |
| 언리얼 엔진 심화 3주차 - 콜리전, 트레이스 및 데미지 타입 (0) | 2026.04.30 |
| 언리얼 C++ - UI 애니메이션 효과 및 3D 위젯 UI 구현하기 (0) | 2026.04.29 |
| 언리얼 C++ - UI 위젯 설계와 실시간 데이터 연동하기 (0) | 2026.04.27 |