Data editor base: EntityEditorWindow (searchable list, form over JSON entities, texts in every language of the mod, other fields as JSON), EntityDocument, LocaleFileService, PropertiesText; mod tests

This commit is contained in:
cthangover committed 2026-10-23 19:09:39 +02:00
1 parent 6ca1fff35f
commit e1a1d770cf
14 files changed
+1502 -1

No files matched your search

+72
View File
@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Localization;
using Godot;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Texts of one mod's <c>locale/*.properties</c> for the editors that show a
/// localized name instead of its key. A language's files are
/// <c>&lt;lang&gt;.properties</c> and <c>&lt;anything&gt;_&lt;lang&gt;.properties</c>,
/// as <c>LocaleLoader</c> reads them.
/// <para/>
/// A write goes into the file of the language that already declares the key;
/// a new key goes into <c>&lt;lang&gt;.properties</c>, else the first non-scenario
/// file of the language, else a new <c>locale/&lt;lang&gt;.properties</c>.
/// Scenario string files (<c>scenarios_*</c>) never get entity texts.
/// </summary>
public static class LocaleFileService
{
private const string Dir = "locale";
/// <summary>Languages the mod has texts for, plus the game's current one; the current one first.</summary>
public static List<string> Languages(string modId)
{
var current = LanguageCode.Normalize(TranslationServer.GetLocale()) ?? "ru";
var languages = new List<string> { current };
foreach (var file in ModFileService.ListFiles(modId, Dir, ".properties"))
{
var language = LanguageOf(file);
if (language != null && !languages.Contains(language))
languages.Add(language);
}
return languages;
}
/// <summary>The mod's text of <paramref name="key"/> in <paramref name="language"/>, or null.</summary>
public static string Read(string modId, string language, string key)
{
string found = null;
foreach (var file in FilesOf(modId, language))
found = PropertiesText.Get(ModFileService.ReadText(modId, file), key) ?? found;
return found;
}
/// <summary>Sets the text of <paramref name="key"/> in <paramref name="language"/>; false when the mod is not writable.</summary>
public static bool Write(string modId, string language, string key, string value)
{
var files = FilesOf(modId, language);
var target = files.FirstOrDefault(f => PropertiesText.Keys(ModFileService.ReadText(modId, f)).Contains(key))
?? files.FirstOrDefault(f => Path.GetFileName(f) == language + ".properties")
?? files.FirstOrDefault(f => !Path.GetFileName(f).StartsWith("scenarios_", StringComparison.Ordinal))
?? $"{Dir}/{language}.properties";
return ModFileService.WriteText(modId, target, PropertiesText.Set(ModFileService.ReadText(modId, target), key, value));
}
/// <summary>Reloads the game's texts of the current language, so a saved name shows at once.</summary>
public static void ReloadGame() => LocaleLoader.LoadLocale(TranslationServer.GetLocale());
private static List<string> FilesOf(string modId, string language)
=> ModFileService.ListFiles(modId, Dir, ".properties").Where(f => LanguageOf(f) == language && Path.GetDirectoryName(f)?.Replace('\\', '/') == Dir).ToList();
private static string LanguageOf(string file)
{
var name = Path.GetFileNameWithoutExtension(file);
var language = name.Substring(name.LastIndexOf('_') + 1);
return LanguageCode.Supported.Contains(language) ? language : null;
}
}
}
+37
View File
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Factories.Impls;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.Services
@@ -121,6 +123,41 @@ namespace Cthangover.Tools.Services
return result;
}
/// <summary>
/// Pictures of a file group (<c>items</c>, <c>characters</c>, <c>charsets</c>…):
/// group-relative paths without extension, sorted — what an entity's picture
/// field holds (<c>food/ration</c>, <c>village/slime</c>).
/// </summary>
public static List<string> GetGroupImageIds(string group)
{
var ids = new List<string>();
var files = Files.Collect(group);
if (files == null)
return ids;
var exts = GetExt();
foreach (var key in files.Keys)
{
if (exts.Contains(Path.GetExtension(key).ToLower()))
ids.Add(Path.ChangeExtension(key, null));
}
ids.Sort(StringComparer.OrdinalIgnoreCase);
return ids;
}
/// <summary>
/// A picture of a file group by the id <see cref="GetGroupImageIds"/> lists
/// (a <c>group/</c> prefix is allowed, as data files often write it); null
/// without an error log when the group has no such picture, so a form can
/// show a half-typed path.
/// </summary>
public static Texture2D LoadGroupImage(string group, string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
var path = id.StartsWith(group + "/", StringComparison.OrdinalIgnoreCase) ? id.Substring(group.Length + 1) : id;
return GetGroupImageIds(group).Contains(path, StringComparer.OrdinalIgnoreCase) ? TextureUtils.LoadFromModGroup(group, path) : null;
}
/// <summary>
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
/// Returns <c>null</c> on any error (missing resource, load failure).
+78
View File
@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Tools.Services
{
/// <summary>
/// Reading and changing one key of a <c>.properties</c> text the way
/// <c>LocaleLoader</c> reads it: <c>key = value</c> per line, <c>#</c> comments,
/// no escapes and no multi-line values (a line break in a value becomes a
/// space). Everything else in the text — comments, order, other keys, the
/// line endings — stays byte for byte, so an editor save shows up in a diff
/// as the one changed line.
/// </summary>
public static class PropertiesText
{
/// <summary>The value of <paramref name="key"/>, or null when the text does not declare it (the last line wins, as in the loader).</summary>
public static string Get(string text, string key)
{
string found = null;
foreach (var line in Lines(text))
{
if (KeyOf(line) == key)
found = line.Substring(line.IndexOf('=') + 1).Trim();
}
return found;
}
/// <summary>All keys declared in the text.</summary>
public static HashSet<string> Keys(string text)
{
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var line in Lines(text))
{
var key = KeyOf(line);
if (key != null)
keys.Add(key);
}
return keys;
}
/// <summary>
/// The text with <paramref name="key"/> set to <paramref name="value"/>: every
/// line of the key is rewritten in place, or a <c>key = value</c> line is
/// appended (after a line break the text may lack). Line endings follow the text.
/// </summary>
public static string Set(string text, string key, string value)
{
text ??= string.Empty;
value = (value ?? string.Empty).Replace("\r", " ").Replace("\n", " ").Trim();
var newline = text.Contains("\r\n") ? "\r\n" : "\n";
var lines = text.Split('\n');
var replaced = false;
for (var i = 0; i < lines.Length; i++)
{
var hasCr = lines[i].EndsWith("\r");
if (KeyOf(hasCr ? lines[i][..^1] : lines[i]) != key)
continue;
lines[i] = $"{key} = {value}" + (hasCr ? "\r" : string.Empty);
replaced = true;
}
if (replaced)
return string.Join("\n", lines);
var head = text.Length == 0 || text.EndsWith("\n") ? text : text + newline;
return head + $"{key} = {value}" + newline;
}
private static IEnumerable<string> Lines(string text) => (text ?? string.Empty).Replace("\r\n", "\n").Split('\n');
private static string KeyOf(string rawLine)
{
var line = rawLine.Trim();
if (line.Length == 0 || line[0] == '#')
return null;
var eq = line.IndexOf('=');
return eq <= 0 ? null : line.Substring(0, eq).Trim();
}
}
}