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