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

Add MrGameEng.AI utility-AI module; format codebase with CSharpier

New MrGameEng.AI module (ResponseCurve, Consideration, UtilityAction,
UtilityAi selector, Blackboard) plus CSharpier formatting applied across
the whole engine. Documents the CSharpier convention in CLAUDE.md.
@
This commit is contained in:
Leonid Pershin
2026-06-12 07:19:10 +03:00
parent 4ae730cafa
commit fd6343bd09
96 changed files with 2066 additions and 589 deletions
+50
View File
@@ -0,0 +1,50 @@
namespace MrGameEng.AI;
/// <summary>
/// A small typed key/value store for an agent's working memory: perceived facts, a current target, a
/// cached path goal — whatever the considerations and actions need to share without being threaded
/// through method signatures. Keys are case-sensitive strings; values are stored boxed, so the
/// blackboard is a convenience for cold paths (perception, planning), not the per-frame hot loop.
/// </summary>
public sealed class Blackboard
{
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
/// <summary>The number of keys currently stored.</summary>
public int Count => _values.Count;
/// <summary>Stores <paramref name="value"/> under <paramref name="key"/>, replacing any existing entry.</summary>
public void Set<T>(string key, T value) => _values[key] = value;
/// <summary>
/// Reads the value under <paramref name="key"/> as <typeparamref name="T"/>. Returns <c>false</c> when
/// the key is missing or holds a value of a different type.
/// </summary>
public bool TryGet<T>(string key, out T value)
{
if (_values.TryGetValue(key, out var stored) && stored is T typed)
{
value = typed;
return true;
}
value = default!;
return false;
}
/// <summary>
/// Reads the value under <paramref name="key"/>, or returns <paramref name="fallback"/> when the key is
/// missing or holds a different type.
/// </summary>
public T GetOrDefault<T>(string key, T fallback = default!) =>
TryGet<T>(key, out var value) ? value : fallback;
/// <summary>True when <paramref name="key"/> has a value (of any type).</summary>
public bool Has(string key) => _values.ContainsKey(key);
/// <summary>Removes <paramref name="key"/>. Returns true when it was present.</summary>
public bool Remove(string key) => _values.Remove(key);
/// <summary>Drops every stored value.</summary>
public void Clear() => _values.Clear();
}
+56
View File
@@ -0,0 +1,56 @@
namespace MrGameEng.AI;
/// <summary>
/// One input to a utility decision. It reads a raw value from the agent's context, normalizes it to
/// <c>[0,1]</c> against an expected range, and shapes it through a <see cref="ResponseCurve"/> into a
/// utility score. Considerations are stateless and reusable: the context carries everything that
/// varies. <typeparamref name="TContext"/> is whatever the game passes in — a struct of perceived
/// values, an entity handle, a blackboard — the AI module never owns it.
/// </summary>
public sealed class Consideration<TContext>
{
private readonly Func<TContext, float> _input;
private readonly float _min;
private readonly float _inverseSpan;
private readonly ResponseCurve _curve;
/// <summary>
/// Creates a consideration named <paramref name="name"/> that reads <paramref name="input"/> from the
/// context, normalizes it from <c>[<paramref name="min"/>, <paramref name="max"/>]</c> to <c>[0,1]</c>
/// (values outside the range clamp to the ends), then applies <paramref name="curve"/>.
/// </summary>
/// <exception cref="ArgumentException"><paramref name="max"/> is not greater than <paramref name="min"/>.</exception>
public Consideration(
string name,
Func<TContext, float> input,
float min = 0f,
float max = 1f,
ResponseCurve? curve = null
)
{
if (max <= min)
{
throw new ArgumentException(
$"max ({max}) must be greater than min ({min}).",
nameof(max)
);
}
Name = name;
_input = input ?? throw new ArgumentNullException(nameof(input));
_min = min;
_inverseSpan = 1f / (max - min);
// default(ResponseCurve) has slope 0 (always 0), so omitting the curve means the identity.
_curve = curve ?? ResponseCurve.Identity;
}
/// <summary>A human-readable label, surfaced in debug/console output.</summary>
public string Name { get; }
/// <summary>Reads the context and returns this consideration's utility in <c>[0,1]</c>.</summary>
public float Score(TContext context)
{
var normalized = Math.Clamp((_input(context) - _min) * _inverseSpan, 0f, 1f);
return _curve.Evaluate(normalized);
}
}
+9
View File
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+115
View File
@@ -0,0 +1,115 @@
namespace MrGameEng.AI;
/// <summary>Shape of a <see cref="ResponseCurve"/> mapping a normalized input to a utility.</summary>
public enum CurveType
{
/// <summary>Straight line: <c>y = slope·(x xShift) + yShift</c>.</summary>
Linear,
/// <summary>Power curve: <c>y = slope·(x xShift)^exponent + yShift</c>; the exponent eases in/out.</summary>
Polynomial,
/// <summary>S-shaped logistic centred on <c>xShift</c>; <c>exponent</c> is the steepness.</summary>
Logistic,
/// <summary>Hermite smoothstep over <c>[xShift, xShift + 1/slope]</c>; flat ends, smooth middle.</summary>
SmoothStep,
}
/// <summary>
/// Maps a normalized input in <c>[0,1]</c> to a utility in <c>[0,1]</c> through one of a few
/// shapes. The input is clamped before evaluation and the output is clamped after, so a curve is
/// always safe to feed a raw normalized <see cref="Consideration{TContext}"/> value. Curves are
/// immutable value types — build them once and reuse them across evaluations.
/// </summary>
public readonly struct ResponseCurve
{
/// <summary>The shape applied by <see cref="Evaluate"/>.</summary>
public CurveType Type { get; }
/// <summary>Vertical scale / steepness (the <c>m</c> term). See <see cref="CurveType"/> per shape.</summary>
public float Slope { get; }
/// <summary>Power for <see cref="CurveType.Polynomial"/> and steepness for <see cref="CurveType.Logistic"/>.</summary>
public float Exponent { get; }
/// <summary>Horizontal shift of the curve (the <c>c</c> term): the input value mapped to the origin.</summary>
public float XShift { get; }
/// <summary>Vertical shift of the curve (the <c>b</c> term) added after scaling.</summary>
public float YShift { get; }
/// <summary>
/// Builds a curve from raw parameters. Prefer the named factories
/// (<see cref="Linear"/>, <see cref="Polynomial"/>, <see cref="Logistic"/>, <see cref="SmoothStep"/>)
/// which document the meaning of each term for their shape.
/// </summary>
public ResponseCurve(
CurveType type,
float slope = 1f,
float exponent = 1f,
float xShift = 0f,
float yShift = 0f
)
{
Type = type;
Slope = slope;
Exponent = exponent;
XShift = xShift;
YShift = yShift;
}
/// <summary>The identity curve: <c>y = x</c>. The default when a consideration needs no shaping.</summary>
public static ResponseCurve Identity => new(CurveType.Linear);
/// <summary>Straight line <c>y = slope·(x xShift) + yShift</c>. A negative slope inverts the input.</summary>
public static ResponseCurve Linear(float slope = 1f, float xShift = 0f, float yShift = 0f) =>
new(CurveType.Linear, slope, 1f, xShift, yShift);
/// <summary>
/// Power curve <c>y = slope·(x xShift)^exponent + yShift</c>. An exponent above 1 eases in
/// (slow start), below 1 eases out (fast start). Quadratic is <c>exponent = 2</c>.
/// </summary>
public static ResponseCurve Polynomial(
float exponent,
float slope = 1f,
float xShift = 0f,
float yShift = 0f
) => new(CurveType.Polynomial, slope, exponent, xShift, yShift);
/// <summary>
/// Logistic S-curve centred on <paramref name="midpoint"/>; <paramref name="steepness"/> controls how
/// sharp the transition is (≈10 gives a soft threshold, larger is more switch-like).
/// </summary>
public static ResponseCurve Logistic(float steepness = 10f, float midpoint = 0.5f) =>
new(CurveType.Logistic, 1f, steepness, midpoint);
/// <summary>
/// Hermite smoothstep rising from 0 to 1 over <c>[xShift, xShift + 1/slope]</c>: flat below the
/// start, flat above the end, smooth in between. Default rises across the whole <c>[0,1]</c> range.
/// </summary>
public static ResponseCurve SmoothStep(float slope = 1f, float xShift = 0f) =>
new(CurveType.SmoothStep, slope, 1f, xShift);
/// <summary>Evaluates the curve. <paramref name="x"/> is clamped to <c>[0,1]</c>; the result is clamped to <c>[0,1]</c>.</summary>
public float Evaluate(float x)
{
x = Math.Clamp(x, 0f, 1f);
var y = Type switch
{
CurveType.Linear => Slope * (x - XShift) + YShift,
CurveType.Polynomial => Slope * MathF.Pow(x - XShift, Exponent) + YShift,
CurveType.Logistic => 1f / (1f + MathF.Exp(-Exponent * (x - XShift))) * Slope + YShift,
CurveType.SmoothStep => SmoothStepValue(x),
_ => x,
};
return Math.Clamp(y, 0f, 1f);
}
private float SmoothStepValue(float x)
{
var t = Math.Clamp((x - XShift) * Slope, 0f, 1f);
return t * t * (3f - 2f * t) + YShift;
}
}
+75
View File
@@ -0,0 +1,75 @@
namespace MrGameEng.AI;
/// <summary>
/// A candidate action scored by a set of <see cref="Consideration{TContext}"/>s. Its score is the
/// product of every consideration (each in <c>[0,1]</c>) times <see cref="Weight"/>, so any single
/// veto (a 0) drops the action out of contention. Multiplying many factors biases the result low, so
/// a compensation factor scales it back up in proportion to how many considerations contributed —
/// the "make-up value" from Dave Mark's Infinite-Axis Utility System.
/// </summary>
public sealed class UtilityAction<TContext>
{
private readonly Consideration<TContext>[] _considerations;
/// <summary>
/// Creates an action named <paramref name="name"/> with a base <paramref name="weight"/> (a static
/// priority multiplier; 1 is neutral) and the considerations that score it for a given context.
/// </summary>
public UtilityAction(string name, float weight, params Consideration<TContext>[] considerations)
{
Name = name;
Weight = weight;
_considerations = considerations ?? [];
}
/// <summary>Creates an action with neutral weight (1).</summary>
public UtilityAction(string name, params Consideration<TContext>[] considerations)
: this(name, 1f, considerations) { }
/// <summary>A human-readable label, surfaced in debug/console output.</summary>
public string Name { get; }
/// <summary>Static priority multiplier applied to the product of considerations.</summary>
public float Weight { get; }
/// <summary>The considerations scoring this action, in evaluation order.</summary>
public IReadOnlyList<Consideration<TContext>> Considerations => _considerations;
/// <summary>Scores this action for <paramref name="context"/>. Higher wins; a 0 consideration vetoes it.</summary>
public float Score(TContext context)
{
var count = _considerations.Length;
if (count == 0)
{
return Math.Max(0f, Weight);
}
var product = Weight;
for (var i = 0; i < count; i++)
{
var score = _considerations[i].Score(context);
if (score <= 0f)
{
return 0f; // veto: no point evaluating the rest
}
product *= score;
}
return Math.Max(0f, Compensate(product, count));
}
// Counteracts the downward bias of multiplying N factors in [0,1]: the more considerations
// contribute, the more the product is nudged back toward its un-multiplied magnitude.
private static float Compensate(float product, int count)
{
if (count <= 1)
{
return product;
}
var modificationFactor = 1f - 1f / count;
var makeUp = (1f - product) * modificationFactor;
return product + makeUp * product;
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace MrGameEng.AI;
/// <summary>
/// A utility reasoner over a fixed set of <see cref="UtilityAction{TContext}"/>s. Each evaluation
/// scores every action for the given context and picks one. Scoring writes into a buffer owned by the
/// reasoner, so repeated evaluations allocate nothing; keep one instance per agent kind (or one per
/// system, reused across agents) and pass each agent's context in. Not thread-safe: the score buffer
/// is shared between calls, so a single instance must not be evaluated from two threads at once.
/// </summary>
public sealed class UtilityAi<TContext>
{
private readonly UtilityAction<TContext>[] _actions;
private readonly float[] _scores;
/// <summary>Creates a reasoner choosing between <paramref name="actions"/> (at least one required).</summary>
/// <exception cref="ArgumentException"><paramref name="actions"/> is empty.</exception>
public UtilityAi(params UtilityAction<TContext>[] actions)
{
if (actions is null || actions.Length == 0)
{
throw new ArgumentException("A UtilityAi needs at least one action.", nameof(actions));
}
_actions = actions;
_scores = new float[actions.Length];
}
/// <summary>The actions this reasoner chooses between, in evaluation order.</summary>
public IReadOnlyList<UtilityAction<TContext>> Actions => _actions;
/// <summary>
/// The scores from the most recent <see cref="Select"/> / <see cref="SelectWeighted"/> call, aligned
/// with <see cref="Actions"/>. Useful for debug overlays and console dumps.
/// </summary>
public ReadOnlySpan<float> LastScores => _scores;
/// <summary>
/// Scores every action for <paramref name="context"/> and returns the highest, or <c>null</c> when no
/// action scores strictly above <paramref name="threshold"/>. Ties resolve to the earliest action,
/// so selection is fully deterministic for identical inputs.
/// </summary>
public UtilityAction<TContext>? Select(TContext context, float threshold = 0f)
{
var best = -1;
var bestScore = threshold;
for (var i = 0; i < _actions.Length; i++)
{
var score = _actions[i].Score(context);
_scores[i] = score;
if (score > bestScore)
{
bestScore = score;
best = i;
}
}
return best >= 0 ? _actions[best] : null;
}
/// <summary>
/// Scores every action and picks one at random in proportion to its score (roulette selection over
/// the actions above <paramref name="threshold"/>), giving believable variety while staying
/// deterministic for a given <paramref name="random"/> sequence. Returns <c>null</c> when nothing
/// qualifies. Pass a seeded <see cref="Random"/> owned by the calling system — never
/// <see cref="Random.Shared"/> — to keep the simulation reproducible.
/// </summary>
public UtilityAction<TContext>? SelectWeighted(
TContext context,
Random random,
float threshold = 0f
)
{
var total = 0f;
for (var i = 0; i < _actions.Length; i++)
{
var score = _actions[i].Score(context);
_scores[i] = score;
if (score > threshold)
{
total += score;
}
}
if (total <= 0f)
{
return null;
}
var roll = (float)random.NextDouble() * total;
for (var i = 0; i < _actions.Length; i++)
{
if (_scores[i] <= threshold)
{
continue;
}
roll -= _scores[i];
if (roll <= 0f)
{
return _actions[i];
}
}
// Floating-point slack can leave roll just above 0; fall back to the last qualifying action.
for (var i = _actions.Length - 1; i >= 0; i--)
{
if (_scores[i] > threshold)
{
return _actions[i];
}
}
return null;
}
}
@@ -15,7 +15,9 @@ namespace MrGameEng.Assets.Generator;
[Generator]
public sealed class AssetHandlesGenerator : IIncrementalGenerator
{
private static readonly Dictionary<string, string> TypeByExtension = new(StringComparer.OrdinalIgnoreCase)
private static readonly Dictionary<string, string> TypeByExtension = new(
StringComparer.OrdinalIgnoreCase
)
{
[".png"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
[".jpg"] = "global::Microsoft.Xna.Framework.Graphics.Texture2D",
@@ -32,29 +34,46 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var options = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns);
provider.GlobalOptions.TryGetValue("build_property.MrGameEngAssetsClassName", out var className);
return (
Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!,
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!);
});
var options = context.AnalyzerConfigOptionsProvider.Select(
static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.RootNamespace", out var ns);
provider.GlobalOptions.TryGetValue(
"build_property.MrGameEngAssetsClassName",
out var className
);
return (
Namespace: string.IsNullOrEmpty(ns) ? "Game" : ns!,
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!
);
}
);
var projectDir = context.AnalyzerConfigOptionsProvider.Select(static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir);
return dir ?? string.Empty;
});
var projectDir = context.AnalyzerConfigOptionsProvider.Select(
static (provider, _) =>
{
provider.GlobalOptions.TryGetValue("build_property.projectdir", out var dir);
return dir ?? string.Empty;
}
);
var assets = context.AdditionalTextsProvider
.Combine(projectDir)
var assets = context
.AdditionalTextsProvider.Combine(projectDir)
.Select(static (pair, _) => ToAssetPath(pair.Left.Path, pair.Right))
.Where(static path => path is not null)
.Collect();
context.RegisterSourceOutput(assets.Combine(options), static (production, input) =>
production.AddSource("GameAssets.g.cs", SourceText.From(Emit(input.Left!, input.Right.Namespace, input.Right.ClassName), Encoding.UTF8)));
context.RegisterSourceOutput(
assets.Combine(options),
static (production, input) =>
production.AddSource(
"GameAssets.g.cs",
SourceText.From(
Emit(input.Left!, input.Right.Namespace, input.Right.ClassName),
Encoding.UTF8
)
)
);
}
/// <summary>
@@ -69,8 +88,13 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
var normalized = fullPath.Replace('\\', '/');
string relative;
var root = string.IsNullOrEmpty(projectDir) ? null : projectDir!.Replace('\\', '/').TrimEnd('/');
if (root is not null && normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase))
var root = string.IsNullOrEmpty(projectDir)
? null
: projectDir!.Replace('\\', '/').TrimEnd('/');
if (
root is not null
&& normalized.StartsWith(root + "/Assets/", StringComparison.OrdinalIgnoreCase)
)
{
relative = normalized.Substring(root.Length + "/Assets/".Length);
}
@@ -115,7 +139,9 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
source.AppendLine("// <auto-generated by MrGameEng.Assets.Generator />");
source.AppendLine($"namespace {ns};");
source.AppendLine();
source.AppendLine("/// <summary>Typed handles for every file under the Assets directory.</summary>");
source.AppendLine(
"/// <summary>Typed handles for every file under the Assets directory.</summary>"
);
source.AppendLine($"public static partial class {className}");
source.AppendLine("{");
EmitNode(source, root, indent: 1, enclosingName: className);
@@ -135,8 +161,9 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
source.AppendLine($"{pad}/// <summary>{XmlEscape(relativePath)}</summary>");
source.AppendLine(
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = " +
$"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});");
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = "
+ $"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});"
);
}
foreach (var pair in node.Children)
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsRoslynComponent>true</IsRoslynComponent>
@@ -14,5 +13,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Assets.Generator.Tests" />
</ItemGroup>
</Project>
+18 -7
View File
@@ -39,7 +39,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
public T Load<T>(AssetRef<T> asset) where T : class
public T Load<T>(AssetRef<T> asset)
where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.TryGetValue(key, out var cached))
@@ -49,13 +50,18 @@ public sealed class AssetManager : IDisposable
if (!_loaders.TryGetValue(typeof(T), out var loader))
{
throw new InvalidOperationException($"No asset loader registered for type {typeof(T)}.");
throw new InvalidOperationException(
$"No asset loader registered for type {typeof(T)}."
);
}
var fullPath = ResolvePath(asset.Path);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException($"Asset '{asset.Path}' not found at '{fullPath}'.", fullPath);
throw new FileNotFoundException(
$"Asset '{asset.Path}' not found at '{fullPath}'.",
fullPath
);
}
var loaded = (T)loader(this, fullPath);
@@ -64,7 +70,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
public void Unload<T>(AssetRef<T> asset) where T : class
public void Unload<T>(AssetRef<T> asset)
where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.Remove(key, out var value) && value is IDisposable disposable)
@@ -74,8 +81,8 @@ public sealed class AssetManager : IDisposable
}
/// <summary>Replaces or adds the loader used for assets of type <typeparamref name="T"/>.</summary>
public void RegisterLoader<T>(Func<AssetManager, string, T> loader) where T : class =>
_loaders[typeof(T)] = loader;
public void RegisterLoader<T>(Func<AssetManager, string, T> loader)
where T : class => _loaders[typeof(T)] = loader;
/// <summary>Resolves an asset-relative path to an absolute file path.</summary>
public string ResolvePath(string relativePath) =>
@@ -95,7 +102,11 @@ public sealed class AssetManager : IDisposable
private static Texture2D LoadTexture(EngineContext context, string path)
{
using var stream = File.OpenRead(path);
return Texture2D.FromStream(context.GraphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
return Texture2D.FromStream(
context.GraphicsDevice,
stream,
DefaultColorProcessors.PremultiplyAlpha
);
}
private static SoundEffect LoadSoundEffect(string path)
+2 -1
View File
@@ -7,7 +7,8 @@ namespace MrGameEng.Assets;
/// </summary>
/// <typeparam name="T">Runtime type the asset loads into (e.g. <c>Texture2D</c>).</typeparam>
/// <param name="Path">Path relative to the asset root, with forward slashes.</param>
public readonly record struct AssetRef<T>(string Path) where T : class
public readonly record struct AssetRef<T>(string Path)
where T : class
{
/// <inheritdoc />
public override string ToString() => $"{typeof(T).Name}:{Path}";
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+143 -56
View File
@@ -44,7 +44,10 @@ public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCoun
/// <summary>Result of an <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
public sealed record AtlasBuildResult(IReadOnlyList<AtlasGroupResult> Groups, IReadOnlyList<string> DeletedOrphans);
public sealed record AtlasBuildResult(
IReadOnlyList<AtlasGroupResult> Groups,
IReadOnlyList<string> DeletedOrphans
);
/// <summary>
/// Build-time utility converting a directory tree of loose images into texture atlases:
@@ -58,7 +61,12 @@ public static class AtlasBuilder
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
/// <summary>Snapshot of one source image taken at scan time (size/mtime feed the staleness check).</summary>
private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks);
private readonly record struct SourceFile(
string FullPath,
string Key,
long Size,
long ModifiedTicks
);
/// <summary>Builds (or incrementally refreshes) all atlases from <see cref="AtlasBuildOptions.SourceDirectory"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
@@ -71,12 +79,17 @@ public static class AtlasBuilder
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
if (!Directory.Exists(sourceRoot))
{
throw new DirectoryNotFoundException($"Atlas source directory not found: '{sourceRoot}'.");
throw new DirectoryNotFoundException(
$"Atlas source directory not found: '{sourceRoot}'."
);
}
return Build(options, Directory
.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories)
.Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath))));
return Build(
options,
Directory
.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories)
.Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath)))
);
}
/// <summary>
@@ -85,7 +98,9 @@ public static class AtlasBuilder
/// in different roots. Region keys come from <c>RelativePath</c> without extension.
/// </summary>
public static AtlasBuildResult Build(
AtlasBuildOptions options, IEnumerable<(string FullPath, string RelativePath)> sources)
AtlasBuildOptions options,
IEnumerable<(string FullPath, string RelativePath)> sources
)
{
Directory.CreateDirectory(options.OutputDirectory);
@@ -101,7 +116,11 @@ public static class AtlasBuilder
}
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
internal static (string AtlasName, string Key) ClassifyPath(string relativePath, int groupDepth, string rootAtlasName)
internal static (string AtlasName, string Key) ClassifyPath(
string relativePath,
int groupDepth,
string rootAtlasName
)
{
var normalized = relativePath.Replace('\\', '/');
var key = normalized[..normalized.LastIndexOf('.')];
@@ -112,21 +131,34 @@ public static class AtlasBuilder
}
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
IEnumerable<(string FullPath, string RelativePath)> sources, AtlasBuildOptions options)
IEnumerable<(string FullPath, string RelativePath)> sources,
AtlasBuildOptions options
)
{
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (fullPath, relative) in sources)
{
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
if (
!SourceExtensions.Contains(
Path.GetExtension(fullPath),
StringComparer.OrdinalIgnoreCase
)
)
{
continue;
}
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
var (atlasName, key) = ClassifyPath(
relative,
options.GroupDepth,
options.RootAtlasName
);
if (keys.TryGetValue(key, out var existing))
{
throw new InvalidDataException($"Duplicate region key '{key}': '{existing}' and '{relative}'.");
throw new InvalidDataException(
$"Duplicate region key '{key}': '{existing}' and '{relative}'."
);
}
keys.Add(key, relative);
@@ -144,7 +176,10 @@ public static class AtlasBuilder
}
private static AtlasGroupResult BuildGroup(
string name, List<SourceFile> files, AtlasBuildOptions options)
string name,
List<SourceFile> files,
AtlasBuildOptions options
)
{
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
@@ -154,11 +189,18 @@ public static class AtlasBuilder
// Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы).
var images = new ImageResult[files.Count];
Parallel.For(0, files.Count, i =>
{
using var stream = File.OpenRead(files[i].FullPath);
images[i] = ImageResult.FromStream(stream, StbImageSharp.ColorComponents.RedGreenBlueAlpha);
});
Parallel.For(
0,
files.Count,
i =>
{
using var stream = File.OpenRead(files[i].FullPath);
images[i] = ImageResult.FromStream(
stream,
StbImageSharp.ColorComponents.RedGreenBlueAlpha
);
}
);
var items = new PackItem[files.Count];
for (var i = 0; i < files.Count; i++)
@@ -182,7 +224,11 @@ public static class AtlasBuilder
}
private static bool IsUpToDate(
string metadataPath, List<SourceFile> files, AtlasBuildOptions options, out int pages)
string metadataPath,
List<SourceFile> files,
AtlasBuildOptions options,
out int pages
)
{
pages = 0;
if (!File.Exists(metadataPath))
@@ -200,8 +246,11 @@ public static class AtlasBuilder
return false;
}
if (metadata.Version != AtlasMetadata.CurrentVersion ||
metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
if (
metadata.Version != AtlasMetadata.CurrentVersion
|| metadata.PageSize != options.MaxPageSize
|| metadata.Padding != options.Padding
)
{
return false;
}
@@ -222,8 +271,11 @@ public static class AtlasBuilder
var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
foreach (var file in files)
{
if (!sourcesByKey.TryGetValue(file.Key, out var source) ||
source.Size != file.Size || source.Modified != file.ModifiedTicks)
if (
!sourcesByKey.TryGetValue(file.Key, out var source)
|| source.Size != file.Size
|| source.Modified != file.ModifiedTicks
)
{
return false;
}
@@ -234,37 +286,60 @@ public static class AtlasBuilder
}
private static void WritePages(
string name, PackResult packed, Dictionary<string, ImageResult> pixelsByKey, string outputDirectory)
string name,
PackResult packed,
Dictionary<string, ImageResult> pixelsByKey,
string outputDirectory
)
{
Parallel.For(0, packed.PageSizes.Count, page =>
{
var (width, height) = packed.PageSizes[page];
var buffer = new byte[width * height * 4];
foreach (var placement in packed.Placements)
Parallel.For(
0,
packed.PageSizes.Count,
page =>
{
if (placement.Page != page)
var (width, height) = packed.PageSizes[page];
var buffer = new byte[width * height * 4];
foreach (var placement in packed.Placements)
{
continue;
if (placement.Page != page)
{
continue;
}
var source = pixelsByKey[placement.Key];
for (var row = 0; row < source.Height; row++)
{
Array.Copy(
source.Data,
row * source.Width * 4,
buffer,
((placement.Y + row) * width + placement.X) * 4,
source.Width * 4
);
}
}
var source = pixelsByKey[placement.Key];
for (var row = 0; row < source.Height; row++)
{
Array.Copy(
source.Data, row * source.Width * 4,
buffer, ((placement.Y + row) * width + placement.X) * 4,
source.Width * 4);
}
using var stream = File.Create(
Path.Combine(outputDirectory, PageFileName(name, page))
);
new ImageWriter().WritePng(
buffer,
width,
height,
StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha,
stream
);
}
using var stream = File.Create(Path.Combine(outputDirectory, PageFileName(name, page)));
new ImageWriter().WritePng(
buffer, width, height, StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha, stream);
});
);
}
private static void WriteMetadata(
string name, PackResult packed, List<SourceFile> files, AtlasBuildOptions options, string metadataPath)
string name,
PackResult packed,
List<SourceFile> files,
AtlasBuildOptions options,
string metadataPath
)
{
var metadata = new AtlasMetadata
{
@@ -273,18 +348,26 @@ public static class AtlasBuilder
Padding = options.Padding,
Sources = files
.OrderBy(f => f.Key, StringComparer.Ordinal)
.Select(f => new AtlasSource { Key = f.Key, Size = f.Size, Modified = f.ModifiedTicks })
.ToList(),
Pages = packed.PageSizes
.Select((size, index) => new AtlasPage
.Select(f => new AtlasSource
{
File = PageFileName(name, index),
Width = size.Width,
Height = size.Height,
Key = f.Key,
Size = f.Size,
Modified = f.ModifiedTicks,
})
.ToList(),
Regions = packed.Placements
.OrderBy(p => p.Key, StringComparer.Ordinal)
Pages = packed
.PageSizes.Select(
(size, index) =>
new AtlasPage
{
File = PageFileName(name, index),
Width = size.Width,
Height = size.Height,
}
)
.ToList(),
Regions = packed
.Placements.OrderBy(p => p.Key, StringComparer.Ordinal)
.Select(p => new AtlasRegion
{
Key = p.Key,
@@ -300,7 +383,8 @@ public static class AtlasBuilder
File.WriteAllText(metadataPath, metadata.ToJson());
}
private static string PageFileName(string atlasName, int page) => $"{atlasName}.atlas.{page}.png";
private static string PageFileName(string atlasName, int page) =>
$"{atlasName}.atlas.{page}.png";
private static void DeleteExtraPages(string name, int pageCount, string outputDirectory)
{
@@ -316,7 +400,10 @@ public static class AtlasBuilder
}
}
private static List<string> DeleteOrphans(string outputDirectory, IEnumerable<string> liveAtlasNames)
private static List<string> DeleteOrphans(
string outputDirectory,
IEnumerable<string> liveAtlasNames
)
{
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
var deleted = new List<string>();
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -18,5 +17,4 @@
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
<ProjectReference Include="..\MrGameEng.Assets\MrGameEng.Assets.csproj" />
</ItemGroup>
</Project>
+42 -16
View File
@@ -13,7 +13,14 @@ public readonly record struct PackItem(string Key, int Width, int Height);
/// <param name="Y">Y position in page pixels.</param>
/// <param name="Width">Item width in pixels.</param>
/// <param name="Height">Item height in pixels.</param>
public readonly record struct PackPlacement(string Key, int Page, int X, int Y, int Width, int Height);
public readonly record struct PackPlacement(
string Key,
int Page,
int X,
int Y,
int Width,
int Height
);
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
/// <param name="Placements">One placement per input item.</param>
@@ -21,7 +28,10 @@ public readonly record struct PackPlacement(string Key, int Page, int X, int Y,
/// Width/height of each page: the next power of two covering its content, clamped to the
/// page-size limit. Dedicated pages of oversized items keep their exact (padded) size.
/// </param>
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
public sealed record PackResult(
IReadOnlyList<PackPlacement> Placements,
IReadOnlyList<(int Width, int Height)> PageSizes
);
/// <summary>
/// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out
@@ -41,17 +51,19 @@ public static class ShelfPacker
ArgumentOutOfRangeException.ThrowIfNegative(padding);
var sorted = items.ToList();
sorted.Sort(static (a, b) =>
{
var byHeight = b.Height.CompareTo(a.Height);
if (byHeight != 0)
sorted.Sort(
static (a, b) =>
{
return byHeight;
}
var byHeight = b.Height.CompareTo(a.Height);
if (byHeight != 0)
{
return byHeight;
}
var byWidth = b.Width.CompareTo(a.Width);
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
});
var byWidth = b.Width.CompareTo(a.Width);
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
}
);
var placements = new List<PackPlacement>(items.Count);
var pageSizes = new List<(int Width, int Height)>();
@@ -68,9 +80,12 @@ public static class ShelfPacker
{
if (open)
{
pageSizes.Add((
PageDimension(usedWidth + padding, maxPageSize),
PageDimension(usedHeight + padding, maxPageSize)));
pageSizes.Add(
(
PageDimension(usedWidth + padding, maxPageSize),
PageDimension(usedHeight + padding, maxPageSize)
)
);
open = false;
}
}
@@ -92,7 +107,16 @@ public static class ShelfPacker
{
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
placements.Add(
new PackPlacement(
item.Key,
pageSizes.Count,
padding,
padding,
item.Width,
item.Height
)
);
pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
}
}
@@ -120,7 +144,9 @@ public static class ShelfPacker
}
}
placements.Add(new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height));
placements.Add(
new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height)
);
x += item.Width + padding;
shelfHeight = Math.Max(shelfHeight, item.Height);
usedWidth = Math.Max(usedWidth, x - padding);
+15 -5
View File
@@ -28,14 +28,19 @@ public sealed class TextureAtlas : IDisposable
{
Name = metadata.Name;
Pages = pages;
_regions = new Dictionary<string, Texture2DRegion>(metadata.Regions.Count, StringComparer.Ordinal);
_regions = new Dictionary<string, Texture2DRegion>(
metadata.Regions.Count,
StringComparer.Ordinal
);
foreach (var region in metadata.Regions)
{
_regions.Add(
region.Key,
new Texture2DRegion(
pages[region.Page],
new Rectangle(region.X, region.Y, region.Width, region.Height)));
new Rectangle(region.X, region.Y, region.Width, region.Height)
)
);
}
}
@@ -59,8 +64,9 @@ public sealed class TextureAtlas : IDisposable
if (metadata.Version != AtlasMetadata.CurrentVersion)
{
throw new InvalidDataException(
$"Atlas '{metadataPath}' has format version {metadata.Version}, expected " +
$"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool.");
$"Atlas '{metadataPath}' has format version {metadata.Version}, expected "
+ $"{AtlasMetadata.CurrentVersion}. Rebuild the atlases with the atlas tool."
);
}
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
@@ -70,7 +76,11 @@ public sealed class TextureAtlas : IDisposable
for (var i = 0; i < pages.Length; i++)
{
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
pages[i] = Texture2D.FromStream(
graphicsDevice,
stream,
DefaultColorProcessors.PremultiplyAlpha
);
}
}
catch
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+6 -3
View File
@@ -46,14 +46,16 @@ public sealed class MusicPlayer : IDisposable
{
reader.Dispose();
throw new NotSupportedException(
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported.");
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported."
);
}
if (reader.SampleRate is < 8000 or > 48000)
{
reader.Dispose();
throw new NotSupportedException(
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 800048000 Hz.");
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 800048000 Hz."
);
}
Stop();
@@ -67,7 +69,8 @@ public sealed class MusicPlayer : IDisposable
_instance = new DynamicSoundEffectInstance(
_reader.SampleRate,
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo)
_reader.Channels == 1 ? AudioChannels.Mono : AudioChannels.Stereo
)
{
Volume = _volume,
};
+18 -16
View File
@@ -40,22 +40,24 @@ public struct Collider : IComponent
public uint CollidesWith;
/// <summary>Creates a circle collider on layer 1 colliding with everything.</summary>
public static Collider Circle(float radius, Vector2 offset = default) => new()
{
Shape = ColliderShape.Circle,
Radius = radius,
Offset = offset,
Layer = 1,
CollidesWith = uint.MaxValue,
};
public static Collider Circle(float radius, Vector2 offset = default) =>
new()
{
Shape = ColliderShape.Circle,
Radius = radius,
Offset = offset,
Layer = 1,
CollidesWith = uint.MaxValue,
};
/// <summary>Creates a box collider on layer 1 colliding with everything.</summary>
public static Collider Box(float width, float height, Vector2 offset = default) => new()
{
Shape = ColliderShape.Box,
HalfExtents = new Vector2(width / 2f, height / 2f),
Offset = offset,
Layer = 1,
CollidesWith = uint.MaxValue,
};
public static Collider Box(float width, float height, Vector2 offset = default) =>
new()
{
Shape = ColliderShape.Box,
HalfExtents = new Vector2(width / 2f, height / 2f),
Offset = offset,
Layer = 1,
CollidesWith = uint.MaxValue,
};
}
+21 -10
View File
@@ -85,9 +85,10 @@ public sealed class CollisionWorld
}
var center = transform.Position + collider.Offset;
var half = collider.Shape == ColliderShape.Circle
? new Vector2(collider.Radius)
: collider.HalfExtents;
var half =
collider.Shape == ColliderShape.Circle
? new Vector2(collider.Radius)
: collider.HalfExtents;
_entries[_count++] = new Entry
{
@@ -173,9 +174,10 @@ public sealed class CollisionWorld
}
float fraction;
var found = entry.Shape == ColliderShape.Circle
? RaySegmentCircle(from, to, entry.Center, entry.Radius, out fraction)
: RaySegmentAabb(from, to, entry.Aabb, out fraction);
var found =
entry.Shape == ColliderShape.Circle
? RaySegmentCircle(from, to, entry.Center, entry.Radius, out fraction)
: RaySegmentAabb(from, to, entry.Aabb, out fraction);
if (found && fraction < bestFraction)
{
@@ -289,8 +291,10 @@ public sealed class CollisionWorld
if (a.Shape == ColliderShape.Box && b.Shape == ColliderShape.Box)
{
// Включительно (касание = пара) — единообразно с кругами; RectF.Intersects строгий.
return a.Aabb.Left <= b.Aabb.Right && b.Aabb.Left <= a.Aabb.Right &&
a.Aabb.Top <= b.Aabb.Bottom && b.Aabb.Top <= a.Aabb.Bottom;
return a.Aabb.Left <= b.Aabb.Right
&& b.Aabb.Left <= a.Aabb.Right
&& a.Aabb.Top <= b.Aabb.Bottom
&& b.Aabb.Top <= a.Aabb.Bottom;
}
// circle vs box
@@ -298,11 +302,18 @@ public sealed class CollisionWorld
ref readonly var box = ref a.Shape == ColliderShape.Circle ? ref b : ref a;
var nearest = new Vector2(
Math.Clamp(circle.Center.X, box.Aabb.Left, box.Aabb.Right),
Math.Clamp(circle.Center.Y, box.Aabb.Top, box.Aabb.Bottom));
Math.Clamp(circle.Center.Y, box.Aabb.Top, box.Aabb.Bottom)
);
return Vector2.DistanceSquared(circle.Center, nearest) <= circle.Radius * circle.Radius;
}
private static bool RaySegmentCircle(Vector2 from, Vector2 to, Vector2 center, float radius, out float fraction)
private static bool RaySegmentCircle(
Vector2 from,
Vector2 to,
Vector2 center,
float radius,
out float fraction
)
{
fraction = 0f;
var d = to - from;
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -8,5 +7,4 @@
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
+4 -1
View File
@@ -22,7 +22,10 @@ public sealed class EngineContext
/// throws when accessed in a headless context (unit tests).
/// </summary>
public GraphicsDevice GraphicsDevice =>
_graphicsDevice ?? throw new InvalidOperationException("GraphicsDevice is not available (headless context).");
_graphicsDevice
?? throw new InvalidOperationException(
"GraphicsDevice is not available (headless context)."
);
/// <summary>True when a graphics device is attached.</summary>
public bool HasGraphicsDevice => _graphicsDevice is not null;
-2
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -12,5 +11,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
</ItemGroup>
</Project>
+5 -3
View File
@@ -23,7 +23,8 @@ public abstract class Scene
public SystemRoot DrawSystems { get; }
/// <summary>Engine context. Valid from <see cref="OnLoad"/> until <see cref="OnUnload"/>.</summary>
public EngineContext Context => _context ?? throw new InvalidOperationException("Scene is not loaded.");
public EngineContext Context =>
_context ?? throw new InvalidOperationException("Scene is not loaded.");
/// <summary>True while the scene is the active, loaded scene.</summary>
public bool IsLoaded => _context is not null;
@@ -66,8 +67,9 @@ public abstract class Scene
if (_loadedOnce)
{
throw new InvalidOperationException(
$"Scene '{GetType().Name}' was already loaded once. Scene instances are " +
"single-use: create a new instance instead of switching back to an old one.");
$"Scene '{GetType().Name}' was already loaded once. Scene instances are "
+ "single-use: create a new instance instead of switching back to an old one."
);
}
_loadedOnce = true;
+18 -3
View File
@@ -67,7 +67,12 @@ public sealed class SceneManager
break;
case State.CoveringOut:
_coverage = Advance(_coverage, +1f, _transition!.OutDuration, clock.UnscaledDeltaTime);
_coverage = Advance(
_coverage,
+1f,
_transition!.OutDuration,
clock.UnscaledDeltaTime
);
if (_coverage >= 1f)
{
ApplyPending();
@@ -77,7 +82,12 @@ public sealed class SceneManager
break;
case State.RevealingIn:
_coverage = Advance(_coverage, -1f, _transition!.InDuration, clock.UnscaledDeltaTime);
_coverage = Advance(
_coverage,
-1f,
_transition!.InDuration,
clock.UnscaledDeltaTime
);
if (_coverage <= 0f)
{
_state = State.Idle;
@@ -127,7 +137,12 @@ public sealed class SceneManager
Log.Info($"Scene switched to {Current?.GetType().Name ?? "<none>"}");
}
private static float Advance(float coverage, float direction, float duration, float deltaTime) =>
private static float Advance(
float coverage,
float direction,
float duration,
float deltaTime
) =>
duration <= 0f
? coverage + direction
: Math.Clamp(coverage + direction * deltaTime / duration, 0f, 1f);
+17 -6
View File
@@ -9,24 +9,31 @@ public sealed class ServiceRegistry
private readonly Dictionary<Type, object> _services = new();
/// <summary>Registers a service instance under type <typeparamref name="T"/>. Throws if already registered.</summary>
public void Add<T>(T service) where T : class
public void Add<T>(T service)
where T : class
{
if (!_services.TryAdd(typeof(T), service))
{
throw new InvalidOperationException($"Service of type {typeof(T)} is already registered.");
throw new InvalidOperationException(
$"Service of type {typeof(T)} is already registered."
);
}
}
/// <summary>Returns the registered service of type <typeparamref name="T"/>. Throws if missing.</summary>
public T Get<T>() where T : class
public T Get<T>()
where T : class
{
return _services.TryGetValue(typeof(T), out var service)
? (T)service
: throw new InvalidOperationException($"Service of type {typeof(T)} is not registered.");
: throw new InvalidOperationException(
$"Service of type {typeof(T)} is not registered."
);
}
/// <summary>Returns the registered service of type <typeparamref name="T"/> or null.</summary>
public T? GetOrDefault<T>() where T : class
public T? GetOrDefault<T>()
where T : class
{
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
}
@@ -41,7 +48,11 @@ public sealed class ServiceRegistry
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (var service in _services.Values)
{
if (!ReferenceEquals(service, except) && service is IDisposable disposable && disposed.Add(service))
if (
!ReferenceEquals(service, except)
&& service is IDisposable disposable
&& disposed.Add(service)
)
{
disposable.Dispose();
}
+10 -3
View File
@@ -50,14 +50,21 @@ public abstract class Transition
private sealed class FadeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase) =>
renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
) => renderer.Fill(0f, 0f, 1f, 1f, color, coverage);
}
private sealed class WipeTransition(float outDuration, float inDuration, Color color)
: Transition(outDuration, inDuration)
{
public override void Draw(TransitionRenderer renderer, float coverage, TransitionPhase phase)
public override void Draw(
TransitionRenderer renderer,
float coverage,
TransitionPhase phase
)
{
// Out: шторка растёт слева направо; In: уезжает дальше вправо.
if (phase == TransitionPhase.Out)
+35 -17
View File
@@ -39,15 +39,28 @@ public sealed class DevConsole : IDisposable
public DevConsole(int capacity = 2048)
{
_lines = new string[capacity];
Register("help", "list available commands", static (console, _) =>
{
foreach (var (name, entry) in console._commands.OrderBy(p => p.Key, StringComparer.Ordinal))
Register(
"help",
"list available commands",
static (console, _) =>
{
console.WriteLine($" {name} — {entry.Description}");
foreach (
var (name, entry) in console._commands.OrderBy(
p => p.Key,
StringComparer.Ordinal
)
)
{
console.WriteLine($" {name} — {entry.Description}");
}
}
});
);
Register("clear", "clear the log", static (console, _) => console.Clear());
Register("echo", "print the arguments", static (console, args) => console.WriteLine(string.Join(' ', args)));
Register(
"echo",
"print the arguments",
static (console, args) => console.WriteLine(string.Join(' ', args))
);
Log.MessageLogged += OnLogMessage;
}
@@ -182,8 +195,8 @@ public sealed class DevConsole : IDisposable
public string Complete(string prefix)
{
prefix = prefix.TrimStart();
var matches = _commands.Keys
.Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
var matches = _commands
.Keys.Where(name => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
@@ -199,8 +212,12 @@ public sealed class DevConsole : IDisposable
foreach (var match in matches[1..])
{
var length = 0;
while (length < common.Length && length < match.Length &&
char.ToLowerInvariant(common[length]) == char.ToLowerInvariant(match[length]))
while (
length < common.Length
&& length < match.Length
&& char.ToLowerInvariant(common[length])
== char.ToLowerInvariant(match[length])
)
{
length++;
}
@@ -296,11 +313,12 @@ public sealed class DevConsole : IDisposable
private void OnLogMessage(LogLevel level, string message) =>
WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}");
private static string LevelTag(LogLevel level) => level switch
{
LogLevel.Debug => "dbg",
LogLevel.Warning => "warn",
LogLevel.Error => "err",
_ => "info",
};
private static string LevelTag(LogLevel level) =>
level switch
{
LogLevel.Debug => "dbg",
LogLevel.Warning => "warn",
LogLevel.Error => "err",
_ => "info",
};
}
+20 -20
View File
@@ -2,11 +2,11 @@ using System.Text;
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MrGameEng.Core;
using Myra;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
using MrGameEng.Core;
namespace MrGameEng.DevConsole;
@@ -26,16 +26,9 @@ internal sealed class DevConsoleUi
{
_console = console;
_log = new Label
{
Text = string.Empty,
Wrap = false,
};
_log = new Label { Text = string.Empty, Wrap = false };
_input = new TextBox
{
HintText = "command ('help')",
};
_input = new TextBox { HintText = "command ('help')" };
_input.TextChanged += (_, _) =>
{
// Клавиша-тогглер (`) не должна попадать в строку ввода.
@@ -222,18 +215,25 @@ public static class SceneDevConsoleExtensions
private static void RegisterEngineCommands(DevConsole console, EngineContext context)
{
console.Register("timescale", "timescale [value] — show or set game speed", (c, args) =>
{
if (args.Length == 0)
console.Register(
"timescale",
"timescale [value] — show or set game speed",
(c, args) =>
{
c.WriteLine($"timescale = {context.Clock.TimeScale}");
if (args.Length == 0)
{
c.WriteLine($"timescale = {context.Clock.TimeScale}");
}
else
{
context.Clock.TimeScale = float.Parse(
args[0],
System.Globalization.CultureInfo.InvariantCulture
);
c.WriteLine($"timescale = {context.Clock.TimeScale}");
}
}
else
{
context.Clock.TimeScale = float.Parse(args[0], System.Globalization.CultureInfo.InvariantCulture);
c.WriteLine($"timescale = {context.Clock.TimeScale}");
}
});
);
console.Register("close", "close the console", static (c, _) => c.Toggle());
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+39 -11
View File
@@ -52,23 +52,35 @@ public readonly struct CameraState
public static class CameraMath
{
/// <summary>Computes the full camera state for a frame.</summary>
public static CameraState Compute(in Camera camera, int virtualWidth, int virtualHeight, ViewportMapping mapping)
public static CameraState Compute(
in Camera camera,
int virtualWidth,
int virtualHeight,
ViewportMapping mapping
)
{
var zoom = camera.Zoom <= 0f ? 1f : camera.Zoom;
var position = ClampToBounds(camera, virtualWidth, virtualHeight, zoom);
var view =
Matrix.CreateTranslation(-position.X, -position.Y, 0f) *
Matrix.CreateRotationZ(-camera.Rotation) *
Matrix.CreateScale(zoom, zoom, 1f) *
Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f);
Matrix.CreateTranslation(-position.X, -position.Y, 0f)
* Matrix.CreateRotationZ(-camera.Rotation)
* Matrix.CreateScale(zoom, zoom, 1f)
* Matrix.CreateTranslation(virtualWidth / 2f, virtualHeight / 2f, 0f);
var inverseView = Matrix.Invert(view);
return new CameraState
{
View = view,
Projection = Matrix.CreateOrthographicOffCenter(0f, virtualWidth, virtualHeight, 0f, 0f, 1f),
Projection = Matrix.CreateOrthographicOffCenter(
0f,
virtualWidth,
virtualHeight,
0f,
0f,
1f
),
InverseView = inverseView,
CullRect = ComputeCullRect(inverseView, virtualWidth, virtualHeight),
VirtualWidth = virtualWidth,
@@ -81,14 +93,29 @@ public static class CameraMath
/// Computes the letterbox mapping that fits the virtual resolution into a physical
/// viewport, preserving aspect ratio and centering.
/// </summary>
public static ViewportMapping ComputeMapping(int screenWidth, int screenHeight, int virtualWidth, int virtualHeight)
public static ViewportMapping ComputeMapping(
int screenWidth,
int screenHeight,
int virtualWidth,
int virtualHeight
)
{
var scale = MathF.Min((float)screenWidth / virtualWidth, (float)screenHeight / virtualHeight);
var offset = new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale) / 2f;
var scale = MathF.Min(
(float)screenWidth / virtualWidth,
(float)screenHeight / virtualHeight
);
var offset =
new Vector2(screenWidth - virtualWidth * scale, screenHeight - virtualHeight * scale)
/ 2f;
return new ViewportMapping(offset, scale);
}
private static Vector2 ClampToBounds(in Camera camera, int virtualWidth, int virtualHeight, float zoom)
private static Vector2 ClampToBounds(
in Camera camera,
int virtualWidth,
int virtualHeight,
float zoom
)
{
if (camera.Bounds is not { } bounds)
{
@@ -100,7 +127,8 @@ public static class CameraMath
var halfH = virtualHeight / (2f * zoom);
return new Vector2(
ClampAxis(camera.Position.X, bounds.Left + halfW, bounds.Right - halfW),
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH));
ClampAxis(camera.Position.Y, bounds.Top + halfH, bounds.Bottom - halfH)
);
}
private static float ClampAxis(float value, float min, float max) =>
+24 -8
View File
@@ -10,7 +10,11 @@ public static class CullingMath
/// (valid for any rotation), given its transform, region size in pixels and origin.
/// </summary>
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
in Transform2D transform, float regionWidth, float regionHeight, Vector2 origin)
in Transform2D transform,
float regionWidth,
float regionHeight,
Vector2 origin
)
{
var scaledW = regionWidth * transform.Scale.X;
var scaledH = regionHeight * transform.Scale.Y;
@@ -24,20 +28,33 @@ public static class CullingMath
/// diagonal (no square root per sprite; conservative for non-uniform scale, exact for uniform).
/// </summary>
public static (Vector2 Center, float Radius) SpriteBoundingCircle(
in Transform2D transform, Texture2DRegion region, Vector2 origin)
in Transform2D transform,
Texture2DRegion region,
Vector2 origin
)
{
var center = SpriteCenter(
in transform, region.Width * transform.Scale.X, region.Height * transform.Scale.Y, origin);
in transform,
region.Width * transform.Scale.X,
region.Height * transform.Scale.Y,
origin
);
var maxScale = MathF.Max(MathF.Abs(transform.Scale.X), MathF.Abs(transform.Scale.Y));
return (center, 0.5f * region.Diagonal * maxScale);
}
private static Vector2 SpriteCenter(in Transform2D transform, float scaledW, float scaledH, Vector2 origin)
private static Vector2 SpriteCenter(
in Transform2D transform,
float scaledW,
float scaledH,
Vector2 origin
)
{
// Offset from the pivot (= transform.Position) to the sprite's geometric center.
var toCenter = new Vector2(
scaledW / 2f - origin.X * transform.Scale.X,
scaledH / 2f - origin.Y * transform.Scale.Y);
scaledH / 2f - origin.Y * transform.Scale.Y
);
if (transform.Rotation == 0f)
{
@@ -45,9 +62,8 @@ public static class CullingMath
}
var (sin, cos) = MathF.SinCos(transform.Rotation);
return transform.Position + new Vector2(
toCenter.X * cos - toCenter.Y * sin,
toCenter.X * sin + toCenter.Y * cos);
return transform.Position
+ new Vector2(toCenter.X * cos - toCenter.Y * sin, toCenter.X * sin + toCenter.Y * cos);
}
/// <summary>True when the circle overlaps the rectangle.</summary>
+11 -3
View File
@@ -52,14 +52,20 @@ public sealed class LayerRegistry
public int Count => _layers.Length;
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
public LayerId Register(
string name,
LayerSpace space = LayerSpace.World,
LayerSortMode sortMode = LayerSortMode.Depth
)
{
lock (_sync)
{
var layers = _layers;
if (layers.Length == 256)
{
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
throw new InvalidOperationException(
"Maximum number of render layers (256) reached."
);
}
var id = new LayerId((byte)layers.Length);
@@ -80,7 +86,9 @@ public sealed class LayerRegistry
return id.Value < layers.Length
? layers[id.Value]
: throw new ArgumentOutOfRangeException(
nameof(id), $"Render layer {id.Value} is not registered (registered: {layers.Length}).");
nameof(id),
$"Render layer {id.Value} is not registered (registered: {layers.Length})."
);
}
}
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -7,5 +6,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+16 -12
View File
@@ -96,20 +96,24 @@ public sealed class SpriteRenderSystem : QuerySystem<Sprite, Transform2D>
}
_renderer.BeginChunkedSubmit(_segmentLengths.AsSpan(0, _segments.Count));
Parallel.For(0, _segments.Count, segmentIndex =>
{
var (chunk, start, length) = _segments[segmentIndex];
var (sprites, transforms) = _chunks[chunk];
var writer = _renderer.GetChunkWriter(segmentIndex);
var s = sprites.Span.Slice(start, length);
var t = transforms.Span.Slice(start, length);
for (var i = 0; i < s.Length; i++)
Parallel.For(
0,
_segments.Count,
segmentIndex =>
{
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
}
var (chunk, start, length) = _segments[segmentIndex];
var (sprites, transforms) = _chunks[chunk];
var writer = _renderer.GetChunkWriter(segmentIndex);
var s = sprites.Span.Slice(start, length);
var t = transforms.Span.Slice(start, length);
for (var i = 0; i < s.Length; i++)
{
_renderer.SubmitInto(ref writer, in t[i], in s[i]);
}
_renderer.EndChunk(segmentIndex, in writer);
});
_renderer.EndChunk(segmentIndex, in writer);
}
);
_renderer.CommitChunkedSubmit();
}
}
+92 -29
View File
@@ -17,7 +17,9 @@ public sealed class Renderer2D : IDisposable
private const int MaxQuadsPerDraw = 8192;
private const int ParallelBlock = 4096;
private static readonly int VertexStride = VertexPositionColorTexture.VertexDeclaration.VertexStride;
private static readonly int VertexStride = VertexPositionColorTexture
.VertexDeclaration
.VertexStride;
/// <summary>Render layer registry. Register layers before the first frame.</summary>
public LayerRegistry Layers { get; } = new();
@@ -71,7 +73,11 @@ public sealed class Renderer2D : IDisposable
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
device, VertexPositionColorTexture.VertexDeclaration, _vertices.Length * 2, BufferUsage.WriteOnly);
device,
VertexPositionColorTexture.VertexDeclaration,
_vertices.Length * 2,
BufferUsage.WriteOnly
);
_effect = new BasicEffect(device)
{
@@ -91,7 +97,11 @@ public sealed class Renderer2D : IDisposable
var (virtualW, virtualH, mapping) = ResolveVirtualResolution();
Camera = CameraMath.Compute(camera, virtualW, virtualH, mapping);
_screenCamera = CameraMath.Compute(
new Camera(new Vector2(virtualW / 2f, virtualH / 2f)), virtualW, virtualH, mapping);
new Camera(new Vector2(virtualW / 2f, virtualH / 2f)),
virtualW,
virtualH,
mapping
);
_batcher.Clear();
SubmittedSprites = 0;
@@ -155,14 +165,18 @@ public sealed class Renderer2D : IDisposable
if (count >= _options.ParallelThreshold)
{
var blocks = (count + ParallelBlock - 1) / ParallelBlock;
Parallel.For(0, blocks, block =>
{
var end = Math.Min((block + 1) * ParallelBlock, count);
for (var i = block * ParallelBlock; i < end; i++)
Parallel.For(
0,
blocks,
block =>
{
BuildVertex(order, i);
var end = Math.Min((block + 1) * ParallelBlock, count);
for (var i = block * ParallelBlock; i < end; i++)
{
BuildVertex(order, i);
}
}
});
);
}
else
{
@@ -190,7 +204,14 @@ public sealed class Renderer2D : IDisposable
hint = SetDataOptions.Discard;
}
_vertexBuffer.SetData(_ringBaseVertex * VertexStride, _vertices, 0, vertexCount, VertexStride, hint);
_vertexBuffer.SetData(
_ringBaseVertex * VertexStride,
_vertices,
0,
vertexCount,
VertexStride,
hint
);
_ringCursor = _ringBaseVertex + vertexCount;
var uploadEnd = Stopwatch.GetTimestamp();
UploadMs = ToMs(uploadEnd - buildEnd);
@@ -213,7 +234,8 @@ public sealed class Renderer2D : IDisposable
(int)MathF.Round(mapping.Offset.X),
(int)MathF.Round(mapping.Offset.Y),
(int)MathF.Round(Camera.VirtualWidth * mapping.Scale),
(int)MathF.Round(Camera.VirtualHeight * mapping.Scale));
(int)MathF.Round(Camera.VirtualHeight * mapping.Scale)
);
}
DrawBatches(order, count);
@@ -247,9 +269,14 @@ public sealed class Renderer2D : IDisposable
_batcher.BeginChunks(chunkLengths);
}
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) => _batcher.GetChunkWriter(chunkIndex);
internal SpriteChunkWriter GetChunkWriter(int chunkIndex) =>
_batcher.GetChunkWriter(chunkIndex);
internal void SubmitInto(ref SpriteChunkWriter writer, in Transform2D transform, in Sprite sprite)
internal void SubmitInto(
ref SpriteChunkWriter writer,
in Transform2D transform,
in Sprite sprite
)
{
switch (TryBuildInstance(in transform, in sprite, out var instance, out var key))
{
@@ -262,7 +289,8 @@ public sealed class Renderer2D : IDisposable
}
}
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) => _batcher.EndChunk(chunkIndex, in writer);
internal void EndChunk(int chunkIndex, in SpriteChunkWriter writer) =>
_batcher.EndChunk(chunkIndex, in writer);
internal void CommitChunkedSubmit()
{
@@ -278,7 +306,11 @@ public sealed class Renderer2D : IDisposable
}
private SubmitResult TryBuildInstance(
in Transform2D transform, in Sprite sprite, out SpriteInstance instance, out ulong key)
in Transform2D transform,
in Sprite sprite,
out SpriteInstance instance,
out ulong key
)
{
instance = default;
key = 0;
@@ -288,10 +320,16 @@ public sealed class Renderer2D : IDisposable
}
var layer = Layers[sprite.Layer];
var (center, radius) = CullingMath.SpriteBoundingCircle(in transform, region, sprite.Origin);
var (center, radius) = CullingMath.SpriteBoundingCircle(
in transform,
region,
sprite.Origin
);
if (layer.Space == LayerSpace.World &&
!CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect))
if (
layer.Space == LayerSpace.World
&& !CullingMath.CircleIntersectsRect(center, radius, Camera.CullRect)
)
{
return SubmitResult.Culled;
}
@@ -304,7 +342,9 @@ public sealed class Renderer2D : IDisposable
{
Region = region,
Center = center,
HalfSize = new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y) / 2f,
HalfSize =
new Vector2(region.Width * transform.Scale.X, region.Height * transform.Scale.Y)
/ 2f,
Rotation = transform.Rotation,
Color = sprite.Color,
Flip = sprite.Flip,
@@ -319,7 +359,8 @@ public sealed class Renderer2D : IDisposable
if (!_begun)
{
throw new InvalidOperationException(
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?).");
"Renderer used outside BeginFrame/EndFrame (is CameraSystem registered first?)."
);
}
}
@@ -331,8 +372,11 @@ public sealed class Renderer2D : IDisposable
return (viewport.Width, viewport.Height, ViewportMapping.Identity);
}
return (virtualSize.X, virtualSize.Y,
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y));
return (
virtualSize.X,
virtualSize.Y,
CameraMath.ComputeMapping(viewport.Width, viewport.Height, virtualSize.X, virtualSize.Y)
);
}
private void BuildVertex(int[] order, int i)
@@ -355,7 +399,8 @@ public sealed class Renderer2D : IDisposable
(v0, v1) = (v1, v0);
}
Vector2 rx, ry;
Vector2 rx,
ry;
if (instance.Rotation == 0f)
{
rx = new Vector2(instance.HalfSize.X, 0f);
@@ -432,7 +477,11 @@ public sealed class Renderer2D : IDisposable
{
pass.Apply();
_device.DrawIndexedPrimitives(
PrimitiveType.TriangleList, _ringBaseVertex + firstQuad * 4, 0, quads * 2);
PrimitiveType.TriangleList,
_ringBaseVertex + firstQuad * 4,
0,
quads * 2
);
DrawCalls++;
}
@@ -461,15 +510,24 @@ public sealed class Renderer2D : IDisposable
{
_vertexBuffer.Dispose();
_vertexBuffer = new DynamicVertexBuffer(
_device, VertexPositionColorTexture.VertexDeclaration, wantedBuffer, BufferUsage.WriteOnly);
_device,
VertexPositionColorTexture.VertexDeclaration,
wantedBuffer,
BufferUsage.WriteOnly
);
_ringCursor = 0;
}
}
private static float ToMs(long timestampDelta) => (float)timestampDelta * 1000f / Stopwatch.Frequency;
private static float ToMs(long timestampDelta) =>
(float)timestampDelta * 1000f / Stopwatch.Frequency;
private static VertexPositionColorTexture Vertex(Vector2 position, Color color, float u, float v) =>
new(new Vector3(position, 0f), color, new Vector2(u, v));
private static VertexPositionColorTexture Vertex(
Vector2 position,
Color color,
float u,
float v
) => new(new Vector3(position, 0f), color, new Vector2(u, v));
private static IndexBuffer CreateQuadIndexBuffer(GraphicsDevice device)
{
@@ -486,7 +544,12 @@ public sealed class Renderer2D : IDisposable
indices[index + 5] = (ushort)(vertex + 3);
}
var buffer = new IndexBuffer(device, IndexElementSize.SixteenBits, indices.Length, BufferUsage.WriteOnly);
var buffer = new IndexBuffer(
device,
IndexElementSize.SixteenBits,
indices.Length,
BufferUsage.WriteOnly
);
buffer.SetData(indices);
return buffer;
}
@@ -13,7 +13,10 @@ public static class SceneGraphicsExtensions
/// is created on first use and shared between scenes. Call from <c>OnLoad</c>.
/// </summary>
public static Renderer2D UseRenderer2D(
this Scene scene, Renderer2DOptions? options = null, params BaseSystem[] extraDrawSystems)
this Scene scene,
Renderer2DOptions? options = null,
params BaseSystem[] extraDrawSystems
)
{
var services = scene.Context.Services;
var renderer = services.GetOrDefault<Renderer2D>();
@@ -25,8 +28,9 @@ public static class SceneGraphicsExtensions
else if (options is not null)
{
Log.Warning(
"UseRenderer2D: the renderer already exists, the passed options are ignored " +
"(Renderer2D is a shared service configured by its first user).");
"UseRenderer2D: the renderer already exists, the passed options are ignored "
+ "(Renderer2D is a shared service configured by its first user)."
);
}
scene.DrawSystems.Add(new CameraSystem(renderer));
+9 -2
View File
@@ -19,11 +19,18 @@ public sealed class SpriteAnimationClip
public float Duration => Frames.Count / FramesPerSecond;
/// <summary>Creates a clip.</summary>
public SpriteAnimationClip(IReadOnlyList<Texture2DRegion> frames, float framesPerSecond = 12f, bool loop = true)
public SpriteAnimationClip(
IReadOnlyList<Texture2DRegion> frames,
float framesPerSecond = 12f,
bool loop = true
)
{
if (frames.Count == 0)
{
throw new ArgumentException("An animation clip needs at least one frame.", nameof(frames));
throw new ArgumentException(
"An animation clip needs at least one frame.",
nameof(frames)
);
}
Frames = frames;
+2 -1
View File
@@ -130,7 +130,8 @@ public sealed class SpriteBatcher
// Биты, различающиеся хотя бы у одной пары ключей: проходы по одинаковым
// разрядам (один слой, одна глубина) пропускаются целиком.
ulong orBits = 0, andBits = ~0UL;
ulong orBits = 0,
andBits = ~0UL;
for (var i = 0; i < n; i++)
{
orBits |= _keys[i];
+3 -1
View File
@@ -9,7 +9,9 @@ public static class SpriteSortKey
{
/// <summary>Composes a sort key from layer, depth and texture grouping key.</summary>
public static ulong Make(byte layer, float depth, int textureKey) =>
((ulong)layer << 56) | ((ulong)DepthToSortableBits(depth) << 24) | ((uint)textureKey & 0xFF_FFFF);
((ulong)layer << 56)
| ((ulong)DepthToSortableBits(depth) << 24)
| ((uint)textureKey & 0xFF_FFFF);
/// <summary>
/// Maps a float to bits whose unsigned order matches the float order
+4 -4
View File
@@ -35,7 +35,9 @@ public sealed class Texture2DRegion
Texture = texture;
Bounds = bounds;
TextureSortKey = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(texture);
Diagonal = MathF.Sqrt((float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height);
Diagonal = MathF.Sqrt(
(float)bounds.Width * bounds.Width + (float)bounds.Height * bounds.Height
);
// UV предрассчитаны один раз — в кадре на каждый спрайт экономятся 4 деления.
// texture может быть null только в headless-тестах.
@@ -50,7 +52,5 @@ public sealed class Texture2DRegion
/// <summary>Creates a region covering the whole <paramref name="texture"/>.</summary>
public Texture2DRegion(Texture2D texture)
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height))
{
}
: this(texture, new Rectangle(0, 0, texture.Width, texture.Height)) { }
}
+32 -19
View File
@@ -7,7 +7,8 @@ namespace MrGameEng.Input;
/// gamepad buttons. Query by action instead of device, rebind at runtime.
/// </summary>
/// <typeparam name="TAction">Enum (or any value) identifying the game's actions.</typeparam>
public sealed class ActionMap<TAction> where TAction : notnull
public sealed class ActionMap<TAction>
where TAction : notnull
{
private readonly InputManager _input;
private readonly Dictionary<TAction, List<Binding>> _bindings = new();
@@ -18,37 +19,49 @@ public sealed class ActionMap<TAction> where TAction : notnull
public ActionMap(InputManager input) => _input = input;
/// <summary>Adds a keyboard binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Keys key) => Add(action, new Binding(key, null, null));
public ActionMap<TAction> Bind(TAction action, Keys key) =>
Add(action, new Binding(key, null, null));
/// <summary>Adds a mouse-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, MouseButton button) => Add(action, new Binding(null, button, null));
public ActionMap<TAction> Bind(TAction action, MouseButton button) =>
Add(action, new Binding(null, button, null));
/// <summary>Adds a gamepad-button binding for <paramref name="action"/>.</summary>
public ActionMap<TAction> Bind(TAction action, Buttons button) => Add(action, new Binding(null, null, button));
public ActionMap<TAction> Bind(TAction action, Buttons button) =>
Add(action, new Binding(null, null, button));
/// <summary>Removes every binding of <paramref name="action"/> (for rebinding).</summary>
public void Unbind(TAction action) => _bindings.Remove(action);
/// <summary>True while any binding of the action is held down.</summary>
public bool IsDown(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyDown(k)) ||
(b.Mouse is { } m && input.IsMouseDown(m)) ||
(b.GamePad is { } g && input.IsButtonDown(g)));
public bool IsDown(TAction action) =>
Any(
action,
static (input, b) =>
(b.Key is { } k && input.IsKeyDown(k))
|| (b.Mouse is { } m && input.IsMouseDown(m))
|| (b.GamePad is { } g && input.IsButtonDown(g))
);
/// <summary>True only on the frame any binding of the action went down.</summary>
public bool IsPressed(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyPressed(k)) ||
(b.Mouse is { } m && input.IsMousePressed(m)) ||
(b.GamePad is { } g && input.IsButtonPressed(g)));
public bool IsPressed(TAction action) =>
Any(
action,
static (input, b) =>
(b.Key is { } k && input.IsKeyPressed(k))
|| (b.Mouse is { } m && input.IsMousePressed(m))
|| (b.GamePad is { } g && input.IsButtonPressed(g))
);
/// <summary>True only on the frame any binding of the action went up.</summary>
public bool IsReleased(TAction action) => Any(action,
static (input, b) =>
(b.Key is { } k && input.IsKeyReleased(k)) ||
(b.Mouse is { } m && input.IsMouseReleased(m)) ||
(b.GamePad is { } g && input.IsButtonReleased(g)));
public bool IsReleased(TAction action) =>
Any(
action,
static (input, b) =>
(b.Key is { } k && input.IsKeyReleased(k))
|| (b.Mouse is { } m && input.IsMouseReleased(m))
|| (b.GamePad is { } g && input.IsButtonReleased(g))
);
/// <summary>Composes -1/0/+1 from two digital actions (e.g. move left / move right).</summary>
public float GetAxis(TAction negative, TAction positive) =>
+32 -21
View File
@@ -50,17 +50,21 @@ public sealed class InputManager
Apply(
default,
new MouseState(
_mouse.X, _mouse.Y, _mouse.ScrollWheelValue,
ButtonState.Released, ButtonState.Released, ButtonState.Released,
ButtonState.Released, ButtonState.Released),
default);
_mouse.X,
_mouse.Y,
_mouse.ScrollWheelValue,
ButtonState.Released,
ButtonState.Released,
ButtonState.Released,
ButtonState.Released,
ButtonState.Released
),
default
);
return;
}
Apply(
Keyboard.GetState(),
Mouse.GetState(),
GamePad.GetState(PlayerIndex.One));
Apply(Keyboard.GetState(), Mouse.GetState(), GamePad.GetState(PlayerIndex.One));
}
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
@@ -77,10 +81,12 @@ public sealed class InputManager
public bool IsKeyDown(Keys key) => _keyboard.IsKeyDown(key);
/// <summary>True only on the frame the key went down.</summary>
public bool IsKeyPressed(Keys key) => _keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
public bool IsKeyPressed(Keys key) =>
_keyboard.IsKeyDown(key) && _previousKeyboard.IsKeyUp(key);
/// <summary>True only on the frame the key went up.</summary>
public bool IsKeyReleased(Keys key) => _keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
public bool IsKeyReleased(Keys key) =>
_keyboard.IsKeyUp(key) && _previousKeyboard.IsKeyDown(key);
/// <summary>Mouse cursor position in window pixels.</summary>
public Point MousePosition => _mouse.Position;
@@ -96,29 +102,34 @@ public sealed class InputManager
/// <summary>True only on the frame the mouse button went down.</summary>
public bool IsMousePressed(MouseButton button) =>
GetButton(_mouse, button) == ButtonState.Pressed && GetButton(_previousMouse, button) == ButtonState.Released;
GetButton(_mouse, button) == ButtonState.Pressed
&& GetButton(_previousMouse, button) == ButtonState.Released;
/// <summary>True only on the frame the mouse button went up.</summary>
public bool IsMouseReleased(MouseButton button) =>
GetButton(_mouse, button) == ButtonState.Released && GetButton(_previousMouse, button) == ButtonState.Pressed;
GetButton(_mouse, button) == ButtonState.Released
&& GetButton(_previousMouse, button) == ButtonState.Pressed;
/// <summary>True while the gamepad button is held down.</summary>
public bool IsButtonDown(Buttons button) => _gamePad.IsButtonDown(button);
/// <summary>True only on the frame the gamepad button went down.</summary>
public bool IsButtonPressed(Buttons button) => _gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
public bool IsButtonPressed(Buttons button) =>
_gamePad.IsButtonDown(button) && _previousGamePad.IsButtonUp(button);
/// <summary>True only on the frame the gamepad button went up.</summary>
public bool IsButtonReleased(Buttons button) => _gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
public bool IsButtonReleased(Buttons button) =>
_gamePad.IsButtonUp(button) && _previousGamePad.IsButtonDown(button);
/// <summary>Left thumbstick, x/y in [-1, 1]. Y is inverted to match the engine's y-down world.</summary>
public Vector2 LeftStick => new(_gamePad.ThumbSticks.Left.X, -_gamePad.ThumbSticks.Left.Y);
private static ButtonState GetButton(in MouseState state, MouseButton button) => button switch
{
MouseButton.Left => state.LeftButton,
MouseButton.Right => state.RightButton,
MouseButton.Middle => state.MiddleButton,
_ => ButtonState.Released,
};
private static ButtonState GetButton(in MouseState state, MouseButton button) =>
button switch
{
MouseButton.Left => state.LeftButton,
MouseButton.Right => state.RightButton,
MouseButton.Middle => state.MiddleButton,
_ => ButtonState.Released,
};
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Input.Tests" />
</ItemGroup>
</Project>
+45 -19
View File
@@ -31,7 +31,8 @@ public sealed class DefDatabase
};
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
public void RegisterType<T>(string typeKey) where T : Def
public void RegisterType<T>(string typeKey)
where T : Def
{
var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) };
if (!_byKey.TryAdd(typeKey, entry))
@@ -56,7 +57,8 @@ public sealed class DefDatabase
continue;
}
var files = Directory.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
var files = Directory
.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
@@ -71,13 +73,15 @@ public sealed class DefDatabase
}
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
public T Get<T>(string defName) where T : Def =>
public T Get<T>(string defName)
where T : Def =>
TryGet<T>(defName, out var def)
? def
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
public bool TryGet<T>(string defName, out T def) where T : Def
public bool TryGet<T>(string defName, out T def)
where T : Def
{
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
{
@@ -90,16 +94,19 @@ public sealed class DefDatabase
}
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
public IReadOnlyList<T> All<T>() where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
public IReadOnlyList<T> All<T>()
where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
/// <summary>Registered def-type keys, sorted.</summary>
public IReadOnlyList<string> TypeKeys => _byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
public IReadOnlyList<string> TypeKeys =>
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
public IReadOnlyList<string> NamesOf(string typeKey) =>
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
private TypeEntry Entry<T>() where T : Def =>
private TypeEntry Entry<T>()
where T : Def =>
_byType.TryGetValue(typeof(T), out var entry)
? entry
: throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered.");
@@ -109,39 +116,53 @@ public sealed class DefDatabase
JsonNode root;
try
{
root = JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
root =
JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException($"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}", exception);
throw new InvalidDataException(
$"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}",
exception
);
}
var typeKey = root["type"]?.GetValue<string>()
?? throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"type\" field.");
var typeKey =
root["type"]?.GetValue<string>()
?? throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
);
if (!_byKey.TryGetValue(typeKey, out var entry))
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; " +
$"registered: {string.Join(", ", TypeKeys)}.");
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; "
+ $"registered: {string.Join(", ", TypeKeys)}."
);
}
if (root["defs"] is not JsonArray defs)
{
throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array.");
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array."
);
}
foreach (var node in defs)
{
if (node is not JsonObject def)
{
throw new InvalidDataException($"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry.");
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry."
);
}
var defName = def["defName"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(defName))
{
throw new InvalidDataException($"A def in '{file}' (mod '{mod.Id}') has no \"defName\".");
throw new InvalidDataException(
$"A def in '{file}' (mod '{mod.Id}') has no \"defName\"."
);
}
entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф
@@ -158,8 +179,11 @@ public sealed class DefDatabase
continue;
}
var def = (Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException($"Def '{defName}' ({entry.Key}) deserialized to null.");
var def =
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException(
$"Def '{defName}' ({entry.Key}) deserialized to null."
);
entry.Resolved[defName] = def;
}
}
@@ -168,7 +192,9 @@ public sealed class DefDatabase
{
if (!seen.Add(defName))
{
throw new InvalidDataException($"Cyclic def inheritance involving '{defName}' ({entry.Key}).");
throw new InvalidDataException(
$"Cyclic def inheritance involving '{defName}' ({entry.Key})."
);
}
if (!entry.Raw.TryGetValue(defName, out var node))
+39 -17
View File
@@ -12,8 +12,9 @@ namespace MrGameEng.Mods;
/// </summary>
public sealed class LanguageManager
{
private readonly Dictionary<string, Dictionary<string, string>> _languages =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Dictionary<string, string>> _languages = new(
StringComparer.OrdinalIgnoreCase
);
/// <summary>Creates a manager whose fallback language is <paramref name="defaultLanguage"/>.</summary>
public LanguageManager(string defaultLanguage = "en")
@@ -46,7 +47,11 @@ public sealed class LanguageManager
continue;
}
foreach (var languageDir in Directory.EnumerateDirectories(languagesDir).OrderBy(d => d, StringComparer.Ordinal))
foreach (
var languageDir in Directory
.EnumerateDirectories(languagesDir)
.OrderBy(d => d, StringComparer.Ordinal)
)
{
var code = Path.GetFileName(languageDir);
if (!_languages.TryGetValue(code, out var strings))
@@ -55,7 +60,8 @@ public sealed class LanguageManager
_languages.Add(code, strings);
}
var files = Directory.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories)
var files = Directory
.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
@@ -86,12 +92,18 @@ public sealed class LanguageManager
/// <summary>Returns the string for <paramref name="key"/>: current language → default language → the key itself.</summary>
public string Get(string key)
{
if (_languages.TryGetValue(CurrentLanguage, out var current) && current.TryGetValue(key, out var value))
if (
_languages.TryGetValue(CurrentLanguage, out var current)
&& current.TryGetValue(key, out var value)
)
{
return value;
}
if (_languages.TryGetValue(DefaultLanguage, out var fallback) && fallback.TryGetValue(key, out value))
if (
_languages.TryGetValue(DefaultLanguage, out var fallback)
&& fallback.TryGetValue(key, out value)
)
{
return value;
}
@@ -108,31 +120,41 @@ public sealed class LanguageManager
JsonNode root;
try
{
root = JsonNode.Parse(
File.ReadAllText(file),
documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}) ?? throw new InvalidDataException("file is empty");
root =
JsonNode.Parse(
File.ReadAllText(file),
documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}
) ?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}", exception);
$"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}",
exception
);
}
if (root is not JsonObject map)
{
throw new InvalidDataException($"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object.");
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object."
);
}
foreach (var (key, value) in map)
{
if (value is not JsonValue jsonValue || jsonValue.GetValueKind() != JsonValueKind.String)
if (
value is not JsonValue jsonValue
|| jsonValue.GetValueKind() != JsonValueKind.String
)
{
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string.");
$"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string."
);
}
strings[key] = jsonValue.GetValue<string>(); // поздний мод переопределяет ключ
+15 -4
View File
@@ -33,7 +33,11 @@ public sealed class ModContentTree
/// (in load order). With <paramref name="extensions"/> only matching files are included
/// (e.g. <c>".png"</c>); without them, every file.
/// </summary>
public static ModContentTree Build(IReadOnlyList<Mod> mods, string contentFolder, params string[] extensions)
public static ModContentTree Build(
IReadOnlyList<Mod> mods,
string contentFolder,
params string[] extensions
)
{
var files = new Dictionary<string, ModFile>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in mods)
@@ -44,10 +48,17 @@ public sealed class ModContentTree
continue;
}
foreach (var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories))
foreach (
var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
)
{
if (extensions.Length > 0 &&
!extensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
if (
extensions.Length > 0
&& !extensions.Contains(
Path.GetExtension(fullPath),
StringComparer.OrdinalIgnoreCase
)
)
{
continue;
}
+21 -7
View File
@@ -18,7 +18,11 @@ public static class ModLoader
/// </summary>
public static string? FindModsRoot(string startDirectory)
{
for (var dir = new DirectoryInfo(Path.GetFullPath(startDirectory)); dir is not null; dir = dir.Parent)
for (
var dir = new DirectoryInfo(Path.GetFullPath(startDirectory));
dir is not null;
dir = dir.Parent
)
{
var candidate = Path.Combine(dir.FullName, "Mods");
if (Directory.Exists(candidate))
@@ -56,7 +60,9 @@ public static class ModLoader
{
if (!discovered.TryGetValue(id, out var mod))
{
throw new InvalidDataException($"Active mod '{id}' is not installed under '{modsRoot}'.");
throw new InvalidDataException(
$"Active mod '{id}' is not installed under '{modsRoot}'."
);
}
active.Add(mod);
@@ -82,12 +88,18 @@ public static class ModLoader
ModInfo info;
try
{
info = JsonSerializer.Deserialize<ModInfo>(File.ReadAllText(aboutPath), ModInfo.JsonOptions)
?? throw new InvalidDataException("About.json deserialized to null.");
info =
JsonSerializer.Deserialize<ModInfo>(
File.ReadAllText(aboutPath),
ModInfo.JsonOptions
) ?? throw new InvalidDataException("About.json deserialized to null.");
}
catch (JsonException exception)
{
throw new InvalidDataException($"Invalid mod metadata '{aboutPath}': {exception.Message}", exception);
throw new InvalidDataException(
$"Invalid mod metadata '{aboutPath}': {exception.Message}",
exception
);
}
if (string.IsNullOrWhiteSpace(info.Id))
@@ -98,7 +110,8 @@ public static class ModLoader
if (discovered.TryGetValue(info.Id, out var existing))
{
throw new InvalidDataException(
$"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'.");
$"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'."
);
}
discovered.Add(info.Id, new Mod(info, dir));
@@ -131,7 +144,8 @@ public static class ModLoader
if (!byId.TryGetValue(dependency, out var parent))
{
throw new InvalidDataException(
$"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active.");
$"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active."
);
}
Visit(parent);
-2
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+12 -5
View File
@@ -86,8 +86,9 @@ public sealed class FlowFieldBuilder
if (_grid.Width != _width || _grid.Height != _height)
{
throw new InvalidOperationException(
$"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " +
"create a new FlowFieldBuilder for the resized grid.");
$"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); "
+ "create a new FlowFieldBuilder for the resized grid."
);
}
field.EnsureSize(_width, _height);
@@ -107,8 +108,13 @@ public sealed class FlowFieldBuilder
foreach (var goal in goals)
{
if (goal.X >= 0 && goal.X < _width && goal.Y >= 0 && goal.Y < _height &&
_grid.IsPassable(goal.X, goal.Y))
if (
goal.X >= 0
&& goal.X < _width
&& goal.Y >= 0
&& goal.Y < _height
&& _grid.IsPassable(goal.X, goal.Y)
)
{
var index = goal.Y * _width + goal.X;
distances[index] = 0f;
@@ -139,7 +145,8 @@ public sealed class FlowFieldBuilder
}
var neighbor = ny * _width + nx;
var tentative = distances[current] + (d < 4 ? 1f : DiagonalCost) * _grid.Cost(nx, ny);
var tentative =
distances[current] + (d < 4 ? 1f : DiagonalCost) * _grid.Cost(nx, ny);
if (tentative < distances[neighbor])
{
distances[neighbor] = tentative;
+15 -5
View File
@@ -67,18 +67,28 @@ public sealed class GridPathfinder
/// and writes it into <paramref name="path"/>. Returns false when no path exists;
/// the list is cleared either way.
/// </summary>
public bool FindPath(Point start, Point goal, List<Point> path, PathAlgorithm algorithm = PathAlgorithm.AStar)
public bool FindPath(
Point start,
Point goal,
List<Point> path,
PathAlgorithm algorithm = PathAlgorithm.AStar
)
{
if (_grid.Width != _width || _grid.Height != _height)
{
throw new InvalidOperationException(
$"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " +
"create a new GridPathfinder for the resized grid.");
$"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); "
+ "create a new GridPathfinder for the resized grid."
);
}
path.Clear();
if (!InBounds(start) || !InBounds(goal) ||
!_grid.IsPassable(start.X, start.Y) || !_grid.IsPassable(goal.X, goal.Y))
if (
!InBounds(start)
|| !InBounds(goal)
|| !_grid.IsPassable(start.X, start.Y)
|| !_grid.IsPassable(goal.X, goal.Y)
)
{
return false;
}
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -7,5 +6,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -12,5 +11,4 @@
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
<ProjectReference Include="..\MrGameEng.Graphics\MrGameEng.Graphics.csproj" />
</ItemGroup>
</Project>
@@ -12,8 +12,11 @@ public static class SceneTilemapExtensions
/// </summary>
public static void UseTilemaps(this Scene scene)
{
var renderer = scene.Context.Services.GetOrDefault<Renderer2D>()
?? throw new InvalidOperationException("UseTilemaps requires UseRenderer2D to be called first.");
var renderer =
scene.Context.Services.GetOrDefault<Renderer2D>()
?? throw new InvalidOperationException(
"UseTilemaps requires UseRenderer2D to be called first."
);
var systems = scene.DrawSystems.ChildSystems;
for (var i = 0; i < systems.Count; i++)
@@ -25,6 +28,8 @@ public static class SceneTilemapExtensions
}
}
throw new InvalidOperationException("RenderFlushSystem not found (is UseRenderer2D wired on this scene?).");
throw new InvalidOperationException(
"RenderFlushSystem not found (is UseRenderer2D wired on this scene?)."
);
}
}
+3 -1
View File
@@ -53,7 +53,9 @@ public sealed class TileGrid
if (!Contains(x, y))
{
throw new ArgumentOutOfRangeException(
nameof(x), $"Cell ({x},{y}) is outside the {Width}x{Height} grid.");
nameof(x),
$"Cell ({x},{y}) is outside the {Width}x{Height} grid."
);
}
}
}
+1 -3
View File
@@ -10,9 +10,7 @@ public readonly record struct TileDef(Texture2DRegion Region, Color Color)
{
/// <summary>Creates an untinted tile.</summary>
public TileDef(Texture2DRegion region)
: this(region, Color.White)
{
}
: this(region, Color.White) { }
}
/// <summary>
+10 -2
View File
@@ -11,8 +11,16 @@ public static class TilemapMath
/// Returns false when the map is entirely outside the rectangle.
/// </summary>
public static bool VisibleCells(
in RectF cullRect, Vector2 origin, float tileSize, int width, int height,
out int x0, out int y0, out int x1, out int y1)
in RectF cullRect,
Vector2 origin,
float tileSize,
int width,
int height,
out int x0,
out int y0,
out int x1,
out int y1
)
{
x0 = Math.Max(0, (int)MathF.Floor((cullRect.Left - origin.X) / tileSize));
y0 = Math.Max(0, (int)MathF.Floor((cullRect.Top - origin.Y) / tileSize));
+33 -9
View File
@@ -25,7 +25,11 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
{
foreach (ref readonly var map in maps.Span)
{
if (map.Grid is not { } grid || map.TileSet is not { } tileSet || map.TileSize <= 0f)
if (
map.Grid is not { } grid
|| map.TileSet is not { } tileSet
|| map.TileSize <= 0f
)
{
continue;
}
@@ -36,9 +40,19 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
// Screen-space слои не куллятся камерой — рисуем весь грид.
SubmitRange(in map, grid, tileSet, 0, 0, grid.Width - 1, grid.Height - 1);
}
else if (TilemapMath.VisibleCells(
in cullRect, map.Origin, map.TileSize, grid.Width, grid.Height,
out var x0, out var y0, out var x1, out var y1))
else if (
TilemapMath.VisibleCells(
in cullRect,
map.Origin,
map.TileSize,
grid.Width,
grid.Height,
out var x0,
out var y0,
out var x1,
out var y1
)
)
{
SubmitRange(in map, grid, tileSet, x0, y0, x1, y1);
}
@@ -46,7 +60,15 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
}
}
private void SubmitRange(in Tilemap map, TileGrid grid, TileSet tileSet, int x0, int y0, int x1, int y1)
private void SubmitRange(
in Tilemap map,
TileGrid grid,
TileSet tileSet,
int x0,
int y0,
int x1,
int y1
)
{
for (var y = y0; y <= y1; y++)
{
@@ -62,12 +84,14 @@ public sealed class TilemapRenderSystem : QuerySystem<Tilemap>
var region = def.Region;
var transform = new Transform2D(
map.Origin + new Vector2(x, y) * map.TileSize,
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height));
scale: new Vector2(map.TileSize / region.Width, map.TileSize / region.Height)
);
var sprite = new Sprite(region, map.Layer)
{
Color = map.Color == Color.White
? def.Color
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
Color =
map.Color == Color.White
? def.Color
: new Color(def.Color.ToVector4() * map.Color.ToVector4()),
Depth = map.Depth,
};
_renderer.Submit(in transform, in sprite);
-2
View File
@@ -1,5 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
@@ -11,5 +10,4 @@
<ItemGroup>
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -1,8 +1,8 @@
using Friflo.Engine.ECS.Systems;
using Microsoft.Xna.Framework;
using MrGameEng.Core;
using Myra;
using Myra.Graphics2D.UI;
using MrGameEng.Core;
namespace MrGameEng.UI;