Files
Naked-Desire/Source/NakedDesire/NPC/NPCDirectorSubsystem.cpp
T
2026-06-03 15:17:02 +03:00

232 lines
6.1 KiB
C++

// © 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<UTimeOfDaySubsystem>())
{
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<UTimeOfDaySubsystem>())
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<ANPC> NPCClass = PickWeightedClass();
if (!NPCClass)
continue;
ANPC* NPC = World->SpawnActor<ANPC>(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<ANPC> 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<UNakedDesireGameInstance>(GetWorld() ? GetWorld()->GetGameInstance() : nullptr);
return GameInstance ? GameInstance->NPCDirector : nullptr;
}