From fd6343bd090981ab4b7b529ffeebbdb2740d824d Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Fri, 12 Jun 2026 07:19:10 +0300 Subject: [PATCH] @ Add MrGameEng.AI utility-AI module; format codebase with CSharpier New MrGameEng.AI module (ResponseCurve, Consideration, UtilityAction, UtilityAi selector, Blackboard) plus CSharpier formatting applied across the whole engine. Documents the CSharpier convention in CLAUDE.md. @ --- CLAUDE.md | 8 +- MrGameEng.sln | 30 +++ docs/architecture.md | 29 +++ src/MrGameEng.AI/Blackboard.cs | 50 +++++ src/MrGameEng.AI/Consideration.cs | 56 +++++ src/MrGameEng.AI/MrGameEng.AI.csproj | 9 + src/MrGameEng.AI/ResponseCurve.cs | 115 ++++++++++ src/MrGameEng.AI/UtilityAction.cs | 75 +++++++ src/MrGameEng.AI/UtilityAi.cs | 115 ++++++++++ .../AssetHandlesGenerator.cs | 73 +++++-- .../MrGameEng.Assets.Generator.csproj | 2 - src/MrGameEng.Assets/AssetManager.cs | 25 ++- src/MrGameEng.Assets/AssetRef.cs | 3 +- src/MrGameEng.Assets/MrGameEng.Assets.csproj | 2 - src/MrGameEng.Atlases/AtlasBuilder.cs | 199 +++++++++++++----- .../MrGameEng.Atlases.csproj | 2 - src/MrGameEng.Atlases/ShelfPacker.cs | 58 +++-- src/MrGameEng.Atlases/TextureAtlas.cs | 20 +- src/MrGameEng.Audio/MrGameEng.Audio.csproj | 2 - src/MrGameEng.Audio/MusicPlayer.cs | 9 +- src/MrGameEng.Collisions/Collider.cs | 34 +-- src/MrGameEng.Collisions/CollisionWorld.cs | 31 ++- .../MrGameEng.Collisions.csproj | 2 - src/MrGameEng.Core/EngineContext.cs | 5 +- src/MrGameEng.Core/MrGameEng.Core.csproj | 2 - src/MrGameEng.Core/Scene.cs | 8 +- src/MrGameEng.Core/SceneManager.cs | 21 +- src/MrGameEng.Core/ServiceRegistry.cs | 23 +- src/MrGameEng.Core/Transition.cs | 13 +- src/MrGameEng.DevConsole/DevConsole.cs | 52 +++-- src/MrGameEng.DevConsole/DevConsoleSystems.cs | 40 ++-- .../MrGameEng.DevConsole.csproj | 2 - src/MrGameEng.Graphics/CameraMath.cs | 50 ++++- src/MrGameEng.Graphics/CullingMath.cs | 32 ++- src/MrGameEng.Graphics/Layers.cs | 14 +- .../MrGameEng.Graphics.csproj | 2 - src/MrGameEng.Graphics/RenderSystems.cs | 28 +-- src/MrGameEng.Graphics/Renderer2D.cs | 121 ++++++++--- .../SceneGraphicsExtensions.cs | 10 +- src/MrGameEng.Graphics/SpriteAnimation.cs | 11 +- src/MrGameEng.Graphics/SpriteBatcher.cs | 3 +- src/MrGameEng.Graphics/SpriteSortKey.cs | 4 +- src/MrGameEng.Graphics/Texture2DRegion.cs | 8 +- src/MrGameEng.Input/ActionMap.cs | 51 +++-- src/MrGameEng.Input/InputManager.cs | 53 +++-- src/MrGameEng.Input/MrGameEng.Input.csproj | 2 - src/MrGameEng.Mods/DefDatabase.cs | 64 ++++-- src/MrGameEng.Mods/LanguageManager.cs | 56 +++-- src/MrGameEng.Mods/ModContentTree.cs | 19 +- src/MrGameEng.Mods/ModLoader.cs | 28 ++- src/MrGameEng.Mods/MrGameEng.Mods.csproj | 2 - src/MrGameEng.Pathfinding/FlowField.cs | 17 +- src/MrGameEng.Pathfinding/GridPathfinder.cs | 20 +- .../MrGameEng.Pathfinding.csproj | 2 - .../MrGameEng.Tilemaps.csproj | 2 - .../SceneTilemapExtensions.cs | 11 +- src/MrGameEng.Tilemaps/TileGrid.cs | 4 +- src/MrGameEng.Tilemaps/TileSet.cs | 4 +- src/MrGameEng.Tilemaps/TilemapMath.cs | 12 +- src/MrGameEng.Tilemaps/TilemapRenderSystem.cs | 42 +++- src/MrGameEng.UI/MrGameEng.UI.csproj | 2 - src/MrGameEng.UI/SceneUiExtensions.cs | 2 +- tests/MrGameEng.AI.Tests/BlackboardTests.cs | 64 ++++++ .../MrGameEng.AI.Tests.csproj | 17 ++ .../MrGameEng.AI.Tests/ResponseCurveTests.cs | 75 +++++++ tests/MrGameEng.AI.Tests/UtilityAiTests.cs | 168 +++++++++++++++ .../AssetHandlesGeneratorTests.cs | 77 ++++--- .../MrGameEng.Assets.Generator.Tests.csproj | 2 - .../AtlasBuilderTests.cs | 106 ++++++++-- .../MrGameEng.Atlases.Tests.csproj | 2 - .../ShelfPackerTests.cs | 31 ++- .../CollisionWorldTests.cs | 4 +- .../MrGameEng.Collisions.Tests.csproj | 2 - .../MrGameEng.Core.Tests.csproj | 2 - .../DevConsoleTests.cs | 2 +- .../MrGameEng.DevConsole.Tests.csproj | 2 - .../CameraMathTests.cs | 21 +- .../MrGameEng.Graphics.Tests/CullingTests.cs | 27 ++- .../MrGameEng.Graphics.Tests.csproj | 2 - .../MrGameEng.Graphics.Tests/SortKeyTests.cs | 4 +- .../SpriteAnimationTests.cs | 3 +- .../SpriteBatcherTests.cs | 4 +- .../InputManagerTests.cs | 25 ++- .../MrGameEng.Input.Tests.csproj | 2 - .../MrGameEng.Mods.Tests/DefDatabaseTests.cs | 25 ++- .../LanguageManagerTests.cs | 12 +- tests/MrGameEng.Mods.Tests/ModLoaderTests.cs | 3 +- .../MrGameEng.Mods.Tests.csproj | 2 - .../FlowFieldTests.cs | 22 +- .../GridPathfinderTests.cs | 66 +++--- .../GridResizeTests.cs | 10 +- .../MrGameEng.Pathfinding.Tests.csproj | 2 - .../MrGameEng.Tilemaps.Tests.csproj | 2 - .../MrGameEng.Tilemaps.Tests/TilemapTests.cs | 58 ++++- .../MrGameEng.AtlasTool.csproj | 2 - tools/MrGameEng.AtlasTool/Program.cs | 18 +- 96 files changed, 2066 insertions(+), 589 deletions(-) create mode 100644 src/MrGameEng.AI/Blackboard.cs create mode 100644 src/MrGameEng.AI/Consideration.cs create mode 100644 src/MrGameEng.AI/MrGameEng.AI.csproj create mode 100644 src/MrGameEng.AI/ResponseCurve.cs create mode 100644 src/MrGameEng.AI/UtilityAction.cs create mode 100644 src/MrGameEng.AI/UtilityAi.cs create mode 100644 tests/MrGameEng.AI.Tests/BlackboardTests.cs create mode 100644 tests/MrGameEng.AI.Tests/MrGameEng.AI.Tests.csproj create mode 100644 tests/MrGameEng.AI.Tests/ResponseCurveTests.cs create mode 100644 tests/MrGameEng.AI.Tests/UtilityAiTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 223ac73..e9a48ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,10 @@ pipeline), `Assets.Generator` (Roslyn source generator for typed asset handles), `Atlases` (texture-atlas builder + runtime loader; CLI wrapper in `tools/MrGameEng.AtlasTool`), `Tilemaps` (code-built tile grids rendered through the batcher; `scene.UseTilemaps()` after `UseRenderer2D()`), `Pathfinding` (grid A*/Dijkstra/BFS and flow fields over a -game-implemented `IPathGrid`; Core-only, owns no world data), `Collisions` (`Collider` +game-implemented `IPathGrid`; Core-only, owns no world data), +`AI` (utility-AI primitives — `ResponseCurve`, `Consideration`, `UtilityAction`, +`UtilityAi` selector, and a `Blackboard`; deterministic, generic over a game context, +Core-only, owns no world data), `Collisions` (`Collider` component, spatial hash rebuilt per tick, pairs/queries/raycast; `scene.UseCollisions()` after movement systems), `UI` (Myra integration: `scene.UseUI()` after `UseRenderer2D()`), `DevConsole` (in-game console capturing `Core.Log`; `scene.UseDevConsole()` last in OnLoad), @@ -78,6 +81,9 @@ generator do not load under the SDK 8 compiler); the target framework stays net8 - Nullable reference types enabled, warnings as errors, file-scoped namespaces. - Public engine API requires XML doc comments (English). - Tests: xUnit, named `Method_Scenario_Expectation`. +- Formatting: all C# code is formatted with **CSharpier**. Match its output — + run `csharpier format .` (or let the editor's format-on-save handle it) before + committing; never hand-format against it. ## Memory (echovault MCP) diff --git a/MrGameEng.sln b/MrGameEng.sln index cad90f4..98d5c0d 100644 --- a/MrGameEng.sln +++ b/MrGameEng.sln @@ -57,6 +57,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods", "src\MrGam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.Mods.Tests", "tests\MrGameEng.Mods.Tests\MrGameEng.Mods.Tests.csproj", "{1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AI", "src\MrGameEng.AI\MrGameEng.AI.csproj", "{576BC97D-E7B4-4F5B-B982-C58AA64991B0}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MrGameEng.AI.Tests", "tests\MrGameEng.AI.Tests\MrGameEng.AI.Tests.csproj", "{EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -355,6 +359,30 @@ Global {1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x64.Build.0 = Release|Any CPU {1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x86.ActiveCfg = Release|Any CPU {1C0BBFAC-6A4F-4541-8703-E9C77F637BAF}.Release|x86.Build.0 = Release|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Debug|x64.ActiveCfg = Debug|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Debug|x64.Build.0 = Debug|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Debug|x86.ActiveCfg = Debug|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Debug|x86.Build.0 = Debug|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Release|Any CPU.Build.0 = Release|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Release|x64.ActiveCfg = Release|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Release|x64.Build.0 = Release|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Release|x86.ActiveCfg = Release|Any CPU + {576BC97D-E7B4-4F5B-B982-C58AA64991B0}.Release|x86.Build.0 = Release|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Debug|x64.ActiveCfg = Debug|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Debug|x64.Build.0 = Debug|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Debug|x86.ActiveCfg = Debug|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Debug|x86.Build.0 = Debug|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Release|Any CPU.Build.0 = Release|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Release|x64.ActiveCfg = Release|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Release|x64.Build.0 = Release|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Release|x86.ActiveCfg = Release|Any CPU + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -384,5 +412,7 @@ Global {B8C132F5-C4C8-4931-B0CE-885811F44DB0} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {1EB7D51B-CF32-4EB3-881B-C20AED6ED592} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {1C0BBFAC-6A4F-4541-8703-E9C77F637BAF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {576BC97D-E7B4-4F5B-B982-C58AA64991B0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {EF6711F8-6129-4719-B39A-CB1CF7CC2C4E} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/docs/architecture.md b/docs/architecture.md index 37aa90d..7e2c23d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,6 +38,7 @@ | `MrGameEng.Atlases` | Текстурные атласы: офлайн-сборка из дерева картинок (`AtlasBuilder`) и рантайм-загрузка (`TextureAtlas`); CLI — `tools/MrGameEng.AtlasTool` | | `MrGameEng.Tilemaps` | Тайловые карты, создаваемые кодом: `TileGrid` + `TileSet` + компонент `Tilemap`, отрисовка видимых клеток через батчер | | `MrGameEng.Pathfinding` | Поиск пути по гриду: A*, Dijkstra, BFS и flow fields для толп; чистая логика без зависимостей | +| `MrGameEng.AI` | Примитивы utility-ИИ: кривые отклика, соображения, действия и выбор (`UtilityAi`), `Blackboard`; детерминированно, generic по контексту игры, без данных мира | | `MrGameEng.Collisions` | Определение столкновений: компонент `Collider`, spatial hash, пары/запросы/raycast | | `MrGameEng.UI` | Игровой UI на [Myra](https://github.com/rds1983/Myra): `Desktop` на сцену, виджеты, скининг | | `MrGameEng.DevConsole` | Ингейм-консоль разработчика: логи `Log`, команды, история, автодополнение | @@ -226,6 +227,34 @@ CLI-обёртка: `dotnet run --project tools/MrGameEng.AtlasTool -- <исто инвалидируются generation-штампом — повторные запросы не аллоцируют и не чистят массивы. Один экземпляр на систему; результаты детерминированы. +## ИИ агентов (utility) + +`MrGameEng.AI` — примитивы для принятия решений агентами (жители LittleSim). +Зависит только от Core, **не владеет данными мира** и не привязан к ECS: всё +параметризовано контекстом `TContext`, который игра передаёт сама (снимок восприятия, +хендл сущности, blackboard — что угодно). Основан на Infinite-Axis Utility System: + +- `ResponseCurve` (value type) — кривая отклика, нормализованный вход `[0,1]` → + полезность `[0,1]`: `Linear`, `Polynomial` (степень), `Logistic` (S-кривая), + `SmoothStep`. Вход и выход клампятся. **Внимание:** `default(ResponseCurve)` имеет + нулевой наклон (всегда 0) — для тождества используйте `ResponseCurve.Identity`. +- `Consideration` — одно соображение: читает сырое значение из контекста, + нормирует по диапазону `[min,max]` и прогоняет через кривую. +- `UtilityAction` — действие из набора соображений. Очки = произведение + соображений × `Weight`; любой ноль ветирует действие. Компенсирующий множитель + (make-up value) убирает смещение произведения многих факторов вниз. +- `UtilityAi` — reasoner: `Select` (детерминированно лучшее действие, при + равенстве — первое) и `SelectWeighted(random)` (рулетка по очкам для разнообразия, + воспроизводимо при seed). Очки пишутся в переиспользуемый буфер — повторные + вычисления не аллоцируют; один экземпляр на вид агента, не потокобезопасен. +- `Blackboard` — типизированная рабочая память агента (`Set`/`TryGet`/`GetOrDefault`) + для холодных путей (восприятие, планирование). + +Витрина в LittleSim: `PawnDecisionSystem` выбирает «бродить/отдыхать» по энергии +жителя, `PawnNeedsSystem` тратит/восстанавливает энергию, уставшие темнеют +(`PawnAppearanceSystem`). Команда консоли `ai [energy]` печатает очки и расклад +отдыхающих/блуждающих. + ## Коллизии `MrGameEng.Collisions` — определение столкновений (без разрешения физики — она в бэклоге): diff --git a/src/MrGameEng.AI/Blackboard.cs b/src/MrGameEng.AI/Blackboard.cs new file mode 100644 index 0000000..62ac75c --- /dev/null +++ b/src/MrGameEng.AI/Blackboard.cs @@ -0,0 +1,50 @@ +namespace MrGameEng.AI; + +/// +/// A small typed key/value store for an agent's working memory: perceived facts, a current target, a +/// cached path goal — whatever the considerations and actions need to share without being threaded +/// through method signatures. Keys are case-sensitive strings; values are stored boxed, so the +/// blackboard is a convenience for cold paths (perception, planning), not the per-frame hot loop. +/// +public sealed class Blackboard +{ + private readonly Dictionary _values = new(StringComparer.Ordinal); + + /// The number of keys currently stored. + public int Count => _values.Count; + + /// Stores under , replacing any existing entry. + public void Set(string key, T value) => _values[key] = value; + + /// + /// Reads the value under as . Returns false when + /// the key is missing or holds a value of a different type. + /// + public bool TryGet(string key, out T value) + { + if (_values.TryGetValue(key, out var stored) && stored is T typed) + { + value = typed; + return true; + } + + value = default!; + return false; + } + + /// + /// Reads the value under , or returns when the key is + /// missing or holds a different type. + /// + public T GetOrDefault(string key, T fallback = default!) => + TryGet(key, out var value) ? value : fallback; + + /// True when has a value (of any type). + public bool Has(string key) => _values.ContainsKey(key); + + /// Removes . Returns true when it was present. + public bool Remove(string key) => _values.Remove(key); + + /// Drops every stored value. + public void Clear() => _values.Clear(); +} diff --git a/src/MrGameEng.AI/Consideration.cs b/src/MrGameEng.AI/Consideration.cs new file mode 100644 index 0000000..3bee5ed --- /dev/null +++ b/src/MrGameEng.AI/Consideration.cs @@ -0,0 +1,56 @@ +namespace MrGameEng.AI; + +/// +/// One input to a utility decision. It reads a raw value from the agent's context, normalizes it to +/// [0,1] against an expected range, and shapes it through a into a +/// utility score. Considerations are stateless and reusable: the context carries everything that +/// varies. is whatever the game passes in — a struct of perceived +/// values, an entity handle, a blackboard — the AI module never owns it. +/// +public sealed class Consideration +{ + private readonly Func _input; + private readonly float _min; + private readonly float _inverseSpan; + private readonly ResponseCurve _curve; + + /// + /// Creates a consideration named that reads from the + /// context, normalizes it from [, ] to [0,1] + /// (values outside the range clamp to the ends), then applies . + /// + /// is not greater than . + public Consideration( + string name, + Func input, + float min = 0f, + float max = 1f, + ResponseCurve? curve = null + ) + { + if (max <= min) + { + throw new ArgumentException( + $"max ({max}) must be greater than min ({min}).", + nameof(max) + ); + } + + Name = name; + _input = input ?? throw new ArgumentNullException(nameof(input)); + _min = min; + _inverseSpan = 1f / (max - min); + // default(ResponseCurve) has slope 0 (always 0), so omitting the curve means the identity. + _curve = curve ?? ResponseCurve.Identity; + } + + /// A human-readable label, surfaced in debug/console output. + public string Name { get; } + + /// Reads the context and returns this consideration's utility in [0,1]. + public float Score(TContext context) + { + var normalized = Math.Clamp((_input(context) - _min) * _inverseSpan, 0f, 1f); + return _curve.Evaluate(normalized); + } +} diff --git a/src/MrGameEng.AI/MrGameEng.AI.csproj b/src/MrGameEng.AI/MrGameEng.AI.csproj new file mode 100644 index 0000000..2586393 --- /dev/null +++ b/src/MrGameEng.AI/MrGameEng.AI.csproj @@ -0,0 +1,9 @@ + + + net8.0 + + + + + + diff --git a/src/MrGameEng.AI/ResponseCurve.cs b/src/MrGameEng.AI/ResponseCurve.cs new file mode 100644 index 0000000..256bd22 --- /dev/null +++ b/src/MrGameEng.AI/ResponseCurve.cs @@ -0,0 +1,115 @@ +namespace MrGameEng.AI; + +/// Shape of a mapping a normalized input to a utility. +public enum CurveType +{ + /// Straight line: y = slope·(x − xShift) + yShift. + Linear, + + /// Power curve: y = slope·(x − xShift)^exponent + yShift; the exponent eases in/out. + Polynomial, + + /// S-shaped logistic centred on xShift; exponent is the steepness. + Logistic, + + /// Hermite smoothstep over [xShift, xShift + 1/slope]; flat ends, smooth middle. + SmoothStep, +} + +/// +/// Maps a normalized input in [0,1] to a utility in [0,1] through one of a few +/// shapes. The input is clamped before evaluation and the output is clamped after, so a curve is +/// always safe to feed a raw normalized value. Curves are +/// immutable value types — build them once and reuse them across evaluations. +/// +public readonly struct ResponseCurve +{ + /// The shape applied by . + public CurveType Type { get; } + + /// Vertical scale / steepness (the m term). See per shape. + public float Slope { get; } + + /// Power for and steepness for . + public float Exponent { get; } + + /// Horizontal shift of the curve (the c term): the input value mapped to the origin. + public float XShift { get; } + + /// Vertical shift of the curve (the b term) added after scaling. + public float YShift { get; } + + /// + /// Builds a curve from raw parameters. Prefer the named factories + /// (, , , ) + /// which document the meaning of each term for their shape. + /// + public ResponseCurve( + CurveType type, + float slope = 1f, + float exponent = 1f, + float xShift = 0f, + float yShift = 0f + ) + { + Type = type; + Slope = slope; + Exponent = exponent; + XShift = xShift; + YShift = yShift; + } + + /// The identity curve: y = x. The default when a consideration needs no shaping. + public static ResponseCurve Identity => new(CurveType.Linear); + + /// Straight line y = slope·(x − xShift) + yShift. A negative slope inverts the input. + public static ResponseCurve Linear(float slope = 1f, float xShift = 0f, float yShift = 0f) => + new(CurveType.Linear, slope, 1f, xShift, yShift); + + /// + /// Power curve y = slope·(x − xShift)^exponent + yShift. An exponent above 1 eases in + /// (slow start), below 1 eases out (fast start). Quadratic is exponent = 2. + /// + public static ResponseCurve Polynomial( + float exponent, + float slope = 1f, + float xShift = 0f, + float yShift = 0f + ) => new(CurveType.Polynomial, slope, exponent, xShift, yShift); + + /// + /// Logistic S-curve centred on ; controls how + /// sharp the transition is (≈10 gives a soft threshold, larger is more switch-like). + /// + public static ResponseCurve Logistic(float steepness = 10f, float midpoint = 0.5f) => + new(CurveType.Logistic, 1f, steepness, midpoint); + + /// + /// Hermite smoothstep rising from 0 to 1 over [xShift, xShift + 1/slope]: flat below the + /// start, flat above the end, smooth in between. Default rises across the whole [0,1] range. + /// + public static ResponseCurve SmoothStep(float slope = 1f, float xShift = 0f) => + new(CurveType.SmoothStep, slope, 1f, xShift); + + /// Evaluates the curve. is clamped to [0,1]; the result is clamped to [0,1]. + public float Evaluate(float x) + { + x = Math.Clamp(x, 0f, 1f); + var y = Type switch + { + CurveType.Linear => Slope * (x - XShift) + YShift, + CurveType.Polynomial => Slope * MathF.Pow(x - XShift, Exponent) + YShift, + CurveType.Logistic => 1f / (1f + MathF.Exp(-Exponent * (x - XShift))) * Slope + YShift, + CurveType.SmoothStep => SmoothStepValue(x), + _ => x, + }; + + return Math.Clamp(y, 0f, 1f); + } + + private float SmoothStepValue(float x) + { + var t = Math.Clamp((x - XShift) * Slope, 0f, 1f); + return t * t * (3f - 2f * t) + YShift; + } +} diff --git a/src/MrGameEng.AI/UtilityAction.cs b/src/MrGameEng.AI/UtilityAction.cs new file mode 100644 index 0000000..9050aeb --- /dev/null +++ b/src/MrGameEng.AI/UtilityAction.cs @@ -0,0 +1,75 @@ +namespace MrGameEng.AI; + +/// +/// A candidate action scored by a set of s. Its score is the +/// product of every consideration (each in [0,1]) times , so any single +/// veto (a 0) drops the action out of contention. Multiplying many factors biases the result low, so +/// a compensation factor scales it back up in proportion to how many considerations contributed — +/// the "make-up value" from Dave Mark's Infinite-Axis Utility System. +/// +public sealed class UtilityAction +{ + private readonly Consideration[] _considerations; + + /// + /// Creates an action named with a base (a static + /// priority multiplier; 1 is neutral) and the considerations that score it for a given context. + /// + public UtilityAction(string name, float weight, params Consideration[] considerations) + { + Name = name; + Weight = weight; + _considerations = considerations ?? []; + } + + /// Creates an action with neutral weight (1). + public UtilityAction(string name, params Consideration[] considerations) + : this(name, 1f, considerations) { } + + /// A human-readable label, surfaced in debug/console output. + public string Name { get; } + + /// Static priority multiplier applied to the product of considerations. + public float Weight { get; } + + /// The considerations scoring this action, in evaluation order. + public IReadOnlyList> Considerations => _considerations; + + /// Scores this action for . Higher wins; a 0 consideration vetoes it. + public float Score(TContext context) + { + var count = _considerations.Length; + if (count == 0) + { + return Math.Max(0f, Weight); + } + + var product = Weight; + for (var i = 0; i < count; i++) + { + var score = _considerations[i].Score(context); + if (score <= 0f) + { + return 0f; // veto: no point evaluating the rest + } + + product *= score; + } + + return Math.Max(0f, Compensate(product, count)); + } + + // Counteracts the downward bias of multiplying N factors in [0,1]: the more considerations + // contribute, the more the product is nudged back toward its un-multiplied magnitude. + private static float Compensate(float product, int count) + { + if (count <= 1) + { + return product; + } + + var modificationFactor = 1f - 1f / count; + var makeUp = (1f - product) * modificationFactor; + return product + makeUp * product; + } +} diff --git a/src/MrGameEng.AI/UtilityAi.cs b/src/MrGameEng.AI/UtilityAi.cs new file mode 100644 index 0000000..02bb855 --- /dev/null +++ b/src/MrGameEng.AI/UtilityAi.cs @@ -0,0 +1,115 @@ +namespace MrGameEng.AI; + +/// +/// A utility reasoner over a fixed set of s. Each evaluation +/// scores every action for the given context and picks one. Scoring writes into a buffer owned by the +/// reasoner, so repeated evaluations allocate nothing; keep one instance per agent kind (or one per +/// system, reused across agents) and pass each agent's context in. Not thread-safe: the score buffer +/// is shared between calls, so a single instance must not be evaluated from two threads at once. +/// +public sealed class UtilityAi +{ + private readonly UtilityAction[] _actions; + private readonly float[] _scores; + + /// Creates a reasoner choosing between (at least one required). + /// is empty. + public UtilityAi(params UtilityAction[] actions) + { + if (actions is null || actions.Length == 0) + { + throw new ArgumentException("A UtilityAi needs at least one action.", nameof(actions)); + } + + _actions = actions; + _scores = new float[actions.Length]; + } + + /// The actions this reasoner chooses between, in evaluation order. + public IReadOnlyList> Actions => _actions; + + /// + /// The scores from the most recent / call, aligned + /// with . Useful for debug overlays and console dumps. + /// + public ReadOnlySpan LastScores => _scores; + + /// + /// Scores every action for and returns the highest, or null when no + /// action scores strictly above . Ties resolve to the earliest action, + /// so selection is fully deterministic for identical inputs. + /// + public UtilityAction? Select(TContext context, float threshold = 0f) + { + var best = -1; + var bestScore = threshold; + for (var i = 0; i < _actions.Length; i++) + { + var score = _actions[i].Score(context); + _scores[i] = score; + if (score > bestScore) + { + bestScore = score; + best = i; + } + } + + return best >= 0 ? _actions[best] : null; + } + + /// + /// Scores every action and picks one at random in proportion to its score (roulette selection over + /// the actions above ), giving believable variety while staying + /// deterministic for a given sequence. Returns null when nothing + /// qualifies. Pass a seeded owned by the calling system — never + /// — to keep the simulation reproducible. + /// + public UtilityAction? SelectWeighted( + TContext context, + Random random, + float threshold = 0f + ) + { + var total = 0f; + for (var i = 0; i < _actions.Length; i++) + { + var score = _actions[i].Score(context); + _scores[i] = score; + if (score > threshold) + { + total += score; + } + } + + if (total <= 0f) + { + return null; + } + + var roll = (float)random.NextDouble() * total; + for (var i = 0; i < _actions.Length; i++) + { + if (_scores[i] <= threshold) + { + continue; + } + + roll -= _scores[i]; + if (roll <= 0f) + { + return _actions[i]; + } + } + + // Floating-point slack can leave roll just above 0; fall back to the last qualifying action. + for (var i = _actions.Length - 1; i >= 0; i--) + { + if (_scores[i] > threshold) + { + return _actions[i]; + } + } + + return null; + } +} diff --git a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs index 3c41adc..8283ffb 100644 --- a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs +++ b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs @@ -15,7 +15,9 @@ namespace MrGameEng.Assets.Generator; [Generator] public sealed class AssetHandlesGenerator : IIncrementalGenerator { - private static readonly Dictionary TypeByExtension = new(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary TypeByExtension = new( + StringComparer.OrdinalIgnoreCase + ) { [".png"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D", [".jpg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D", @@ -32,29 +34,46 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator /// public void Initialize(IncrementalGeneratorInitializationContext context) { - var options = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) => - { - provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns); - provider.GlobalOptions.TryGetValue("build_property.MrGameEngAssetsClassName", out var className); - return ( - Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!, - ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!); - }); + var options = context.AnalyzerConfigOptionsProvider.Select( + static (provider, _) => + { + provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns); + provider.GlobalOptions.TryGetValue( + "build_property.MrGameEngAssetsClassName", + out var className + ); + return ( + Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!, + ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className! + ); + } + ); - var projectDir = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) => - { - provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir); - return dir ?? string.Empty; - }); + var projectDir = context.AnalyzerConfigOptionsProvider.Select( + static (provider, _) => + { + provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir); + return dir ?? string.Empty; + } + ); - var assets = context.AdditionalTextsProvider - .Combine(projectDir) + var assets = context + .AdditionalTextsProvider.Combine(projectDir) .Select(static (pair, _) => ToAssetPath(pair.Left.Path, pair.Right)) .Where(static path => path is not null) .Collect(); - context.RegisterSourceOutput(assets.Combine(options), static (production, input) => - production.AddSource("GameAssets.g.cs", SourceText.From(Emit(input.Left!, input.Right.Namespace, input.Right.ClassName), Encoding.UTF8))); + context.RegisterSourceOutput( + assets.Combine(options), + static (production, input) => + production.AddSource( + "GameAssets.g.cs", + SourceText.From( + Emit(input.Left!, input.Right.Namespace, input.Right.ClassName), + Encoding.UTF8 + ) + ) + ); } /// @@ -69,8 +88,13 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator var normalized = fullPath.Replace('\\', '/'); string relative; - var root = string.IsNullOrEmpty(projectDir) ? null : projectDir!.Replace('\\', '/').TrimEnd('/'); - if (root is not null && normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase)) + var root = string.IsNullOrEmpty(projectDir) + ? null + : projectDir!.Replace('\\', '/').TrimEnd('/'); + if ( + root is not null + && normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase) + ) { relative = normalized.Substring(root.Length + "/Assets/".Length); } @@ -115,7 +139,9 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator source.AppendLine("// "); source.AppendLine($"namespace {ns};"); source.AppendLine(); - source.AppendLine("/// Typed handles for every file under the Assets directory."); + source.AppendLine( + "/// Typed handles for every file under the Assets directory." + ); source.AppendLine($"public static partial class {className}"); source.AppendLine("{"); EmitNode(source, root, indent: 1, enclosingName: className); @@ -135,8 +161,9 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName))); source.AppendLine($"{pad}/// {XmlEscape(relativePath)}"); source.AppendLine( - $"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = " + - $"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});"); + $"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = " + + $"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});" + ); } foreach (var pair in node.Children) diff --git a/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj b/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj index 24ea496..d12cb15 100644 --- a/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj +++ b/src/MrGameEng.Assets.Generator/MrGameEng.Assets.Generator.csproj @@ -1,5 +1,4 @@ - netstandard2.0 true @@ -14,5 +13,4 @@ - diff --git a/src/MrGameEng.Assets/AssetManager.cs b/src/MrGameEng.Assets/AssetManager.cs index 8cd0c19..7bfd713 100644 --- a/src/MrGameEng.Assets/AssetManager.cs +++ b/src/MrGameEng.Assets/AssetManager.cs @@ -39,7 +39,8 @@ public sealed class AssetManager : IDisposable } /// Loads (or returns the cached) asset for . - public T Load(AssetRef asset) where T : class + public T Load(AssetRef asset) + where T : class { var key = (typeof(T), asset.Path); if (_cache.TryGetValue(key, out var cached)) @@ -49,13 +50,18 @@ public sealed class AssetManager : IDisposable if (!_loaders.TryGetValue(typeof(T), out var loader)) { - throw new InvalidOperationException($"No asset loader registered for type {typeof(T)}."); + throw new InvalidOperationException( + $"No asset loader registered for type {typeof(T)}." + ); } var fullPath = ResolvePath(asset.Path); if (!File.Exists(fullPath)) { - throw new FileNotFoundException($"Asset '{asset.Path}' not found at '{fullPath}'.", fullPath); + throw new FileNotFoundException( + $"Asset '{asset.Path}' not found at '{fullPath}'.", + fullPath + ); } var loaded = (T)loader(this, fullPath); @@ -64,7 +70,8 @@ public sealed class AssetManager : IDisposable } /// Removes one asset from the cache, disposing it if disposable. - public void Unload(AssetRef asset) where T : class + public void Unload(AssetRef asset) + where T : class { var key = (typeof(T), asset.Path); if (_cache.Remove(key, out var value) && value is IDisposable disposable) @@ -74,8 +81,8 @@ public sealed class AssetManager : IDisposable } /// Replaces or adds the loader used for assets of type . - public void RegisterLoader(Func loader) where T : class => - _loaders[typeof(T)] = loader; + public void RegisterLoader(Func loader) + where T : class => _loaders[typeof(T)] = loader; /// Resolves an asset-relative path to an absolute file path. public string ResolvePath(string relativePath) => @@ -95,7 +102,11 @@ public sealed class AssetManager : IDisposable private static Texture2D LoadTexture(EngineContext context, string path) { using var stream = File.OpenRead(path); - return Texture2D.FromStream(context.GraphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha); + return Texture2D.FromStream( + context.GraphicsDevice, + stream, + DefaultColorProcessors.PremultiplyAlpha + ); } private static SoundEffect LoadSoundEffect(string path) diff --git a/src/MrGameEng.Assets/AssetRef.cs b/src/MrGameEng.Assets/AssetRef.cs index 0b3eb77..8aa7e15 100644 --- a/src/MrGameEng.Assets/AssetRef.cs +++ b/src/MrGameEng.Assets/AssetRef.cs @@ -7,7 +7,8 @@ namespace MrGameEng.Assets; /// /// Runtime type the asset loads into (e.g. Texture2D). /// Path relative to the asset root, with forward slashes. -public readonly record struct AssetRef(string Path) where T : class +public readonly record struct AssetRef(string Path) + where T : class { /// public override string ToString() => $"{typeof(T).Name}:{Path}"; diff --git a/src/MrGameEng.Assets/MrGameEng.Assets.csproj b/src/MrGameEng.Assets/MrGameEng.Assets.csproj index ed6475c..09a87b8 100644 --- a/src/MrGameEng.Assets/MrGameEng.Assets.csproj +++ b/src/MrGameEng.Assets/MrGameEng.Assets.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -11,5 +10,4 @@ - diff --git a/src/MrGameEng.Atlases/AtlasBuilder.cs b/src/MrGameEng.Atlases/AtlasBuilder.cs index c54bc51..6ecd0e5 100644 --- a/src/MrGameEng.Atlases/AtlasBuilder.cs +++ b/src/MrGameEng.Atlases/AtlasBuilder.cs @@ -44,7 +44,10 @@ public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCoun /// Result of an run. /// Per-atlas outcomes, sorted by name. /// Output files of atlases whose source group no longer exists. -public sealed record AtlasBuildResult(IReadOnlyList Groups, IReadOnlyList DeletedOrphans); +public sealed record AtlasBuildResult( + IReadOnlyList Groups, + IReadOnlyList DeletedOrphans +); /// /// Build-time utility converting a directory tree of loose images into texture atlases: @@ -58,7 +61,12 @@ public static class AtlasBuilder private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"]; /// Snapshot of one source image taken at scan time (size/mtime feed the staleness check). - private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks); + private readonly record struct SourceFile( + string FullPath, + string Key, + long Size, + long ModifiedTicks + ); /// Builds (or incrementally refreshes) all atlases from . public static AtlasBuildResult Build(AtlasBuildOptions options) @@ -71,12 +79,17 @@ public static class AtlasBuilder var sourceRoot = Path.GetFullPath(options.SourceDirectory); if (!Directory.Exists(sourceRoot)) { - throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'."); + throw new DirectoryNotFoundException( + $"Atlas source directory not found: '{sourceRoot}'." + ); } - return Build(options, Directory - .EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories) - .Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath)))); + return Build( + options, + Directory + .EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories) + .Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath))) + ); } /// @@ -85,7 +98,9 @@ public static class AtlasBuilder /// in different roots. Region keys come from RelativePath without extension. /// public static AtlasBuildResult Build( - AtlasBuildOptions options, IEnumerable<(string FullPath, string RelativePath)> sources) + AtlasBuildOptions options, + IEnumerable<(string FullPath, string RelativePath)> sources + ) { Directory.CreateDirectory(options.OutputDirectory); @@ -101,7 +116,11 @@ public static class AtlasBuilder } /// Maps a source-relative image path to its atlas name and region key. - internal static (string AtlasName, string Key) ClassifyPath(string relativePath, int groupDepth, string rootAtlasName) + internal static (string AtlasName, string Key) ClassifyPath( + string relativePath, + int groupDepth, + string rootAtlasName + ) { var normalized = relativePath.Replace('\\', '/'); var key = normalized[..normalized.LastIndexOf('.')]; @@ -112,21 +131,34 @@ public static class AtlasBuilder } private static SortedDictionary> ScanGroups( - IEnumerable<(string FullPath, string RelativePath)> sources, AtlasBuildOptions options) + IEnumerable<(string FullPath, string RelativePath)> sources, + AtlasBuildOptions options + ) { var groups = new SortedDictionary>(StringComparer.Ordinal); var keys = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (fullPath, relative) in sources) { - if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase)) + if ( + !SourceExtensions.Contains( + Path.GetExtension(fullPath), + StringComparer.OrdinalIgnoreCase + ) + ) { continue; } - var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName); + var (atlasName, key) = ClassifyPath( + relative, + options.GroupDepth, + options.RootAtlasName + ); if (keys.TryGetValue(key, out var existing)) { - throw new InvalidDataException($"Duplicate region key '{key}': '{existing}' and '{relative}'."); + throw new InvalidDataException( + $"Duplicate region key '{key}': '{existing}' and '{relative}'." + ); } keys.Add(key, relative); @@ -144,7 +176,10 @@ public static class AtlasBuilder } private static AtlasGroupResult BuildGroup( - string name, List files, AtlasBuildOptions options) + string name, + List files, + AtlasBuildOptions options + ) { var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas"); if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages)) @@ -154,11 +189,18 @@ public static class AtlasBuilder // Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы). var images = new ImageResult[files.Count]; - Parallel.For(0, files.Count, i => - { - using var stream = File.OpenRead(files[i].FullPath); - images[i] = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha); - }); + Parallel.For( + 0, + files.Count, + i => + { + using var stream = File.OpenRead(files[i].FullPath); + images[i] = ImageResult.FromStream( + stream, + StbImageSharp.ColorComponents.RedGreenBlueAlpha + ); + } + ); var items = new PackItem[files.Count]; for (var i = 0; i < files.Count; i++) @@ -182,7 +224,11 @@ public static class AtlasBuilder } private static bool IsUpToDate( - string metadataPath, List files, AtlasBuildOptions options, out int pages) + string metadataPath, + List files, + AtlasBuildOptions options, + out int pages + ) { pages = 0; if (!File.Exists(metadataPath)) @@ -200,8 +246,11 @@ public static class AtlasBuilder return false; } - if (metadata.Version != AtlasMetadata.CurrentVersion || - metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding) + if ( + metadata.Version != AtlasMetadata.CurrentVersion + || metadata.PageSize != options.MaxPageSize + || metadata.Padding != options.Padding + ) { return false; } @@ -222,8 +271,11 @@ public static class AtlasBuilder var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal); foreach (var file in files) { - if (!sourcesByKey.TryGetValue(file.Key, out var source) || - source.Size != file.Size || source.Modified != file.ModifiedTicks) + if ( + !sourcesByKey.TryGetValue(file.Key, out var source) + || source.Size != file.Size + || source.Modified != file.ModifiedTicks + ) { return false; } @@ -234,37 +286,60 @@ public static class AtlasBuilder } private static void WritePages( - string name, PackResult packed, Dictionary pixelsByKey, string outputDirectory) + string name, + PackResult packed, + Dictionary pixelsByKey, + string outputDirectory + ) { - Parallel.For(0, packed.PageSizes.Count, page => - { - var (width, height) = packed.PageSizes[page]; - var buffer = new byte[width * height * 4]; - foreach (var placement in packed.Placements) + Parallel.For( + 0, + packed.PageSizes.Count, + page => { - if (placement.Page != page) + var (width, height) = packed.PageSizes[page]; + var buffer = new byte[width * height * 4]; + foreach (var placement in packed.Placements) { - continue; + if (placement.Page != page) + { + continue; + } + + var source = pixelsByKey[placement.Key]; + for (var row = 0; row < source.Height; row++) + { + Array.Copy( + source.Data, + row * source.Width * 4, + buffer, + ((placement.Y + row) * width + placement.X) * 4, + source.Width * 4 + ); + } } - var source = pixelsByKey[placement.Key]; - for (var row = 0; row < source.Height; row++) - { - Array.Copy( - source.Data, row * source.Width * 4, - buffer, ((placement.Y + row) * width + placement.X) * 4, - source.Width * 4); - } + using var stream = File.Create( + Path.Combine(outputDirectory, PageFileName(name, page)) + ); + new ImageWriter().WritePng( + buffer, + width, + height, + StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, + stream + ); } - - using var stream = File.Create(Path.Combine(outputDirectory, PageFileName(name, page))); - new ImageWriter().WritePng( - buffer, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream); - }); + ); } private static void WriteMetadata( - string name, PackResult packed, List files, AtlasBuildOptions options, string metadataPath) + string name, + PackResult packed, + List files, + AtlasBuildOptions options, + string metadataPath + ) { var metadata = new AtlasMetadata { @@ -273,18 +348,26 @@ public static class AtlasBuilder Padding = options.Padding, Sources = files .OrderBy(f => f.Key, StringComparer.Ordinal) - .Select(f => new AtlasSource { Key = f.Key, Size = f.Size, Modified = f.ModifiedTicks }) - .ToList(), - Pages = packed.PageSizes - .Select((size, index) => new AtlasPage + .Select(f => new AtlasSource { - File = PageFileName(name, index), - Width = size.Width, - Height = size.Height, + Key = f.Key, + Size = f.Size, + Modified = f.ModifiedTicks, }) .ToList(), - Regions = packed.Placements - .OrderBy(p => p.Key, StringComparer.Ordinal) + Pages = packed + .PageSizes.Select( + (size, index) => + new AtlasPage + { + File = PageFileName(name, index), + Width = size.Width, + Height = size.Height, + } + ) + .ToList(), + Regions = packed + .Placements.OrderBy(p => p.Key, StringComparer.Ordinal) .Select(p => new AtlasRegion { Key = p.Key, @@ -300,7 +383,8 @@ public static class AtlasBuilder File.WriteAllText(metadataPath, metadata.ToJson()); } - private static string PageFileName(string atlasName, int page) => $"{atlasName}.atlas.{page}.png"; + private static string PageFileName(string atlasName, int page) => + $"{atlasName}.atlas.{page}.png"; private static void DeleteExtraPages(string name, int pageCount, string outputDirectory) { @@ -316,7 +400,10 @@ public static class AtlasBuilder } } - private static List DeleteOrphans(string outputDirectory, IEnumerable liveAtlasNames) + private static List DeleteOrphans( + string outputDirectory, + IEnumerable liveAtlasNames + ) { var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal); var deleted = new List(); diff --git a/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj b/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj index 3a75bc7..e9b62a3 100644 --- a/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj +++ b/src/MrGameEng.Atlases/MrGameEng.Atlases.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -18,5 +17,4 @@ - diff --git a/src/MrGameEng.Atlases/ShelfPacker.cs b/src/MrGameEng.Atlases/ShelfPacker.cs index e3f45b1..e316e3d 100644 --- a/src/MrGameEng.Atlases/ShelfPacker.cs +++ b/src/MrGameEng.Atlases/ShelfPacker.cs @@ -13,7 +13,14 @@ public readonly record struct PackItem(string Key, int Width, int Height); /// Y position in page pixels. /// Item width in pixels. /// Item height in pixels. -public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height); +public readonly record struct PackPlacement( + string Key, + int Page, + int X, + int Y, + int Width, + int Height +); /// Result of a packing run: placements plus the trimmed size of every page. /// One placement per input item. @@ -21,7 +28,10 @@ public readonly record struct PackPlacement(string Key, int Page, int X, int Y, /// Width/height of each page: the next power of two covering its content, clamped to the /// page-size limit. Dedicated pages of oversized items keep their exact (padded) size. /// -public sealed record PackResult(IReadOnlyList Placements, IReadOnlyList<(int Width, int Height)> PageSizes); +public sealed record PackResult( + IReadOnlyList Placements, + IReadOnlyList<(int Width, int Height)> PageSizes +); /// /// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out @@ -41,17 +51,19 @@ public static class ShelfPacker ArgumentOutOfRangeException.ThrowIfNegative(padding); var sorted = items.ToList(); - sorted.Sort(static (a, b) => - { - var byHeight = b.Height.CompareTo(a.Height); - if (byHeight != 0) + sorted.Sort( + static (a, b) => { - return byHeight; - } + var byHeight = b.Height.CompareTo(a.Height); + if (byHeight != 0) + { + return byHeight; + } - var byWidth = b.Width.CompareTo(a.Width); - return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key); - }); + var byWidth = b.Width.CompareTo(a.Width); + return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key); + } + ); var placements = new List(items.Count); var pageSizes = new List<(int Width, int Height)>(); @@ -68,9 +80,12 @@ public static class ShelfPacker { if (open) { - pageSizes.Add(( - PageDimension(usedWidth + padding, maxPageSize), - PageDimension(usedHeight + padding, maxPageSize))); + pageSizes.Add( + ( + PageDimension(usedWidth + padding, maxPageSize), + PageDimension(usedHeight + padding, maxPageSize) + ) + ); open = false; } } @@ -92,7 +107,16 @@ public static class ShelfPacker { if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize) { - placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height)); + placements.Add( + new PackPlacement( + item.Key, + pageSizes.Count, + padding, + padding, + item.Width, + item.Height + ) + ); pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding)); } } @@ -120,7 +144,9 @@ public static class ShelfPacker } } - placements.Add(new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height)); + placements.Add( + new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height) + ); x += item.Width + padding; shelfHeight = Math.Max(shelfHeight, item.Height); usedWidth = Math.Max(usedWidth, x - padding); diff --git a/src/MrGameEng.Atlases/TextureAtlas.cs b/src/MrGameEng.Atlases/TextureAtlas.cs index d7b6e89..775140a 100644 --- a/src/MrGameEng.Atlases/TextureAtlas.cs +++ b/src/MrGameEng.Atlases/TextureAtlas.cs @@ -28,14 +28,19 @@ public sealed class TextureAtlas : IDisposable { Name = metadata.Name; Pages = pages; - _regions = new Dictionary(metadata.Regions.Count, StringComparer.Ordinal); + _regions = new Dictionary( + metadata.Regions.Count, + StringComparer.Ordinal + ); foreach (var region in metadata.Regions) { _regions.Add( region.Key, new Texture2DRegion( pages[region.Page], - new Rectangle(region.X, region.Y, region.Width, region.Height))); + new Rectangle(region.X, region.Y, region.Width, region.Height) + ) + ); } } @@ -59,8 +64,9 @@ public sealed class TextureAtlas : IDisposable if (metadata.Version != AtlasMetadata.CurrentVersion) { throw new InvalidDataException( - $"Atlas '{metadataPath}' has format version {metadata.Version}, expected " + - $"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool."); + $"Atlas '{metadataPath}' has format version {metadata.Version}, expected " + + $"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool." + ); } var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!; @@ -70,7 +76,11 @@ public sealed class TextureAtlas : IDisposable for (var i = 0; i < pages.Length; i++) { using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File)); - pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha); + pages[i] = Texture2D.FromStream( + graphicsDevice, + stream, + DefaultColorProcessors.PremultiplyAlpha + ); } } catch diff --git a/src/MrGameEng.Audio/MrGameEng.Audio.csproj b/src/MrGameEng.Audio/MrGameEng.Audio.csproj index 48a161c..8968c4b 100644 --- a/src/MrGameEng.Audio/MrGameEng.Audio.csproj +++ b/src/MrGameEng.Audio/MrGameEng.Audio.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -11,5 +10,4 @@ - diff --git a/src/MrGameEng.Audio/MusicPlayer.cs b/src/MrGameEng.Audio/MusicPlayer.cs index 0238876..bacddda 100644 --- a/src/MrGameEng.Audio/MusicPlayer.cs +++ b/src/MrGameEng.Audio/MusicPlayer.cs @@ -46,14 +46,16 @@ public sealed class MusicPlayer : IDisposable { reader.Dispose(); throw new NotSupportedException( - $"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported."); + $"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported." + ); } if (reader.SampleRate is < 8000 or > 48000) { reader.Dispose(); throw new NotSupportedException( - $"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 8000–48000 Hz."); + $"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 8000–48000 Hz." + ); } Stop(); @@ -67,7 +69,8 @@ public sealed class MusicPlayer : IDisposable _instance = new DynamicSoundEffectInstance( _reader.SampleRate, - _reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo) + _reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo + ) { Volume = _volume, }; diff --git a/src/MrGameEng.Collisions/Collider.cs b/src/MrGameEng.Collisions/Collider.cs index f203b64..fe4abd0 100644 --- a/src/MrGameEng.Collisions/Collider.cs +++ b/src/MrGameEng.Collisions/Collider.cs @@ -40,22 +40,24 @@ public struct Collider : IComponent public uint CollidesWith; /// Creates a circle collider on layer 1 colliding with everything. - public static Collider Circle(float radius, Vector2 offset = default) => new() - { - Shape = ColliderShape.Circle, - Radius = radius, - Offset = offset, - Layer = 1, - CollidesWith = uint.MaxValue, - }; + public static Collider Circle(float radius, Vector2 offset = default) => + new() + { + Shape = ColliderShape.Circle, + Radius = radius, + Offset = offset, + Layer = 1, + CollidesWith = uint.MaxValue, + }; /// Creates a box collider on layer 1 colliding with everything. - public static Collider Box(float width, float height, Vector2 offset = default) => new() - { - Shape = ColliderShape.Box, - HalfExtents = new Vector2(width / 2f, height / 2f), - Offset = offset, - Layer = 1, - CollidesWith = uint.MaxValue, - }; + public static Collider Box(float width, float height, Vector2 offset = default) => + new() + { + Shape = ColliderShape.Box, + HalfExtents = new Vector2(width / 2f, height / 2f), + Offset = offset, + Layer = 1, + CollidesWith = uint.MaxValue, + }; } diff --git a/src/MrGameEng.Collisions/CollisionWorld.cs b/src/MrGameEng.Collisions/CollisionWorld.cs index 5ff75a0..718ebf3 100644 --- a/src/MrGameEng.Collisions/CollisionWorld.cs +++ b/src/MrGameEng.Collisions/CollisionWorld.cs @@ -85,9 +85,10 @@ public sealed class CollisionWorld } var center = transform.Position + collider.Offset; - var half = collider.Shape == ColliderShape.Circle - ? new Vector2(collider.Radius) - : collider.HalfExtents; + var half = + collider.Shape == ColliderShape.Circle + ? new Vector2(collider.Radius) + : collider.HalfExtents; _entries[_count++] = new Entry { @@ -173,9 +174,10 @@ public sealed class CollisionWorld } float fraction; - var found = entry.Shape == ColliderShape.Circle - ? RaySegmentCircle(from, to, entry.Center, entry.Radius, out fraction) - : RaySegmentAabb(from, to, entry.Aabb, out fraction); + var found = + entry.Shape == ColliderShape.Circle + ? RaySegmentCircle(from, to, entry.Center, entry.Radius, out fraction) + : RaySegmentAabb(from, to, entry.Aabb, out fraction); if (found && fraction < bestFraction) { @@ -289,8 +291,10 @@ public sealed class CollisionWorld if (a.Shape == ColliderShape.Box && b.Shape == ColliderShape.Box) { // Включительно (касание = пара) — единообразно с кругами; RectF.Intersects строгий. - return a.Aabb.Left <= b.Aabb.Right && b.Aabb.Left <= a.Aabb.Right && - a.Aabb.Top <= b.Aabb.Bottom && b.Aabb.Top <= a.Aabb.Bottom; + return a.Aabb.Left <= b.Aabb.Right + && b.Aabb.Left <= a.Aabb.Right + && a.Aabb.Top <= b.Aabb.Bottom + && b.Aabb.Top <= a.Aabb.Bottom; } // circle vs box @@ -298,11 +302,18 @@ public sealed class CollisionWorld ref readonly var box = ref a.Shape == ColliderShape.Circle ? ref b : ref a; var nearest = new Vector2( Math.Clamp(circle.Center.X, box.Aabb.Left, box.Aabb.Right), - Math.Clamp(circle.Center.Y, box.Aabb.Top, box.Aabb.Bottom)); + Math.Clamp(circle.Center.Y, box.Aabb.Top, box.Aabb.Bottom) + ); return Vector2.DistanceSquared(circle.Center, nearest) <= circle.Radius * circle.Radius; } - private static bool RaySegmentCircle(Vector2 from, Vector2 to, Vector2 center, float radius, out float fraction) + private static bool RaySegmentCircle( + Vector2 from, + Vector2 to, + Vector2 center, + float radius, + out float fraction + ) { fraction = 0f; var d = to - from; diff --git a/src/MrGameEng.Collisions/MrGameEng.Collisions.csproj b/src/MrGameEng.Collisions/MrGameEng.Collisions.csproj index c594d33..70f9638 100644 --- a/src/MrGameEng.Collisions/MrGameEng.Collisions.csproj +++ b/src/MrGameEng.Collisions/MrGameEng.Collisions.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -8,5 +7,4 @@ - diff --git a/src/MrGameEng.Core/EngineContext.cs b/src/MrGameEng.Core/EngineContext.cs index d871908..f8a8241 100644 --- a/src/MrGameEng.Core/EngineContext.cs +++ b/src/MrGameEng.Core/EngineContext.cs @@ -22,7 +22,10 @@ public sealed class EngineContext /// throws when accessed in a headless context (unit tests). /// public GraphicsDevice GraphicsDevice => - _graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context)."); + _graphicsDevice + ?? throw new InvalidOperationException( + "GraphicsDevice is not available (headless context)." + ); /// True when a graphics device is attached. public bool HasGraphicsDevice => _graphicsDevice is not null; diff --git a/src/MrGameEng.Core/MrGameEng.Core.csproj b/src/MrGameEng.Core/MrGameEng.Core.csproj index d7ab0d1..3556987 100644 --- a/src/MrGameEng.Core/MrGameEng.Core.csproj +++ b/src/MrGameEng.Core/MrGameEng.Core.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -12,5 +11,4 @@ - diff --git a/src/MrGameEng.Core/Scene.cs b/src/MrGameEng.Core/Scene.cs index 9c615ea..e4153a7 100644 --- a/src/MrGameEng.Core/Scene.cs +++ b/src/MrGameEng.Core/Scene.cs @@ -23,7 +23,8 @@ public abstract class Scene public SystemRoot DrawSystems { get; } /// Engine context. Valid from until . - public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded."); + public EngineContext Context => + _context ?? throw new InvalidOperationException("Scene is not loaded."); /// True while the scene is the active, loaded scene. public bool IsLoaded => _context is not null; @@ -66,8 +67,9 @@ public abstract class Scene if (_loadedOnce) { throw new InvalidOperationException( - $"Scene '{GetType().Name}' was already loaded once. Scene instances are " + - "single-use: create a new instance instead of switching back to an old one."); + $"Scene '{GetType().Name}' was already loaded once. Scene instances are " + + "single-use: create a new instance instead of switching back to an old one." + ); } _loadedOnce = true; diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs index 58ca3a7..f9b6ce0 100644 --- a/src/MrGameEng.Core/SceneManager.cs +++ b/src/MrGameEng.Core/SceneManager.cs @@ -67,7 +67,12 @@ public sealed class SceneManager break; case State.CoveringOut: - _coverage = Advance(_coverage, +1f, _transition!.OutDuration, clock.UnscaledDeltaTime); + _coverage = Advance( + _coverage, + +1f, + _transition!.OutDuration, + clock.UnscaledDeltaTime + ); if (_coverage >= 1f) { ApplyPending(); @@ -77,7 +82,12 @@ public sealed class SceneManager break; case State.RevealingIn: - _coverage = Advance(_coverage, -1f, _transition!.InDuration, clock.UnscaledDeltaTime); + _coverage = Advance( + _coverage, + -1f, + _transition!.InDuration, + clock.UnscaledDeltaTime + ); if (_coverage <= 0f) { _state = State.Idle; @@ -127,7 +137,12 @@ public sealed class SceneManager Log.Info($"Scene switched to {Current?.GetType().Name ?? ""}"); } - private static float Advance(float coverage, float direction, float duration, float deltaTime) => + private static float Advance( + float coverage, + float direction, + float duration, + float deltaTime + ) => duration <= 0f ? coverage + direction : Math.Clamp(coverage + direction * deltaTime / duration, 0f, 1f); diff --git a/src/MrGameEng.Core/ServiceRegistry.cs b/src/MrGameEng.Core/ServiceRegistry.cs index a8ccb6e..fa03963 100644 --- a/src/MrGameEng.Core/ServiceRegistry.cs +++ b/src/MrGameEng.Core/ServiceRegistry.cs @@ -9,24 +9,31 @@ public sealed class ServiceRegistry private readonly Dictionary _services = new(); /// Registers a service instance under type . Throws if already registered. - public void Add(T service) where T : class + public void Add(T service) + where T : class { if (!_services.TryAdd(typeof(T), service)) { - throw new InvalidOperationException($"Service of type {typeof(T)} is already registered."); + throw new InvalidOperationException( + $"Service of type {typeof(T)} is already registered." + ); } } /// Returns the registered service of type . Throws if missing. - public T Get() where T : class + public T Get() + where T : class { return _services.TryGetValue(typeof(T), out var service) ? (T)service - : throw new InvalidOperationException($"Service of type {typeof(T)} is not registered."); + : throw new InvalidOperationException( + $"Service of type {typeof(T)} is not registered." + ); } /// Returns the registered service of type or null. - public T? GetOrDefault() where T : class + public T? GetOrDefault() + where T : class { return _services.TryGetValue(typeof(T), out var service) ? (T)service : null; } @@ -41,7 +48,11 @@ public sealed class ServiceRegistry var disposed = new HashSet(ReferenceEqualityComparer.Instance); foreach (var service in _services.Values) { - if (!ReferenceEquals(service, except) && service is IDisposable disposable && disposed.Add(service)) + if ( + !ReferenceEquals(service, except) + && service is IDisposable disposable + && disposed.Add(service) + ) { disposable.Dispose(); } diff --git a/src/MrGameEng.Core/Transition.cs b/src/MrGameEng.Core/Transition.cs index 84bd122..3725727 100644 --- a/src/MrGameEng.Core/Transition.cs +++ b/src/MrGameEng.Core/Transition.cs @@ -50,14 +50,21 @@ public abstract class Transition private sealed class FadeTransition(float outDuration, float inDuration, Color color) : Transition(outDuration, inDuration) { - public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) => - renderer.Fill(0f, 0f, 1f, 1f, color, coverage); + public override void Draw( + TransitionRenderer renderer, + float coverage, + TransitionPhase phase + ) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage); } private sealed class WipeTransition(float outDuration, float inDuration, Color color) : Transition(outDuration, inDuration) { - public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) + public override void Draw( + TransitionRenderer renderer, + float coverage, + TransitionPhase phase + ) { // Out: шторка растёт слева направо; In: уезжает дальше вправо. if (phase == TransitionPhase.Out) diff --git a/src/MrGameEng.DevConsole/DevConsole.cs b/src/MrGameEng.DevConsole/DevConsole.cs index 4b3509d..8c3d25b 100644 --- a/src/MrGameEng.DevConsole/DevConsole.cs +++ b/src/MrGameEng.DevConsole/DevConsole.cs @@ -39,15 +39,28 @@ public sealed class DevConsole : IDisposable public DevConsole(int capacity = 2048) { _lines = new string[capacity]; - Register("help", "list available commands", static (console, _) => - { - foreach (var (name, entry) in console._commands.OrderBy(p => p.Key, StringComparer.Ordinal)) + Register( + "help", + "list available commands", + static (console, _) => { - console.WriteLine($" {name} — {entry.Description}"); + foreach ( + var (name, entry) in console._commands.OrderBy( + p => p.Key, + StringComparer.Ordinal + ) + ) + { + console.WriteLine($" {name} — {entry.Description}"); + } } - }); + ); Register("clear", "clear the log", static (console, _) => console.Clear()); - Register("echo", "print the arguments", static (console, args) => console.WriteLine(string.Join(' ', args))); + Register( + "echo", + "print the arguments", + static (console, args) => console.WriteLine(string.Join(' ', args)) + ); Log.MessageLogged += OnLogMessage; } @@ -182,8 +195,8 @@ public sealed class DevConsole : IDisposable public string Complete(string prefix) { prefix = prefix.TrimStart(); - var matches = _commands.Keys - .Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + var matches = _commands + .Keys.Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) .OrderBy(name => name, StringComparer.Ordinal) .ToArray(); @@ -199,8 +212,12 @@ public sealed class DevConsole : IDisposable foreach (var match in matches[1..]) { var length = 0; - while (length < common.Length && length < match.Length && - char.ToLowerInvariant(common[length]) == char.ToLowerInvariant(match[length])) + while ( + length < common.Length + && length < match.Length + && char.ToLowerInvariant(common[length]) + == char.ToLowerInvariant(match[length]) + ) { length++; } @@ -296,11 +313,12 @@ public sealed class DevConsole : IDisposable private void OnLogMessage(LogLevel level, string message) => WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}"); - private static string LevelTag(LogLevel level) => level switch - { - LogLevel.Debug => "dbg", - LogLevel.Warning => "warn", - LogLevel.Error => "err", - _ => "info", - }; + private static string LevelTag(LogLevel level) => + level switch + { + LogLevel.Debug => "dbg", + LogLevel.Warning => "warn", + LogLevel.Error => "err", + _ => "info", + }; } diff --git a/src/MrGameEng.DevConsole/DevConsoleSystems.cs b/src/MrGameEng.DevConsole/DevConsoleSystems.cs index a422ad3..418f539 100644 --- a/src/MrGameEng.DevConsole/DevConsoleSystems.cs +++ b/src/MrGameEng.DevConsole/DevConsoleSystems.cs @@ -2,11 +2,11 @@ using System.Text; using Friflo.Engine.ECS.Systems; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; +using MrGameEng.Core; using Myra; using Myra.Graphics2D; using Myra.Graphics2D.Brushes; using Myra.Graphics2D.UI; -using MrGameEng.Core; namespace MrGameEng.DevConsole; @@ -26,16 +26,9 @@ internal sealed class DevConsoleUi { _console = console; - _log = new Label - { - Text = string.Empty, - Wrap = false, - }; + _log = new Label { Text = string.Empty, Wrap = false }; - _input = new TextBox - { - HintText = "command ('help')", - }; + _input = new TextBox { HintText = "command ('help')" }; _input.TextChanged += (_, _) => { // Клавиша-тогглер (`) не должна попадать в строку ввода. @@ -222,18 +215,25 @@ public static class SceneDevConsoleExtensions private static void RegisterEngineCommands(DevConsole console, EngineContext context) { - console.Register("timescale", "timescale [value] — show or set game speed", (c, args) => - { - if (args.Length == 0) + console.Register( + "timescale", + "timescale [value] — show or set game speed", + (c, args) => { - c.WriteLine($"timescale = {context.Clock.TimeScale}"); + if (args.Length == 0) + { + c.WriteLine($"timescale = {context.Clock.TimeScale}"); + } + else + { + context.Clock.TimeScale = float.Parse( + args[0], + System.Globalization.CultureInfo.InvariantCulture + ); + c.WriteLine($"timescale = {context.Clock.TimeScale}"); + } } - else - { - context.Clock.TimeScale = float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture); - c.WriteLine($"timescale = {context.Clock.TimeScale}"); - } - }); + ); console.Register("close", "close the console", static (c, _) => c.Toggle()); diff --git a/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj b/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj index b87d0bc..0ea8a0a 100644 --- a/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj +++ b/src/MrGameEng.DevConsole/MrGameEng.DevConsole.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -11,5 +10,4 @@ - diff --git a/src/MrGameEng.Graphics/CameraMath.cs b/src/MrGameEng.Graphics/CameraMath.cs index 182f775..9804ec4 100644 --- a/src/MrGameEng.Graphics/CameraMath.cs +++ b/src/MrGameEng.Graphics/CameraMath.cs @@ -52,23 +52,35 @@ public readonly struct CameraState public static class CameraMath { /// Computes the full camera state for a frame. - public static CameraState Compute(in Camera camera, int virtualWidth, int virtualHeight, ViewportMapping mapping) + public static CameraState Compute( + in Camera camera, + int virtualWidth, + int virtualHeight, + ViewportMapping mapping + ) { var zoom = camera.Zoom <= 0f ? 1f : camera.Zoom; var position = ClampToBounds(camera, virtualWidth, virtualHeight, zoom); var view = - Matrix.CreateTranslation(-position.X, -position.Y, 0f) * - Matrix.CreateRotationZ(-camera.Rotation) * - Matrix.CreateScale(zoom, zoom, 1f) * - Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f); + Matrix.CreateTranslation(-position.X, -position.Y, 0f) + * Matrix.CreateRotationZ(-camera.Rotation) + * Matrix.CreateScale(zoom, zoom, 1f) + * Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f); var inverseView = Matrix.Invert(view); return new CameraState { View = view, - Projection = Matrix.CreateOrthographicOffCenter(0f, virtualWidth, virtualHeight, 0f, 0f, 1f), + Projection = Matrix.CreateOrthographicOffCenter( + 0f, + virtualWidth, + virtualHeight, + 0f, + 0f, + 1f + ), InverseView = inverseView, CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight), VirtualWidth = virtualWidth, @@ -81,14 +93,29 @@ public static class CameraMath /// Computes the letterbox mapping that fits the virtual resolution into a physical /// viewport, preserving aspect ratio and centering. /// - public static ViewportMapping ComputeMapping(int screenWidth, int screenHeight, int virtualWidth, int virtualHeight) + public static ViewportMapping ComputeMapping( + int screenWidth, + int screenHeight, + int virtualWidth, + int virtualHeight + ) { - var scale = MathF.Min((float)screenWidth / virtualWidth, (float)screenHeight / virtualHeight); - var offset = new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) / 2f; + var scale = MathF.Min( + (float)screenWidth / virtualWidth, + (float)screenHeight / virtualHeight + ); + var offset = + new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) + / 2f; return new ViewportMapping(offset, scale); } - private static Vector2 ClampToBounds(in Camera camera, int virtualWidth, int virtualHeight, float zoom) + private static Vector2 ClampToBounds( + in Camera camera, + int virtualWidth, + int virtualHeight, + float zoom + ) { if (camera.Bounds is not { } bounds) { @@ -100,7 +127,8 @@ public static class CameraMath var halfH = virtualHeight / (2f * zoom); return new Vector2( ClampAxis(camera.Position.X, bounds.Left + halfW, bounds.Right - halfW), - ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH)); + ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH) + ); } private static float ClampAxis(float value, float min, float max) => diff --git a/src/MrGameEng.Graphics/CullingMath.cs b/src/MrGameEng.Graphics/CullingMath.cs index b75c334..c547e80 100644 --- a/src/MrGameEng.Graphics/CullingMath.cs +++ b/src/MrGameEng.Graphics/CullingMath.cs @@ -10,7 +10,11 @@ public static class CullingMath /// (valid for any rotation), given its transform, region size in pixels and origin. /// public static (Vector2 Center, float Radius) SpriteBoundingCircle( - in Transform2D transform, float regionWidth, float regionHeight, Vector2 origin) + in Transform2D transform, + float regionWidth, + float regionHeight, + Vector2 origin + ) { var scaledW = regionWidth * transform.Scale.X; var scaledH = regionHeight * transform.Scale.Y; @@ -24,20 +28,33 @@ public static class CullingMath /// diagonal (no square root per sprite; conservative for non-uniform scale, exact for uniform). /// public static (Vector2 Center, float Radius) SpriteBoundingCircle( - in Transform2D transform, Texture2DRegion region, Vector2 origin) + in Transform2D transform, + Texture2DRegion region, + Vector2 origin + ) { var center = SpriteCenter( - in transform, region.Width * transform.Scale.X, region.Height * transform.Scale.Y, origin); + in transform, + region.Width * transform.Scale.X, + region.Height * transform.Scale.Y, + origin + ); var maxScale = MathF.Max(MathF.Abs(transform.Scale.X), MathF.Abs(transform.Scale.Y)); return (center, 0.5f * region.Diagonal * maxScale); } - private static Vector2 SpriteCenter(in Transform2D transform, float scaledW, float scaledH, Vector2 origin) + private static Vector2 SpriteCenter( + in Transform2D transform, + float scaledW, + float scaledH, + Vector2 origin + ) { // Offset from the pivot (= transform.Position) to the sprite's geometric center. var toCenter = new Vector2( scaledW / 2f - origin.X * transform.Scale.X, - scaledH / 2f - origin.Y * transform.Scale.Y); + scaledH / 2f - origin.Y * transform.Scale.Y + ); if (transform.Rotation == 0f) { @@ -45,9 +62,8 @@ public static class CullingMath } var (sin, cos) = MathF.SinCos(transform.Rotation); - return transform.Position + new Vector2( - toCenter.X * cos - toCenter.Y * sin, - toCenter.X * sin + toCenter.Y * cos); + return transform.Position + + new Vector2(toCenter.X * cos - toCenter.Y * sin, toCenter.X * sin + toCenter.Y * cos); } /// True when the circle overlaps the rectangle. diff --git a/src/MrGameEng.Graphics/Layers.cs b/src/MrGameEng.Graphics/Layers.cs index 5f3797d..3fff1b8 100644 --- a/src/MrGameEng.Graphics/Layers.cs +++ b/src/MrGameEng.Graphics/Layers.cs @@ -52,14 +52,20 @@ public sealed class LayerRegistry public int Count => _layers.Length; /// Registers a layer drawn after all previously registered ones. - public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth) + public LayerId Register( + string name, + LayerSpace space = LayerSpace.World, + LayerSortMode sortMode = LayerSortMode.Depth + ) { lock (_sync) { var layers = _layers; if (layers.Length == 256) { - throw new InvalidOperationException("Maximum number of render layers (256) reached."); + throw new InvalidOperationException( + "Maximum number of render layers (256) reached." + ); } var id = new LayerId((byte)layers.Length); @@ -80,7 +86,9 @@ public sealed class LayerRegistry return id.Value < layers.Length ? layers[id.Value] : throw new ArgumentOutOfRangeException( - nameof(id), $"Render layer {id.Value} is not registered (registered: {layers.Length})."); + nameof(id), + $"Render layer {id.Value} is not registered (registered: {layers.Length})." + ); } } } diff --git a/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj b/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj index 87669eb..2586393 100644 --- a/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj +++ b/src/MrGameEng.Graphics/MrGameEng.Graphics.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -7,5 +6,4 @@ - diff --git a/src/MrGameEng.Graphics/RenderSystems.cs b/src/MrGameEng.Graphics/RenderSystems.cs index cbf4957..ac7093f 100644 --- a/src/MrGameEng.Graphics/RenderSystems.cs +++ b/src/MrGameEng.Graphics/RenderSystems.cs @@ -96,20 +96,24 @@ public sealed class SpriteRenderSystem : QuerySystem } _renderer.BeginChunkedSubmit(_segmentLengths.AsSpan(0, _segments.Count)); - Parallel.For(0, _segments.Count, segmentIndex => - { - var (chunk, start, length) = _segments[segmentIndex]; - var (sprites, transforms) = _chunks[chunk]; - var writer = _renderer.GetChunkWriter(segmentIndex); - var s = sprites.Span.Slice(start, length); - var t = transforms.Span.Slice(start, length); - for (var i = 0; i < s.Length; i++) + Parallel.For( + 0, + _segments.Count, + segmentIndex => { - _renderer.SubmitInto(ref writer, in t[i], in s[i]); - } + var (chunk, start, length) = _segments[segmentIndex]; + var (sprites, transforms) = _chunks[chunk]; + var writer = _renderer.GetChunkWriter(segmentIndex); + var s = sprites.Span.Slice(start, length); + var t = transforms.Span.Slice(start, length); + for (var i = 0; i < s.Length; i++) + { + _renderer.SubmitInto(ref writer, in t[i], in s[i]); + } - _renderer.EndChunk(segmentIndex, in writer); - }); + _renderer.EndChunk(segmentIndex, in writer); + } + ); _renderer.CommitChunkedSubmit(); } } diff --git a/src/MrGameEng.Graphics/Renderer2D.cs b/src/MrGameEng.Graphics/Renderer2D.cs index 37e48bb..4f38d7e 100644 --- a/src/MrGameEng.Graphics/Renderer2D.cs +++ b/src/MrGameEng.Graphics/Renderer2D.cs @@ -17,7 +17,9 @@ public sealed class Renderer2D : IDisposable private const int MaxQuadsPerDraw = 8192; private const int ParallelBlock = 4096; - private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride; + private static readonly int VertexStride = VertexPositionColorTexture + .VertexDeclaration + .VertexStride; /// Render layer registry. Register layers before the first frame. public LayerRegistry Layers { get; } = new(); @@ -71,7 +73,11 @@ public sealed class Renderer2D : IDisposable _batcher = new SpriteBatcher(_options.InitialCapacity); _vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4]; _vertexBuffer = new DynamicVertexBuffer( - device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly); + device, + VertexPositionColorTexture.VertexDeclaration, + _vertices.Length * 2, + BufferUsage.WriteOnly + ); _effect = new BasicEffect(device) { @@ -91,7 +97,11 @@ public sealed class Renderer2D : IDisposable var (virtualW, virtualH, mapping) = ResolveVirtualResolution(); Camera = CameraMath.Compute(camera, virtualW, virtualH, mapping); _screenCamera = CameraMath.Compute( - new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), virtualW, virtualH, mapping); + new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), + virtualW, + virtualH, + mapping + ); _batcher.Clear(); SubmittedSprites = 0; @@ -155,14 +165,18 @@ public sealed class Renderer2D : IDisposable if (count >= _options.ParallelThreshold) { var blocks = (count + ParallelBlock - 1) / ParallelBlock; - Parallel.For(0, blocks, block => - { - var end = Math.Min((block + 1) * ParallelBlock, count); - for (var i = block * ParallelBlock; i < end; i++) + Parallel.For( + 0, + blocks, + block => { - BuildVertex(order, i); + var end = Math.Min((block + 1) * ParallelBlock, count); + for (var i = block * ParallelBlock; i < end; i++) + { + BuildVertex(order, i); + } } - }); + ); } else { @@ -190,7 +204,14 @@ public sealed class Renderer2D : IDisposable hint = SetDataOptions.Discard; } - _vertexBuffer.SetData(_ringBaseVertex * VertexStride, _vertices, 0, vertexCount, VertexStride, hint); + _vertexBuffer.SetData( + _ringBaseVertex * VertexStride, + _vertices, + 0, + vertexCount, + VertexStride, + hint + ); _ringCursor = _ringBaseVertex + vertexCount; var uploadEnd = Stopwatch.GetTimestamp(); UploadMs = ToMs(uploadEnd - buildEnd); @@ -213,7 +234,8 @@ public sealed class Renderer2D : IDisposable (int)MathF.Round(mapping.Offset.X), (int)MathF.Round(mapping.Offset.Y), (int)MathF.Round(Camera.VirtualWidth * mapping.Scale), - (int)MathF.Round(Camera.VirtualHeight * mapping.Scale)); + (int)MathF.Round(Camera.VirtualHeight * mapping.Scale) + ); } DrawBatches(order, count); @@ -247,9 +269,14 @@ public sealed class Renderer2D : IDisposable _batcher.BeginChunks(chunkLengths); } - internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => _batcher.GetChunkWriter(chunkIndex); + internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => + _batcher.GetChunkWriter(chunkIndex); - internal void SubmitInto(ref SpriteChunkWriter writer, in Transform2D transform, in Sprite sprite) + internal void SubmitInto( + ref SpriteChunkWriter writer, + in Transform2D transform, + in Sprite sprite + ) { switch (TryBuildInstance(in transform, in sprite, out var instance, out var key)) { @@ -262,7 +289,8 @@ public sealed class Renderer2D : IDisposable } } - internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => _batcher.EndChunk(chunkIndex, in writer); + internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => + _batcher.EndChunk(chunkIndex, in writer); internal void CommitChunkedSubmit() { @@ -278,7 +306,11 @@ public sealed class Renderer2D : IDisposable } private SubmitResult TryBuildInstance( - in Transform2D transform, in Sprite sprite, out SpriteInstance instance, out ulong key) + in Transform2D transform, + in Sprite sprite, + out SpriteInstance instance, + out ulong key + ) { instance = default; key = 0; @@ -288,10 +320,16 @@ public sealed class Renderer2D : IDisposable } var layer = Layers[sprite.Layer]; - var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin); + var (center, radius) = CullingMath.SpriteBoundingCircle( + in transform, + region, + sprite.Origin + ); - if (layer.Space == LayerSpace.World && - !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect)) + if ( + layer.Space == LayerSpace.World + && !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect) + ) { return SubmitResult.Culled; } @@ -304,7 +342,9 @@ public sealed class Renderer2D : IDisposable { Region = region, Center = center, - HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f, + HalfSize = + new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) + / 2f, Rotation = transform.Rotation, Color = sprite.Color, Flip = sprite.Flip, @@ -319,7 +359,8 @@ public sealed class Renderer2D : IDisposable if (!_begun) { throw new InvalidOperationException( - "Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?)."); + "Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?)." + ); } } @@ -331,8 +372,11 @@ public sealed class Renderer2D : IDisposable return (viewport.Width, viewport.Height, ViewportMapping.Identity); } - return (virtualSize.X, virtualSize.Y, - CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)); + return ( + virtualSize.X, + virtualSize.Y, + CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y) + ); } private void BuildVertex(int[] order, int i) @@ -355,7 +399,8 @@ public sealed class Renderer2D : IDisposable (v0, v1) = (v1, v0); } - Vector2 rx, ry; + Vector2 rx, + ry; if (instance.Rotation == 0f) { rx = new Vector2(instance.HalfSize.X, 0f); @@ -432,7 +477,11 @@ public sealed class Renderer2D : IDisposable { pass.Apply(); _device.DrawIndexedPrimitives( - PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2); + PrimitiveType.TriangleList, + _ringBaseVertex + firstQuad * 4, + 0, + quads * 2 + ); DrawCalls++; } @@ -461,15 +510,24 @@ public sealed class Renderer2D : IDisposable { _vertexBuffer.Dispose(); _vertexBuffer = new DynamicVertexBuffer( - _device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly); + _device, + VertexPositionColorTexture.VertexDeclaration, + wantedBuffer, + BufferUsage.WriteOnly + ); _ringCursor = 0; } } - private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency; + private static float ToMs(long timestampDelta) => + (float)timestampDelta * 1000f / Stopwatch.Frequency; - private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) => - new(new Vector3(position, 0f), color, new Vector2(u, v)); + private static VertexPositionColorTexture Vertex( + Vector2 position, + Color color, + float u, + float v + ) => new(new Vector3(position, 0f), color, new Vector2(u, v)); private static IndexBuffer CreateQuadIndexBuffer(GraphicsDevice device) { @@ -486,7 +544,12 @@ public sealed class Renderer2D : IDisposable indices[index + 5] = (ushort)(vertex + 3); } - var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly); + var buffer = new IndexBuffer( + device, + IndexElementSize.SixteenBits, + indices.Length, + BufferUsage.WriteOnly + ); buffer.SetData(indices); return buffer; } diff --git a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs index 68b31d5..8e9dd6f 100644 --- a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs +++ b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs @@ -13,7 +13,10 @@ public static class SceneGraphicsExtensions /// is created on first use and shared between scenes. Call from OnLoad. /// public static Renderer2D UseRenderer2D( - this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems) + this Scene scene, + Renderer2DOptions? options = null, + params BaseSystem[] extraDrawSystems + ) { var services = scene.Context.Services; var renderer = services.GetOrDefault(); @@ -25,8 +28,9 @@ public static class SceneGraphicsExtensions else if (options is not null) { Log.Warning( - "UseRenderer2D: the renderer already exists, the passed options are ignored " + - "(Renderer2D is a shared service configured by its first user)."); + "UseRenderer2D: the renderer already exists, the passed options are ignored " + + "(Renderer2D is a shared service configured by its first user)." + ); } scene.DrawSystems.Add(new CameraSystem(renderer)); diff --git a/src/MrGameEng.Graphics/SpriteAnimation.cs b/src/MrGameEng.Graphics/SpriteAnimation.cs index 04161b6..6413589 100644 --- a/src/MrGameEng.Graphics/SpriteAnimation.cs +++ b/src/MrGameEng.Graphics/SpriteAnimation.cs @@ -19,11 +19,18 @@ public sealed class SpriteAnimationClip public float Duration => Frames.Count / FramesPerSecond; /// Creates a clip. - public SpriteAnimationClip(IReadOnlyList frames, float framesPerSecond = 12f, bool loop = true) + public SpriteAnimationClip( + IReadOnlyList frames, + float framesPerSecond = 12f, + bool loop = true + ) { if (frames.Count == 0) { - throw new ArgumentException("An animation clip needs at least one frame.", nameof(frames)); + throw new ArgumentException( + "An animation clip needs at least one frame.", + nameof(frames) + ); } Frames = frames; diff --git a/src/MrGameEng.Graphics/SpriteBatcher.cs b/src/MrGameEng.Graphics/SpriteBatcher.cs index 0b156a6..004355e 100644 --- a/src/MrGameEng.Graphics/SpriteBatcher.cs +++ b/src/MrGameEng.Graphics/SpriteBatcher.cs @@ -130,7 +130,8 @@ public sealed class SpriteBatcher // Биты, различающиеся хотя бы у одной пары ключей: проходы по одинаковым // разрядам (один слой, одна глубина) пропускаются целиком. - ulong orBits = 0, andBits = ~0UL; + ulong orBits = 0, + andBits = ~0UL; for (var i = 0; i < n; i++) { orBits |= _keys[i]; diff --git a/src/MrGameEng.Graphics/SpriteSortKey.cs b/src/MrGameEng.Graphics/SpriteSortKey.cs index 906f99f..28660be 100644 --- a/src/MrGameEng.Graphics/SpriteSortKey.cs +++ b/src/MrGameEng.Graphics/SpriteSortKey.cs @@ -9,7 +9,9 @@ public static class SpriteSortKey { /// Composes a sort key from layer, depth and texture grouping key. public static ulong Make(byte layer, float depth, int textureKey) => - ((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF); + ((ulong)layer << 56) + | ((ulong)DepthToSortableBits(depth) << 24) + | ((uint)textureKey & 0xFF_FFFF); /// /// Maps a float to bits whose unsigned order matches the float order diff --git a/src/MrGameEng.Graphics/Texture2DRegion.cs b/src/MrGameEng.Graphics/Texture2DRegion.cs index a553337..ea44d11 100644 --- a/src/MrGameEng.Graphics/Texture2DRegion.cs +++ b/src/MrGameEng.Graphics/Texture2DRegion.cs @@ -35,7 +35,9 @@ public sealed class Texture2DRegion Texture = texture; Bounds = bounds; TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture); - Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height); + Diagonal = MathF.Sqrt( + (float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height + ); // UV предрассчитаны один раз — в кадре на каждый спрайт экономятся 4 деления. // texture может быть null только в headless-тестах. @@ -50,7 +52,5 @@ public sealed class Texture2DRegion /// Creates a region covering the whole . public Texture2DRegion(Texture2D texture) - : this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) - { - } + : this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) { } } diff --git a/src/MrGameEng.Input/ActionMap.cs b/src/MrGameEng.Input/ActionMap.cs index 29e1db6..1e6c5e2 100644 --- a/src/MrGameEng.Input/ActionMap.cs +++ b/src/MrGameEng.Input/ActionMap.cs @@ -7,7 +7,8 @@ namespace MrGameEng.Input; /// gamepad buttons. Query by action instead of device, rebind at runtime. /// /// Enum (or any value) identifying the game's actions. -public sealed class ActionMap where TAction : notnull +public sealed class ActionMap + where TAction : notnull { private readonly InputManager _input; private readonly Dictionary> _bindings = new(); @@ -18,37 +19,49 @@ public sealed class ActionMap where TAction : notnull public ActionMap(InputManager input) => _input = input; /// Adds a keyboard binding for . - public ActionMap Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null)); + public ActionMap Bind(TAction action, Keys key) => + Add(action, new Binding(key, null, null)); /// Adds a mouse-button binding for . - public ActionMap Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null)); + public ActionMap Bind(TAction action, MouseButton button) => + Add(action, new Binding(null, button, null)); /// Adds a gamepad-button binding for . - public ActionMap Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button)); + public ActionMap Bind(TAction action, Buttons button) => + Add(action, new Binding(null, null, button)); /// Removes every binding of (for rebinding). public void Unbind(TAction action) => _bindings.Remove(action); /// True while any binding of the action is held down. - public bool IsDown(TAction action) => Any(action, - static (input, b) => - (b.Key is { } k && input.IsKeyDown(k)) || - (b.Mouse is { } m && input.IsMouseDown(m)) || - (b.GamePad is { } g && input.IsButtonDown(g))); + public bool IsDown(TAction action) => + Any( + action, + static (input, b) => + (b.Key is { } k && input.IsKeyDown(k)) + || (b.Mouse is { } m && input.IsMouseDown(m)) + || (b.GamePad is { } g && input.IsButtonDown(g)) + ); /// True only on the frame any binding of the action went down. - public bool IsPressed(TAction action) => Any(action, - static (input, b) => - (b.Key is { } k && input.IsKeyPressed(k)) || - (b.Mouse is { } m && input.IsMousePressed(m)) || - (b.GamePad is { } g && input.IsButtonPressed(g))); + public bool IsPressed(TAction action) => + Any( + action, + static (input, b) => + (b.Key is { } k && input.IsKeyPressed(k)) + || (b.Mouse is { } m && input.IsMousePressed(m)) + || (b.GamePad is { } g && input.IsButtonPressed(g)) + ); /// True only on the frame any binding of the action went up. - public bool IsReleased(TAction action) => Any(action, - static (input, b) => - (b.Key is { } k && input.IsKeyReleased(k)) || - (b.Mouse is { } m && input.IsMouseReleased(m)) || - (b.GamePad is { } g && input.IsButtonReleased(g))); + public bool IsReleased(TAction action) => + Any( + action, + static (input, b) => + (b.Key is { } k && input.IsKeyReleased(k)) + || (b.Mouse is { } m && input.IsMouseReleased(m)) + || (b.GamePad is { } g && input.IsButtonReleased(g)) + ); /// Composes -1/0/+1 from two digital actions (e.g. move left / move right). public float GetAxis(TAction negative, TAction positive) => diff --git a/src/MrGameEng.Input/InputManager.cs b/src/MrGameEng.Input/InputManager.cs index ca988e5..edfe6fc 100644 --- a/src/MrGameEng.Input/InputManager.cs +++ b/src/MrGameEng.Input/InputManager.cs @@ -50,17 +50,21 @@ public sealed class InputManager Apply( default, new MouseState( - _mouse.X, _mouse.Y, _mouse.ScrollWheelValue, - ButtonState.Released, ButtonState.Released, ButtonState.Released, - ButtonState.Released, ButtonState.Released), - default); + _mouse.X, + _mouse.Y, + _mouse.ScrollWheelValue, + ButtonState.Released, + ButtonState.Released, + ButtonState.Released, + ButtonState.Released, + ButtonState.Released + ), + default + ); return; } - Apply( - Keyboard.GetState(), - Mouse.GetState(), - GamePad.GetState(PlayerIndex.One)); + Apply(Keyboard.GetState(), Mouse.GetState(), GamePad.GetState(PlayerIndex.One)); } internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad) @@ -77,10 +81,12 @@ public sealed class InputManager public bool IsKeyDown(Keys key) => _keyboard.IsKeyDown(key); /// True only on the frame the key went down. - public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key); + public bool IsKeyPressed(Keys key) => + _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key); /// True only on the frame the key went up. - public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key); + public bool IsKeyReleased(Keys key) => + _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key); /// Mouse cursor position in window pixels. public Point MousePosition => _mouse.Position; @@ -96,29 +102,34 @@ public sealed class InputManager /// True only on the frame the mouse button went down. public bool IsMousePressed(MouseButton button) => - GetButton(_mouse, button) == ButtonState.Pressed && GetButton(_previousMouse, button) == ButtonState.Released; + GetButton(_mouse, button) == ButtonState.Pressed + && GetButton(_previousMouse, button) == ButtonState.Released; /// True only on the frame the mouse button went up. public bool IsMouseReleased(MouseButton button) => - GetButton(_mouse, button) == ButtonState.Released && GetButton(_previousMouse, button) == ButtonState.Pressed; + GetButton(_mouse, button) == ButtonState.Released + && GetButton(_previousMouse, button) == ButtonState.Pressed; /// True while the gamepad button is held down. public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button); /// True only on the frame the gamepad button went down. - public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button); + public bool IsButtonPressed(Buttons button) => + _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button); /// True only on the frame the gamepad button went up. - public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button); + public bool IsButtonReleased(Buttons button) => + _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button); /// Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world. public Vector2 LeftStick => new(_gamePad.ThumbSticks.Left.X, -_gamePad.ThumbSticks.Left.Y); - private static ButtonState GetButton(in MouseState state, MouseButton button) => button switch - { - MouseButton.Left => state.LeftButton, - MouseButton.Right => state.RightButton, - MouseButton.Middle => state.MiddleButton, - _ => ButtonState.Released, - }; + private static ButtonState GetButton(in MouseState state, MouseButton button) => + button switch + { + MouseButton.Left => state.LeftButton, + MouseButton.Right => state.RightButton, + MouseButton.Middle => state.MiddleButton, + _ => ButtonState.Released, + }; } diff --git a/src/MrGameEng.Input/MrGameEng.Input.csproj b/src/MrGameEng.Input/MrGameEng.Input.csproj index 3b70bf2..ab9671c 100644 --- a/src/MrGameEng.Input/MrGameEng.Input.csproj +++ b/src/MrGameEng.Input/MrGameEng.Input.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -11,5 +10,4 @@ - diff --git a/src/MrGameEng.Mods/DefDatabase.cs b/src/MrGameEng.Mods/DefDatabase.cs index b3c51e4..860859c 100644 --- a/src/MrGameEng.Mods/DefDatabase.cs +++ b/src/MrGameEng.Mods/DefDatabase.cs @@ -31,7 +31,8 @@ public sealed class DefDatabase }; /// Registers the CLR type behind a def-type key (the "type" field of def files). - public void RegisterType(string typeKey) where T : Def + public void RegisterType(string typeKey) + where T : Def { var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) }; if (!_byKey.TryAdd(typeKey, entry)) @@ -56,7 +57,8 @@ public sealed class DefDatabase continue; } - var files = Directory.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories) + var files = Directory + .EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories) .OrderBy(f => f, StringComparer.Ordinal); foreach (var file in files) { @@ -71,13 +73,15 @@ public sealed class DefDatabase } /// Returns the def of type named ; throws when missing. - public T Get(string defName) where T : Def => + public T Get(string defName) + where T : Def => TryGet(defName, out var def) ? def : throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'."); /// Returns the def of type named , or false. - public bool TryGet(string defName, out T def) where T : Def + public bool TryGet(string defName, out T def) + where T : Def { if (Entry().Resolved.TryGetValue(defName, out var found)) { @@ -90,16 +94,19 @@ public sealed class DefDatabase } /// All resolved defs of type , sorted by def name (deterministic). - public IReadOnlyList All() where T : Def => Entry().Resolved.Values.Cast().ToList(); + public IReadOnlyList All() + where T : Def => Entry().Resolved.Values.Cast().ToList(); /// Registered def-type keys, sorted. - public IReadOnlyList TypeKeys => _byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList(); + public IReadOnlyList TypeKeys => + _byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList(); /// Resolved def names of the given type key, sorted; empty for unknown keys. public IReadOnlyList NamesOf(string typeKey) => _byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : []; - private TypeEntry Entry() where T : Def => + private TypeEntry Entry() + where T : Def => _byType.TryGetValue(typeof(T), out var entry) ? entry : throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered."); @@ -109,39 +116,53 @@ public sealed class DefDatabase JsonNode root; try { - root = JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions) + root = + JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions) ?? throw new InvalidDataException("file is empty"); } catch (JsonException exception) { - throw new InvalidDataException($"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}", exception); + throw new InvalidDataException( + $"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}", + exception + ); } - var typeKey = root["type"]?.GetValue() - ?? throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."); + var typeKey = + root["type"]?.GetValue() + ?? throw new InvalidDataException( + $"Def file '{file}' (mod '{mod.Id}') has no \"type\" field." + ); if (!_byKey.TryGetValue(typeKey, out var entry)) { throw new InvalidDataException( - $"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; " + - $"registered: {string.Join(", ", TypeKeys)}."); + $"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; " + + $"registered: {string.Join(", ", TypeKeys)}." + ); } if (root["defs"] is not JsonArray defs) { - throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array."); + throw new InvalidDataException( + $"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array." + ); } foreach (var node in defs) { if (node is not JsonObject def) { - throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry."); + throw new InvalidDataException( + $"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry." + ); } var defName = def["defName"]?.GetValue(); if (string.IsNullOrWhiteSpace(defName)) { - throw new InvalidDataException($"A def in '{file}' (mod '{mod.Id}') has no \"defName\"."); + throw new InvalidDataException( + $"A def in '{file}' (mod '{mod.Id}') has no \"defName\"." + ); } entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф @@ -158,8 +179,11 @@ public sealed class DefDatabase continue; } - var def = (Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions) - ?? throw new InvalidDataException($"Def '{defName}' ({entry.Key}) deserialized to null."); + var def = + (Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions) + ?? throw new InvalidDataException( + $"Def '{defName}' ({entry.Key}) deserialized to null." + ); entry.Resolved[defName] = def; } } @@ -168,7 +192,9 @@ public sealed class DefDatabase { if (!seen.Add(defName)) { - throw new InvalidDataException($"Cyclic def inheritance involving '{defName}' ({entry.Key})."); + throw new InvalidDataException( + $"Cyclic def inheritance involving '{defName}' ({entry.Key})." + ); } if (!entry.Raw.TryGetValue(defName, out var node)) diff --git a/src/MrGameEng.Mods/LanguageManager.cs b/src/MrGameEng.Mods/LanguageManager.cs index dedf568..1d922c0 100644 --- a/src/MrGameEng.Mods/LanguageManager.cs +++ b/src/MrGameEng.Mods/LanguageManager.cs @@ -12,8 +12,9 @@ namespace MrGameEng.Mods; /// public sealed class LanguageManager { - private readonly Dictionary> _languages = - new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _languages = new( + StringComparer.OrdinalIgnoreCase + ); /// Creates a manager whose fallback language is . public LanguageManager(string defaultLanguage = "en") @@ -46,7 +47,11 @@ public sealed class LanguageManager continue; } - foreach (var languageDir in Directory.EnumerateDirectories(languagesDir).OrderBy(d => d, StringComparer.Ordinal)) + foreach ( + var languageDir in Directory + .EnumerateDirectories(languagesDir) + .OrderBy(d => d, StringComparer.Ordinal) + ) { var code = Path.GetFileName(languageDir); if (!_languages.TryGetValue(code, out var strings)) @@ -55,7 +60,8 @@ public sealed class LanguageManager _languages.Add(code, strings); } - var files = Directory.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories) + var files = Directory + .EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories) .OrderBy(f => f, StringComparer.Ordinal); foreach (var file in files) { @@ -86,12 +92,18 @@ public sealed class LanguageManager /// Returns the string for : current language → default language → the key itself. public string Get(string key) { - if (_languages.TryGetValue(CurrentLanguage, out var current) && current.TryGetValue(key, out var value)) + if ( + _languages.TryGetValue(CurrentLanguage, out var current) + && current.TryGetValue(key, out var value) + ) { return value; } - if (_languages.TryGetValue(DefaultLanguage, out var fallback) && fallback.TryGetValue(key, out value)) + if ( + _languages.TryGetValue(DefaultLanguage, out var fallback) + && fallback.TryGetValue(key, out value) + ) { return value; } @@ -108,31 +120,41 @@ public sealed class LanguageManager JsonNode root; try { - root = JsonNode.Parse( - File.ReadAllText(file), - documentOptions: new JsonDocumentOptions - { - CommentHandling = JsonCommentHandling.Skip, - AllowTrailingCommas = true, - }) ?? throw new InvalidDataException("file is empty"); + root = + JsonNode.Parse( + File.ReadAllText(file), + documentOptions: new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + } + ) ?? throw new InvalidDataException("file is empty"); } catch (JsonException exception) { throw new InvalidDataException( - $"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}", exception); + $"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}", + exception + ); } if (root is not JsonObject map) { - throw new InvalidDataException($"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object."); + throw new InvalidDataException( + $"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object." + ); } foreach (var (key, value) in map) { - if (value is not JsonValue jsonValue || jsonValue.GetValueKind() != JsonValueKind.String) + if ( + value is not JsonValue jsonValue + || jsonValue.GetValueKind() != JsonValueKind.String + ) { throw new InvalidDataException( - $"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string."); + $"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string." + ); } strings[key] = jsonValue.GetValue(); // поздний мод переопределяет ключ diff --git a/src/MrGameEng.Mods/ModContentTree.cs b/src/MrGameEng.Mods/ModContentTree.cs index 461db86..bb458b9 100644 --- a/src/MrGameEng.Mods/ModContentTree.cs +++ b/src/MrGameEng.Mods/ModContentTree.cs @@ -33,7 +33,11 @@ public sealed class ModContentTree /// (in load order). With only matching files are included /// (e.g. ".png"); without them, every file. /// - public static ModContentTree Build(IReadOnlyList mods, string contentFolder, params string[] extensions) + public static ModContentTree Build( + IReadOnlyList mods, + string contentFolder, + params string[] extensions + ) { var files = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var mod in mods) @@ -44,10 +48,17 @@ public sealed class ModContentTree continue; } - foreach (var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + foreach ( + var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + ) { - if (extensions.Length > 0 && - !extensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase)) + if ( + extensions.Length > 0 + && !extensions.Contains( + Path.GetExtension(fullPath), + StringComparer.OrdinalIgnoreCase + ) + ) { continue; } diff --git a/src/MrGameEng.Mods/ModLoader.cs b/src/MrGameEng.Mods/ModLoader.cs index 64d7f3f..901e9f1 100644 --- a/src/MrGameEng.Mods/ModLoader.cs +++ b/src/MrGameEng.Mods/ModLoader.cs @@ -18,7 +18,11 @@ public static class ModLoader /// public static string? FindModsRoot(string startDirectory) { - for (var dir = new DirectoryInfo(Path.GetFullPath(startDirectory)); dir is not null; dir = dir.Parent) + for ( + var dir = new DirectoryInfo(Path.GetFullPath(startDirectory)); + dir is not null; + dir = dir.Parent + ) { var candidate = Path.Combine(dir.FullName, "Mods"); if (Directory.Exists(candidate)) @@ -56,7 +60,9 @@ public static class ModLoader { if (!discovered.TryGetValue(id, out var mod)) { - throw new InvalidDataException($"Active mod '{id}' is not installed under '{modsRoot}'."); + throw new InvalidDataException( + $"Active mod '{id}' is not installed under '{modsRoot}'." + ); } active.Add(mod); @@ -82,12 +88,18 @@ public static class ModLoader ModInfo info; try { - info = JsonSerializer.Deserialize(File.ReadAllText(aboutPath), ModInfo.JsonOptions) - ?? throw new InvalidDataException("About.json deserialized to null."); + info = + JsonSerializer.Deserialize( + File.ReadAllText(aboutPath), + ModInfo.JsonOptions + ) ?? throw new InvalidDataException("About.json deserialized to null."); } catch (JsonException exception) { - throw new InvalidDataException($"Invalid mod metadata '{aboutPath}': {exception.Message}", exception); + throw new InvalidDataException( + $"Invalid mod metadata '{aboutPath}': {exception.Message}", + exception + ); } if (string.IsNullOrWhiteSpace(info.Id)) @@ -98,7 +110,8 @@ public static class ModLoader if (discovered.TryGetValue(info.Id, out var existing)) { throw new InvalidDataException( - $"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'."); + $"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'." + ); } discovered.Add(info.Id, new Mod(info, dir)); @@ -131,7 +144,8 @@ public static class ModLoader if (!byId.TryGetValue(dependency, out var parent)) { throw new InvalidDataException( - $"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active."); + $"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active." + ); } Visit(parent); diff --git a/src/MrGameEng.Mods/MrGameEng.Mods.csproj b/src/MrGameEng.Mods/MrGameEng.Mods.csproj index d3fbd54..fa28570 100644 --- a/src/MrGameEng.Mods/MrGameEng.Mods.csproj +++ b/src/MrGameEng.Mods/MrGameEng.Mods.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -11,5 +10,4 @@ - diff --git a/src/MrGameEng.Pathfinding/FlowField.cs b/src/MrGameEng.Pathfinding/FlowField.cs index 734e679..05bcfeb 100644 --- a/src/MrGameEng.Pathfinding/FlowField.cs +++ b/src/MrGameEng.Pathfinding/FlowField.cs @@ -86,8 +86,9 @@ public sealed class FlowFieldBuilder if (_grid.Width != _width || _grid.Height != _height) { throw new InvalidOperationException( - $"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " + - "create a new FlowFieldBuilder for the resized grid."); + $"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " + + "create a new FlowFieldBuilder for the resized grid." + ); } field.EnsureSize(_width, _height); @@ -107,8 +108,13 @@ public sealed class FlowFieldBuilder foreach (var goal in goals) { - if (goal.X >= 0 && goal.X < _width && goal.Y >= 0 && goal.Y < _height && - _grid.IsPassable(goal.X, goal.Y)) + if ( + goal.X >= 0 + && goal.X < _width + && goal.Y >= 0 + && goal.Y < _height + && _grid.IsPassable(goal.X, goal.Y) + ) { var index = goal.Y * _width + goal.X; distances[index] = 0f; @@ -139,7 +145,8 @@ public sealed class FlowFieldBuilder } var neighbor = ny * _width + nx; - var tentative = distances[current] + (d < 4 ? 1f : DiagonalCost) * _grid.Cost(nx, ny); + var tentative = + distances[current] + (d < 4 ? 1f : DiagonalCost) * _grid.Cost(nx, ny); if (tentative < distances[neighbor]) { distances[neighbor] = tentative; diff --git a/src/MrGameEng.Pathfinding/GridPathfinder.cs b/src/MrGameEng.Pathfinding/GridPathfinder.cs index 6edfe81..8dfabad 100644 --- a/src/MrGameEng.Pathfinding/GridPathfinder.cs +++ b/src/MrGameEng.Pathfinding/GridPathfinder.cs @@ -67,18 +67,28 @@ public sealed class GridPathfinder /// and writes it into . Returns false when no path exists; /// the list is cleared either way. /// - public bool FindPath(Point start, Point goal, List path, PathAlgorithm algorithm = PathAlgorithm.AStar) + public bool FindPath( + Point start, + Point goal, + List path, + PathAlgorithm algorithm = PathAlgorithm.AStar + ) { if (_grid.Width != _width || _grid.Height != _height) { throw new InvalidOperationException( - $"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " + - "create a new GridPathfinder for the resized grid."); + $"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " + + "create a new GridPathfinder for the resized grid." + ); } path.Clear(); - if (!InBounds(start) || !InBounds(goal) || - !_grid.IsPassable(start.X, start.Y) || !_grid.IsPassable(goal.X, goal.Y)) + if ( + !InBounds(start) + || !InBounds(goal) + || !_grid.IsPassable(start.X, start.Y) + || !_grid.IsPassable(goal.X, goal.Y) + ) { return false; } diff --git a/src/MrGameEng.Pathfinding/MrGameEng.Pathfinding.csproj b/src/MrGameEng.Pathfinding/MrGameEng.Pathfinding.csproj index 87669eb..2586393 100644 --- a/src/MrGameEng.Pathfinding/MrGameEng.Pathfinding.csproj +++ b/src/MrGameEng.Pathfinding/MrGameEng.Pathfinding.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -7,5 +6,4 @@ - diff --git a/src/MrGameEng.Tilemaps/MrGameEng.Tilemaps.csproj b/src/MrGameEng.Tilemaps/MrGameEng.Tilemaps.csproj index be26e79..f4b0897 100644 --- a/src/MrGameEng.Tilemaps/MrGameEng.Tilemaps.csproj +++ b/src/MrGameEng.Tilemaps/MrGameEng.Tilemaps.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -12,5 +11,4 @@ - diff --git a/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs b/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs index e52cb79..7a226b6 100644 --- a/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs +++ b/src/MrGameEng.Tilemaps/SceneTilemapExtensions.cs @@ -12,8 +12,11 @@ public static class SceneTilemapExtensions /// public static void UseTilemaps(this Scene scene) { - var renderer = scene.Context.Services.GetOrDefault() - ?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first."); + var renderer = + scene.Context.Services.GetOrDefault() + ?? throw new InvalidOperationException( + "UseTilemaps requires UseRenderer2D to be called first." + ); var systems = scene.DrawSystems.ChildSystems; for (var i = 0; i < systems.Count; i++) @@ -25,6 +28,8 @@ public static class SceneTilemapExtensions } } - throw new InvalidOperationException("RenderFlushSystem not found (is UseRenderer2D wired on this scene?)."); + throw new InvalidOperationException( + "RenderFlushSystem not found (is UseRenderer2D wired on this scene?)." + ); } } diff --git a/src/MrGameEng.Tilemaps/TileGrid.cs b/src/MrGameEng.Tilemaps/TileGrid.cs index 08b9818..09b505f 100644 --- a/src/MrGameEng.Tilemaps/TileGrid.cs +++ b/src/MrGameEng.Tilemaps/TileGrid.cs @@ -53,7 +53,9 @@ public sealed class TileGrid if (!Contains(x, y)) { throw new ArgumentOutOfRangeException( - nameof(x), $"Cell ({x},{y}) is outside the {Width}x{Height} grid."); + nameof(x), + $"Cell ({x},{y}) is outside the {Width}x{Height} grid." + ); } } } diff --git a/src/MrGameEng.Tilemaps/TileSet.cs b/src/MrGameEng.Tilemaps/TileSet.cs index 543b6cf..4e035d2 100644 --- a/src/MrGameEng.Tilemaps/TileSet.cs +++ b/src/MrGameEng.Tilemaps/TileSet.cs @@ -10,9 +10,7 @@ public readonly record struct TileDef(Texture2DRegion Region, Color Color) { /// Creates an untinted tile. public TileDef(Texture2DRegion region) - : this(region, Color.White) - { - } + : this(region, Color.White) { } } /// diff --git a/src/MrGameEng.Tilemaps/TilemapMath.cs b/src/MrGameEng.Tilemaps/TilemapMath.cs index 0394520..1d25c0f 100644 --- a/src/MrGameEng.Tilemaps/TilemapMath.cs +++ b/src/MrGameEng.Tilemaps/TilemapMath.cs @@ -11,8 +11,16 @@ public static class TilemapMath /// Returns false when the map is entirely outside the rectangle. /// public static bool VisibleCells( - in RectF cullRect, Vector2 origin, float tileSize, int width, int height, - out int x0, out int y0, out int x1, out int y1) + in RectF cullRect, + Vector2 origin, + float tileSize, + int width, + int height, + out int x0, + out int y0, + out int x1, + out int y1 + ) { x0 = Math.Max(0, (int)MathF.Floor((cullRect.Left - origin.X) / tileSize)); y0 = Math.Max(0, (int)MathF.Floor((cullRect.Top - origin.Y) / tileSize)); diff --git a/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs b/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs index 89b7afb..b92efc6 100644 --- a/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs +++ b/src/MrGameEng.Tilemaps/TilemapRenderSystem.cs @@ -25,7 +25,11 @@ public sealed class TilemapRenderSystem : QuerySystem { foreach (ref readonly var map in maps.Span) { - if (map.Grid is not { } grid || map.TileSet is not { } tileSet || map.TileSize <= 0f) + if ( + map.Grid is not { } grid + || map.TileSet is not { } tileSet + || map.TileSize <= 0f + ) { continue; } @@ -36,9 +40,19 @@ public sealed class TilemapRenderSystem : QuerySystem // Screen-space слои не куллятся камерой — рисуем весь грид. SubmitRange(in map, grid, tileSet, 0, 0, grid.Width - 1, grid.Height - 1); } - else if (TilemapMath.VisibleCells( - in cullRect, map.Origin, map.TileSize, grid.Width, grid.Height, - out var x0, out var y0, out var x1, out var y1)) + else if ( + TilemapMath.VisibleCells( + in cullRect, + map.Origin, + map.TileSize, + grid.Width, + grid.Height, + out var x0, + out var y0, + out var x1, + out var y1 + ) + ) { SubmitRange(in map, grid, tileSet, x0, y0, x1, y1); } @@ -46,7 +60,15 @@ public sealed class TilemapRenderSystem : QuerySystem } } - private void SubmitRange(in Tilemap map, TileGrid grid, TileSet tileSet, int x0, int y0, int x1, int y1) + private void SubmitRange( + in Tilemap map, + TileGrid grid, + TileSet tileSet, + int x0, + int y0, + int x1, + int y1 + ) { for (var y = y0; y <= y1; y++) { @@ -62,12 +84,14 @@ public sealed class TilemapRenderSystem : QuerySystem var region = def.Region; var transform = new Transform2D( map.Origin + new Vector2(x, y) * map.TileSize, - scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height)); + scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height) + ); var sprite = new Sprite(region, map.Layer) { - Color = map.Color == Color.White - ? def.Color - : new Color(def.Color.ToVector4() * map.Color.ToVector4()), + Color = + map.Color == Color.White + ? def.Color + : new Color(def.Color.ToVector4() * map.Color.ToVector4()), Depth = map.Depth, }; _renderer.Submit(in transform, in sprite); diff --git a/src/MrGameEng.UI/MrGameEng.UI.csproj b/src/MrGameEng.UI/MrGameEng.UI.csproj index b87d0bc..0ea8a0a 100644 --- a/src/MrGameEng.UI/MrGameEng.UI.csproj +++ b/src/MrGameEng.UI/MrGameEng.UI.csproj @@ -1,5 +1,4 @@ - net8.0 @@ -11,5 +10,4 @@ - diff --git a/src/MrGameEng.UI/SceneUiExtensions.cs b/src/MrGameEng.UI/SceneUiExtensions.cs index 8578cac..7c62327 100644 --- a/src/MrGameEng.UI/SceneUiExtensions.cs +++ b/src/MrGameEng.UI/SceneUiExtensions.cs @@ -1,8 +1,8 @@ using Friflo.Engine.ECS.Systems; using Microsoft.Xna.Framework; +using MrGameEng.Core; using Myra; using Myra.Graphics2D.UI; -using MrGameEng.Core; namespace MrGameEng.UI; diff --git a/tests/MrGameEng.AI.Tests/BlackboardTests.cs b/tests/MrGameEng.AI.Tests/BlackboardTests.cs new file mode 100644 index 0000000..1628844 --- /dev/null +++ b/tests/MrGameEng.AI.Tests/BlackboardTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Xna.Framework; +using MrGameEng.AI; +using Xunit; + +namespace MrGameEng.AI.Tests; + +public class BlackboardTests +{ + [Fact] + public void SetThenTryGet_RoundTripsTypedValues() + { + var board = new Blackboard(); + board.Set("target", new Vector2(3f, 4f)); + + Assert.True(board.TryGet("target", out var value)); + Assert.Equal(new Vector2(3f, 4f), value); + } + + [Fact] + public void TryGet_ReturnsFalse_OnMissingKeyOrTypeMismatch() + { + var board = new Blackboard(); + board.Set("count", 5); + + Assert.False(board.TryGet("missing", out _)); + Assert.False(board.TryGet("count", out _)); // wrong type + } + + [Fact] + public void GetOrDefault_FallsBackWhenAbsent() + { + var board = new Blackboard(); + + Assert.Equal(42, board.GetOrDefault("hp", 42)); + board.Set("hp", 7); + Assert.Equal(7, board.GetOrDefault("hp", 42)); + } + + [Fact] + public void Set_OverwritesExistingValue() + { + var board = new Blackboard(); + board.Set("k", 1); + board.Set("k", 2); + + Assert.Equal(2, board.GetOrDefault("k", 0)); + Assert.Equal(1, board.Count); + } + + [Fact] + public void RemoveAndClear_DropKeys() + { + var board = new Blackboard(); + board.Set("a", 1); + board.Set("b", 2); + + Assert.True(board.Remove("a")); + Assert.False(board.Remove("a")); + Assert.True(board.Has("b")); + + board.Clear(); + Assert.Equal(0, board.Count); + } +} diff --git a/tests/MrGameEng.AI.Tests/MrGameEng.AI.Tests.csproj b/tests/MrGameEng.AI.Tests/MrGameEng.AI.Tests.csproj new file mode 100644 index 0000000..7cc33de --- /dev/null +++ b/tests/MrGameEng.AI.Tests/MrGameEng.AI.Tests.csproj @@ -0,0 +1,17 @@ + + + net8.0 + Exe + false + + + + + + + + + + + + diff --git a/tests/MrGameEng.AI.Tests/ResponseCurveTests.cs b/tests/MrGameEng.AI.Tests/ResponseCurveTests.cs new file mode 100644 index 0000000..ed6e5b4 --- /dev/null +++ b/tests/MrGameEng.AI.Tests/ResponseCurveTests.cs @@ -0,0 +1,75 @@ +using MrGameEng.AI; +using Xunit; + +namespace MrGameEng.AI.Tests; + +public class ResponseCurveTests +{ + [Fact] + public void Identity_ReturnsInputUnchanged() + { + var curve = ResponseCurve.Identity; + + Assert.Equal(0f, curve.Evaluate(0f)); + Assert.Equal(0.5f, curve.Evaluate(0.5f), 5); + Assert.Equal(1f, curve.Evaluate(1f)); + } + + [Fact] + public void Default_HasZeroSlope_SoIdentityMustBeUsedExplicitly() + { + // Guards the gotcha that default(ResponseCurve) is NOT the identity curve. + var zero = default(ResponseCurve); + + Assert.Equal(0f, zero.Evaluate(0.5f)); + } + + [Fact] + public void Evaluate_ClampsInputAndOutputToUnitRange() + { + var curve = ResponseCurve.Linear(); + + Assert.Equal(0f, curve.Evaluate(-2f)); + Assert.Equal(1f, curve.Evaluate(5f)); + } + + [Fact] + public void Linear_NegativeSlope_InvertsTheInput() + { + var curve = ResponseCurve.Linear(slope: -1f, yShift: 1f); + + Assert.Equal(1f, curve.Evaluate(0f), 5); + Assert.Equal(0.75f, curve.Evaluate(0.25f), 5); + Assert.Equal(0f, curve.Evaluate(1f), 5); + } + + [Fact] + public void Polynomial_Quadratic_EasesInFromZero() + { + var curve = ResponseCurve.Polynomial(exponent: 2f); + + Assert.Equal(0.25f, curve.Evaluate(0.5f), 5); + Assert.True(curve.Evaluate(0.25f) < 0.25f); // below the linear line: slow start + } + + [Fact] + public void Logistic_IsMonotonicAndCentredOnMidpoint() + { + var curve = ResponseCurve.Logistic(steepness: 12f, midpoint: 0.5f); + + Assert.Equal(0.5f, curve.Evaluate(0.5f), 2); + Assert.True(curve.Evaluate(0.2f) < 0.5f); + Assert.True(curve.Evaluate(0.8f) > 0.5f); + Assert.True(curve.Evaluate(0.6f) > curve.Evaluate(0.4f)); + } + + [Fact] + public void SmoothStep_IsFlatAtTheEnds() + { + var curve = ResponseCurve.SmoothStep(); + + Assert.Equal(0f, curve.Evaluate(0f), 5); + Assert.Equal(1f, curve.Evaluate(1f), 5); + Assert.Equal(0.5f, curve.Evaluate(0.5f), 5); + } +} diff --git a/tests/MrGameEng.AI.Tests/UtilityAiTests.cs b/tests/MrGameEng.AI.Tests/UtilityAiTests.cs new file mode 100644 index 0000000..9f4778a --- /dev/null +++ b/tests/MrGameEng.AI.Tests/UtilityAiTests.cs @@ -0,0 +1,168 @@ +using MrGameEng.AI; +using Xunit; + +namespace MrGameEng.AI.Tests; + +public class UtilityAiTests +{ + // A minimal agent context: everything the considerations read. + private record struct Ctx(float Energy, float Hunger); + + private static UtilityAi BuildBrain() + { + // Rest gets attractive as energy drops; wander as energy is high. + var rest = new UtilityAction( + "rest", + new Consideration( + "tired", + c => c.Energy, + 0f, + 1f, + ResponseCurve.Linear(slope: -1f, yShift: 1f) + ) + ); + var wander = new UtilityAction( + "wander", + new Consideration("rested", c => c.Energy) + ); + return new UtilityAi(rest, wander); + } + + [Fact] + public void Select_PicksTheHighestScoringAction() + { + var brain = BuildBrain(); + + Assert.Equal("rest", brain.Select(new Ctx(Energy: 0.1f, Hunger: 0f))!.Name); + Assert.Equal("wander", brain.Select(new Ctx(Energy: 0.9f, Hunger: 0f))!.Name); + } + + [Fact] + public void Select_IsDeterministicAcrossRepeatedCalls() + { + var brain = BuildBrain(); + var ctx = new Ctx(Energy: 0.3f, Hunger: 0.5f); + + var first = brain.Select(ctx)!.Name; + for (var i = 0; i < 100; i++) + { + Assert.Equal(first, brain.Select(ctx)!.Name); + } + } + + [Fact] + public void Select_TieResolvesToEarliestAction() + { + // Two actions that always score equally; the first declared must win. + var a = new UtilityAction("a", new Consideration("k", _ => 0.5f)); + var b = new UtilityAction("b", new Consideration("k", _ => 0.5f)); + var brain = new UtilityAi(a, b); + + Assert.Equal("a", brain.Select(default)!.Name); + } + + [Fact] + public void Select_ReturnsNull_WhenNothingBeatsThreshold() + { + var brain = BuildBrain(); + + Assert.Null(brain.Select(new Ctx(Energy: 0.5f, Hunger: 0f), threshold: 0.99f)); + } + + [Fact] + public void Select_PopulatesLastScoresAlignedWithActions() + { + var brain = BuildBrain(); + + brain.Select(new Ctx(Energy: 0.2f, Hunger: 0f)); + + Assert.Equal(2, brain.LastScores.Length); + Assert.True(brain.LastScores[0] > brain.LastScores[1]); // rest scores above wander + } + + [Fact] + public void VetoConsideration_ZeroesTheAction() + { + var action = new UtilityAction( + "eat", + new Consideration("has-food", _ => 0f), // veto: no food + new Consideration("hungry", _ => 1f) + ); + + Assert.Equal(0f, action.Score(default)); + } + + [Fact] + public void Weight_ScalesTheActionScore() + { + var low = new UtilityAction("a", 0.5f, new Consideration("k", _ => 0.4f)); + var high = new UtilityAction("a", 2f, new Consideration("k", _ => 0.4f)); + + Assert.True(high.Score(default) > low.Score(default)); + } + + [Fact] + public void SelectWeighted_IsReproducibleForTheSameSeed() + { + var brain = BuildBrain(); + var ctx = new Ctx(Energy: 0.5f, Hunger: 0f); + + var first = Run(new Random(1234)); + var second = Run(new Random(1234)); + Assert.Equal(first, second); + + List Run(Random random) + { + var picks = new List(); + for (var i = 0; i < 50; i++) + { + picks.Add(brain.SelectWeighted(ctx, random)!.Name); + } + + return picks; + } + } + + [Fact] + public void SelectWeighted_FavoursTheHigherScoreOverManyRolls() + { + var brain = BuildBrain(); + var ctx = new Ctx(Energy: 0.1f, Hunger: 0f); // rest should dominate + var random = new Random(7); + + var rest = 0; + for (var i = 0; i < 1000; i++) + { + if (brain.SelectWeighted(ctx, random)!.Name == "rest") + { + rest++; + } + } + + Assert.True(rest > 800, $"expected rest to dominate, got {rest}/1000"); + } + + [Fact] + public void Constructor_Throws_WhenNoActions() + { + Assert.Throws(() => new UtilityAi()); + } + + [Fact] + public void Consideration_Throws_WhenRangeIsDegenerate() + { + Assert.Throws(() => + new Consideration("bad", c => c.Energy, min: 1f, max: 1f) + ); + } + + [Fact] + public void Consideration_NormalizesRawValuesAgainstItsRange() + { + var c = new Consideration("hunger", x => x.Hunger, min: 0f, max: 200f); + + Assert.Equal(0f, c.Score(new Ctx(0f, 0f)), 5); + Assert.Equal(0.5f, c.Score(new Ctx(0f, 100f)), 5); + Assert.Equal(1f, c.Score(new Ctx(0f, 9999f)), 5); // clamps above range + } +} diff --git a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs index 6a8a71e..a3a56e7 100644 --- a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs +++ b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs @@ -23,7 +23,8 @@ public class AssetHandlesGeneratorTests values.TryGetValue(key, out value!); } - private sealed class FakeOptionsProvider(Dictionary values) : AnalyzerConfigOptionsProvider + private sealed class FakeOptionsProvider(Dictionary values) + : AnalyzerConfigOptionsProvider { public override AnalyzerConfigOptions GlobalOptions { get; } = new FakeOptions(values); @@ -36,11 +37,18 @@ public class AssetHandlesGeneratorTests { var driver = CSharpGeneratorDriver.Create( [new AssetHandlesGenerator().AsSourceGenerator()], - additionalTexts: Array.ConvertAll(files, f => (AdditionalText)new FakeAdditionalText(f)), - optionsProvider: new FakeOptionsProvider(options ?? new Dictionary - { - ["build_property.RootNamespace"] = "MyGame", - })); + additionalTexts: Array.ConvertAll( + files, + f => (AdditionalText)new FakeAdditionalText(f) + ), + optionsProvider: new FakeOptionsProvider( + options + ?? new Dictionary + { + ["build_property.RootNamespace"] = "MyGame", + } + ) + ); var compilation = CSharpCompilation.Create("test"); var result = driver.RunGenerators(compilation).GetRunResult(); @@ -50,8 +58,7 @@ public class AssetHandlesGeneratorTests [Fact] public void GeneratesTypedHandles_ForKnownExtensions() { - var source = RunGenerator( - [ + var source = RunGenerator([ @"D:\game\Assets\Textures\player.png", @"D:\game\Assets\Sounds\jump.wav", @"D:\game\Assets\Fonts\main.ttf", @@ -63,10 +70,12 @@ public class AssetHandlesGeneratorTests Assert.Contains("public static class Textures", source); Assert.Contains( "AssetRef Player = new(\"Textures/player.png\")", - source); + source + ); Assert.Contains( "AssetRef Jump = new(\"Sounds/jump.wav\")", - source); + source + ); Assert.Contains("AssetRef Main", source); Assert.Contains("AssetRef Theme", source); } @@ -74,8 +83,7 @@ public class AssetHandlesGeneratorTests [Fact] public void IgnoresUnknownExtensions_AndFilesOutsideAssets() { - var source = RunGenerator( - [ + var source = RunGenerator([ @"D:\game\Assets\readme.md", @"D:\game\Other\image.png", @"D:\game\Assets\valid.png", @@ -89,8 +97,7 @@ public class AssetHandlesGeneratorTests [Fact] public void AtlasFiles_GetTextureAtlasHandles_AndPagesAreExcluded() { - var source = RunGenerator( - [ + var source = RunGenerator([ @"D:\game\Assets\Atlases\Things.Pawn.atlas", @"D:\game\Assets\Atlases\Things.Pawn.atlas.0.png", @"D:\game\Assets\Atlases\Things.Pawn.atlas.1.png", @@ -98,7 +105,8 @@ public class AssetHandlesGeneratorTests Assert.Contains( "AssetRef ThingsPawn = new(\"Atlases/Things.Pawn.atlas\")", - source); + source + ); Assert.DoesNotContain("Texture2D> ThingsPawn", source); } @@ -125,15 +133,33 @@ public class AssetHandlesGeneratorTests var compilation = CSharpCompilation.Create( "generated", - [CSharpSyntaxTree.ParseText(source, cancellationToken: TestContext.Current.CancellationToken), - CSharpSyntaxTree.ParseText(stubs, cancellationToken: TestContext.Current.CancellationToken)], - [MetadataReference.CreateFromFile(typeof(object).Assembly.Location), - MetadataReference.CreateFromFile(System.Runtime.Loader.AssemblyLoadContext.Default - .LoadFromAssemblyName(new System.Reflection.AssemblyName("System.Runtime")).Location)], - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + [ + CSharpSyntaxTree.ParseText( + source, + cancellationToken: TestContext.Current.CancellationToken + ), + CSharpSyntaxTree.ParseText( + stubs, + cancellationToken: TestContext.Current.CancellationToken + ), + ], + [ + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + MetadataReference.CreateFromFile( + System + .Runtime.Loader.AssemblyLoadContext.Default.LoadFromAssemblyName( + new System.Reflection.AssemblyName("System.Runtime") + ) + .Location + ), + ], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); - var errors = compilation.GetDiagnostics(TestContext.Current.CancellationToken) - .Where(d => d.Severity == DiagnosticSeverity.Error).ToList(); + var errors = compilation + .GetDiagnostics(TestContext.Current.CancellationToken) + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToList(); Assert.Empty(errors); } @@ -162,7 +188,10 @@ public class AssetHandlesGeneratorTests [InlineData(@"D:\game\Assets\icon.png", @"D:\game\", "icon.png")] [InlineData(@"D:\game\Other\icon.png", @"D:\game", null)] public void ToAssetPath_WithProjectDir_RootsAtProjectAssetsFolder( - string fullPath, string projectDir, string? expected) + string fullPath, + string projectDir, + string? expected + ) { Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath, projectDir)); } diff --git a/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj b/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj index a6c000d..abe1c8e 100644 --- a/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj +++ b/tests/MrGameEng.Assets.Generator.Tests/MrGameEng.Assets.Generator.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -16,5 +15,4 @@ - diff --git a/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs b/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs index 6bde350..58a1142 100644 --- a/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs +++ b/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs @@ -14,7 +14,15 @@ public sealed class AtlasBuilderTests : IDisposable public void Dispose() => Directory.Delete(_root, recursive: true); /// Writes a PNG filled with one RGBA color. - private void WritePng(string relativePath, int width, int height, byte r, byte g, byte b, byte a = 255) + private void WritePng( + string relativePath, + int width, + int height, + byte r, + byte g, + byte b, + byte a = 255 + ) { var fullPath = Path.Combine(SourceDir, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); @@ -28,25 +36,37 @@ public sealed class AtlasBuilderTests : IDisposable } using var stream = File.Create(fullPath); - new ImageWriter().WritePng(data, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream); + new ImageWriter().WritePng( + data, + width, + height, + StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, + stream + ); } - private AtlasBuildOptions Options(int groupDepth = 1, bool force = false) => new() - { - SourceDirectory = SourceDir, - OutputDirectory = OutputDir, - GroupDepth = groupDepth, - MaxPageSize = 128, - Padding = 2, - Force = force, - }; + private AtlasBuildOptions Options(int groupDepth = 1, bool force = false) => + new() + { + SourceDirectory = SourceDir, + OutputDirectory = OutputDir, + GroupDepth = groupDepth, + MaxPageSize = 128, + Padding = 2, + Force = force, + }; [Theory] [InlineData("Terrain/Surfaces/Marsh.png", 1, "Terrain", "Terrain/Surfaces/Marsh")] [InlineData("Terrain/Surfaces/Marsh.png", 2, "Terrain.Surfaces", "Terrain/Surfaces/Marsh")] [InlineData("Terrain/Surfaces/Marsh.png", 0, "Root", "Terrain/Surfaces/Marsh")] [InlineData("loose.png", 3, "Root", "loose")] - public void ClassifyPath_GroupsByDepth(string path, int depth, string expectedAtlas, string expectedKey) + public void ClassifyPath_GroupsByDepth( + string path, + int depth, + string expectedAtlas, + string expectedKey + ) { var (atlas, key) = AtlasBuilder.ClassifyPath(path, depth, "Root"); @@ -67,13 +87,18 @@ public sealed class AtlasBuilderTests : IDisposable Assert.Equal(2, group.RegionCount); Assert.False(group.Skipped); - var metadata = AtlasMetadata.FromJson(File.ReadAllText(Path.Combine(OutputDir, "Terrain.atlas"))); + var metadata = AtlasMetadata.FromJson( + File.ReadAllText(Path.Combine(OutputDir, "Terrain.atlas")) + ); Assert.Equal(["Terrain/Grass", "Terrain/Water"], metadata.Regions.Select(x => x.Key)); var grass = metadata.Regions.Single(x => x.Key == "Terrain/Grass"); var page = metadata.Pages[grass.Page]; using var stream = File.OpenRead(Path.Combine(OutputDir, page.File)); - var pixels = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha); + var pixels = ImageResult.FromStream( + stream, + StbImageSharp.ColorComponents.RedGreenBlueAlpha + ); // Центральный пиксель региона должен быть цветом исходной картинки. var center = ((grass.Y + 8) * pixels.Width + grass.X + 8) * 4; @@ -101,7 +126,9 @@ public sealed class AtlasBuilderTests : IDisposable AtlasBuilder.Build(Options()); File.SetLastWriteTimeUtc( - Path.Combine(SourceDir, "UI/button.png"), DateTime.UtcNow.AddMinutes(1)); + Path.Combine(SourceDir, "UI/button.png"), + DateTime.UtcNow.AddMinutes(1) + ); var result = AtlasBuilder.Build(Options()); Assert.False(Assert.Single(result.Groups).Skipped); @@ -192,8 +219,27 @@ public sealed class AtlasBuilderTests : IDisposable Name = "Things.Pawn", PageSize = 2048, Padding = 2, - Pages = [new AtlasPage { File = "Things.Pawn.atlas.0.png", Width = 256, Height = 128 }], - Regions = [new AtlasRegion { Key = "Things/Pawn/Fox", Page = 0, X = 2, Y = 4, Width = 64, Height = 32 }], + Pages = + [ + new AtlasPage + { + File = "Things.Pawn.atlas.0.png", + Width = 256, + Height = 128, + }, + ], + Regions = + [ + new AtlasRegion + { + Key = "Things/Pawn/Fox", + Page = 0, + X = 2, + Y = 4, + Width = 64, + Height = 32, + }, + ], }; var parsed = AtlasMetadata.FromJson(metadata.ToJson()); @@ -203,8 +249,10 @@ public sealed class AtlasBuilderTests : IDisposable var page = Assert.Single(parsed.Pages); Assert.Equal(("Things.Pawn.atlas.0.png", 256, 128), (page.File, page.Width, page.Height)); var region = Assert.Single(parsed.Regions); - Assert.Equal(("Things/Pawn/Fox", 0, 2, 4, 64, 32), - (region.Key, region.Page, region.X, region.Y, region.Width, region.Height)); + Assert.Equal( + ("Things/Pawn/Fox", 0, 2, 4, 64, 32), + (region.Key, region.Page, region.X, region.Y, region.Width, region.Height) + ); } [Fact] @@ -213,10 +261,26 @@ public sealed class AtlasBuilderTests : IDisposable var metadata = new AtlasMetadata { Name = "Test", - Pages = [new AtlasPage { File = "Test.atlas.0.png", Width = 64, Height = 64 }], + Pages = + [ + new AtlasPage + { + File = "Test.atlas.0.png", + Width = 64, + Height = 64, + }, + ], Regions = [ - new AtlasRegion { Key = "a/b", Page = 0, X = 2, Y = 2, Width = 10, Height = 12 }, + new AtlasRegion + { + Key = "a/b", + Page = 0, + X = 2, + Y = 2, + Width = 10, + Height = 12, + }, ], }; diff --git a/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj b/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj index 7592188..492b8ca 100644 --- a/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj +++ b/tests/MrGameEng.Atlases.Tests/MrGameEng.Atlases.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs b/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs index 638afc2..638e745 100644 --- a/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs +++ b/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs @@ -46,8 +46,10 @@ public class ShelfPackerTests var a = list[i]; var b = list[j]; var separated = - a.X + a.Width + 2 <= b.X || b.X + b.Width + 2 <= a.X || - a.Y + a.Height + 2 <= b.Y || b.Y + b.Height + 2 <= a.Y; + a.X + a.Width + 2 <= b.X + || b.X + b.Width + 2 <= a.X + || a.Y + a.Height + 2 <= b.Y + || b.Y + b.Height + 2 <= a.Y; Assert.True(separated, $"{a.Key} overlaps {b.Key} (padding included)"); } } @@ -61,7 +63,10 @@ public class ShelfPackerTests var result = ShelfPacker.Pack(Squares(9, 100), maxPageSize: 256, padding: 2); Assert.True(result.PageSizes.Count >= 3); - Assert.Equal(Enumerable.Range(0, result.PageSizes.Count), result.Placements.Select(p => p.Page).Distinct().Order()); + Assert.Equal( + Enumerable.Range(0, result.PageSizes.Count), + result.Placements.Select(p => p.Page).Distinct().Order() + ); } [Fact] @@ -80,7 +85,9 @@ public class ShelfPackerTests [Fact] public void Pack_IsDeterministic_RegardlessOfInputOrder() { - var items = Squares(30, 20).Concat(Squares(10, 50).Select(i => i with { Key = "b" + i.Key })).ToList(); + var items = Squares(30, 20) + .Concat(Squares(10, 50).Select(i => i with { Key = "b" + i.Key })) + .ToList(); var shuffled = items.AsEnumerable().Reverse().ToList(); var a = ShelfPacker.Pack(items, 128, 2); @@ -88,7 +95,8 @@ public class ShelfPackerTests Assert.Equal( a.Placements.OrderBy(p => p.Key, StringComparer.Ordinal), - b.Placements.OrderBy(p => p.Key, StringComparer.Ordinal)); + b.Placements.OrderBy(p => p.Key, StringComparer.Ordinal) + ); } [Fact] @@ -115,11 +123,14 @@ public class ShelfPackerTests // 40² с padding на лимит 100: POT-округление дало бы 128 > лимита. var result = ShelfPacker.Pack(Squares(10, 40), maxPageSize: 100, padding: 2); - Assert.All(result.PageSizes, size => - { - Assert.True(size.Width <= 100, $"page width {size.Width} > 100"); - Assert.True(size.Height <= 100, $"page height {size.Height} > 100"); - }); + Assert.All( + result.PageSizes, + size => + { + Assert.True(size.Width <= 100, $"page width {size.Width} > 100"); + Assert.True(size.Height <= 100, $"page height {size.Height} > 100"); + } + ); } [Fact] diff --git a/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs b/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs index f615c0f..95a57fd 100644 --- a/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs +++ b/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs @@ -168,7 +168,9 @@ public class CollisionWorldTests Assert.Equal(nearEntity, hit.Entity); Assert.Equal(25f, hit.Point.X, 1); - Assert.True(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out hit, mask: 0b10)); + Assert.True( + scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out hit, mask: 0b10) + ); Assert.Equal(farEntity, hit.Entity); } diff --git a/tests/MrGameEng.Collisions.Tests/MrGameEng.Collisions.Tests.csproj b/tests/MrGameEng.Collisions.Tests/MrGameEng.Collisions.Tests.csproj index bf04795..99a8245 100644 --- a/tests/MrGameEng.Collisions.Tests/MrGameEng.Collisions.Tests.csproj +++ b/tests/MrGameEng.Collisions.Tests/MrGameEng.Collisions.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj b/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj index 947b90f..8e2f14c 100644 --- a/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj +++ b/tests/MrGameEng.Core.Tests/MrGameEng.Core.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs b/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs index 0bfb85a..70d4151 100644 --- a/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs +++ b/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs @@ -94,7 +94,7 @@ public class DevConsoleTests Assert.Equal("a 1", console.HistoryPrevious()); Assert.Equal("a 1", console.HistoryPrevious()); // упёрлись в начало Assert.Equal("a 2", console.HistoryNext()); - Assert.Equal("", console.HistoryNext()); // за последним — пустая строка + Assert.Equal("", console.HistoryNext()); // за последним — пустая строка } [Fact] diff --git a/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj b/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj index 4291e6b..a150220 100644 --- a/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj +++ b/tests/MrGameEng.DevConsole.Tests/MrGameEng.DevConsole.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs b/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs index 8bc150b..d6cc6e1 100644 --- a/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs +++ b/tests/MrGameEng.Graphics.Tests/CameraMathTests.cs @@ -26,7 +26,12 @@ public class CameraMathTests public void ScreenToWorld_RoundTripsWithWorldToScreen() { var camera = new Camera(new Vector2(123f, -45f), zoom: 1.5f, rotation: 0.3f); - var state = CameraMath.Compute(camera, 1280, 720, new ViewportMapping(new Vector2(0f, 60f), 1.5f)); + var state = CameraMath.Compute( + camera, + 1280, + 720, + new ViewportMapping(new Vector2(0f, 60f), 1.5f) + ); var screen = new Vector2(200f, 500f); var world = state.ScreenToWorld(screen); @@ -49,8 +54,18 @@ public class CameraMathTests [Fact] public void Rotation_ExpandsCullRectToCoverRotatedView() { - var straight = CameraMath.Compute(new Camera(Vector2.Zero), 800, 600, ViewportMapping.Identity); - var rotated = CameraMath.Compute(new Camera(Vector2.Zero, rotation: MathF.PI / 4f), 800, 600, ViewportMapping.Identity); + var straight = CameraMath.Compute( + new Camera(Vector2.Zero), + 800, + 600, + ViewportMapping.Identity + ); + var rotated = CameraMath.Compute( + new Camera(Vector2.Zero, rotation: MathF.PI / 4f), + 800, + 600, + ViewportMapping.Identity + ); Assert.True(rotated.CullRect.Width > straight.CullRect.Width); Assert.True(rotated.CullRect.Height > straight.CullRect.Height); diff --git a/tests/MrGameEng.Graphics.Tests/CullingTests.cs b/tests/MrGameEng.Graphics.Tests/CullingTests.cs index bcf5e0c..864d6e6 100644 --- a/tests/MrGameEng.Graphics.Tests/CullingTests.cs +++ b/tests/MrGameEng.Graphics.Tests/CullingTests.cs @@ -22,7 +22,12 @@ public class CullingTests { var transform = Transform2D.At(new Vector2(50f, 50f)); - var (center, _) = CullingMath.SpriteBoundingCircle(transform, 64f, 32f, new Vector2(32f, 16f)); + var (center, _) = CullingMath.SpriteBoundingCircle( + transform, + 64f, + 32f, + new Vector2(32f, 16f) + ); Assert.Equal(new Vector2(50f, 50f), center); } @@ -41,7 +46,11 @@ public class CullingTests public void BoundingCircle_RegionOverload_MatchesSizeOverload_ForUniformScale() { var region = new Texture2DRegion(null!, new Rectangle(0, 0, 48, 24)); - var transform = new Transform2D(new Vector2(10f, 20f), rotation: 0.6f, scale: new Vector2(1.5f, 1.5f)); + var transform = new Transform2D( + new Vector2(10f, 20f), + rotation: 0.6f, + scale: new Vector2(1.5f, 1.5f) + ); var origin = new Vector2(5f, 7f); var (centerA, radiusA) = CullingMath.SpriteBoundingCircle(transform, 48f, 24f, origin); @@ -59,16 +68,20 @@ public class CullingTests var transform = new Transform2D(Vector2.Zero, scale: new Vector2(1f, 3f)); var (_, exact) = CullingMath.SpriteBoundingCircle(transform, 100f, 10f, Vector2.Zero); - var (_, conservative) = CullingMath.SpriteBoundingCircle(in transform, region, Vector2.Zero); + var (_, conservative) = CullingMath.SpriteBoundingCircle( + in transform, + region, + Vector2.Zero + ); Assert.True(conservative >= exact); } [Theory] - [InlineData(50f, 50f, true)] // inside - [InlineData(-4f, 50f, true)] // touching from the left (radius 5) - [InlineData(-20f, 50f, false)] // far left - [InlineData(50f, 130f, false)] // far below + [InlineData(50f, 50f, true)] // inside + [InlineData(-4f, 50f, true)] // touching from the left (radius 5) + [InlineData(-20f, 50f, false)] // far left + [InlineData(50f, 130f, false)] // far below public void CircleIntersectsRect_DetectsOverlap(float x, float y, bool expected) { var rect = new RectF(0f, 0f, 100f, 100f); diff --git a/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj b/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj index dd65987..df8c6fa 100644 --- a/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj +++ b/tests/MrGameEng.Graphics.Tests/MrGameEng.Graphics.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs b/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs index 1c2d6d3..e590d4c 100644 --- a/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs +++ b/tests/MrGameEng.Graphics.Tests/SortKeyTests.cs @@ -31,7 +31,9 @@ public class SortKeyTests [InlineData(-0.5f, 0.5f)] public void DepthBits_PreserveFloatOrder(float smaller, float larger) { - Assert.True(SpriteSortKey.DepthToSortableBits(smaller) < SpriteSortKey.DepthToSortableBits(larger)); + Assert.True( + SpriteSortKey.DepthToSortableBits(smaller) < SpriteSortKey.DepthToSortableBits(larger) + ); } [Fact] diff --git a/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs b/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs index 2da2830..41ce741 100644 --- a/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs +++ b/tests/MrGameEng.Graphics.Tests/SpriteAnimationTests.cs @@ -42,7 +42,8 @@ public class SpriteAnimationTests this.UseSpriteAnimation(); Animated = Store.CreateEntity( new Sprite { Color = Color.White }, - new SpriteAnimator(Clip)); + new SpriteAnimator(Clip) + ); } } diff --git a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs index 5526ad9..b0a6653 100644 --- a/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs +++ b/tests/MrGameEng.Graphics.Tests/SpriteBatcherTests.cs @@ -122,9 +122,9 @@ public class SpriteBatcherTests Assert.Equal(3, accepted); Assert.Equal(1, batcher.LastChunkCulled); var order = batcher.Sort(); - Assert.Equal(1, batcher[order[0]].Layer); // сначала чанк 0... + Assert.Equal(1, batcher[order[0]].Layer); // сначала чанк 0... Assert.Equal(2, batcher[order[1]].Layer); - Assert.Equal(10, batcher[order[2]].Layer); // ...затем чанк 1 — стабильно + Assert.Equal(10, batcher[order[2]].Layer); // ...затем чанк 1 — стабильно } [Fact] diff --git a/tests/MrGameEng.Input.Tests/InputManagerTests.cs b/tests/MrGameEng.Input.Tests/InputManagerTests.cs index fc97bfc..ff3b8fa 100644 --- a/tests/MrGameEng.Input.Tests/InputManagerTests.cs +++ b/tests/MrGameEng.Input.Tests/InputManagerTests.cs @@ -7,11 +7,28 @@ namespace MrGameEng.Input.Tests; public class InputManagerTests { - private static MouseState Mouse(int x = 0, int y = 0, int wheel = 0, ButtonState left = ButtonState.Released) => - new(x, y, wheel, left, ButtonState.Released, ButtonState.Released, ButtonState.Released, ButtonState.Released); + private static MouseState Mouse( + int x = 0, + int y = 0, + int wheel = 0, + ButtonState left = ButtonState.Released + ) => + new( + x, + y, + wheel, + left, + ButtonState.Released, + ButtonState.Released, + ButtonState.Released, + ButtonState.Released + ); - private static void Frame(InputManager input, KeyboardState keyboard = default, MouseState mouse = default) => - input.Apply(keyboard, mouse, GamePadState.Default); + private static void Frame( + InputManager input, + KeyboardState keyboard = default, + MouseState mouse = default + ) => input.Apply(keyboard, mouse, GamePadState.Default); [Fact] public void KeyPressed_OnlyOnTheFrameItGoesDown() diff --git a/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj b/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj index a81782d..292553c 100644 --- a/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj +++ b/tests/MrGameEng.Input.Tests/MrGameEng.Input.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs b/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs index bf4ad77..841614b 100644 --- a/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs +++ b/tests/MrGameEng.Mods.Tests/DefDatabaseTests.cs @@ -45,7 +45,8 @@ public sealed class DefDatabaseTests : IDisposable { "defName": "Wolf", "label": "волк", "speed": 9 }, { "defName": "Bear", "speed": 6 } ]} - """); + """ + ); var database = LoadAnimals(mod); @@ -65,15 +66,16 @@ public sealed class DefDatabaseTests : IDisposable { "defName": "Hare", "parent": "BaseAnimal", "speed": 12 }, { "defName": "Snail", "parent": "BaseAnimal", "legs": 0, "tags": ["slow", "slimy"] } ]} - """); + """ + ); var database = LoadAnimals(mod); var hare = database.Get("Hare"); - Assert.Equal(12f, hare.Speed); // своё поле победило - Assert.Equal(["wild"], hare.Tags); // унаследовано + Assert.Equal(12f, hare.Speed); // своё поле победило + Assert.Equal(["wild"], hare.Tags); // унаследовано var snail = database.Get("Snail"); - Assert.Equal(5f, snail.Speed); // унаследовано + Assert.Equal(5f, snail.Speed); // унаследовано Assert.Equal(0, snail.Legs); Assert.Equal(["slow", "slimy"], snail.Tags); // массив заменён целиком Assert.False(database.TryGet("BaseAnimal", out _)); // абстрактный не эмитится @@ -83,9 +85,11 @@ public sealed class DefDatabaseTests : IDisposable public void Load_LaterMod_ReplacesSameDefName() { var core = WriteDefsMod( - """{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 9, "tags": ["wild"] } ] }"""); + """{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 9, "tags": ["wild"] } ] }""" + ); var patch = WriteDefsMod( - """{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 20 } ] }"""); + """{ "type": "Animal", "defs": [ { "defName": "Wolf", "speed": 20 } ] }""" + ); var database = LoadAnimals(core, patch); @@ -103,7 +107,8 @@ public sealed class DefDatabaseTests : IDisposable { "defName": "A", "parent": "B" }, { "defName": "B", "parent": "A" } ]} - """); + """ + ); var exception = Assert.Throws(() => LoadAnimals(mod)); Assert.Contains("Cyclic", exception.Message); @@ -112,7 +117,9 @@ public sealed class DefDatabaseTests : IDisposable [Fact] public void Load_UnknownParent_Throws() { - var mod = WriteDefsMod("""{ "type": "Animal", "defs": [ { "defName": "X", "parent": "Ghost" } ] }"""); + var mod = WriteDefsMod( + """{ "type": "Animal", "defs": [ { "defName": "X", "parent": "Ghost" } ] }""" + ); var exception = Assert.Throws(() => LoadAnimals(mod)); Assert.Contains("Ghost", exception.Message); diff --git a/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs b/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs index ebcc9a7..a7f9376 100644 --- a/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs +++ b/tests/MrGameEng.Mods.Tests/LanguageManagerTests.cs @@ -22,7 +22,11 @@ public sealed class LanguageManagerTests : IDisposable [Fact] public void Get_UsesCurrentLanguage_FallsBackToDefault_ThenKey() { - var mod = WriteLanguageMod("en", "ui.json", """{ "hud.title": "Title", "hud.only-en": "English only" }"""); + var mod = WriteLanguageMod( + "en", + "ui.json", + """{ "hud.title": "Title", "hud.only-en": "English only" }""" + ); var ruDir = Path.Combine(mod.RootPath, "Languages", "ru"); Directory.CreateDirectory(ruDir); File.WriteAllText(Path.Combine(ruDir, "ui.json"), """{ "hud.title": "Заголовок" }"""); @@ -39,7 +43,11 @@ public sealed class LanguageManagerTests : IDisposable [Fact] public void Load_LaterMod_OverridesKey() { - var core = WriteLanguageMod("en", "ui.json", """{ "hud.title": "Core", "hud.other": "Other" }"""); + var core = WriteLanguageMod( + "en", + "ui.json", + """{ "hud.title": "Core", "hud.other": "Other" }""" + ); var patch = WriteLanguageMod("en", "ui.json", """{ "hud.title": "Patched" }"""); var languages = new LanguageManager(); diff --git a/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs b/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs index 4e04954..c589905 100644 --- a/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs +++ b/tests/MrGameEng.Mods.Tests/ModLoaderTests.cs @@ -18,7 +18,8 @@ public sealed class ModLoaderTests : IDisposable var deps = string.Join(", ", dependencies.Select(d => $"\"{d}\"")); File.WriteAllText( Path.Combine(aboutDir, "About.json"), - $$"""{ "id": "{{id}}", "name": "{{id}} mod", "version": "1.0", "dependencies": [{{deps}}] }"""); + $$"""{ "id": "{{id}}", "name": "{{id}} mod", "version": "1.0", "dependencies": [{{deps}}] }""" + ); } [Fact] diff --git a/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj b/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj index 176b963..5c63ddc 100644 --- a/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj +++ b/tests/MrGameEng.Mods.Tests/MrGameEng.Mods.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Pathfinding.Tests/FlowFieldTests.cs b/tests/MrGameEng.Pathfinding.Tests/FlowFieldTests.cs index a880b6d..bc10e66 100644 --- a/tests/MrGameEng.Pathfinding.Tests/FlowFieldTests.cs +++ b/tests/MrGameEng.Pathfinding.Tests/FlowFieldTests.cs @@ -8,10 +8,7 @@ public class FlowFieldTests [Fact] public void Build_DistancesGrowFromGoal_DirectionsDescend() { - var grid = new TestGrid( - ".....", - ".###.", - "....."); + var grid = new TestGrid(".....", ".###.", "....."); var builder = new FlowFieldBuilder(grid); var field = new FlowField(); @@ -25,7 +22,11 @@ public class FlowFieldTests { for (var x = 0; x < grid.Width; x++) { - if (!grid.IsPassable(x, y) || !field.IsReachable(x, y) || field.DistanceAt(x, y) == 0f) + if ( + !grid.IsPassable(x, y) + || !field.IsReachable(x, y) + || field.DistanceAt(x, y) == 0f + ) { continue; } @@ -34,8 +35,10 @@ public class FlowFieldTests Assert.NotEqual(Vector2.Zero, direction); var nx = x + Math.Sign(MathF.Round(direction.X * 10f)); var ny = y + Math.Sign(MathF.Round(direction.Y * 10f)); - Assert.True(field.DistanceAt(nx, ny) < field.DistanceAt(x, y), - $"direction at ({x},{y}) does not descend"); + Assert.True( + field.DistanceAt(nx, ny) < field.DistanceAt(x, y), + $"direction at ({x},{y}) does not descend" + ); } } } @@ -43,10 +46,7 @@ public class FlowFieldTests [Fact] public void Build_UnreachablePocket_IsFlagged() { - var grid = new TestGrid( - "..#..", - "..#..", - "..#.."); + var grid = new TestGrid("..#..", "..#..", "..#.."); var builder = new FlowFieldBuilder(grid); var field = new FlowField(); diff --git a/tests/MrGameEng.Pathfinding.Tests/GridPathfinderTests.cs b/tests/MrGameEng.Pathfinding.Tests/GridPathfinderTests.cs index 9927a3c..31d2bee 100644 --- a/tests/MrGameEng.Pathfinding.Tests/GridPathfinderTests.cs +++ b/tests/MrGameEng.Pathfinding.Tests/GridPathfinderTests.cs @@ -5,9 +5,13 @@ namespace MrGameEng.Pathfinding.Tests; public class GridPathfinderTests { - private static List Path(IPathGrid grid, Point start, Point goal, + private static List Path( + IPathGrid grid, + Point start, + Point goal, PathAlgorithm algorithm = PathAlgorithm.AStar, - GridConnectivity connectivity = GridConnectivity.Eight) + GridConnectivity connectivity = GridConnectivity.Eight + ) { var pathfinder = new GridPathfinder(grid, connectivity); var path = new List(); @@ -26,7 +30,10 @@ public class GridPathfinderTests { var dx = Math.Abs(path[i].X - path[i - 1].X); var dy = Math.Abs(path[i].Y - path[i - 1].Y); - Assert.True(dx <= 1 && dy <= 1 && dx + dy > 0, $"non-adjacent step {path[i - 1]} -> {path[i]}"); + Assert.True( + dx <= 1 && dy <= 1 && dx + dy > 0, + $"non-adjacent step {path[i - 1]} -> {path[i]}" + ); } } } @@ -37,10 +44,7 @@ public class GridPathfinderTests [InlineData(PathAlgorithm.BreadthFirst)] public void FindPath_OpenField_StraightLine(PathAlgorithm algorithm) { - var grid = new TestGrid( - ".....", - ".....", - "....."); + var grid = new TestGrid(".....", ".....", "....."); var path = Path(grid, new Point(0, 1), new Point(4, 1), algorithm); @@ -54,10 +58,7 @@ public class GridPathfinderTests [InlineData(PathAlgorithm.BreadthFirst)] public void FindPath_WallsForceDetour(PathAlgorithm algorithm) { - var grid = new TestGrid( - ".....", - "####.", - "....."); + var grid = new TestGrid(".....", "####.", "....."); var path = Path(grid, new Point(0, 0), new Point(0, 2), algorithm); @@ -68,10 +69,7 @@ public class GridPathfinderTests [Fact] public void FindPath_NoRoute_ReturnsFalse() { - var grid = new TestGrid( - ".#.", - ".#.", - ".#."); + var grid = new TestGrid(".#.", ".#.", ".#."); var pathfinder = new GridPathfinder(grid); var path = new List(); @@ -92,9 +90,7 @@ public class GridPathfinderTests [Fact] public void FindPath_DiagonalNeverCutsCorners() { - var grid = new TestGrid( - ".#", - "#."); + var grid = new TestGrid(".#", "#."); var pathfinder = new GridPathfinder(grid, GridConnectivity.Eight); var path = new List(); @@ -105,12 +101,14 @@ public class GridPathfinderTests [Fact] public void FindPath_FourConnectivity_NoDiagonalSteps() { - var grid = new TestGrid( - "...", - "...", - "..."); + var grid = new TestGrid("...", "...", "..."); - var path = Path(grid, new Point(0, 0), new Point(2, 2), connectivity: GridConnectivity.Four); + var path = Path( + grid, + new Point(0, 0), + new Point(2, 2), + connectivity: GridConnectivity.Four + ); Assert.Equal(5, path.Count); // манхэттен: 4 шага for (var i = 1; i < path.Count; i++) @@ -127,10 +125,7 @@ public class GridPathfinderTests public void FindPath_CostAware_AvoidsExpensiveTerrain(PathAlgorithm algorithm) { // Прямой путь через болото (цена 9) дороже обхода по краю. - var grid = new TestGrid( - ".....", - ".999.", - "....."); + var grid = new TestGrid(".....", ".999.", "....."); var path = Path(grid, new Point(0, 1), new Point(4, 1), algorithm); @@ -140,10 +135,7 @@ public class GridPathfinderTests [Fact] public void FindPath_BreadthFirst_IgnoresCosts() { - var grid = new TestGrid( - ".....", - ".999.", - "....."); + var grid = new TestGrid(".....", ".999.", "....."); var path = Path(grid, new Point(0, 1), new Point(4, 1), PathAlgorithm.BreadthFirst); @@ -153,10 +145,7 @@ public class GridPathfinderTests [Fact] public void FindPath_ReusedInstance_GivesCleanResults() { - var grid = new TestGrid( - ".....", - ".###.", - "....."); + var grid = new TestGrid(".....", ".###.", "....."); var pathfinder = new GridPathfinder(grid); var path = new List(); @@ -170,12 +159,7 @@ public class GridPathfinderTests [Fact] public void FindPath_AStarMatchesDijkstraCost() { - var grid = new TestGrid( - "..3..", - ".#3#.", - "..3..", - ".###.", - "....."); + var grid = new TestGrid("..3..", ".#3#.", "..3..", ".###.", "....."); var start = new Point(0, 0); var goal = new Point(4, 4); diff --git a/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs b/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs index 84f96b0..acc3ece 100644 --- a/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs +++ b/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs @@ -27,8 +27,9 @@ public class GridResizeTests var pathfinder = new GridPathfinder(grid); grid.Width = 8; - Assert.Throws( - () => pathfinder.FindPath(new Point(0, 0), new Point(1, 1), [])); + Assert.Throws(() => + pathfinder.FindPath(new Point(0, 0), new Point(1, 1), []) + ); } [Fact] @@ -38,7 +39,8 @@ public class GridResizeTests var builder = new FlowFieldBuilder(grid); grid.Height = 8; - Assert.Throws( - () => builder.Build([new Point(0, 0)], new FlowField())); + Assert.Throws(() => + builder.Build([new Point(0, 0)], new FlowField()) + ); } } diff --git a/tests/MrGameEng.Pathfinding.Tests/MrGameEng.Pathfinding.Tests.csproj b/tests/MrGameEng.Pathfinding.Tests/MrGameEng.Pathfinding.Tests.csproj index ccee2f0..4bda5a2 100644 --- a/tests/MrGameEng.Pathfinding.Tests/MrGameEng.Pathfinding.Tests.csproj +++ b/tests/MrGameEng.Pathfinding.Tests/MrGameEng.Pathfinding.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj b/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj index e2f45c1..d06ac70 100644 --- a/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj +++ b/tests/MrGameEng.Tilemaps.Tests/MrGameEng.Tilemaps.Tests.csproj @@ -1,5 +1,4 @@ - net8.0 Exe @@ -15,5 +14,4 @@ - diff --git a/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs b/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs index c5ffa5c..885c7c0 100644 --- a/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs +++ b/tests/MrGameEng.Tilemaps.Tests/TilemapTests.cs @@ -105,8 +105,17 @@ public class TilemapMathTests { var cull = new RectF(35f, 18f, 40f, 30f); // правый край 75, нижний 48 - var visible = TilemapMath.VisibleCells(in cull, Vector2.Zero, 16f, 10, 10, - out var x0, out var y0, out var x1, out var y1); + var visible = TilemapMath.VisibleCells( + in cull, + Vector2.Zero, + 16f, + 10, + 10, + out var x0, + out var y0, + out var x1, + out var y1 + ); Assert.True(visible); Assert.Equal((2, 1, 4, 3), (x0, y0, x1, y1)); @@ -117,8 +126,17 @@ public class TilemapMathTests { var cull = new RectF(0f, 0f, 64f, 64f); - var visible = TilemapMath.VisibleCells(in cull, new Vector2(-32f, -32f), 16f, 100, 100, - out var x0, out var y0, out var x1, out var y1); + var visible = TilemapMath.VisibleCells( + in cull, + new Vector2(-32f, -32f), + 16f, + 100, + 100, + out var x0, + out var y0, + out var x1, + out var y1 + ); Assert.True(visible); Assert.Equal((2, 2, 6, 6), (x0, y0, x1, y1)); @@ -129,23 +147,41 @@ public class TilemapMathTests { var cull = new RectF(-1000f, -1000f, 5000f, 5000f); - var visible = TilemapMath.VisibleCells(in cull, Vector2.Zero, 16f, 8, 6, - out var x0, out var y0, out var x1, out var y1); + var visible = TilemapMath.VisibleCells( + in cull, + Vector2.Zero, + 16f, + 8, + 6, + out var x0, + out var y0, + out var x1, + out var y1 + ); Assert.True(visible); Assert.Equal((0, 0, 7, 5), (x0, y0, x1, y1)); } [Theory] - [InlineData(200f, 0f)] // справа от карты - [InlineData(-200f, 0f)] // слева - [InlineData(0f, 200f)] // ниже + [InlineData(200f, 0f)] // справа от карты + [InlineData(-200f, 0f)] // слева + [InlineData(0f, 200f)] // ниже public void CameraOutsideMap_ReturnsFalse(float offsetX, float offsetY) { var cull = new RectF(offsetX, offsetY, 100f, 100f); - var visible = TilemapMath.VisibleCells(in cull, new Vector2(-150f, -150f), 16f, 8, 8, - out _, out _, out _, out _); + var visible = TilemapMath.VisibleCells( + in cull, + new Vector2(-150f, -150f), + 16f, + 8, + 8, + out _, + out _, + out _, + out _ + ); Assert.False(visible); } diff --git a/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj b/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj index 956a704..8be1bc4 100644 --- a/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj +++ b/tools/MrGameEng.AtlasTool/MrGameEng.AtlasTool.csproj @@ -1,5 +1,4 @@ - Exe net8.0 @@ -9,5 +8,4 @@ - diff --git a/tools/MrGameEng.AtlasTool/Program.cs b/tools/MrGameEng.AtlasTool/Program.cs index 8d38a09..ba73120 100644 --- a/tools/MrGameEng.AtlasTool/Program.cs +++ b/tools/MrGameEng.AtlasTool/Program.cs @@ -15,7 +15,8 @@ if (args.Length < 2 || args.Contains("--help") || args.Contains("-h")) --padding gap between images in pixels (default 2) --root-name atlas name for files above group depth (default "Atlas") --force rebuild even when sources are unchanged - """); + """ + ); return args.Length < 2 && !args.Contains("--help") && !args.Contains("-h") ? 1 : 0; } @@ -45,7 +46,8 @@ var options = new AtlasBuildOptions GroupDepth = Option("--group-depth", 1), MaxPageSize = Option("--page-size", 2048), Padding = Option("--padding", 2), - RootAtlasName = rootNameIndex >= 0 && rootNameIndex + 1 < args.Length ? args[rootNameIndex + 1] : "Atlas", + RootAtlasName = + rootNameIndex >= 0 && rootNameIndex + 1 < args.Length ? args[rootNameIndex + 1] : "Atlas", Force = args.Contains("--force"), }; @@ -55,9 +57,11 @@ stopwatch.Stop(); foreach (var group in result.Groups) { - Console.WriteLine(group.Skipped - ? $" {group.Name}: up to date ({group.RegionCount} regions, {group.PageCount} pages)" - : $" {group.Name}: {group.RegionCount} regions -> {group.PageCount} pages"); + Console.WriteLine( + group.Skipped + ? $" {group.Name}: up to date ({group.RegionCount} regions, {group.PageCount} pages)" + : $" {group.Name}: {group.RegionCount} regions -> {group.PageCount} pages" + ); } foreach (var orphan in result.DeletedOrphans) @@ -66,5 +70,7 @@ foreach (var orphan in result.DeletedOrphans) } var built = result.Groups.Count(g => !g.Skipped); -Console.WriteLine($"Done: {built} atlases built, {result.Groups.Count - built} up to date, {stopwatch.Elapsed.TotalSeconds:F1}s."); +Console.WriteLine( + $"Done: {built} atlases built, {result.Groups.Count - built} up to date, {stopwatch.Elapsed.TotalSeconds:F1}s." +); return 0;