diff --git a/CLAUDE.md b/CLAUDE.md index c7c918f..56d5bea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,13 +27,15 @@ 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, plus **Input** (`MrGameEng.Input`: - `InputManager`, `ActionMap`, `InputSystem`, in `Core/Input/`). Depends only on - MonoGame and Friflo.Engine.ECS. +- **`Core`** — game loop, ECS world, scenes, time (`GameClock` with `TimeScale`; `GameSpeed` + for discrete pause/1×/3×/6× speed control over the clock, `context.UseGameSpeed(...)`), + 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`. -- **`Audio`** — ogg playback (NVorbis). → `Core`. +- **`Audio`** — ogg playback (NVorbis); `AudioManager` with `SoundVolume`/`MasterVolume` + (one knob for effects + music). → `Core`. - **`Content`** — the asset/content pipeline: **Assets** (`MrGameEng.Assets`: runtime loading, no MGCB pipeline; FontStash), **Atlases** (`MrGameEng.Atlases`: texture-atlas builder + runtime loader, CLI wrapper in `tools/MrGameEng.AtlasTool`; StbImage), and diff --git a/MrGameEng.sln b/MrGameEng.sln index ededcc5..1345c81 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -37,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Simulation.Tests" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.UI.Tests", "tests\MrGameEng.UI.Tests\MrGameEng.UI.Tests.csproj", "{7F7D9641-2409-40CB-88A9-56BCE8C90A45}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Audio.Tests", "tests\MrGameEng.Audio.Tests\MrGameEng.Audio.Tests.csproj", "{4407F6E6-0B65-41A3-ADFA-B78684A9B918}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -215,6 +217,18 @@ Global {7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x64.Build.0 = Release|Any CPU {7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x86.ActiveCfg = Release|Any CPU {7F7D9641-2409-40CB-88A9-56BCE8C90A45}.Release|x86.Build.0 = Release|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x64.ActiveCfg = Debug|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x64.Build.0 = Debug|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x86.ActiveCfg = Debug|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Debug|x86.Build.0 = Debug|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|Any CPU.Build.0 = Release|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.ActiveCfg = Release|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x64.Build.0 = Release|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.ActiveCfg = Release|Any CPU + {4407F6E6-0B65-41A3-ADFA-B78684A9B918}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -234,5 +248,6 @@ Global {6A47DB40-FD1F-4DD5-A82E-1E526D3E1C39} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {C34F2AFF-F2B0-4AC4-A6F9-B114A06FB272} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {7F7D9641-2409-40CB-88A9-56BCE8C90A45} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {4407F6E6-0B65-41A3-ADFA-B78684A9B918} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index 334bf22..7aec8d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,9 +33,9 @@ | Библиотека (сборка) | Фичи (неймспейсы) и ответственность | |------------------------------|------------------------------------------------------------| -| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время, жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps | +| `MrGameEng.Core` | Игровой цикл (хост над `Game`), `EntityStore`, `SystemRoot`, сцены, время (`GameClock.TimeScale`; `GameSpeed` — дискретная скорость пауза/1×/3×/6× поверх часов), жизненный цикл. **Input** (`MrGameEng.Input`, `Core/Input/`): клавиатура, мышь, геймпад, action maps | | `MrGameEng.Graphics` | Собственный батчер-рендерер (см. «Рендеринг»), камера, спрайты, анимации, слои. **Tilemaps** (`MrGameEng.Tilemaps`, `Graphics/Tilemaps/`): тайловые карты кодом — `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер | -| `MrGameEng.Audio` | Звуковые эффекты и музыка (NVorbis) | +| `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 | | `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг. **DevConsole** (`MrGameEng.DevConsole`): ингейм-консоль — логи `Log`, команды, история, автодополнение | diff --git a/src/MrGameEng.Audio/AudioManager.cs b/src/MrGameEng.Audio/AudioManager.cs index 5fdadf8..df48c4c 100644 --- a/src/MrGameEng.Audio/AudioManager.cs +++ b/src/MrGameEng.Audio/AudioManager.cs @@ -19,7 +19,23 @@ public sealed class AudioManager : IDisposable set => _soundVolume = Math.Clamp(value, 0f, 1f); } + /// + /// Master volume 0..1 applied on top of for sound effects and + /// mirrored onto , so a single knob (e.g. a settings + /// slider) controls both effects and music. + /// + public float MasterVolume + { + get => _masterVolume; + set + { + _masterVolume = Math.Clamp(value, 0f, 1f); + Music.Volume = _masterVolume; + } + } + private float _soundVolume = 1f; + private float _masterVolume = 1f; /// Plays a sound effect (fire and forget). /// The loaded sound effect. @@ -27,7 +43,7 @@ public sealed class AudioManager : IDisposable /// Pitch offset in octaves, -1..1. /// Stereo pan, -1 (left) .. 1 (right). public void Play(SoundEffect sound, float volume = 1f, float pitch = 0f, float pan = 0f) => - sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume, pitch, pan); + sound.Play(Math.Clamp(volume, 0f, 1f) * _soundVolume * _masterVolume, pitch, pan); /// public void Dispose() => Music.Dispose(); diff --git a/src/MrGameEng.Core/GameSpeed.cs b/src/MrGameEng.Core/GameSpeed.cs new file mode 100644 index 0000000..80807cb --- /dev/null +++ b/src/MrGameEng.Core/GameSpeed.cs @@ -0,0 +1,144 @@ +namespace MrGameEng.Core; + +/// +/// Discrete game-speed control layered over : a pause plus an +/// ordered list of speed multipliers (1×, 3×, 6× by default). Pausing remembers the current +/// running step so restores it. fires on every +/// transition so UI (speed buttons, indicators) can refresh. Deterministic and GPU-free; +/// registered as a service via . +/// +public sealed class GameSpeed +{ + private readonly GameClock _clock; + private readonly float[] _steps; + private int _stepIndex; + private bool _paused; + + /// + /// Creates a controller that writes to + /// . are the running multipliers in + /// ascending order; each must be positive. Empty defaults to 1×, 3×, 6×. + /// + public GameSpeed(GameClock clock, params float[] steps) + { + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _steps = steps is { Length: > 0 } ? (float[])steps.Clone() : [1f, 3f, 6f]; + foreach (var step in _steps) + { + if (step <= 0f) + { + throw new ArgumentOutOfRangeException( + nameof(steps), + "Speed steps must be positive." + ); + } + } + + Apply(); + } + + /// The ordered running speeds (excludes the pause state). + public IReadOnlyList Steps => _steps; + + /// Index of the active running step within . + public int StepIndex => _stepIndex; + + /// True while gameplay is paused (clock time scale is 0). + public bool IsPaused => _paused; + + /// Active multiplier: 0 while paused, otherwise Steps[StepIndex]. + public float CurrentSpeed => _paused ? 0f : _steps[_stepIndex]; + + /// Raised after any change to the pause state or the active step. + public event Action? Changed; + + /// Pauses gameplay, remembering the current step for . + public void Pause() + { + if (_paused) + { + return; + } + + _paused = true; + Apply(); + } + + /// Resumes gameplay at the remembered step. + public void Resume() + { + if (!_paused) + { + return; + } + + _paused = false; + Apply(); + } + + /// Toggles between paused and running. + public void TogglePause() + { + _paused = !_paused; + Apply(); + } + + /// Selects a running step by index (clamped to the valid range) and unpauses. + public void SetStep(int index) + { + _stepIndex = Math.Clamp(index, 0, _steps.Length - 1); + _paused = false; + Apply(); + } + + /// Steps to the next faster speed (clamped to the fastest) and unpauses. + public void Faster() => SetStep(_stepIndex + 1); + + /// Steps to the next slower speed (clamped to the slowest) and unpauses. + public void Slower() => SetStep(_stepIndex - 1); + + /// + /// Cycles through states: pause → slowest step → … → fastest step → pause. Handy for a + /// single "next speed" key or button. + /// + public void Cycle() + { + if (_paused) + { + _paused = false; + _stepIndex = 0; + } + else if (_stepIndex + 1 < _steps.Length) + { + _stepIndex++; + } + else + { + _paused = true; + } + + Apply(); + } + + private void Apply() + { + _clock.TimeScale = CurrentSpeed; + Changed?.Invoke(); + } +} + +/// Wires the game-speed controller into the engine. +public static class GameSpeedEngineExtensions +{ + /// + /// Creates a bound to the context's clock and registers it as a + /// service. Call once at startup. are the running multipliers + /// (defaults to 1×, 3×, 6× when empty). + /// + public static GameSpeed UseGameSpeed(this EngineContext context, params float[] steps) + { + var speed = new GameSpeed(context.Clock, steps); + context.Services.Add(speed); + return speed; + } +} diff --git a/tests/MrGameEng.Audio.Tests/AudioManagerTests.cs b/tests/MrGameEng.Audio.Tests/AudioManagerTests.cs new file mode 100644 index 0000000..8d29aa3 --- /dev/null +++ b/tests/MrGameEng.Audio.Tests/AudioManagerTests.cs @@ -0,0 +1,42 @@ +using MrGameEng.Audio; +using Xunit; + +namespace MrGameEng.Audio.Tests; + +public class AudioManagerTests +{ + [Fact] + public void MasterVolume_Default_IsOne() + { + using var audio = new AudioManager(); + + Assert.Equal(1f, audio.MasterVolume); + } + + [Theory] + [InlineData(-0.5f, 0f)] + [InlineData(0.4f, 0.4f)] + [InlineData(1.7f, 1f)] + public void MasterVolume_Clamps_AndMirrorsToMusic(float input, float expected) + { + using var audio = new AudioManager(); + + audio.MasterVolume = input; + + Assert.Equal(expected, audio.MasterVolume); + Assert.Equal(expected, audio.Music.Volume); + } + + [Theory] + [InlineData(-1f, 0f)] + [InlineData(0.25f, 0.25f)] + [InlineData(2f, 1f)] + public void SoundVolume_Clamps(float input, float expected) + { + using var audio = new AudioManager(); + + audio.SoundVolume = input; + + Assert.Equal(expected, audio.SoundVolume); + } +} diff --git a/tests/MrGameEng.Audio.Tests/MrGameEng.Audio.Tests.csproj b/tests/MrGameEng.Audio.Tests/MrGameEng.Audio.Tests.csproj new file mode 100644 index 0000000..2a5c8f0 --- /dev/null +++ b/tests/MrGameEng.Audio.Tests/MrGameEng.Audio.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + Exe + false + + + + + + + + + + + + diff --git a/tests/MrGameEng.Core.Tests/GameSpeedTests.cs b/tests/MrGameEng.Core.Tests/GameSpeedTests.cs new file mode 100644 index 0000000..ac70ade --- /dev/null +++ b/tests/MrGameEng.Core.Tests/GameSpeedTests.cs @@ -0,0 +1,115 @@ +using MrGameEng.Core; +using Xunit; + +namespace MrGameEng.Core.Tests; + +public class GameSpeedTests +{ + [Fact] + public void Constructor_DefaultSteps_RunsAtFirstStep() + { + var clock = new GameClock(); + + var speed = new GameSpeed(clock); + + Assert.Equal(new[] { 1f, 3f, 6f }, speed.Steps); + Assert.False(speed.IsPaused); + Assert.Equal(1f, speed.CurrentSpeed); + Assert.Equal(1f, clock.TimeScale); + } + + [Fact] + public void Constructor_NonPositiveStep_Throws() + { + Assert.Throws(() => new GameSpeed(new GameClock(), 1f, 0f)); + } + + [Fact] + public void Pause_SetsClockToZero_ResumeRestoresStep() + { + var clock = new GameClock(); + var speed = new GameSpeed(clock, 1f, 3f, 6f); + speed.SetStep(2); + + speed.Pause(); + + Assert.True(speed.IsPaused); + Assert.Equal(0f, clock.TimeScale); + Assert.Equal(0f, speed.CurrentSpeed); + + speed.Resume(); + + Assert.False(speed.IsPaused); + Assert.Equal(6f, clock.TimeScale); + Assert.Equal(2, speed.StepIndex); + } + + [Fact] + public void SetStep_OutOfRange_Clamps() + { + var clock = new GameClock(); + var speed = new GameSpeed(clock, 1f, 3f, 6f); + + speed.SetStep(99); + Assert.Equal(2, speed.StepIndex); + Assert.Equal(6f, clock.TimeScale); + + speed.SetStep(-5); + Assert.Equal(0, speed.StepIndex); + Assert.Equal(1f, clock.TimeScale); + } + + [Fact] + public void FasterAndSlower_ClampAtEnds_AndUnpause() + { + var clock = new GameClock(); + var speed = new GameSpeed(clock, 1f, 3f, 6f); + + speed.Faster(); + Assert.Equal(1, speed.StepIndex); + speed.Faster(); + speed.Faster(); // clamps at fastest + Assert.Equal(2, speed.StepIndex); + + speed.Pause(); + speed.Slower(); // unpauses and steps down + Assert.False(speed.IsPaused); + Assert.Equal(1, speed.StepIndex); + } + + [Fact] + public void Cycle_GoesPauseThroughStepsBackToPause() + { + var clock = new GameClock(); + var speed = new GameSpeed(clock, 1f, 3f, 6f); + speed.Pause(); + + speed.Cycle(); // -> step 0 + Assert.False(speed.IsPaused); + Assert.Equal(0, speed.StepIndex); + + speed.Cycle(); // -> step 1 + Assert.Equal(1, speed.StepIndex); + + speed.Cycle(); // -> step 2 (fastest) + Assert.Equal(2, speed.StepIndex); + + speed.Cycle(); // -> pause + Assert.True(speed.IsPaused); + } + + [Fact] + public void Changed_FiresOnTransitions() + { + var clock = new GameClock(); + var speed = new GameSpeed(clock, 1f, 3f, 6f); + var count = 0; + speed.Changed += () => count++; + + speed.Pause(); + speed.Resume(); + speed.Faster(); + + Assert.Equal(3, count); + } +}