// © 2025 Naked People Team. All Rights Reserved. #include "NPCDirectorSubsystem.h" #include "NPC.h" #include "NPCDirectorConfig.h" #include "NavigationSystem.h" #include "Engine/World.h" #include "GameFramework/Pawn.h" #include "Kismet/GameplayStatics.h" #include "NakedDesire/Global/NakedDesireGameInstance.h" void UNPCDirectorSubsystem::OnWorldBeginPlay(UWorld& InWorld) { Super::OnWorldBeginPlay(InWorld); if (!InWorld.IsGameWorld()) return; const UNPCDirectorConfig* Config = GetConfig(); if (!Config || Config->SpawnTable.Num() == 0) return; if (UTimeOfDaySubsystem* Time = InWorld.GetSubsystem()) { Time->OnPhaseChanged.AddUniqueDynamic(this, &UNPCDirectorSubsystem::HandlePhaseChanged); CachedPhase = Time->GetPhase(); } PrewarmPool(); const float Interval = FMath::Max(Config->UpdateInterval, 0.05f); InWorld.GetTimerManager().SetTimer( UpdateTimerHandle, this, &UNPCDirectorSubsystem::UpdatePopulation, Interval, true); UpdatePopulation(); } void UNPCDirectorSubsystem::Deinitialize() { if (const UWorld* World = GetWorld()) { World->GetTimerManager().ClearTimer(UpdateTimerHandle); if (UTimeOfDaySubsystem* Time = World->GetSubsystem()) Time->OnPhaseChanged.RemoveDynamic(this, &UNPCDirectorSubsystem::HandlePhaseChanged); } Super::Deinitialize(); } void UNPCDirectorSubsystem::HandlePhaseChanged(EDayPhase NewPhase) { // The timer applies the new target on its next reconcile; just cache it. CachedPhase = NewPhase; } void UNPCDirectorSubsystem::PrewarmPool() { UWorld* World = GetWorld(); const UNPCDirectorConfig* Config = GetConfig(); if (!World || !Config) return; FActorSpawnParameters Params; Params.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn; for (int32 i = 0; i < Config->MaxNPCs; ++i) { const TSubclassOf NPCClass = PickWeightedClass(); if (!NPCClass) continue; ANPC* NPC = World->SpawnActor(NPCClass, FVector::ZeroVector, FRotator::ZeroRotator, Params); if (!NPC) continue; NPC->DeactivateToPool(); Pool.Add(NPC); } } void UNPCDirectorSubsystem::UpdatePopulation() { const APawn* Player = GetPlayerPawn(); const UNPCDirectorConfig* Config = GetConfig(); if (!Player || !Config) return; const FVector PlayerLocation = Player->GetActorLocation(); const float DespawnRadiusSq = FMath::Square(Config->DespawnRadius); // 1. Recycle anything that wandered (or was left) beyond the despawn radius. for (int32 i = Active.Num() - 1; i >= 0; --i) { ANPC* NPC = Active[i]; if (!NPC || FVector::DistSquared(NPC->GetActorLocation(), PlayerLocation) > DespawnRadiusSq) ReturnToPool(NPC); } const int32 Target = CurrentTargetCount(); // 2. Over target (e.g. day -> night drop): recycle the farthest live NPCs down to target. while (Active.Num() > Target) { int32 FarthestIdx = INDEX_NONE; float FarthestDistSq = -1.0f; for (int32 i = 0; i < Active.Num(); ++i) { const float DistSq = FVector::DistSquared(Active[i]->GetActorLocation(), PlayerLocation); if (DistSq > FarthestDistSq) { FarthestDistSq = DistSq; FarthestIdx = i; } } if (FarthestIdx == INDEX_NONE) break; ReturnToPool(Active[FarthestIdx]); } // 3. Under target: activate pooled NPCs at NavMesh points in the spawn ring. while (Active.Num() < Target && Pool.Num() > 0) { FVector SpawnLocation; if (!FindSpawnPoint(PlayerLocation, SpawnLocation)) break; // no reachable point this tick; try again next reconcile ANPC* NPC = TakeFromPool(); if (!NPC) break; // Face away from the player so a newly activated NPC reads as walking into the scene. const FRotator Facing = (SpawnLocation - PlayerLocation).GetSafeNormal2D().Rotation(); NPC->ActivateFromPool(SpawnLocation, Facing); Active.Add(NPC); } } int32 UNPCDirectorSubsystem::CurrentTargetCount() const { const UNPCDirectorConfig* Config = GetConfig(); if (!Config) return 0; const int32 PhaseTarget = (CachedPhase == EDayPhase::Day) ? Config->TargetCountDay : Config->TargetCountNight; // Can never exceed what the pool can hold. return FMath::Clamp(PhaseTarget, 0, Config->MaxNPCs); } ANPC* UNPCDirectorSubsystem::TakeFromPool() { while (Pool.Num() > 0) { ANPC* NPC = Pool.Pop(EAllowShrinking::No); if (NPC) return NPC; } return nullptr; } void UNPCDirectorSubsystem::ReturnToPool(ANPC* NPC) { Active.RemoveSwap(NPC); if (NPC) { NPC->DeactivateToPool(); Pool.Add(NPC); } } TSubclassOf UNPCDirectorSubsystem::PickWeightedClass() const { const UNPCDirectorConfig* Config = GetConfig(); if (!Config) return nullptr; float TotalWeight = 0.0f; for (const FNPCSpawnEntry& Entry : Config->SpawnTable) { if (Entry.NPCClass && Entry.Weight > 0.0f) TotalWeight += Entry.Weight; } if (TotalWeight <= 0.0f) return nullptr; float Roll = FMath::FRandRange(0.0f, TotalWeight); for (const FNPCSpawnEntry& Entry : Config->SpawnTable) { if (!Entry.NPCClass || Entry.Weight <= 0.0f) continue; Roll -= Entry.Weight; if (Roll <= 0.0f) return Entry.NPCClass; } return nullptr; } bool UNPCDirectorSubsystem::FindSpawnPoint(const FVector& Around, FVector& OutLocation) const { UNavigationSystemV1* NavSys = UNavigationSystemV1::GetCurrent(GetWorld()); const UNPCDirectorConfig* Config = GetConfig(); if (!NavSys || !Config) return false; const float MinRadiusSq = FMath::Square(Config->SpawnRadiusMin); // Rejection-sample a reachable point in the outer radius until one lands outside the inner radius. for (int32 Attempt = 0; Attempt < 8; ++Attempt) { FNavLocation NavLocation; if (NavSys->GetRandomReachablePointInRadius(Around, Config->SpawnRadiusMax, NavLocation) && FVector::DistSquared(NavLocation.Location, Around) >= MinRadiusSq) { OutLocation = NavLocation.Location; return true; } } return false; } APawn* UNPCDirectorSubsystem::GetPlayerPawn() const { return UGameplayStatics::GetPlayerPawn(GetWorld(), 0); } UNPCDirectorConfig* UNPCDirectorSubsystem::GetConfig() const { const UNakedDesireGameInstance* GameInstance = Cast(GetWorld() ? GetWorld()->GetGameInstance() : nullptr); return GameInstance ? GameInstance->NPCDirector : nullptr; }