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:
co-authored by
Claude Fable 5
parent
56f2a85478
commit
501d81e19f
@@ -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("&", "&").Replace("<", "<").Replace(">", ">");
|
||||
|
||||
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
|
||||
internal static string Identifier(string name)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 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,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -9,4 +9,8 @@
|
||||
<PackageReference Include="Friflo.Engine.ECS" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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&b <c>", 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()));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user