From ef1111bcb6144af4d633fc45849c5d48b1693fff Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 08:41:39 +0300 Subject: [PATCH] Enhance audio management and documentation Added a MasterVolume property to the AudioManager for unified control over sound effects and music volume. Updated the Play method to incorporate MasterVolume adjustments. Enhanced documentation in CLAUDE.md and architecture.md to reflect changes in audio management and overall engine architecture. Introduced a new test project for audio functionalities in the solution file. --- CLAUDE.md | 10 +- MrGameEng.sln | 15 ++ docs/architecture.md | 4 +- src/MrGameEng.Audio/AudioManager.cs | 18 ++- src/MrGameEng.Core/GameSpeed.cs | 144 ++++++++++++++++++ .../AudioManagerTests.cs | 42 +++++ .../MrGameEng.Audio.Tests.csproj | 17 +++ tests/MrGameEng.Core.Tests/GameSpeedTests.cs | 115 ++++++++++++++ 8 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 src/MrGameEng.Core/GameSpeed.cs create mode 100644 tests/MrGameEng.Audio.Tests/AudioManagerTests.cs create mode 100644 tests/MrGameEng.Audio.Tests/MrGameEng.Audio.Tests.csproj create mode 100644 tests/MrGameEng.Core.Tests/GameSpeedTests.cs 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); + } +}