Today I Learned |
Unreal Engine 5|GAS|StateTree|Object Pooling|C++|Git
현상: 모든 Enemy가 사망했는데 리스폰 타이머가 전혀 시작되지 않는다.
원인: 이벤트 발생 순서 역전
예상 순서: OnEnemyDeath → SpawnedPawns 비어짐 → 리스폰 타이머 시작
실제 순서: DespawnSphereEndOverlap → SpawnedPawns 아직 비어있지 않음
→ "살아있는 폰이 있음" 판단 → DespawnAll() 분기 → 타이머 미시작
DespawnSphereEndOverlap이 OnEnemyDeath보다 먼저 발생해 SpawnedPawns가
아직 채워진 상태에서 분기 판단이 일어났다.
더 근본적인 문제: "하나가 살아있으면 전부 되살리는" 전체 리스폰 설계가 잘못됐다.
Enemy 1이 살아있는 동안 사망한 Enemy 2, 3을 되살리지 않는다는 게 이상하다.
해결: TArray<FTimerHandle> RespawnTimerHandles로 인덱스별 독립 타이머
// SpawnerBase.h
TArray<FTimerHandle> RespawnTimerHandles; // SpawnList와 1:1 병렬 배열
// BeginPlay
RespawnTimerHandles.SetNum(SpawnList.Num());
// OnEnemyDeath — SpawnedPawns에서 제거 후
// EntryPawns에서 인덱스를 찾아 해당 슬롯에만 타이머 설정
FTimerDelegate Delegate;
Delegate.BindUObject(this, &ASpawnerBase::TryRespawnEntry, EntryIndex);
GetWorld()->GetTimerManager().SetTimer(
RespawnTimerHandles[EntryIndex], Delegate, RespawnDelay, false);
현상: 개별 리스폰 타이머를 설정했는데 플레이어가 스포너를 이탈하면 타이머가 모두 사라진다.
원인: DespawnAll()에 타이머 클리어 코드가 포함되어 있었다.
// ❌ DespawnAll이 개별 타이머까지 정리해버림
void ASpawnerBase::DespawnAll()
{
for (FTimerHandle& Handle : RespawnTimerHandles)
GetWorld()->GetTimerManager().ClearTimer(Handle); // 이게 문제
for (TWeakObjectPtr<APawn>& Pawn : SpawnedPawns)
// ...
}
DespawnAll은 "살아있는 Enemy를 비활성화"하는 역할이다.
"개별 리스폰 타이머 관리"는 별개의 책임이다.
// ✅ DespawnAll은 활성 폰 비활성화만 담당
void ASpawnerBase::DespawnAll()
{
// 타이머 클리어 없음
for (TWeakObjectPtr<APawn>& Pawn : SpawnedPawns)
{
if (IsValid(Pawn.Get()))
// Deactivate 처리
}
SpawnedPawns.Empty();
}
EndPlay에서만 모든 타이머를 정리한다.
현상: 리스폰 로그는 정상 출력되고 AIController도 재활성화됐는데,
Enemy가 화면에 나타나지 않는다. StateTree DebugText만 공중에 떠 있다.
원인: 사망 시 설정한 Ragdoll 상태가 리스폰 시 복원되지 않는다.
사망 시 GA_Enemy_Die에서:
SetCollisionProfileName("Ragdoll") ← Mesh를 CharacterMesh 외부로 전환
SetSimulatePhysics(true) ← 물리 시뮬레이션 시작
리스폰 시 ActivateEnemy에서:
SetActorHiddenInGame(false) ← 가시성만 복원, Mesh는 여전히 Ragdoll
→ Mesh가 아무 곳에나 떨어진 상태로 보이지 않음
해결: BeginPlay에서 초기 Mesh Transform을 저장해두고, ActivateEnemy에서 복원
// BeginPlay
InitialMeshRelativeTransform = GetMesh()->GetRelativeTransform();
// ActivateEnemy — 반드시 이 순서를 지켜야 한다
void ARetrieveEnemyCharacter::ActivateEnemy(const FTransform& SpawnTransform, bool bIsRespawn)
{
if (USkeletalMeshComponent* MeshComp = GetMesh())
{
// 1. 물리 시뮬레이션 먼저 끊어야 Transform 제어가 가능해진다
MeshComp->SetSimulatePhysics(false);
MeshComp->SetCollisionProfileName(TEXT("CharacterMesh"));
// 2. 루트 컴포넌트에 다시 부착
MeshComp->AttachToComponent(
GetCapsuleComponent(),
FAttachmentTransformRules::SnapToTargetNotIncludingScale);
// 3. 저장해둔 상대 Transform 복원
MeshComp->SetRelativeTransform(InitialMeshRelativeTransform);
}
// 4. 그 다음에 Actor Transform 설정 (Mesh가 Physics에 끌려가지 않음)
SetActorTransform(SpawnTransform);
SetActorHiddenInGame(false);
SetActorEnableCollision(true);
if (bIsRespawn)
GetHealthComponent()->ResetHealth();
if (AEnemyAIController* AI = Cast<AEnemyAIController>(GetController()))
AI->Reactivate();
}
핵심 순서:
SetSimulatePhysics(false)→AttachToComponent→SetRelativeTransform→SetActorTransform
물리 시뮬레이션이 켜진 상태에서 Transform을 설정하면 물리 엔진이 즉시 덮어쓴다.
return vs continueRetrieveEnemyTargetEvaluator::Tick의 인지 대상 순회 루프에서
return을 사용해 함수 전체가 조기 종료되는 버그가 있었다.
// ❌ 첫 번째 Actor가 비적대이면 루프 전체 종료 → 이후 적대 Actor를 못 찾음
for (AActor* Actor : PerceivedActors)
{
if (OwnerController->GetTeamAttitudeTowards(*Actor) != ETeamAttitude::Hostile)
{
return; // Tick 함수 자체가 끝남
}
// ...
}
// ✅ 해당 Actor만 건너뛰고 나머지 계속 순회
for (AActor* Actor : PerceivedActors)
{
if (OwnerController->GetTeamAttitudeTowards(*Actor) != ETeamAttitude::Hostile)
{
continue; // 이 Actor만 스킵, 다음 Actor 계속 체크
}
// ...
}
bDetectNeutrals = false, bDetectFriendlies = false로 설정되어
GetCurrentlyPerceivedActors()가 반환하는 배열에 비적대 Actor가 거의 없었기 때문이다.
Epic 이상에서 복수 타입 Actor를 감지하게 되면 실제 문제로 발현됐을 것이다.
- 리스폰 타이머는
TArray<FTimerHandle>로 SpawnList와 1:1 인덱스를 맞춰 개별 관리해야 한다. 전체 타이머 1개로 전원 리스폰하는 설계는 "살아있는 적이 있어도 죽은 적을 되살리지 않는" 문제를 만든다.DespawnAll의 역할은 "활성 Enemy 비활성화"로 한정한다. 리스폰 타이머를 여기서 클리어하면 이탈 → 재진입 시 리스폰이 동작하지 않는다.- Ragdoll 복원 순서는 반드시
SetSimulatePhysics(false)→AttachToComponent→SetRelativeTransform→SetActorTransform순서를 따른다. 물리가 켜진 상태에서 Transform을 설정하면 물리 엔진이 즉시 덮어쓴다.- for 루프 안에서
return은 함수 전체를 종료하고,continue는 해당 반복만 건너뛴다. Perception 루프처럼 복수 Actor를 순회할 때 혼동하면 첫 비매칭 Actor에서 탐지가 멈춘다.
7기 Team2 Final Project — SpawnerBase 리스폰 버그 · 코드 재점검 · Evaluator 버그 · PatternCounter 분석 (2026-05-27)