From 30e5e4ca4195de56667bfd75da8ca3840a3fbe61 Mon Sep 17 00:00:00 2001 From: koritsa Date: Mon, 1 Jun 2026 16:26:15 +0300 Subject: [PATCH] Setup NPC director --- NakedDesire.uproject | 2 +- PLAN.md | 5 +- Source/NakedDesire/Clothing/BodyPart.h | 15 ++ .../Objectives/ExposeBodyPartObjective.cpp | 14 -- .../ExposeWhileWalkingObjective.cpp | 14 -- .../Global/NakedDesireGameInstance.h | 5 + Source/NakedDesire/NPC/NPC.cpp | 65 +++++ Source/NakedDesire/NPC/NPC.h | 38 ++- Source/NakedDesire/NPC/NPCAIController.cpp | 17 +- Source/NakedDesire/NPC/NPCAIController.h | 8 +- Source/NakedDesire/NPC/NPCDirectorConfig.h | 62 +++++ .../NakedDesire/NPC/NPCDirectorSubsystem.cpp | 232 ++++++++++++++++++ Source/NakedDesire/NPC/NPCDirectorSubsystem.h | 59 +++++ Source/NakedDesire/NPC/NPCType.h | 20 ++ Source/NakedDesire/NPC/NPCTypeDefinition.h | 42 ++++ Source/NakedDesire/Stats/StatsManager.cpp | 23 +- Source/NakedDesire/Stats/StatsManager.h | 19 +- 17 files changed, 590 insertions(+), 50 deletions(-) create mode 100644 Source/NakedDesire/NPC/NPCDirectorConfig.h create mode 100644 Source/NakedDesire/NPC/NPCDirectorSubsystem.cpp create mode 100644 Source/NakedDesire/NPC/NPCDirectorSubsystem.h create mode 100644 Source/NakedDesire/NPC/NPCType.h create mode 100644 Source/NakedDesire/NPC/NPCTypeDefinition.h diff --git a/NakedDesire.uproject b/NakedDesire.uproject index b2d996bf..59baa48e 100644 --- a/NakedDesire.uproject +++ b/NakedDesire.uproject @@ -45,7 +45,7 @@ }, { "Name": "StructUtils", - "Enabled": true + "Enabled": false } ] } \ No newline at end of file diff --git a/PLAN.md b/PLAN.md index aa06bd15..ede34c67 100644 --- a/PLAN.md +++ b/PLAN.md @@ -51,7 +51,7 @@ Each `VS-n` is a vertical task with a concrete exit check. VS-1 is the gate — - **VS-2 — Lust + Pulse (minimal).** Add `Lust`/`MaxLust` and `Pulse`/baseline to `StatsManager`. Passive lust gain; pulse rises on run / exposure events / observed-while-exposed and decays at rest; pulse multiplies embarrassment **and** lust gain (§7.5). Masturbate quick-action resets lust. **Gating (§23 #25): home masturbation is always available; in-session masturbation is Slut-path-gated.** For the slice the in-session entry can ship open if path investment isn't wired yet, but keep the home-vs-session split in mind so it's a config flip later, not a rewrite. **Exit:** the three attributes visibly interact in a playtest — running spikes pulse, which accelerates embarrassment gain; masturbating drops lust. - **VS-3 — Expose action.** Radial-menu entry per equipped garment exposing the parts in its `CanExpose`; temporarily counts those parts as uncovered for VS-1 math; blocked when another garment covers the same part (§6.3.6). Reuses the existing `RadialMenu` + `ClothingManager`. **Exit:** flashing a coat in front of an NPC produces an embarrassment spike that ends when the expose ends. - **VS-4 — Commission accept + instant reward.** Add the Accept lifecycle (commit on accept, no penalty when un-accepted) and **instant reward crediting on `OnMissionCompleted`** — money to the save, XP to the shared pool, followers stub (§13.1/§13.2/§23 #23). `MissionsManager` currently completes a mission but credits nothing (`MissionsManager.cpp:27-32`) — wire crediting here. Ensure the typed goals the slice needs exist (`BeFullyNaked`, `BeFullyNakedNearNPCs`, `ExposeBodyPart` — partially covered by `FlashGoal`/`ExposeBodyPartRestriction`). Surface it through a **minimal forum/board widget** (accept + active-objective tracker) reusing the existing UI layer. **Exit:** accept a commission, satisfy it in the world, see money/XP/followers tick up at the moment of completion with no return-home step. -- **VS-5 — NPC types + the session-loss threat.** Differentiate at least **Walker / Stalker / Blogger** by observation weight + behavior (Stalker = sustained stare, high embarrassment; Blogger = photo → recognition stub), plus a **Snitch → Police** beat that drives a real escape: Snitch report sets a chase, Police capture routes through the existing `SessionLossResolver` (`PoliceCapture`, `bPoliceChaseActive`). This is what makes the street a risk space (Pillar 1). **Exit:** the three civilian types feel distinct; a Snitch can trigger a police chase that ends in a §4.4 capture resolution. +- **VS-5 — NPC types + the session-loss threat. IN PROGRESS.** Differentiate at least **Walker / Stalker / Blogger** by observation weight + behavior (Stalker = sustained stare, high embarrassment; Blogger = photo → recognition stub), plus a **Snitch → Police** beat that drives a real escape: Snitch report sets a chase, Police capture routes through the existing `SessionLossResolver` (`PoliceCapture`, `bPoliceChaseActive`). This is what makes the street a risk space (Pillar 1). **Done:** the data-driven NPC type model (`ENPCType`, `UNPCTypeDefinition`, `ANPC` accessors) and the Walker/Stalker mechanical split via per-observer observation weight in `StatsManager` (see §1.3). **Remaining:** author the `DA_Walker`/`DA_Stalker` assets + BT branch on `ShouldStopToObserve`; then Blogger (recognition stub) and the Snitch→Police chase. **Exit:** the three civilian types feel distinct; a Snitch can trigger a police chase that ends in a §4.4 capture resolution. - **VS-6 — Day loop.** A lightweight `TimeOfDaySubsystem` (or formalize the BP-driven `GameMode::OnHourChanged`): day phase 08:00–20:00, NPC density gated by phase (fix the `09–21` window in `NPCSpawner.cpp:40` and the `Hour==4` day-roll in `GameMode.cpp:54`). Sleep at the apartment bed advances the day, restores energy, and autosaves. Rent stays stubbed off. **Exit:** play a full day, sleep, wake on the next day with state intact. - **VS-7 — Save round-trip across the loop (gate).** Validate equip/unequip, world drop, wardrobe, money, day index, and attribute snapshot survive save → quit → reload, exercising the loop end-to-end (this is the existing Phase 1 exit criterion, re-run against the live loop). **Exit:** §6.6 inventory table holds after each session-loss cause, and a mid-run reload reproduces the player's exact state. @@ -121,7 +121,8 @@ State of the C++ module as of the latest pass. File references use `Source/Naked - **`PhoneSubsystem` (§17.1)** — tickable subsystem owning phone state (battery %, active app, livestream session lifecycle, charger interaction). Does not yet exist. - **Forum (§13)** — no `UCommissionTemplate`, no procedural generation, no weekly missions distinct from daily, no profile (incl. weekly follower-income summary), no posting photos. Forum scope is locked minimal (board + own profile only, no threads / no other-users feed) — that's a non-build, but worth recording. - **Photo & livestream** — absent. -- **NPC types (§10.2)** — only one generic `ANPC` class. No Walker / Stalker / Blogger / Snitch / Harasser, no Police, no Wanted-poster mechanic. +- **NPC types (§10.2) — data model + Walker/Stalker landed (VS-5, partial).** `NPC/NPCType.h` defines the full `ENPCType` vocabulary (`Walker, Stalker, Blogger, Snitch, Harasser, Police`). `NPC/NPCTypeDefinition` (`UPrimaryDataAsset`, §17.4) carries `Type`, `ObservationWeight`, `bStopsToObserve`, `ObserveDurationSeconds`, `ReactionMontage`. `ANPC` holds a `NPCTypeDefinition` and exposes BlueprintPure `GetNPCType`/`GetObservationWeight`/`ShouldStopToObserve`/`GetObserveDuration` (Walker-ish fallbacks when null). The mechanical Walker/Stalker split is live: `UStatsManager::SetObserved` now takes a per-observer `Weight`, stored on the `FObserverEntry` and multiplied into `ComputeObservedExposureRate` so a Stalker's stare contributes more embarrassment than a Walker's glance; `ANPCAIController` reads the weight from the possessed `ANPC`'s type. **Still BP / later phases:** the BT branch on `ShouldStopToObserve` (Walker walks past vs Stalker stops & stares), the `DA_Walker`/`DA_Stalker` assets + BP variants, reaction montages, and the remaining types — Blogger needs Recognition (§7.6), Snitch/Police need wanted + chase (§10.3), Harasser needs the grope/evade beat. No Wanted-poster mechanic. +- **NPC crowd director (§17.1 `NPCManager`, §19) — landed.** `NPC/NPCDirectorSubsystem` (`UWorldSubsystem`, mirrors `UMissionSubsystem`) is the single authority for the live crowd around the player. **Mesh-agnostic** (NPCs will be lightweight skeletal meshes, **no MetaHumans / no motion matching** — decided 2026-06-01). On a light timer (`UpdateInterval`, default 0.5s) it reconciles population: recycles NPCs past `DespawnRadius`, trims to target when the phase target drops, and activates pooled NPCs at `UNavigationSystemV1::GetRandomReachablePointInRadius` points in the `[SpawnRadiusMin, SpawnRadiusMax]` ring (rejection-sampled outside the inner radius). Density target is day/night-driven via `UTimeOfDaySubsystem::OnPhaseChanged` (`TargetCountDay`/`TargetCountNight`). **Pooling:** `MaxNPCs` actors are prewarmed from `UNPCDirectorConfig::SpawnTable` (weighted `TSubclassOf` entries → composition) and recycled, never destroyed — no spawn hitch / GC churn. Config is `UNPCDirectorConfig` (`UPrimaryDataAsset`) on `UNakedDesireGameInstance::NPCDirector`. `ANPC` gained `ActivateFromPool`/`DeactivateToPool` (transform/visibility/collision/movement + `OnActivatedFromPool`/`OnDeactivatedToPool` BlueprintImplementableEvents) and crowd anim hygiene (`VisibilityBasedAnimTickOption = OnlyTickPoseWhenRendered` + URO). `ANPCAIController::ClearObservation` drops observation when an NPC is pooled out. **BP contract / follow-ups:** author `BP_NPC_*` classes (mesh + `UNPCTypeDefinition`) + a `DA_NPCDirector` config and assign it on the GI; the NPC BT must **start in `OnActivatedFromPool` and stop in `OnDeactivatedToPool`** (not on possess) so pooled NPCs don't tick AI; **remove `BPA_NPCSpawner` from the level** — the director replaces it. Liveliness behaviors (wander/destinations, idle-at-POI, reaction montages) remain BP, director-assisted. Background/instanced crowd layer deferred (handful density target). - **Calendar, rent, sleep (§2.4, §15.2)** — **core landed (Phase 5):** `UTimeOfDaySubsystem` (`Global/TimeOfDaySubsystem`) owns the clock, rolls the calendar at 04:00, fires `OnHourChanged`/`OnDayChanged`/`OnPhaseChanged`, charges `WEEKLY_RENT` every 7th roll with immediate eviction via `OnCampaignEnded(Evicted)`, and exposes `Sleep()`/`SkipToNextMorning()`/`SkipTime()`. Mission refresh moved to `GameMode::HandleDayChanged`. The apartment **bed interactable** (`Interactables/Bed`) is in — interacting calls `USessionLossResolver::ResolveSleepLoss()` (outside-clothing loss) then `UTimeOfDaySubsystem::Sleep()`, with a BP `PlaySleepTransition` fade hook. **Still to do:** rewire the UltraDynamicSky actor to follow `SetCurrentTime` (and stop the old BP clock from advancing independently — otherwise the clock double-advances); the §4.4 cutscenes calling into the new skip API; the ending/eviction screen reacting to `OnCampaignEnded`; daily follower deposit is stubbed (no follower count until Phase 8); phone charge-to-100% on sleep stubbed (Phase 9). `WEEKLY_RENT` is a §21 tuning placeholder. - **Item-world AActor (§6.1)** — no `AItemActor` / `AClothingPickup` base. - **Bag inventory (§6.4)** — absent. Per the locked model (GDD §6.4 / §27): bags are the **only** container, capacity is a flat per-bag **item count** (no S/M/L size classes), and any item type can be stored. The container concept is removed from clothing — the per-garment `UClothingItemInstance::StoredItems` array has been deleted; the item list will live on a future bag `UItemInstance` subclass. diff --git a/Source/NakedDesire/Clothing/BodyPart.h b/Source/NakedDesire/Clothing/BodyPart.h index 4826532a..b508ddbf 100644 --- a/Source/NakedDesire/Clothing/BodyPart.h +++ b/Source/NakedDesire/Clothing/BodyPart.h @@ -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 diff --git a/Source/NakedDesire/Commissions/Objectives/ExposeBodyPartObjective.cpp b/Source/NakedDesire/Commissions/Objectives/ExposeBodyPartObjective.cpp index 6d0266fc..a5ace1aa 100644 --- a/Source/NakedDesire/Commissions/Objectives/ExposeBodyPartObjective.cpp +++ b/Source/NakedDesire/Commissions/Objectives/ExposeBodyPartObjective.cpp @@ -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) diff --git a/Source/NakedDesire/Commissions/Objectives/ExposeWhileWalkingObjective.cpp b/Source/NakedDesire/Commissions/Objectives/ExposeWhileWalkingObjective.cpp index c9ec4579..43f8b147 100644 --- a/Source/NakedDesire/Commissions/Objectives/ExposeWhileWalkingObjective.cpp +++ b/Source/NakedDesire/Commissions/Objectives/ExposeWhileWalkingObjective.cpp @@ -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"), diff --git a/Source/NakedDesire/Global/NakedDesireGameInstance.h b/Source/NakedDesire/Global/NakedDesireGameInstance.h index d0ef9144..19a35839 100644 --- a/Source/NakedDesire/Global/NakedDesireGameInstance.h +++ b/Source/NakedDesire/Global/NakedDesireGameInstance.h @@ -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 CommissionBoard; + + // Crowd population tuning the UNPCDirectorSubsystem uses (§10.2, §17.1). + UPROPERTY(EditDefaultsOnly, Category = "NPC") + TObjectPtr NPCDirector; }; \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPC.cpp b/Source/NakedDesire/NPC/NPC.cpp index d5dd53d5..437a7c9c 100644 --- a/Source/NakedDesire/NPC/NPC.cpp +++ b/Source/NakedDesire/NPC/NPC.cpp @@ -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(GetController())) + NPCController->ClearObservation(); + + if (UCharacterMovementComponent* Move = GetCharacterMovement()) + { + Move->StopMovementImmediately(); + Move->DisableMovement(); + Move->SetComponentTickEnabled(false); + } + + SetActorHiddenInGame(true); + SetActorEnableCollision(false); + + OnDeactivatedToPool(); +} \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPC.h b/Source/NakedDesire/NPC/NPC.h index a93046cc..625eb1cd 100644 --- a/Source/NakedDesire/NPC/NPC.h +++ b/Source/NakedDesire/NPC/NPC.h @@ -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 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(); +}; \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPCAIController.cpp b/Source/NakedDesire/NPC/NPCAIController.cpp index 582c7072..7feaab77 100644 --- a/Source/NakedDesire/NPC/NPCAIController.cpp +++ b/Source/NakedDesire/NPC/NPCAIController.cpp @@ -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(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()); } diff --git a/Source/NakedDesire/NPC/NPCAIController.h b/Source/NakedDesire/NPC/NPCAIController.h index dd2384f1..3229045e 100644 --- a/Source/NakedDesire/NPC/NPCAIController.h +++ b/Source/NakedDesire/NPC/NPCAIController.h @@ -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; }; diff --git a/Source/NakedDesire/NPC/NPCDirectorConfig.h b/Source/NakedDesire/NPC/NPCDirectorConfig.h new file mode 100644 index 00000000..b87ce046 --- /dev/null +++ b/Source/NakedDesire/NPC/NPCDirectorConfig.h @@ -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 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 SpawnTable; +}; \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPCDirectorSubsystem.cpp b/Source/NakedDesire/NPC/NPCDirectorSubsystem.cpp new file mode 100644 index 00000000..d8af3489 --- /dev/null +++ b/Source/NakedDesire/NPC/NPCDirectorSubsystem.cpp @@ -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()) + { + 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; +} \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPCDirectorSubsystem.h b/Source/NakedDesire/NPC/NPCDirectorSubsystem.h new file mode 100644 index 00000000..369d9718 --- /dev/null +++ b/Source/NakedDesire/NPC/NPCDirectorSubsystem.h @@ -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 PickWeightedClass() const; + bool FindSpawnPoint(const FVector& Around, FVector& OutLocation) const; + + APawn* GetPlayerPawn() const; + UNPCDirectorConfig* GetConfig() const; + + // Inactive (hidden) NPCs ready to activate. + UPROPERTY() + TArray> Pool; + + // Currently live NPCs around the player. + UPROPERTY() + TArray> Active; + + FTimerHandle UpdateTimerHandle; + EDayPhase CachedPhase = EDayPhase::Day; +}; \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPCType.h b/Source/NakedDesire/NPC/NPCType.h new file mode 100644 index 00000000..a65e0676 --- /dev/null +++ b/Source/NakedDesire/NPC/NPCType.h @@ -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") +}; \ No newline at end of file diff --git a/Source/NakedDesire/NPC/NPCTypeDefinition.h b/Source/NakedDesire/NPC/NPCTypeDefinition.h new file mode 100644 index 00000000..317877ba --- /dev/null +++ b/Source/NakedDesire/NPC/NPCTypeDefinition.h @@ -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 ReactionMontage; +}; \ No newline at end of file diff --git a/Source/NakedDesire/Stats/StatsManager.cpp b/Source/NakedDesire/Stats/StatsManager.cpp index 6af5e706..faecc4a2 100644 --- a/Source/NakedDesire/Stats/StatsManager.cpp +++ b/Source/NakedDesire/Stats/StatsManager.cpp @@ -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& Observer : Observers) + for (const FObserverEntry& Observer : Observers) { - if (Observer.IsValid()) + if (Observer.Actor.IsValid()) ++Count; } return Count; diff --git a/Source/NakedDesire/Stats/StatsManager.h b/Source/NakedDesire/Stats/StatsManager.h index aba5b205..3c9cf5e6 100644 --- a/Source/NakedDesire/Stats/StatsManager.h +++ b/Source/NakedDesire/Stats/StatsManager.h @@ -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 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> Observers; + // One perceiving NPC and its observation weight (§10.2). Weak so a destroyed observer drops + // out without dangling. + struct FObserverEntry + { + TWeakObjectPtr Actor; + float Weight = 1.0f; + }; + + // NPCs currently perceiving the player (added/removed via SetObserved). Stale entries are + // compacted during the tick. + TArray Observers; };