// © 2025 Naked People Team. All Rights Reserved. #include "TimeOfDaySubsystem.h" #include "Constants.h" #include "Kismet/GameplayStatics.h" #include "Misc/Timecode.h" #include "NakedDesire/Player/NakedDesireCharacter.h" #include "NakedDesire/SaveGame/GlobalSaveGameData.h" #include "NakedDesire/SaveGame/SaveSubsystem.h" #include "NakedDesire/Stats/StatsManager.h" void UTimeOfDaySubsystem::OnWorldBeginPlay(UWorld& InWorld) { Super::OnWorldBeginPlay(InWorld); if (const UGlobalSaveGameData* Save = GetSave()) { CurrentPhase = ComputePhase(Save->MinuteOfDay); } bBegunPlay = true; PushTimeToSky(); // sync the sky to the loaded time immediately } void UTimeOfDaySubsystem::Tick(float DeltaTime) { if (!bBegunPlay || IsPaused()) return; AdvanceClock(static_cast(DeltaTime) * INGAME_MINUTES_PER_REAL_SECOND); } TStatId UTimeOfDaySubsystem::GetStatId() const { RETURN_QUICK_DECLARE_CYCLE_STAT(UTimeOfDaySubsystem, STATGROUP_Tickables); } int32 UTimeOfDaySubsystem::GetDay() const { const UGlobalSaveGameData* Save = GetSave(); return Save ? Save->DaysPassed : 0; } float UTimeOfDaySubsystem::GetMinuteOfDay() const { const UGlobalSaveGameData* Save = GetSave(); return Save ? Save->MinuteOfDay : 0.0f; } int32 UTimeOfDaySubsystem::GetHour() const { return FMath::FloorToInt(GetMinuteOfDay() / MINUTES_PER_HOUR); } int32 UTimeOfDaySubsystem::GetMinute() const { return FMath::FloorToInt(GetMinuteOfDay()) % MINUTES_PER_HOUR; } EDayPhase UTimeOfDaySubsystem::GetPhase() const { return ComputePhase(GetMinuteOfDay()); } void UTimeOfDaySubsystem::SkipTime(float Minutes) { if (Minutes > 0.0f) { AdvanceClock(static_cast(Minutes)); } } void UTimeOfDaySubsystem::SkipToNextMorning() { const double Target = DAY_START_HOUR * MINUTES_PER_HOUR; // 08:00 double Delta = Target - GetMinuteOfDay(); if (Delta <= 0.0) { Delta += MINUTES_PER_DAY; } AdvanceClock(Delta); } void UTimeOfDaySubsystem::Sleep() { SkipTime(SLEEP_DURATION_HOURS * MINUTES_PER_HOUR); 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. Autosave(); } void UTimeOfDaySubsystem::PushPause(FName Reason) { PauseReasons.Add(Reason); } void UTimeOfDaySubsystem::PopPause(FName Reason) { PauseReasons.Remove(Reason); } void UTimeOfDaySubsystem::AdvanceClock(double DeltaMinutes) { UGlobalSaveGameData* Save = GetSave(); if (!Save || DeltaMinutes <= 0.0) return; const double Prev = Save->MinuteOfDay; double Next = Prev + DeltaMinutes; // Fire an hour boundary for every integer hour crossed (handles midnight wrap and // multi-hour skips). Each index is folded to 0–23; boundary 4 rolls the calendar. const int32 PrevHourIdx = FMath::FloorToInt(Prev / MINUTES_PER_HOUR); const int32 NextHourIdx = FMath::FloorToInt(Next / MINUTES_PER_HOUR); for (int32 HourIdx = PrevHourIdx + 1; HourIdx <= NextHourIdx; ++HourIdx) { HandleHourBoundary(((HourIdx % 24) + 24) % 24); } while (Next >= MINUTES_PER_DAY) { Next -= MINUTES_PER_DAY; } Save->MinuteOfDay = static_cast(Next); PushTimeToSky(); } void UTimeOfDaySubsystem::HandleHourBoundary(int32 HourOfDay) { OnHourChanged.Broadcast(HourOfDay); if (HourOfDay == DAY_ROLL_HOUR) { AdvanceCalendarDay(); } if (HourOfDay == FMath::FloorToInt(DAY_START_HOUR)) { SetPhase(EDayPhase::Day); } else if (HourOfDay == FMath::FloorToInt(NIGHT_START_HOUR)) { SetPhase(EDayPhase::Night); } } void UTimeOfDaySubsystem::SetPhase(EDayPhase NewPhase) { if (NewPhase == CurrentPhase) return; CurrentPhase = NewPhase; OnPhaseChanged.Broadcast(NewPhase); } void UTimeOfDaySubsystem::AdvanceCalendarDay() { UGlobalSaveGameData* Save = GetSave(); if (!Save) return; Save->DaysPassed++; OnDayChanged.Broadcast(Save->DaysPassed); DepositDailyFollowerIncome(); if (Save->DaysPassed > 0 && (Save->DaysPassed % WEEK_LENGTH_DAYS) == 0) { ChargeWeeklyRent(); } // §3.3: surviving to day 90 ends the campaign (win). Days advance one at a time, so // an exact-match fires this once. Endless mode never ends here. if (!Save->bEndlessMode && Save->DaysPassed == CAMPAIGN_LENGTH_DAYS) { OnCampaignEnded.Broadcast(ECampaignEndReason::CampaignComplete); } } void UTimeOfDaySubsystem::ChargeWeeklyRent() { UGlobalSaveGameData* Save = GetSave(); if (!Save || Save->bEndlessMode) return; if (Save->Money >= WEEKLY_RENT) { Save->Money -= WEEKLY_RENT; Save->LastRentChargeDay = Save->DaysPassed; Autosave(); } else { // §3.3 / §22 #8: can't make rent → eviction → run over. No grace period. OnCampaignEnded.Broadcast(ECampaignEndReason::Evicted); } } void UTimeOfDaySubsystem::DepositDailyFollowerIncome() { // §7.9 / §20 #25: passive follower income auto-deposits to the bank each day-roll. // TODO(Phase 8): once a follower-count attribute exists, deposit // FollowerCount * into Save->Money here. There is no // follower attribute yet, so this is intentionally a no-op (payout reads 0). } void UTimeOfDaySubsystem::PushTimeToSky() { const UGlobalSaveGameData* Save = GetSave(); if (!Save) return; const int32 CurMinute = FMath::FloorToInt(Save->MinuteOfDay); if (CurMinute == LastPushedMinute) return; LastPushedMinute = CurMinute; const int32 Hours = CurMinute / MINUTES_PER_HOUR; const int32 Minutes = CurMinute % MINUTES_PER_HOUR; OnPushTimeToSky.Broadcast(FTimecode(Hours, Minutes, 0, 0, false)); } EDayPhase UTimeOfDaySubsystem::ComputePhase(float InMinuteOfDay) { const float Hour = InMinuteOfDay / MINUTES_PER_HOUR; return (Hour >= DAY_START_HOUR && Hour < NIGHT_START_HOUR) ? EDayPhase::Day : EDayPhase::Night; } UGlobalSaveGameData* UTimeOfDaySubsystem::GetSave() const { if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance()) { if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem()) { return SaveSubsystem->GetCurrentSave(); } } return nullptr; } void UTimeOfDaySubsystem::RestorePlayerEnergy() const { if (ANakedDesireCharacter* Player = Cast(UGameplayStatics::GetPlayerCharacter(this, SLOT_PLAYER))) { if (Player->StatsManager) { Player->StatsManager->RestoreEnergy(); } } } void UTimeOfDaySubsystem::Autosave() const { if (const UGameInstance* GameInstance = GetWorld()->GetGameInstance()) { if (USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem()) { SaveSubsystem->SaveGame(); } } }