Setup NPC director

This commit is contained in:
2026-06-01 16:26:15 +03:00
parent f63c98d5d7
commit 192bd94bb7
17 changed files with 590 additions and 50 deletions
+15
View File
@@ -13,3 +13,18 @@ enum class EBodyPart : uint8
Ass = 2,
Genitals = 3,
};
#define LOCTEXT_NAMESPACE "BodyPart"
inline FText BodyPartText(const EBodyPart Part)
{
switch (Part)
{
case EBodyPart::Boobs: return LOCTEXT("Boobs", "boobs");
case EBodyPart::Ass: return LOCTEXT("Ass", "ass");
case EBodyPart::Genitals: return LOCTEXT("Genitals", "genitals");
default: return LOCTEXT("None", "nothing");
}
}
#undef LOCTEXT_NAMESPACE
@@ -5,20 +5,6 @@
#define LOCTEXT_NAMESPACE "Commissions.Objectives.ExposeBodyPart"
namespace
{
FText BodyPartText(EBodyPart Part)
{
switch (Part)
{
case EBodyPart::Boobs: return LOCTEXT("Boobs", "boobs");
case EBodyPart::Ass: return LOCTEXT("Ass", "ass");
case EBodyPart::Genitals: return LOCTEXT("Genitals", "genitals");
default: return LOCTEXT("None", "nothing");
}
}
}
FText UExposeBodyPartObjective::GetDescription() const
{
if (RequiredHoldSeconds > 0.0f)
@@ -5,20 +5,6 @@
#define LOCTEXT_NAMESPACE "Commissions.Objectives.ExposeWhileWalking"
namespace
{
FText BodyPartText(EBodyPart Part)
{
switch (Part)
{
case EBodyPart::Boobs: return LOCTEXT("Boobs", "boobs");
case EBodyPart::Ass: return LOCTEXT("Ass", "ass");
case EBodyPart::Genitals: return LOCTEXT("Genitals", "genitals");
default: return LOCTEXT("None", "nothing");
}
}
}
FText UExposeWhileWalkingObjective::GetDescription() const
{
return FText::Format(LOCTEXT("Walk", "Walk {0} m with your {1} exposed"),
@@ -7,6 +7,7 @@
class UStartingSaveData;
class UCommissionBoardConfig;
class UNPCDirectorConfig;
UCLASS()
class NAKEDDESIRE_API UNakedDesireGameInstance : public UGameInstance
@@ -20,4 +21,8 @@ public:
// Hand-authored commission pool the UMissionSubsystem offers (§13).
UPROPERTY(EditDefaultsOnly, Category = "Commissions")
TObjectPtr<UCommissionBoardConfig> CommissionBoard;
// Crowd population tuning the UNPCDirectorSubsystem uses (§10.2, §17.1).
UPROPERTY(EditDefaultsOnly, Category = "NPC")
TObjectPtr<UNPCDirectorConfig> NPCDirector;
};
+65
View File
@@ -2,6 +2,9 @@
#include "NPC.h"
#include "NPCTypeDefinition.h"
#include "NPCAIController.h"
#include "Components/SkeletalMeshComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
ANPC::ANPC()
@@ -13,5 +16,67 @@ ANPC::ANPC()
GetCharacterMovement()->bOrientRotationToMovement = false;
bUseControllerRotationYaw = false;
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
// Crowd anim hygiene (mesh-agnostic): only evaluate the pose while the NPC is on screen and
// throttle the eval rate by distance. CharacterMovement still ticks off-screen so pooled-in
// NPCs can keep walking; only the visible pose freezes when unrendered.
if (USkeletalMeshComponent* MeshComp = GetMesh())
{
MeshComp->VisibilityBasedAnimTickOption = EVisibilityBasedAnimTickOption::OnlyTickPoseWhenRendered;
MeshComp->bEnableUpdateRateOptimizations = true;
}
}
ENPCType ANPC::GetNPCType() const
{
return NPCTypeDefinition ? NPCTypeDefinition->Type : ENPCType::Walker;
}
float ANPC::GetObservationWeight() const
{
return NPCTypeDefinition ? NPCTypeDefinition->ObservationWeight : 1.0f;
}
bool ANPC::ShouldStopToObserve() const
{
return NPCTypeDefinition ? NPCTypeDefinition->bStopsToObserve : false;
}
float ANPC::GetObserveDuration() const
{
return NPCTypeDefinition ? NPCTypeDefinition->ObserveDurationSeconds : 0.0f;
}
void ANPC::ActivateFromPool(const FVector& Location, const FRotator& Rotation)
{
SetActorLocationAndRotation(Location, Rotation, false, nullptr, ETeleportType::TeleportPhysics);
SetActorHiddenInGame(false);
SetActorEnableCollision(true);
if (UCharacterMovementComponent* Move = GetCharacterMovement())
{
Move->SetComponentTickEnabled(true);
Move->SetMovementMode(MOVE_Walking);
}
OnActivatedFromPool();
}
void ANPC::DeactivateToPool()
{
// Drop any active observation so a pooled-out NPC stops contributing to the player's embarrassment.
if (ANPCAIController* NPCController = Cast<ANPCAIController>(GetController()))
NPCController->ClearObservation();
if (UCharacterMovementComponent* Move = GetCharacterMovement())
{
Move->StopMovementImmediately();
Move->DisableMovement();
Move->SetComponentTickEnabled(false);
}
SetActorHiddenInGame(true);
SetActorEnableCollision(false);
OnDeactivatedToPool();
}
+36 -2
View File
@@ -4,9 +4,10 @@
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "NPCType.h"
#include "NPC.generated.h"
class ANPC;
class UNPCTypeDefinition;
UCLASS()
class NAKEDDESIRE_API ANPC : public ACharacter
@@ -15,4 +16,37 @@ class NAKEDDESIRE_API ANPC : public ACharacter
public:
ANPC();
};
// Behavioral template for this NPC (Walker / Stalker / …). Author one DA_* per type and assign
// here (or on the NPC blueprint). Null falls back to Walker-ish defaults (GDD §10.2, §17.4).
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "NPC")
TObjectPtr<UNPCTypeDefinition> NPCTypeDefinition;
UFUNCTION(BlueprintPure, Category = "NPC")
ENPCType GetNPCType() const;
// Multiplier on this NPC's embarrassment contribution; read by StatsManager via the AI controller.
UFUNCTION(BlueprintPure, Category = "NPC")
float GetObservationWeight() const;
// True for Stalker-like types; the behavior tree branches on this to stop and stare vs. walk past.
UFUNCTION(BlueprintPure, Category = "NPC")
bool ShouldStopToObserve() const;
// Seconds a stopping NPC lingers staring before resuming its path.
UFUNCTION(BlueprintPure, Category = "NPC")
float GetObserveDuration() const;
// Pooling lifecycle driven by UNPCDirectorSubsystem. The native side handles transform /
// visibility / collision / movement; the BlueprintImplementableEvents let the behavior-tree
// layer restart and pick a fresh destination (BT startup lives in BP, §17.5).
void ActivateFromPool(const FVector& Location, const FRotator& Rotation);
void DeactivateToPool();
protected:
UFUNCTION(BlueprintImplementableEvent, Category = "NPC")
void OnActivatedFromPool();
UFUNCTION(BlueprintImplementableEvent, Category = "NPC")
void OnDeactivatedToPool();
};
+15 -2
View File
@@ -2,6 +2,7 @@
#include "NPCAIController.h"
#include "NPC.h"
#include "Perception/AISense_Sight.h"
#include "Perception/AISenseConfig_Sight.h"
#include "NakedDesire/Player/NakedDesireCharacter.h"
@@ -29,13 +30,25 @@ void ANPCAIController::OnPossess(APawn* InPawn)
}
void ANPCAIController::OnUnPossess()
{
ClearObservation();
Super::OnUnPossess();
}
void ANPCAIController::ClearObservation()
{
if (bCurrentlyObserving && PlayerCharacter)
{
PlayerCharacter->StatsManager->SetObserved(false, GetPawn());
bCurrentlyObserving = false;
}
Super::OnUnPossess();
}
float ANPCAIController::GetObservationWeight() const
{
if (const ANPC* NPC = Cast<ANPC>(GetPawn()))
return NPC->GetObservationWeight();
return 1.0f;
}
void ANPCAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus)
@@ -54,5 +67,5 @@ void ANPCAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimu
return;
bCurrentlyObserving = bSensed;
PlayerCharacter->StatsManager->SetObserved(bSensed, GetPawn());
PlayerCharacter->StatsManager->SetObserved(bSensed, GetPawn(), GetObservationWeight());
}
+7 -1
View File
@@ -25,10 +25,16 @@ class NAKEDDESIRE_API ANPCAIController : public ADetourCrowdAIController
public:
ANPCAIController();
// Clears any active observation of the player (used on un-possess and when the NPC is pooled out).
void ClearObservation();
protected:
UFUNCTION()
void OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus);
// Observation weight of the possessed NPC's type (§10.2), 1.0 if not an ANPC.
float GetObservationWeight() const;
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
};
@@ -0,0 +1,62 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "NPCDirectorConfig.generated.h"
class ANPC;
// One weighted entry in the spawn pool. Author BP_NPC_Walker / BP_NPC_Stalker (each carrying its
// own UNPCTypeDefinition + mesh) and weight them so the crowd composition reads mostly-Walkers.
USTRUCT(BlueprintType)
struct FNPCSpawnEntry
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, Category = "NPC")
TSubclassOf<ANPC> NPCClass;
UPROPERTY(EditDefaultsOnly, Category = "NPC", meta = (ClampMin = "0.0"))
float Weight = 1.0f;
};
// Tuning for UNPCDirectorSubsystem (the GDD §17.1 NPCManager). Assigned on
// UNakedDesireGameInstance::NPCDirector, mirroring CommissionBoard. All distances in cm.
UCLASS(BlueprintType)
class NAKEDDESIRE_API UNPCDirectorConfig : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
// Pool size / hard cap on simultaneous NPC actors. Should be >= the largest target count.
UPROPERTY(EditDefaultsOnly, Category = "Population", meta = (ClampMin = "0"))
int32 MaxNPCs = 20;
// Target live count by day/night phase (§10.1) — day streets are busier than night.
UPROPERTY(EditDefaultsOnly, Category = "Population", meta = (ClampMin = "0"))
int32 TargetCountDay = 14;
UPROPERTY(EditDefaultsOnly, Category = "Population", meta = (ClampMin = "0"))
int32 TargetCountNight = 5;
// Reconcile cadence (seconds). 0.5 is plenty — the director is not per-frame.
UPROPERTY(EditDefaultsOnly, Category = "Population", meta = (ClampMin = "0.05"))
float UpdateInterval = 0.5f;
// Spawn ring around the player: spawn between Min and Max (Min keeps NPCs from popping in on top
// of the player), recycle past DespawnRadius (kept > Max so crossing the ring doesn't churn).
UPROPERTY(EditDefaultsOnly, Category = "Ring", meta = (ClampMin = "0.0"))
float SpawnRadiusMin = 1500.0f;
UPROPERTY(EditDefaultsOnly, Category = "Ring", meta = (ClampMin = "0.0"))
float SpawnRadiusMax = 3500.0f;
UPROPERTY(EditDefaultsOnly, Category = "Ring", meta = (ClampMin = "0.0"))
float DespawnRadius = 4500.0f;
// Weighted classes drawn into the pool at prewarm. Empty = director spawns nothing.
UPROPERTY(EditDefaultsOnly, Category = "Population")
TArray<FNPCSpawnEntry> SpawnTable;
};
@@ -0,0 +1,232 @@
// © 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;
}
@@ -0,0 +1,59 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "NakedDesire/Global/TimeOfDaySubsystem.h"
#include "NPCDirectorSubsystem.generated.h"
class ANPC;
class APawn;
class UNPCDirectorConfig;
/**
* The GDD §17.1 NPCManager: single authority for the crowd around the player (§10.2, §19).
* A WorldSubsystem (like UMissionSubsystem / UTimeOfDaySubsystem) — it needs world access plus the
* day/night phase to drive density.
*
* Keeps a pool of NPC actors prewarmed from UNPCDirectorConfig::SpawnTable and, on a light timer,
* reconciles the live population: recycles NPCs past the despawn radius and activates pooled ones at
* NavMesh points in the spawn ring until the phase-appropriate target count is met. Mesh-agnostic —
* it never touches what an NPC looks like, only how many exist and where. Supersedes the BP spawner.
*/
UCLASS()
class NAKEDDESIRE_API UNPCDirectorSubsystem : public UWorldSubsystem
{
GENERATED_BODY()
public:
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
virtual void Deinitialize() override;
private:
UFUNCTION()
void HandlePhaseChanged(EDayPhase NewPhase);
void PrewarmPool();
void UpdatePopulation(); // timer-driven reconcile
int32 CurrentTargetCount() const;
ANPC* TakeFromPool();
void ReturnToPool(ANPC* NPC);
TSubclassOf<ANPC> PickWeightedClass() const;
bool FindSpawnPoint(const FVector& Around, FVector& OutLocation) const;
APawn* GetPlayerPawn() const;
UNPCDirectorConfig* GetConfig() const;
// Inactive (hidden) NPCs ready to activate.
UPROPERTY()
TArray<TObjectPtr<ANPC>> Pool;
// Currently live NPCs around the player.
UPROPERTY()
TArray<TObjectPtr<ANPC>> Active;
FTimerHandle UpdateTimerHandle;
EDayPhase CachedPhase = EDayPhase::Day;
};
+20
View File
@@ -0,0 +1,20 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "NPCType.generated.h"
// The §10.2 NPC vocabulary. Walker / Stalker are the first implemented pair (they ride the
// existing observation→embarrassment pipeline); the rest are reserved for later phases:
// Blogger needs Recognition (§7.6), Snitch/Police need the wanted + chase systems (§10.3).
UENUM(BlueprintType)
enum class ENPCType : uint8
{
Walker UMETA(DisplayName = "Walker"),
Stalker UMETA(DisplayName = "Stalker"),
Blogger UMETA(DisplayName = "Blogger"),
Snitch UMETA(DisplayName = "Snitch"),
Harasser UMETA(DisplayName = "Harasser"),
Police UMETA(DisplayName = "Police")
};
@@ -0,0 +1,42 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "NPCType.h"
#include "NPCTypeDefinition.generated.h"
class UAnimMontage;
// Data-driven NPC template (GDD §10.2, §17.4). One asset per behavioral type (DA_Walker,
// DA_Stalker, …). ANPC points at one of these; the AI controller + behavior tree read it to
// drive observation weight and the stop-and-stare branch.
UCLASS(BlueprintType)
class NAKEDDESIRE_API UNPCTypeDefinition : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "NPC")
ENPCType Type = ENPCType::Walker;
// Multiplier on this NPC's contribution to the player's observed-exposure embarrassment gain
// (StatsManager §7.1). Walkers read low (~0.35: they glance and move on); Stalkers read high
// (~1.5: a sustained, deliberate stare). 1.0 is the single-close-observer baseline.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "NPC", meta = (ClampMin = "0.0"))
float ObservationWeight = 1.0f;
// When true the NPC halts and observes the player (Stalker); when false it reacts but keeps
// walking (Walker). The behavior tree branches on ANPC::ShouldStopToObserve().
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "NPC")
bool bStopsToObserve = false;
// How long a stopping NPC lingers staring before resuming its path. Ignored when bStopsToObserve is false.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "NPC", meta = (ClampMin = "0.0", EditCondition = "bStopsToObserve"))
float ObserveDurationSeconds = 5.0f;
// Reaction played when the NPC first notices the player (Walker glance / Stalker stare). Optional.
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "NPC")
TSoftObjectPtr<UAnimMontage> ReactionMontage;
};
+14 -9
View File
@@ -54,7 +54,7 @@ float UStatsManager::ComputeObservedExposureRate()
for (int32 i = Observers.Num() - 1; i >= 0; --i)
{
AActor* Observer = Observers[i].Get();
AActor* Observer = Observers[i].Actor.Get();
if (!Observer)
{
Observers.RemoveAtSwap(i);
@@ -64,7 +64,8 @@ float UStatsManager::ComputeObservedExposureRate()
const float Exposure = OwnerCharacter->GetObservedExposureFrom(Observer->GetActorLocation(), Observer);
if (Exposure > 0.0f)
{
SumNormalizedExposure += Exposure / MaxObservedExposure;
// Weight by NPC type (§10.2): a Stalker's stare contributes more than a Walker's glance.
SumNormalizedExposure += (Exposure / MaxObservedExposure) * Observers[i].Weight;
++ActiveObservers;
}
}
@@ -83,23 +84,27 @@ void UStatsManager::Init(UClothingManager* InClothingManager)
ClothingManager = InClothingManager;
}
void UStatsManager::SetObserved(const bool bObserved, AActor* Observer)
void UStatsManager::SetObserved(const bool bObserved, AActor* Observer, const float Weight)
{
if (!Observer)
return;
const int32 ExistingIndex = Observers.IndexOfByPredicate(
[Observer](const FObserverEntry& Entry) { return Entry.Actor == Observer; });
bool bChanged = false;
if (bObserved)
{
if (!Observers.Contains(Observer))
if (ExistingIndex == INDEX_NONE)
{
Observers.Add(Observer);
Observers.Add({ Observer, Weight });
bChanged = true;
}
}
else
else if (ExistingIndex != INDEX_NONE)
{
bChanged = Observers.Remove(Observer) > 0;
Observers.RemoveAtSwap(ExistingIndex);
bChanged = true;
}
if (bChanged)
@@ -109,9 +114,9 @@ void UStatsManager::SetObserved(const bool bObserved, AActor* Observer)
int32 UStatsManager::GetObserverCount() const
{
int32 Count = 0;
for (const TWeakObjectPtr<AActor>& Observer : Observers)
for (const FObserverEntry& Observer : Observers)
{
if (Observer.IsValid())
if (Observer.Actor.IsValid())
++Count;
}
return Count;
+14 -5
View File
@@ -45,8 +45,9 @@ public:
// Called by NPCAIController when an NPC gains or loses sight of the player. Observer is the
// observing pawn; its live location is re-traced each tick to weight embarrassment by the
// body parts that observer can actually see (GDD §7.1).
void SetObserved(bool bObserved, AActor* Observer);
// body parts that observer can actually see (GDD §7.1). Weight is the observer's NPC-type
// multiplier (§10.2) — Walkers read low, Stalkers high; ignored on the un-observe path.
void SetObserved(bool bObserved, AActor* Observer, float Weight = 1.0f);
// Number of NPCs currently perceiving the player (used by commission objectives, §13.4).
UFUNCTION(BlueprintPure)
@@ -84,7 +85,15 @@ private:
UPROPERTY()
TObjectPtr<ANakedDesireCharacter> OwnerCharacter;
// NPCs currently perceiving the player (added/removed via SetObserved). Weak so a destroyed
// observer drops out without dangling; stale entries are compacted during the tick.
TArray<TWeakObjectPtr<AActor>> Observers;
// One perceiving NPC and its observation weight (§10.2). Weak so a destroyed observer drops
// out without dangling.
struct FObserverEntry
{
TWeakObjectPtr<AActor> Actor;
float Weight = 1.0f;
};
// NPCs currently perceiving the player (added/removed via SetObserved). Stale entries are
// compacted during the tick.
TArray<FObserverEntry> Observers;
};