Fix engine-wide code review findings
CI / build-test (push) Failing after 1m7s

Collisions: init bucket heads to -1 (QueryAabb hung before the first
rebuild), reset query stamps on truncated QueryAabb (later queries
silently dropped entities), inside-origin raycasts hit at fraction 0 for
circles too, exactly-touching boxes now pair like touching circles.

Graphics: render into the letterbox viewport so the picture matches
ScreenToWorld/WorldToScreen instead of stretching; Y-sort by the
transform pivot rather than the quad center; lock-free snapshot
LayerRegistry (parallel submit read it unsynchronized); validate
InitialCapacity; warn when UseRenderer2D drops options of a later scene.

Core: scenes are explicitly single-use (re-loading threw silently
duplicated systems/entities before — now it throws), Scene.RegisterUnload
for per-scene resources, a switch requested during the reveal phase
covers again instead of hard-swapping, borderless fullscreen
(HardwareModeSwitch off), InputCapture service for input-suppressing
overlays, host disposes the transition renderer and IDisposable services
on shutdown.

Input: game input reads as released while InputCapture is held; mouse
position and wheel freeze so deltas stay zero.

DevConsole: holds InputCapture while open (typing no longer drives the
camera), Revision increments only under the lock, quoted command
arguments, history capped at 256.

UI: scene Desktop skips Myra input processing while the console is open
(clicks no longer fall through), is disposed on scene unload, and Myra
init no longer depends on a process-static flag.

Audio: validate channel count/sample rate before stopping the previous
track, empty looped oggs no longer hang FillBuffers, the instance stops
when a non-looping track drains (IsPlaying was stuck true).

Atlases: metadata v2 stores per-source size+mtime snapshots, so
timestamp-preserving copies and renames invalidate correctly; loader
checks the version and disposes pages on partial load failure; shared
pages never exceed a non-POT MaxPageSize; oversized items pack first
onto exact-size pages instead of splitting an open shared page; the CLI
validates numeric options.

Assets.Generator: file names are escaped in XML docs and string
literals, members no longer collide with the enclosing class (CS0542),
and the Assets root is resolved against build_property.projectdir so
nested "Assets" directories do not shift region paths.

Pathfinding: queries throw when the grid was resized after construction;
generation stamps survive int overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 21:18:08 +03:00
co-authored by Claude Fable 5
parent 56f2a85478
commit 501d81e19f
35 changed files with 926 additions and 98 deletions
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
namespace MrGameEng.Assets.Generator;
@@ -40,8 +41,15 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
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 assets = context.AdditionalTextsProvider
.Select(static (text, _) => ToAssetPath(text.Path))
.Combine(projectDir)
.Select(static (pair, _) => ToAssetPath(pair.Left.Path, pair.Right))
.Where(static path => path is not null)
.Collect();
@@ -51,18 +59,31 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
/// <summary>
/// Extracts the path relative to the "Assets" directory (forward slashes), or null when
/// the file is outside an Assets directory or has an unknown extension.
/// the file is outside an Assets directory or has an unknown extension. With a known
/// <paramref name="projectDir"/> the marker is the project's root <c>Assets/</c> folder,
/// so nested directories that happen to be called "Assets" do not shift the root;
/// without one, the last <c>/Assets/</c> segment of the path is used.
/// </summary>
internal static string? ToAssetPath(string fullPath)
internal static string? ToAssetPath(string fullPath, string? projectDir = null)
{
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))
{
relative = normalized.Substring(root.Length + "/Assets/".Length);
}
else
{
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
{
return null;
}
var relative = normalized.Substring(marker + "/Assets/".Length);
relative = normalized.Substring(marker + "/Assets/".Length);
}
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
// им собственные Texture2D-хендлы не нужны.
@@ -97,36 +118,41 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
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);
EmitNode(source, root, indent: 1, enclosingName: className);
source.AppendLine("}");
return source.ToString();
}
private static void EmitNode(StringBuilder source, Node node, int indent)
private static void EmitNode(StringBuilder source, Node node, int indent, string enclosingName)
{
var pad = new string(' ', indent * 4);
var usedNames = new HashSet<string>();
// Имя вмещающего класса занято: член с тем же именем — ошибка CS0542.
var usedNames = new HashSet<string> { enclosingName };
foreach (var (fileName, relativePath) in node.Files)
{
var type = TypeByExtension[Path.GetExtension(fileName)];
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
source.AppendLine($"{pad}/// <summary>{relativePath}</summary>");
source.AppendLine($"{pad}/// <summary>{XmlEscape(relativePath)}</summary>");
source.AppendLine(
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = new(\"{relativePath}\");");
$"{pad}public static readonly global::MrGameEng.Assets.AssetRef<{type}> {name} = " +
$"new({SymbolDisplay.FormatLiteral(relativePath, quote: true)});");
}
foreach (var pair in node.Children)
{
var name = Unique(usedNames, Identifier(pair.Key));
source.AppendLine($"{pad}/// <summary>{pair.Key}/</summary>");
source.AppendLine($"{pad}/// <summary>{XmlEscape(pair.Key)}/</summary>");
source.AppendLine($"{pad}public static class {name}");
source.AppendLine($"{pad}{{");
EmitNode(source, pair.Value, indent + 1);
EmitNode(source, pair.Value, indent + 1, enclosingName: name);
source.AppendLine($"{pad}}}");
}
}
private static string XmlEscape(string text) =>
text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
internal static string Identifier(string name)
{
+27 -12
View File
@@ -54,6 +54,9 @@ 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);
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
@@ -87,10 +90,10 @@ public static class AtlasBuilder
return (name, key);
}
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
{
var groups = new SortedDictionary<string, List<(string, string)>>(StringComparer.Ordinal);
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
{
@@ -113,14 +116,15 @@ public static class AtlasBuilder
groups.Add(atlasName, list);
}
list.Add((fullPath, key));
var info = new FileInfo(fullPath);
list.Add(new SourceFile(fullPath, key, info.Length, info.LastWriteTimeUtc.Ticks));
}
return groups;
}
private static AtlasGroupResult BuildGroup(
string name, List<(string FullPath, string Key)> 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))
@@ -151,14 +155,14 @@ public static class AtlasBuilder
}
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
WriteMetadata(name, packed, options, metadataPath);
WriteMetadata(name, packed, files, options, metadataPath);
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
}
private static bool IsUpToDate(
string metadataPath, List<(string FullPath, string Key)> files, AtlasBuildOptions options, out int pages)
string metadataPath, List<SourceFile> files, AtlasBuildOptions options, out int pages)
{
pages = 0;
if (!File.Exists(metadataPath))
@@ -176,7 +180,8 @@ public static class AtlasBuilder
return false;
}
if (metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
if (metadata.Version != AtlasMetadata.CurrentVersion ||
metadata.PageSize != options.MaxPageSize || metadata.Padding != options.Padding)
{
return false;
}
@@ -187,17 +192,22 @@ public static class AtlasBuilder
return false;
}
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
if (metadata.Sources.Count != files.Count)
{
return false;
}
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
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)
{
return false;
}
}
pages = metadata.Pages.Count;
return true;
@@ -233,13 +243,18 @@ public static class AtlasBuilder
});
}
private static void WriteMetadata(string name, PackResult packed, AtlasBuildOptions options, string metadataPath)
private static void WriteMetadata(
string name, PackResult packed, List<SourceFile> files, AtlasBuildOptions options, string metadataPath)
{
var metadata = new AtlasMetadata
{
Name = name,
PageSize = options.MaxPageSize,
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
{
+25 -2
View File
@@ -15,8 +15,11 @@ public sealed class AtlasMetadata
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>Format version, bumped on breaking metadata changes.</summary>
public int Version { get; init; } = 1;
/// <summary>Current format version. Bumped on breaking metadata changes.</summary>
public const int CurrentVersion = 2;
/// <summary>Format version of this file; readers reject other versions.</summary>
public int Version { get; init; } = CurrentVersion;
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
public string Name { get; init; } = "";
@@ -33,6 +36,9 @@ public sealed class AtlasMetadata
/// <summary>Packed regions, sorted by key.</summary>
public List<AtlasRegion> Regions { get; init; } = [];
/// <summary>Source files the atlas was built from, sorted by key (staleness check input).</summary>
public List<AtlasSource> Sources { get; init; } = [];
/// <summary>Serializes this metadata to indented JSON.</summary>
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
@@ -55,6 +61,23 @@ public sealed class AtlasPage
public int Height { get; init; }
}
/// <summary>
/// Snapshot of one source file at build time. The incremental rebuild compares key, size and
/// modification time instead of relying on "source newer than metadata", so timestamp-preserving
/// renames and copies still invalidate the atlas.
/// </summary>
public sealed class AtlasSource
{
/// <summary>Region key of the source file.</summary>
public string Key { get; init; } = "";
/// <summary>Source file size in bytes.</summary>
public long Size { get; init; }
/// <summary>Source file <c>LastWriteTimeUtc</c> in ticks.</summary>
public long Modified { get; init; }
}
/// <summary>One packed source texture inside an atlas.</summary>
public sealed class AtlasRegion
{
+23 -6
View File
@@ -17,7 +17,10 @@ public readonly record struct PackPlacement(string Key, int Page, int X, int Y,
/// <summary>Result of a packing run: placements plus the trimmed size of every page.</summary>
/// <param name="Placements">One placement per input item.</param>
/// <param name="PageSizes">Width/height of each page, trimmed to the next power of two covering its content.</param>
/// <param name="PageSizes">
/// 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);
/// <summary>
@@ -65,7 +68,9 @@ public static class ShelfPacker
{
if (open)
{
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
pageSizes.Add((
PageDimension(usedWidth + padding, maxPageSize),
PageDimension(usedHeight + padding, maxPageSize)));
open = false;
}
}
@@ -81,15 +86,22 @@ public static class ShelfPacker
usedHeight = 0;
}
// Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
// они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
foreach (var item in sorted)
{
// Слишком большой для общей страницы — отдельная страница точно под него.
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
CloseOpenPage();
placements.Add(new PackPlacement(item.Key, pageSizes.Count, padding, padding, item.Width, item.Height));
pageSizes.Add((NextPowerOfTwo(item.Width + 2 * padding), NextPowerOfTwo(item.Height + 2 * padding)));
continue;
pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
}
}
foreach (var item in sorted)
{
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
continue; // уже размещён на отдельной странице
}
if (!open)
@@ -119,6 +131,11 @@ public static class ShelfPacker
return new PackResult(placements, pageSizes);
}
// POT удобен GPU, но страница не должна превышать заявленный лимит,
// когда maxPageSize сам не степень двойки.
private static int PageDimension(int used, int maxPageSize) =>
Math.Min(NextPowerOfTwo(used), maxPageSize);
internal static int NextPowerOfTwo(int value)
{
var result = 1;
+19
View File
@@ -56,13 +56,32 @@ public sealed class TextureAtlas : IDisposable
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
{
var metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
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.");
}
var directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
var pages = new Texture2D[metadata.Pages.Count];
try
{
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);
}
}
catch
{
foreach (var page in pages)
{
page?.Dispose(); // частично загруженные страницы не должны утекать
}
throw;
}
return new TextureAtlas(metadata, pages);
}
+30 -5
View File
@@ -40,9 +40,25 @@ public sealed class MusicPlayer : IDisposable
/// <summary>Starts streaming <paramref name="track"/>, stopping the previous one.</summary>
public void Play(MusicTrack track, bool loop = true)
{
// Валидация до Stop(): негодный файл не должен обрывать играющий трек.
var reader = new VorbisReader(track.FullPath);
if (reader.Channels is < 1 or > 2)
{
reader.Dispose();
throw new NotSupportedException(
$"Music '{track.FullPath}' has {reader.Channels} channels; only mono and stereo are supported.");
}
if (reader.SampleRate is < 8000 or > 48000)
{
reader.Dispose();
throw new NotSupportedException(
$"Music '{track.FullPath}' has sample rate {reader.SampleRate} Hz; supported range is 800048000 Hz.");
}
Stop();
_loop = loop;
_reader = new VorbisReader(track.FullPath);
_reader = reader;
// ~0.5 seconds of samples per submitted buffer.
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
@@ -91,15 +107,24 @@ public sealed class MusicPlayer : IDisposable
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
if (read == 0)
{
if (!_loop)
// SamplePosition > 0 отличает конец трека от пустого файла: после перемотки
// на 0 повторный read == 0 не зацикливается, а завершает воспроизведение.
if (_loop && _reader.SamplePosition > 0)
{
return;
}
_reader.SamplePosition = 0;
continue;
}
// Конец незацикленного трека (или пустой файл): когда буферы доиграли,
// останавливаем инстанс — иначе IsPlaying остаётся true навсегда.
if (_instance.PendingBufferCount == 0)
{
_instance.Stop();
}
return;
}
for (var i = 0; i < read; i++)
{
var sample = (short)(Math.Clamp(_sampleBuffer[i], -1f, 1f) * short.MaxValue);
+17 -7
View File
@@ -54,9 +54,14 @@ public sealed class CollisionWorld
}
_cellSize = cellSize;
Array.Fill(_bucketHeads, -1); // пустой хэш до первого ребилда: обход бакета сразу завершается
}
/// <summary>Pairs found by the last rebuild.</summary>
/// <summary>
/// Pairs found by the last rebuild. Order is deterministic for identical registration
/// history; it may differ between sessions whose peak collider count differed
/// (the hash table only grows and its size affects bucket iteration order).
/// </summary>
public ReadOnlySpan<CollisionPair> Pairs => _pairs.AsSpan(0, _pairCount);
/// <summary>Colliders registered in the last rebuild.</summary>
@@ -134,6 +139,7 @@ public sealed class CollisionWorld
{
if (found == results.Length)
{
ResetQueryStamps(); // иначе следующий запрос пропустит помеченные записи
return found;
}
@@ -150,6 +156,8 @@ public sealed class CollisionWorld
/// <summary>
/// Casts a segment and returns the closest hit among colliders whose
/// <see cref="Collider.Layer"/> intersects <paramref name="mask"/>.
/// A ray starting inside a collider hits it at fraction 0. Linear scan over all
/// registered colliders — fine for occasional rays, not for thousands per tick.
/// </summary>
public bool Raycast(Vector2 from, Vector2 to, out RaycastHit hit, uint mask = uint.MaxValue)
{
@@ -280,7 +288,9 @@ public sealed class CollisionWorld
if (a.Shape == ColliderShape.Box && b.Shape == ColliderShape.Box)
{
return a.Aabb.Intersects(b.Aabb);
// Включительно (касание = пара) — единообразно с кругами; 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;
}
// circle vs box
@@ -305,6 +315,11 @@ public sealed class CollisionWorld
var b = 2f * Vector2.Dot(f, d);
var c = Vector2.Dot(f, f) - radius * radius;
if (c <= 0f)
{
return true; // старт внутри круга — попадание в точке старта (как и у AABB)
}
var discriminant = b * b - 4f * a * c;
if (discriminant < 0f)
{
@@ -313,11 +328,6 @@ public sealed class CollisionWorld
var sqrt = MathF.Sqrt(discriminant);
var t = (-b - sqrt) / (2f * a);
if (t < 0f)
{
t = (-b + sqrt) / (2f * a); // старт внутри круга
}
if (t < 0f || t > 1f)
{
return false;
+12
View File
@@ -36,4 +36,16 @@ public sealed class EngineContext
}
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
/// <summary>
/// Disposes everything the context owns: registered <see cref="IDisposable"/> services
/// and the transition renderer. <paramref name="except"/> (the host itself, also a
/// registered service) is skipped — it is being disposed by the caller already.
/// Called by <see cref="GameHost.Dispose(bool)"/>.
/// </summary>
internal void DisposeOwnedResources(object except)
{
Scenes.DisposeRenderer();
Services.DisposeServices(except);
}
}
+14
View File
@@ -29,6 +29,9 @@ public class GameHost : Game
PreferredBackBufferWidth = options.Width,
PreferredBackBufferHeight = options.Height,
IsFullScreen = options.Fullscreen,
// Borderless, как и обещает GameHostOptions.Fullscreen; по умолчанию MonoGame
// делает эксклюзивное переключение видеорежима монитора.
HardwareModeSwitch = false,
SynchronizeWithVerticalRetrace = options.VSync,
};
@@ -75,4 +78,15 @@ public class GameHost : Game
Context.Scenes.ApplyPending();
base.OnExiting(sender, args);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
if (disposing)
{
Context.DisposeOwnedResources(except: this);
}
base.Dispose(disposing);
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace MrGameEng.Core;
/// <summary>
/// Shared flag service: true while a UI overlay (the developer console, a modal dialog)
/// captures input. Producers (e.g. the DevConsole module) set <see cref="Captured"/> while
/// open; consumers (the Input module, scene UI rendering) suppress game-facing input while
/// it is set. Lives in Core so modules can cooperate without referencing each other.
/// </summary>
public sealed class InputCapture
{
/// <summary>True while game-facing input should be suppressed.</summary>
public bool Captured { get; set; }
}
+4
View File
@@ -9,4 +9,8 @@
<PackageReference Include="Friflo.Engine.ECS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
</ItemGroup>
</Project>
+27
View File
@@ -7,6 +7,9 @@ namespace MrGameEng.Core;
/// A scene owns its ECS world (<see cref="EntityStore"/>) and two system roots:
/// <see cref="UpdateSystems"/> for game logic and <see cref="DrawSystems"/> for rendering.
/// Override <see cref="OnLoad"/> to create entities and register systems.
/// Scene instances are single-use: <see cref="OnLoad"/> populates the store and the system
/// roots, and nothing resets them on unload — switch to a <b>new</b> instance instead of
/// reloading an old one (re-loading throws).
/// </summary>
public abstract class Scene
{
@@ -26,6 +29,8 @@ public abstract class Scene
public bool IsLoaded => _context is not null;
private EngineContext? _context;
private bool _loadedOnce;
private readonly List<Action> _unloadActions = [];
/// <summary>Initializes the scene's ECS world and system roots.</summary>
protected Scene()
@@ -40,6 +45,12 @@ public abstract class Scene
/// <summary>Called once when the scene is replaced or the game exits. Release scene resources here.</summary>
protected virtual void OnUnload() { }
/// <summary>
/// Registers a callback run once when the scene unloads (after <see cref="OnUnload"/>),
/// in reverse registration order. Engine modules use this to release per-scene resources.
/// </summary>
public void RegisterUnload(Action action) => _unloadActions.Add(action);
/// <summary>Runs the update phase. Called by <see cref="SceneManager"/>.</summary>
public virtual void Update(GameClock clock) =>
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
@@ -50,6 +61,16 @@ public abstract class Scene
internal void Load(EngineContext context)
{
// Без guard'а повторная загрузка молча дублирует системы и сущности
// (OnLoad добавляет в те же SystemRoot/EntityStore поверх старого содержимого).
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.");
}
_loadedOnce = true;
_context = context;
OnLoad();
}
@@ -57,6 +78,12 @@ public abstract class Scene
internal void Unload()
{
OnUnload();
for (var i = _unloadActions.Count - 1; i >= 0; i--)
{
_unloadActions[i]();
}
_unloadActions.Clear();
_context = null;
}
}
+16 -3
View File
@@ -36,19 +36,25 @@ public sealed class SceneManager
/// Requests a switch to <paramref name="scene"/>. Without a transition the swap happens at
/// the start of the next update tick; with one, the old scene is first covered.
/// Passing null unloads the current scene. Calling during an active transition replaces
/// the pending target scene.
/// the pending target scene; a switch requested while a transition is revealing starts
/// covering again from the current coverage.
/// </summary>
public void Switch(Scene? scene, Transition? transition = null)
{
_pending = scene;
_hasPending = true;
if (_state == State.Idle && transition is not null)
if (transition is not null && _state != State.CoveringOut)
{
_transition = transition;
_state = State.CoveringOut;
if (_state == State.Idle)
{
_coverage = 0f;
}
// Из RevealingIn закрытие продолжается с текущего coverage — без скачка.
_state = State.CoveringOut;
}
}
/// <summary>Advances a transition and updates the active scene. Called by the host.</summary>
@@ -99,6 +105,13 @@ public sealed class SceneManager
_transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase);
}
/// <summary>Disposes the lazily created transition renderer. Called on host shutdown.</summary>
internal void DisposeRenderer()
{
_renderer?.Dispose();
_renderer = null;
}
internal void ApplyPending()
{
if (!_hasPending)
+19
View File
@@ -30,4 +30,23 @@ public sealed class ServiceRegistry
{
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
}
/// <summary>
/// Disposes every registered <see cref="IDisposable"/> service (each instance once, even
/// when registered under several types) and clears the registry. <paramref name="except"/>
/// is skipped. Called on host shutdown.
/// </summary>
internal void DisposeServices(object? except = null)
{
var disposed = new HashSet<object>(ReferenceEqualityComparer.Instance);
foreach (var service in _services.Values)
{
if (!ReferenceEquals(service, except) && service is IDisposable disposable && disposed.Add(service))
{
disposable.Dispose();
}
}
_services.Clear();
}
}
+4 -1
View File
@@ -7,12 +7,15 @@ namespace MrGameEng.Core;
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
/// </summary>
public sealed class TransitionRenderer
public sealed class TransitionRenderer : IDisposable
{
private readonly GraphicsDevice _device;
private readonly BasicEffect _effect;
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
/// <summary>Disposes the GPU effect. Called by <see cref="SceneManager"/> on shutdown.</summary>
public void Dispose() => _effect.Dispose();
internal TransitionRenderer(GraphicsDevice device)
{
_device = device;
+61 -1
View File
@@ -14,6 +14,8 @@ public delegate void ConsoleCommand(DevConsole console, string[] args);
/// </summary>
public sealed class DevConsole : IDisposable
{
private const int MaxHistory = 256;
private readonly object _sync = new();
private readonly string[] _lines;
private readonly Dictionary<string, (string Description, ConsoleCommand Handler)> _commands =
@@ -55,10 +57,13 @@ public sealed class DevConsole : IDisposable
/// <summary>Opens or closes the console overlay.</summary>
public void Toggle()
{
lock (_sync) // Revision инкрементируют и фоновые WriteLine — RMW только под локом
{
IsOpen = !IsOpen;
Revision++;
}
}
/// <summary>Registers (or replaces) a command. Name matching is case-insensitive.</summary>
public void Register(string name, string description, ConsoleCommand handler) =>
@@ -111,12 +116,24 @@ public sealed class DevConsole : IDisposable
if (_history.Count == 0 || _history[^1] != input)
{
_history.Add(input);
if (_history.Count > MaxHistory)
{
_history.RemoveAt(0);
}
}
_historyCursor = _history.Count;
lock (_sync)
{
_scrollOffset = 0;
}
var parts = SplitArguments(input);
if (parts.Length == 0)
{
return;
}
var parts = input.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (!_commands.TryGetValue(parts[0], out var command))
{
WriteLine($"unknown command '{parts[0]}' — try 'help'");
@@ -233,6 +250,49 @@ public sealed class DevConsole : IDisposable
}
}
/// <summary>
/// Splits a command line on whitespace; double quotes group words into one argument
/// (<c>say "hello world"</c> → <c>say</c>, <c>hello world</c>). Unterminated quotes
/// run to the end of the line.
/// </summary>
internal static string[] SplitArguments(string input)
{
var parts = new List<string>();
var current = new StringBuilder();
var quoted = false;
var hasToken = false;
foreach (var c in input)
{
if (c == '"')
{
quoted = !quoted;
hasToken = true; // "" — пустой аргумент тоже аргумент
}
else if (char.IsWhiteSpace(c) && !quoted)
{
if (hasToken)
{
parts.Add(current.ToString());
current.Clear();
hasToken = false;
}
}
else
{
current.Append(c);
hasToken = true;
}
}
if (hasToken)
{
parts.Add(current.ToString());
}
return parts.ToArray();
}
private void OnLogMessage(LogLevel level, string message) =>
WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}");
+14 -3
View File
@@ -129,19 +129,22 @@ internal sealed class DevConsoleUi
/// <summary>
/// Update-phase system: toggles the console with the backquote (`) key and scrolls the log
/// with the mouse wheel while open. Polls the keyboard itself, so it works regardless of
/// the input module.
/// the input module. While the console is open, <see cref="InputCapture.Captured"/> is held,
/// suppressing game-facing input (the Input module and scene UI honor it).
/// </summary>
public sealed class DevConsoleSystem : BaseSystem
{
private readonly DevConsole _console;
private readonly DevConsoleUi _ui;
private readonly InputCapture _capture;
private KeyboardState _previousKeyboard;
private int _previousWheel;
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui)
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui, InputCapture capture)
{
_console = console;
_ui = ui;
_capture = capture;
}
/// <inheritdoc />
@@ -158,6 +161,7 @@ public sealed class DevConsoleSystem : BaseSystem
}
_previousKeyboard = keyboard;
_capture.Captured = _console.IsOpen;
var wheel = Mouse.GetState().ScrollWheelValue;
if (_console.IsOpen && wheel != _previousWheel)
@@ -203,8 +207,15 @@ public static class SceneDevConsoleExtensions
Log.Info("Developer console ready — press ` to toggle, 'help' for commands");
}
var capture = services.GetOrDefault<InputCapture>();
if (capture is null)
{
capture = new InputCapture();
services.Add(capture);
}
var ui = services.Get<DevConsoleUi>();
scene.UpdateSystems.Insert(0, new DevConsoleSystem(console, ui));
scene.UpdateSystems.Insert(0, new DevConsoleSystem(console, ui, capture));
scene.DrawSystems.Add(new DevConsoleRenderSystem(ui));
return console;
}
+33 -9
View File
@@ -23,7 +23,11 @@ public enum LayerSortMode
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
Depth,
/// <summary>Order by world Y position (top-down games: lower on screen = drawn in front).</summary>
/// <summary>
/// Order by the entity's world Y position (<see cref="Transform2D.Position"/>):
/// top-down games, lower on screen = drawn in front. Place sprite origins at the
/// feet/base so the sort point matches the visual anchor.
/// </summary>
YSort,
}
@@ -32,31 +36,51 @@ public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, Laye
/// <summary>
/// Registry of render layers. Layers are registered up front (typically when the renderer is
/// created) and drawn in registration order. Maximum 256 layers.
/// created) and drawn in registration order. Maximum 256 layers. Reads are lock-free and
/// thread-safe (the renderer reads layers from parallel submit workers); registration swaps
/// an immutable snapshot, so registering mid-frame never tears a concurrent read.
/// </summary>
public sealed class LayerRegistry
{
private readonly List<RenderLayer> _layers = [];
private readonly object _sync = new();
private volatile RenderLayer[] _layers = [];
/// <summary>Creates a registry containing the built-in "Default" world layer.</summary>
public LayerRegistry() => Register("Default");
/// <summary>Number of registered layers.</summary>
public int Count => _layers.Count;
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)
{
if (_layers.Count == 256)
lock (_sync)
{
var layers = _layers;
if (layers.Length == 256)
{
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
}
var id = new LayerId((byte)_layers.Count);
_layers.Add(new RenderLayer(id, name, space, sortMode));
var id = new LayerId((byte)layers.Length);
var grown = new RenderLayer[layers.Length + 1];
Array.Copy(layers, grown, layers.Length);
grown[layers.Length] = new RenderLayer(id, name, space, sortMode);
_layers = grown;
return id;
}
}
/// <summary>Returns the layer with the given id.</summary>
public RenderLayer this[LayerId id] => _layers[id.Value];
/// <summary>Returns the layer with the given id; throws when the id was never registered.</summary>
public RenderLayer this[LayerId id]
{
get
{
var layers = _layers;
return id.Value < layers.Length
? layers[id.Value]
: throw new ArgumentOutOfRangeException(
nameof(id), $"Render layer {id.Value} is not registered (registered: {layers.Length}).");
}
}
}
+23 -1
View File
@@ -67,6 +67,7 @@ public sealed class Renderer2D : IDisposable
{
_device = device;
_options = options ?? new Renderer2DOptions();
ArgumentOutOfRangeException.ThrowIfLessThan(_options.InitialCapacity, 1, nameof(options));
_batcher = new SpriteBatcher(_options.InitialCapacity);
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
_vertexBuffer = new DynamicVertexBuffer(
@@ -201,7 +202,26 @@ public sealed class Renderer2D : IDisposable
_device.SetVertexBuffer(_vertexBuffer);
_device.Indices = _indexBuffer;
// Виртуальное разрешение рисуется в letterbox-прямоугольник — тот же, по которому
// считают ScreenToWorld/WorldToScreen; иначе картинка растягивается мимо маппинга.
var previousViewport = _device.Viewport;
var letterboxed = _options.VirtualResolution is not null;
if (letterboxed)
{
var mapping = Camera.Mapping;
_device.Viewport = new Viewport(
(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));
}
DrawBatches(order, count);
if (letterboxed)
{
_device.Viewport = previousViewport;
}
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
}
@@ -276,7 +296,9 @@ public sealed class Renderer2D : IDisposable
return SubmitResult.Culled;
}
var depth = layer.SortMode == LayerSortMode.YSort ? center.Y : sprite.Depth;
// Y-sort по пивоту (Transform2D.Position), а не по центру квада: спрайты разной
// высоты с origin «в ногах» сортируются по ногам, как принято в top-down.
var depth = layer.SortMode == LayerSortMode.YSort ? transform.Position.Y : sprite.Depth;
instance = new SpriteInstance
{
@@ -22,6 +22,12 @@ public static class SceneGraphicsExtensions
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
services.Add(renderer);
}
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).");
}
scene.DrawSystems.Add(new CameraSystem(renderer));
scene.DrawSystems.Add(new SpriteRenderSystem(renderer));
+28 -2
View File
@@ -20,10 +20,13 @@ public enum MouseButton
/// Polls keyboard, mouse and gamepad once per frame and keeps the previous frame's state,
/// enabling edge queries (<c>Pressed</c> = went down this frame, <c>Released</c> = went up).
/// Registered as a service by <c>scene.UseInput()</c>; polled by <see cref="InputSystem"/>
/// at the start of the update phase.
/// at the start of the update phase. While <see cref="MrGameEng.Core.InputCapture.Captured"/>
/// is set (e.g. the developer console is open), game-facing input reads as released.
/// </summary>
public sealed class InputManager
{
private readonly MrGameEng.Core.InputCapture? _capture;
private KeyboardState _keyboard;
private KeyboardState _previousKeyboard;
private MouseState _mouse;
@@ -31,11 +34,34 @@ public sealed class InputManager
private GamePadState _gamePad;
private GamePadState _previousGamePad;
/// <summary>
/// Creates a manager. With a <paramref name="capture"/>, input is suppressed while a UI
/// overlay holds it (keys/buttons read as released, mouse position and wheel freeze).
/// </summary>
public InputManager(MrGameEng.Core.InputCapture? capture = null) => _capture = capture;
/// <summary>Polls all devices. Called once per frame by <see cref="InputSystem"/>.</summary>
public void Update() => Apply(
public void Update()
{
if (_capture?.Captured == true)
{
// Оверлей (консоль) захватил ввод: клавиши и кнопки считаются отпущенными,
// позиция мыши и счётчик колеса замораживаются — все дельты нулевые.
Apply(
default,
new MouseState(
_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));
}
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
{
+8 -1
View File
@@ -29,7 +29,14 @@ public static class SceneInputExtensions
var input = services.GetOrDefault<InputManager>();
if (input is null)
{
input = new InputManager();
var capture = services.GetOrDefault<InputCapture>();
if (capture is null)
{
capture = new InputCapture();
services.Add(capture);
}
input = new InputManager(capture);
services.Add(input);
}
+18 -1
View File
@@ -60,7 +60,10 @@ public sealed class FlowFieldBuilder
private int _generation;
private int _heapCount;
/// <summary>Creates a builder bound to <paramref name="grid"/>.</summary>
/// <summary>
/// Creates a builder bound to <paramref name="grid"/>. Grid dimensions are captured
/// here; if the grid is resized later, <see cref="Build"/> throws — create a new builder.
/// </summary>
public FlowFieldBuilder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight)
{
_grid = grid;
@@ -80,11 +83,25 @@ public sealed class FlowFieldBuilder
/// </summary>
public void Build(ReadOnlySpan<Point> goals, FlowField field)
{
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.");
}
field.EnsureSize(_width, _height);
var distances = field.Distances;
var directions = field.Directions;
Array.Fill(distances, float.PositiveInfinity, 0, _width * _height);
// Переполнение штампа: см. GridPathfinder.NextGeneration.
if (_generation == int.MaxValue)
{
Array.Clear(_closedStamp);
_generation = 0;
}
_generation++;
_heapCount = 0;
+27 -3
View File
@@ -42,7 +42,10 @@ public sealed class GridPathfinder
private int _generation;
private int _heapCount;
/// <summary>Creates a pathfinder bound to <paramref name="grid"/>.</summary>
/// <summary>
/// Creates a pathfinder bound to <paramref name="grid"/>. Grid dimensions are captured
/// here; if the grid is resized later, queries throw — create a new pathfinder instead.
/// </summary>
public GridPathfinder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight)
{
_grid = grid;
@@ -66,6 +69,13 @@ public sealed class GridPathfinder
/// </summary>
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.");
}
path.Clear();
if (!InBounds(start) || !InBounds(goal) ||
!_grid.IsPassable(start.X, start.Y) || !_grid.IsPassable(goal.X, goal.Y))
@@ -86,7 +96,7 @@ public sealed class GridPathfinder
private bool WeightedSearch(Point start, Point goal, List<Point> path, bool useHeuristic)
{
_generation++;
NextGeneration();
_heapCount = 0;
var startIndex = Index(start.X, start.Y);
@@ -149,7 +159,7 @@ public sealed class GridPathfinder
private bool BreadthFirst(Point start, Point goal, List<Point> path)
{
_generation++;
NextGeneration();
var head = 0;
var tail = 0;
@@ -234,6 +244,20 @@ public sealed class GridPathfinder
path.Reverse();
}
private void NextGeneration()
{
// Переполнение штампа (2^31 поисков): нетронутые ячейки со штампом 0 читались бы
// как посещённые — очищаем массивы и начинаем нумерацию заново.
if (_generation == int.MaxValue)
{
Array.Clear(_openStamp);
Array.Clear(_closedStamp);
_generation = 0;
}
_generation++;
}
private bool InBounds(Point p) => p.X >= 0 && p.X < _width && p.Y >= 0 && p.Y < _height;
private int Index(int x, int y) => y * _width + x;
+36 -11
View File
@@ -9,41 +9,66 @@ namespace MrGameEng.UI;
/// <summary>
/// Draw system rendering the scene's Myra <see cref="Desktop"/>. Must run after the scene's
/// world rendering (register UI last in the draw phase) — the UI is drawn on top in window
/// pixels and also processes mouse/keyboard interaction during render.
/// pixels and also processes mouse/keyboard interaction during render. While
/// <see cref="InputCapture.Captured"/> is set (developer console open), the desktop is drawn
/// without processing input, so overlay clicks and keystrokes do not fall through to it.
/// </summary>
public sealed class UiRenderSystem : BaseSystem
{
private readonly Desktop _desktop;
private readonly InputCapture? _capture;
/// <summary>Creates the system for <paramref name="desktop"/>.</summary>
public UiRenderSystem(Desktop desktop) => _desktop = desktop;
public UiRenderSystem(Desktop desktop, InputCapture? capture = null)
{
_desktop = desktop;
_capture = capture;
}
/// <inheritdoc />
protected override void OnUpdateGroup() => _desktop.Render();
protected override void OnUpdateGroup()
{
if (_capture?.Captured == true)
{
// Render() = UpdateInput + UpdateLayout + RenderVisual; пропускаем только ввод.
_desktop.UpdateLayout();
_desktop.RenderVisual();
}
else
{
_desktop.Render();
}
}
}
/// <summary>Wires the UI module (Myra) into a <see cref="Scene"/>.</summary>
public static class SceneUiExtensions
{
private static bool _environmentInitialized;
/// <summary>
/// Creates a Myra <see cref="Desktop"/> for this scene and registers
/// <see cref="UiRenderSystem"/> in the draw phase. Call from <c>OnLoad</c>
/// <b>after</b> <c>UseRenderer2D()</c> so the UI draws on top of the world.
/// Build the UI by assigning <see cref="Desktop.Root"/>.
/// Build the UI by assigning <see cref="Desktop.Root"/>. The desktop is disposed
/// automatically when the scene unloads.
/// </summary>
public static Desktop UseUI(this Scene scene)
{
// Геттер MyraEnvironment.Game бросает исключение, пока Game не задан — проверять через ??= нельзя.
if (!_environmentInitialized)
var services = scene.Context.Services;
// Присваивание идемпотентно; геттер MyraEnvironment.Game бросает исключение, пока
// Game не задан, поэтому проверять текущее значение перед записью нельзя.
MyraEnvironment.Game = services.Get<Game>();
var capture = services.GetOrDefault<InputCapture>();
if (capture is null)
{
MyraEnvironment.Game = scene.Context.Services.Get<Game>();
_environmentInitialized = true;
capture = new InputCapture();
services.Add(capture);
}
var desktop = new Desktop();
scene.DrawSystems.Add(new UiRenderSystem(desktop));
scene.RegisterUnload(desktop.Dispose);
scene.DrawSystems.Add(new UiRenderSystem(desktop, capture));
return desktop;
}
}
@@ -156,4 +156,33 @@ public class AssetHandlesGeneratorTests
{
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath));
}
[Theory]
[InlineData(@"D:\game\Assets\ui\Assets\icon.png", @"D:\game", "ui/Assets/icon.png")]
[InlineData(@"D:\game\Assets\icon.png", @"D:\game\", "icon.png")]
[InlineData(@"D:\game\Other\icon.png", @"D:\game", null)]
public void ToAssetPath_WithProjectDir_RootsAtProjectAssetsFolder(
string fullPath, string projectDir, string? expected)
{
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath, projectDir));
}
[Fact]
public void SpecialCharactersInNames_AreEscapedInDocsAndLiterals()
{
var source = RunGenerator([@"D:\game\Assets\UI\a&b <c>.png"]);
Assert.Contains("a&amp;b &lt;c&gt;", source); // XML-док экранирован (иначе CS1570)
Assert.Contains("new(\"UI/a&b <c>.png\")", source);
}
[Fact]
public void FileNamedLikeContainerClass_DoesNotCollide()
{
// Член с именем вмещающего класса — ошибка CS0542; генератор должен переименовать.
var source = RunGenerator([@"D:\game\Assets\GameAssets.png"]);
Assert.Contains("GameAssets2 = new(\"GameAssets.png\")", source);
Assert.DoesNotContain("GameAssets = new(", source);
}
}
@@ -151,6 +151,39 @@ public sealed class AtlasBuilderTests : IDisposable
Assert.True(Assert.Single(result.Groups).PageCount >= 3);
}
[Fact]
public void Build_ContentChangedWithSameTimestamp_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
// Копия «с сохранением времени» (robocopy, zip): mtime прежний, контент другой.
var path = Path.Combine(SourceDir, "UI/button.png");
var timestamp = File.GetLastWriteTimeUtc(path);
WritePng("UI/button.png", 16, 16, 9, 9, 9);
File.SetLastWriteTimeUtc(path, timestamp);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Build_OldMetadataVersion_Rebuilds()
{
WritePng("UI/button.png", 8, 8, 1, 2, 3);
AtlasBuilder.Build(Options());
var metadataPath = Path.Combine(OutputDir, "UI.atlas");
var json = File.ReadAllText(metadataPath)
.Replace($"\"version\": {AtlasMetadata.CurrentVersion}", "\"version\": 1");
File.WriteAllText(metadataPath, json);
var result = AtlasBuilder.Build(Options());
Assert.False(Assert.Single(result.Groups).Skipped);
}
[Fact]
public void Metadata_JsonRoundtrip_PreservesEverything()
{
@@ -108,4 +108,41 @@ public class ShelfPackerTests
{
Assert.Equal(expected, ShelfPacker.NextPowerOfTwo(value));
}
[Fact]
public void Pack_NonPowerOfTwoLimit_SharedPagesNeverExceedIt()
{
// 40² с padding на лимит 100: POT-округление дало бы 128 > лимита.
var result = ShelfPacker.Pack(Squares(10, 40), maxPageSize: 100, padding: 2);
Assert.All(result.PageSizes, size =>
{
Assert.True(size.Width <= 100, $"page width {size.Width} > 100");
Assert.True(size.Height <= 100, $"page height {size.Height} > 100");
});
}
[Fact]
public void Pack_OversizedItem_PageHasExactPaddedSize()
{
var result = ShelfPacker.Pack([new PackItem("big", 300, 50)], maxPageSize: 128, padding: 2);
Assert.Equal((304, 54), result.PageSizes.Single());
}
[Fact]
public void Pack_OversizedAmongSmall_DoesNotSplitSharedPage()
{
// Широкий и низкий негабарит сортируется в конец по высоте; раньше он закрывал
// наполовину заполненную общую страницу, и остаток уезжал на новую.
var items = new List<PackItem> { new("big", 300, 10) };
for (var i = 0; i < 4; i++)
{
items.Add(new PackItem($"s{i}", 30, 30));
}
var result = ShelfPacker.Pack(items, maxPageSize: 128, padding: 2);
Assert.Equal(2, result.PageSizes.Count); // отдельная страница big + одна общая
}
}
@@ -182,4 +182,59 @@ public class CollisionWorldTests
Assert.False(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out _));
}
[Fact]
public void QueryAabb_BeforeFirstRebuild_ReturnsZero()
{
// До первого EndRebuild хэш пуст; запрос не должен зависать и что-то находить.
var world = new CollisionWorld();
Span<Entity> results = new Entity[4];
Assert.Equal(0, world.QueryAabb(new RectF(-10f, -10f, 20f, 20f), results));
}
[Fact]
public void QueryAabb_TruncatedResults_DoNotPoisonNextQuery()
{
var (context, scene) = CreateScene();
for (var i = 0; i < 3; i++)
{
Spawn(scene, new Vector2(i * 5f, 0f), Collider.Circle(4f));
}
Tick(context);
var area = new RectF(-10f, -10f, 40f, 20f);
Span<Entity> tiny = new Entity[1];
Assert.Equal(1, scene.World.QueryAabb(area, tiny)); // обрезан по размеру буфера
Span<Entity> all = new Entity[8];
Assert.Equal(3, scene.World.QueryAabb(area, all)); // повторный запрос видит всех
}
[Fact]
public void Raycast_FromInsideCircle_HitsAtOrigin()
{
var (context, scene) = CreateScene();
var entity = Spawn(scene, new Vector2(0f, 0f), Collider.Circle(10f));
Tick(context);
Assert.True(scene.World.Raycast(new Vector2(0f, 0f), new Vector2(100f, 0f), out var hit));
Assert.Equal(entity, hit.Entity);
Assert.Equal(0f, hit.Fraction);
Assert.Equal(Vector2.Zero, hit.Point);
}
[Fact]
public void ExactlyTouchingBoxes_ProducePair()
{
var (context, scene) = CreateScene();
Spawn(scene, new Vector2(0f, 0f), Collider.Box(20f, 20f));
Spawn(scene, new Vector2(20f, 0f), Collider.Box(20f, 20f)); // грани соприкасаются ровно
Tick(context);
Assert.Equal(1, scene.World.Pairs.Length); // как и у касающихся кругов
}
}
@@ -84,4 +84,69 @@ public class SceneManagerTests
Assert.Null(context.Scenes.Current);
}
private sealed class CallbackScene(Action<Scene> onLoad) : Scene
{
protected override void OnLoad() => onLoad(this);
}
[Fact]
public void Switch_BackToLoadedSceneInstance_Throws()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
context.Scenes.Switch(first);
context.Scenes.Update(context.Clock);
context.Scenes.Switch(second);
context.Scenes.Update(context.Clock);
// Повторная загрузка молча задвоила бы системы и сущности — должен быть отказ.
context.Scenes.Switch(first);
Assert.Throws<InvalidOperationException>(() => context.Scenes.Update(context.Clock));
}
[Fact]
public void RegisterUnload_RunsOnUnload_InReverseOrder()
{
var context = new EngineContext();
var order = new List<int>();
var scene = new CallbackScene(s =>
{
s.RegisterUnload(() => order.Add(1));
s.RegisterUnload(() => order.Add(2));
});
context.Scenes.Switch(scene);
context.Scenes.Update(context.Clock);
Assert.Empty(order);
context.Scenes.Switch(null);
context.Scenes.Update(context.Clock);
Assert.Equal([2, 1], order);
}
private sealed class DisposableService : IDisposable
{
public int DisposeCount;
public void Dispose() => DisposeCount++;
}
[Fact]
public void DisposeServices_DisposesEachServiceOnce_AndSkipsExcept()
{
var registry = new ServiceRegistry();
var service = new DisposableService();
var host = new DisposableService();
registry.Add(service);
registry.Add<IDisposable>(host); // host зарегистрирован, но его освобождает вызывающий
registry.DisposeServices(except: host);
Assert.Equal(1, service.DisposeCount);
Assert.Equal(0, host.DisposeCount);
Assert.Null(registry.GetOrDefault<DisposableService>());
}
}
@@ -89,6 +89,32 @@ public class SceneTransitionTests
Assert.Equal(0, second.LoadCount);
}
[Fact]
public void SwitchDuringReveal_CoversAgain_InsteadOfHardSwap()
{
var context = new EngineContext();
var first = new TrackingScene();
var second = new TrackingScene();
var third = new TrackingScene();
context.Scenes.Switch(first);
Tick(context, 0.016f);
context.Scenes.Switch(second, Transition.Fade(1f)); // 0.5 c закрытие + 0.5 c открытие
Tick(context, 0.6f); // закрыто, своп на second, началось открытие
Assert.Same(second, context.Scenes.Current);
Tick(context, 0.25f); // открытие наполовину (coverage ~0.5)
context.Scenes.Switch(third, Transition.Fade(1f)); // передумали во время открытия
Tick(context, 0.05f); // экран снова закрывается — свопа ещё нет
Assert.Same(second, context.Scenes.Current);
Assert.True(context.Scenes.IsTransitioning);
Tick(context, 0.6f); // полностью закрыт — теперь своп на third
Assert.Same(third, context.Scenes.Current);
Assert.Equal(1, third.LoadCount);
}
[Fact]
public void ZeroDurationTransition_SwapsOnNextUpdates()
{
@@ -131,6 +131,49 @@ public class DevConsoleTests
Assert.Contains("[warn] careful", visible);
}
[Fact]
public void Execute_QuotedArguments_KeptAsSingleArgument()
{
using var console = new DevConsole();
string[]? received = null;
console.Register("say", "", (_, args) => received = args);
console.Execute("say \"hello world\" plain \"\"");
Assert.Equal(["hello world", "plain", ""], received!);
}
[Fact]
public void Execute_UnterminatedQuote_RunsToEndOfLine()
{
using var console = new DevConsole();
string[]? received = null;
console.Register("say", "", (_, args) => received = args);
console.Execute("say \"one two");
Assert.Equal(["one two"], received!);
}
[Fact]
public void History_IsCapped_OldestEntriesDropped()
{
using var console = new DevConsole();
console.Register("n", "", (_, _) => { });
for (var i = 0; i < 300; i++)
{
console.Execute($"n {i}");
}
string? oldest = null;
for (var i = 0; i < 400; i++)
{
oldest = console.HistoryPrevious(); // упирается в самую старую сохранённую
}
Assert.Equal("n 44", oldest); // 300 256 (лимит) = 44
}
[Fact]
public void Revision_ChangesOnlyOnVisibleChanges()
{
@@ -40,6 +40,28 @@ public class InputManagerTests
Assert.False(input.IsKeyReleased(Keys.A));
}
[Fact]
public void Capture_SuppressesInput_KeepsMousePositionAndWheel()
{
var capture = new MrGameEng.Core.InputCapture();
var input = new InputManager(capture);
Frame(input, new KeyboardState(Keys.W), Mouse(x: 10, y: 20, wheel: 120));
Assert.True(input.IsKeyDown(Keys.W));
capture.Captured = true;
input.Update(); // ввод захвачен оверлеем — устройства не опрашиваются
Assert.False(input.IsKeyDown(Keys.W));
Assert.True(input.IsKeyReleased(Keys.W)); // одно корректное событие отпускания
Assert.Equal(new Point(10, 20), input.MousePosition);
Assert.Equal(0, input.WheelDelta);
Assert.Equal(Point.Zero, input.MouseDelta);
input.Update();
Assert.False(input.IsKeyReleased(Keys.W)); // и больше никаких фантомных событий
}
[Fact]
public void MouseDeltaAndWheelDelta_ComputedBetweenFrames()
{
@@ -0,0 +1,44 @@
using Microsoft.Xna.Framework;
using Xunit;
namespace MrGameEng.Pathfinding.Tests;
/// <summary>
/// Размеры грида фиксируются в конструкторе; «уехавший» грид должен давать
/// громкую ошибку, а не молчаливое чтение мимо границ.
/// </summary>
public class GridResizeTests
{
private sealed class ResizableGrid : IPathGrid
{
public int Width { get; set; } = 4;
public int Height { get; set; } = 4;
public bool IsPassable(int x, int y) => true;
public float Cost(int x, int y) => 1f;
}
[Fact]
public void FindPath_AfterGridResize_Throws()
{
var grid = new ResizableGrid();
var pathfinder = new GridPathfinder(grid);
grid.Width = 8;
Assert.Throws<InvalidOperationException>(
() => pathfinder.FindPath(new Point(0, 0), new Point(1, 1), []));
}
[Fact]
public void FlowFieldBuild_AfterGridResize_Throws()
{
var grid = new ResizableGrid();
var builder = new FlowFieldBuilder(grid);
grid.Height = 8;
Assert.Throws<InvalidOperationException>(
() => builder.Build([new Point(0, 0)], new FlowField()));
}
}
+13 -1
View File
@@ -22,7 +22,19 @@ if (args.Length < 2 || args.Contains("--help") || args.Contains("-h"))
int Option(string name, int fallback)
{
var index = Array.IndexOf(args, name);
return index >= 0 && index + 1 < args.Length ? int.Parse(args[index + 1]) : fallback;
if (index < 0)
{
return fallback;
}
if (index + 1 < args.Length && int.TryParse(args[index + 1], out var value) && value >= 0)
{
return value;
}
Console.Error.WriteLine($"Option {name} requires a non-negative integer value.");
Environment.Exit(1);
return fallback; // недостижимо
}
var rootNameIndex = Array.IndexOf(args, "--root-name");