BulletAnt 개발일지 (1) - SpawnManagerSubsystem, WorldSettings

김펭귄·2026년 5월 11일

Today What I Learned (TIL)

목록 보기
116/139

StateTree와 WorldSubsystem을 활용한 적 AI & 스폰 시스템 설계

개발 배경

프로젝트에서 다수의 적이 등장하는 웨이브 시스템과 다양한 적을 구현하게 되었다.
단순히 기능 구현에 그치지 않고, "어떻게 하면 수많은 AI 객체를 성능 부하 없이 관리하고, 유연하게 스폰 데이터를 관리할 수 있을까?"라는 질문에서 설계를 시작

기존의 Behavior Tree보다 가볍고 상태 중심적인 StateTree를 메인 AI 프레임워크로 선택했으며, 전반적인 월드 데이터와 스폰 로직은 World Subsystem으로 캡슐화하여 구조적 이점과 성능 최적화를 동시에 챙기고자 했습니다.


핵심 구조 설계

1. 데이터 주도형 스폰 시스템 (Data-Driven)

레벨마다 적의 구성이나 웨이브 간격이 달라져야 했기에, 하드코딩 대신 DataTable 기반의 설계를 채택

  • FSpawnEnemyData: 적 클래스, 적의 종족값, 스폰 개수 및 간격을 정의
  • FEnemySpawnerEntry: 웨이브 인덱스, 웨이브 시간, 스폰할 FSpawnEnemyData 배열 정의
USTRUCT(BlueprintType)
struct BULLETANT_API FSpawnEnemyData
{
	GENERATED_BODY()

	UPROPERTY(EditDefaultsOnly)
	TSubclassOf<ABaseEnemyCharacter> EnemyClass;

	UPROPERTY(EditDefaultsOnly)
	TObjectPtr<UTribeDataAsset> TribeType;

	UPROPERTY(EditDefaultsOnly)
	int32 Count = 10;

	UPROPERTY(EditDefaultsOnly)
	float SpawnInterval = 1.f;
};

USTRUCT(BlueprintType)
struct BULLETANT_API FEnemySpawnerEntry : public FTableRowBase
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	int32 WaveIndex = 0;

	UPROPERTY(EditDefaultsOnly)
	int32 WavePreparationTime = 300;

	UPROPERTY(EditDefaultsOnly)
	int32 SpawnTime = 100;

	UPROPERTY(EditAnywhere, BlueprintReadOnly)
	TArray<FSpawnEnemyData> SpawnEnemyDataArray;
};

2. World Subsystem & WorldSettings 활용

웨이브와 스폰 로직을 관리하기 위해 USpawnManagerSubsystem을 구현했습니다.

  • World Subsystem 선택 이유: 메인 레벨에서만 필요하며, 월드의 생명주기와 함께 관리되어야 하므로 싱글톤 패턴보다 안전하고 언리얼 친화적인 서브시스템을 선택

  • 데이터 주입 방식: 서브시스템은 블루프린트 상속이 불가능하여 DataTable을 직접 할당하기 어렵습니다. 이를 해결하기 위해 AWorldSettings를 상속받은 ABAWorldSettings를 만들어 레벨마다 필요한 데이터 테이블을 갈아 끼울 수 있는 '어댑터' 역할을 수행하게 했습니다.

// BAWorldSettings.h
UCLASS()
class BULLETANT_API ABAWorldSettings : public AWorldSettings
{
	GENERATED_BODY()

public:
	UPROPERTY(EditAnywhere, Category="Spawn")
	UDataTable* SpawnTable;
};

// SpawnManagerSubsystem.h
void USpawnManagerSubsystem::SetSpawnDataTable()
{
	AWorldSettings* WorldSettings = GetWorld()->GetWorldSettings();
	ABAWorldSettings* BAWorldSettings = Cast<ABAWorldSettings>(WorldSettings);
	EnemySpawnHandle.DataTable = BAWorldSettings->SpawnTable;
}
  • 구조적 유연성: WorldSettings의 데이터테이블만 변경하면 코드를 수정하지 않고도 레벨별로 전혀 다른 웨이브 구성을 적용할 수 있게 되었습니다.

구현 과정

StateTree Task의 C++ 확장

언리얼의 StateTree Task Base는 구조체 기반이라 일반적인 C++ 클래스 상속으로는 노출되지 않는 문제가 있었습니다. 이를 해결하기 위해 UStateTreeTaskBlueprintBase를 상속받아 C++에서 AI 로직을 작성하고, StateTree 에디터에서 자유롭게 매개변수를 바인딩할 수 있도록 구현했습니다.

#include "Blueprint/StateTreeTaskBlueprintBase.h"

UCLASS()
class BULLETANT_API UDiveTask : public UStateTreeTaskBlueprintBase
{
	// ...
}

트러블 슈팅

1. 에디터 프리뷰 및 불필요한 레벨에서의 크래시

서브시스템이 게임 월드뿐만 아니라 에디터나 원치 않는 레벨(예: 툴 레벨)에서도 생성되어 로직을 실행하려다 터지는 문제가 발생했습니다.

  • 해결: ShouldCreateSubsystem을 오버라이드하여 IsGameWorld()IsPlayInEditor() 체크를 추가하고, 특정 레벨 이름(MainLevel 등)에서만 활성화되도록 필터링하여 안정성을 확보했습니다.
USpawnManagerSubsystem::ShouldCreateSubsystem(UObject* Outer) const
{
	if (!Super::ShouldCreateSubsystem(Outer))
	{
		return false;
	}

	UWorld* World = Cast<UWorld>(Outer);
	if (IsValid((World)))
	{		
		if (!(World->IsGameWorld() || World->IsPlayInEditor()))
		{
			return false;
		}

		FString LevelName = World->GetMapName();
		if (LevelName.Contains(TEXT("TestMap")) || LevelName.Contains(TEXT("MainLevel")))
		{
			return true; 
		}
	}
	return false;
}

2. StateTree 실행 시점과 BeginPlay의 순서 문제

적 스폰 직후 AI가 타겟을 찾지 못하고 IDLE 상태로 빠지는 현상이 있었습니다. 로그 확인 결과, StateTree의 로직이 액터의 BeginPlay보다 먼저 실행되어 타겟 액터가 nullptr인 상태로 Task가 시작된 것이 원인이었습니다.

  • 초기 시도: 델리게이트를 통해 타겟 설정 후 재실행하려 했으나 구조가 복잡해졌습니다.

  • 최종 해결: StateTreeComponent 생성자에서 SetStartLogicAutomatically(false)를 설정하여 자동 실행을 막았습니다. 이후 액터의 BeginPlay에서 타겟 설정을 완벽히 마친 뒤, 명시적으로 StartLogic()을 호출하여 실행 순서를 보장했습니다.

profile
반갑습니다

0개의 댓글