From a684c23cd96249dc686c4747a3bc52b2c6361421 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 16:45:49 +0300 Subject: [PATCH] Add Climate and day/night ambient lighting Climate (Core) layers a continuous seasonal-plus-daily temperature and the current season over the Calendar, registered via context.UseClimate. A new Lighting module (Graphics) adds DayNight: a daylight factor and ambient color from the calendar's time of day, pushed into the renderer's new AmbientLight (multiplied into world-space sprites only, so the scene darkens at night while screen-space overlays stay readable) and exposed as a sampleable SampleAt for the simulation. Both deterministic and GPU-free, covered by tests; docs updated. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 9 +- docs/architecture.md | 4 +- src/MrGameEng.Core/Climate.cs | 137 ++++++++++++++++++ src/MrGameEng.Graphics/Lighting/DayNight.cs | 66 +++++++++ .../Lighting/DayNightSystem.cs | 49 +++++++ src/MrGameEng.Graphics/Renderer2D.cs | 15 +- tests/MrGameEng.Core.Tests/ClimateTests.cs | 84 +++++++++++ .../MrGameEng.Graphics.Tests/DayNightTests.cs | 54 +++++++ 8 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 src/MrGameEng.Core/Climate.cs create mode 100644 src/MrGameEng.Graphics/Lighting/DayNight.cs create mode 100644 src/MrGameEng.Graphics/Lighting/DayNightSystem.cs create mode 100644 tests/MrGameEng.Core.Tests/ClimateTests.cs create mode 100644 tests/MrGameEng.Graphics.Tests/DayNightTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 2c24f76..1d5e126 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,12 +29,17 @@ Engine libraries (each feature is a namespaced subfolder of its host): - **`Core`** — game loop, ECS world, scenes, time (`GameClock` with `TimeScale`; `GameSpeed` for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`; - `Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`), + `Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`; + `Climate` — continuous seasonal/daily temperature and season over the calendar, + `context.UseClimate(settings)`), plus **Input** (`MrGameEng.Input`: `InputManager`, `ActionMap`, `InputSystem`, in `Core/Input/`). Depends only on MonoGame and Friflo.Engine.ECS. - **`Graphics`** — custom batched renderer, camera, sprites, plus **Tilemaps** (`MrGameEng.Tilemaps`: code-built tile grids rendered through the batcher, - `scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`). → `Core`. + `scene.UseTilemaps()` after `UseRenderer2D()`, in `Graphics/Tilemaps/`) and **Lighting** + (`MrGameEng.Lighting`, `Graphics/Lighting/`: day/night ambient over the `Calendar` driving + `Renderer2D.AmbientLight` on world layers, `scene.UseDayNight(renderer)`; a sampleable + `DayNight.SampleAt` for the simulation — point lights and shadows land here later). → `Core`. - **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume` (one knob for effects + music). → `Core`. - **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime diff --git a/docs/architecture.md b/docs/architecture.md index 2559069..7bb4c25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,8 +33,8 @@ | Библиотека (сборка) | Фичи (неймспейсы) и ответственность | |------------------------------|------------------------------------------------------------| -| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps | -| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер | +| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов; `Calendar` — игровые дни поверх масштабированного времени, `context.UseCalendar(...)`; `Climate` — непрерывная сезонная/суточная температура и сезон поверх календаря, `context.UseClimate(...)`), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps | +| `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер. **Lighting** (`MrGameEng.Lighting`, `Graphics/Lighting/`): амбиент день/ночь поверх `Calendar` → `Renderer2D.AmbientLight` на World-слоях, `scene.UseDayNight(renderer)`, сэмплируемый `DayNight.SampleAt` для симуляции | | `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis); `AudioManager` с `SoundVolume`/`MasterVolume` (одна ручка на эффекты и музыку) | | `MrGameEng.Content` | Пайплайн контента. **Assets** (`MrGameEng.Assets`): runtime-загрузка без Content Pipeline, кэш, `AssetRef`. **Atlases** (`MrGameEng.Atlases`): текстурные атласы — сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`), CLI `tools/MrGameEng.AtlasTool`. **Mods** (`MrGameEng.Mods`): система модов — порядок загрузки, JSON-дефы, локализация, слияние деревьев контента | | `MrGameEng.Simulation` | Детерминированные геймплей-примитивы без данных мира. **Pathfinding** (`MrGameEng.Pathfinding`): A*, Dijkstra, BFS, flow fields по гриду. **AI** (`MrGameEng.AI`): utility-ИИ — кривые отклика, соображения, действия, выбор (`UtilityAi`), `Blackboard`. **Collisions** (`MrGameEng.Collisions`): компонент `Collider`, spatial hash, пары/запросы/raycast | diff --git a/src/MrGameEng.Core/Climate.cs b/src/MrGameEng.Core/Climate.cs new file mode 100644 index 0000000..781599d --- /dev/null +++ b/src/MrGameEng.Core/Climate.cs @@ -0,0 +1,137 @@ +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; + } +} diff --git a/src/MrGameEng.Graphics/Lighting/DayNight.cs b/src/MrGameEng.Graphics/Lighting/DayNight.cs new file mode 100644 index 0000000..8f09853 --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/DayNight.cs @@ -0,0 +1,66 @@ +using Microsoft.Xna.Framework; +using MrGameEng.Core; + +namespace MrGameEng.Lighting; + +/// Colors for the day/night ambient: the tint at deep night and at midday. +public readonly record struct DayNightSettings +{ + /// Ambient tint at midnight (a dim, cool night). + public Color NightColor { get; init; } + + /// Ambient tint at noon (white = no tint). + public Color DayColor { get; init; } + + /// Sensible defaults: a dim blue night, untinted day. + public static DayNightSettings Default => + new() { NightColor = new Color(45, 55, 95), DayColor = Color.White }; +} + +/// +/// Day/night ambient driven by : a daylight factor (0 at midnight, +/// 1 at noon) and an ambient color lerped from night to day. is the sampleable +/// light interface read by both the renderer (scene darkening) and the simulation (plant light); +/// it returns the global daylight today and will return locally-shadowed light once point lights and +/// occlusion land. Pure read-side, GPU-free and deterministic. +/// +public sealed class DayNight +{ + private readonly Calendar _calendar; + private readonly DayNightSettings _settings; + + /// Creates the cycle reading with the given . + public DayNight(Calendar calendar, DayNightSettings settings) + { + _calendar = calendar ?? throw new ArgumentNullException(nameof(calendar)); + _settings = settings; + } + + /// Daylight factor in [0, 1] — 0 at midnight, 1 at noon, 0 again at midnight. + public float Daylight + { + get + { + var value = -MathF.Cos(MathF.Tau * _calendar.DayProgress); + return value > 0f ? value : 0f; + } + } + + /// Global light intensity in [0, 1]; same as today. + public float Intensity => Daylight; + + /// Light intensity at a world point in [0, 1]. Global today; local (with shadows) later. + public float SampleAt(Vector2 world) => Daylight; + + /// Ambient tint for the scene: night color at night, day color at noon, eased between. + public Color Ambient + { + get + { + var t = Smoothstep(Daylight); + return Color.Lerp(_settings.NightColor, _settings.DayColor, t); + } + } + + private static float Smoothstep(float x) => x * x * (3f - 2f * x); +} diff --git a/src/MrGameEng.Graphics/Lighting/DayNightSystem.cs b/src/MrGameEng.Graphics/Lighting/DayNightSystem.cs new file mode 100644 index 0000000..70d9d6c --- /dev/null +++ b/src/MrGameEng.Graphics/Lighting/DayNightSystem.cs @@ -0,0 +1,49 @@ +using Friflo.Engine.ECS.Systems; +using MrGameEng.Core; +using MrGameEng.Graphics; + +namespace MrGameEng.Lighting; + +/// +/// Update-phase system that pushes the current into the renderer's +/// each frame, so the world darkens toward night and brightens +/// toward noon. Runs in the update phase, before the draw phase consumes the value. +/// +public sealed class DayNightSystem : BaseSystem +{ + private readonly DayNight _dayNight; + private readonly Renderer2D _renderer; + + internal DayNightSystem(DayNight dayNight, Renderer2D renderer) + { + _dayNight = dayNight; + _renderer = renderer; + } + + /// + protected override void OnUpdateGroup() => _renderer.AmbientLight = _dayNight.Ambient; +} + +/// Wires the day/night ambient cycle into a . +public static class SceneLightingExtensions +{ + /// + /// Creates a bound to the scene's service, registers + /// it, and drives 's ambient light from it. Call from OnLoad + /// after and UseRenderer2D. + /// + public static DayNight UseDayNight( + this Scene scene, + Renderer2D renderer, + DayNightSettings? settings = null + ) + { + var dayNight = new DayNight( + scene.Context.Services.Get(), + settings ?? DayNightSettings.Default + ); + scene.Context.Services.Add(dayNight); + scene.UpdateSystems.Add(new DayNightSystem(dayNight, renderer)); + return dayNight; + } +} diff --git a/src/MrGameEng.Graphics/Renderer2D.cs b/src/MrGameEng.Graphics/Renderer2D.cs index 4f38d7e..15aac8d 100644 --- a/src/MrGameEng.Graphics/Renderer2D.cs +++ b/src/MrGameEng.Graphics/Renderer2D.cs @@ -27,6 +27,13 @@ public sealed class Renderer2D : IDisposable /// Camera state of the current frame. Valid between BeginFrame and the next BeginFrame. public CameraState Camera { get; private set; } + /// + /// Global ambient light multiplied into every sprite — the engine + /// hook a day/night cycle drives. (default) leaves the scene unchanged; + /// screen-space layers (HUD overlays) are never tinted. + /// + public Color AmbientLight { get; set; } = Color.White; + /// Draw calls issued by the last . public int DrawCalls { get; private set; } @@ -338,6 +345,12 @@ public sealed class Renderer2D : IDisposable // высоты с origin «в ногах» сортируются по ногам, как принято в top-down. var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.Y : sprite.Depth; + // Ambient light tints world sprites (day/night); screen-space overlays stay at full brightness. + var color = + layer.Space == LayerSpace.World && AmbientLight != Color.White + ? new Color(sprite.Color.ToVector4() * AmbientLight.ToVector4()) + : sprite.Color; + instance = new SpriteInstance { Region = region, @@ -346,7 +359,7 @@ public sealed class Renderer2D : IDisposable new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f, Rotation = transform.Rotation, - Color = sprite.Color, + Color = color, Flip = sprite.Flip, Layer = sprite.Layer.Value, }; diff --git a/tests/MrGameEng.Core.Tests/ClimateTests.cs b/tests/MrGameEng.Core.Tests/ClimateTests.cs new file mode 100644 index 0000000..42fce5c --- /dev/null +++ b/tests/MrGameEng.Core.Tests/ClimateTests.cs @@ -0,0 +1,84 @@ +using MrGameEng.Core; +using Xunit; + +namespace MrGameEng.Core.Tests; + +public class ClimateTests +{ + private static (GameClock Clock, Climate Climate) Make(ClimateSettings settings) + { + var clock = new GameClock(); + var calendar = new Calendar(clock, secondsPerDay: 10f); + return (clock, new Climate(calendar, settings)); + } + + private static ClimateSettings Seasonal => + new() + { + DaysPerYear = 60, + MeanTemperature = 10f, + SeasonalAmplitude = 20f, + DailyAmplitude = 0f, + WarmestDay = 15, + }; + + [Fact] + public void Constructor_NonPositiveYear_Throws() + { + Assert.Throws(() => + new Climate(new Calendar(new GameClock(), 10f), Seasonal with { DaysPerYear = 0 }) + ); + } + + [Fact] + public void Temperature_PeaksOnWarmestDay_AndBottomsHalfYearLater() + { + var (clock, climate) = Make(Seasonal); + + clock.Advance(15 * 10f); // day 15 = warmest + Assert.Equal(30f, climate.Temperature, 2); // mean 10 + amplitude 20 + + clock.Advance(30 * 10f); // +30 days = half a 60-day year later + Assert.Equal(-10f, climate.Temperature, 2); // mean 10 - amplitude 20 + } + + [Fact] + public void DailySwing_WarmerAtNoonThanMidnight() + { + var settings = Seasonal with { SeasonalAmplitude = 0f, DailyAmplitude = 6f }; + var (clock, climate) = Make(settings); + + clock.Advance(5f); // day 0, noon (DayProgress 0.5) + Assert.Equal(16f, climate.Temperature, 2); // mean 10 + daily 6 + + clock.Advance(5f); // day 1, midnight (DayProgress 0) + Assert.Equal(4f, climate.Temperature, 2); // mean 10 - daily 6 + } + + [Fact] + public void Season_FollowsTheQuarterOfTheYear() + { + var (clock, climate) = Make(Seasonal); // 60-day year → 15 days per season + + Assert.Equal(Season.Spring, climate.Season); + clock.Advance(20 * 10f); + Assert.Equal(Season.Summer, climate.Season); + clock.Advance(15 * 10f); + Assert.Equal(Season.Autumn, climate.Season); + clock.Advance(15 * 10f); + Assert.Equal(Season.Winter, climate.Season); + } + + [Fact] + public void YearAndDayOfYear_RollOver() + { + var (clock, climate) = Make(Seasonal); + + Assert.Equal(1, climate.Year); + Assert.Equal(0, climate.DayOfYear); + + clock.Advance(60 * 10f); // exactly one year + Assert.Equal(2, climate.Year); + Assert.Equal(0, climate.DayOfYear); + } +} diff --git a/tests/MrGameEng.Graphics.Tests/DayNightTests.cs b/tests/MrGameEng.Graphics.Tests/DayNightTests.cs new file mode 100644 index 0000000..e21b126 --- /dev/null +++ b/tests/MrGameEng.Graphics.Tests/DayNightTests.cs @@ -0,0 +1,54 @@ +using MrGameEng.Core; +using MrGameEng.Lighting; +using Xunit; + +namespace MrGameEng.Lighting.Tests; + +public class DayNightTests +{ + private static (GameClock Clock, DayNight DayNight) Make() + { + var clock = new GameClock(); + var calendar = new Calendar(clock, secondsPerDay: 24f); // 1 second = 1 in-game hour + return (clock, new DayNight(calendar, DayNightSettings.Default)); + } + + [Fact] + public void Daylight_ZeroAtMidnight_OneAtNoon() + { + var (clock, dayNight) = Make(); + + Assert.Equal(0f, dayNight.Daylight, 3); // 00:00 + + clock.Advance(12f); // 12:00 + Assert.Equal(1f, dayNight.Daylight, 3); + } + + [Fact] + public void Daylight_RisesFromDawnToNoon() + { + var (clock, dayNight) = Make(); + + clock.Advance(6f); // 06:00 + var dawn = dayNight.Daylight; + clock.Advance(3f); // 09:00 + var mid = dayNight.Daylight; + clock.Advance(3f); // 12:00 + var noon = dayNight.Daylight; + + Assert.True(dawn < mid && mid < noon, "daylight should rise from dawn to noon"); + } + + [Fact] + public void Ambient_DarkerAtNightThanAtNoon() + { + var (clock, dayNight) = Make(); + + var night = dayNight.Ambient; // 00:00 + clock.Advance(12f); // 12:00 + var noon = dayNight.Ambient; + + Assert.True(night.R < noon.R && night.G < noon.G && night.B < noon.B); + Assert.Equal(255, noon.R); // day color is white at noon + } +}