added session loss sequence, added energy and stamina to HUD, added home location trigger

This commit is contained in:
2026-06-04 18:59:04 +03:00
parent f2fcd42edf
commit 034694695a
26 changed files with 404 additions and 24 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -93,9 +93,9 @@ State of the C++ module as of the latest pass. File references use `Source/Naked
- **Commission system — rebuilt (`Commissions/`, slice-first foundation).** Replaces the old `MissionBuilder/` Goal/Restriction model with the §13.4 vocabulary. `UCommissionObjective` is the unified typed step (owns its condition + an optional `RequiredHoldSeconds` hold timer — "expose for N s" vs "expose once" is a data value, not a class); concrete steps derive from a shared base chain — `UCommissionObjective``UObserverObjectiveBase` (reacts to the observer count) → `UCoverageObjectiveBase` (re-evaluates on equip/unequip via `ClothingManager::GetEffectiveCoverage`). Implemented steps: `BeFullyNaked`, `ExposeBodyPart`, `BeFullyNakedNearNPCs`, `StayUnseenWhileNaked`, `GatherCrowd`, `BeObservedWhileExposed`, `WearOnlyUnderwear`, `BareRegion` (topless/bottomless), `StayBelowCoverage`, `ReachEmbarrassment`/`SustainEmbarrassment`, plus the `UTravelObjectiveBase` distance family — `RunNakedDistance`, `WalkNakedDistance`, `ExposeWhileWalking`, `WalkNakedWhileObserved` (shared clamped distance-sampling timer; subclasses only override `DoesSampleCount()`) — and `MoveDistanceFromClothing` (polls the new `UDroppedClothingSubsystem` for the nearest garment you left behind; `ReachLocationAwayFromClothing` is this + a `ULocationConstraint`, no new class). Location objectives (`EnterLocationNaked`, etc.) are authored as a coverage step + `ULocationConstraint` (no new class). `UCommission` is the `Offered→Accepted→Completed/Expired` state machine + `FCommissionReward` (money/XP/followers). `UMissionSubsystem` (`UWorldSubsystem`, like `TimeOfDaySubsystem`) offers a hand-authored `UCommissionBoardConfig` pool (set on `UNakedDesireGameInstance::CommissionBoard`), drives accept/abandon, **pays rewards instantly on completion** (money→save, XP→character; followers stubbed, Phase 8), **expires accepted commissions on `OnDayChanged`**, and persists state to a new `UGlobalSaveGameData::Commissions` bucket (id-keyed, state-level — objective mid-progress not preserved). Added `UStatsManager::GetObserverCount()` + `OnObserversChanged` so "near NPCs" reuses the embarrassment observer set. **Composition:** objectives gate on **constraints** (`UCommissionConstraint``UObservedConstraint`, `UDayPhaseConstraint`, `UWearingSlotConstraint`) — "do X while Y" with no new objective code; and a commission can set `bSequentialObjectives` to require its steps in array order (strip → walk → …). The full objective/constraint idea backlog with feasibility tags lives in `COMMISSIONS.md`. **Follow-ups:** commission board UI is in (forum app — see Phase 8 phone block); profile tab is still a placeholder; `failurePenalty` is a hook only (no reputation/followers yet); procedural generation + path-filtering + the remaining §13.4 step types (`PerformAction`, `BeObservedByNPCType`, `TakePhotoAtLocation`, `DeliverItemTo`) are Phase 7. Weekly commissions share the daily lifecycle today (re-offered + expired every day-roll), so a true week-long weekly arc — survive day-rolls, expire at week-end, retain "completed this week" — is still unbuilt (§13.1).
- **Old mission framework — parked, not deleted.** `MissionBuilder/` (`Mission`/`MissionGoal`/`GoalRestriction`, `FlashGoal`/`MinTimeGoal`, the 3 restrictions, `MissionsConfig`) and the `ANakedDesireCharacter::MissionsManager` component remain on disk but dormant; remove in a cleanup pass once the new system's UI is wired and no Blueprint references the old classes.
- **Daily-mission OOB guarded** — `NakedDesireGameMode::RefreshDailyMissions` now clamps `DaysPassed` to the authored array bounds (`NakedDesireGameMode.cpp:70-71`). Still a hand-authored list (see §1.3).
- **Location system (GDD §10.4) — unified on `ULocationSubsystem`.** `Locations/LocationSubsystem` (`UWorldSubsystem`) is the single authority on which tagged locations the player occupies: `ALocationTrigger` volumes report player enter/leave (ref-counted, so overlapping boxes of one place don't churn), and it exposes `IsPlayerInLocation(tag)` (hierarchical via `FGameplayTagContainer::HasTag`), `GetCurrentLocation()`, and `OnLocationEntered/Exited`. Locations are identified by `ULocationData::Tag` and **nest** (inside `Location.City.Beach` you also match `Location.City`). `bIsApartment` is **gone**: `USessionManagerSubsystem` now subscribes to the subsystem and starts/ends the session on the native tag `TAG_Location_Apartment` (`Global/NakedDesireGameplayTags`). Commission location gating is `Commissions/Constraints/LocationConstraint`. **Content requirement:** the apartment trigger's `ULocationData` must be tagged `Location.Apartment` (or a child); each trigger box needs overlap-with-Pawn collision.
- **Location system (GDD §10.4) — unified on `ULocationSubsystem`.** `Locations/LocationSubsystem` (`UWorldSubsystem`) is the single authority on which tagged locations the player occupies: `ALocationTrigger` volumes report player enter/leave (ref-counted, so overlapping boxes of one place don't churn), and it exposes `IsPlayerInLocation(tag)` (hierarchical via `FGameplayTagContainer::HasTag`), `GetCurrentLocation()`, and `OnLocationEntered/Exited`. Locations are identified by `ULocationData::Tag` and **nest** (inside `Location.City.Beach` you also match `Location.City`). `bIsApartment` is **gone**: `USessionManagerSubsystem` now subscribes to the subsystem and starts/ends the session on the native tag `TAG_Location_Apartment` (`Global/NakedDesireGameplayTags`). Commission location gating is `Commissions/Constraints/LocationConstraint`. `ALocationTrigger` tracks player presence per-box (a bool) and **seeds its initial overlap one tick after BeginPlay**, so a pawn that *spawns* already inside a volume (the apartment at game start) is registered — without this the first `ExitLocation` is dropped (no matching enter) and the first session never starts. Enter/exit forwarding is idempotent and exits only fire once the player fully clears the box. **Content requirement:** the apartment trigger's `ULocationData` must be tagged `Location.Apartment` (or a child); each trigger box needs overlap-with-Pawn collision.
- **Session manager (GDD §4.1–§4.4)** — `Global/SessionManagerSubsystem.h/.cpp` (`UWorldSubsystem`). Tracks `bSessionActive`, emits `OnSessionStart` / `OnSessionEnd(ESessionLossCause)`; `ESessionLossCause = { SafeReturn, EmbarrassmentMax, EnergyZero, PoliceCapture }`. Apartment `ALocationTrigger` starts a session on exit and safely ends it on re-entry. Subscribes (next-tick after world begin play) to `UStatsManager::EmbarrassmentUpdate` (max-hit → `EmbarrassmentMax`) and `EnergyUpdate` (≤0 → `EnergyZero`). Exposes `bPoliceChaseActive` (+ setter / getter) for the §4.4 loss-precedence rule the resolver owns. Replaces the old `EndGameEmbarrassed` GameMode BP call, which `UStatsManager::IncreaseEmbarrassment` no longer invokes. The `EndGameEmbarrassed` BlueprintImplementableEvent declaration still exists on `ANakedDesireGameMode` but is now dead from C++ and should be removed once BP no longer references it.
- **Session loss resolver (GDD §4.4)** — `Global/SessionLossResolver.h/.cpp` (`UWorldSubsystem`). Single entry point `ResolveLoss(ESessionLossCause)`, bound to `USessionManagerSubsystem::OnSessionEnd`. Applies the police-chase precedence override (any cause → `PoliceCapture` while `bPoliceChaseActive`), then per cause: `EmbarrassmentMax` no-cost; `EnergyZero` destroys every world `AItemPickup` + clears its save record (guaranteed sleep loss); `PoliceCapture` deducts `PoliceCaptureMoneyPenalty` if affordable else flags a holding-cell outcome; `SafeReturn` no loss. Never strips equipped clothing. Autosaves, then broadcasts `OnSessionLossResolved(FinalCause, bWentToHoldingCell)` for the BP presentation / time-skip layer. See §1.3 for the pieces still delegated to BP / later phases.
- **Session loss resolver (GDD §4.4)** — `Global/SessionLossResolver.h/.cpp` (`UWorldSubsystem`). Single entry point `ResolveLoss(ESessionLossCause)`, bound to `USessionManagerSubsystem::OnSessionEnd`. Applies the police-chase precedence override (any cause → `PoliceCapture` while `bPoliceChaseActive`), then per cause: `EmbarrassmentMax` no-cost; `EnergyZero` destroys every world `AItemPickup` + clears its save record (guaranteed sleep loss); `PoliceCapture` deducts `PoliceCaptureMoneyPenalty` if affordable else flags a holding-cell outcome; `SafeReturn` no loss. Never strips equipped clothing. Autosaves, broadcasts `OnSessionLossResolved(FinalCause, bWentToHoldingCell)`, then runs `BeginLossPresentation(Cause)`: looks up the cause's cutscene in the data-driven `ULossPresentationConfig` (`UNakedDesireGameInstance::LossPresentation`, a `TMap<ESessionLossCause, TSoftObjectPtr<ULevelSequence>>`), plays it via `ULevelSequencePlayer` (movement/look input disabled during playback), and on `OnFinished` teleports the player to the home `APlayerStart` (one tagged `"Home"` preferred). No cutscene authored for a cause → teleports immediately; `SafeReturn` → no presentation. See §1.3 for the per-cause time-skip extras still pending.
- **Movement** — `EnhancedInput`, walk / run / crouch (`NakedDesireCharacter.cpp:115-127`), stamina-gated run (`Tick` lines 91-113).
- **Wardrobe storage + management** — `Inventory/InventorySubsystem` (`UGameInstanceSubsystem`) is the single runtime owner of the off-body store. It holds live `UItemInstance`s mirrored from `UGlobalSaveGameData::WardrobeItems` and exposes the atomic moves `AddToWardrobe` / `RemoveFromWardrobe` / `EquipFromWardrobe` / `UnequipToWardrobe`, each mutating the wardrobe + equipped save buckets together and broadcasting `OnWardrobeChanged`. `AClothingManager` stays the body-state authority (owns the `EquippedItems` bucket via the new `EquipSlot` + existing `RemoveClothing`); the bodysuit exclusion rule is now the shared static `UClothingManager::GetBodysuitExcludedSlots` and routes displaced garments back to the wardrobe instead of dropping them to the world. `AWardrobe` is reduced to an interaction shell that forwards to the subsystem (its stale `ClothingItems` array is gone). UI: `WardrobeScreenWidget` inits the inventory list and hosts the `EquipmentSlotMenuWidget` popup (same plumbing as `InventoryScreenWidget`); `WardrobeInventoryWidget` renders the live list and re-renders on `OnWardrobeChanged`; clicking a wardrobe item calls `EquipFromWardrobe`. The slot menu has a single Remove button whose `Init(slot, bAtWardrobe)` flag decides the action — store via `UnequipToWardrobe` when opened at the wardrobe, drop to the world otherwise. **Home-storage model (GDD §6.5 / §10.4 / §28):** the wardrobe is the general home stockpile for **all non-food** items (clothing, sex toys, phones, keys, spare bags) — `WardrobeItems` is already a generic `UItemInstance` list, so this matches with no change. Food is **not** stored in the wardrobe; it lives in the **fridge** (separate fixture, pending — see §1.3). **Follow-ups:** `BuyItem` buy-flow not reattached (no caller yet); world pickup (`ItemPickup``TakeClothing`) still doesn't clear the `WorldItems` record; non-clothing wardrobe items (phones/toys) are stored but not yet rendered in the wardrobe UI.
@@ -111,7 +111,7 @@ State of the C++ module as of the latest pass. File references use `Source/Naked
### 1.3 Missing
- **Session loss — remaining (GDD §4.4)** — the transactional resolver exists (`USessionLossResolver`, see §1.1), but several outcomes depend on systems not yet built: the energy-zero trudge-home cutscene, the holding-cell cutscene, and the fade/teleport-to-apartment are delegated to BP via `OnSessionLossResolved` and not yet authored. The time skips now have a C++ entry point — `UTimeOfDaySubsystem::Sleep()` (sleep cycle) and `SkipToNextMorning()` (holding cell) — so the BP cutscenes call those rather than reimplementing the skip; this also runs the skip before the resolver's autosave, closing the prior ordering gap. Bag-placed-in-world loss is a TODO (bags absent, §6.4). `ClearWanted()` is a stub (no Wanted attribute until Phase 4/6, §7.7). Apartment-interior detection for loose-item loss is approximated (all `AItemPickup`s treated as "outside").
- **Session loss — remaining (GDD §4.4)** — the transactional resolver + presentation flow exist (`USessionLossResolver`, see §1.1): cutscene-per-cause playback and teleport-home are now C++ (data-driven via `ULossPresentationConfig`). **Remaining content:** author the actual `ULevelSequence` cutscene assets, create + assign the `ULossPresentationConfig` data asset on the GameInstance, and place/tag the home `APlayerStart`. **Remaining per-cause behavior:** the time-skip extras are not wired into the resolver yet — `EnergyZero` should advance a sleep cycle (`UTimeOfDaySubsystem::Sleep()`) and the can't-pay `PoliceCapture` should fast-forward to next morning (`SkipToNextMorning()`); `EmbarrassmentMax` must stay a no-time-skip return. These entry points exist; decide whether the resolver calls them post-teleport or the cutscene sequence does. Bag-placed-in-world loss is a TODO (bags absent, §6.4). `ClearWanted()` is a stub (no Wanted attribute until Phase 4/6, §7.7). Apartment-interior detection for loose-item loss is approximated (all `AItemPickup`s treated as "outside").
- **Three progression paths runtime (§5)** — enum exists; no XP pool, no per-path level derived from investment, no path-gated unlocks at runtime, no level-up flow. **XP is a single shared pool, not per-path** (GDD §5, §7.10).
- **Phone (§9)** — entire system absent: camera, gallery, livestream, bank, Feetex, maps, health tracker. Includes the new sub-systems:
- **Battery (§9.8)** — passive base + per-app multiplier drain; apartment charger; portable powerbank consumable (Convenience Store); hard shutdown at 0%; mid-livestream cutoff with earnings-to-date deposited; sleep always charges to 100%.
@@ -110,6 +110,21 @@ FText UCommissionObjective::GetDescription() const
return FText::GetEmpty();
}
FText UCommissionObjective::GetConstraintsDescription() const
{
TArray<FString> Parts;
for (const UCommissionConstraint* Constraint : Constraints)
{
if (!Constraint)
continue;
const FString Text = Constraint->GetDescription().ToString();
if (!Text.IsEmpty())
Parts.Add(Text);
}
return FText::FromString(FString::Join(Parts, TEXT(", ")));
}
float UCommissionObjective::GetProgress() const
{
if (bSatisfied)
@@ -39,6 +39,11 @@ public:
UFUNCTION(BlueprintPure)
virtual float GetProgress() const;
// Combined "while Y" text for every attached constraint (e.g. "while at Beach, during Night"),
// or empty when the objective is unconstrained. For UI to show alongside GetDescription().
UFUNCTION(BlueprintPure)
FText GetConstraintsDescription() const;
protected:
UPROPERTY()
ANakedDesireCharacter* Player = nullptr;
+1
View File
@@ -16,6 +16,7 @@ inline constexpr float DAY_START_HOUR = 8.0f; // 08:00 — day phase begins
inline constexpr float NIGHT_START_HOUR = 20.0f; // 20:00 — night phase begins (§10.1)
inline constexpr int32 DAY_ROLL_HOUR = 4; // 04:00 — calendar day increments (§20 #25)
inline constexpr float SLEEP_DURATION_HOURS = 8.0f; // §2.4 sleep fast-forwards 8 hours
inline constexpr float SLEEP_TRANSITION_SECONDS = 3.0f; // real seconds to sweep the sky across the sleep skip
inline constexpr int32 CAMPAIGN_LENGTH_DAYS = 90; // §3.3 survive 90 days
inline constexpr int32 WEEK_LENGTH_DAYS = 7; // §2.4 rent due each week
inline constexpr float WEEKLY_RENT = 20000.0f; // §15.3 early-tier placeholder (§21 tuning)
@@ -0,0 +1,26 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "SessionManagerSubsystem.h"
#include "LossPresentationConfig.generated.h"
class ULevelSequence;
/**
* Data-driven cutscene-per-loss-cause map (GDD §4.4, §17.4). The USessionLossResolver
* looks up the sequence for the resolved cause, plays it, then teleports the player home.
* A cause with no entry skips straight to the teleport; SafeReturn never plays one (the
* player walked home). Soft refs so cutscene assets only load when a loss actually fires.
*/
UCLASS()
class NAKEDDESIRE_API ULossPresentationConfig : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, Category = "Loss")
TMap<ESessionLossCause, TSoftObjectPtr<ULevelSequence>> LossCutscenes;
};
@@ -8,6 +8,7 @@
class UStartingSaveData;
class UCommissionBoardConfig;
class UNPCDirectorConfig;
class ULossPresentationConfig;
UCLASS()
class NAKEDDESIRE_API UNakedDesireGameInstance : public UGameInstance
@@ -25,4 +26,8 @@ public:
// Crowd population tuning the UNPCDirectorSubsystem uses (§10.2, §17.1).
UPROPERTY(EditDefaultsOnly, Category = "NPC")
TObjectPtr<UNPCDirectorConfig> NPCDirector;
// Cutscene-per-loss-cause map the USessionLossResolver plays before teleporting home (§4.4).
UPROPERTY(EditDefaultsOnly, Category = "Session")
TObjectPtr<ULossPresentationConfig> LossPresentation;
};
@@ -3,12 +3,24 @@
#include "SessionLossResolver.h"
#include "DefaultLevelSequenceInstanceData.h"
#include "LevelSequence.h"
#include "LevelSequenceActor.h"
#include "LevelSequencePlayer.h"
#include "LossPresentationConfig.h"
#include "MovieSceneSequencePlaybackSettings.h"
#include "NakedDesireGameInstance.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/PlayerStart.h"
#include "Kismet/GameplayStatics.h"
#include "NakedDesire/Clothing/ClothingItemInstance.h"
#include "NakedDesire/Interactables/ItemPickup.h"
#include "NakedDesire/SaveGame/GlobalSaveGameData.h"
#include "NakedDesire/SaveGame/SaveSubsystem.h"
const FName USessionLossResolver::HomePlayerStartTag = FName(TEXT("Home"));
void USessionLossResolver::OnWorldBeginPlay(UWorld& InWorld)
{
Super::OnWorldBeginPlay(InWorld);
@@ -74,6 +86,9 @@ void USessionLossResolver::ResolveLoss(ESessionLossCause Cause)
Autosave();
OnSessionLossResolved.Broadcast(Cause, bWentToHoldingCell);
// Presentation: play the cause's cutscene, then teleport the player home on finish.
BeginLossPresentation(Cause);
}
void USessionLossResolver::ResolveSleepLoss()
@@ -117,6 +132,122 @@ void USessionLossResolver::ClearWanted()
// TODO(§7.7 / Phase 6): clear the `wanted` tag once the Wanted attribute exists.
}
void USessionLossResolver::BeginLossPresentation(ESessionLossCause Cause)
{
// SafeReturn = the player walked home under their own power; nothing to present.
if (Cause == ESessionLossCause::SafeReturn)
return;
ULevelSequence* Sequence = nullptr;
if (const ULossPresentationConfig* Config = GetPresentationConfig())
{
if (const TSoftObjectPtr<ULevelSequence>* Found = Config->LossCutscenes.Find(Cause))
{
// Synchronous load is acceptable here: the loss has already resolved and the
// player is stationary, so the brief hitch is hidden by the transition.
Sequence = Found->LoadSynchronous();
}
}
if (!Sequence)
{
// No cutscene authored for this cause — go straight home.
TeleportPlayerHome();
return;
}
// Lock the player out of movement / look while the cutscene owns the view; state is
// restored when the sequence finishes (just before we teleport home).
FMovieSceneSequencePlaybackSettings Settings;
Settings.bDisableMovementInput = true;
Settings.bDisableLookAtInput = true;
// Restore track state on finish so the view/player return to normal before we teleport.
Settings.FinishCompletionStateOverride = EMovieSceneCompletionModeOverride::ForceRestoreState;
ALevelSequenceActor* SequenceActor = nullptr;
ActiveLossSequencePlayer = ULevelSequencePlayer::CreateLevelSequencePlayer(this, Sequence, Settings, SequenceActor);
ActiveLossSequenceActor = SequenceActor;
ActiveLossSequenceActor->bOverrideInstanceData = true;
UDefaultLevelSequenceInstanceData* InstanceData = Cast<UDefaultLevelSequenceInstanceData>(ActiveLossSequenceActor->DefaultInstanceData.Get());
InstanceData->TransformOriginActor = UGameplayStatics::GetPlayerPawn(GetWorld(), 0);
if (!ActiveLossSequencePlayer)
{
// Playback couldn't be created — don't strand the player outside.
TeleportPlayerHome();
return;
}
ActiveLossSequencePlayer->OnFinished.AddDynamic(this, &USessionLossResolver::HandleLossCutsceneFinished);
ActiveLossSequencePlayer->Play();
}
void USessionLossResolver::HandleLossCutsceneFinished()
{
if (ActiveLossSequencePlayer)
{
ActiveLossSequencePlayer->OnFinished.RemoveDynamic(this, &USessionLossResolver::HandleLossCutsceneFinished);
}
TeleportPlayerHome();
// Tear down the spawned sequence player/actor now that the cutscene is done.
if (ActiveLossSequenceActor)
{
ActiveLossSequenceActor->Destroy();
}
ActiveLossSequenceActor = nullptr;
ActiveLossSequencePlayer = nullptr;
}
void USessionLossResolver::TeleportPlayerHome()
{
UWorld* World = GetWorld();
if (!World)
return;
APawn* PlayerPawn = UGameplayStatics::GetPlayerPawn(World, 0);
if (!PlayerPawn)
return;
// Prefer a PlayerStart tagged "Home"; fall back to the first one in the level.
TArray<AActor*> Starts;
UGameplayStatics::GetAllActorsOfClass(World, APlayerStart::StaticClass(), Starts);
APlayerStart* Home = nullptr;
for (AActor* Actor : Starts)
{
APlayerStart* Start = Cast<APlayerStart>(Actor);
if (!Start)
continue;
if (!Home)
Home = Start;
if (Start->PlayerStartTag == HomePlayerStartTag)
{
Home = Start;
break;
}
}
if (!Home)
{
UE_LOG(LogTemp, Warning, TEXT("USessionLossResolver: no APlayerStart found; cannot teleport player home."));
return;
}
PlayerPawn->TeleportTo(Home->GetActorLocation(), Home->GetActorRotation());
// Kill residual velocity so the character doesn't slide on arrival.
if (const ACharacter* Character = Cast<ACharacter>(PlayerPawn))
{
if (UCharacterMovementComponent* Movement = Character->GetCharacterMovement())
{
Movement->StopMovementImmediately();
}
}
}
void USessionLossResolver::Autosave() const
{
if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance())
@@ -138,4 +269,11 @@ UGlobalSaveGameData* USessionLossResolver::GetSave() const
}
}
return nullptr;
}
ULossPresentationConfig* USessionLossResolver::GetPresentationConfig() const
{
const UNakedDesireGameInstance* GameInstance =
Cast<UNakedDesireGameInstance>(GetWorld() ? GetWorld()->GetGameInstance() : nullptr);
return GameInstance ? GameInstance->LossPresentation : nullptr;
}
@@ -48,6 +48,11 @@ public:
UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionLossResolvedSignature OnSessionLossResolved;
// Teleport the player pawn to the home APlayerStart (preferring one tagged "Home").
// Called automatically when a loss cutscene finishes; exposed for debug / direct use.
UFUNCTION(BlueprintCallable, Category = "Session")
void TeleportPlayerHome();
private:
// EnergyZero / sleep: every clothing item left outside the apartment is guaranteed lost.
void LoseAllWorldClothing();
@@ -55,9 +60,28 @@ private:
// §7.7: cleared on police capture. No-op until the Wanted attribute exists (Phase 6).
void ClearWanted();
// Plays the configured cutscene for the resolved cause, then teleports the player home
// on finish. No cutscene authored → teleports immediately. SafeReturn → no presentation.
void BeginLossPresentation(ESessionLossCause Cause);
UFUNCTION()
void HandleLossCutsceneFinished();
void Autosave() const;
class UGlobalSaveGameData* GetSave() const;
class ULossPresentationConfig* GetPresentationConfig() const;
// §21 tuning placeholder — the police-capture money penalty.
float PoliceCaptureMoneyPenalty = 200.0f;
// PlayerStart tag of the home spawn, preferred over an untagged start (GDD §4.4).
static const FName HomePlayerStartTag;
// The level-sequence player/actor for the in-flight loss cutscene. Held so they survive
// GC until OnFinished fires; torn down in HandleLossCutsceneFinished.
UPROPERTY()
TObjectPtr<class ULevelSequencePlayer> ActiveLossSequencePlayer;
UPROPERTY()
TObjectPtr<class ALevelSequenceActor> ActiveLossSequenceActor;
};
@@ -31,7 +31,18 @@ void UTimeOfDaySubsystem::OnWorldBeginPlay(UWorld& InWorld)
void UTimeOfDaySubsystem::Tick(float DeltaTime)
{
if (!bBegunPlay || IsPaused())
if (!bBegunPlay)
return;
// The sleep sweep owns the clock while it runs — it drives AdvanceClock itself, so
// skip the normal real-time advancement (and ignore pause, which sleep doesn't honor).
if (bSleeping)
{
TickSleep(DeltaTime);
return;
}
if (IsPaused())
return;
AdvanceClock(static_cast<double>(DeltaTime) * INGAME_MINUTES_PER_REAL_SECOND);
@@ -91,7 +102,41 @@ void UTimeOfDaySubsystem::SkipToNextMorning()
void UTimeOfDaySubsystem::Sleep()
{
SkipTime(SLEEP_DURATION_HOURS * MINUTES_PER_HOUR);
if (bSleeping)
return; // already sweeping; ignore re-entrant interacts
// Kick off the animated sweep — Tick advances the clock a slice at a time so the sky
// sun/lighting interpolate across the 8 hours instead of snapping. Energy restore and
// autosave are deferred to FinishSleep() so they land on the final time, not the start.
const double TotalMinutes = SLEEP_DURATION_HOURS * MINUTES_PER_HOUR;
SleepMinutesRemaining = TotalMinutes;
SleepMinutesPerRealSecond = TotalMinutes / FMath::Max(SLEEP_TRANSITION_SECONDS, KINDA_SMALL_NUMBER);
bSleeping = true;
}
void UTimeOfDaySubsystem::TickSleep(float DeltaTime)
{
// Clamp the final slice so we land exactly on +8h rather than overshooting.
double Step = SleepMinutesPerRealSecond * static_cast<double>(DeltaTime);
if (Step >= SleepMinutesRemaining)
Step = SleepMinutesRemaining;
SleepMinutesRemaining -= Step;
// Throttled push (bForceSkyPush=false) keeps the sky at the smooth 30fps cadence; the
// per-hour boundaries (phase flip, day-roll, rent) still fire as each hour is crossed.
AdvanceClock(Step);
if (SleepMinutesRemaining <= 0.0)
FinishSleep();
}
void UTimeOfDaySubsystem::FinishSleep()
{
bSleeping = false;
SleepMinutesRemaining = 0.0;
SleepMinutesPerRealSecond = 0.0;
PushTimeToSky(/*bForce=*/true); // snap the sky to the exact final time
RestorePlayerEnergy();
// TODO(§9.8 / Phase 9): charge the equipped phone to 100% as part of the sleep cycle.
// TODO(§7.3): sleep does NOT reset hunger / effective-max decay — only eating does.
@@ -90,9 +90,15 @@ public:
// §2.4 sleep: fast-forward 8 hours, restore energy, autosave. (The apartment bed
// also routes outside-clothing loss through USessionLossResolver — see §4.4.)
// The skip is animated over SLEEP_TRANSITION_SECONDS — the clock advances a little
// each frame so the sky sweeps smoothly; energy/autosave land when the sweep finishes.
UFUNCTION(BlueprintCallable, Category = "Time")
void Sleep();
// True while the sleep time-lapse is running (BP can hold a dim overlay / lock input).
UFUNCTION(BlueprintPure, Category = "Time")
bool IsSleeping() const { return bSleeping; }
// --- Pause (reason-keyed; the clock runs only while the reason set is empty).
// Note §11.17: the holding-cell cutscene deliberately does NOT pause the clock. ---
UFUNCTION(BlueprintCallable, Category = "Time")
@@ -124,6 +130,9 @@ private:
// bForceSkyPush bypasses the 30fps throttle for discrete jumps (skips / load),
// so the sky snaps to the new time immediately instead of waiting a frame.
void AdvanceClock(double DeltaMinutes, bool bForceSkyPush = false);
// Per-frame driver for the animated sleep sweep; finalizes when the budget is spent.
void TickSleep(float DeltaTime);
void FinishSleep();
void HandleHourBoundary(int32 HourOfDay); // 023
void SetPhase(EDayPhase NewPhase);
void AdvanceCalendarDay();
@@ -142,4 +151,10 @@ private:
EDayPhase CurrentPhase = EDayPhase::Day;
TSet<FName> PauseReasons;
bool bBegunPlay = false;
// Animated sleep state. While bSleeping, Tick drives the clock at SleepMinutesPerRealSecond
// instead of the normal rate, advancing SleepMinutesRemaining in-game minutes total.
bool bSleeping = false;
double SleepMinutesRemaining = 0.0;
double SleepMinutesPerRealSecond = 0.0;
};
@@ -7,9 +7,6 @@
#include "Engine/DataAsset.h"
#include "LocationData.generated.h"
/**
*
*/
UCLASS()
class NAKEDDESIRE_API ULocationData : public UPrimaryDataAsset
{
@@ -1,4 +1,4 @@
// © 2025 Naked People Team. All Rights Reserved.
// © 2025 Naked People Team. All Rights Reserved.
#include "LocationTrigger.h"
@@ -6,6 +6,7 @@
#include "Components/BoxComponent.h"
#include "LocationSubsystem.h"
#include "NakedDesire/Player/NakedDesireCharacter.h"
#include "TimerManager.h"
ALocationTrigger::ALocationTrigger()
@@ -27,16 +28,26 @@ void ALocationTrigger::BeginPlay()
BoxTrigger->OnComponentBeginOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerBeginOverlap);
BoxTrigger->OnComponentEndOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerEndOverlap);
// Defer to next tick so the player pawn is spawned and positioned and physics overlaps
// have been computed before we check whether it started out inside this volume.
GetWorld()->GetTimerManager().SetTimerForNextTick(this, &ALocationTrigger::SeedInitialPlayerOverlap);
}
void ALocationTrigger::OnConstruction(const FTransform& Transform)
{
Super::OnConstruction(Transform);
BoxTrigger->SetBoxExtent(TriggerSize);
}
void ALocationTrigger::OnTriggerBeginOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex, bool bFromSweep, const FHitResult& SweepResult)
{
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
return;
if (ULocationSubsystem* Locations = GetWorld()->GetSubsystem<ULocationSubsystem>())
Locations->EnterLocation(LocationData);
SetPlayerInside(true);
}
void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
@@ -45,6 +56,39 @@ void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedCompon
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
return;
// Another of the player's components may still overlap the box (capsule vs. mesh); only
// report the exit once the player has fully left, so we fire exactly one Exit per Enter.
if (IsPlayerOverlapping())
return;
SetPlayerInside(false);
}
void ALocationTrigger::SeedInitialPlayerOverlap()
{
if (IsPlayerOverlapping())
SetPlayerInside(true);
}
void ALocationTrigger::SetPlayerInside(const bool bInside)
{
if (bInside == bPlayerInside)
return;
bPlayerInside = bInside;
if (ULocationSubsystem* Locations = GetWorld()->GetSubsystem<ULocationSubsystem>())
Locations->ExitLocation(LocationData);
{
if (bInside)
Locations->EnterLocation(LocationData);
else
Locations->ExitLocation(LocationData);
}
}
bool ALocationTrigger::IsPlayerOverlapping() const
{
TArray<AActor*> Overlapping;
BoxTrigger->GetOverlappingActors(Overlapping, ANakedDesireCharacter::StaticClass());
return Overlapping.Num() > 0;
}
+18 -1
View File
@@ -1,4 +1,4 @@
// © 2025 Naked People Team. All Rights Reserved.
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
@@ -22,10 +22,15 @@ class NAKEDDESIRE_API ALocationTrigger : public AActor
UPROPERTY(EditAnywhere)
ULocationData* LocationData;
UPROPERTY(EditAnywhere)
FVector TriggerSize;
public:
ALocationTrigger();
virtual void OnConstruction(const FTransform& Transform) override;
ULocationData* GetLocationData() const;
protected:
@@ -39,4 +44,16 @@ private:
UFUNCTION()
void OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex);
// A pawn that spawns already inside this volume (e.g. the apartment at game start) gets no
// begin-overlap event, so seed the state one tick after BeginPlay once the world settles.
void SeedInitialPlayerOverlap();
// Forwards a single enter/exit to ULocationSubsystem on a real transition. Idempotent so the
// seed above can't double-count against a begin-overlap the engine does deliver.
void SetPlayerInside(bool bInside);
bool IsPlayerOverlapping() const;
bool bPlayerInside = false;
};
+1 -1
View File
@@ -11,7 +11,7 @@ public class NakedDesire : ModuleRules
PublicDependencyModuleNames.AddRange(new string[]
{
"Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "UMG", "CommonUI", "NavigationSystem",
"AIModule", "GameplayTags", "Slate", "SlateCore"
"AIModule", "GameplayTags", "Slate", "SlateCore", "LevelSequence", "MovieScene"
});
}
}
+12
View File
@@ -15,9 +15,21 @@ void UHUDWidget::NativeConstruct()
return;
Player->StatsManager->EmbarrassmentUpdate.AddUniqueDynamic(this, &UHUDWidget::OnEmbarrassmentUpdated);
Player->StatsManager->EnergyUpdate.AddUniqueDynamic(this, &UHUDWidget::OnEnergyUpdated);
Player->StatsManager->StaminaUpdate.AddUniqueDynamic(this, &UHUDWidget::OnStaminaUpdated);
}
void UHUDWidget::OnEmbarrassmentUpdated(float CurrentValue, float MaxValue)
{
EmbarrassmentBar->SetPercent(CurrentValue / MaxValue);
}
void UHUDWidget::OnEnergyUpdated(float CurrentValue, float MaxValue)
{
EnergyBar->SetPercent(CurrentValue / MaxValue);
}
void UHUDWidget::OnStaminaUpdated(float CurrentValue, float MaxValue)
{
StaminaBar->SetPercent(CurrentValue / MaxValue);
}
+12
View File
@@ -16,10 +16,22 @@ class NAKEDDESIRE_API UHUDWidget : public UCommonUserWidget
UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> EmbarrassmentBar;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> EnergyBar;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> StaminaBar;
protected:
virtual void NativeConstruct() override;
private:
UFUNCTION()
void OnEmbarrassmentUpdated(float CurrentValue, float MaxValue);
UFUNCTION()
void OnEnergyUpdated(float CurrentValue, float MaxValue);
UFUNCTION()
void OnStaminaUpdated(float CurrentValue, float MaxValue);
};
@@ -104,8 +104,17 @@ FString UForumCommissionWidget::BuildObjectivesString() const
if (!Result.IsEmpty())
Result += LINE_TERMINATOR;
FString Line = FString::Printf(TEXT("• %s"), *Objective->GetDescription().ToString());
// Append any "while Y" constraint text so the row reads "Be fully naked while at Beach (50%)".
const FString Constraints = Objective->GetConstraintsDescription().ToString();
if (!Constraints.IsEmpty())
Line += FString::Printf(TEXT(" %s"), *Constraints);
const int32 Pct = FMath::RoundToInt(Objective->GetProgress() * 100.0f);
Result += FString::Printf(TEXT("• %s (%d%%)"), *Objective->GetDescription().ToString(), Pct);
Line += FString::Printf(TEXT(" (%d%%)"), Pct);
Result += Line;
}
return Result;
}