Today I Learned |
Unreal Engine 5|GAS|StateTree|Object Pooling|AI|C++
현상: Enemy가 공격 후 5초간 제자리에 서 있다가 Chase 상태로 복귀됩니다.
원인: StateTreeTask_EnemyAttack::Tick에서 IsPatternActive() 체크가 주석 처리되어 있었습니다.
GA가 끝나(State_Enemy_Attack 태그 제거) 패턴이 비활성화돼도 Task가 Running을
계속 반환하고, MaxAttackDuration(5초) 만료 후에야 Succeeded를 반환하는 상태였습니다.
// ❌ 주석 처리된 상태 (공격 후 5초 멈춤)
/*if (!EnemyCharacter->IsPatternActive())
return EStateTreeRunStatus::Succeeded;*/
// ✅ 주석 해제 (GA 종료 즉시 Chase 전환)
if (!EnemyCharacter->IsPatternActive())
return EStateTreeRunStatus::Succeeded;
현상: Send Gameplay Event to Actor 노드로 어빌리티를 트리거해도 실행되지 않습니다.
원인: PD_Enemy_Zombie, AS_TestEnemy 등 에셋에서 AbilitySet과 TargetEnemy 설정이
초기화 누락 상태였습니다. GiveToAbilitySystem 단계에서 어빌리티 자체가 등록되지 않았으므로
이벤트가 도달해도 처리할 Spec이 없었던 것입니다.
수정: 에셋에서 AbilitySet + TargetEnemy 설정 복원. 코드 변경은 없습니다.
체크포인트: GAS 어빌리티가 트리거되지 않을 때는 로직보다
GiveToAbilitySystem이 제대로 호출됐는지, AbilitySet에 어빌리티가 등록됐는지를
먼저 확인하는 것이 좋습니다.
현상: PIE 종료 시 Dead 상태의 Enemy가 있으면 TCheckedPointerIterator assert로 크래시가 발생합니다.
원인 1: 순회 중 배열 수정 (재진입)
TakeFromAbilitySystem에서 AbilitySpecHandles를 순회하며 ClearAbility를 호출하면,
GAS 내부 콜백이 다시 TakeFromAbilitySystem으로 재진입해 배열을 수정합니다.
TCheckedPointerIterator는 순회 중 배열 수정을 assert로 차단합니다.
// ❌ 순회 중 ClearAbility → 콜백 재진입 → 배열 수정 → assert 크래시
for (const FGameplayAbilitySpecHandle& Handle : AbilitySpecHandles)
{
ASC->ClearAbility(Handle); // 내부에서 AbilitySpecHandles가 바뀔 수 있습니다
}
// ✅ MoveTemp로 배열을 로컬로 이동 → 원본이 비어있어 재진입해도 안전합니다
TArray<FGameplayAbilitySpecHandle> Handles = MoveTemp(AbilitySpecHandles);
for (const FGameplayAbilitySpecHandle& Handle : Handles)
{
ASC->ClearAbility(Handle);
}
세 배열(AbilitySpecHandles, GameplayEffectHandles, GrantedAttributeSets) 모두 동일하게 적용했습니다.
원인 2: AbilityActorInfo nullptr 역참조
UninitializeAbilitySystem에서 GetAvatarActor()는 AbilityActorInfo를 통해 접근합니다.
Dead Enemy의 경우 이미 AbilityActorInfo가 null일 수 있어 null 역참조가 발생했습니다.
// ❌ AbilityActorInfo가 null이면 내부에서 crash
if (AbilitySystemComponent->GetAvatarActor() == GetOwner())
// ✅ 구조체 직접 접근 (AbilityActorInfo null 체크 우회)
if (ASC->GetAvatarActor_Direct() == Owner)
재진입 방어 패턴: 정리 시작 시점에 멤버 포인터를 먼저 nullptr로 설정합니다.
재진입이 들어와도 이미 nullptr으로 보여 조기 반환됩니다.
void URetrievePawnExtensionComponent::UninitializeAbilitySystem()
{
URetrieveAbilitySystemComponent* ASC = AbilitySystemComponent;
AActor* Owner = GetOwner();
if (!IsValid(ASC)) { AbilitySystemComponent = nullptr; return; }
AbilitySystemComponent = nullptr; // ← 재진입 시 이미 nullptr로 보입니다
GrantedHandles.TakeFromAbilitySystem(ASC);
if (IsValid(ASC) && ASC->GetAvatarActor_Direct() == Owner)
ASC->ClearActorInfo();
}
월드에 미리 배치된 Enemy는 StateTree가 정상 동작했으나,
런타임에 동적으로 스폰된 Enemy는 StateTree가 전혀 실행되지 않았습니다.
처음에는 Controller가 생성되지 않는 문제로 오인했습니다.
World Outliner에서 Controller 생성을 확인한 뒤,
ConfigureStateTree()가 어디서도 호출되지 않는다는 것이 진짜 원인이었습니다.
OnPossess는 BeginPlay 이전에 실행됩니다. StateTreeAIComponent가 아직 완전히
초기화되지 않은 시점에 StartLogic을 호출하면 무시됩니다.
SetTimerForNextTick으로 한 프레임 뒤에 호출하면 타이밍 문제를 회피할 수 있습니다.
void AEnemyAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
GetWorld()->GetTimerManager().SetTimerForNextTick(
this, &AEnemyAIController::TryStartStateTree);
}
void AEnemyAIController::TryStartStateTree()
{
if (StateTreeAIComp && !StateTreeAIComp->IsRunning())
StateTreeAIComp->StartLogic();
}
1.5초마다 타이머로 CheckDistance()를 폴링합니다.
반응이 늦고, 플레이어 이동 속도가 빠를 경우 스폰/디스폰 타이밍이 어긋납니다.
Spawner Actor에 USphereComponent 두 개를 부착합니다.
| 컴포넌트 | 반경 | 이벤트 | 역할 |
|---|---|---|---|
SpawnSphereComp | 2000 | BeginOverlap | 플레이어 진입 → SpawnAll |
DespawnSphereComp | 3000 | EndOverlap | 플레이어 이탈 → DespawnAll / 리스폰 예약 |
정적 Actor에 부착된 SphereComponent는 BVH에 1회 등록됩니다.
플레이어 쪽에 큰 볼륨을 붙이면 매 프레임 Broad Phase 재계산이 발생하므로
Spawner Actor 쪽에 부착하는 것이 올바른 방향입니다.
// ECC_Pawn만 감지, 물리 시뮬레이션 없음
SpawnSphereComp->SetCollisionEnabled(ECollisionEnabled::QueryOnly);
SpawnSphereComp->SetCollisionResponseToAllChannels(ECR_Ignore);
SpawnSphereComp->SetCollisionResponseToChannel(ECC_Pawn, ECR_Overlap);
TSubclassOf<AActor>로 특정 클래스만 트리거를 허용합니다.
Cast 없이 IsA()로 클래스 계층을 체크할 수 있습니다.
UPROPERTY(EditAnywhere, Category="Spawner")
TSubclassOf<AActor> TriggerActorClass;
// Overlap 핸들러 내부
if (!OtherActor || !TriggerActorClass || !OtherActor->IsA(TriggerActorClass))
return;
DespawnAll에서 Pawn->Destroy()를 호출하고, 리스폰 시 SpawnActorDeferred로 재생성합니다.
GA_Enemy_Die에서 DestroyActor 노드를 HandleDeathEnded 함수 호출로 교체합니다.
bRespawnable 플래그로 Destroy vs Deactivate 분기를 결정합니다.
HandleDeathEnded 흐름
bRespawnable = true → SetActorHiddenInGame + SetActorEnableCollision(false)
+ Controller.Deactivate()
+ OnDeathEnded.Broadcast(this) → Spawner가 PooledPawns에 이동
bRespawnable = false → Destroy()
TArray<TWeakObjectPtr<APawn>> SpawnedPawns; // 현재 활성 Enemy
TArray<TWeakObjectPtr<APawn>> PooledPawns; // 비활성(Deactivated) Enemy
SpawnAll 시 풀에서 같은 클래스를 먼저 찾고, 없을 때만 새로 스폰합니다.
SpawnAll 흐름
1. PooledPawns에서 같은 PawnClass 검색
2. 있으면 → Activate(SpawnTransform) + AI.Reactivate() + SpawnedPawns 이동
3. 없으면 → SpawnActorDeferred로 신규 스폰
| 역할 | 담당 |
|---|---|
| bRespawnable 설정 | Spawner → EnemyCharacter::SetRespawnable() |
| Deactivate 처리 | EnemyCharacter (자신의 숨김/콜리전) |
| AI 중단 | EnemyAIController::Deactivate() |
| 풀 이동 | SpawnerBase::OnEnemyDeath() (OnDeathEnded 바인딩) |
// ❌ 컴파일 에러 (이름 없음)
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDeathEnded, ARetrieveEnemyCharacter*);
// ✅ 타입 + 이름 모두 필요합니다
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDeathEnded, ARetrieveEnemyCharacter*, DeadCharacter);
헤더 파일 안에서 자신의 클래스를 forward declare하는 것은 불필요합니다.
클래스 정의가 바로 뒤에 오면 컴파일러가 이미 알고 있습니다.
SetRespawnable(bool)을 만들었는데 bRespawnable이 public이면 Setter가 의미 없습니다.
Setter를 제공하는 필드는 반드시 private으로 관리하는 것이 좋습니다.
Blueprint GA 노드로 C++ 함수를 호출하려면 UFUNCTION(BlueprintCallable)이 필요합니다.
protected + 비-BlueprintCallable 함수는 BP에서 접근이 불가합니다.
| 타입 | 용도 |
|---|---|
TWeakPtr<T> | TSharedPtr 생태계 전용. UObject에는 사용할 수 없습니다 |
TWeakObjectPtr<T> | UObject 전용. UPROPERTY 없이 GC 안전 참조가 가능합니다 |
TObjectPtr<T> | UPROPERTY 멤버. 에디터 드래그앤드롭을 지원합니다 |
- GAS
TakeFromAbilitySystem에서ClearAbility순회 중 콜백 재진입으로 배열이 변경되면TCheckedPointerIteratorassert가 발생합니다.MoveTemp로 배열을 로컬로 이동한 뒤 순회하면 원본이 비어있어 재진입해도 안전합니다.GetAvatarActor()대신GetAvatarActor_Direct()를 사용하면AbilityActorInfo가 null일 때 null 역참조를 피할 수 있습니다.- 동적 스폰된 Enemy의 StateTree는
OnPossess에서SetTimerForNextTick으로 한 프레임 뒤에StartLogic을 호출해야 합니다.- SphereComponent Overlap은 플레이어가 아닌 Spawner에 부착해야 Broad Phase 비용이 최소화됩니다.
- Object Pooling의 핵심은 Destroy 대신 Deactivate,
SpawnedPawns(활성) +PooledPawns(비활성) 두 배열로 상태를 분리하는 것입니다.