diff --git a/CLAUDE.md b/CLAUDE.md index 56d5bea..2f73baf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,8 @@ is the living showcase — new engine features are demonstrated there. 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(...)`), + for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`; + `Calendar` turning scaled time into in-game days, `context.UseCalendar(secondsPerDay)`), 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** diff --git a/docs/architecture.md b/docs/architecture.md index 7aec8d9..1f0328c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,7 @@ | Библиотека (сборка) | Фичи (неймспейсы) и ответственность | |------------------------------|------------------------------------------------------------| -| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps | +| `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.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-дефы, локализация, слияние деревьев контента | diff --git a/src/MrGameEng.Core/Calendar.cs b/src/MrGameEng.Core/Calendar.cs new file mode 100644 index 0000000..b64fca8 --- /dev/null +++ b/src/MrGameEng.Core/Calendar.cs @@ -0,0 +1,70 @@ +namespace MrGameEng.Core; + +/// +/// In-game calendar layered over : turns scaled elapsed time into whole +/// days and a fraction-of-day (time of day). One in-game day spans +/// seconds of scaled clock time, so pausing or changing slows +/// or stops the calendar automatically. Pure read-side, GPU-free and deterministic; registered as +/// a service via . +/// +public sealed class Calendar +{ + private readonly GameClock _clock; + private float _secondsPerDay; + + /// + /// Creates a calendar reading ; one day spans + /// seconds of scaled time (must be positive). + /// + public Calendar(GameClock clock, float secondsPerDay) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + SecondsPerDay = secondsPerDay; + } + + /// Scaled seconds per in-game day. Must be positive; raising it slows the calendar. + public float SecondsPerDay + { + get => _secondsPerDay; + set => + _secondsPerDay = + value > 0f + ? value + : throw new ArgumentOutOfRangeException( + nameof(value), + "Seconds per day must be positive." + ); + } + + /// Total elapsed days as a continuous value (e.g. 3.5 = midday of day 4). + public double TotalDays => _clock.TotalTime / _secondsPerDay; + + /// The current day number, counting from 1. + public int Day => (int)TotalDays + 1; + + /// Progress through the current day in [0, 1) — 0 at dawn, ~0.5 at midday. + public float DayProgress + { + get + { + var days = TotalDays; + return (float)(days - Math.Floor(days)); + } + } +} + +/// Wires the in-game calendar into the engine. +public static class CalendarEngineExtensions +{ + /// + /// Creates a bound to the context's clock and registers it as a + /// service. Call once per world. is the scaled-time length + /// of one in-game day (must be positive). + /// + public static Calendar UseCalendar(this EngineContext context, float secondsPerDay) + { + var calendar = new Calendar(context.Clock, secondsPerDay); + context.Services.Add(calendar); + return calendar; + } +} diff --git a/tests/MrGameEng.Core.Tests/CalendarTests.cs b/tests/MrGameEng.Core.Tests/CalendarTests.cs new file mode 100644 index 0000000..43fcbb2 --- /dev/null +++ b/tests/MrGameEng.Core.Tests/CalendarTests.cs @@ -0,0 +1,69 @@ +using MrGameEng.Core; +using Xunit; + +namespace MrGameEng.Core.Tests; + +public class CalendarTests +{ + [Fact] + public void Constructor_NonPositiveSecondsPerDay_Throws() + { + Assert.Throws(() => new Calendar(new GameClock(), 0f)); + } + + [Fact] + public void TotalDays_TracksScaledClockTime() + { + var clock = new GameClock(); + var calendar = new Calendar(clock, secondsPerDay: 10f); + + clock.Advance(5f); // half a day + + Assert.Equal(0.5, calendar.TotalDays, 5); + Assert.Equal(1, calendar.Day); + Assert.Equal(0.5f, calendar.DayProgress, 5); + } + + [Fact] + public void Day_CountsFromOne_AndRollsOver() + { + var clock = new GameClock(); + var calendar = new Calendar(clock, secondsPerDay: 10f); + + Assert.Equal(1, calendar.Day); + + clock.Advance(10f); // exactly one day + Assert.Equal(2, calendar.Day); + Assert.Equal(0f, calendar.DayProgress, 5); + + clock.Advance(15f); // total 25s = 2.5 days + Assert.Equal(3, calendar.Day); + Assert.Equal(0.5f, calendar.DayProgress, 5); + } + + [Fact] + public void Pause_DoesNotAdvanceTheCalendar() + { + var clock = new GameClock { TimeScale = 0f }; + var calendar = new Calendar(clock, secondsPerDay: 10f); + + clock.Advance(100f); + + Assert.Equal(0.0, calendar.TotalDays, 5); + Assert.Equal(1, calendar.Day); + } + + [Fact] + public void TimeScale_SpeedsUpTheCalendar() + { + var slow = new GameClock(); + var fast = new GameClock { TimeScale = 6f }; + var slowCal = new Calendar(slow, secondsPerDay: 10f); + var fastCal = new Calendar(fast, secondsPerDay: 10f); + + slow.Advance(10f); + fast.Advance(10f); + + Assert.Equal(6.0, fastCal.TotalDays / slowCal.TotalDays, 5); + } +}