This repository has been archived on 2026-05-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
AgrarianGameArchive/Source/AgrarianGame/AgrarianGameState.cpp
T
2026-05-15 21:53:10 -07:00

477 lines
16 KiB
C++

// Copyright Pacificao. All Rights Reserved.
#include "AgrarianGameState.h"
#include "Net/UnrealNetwork.h"
namespace
{
int32 NormalizeCalendarDay(int32 DayOfYear, int32 DaysPerYear)
{
const int32 SafeDaysPerYear = FMath::Max(1, DaysPerYear);
const int32 ZeroBasedDay = ((DayOfYear - 1) % SafeDaysPerYear + SafeDaysPerYear) % SafeDaysPerYear;
return ZeroBasedDay + 1;
}
bool IsDayInRange(int32 DayOfYear, int32 StartDay, int32 EndDay, int32 DaysPerYear)
{
const int32 Day = NormalizeCalendarDay(DayOfYear, DaysPerYear);
const int32 Start = NormalizeCalendarDay(StartDay, DaysPerYear);
const int32 End = NormalizeCalendarDay(EndDay, DaysPerYear);
if (Start <= End)
{
return Day >= Start && Day <= End;
}
return Day >= Start || Day <= End;
}
float GetWrappedHourDelta(float FromHour, float ToHour)
{
float Delta = FMath::Fmod(ToHour - FromHour, 24.0f);
if (Delta < 0.0f)
{
Delta += 24.0f;
}
return Delta;
}
}
AAgrarianGameState::AAgrarianGameState()
{
PrimaryActorTick.bCanEverTick = true;
bReplicates = true;
ActiveGrowingSeason.TileId = ActiveSolarTileId;
ActiveGrowingSeason.GrowingZoneLabel = TEXT("USDA 10a");
ActiveGrowingSeason.GrowingSeasonStartDay = 46;
ActiveGrowingSeason.GrowingSeasonEndDay = 350;
ActiveGrowingSeason.FrostFreeDays = 305;
ActiveGrowingSeason.MinAverageGrowingTempC = 7.0f;
ActiveGrowingSeason.CropSafetyBufferDays = 14;
ActiveGrowingSeason.ClimateProfile = TEXT("coastal_mediterranean_mild");
}
void AAgrarianGameState::BeginPlay()
{
Super::BeginPlay();
if (HasAuthority())
{
UpdateSolarTimes();
}
}
void AAgrarianGameState::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (!HasAuthority())
{
return;
}
WorldHours += (DeltaSeconds / 60.0f) * GameHoursPerRealMinute;
while (WorldHours >= 24.0f)
{
WorldHours -= 24.0f;
const int32 PreviousDayOfYear = ActiveDayOfYear;
ActiveDayOfYear = (ActiveDayOfYear % FMath::Max(1, DaysPerAgrarianYear)) + 1;
if (ActiveDayOfYear < PreviousDayOfYear)
{
++ActiveYear;
}
UpdateSolarTimes();
}
UpdateAmbientTemperature();
}
void AAgrarianGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(AAgrarianGameState, WorldHours);
DOREPLIFETIME(AAgrarianGameState, Weather);
DOREPLIFETIME(AAgrarianGameState, AmbientTemperatureC);
DOREPLIFETIME(AAgrarianGameState, RegionalDailyLowTemperatureC);
DOREPLIFETIME(AAgrarianGameState, RegionalDailyHighTemperatureC);
DOREPLIFETIME(AAgrarianGameState, RegionalObservedTemperatureC);
DOREPLIFETIME(AAgrarianGameState, ObservedTemperatureBlend);
DOREPLIFETIME(AAgrarianGameState, bHasRegionalObservedTemperature);
DOREPLIFETIME(AAgrarianGameState, RegionalWeatherSource);
DOREPLIFETIME(AAgrarianGameState, DaysPerAgrarianYear);
DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId);
DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude);
DOREPLIFETIME(AAgrarianGameState, ActiveTileLongitude);
DOREPLIFETIME(AAgrarianGameState, ActiveTileTimeZoneId);
DOREPLIFETIME(AAgrarianGameState, ActiveTileUtcOffsetHours);
DOREPLIFETIME(AAgrarianGameState, ActiveDayOfYear);
DOREPLIFETIME(AAgrarianGameState, ActiveYear);
DOREPLIFETIME(AAgrarianGameState, ActiveGrowingSeason);
DOREPLIFETIME(AAgrarianGameState, bHasActiveTileSolarData);
DOREPLIFETIME(AAgrarianGameState, SunriseHourLocal);
DOREPLIFETIME(AAgrarianGameState, SunsetHourLocal);
DOREPLIFETIME(AAgrarianGameState, SolarNoonHourLocal);
DOREPLIFETIME(AAgrarianGameState, DayLengthHours);
}
bool AAgrarianGameState::IsNight() const
{
if (bHasActiveTileSolarData)
{
return WorldHours < SunriseHourLocal || WorldHours > SunsetHourLocal;
}
return WorldHours < 6.0f || WorldHours > 20.0f;
}
void AAgrarianGameState::SetWeather(EAgrarianWeatherType NewWeather)
{
if (HasAuthority())
{
Weather = NewWeather;
UpdateAmbientTemperature();
OnRep_Weather();
}
}
void AAgrarianGameState::SetRegionalTemperatureProfile(float DailyLowTemperatureC, float DailyHighTemperatureC)
{
if (!HasAuthority())
{
return;
}
RegionalDailyLowTemperatureC = FMath::Clamp(FMath::Min(DailyLowTemperatureC, DailyHighTemperatureC), -80.0f, 70.0f);
RegionalDailyHighTemperatureC = FMath::Clamp(FMath::Max(DailyLowTemperatureC, DailyHighTemperatureC), -80.0f, 70.0f);
UpdateAmbientTemperature();
}
void AAgrarianGameState::SetRegionalObservedTemperature(float ObservedTemperatureC, float BlendWeight, const FString& WeatherSource)
{
if (!HasAuthority())
{
return;
}
RegionalObservedTemperatureC = FMath::Clamp(ObservedTemperatureC, -80.0f, 70.0f);
ObservedTemperatureBlend = FMath::Clamp(BlendWeight, 0.0f, 1.0f);
bHasRegionalObservedTemperature = ObservedTemperatureBlend > 0.0f;
RegionalWeatherSource = WeatherSource.IsEmpty() ? TEXT("server_weather_adapter") : WeatherSource;
UpdateAmbientTemperature();
}
float AAgrarianGameState::GetClearSkyTemperatureForHour(float HourOfDay) const
{
const float LowTemperature = FMath::Min(RegionalDailyLowTemperatureC, RegionalDailyHighTemperatureC);
const float HighTemperature = FMath::Max(RegionalDailyLowTemperatureC, RegionalDailyHighTemperatureC);
const float LowHour = bHasActiveTileSolarData ? SunriseHourLocal : 6.0f;
const float HighHour = bHasActiveTileSolarData ? FMath::Fmod(SolarNoonHourLocal + 3.0f, 24.0f) : 14.0f;
const float NormalizedHour = FMath::Fmod(HourOfDay, 24.0f) < 0.0f
? FMath::Fmod(HourOfDay, 24.0f) + 24.0f
: FMath::Fmod(HourOfDay, 24.0f);
const float RisingDuration = FMath::Max(0.1f, GetWrappedHourDelta(LowHour, HighHour));
const float HoursSinceLow = GetWrappedHourDelta(LowHour, NormalizedHour);
if (HoursSinceLow <= RisingDuration)
{
const float Alpha = 0.5f - (0.5f * FMath::Cos(PI * (HoursSinceLow / RisingDuration)));
return FMath::Lerp(LowTemperature, HighTemperature, Alpha);
}
const float CoolingDuration = FMath::Max(0.1f, 24.0f - RisingDuration);
const float HoursSinceHigh = GetWrappedHourDelta(HighHour, NormalizedHour);
const float Alpha = 0.5f - (0.5f * FMath::Cos(PI * (HoursSinceHigh / CoolingDuration)));
return FMath::Lerp(HighTemperature, LowTemperature, Alpha);
}
bool AAgrarianGameState::ConfigureActiveSolarTile(FName TileId, float Latitude, float Longitude, const FString& TimeZoneId, float UtcOffsetHours)
{
if (!HasAuthority() || TileId == NAME_None)
{
return false;
}
ActiveSolarTileId = TileId;
ActiveTileLatitude = FMath::Clamp(Latitude, -90.0f, 90.0f);
ActiveTileLongitude = FMath::Clamp(Longitude, -180.0f, 180.0f);
ActiveTileTimeZoneId = TimeZoneId;
ActiveTileUtcOffsetHours = FMath::Clamp(UtcOffsetHours, -12.0f, 14.0f);
ActiveGrowingSeason.TileId = TileId;
UpdateSolarTimes();
UpdateAmbientTemperature();
return bHasActiveTileSolarData;
}
FAgrarianCalendarSnapshot AAgrarianGameState::GetCalendarSnapshot() const
{
FAgrarianCalendarSnapshot Snapshot;
Snapshot.DayOfYear = NormalizeCalendarDay(ActiveDayOfYear, DaysPerAgrarianYear);
Snapshot.Year = FMath::Max(1, ActiveYear);
Snapshot.AbsoluteDay = GetAbsoluteAgrarianDay();
Snapshot.DayOfSeason = GetDayOfSeason(Snapshot.DayOfYear);
Snapshot.Season = GetSeasonForDay(Snapshot.DayOfYear);
Snapshot.HourOfDay = FMath::Clamp(WorldHours, 0.0f, 24.0f);
Snapshot.bInsideGrowingSeason = IsDayInsideActiveGrowingSeason(Snapshot.DayOfYear);
Snapshot.GrowingSeasonDaysRemaining = GetDaysRemainingInActiveGrowingSeason(Snapshot.DayOfYear);
return Snapshot;
}
int32 AAgrarianGameState::GetAbsoluteAgrarianDay() const
{
const int32 SafeYear = FMath::Max(1, ActiveYear);
const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear);
return ((SafeYear - 1) * SafeDaysPerYear) + NormalizeCalendarDay(ActiveDayOfYear, SafeDaysPerYear);
}
EAgrarianSeason AAgrarianGameState::GetSeasonForDay(int32 DayOfYear) const
{
const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear);
const int32 SeasonalDay = ActiveTileLatitude < 0.0f
? NormalizeCalendarDay(DayOfYear + (SafeDaysPerYear / 2), SafeDaysPerYear)
: NormalizeCalendarDay(DayOfYear, SafeDaysPerYear);
if (SeasonalDay >= 355 || SeasonalDay < 80)
{
return EAgrarianSeason::Winter;
}
if (SeasonalDay < 172)
{
return EAgrarianSeason::Spring;
}
if (SeasonalDay < 264)
{
return EAgrarianSeason::Summer;
}
return EAgrarianSeason::Autumn;
}
int32 AAgrarianGameState::GetDayOfSeason(int32 DayOfYear) const
{
const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear);
const int32 SeasonalDay = ActiveTileLatitude < 0.0f
? NormalizeCalendarDay(DayOfYear + (SafeDaysPerYear / 2), SafeDaysPerYear)
: NormalizeCalendarDay(DayOfYear, SafeDaysPerYear);
switch (GetSeasonForDay(DayOfYear))
{
case EAgrarianSeason::Spring:
return SeasonalDay - 79;
case EAgrarianSeason::Summer:
return SeasonalDay - 171;
case EAgrarianSeason::Autumn:
return SeasonalDay - 263;
default:
return SeasonalDay >= 355 ? SeasonalDay - 354 : SeasonalDay + 12;
}
}
float AAgrarianGameState::ConvertAgrarianDaysToRealHours(float AgrarianDays) const
{
const float SafeGameHoursPerRealMinute = FMath::Max(0.001f, GameHoursPerRealMinute);
return FMath::Max(0.0f, AgrarianDays) * (24.0f / SafeGameHoursPerRealMinute) / 60.0f;
}
float AAgrarianGameState::ConvertRealHoursToAgrarianDays(float RealHours) const
{
return (FMath::Max(0.0f, RealHours) * 60.0f * FMath::Max(0.001f, GameHoursPerRealMinute)) / 24.0f;
}
float AAgrarianGameState::GetLongTaskProgress(int32 StartAbsoluteDay, float StartHourOfDay, float DurationAgrarianDays) const
{
const float SafeDuration = FMath::Max(0.001f, DurationAgrarianDays);
const float CurrentAgrarianDay = static_cast<float>(GetAbsoluteAgrarianDay() - 1) + (WorldHours / 24.0f);
const float StartAgrarianDay = static_cast<float>(FMath::Max(1, StartAbsoluteDay) - 1)
+ (FMath::Clamp(StartHourOfDay, 0.0f, 24.0f) / 24.0f);
return FMath::Clamp((CurrentAgrarianDay - StartAgrarianDay) / SafeDuration, 0.0f, 1.0f);
}
bool AAgrarianGameState::IsDayInsideActiveGrowingSeason(int32 DayOfYear) const
{
return IsDayInRange(
DayOfYear,
ActiveGrowingSeason.GrowingSeasonStartDay,
ActiveGrowingSeason.GrowingSeasonEndDay,
DaysPerAgrarianYear);
}
int32 AAgrarianGameState::GetDaysRemainingInActiveGrowingSeason(int32 PlantDayOfYear) const
{
const int32 SafeDaysPerYear = FMath::Max(1, DaysPerAgrarianYear);
const int32 Day = NormalizeCalendarDay(PlantDayOfYear, SafeDaysPerYear);
const int32 Start = NormalizeCalendarDay(ActiveGrowingSeason.GrowingSeasonStartDay, SafeDaysPerYear);
const int32 End = NormalizeCalendarDay(ActiveGrowingSeason.GrowingSeasonEndDay, SafeDaysPerYear);
if (Start <= End)
{
if (Day < Start)
{
return End - Start + 1;
}
if (Day > End)
{
return 0;
}
return End - Day + 1;
}
if (Day >= Start)
{
return (SafeDaysPerYear - Day + 1) + End;
}
if (Day <= End)
{
return End - Day + 1;
}
return 0;
}
FAgrarianCropSeasonAssessment AAgrarianGameState::AssessCropForActiveGrowingSeason(int32 CropMaturityDays, int32 PlantDayOfYear) const
{
FAgrarianCropSeasonAssessment Assessment;
Assessment.DaysAvailable = GetDaysRemainingInActiveGrowingSeason(PlantDayOfYear);
Assessment.SafetyBufferDays = FMath::Max(0, ActiveGrowingSeason.CropSafetyBufferDays);
Assessment.DaysRequired = FMath::Max(0, CropMaturityDays) + Assessment.SafetyBufferDays;
Assessment.GrowingZoneLabel = ActiveGrowingSeason.GrowingZoneLabel;
Assessment.bCanPlantToday = false;
if (CropMaturityDays <= 0)
{
Assessment.Fit = EAgrarianCropSeasonFit::Unknown;
Assessment.Reason = FText::FromString(TEXT("Crop maturity days must be greater than zero."));
return Assessment;
}
if (!IsDayInsideActiveGrowingSeason(PlantDayOfYear))
{
Assessment.Fit = EAgrarianCropSeasonFit::OutOfSeason;
Assessment.Reason = FText::FromString(TEXT("Planting day is outside the active tile growing season."));
return Assessment;
}
if (Assessment.DaysRequired > ActiveGrowingSeason.FrostFreeDays)
{
Assessment.Fit = EAgrarianCropSeasonFit::TooLong;
Assessment.Reason = FText::FromString(TEXT("Crop maturity is too long for this tile's frost-free growing window."));
return Assessment;
}
if (Assessment.DaysRequired > Assessment.DaysAvailable)
{
Assessment.Fit = EAgrarianCropSeasonFit::Marginal;
Assessment.Reason = FText::FromString(TEXT("Crop can grow in this zone, but this planting date is too late for a reliable harvest."));
return Assessment;
}
Assessment.Fit = EAgrarianCropSeasonFit::FitsSeason;
Assessment.bCanPlantToday = true;
Assessment.Reason = FText::FromString(TEXT("Crop maturity fits the active tile growing season."));
return Assessment;
}
void AAgrarianGameState::OnRep_Weather()
{
UpdateAmbientTemperature();
}
void AAgrarianGameState::UpdateSolarTimes()
{
if (ActiveSolarTileId == NAME_None)
{
bHasActiveTileSolarData = false;
SunriseHourLocal = 6.0f;
SunsetHourLocal = 20.0f;
SolarNoonHourLocal = 13.0f;
DayLengthHours = 14.0f;
return;
}
const float ClampedLatitude = FMath::Clamp(ActiveTileLatitude, -89.8f, 89.8f);
const float ClampedLongitude = FMath::Clamp(ActiveTileLongitude, -180.0f, 180.0f);
const int32 ClampedDayOfYear = FMath::Clamp(ActiveDayOfYear, 1, 366);
// NOAA approximate sunrise/sunset model; good enough for MVP regional light timing.
const double Gamma = (2.0 * PI / 365.0) * (static_cast<double>(ClampedDayOfYear) - 1.0);
const double EquationOfTimeMinutes = 229.18 * (
0.000075
+ 0.001868 * FMath::Cos(Gamma)
- 0.032077 * FMath::Sin(Gamma)
- 0.014615 * FMath::Cos(2.0 * Gamma)
- 0.040849 * FMath::Sin(2.0 * Gamma));
const double SolarDeclinationRadians =
0.006918
- 0.399912 * FMath::Cos(Gamma)
+ 0.070257 * FMath::Sin(Gamma)
- 0.006758 * FMath::Cos(2.0 * Gamma)
+ 0.000907 * FMath::Sin(2.0 * Gamma)
- 0.002697 * FMath::Cos(3.0 * Gamma)
+ 0.00148 * FMath::Sin(3.0 * Gamma);
const double LatitudeRadians = FMath::DegreesToRadians(static_cast<double>(ClampedLatitude));
const double SolarZenithRadians = FMath::DegreesToRadians(90.833);
const double HourAngleArgument =
(FMath::Cos(SolarZenithRadians) / (FMath::Cos(LatitudeRadians) * FMath::Cos(SolarDeclinationRadians)))
- FMath::Tan(LatitudeRadians) * FMath::Tan(SolarDeclinationRadians);
if (HourAngleArgument <= -1.0 || HourAngleArgument >= 1.0)
{
bHasActiveTileSolarData = true;
SolarNoonHourLocal = FMath::Fmod(12.0f + ActiveTileUtcOffsetHours - (ClampedLongitude / 15.0f), 24.0f);
if (SolarNoonHourLocal < 0.0f)
{
SolarNoonHourLocal += 24.0f;
}
DayLengthHours = HourAngleArgument <= -1.0 ? 24.0f : 0.0f;
SunriseHourLocal = DayLengthHours >= 24.0f ? 0.0f : SolarNoonHourLocal;
SunsetHourLocal = DayLengthHours >= 24.0f ? 24.0f : SolarNoonHourLocal;
return;
}
const double HourAngleDegrees = FMath::RadiansToDegrees(FMath::Acos(HourAngleArgument));
const double SolarNoonMinutes = 720.0 - (4.0 * ClampedLongitude) - EquationOfTimeMinutes + (ActiveTileUtcOffsetHours * 60.0);
const double SunriseMinutes = SolarNoonMinutes - (HourAngleDegrees * 4.0);
const double SunsetMinutes = SolarNoonMinutes + (HourAngleDegrees * 4.0);
auto NormalizeHour = [](double Minutes)
{
double Hours = FMath::Fmod(Minutes / 60.0, 24.0);
if (Hours < 0.0)
{
Hours += 24.0;
}
return static_cast<float>(Hours);
};
bHasActiveTileSolarData = true;
SunriseHourLocal = NormalizeHour(SunriseMinutes);
SunsetHourLocal = NormalizeHour(SunsetMinutes);
SolarNoonHourLocal = NormalizeHour(SolarNoonMinutes);
DayLengthHours = static_cast<float>((SunsetMinutes - SunriseMinutes) / 60.0);
}
void AAgrarianGameState::UpdateAmbientTemperature()
{
const float ClearSkyTemperatureC = GetClearSkyTemperatureForHour(WorldHours);
const float DailyMeanTemperatureC = (RegionalDailyLowTemperatureC + RegionalDailyHighTemperatureC) * 0.5f;
const float ObservedAnchoredTemperatureC = bHasRegionalObservedTemperature
? RegionalObservedTemperatureC + (ClearSkyTemperatureC - DailyMeanTemperatureC)
: ClearSkyTemperatureC;
const float BaseTemperatureC = bHasRegionalObservedTemperature
? FMath::Lerp(ClearSkyTemperatureC, ObservedAnchoredTemperatureC, ObservedTemperatureBlend)
: ClearSkyTemperatureC;
float WeatherModifier = 0.0f;
switch (Weather)
{
case EAgrarianWeatherType::Rain:
WeatherModifier = -2.0f;
break;
case EAgrarianWeatherType::ColdWind:
WeatherModifier = -8.0f;
break;
case EAgrarianWeatherType::Storm:
WeatherModifier = -5.0f;
break;
default:
WeatherModifier = 0.0f;
break;
}
AmbientTemperatureC = FMath::Clamp(BaseTemperatureC + WeatherModifier, -80.0f, 70.0f);
}