From 034694695a7e3e51472d7a5ea59a04d591d993ea Mon Sep 17 00:00:00 2001 From: koritsa Date: Thu, 4 Jun 2026 18:59:04 +0300 Subject: [PATCH] added session loss sequence, added energy and stamina to HUD, added home location trigger --- Content/Blueprints/A_LocationTrigger.uasset | 3 + Content/Blueprints/Data/Apartment.uasset | 3 + .../Data/CommissionBoardConfig.uasset | 4 +- .../Data/LossPresentationConfig.uasset | 3 + Content/Blueprints/GI_NakedDesire.uasset | 4 +- Content/Sequences/LS_Embarrassment.uasset | 3 + Content/Test/Maps/L_SessionLoss.umap | 3 + Content/Test/Maps/L_Test.umap | 4 +- Content/UI/HUD/W_HUD.uasset | 4 +- PLAN.md | 6 +- .../Commissions/CommissionObjective.cpp | 15 ++ .../Commissions/CommissionObjective.h | 5 + Source/NakedDesire/Global/Constants.h | 1 + .../Global/LossPresentationConfig.h | 26 ++++ .../Global/NakedDesireGameInstance.h | 5 + .../Global/SessionLossResolver.cpp | 138 ++++++++++++++++++ .../NakedDesire/Global/SessionLossResolver.h | 24 +++ .../NakedDesire/Global/TimeOfDaySubsystem.cpp | 49 ++++++- .../NakedDesire/Global/TimeOfDaySubsystem.h | 15 ++ Source/NakedDesire/Locations/LocationData.h | 3 - .../NakedDesire/Locations/LocationTrigger.cpp | 54 ++++++- .../NakedDesire/Locations/LocationTrigger.h | 19 ++- Source/NakedDesire/NakedDesire.Build.cs | 2 +- Source/NakedDesire/UI/HUDWidget.cpp | 12 ++ Source/NakedDesire/UI/HUDWidget.h | 12 ++ .../UI/Phone/Apps/ForumCommissionWidget.cpp | 11 +- 26 files changed, 404 insertions(+), 24 deletions(-) create mode 100644 Content/Blueprints/A_LocationTrigger.uasset create mode 100644 Content/Blueprints/Data/Apartment.uasset create mode 100644 Content/Blueprints/Data/LossPresentationConfig.uasset create mode 100644 Content/Sequences/LS_Embarrassment.uasset create mode 100644 Content/Test/Maps/L_SessionLoss.umap create mode 100644 Source/NakedDesire/Global/LossPresentationConfig.h diff --git a/Content/Blueprints/A_LocationTrigger.uasset b/Content/Blueprints/A_LocationTrigger.uasset new file mode 100644 index 00000000..f76e1191 --- /dev/null +++ b/Content/Blueprints/A_LocationTrigger.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e33e1951bad6667106eeb76a192558ebbfec6ea9c466b90ae106800f5f07cf08 +size 23800 diff --git a/Content/Blueprints/Data/Apartment.uasset b/Content/Blueprints/Data/Apartment.uasset new file mode 100644 index 00000000..3d767d67 --- /dev/null +++ b/Content/Blueprints/Data/Apartment.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6142071805db046a4285f0dac7618bad975f9be0c0a1ea67ceb5361091440856 +size 1873 diff --git a/Content/Blueprints/Data/CommissionBoardConfig.uasset b/Content/Blueprints/Data/CommissionBoardConfig.uasset index 30a98f6f..f232a69a 100644 --- a/Content/Blueprints/Data/CommissionBoardConfig.uasset +++ b/Content/Blueprints/Data/CommissionBoardConfig.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:766a229225a5528bc11db90fa0564a9bd3ed7ad9ffd6d07fe22a17273909dbce -size 2969 +oid sha256:051d1c08a3dfe709ecb4e1d9aac73bb6d7bd3dffb3a0768a1eae06547630ccf4 +size 4674 diff --git a/Content/Blueprints/Data/LossPresentationConfig.uasset b/Content/Blueprints/Data/LossPresentationConfig.uasset new file mode 100644 index 00000000..1d742d54 --- /dev/null +++ b/Content/Blueprints/Data/LossPresentationConfig.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:24ff54e101429c535a24b3119c99f17953eb575a0214be7c73ce3effdf350978 +size 1837 diff --git a/Content/Blueprints/GI_NakedDesire.uasset b/Content/Blueprints/GI_NakedDesire.uasset index 4bf40b4e..37fe33fc 100644 --- a/Content/Blueprints/GI_NakedDesire.uasset +++ b/Content/Blueprints/GI_NakedDesire.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4a46f6ee026f0bd35510e70748431a68b4a814d30134226ec6f33d77226bdef2 -size 35284 +oid sha256:ccfc137f140a31e13e55fe8fb122ab71857a2ba2e71df10cfe9f09beab2d7e24 +size 35502 diff --git a/Content/Sequences/LS_Embarrassment.uasset b/Content/Sequences/LS_Embarrassment.uasset new file mode 100644 index 00000000..46bc75a6 --- /dev/null +++ b/Content/Sequences/LS_Embarrassment.uasset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3615eaa1f3cba43e649824c623565b7bc70ea495a5b9982cc37602d22a7fef8 +size 58427 diff --git a/Content/Test/Maps/L_SessionLoss.umap b/Content/Test/Maps/L_SessionLoss.umap new file mode 100644 index 00000000..6c652b36 --- /dev/null +++ b/Content/Test/Maps/L_SessionLoss.umap @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d099819e1393e9a76f89fc69f6f9bd632ee34a52117803286246de854968544 +size 16766 diff --git a/Content/Test/Maps/L_Test.umap b/Content/Test/Maps/L_Test.umap index 24d6cf68..d8c0b6c2 100644 --- a/Content/Test/Maps/L_Test.umap +++ b/Content/Test/Maps/L_Test.umap @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8659eefcc2cb93e6e4907b5ae938bd05995380444639a30e778b1405c7034b5e -size 235858 +oid sha256:d50c0b04fef38f987250ece6f2a9bf5c6e51bec6242b3d95472aedda3bdef300 +size 240234 diff --git a/Content/UI/HUD/W_HUD.uasset b/Content/UI/HUD/W_HUD.uasset index 78536e98..66b6392d 100644 --- a/Content/UI/HUD/W_HUD.uasset +++ b/Content/UI/HUD/W_HUD.uasset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5c2d77624098634c2d86c1781664b90c31a8634a3ee198eddff6054b5f05d9c2 -size 27021 +oid sha256:a53117b5668f523a0cf7742fe5190ed76c490d85eb459d9c46d180fcaaaed0e2 +size 30964 diff --git a/PLAN.md b/PLAN.md index 0dc0db08..3038d2a6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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>`), 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%. diff --git a/Source/NakedDesire/Commissions/CommissionObjective.cpp b/Source/NakedDesire/Commissions/CommissionObjective.cpp index f8e4d4e9..23b97b37 100644 --- a/Source/NakedDesire/Commissions/CommissionObjective.cpp +++ b/Source/NakedDesire/Commissions/CommissionObjective.cpp @@ -110,6 +110,21 @@ FText UCommissionObjective::GetDescription() const return FText::GetEmpty(); } +FText UCommissionObjective::GetConstraintsDescription() const +{ + TArray 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) diff --git a/Source/NakedDesire/Commissions/CommissionObjective.h b/Source/NakedDesire/Commissions/CommissionObjective.h index b3c6a377..f88f41cd 100644 --- a/Source/NakedDesire/Commissions/CommissionObjective.h +++ b/Source/NakedDesire/Commissions/CommissionObjective.h @@ -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; diff --git a/Source/NakedDesire/Global/Constants.h b/Source/NakedDesire/Global/Constants.h index b459fbc1..c7e5cd3b 100644 --- a/Source/NakedDesire/Global/Constants.h +++ b/Source/NakedDesire/Global/Constants.h @@ -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) diff --git a/Source/NakedDesire/Global/LossPresentationConfig.h b/Source/NakedDesire/Global/LossPresentationConfig.h new file mode 100644 index 00000000..6f358219 --- /dev/null +++ b/Source/NakedDesire/Global/LossPresentationConfig.h @@ -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> LossCutscenes; +}; \ No newline at end of file diff --git a/Source/NakedDesire/Global/NakedDesireGameInstance.h b/Source/NakedDesire/Global/NakedDesireGameInstance.h index 19a35839..55f00f33 100644 --- a/Source/NakedDesire/Global/NakedDesireGameInstance.h +++ b/Source/NakedDesire/Global/NakedDesireGameInstance.h @@ -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 NPCDirector; + + // Cutscene-per-loss-cause map the USessionLossResolver plays before teleporting home (§4.4). + UPROPERTY(EditDefaultsOnly, Category = "Session") + TObjectPtr LossPresentation; }; \ No newline at end of file diff --git a/Source/NakedDesire/Global/SessionLossResolver.cpp b/Source/NakedDesire/Global/SessionLossResolver.cpp index 23118326..1512920f 100644 --- a/Source/NakedDesire/Global/SessionLossResolver.cpp +++ b/Source/NakedDesire/Global/SessionLossResolver.cpp @@ -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* 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(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 Starts; + UGameplayStatics::GetAllActorsOfClass(World, APlayerStart::StaticClass(), Starts); + + APlayerStart* Home = nullptr; + for (AActor* Actor : Starts) + { + APlayerStart* Start = Cast(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(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(GetWorld() ? GetWorld()->GetGameInstance() : nullptr); + return GameInstance ? GameInstance->LossPresentation : nullptr; } \ No newline at end of file diff --git a/Source/NakedDesire/Global/SessionLossResolver.h b/Source/NakedDesire/Global/SessionLossResolver.h index c57ec7ba..80f3baa4 100644 --- a/Source/NakedDesire/Global/SessionLossResolver.h +++ b/Source/NakedDesire/Global/SessionLossResolver.h @@ -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 ActiveLossSequencePlayer; + + UPROPERTY() + TObjectPtr ActiveLossSequenceActor; }; \ No newline at end of file diff --git a/Source/NakedDesire/Global/TimeOfDaySubsystem.cpp b/Source/NakedDesire/Global/TimeOfDaySubsystem.cpp index a1b833c7..6635fa3b 100644 --- a/Source/NakedDesire/Global/TimeOfDaySubsystem.cpp +++ b/Source/NakedDesire/Global/TimeOfDaySubsystem.cpp @@ -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(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(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. diff --git a/Source/NakedDesire/Global/TimeOfDaySubsystem.h b/Source/NakedDesire/Global/TimeOfDaySubsystem.h index 6da851ad..ece94695 100644 --- a/Source/NakedDesire/Global/TimeOfDaySubsystem.h +++ b/Source/NakedDesire/Global/TimeOfDaySubsystem.h @@ -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); // 0–23 void SetPhase(EDayPhase NewPhase); void AdvanceCalendarDay(); @@ -142,4 +151,10 @@ private: EDayPhase CurrentPhase = EDayPhase::Day; TSet 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; }; \ No newline at end of file diff --git a/Source/NakedDesire/Locations/LocationData.h b/Source/NakedDesire/Locations/LocationData.h index 035a3924..30caa0de 100644 --- a/Source/NakedDesire/Locations/LocationData.h +++ b/Source/NakedDesire/Locations/LocationData.h @@ -7,9 +7,6 @@ #include "Engine/DataAsset.h" #include "LocationData.generated.h" -/** - * - */ UCLASS() class NAKEDDESIRE_API ULocationData : public UPrimaryDataAsset { diff --git a/Source/NakedDesire/Locations/LocationTrigger.cpp b/Source/NakedDesire/Locations/LocationTrigger.cpp index 894d8484..7c273f13 100644 --- a/Source/NakedDesire/Locations/LocationTrigger.cpp +++ b/Source/NakedDesire/Locations/LocationTrigger.cpp @@ -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()) return; - if (ULocationSubsystem* Locations = GetWorld()->GetSubsystem()) - 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()) 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()) - Locations->ExitLocation(LocationData); + { + if (bInside) + Locations->EnterLocation(LocationData); + else + Locations->ExitLocation(LocationData); + } +} + +bool ALocationTrigger::IsPlayerOverlapping() const +{ + TArray Overlapping; + BoxTrigger->GetOverlappingActors(Overlapping, ANakedDesireCharacter::StaticClass()); + return Overlapping.Num() > 0; } \ No newline at end of file diff --git a/Source/NakedDesire/Locations/LocationTrigger.h b/Source/NakedDesire/Locations/LocationTrigger.h index 7bc0d482..b9ce9241 100644 --- a/Source/NakedDesire/Locations/LocationTrigger.h +++ b/Source/NakedDesire/Locations/LocationTrigger.h @@ -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; }; \ No newline at end of file diff --git a/Source/NakedDesire/NakedDesire.Build.cs b/Source/NakedDesire/NakedDesire.Build.cs index 1347bf46..8119951e 100644 --- a/Source/NakedDesire/NakedDesire.Build.cs +++ b/Source/NakedDesire/NakedDesire.Build.cs @@ -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" }); } } \ No newline at end of file diff --git a/Source/NakedDesire/UI/HUDWidget.cpp b/Source/NakedDesire/UI/HUDWidget.cpp index 3060286a..719dea12 100644 --- a/Source/NakedDesire/UI/HUDWidget.cpp +++ b/Source/NakedDesire/UI/HUDWidget.cpp @@ -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); +} diff --git a/Source/NakedDesire/UI/HUDWidget.h b/Source/NakedDesire/UI/HUDWidget.h index 2a50fe79..be24cb05 100644 --- a/Source/NakedDesire/UI/HUDWidget.h +++ b/Source/NakedDesire/UI/HUDWidget.h @@ -16,10 +16,22 @@ class NAKEDDESIRE_API UHUDWidget : public UCommonUserWidget UPROPERTY(meta = (BindWidget)) TObjectPtr EmbarrassmentBar; + UPROPERTY(meta = (BindWidget)) + TObjectPtr EnergyBar; + + UPROPERTY(meta = (BindWidget)) + TObjectPtr 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); }; diff --git a/Source/NakedDesire/UI/Phone/Apps/ForumCommissionWidget.cpp b/Source/NakedDesire/UI/Phone/Apps/ForumCommissionWidget.cpp index fa321dd4..e29ca1ba 100644 --- a/Source/NakedDesire/UI/Phone/Apps/ForumCommissionWidget.cpp +++ b/Source/NakedDesire/UI/Phone/Apps/ForumCommissionWidget.cpp @@ -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; }