Updated observation logic

This commit is contained in:
2026-05-30 15:35:59 +03:00
parent a7a61bd60e
commit 4218b36ac9
9 changed files with 190 additions and 47 deletions
@@ -58,6 +58,30 @@ bool UClothingManager::IsBodyPartExposed(const EBodyPart BodyPart)
return true;
}
float UClothingManager::GetEffectiveCoverage(const EBodyPart BodyPart)
{
// GDD §6.3.2: effective coverage of a part = max() across garments that include it.
// A garment that doesn't cover the part contributes nothing; nothing covering it = 0.
float MaxCoverage = 0.0f;
for (const auto& [Slot, Instance] : EquippedClothing)
{
if (!Instance)
continue;
const UClothingItemDefinition* Definition = Instance->GetClothingItemDefinition();
if (!Definition)
continue;
for (const FBodyPartCoverage& Coverage : Definition->CoveredBodyParts)
{
if (Coverage.BodyPart == BodyPart)
MaxCoverage = FMath::Max(MaxCoverage, Coverage.Coverage);
}
}
return MaxCoverage;
}
void UClothingManager::HydrateClothing()
{
USaveSubsystem* SaveSubsystem = UGameplayStatics::GetGameInstance(GetWorld())->GetSubsystem<USaveSubsystem>();
@@ -46,6 +46,7 @@ public:
TArray<UClothingItemInstance*> GetEquippedClothing() const;
UClothingItemInstance* GetSlotClothing(EClothingSlotType SlotType);
bool IsBodyPartExposed(EBodyPart BodyPart);
float GetEffectiveCoverage(EBodyPart BodyPart);
void HydrateClothing();
+2 -2
View File
@@ -47,7 +47,7 @@ void ANPCAIController::OnUnPossess()
{
if (bCurrentlyObserving && PlayerCharacter)
{
PlayerCharacter->StatsManager->SetObserved(false);
PlayerCharacter->StatsManager->SetObserved(false, GetPawn());
bCurrentlyObserving = false;
}
Super::OnUnPossess();
@@ -65,5 +65,5 @@ void ANPCAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimu
return;
bCurrentlyObserving = bSensed;
PlayerCharacter->StatsManager->SetObserved(bSensed);
PlayerCharacter->StatsManager->SetObserved(bSensed, GetPawn());
}
@@ -134,40 +134,63 @@ UAISense_Sight::EVisibilityResult ANakedDesireCharacter::CanBeSeenFrom(const FCa
FVector& OutSeenLocation, int32& OutNumberOfLoSChecksPerformed, int32& OutNumberOfAsyncLosCheckRequested,
float& OutSightStrength, int32* UserData, const FOnPendingVisibilityQueryProcessedDelegate* Delegate)
{
const FVector StartLocation = Context.ObserverLocation;
const FVector BoobsBoneLocation = GetMesh()->GetBoneLocation(FName(TEXT("boobs_root")));
const FVector PelvisBoneLocation = GetMesh()->GetBoneLocation(FName(TEXT("pelvis")));
OutNumberOfLoSChecksPerformed++;
FHitResult BoobsHitResult;
const bool BoobsHit = CheckSight(StartLocation, BoobsBoneLocation, BoobsHitResult, Context.IgnoreActor);
FHitResult PelvisHitResult;
const bool PelvisHit = CheckSight(StartLocation, PelvisBoneLocation, PelvisHitResult, Context.IgnoreActor);
bool bTopVisible = false;
bool bBottomVisible = false;
const float Exposure = ComputeObservedExposure(Context.ObserverLocation, Context.IgnoreActor, bTopVisible, bBottomVisible);
if ((!BoobsHit || BoobsHitResult.GetActor() == this) && ClothingManager->IsBodyPartExposed(EBodyPart::Boobs))
// An observer perceives the player only when a revealing body part is within their line of sight.
if (Exposure > 0.0f)
{
UE_LOG(LogTemp, Warning, TEXT("Boobs hit"));
OutSeenLocation = BoobsBoneLocation;
const bool bSeenTop = bTopVisible && RevealAmount(EBodyPart::Boobs) > 0.0f;
OutSeenLocation = GetMesh()->GetBoneLocation(FName(bSeenTop ? TEXT("boobs_root") : TEXT("pelvis")));
OutSightStrength = 1.0f;
return UAISense_Sight::EVisibilityResult::Visible;
}
if ((!PelvisHit || PelvisHitResult.GetActor() == this) &&
(ClothingManager->IsBodyPartExposed(EBodyPart::Ass) || ClothingManager->IsBodyPartExposed(EBodyPart::Genitals)))
{
UE_LOG(LogTemp, Warning, TEXT("Pelvis hit"));
OutSeenLocation = PelvisBoneLocation;
OutSightStrength = 1.0f;
return UAISense_Sight::EVisibilityResult::Visible;
}
UE_LOG(LogTemp, Warning, TEXT("Not visoble"));
return UAISense_Sight::EVisibilityResult::NotVisible;
}
float ANakedDesireCharacter::GetObservedExposureFrom(const FVector& ObserverLocation, const AActor* IgnoreActor)
{
bool bTopVisible = false;
bool bBottomVisible = false;
return ComputeObservedExposure(ObserverLocation, IgnoreActor, bTopVisible, bBottomVisible);
}
float ANakedDesireCharacter::ComputeObservedExposure(const FVector& ObserverLocation, const AActor* IgnoreActor,
bool& bOutTopVisible, bool& bOutBottomVisible)
{
const FVector BoobsBoneLocation = GetMesh()->GetBoneLocation(FName(TEXT("boobs_root")));
const FVector PelvisBoneLocation = GetMesh()->GetBoneLocation(FName(TEXT("pelvis")));
float Exposure = 0.0f;
// Top region -> Boobs. A region is "visible" when its LOS trace is unobstructed (or only hits self).
FHitResult TopHit;
bOutTopVisible = !CheckSight(ObserverLocation, BoobsBoneLocation, TopHit, IgnoreActor) || TopHit.GetActor() == this;
if (bOutTopVisible)
Exposure += RevealAmount(EBodyPart::Boobs);
// Bottom region (pelvis) -> Ass + Genitals; one trace gates both lower parts.
FHitResult BottomHit;
bOutBottomVisible = !CheckSight(ObserverLocation, PelvisBoneLocation, BottomHit, IgnoreActor) || BottomHit.GetActor() == this;
if (bOutBottomVisible)
{
Exposure += RevealAmount(EBodyPart::Ass);
Exposure += RevealAmount(EBodyPart::Genitals);
}
return Exposure;
}
float ANakedDesireCharacter::RevealAmount(const EBodyPart BodyPart)
{
const float Coverage = ClothingManager->GetEffectiveCoverage(BodyPart);
return Coverage < ObservationRevealThreshold ? (1.0f - Coverage) : 0.0f;
}
bool ANakedDesireCharacter::CheckSight(const FVector& StartLocation, const FVector& EndLocation, FHitResult& HitResult,
const AActor* IgnoreActor)
{
@@ -6,6 +6,7 @@
#include "GameFramework/Character.h"
#include "InputAction.h"
#include "InputMappingContext.h"
#include "NakedDesire/Clothing/BodyPart.h"
#include "NakedDesire/Global/Gait.h"
#include "NakedDesire/Global/NakedDesireUserSettings.h"
#include "NakedDesire/Global/Stance.h"
@@ -121,7 +122,18 @@ public:
virtual void NotifyControllerChanged() override;
virtual void BeginPlay() override;
virtual UAISense_Sight::EVisibilityResult CanBeSeenFrom(const FCanBeSeenFromContext& Context, FVector& OutSeenLocation, int32& OutNumberOfLoSChecksPerformed, int32& OutNumberOfAsyncLosCheckRequested, float& OutSightStrength, int32* UserData = nullptr, const FOnPendingVisibilityQueryProcessedDelegate* Delegate = nullptr) override;
// Player exposure as read by an observer at ObserverLocation, in [0, 3]: the sum over the
// LOS-visible body regions (top -> Boobs, bottom -> Ass + Genitals) of (1 - effective coverage)
// for parts below ObservationRevealThreshold. 0 means nothing revealing is visible to them.
// Queried per observing NPC each tick by UStatsManager to drive embarrassment (GDD §7.1).
float GetObservedExposureFrom(const FVector& ObserverLocation, const AActor* IgnoreActor);
// Below this effective coverage [0,1] a body part reads as "revealing": observers notice it and
// it contributes to embarrassment. At or above it the part is treated as decently covered.
UPROPERTY(EditDefaultsOnly, Category = "Observation")
float ObservationRevealThreshold = 0.9f;
private:
EGait Gait = EGait::Walk;
EStance Stance = EStance::Stand;
@@ -135,6 +147,14 @@ private:
void OnInteractPress(const FInputActionValue& Value);
bool CheckSight(const FVector& StartLocation, const FVector& EndLocation, FHitResult& HitResult, const AActor* IgnoreActor);
// Traces observer->boobs_root and observer->pelvis for line of sight (top / bottom regions),
// then sums (1 - coverage) over the revealing parts in each LOS-visible region. The out-bools
// report raw line of sight per region (before coverage), used to pick a seen location.
float ComputeObservedExposure(const FVector& ObserverLocation, const AActor* IgnoreActor, bool& bOutTopVisible, bool& bOutBottomVisible);
// (1 - effective coverage) when BodyPart is below ObservationRevealThreshold, else 0.
float RevealAmount(EBodyPart BodyPart);
UFUNCTION(Exec)
void LogTest();
+65 -11
View File
@@ -3,6 +3,16 @@
#include "StatsManager.h"
#include "NakedDesire/Player/NakedDesireCharacter.h"
namespace
{
// Max raw exposure one observer can read at once: Boobs + Ass + Genitals fully exposed, equal
// weights (GDD §7.1 — per-part weighting is uniform for now). Normalizes per-observer exposure
// to [0,1] so EmbarrassmentGainRate reads as "gain/sec at full exposure, single close observer".
constexpr float MaxObservedExposure = 3.0f;
}
UStatsManager::UStatsManager()
{
PrimaryComponentTick.bCanEverTick = true;
@@ -12,7 +22,9 @@ UStatsManager::UStatsManager()
void UStatsManager::BeginPlay()
{
Super::BeginPlay();
OwnerCharacter = Cast<ANakedDesireCharacter>(GetOwner());
EmbarrassmentUpdate.Broadcast(Embarrassment, MaxEmbarrassment);
StaminaUpdate.Broadcast(Stamina, MaxStamina);
EnergyUpdate.Broadcast(Energy, MaxEnergy);
@@ -23,21 +35,63 @@ void UStatsManager::TickComponent(float DeltaTime, enum ELevelTick TickType,
{
DecreaseEnergy(0.9f);
if (ObserverCount > 0)
{
// TODO (#05): replace 0.0f with ClothingManager->GetEffectiveCoverage() when that lands.
constexpr float CoverageWeight = 0.0f;
IncreaseEmbarrassment(EmbarrassmentGainRate * (1.0f - CoverageWeight) * DeltaTime);
}
const float ExposureRate = ComputeObservedExposureRate();
if (ExposureRate > 0.0f)
IncreaseEmbarrassment(EmbarrassmentGainRate * ExposureRate * DeltaTime);
else
{
DecreaseEmbarrassment(EmbarrassmentDecayRate * DeltaTime);
}
}
void UStatsManager::SetObserved(const bool bObserved, const float CoverageWeight)
float UStatsManager::ComputeObservedExposureRate()
{
ObserverCount = FMath::Max(0, bObserved ? ObserverCount + 1 : ObserverCount - 1);
if (!OwnerCharacter)
return 0.0f;
// Sum each active observer's normalized exposure, re-traced live so redressing / exposing
// updates the rate immediately even while the observer set is unchanged.
float SumNormalizedExposure = 0.0f;
int32 ActiveObservers = 0;
for (int32 i = Observers.Num() - 1; i >= 0; --i)
{
AActor* Observer = Observers[i].Get();
if (!Observer)
{
Observers.RemoveAtSwap(i);
continue;
}
const float Exposure = OwnerCharacter->GetObservedExposureFrom(Observer->GetActorLocation(), Observer);
if (Exposure > 0.0f)
{
SumNormalizedExposure += Exposure / MaxObservedExposure;
++ActiveObservers;
}
}
if (ActiveObservers == 0)
return 0.0f;
// Mean per-observer exposure, then a saturating crowd multiplier (diminishing returns).
const float MeanExposure = SumNormalizedExposure / ActiveObservers;
const float DensityMultiplier = 1.0f + ObserverDensityScale * FMath::Loge(static_cast<float>(ActiveObservers));
return MeanExposure * DensityMultiplier;
}
void UStatsManager::Init(UClothingManager* InClothingManager)
{
ClothingManager = InClothingManager;
}
void UStatsManager::SetObserved(const bool bObserved, AActor* Observer)
{
if (!Observer)
return;
if (bObserved)
Observers.AddUnique(Observer);
else
Observers.Remove(Observer);
}
void UStatsManager::IncreaseEmbarrassment(const float Amount)
+27 -7
View File
@@ -6,6 +6,8 @@
#include "Components/ActorComponent.h"
#include "StatsManager.generated.h"
class UClothingManager;
class ANakedDesireCharacter;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FAttributeUpdateSignature, float, CurrentValue, float, MaxValue);
@@ -19,8 +21,6 @@ class NAKEDDESIRE_API UStatsManager : public UActorComponent
float Energy = 1000.0f;
float MaxEnergy = 1000.0f;
int32 ObserverCount = 0;
public:
UStatsManager();
@@ -30,17 +30,22 @@ public:
UPROPERTY(EditDefaultsOnly, Category = "Embarrassment")
float EmbarrassmentDecayRate = 1.0f;
protected:
// Crowd scaling: N active observers multiply embarrassment gain by 1 + ObserverDensityScale * ln(N).
// A single observer (N=1) is the x1 baseline; extra observers add with diminishing returns.
UPROPERTY(EditDefaultsOnly, Category = "Embarrassment")
float ObserverDensityScale = 0.5f;
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, enum ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
void Init(UClothingManager* InClothingManager);
public:
float Stamina = 100.0f;
float MaxStamina = 100.0f;
// Called by NPCAIController when sight is gained or lost.
// CoverageWeight: fraction of body covered [0..1]; pass 0.0f until #05 (GetEffectiveCoverage) lands.
void SetObserved(bool bObserved, float CoverageWeight = 0.0f);
// 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);
UFUNCTION(BlueprintCallable)
void IncreaseEmbarrassment(float Amount);
@@ -58,4 +63,19 @@ public:
UPROPERTY(BlueprintAssignable)
FAttributeUpdateSignature EnergyUpdate;
private:
// Re-traces every current observer against the player's live coverage and returns the
// normalized embarrassment-gain factor (0 when nothing revealing is currently observed).
float ComputeObservedExposureRate();
UPROPERTY()
TObjectPtr<UClothingManager> ClothingManager;
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;
};