// © 2025 Naked People Team. All Rights Reserved. #include "TimeOfDaySubsystem.h" #include "Constants.h" #include "Kismet/GameplayStatics.h" #include "Misc/App.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(/*bForce=*/true); // sync the sky to the loaded time immediately // Sanity-log the clock speed: how long a full 24h in-game day takes in real time. const double RealMinutesPerDay = MINUTES_PER_DAY / (INGAME_MINUTES_PER_REAL_SECOND * 60.0); UE_LOG(LogTemp, Warning, TEXT("UTimeOfDaySubsystem: a full in-game day takes %.2f real minutes (%.1f in-game minutes/real second)."), RealMinutesPerDay, INGAME_MINUTES_PER_REAL_SECOND); } void UTimeOfDaySubsystem::Tick(float DeltaTime) { 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); } 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) { // Discrete jump — snap the sky to the new time, don't wait for the throttle. AdvanceClock(static_cast(Minutes), /*bForceSkyPush=*/true); } } 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, /*bForceSkyPush=*/true); } void UTimeOfDaySubsystem::Sleep() { 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. Autosave(); } void UTimeOfDaySubsystem::PushPause(FName Reason) { PauseReasons.Add(Reason); } void UTimeOfDaySubsystem::PopPause(FName Reason) { PauseReasons.Remove(Reason); } void UTimeOfDaySubsystem::AdvanceClock(double DeltaMinutes, bool bForceSkyPush) { 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(bForceSkyPush); } 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(bool bForce) { const UGlobalSaveGameData* Save = GetSave(); if (!Save) return; // Throttle continuous updates to 30fps of real time. Discrete jumps (load / skips) // force a push so the sky snaps to the new time without waiting for the next frame. const double Now = FApp::GetCurrentTime(); if (!bForce && LastSkyPushRealTime >= 0.0 && (Now - LastSkyPushRealTime) < SKY_PUSH_INTERVAL_SECONDS) return; LastSkyPushRealTime = Now; // Carry sub-minute precision so the sun moves smoothly between minute marks. The // fractional minute becomes seconds + frames (at the same 30fps cadence). const double MinuteOfDay = Save->MinuteOfDay; const int32 Hours = FMath::FloorToInt(MinuteOfDay / MINUTES_PER_HOUR); const int32 Minutes = FMath::FloorToInt(MinuteOfDay) % MINUTES_PER_HOUR; const double FracMinute = MinuteOfDay - FMath::FloorToDouble(MinuteOfDay); const double FracSeconds = FracMinute * 60.0; const int32 Seconds = FMath::FloorToInt(FracSeconds); const int32 Frames = FMath::FloorToInt((FracSeconds - Seconds) * 30.0); OnPushTimeToSky.Broadcast(FTimecode(Hours, Minutes, Seconds, Frames, 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 (const USaveSubsystem* SaveSubsystem = GameInstance->GetSubsystem()) { SaveSubsystem->SaveGame(); } } }