Compare commits

...

2 Commits

45 changed files with 934 additions and 40 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.
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.
Binary file not shown.
Binary file not shown.
+5 -4
View File
@@ -90,12 +90,12 @@ State of the C++ module as of the latest pass. File references use `Source/Naked
- **Equip / unequip / drop event** — `Clothing/ClothingManager.cpp:88-163` (slot-based, broadcasts `OnClothingEquip / Unequip / Dropped` with `UClothingItemInstance*`). - **Equip / unequip / drop event** — `Clothing/ClothingManager.cpp:88-163` (slot-based, broadcasts `OnClothingEquip / Unequip / Dropped` with `UClothingItemInstance*`).
- **Censorship toggle** — compliance feature on `NakedDesireCharacter` (`BoobL/R`, `Front/BackBottom` static meshes), driven by `UNakedDesireUserSettings`. - **Censorship toggle** — compliance feature on `NakedDesireCharacter` (`BoobL/R`, `Front/BackBottom` static meshes), driven by `UNakedDesireUserSettings`.
- **AI sight + behavior tree** — `NPC/NPCAIController.cpp` runs a BT with `Player` / `TargetLocation` / `SpawnLocation` blackboard keys. `NPC/NPCSpawner.cpp` proximity-gated spawn with day / night caps. - **AI sight + behavior tree** — `NPC/NPCAIController.cpp` runs a BT with `Player` / `TargetLocation` / `SpawnLocation` blackboard keys. `NPC/NPCSpawner.cpp` proximity-gated spawn with day / night caps.
- **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:** no board/commission UI yet (the old forum widget isn't wired to this); `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. - **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. - **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). - **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 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). - **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. - **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 ### 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). - **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: - **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%. - **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%.
@@ -352,6 +352,7 @@ Phase estimates are rough and assume one engineer. Adjust as we go.
### Phase 8 — Phone + forum UI + battery + livestream (34 weeks) ### Phase 8 — Phone + forum UI + battery + livestream (34 weeks)
- **Phone UI shell — landed early (pulled forward from this phase).** CommonUI nested-stack shell under `UI/Phone/`: `UPhoneScreenWidget` (activatable pushed onto the GameLayout `WidgetStack`) owns an inner `AppStack` (`UCommonActivatableWidgetStack`) with the home screen at its base; `OpenApp` pushes an app, the physical `HomeButton`/`GoHome` clears back to home, and CommonUI back navigation pops app→home→close-phone for free. `UPhoneAppWidget` is the abstract base for every app; `UPhoneHomeScreenWidget` (a `PhoneAppWidget`) builds an icon grid from a data-driven `AppEntries` array (`FPhoneAppEntry` = name/icon/`TSubclassOf<UPhoneAppWidget>`, §17.4) into a `BindWidget` `AppContainer`, spawning one `UPhoneAppIconWidget` each and routing taps back to `OpenApp` via `OnAppSelected`. `UGameLayoutWidget::OpenPhone()` pushes the shell. **Open path is temporary:** a dev `PhoneAction` input on `ANakedDesireCharacter` (`OnPhonePress`) calls `OpenPhone()` — replace with phone-slot interaction + `BlockPhoneUse`/battery gating when the systems below land. **BP to author in-editor:** `WBP_PhoneScreen` (binds `AppStack`, `HomeButton`; set `HomeScreenClass`), `WBP_PhoneHome` (binds `AppContainer`; set `AppIconWidgetClass` + fill `AppEntries`), `WBP_PhoneAppIcon` (binds `IconButton`; optional `IconImage`/`NameText`), placeholder `WBP_App_Camera`/`Gallery`/`Forum` (each a `UPhoneAppWidget`); assign `PhoneScreenWidgetClass` on the GameLayout BP and the `PhoneAction` `UInputAction` + mapping on the player BP. App **contents** (capture, posting, etc.) still pending below. - **Phone UI shell — landed early (pulled forward from this phase).** CommonUI nested-stack shell under `UI/Phone/`: `UPhoneScreenWidget` (activatable pushed onto the GameLayout `WidgetStack`) owns an inner `AppStack` (`UCommonActivatableWidgetStack`) with the home screen at its base; `OpenApp` pushes an app, the physical `HomeButton`/`GoHome` clears back to home, and CommonUI back navigation pops app→home→close-phone for free. `UPhoneAppWidget` is the abstract base for every app; `UPhoneHomeScreenWidget` (a `PhoneAppWidget`) builds an icon grid from a data-driven `AppEntries` array (`FPhoneAppEntry` = name/icon/`TSubclassOf<UPhoneAppWidget>`, §17.4) into a `BindWidget` `AppContainer`, spawning one `UPhoneAppIconWidget` each and routing taps back to `OpenApp` via `OnAppSelected`. `UGameLayoutWidget::OpenPhone()` pushes the shell. **Open path is temporary:** a dev `PhoneAction` input on `ANakedDesireCharacter` (`OnPhonePress`) calls `OpenPhone()` — replace with phone-slot interaction + `BlockPhoneUse`/battery gating when the systems below land. **BP to author in-editor:** `WBP_PhoneScreen` (binds `AppStack`, `HomeButton`; set `HomeScreenClass`), `WBP_PhoneHome` (binds `AppContainer`; set `AppIconWidgetClass` + fill `AppEntries`), `WBP_PhoneAppIcon` (binds `IconButton`; optional `IconImage`/`NameText`), placeholder `WBP_App_Camera`/`Gallery`/`Forum` (each a `UPhoneAppWidget`); assign `PhoneScreenWidgetClass` on the GameLayout BP and the `PhoneAction` `UInputAction` + mapping on the player BP. App **contents** (capture, posting, etc.) still pending below.
- **Forum app — commissions tab landed.** `UI/Phone/Apps/`: `UForumAppWidget` (a `UPhoneAppWidget`) hosts two bottom tabs as `BindWidget` `UWidgetSwitcher` pages (Commissions index 0 / Profile index 1) toggled by `CommissionsTabButton`/`ProfileTabButton`; opens on Commissions. `UForumCommissionsWidget` (the Commissions page) reads the live board from `UMissionSubsystem`, splits offered commissions by `GetTier()` into `DailyContainer`/`WeeklyContainer`, fills `AcceptedContainer` (both tiers) + `CompletedContainer` (completed-this-period), rebuilds on `OnBoardChanged`, and routes row accept/abandon to `AcceptCommission`/`AbandonCommission`. `UForumCommissionWidget` is one row — title/poster/reward/objective-progress, with state-gated Accept (Offered) / Abandon (Accepted) buttons reported up via raw delegates. Added `UMissionSubsystem::GetCompletedCommissions()`. **BP to author:** `WBP_App_Forum` reparented to `UForumAppWidget` (bind `TabSwitcher` + two tab buttons; switcher child 0 = commissions widget, child 1 = profile placeholder), `WBP_ForumCommissions` reparented to `UForumCommissionsWidget` (bind the four scroll/box containers; set `CommissionEntryClass`), `WBP_ForumCommissionEntry` reparented to `UForumCommissionWidget` (bind `TitleText`/`DescriptionText`/`AcceptButton`; optional `PosterText`/`RewardText`/`AbandonButton`). **Pending:** the Profile tab page (followers / gallery / livestream history) is Phase 8 content.
- Phone as an `AItemActor` (Phase 2 base). Usable from the dedicated **phone slot** (§6.5 / §27); placed-in-world streaming exception still applies (§9.1.1). - Phone as an `AItemActor` (Phase 2 base). Usable from the dedicated **phone slot** (§6.5 / §27); placed-in-world streaming exception still applies (§9.1.1).
- `PhoneSubsystem` (§17.1): tickable; owns battery %, active app, livestream session lifecycle, charger interaction. - `PhoneSubsystem` (§17.1): tickable; owns battery %, active app, livestream session lifecycle, charger interaction.
- **Battery (§9.8):** - **Battery (§9.8):**
@@ -110,6 +110,21 @@ FText UCommissionObjective::GetDescription() const
return FText::GetEmpty(); 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 float UCommissionObjective::GetProgress() const
{ {
if (bSatisfied) if (bSatisfied)
@@ -39,6 +39,11 @@ public:
UFUNCTION(BlueprintPure) UFUNCTION(BlueprintPure)
virtual float GetProgress() const; 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: protected:
UPROPERTY() UPROPERTY()
ANakedDesireCharacter* Player = nullptr; ANakedDesireCharacter* Player = nullptr;
@@ -36,6 +36,12 @@ public:
UFUNCTION(BlueprintPure) UFUNCTION(BlueprintPure)
const TArray<UCommission*>& GetAcceptedCommissions() const { return AcceptedCommissions; } const TArray<UCommission*>& GetAcceptedCommissions() const { return AcceptedCommissions; }
// Commissions completed within the current period. Cleared at the day-roll alongside the accepted
// list (see ExpireAccepted), so today this is "completed today" for both tiers — true week-long
// retention for weeklies needs the deferred §13.1 weekly-lifecycle work.
UFUNCTION(BlueprintPure)
const TArray<UCommission*>& GetCompletedCommissions() const { return CompletedCommissions; }
UFUNCTION(BlueprintCallable) UFUNCTION(BlueprintCallable)
void AcceptCommission(UCommission* Commission); void AcceptCommission(UCommission* Commission);
+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 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 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_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 CAMPAIGN_LENGTH_DAYS = 90; // §3.3 survive 90 days
inline constexpr int32 WEEK_LENGTH_DAYS = 7; // §2.4 rent due each week 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) 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 UStartingSaveData;
class UCommissionBoardConfig; class UCommissionBoardConfig;
class UNPCDirectorConfig; class UNPCDirectorConfig;
class ULossPresentationConfig;
UCLASS() UCLASS()
class NAKEDDESIRE_API UNakedDesireGameInstance : public UGameInstance class NAKEDDESIRE_API UNakedDesireGameInstance : public UGameInstance
@@ -25,4 +26,8 @@ public:
// Crowd population tuning the UNPCDirectorSubsystem uses (§10.2, §17.1). // Crowd population tuning the UNPCDirectorSubsystem uses (§10.2, §17.1).
UPROPERTY(EditDefaultsOnly, Category = "NPC") UPROPERTY(EditDefaultsOnly, Category = "NPC")
TObjectPtr<UNPCDirectorConfig> NPCDirector; 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 "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 "Kismet/GameplayStatics.h"
#include "NakedDesire/Clothing/ClothingItemInstance.h" #include "NakedDesire/Clothing/ClothingItemInstance.h"
#include "NakedDesire/Interactables/ItemPickup.h" #include "NakedDesire/Interactables/ItemPickup.h"
#include "NakedDesire/SaveGame/GlobalSaveGameData.h" #include "NakedDesire/SaveGame/GlobalSaveGameData.h"
#include "NakedDesire/SaveGame/SaveSubsystem.h" #include "NakedDesire/SaveGame/SaveSubsystem.h"
const FName USessionLossResolver::HomePlayerStartTag = FName(TEXT("Home"));
void USessionLossResolver::OnWorldBeginPlay(UWorld& InWorld) void USessionLossResolver::OnWorldBeginPlay(UWorld& InWorld)
{ {
Super::OnWorldBeginPlay(InWorld); Super::OnWorldBeginPlay(InWorld);
@@ -74,6 +86,9 @@ void USessionLossResolver::ResolveLoss(ESessionLossCause Cause)
Autosave(); Autosave();
OnSessionLossResolved.Broadcast(Cause, bWentToHoldingCell); OnSessionLossResolved.Broadcast(Cause, bWentToHoldingCell);
// Presentation: play the cause's cutscene, then teleport the player home on finish.
BeginLossPresentation(Cause);
} }
void USessionLossResolver::ResolveSleepLoss() void USessionLossResolver::ResolveSleepLoss()
@@ -117,6 +132,122 @@ void USessionLossResolver::ClearWanted()
// TODO(§7.7 / Phase 6): clear the `wanted` tag once the Wanted attribute exists. // 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 void USessionLossResolver::Autosave() const
{ {
if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance()) if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance())
@@ -139,3 +270,10 @@ UGlobalSaveGameData* USessionLossResolver::GetSave() const
} }
return nullptr; 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") UPROPERTY(BlueprintAssignable, Category = "Session")
FOnSessionLossResolvedSignature OnSessionLossResolved; 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: private:
// EnergyZero / sleep: every clothing item left outside the apartment is guaranteed lost. // EnergyZero / sleep: every clothing item left outside the apartment is guaranteed lost.
void LoseAllWorldClothing(); void LoseAllWorldClothing();
@@ -55,9 +60,28 @@ private:
// §7.7: cleared on police capture. No-op until the Wanted attribute exists (Phase 6). // §7.7: cleared on police capture. No-op until the Wanted attribute exists (Phase 6).
void ClearWanted(); 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; void Autosave() const;
class UGlobalSaveGameData* GetSave() const; class UGlobalSaveGameData* GetSave() const;
class ULossPresentationConfig* GetPresentationConfig() const;
// §21 tuning placeholder — the police-capture money penalty. // §21 tuning placeholder — the police-capture money penalty.
float PoliceCaptureMoneyPenalty = 200.0f; 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) 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; return;
AdvanceClock(static_cast<double>(DeltaTime) * INGAME_MINUTES_PER_REAL_SECOND); AdvanceClock(static_cast<double>(DeltaTime) * INGAME_MINUTES_PER_REAL_SECOND);
@@ -91,7 +102,41 @@ void UTimeOfDaySubsystem::SkipToNextMorning()
void UTimeOfDaySubsystem::Sleep() 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(); RestorePlayerEnergy();
// TODO(§9.8 / Phase 9): charge the equipped phone to 100% as part of the sleep cycle. // 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. // 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 // §2.4 sleep: fast-forward 8 hours, restore energy, autosave. (The apartment bed
// also routes outside-clothing loss through USessionLossResolver — see §4.4.) // 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") UFUNCTION(BlueprintCallable, Category = "Time")
void Sleep(); 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). // --- 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. --- // Note §11.17: the holding-cell cutscene deliberately does NOT pause the clock. ---
UFUNCTION(BlueprintCallable, Category = "Time") UFUNCTION(BlueprintCallable, Category = "Time")
@@ -124,6 +130,9 @@ private:
// bForceSkyPush bypasses the 30fps throttle for discrete jumps (skips / load), // bForceSkyPush bypasses the 30fps throttle for discrete jumps (skips / load),
// so the sky snaps to the new time immediately instead of waiting a frame. // so the sky snaps to the new time immediately instead of waiting a frame.
void AdvanceClock(double DeltaMinutes, bool bForceSkyPush = false); 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 HandleHourBoundary(int32 HourOfDay); // 023
void SetPhase(EDayPhase NewPhase); void SetPhase(EDayPhase NewPhase);
void AdvanceCalendarDay(); void AdvanceCalendarDay();
@@ -142,4 +151,10 @@ private:
EDayPhase CurrentPhase = EDayPhase::Day; EDayPhase CurrentPhase = EDayPhase::Day;
TSet<FName> PauseReasons; TSet<FName> PauseReasons;
bool bBegunPlay = false; 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 "Engine/DataAsset.h"
#include "LocationData.generated.h" #include "LocationData.generated.h"
/**
*
*/
UCLASS() UCLASS()
class NAKEDDESIRE_API ULocationData : public UPrimaryDataAsset 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" #include "LocationTrigger.h"
@@ -6,6 +6,7 @@
#include "Components/BoxComponent.h" #include "Components/BoxComponent.h"
#include "LocationSubsystem.h" #include "LocationSubsystem.h"
#include "NakedDesire/Player/NakedDesireCharacter.h" #include "NakedDesire/Player/NakedDesireCharacter.h"
#include "TimerManager.h"
ALocationTrigger::ALocationTrigger() ALocationTrigger::ALocationTrigger()
@@ -27,16 +28,26 @@ void ALocationTrigger::BeginPlay()
BoxTrigger->OnComponentBeginOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerBeginOverlap); BoxTrigger->OnComponentBeginOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerBeginOverlap);
BoxTrigger->OnComponentEndOverlap.AddDynamic(this, &ALocationTrigger::OnTriggerEndOverlap); 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, 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>()) if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
return; return;
if (ULocationSubsystem* Locations = GetWorld()->GetSubsystem<ULocationSubsystem>()) SetPlayerInside(true);
Locations->EnterLocation(LocationData);
} }
void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
@@ -45,6 +56,39 @@ void ALocationTrigger::OnTriggerEndOverlap(UPrimitiveComponent* OverlappedCompon
if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>()) if (!OtherActor || !OtherActor->IsA<ANakedDesireCharacter>())
return; return;
if (ULocationSubsystem* Locations = GetWorld()->GetSubsystem<ULocationSubsystem>()) // Another of the player's components may still overlap the box (capsule vs. mesh); only
Locations->ExitLocation(LocationData); // 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>())
{
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 #pragma once
@@ -23,9 +23,14 @@ class NAKEDDESIRE_API ALocationTrigger : public AActor
UPROPERTY(EditAnywhere) UPROPERTY(EditAnywhere)
ULocationData* LocationData; ULocationData* LocationData;
UPROPERTY(EditAnywhere)
FVector TriggerSize;
public: public:
ALocationTrigger(); ALocationTrigger();
virtual void OnConstruction(const FTransform& Transform) override;
ULocationData* GetLocationData() const; ULocationData* GetLocationData() const;
protected: protected:
@@ -39,4 +44,16 @@ private:
UFUNCTION() UFUNCTION()
void OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor, void OnTriggerEndOverlap(UPrimitiveComponent* OverlappedComponent, AActor* OtherActor,
UPrimitiveComponent* OtherComp, int32 OtherBodyIndex); 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[] PublicDependencyModuleNames.AddRange(new string[]
{ {
"Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "UMG", "CommonUI", "NavigationSystem", "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; return;
Player->StatsManager->EmbarrassmentUpdate.AddUniqueDynamic(this, &UHUDWidget::OnEmbarrassmentUpdated); 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) void UHUDWidget::OnEmbarrassmentUpdated(float CurrentValue, float MaxValue)
{ {
EmbarrassmentBar->SetPercent(CurrentValue / 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)) UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> EmbarrassmentBar; TObjectPtr<UProgressBar> EmbarrassmentBar;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> EnergyBar;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UProgressBar> StaminaBar;
protected: protected:
virtual void NativeConstruct() override; virtual void NativeConstruct() override;
private: private:
UFUNCTION() UFUNCTION()
void OnEmbarrassmentUpdated(float CurrentValue, float MaxValue); void OnEmbarrassmentUpdated(float CurrentValue, float MaxValue);
UFUNCTION()
void OnEnergyUpdated(float CurrentValue, float MaxValue);
UFUNCTION()
void OnStaminaUpdated(float CurrentValue, float MaxValue);
}; };
@@ -0,0 +1,40 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "ForumAppWidget.h"
#include "Components/Button.h"
#include "Components/WidgetSwitcher.h"
namespace
{
// Switcher page order — must match the child order authored in the BP.
constexpr int32 CommissionsTabIndex = 0;
constexpr int32 ProfileTabIndex = 1;
}
void UForumAppWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (CommissionsTabButton)
CommissionsTabButton->OnClicked.AddUniqueDynamic(this, &UForumAppWidget::ShowCommissions);
if (ProfileTabButton)
ProfileTabButton->OnClicked.AddUniqueDynamic(this, &UForumAppWidget::ShowProfile);
// Open on the commission board — it is the forum's primary loop surface (§13).
ShowCommissions();
}
void UForumAppWidget::ShowCommissions()
{
if (TabSwitcher)
TabSwitcher->SetActiveWidgetIndex(CommissionsTabIndex);
}
void UForumAppWidget::ShowProfile()
{
if (TabSwitcher)
TabSwitcher->SetActiveWidgetIndex(ProfileTabIndex);
}
@@ -0,0 +1,39 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "NakedDesire/UI/Phone/PhoneAppWidget.h"
#include "ForumAppWidget.generated.h"
class UButton;
class UWidgetSwitcher;
// The forum app (GDD §13). Hosts the two bottom tabs the forum surface is scoped to — the commission
// board and the player's own profile (§13.3) — as pages of a switcher. Page content lives in BP:
// the Commissions page is a UForumCommissionsWidget; the Profile page is a placeholder until the
// profile / followers systems land (Phase 8). This shell only drives tab selection.
UCLASS()
class NAKEDDESIRE_API UForumAppWidget : public UPhoneAppWidget
{
GENERATED_BODY()
protected:
virtual void NativeOnInitialized() override;
private:
UPROPERTY(meta = (BindWidget))
TObjectPtr<UWidgetSwitcher> TabSwitcher;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> CommissionsTabButton;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> ProfileTabButton;
UFUNCTION()
void ShowCommissions();
UFUNCTION()
void ShowProfile();
};
@@ -0,0 +1,135 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "ForumCommissionWidget.h"
#include "Components/Button.h"
#include "CommonTextBlock.h"
#include "NakedDesire/Commissions/Commission.h"
#include "NakedDesire/Commissions/CommissionObjective.h"
#include "NakedDesire/Commissions/CommissionTypes.h"
#define LOCTEXT_NAMESPACE "ForumCommission"
void UForumCommissionWidget::NativeOnInitialized()
{
Super::NativeOnInitialized();
if (AcceptButton)
AcceptButton->OnClicked.AddUniqueDynamic(this, &UForumCommissionWidget::HandleAcceptClicked);
if (AbandonButton)
AbandonButton->OnClicked.AddUniqueDynamic(this, &UForumCommissionWidget::HandleAbandonClicked);
}
void UForumCommissionWidget::SetCommission(UCommission* InCommission)
{
Commission = InCommission;
CachedObjectivesString.Reset(); // force a fresh push for the (possibly reused) row
TimeSinceProgressRefresh = 0.0f;
if (!Commission)
return;
if (TitleText)
TitleText->SetText(Commission->GetTitle());
if (PosterText)
PosterText->SetText(FText::Format(LOCTEXT("PosterFmt", "by {0}"), Commission->GetPosterUsername()));
if (RewardText)
RewardText->SetText(FormatReward(Commission->GetReward()));
UpdateObjectivesText();
// Accept only offers; abandon only commitments. Completed / expired rows show neither control.
const ECommissionState State = Commission->GetState();
if (AcceptButton)
AcceptButton->SetVisibility(State == ECommissionState::Offered ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
if (AbandonButton)
AbandonButton->SetVisibility(State == ECommissionState::Accepted ? ESlateVisibility::Visible : ESlateVisibility::Collapsed);
}
void UForumCommissionWidget::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
// Only accepted rows have live progress; offered / completed / expired rows are static.
if (!Commission || Commission->GetState() != ECommissionState::Accepted)
return;
TimeSinceProgressRefresh += InDeltaTime;
if (TimeSinceProgressRefresh < ProgressRefreshInterval)
return;
TimeSinceProgressRefresh = 0.0f;
UpdateObjectivesText();
}
void UForumCommissionWidget::HandleAcceptClicked()
{
if (Commission)
OnAcceptClicked.ExecuteIfBound(Commission);
}
void UForumCommissionWidget::HandleAbandonClicked()
{
if (Commission)
OnAbandonClicked.ExecuteIfBound(Commission);
}
void UForumCommissionWidget::UpdateObjectivesText()
{
if (!DescriptionText)
return;
FString NewText = BuildObjectivesString();
if (NewText == CachedObjectivesString)
return; // nothing changed since the last refresh — skip the SetText churn
CachedObjectivesString = MoveTemp(NewText);
DescriptionText->SetText(FText::FromString(CachedObjectivesString));
}
FString UForumCommissionWidget::BuildObjectivesString() const
{
FString Result;
if (!Commission)
return Result;
for (const UCommissionObjective* Objective : Commission->GetObjectives())
{
if (!Objective)
continue;
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);
Line += FString::Printf(TEXT(" (%d%%)"), Pct);
Result += Line;
}
return Result;
}
FText UForumCommissionWidget::FormatReward(const FCommissionReward& Reward)
{
TArray<FString> Parts;
if (Reward.Money != 0)
Parts.Add(FString::Printf(TEXT("¥%d"), Reward.Money)); // ¥ = yen sign
if (!FMath::IsNearlyZero(Reward.XP))
Parts.Add(FString::Printf(TEXT("%.0f XP"), Reward.XP));
if (Reward.Followers != 0)
Parts.Add(FString::Printf(TEXT("+%d followers"), Reward.Followers));
return FText::FromString(FString::Join(Parts, TEXT(" ")));
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,78 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "CommonUserWidget.h"
#include "ForumCommissionWidget.generated.h"
class UButton;
class UCommonTextBlock;
class UCommission;
struct FCommissionReward;
// One commission entry on the forum board (GDD §13). Populated from a runtime UCommission by the
// owning UForumCommissionsWidget; it shows the title, lore poster, reward, and per-objective progress,
// and surfaces the accept / abandon control appropriate to the commission's lifecycle state. The row
// owns no logic beyond display — it reports button taps up via delegates, like UPhoneAppIconWidget.
UCLASS(Abstract)
class NAKEDDESIRE_API UForumCommissionWidget : public UCommonUserWidget
{
GENERATED_BODY()
public:
// Bind a runtime commission and refresh the row. Passing null leaves the row blank.
void SetCommission(UCommission* InCommission);
DECLARE_DELEGATE_OneParam(FOnCommissionActionRequested, UCommission*);
FOnCommissionActionRequested OnAcceptClicked; // Offered -> accept
FOnCommissionActionRequested OnAbandonClicked; // Accepted -> abandon (§13.2, no penalty)
protected:
virtual void NativeOnInitialized() override;
// Accepted commissions ramp progress continuously (travel distance, hold timers) with no per-tick
// delegate, so the row polls its objectives here while live to keep the percentages current.
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCommonTextBlock> TitleText;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UCommonTextBlock> PosterText;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UCommonTextBlock> RewardText;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCommonTextBlock> DescriptionText;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UButton> AcceptButton;
UPROPERTY(meta = (BindWidgetOptional))
TObjectPtr<UButton> AbandonButton;
UFUNCTION()
void HandleAcceptClicked();
UFUNCTION()
void HandleAbandonClicked();
// Rebuild the per-objective progress text, but only push it to the widget when it actually changed,
// so the poll doesn't churn SetText every tick.
void UpdateObjectivesText();
FString BuildObjectivesString() const;
static FText FormatReward(const FCommissionReward& Reward);
UPROPERTY()
TObjectPtr<UCommission> Commission;
// Throttle for the in-progress poll (seconds between objective-text refreshes) and its accumulator.
static constexpr float ProgressRefreshInterval = 0.25f;
float TimeSinceProgressRefresh = 0.0f;
// Last string pushed to DescriptionText; used to skip redundant SetText calls.
FString CachedObjectivesString;
};
@@ -0,0 +1,103 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "ForumCommissionsWidget.h"
#include "ForumCommissionWidget.h"
#include "Components/PanelWidget.h"
#include "NakedDesire/Commissions/Commission.h"
#include "NakedDesire/Commissions/MissionSubsystem.h"
void UForumCommissionsWidget::NativeConstruct()
{
Super::NativeConstruct();
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->OnBoardChanged.AddUniqueDynamic(this, &UForumCommissionsWidget::Rebuild);
Rebuild();
}
void UForumCommissionsWidget::NativeDestruct()
{
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->OnBoardChanged.RemoveDynamic(this, &UForumCommissionsWidget::Rebuild);
Super::NativeDestruct();
}
void UForumCommissionsWidget::Rebuild()
{
UMissionSubsystem* Missions = GetMissionSubsystem();
if (!Missions || !CommissionEntryClass)
return;
PopulateOffered(DailyContainer, ECommissionTier::Daily);
PopulateOffered(WeeklyContainer, ECommissionTier::Weekly);
PopulateContainer(AcceptedContainer, Missions->GetAcceptedCommissions());
PopulateContainer(CompletedContainer, Missions->GetCompletedCommissions());
}
void UForumCommissionsWidget::PopulateOffered(UPanelWidget* Container, ECommissionTier Tier)
{
if (!Container)
return;
Container->ClearChildren();
UMissionSubsystem* Missions = GetMissionSubsystem();
if (!Missions)
return;
for (UCommission* Commission : Missions->GetOfferedCommissions())
{
if (Commission && Commission->GetTier() == Tier)
AddEntry(Container, Commission);
}
}
void UForumCommissionsWidget::PopulateContainer(UPanelWidget* Container, const TArray<UCommission*>& Commissions)
{
if (!Container)
return;
Container->ClearChildren();
for (UCommission* Commission : Commissions)
{
if (Commission)
AddEntry(Container, Commission);
}
}
UForumCommissionWidget* UForumCommissionsWidget::AddEntry(UPanelWidget* Container, UCommission* Commission)
{
UForumCommissionWidget* Entry = CreateWidget<UForumCommissionWidget>(this, CommissionEntryClass);
if (!Entry)
return nullptr;
Entry->SetCommission(Commission);
Entry->OnAcceptClicked.BindUObject(this, &UForumCommissionsWidget::HandleAcceptClicked);
Entry->OnAbandonClicked.BindUObject(this, &UForumCommissionsWidget::HandleAbandonClicked);
Container->AddChild(Entry);
return Entry;
}
void UForumCommissionsWidget::HandleAcceptClicked(UCommission* Commission)
{
// AcceptCommission broadcasts OnBoardChanged, which drives Rebuild — no manual refresh here.
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->AcceptCommission(Commission);
}
void UForumCommissionsWidget::HandleAbandonClicked(UCommission* Commission)
{
if (UMissionSubsystem* Missions = GetMissionSubsystem())
Missions->AbandonCommission(Commission);
}
UMissionSubsystem* UForumCommissionsWidget::GetMissionSubsystem() const
{
const UWorld* World = GetWorld();
return World ? World->GetSubsystem<UMissionSubsystem>() : nullptr;
}
@@ -0,0 +1,58 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "CommonUserWidget.h"
#include "NakedDesire/Commissions/CommissionTypes.h"
#include "ForumCommissionsWidget.generated.h"
class UPanelWidget;
class UForumCommissionWidget;
class UMissionSubsystem;
class UCommission;
// The forum's Commissions tab (GDD §13). Reads the live board from UMissionSubsystem and lays the
// commissions out into four sections: offered dailies, offered weeklies, accepted (in-progress, both
// tiers), and completed-this-period. Rebuilds whenever the board changes, and routes row accept /
// abandon taps back to the subsystem. Section containers + the row class are authored in BP (§17.5).
UCLASS(Abstract)
class NAKEDDESIRE_API UForumCommissionsWidget : public UCommonUserWidget
{
GENERATED_BODY()
protected:
virtual void NativeConstruct() override;
virtual void NativeDestruct() override;
private:
// Offered commissions, split by tier.
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> DailyContainer;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> WeeklyContainer;
// Accepted commitments (both tiers) and completions for the current period.
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> AcceptedContainer;
UPROPERTY(meta = (BindWidget))
TObjectPtr<UPanelWidget> CompletedContainer;
UPROPERTY(EditDefaultsOnly, Category = "Forum")
TSubclassOf<UForumCommissionWidget> CommissionEntryClass;
// Bound to UMissionSubsystem::OnBoardChanged (a dynamic delegate, hence UFUNCTION).
UFUNCTION()
void Rebuild();
void PopulateOffered(UPanelWidget* Container, ECommissionTier Tier);
void PopulateContainer(UPanelWidget* Container, const TArray<UCommission*>& Commissions);
UForumCommissionWidget* AddEntry(UPanelWidget* Container, UCommission* Commission);
void HandleAcceptClicked(UCommission* Commission);
void HandleAbandonClicked(UCommission* Commission);
UMissionSubsystem* GetMissionSubsystem() const;
};
@@ -0,0 +1,28 @@
// © 2025 Naked People Team. All Rights Reserved.
#include "PhoneStatusBar.h"
#include "CommonTextBlock.h"
#include "NakedDesire/Global/TimeOfDaySubsystem.h"
void UPhoneStatusBar::NativeTick(const FGeometry& MyGeometry, float InDeltaTime)
{
Super::NativeTick(MyGeometry, InDeltaTime);
if (!TimeText)
return;
const UWorld* World = GetWorld();
const UTimeOfDaySubsystem* Time = World ? World->GetSubsystem<UTimeOfDaySubsystem>() : nullptr;
if (!Time)
return;
// 24-hour HH:MM, refreshed only when the minute rolls so we aren't re-laying-out text every frame.
FString NewTime = FString::Printf(TEXT("%02d:%02d"), Time->GetHour(), Time->GetMinute());
if (NewTime == CachedTimeString)
return;
CachedTimeString = MoveTemp(NewTime);
TimeText->SetText(FText::FromString(CachedTimeString));
}
@@ -0,0 +1,26 @@
// © 2025 Naked People Team. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "CommonUserWidget.h"
#include "PhoneStatusBar.generated.h"
class UCommonTextBlock;
UCLASS()
class NAKEDDESIRE_API UPhoneStatusBar : public UCommonUserWidget
{
GENERATED_BODY()
UPROPERTY(meta = (BindWidget))
TObjectPtr<UCommonTextBlock> TimeText;
protected:
virtual void NativeTick(const FGeometry& MyGeometry, float InDeltaTime) override;
private:
// Last HH:MM string pushed to TimeText; the tick re-reads the clock each frame but only
// touches the widget when the displayed minute actually changes.
FString CachedTimeString;
};