namespace MrGameEng.Core;
/// The four seasons, in calendar order from the start of the year.
public enum Season
{
/// First quarter of the year — warming.
Spring,
/// Second quarter — warmest.
Summer,
/// Third quarter — cooling.
Autumn,
/// Last quarter — coldest.
Winter,
}
///
/// Tuning for . A year spans in-game days; temperature
/// follows a seasonal cosine peaking on , plus a daily swing (cooler at night).
///
public readonly record struct ClimateSettings
{
/// In-game days in one year (e.g. 60 = four 15-day seasons).
public int DaysPerYear { get; init; }
/// Yearly mean temperature.
public float MeanTemperature { get; init; }
/// Peak deviation from the mean across the seasons (summer high / winter low).
public float SeasonalAmplitude { get; init; }
/// Peak deviation from the seasonal mean across one day (warmer at noon, cooler at night).
public float DailyAmplitude { get; init; }
/// Day of the year (0-based) with the highest temperature; defaults to mid-summer.
public int WarmestDay { get; init; }
/// Temperate defaults: a 60-day year, mean 12°, ±14° seasonal, ±5° daily, warmest mid-summer.
public static ClimateSettings Default =>
new()
{
DaysPerYear = 60,
MeanTemperature = 12f,
SeasonalAmplitude = 14f,
DailyAmplitude = 5f,
WarmestDay = 22, // ~middle of the summer quarter of a 60-day year
};
}
///
/// Continuous climate layered over : a temperature that varies smoothly with the
/// season and the time of day, plus the current season and year. The whole curve is derived from the
/// calendar's elapsed time, so it slows or stops with the game clock. Pure read-side, GPU-free and
/// deterministic; registered as a service via .
///
public sealed class Climate
{
private readonly Calendar _calendar;
private readonly ClimateSettings _settings;
/// Creates a climate reading with the given .
public Climate(Calendar calendar, ClimateSettings settings)
{
_calendar = calendar ?? throw new ArgumentNullException(nameof(calendar));
if (settings.DaysPerYear <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(settings),
"Days per year must be positive."
);
}
_settings = settings;
}
/// In-game days per year.
public int DaysPerYear => _settings.DaysPerYear;
/// Continuous position within the current year in [0, 1).
public double YearProgress
{
get
{
var years = _calendar.TotalDays / _settings.DaysPerYear;
return years - Math.Floor(years);
}
}
/// The current year, counting from 1.
public int Year => (int)(_calendar.TotalDays / _settings.DaysPerYear) + 1;
/// Day within the current year, 0-based.
public int DayOfYear => (int)(YearProgress * _settings.DaysPerYear);
/// The current season, derived from the quarter of the year.
public Season Season => (Season)Math.Clamp((int)(YearProgress * 4.0), 0, 3);
/// Current temperature: seasonal cosine peaking on the warmest day, plus a daily swing.
public float Temperature
{
get
{
var warmFraction = _settings.WarmestDay / (float)_settings.DaysPerYear;
var seasonal =
_settings.MeanTemperature
+ _settings.SeasonalAmplitude
* MathF.Cos(MathF.Tau * ((float)YearProgress - warmFraction));
var daily = _settings.DailyAmplitude * (2f * Daylight() - 1f);
return seasonal + daily;
}
}
// Daytime factor 0..1 (0 at midnight, 1 at noon). Mirrors the day/night curve without a
// Graphics dependency — Core owns the daily temperature swing.
private float Daylight()
{
var value = -MathF.Cos(MathF.Tau * _calendar.DayProgress);
return value > 0f ? value : 0f;
}
}
/// Wires the climate model into the engine.
public static class ClimateEngineExtensions
{
///
/// Creates a bound to the context's service and
/// registers it. Call once per world, after .
///
public static Climate UseClimate(this EngineContext context, ClimateSettings settings)
{
var climate = new Climate(context.Services.Get(), settings);
context.Services.Add(climate);
return climate;
}
}