282 lines
7.7 KiB
C++
282 lines
7.7 KiB
C++
// © 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 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<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();
|
||
}
|
||
}
|
||
} |