Files
Naked-Desire/Source/NakedDesire/Global/TimeOfDaySubsystem.cpp
T

282 lines
7.7 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// © 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 || IsPaused())
return;
AdvanceClock(static_cast<double>(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<double>(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()
{
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, 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 023; 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<float>(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 * <daily per-follower rate> 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<USaveSubsystem>())
{
return SaveSubsystem->GetCurrentSave();
}
}
return nullptr;
}
void UTimeOfDaySubsystem::RestorePlayerEnergy() const
{
if (ANakedDesireCharacter* Player = Cast<ANakedDesireCharacter>(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<USaveSubsystem>())
{
SaveSubsystem->SaveGame();
}
}
}