언리얼 C++에서 타이머를 이용해서 일정 시간이 지나면 파티클이 제거되게끔 구현을 하였다.
아래는 구현한 코드
// BaseItem.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ItemInterface.h"
#include "BaseItem.generated.h"
class USphereComponent;
UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
GENERATED_BODY()
public:
ABaseItem();
protected:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item")
FName ItemType;
// 루트 컴포넌트 (씬)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
USceneComponent* Scene;
// 충돌 컴포넌트 (플레이어 진입 범위 감지용)
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
USphereComponent* Collision;
// 아이템 시각 표현용 스태틱 메시
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
UStaticMeshComponent* StaticMesh;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
UParticleSystem* PickupParticle;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
USoundBase* PickupSound;
virtual void OnItemOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult) override;
virtual void OnItemEndOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex) override;
virtual void ActivateItem(AActor* Activator) override;
virtual FName GetItemType() const override;
void DestroyItem();
};
// BaseItem.cpp
#include "BaseItem.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Particles/ParticleSystemComponent.h"
ABaseItem::ABaseItem()
{
PrimaryActorTick.bCanEverTick = false;
// 루트 컴포넌트 생성 및 설정
Scene = CreateDefaultSubobject<USceneComponent>(TEXT("Scene"));
SetRootComponent(Scene);
// 충돌 컴포넌트 생성 및 설정
Collision = CreateDefaultSubobject<USphereComponent>(TEXT("Collision"));
// 겹침만 감지하는 프로파일 설정
Collision->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
// 루트 컴포넌트로 설정
Collision->SetupAttachment(Scene);
// 스태틱 메시 컴포넌트 생성 및 설정
StaticMesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("StaticMesh"));
StaticMesh->SetupAttachment(Collision);
// 메시가 불필요하게 충돌을 막지 않도록 하기 위해, 별도로 NoCollision 등으로 설정할 수 있음.
// Overlap 이벤트 바인딩
Collision->OnComponentBeginOverlap.AddDynamic(this, &ABaseItem::OnItemOverlap);
Collision->OnComponentEndOverlap.AddDynamic(this, &ABaseItem::OnItemEndOverlap);
}
void ABaseItem::OnItemOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex,
bool bFromSweep,
const FHitResult& SweepResult)
{
// OtherActor가 플레이어인지 확인 ("Player" 태그 활용)
if (OtherActor && OtherActor->ActorHasTag("Player"))
{
GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Green, FString::Printf(TEXT("Overlap!!!")));
// 아이템 사용 (획득) 로직 호출
ActivateItem(OtherActor);
}
}
void ABaseItem::OnItemEndOverlap(
UPrimitiveComponent* OverlappedComp,
AActor* OtherActor,
UPrimitiveComponent* OtherComp,
int32 OtherBodyIndex)
{
}
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()
);
}
if (Particle)
{
FTimerHandle DestroyParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[Particle]()
{
Particle->DestroyComponent();
},
2.0f,
false
);
}
}
FName ABaseItem::GetItemType() const
{
return ItemType;
}
void ABaseItem::DestroyItem()
{
Destroy();
}
// MineItem.h
#pragma once
#include "CoreMinimal.h"
#include "BaseItem.h"
#include "MineItem.generated.h"
UCLASS()
class SPARTA_API AMineItem : public ABaseItem
{
GENERATED_BODY()
public:
AMineItem();
protected:
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Item|Component")
USphereComponent* ExplosionCollision;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
UParticleSystem* ExplosionParticle;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Item|Effects")
USoundBase* ExplosionSound;
// 폭발까지 걸리는 시간 (5초)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mine")
float ExplosionDelay;
// 폭발 범위
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mine")
float ExplosionRadius;
// 폭발 데미지
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Mine")
int ExplosionDamage;
bool bHasExploded;
// 지뢰 발동 여부
FTimerHandle ExplosionTimerHandle;
virtual void ActivateItem(AActor* Activator) override;
void Explode();
};
// MineItem.cpp
#include "MineItem.h"
#include "Components/SphereComponent.h"
#include "Kismet/GameplayStatics.h"
#include "Particles/ParticleSystemComponent.h"
AMineItem::AMineItem()
{
ExplosionDelay = 5.0f;
ExplosionRadius = 300.0f;
ExplosionDamage = 30.0f;
ItemType = "Mine";
bHasExploded = false;
ExplosionCollision = CreateDefaultSubobject<USphereComponent>(TEXT("ExplosionCollision"));
ExplosionCollision->InitSphereRadius(ExplosionRadius);
ExplosionCollision->SetCollisionProfileName(TEXT("OverlapAllDynamic"));
ExplosionCollision->SetupAttachment(Scene);
}
void AMineItem::ActivateItem(AActor* Activator)
{
if (bHasExploded) return;
Super::ActivateItem(Activator);
GetWorld()->GetTimerManager().SetTimer(
ExplosionTimerHandle,
this,
&AMineItem::Explode,
ExplosionDelay,
false
);
bHasExploded = true;
}
void AMineItem::Explode()
{
UParticleSystemComponent* Particle = nullptr;
if (ExplosionParticle)
{
Particle = UGameplayStatics::SpawnEmitterAtLocation(
GetWorld(),
ExplosionParticle,
GetActorLocation(),
GetActorRotation(),
false
);
}
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()
);
}
}
// 지뢰 제거
DestroyItem();
/*if (Particle)
{
FTimerHandle DestroyParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[Particle]()
{
Particle->DestroyComponent();
},
2.0f,
false
);
}*/
}
구현이 다 끝나고 게임 실행을 해서 테스트를 해보니,
레벨이 바뀔때 갑자기 에디터가 종료되고 비주얼스튜디오에서 아래 코드로 이동되면서 디버깅이 실행이 되었다.
// faster than GetValueOnAnyThread()
T GetValueOnRenderThread() const
{
UE::AccessDetection::ReportAccess(UE::AccessDetection::EType::CVar);
#if !defined(__clang__) // @todo Mac: figure out how to make this compile
// compiled out in shipping for performance (we can change in development later), if this get triggered you need to call GetValueOnGameThread() or GetValueOnAnyThread(), the last one is a bit slower
cvarCheckCode(ensure(IsInParallelRenderingThread())); // ensure to not block content creators, #if to optimize in shipping
#endif
return ShadowedValue[1];
}
cvarCheckCode(ensure(IsInParallelRenderingThread())); // ensure to not block content creators, #if to optimize in shipping

case FTimerDelegateVariant::IndexOfType<FTimerFunction>():
{
if (const FTimerFunction& TimerFunction = VariantDelegate.Get<FTimerFunction>())
{
QUICK_SCOPE_CYCLE_COUNTER(STAT_FTimerUnifiedDelegate_Execute);
TimerFunction();
}
break;
}
default:
break;
}

계속 테스트 해보면서 원인을 찾아보니 파티클을 제거하는 타이머가 실행중일때 다음 레벨로 바뀌면서 오류가 뜨는거였다.
레벨1 > 레벨2로 바뀌면서 레벨1에 있던 기존 액터들이 전부 제거되고 레벨2에서 새로운 액터들이 생성 되는 구조인데,
레벨2로 바뀌면서 제거된 후 레벨1에서 타이머 작동중인 액터가 있었을때 타이머가 그 액터로 접근을 하면서 오류가 실행 된 것이다.
문제라고 생각한 코드
// BaseItem.cpp
void ABaseItem::ActivateItem(AActor* Activator)
{
UParticleSystemComponent* Particle = nullptr;
if (Particle)
{
FTimerHandle DestroyParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[Particle]()
{
Particle->DestroyComponent();
},
2.0f,
false
);
}
}
// MineItem.cpp
void AMineItem::Explode()
{
UParticleSystemComponent* Particle = nullptr;
if (Particle)
{
FTimerHandle DestroyParticleTimerHandle;
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[Particle]()
{
Particle->DestroyComponent();
},
2.0f,
false
);
}
}
이 두 코드의 타이머가 제거가 안된 상태에서,
레벨이 넘어가며 삭제된 파티클 삭제 타이머에 접근하여 오류가 난 것이라고 생각이 들어서
아래처럼 함수와 변수를 추가해주었고 코드를 수정 하였다
// BaseItem.h
UCLASS()
class SPARTA_API ABaseItem : public AActor, public IItemInterface
{
GENERATED_BODY()
public:
ABaseItem();
// ActivateItem 함수 안에서 지역변수로 사용하던 타이머핸들을
// 멤버 변수로 분리하여 다른 함수에서도 사용 가능하게 정의
FTimerHandle DestroyParticleTimerHandle;
// 파티클 삭제를 위한 함수
// 람다 내부에서 직접 처리하지 않고 함수로 분리하여 사용
void DestroyParticle(UParticleSystemComponent* Particle);
// DestroyParticleTimerHandle를 삭제하는 함수
// 레벨 변경시 타이머로 인한 오류 발생 방지
void ClearTimer();
};
// BaseItem.cpp
// 파티클이 생성된 경우
if (Particle)
{ // 일정시간(2초) 후 DestroyParticle(Particle); 함수 실행
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[Particle, this]()
{ // 기존 Particle->DestroyComponent(); 대신
// 파티클과 타이머 삭제 함수를 만들어서 사용
DestroyParticle(Particle);
},
2.0f,
false
);
}
// 파티클과 파티클 삭제 타이머를 제거하는 함수
void ABaseItem::DestroyParticle(UParticleSystemComponent* Particle)
{
// 파티클 삭제 타이머 함수 호출(타이머 삭제)
ClearTimer();
// 생성된 파티클 컴포넌트 삭제
Particle->DestroyComponent();
}
// 파티클 삭제 타이머를 제거하는 함수
void ABaseItem::ClearTimer()
{ // DestroyParticleTimerHandle에 등록된 타이머를 제거
// 레벨 전환시 살아있는 타이머 때문에 생기는 오류 방지
GetWorld()->GetTimerManager().ClearTimer(DestroyParticleTimerHandle);
}
// MineItem.cpp
if (Particle)
{
GetWorld()->GetTimerManager().SetTimer(
DestroyParticleTimerHandle,
[Particle, this]()
{
ABaseItem::DestroyParticle(Particle);
},
2.0f,
false
);
}
위와 같이 코드를 수정하고 변수와 함수를 새로 추가해주었고,
레벨이 변경되는 시점에 코드를 추가해주었따.
아래는 수정 전 코드
// GameState.cpp
void ASpartaGameState::EndLevel()
{
GetWorldTimerManager().ClearTimer(LevelTimerHandle);
if (UGameInstance* GameInstance = GetGameInstance())
{
USpartaGameInstance* SpartaGameInstance = Cast<USpartaGameInstance>(GameInstance);
if (SpartaGameInstance)
{
AddScore(Score);
CurrentLevelIndex++;
SpartaGameInstance->CurrentLevelIndex = CurrentLevelIndex;
if (CurrentLevelIndex >= MaxLevels)
{
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
{
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
}
else
{
OnGameOver();
}
}
}
}
아래는 수정한 코드
// GameState.cpp
void ASpartaGameState::EndLevel()
{
GetWorldTimerManager().ClearTimer(LevelTimerHandle);
{ // 현재 월드 내에 존재하는 모든 액터를 찾기 위한 배열 정의
TArray<AActor*> Actors;
// BaseItem 클래스를 상속받은 모든 액터를 찾아서 Actors 배열에 저장
UGameplayStatics::GetAllActorsOfClass(GetWorld(), ABaseItem::StaticClass(), Actors);
// 찾은 모든 액터를 순회
for (AActor* Item : Actors)
{ // AActor를 ABaseItem으로 캐스팅
if (ABaseItem* BaseItem = Cast<ABaseItem>(Item))
{ // 액터들에 설정되어 있는 파티클 삭제 타이머를 제거
BaseItem->ClearTimer();
}
}
}
if (UGameInstance* GameInstance = GetGameInstance())
{
USpartaGameInstance* SpartaGameInstance = Cast<USpartaGameInstance>(GameInstance);
if (SpartaGameInstance)
{
AddScore(Score);
CurrentLevelIndex++;
SpartaGameInstance->CurrentLevelIndex = CurrentLevelIndex;
if (CurrentLevelIndex >= MaxLevels)
{
OnGameOver();
return;
}
if (LevelMapNames.IsValidIndex(CurrentLevelIndex))
{
UGameplayStatics::OpenLevel(GetWorld(), LevelMapNames[CurrentLevelIndex]);
}
else
{
OnGameOver();
}
}
}
}
이번 문제를 발견하고 해결하면서 타이머는 액터가 삭제되더라도 자동으로 정리되지 않으며,
레벨 전환시 타이머를 직접 관리를 해줘야 한다는 것을 알게 되었다.
| Unreal C++ 게임 루프 및 UI 설계 후 코인 획득 게임 구현 트러블슈팅 (0) | 2026.04.30 |
|---|---|
| Unreal C++ 게임 루프 및 UI 설계 후 코인 획득 게임 구현 (0) | 2026.04.29 |
| Unreal C++ 기초 8. 아이템 스폰 및 레벨 데이터 구현 (0) | 2026.04.17 |
| Unreal C++ 기초 7. InterFace 기반 아이템 클래스 기능 구현 (0) | 2026.04.16 |
| Unreal C++ 기초 6. InterFace 기반 아이템 클래스 생성 (0) | 2026.04.15 |