Setup NPC director
This commit is contained in:
@@ -45,7 +45,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "StructUtils",
|
"Name": "StructUtils",
|
||||||
"Enabled": true
|
"Enabled": false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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-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-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-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-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.
|
- **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.
|
- **`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.
|
- **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.
|
- **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<ANPC>` 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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -13,3 +13,18 @@ enum class EBodyPart : uint8
|
|||||||
Ass = 2,
|
Ass = 2,
|
||||||
Genitals = 3,
|
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"
|
#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
|
FText UExposeBodyPartObjective::GetDescription() const
|
||||||
{
|
{
|
||||||
if (RequiredHoldSeconds > 0.0f)
|
if (RequiredHoldSeconds > 0.0f)
|
||||||
|
|||||||
@@ -5,20 +5,6 @@
|
|||||||
|
|
||||||
#define LOCTEXT_NAMESPACE "Commissions.Objectives.ExposeWhileWalking"
|
#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
|
FText UExposeWhileWalkingObjective::GetDescription() const
|
||||||
{
|
{
|
||||||
return FText::Format(LOCTEXT("Walk", "Walk {0} m with your {1} exposed"),
|
return FText::Format(LOCTEXT("Walk", "Walk {0} m with your {1} exposed"),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
class UStartingSaveData;
|
class UStartingSaveData;
|
||||||
class UCommissionBoardConfig;
|
class UCommissionBoardConfig;
|
||||||
|
class UNPCDirectorConfig;
|
||||||
|
|
||||||
UCLASS()
|
UCLASS()
|
||||||
class NAKEDDESIRE_API UNakedDesireGameInstance : public UGameInstance
|
class NAKEDDESIRE_API UNakedDesireGameInstance : public UGameInstance
|
||||||
@@ -20,4 +21,8 @@ public:
|
|||||||
// Hand-authored commission pool the UMissionSubsystem offers (§13).
|
// Hand-authored commission pool the UMissionSubsystem offers (§13).
|
||||||
UPROPERTY(EditDefaultsOnly, Category = "Commissions")
|
UPROPERTY(EditDefaultsOnly, Category = "Commissions")
|
||||||
TObjectPtr<UCommissionBoardConfig> CommissionBoard;
|
TObjectPtr<UCommissionBoardConfig> CommissionBoard;
|
||||||
|
|
||||||
|
// Crowd population tuning the UNPCDirectorSubsystem uses (§10.2, §17.1).
|
||||||
|
UPROPERTY(EditDefaultsOnly, Category = "NPC")
|
||||||
|
TObjectPtr<UNPCDirectorConfig> NPCDirector;
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
|
|
||||||
#include "NPC.h"
|
#include "NPC.h"
|
||||||
|
#include "NPCTypeDefinition.h"
|
||||||
|
#include "NPCAIController.h"
|
||||||
|
#include "Components/SkeletalMeshComponent.h"
|
||||||
#include "GameFramework/CharacterMovementComponent.h"
|
#include "GameFramework/CharacterMovementComponent.h"
|
||||||
|
|
||||||
ANPC::ANPC()
|
ANPC::ANPC()
|
||||||
@@ -13,5 +16,67 @@ ANPC::ANPC()
|
|||||||
GetCharacterMovement()->bOrientRotationToMovement = false;
|
GetCharacterMovement()->bOrientRotationToMovement = false;
|
||||||
bUseControllerRotationYaw = false;
|
bUseControllerRotationYaw = false;
|
||||||
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
|
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();
|
||||||
|
}
|
||||||
@@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
#include "CoreMinimal.h"
|
#include "CoreMinimal.h"
|
||||||
#include "GameFramework/Character.h"
|
#include "GameFramework/Character.h"
|
||||||
|
#include "NPCType.h"
|
||||||
#include "NPC.generated.h"
|
#include "NPC.generated.h"
|
||||||
|
|
||||||
class ANPC;
|
class UNPCTypeDefinition;
|
||||||
|
|
||||||
UCLASS()
|
UCLASS()
|
||||||
class NAKEDDESIRE_API ANPC : public ACharacter
|
class NAKEDDESIRE_API ANPC : public ACharacter
|
||||||
@@ -15,4 +16,37 @@ class NAKEDDESIRE_API ANPC : public ACharacter
|
|||||||
|
|
||||||
public:
|
public:
|
||||||
ANPC();
|
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();
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
#include "NPCAIController.h"
|
#include "NPCAIController.h"
|
||||||
|
#include "NPC.h"
|
||||||
#include "Perception/AISense_Sight.h"
|
#include "Perception/AISense_Sight.h"
|
||||||
#include "Perception/AISenseConfig_Sight.h"
|
#include "Perception/AISenseConfig_Sight.h"
|
||||||
#include "NakedDesire/Player/NakedDesireCharacter.h"
|
#include "NakedDesire/Player/NakedDesireCharacter.h"
|
||||||
@@ -29,13 +30,25 @@ void ANPCAIController::OnPossess(APawn* InPawn)
|
|||||||
}
|
}
|
||||||
|
|
||||||
void ANPCAIController::OnUnPossess()
|
void ANPCAIController::OnUnPossess()
|
||||||
|
{
|
||||||
|
ClearObservation();
|
||||||
|
Super::OnUnPossess();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ANPCAIController::ClearObservation()
|
||||||
{
|
{
|
||||||
if (bCurrentlyObserving && PlayerCharacter)
|
if (bCurrentlyObserving && PlayerCharacter)
|
||||||
{
|
{
|
||||||
PlayerCharacter->StatsManager->SetObserved(false, GetPawn());
|
PlayerCharacter->StatsManager->SetObserved(false, GetPawn());
|
||||||
bCurrentlyObserving = false;
|
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)
|
void ANPCAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus)
|
||||||
@@ -54,5 +67,5 @@ void ANPCAIController::OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimu
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
bCurrentlyObserving = bSensed;
|
bCurrentlyObserving = bSensed;
|
||||||
PlayerCharacter->StatsManager->SetObserved(bSensed, GetPawn());
|
PlayerCharacter->StatsManager->SetObserved(bSensed, GetPawn(), GetObservationWeight());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,10 +25,16 @@ class NAKEDDESIRE_API ANPCAIController : public ADetourCrowdAIController
|
|||||||
public:
|
public:
|
||||||
ANPCAIController();
|
ANPCAIController();
|
||||||
|
|
||||||
|
// Clears any active observation of the player (used on un-possess and when the NPC is pooled out).
|
||||||
|
void ClearObservation();
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
UFUNCTION()
|
UFUNCTION()
|
||||||
void OnTargetPerceptionUpdate(AActor* Actor, FAIStimulus Stimulus);
|
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 OnPossess(APawn* InPawn) override;
|
||||||
virtual void OnUnPossess() 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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -54,7 +54,7 @@ float UStatsManager::ComputeObservedExposureRate()
|
|||||||
|
|
||||||
for (int32 i = Observers.Num() - 1; i >= 0; --i)
|
for (int32 i = Observers.Num() - 1; i >= 0; --i)
|
||||||
{
|
{
|
||||||
AActor* Observer = Observers[i].Get();
|
AActor* Observer = Observers[i].Actor.Get();
|
||||||
if (!Observer)
|
if (!Observer)
|
||||||
{
|
{
|
||||||
Observers.RemoveAtSwap(i);
|
Observers.RemoveAtSwap(i);
|
||||||
@@ -64,7 +64,8 @@ float UStatsManager::ComputeObservedExposureRate()
|
|||||||
const float Exposure = OwnerCharacter->GetObservedExposureFrom(Observer->GetActorLocation(), Observer);
|
const float Exposure = OwnerCharacter->GetObservedExposureFrom(Observer->GetActorLocation(), Observer);
|
||||||
if (Exposure > 0.0f)
|
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;
|
++ActiveObservers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,23 +84,27 @@ void UStatsManager::Init(UClothingManager* InClothingManager)
|
|||||||
ClothingManager = InClothingManager;
|
ClothingManager = InClothingManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
void UStatsManager::SetObserved(const bool bObserved, AActor* Observer)
|
void UStatsManager::SetObserved(const bool bObserved, AActor* Observer, const float Weight)
|
||||||
{
|
{
|
||||||
if (!Observer)
|
if (!Observer)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const int32 ExistingIndex = Observers.IndexOfByPredicate(
|
||||||
|
[Observer](const FObserverEntry& Entry) { return Entry.Actor == Observer; });
|
||||||
|
|
||||||
bool bChanged = false;
|
bool bChanged = false;
|
||||||
if (bObserved)
|
if (bObserved)
|
||||||
{
|
{
|
||||||
if (!Observers.Contains(Observer))
|
if (ExistingIndex == INDEX_NONE)
|
||||||
{
|
{
|
||||||
Observers.Add(Observer);
|
Observers.Add({ Observer, Weight });
|
||||||
bChanged = true;
|
bChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else if (ExistingIndex != INDEX_NONE)
|
||||||
{
|
{
|
||||||
bChanged = Observers.Remove(Observer) > 0;
|
Observers.RemoveAtSwap(ExistingIndex);
|
||||||
|
bChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bChanged)
|
if (bChanged)
|
||||||
@@ -109,9 +114,9 @@ void UStatsManager::SetObserved(const bool bObserved, AActor* Observer)
|
|||||||
int32 UStatsManager::GetObserverCount() const
|
int32 UStatsManager::GetObserverCount() const
|
||||||
{
|
{
|
||||||
int32 Count = 0;
|
int32 Count = 0;
|
||||||
for (const TWeakObjectPtr<AActor>& Observer : Observers)
|
for (const FObserverEntry& Observer : Observers)
|
||||||
{
|
{
|
||||||
if (Observer.IsValid())
|
if (Observer.Actor.IsValid())
|
||||||
++Count;
|
++Count;
|
||||||
}
|
}
|
||||||
return Count;
|
return Count;
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ public:
|
|||||||
|
|
||||||
// Called by NPCAIController when an NPC gains or loses sight of the player. Observer is the
|
// 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
|
// 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).
|
// body parts that observer can actually see (GDD §7.1). Weight is the observer's NPC-type
|
||||||
void SetObserved(bool bObserved, AActor* Observer);
|
// 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).
|
// Number of NPCs currently perceiving the player (used by commission objectives, §13.4).
|
||||||
UFUNCTION(BlueprintPure)
|
UFUNCTION(BlueprintPure)
|
||||||
@@ -84,7 +85,15 @@ private:
|
|||||||
UPROPERTY()
|
UPROPERTY()
|
||||||
TObjectPtr<ANakedDesireCharacter> OwnerCharacter;
|
TObjectPtr<ANakedDesireCharacter> OwnerCharacter;
|
||||||
|
|
||||||
// NPCs currently perceiving the player (added/removed via SetObserved). Weak so a destroyed
|
// One perceiving NPC and its observation weight (§10.2). Weak so a destroyed observer drops
|
||||||
// observer drops out without dangling; stale entries are compacted during the tick.
|
// out without dangling.
|
||||||
TArray<TWeakObjectPtr<AActor>> Observers;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user