Compare commits
1
Commits
4ae730cafa
...
fd6343bd09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd6343bd09 |
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` — определение столкновений (без разрешения физики — она в бэклоге):
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,14 +46,16 @@ public sealed class MusicPlayer : IDisposable
|
||||
{
|
||||
reader.Dispose();
|
||||
throw new NotSupportedException(
|
||||
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported.");
|
||||
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported."
|
||||
);
|
||||
}
|
||||
|
||||
if (reader.SampleRate is < 8000 or > 48000)
|
||||
{
|
||||
reader.Dispose();
|
||||
throw new NotSupportedException(
|
||||
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 8000–48000 Hz.");
|
||||
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 8000–48000 Hz."
|
||||
);
|
||||
}
|
||||
|
||||
Stop();
|
||||
@@ -67,7 +69,8 @@ public sealed class MusicPlayer : IDisposable
|
||||
|
||||
_instance = new DynamicSoundEffectInstance(
|
||||
_reader.SampleRate,
|
||||
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo)
|
||||
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo
|
||||
)
|
||||
{
|
||||
Volume = _volume,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) { }
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>(); // поздний мод переопределяет ключ
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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?)."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user