diff --git a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs index 6cfc7ba..3c41adc 100644 --- a/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs +++ b/src/MrGameEng.Assets.Generator/AssetHandlesGenerator.cs @@ -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 /// /// 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 + /// the marker is the project's root Assets/ folder, + /// so nested directories that happen to be called "Assets" do not shift the root; + /// without one, the last /Assets/ segment of the path is used. /// - internal static string? ToAssetPath(string fullPath) + internal static string? ToAssetPath(string fullPath, string? projectDir = null) { var normalized = fullPath.Replace('\\', '/'); - var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase); - if (marker < 0) - { - return null; - } - var relative = normalized.Substring(marker + "/Assets/".Length); + 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; + } + + relative = normalized.Substring(marker + "/Assets/".Length); + } // Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas, // им собственные Texture2D-хендлы не нужны. @@ -97,36 +118,41 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator source.AppendLine("/// Typed handles for every file under the Assets directory."); 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(); + // Имя вмещающего класса занято: член с тем же именем — ошибка CS0542. + var usedNames = new HashSet { 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}/// {relativePath}"); + source.AppendLine($"{pad}/// {XmlEscape(relativePath)}"); 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}/// {pair.Key}/"); + source.AppendLine($"{pad}/// {XmlEscape(pair.Key)}/"); 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("&", "&").Replace("<", "<").Replace(">", ">"); + /// Converts an arbitrary file or directory name to a PascalCase C# identifier. internal static string Identifier(string name) { diff --git a/src/MrGameEng.Atlases/AtlasBuilder.cs b/src/MrGameEng.Atlases/AtlasBuilder.cs index 89880e2..656e36d 100644 --- a/src/MrGameEng.Atlases/AtlasBuilder.cs +++ b/src/MrGameEng.Atlases/AtlasBuilder.cs @@ -54,6 +54,9 @@ public static class AtlasBuilder { private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"]; + /// Snapshot of one source image taken at scan time (size/mtime feed the staleness check). + private readonly record struct SourceFile(string FullPath, string Key, long Size, long ModifiedTicks); + /// Builds (or incrementally refreshes) all atlases for . public static AtlasBuildResult Build(AtlasBuildOptions options) { @@ -87,10 +90,10 @@ public static class AtlasBuilder return (name, key); } - private static SortedDictionary> ScanGroups( + private static SortedDictionary> ScanGroups( string sourceRoot, AtlasBuildOptions options) { - var groups = new SortedDictionary>(StringComparer.Ordinal); + var groups = new SortedDictionary>(StringComparer.Ordinal); var keys = new Dictionary(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 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 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,16 +192,21 @@ 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) { - return false; + if (!sourcesByKey.TryGetValue(file.Key, out var source) || + source.Size != file.Size || source.Modified != file.ModifiedTicks) + { + return false; + } } pages = metadata.Pages.Count; @@ -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 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 { diff --git a/src/MrGameEng.Atlases/AtlasMetadata.cs b/src/MrGameEng.Atlases/AtlasMetadata.cs index 171eeaa..ff2ae26 100644 --- a/src/MrGameEng.Atlases/AtlasMetadata.cs +++ b/src/MrGameEng.Atlases/AtlasMetadata.cs @@ -15,8 +15,11 @@ public sealed class AtlasMetadata PropertyNamingPolicy = JsonNamingPolicy.CamelCase, }; - /// Format version, bumped on breaking metadata changes. - public int Version { get; init; } = 1; + /// Current format version. Bumped on breaking metadata changes. + public const int CurrentVersion = 2; + + /// Format version of this file; readers reject other versions. + public int Version { get; init; } = CurrentVersion; /// Atlas name (group key with '/' replaced by '.'). public string Name { get; init; } = ""; @@ -33,6 +36,9 @@ public sealed class AtlasMetadata /// Packed regions, sorted by key. public List Regions { get; init; } = []; + /// Source files the atlas was built from, sorted by key (staleness check input). + public List Sources { get; init; } = []; + /// Serializes this metadata to indented JSON. public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); @@ -55,6 +61,23 @@ public sealed class AtlasPage public int Height { get; init; } } +/// +/// 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. +/// +public sealed class AtlasSource +{ + /// Region key of the source file. + public string Key { get; init; } = ""; + + /// Source file size in bytes. + public long Size { get; init; } + + /// Source file LastWriteTimeUtc in ticks. + public long Modified { get; init; } +} + /// One packed source texture inside an atlas. public sealed class AtlasRegion { diff --git a/src/MrGameEng.Atlases/ShelfPacker.cs b/src/MrGameEng.Atlases/ShelfPacker.cs index b926be6..e3f45b1 100644 --- a/src/MrGameEng.Atlases/ShelfPacker.cs +++ b/src/MrGameEng.Atlases/ShelfPacker.cs @@ -17,7 +17,10 @@ public readonly record struct PackPlacement(string Key, int Page, int X, int Y, /// Result of a packing run: placements plus the trimmed size of every page. /// One placement per input item. -/// Width/height of each page, trimmed to the next power of two covering its content. +/// +/// 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. +/// public sealed record PackResult(IReadOnlyList Placements, IReadOnlyList<(int Width, int Height)> PageSizes); /// @@ -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; diff --git a/src/MrGameEng.Atlases/TextureAtlas.cs b/src/MrGameEng.Atlases/TextureAtlas.cs index 3748203..d7b6e89 100644 --- a/src/MrGameEng.Atlases/TextureAtlas.cs +++ b/src/MrGameEng.Atlases/TextureAtlas.cs @@ -56,12 +56,31 @@ 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]; - for (var i = 0; i < pages.Length; i++) + try { - using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File)); - pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha); + 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); diff --git a/src/MrGameEng.Audio/MusicPlayer.cs b/src/MrGameEng.Audio/MusicPlayer.cs index 1e393f6..0238876 100644 --- a/src/MrGameEng.Audio/MusicPlayer.cs +++ b/src/MrGameEng.Audio/MusicPlayer.cs @@ -40,9 +40,25 @@ public sealed class MusicPlayer : IDisposable /// Starts streaming , stopping the previous one. 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 8000–48000 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,13 +107,22 @@ 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; } - _reader.SamplePosition = 0; - continue; + // Конец незацикленного трека (или пустой файл): когда буферы доиграли, + // останавливаем инстанс — иначе IsPlaying остаётся true навсегда. + if (_instance.PendingBufferCount == 0) + { + _instance.Stop(); + } + + return; } for (var i = 0; i < read; i++) diff --git a/src/MrGameEng.Collisions/CollisionWorld.cs b/src/MrGameEng.Collisions/CollisionWorld.cs index e49bab9..5ff75a0 100644 --- a/src/MrGameEng.Collisions/CollisionWorld.cs +++ b/src/MrGameEng.Collisions/CollisionWorld.cs @@ -54,9 +54,14 @@ public sealed class CollisionWorld } _cellSize = cellSize; + Array.Fill(_bucketHeads, -1); // пустой хэш до первого ребилда: обход бакета сразу завершается } - /// Pairs found by the last rebuild. + /// + /// 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). + /// public ReadOnlySpan Pairs => _pairs.AsSpan(0, _pairCount); /// Colliders registered in the last rebuild. @@ -134,6 +139,7 @@ public sealed class CollisionWorld { if (found == results.Length) { + ResetQueryStamps(); // иначе следующий запрос пропустит помеченные записи return found; } @@ -150,6 +156,8 @@ public sealed class CollisionWorld /// /// Casts a segment and returns the closest hit among colliders whose /// intersects . + /// 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. /// 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; diff --git a/src/MrGameEng.Core/EngineContext.cs b/src/MrGameEng.Core/EngineContext.cs index e5f6c54..d871908 100644 --- a/src/MrGameEng.Core/EngineContext.cs +++ b/src/MrGameEng.Core/EngineContext.cs @@ -36,4 +36,16 @@ public sealed class EngineContext } internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device; + + /// + /// Disposes everything the context owns: registered services + /// and the transition renderer. (the host itself, also a + /// registered service) is skipped — it is being disposed by the caller already. + /// Called by . + /// + internal void DisposeOwnedResources(object except) + { + Scenes.DisposeRenderer(); + Services.DisposeServices(except); + } } diff --git a/src/MrGameEng.Core/GameHost.cs b/src/MrGameEng.Core/GameHost.cs index fe789b4..6f4566c 100644 --- a/src/MrGameEng.Core/GameHost.cs +++ b/src/MrGameEng.Core/GameHost.cs @@ -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); } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + Context.DisposeOwnedResources(except: this); + } + + base.Dispose(disposing); + } } diff --git a/src/MrGameEng.Core/InputCapture.cs b/src/MrGameEng.Core/InputCapture.cs new file mode 100644 index 0000000..0fb9a4b --- /dev/null +++ b/src/MrGameEng.Core/InputCapture.cs @@ -0,0 +1,13 @@ +namespace MrGameEng.Core; + +/// +/// Shared flag service: true while a UI overlay (the developer console, a modal dialog) +/// captures input. Producers (e.g. the DevConsole module) set 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. +/// +public sealed class InputCapture +{ + /// True while game-facing input should be suppressed. + public bool Captured { get; set; } +} diff --git a/src/MrGameEng.Core/MrGameEng.Core.csproj b/src/MrGameEng.Core/MrGameEng.Core.csproj index 86c4479..d7ab0d1 100644 --- a/src/MrGameEng.Core/MrGameEng.Core.csproj +++ b/src/MrGameEng.Core/MrGameEng.Core.csproj @@ -9,4 +9,8 @@ + + + + diff --git a/src/MrGameEng.Core/Scene.cs b/src/MrGameEng.Core/Scene.cs index cf594c2..9c615ea 100644 --- a/src/MrGameEng.Core/Scene.cs +++ b/src/MrGameEng.Core/Scene.cs @@ -7,6 +7,9 @@ namespace MrGameEng.Core; /// A scene owns its ECS world () and two system roots: /// for game logic and for rendering. /// Override to create entities and register systems. +/// Scene instances are single-use: populates the store and the system +/// roots, and nothing resets them on unload — switch to a new instance instead of +/// reloading an old one (re-loading throws). /// 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 _unloadActions = []; /// Initializes the scene's ECS world and system roots. protected Scene() @@ -40,6 +45,12 @@ public abstract class Scene /// Called once when the scene is replaced or the game exits. Release scene resources here. protected virtual void OnUnload() { } + /// + /// Registers a callback run once when the scene unloads (after ), + /// in reverse registration order. Engine modules use this to release per-scene resources. + /// + public void RegisterUnload(Action action) => _unloadActions.Add(action); + /// Runs the update phase. Called by . 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; } } diff --git a/src/MrGameEng.Core/SceneManager.cs b/src/MrGameEng.Core/SceneManager.cs index 5199910..58ca3a7 100644 --- a/src/MrGameEng.Core/SceneManager.cs +++ b/src/MrGameEng.Core/SceneManager.cs @@ -36,18 +36,24 @@ public sealed class SceneManager /// Requests a switch to . 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. /// 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; + if (_state == State.Idle) + { + _coverage = 0f; + } + + // Из RevealingIn закрытие продолжается с текущего coverage — без скачка. _state = State.CoveringOut; - _coverage = 0f; } } @@ -99,6 +105,13 @@ public sealed class SceneManager _transition!.Draw(_renderer, Math.Clamp(_coverage, 0f, 1f), phase); } + /// Disposes the lazily created transition renderer. Called on host shutdown. + internal void DisposeRenderer() + { + _renderer?.Dispose(); + _renderer = null; + } + internal void ApplyPending() { if (!_hasPending) diff --git a/src/MrGameEng.Core/ServiceRegistry.cs b/src/MrGameEng.Core/ServiceRegistry.cs index 3b345fc..a8ccb6e 100644 --- a/src/MrGameEng.Core/ServiceRegistry.cs +++ b/src/MrGameEng.Core/ServiceRegistry.cs @@ -30,4 +30,23 @@ public sealed class ServiceRegistry { return _services.TryGetValue(typeof(T), out var service) ? (T)service : null; } + + /// + /// Disposes every registered service (each instance once, even + /// when registered under several types) and clears the registry. + /// is skipped. Called on host shutdown. + /// + internal void DisposeServices(object? except = null) + { + var disposed = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var service in _services.Values) + { + if (!ReferenceEquals(service, except) && service is IDisposable disposable && disposed.Add(service)) + { + disposable.Dispose(); + } + } + + _services.Clear(); + } } diff --git a/src/MrGameEng.Core/TransitionRenderer.cs b/src/MrGameEng.Core/TransitionRenderer.cs index d56d15f..8a63a7a 100644 --- a/src/MrGameEng.Core/TransitionRenderer.cs +++ b/src/MrGameEng.Core/TransitionRenderer.cs @@ -7,12 +7,15 @@ namespace MrGameEng.Core; /// Minimal overlay renderer handed to : fills rectangles in /// normalized screen coordinates (0..1 on both axes) over the rendered scene. /// -public sealed class TransitionRenderer +public sealed class TransitionRenderer : IDisposable { private readonly GraphicsDevice _device; private readonly BasicEffect _effect; private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6]; + /// Disposes the GPU effect. Called by on shutdown. + public void Dispose() => _effect.Dispose(); + internal TransitionRenderer(GraphicsDevice device) { _device = device; diff --git a/src/MrGameEng.DevConsole/DevConsole.cs b/src/MrGameEng.DevConsole/DevConsole.cs index 8e8d384..4b3509d 100644 --- a/src/MrGameEng.DevConsole/DevConsole.cs +++ b/src/MrGameEng.DevConsole/DevConsole.cs @@ -14,6 +14,8 @@ public delegate void ConsoleCommand(DevConsole console, string[] args); /// public sealed class DevConsole : IDisposable { + private const int MaxHistory = 256; + private readonly object _sync = new(); private readonly string[] _lines; private readonly Dictionary _commands = @@ -56,8 +58,11 @@ public sealed class DevConsole : IDisposable /// Opens or closes the console overlay. public void Toggle() { - IsOpen = !IsOpen; - Revision++; + lock (_sync) // Revision инкрементируют и фоновые WriteLine — RMW только под локом + { + IsOpen = !IsOpen; + Revision++; + } } /// Registers (or replaces) a command. Name matching is case-insensitive. @@ -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; - _scrollOffset = 0; + 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 } } + /// + /// Splits a command line on whitespace; double quotes group words into one argument + /// (say "hello world"say, hello world). Unterminated quotes + /// run to the end of the line. + /// + internal static string[] SplitArguments(string input) + { + var parts = new List(); + 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}"); diff --git a/src/MrGameEng.DevConsole/DevConsoleSystems.cs b/src/MrGameEng.DevConsole/DevConsoleSystems.cs index 68d58c0..a422ad3 100644 --- a/src/MrGameEng.DevConsole/DevConsoleSystems.cs +++ b/src/MrGameEng.DevConsole/DevConsoleSystems.cs @@ -129,19 +129,22 @@ internal sealed class DevConsoleUi /// /// 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, is held, +/// suppressing game-facing input (the Input module and scene UI honor it). /// 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; } /// @@ -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(); + if (capture is null) + { + capture = new InputCapture(); + services.Add(capture); + } + var ui = services.Get(); - 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; } diff --git a/src/MrGameEng.Graphics/Layers.cs b/src/MrGameEng.Graphics/Layers.cs index 2f9a813..5f3797d 100644 --- a/src/MrGameEng.Graphics/Layers.cs +++ b/src/MrGameEng.Graphics/Layers.cs @@ -23,7 +23,11 @@ public enum LayerSortMode /// Order by the sprite's value (smaller = drawn first). Depth, - /// Order by world Y position (top-down games: lower on screen = drawn in front). + /// + /// Order by the entity's world Y 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. + /// YSort, } @@ -32,31 +36,51 @@ public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, Laye /// /// 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. /// public sealed class LayerRegistry { - private readonly List _layers = []; + private readonly object _sync = new(); + private volatile RenderLayer[] _layers = []; /// Creates a registry containing the built-in "Default" world layer. public LayerRegistry() => Register("Default"); /// Number of registered layers. - public int Count => _layers.Count; + public int Count => _layers.Length; /// Registers a layer drawn after all previously registered ones. public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth) { - if (_layers.Count == 256) + lock (_sync) { - throw new InvalidOperationException("Maximum number of render layers (256) reached."); - } + 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)); - return id; + 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; + } } - /// Returns the layer with the given id. - public RenderLayer this[LayerId id] => _layers[id.Value]; + /// Returns the layer with the given id; throws when the id was never registered. + 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})."); + } + } } diff --git a/src/MrGameEng.Graphics/Renderer2D.cs b/src/MrGameEng.Graphics/Renderer2D.cs index be8076f..37e48bb 100644 --- a/src/MrGameEng.Graphics/Renderer2D.cs +++ b/src/MrGameEng.Graphics/Renderer2D.cs @@ -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 { diff --git a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs index 076fe35..68b31d5 100644 --- a/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs +++ b/src/MrGameEng.Graphics/SceneGraphicsExtensions.cs @@ -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)); diff --git a/src/MrGameEng.Input/InputManager.cs b/src/MrGameEng.Input/InputManager.cs index 5f66720..ca988e5 100644 --- a/src/MrGameEng.Input/InputManager.cs +++ b/src/MrGameEng.Input/InputManager.cs @@ -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 (Pressed = went down this frame, Released = went up). /// Registered as a service by scene.UseInput(); polled by -/// at the start of the update phase. +/// at the start of the update phase. While +/// is set (e.g. the developer console is open), game-facing input reads as released. /// 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; + /// + /// Creates a manager. With a , input is suppressed while a UI + /// overlay holds it (keys/buttons read as released, mouse position and wheel freeze). + /// + public InputManager(MrGameEng.Core.InputCapture? capture = null) => _capture = capture; + /// Polls all devices. Called once per frame by . - public void Update() => Apply( - Keyboard.GetState(), - Mouse.GetState(), - GamePad.GetState(PlayerIndex.One)); + 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) { diff --git a/src/MrGameEng.Input/InputSystem.cs b/src/MrGameEng.Input/InputSystem.cs index b6b99bd..a012502 100644 --- a/src/MrGameEng.Input/InputSystem.cs +++ b/src/MrGameEng.Input/InputSystem.cs @@ -29,7 +29,14 @@ public static class SceneInputExtensions var input = services.GetOrDefault(); if (input is null) { - input = new InputManager(); + var capture = services.GetOrDefault(); + if (capture is null) + { + capture = new InputCapture(); + services.Add(capture); + } + + input = new InputManager(capture); services.Add(input); } diff --git a/src/MrGameEng.Pathfinding/FlowField.cs b/src/MrGameEng.Pathfinding/FlowField.cs index f58fa9f..734e679 100644 --- a/src/MrGameEng.Pathfinding/FlowField.cs +++ b/src/MrGameEng.Pathfinding/FlowField.cs @@ -60,7 +60,10 @@ public sealed class FlowFieldBuilder private int _generation; private int _heapCount; - /// Creates a builder bound to . + /// + /// Creates a builder bound to . Grid dimensions are captured + /// here; if the grid is resized later, throws — create a new builder. + /// public FlowFieldBuilder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight) { _grid = grid; @@ -80,11 +83,25 @@ public sealed class FlowFieldBuilder /// public void Build(ReadOnlySpan 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; diff --git a/src/MrGameEng.Pathfinding/GridPathfinder.cs b/src/MrGameEng.Pathfinding/GridPathfinder.cs index 3485ae7..6edfe81 100644 --- a/src/MrGameEng.Pathfinding/GridPathfinder.cs +++ b/src/MrGameEng.Pathfinding/GridPathfinder.cs @@ -42,7 +42,10 @@ public sealed class GridPathfinder private int _generation; private int _heapCount; - /// Creates a pathfinder bound to . + /// + /// Creates a pathfinder bound to . Grid dimensions are captured + /// here; if the grid is resized later, queries throw — create a new pathfinder instead. + /// public GridPathfinder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight) { _grid = grid; @@ -66,6 +69,13 @@ public sealed class GridPathfinder /// public bool FindPath(Point start, Point goal, List 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 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 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; diff --git a/src/MrGameEng.UI/SceneUiExtensions.cs b/src/MrGameEng.UI/SceneUiExtensions.cs index 0e0a521..8578cac 100644 --- a/src/MrGameEng.UI/SceneUiExtensions.cs +++ b/src/MrGameEng.UI/SceneUiExtensions.cs @@ -9,41 +9,66 @@ namespace MrGameEng.UI; /// /// Draw system rendering the scene's Myra . 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 +/// is set (developer console open), the desktop is drawn +/// without processing input, so overlay clicks and keystrokes do not fall through to it. /// public sealed class UiRenderSystem : BaseSystem { private readonly Desktop _desktop; + private readonly InputCapture? _capture; /// Creates the system for . - public UiRenderSystem(Desktop desktop) => _desktop = desktop; + public UiRenderSystem(Desktop desktop, InputCapture? capture = null) + { + _desktop = desktop; + _capture = capture; + } /// - 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(); + } + } } /// Wires the UI module (Myra) into a . public static class SceneUiExtensions { - private static bool _environmentInitialized; - /// /// Creates a Myra for this scene and registers /// in the draw phase. Call from OnLoad /// after UseRenderer2D() so the UI draws on top of the world. - /// Build the UI by assigning . + /// Build the UI by assigning . The desktop is disposed + /// automatically when the scene unloads. /// public static Desktop UseUI(this Scene scene) { - // Геттер MyraEnvironment.Game бросает исключение, пока Game не задан — проверять через ??= нельзя. - if (!_environmentInitialized) + var services = scene.Context.Services; + + // Присваивание идемпотентно; геттер MyraEnvironment.Game бросает исключение, пока + // Game не задан, поэтому проверять текущее значение перед записью нельзя. + MyraEnvironment.Game = services.Get(); + + var capture = services.GetOrDefault(); + if (capture is null) { - MyraEnvironment.Game = scene.Context.Services.Get(); - _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; } } diff --git a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs index 58cedaa..6a8a71e 100644 --- a/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs +++ b/tests/MrGameEng.Assets.Generator.Tests/AssetHandlesGeneratorTests.cs @@ -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 .png"]); + + Assert.Contains("a&b <c>", source); // XML-док экранирован (иначе CS1570) + Assert.Contains("new(\"UI/a&b .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); + } } diff --git a/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs b/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs index 3582864..6bde350 100644 --- a/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs +++ b/tests/MrGameEng.Atlases.Tests/AtlasBuilderTests.cs @@ -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() { diff --git a/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs b/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs index fb09533..638afc2 100644 --- a/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs +++ b/tests/MrGameEng.Atlases.Tests/ShelfPackerTests.cs @@ -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 { 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 + одна общая + } } diff --git a/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs b/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs index 8657f75..f615c0f 100644 --- a/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs +++ b/tests/MrGameEng.Collisions.Tests/CollisionWorldTests.cs @@ -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 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 tiny = new Entity[1]; + Assert.Equal(1, scene.World.QueryAabb(area, tiny)); // обрезан по размеру буфера + + Span 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); // как и у касающихся кругов + } } diff --git a/tests/MrGameEng.Core.Tests/SceneManagerTests.cs b/tests/MrGameEng.Core.Tests/SceneManagerTests.cs index 0376891..9c8cada 100644 --- a/tests/MrGameEng.Core.Tests/SceneManagerTests.cs +++ b/tests/MrGameEng.Core.Tests/SceneManagerTests.cs @@ -84,4 +84,69 @@ public class SceneManagerTests Assert.Null(context.Scenes.Current); } + + private sealed class CallbackScene(Action 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(() => context.Scenes.Update(context.Clock)); + } + + [Fact] + public void RegisterUnload_RunsOnUnload_InReverseOrder() + { + var context = new EngineContext(); + var order = new List(); + 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(host); // host зарегистрирован, но его освобождает вызывающий + + registry.DisposeServices(except: host); + + Assert.Equal(1, service.DisposeCount); + Assert.Equal(0, host.DisposeCount); + Assert.Null(registry.GetOrDefault()); + } } diff --git a/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs b/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs index bbc5735..5c7b5a2 100644 --- a/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs +++ b/tests/MrGameEng.Core.Tests/SceneTransitionTests.cs @@ -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() { diff --git a/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs b/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs index 977e871..0bfb85a 100644 --- a/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs +++ b/tests/MrGameEng.DevConsole.Tests/DevConsoleTests.cs @@ -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() { diff --git a/tests/MrGameEng.Input.Tests/InputManagerTests.cs b/tests/MrGameEng.Input.Tests/InputManagerTests.cs index d24640f..fc97bfc 100644 --- a/tests/MrGameEng.Input.Tests/InputManagerTests.cs +++ b/tests/MrGameEng.Input.Tests/InputManagerTests.cs @@ -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() { diff --git a/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs b/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs new file mode 100644 index 0000000..84f96b0 --- /dev/null +++ b/tests/MrGameEng.Pathfinding.Tests/GridResizeTests.cs @@ -0,0 +1,44 @@ +using Microsoft.Xna.Framework; +using Xunit; + +namespace MrGameEng.Pathfinding.Tests; + +/// +/// Размеры грида фиксируются в конструкторе; «уехавший» грид должен давать +/// громкую ошибку, а не молчаливое чтение мимо границ. +/// +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( + () => 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( + () => builder.Build([new Point(0, 0)], new FlowField())); + } +} diff --git a/tools/MrGameEng.AtlasTool/Program.cs b/tools/MrGameEng.AtlasTool/Program.cs index 2eb88b3..8d38a09 100644 --- a/tools/MrGameEng.AtlasTool/Program.cs +++ b/tools/MrGameEng.AtlasTool/Program.cs @@ -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");