232 lines
6.1 KiB
C++
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;
|
|
} |