@
CI / build-test (push) Failing after 1m8s

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.
@
This commit is contained in:
Leonid Pershin
2026-06-12 07:19:10 +03:00
parent 4ae730cafa
commit fd6343bd09
96 changed files with 2066 additions and 589 deletions
+7 -1
View File
@@ -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<TContext>`, `UtilityAction<TContext>`,
`UtilityAi<TContext>` 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)
+30
View File
@@ -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
+29
View File
@@ -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<TContext>`), `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<TContext>` — одно соображение: читает сырое значение из контекста,
нормирует по диапазону `[min,max]` и прогоняет через кривую.
- `UtilityAction<TContext>` — действие из набора соображений. Очки = произведение
соображений × `Weight`; любой ноль ветирует действие. Компенсирующий множитель
(make-up value) убирает смещение произведения многих факторов вниз.
- `UtilityAi<TContext>` — reasoner: `Select` (детерминированно лучшее действие, при
равенстве — первое) и `SelectWeighted(random)` (рулетка по очкам для разнообразия,
воспроизводимо при seed). Очки пишутся в переиспользуемый буфер — повторные
вычисления не аллоцируют; один экземпляр на вид агента, не потокобезопасен.
- `Blackboard` — типизированная рабочая память агента (`Set`/`TryGet`/`GetOrDefault`)
для холодных путей (восприятие, планирование).
Витрина в LittleSim: `PawnDecisionSystem` выбирает «бродить/отдыхать» по энергии
жителя, `PawnNeedsSystem` тратит/восстанавливает энергию, уставшие темнеют
(`PawnAppearanceSystem`). Команда консоли `ai [energy]` печатает очки и расклад
отдыхающих/блуждающих.
## Коллизии
`MrGameEng.Collisions` — определение столкновений (без разрешения физики — она в бэклоге):
+50
View File
@@ -0,0 +1,50 @@
namespace MrGameEng.AI;
/// <summary>
/// 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.
/// </summary>
public sealed class Blackboard
{
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
/// <summary>The number of keys currently stored.</summary>
public int Count => _values.Count;
/// <summary>Stores <paramref name="value"/> under <paramref name="key"/>, replacing any existing entry.</summary>
public void Set<T>(string key, T value) => _values[key] = value;
/// <summary>
/// Reads the value under <paramref name="key"/> as <typeparamref name="T"/>. Returns <c>false</c> when
/// the key is missing or holds a value of a different type.
/// </summary>
public bool TryGet<T>(string key, out T value)
{
if (_values.TryGetValue(key, out var stored) && stored is T typed)
{
value = typed;
return true;
}
value = default!;
return false;
}
/// <summary>
/// Reads the value under <paramref name="key"/>, or returns <paramref name="fallback"/> when the key is
/// missing or holds a different type.
/// </summary>
public T GetOrDefault<T>(string key, T fallback = default!) =>
TryGet<T>(key, out var value) ? value : fallback;
/// <summary>True when <paramref name="key"/> has a value (of any type).</summary>
public bool Has(string key) => _values.ContainsKey(key);
/// <summary>Removes <paramref name="key"/>. Returns true when it was present.</summary>
public bool Remove(string key) => _values.Remove(key);
/// <summary>Drops every stored value.</summary>
public void Clear() => _values.Clear();
}
+56
View File
@@ -0,0 +1,56 @@
namespace MrGameEng.AI;
/// <summary>
/// One input to a utility decision. It reads a raw value from the agent's context, normalizes it to
/// <c>[0,1]</c> against an expected range, and shapes it through a <see cref="ResponseCurve"/> into a
/// utility score. Considerations are stateless and reusable: the context carries everything that
/// varies. <typeparamref name="TContext"/> is whatever the game passes in — a struct of perceived
/// values, an entity handle, a blackboard — the AI module never owns it.
/// </summary>
public sealed class Consideration<TContext>
{
private readonly Func<TContext, float> _input;
private readonly float _min;
private readonly float _inverseSpan;
private readonly ResponseCurve _curve;
/// <summary>
/// Creates a consideration named <paramref name="name"/> that reads <paramref name="input"/> from the
/// context, normalizes it from <c>[<paramref name="min"/>, <paramref name="max"/>]</c> to <c>[0,1]</c>
/// (values outside the range clamp to the ends), then applies <paramref name="curve"/>.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="max"/> is not greater than <paramref name="min"/>.</exception>
public Consideration(
string name,
Func<TContext, float> 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;
}
/// <summary>A human-readable label, surfaced in debug/console output.</summary>
public string Name { get; }
/// <summary>Reads the context and returns this consideration's utility in <c>[0,1]</c>.</summary>
public float Score(TContext context)
{
var normalized = Math.Clamp((_input(context) - _min) * _inverseSpan, 0f, 1f);
return _curve.Evaluate(normalized);
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+115
View File
@@ -0,0 +1,115 @@
namespace MrGameEng.AI;
/// <summary>Shape of a <see cref="ResponseCurve"/> mapping a normalized input to a utility.</summary>
public enum CurveType
{
/// <summary>Straight line: <c>y = slope·(x xShift) + yShift</c>.</summary>
Linear,
/// <summary>Power curve: <c>y = slope·(x xShift)^exponent + yShift</c>; the exponent eases in/out.</summary>
Polynomial,
/// <summary>S-shaped logistic centred on <c>xShift</c>; <c>exponent</c> is the steepness.</summary>
Logistic,
/// <summary>Hermite smoothstep over <c>[xShift, xShift + 1/slope]</c>; flat ends, smooth middle.</summary>
SmoothStep,
}
/// <summary>
/// Maps a normalized input in <c>[0,1]</c> to a utility in <c>[0,1]</c> 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 <see cref="Consideration{TContext}"/> value. Curves are
/// immutable value types — build them once and reuse them across evaluations.
/// </summary>
public readonly struct ResponseCurve
{
/// <summary>The shape applied by <see cref="Evaluate"/>.</summary>
public CurveType Type { get; }
/// <summary>Vertical scale / steepness (the <c>m</c> term). See <see cref="CurveType"/> per shape.</summary>
public float Slope { get; }
/// <summary>Power for <see cref="CurveType.Polynomial"/> and steepness for <see cref="CurveType.Logistic"/>.</summary>
public float Exponent { get; }
/// <summary>Horizontal shift of the curve (the <c>c</c> term): the input value mapped to the origin.</summary>
public float XShift { get; }
/// <summary>Vertical shift of the curve (the <c>b</c> term) added after scaling.</summary>
public float YShift { get; }
/// <summary>
/// Builds a curve from raw parameters. Prefer the named factories
/// (<see cref="Linear"/>, <see cref="Polynomial"/>, <see cref="Logistic"/>, <see cref="SmoothStep"/>)
/// which document the meaning of each term for their shape.
/// </summary>
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;
}
/// <summary>The identity curve: <c>y = x</c>. The default when a consideration needs no shaping.</summary>
public static ResponseCurve Identity => new(CurveType.Linear);
/// <summary>Straight line <c>y = slope·(x xShift) + yShift</c>. A negative slope inverts the input.</summary>
public static ResponseCurve Linear(float slope = 1f, float xShift = 0f, float yShift = 0f) =>
new(CurveType.Linear, slope, 1f, xShift, yShift);
/// <summary>
/// Power curve <c>y = slope·(x xShift)^exponent + yShift</c>. An exponent above 1 eases in
/// (slow start), below 1 eases out (fast start). Quadratic is <c>exponent = 2</c>.
/// </summary>
public static ResponseCurve Polynomial(
float exponent,
float slope = 1f,
float xShift = 0f,
float yShift = 0f
) => new(CurveType.Polynomial, slope, exponent, xShift, yShift);
/// <summary>
/// Logistic S-curve centred on <paramref name="midpoint"/>; <paramref name="steepness"/> controls how
/// sharp the transition is (≈10 gives a soft threshold, larger is more switch-like).
/// </summary>
public static ResponseCurve Logistic(float steepness = 10f, float midpoint = 0.5f) =>
new(CurveType.Logistic, 1f, steepness, midpoint);
/// <summary>
/// Hermite smoothstep rising from 0 to 1 over <c>[xShift, xShift + 1/slope]</c>: flat below the
/// start, flat above the end, smooth in between. Default rises across the whole <c>[0,1]</c> range.
/// </summary>
public static ResponseCurve SmoothStep(float slope = 1f, float xShift = 0f) =>
new(CurveType.SmoothStep, slope, 1f, xShift);
/// <summary>Evaluates the curve. <paramref name="x"/> is clamped to <c>[0,1]</c>; the result is clamped to <c>[0,1]</c>.</summary>
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;
}
}
+75
View File
@@ -0,0 +1,75 @@
namespace MrGameEng.AI;
/// <summary>
/// A candidate action scored by a set of <see cref="Consideration{TContext}"/>s. Its score is the
/// product of every consideration (each in <c>[0,1]</c>) times <see cref="Weight"/>, 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.
/// </summary>
public sealed class UtilityAction<TContext>
{
private readonly Consideration<TContext>[] _considerations;
/// <summary>
/// Creates an action named <paramref name="name"/> with a base <paramref name="weight"/> (a static
/// priority multiplier; 1 is neutral) and the considerations that score it for a given context.
/// </summary>
public UtilityAction(string name, float weight, params Consideration<TContext>[] considerations)
{
Name = name;
Weight = weight;
_considerations = considerations ?? [];
}
/// <summary>Creates an action with neutral weight (1).</summary>
public UtilityAction(string name, params Consideration<TContext>[] considerations)
: this(name, 1f, considerations) { }
/// <summary>A human-readable label, surfaced in debug/console output.</summary>
public string Name { get; }
/// <summary>Static priority multiplier applied to the product of considerations.</summary>
public float Weight { get; }
/// <summary>The considerations scoring this action, in evaluation order.</summary>
public IReadOnlyList<Consideration<TContext>> Considerations => _considerations;
/// <summary>Scores this action for <paramref name="context"/>. Higher wins; a 0 consideration vetoes it.</summary>
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;
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace MrGameEng.AI;
/// <summary>
/// A utility reasoner over a fixed set of <see cref="UtilityAction{TContext}"/>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.
/// </summary>
public sealed class UtilityAi<TContext>
{
private readonly UtilityAction<TContext>[] _actions;
private readonly float[] _scores;
/// <summary>Creates a reasoner choosing between <paramref name="actions"/> (at least one required).</summary>
/// <exception cref="ArgumentException"><paramref name="actions"/> is empty.</exception>
public UtilityAi(params UtilityAction<TContext>[] 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];
}
/// <summary>The actions this reasoner chooses between, in evaluation order.</summary>
public IReadOnlyList<UtilityAction<TContext>> Actions => _actions;
/// <summary>
/// The scores from the most recent <see cref="Select"/> / <see cref="SelectWeighted"/> call, aligned
/// with <see cref="Actions"/>. Useful for debug overlays and console dumps.
/// </summary>
public ReadOnlySpan<float> LastScores => _scores;
/// <summary>
/// Scores every action for <paramref name="context"/> and returns the highest, or <c>null</c> when no
/// action scores strictly above <paramref name="threshold"/>. Ties resolve to the earliest action,
/// so selection is fully deterministic for identical inputs.
/// </summary>
public UtilityAction<TContext>? 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;
}
/// <summary>
/// Scores every action and picks one at random in proportion to its score (roulette selection over
/// the actions above <paramref name="threshold"/>), giving believable variety while staying
/// deterministic for a given <paramref name="random"/> sequence. Returns <c>null</c> when nothing
/// qualifies. Pass a seeded <see cref="Random"/> owned by the calling system — never
/// <see cref="Random.Shared"/> — to keep the simulation reproducible.
/// </summary>
public UtilityAction<TContext>? 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;
}
}
@@ -15,7 +15,9 @@ namespace MrGameEng.Assets.Generator;
[Generator]
public sealed class AssetHandlesGenerator : IIncrementalGenerator
{
private static readonly Dictionary<string, string> TypeByExtension = new(StringComparer.OrdinalIgnoreCase)
private static readonly Dictionary<string, string> 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
/// <inheritdoc />
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
)
)
);
}
/// <summary>
@@ -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("// <auto-generated by MrGameEng.Assets.Generator />");
source.AppendLine($"namespace {ns};");
source.AppendLine();
source.AppendLine("/// <summary>Typed handles for every file under the Assets directory.</summary>");
source.AppendLine(
"/// <summary>Typed handles for every file under the Assets directory.</summary>"
);
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}/// <summary>{XmlEscape(relativePath)}</summary>");
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)
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
@@ -14,5 +13,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Assets.Generator.Tests" />
</ItemGroup>
</Project>
+18 -7
View File
@@ -39,7 +39,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
public T Load<T>(AssetRef<T> asset) where T : class
public T Load<T>(AssetRef<T> 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
}
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
public void Unload<T>(AssetRef<T> asset) where T : class
public void Unload<T>(AssetRef<T> 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
}
/// <summary>Replaces or adds the loader used for assets of type <typeparamref name="T"/>.</summary>
public void RegisterLoader<T>(Func<AssetManager, string, T> loader) where T : class =>
_loaders[typeof(T)] = loader;
public void RegisterLoader<T>(Func<AssetManager, string, T> loader)
where T : class => _loaders[typeof(T)] = loader;
/// <summary>Resolves an asset-relative path to an absolute file path.</summary>
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)
+2 -1
View File
@@ -7,7 +7,8 @@ namespace MrGameEng.Assets;
/// </summary>
/// <typeparam name="T">Runtime type the asset loads into (e.g. <c>Texture2D</c>).</typeparam>
/// <param name="Path">Path relative to the asset root, with forward slashes.</param>
public readonly record struct AssetRef<T>(string Path) where T : class
public readonly record struct AssetRef<T>(string Path)
where T : class
{
/// <inheritdoc />
public override string ToString() => $"{typeof(T).Name}:{Path}";
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+143 -56
View File
@@ -44,7 +44,10 @@ public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCoun
/// <summary>Result of an <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
public sealed record AtlasBuildResult(
IReadOnlyList<AtlasGroupResult> Groups,
IReadOnlyList<string> DeletedOrphans
);
/// <summary>
/// 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"];
/// <summary>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
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
);
/// <summary>Builds (or incrementally refreshes) all atlases from <see cref="AtlasBuildOptions.SourceDirectory"/>.</summary>
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)))
);
}
/// <summary>
@@ -85,7 +98,9 @@ public static class AtlasBuilder
/// in different roots. Region keys come from <c>RelativePath</c> without extension.
/// </summary>
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
}
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
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<string, List<SourceFile>> ScanGroups(
IEnumerable<(string FullPath, string RelativePath)> sources, AtlasBuildOptions options)
IEnumerable<(string FullPath, string RelativePath)> sources,
AtlasBuildOptions options
)
{
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(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<SourceFile> files, AtlasBuildOptions options)
string name,
List<SourceFile> 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<SourceFile> files, AtlasBuildOptions options, out int pages)
string metadataPath,
List<SourceFile> 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<string, ImageResult> pixelsByKey, string outputDirectory)
string name,
PackResult packed,
Dictionary<string, ImageResult> 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<SourceFile> files, AtlasBuildOptions options, string metadataPath)
string name,
PackResult packed,
List<SourceFile> 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<string> DeleteOrphans(string outputDirectory, IEnumerable<string> liveAtlasNames)
private static List<string> DeleteOrphans(
string outputDirectory,
IEnumerable<string> liveAtlasNames
)
{
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
var deleted = new List<string>();
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -18,5 +17,4 @@
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\MrGameEng.Assets\MrGameEng.Assets.csproj" />
</ItemGroup>
</Project>
+42 -16
View File
@@ -13,7 +13,14 @@ public readonly record struct PackItem(string Key, int Width, int Height);
/// <param name="Y">Y position in page pixels.</param>
/// <param name="Width">Item width in pixels.</param>
/// <param name="Height">Item height in pixels.</param>
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
);
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
/// <param name="Placements">One placement per input item.</param>
@@ -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.
/// </param>
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
public sealed record PackResult(
IReadOnlyList<PackPlacement> Placements,
IReadOnlyList<(int Width, int Height)> PageSizes
);
/// <summary>
/// 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<PackPlacement>(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);
+15 -5
View File
@@ -28,14 +28,19 @@ public sealed class TextureAtlas : IDisposable
{
Name = metadata.Name;
Pages = pages;
_regions = new Dictionary<string, Texture2DRegion>(metadata.Regions.Count, StringComparer.Ordinal);
_regions = new Dictionary<string, Texture2DRegion>(
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
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+6 -3
View File
@@ -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 800048000 Hz.");
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 800048000 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,
};
+18 -16
View File
@@ -40,22 +40,24 @@ public struct Collider : IComponent
public uint CollidesWith;
/// <summary>Creates a circle collider on layer 1 colliding with everything.</summary>
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,
};
/// <summary>Creates a box collider on layer 1 colliding with everything.</summary>
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,
};
}
+21 -10
View File
@@ -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;
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -8,5 +7,4 @@
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
+4 -1
View File
@@ -22,7 +22,10 @@ public sealed class EngineContext
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context).");
_graphicsDevice
?? throw new InvalidOperationException(
"GraphicsDevice is not available (headless context)."
);
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
-2
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -12,5 +11,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
</ItemGroup>
</Project>
+5 -3
View File
@@ -23,7 +23,8 @@ public abstract class Scene
public SystemRoot DrawSystems { get; }
/// <summary>Engine context. Valid from <see cref="OnLoad"/> until <see cref="OnUnload"/>.</summary>
public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded.");
public EngineContext Context =>
_context ?? throw new InvalidOperationException("Scene is not loaded.");
/// <summary>True while the scene is the active, loaded scene.</summary>
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;
+18 -3
View File
@@ -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 ?? "<none>"}");
}
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);
+17 -6
View File
@@ -9,24 +9,31 @@ public sealed class ServiceRegistry
private readonly Dictionary<Type, object> _services = new();
/// <summary>Registers a service instance under type <typeparamref name="T"/>. Throws if already registered.</summary>
public void Add<T>(T service) where T : class
public void Add<T>(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."
);
}
}
/// <summary>Returns the registered service of type <typeparamref name="T"/>. Throws if missing.</summary>
public T Get<T>() where T : class
public T Get<T>()
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."
);
}
/// <summary>Returns the registered service of type <typeparamref name="T"/> or null.</summary>
public T? GetOrDefault<T>() where T : class
public T? GetOrDefault<T>()
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<object>(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();
}
+10 -3
View File
@@ -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)
+35 -17
View File
@@ -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",
};
}
+20 -20
View File
@@ -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());
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+39 -11
View File
@@ -52,23 +52,35 @@ public readonly struct CameraState
public static class CameraMath
{
/// <summary>Computes the full camera state for a frame.</summary>
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.
/// </summary>
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) =>
+24 -8
View File
@@ -10,7 +10,11 @@ public static class CullingMath
/// (valid for any rotation), given its transform, region size in pixels and origin.
/// </summary>
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).
/// </summary>
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);
}
/// <summary>True when the circle overlaps the rectangle.</summary>
+11 -3
View File
@@ -52,14 +52,20 @@ public sealed class LayerRegistry
public int Count => _layers.Length;
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
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})."
);
}
}
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -7,5 +6,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+16 -12
View File
@@ -96,20 +96,24 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
}
_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();
}
}
+92 -29
View File
@@ -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;
/// <summary>Render layer registry. Register layers before the first frame.</summary>
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;
}
@@ -13,7 +13,10 @@ public static class SceneGraphicsExtensions
/// is created on first use and shared between scenes. Call from <c>OnLoad</c>.
/// </summary>
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<Renderer2D>();
@@ -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));
+9 -2
View File
@@ -19,11 +19,18 @@ public sealed class SpriteAnimationClip
public float Duration => Frames.Count / FramesPerSecond;
/// <summary>Creates a clip.</summary>
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> frames, float framesPerSecond = 12f, bool loop = true)
public SpriteAnimationClip(
IReadOnlyList<Texture2DRegion> 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;
+2 -1
View File
@@ -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];
+3 -1
View File
@@ -9,7 +9,9 @@ public static class SpriteSortKey
{
/// <summary>Composes a sort key from layer, depth and texture grouping key.</summary>
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);
/// <summary>
/// Maps a float to bits whose unsigned order matches the float order
+4 -4
View File
@@ -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
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
public Texture2DRegion(Texture2D texture)
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height))
{
}
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) { }
}
+32 -19
View File
@@ -7,7 +7,8 @@ namespace MrGameEng.Input;
/// gamepad buttons. Query by action instead of device, rebind at runtime.
/// </summary>
/// <typeparam name="TAction">Enum (or any value) identifying the game's actions.</typeparam>
public sealed class ActionMap<TAction> where TAction : notnull
public sealed class ActionMap<TAction>
where TAction : notnull
{
private readonly InputManager _input;
private readonly Dictionary<TAction, List<Binding>> _bindings = new();
@@ -18,37 +19,49 @@ public sealed class ActionMap<TAction> where TAction : notnull
public ActionMap(InputManager input) => _input = input;
/// <summary>Adds a keyboard binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null));
public ActionMap<TAction> Bind(TAction action, Keys key) =>
Add(action, new Binding(key, null, null));
/// <summary>Adds a mouse-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null));
public ActionMap<TAction> Bind(TAction action, MouseButton button) =>
Add(action, new Binding(null, button, null));
/// <summary>Adds a gamepad-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button));
public ActionMap<TAction> Bind(TAction action, Buttons button) =>
Add(action, new Binding(null, null, button));
/// <summary>Removes every binding of <paramref name="action"/> (for rebinding).</summary>
public void Unbind(TAction action) => _bindings.Remove(action);
/// <summary>True while any binding of the action is held down.</summary>
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))
);
/// <summary>True only on the frame any binding of the action went down.</summary>
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))
);
/// <summary>True only on the frame any binding of the action went up.</summary>
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))
);
/// <summary>Composes -1/0/+1 from two digital actions (e.g. move left / move right).</summary>
public float GetAxis(TAction negative, TAction positive) =>
+32 -21
View File
@@ -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);
/// <summary>True only on the frame the key went down.</summary>
public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
public bool IsKeyPressed(Keys key) =>
_keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
/// <summary>True only on the frame the key went up.</summary>
public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
public bool IsKeyReleased(Keys key) =>
_keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
/// <summary>Mouse cursor position in window pixels.</summary>
public Point MousePosition => _mouse.Position;
@@ -96,29 +102,34 @@ public sealed class InputManager
/// <summary>True only on the frame the mouse button went down.</summary>
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;
/// <summary>True only on the frame the mouse button went up.</summary>
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;
/// <summary>True while the gamepad button is held down.</summary>
public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button);
/// <summary>True only on the frame the gamepad button went down.</summary>
public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
public bool IsButtonPressed(Buttons button) =>
_gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
/// <summary>True only on the frame the gamepad button went up.</summary>
public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
public bool IsButtonReleased(Buttons button) =>
_gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
/// <summary>Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world.</summary>
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,
};
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Input.Tests" />
</ItemGroup>
</Project>
+45 -19
View File
@@ -31,7 +31,8 @@ public sealed class DefDatabase
};
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
public void RegisterType<T>(string typeKey) where T : Def
public void RegisterType<T>(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
}
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
public T Get<T>(string defName) where T : Def =>
public T Get<T>(string defName)
where T : Def =>
TryGet<T>(defName, out var def)
? def
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
public bool TryGet<T>(string defName, out T def) where T : Def
public bool TryGet<T>(string defName, out T def)
where T : Def
{
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
{
@@ -90,16 +94,19 @@ public sealed class DefDatabase
}
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
public IReadOnlyList<T> All<T>() where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
public IReadOnlyList<T> All<T>()
where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
/// <summary>Registered def-type keys, sorted.</summary>
public IReadOnlyList<string> TypeKeys => _byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
public IReadOnlyList<string> TypeKeys =>
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
public IReadOnlyList<string> NamesOf(string typeKey) =>
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
private TypeEntry Entry<T>() where T : Def =>
private TypeEntry Entry<T>()
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<string>()
?? throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"type\" field.");
var typeKey =
root["type"]?.GetValue<string>()
?? 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<string>();
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))
+39 -17
View File
@@ -12,8 +12,9 @@ namespace MrGameEng.Mods;
/// </summary>
public sealed class LanguageManager
{
private readonly Dictionary<string, Dictionary<string, string>> _languages =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Dictionary<string, string>> _languages = new(
StringComparer.OrdinalIgnoreCase
);
/// <summary>Creates a manager whose fallback language is <paramref name="defaultLanguage"/>.</summary>
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
/// <summary>Returns the string for <paramref name="key"/>: current language → default language → the key itself.</summary>
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<string>(); // поздний мод переопределяет ключ
+15 -4
View File
@@ -33,7 +33,11 @@ public sealed class ModContentTree
/// (in load order). With <paramref name="extensions"/> only matching files are included
/// (e.g. <c>".png"</c>); without them, every file.
/// </summary>
public static ModContentTree Build(IReadOnlyList<Mod> mods, string contentFolder, params string[] extensions)
public static ModContentTree Build(
IReadOnlyList<Mod> mods,
string contentFolder,
params string[] extensions
)
{
var files = new Dictionary<string, ModFile>(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;
}
+21 -7
View File
@@ -18,7 +18,11 @@ public static class ModLoader
/// </summary>
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<ModInfo>(File.ReadAllText(aboutPath), ModInfo.JsonOptions)
?? throw new InvalidDataException("About.json deserialized to null.");
info =
JsonSerializer.Deserialize<ModInfo>(
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);
-2
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+12 -5
View File
@@ -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;
+15 -5
View File
@@ -67,18 +67,28 @@ public sealed class GridPathfinder
/// and writes it into <paramref name="path"/>. Returns false when no path exists;
/// the list is cleared either way.
/// </summary>
public bool FindPath(Point start, Point goal, List<Point> path, PathAlgorithm algorithm = PathAlgorithm.AStar)
public bool FindPath(
Point start,
Point goal,
List<Point> 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;
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -7,5 +6,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -12,5 +11,4 @@
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -12,8 +12,11 @@ public static class SceneTilemapExtensions
/// </summary>
public static void UseTilemaps(this Scene scene)
{
var renderer = scene.Context.Services.GetOrDefault<Renderer2D>()
?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first.");
var renderer =
scene.Context.Services.GetOrDefault<Renderer2D>()
?? 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?)."
);
}
}
+3 -1
View File
@@ -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."
);
}
}
}
+1 -3
View File
@@ -10,9 +10,7 @@ public readonly record struct TileDef(Texture2DRegion Region, Color Color)
{
/// <summary>Creates an untinted tile.</summary>
public TileDef(Texture2DRegion region)
: this(region, Color.White)
{
}
: this(region, Color.White) { }
}
/// <summary>
+10 -2
View File
@@ -11,8 +11,16 @@ public static class TilemapMath
/// Returns false when the map is entirely outside the rectangle.
/// </summary>
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));
+33 -9
View File
@@ -25,7 +25,11 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
{
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<Tilemap>
// 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<Tilemap>
}
}
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<Tilemap>
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);
-2
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -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;
@@ -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<Vector2>("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<int>("missing", out _));
Assert.False(board.TryGet<string>("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);
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.AI\MrGameEng.AI.csproj" />
</ItemGroup>
</Project>
@@ -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);
}
}
+168
View File
@@ -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<Ctx> BuildBrain()
{
// Rest gets attractive as energy drops; wander as energy is high.
var rest = new UtilityAction<Ctx>(
"rest",
new Consideration<Ctx>(
"tired",
c => c.Energy,
0f,
1f,
ResponseCurve.Linear(slope: -1f, yShift: 1f)
)
);
var wander = new UtilityAction<Ctx>(
"wander",
new Consideration<Ctx>("rested", c => c.Energy)
);
return new UtilityAi<Ctx>(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<Ctx>("a", new Consideration<Ctx>("k", _ => 0.5f));
var b = new UtilityAction<Ctx>("b", new Consideration<Ctx>("k", _ => 0.5f));
var brain = new UtilityAi<Ctx>(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<Ctx>(
"eat",
new Consideration<Ctx>("has-food", _ => 0f), // veto: no food
new Consideration<Ctx>("hungry", _ => 1f)
);
Assert.Equal(0f, action.Score(default));
}
[Fact]
public void Weight_ScalesTheActionScore()
{
var low = new UtilityAction<Ctx>("a", 0.5f, new Consideration<Ctx>("k", _ => 0.4f));
var high = new UtilityAction<Ctx>("a", 2f, new Consideration<Ctx>("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<string> Run(Random random)
{
var picks = new List<string>();
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<ArgumentException>(() => new UtilityAi<Ctx>());
}
[Fact]
public void Consideration_Throws_WhenRangeIsDegenerate()
{
Assert.Throws<ArgumentException>(() =>
new Consideration<Ctx>("bad", c => c.Energy, min: 1f, max: 1f)
);
}
[Fact]
public void Consideration_NormalizesRawValuesAgainstItsRange()
{
var c = new Consideration<Ctx>("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
}
}
@@ -23,7 +23,8 @@ public class AssetHandlesGeneratorTests
values.TryGetValue(key, out value!);
}
private sealed class FakeOptionsProvider(Dictionary<string, string> values) : AnalyzerConfigOptionsProvider
private sealed class FakeOptionsProvider(Dictionary<string, string> 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<string, string>
{
["build_property.RootNamespace"] = "MyGame",
}));
additionalTexts: Array.ConvertAll(
files,
f => (AdditionalText)new FakeAdditionalText(f)
),
optionsProvider: new FakeOptionsProvider(
options
?? new Dictionary<string, string>
{
["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<global::Microsoft.Xna.Framework.Graphics.Texture2D> Player = new(\"Textures/player.png\")",
source);
source
);
Assert.Contains(
"AssetRef<global::Microsoft.Xna.Framework.Audio.SoundEffect> Jump = new(\"Sounds/jump.wav\")",
source);
source
);
Assert.Contains("AssetRef<global::FontStashSharp.FontSystem> Main", source);
Assert.Contains("AssetRef<global::MrGameEng.Core.MusicTrack> 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<global::MrGameEng.Atlases.TextureAtlas> 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));
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -16,5 +15,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Assets.Generator\MrGameEng.Assets.Generator.csproj" />
</ItemGroup>
</Project>
@@ -14,7 +14,15 @@ public sealed class AtlasBuilderTests : IDisposable
public void Dispose() => Directory.Delete(_root, recursive: true);
/// <summary>Writes a PNG filled with one RGBA color.</summary>
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,
},
],
};
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
</ItemGroup>
</Project>
@@ -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]
@@ -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);
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Collisions\MrGameEng.Collisions.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -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]
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.DevConsole\MrGameEng.DevConsole.csproj" />
</ItemGroup>
</Project>
@@ -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);
+20 -7
View File
@@ -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);
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -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]
@@ -42,7 +42,8 @@ public class SpriteAnimationTests
this.UseSpriteAnimation();
Animated = Store.CreateEntity(
new Sprite { Color = Color.White },
new SpriteAnimator(Clip));
new SpriteAnimator(Clip)
);
}
}
@@ -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]
@@ -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()
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Input\MrGameEng.Input.csproj" />
</ItemGroup>
</Project>
+16 -9
View File
@@ -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<AnimalDef>("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<AnimalDef>("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<AnimalDef>("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<InvalidDataException>(() => 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<InvalidDataException>(() => LoadAnimals(mod));
Assert.Contains("Ghost", exception.Message);
@@ -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();
+2 -1
View File
@@ -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]
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Mods\MrGameEng.Mods.csproj" />
</ItemGroup>
</Project>
@@ -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();
@@ -5,9 +5,13 @@ namespace MrGameEng.Pathfinding.Tests;
public class GridPathfinderTests
{
private static List<Point> Path(IPathGrid grid, Point start, Point goal,
private static List<Point> 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<Point>();
@@ -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<Point>();
@@ -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<Point>();
@@ -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<Point>();
@@ -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);
@@ -27,8 +27,9 @@ public class GridResizeTests
var pathfinder = new GridPathfinder(grid);
grid.Width = 8;
Assert.Throws<InvalidOperationException>(
() => pathfinder.FindPath(new Point(0, 0), new Point(1, 1), []));
Assert.Throws<InvalidOperationException>(() =>
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<InvalidOperationException>(
() => builder.Build([new Point(0, 0)], new FlowField()));
Assert.Throws<InvalidOperationException>(() =>
builder.Build([new Point(0, 0)], new FlowField())
);
}
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Pathfinding\MrGameEng.Pathfinding.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputType>Exe</OutputType>
@@ -15,5 +14,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Tilemaps\MrGameEng.Tilemaps.csproj" />
</ItemGroup>
</Project>
+47 -11
View File
@@ -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);
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
@@ -9,5 +8,4 @@
<ItemGroup>
<ProjectReference Include="..\..\src\MrGameEng.Atlases\MrGameEng.Atlases.csproj" />
</ItemGroup>
</Project>
+12 -6
View File
@@ -15,7 +15,8 @@ if (args.Length < 2 || args.Contains("--help") || args.Contains("-h"))
--padding <n> gap between images in pixels (default 2)
--root-name <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;