Add regional temperature curve

This commit is contained in:
2026-05-15 21:53:10 -07:00
parent 8ee1f83b16
commit ca2c3ee3db
5 changed files with 172 additions and 5 deletions
+1 -1
View File
@@ -417,7 +417,7 @@ Target deliverable: A small group can join a server, spawn into one biome, gathe
- [x] Set default server time scale to `4 real hours = 1 in-game day`. - [x] Set default server time scale to `4 real hours = 1 in-game day`.
- [x] Add real local time-zone and sunrise/sunset lookup for Ground Zero by latitude/longitude. Added tile-aware solar metadata to `AAgrarianGameState`, Ground Zero Pacifica defaults, local sunrise/sunset/solar-noon/day-length approximation, replicated solar state, `IsNight` based on active tile solar bounds, and a repeatable registry-driven solar metadata generator that skips placeholder/unknown tiles. - [x] Add real local time-zone and sunrise/sunset lookup for Ground Zero by latitude/longitude. Added tile-aware solar metadata to `AAgrarianGameState`, Ground Zero Pacifica defaults, local sunrise/sunset/solar-noon/day-length approximation, replicated solar state, `IsNight` based on active tile solar bounds, and a repeatable registry-driven solar metadata generator that skips placeholder/unknown tiles.
- [x] Add Agrarian calendar conversion helpers for days, seasons, crop cycles, livestock maturity, spoilage, and long-running tasks. Added replicated year/day and absolute-day helpers, season/day-of-season lookup, `4 real hours = 1 in-game day` conversion helpers, long-task progress calculation, active tile growing-zone profile, crop maturity fit checks against frost-free/growing-season windows, and a repeatable growing-zone metadata generator that skips placeholder/unknown tiles. - [x] Add Agrarian calendar conversion helpers for days, seasons, crop cycles, livestock maturity, spoilage, and long-running tasks. Added replicated year/day and absolute-day helpers, season/day-of-season lookup, `4 real hours = 1 in-game day` conversion helpers, long-task progress calculation, active tile growing-zone profile, crop maturity fit checks against frost-free/growing-season windows, and a repeatable growing-zone metadata generator that skips placeholder/unknown tiles.
- [ ] Add temperature curve by time of day. - [x] Add temperature curve by time of day. Added replicated regional daily low/high temperatures, a sunrise-to-afternoon warming curve, overnight cooling, observed regional temperature blending for future real-weather adapters, weather modifiers, and deterministic fallback behavior when no provider data is available.
- [x] Add simple weather states. - [x] Add simple weather states.
- [x] Add clear weather. - [x] Add clear weather.
- [x] Add rain. - [x] Add rain.
+10
View File
@@ -137,6 +137,16 @@ conservative Pacifica coastal profile; later regional expansion should replace
or enrich these overrides with authoritative zone, climate, and temperature or enrich these overrides with authoritative zone, climate, and temperature
datasets. datasets.
Temperature is authoritative on `AAgrarianGameState`. The MVP curve uses the
active tile's sunrise and solar noon to place the daily low near sunrise and the
daily high after solar noon, then applies weather modifiers for rain, cold wind,
and storms. Regional daily low/high values provide the deterministic fallback.
When a server-side weather adapter is available, it should set observed regional
temperature and blend weight through the game-state hook rather than allowing
clients to call public weather APIs directly. This keeps real-world temperature
and weather tied to the represented map tile while preserving a deterministic
fallback if an external provider is unavailable.
## Terrain And Tile Delivery ## Terrain And Tile Delivery
### MVP Tile ### MVP Tile
+58
View File
@@ -0,0 +1,58 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
GAME_STATE_H = ROOT / "Source" / "AgrarianGame" / "AgrarianGameState.h"
GAME_STATE_CPP = ROOT / "Source" / "AgrarianGame" / "AgrarianGameState.cpp"
TDD = ROOT / "Docs" / "TechnicalDesignDocument.md"
ROADMAP = ROOT / "AGRARIAN_DEVELOPMENT_ROADMAP.md"
EXPECTED = {
GAME_STATE_H: [
"RegionalDailyLowTemperatureC",
"RegionalDailyHighTemperatureC",
"RegionalObservedTemperatureC",
"ObservedTemperatureBlend",
"bHasRegionalObservedTemperature",
"SetRegionalTemperatureProfile",
"SetRegionalObservedTemperature",
"GetClearSkyTemperatureForHour",
],
GAME_STATE_CPP: [
"GetWrappedHourDelta",
"DOREPLIFETIME(AAgrarianGameState, RegionalDailyLowTemperatureC);",
"DOREPLIFETIME(AAgrarianGameState, RegionalWeatherSource);",
"void AAgrarianGameState::SetRegionalTemperatureProfile",
"void AAgrarianGameState::SetRegionalObservedTemperature",
"float AAgrarianGameState::GetClearSkyTemperatureForHour",
"const float LowHour = bHasActiveTileSolarData ? SunriseHourLocal : 6.0f;",
"const float HighHour = bHasActiveTileSolarData ? FMath::Fmod(SolarNoonHourLocal + 3.0f, 24.0f) : 14.0f;",
"AmbientTemperatureC = FMath::Clamp(BaseTemperatureC + WeatherModifier, -80.0f, 70.0f);",
],
TDD: [
"Temperature is authoritative on `AAgrarianGameState`",
"server-side weather adapter",
"deterministic fallback",
],
ROADMAP: [
"[x] Add temperature curve by time of day.",
"observed regional temperature blending",
],
}
def main() -> None:
missing = []
for path, snippets in EXPECTED.items():
text = path.read_text(encoding="utf-8")
for snippet in snippets:
if snippet not in text:
missing.append(f"{path.relative_to(ROOT)}: {snippet}")
if missing:
raise RuntimeError("Temperature curve verification failed: " + "; ".join(missing))
print("Agrarian temperature curve verification complete.")
if __name__ == "__main__":
main()
+76 -4
View File
@@ -25,6 +25,16 @@ bool IsDayInRange(int32 DayOfYear, int32 StartDay, int32 EndDay, int32 DaysPerYe
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() AAgrarianGameState::AAgrarianGameState()
@@ -82,6 +92,12 @@ void AAgrarianGameState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& O
DOREPLIFETIME(AAgrarianGameState, WorldHours); DOREPLIFETIME(AAgrarianGameState, WorldHours);
DOREPLIFETIME(AAgrarianGameState, Weather); DOREPLIFETIME(AAgrarianGameState, Weather);
DOREPLIFETIME(AAgrarianGameState, AmbientTemperatureC); 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, DaysPerAgrarianYear);
DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId); DOREPLIFETIME(AAgrarianGameState, ActiveSolarTileId);
DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude); DOREPLIFETIME(AAgrarianGameState, ActiveTileLatitude);
@@ -118,6 +134,56 @@ void AAgrarianGameState::SetWeather(EAgrarianWeatherType NewWeather)
} }
} }
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) bool AAgrarianGameState::ConfigureActiveSolarTile(FName TileId, float Latitude, float Longitude, const FString& TimeZoneId, float UtcOffsetHours)
{ {
if (!HasAuthority() || TileId == NAME_None) if (!HasAuthority() || TileId == NAME_None)
@@ -380,14 +446,20 @@ void AAgrarianGameState::UpdateSolarTimes()
void AAgrarianGameState::UpdateAmbientTemperature() void AAgrarianGameState::UpdateAmbientTemperature()
{ {
const float WarmestHour = bHasActiveTileSolarData ? FMath::Fmod(SolarNoonHourLocal + 3.0f, 24.0f) : 14.0f; const float ClearSkyTemperatureC = GetClearSkyTemperatureForHour(WorldHours);
const float DayWarmth = FMath::Sin(((WorldHours - WarmestHour) / 24.0f) * 2.0f * PI + (PI * 0.5f)) * 8.0f; 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; float WeatherModifier = 0.0f;
switch (Weather) switch (Weather)
{ {
case EAgrarianWeatherType::Rain: case EAgrarianWeatherType::Rain:
WeatherModifier = -3.0f; WeatherModifier = -2.0f;
break; break;
case EAgrarianWeatherType::ColdWind: case EAgrarianWeatherType::ColdWind:
WeatherModifier = -8.0f; WeatherModifier = -8.0f;
@@ -400,5 +472,5 @@ void AAgrarianGameState::UpdateAmbientTemperature()
break; break;
} }
AmbientTemperatureC = 10.0f + DayWarmth + WeatherModifier; AmbientTemperatureC = FMath::Clamp(BaseTemperatureC + WeatherModifier, -80.0f, 70.0f);
} }
+27
View File
@@ -34,6 +34,24 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World") UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World")
float AmbientTemperatureC = 12.0f; float AmbientTemperatureC = 12.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature")
float RegionalDailyLowTemperatureC = 9.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature")
float RegionalDailyHighTemperatureC = 18.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature")
float RegionalObservedTemperatureC = 12.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float ObservedTemperatureBlend = 0.0f;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature")
bool bHasRegionalObservedTemperature = false;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Temperature")
FString RegionalWeatherSource = TEXT("deterministic_tile_curve");
UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Tile Solar") UPROPERTY(EditAnywhere, BlueprintReadOnly, Replicated, Category = "Agrarian|World|Tile Solar")
FName ActiveSolarTileId = TEXT("gz_us_ca_pacifica_utm10n_e544_n4160"); FName ActiveSolarTileId = TEXT("gz_us_ca_pacifica_utm10n_e544_n4160");
@@ -79,6 +97,15 @@ public:
UFUNCTION(BlueprintCallable, Category = "Agrarian|World") UFUNCTION(BlueprintCallable, Category = "Agrarian|World")
void SetWeather(EAgrarianWeatherType NewWeather); void SetWeather(EAgrarianWeatherType NewWeather);
UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Temperature")
void SetRegionalTemperatureProfile(float DailyLowTemperatureC, float DailyHighTemperatureC);
UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Temperature")
void SetRegionalObservedTemperature(float ObservedTemperatureC, float BlendWeight, const FString& WeatherSource);
UFUNCTION(BlueprintPure, Category = "Agrarian|World|Temperature")
float GetClearSkyTemperatureForHour(float HourOfDay) const;
UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Tile Solar") UFUNCTION(BlueprintCallable, Category = "Agrarian|World|Tile Solar")
bool ConfigureActiveSolarTile(FName TileId, float Latitude, float Longitude, const FString& TimeZoneId, float UtcOffsetHours); bool ConfigureActiveSolarTile(FName TileId, float Latitude, float Longitude, const FString& TimeZoneId, float UtcOffsetHours);