
나중에 AssetManager를 통해 에셋을 동적으로 로드하거나 특정 번들로 묶어 최적화할 수 있도록 일반 DataAsset이 아닌 PrimaryDataAsset을 상속받아 구현했습니다.
디폴트 어빌리티(Default Ability), 애님 몽타주와 같이 중간에 변하지 않는 초기값들은 모든 객체가 하나의 데이터 에셋을 직접 참고하게 했습니다.
같은 클래스의 여러 객체가 하나의 메모리 주소를 공유하므로, 수백 마리의 적이 소환되어도 불필요한 메모리 낭비가 발생하지 않습니다.
메모리 최적화: 모든 객체가 동일한 데이터를 각각 들고 있지 않고 하나의 원본(Source)을 바라보는 형식을 취해 메모리 사용량을 최소화했습니다.
유지보수 효율: 적의 외형이나 공통 스탯을 수정할 때, 개별 블루프린트를 일일이 건드리지 않고 데이터 에셋 하나만 수정하면 모든 인스턴스에 즉시 반영됩니다.
확장성: PrimaryDataAsset 구조를 채택함으로써, 향후 대규모 최적화 작업 시 자산 로드/언로드 관리를 체계적으로 수행할 수 있는 기반을 마련했습니다.
UCLASS()
class BULLETANT_API UBaseEnemyDataAsset : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, Category = "GAS|Abilities")
TArray<TSubclassOf<UGameplayAbility>> DefaultAbilities;
UPROPERTY(EditDefaultsOnly, Category = "BaseStat")
int32 Health = 100.f;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Death")
TSubclassOf<UGameplayEffect> DeathEffect;
UPROPERTY(EditDefaultsOnly, Category = "Death")
FGameplayTag DeathStateTag;
UPROPERTY(EditDefaultsOnly, Category = "Death")
TObjectPtr<UAnimMontage> DieAnimMontage;
// ... //
};
AI 시스템을 구성하면서 가장 먼저 정리하려 했던 부분은
“상태(State)를 어느 시스템이 관리할 것인가”였다.
초기에는 GAS의 GameplayTag를 기반으로 상태를 전환하는 구조도 고려했다.
하지만 이 방식은 상태 변경의 흐름이 Tag 변화에 의존하게 되면서,
AI의 전체 흐름을 추적하기 어려워지는 문제가 있었다.
특히 아래와 같은 부분이 불편했다.
결국 상태 전환의 책임을 하나의 시스템으로 통일할 필요가 있다고 판단했다.
최종적으로는 아래와 같이 역할을 분리했다.
| 시스템 | 역할 |
|---|---|
| StateTree | 상태 판단 및 상태 전환 |
| Task | 상태 내부 로직 수행 |
| GAS | 애니메이션 / 사운드 / 이펙트 실행 |
핵심은:
“AI의 상태 결정은 StateTree가 담당한다”
는 구조였다.
상태 전환은 모두 StateTree 내부에서 처리하도록 구성했다.
예를 들어 공격 상태로 전환해야 하는 경우:
이 흐름으로 통일했다.
이를 통해 상태 변경 책임이 외부 시스템으로 분산되지 않도록 했다.
GAS는 상태를 관리하지 않는다.
대신 실제 Gameplay 실행만 담당하도록 역할을 제한했다.
즉:
StateTree가 “무엇을 할지” 결정하고,
GAS가 “실제 행동”을 수행하는 구조다.
구조를 분리한 이후 가장 크게 개선된 부분은 디버깅이었다.
이전에는:
상태 변경 원인을 추적하기 어려웠다.
반면 현재 구조에서는:
“상태 변화는 StateTree에서만 발생한다”
는 기준이 생기면서
AI 흐름을 훨씬 쉽게 추적할 수 있게 되었다.
또한 AI 로직 자체도 읽기 쉬워졌고,
추후 상태가 추가되더라도 구조가 쉽게 무너지지 않도록 만들 수 있었다.