Remove obsolete project files for MrGameEng.AI, MrGameEng.Assets, MrGameEng.Atlases, and MrGameEng.Collisions modules. Introduce new AssetManager and related classes for asset loading and management, including support for texture atlases and mod definitions. Enhance mod loading capabilities with DefDatabase and LanguageManager for JSON-based definitions and localization. Implement a shelf packing algorithm for efficient texture atlas creation.

This commit is contained in:
Leonid Pershin
2026-06-12 07:47:04 +03:00
parent 1f87fb0b74
commit c30e2ce764
70 changed files with 0 additions and 233 deletions
@@ -0,0 +1,142 @@
using FontStashSharp;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Core;
namespace MrGameEng.Assets;
/// <summary>
/// Loads raw asset files at runtime (no content pipeline) by typed <see cref="AssetRef{T}"/>
/// handles, caches them by path and owns their lifetime. Built-in loaders:
/// <c>Texture2D</c> (png/jpg, premultiplied), <c>SoundEffect</c> (wav),
/// <c>FontSystem</c> (ttf via FontStashSharp), <c>Effect</c> (precompiled .mgfx),
/// <c>MusicTrack</c> (ogg, streamed by the audio module). Register custom loaders
/// with <see cref="RegisterLoader{T}"/>.
/// </summary>
public sealed class AssetManager : IDisposable
{
/// <summary>Absolute path of the asset root directory.</summary>
public string RootPath { get; }
private readonly EngineContext _context;
private readonly Dictionary<(Type Type, string Path), object> _cache = new();
private readonly Dictionary<Type, Func<AssetManager, string, object>> _loaders = new();
/// <summary>
/// Creates a manager reading from <paramref name="rootPath"/> (relative paths are resolved
/// against the executable directory; default "Assets").
/// </summary>
public AssetManager(EngineContext context, string rootPath = "Assets")
{
_context = context;
RootPath = Path.GetFullPath(rootPath, AppContext.BaseDirectory);
RegisterLoader((manager, path) => LoadTexture(manager._context, path));
RegisterLoader((_, path) => LoadSoundEffect(path));
RegisterLoader((_, path) => LoadFontSystem(path));
RegisterLoader((manager, path) => LoadEffect(manager._context, path));
RegisterLoader((_, path) => new MusicTrack(path));
}
/// <summary>Loads (or returns the cached) asset for <paramref name="asset"/>.</summary>
public T Load<T>(AssetRef<T> asset)
where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.TryGetValue(key, out var cached))
{
return (T)cached;
}
if (!_loaders.TryGetValue(typeof(T), out var loader))
{
throw new InvalidOperationException(
$"No asset loader registered for type {typeof(T)}."
);
}
var fullPath = ResolvePath(asset.Path);
if (!File.Exists(fullPath))
{
throw new FileNotFoundException(
$"Asset '{asset.Path}' not found at '{fullPath}'.",
fullPath
);
}
var loaded = (T)loader(this, fullPath);
_cache.Add(key, loaded);
return loaded;
}
/// <summary>Removes one asset from the cache, disposing it if disposable.</summary>
public void Unload<T>(AssetRef<T> asset)
where T : class
{
var key = (typeof(T), asset.Path);
if (_cache.Remove(key, out var value) && value is IDisposable disposable)
{
disposable.Dispose();
}
}
/// <summary>Replaces or adds the loader used for assets of type <typeparamref name="T"/>.</summary>
public void RegisterLoader<T>(Func<AssetManager, string, T> loader)
where T : class => _loaders[typeof(T)] = loader;
/// <summary>Resolves an asset-relative path to an absolute file path.</summary>
public string ResolvePath(string relativePath) =>
Path.GetFullPath(Path.Combine(RootPath, relativePath));
/// <summary>Disposes every cached asset and clears the cache.</summary>
public void Dispose()
{
foreach (var value in _cache.Values)
{
(value as IDisposable)?.Dispose();
}
_cache.Clear();
}
private static Texture2D LoadTexture(EngineContext context, string path)
{
using var stream = File.OpenRead(path);
return Texture2D.FromStream(
context.GraphicsDevice,
stream,
DefaultColorProcessors.PremultiplyAlpha
);
}
private static SoundEffect LoadSoundEffect(string path)
{
using var stream = File.OpenRead(path);
return SoundEffect.FromStream(stream);
}
private static FontSystem LoadFontSystem(string path)
{
var fontSystem = new FontSystem();
fontSystem.AddFont(File.ReadAllBytes(path));
return fontSystem;
}
private static Effect LoadEffect(EngineContext context, string path) =>
new(context.GraphicsDevice, File.ReadAllBytes(path));
}
/// <summary>Wires the assets module into the engine.</summary>
public static class AssetsEngineExtensions
{
/// <summary>
/// Creates the <see cref="AssetManager"/> and registers it as a service.
/// Call once at startup (e.g. in the first scene's <c>OnLoad</c>).
/// </summary>
public static AssetManager UseAssets(this EngineContext context, string rootPath = "Assets")
{
var manager = new AssetManager(context, rootPath);
context.Services.Add(manager);
return manager;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace MrGameEng.Assets;
/// <summary>
/// Typed handle to an asset: a path relative to the asset root plus the asset's runtime type.
/// Instances are produced by the <c>MrGameEng.Assets.Generator</c> source generator —
/// game code should never construct them from string literals.
/// </summary>
/// <typeparam name="T">Runtime type the asset loads into (e.g. <c>Texture2D</c>).</typeparam>
/// <param name="Path">Path relative to the asset root, with forward slashes.</param>
public readonly record struct AssetRef<T>(string Path)
where T : class
{
/// <inheritdoc />
public override string ToString() => $"{typeof(T).Name}:{Path}";
}
@@ -0,0 +1,425 @@
using StbImageSharp;
using StbImageWriteSharp;
namespace MrGameEng.Atlases;
/// <summary>Options for one <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
public sealed class AtlasBuildOptions
{
/// <summary>
/// Directory scanned recursively for source images (png/jpg/jpeg/bmp). Used only by the
/// directory-scanning overload; the explicit-sources overload ignores it.
/// </summary>
public string SourceDirectory { get; init; } = "";
/// <summary>Directory the <c>.atlas</c> metadata and page images are written to.</summary>
public required string OutputDirectory { get; init; }
/// <summary>
/// How many leading directories of a source-relative path form the atlas group:
/// 0 packs everything into one atlas, 1 packs per top-level folder, and so on.
/// </summary>
public int GroupDepth { get; init; } = 1;
/// <summary>Maximum page width/height in pixels.</summary>
public int MaxPageSize { get; init; } = 2048;
/// <summary>Gap in pixels between packed images and page edges (bleed protection).</summary>
public int Padding { get; init; } = 2;
/// <summary>Atlas name for images that have fewer directories than <see cref="GroupDepth"/>.</summary>
public string RootAtlasName { get; init; } = "Atlas";
/// <summary>Rebuild every atlas even when sources are unchanged.</summary>
public bool Force { get; init; }
}
/// <summary>Build outcome for one atlas group.</summary>
/// <param name="Name">Atlas name (group key with '/' replaced by '.').</param>
/// <param name="RegionCount">Number of packed source images.</param>
/// <param name="PageCount">Number of page images written.</param>
/// <param name="Skipped">True when the atlas was up to date and not rebuilt.</param>
public sealed record AtlasGroupResult(string Name, int RegionCount, int PageCount, bool Skipped);
/// <summary>Result of an <see cref="AtlasBuilder.Build(AtlasBuildOptions)"/> run.</summary>
/// <param name="Groups">Per-atlas outcomes, sorted by name.</param>
/// <param name="DeletedOrphans">Output files of atlases whose source group no longer exists.</param>
public sealed record AtlasBuildResult(
IReadOnlyList<AtlasGroupResult> Groups,
IReadOnlyList<string> DeletedOrphans
);
/// <summary>
/// Build-time utility converting a directory tree of loose images into texture atlases:
/// page images plus an <see cref="AtlasMetadata"/> JSON per group. Pure CPU (StbImageSharp),
/// no graphics device — intended for tools and build scripts, not for the render loop.
/// Region keys are source-relative paths without extension, so game code addresses sprites
/// by the same path it would have used for the loose file.
/// </summary>
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 from <see cref="AtlasBuildOptions.SourceDirectory"/>.</summary>
public static AtlasBuildResult Build(AtlasBuildOptions options)
{
if (string.IsNullOrEmpty(options.SourceDirectory))
{
throw new ArgumentException("SourceDirectory is not set.", nameof(options));
}
var sourceRoot = Path.GetFullPath(options.SourceDirectory);
if (!Directory.Exists(sourceRoot))
{
throw new DirectoryNotFoundException(
$"Atlas source directory not found: '{sourceRoot}'."
);
}
return Build(
options,
Directory
.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories)
.Select(fullPath => (fullPath, Path.GetRelativePath(sourceRoot, fullPath)))
);
}
/// <summary>
/// Builds (or incrementally refreshes) all atlases from an explicit source list —
/// e.g. a texture tree merged across mods, where files with one relative path may live
/// in different roots. Region keys come from <c>RelativePath</c> without extension.
/// </summary>
public static AtlasBuildResult Build(
AtlasBuildOptions options,
IEnumerable<(string FullPath, string RelativePath)> sources
)
{
Directory.CreateDirectory(options.OutputDirectory);
var groups = ScanGroups(sources, options);
var results = new List<AtlasGroupResult>();
foreach (var (name, files) in groups)
{
results.Add(BuildGroup(name, files, options));
}
var orphans = DeleteOrphans(options.OutputDirectory, groups.Keys);
return new AtlasBuildResult(results, orphans);
}
/// <summary>Maps a source-relative image path to its atlas name and region key.</summary>
internal static (string AtlasName, string Key) ClassifyPath(
string relativePath,
int groupDepth,
string rootAtlasName
)
{
var normalized = relativePath.Replace('\\', '/');
var key = normalized[..normalized.LastIndexOf('.')];
var segments = normalized.Split('/');
var depth = Math.Min(groupDepth, segments.Length - 1);
var name = depth == 0 ? rootAtlasName : string.Join('.', segments[..depth]);
return (name, key);
}
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
IEnumerable<(string FullPath, string RelativePath)> sources,
AtlasBuildOptions options
)
{
var groups = new SortedDictionary<string, List<SourceFile>>(StringComparer.Ordinal);
var keys = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (fullPath, relative) in sources)
{
if (
!SourceExtensions.Contains(
Path.GetExtension(fullPath),
StringComparer.OrdinalIgnoreCase
)
)
{
continue;
}
var (atlasName, key) = ClassifyPath(
relative,
options.GroupDepth,
options.RootAtlasName
);
if (keys.TryGetValue(key, out var existing))
{
throw new InvalidDataException(
$"Duplicate region key '{key}': '{existing}' and '{relative}'."
);
}
keys.Add(key, relative);
if (!groups.TryGetValue(atlasName, out var list))
{
list = [];
groups.Add(atlasName, list);
}
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<SourceFile> files,
AtlasBuildOptions options
)
{
var metadataPath = Path.Combine(options.OutputDirectory, name + ".atlas");
if (!options.Force && IsUpToDate(metadataPath, files, options, out var existingPages))
{
return new AtlasGroupResult(name, files.Count, existingPages, Skipped: true);
}
// Декодирование — самая дорогая фаза, параллелим (билд-тайм, аллокации допустимы).
var images = new ImageResult[files.Count];
Parallel.For(
0,
files.Count,
i =>
{
using var stream = File.OpenRead(files[i].FullPath);
images[i] = ImageResult.FromStream(
stream,
StbImageSharp.ColorComponents.RedGreenBlueAlpha
);
}
);
var items = new PackItem[files.Count];
for (var i = 0; i < files.Count; i++)
{
items[i] = new PackItem(files[i].Key, images[i].Width, images[i].Height);
}
var packed = ShelfPacker.Pack(items, options.MaxPageSize, options.Padding);
var pixelsByKey = new Dictionary<string, ImageResult>(files.Count, StringComparer.Ordinal);
for (var i = 0; i < files.Count; i++)
{
pixelsByKey.Add(files[i].Key, images[i]);
}
WritePages(name, packed, pixelsByKey, options.OutputDirectory);
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<SourceFile> files,
AtlasBuildOptions options,
out int pages
)
{
pages = 0;
if (!File.Exists(metadataPath))
{
return false;
}
AtlasMetadata metadata;
try
{
metadata = AtlasMetadata.FromJson(File.ReadAllText(metadataPath));
}
catch (Exception)
{
return false;
}
if (
metadata.Version != AtlasMetadata.CurrentVersion
|| metadata.PageSize != options.MaxPageSize
|| metadata.Padding != options.Padding
)
{
return false;
}
var outputDirectory = Path.GetDirectoryName(metadataPath)!;
if (metadata.Pages.Any(page => !File.Exists(Path.Combine(outputDirectory, page.File))))
{
return false;
}
// Источники сравниваются по точному снапшоту (ключ + размер + mtime), а не по
// «новее метаданных»: переименования и копии с сохранением времени тоже ловятся.
if (metadata.Sources.Count != files.Count)
{
return false;
}
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;
}
private static void WritePages(
string name,
PackResult packed,
Dictionary<string, ImageResult> pixelsByKey,
string outputDirectory
)
{
Parallel.For(
0,
packed.PageSizes.Count,
page =>
{
var (width, height) = packed.PageSizes[page];
var buffer = new byte[width * height * 4];
foreach (var placement in packed.Placements)
{
if (placement.Page != page)
{
continue;
}
var source = pixelsByKey[placement.Key];
for (var row = 0; row < source.Height; row++)
{
Array.Copy(
source.Data,
row * source.Width * 4,
buffer,
((placement.Y + row) * width + placement.X) * 4,
source.Width * 4
);
}
}
using var stream = File.Create(
Path.Combine(outputDirectory, PageFileName(name, page))
);
new ImageWriter().WritePng(
buffer,
width,
height,
StbImageWriteSharp.ColorComponents.RedGreenBlueAlpha,
stream
);
}
);
}
private static void WriteMetadata(
string name,
PackResult packed,
List<SourceFile> files,
AtlasBuildOptions options,
string metadataPath
)
{
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
{
File = PageFileName(name, index),
Width = size.Width,
Height = size.Height,
}
)
.ToList(),
Regions = packed
.Placements.OrderBy(p => p.Key, StringComparer.Ordinal)
.Select(p => new AtlasRegion
{
Key = p.Key,
Page = p.Page,
X = p.X,
Y = p.Y,
Width = p.Width,
Height = p.Height,
})
.ToList(),
};
File.WriteAllText(metadataPath, metadata.ToJson());
}
private static string PageFileName(string atlasName, int page) =>
$"{atlasName}.atlas.{page}.png";
private static void DeleteExtraPages(string name, int pageCount, string outputDirectory)
{
for (var page = pageCount; ; page++)
{
var path = Path.Combine(outputDirectory, PageFileName(name, page));
if (!File.Exists(path))
{
return;
}
File.Delete(path);
}
}
private static List<string> DeleteOrphans(
string outputDirectory,
IEnumerable<string> liveAtlasNames
)
{
var live = liveAtlasNames.ToHashSet(StringComparer.Ordinal);
var deleted = new List<string>();
foreach (var metadataPath in Directory.EnumerateFiles(outputDirectory, "*.atlas"))
{
var name = Path.GetFileNameWithoutExtension(metadataPath);
if (live.Contains(name))
{
continue;
}
File.Delete(metadataPath);
deleted.Add(metadataPath);
DeleteExtraPages(name, 0, outputDirectory);
}
return deleted;
}
}
@@ -0,0 +1,106 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MrGameEng.Atlases;
/// <summary>
/// Serializable description of one packed atlas: its page image files and the source-relative
/// region keys with their pixel rectangles. Stored as a JSON <c>.atlas</c> file next to the pages.
/// </summary>
public sealed class AtlasMetadata
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <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; } = "";
/// <summary>Maximum page size the atlas was built with (staleness check input).</summary>
public int PageSize { get; init; }
/// <summary>Padding in pixels the atlas was built with (staleness check input).</summary>
public int Padding { get; init; }
/// <summary>Page image files (relative to the metadata file), in page-index order.</summary>
public List<AtlasPage> Pages { get; init; } = [];
/// <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);
/// <summary>Parses metadata from JSON produced by <see cref="ToJson"/>.</summary>
public static AtlasMetadata FromJson(string json) =>
JsonSerializer.Deserialize<AtlasMetadata>(json, JsonOptions)
?? throw new InvalidDataException("Atlas metadata JSON deserialized to null.");
}
/// <summary>One page image of an atlas.</summary>
public sealed class AtlasPage
{
/// <summary>Image file name, relative to the metadata file.</summary>
public string File { get; init; } = "";
/// <summary>Page width in pixels.</summary>
public int Width { get; init; }
/// <summary>Page height in pixels.</summary>
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
{
/// <summary>
/// Region key: the source path relative to the build source root, forward slashes,
/// without the file extension (e.g. <c>Things/Pawn/Animal/Fox</c>).
/// </summary>
public string Key { get; init; } = "";
/// <summary>Index of the page containing this region.</summary>
public int Page { get; init; }
/// <summary>X of the region in page pixels.</summary>
public int X { get; init; }
/// <summary>Y of the region in page pixels.</summary>
public int Y { get; init; }
/// <summary>Region width in pixels.</summary>
[JsonPropertyName("w")]
public int Width { get; init; }
/// <summary>Region height in pixels.</summary>
[JsonPropertyName("h")]
public int Height { get; init; }
}
@@ -0,0 +1,19 @@
using MrGameEng.Assets;
using MrGameEng.Core;
namespace MrGameEng.Atlases;
/// <summary>Wires the atlases module into the engine.</summary>
public static class AtlasesEngineExtensions
{
/// <summary>
/// Registers the <see cref="TextureAtlas"/> loader on the <see cref="AssetManager"/>,
/// enabling <c>assets.Load(GameAssets.…)</c> for generated <c>.atlas</c> handles.
/// Call once at startup after <see cref="AssetsEngineExtensions.UseAssets"/>.
/// </summary>
public static void UseTextureAtlases(this EngineContext context)
{
var assets = context.Services.Get<AssetManager>();
assets.RegisterLoader((_, path) => TextureAtlas.Load(context.GraphicsDevice, path));
}
}
@@ -0,0 +1,175 @@
namespace MrGameEng.Atlases;
/// <summary>Input rectangle for the packer: an opaque key plus pixel dimensions.</summary>
/// <param name="Key">Caller-defined identifier carried through to the placement.</param>
/// <param name="Width">Width in pixels.</param>
/// <param name="Height">Height in pixels.</param>
public readonly record struct PackItem(string Key, int Width, int Height);
/// <summary>Where one item ended up: page index plus position in page pixels.</summary>
/// <param name="Key">Key of the packed item.</param>
/// <param name="Page">Index into <see cref="PackResult.PageSizes"/>.</param>
/// <param name="X">X position in page pixels.</param>
/// <param name="Y">Y position in page pixels.</param>
/// <param name="Width">Item width in pixels.</param>
/// <param name="Height">Item height in pixels.</param>
public readonly record struct PackPlacement(
string Key,
int Page,
int X,
int Y,
int Width,
int Height
);
/// <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: 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>
/// Deterministic shelf packer: items are sorted by height (then width, then key) and laid out
/// in horizontal shelves; a new page starts when a shelf does not fit. Simple and fast, with
/// good occupancy for sprite sets of similar heights. Items larger than the page size get a
/// dedicated page of their own exact size.
/// </summary>
public static class ShelfPacker
{
/// <summary>
/// Packs <paramref name="items"/> into pages of at most <paramref name="maxPageSize"/>²
/// pixels keeping <paramref name="padding"/> pixels between items and page edges.
/// </summary>
public static PackResult Pack(IReadOnlyList<PackItem> items, int maxPageSize, int padding)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maxPageSize, 1);
ArgumentOutOfRangeException.ThrowIfNegative(padding);
var sorted = items.ToList();
sorted.Sort(
static (a, b) =>
{
var byHeight = b.Height.CompareTo(a.Height);
if (byHeight != 0)
{
return byHeight;
}
var byWidth = b.Width.CompareTo(a.Width);
return byWidth != 0 ? byWidth : string.CompareOrdinal(a.Key, b.Key);
}
);
var placements = new List<PackPlacement>(items.Count);
var pageSizes = new List<(int Width, int Height)>();
// Открытая страница ещё не записана в pageSizes — её индекс всегда pageSizes.Count.
var open = false;
var x = 0;
var y = 0;
var shelfHeight = 0;
var usedWidth = 0;
var usedHeight = 0;
void CloseOpenPage()
{
if (open)
{
pageSizes.Add(
(
PageDimension(usedWidth + padding, maxPageSize),
PageDimension(usedHeight + padding, maxPageSize)
)
);
open = false;
}
}
void OpenFreshPage()
{
CloseOpenPage();
open = true;
x = padding;
y = padding;
shelfHeight = 0;
usedWidth = 0;
usedHeight = 0;
}
// Негабаритные — первыми, на отдельные страницы точно под себя: посреди потока
// они закрывали бы наполовину заполненную общую страницу (потеря occupancy).
foreach (var item in sorted)
{
if (item.Width + 2 * padding > maxPageSize || item.Height + 2 * padding > maxPageSize)
{
placements.Add(
new PackPlacement(
item.Key,
pageSizes.Count,
padding,
padding,
item.Width,
item.Height
)
);
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)
{
OpenFreshPage();
}
else if (x + item.Width + padding > maxPageSize)
{
// Конец полки: следующая полка ниже; если не влезает по высоте — новая страница.
y += shelfHeight + padding;
x = padding;
shelfHeight = 0;
if (y + item.Height + padding > maxPageSize)
{
OpenFreshPage();
}
}
placements.Add(
new PackPlacement(item.Key, pageSizes.Count, x, y, item.Width, item.Height)
);
x += item.Width + padding;
shelfHeight = Math.Max(shelfHeight, item.Height);
usedWidth = Math.Max(usedWidth, x - padding);
usedHeight = Math.Max(usedHeight, y + item.Height);
}
CloseOpenPage();
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;
while (result < value)
{
result <<= 1;
}
return result;
}
}
@@ -0,0 +1,107 @@
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MrGameEng.Graphics;
namespace MrGameEng.Atlases;
/// <summary>
/// A loaded texture atlas: page textures plus a lookup from region key (source-relative path
/// without extension, e.g. <c>Things/Pawn/Animal/Fox</c>) to <see cref="Texture2DRegion"/>.
/// Sprites taken from one atlas page batch into a single draw call automatically.
/// Owns its page textures and disposes them with the atlas.
/// </summary>
public sealed class TextureAtlas : IDisposable
{
private readonly Dictionary<string, Texture2DRegion> _regions;
/// <summary>Atlas name from the metadata.</summary>
public string Name { get; }
/// <summary>Page textures, in page-index order.</summary>
public IReadOnlyList<Texture2D> Pages { get; }
/// <summary>All regions by key.</summary>
public IReadOnlyDictionary<string, Texture2DRegion> Regions => _regions;
/// <summary>Creates an atlas over already-loaded page textures.</summary>
public TextureAtlas(AtlasMetadata metadata, IReadOnlyList<Texture2D> pages)
{
Name = metadata.Name;
Pages = pages;
_regions = new Dictionary<string, Texture2DRegion>(
metadata.Regions.Count,
StringComparer.Ordinal
);
foreach (var region in metadata.Regions)
{
_regions.Add(
region.Key,
new Texture2DRegion(
pages[region.Page],
new Rectangle(region.X, region.Y, region.Width, region.Height)
)
);
}
}
/// <summary>Returns the region for <paramref name="key"/>; throws when the key is unknown.</summary>
public Texture2DRegion GetRegion(string key) =>
_regions.TryGetValue(key, out var region)
? region
: throw new KeyNotFoundException($"Atlas '{Name}' has no region '{key}'.");
/// <summary>Returns the region for <paramref name="key"/> or false when unknown.</summary>
public bool TryGetRegion(string key, out Texture2DRegion region) =>
_regions.TryGetValue(key, out region!);
/// <summary>
/// Loads an atlas from a <c>.atlas</c> metadata file; page images are loaded from the same
/// directory with premultiplied alpha (matching the engine's texture loader).
/// </summary>
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);
}
/// <summary>Disposes every page texture.</summary>
public void Dispose()
{
foreach (var page in Pages)
{
page?.Dispose();
}
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace MrGameEng.Mods;
/// <summary>
/// Base class of all data definitions loaded by <see cref="DefDatabase"/> from mod JSON.
/// Games subclass this per content kind (terrain, things, pawns, …) with plain
/// serializable properties.
/// </summary>
public abstract class Def
{
/// <summary>
/// Unique name within the def type. A later mod redefining the same name fully
/// replaces the earlier def.
/// </summary>
public string DefName { get; init; } = "";
/// <summary>
/// Name of the def (same type) whose fields this def starts from; own fields override
/// the inherited ones. Abstractness is not inherited.
/// </summary>
public string? Parent { get; init; }
/// <summary>Abstract defs only serve as parents and are not emitted into the database.</summary>
public bool Abstract { get; init; }
/// <summary>Display label — plain text or a localization key, as the game decides.</summary>
public string Label { get; init; } = "";
/// <inheritdoc />
public override string ToString() => $"{GetType().Name} {DefName}";
}
+221
View File
@@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MrGameEng.Mods;
/// <summary>
/// Database of data definitions loaded from mod JSON. Each file under a mod's
/// <c>Defs/</c> folder is an envelope <c>{ "type": "&lt;key&gt;", "defs": [ … ] }</c>; the
/// game registers the CLR type for every key before <see cref="Load"/>. Defs from later
/// mods replace same-named defs of earlier mods; <c>parent</c> chains are merged
/// field-by-field (own fields win, nested objects are replaced whole); defs marked
/// <c>abstract</c> serve only as parents.
/// </summary>
public sealed class DefDatabase
{
private sealed class TypeEntry
{
public required string Key;
public required Type ClrType;
public readonly Dictionary<string, JsonObject> Raw = new(StringComparer.Ordinal);
public readonly SortedDictionary<string, Def> Resolved = new(StringComparer.Ordinal);
}
private readonly Dictionary<string, TypeEntry> _byKey = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<Type, TypeEntry> _byType = [];
private static readonly JsonDocumentOptions DocumentOptions = new()
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
/// <summary>Registers the CLR type behind a def-type key (the <c>"type"</c> field of def files).</summary>
public void RegisterType<T>(string typeKey)
where T : Def
{
var entry = new TypeEntry { Key = typeKey, ClrType = typeof(T) };
if (!_byKey.TryAdd(typeKey, entry))
{
throw new InvalidOperationException($"Def type '{typeKey}' is already registered.");
}
_byType.Add(typeof(T), entry);
}
/// <summary>
/// Loads every <c>Defs/**/*.json</c> of <paramref name="mods"/> (in load order) and
/// resolves inheritance. Call once after registering all def types.
/// </summary>
public void Load(IReadOnlyList<Mod> mods)
{
foreach (var mod in mods)
{
var defsDir = mod.ContentPath("Defs");
if (!Directory.Exists(defsDir))
{
continue;
}
var files = Directory
.EnumerateFiles(defsDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
LoadFile(mod, file);
}
}
foreach (var entry in _byKey.Values)
{
Resolve(entry);
}
}
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>; throws when missing.</summary>
public T Get<T>(string defName)
where T : Def =>
TryGet<T>(defName, out var def)
? def
: throw new KeyNotFoundException($"No {typeof(T).Name} def named '{defName}'.");
/// <summary>Returns the def of type <typeparamref name="T"/> named <paramref name="defName"/>, or false.</summary>
public bool TryGet<T>(string defName, out T def)
where T : Def
{
if (Entry<T>().Resolved.TryGetValue(defName, out var found))
{
def = (T)found;
return true;
}
def = null!;
return false;
}
/// <summary>All resolved defs of type <typeparamref name="T"/>, sorted by def name (deterministic).</summary>
public IReadOnlyList<T> All<T>()
where T : Def => Entry<T>().Resolved.Values.Cast<T>().ToList();
/// <summary>Registered def-type keys, sorted.</summary>
public IReadOnlyList<string> TypeKeys =>
_byKey.Values.Select(e => e.Key).Order(StringComparer.Ordinal).ToList();
/// <summary>Resolved def names of the given type key, sorted; empty for unknown keys.</summary>
public IReadOnlyList<string> NamesOf(string typeKey) =>
_byKey.TryGetValue(typeKey, out var entry) ? entry.Resolved.Keys.ToList() : [];
private TypeEntry Entry<T>()
where T : Def =>
_byType.TryGetValue(typeof(T), out var entry)
? entry
: throw new InvalidOperationException($"Def type {typeof(T).Name} is not registered.");
private void LoadFile(Mod mod, string file)
{
JsonNode root;
try
{
root =
JsonNode.Parse(File.ReadAllText(file), documentOptions: DocumentOptions)
?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid def file '{file}' (mod '{mod.Id}'): {exception.Message}",
exception
);
}
var typeKey =
root["type"]?.GetValue<string>()
?? throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"type\" field."
);
if (!_byKey.TryGetValue(typeKey, out var entry))
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') uses unknown def type '{typeKey}'; "
+ $"registered: {string.Join(", ", TypeKeys)}."
);
}
if (root["defs"] is not JsonArray defs)
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') has no \"defs\" array."
);
}
foreach (var node in defs)
{
if (node is not JsonObject def)
{
throw new InvalidDataException(
$"Def file '{file}' (mod '{mod.Id}') contains a non-object def entry."
);
}
var defName = def["defName"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(defName))
{
throw new InvalidDataException(
$"A def in '{file}' (mod '{mod.Id}') has no \"defName\"."
);
}
entry.Raw[defName] = def; // поздний мод/файл полностью заменяет одноимённый деф
}
}
private static void Resolve(TypeEntry entry)
{
foreach (var defName in entry.Raw.Keys.Order(StringComparer.Ordinal))
{
var merged = MergeChain(entry, defName, []);
if (merged["abstract"]?.GetValue<bool>() == true)
{
continue;
}
var def =
(Def?)merged.Deserialize(entry.ClrType, ModInfo.JsonOptions)
?? throw new InvalidDataException(
$"Def '{defName}' ({entry.Key}) deserialized to null."
);
entry.Resolved[defName] = def;
}
}
private static JsonObject MergeChain(TypeEntry entry, string defName, HashSet<string> seen)
{
if (!seen.Add(defName))
{
throw new InvalidDataException(
$"Cyclic def inheritance involving '{defName}' ({entry.Key})."
);
}
if (!entry.Raw.TryGetValue(defName, out var node))
{
throw new InvalidDataException($"Unknown parent def '{defName}' ({entry.Key}).");
}
var parentName = node["parent"]?.GetValue<string>();
if (parentName is null)
{
return (JsonObject)node.DeepClone();
}
var merged = MergeChain(entry, parentName, seen);
merged.Remove("abstract"); // абстрактность не наследуется
merged.Remove("defName");
foreach (var (key, value) in node)
{
merged[key] = value?.DeepClone();
}
return merged;
}
}
@@ -0,0 +1,163 @@
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace MrGameEng.Mods;
/// <summary>
/// Keyed localization strings loaded from mods: <c>Languages/&lt;code&gt;/**/*.json</c>,
/// each file a flat string-to-string map. Later mods override earlier ones key by key.
/// Lookup falls back from the current language to the default one; a missing key returns
/// the key itself, so untranslated strings are visible instead of crashing.
/// </summary>
public sealed class LanguageManager
{
private readonly Dictionary<string, Dictionary<string, string>> _languages = new(
StringComparer.OrdinalIgnoreCase
);
/// <summary>Creates a manager whose fallback language is <paramref name="defaultLanguage"/>.</summary>
public LanguageManager(string defaultLanguage = "en")
{
DefaultLanguage = defaultLanguage;
CurrentLanguage = defaultLanguage;
}
/// <summary>Fallback language code.</summary>
public string DefaultLanguage { get; }
/// <summary>Active language code. Change with <see cref="SetLanguage"/>.</summary>
public string CurrentLanguage { get; private set; }
/// <summary>Increments whenever loaded strings or the active language change — UI rebuilds on it.</summary>
public int Revision { get; private set; }
/// <summary>Language codes that have at least one loaded string, sorted.</summary>
public IReadOnlyList<string> AvailableLanguages =>
_languages.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>Loads (merges in) language files of <paramref name="mods"/> in load order.</summary>
public void Load(IReadOnlyList<Mod> mods)
{
foreach (var mod in mods)
{
var languagesDir = mod.ContentPath("Languages");
if (!Directory.Exists(languagesDir))
{
continue;
}
foreach (
var languageDir in Directory
.EnumerateDirectories(languagesDir)
.OrderBy(d => d, StringComparer.Ordinal)
)
{
var code = Path.GetFileName(languageDir);
if (!_languages.TryGetValue(code, out var strings))
{
strings = new Dictionary<string, string>(StringComparer.Ordinal);
_languages.Add(code, strings);
}
var files = Directory
.EnumerateFiles(languageDir, "*.json", SearchOption.AllDirectories)
.OrderBy(f => f, StringComparer.Ordinal);
foreach (var file in files)
{
LoadFile(mod, file, strings);
}
}
}
Revision++;
}
/// <summary>
/// Switches the active language. Returns false (and keeps the current one) when no
/// strings are loaded for <paramref name="code"/>.
/// </summary>
public bool SetLanguage(string code)
{
if (!_languages.ContainsKey(code))
{
return false;
}
CurrentLanguage = code;
Revision++;
return true;
}
/// <summary>Returns the string for <paramref name="key"/>: current language → default language → the key itself.</summary>
public string Get(string key)
{
if (
_languages.TryGetValue(CurrentLanguage, out var current)
&& current.TryGetValue(key, out var value)
)
{
return value;
}
if (
_languages.TryGetValue(DefaultLanguage, out var fallback)
&& fallback.TryGetValue(key, out value)
)
{
return value;
}
return key;
}
/// <summary>Formats the string for <paramref name="key"/> with <paramref name="args"/> (invariant culture).</summary>
public string Format(string key, params object[] args) =>
string.Format(CultureInfo.InvariantCulture, Get(key), args);
private static void LoadFile(Mod mod, string file, Dictionary<string, string> strings)
{
JsonNode root;
try
{
root =
JsonNode.Parse(
File.ReadAllText(file),
documentOptions: new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
}
) ?? throw new InvalidDataException("file is empty");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid language file '{file}' (mod '{mod.Id}'): {exception.Message}",
exception
);
}
if (root is not JsonObject map)
{
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}') must be a flat JSON object."
);
}
foreach (var (key, value) in map)
{
if (
value is not JsonValue jsonValue
|| jsonValue.GetValueKind() != JsonValueKind.String
)
{
throw new InvalidDataException(
$"Language file '{file}' (mod '{mod.Id}'): key '{key}' must map to a string."
);
}
strings[key] = jsonValue.GetValue<string>(); // поздний мод переопределяет ключ
}
}
}
+29
View File
@@ -0,0 +1,29 @@
namespace MrGameEng.Mods;
/// <summary>One discovered mod: its metadata and content directories on disk.</summary>
public sealed class Mod
{
internal Mod(ModInfo info, string rootPath)
{
Info = info;
RootPath = rootPath;
}
/// <summary>Metadata from <c>About/About.json</c>.</summary>
public ModInfo Info { get; }
/// <summary>Absolute path of the mod's root directory.</summary>
public string RootPath { get; }
/// <summary>Unique mod id (shortcut for <c>Info.Id</c>).</summary>
public string Id => Info.Id;
/// <summary>
/// Absolute path of a content folder inside the mod (e.g. <c>Defs</c>, <c>Textures</c>,
/// <c>Languages</c>). The folder is not required to exist.
/// </summary>
public string ContentPath(string folder) => Path.Combine(RootPath, folder);
/// <inheritdoc />
public override string ToString() => $"{Id} {Info.Version}".TrimEnd();
}
@@ -0,0 +1,76 @@
namespace MrGameEng.Mods;
/// <summary>A file contributed by a mod, addressed by its content-relative path.</summary>
/// <param name="RelativePath">Path relative to the content folder, forward slashes.</param>
/// <param name="FullPath">Absolute path of the winning file on disk.</param>
/// <param name="Mod">The mod that contributed the file.</param>
public readonly record struct ModFile(string RelativePath, string FullPath, Mod Mod);
/// <summary>
/// Merged view of one content folder (e.g. <c>Textures</c>) across the active mods in load
/// order: a later mod shipping a file under the same relative path overrides the earlier
/// one. Paths use forward slashes and match case-insensitively (Windows-friendly content).
/// </summary>
public sealed class ModContentTree
{
private readonly Dictionary<string, ModFile> _files;
private ModContentTree(Dictionary<string, ModFile> files, IReadOnlyList<ModFile> ordered)
{
_files = files;
Files = ordered;
}
/// <summary>Winning files, sorted by relative path — deterministic for identical mod sets.</summary>
public IReadOnlyList<ModFile> Files { get; }
/// <summary>Returns the winning file for <paramref name="relativePath"/>.</summary>
public bool TryGet(string relativePath, out ModFile file) =>
_files.TryGetValue(Normalize(relativePath), out file);
/// <summary>
/// Builds the merged tree of <paramref name="contentFolder"/> over <paramref name="mods"/>
/// (in load order). With <paramref name="extensions"/> only matching files are included
/// (e.g. <c>".png"</c>); without them, every file.
/// </summary>
public static ModContentTree Build(
IReadOnlyList<Mod> mods,
string contentFolder,
params string[] extensions
)
{
var files = new Dictionary<string, ModFile>(StringComparer.OrdinalIgnoreCase);
foreach (var mod in mods)
{
var root = mod.ContentPath(contentFolder);
if (!Directory.Exists(root))
{
continue;
}
foreach (
var fullPath in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)
)
{
if (
extensions.Length > 0
&& !extensions.Contains(
Path.GetExtension(fullPath),
StringComparer.OrdinalIgnoreCase
)
)
{
continue;
}
var relative = Normalize(Path.GetRelativePath(root, fullPath));
files[relative] = new ModFile(relative, fullPath, mod); // поздний мод побеждает
}
}
var ordered = files.Values.OrderBy(f => f.RelativePath, StringComparer.Ordinal).ToList();
return new ModContentTree(files, ordered);
}
private static string Normalize(string path) => path.Replace('\\', '/');
}
+36
View File
@@ -0,0 +1,36 @@
using System.Text.Json;
namespace MrGameEng.Mods;
/// <summary>
/// Mod metadata loaded from <c>About/About.json</c> in the mod's root directory.
/// </summary>
public sealed class ModInfo
{
/// <summary>JSON options shared by all mod content readers (camelCase, comments and trailing commas allowed).</summary>
internal static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
};
/// <summary>Unique mod id, referenced by <see cref="Dependencies"/> of other mods.</summary>
public string Id { get; init; } = "";
/// <summary>Human-readable mod name.</summary>
public string Name { get; init; } = "";
/// <summary>Mod author.</summary>
public string Author { get; init; } = "";
/// <summary>Mod version string (informational).</summary>
public string Version { get; init; } = "";
/// <summary>Short description shown in mod lists.</summary>
public string Description { get; init; } = "";
/// <summary>Ids of mods that must be active and load before this one.</summary>
public List<string> Dependencies { get; init; } = [];
}
+166
View File
@@ -0,0 +1,166 @@
using System.Text.Json;
using MrGameEng.Core;
namespace MrGameEng.Mods;
/// <summary>
/// Discovers mods — subdirectories of a mods root containing <c>About/About.json</c> —
/// and resolves a deterministic load order: dependencies first, ties broken
/// alphabetically by id. Later mods override earlier ones in every content system
/// (defs, textures, languages).
/// </summary>
public static class ModLoader
{
/// <summary>
/// Walks up from <paramref name="startDirectory"/> looking for a <c>Mods</c> folder.
/// Lets dev builds run from <c>bin/…</c> while shipped builds keep <c>Mods</c> next to
/// the executable. Returns null when no such folder exists on the path to the root.
/// </summary>
public static string? FindModsRoot(string startDirectory)
{
for (
var dir = new DirectoryInfo(Path.GetFullPath(startDirectory));
dir is not null;
dir = dir.Parent
)
{
var candidate = Path.Combine(dir.FullName, "Mods");
if (Directory.Exists(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Loads mods under <paramref name="modsRoot"/> in dependency order. With
/// <paramref name="activeIds"/> only that subset is loaded (its dependencies must be
/// included); without it every discovered mod is active.
/// </summary>
public static IReadOnlyList<Mod> Load(string modsRoot, IEnumerable<string>? activeIds = null)
{
if (!Directory.Exists(modsRoot))
{
throw new DirectoryNotFoundException($"Mods root not found: '{modsRoot}'.");
}
var discovered = Discover(modsRoot);
List<Mod> active;
if (activeIds is null)
{
active = discovered.Values.ToList();
}
else
{
active = [];
foreach (var id in activeIds)
{
if (!discovered.TryGetValue(id, out var mod))
{
throw new InvalidDataException(
$"Active mod '{id}' is not installed under '{modsRoot}'."
);
}
active.Add(mod);
}
}
var ordered = SortByDependencies(active);
Log.Info($"Mods loaded: {string.Join(", ", ordered)}");
return ordered;
}
private static SortedDictionary<string, Mod> Discover(string modsRoot)
{
var discovered = new SortedDictionary<string, Mod>(StringComparer.Ordinal);
foreach (var dir in Directory.EnumerateDirectories(modsRoot))
{
var aboutPath = Path.Combine(dir, "About", "About.json");
if (!File.Exists(aboutPath))
{
continue; // не мод — служебная папка
}
ModInfo info;
try
{
info =
JsonSerializer.Deserialize<ModInfo>(
File.ReadAllText(aboutPath),
ModInfo.JsonOptions
) ?? throw new InvalidDataException("About.json deserialized to null.");
}
catch (JsonException exception)
{
throw new InvalidDataException(
$"Invalid mod metadata '{aboutPath}': {exception.Message}",
exception
);
}
if (string.IsNullOrWhiteSpace(info.Id))
{
throw new InvalidDataException($"Mod at '{dir}' has an empty id in About.json.");
}
if (discovered.TryGetValue(info.Id, out var existing))
{
throw new InvalidDataException(
$"Duplicate mod id '{info.Id}': '{existing.RootPath}' and '{dir}'."
);
}
discovered.Add(info.Id, new Mod(info, dir));
}
return discovered;
}
private static List<Mod> SortByDependencies(List<Mod> active)
{
var byId = active.ToDictionary(m => m.Id, StringComparer.Ordinal);
var ordered = new List<Mod>(active.Count);
var state = new Dictionary<string, bool>(StringComparer.Ordinal); // false = в обработке, true = готов
void Visit(Mod mod)
{
if (state.TryGetValue(mod.Id, out var done))
{
if (!done)
{
throw new InvalidDataException($"Cyclic mod dependency involving '{mod.Id}'.");
}
return;
}
state[mod.Id] = false;
foreach (var dependency in mod.Info.Dependencies)
{
if (!byId.TryGetValue(dependency, out var parent))
{
throw new InvalidDataException(
$"Mod '{mod.Id}' requires '{dependency}', which is not installed or not active."
);
}
Visit(parent);
}
state[mod.Id] = true;
ordered.Add(mod);
}
// Обход в алфавитном порядке id — итоговый порядок детерминирован.
foreach (var mod in active.OrderBy(m => m.Id, StringComparer.Ordinal))
{
Visit(mod);
}
return ordered;
}
}