Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
501d81e19f |
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.CodeAnalysis;
|
using Microsoft.CodeAnalysis;
|
||||||
|
using Microsoft.CodeAnalysis.CSharp;
|
||||||
using Microsoft.CodeAnalysis.Text;
|
using Microsoft.CodeAnalysis.Text;
|
||||||
|
|
||||||
namespace MrGameEng.Assets.Generator;
|
namespace MrGameEng.Assets.Generator;
|
||||||
@@ -40,8 +41,15 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
|||||||
ClassName: string.IsNullOrEmpty(className) ? "GameAssets" : className!);
|
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
|
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)
|
.Where(static path => path is not null)
|
||||||
.Collect();
|
.Collect();
|
||||||
|
|
||||||
@@ -51,18 +59,31 @@ public sealed class AssetHandlesGenerator : IIncrementalGenerator
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts the path relative to the "Assets" directory (forward slashes), or null when
|
/// 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>
|
/// </summary>
|
||||||
internal static string? ToAssetPath(string fullPath)
|
internal static string? ToAssetPath(string fullPath, string? projectDir = null)
|
||||||
{
|
{
|
||||||
var normalized = fullPath.Replace('\\', '/');
|
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);
|
var marker = normalized.LastIndexOf("/Assets/", StringComparison.OrdinalIgnoreCase);
|
||||||
if (marker < 0)
|
if (marker < 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var relative = normalized.Substring(marker + "/Assets/".Length);
|
relative = normalized.Substring(marker + "/Assets/".Length);
|
||||||
|
}
|
||||||
|
|
||||||
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
|
// Страницы атласов (Name.atlas.0.png) — внутренние файлы метаданных .atlas,
|
||||||
// им собственные Texture2D-хендлы не нужны.
|
// им собственные 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("/// <summary>Typed handles for every file under the Assets directory.</summary>");
|
||||||
source.AppendLine($"public static partial class {className}");
|
source.AppendLine($"public static partial class {className}");
|
||||||
source.AppendLine("{");
|
source.AppendLine("{");
|
||||||
EmitNode(source, root, indent: 1);
|
EmitNode(source, root, indent: 1, enclosingName: className);
|
||||||
source.AppendLine("}");
|
source.AppendLine("}");
|
||||||
return source.ToString();
|
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 pad = new string(' ', indent * 4);
|
||||||
var usedNames = new HashSet<string>();
|
// Имя вмещающего класса занято: член с тем же именем — ошибка CS0542.
|
||||||
|
var usedNames = new HashSet<string> { enclosingName };
|
||||||
|
|
||||||
foreach (var (fileName, relativePath) in node.Files)
|
foreach (var (fileName, relativePath) in node.Files)
|
||||||
{
|
{
|
||||||
var type = TypeByExtension[Path.GetExtension(fileName)];
|
var type = TypeByExtension[Path.GetExtension(fileName)];
|
||||||
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
|
var name = Unique(usedNames, Identifier(Path.GetFileNameWithoutExtension(fileName)));
|
||||||
source.AppendLine($"{pad}/// <summary>{relativePath}</summary>");
|
source.AppendLine($"{pad}/// <summary>{XmlEscape(relativePath)}</summary>");
|
||||||
source.AppendLine(
|
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)
|
foreach (var pair in node.Children)
|
||||||
{
|
{
|
||||||
var name = Unique(usedNames, Identifier(pair.Key));
|
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}public static class {name}");
|
||||||
source.AppendLine($"{pad}{{");
|
source.AppendLine($"{pad}{{");
|
||||||
EmitNode(source, pair.Value, indent + 1);
|
EmitNode(source, pair.Value, indent + 1, enclosingName: name);
|
||||||
source.AppendLine($"{pad}}}");
|
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>
|
/// <summary>Converts an arbitrary file or directory name to a PascalCase C# identifier.</summary>
|
||||||
internal static string Identifier(string name)
|
internal static string Identifier(string name)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ public static class AtlasBuilder
|
|||||||
{
|
{
|
||||||
private static readonly string[] SourceExtensions = [".png", ".jpg", ".jpeg", ".bmp"];
|
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>
|
/// <summary>Builds (or incrementally refreshes) all atlases for <paramref name="options"/>.</summary>
|
||||||
public static AtlasBuildResult Build(AtlasBuildOptions options)
|
public static AtlasBuildResult Build(AtlasBuildOptions options)
|
||||||
{
|
{
|
||||||
@@ -87,10 +90,10 @@ public static class AtlasBuilder
|
|||||||
return (name, key);
|
return (name, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SortedDictionary<string, List<(string FullPath, string Key)>> ScanGroups(
|
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
|
||||||
string sourceRoot, AtlasBuildOptions options)
|
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);
|
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
|
foreach (var fullPath in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
|
||||||
{
|
{
|
||||||
@@ -113,14 +116,15 @@ public static class AtlasBuilder
|
|||||||
groups.Add(atlasName, list);
|
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;
|
return groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AtlasGroupResult BuildGroup(
|
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");
|
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
|
||||||
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
|
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
|
||||||
@@ -151,14 +155,14 @@ public static class AtlasBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
|
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
|
||||||
WriteMetadata(name, packed, options, metadataPath);
|
WriteMetadata(name, packed, files, options, metadataPath);
|
||||||
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
|
DeleteExtraPages(name, packed.PageSizes.Count, options.OutputDirectory);
|
||||||
|
|
||||||
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
|
return new AtlasGroupResult(name, files.Count, packed.PageSizes.Count, Skipped: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsUpToDate(
|
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;
|
pages = 0;
|
||||||
if (!File.Exists(metadataPath))
|
if (!File.Exists(metadataPath))
|
||||||
@@ -176,7 +180,8 @@ public static class AtlasBuilder
|
|||||||
return false;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -187,17 +192,22 @@ public static class AtlasBuilder
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata.Regions.Select(r => r.Key).Order(StringComparer.Ordinal)
|
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
|
||||||
.SequenceEqual(files.Select(f => f.Key).Order(StringComparer.Ordinal)))
|
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
|
||||||
|
if (metadata.Sources.Count != files.Count)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var builtAt = File.GetLastWriteTimeUtc(metadataPath);
|
var sourcesByKey = metadata.Sources.ToDictionary(s => s.Key, StringComparer.Ordinal);
|
||||||
if (files.Any(f => File.GetLastWriteTimeUtc(f.FullPath) > builtAt))
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
if (!sourcesByKey.TryGetValue(file.Key, out var source) ||
|
||||||
|
source.Size != file.Size || source.Modified != file.ModifiedTicks)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pages = metadata.Pages.Count;
|
pages = metadata.Pages.Count;
|
||||||
return true;
|
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
|
var metadata = new AtlasMetadata
|
||||||
{
|
{
|
||||||
Name = name,
|
Name = name,
|
||||||
PageSize = options.MaxPageSize,
|
PageSize = options.MaxPageSize,
|
||||||
Padding = options.Padding,
|
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
|
Pages = packed.PageSizes
|
||||||
.Select((size, index) => new AtlasPage
|
.Select((size, index) => new AtlasPage
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ public sealed class AtlasMetadata
|
|||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Format version, bumped on breaking metadata changes.</summary>
|
/// <summary>Current format version. Bumped on breaking metadata changes.</summary>
|
||||||
public int Version { get; init; } = 1;
|
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>
|
/// <summary>Atlas name (group key with '/' replaced by '.').</summary>
|
||||||
public string Name { get; init; } = "";
|
public string Name { get; init; } = "";
|
||||||
@@ -33,6 +36,9 @@ public sealed class AtlasMetadata
|
|||||||
/// <summary>Packed regions, sorted by key.</summary>
|
/// <summary>Packed regions, sorted by key.</summary>
|
||||||
public List<AtlasRegion> Regions { get; init; } = [];
|
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>
|
/// <summary>Serializes this metadata to indented JSON.</summary>
|
||||||
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
public string ToJson() => JsonSerializer.Serialize(this, JsonOptions);
|
||||||
|
|
||||||
@@ -55,6 +61,23 @@ public sealed class AtlasPage
|
|||||||
public int Height { get; init; }
|
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>
|
/// <summary>One packed source texture inside an atlas.</summary>
|
||||||
public sealed class AtlasRegion
|
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>
|
/// <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="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);
|
public sealed record PackResult(IReadOnlyList<PackPlacement> Placements, IReadOnlyList<(int Width, int Height)> PageSizes);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -65,7 +68,9 @@ public static class ShelfPacker
|
|||||||
{
|
{
|
||||||
if (open)
|
if (open)
|
||||||
{
|
{
|
||||||
pageSizes.Add((NextPowerOfTwo(usedWidth + padding), NextPowerOfTwo(usedHeight + padding)));
|
pageSizes.Add((
|
||||||
|
PageDimension(usedWidth + padding, maxPageSize),
|
||||||
|
PageDimension(usedHeight + padding, maxPageSize)));
|
||||||
open = false;
|
open = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,15 +86,22 @@ public static class ShelfPacker
|
|||||||
usedHeight = 0;
|
usedHeight = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
|
||||||
|
// они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
|
||||||
foreach (var item in sorted)
|
foreach (var item in sorted)
|
||||||
{
|
{
|
||||||
// Слишком большой для общей страницы — отдельная страница точно под него.
|
|
||||||
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
|
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));
|
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)));
|
pageSizes.Add((item.Width + 2 * padding, item.Height + 2 * padding));
|
||||||
continue;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var item in sorted)
|
||||||
|
{
|
||||||
|
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
|
||||||
|
{
|
||||||
|
continue; // уже размещён на отдельной странице
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!open)
|
if (!open)
|
||||||
@@ -119,6 +131,11 @@ public static class ShelfPacker
|
|||||||
return new PackResult(placements, pageSizes);
|
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)
|
internal static int NextPowerOfTwo(int value)
|
||||||
{
|
{
|
||||||
var result = 1;
|
var result = 1;
|
||||||
|
|||||||
@@ -56,13 +56,32 @@ public sealed class TextureAtlas : IDisposable
|
|||||||
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
|
public static TextureAtlas Load(GraphicsDevice graphicsDevice, string metadataPath)
|
||||||
{
|
{
|
||||||
var metadata = AtlasMetadata.FromJson(File.ReadAllText(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 directory = Path.GetDirectoryName(Path.GetFullPath(metadataPath))!;
|
||||||
var pages = new Texture2D[metadata.Pages.Count];
|
var pages = new Texture2D[metadata.Pages.Count];
|
||||||
|
try
|
||||||
|
{
|
||||||
for (var i = 0; i < pages.Length; i++)
|
for (var i = 0; i < pages.Length; i++)
|
||||||
{
|
{
|
||||||
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
|
using var stream = File.OpenRead(Path.Combine(directory, metadata.Pages[i].File));
|
||||||
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
|
pages[i] = Texture2D.FromStream(graphicsDevice, stream, DefaultColorProcessors.PremultiplyAlpha);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
foreach (var page in pages)
|
||||||
|
{
|
||||||
|
page?.Dispose(); // частично загруженные страницы не должны утекать
|
||||||
|
}
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
return new TextureAtlas(metadata, pages);
|
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>
|
/// <summary>Starts streaming <paramref name="track"/>, stopping the previous one.</summary>
|
||||||
public void Play(MusicTrack track, bool loop = true)
|
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();
|
Stop();
|
||||||
_loop = loop;
|
_loop = loop;
|
||||||
_reader = new VorbisReader(track.FullPath);
|
_reader = reader;
|
||||||
|
|
||||||
// ~0.5 seconds of samples per submitted buffer.
|
// ~0.5 seconds of samples per submitted buffer.
|
||||||
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
|
var samplesPerBuffer = _reader.SampleRate * _reader.Channels / 2;
|
||||||
@@ -91,15 +107,24 @@ public sealed class MusicPlayer : IDisposable
|
|||||||
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
|
var read = _reader.ReadSamples(_sampleBuffer, 0, _sampleBuffer.Length);
|
||||||
if (read == 0)
|
if (read == 0)
|
||||||
{
|
{
|
||||||
if (!_loop)
|
// SamplePosition > 0 отличает конец трека от пустого файла: после перемотки
|
||||||
|
// на 0 повторный read == 0 не зацикливается, а завершает воспроизведение.
|
||||||
|
if (_loop && _reader.SamplePosition > 0)
|
||||||
{
|
{
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_reader.SamplePosition = 0;
|
_reader.SamplePosition = 0;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Конец незацикленного трека (или пустой файл): когда буферы доиграли,
|
||||||
|
// останавливаем инстанс — иначе IsPlaying остаётся true навсегда.
|
||||||
|
if (_instance.PendingBufferCount == 0)
|
||||||
|
{
|
||||||
|
_instance.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (var i = 0; i < read; i++)
|
for (var i = 0; i < read; i++)
|
||||||
{
|
{
|
||||||
var sample = (short)(Math.Clamp(_sampleBuffer[i], -1f, 1f) * short.MaxValue);
|
var sample = (short)(Math.Clamp(_sampleBuffer[i], -1f, 1f) * short.MaxValue);
|
||||||
|
|||||||
@@ -54,9 +54,14 @@ public sealed class CollisionWorld
|
|||||||
}
|
}
|
||||||
|
|
||||||
_cellSize = cellSize;
|
_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);
|
public ReadOnlySpan<CollisionPair> Pairs => _pairs.AsSpan(0, _pairCount);
|
||||||
|
|
||||||
/// <summary>Colliders registered in the last rebuild.</summary>
|
/// <summary>Colliders registered in the last rebuild.</summary>
|
||||||
@@ -134,6 +139,7 @@ public sealed class CollisionWorld
|
|||||||
{
|
{
|
||||||
if (found == results.Length)
|
if (found == results.Length)
|
||||||
{
|
{
|
||||||
|
ResetQueryStamps(); // иначе следующий запрос пропустит помеченные записи
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +156,8 @@ public sealed class CollisionWorld
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Casts a segment and returns the closest hit among colliders whose
|
/// Casts a segment and returns the closest hit among colliders whose
|
||||||
/// <see cref="Collider.Layer"/> intersects <paramref name="mask"/>.
|
/// <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>
|
/// </summary>
|
||||||
public bool Raycast(Vector2 from, Vector2 to, out RaycastHit hit, uint mask = uint.MaxValue)
|
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)
|
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
|
// circle vs box
|
||||||
@@ -305,6 +315,11 @@ public sealed class CollisionWorld
|
|||||||
|
|
||||||
var b = 2f * Vector2.Dot(f, d);
|
var b = 2f * Vector2.Dot(f, d);
|
||||||
var c = Vector2.Dot(f, f) - radius * radius;
|
var c = Vector2.Dot(f, f) - radius * radius;
|
||||||
|
if (c <= 0f)
|
||||||
|
{
|
||||||
|
return true; // старт внутри круга — попадание в точке старта (как и у AABB)
|
||||||
|
}
|
||||||
|
|
||||||
var discriminant = b * b - 4f * a * c;
|
var discriminant = b * b - 4f * a * c;
|
||||||
if (discriminant < 0f)
|
if (discriminant < 0f)
|
||||||
{
|
{
|
||||||
@@ -313,11 +328,6 @@ public sealed class CollisionWorld
|
|||||||
|
|
||||||
var sqrt = MathF.Sqrt(discriminant);
|
var sqrt = MathF.Sqrt(discriminant);
|
||||||
var t = (-b - sqrt) / (2f * a);
|
var t = (-b - sqrt) / (2f * a);
|
||||||
if (t < 0f)
|
|
||||||
{
|
|
||||||
t = (-b + sqrt) / (2f * a); // старт внутри круга
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t < 0f || t > 1f)
|
if (t < 0f || t > 1f)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -36,4 +36,16 @@ public sealed class EngineContext
|
|||||||
}
|
}
|
||||||
|
|
||||||
internal void AttachGraphicsDevice(GraphicsDevice device) => _graphicsDevice = device;
|
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,
|
PreferredBackBufferWidth = options.Width,
|
||||||
PreferredBackBufferHeight = options.Height,
|
PreferredBackBufferHeight = options.Height,
|
||||||
IsFullScreen = options.Fullscreen,
|
IsFullScreen = options.Fullscreen,
|
||||||
|
// Borderless, как и обещает GameHostOptions.Fullscreen; по умолчанию MonoGame
|
||||||
|
// делает эксклюзивное переключение видеорежима монитора.
|
||||||
|
HardwareModeSwitch = false,
|
||||||
SynchronizeWithVerticalRetrace = options.VSync,
|
SynchronizeWithVerticalRetrace = options.VSync,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,4 +78,15 @@ public class GameHost : Game
|
|||||||
Context.Scenes.ApplyPending();
|
Context.Scenes.ApplyPending();
|
||||||
base.OnExiting(sender, args);
|
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" />
|
<PackageReference Include="Friflo.Engine.ECS" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="MrGameEng.Core.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ namespace MrGameEng.Core;
|
|||||||
/// A scene owns its ECS world (<see cref="EntityStore"/>) and two system roots:
|
/// 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.
|
/// <see cref="UpdateSystems"/> for game logic and <see cref="DrawSystems"/> for rendering.
|
||||||
/// Override <see cref="OnLoad"/> to create entities and register systems.
|
/// 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>
|
/// </summary>
|
||||||
public abstract class Scene
|
public abstract class Scene
|
||||||
{
|
{
|
||||||
@@ -26,6 +29,8 @@ public abstract class Scene
|
|||||||
public bool IsLoaded => _context is not null;
|
public bool IsLoaded => _context is not null;
|
||||||
|
|
||||||
private EngineContext? _context;
|
private EngineContext? _context;
|
||||||
|
private bool _loadedOnce;
|
||||||
|
private readonly List<Action> _unloadActions = [];
|
||||||
|
|
||||||
/// <summary>Initializes the scene's ECS world and system roots.</summary>
|
/// <summary>Initializes the scene's ECS world and system roots.</summary>
|
||||||
protected Scene()
|
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>
|
/// <summary>Called once when the scene is replaced or the game exits. Release scene resources here.</summary>
|
||||||
protected virtual void OnUnload() { }
|
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>
|
/// <summary>Runs the update phase. Called by <see cref="SceneManager"/>.</summary>
|
||||||
public virtual void Update(GameClock clock) =>
|
public virtual void Update(GameClock clock) =>
|
||||||
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
|
UpdateSystems.Update(new UpdateTick(clock.DeltaTime, (float)clock.TotalTime));
|
||||||
@@ -50,6 +61,16 @@ public abstract class Scene
|
|||||||
|
|
||||||
internal void Load(EngineContext context)
|
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;
|
_context = context;
|
||||||
OnLoad();
|
OnLoad();
|
||||||
}
|
}
|
||||||
@@ -57,6 +78,12 @@ public abstract class Scene
|
|||||||
internal void Unload()
|
internal void Unload()
|
||||||
{
|
{
|
||||||
OnUnload();
|
OnUnload();
|
||||||
|
for (var i = _unloadActions.Count - 1; i >= 0; i--)
|
||||||
|
{
|
||||||
|
_unloadActions[i]();
|
||||||
|
}
|
||||||
|
|
||||||
|
_unloadActions.Clear();
|
||||||
_context = null;
|
_context = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,19 +36,25 @@ public sealed class SceneManager
|
|||||||
/// Requests a switch to <paramref name="scene"/>. Without a transition the swap happens at
|
/// 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.
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public void Switch(Scene? scene, Transition? transition = null)
|
public void Switch(Scene? scene, Transition? transition = null)
|
||||||
{
|
{
|
||||||
_pending = scene;
|
_pending = scene;
|
||||||
_hasPending = true;
|
_hasPending = true;
|
||||||
|
|
||||||
if (_state == State.Idle && transition is not null)
|
if (transition is not null && _state != State.CoveringOut)
|
||||||
{
|
{
|
||||||
_transition = transition;
|
_transition = transition;
|
||||||
_state = State.CoveringOut;
|
if (_state == State.Idle)
|
||||||
|
{
|
||||||
_coverage = 0f;
|
_coverage = 0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Из RevealingIn закрытие продолжается с текущего coverage — без скачка.
|
||||||
|
_state = State.CoveringOut;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Advances a transition and updates the active scene. Called by the host.</summary>
|
/// <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);
|
_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()
|
internal void ApplyPending()
|
||||||
{
|
{
|
||||||
if (!_hasPending)
|
if (!_hasPending)
|
||||||
|
|||||||
@@ -30,4 +30,23 @@ public sealed class ServiceRegistry
|
|||||||
{
|
{
|
||||||
return _services.TryGetValue(typeof(T), out var service) ? (T)service : null;
|
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
|
/// Minimal overlay renderer handed to <see cref="Transition.Draw"/>: fills rectangles in
|
||||||
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
|
/// normalized screen coordinates (0..1 on both axes) over the rendered scene.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class TransitionRenderer
|
public sealed class TransitionRenderer : IDisposable
|
||||||
{
|
{
|
||||||
private readonly GraphicsDevice _device;
|
private readonly GraphicsDevice _device;
|
||||||
private readonly BasicEffect _effect;
|
private readonly BasicEffect _effect;
|
||||||
private readonly VertexPositionColor[] _vertices = new VertexPositionColor[6];
|
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)
|
internal TransitionRenderer(GraphicsDevice device)
|
||||||
{
|
{
|
||||||
_device = device;
|
_device = device;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ public delegate void ConsoleCommand(DevConsole console, string[] args);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class DevConsole : IDisposable
|
public sealed class DevConsole : IDisposable
|
||||||
{
|
{
|
||||||
|
private const int MaxHistory = 256;
|
||||||
|
|
||||||
private readonly object _sync = new();
|
private readonly object _sync = new();
|
||||||
private readonly string[] _lines;
|
private readonly string[] _lines;
|
||||||
private readonly Dictionary<string, (string Description, ConsoleCommand Handler)> _commands =
|
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>
|
/// <summary>Opens or closes the console overlay.</summary>
|
||||||
public void Toggle()
|
public void Toggle()
|
||||||
|
{
|
||||||
|
lock (_sync) // Revision инкрементируют и фоновые WriteLine — RMW только под локом
|
||||||
{
|
{
|
||||||
IsOpen = !IsOpen;
|
IsOpen = !IsOpen;
|
||||||
Revision++;
|
Revision++;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Registers (or replaces) a command. Name matching is case-insensitive.</summary>
|
/// <summary>Registers (or replaces) a command. Name matching is case-insensitive.</summary>
|
||||||
public void Register(string name, string description, ConsoleCommand handler) =>
|
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)
|
if (_history.Count == 0 || _history[^1] != input)
|
||||||
{
|
{
|
||||||
_history.Add(input);
|
_history.Add(input);
|
||||||
|
if (_history.Count > MaxHistory)
|
||||||
|
{
|
||||||
|
_history.RemoveAt(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_historyCursor = _history.Count;
|
_historyCursor = _history.Count;
|
||||||
|
lock (_sync)
|
||||||
|
{
|
||||||
_scrollOffset = 0;
|
_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))
|
if (!_commands.TryGetValue(parts[0], out var command))
|
||||||
{
|
{
|
||||||
WriteLine($"unknown command '{parts[0]}' — try 'help'");
|
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) =>
|
private void OnLogMessage(LogLevel level, string message) =>
|
||||||
WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}");
|
WriteLine(level == LogLevel.Info ? message : $"[{LevelTag(level)}] {message}");
|
||||||
|
|
||||||
|
|||||||
@@ -129,19 +129,22 @@ internal sealed class DevConsoleUi
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Update-phase system: toggles the console with the backquote (`) key and scrolls the log
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public sealed class DevConsoleSystem : BaseSystem
|
public sealed class DevConsoleSystem : BaseSystem
|
||||||
{
|
{
|
||||||
private readonly DevConsole _console;
|
private readonly DevConsole _console;
|
||||||
private readonly DevConsoleUi _ui;
|
private readonly DevConsoleUi _ui;
|
||||||
|
private readonly InputCapture _capture;
|
||||||
private KeyboardState _previousKeyboard;
|
private KeyboardState _previousKeyboard;
|
||||||
private int _previousWheel;
|
private int _previousWheel;
|
||||||
|
|
||||||
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui)
|
internal DevConsoleSystem(DevConsole console, DevConsoleUi ui, InputCapture capture)
|
||||||
{
|
{
|
||||||
_console = console;
|
_console = console;
|
||||||
_ui = ui;
|
_ui = ui;
|
||||||
|
_capture = capture;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -158,6 +161,7 @@ public sealed class DevConsoleSystem : BaseSystem
|
|||||||
}
|
}
|
||||||
|
|
||||||
_previousKeyboard = keyboard;
|
_previousKeyboard = keyboard;
|
||||||
|
_capture.Captured = _console.IsOpen;
|
||||||
|
|
||||||
var wheel = Mouse.GetState().ScrollWheelValue;
|
var wheel = Mouse.GetState().ScrollWheelValue;
|
||||||
if (_console.IsOpen && wheel != _previousWheel)
|
if (_console.IsOpen && wheel != _previousWheel)
|
||||||
@@ -203,8 +207,15 @@ public static class SceneDevConsoleExtensions
|
|||||||
Log.Info("Developer console ready — press ` to toggle, 'help' for commands");
|
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>();
|
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));
|
scene.DrawSystems.Add(new DevConsoleRenderSystem(ui));
|
||||||
return console;
|
return console;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ public enum LayerSortMode
|
|||||||
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
|
/// <summary>Order by the sprite's <see cref="Sprite.Depth"/> value (smaller = drawn first).</summary>
|
||||||
Depth,
|
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,
|
YSort,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,31 +36,51 @@ public sealed record RenderLayer(LayerId Id, string Name, LayerSpace Space, Laye
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Registry of render layers. Layers are registered up front (typically when the renderer is
|
/// 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>
|
/// </summary>
|
||||||
public sealed class LayerRegistry
|
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>
|
/// <summary>Creates a registry containing the built-in "Default" world layer.</summary>
|
||||||
public LayerRegistry() => Register("Default");
|
public LayerRegistry() => Register("Default");
|
||||||
|
|
||||||
/// <summary>Number of registered layers.</summary>
|
/// <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>
|
/// <summary>Registers a layer drawn after all previously registered ones.</summary>
|
||||||
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
|
public LayerId Register(string name, LayerSpace space = LayerSpace.World, LayerSortMode sortMode = LayerSortMode.Depth)
|
||||||
{
|
{
|
||||||
if (_layers.Count == 256)
|
lock (_sync)
|
||||||
|
{
|
||||||
|
var layers = _layers;
|
||||||
|
if (layers.Length == 256)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
|
throw new InvalidOperationException("Maximum number of render layers (256) reached.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var id = new LayerId((byte)_layers.Count);
|
var id = new LayerId((byte)layers.Length);
|
||||||
_layers.Add(new RenderLayer(id, name, space, sortMode));
|
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;
|
return id;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Returns the layer with the given id.</summary>
|
/// <summary>Returns the layer with the given id; throws when the id was never registered.</summary>
|
||||||
public RenderLayer this[LayerId id] => _layers[id.Value];
|
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;
|
_device = device;
|
||||||
_options = options ?? new Renderer2DOptions();
|
_options = options ?? new Renderer2DOptions();
|
||||||
|
ArgumentOutOfRangeException.ThrowIfLessThan(_options.InitialCapacity, 1, nameof(options));
|
||||||
_batcher = new SpriteBatcher(_options.InitialCapacity);
|
_batcher = new SpriteBatcher(_options.InitialCapacity);
|
||||||
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
|
_vertices = new VertexPositionColorTexture[_options.InitialCapacity * 4];
|
||||||
_vertexBuffer = new DynamicVertexBuffer(
|
_vertexBuffer = new DynamicVertexBuffer(
|
||||||
@@ -201,7 +202,26 @@ public sealed class Renderer2D : IDisposable
|
|||||||
_device.SetVertexBuffer(_vertexBuffer);
|
_device.SetVertexBuffer(_vertexBuffer);
|
||||||
_device.Indices = _indexBuffer;
|
_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);
|
DrawBatches(order, count);
|
||||||
|
if (letterboxed)
|
||||||
|
{
|
||||||
|
_device.Viewport = previousViewport;
|
||||||
|
}
|
||||||
|
|
||||||
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
|
DrawMs = ToMs(Stopwatch.GetTimestamp() - uploadEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +296,9 @@ public sealed class Renderer2D : IDisposable
|
|||||||
return SubmitResult.Culled;
|
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
|
instance = new SpriteInstance
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ public static class SceneGraphicsExtensions
|
|||||||
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
|
renderer = new Renderer2D(scene.Context.GraphicsDevice, options);
|
||||||
services.Add(renderer);
|
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 CameraSystem(renderer));
|
||||||
scene.DrawSystems.Add(new SpriteRenderSystem(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,
|
/// 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).
|
/// 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"/>
|
/// 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>
|
/// </summary>
|
||||||
public sealed class InputManager
|
public sealed class InputManager
|
||||||
{
|
{
|
||||||
|
private readonly MrGameEng.Core.InputCapture? _capture;
|
||||||
|
|
||||||
private KeyboardState _keyboard;
|
private KeyboardState _keyboard;
|
||||||
private KeyboardState _previousKeyboard;
|
private KeyboardState _previousKeyboard;
|
||||||
private MouseState _mouse;
|
private MouseState _mouse;
|
||||||
@@ -31,11 +34,34 @@ public sealed class InputManager
|
|||||||
private GamePadState _gamePad;
|
private GamePadState _gamePad;
|
||||||
private GamePadState _previousGamePad;
|
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>
|
/// <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(),
|
Keyboard.GetState(),
|
||||||
Mouse.GetState(),
|
Mouse.GetState(),
|
||||||
GamePad.GetState(PlayerIndex.One));
|
GamePad.GetState(PlayerIndex.One));
|
||||||
|
}
|
||||||
|
|
||||||
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
|
internal void Apply(KeyboardState keyboard, MouseState mouse, GamePadState gamePad)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ public static class SceneInputExtensions
|
|||||||
var input = services.GetOrDefault<InputManager>();
|
var input = services.GetOrDefault<InputManager>();
|
||||||
if (input is null)
|
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);
|
services.Add(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ public sealed class FlowFieldBuilder
|
|||||||
private int _generation;
|
private int _generation;
|
||||||
private int _heapCount;
|
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)
|
public FlowFieldBuilder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight)
|
||||||
{
|
{
|
||||||
_grid = grid;
|
_grid = grid;
|
||||||
@@ -80,11 +83,25 @@ public sealed class FlowFieldBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public void Build(ReadOnlySpan<Point> goals, FlowField field)
|
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);
|
field.EnsureSize(_width, _height);
|
||||||
var distances = field.Distances;
|
var distances = field.Distances;
|
||||||
var directions = field.Directions;
|
var directions = field.Directions;
|
||||||
Array.Fill(distances, float.PositiveInfinity, 0, _width * _height);
|
Array.Fill(distances, float.PositiveInfinity, 0, _width * _height);
|
||||||
|
|
||||||
|
// Переполнение штампа: см. GridPathfinder.NextGeneration.
|
||||||
|
if (_generation == int.MaxValue)
|
||||||
|
{
|
||||||
|
Array.Clear(_closedStamp);
|
||||||
|
_generation = 0;
|
||||||
|
}
|
||||||
|
|
||||||
_generation++;
|
_generation++;
|
||||||
_heapCount = 0;
|
_heapCount = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ public sealed class GridPathfinder
|
|||||||
private int _generation;
|
private int _generation;
|
||||||
private int _heapCount;
|
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)
|
public GridPathfinder(IPathGrid grid, GridConnectivity connectivity = GridConnectivity.Eight)
|
||||||
{
|
{
|
||||||
_grid = grid;
|
_grid = grid;
|
||||||
@@ -66,6 +69,13 @@ public sealed class GridPathfinder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FindPath(Point start, Point goal, List<Point> path, PathAlgorithm algorithm = PathAlgorithm.AStar)
|
public bool FindPath(Point start, Point goal, List<Point> path, PathAlgorithm algorithm = PathAlgorithm.AStar)
|
||||||
{
|
{
|
||||||
|
if (_grid.Width != _width || _grid.Height != _height)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"IPathGrid size changed ({_width}x{_height} -> {_grid.Width}x{_grid.Height}); " +
|
||||||
|
"create a new GridPathfinder for the resized grid.");
|
||||||
|
}
|
||||||
|
|
||||||
path.Clear();
|
path.Clear();
|
||||||
if (!InBounds(start) || !InBounds(goal) ||
|
if (!InBounds(start) || !InBounds(goal) ||
|
||||||
!_grid.IsPassable(start.X, start.Y) || !_grid.IsPassable(goal.X, goal.Y))
|
!_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)
|
private bool WeightedSearch(Point start, Point goal, List<Point> path, bool useHeuristic)
|
||||||
{
|
{
|
||||||
_generation++;
|
NextGeneration();
|
||||||
_heapCount = 0;
|
_heapCount = 0;
|
||||||
|
|
||||||
var startIndex = Index(start.X, start.Y);
|
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)
|
private bool BreadthFirst(Point start, Point goal, List<Point> path)
|
||||||
{
|
{
|
||||||
_generation++;
|
NextGeneration();
|
||||||
var head = 0;
|
var head = 0;
|
||||||
var tail = 0;
|
var tail = 0;
|
||||||
|
|
||||||
@@ -234,6 +244,20 @@ public sealed class GridPathfinder
|
|||||||
path.Reverse();
|
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 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;
|
private int Index(int x, int y) => y * _width + x;
|
||||||
|
|||||||
@@ -9,41 +9,66 @@ namespace MrGameEng.UI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Draw system rendering the scene's Myra <see cref="Desktop"/>. Must run after the scene's
|
/// 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
|
/// 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>
|
/// </summary>
|
||||||
public sealed class UiRenderSystem : BaseSystem
|
public sealed class UiRenderSystem : BaseSystem
|
||||||
{
|
{
|
||||||
private readonly Desktop _desktop;
|
private readonly Desktop _desktop;
|
||||||
|
private readonly InputCapture? _capture;
|
||||||
|
|
||||||
/// <summary>Creates the system for <paramref name="desktop"/>.</summary>
|
/// <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 />
|
/// <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>
|
/// <summary>Wires the UI module (Myra) into a <see cref="Scene"/>.</summary>
|
||||||
public static class SceneUiExtensions
|
public static class SceneUiExtensions
|
||||||
{
|
{
|
||||||
private static bool _environmentInitialized;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a Myra <see cref="Desktop"/> for this scene and registers
|
/// Creates a Myra <see cref="Desktop"/> for this scene and registers
|
||||||
/// <see cref="UiRenderSystem"/> in the draw phase. Call from <c>OnLoad</c>
|
/// <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.
|
/// <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>
|
/// </summary>
|
||||||
public static Desktop UseUI(this Scene scene)
|
public static Desktop UseUI(this Scene scene)
|
||||||
{
|
{
|
||||||
// Геттер MyraEnvironment.Game бросает исключение, пока Game не задан — проверять через ??= нельзя.
|
var services = scene.Context.Services;
|
||||||
if (!_environmentInitialized)
|
|
||||||
|
// Присваивание идемпотентно; геттер MyraEnvironment.Game бросает исключение, пока
|
||||||
|
// Game не задан, поэтому проверять текущее значение перед записью нельзя.
|
||||||
|
MyraEnvironment.Game = services.Get<Game>();
|
||||||
|
|
||||||
|
var capture = services.GetOrDefault<InputCapture>();
|
||||||
|
if (capture is null)
|
||||||
{
|
{
|
||||||
MyraEnvironment.Game = scene.Context.Services.Get<Game>();
|
capture = new InputCapture();
|
||||||
_environmentInitialized = true;
|
services.Add(capture);
|
||||||
}
|
}
|
||||||
|
|
||||||
var desktop = new Desktop();
|
var desktop = new Desktop();
|
||||||
scene.DrawSystems.Add(new UiRenderSystem(desktop));
|
scene.RegisterUnload(desktop.Dispose);
|
||||||
|
scene.DrawSystems.Add(new UiRenderSystem(desktop, capture));
|
||||||
return desktop;
|
return desktop;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,4 +156,33 @@ public class AssetHandlesGeneratorTests
|
|||||||
{
|
{
|
||||||
Assert.Equal(expected, AssetHandlesGenerator.ToAssetPath(fullPath));
|
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);
|
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]
|
[Fact]
|
||||||
public void Metadata_JsonRoundtrip_PreservesEverything()
|
public void Metadata_JsonRoundtrip_PreservesEverything()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -108,4 +108,41 @@ public class ShelfPackerTests
|
|||||||
{
|
{
|
||||||
Assert.Equal(expected, ShelfPacker.NextPowerOfTwo(value));
|
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 _));
|
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);
|
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);
|
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]
|
[Fact]
|
||||||
public void ZeroDurationTransition_SwapsOnNextUpdates()
|
public void ZeroDurationTransition_SwapsOnNextUpdates()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -131,6 +131,49 @@ public class DevConsoleTests
|
|||||||
Assert.Contains("[warn] careful", visible);
|
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]
|
[Fact]
|
||||||
public void Revision_ChangesOnlyOnVisibleChanges()
|
public void Revision_ChangesOnlyOnVisibleChanges()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,6 +40,28 @@ public class InputManagerTests
|
|||||||
Assert.False(input.IsKeyReleased(Keys.A));
|
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]
|
[Fact]
|
||||||
public void MouseDeltaAndWheelDelta_ComputedBetweenFrames()
|
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)
|
int Option(string name, int fallback)
|
||||||
{
|
{
|
||||||
var index = Array.IndexOf(args, name);
|
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");
|
var rootNameIndex = Array.IndexOf(args, "--root-name");
|
||||||
|
|||||||
Reference in New Issue
Block a user