Add MrGameEng.Mods: mod loading, JSON defs and localization
CI / build-test (push) Failing after 1m8s
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:
co-authored by
Claude Fable 5
parent
501d81e19f
commit
5d3c18de40
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
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": "<key>", "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,141 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace MrGameEng.Mods;
|
||||
|
||||
/// <summary>
|
||||
/// Keyed localization strings loaded from mods: <c>Languages/<code>/**/*.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>(); // поздний мод переопределяет ключ
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,65 @@
|
||||
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('\\', '/');
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="MrGameEng.Mods.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MrGameEng.Core\MrGameEng.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user