정찬호·2026년 5월 27일

[TIL] SpawnerBase 리스폰 버그 수정 · PatternCounterComponent 설계 분석 · Evaluator 버그 수정 · 오프라인 클론 체크

Today I Learned | Unreal Engine 5 | GAS | StateTree | Object Pooling | C++ | Git


1부. SpawnerBase 리스폰 버그 3종 수정

1-1. 리스폰이 동작하지 않음 — 전체 리스폰 설계 자체의 문제

현상: 모든 Enemy가 사망했는데 리스폰 타이머가 전혀 시작되지 않는다.

원인: 이벤트 발생 순서 역전

예상 순서: OnEnemyDeath → SpawnedPawns 비어짐 → 리스폰 타이머 시작
실제 순서: DespawnSphereEndOverlap → SpawnedPawns 아직 비어있지 않음
             → "살아있는 폰이 있음" 판단 → DespawnAll() 분기 → 타이머 미시작

DespawnSphereEndOverlapOnEnemyDeath보다 먼저 발생해 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);

1-2. DespawnAll이 개별 리스폰 타이머를 전부 클리어하는 버그

현상: 개별 리스폰 타이머를 설정했는데 플레이어가 스포너를 이탈하면 타이머가 모두 사라진다.

원인: 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에서만 모든 타이머를 정리한다.


1-3. Ragdoll 복원 미처리 — 리스폰 후 화면에 보이지 않음

현상: 리스폰 로그는 정상 출력되고 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)AttachToComponentSetRelativeTransformSetActorTransform
물리 시뮬레이션이 켜진 상태에서 Transform을 설정하면 물리 엔진이 즉시 덮어쓴다.


2부. Evaluator 버그 수정 — return vs continue

현상

RetrieveEnemyTargetEvaluator::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)AttachToComponentSetRelativeTransformSetActorTransform 순서를 따른다. 물리가 켜진 상태에서 Transform을 설정하면 물리 엔진이 즉시 덮어쓴다.
  • for 루프 안에서 return은 함수 전체를 종료하고, continue는 해당 반복만 건너뛴다. Perception 루프처럼 복수 Actor를 순회할 때 혼동하면 첫 비매칭 Actor에서 탐지가 멈춘다.

7기 Team2 Final Project — SpawnerBase 리스폰 버그 · 코드 재점검 · Evaluator 버그 · PatternCounter 분석 (2026-05-27)

profile
게임 개발 지망생입니다.

0개의 댓글