Add MrGameEng.Mods: mod loading, JSON defs and localization
CI / build-test (push) Failing after 1m8s

Mods are folders with About/About.json metadata; ModLoader resolves a
deterministic load order (dependencies first, ties alphabetical) and
later mods override earlier ones everywhere.

DefDatabase loads JSON def files ({ "type", "defs": [...] }) into
game-registered Def subclasses, with parent inheritance (own fields on
top of the parent's, nested values replaced whole), abstract parents
and full replacement of same-named defs by later mods.

LanguageManager loads Languages/<code>/*.json flat key-string maps,
switches language at runtime and falls back current -> default -> key.

ModContentTree merges one content folder across mods by relative path;
AtlasBuilder gains an explicit-sources Build overload so a merged
texture tree can be packed incrementally at game start.

The LittleSim game now ships its entire content as the Core mod,
demonstrating the module end to end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-06-11 21:51:17 +03:00
co-authored by Claude Fable 5
parent 501d81e19f
commit 5d3c18de40
16 changed files with 1114 additions and 11 deletions
+29 -9
View File
@@ -3,11 +3,14 @@ using StbImageWriteSharp;
namespace MrGameEng.Atlases;
/// <summary>Options for one <see cref="AtlasBuilder.Build"/> run.</summary>
/// <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).</summary>
public required string SourceDirectory { get; init; }
/// <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; }
@@ -38,7 +41,7 @@ public sealed class AtlasBuildOptions
/// <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"/> run.</summary>
/// <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);
@@ -57,18 +60,36 @@ public static class AtlasBuilder
/// <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 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(sourceRoot, options);
var groups = ScanGroups(sources, options);
var results = new List<AtlasGroupResult>();
foreach (var (name, files) in groups)
{
@@ -91,18 +112,17 @@ public static class AtlasBuilder
}
private static SortedDictionary<string, List<SourceFile>> ScanGroups(
string sourceRoot, AtlasBuildOptions options)
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 in Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories))
foreach (var (fullPath, relative) in sources)
{
if (!SourceExtensions.Contains(Path.GetExtension(fullPath), StringComparer.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(sourceRoot, fullPath);
var (atlasName, key) = ClassifyPath(relative, options.GroupDepth, options.RootAtlasName);
if (keys.TryGetValue(key, out var existing))
{