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

+35
View File
@@ -305,3 +305,38 @@ tools/scene_editor/pick_scene = Pick a scene on the left or create a new one.
tools/scene_editor/preview_hint = Pick a background to see it.
tools/scene_editor/not_in_scene = (not in the scene)
tools/scene_editor/new_line_text = A new scene. Replace this line with your text.
tools/entity_editor/hint = The list with a search on the left, the fields of the selected entry on the right · Ctrl+N new, Ctrl+D copy · Ctrl+Z/Y undo/redo · Ctrl+S save, the game sees the change at once
tools/entity_editor/section_list = Entries of the mod
tools/entity_editor/search = Search by name or id
tools/entity_editor/mod_tip = The mod whose entries are edited; new entries go into its file
tools/entity_editor/new = New
tools/entity_editor/new_tip = Add an entry to the chosen mod (Ctrl+N)
tools/entity_editor/duplicate = Copy
tools/entity_editor/duplicate_tip = Copy the selected entry under a new id (Ctrl+D)
tools/entity_editor/delete = Delete
tools/entity_editor/delete_tip = Remove the selected entry from the mod's file
tools/entity_editor/delete_title = Delete the entry?
tools/entity_editor/delete_text = The entry leaves the mod's file on save. References to it in scenarios and maps stay — check them.
tools/entity_editor/undo = Undo
tools/entity_editor/undo_tip = Undo the last change (Ctrl+Z)
tools/entity_editor/redo = Redo
tools/entity_editor/redo_tip = Redo what was undone (Ctrl+Y)
tools/entity_editor/no_entity = Pick an entry on the left or make one with "New".
tools/entity_editor/file = Mod {0} · file {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = Unique id: scenarios, maps and other mods find the entry by it. A mod that depends on the owner and ships the same id replaces the entry
tools/entity_editor/locale_key_tip = Locale key; the texts below go into the mod's locale/*.properties
tools/entity_editor/text_in = text ({0})
tools/entity_editor/picture_pick_tip = Pick a picture from the mod folders
tools/entity_editor/picture_pick_title = Pictures of the “{0}” group
tools/entity_editor/section_other = Other fields
tools/entity_editor/other_hint = Fields the form does not show (other mods' data, loot…) as JSON. Edits apply as soon as the JSON is valid.
tools/entity_editor/other_invalid = Other fields: invalid JSON — {0}
tools/entity_editor/other_applied = Other fields applied
tools/entity_editor/opened = {0} entries in mod {1}
tools/entity_editor/added = Added entry {0} to {1}
tools/entity_editor/deleted = Entry {0} deleted (save to write it)
tools/entity_editor/saved = Saved: {0} file(s), {1} text(s) in mod {2}; the game re-read its data
tools/entity_editor/broken_files = Files with broken JSON were not read: {0}
tools/entity_editor/problem_id = An entry in file {0} has an empty id
tools/entity_editor/problem_duplicate = Id {0} occurs more than once
+35
View File
@@ -305,3 +305,38 @@ tools/scene_editor/pick_scene = Выберите сцену слева или с
tools/scene_editor/preview_hint = Выберите фон, чтобы посмотреть его.
tools/scene_editor/not_in_scene = (не в сцене)
tools/scene_editor/new_line_text = Новая сцена. Замените эту строку своим текстом.
tools/entity_editor/hint = Слева — список с поиском, справа — поля выбранной записи · Ctrl+N — новая, Ctrl+D — копия · Ctrl+Z/Y — отмена/повтор · Ctrl+S — сохранить, игра сразу видит изменения
tools/entity_editor/section_list = Записи мода
tools/entity_editor/search = Поиск по названию или id
tools/entity_editor/mod_tip = Мод, записи которого правятся; новые записи попадают в его файл
tools/entity_editor/new = Новый
tools/entity_editor/new_tip = Добавить запись в выбранный мод (Ctrl+N)
tools/entity_editor/duplicate = Копия
tools/entity_editor/duplicate_tip = Скопировать выбранную запись под новым id (Ctrl+D)
tools/entity_editor/delete = Удалить
tools/entity_editor/delete_tip = Удалить выбранную запись из файла мода
tools/entity_editor/delete_title = Удалить запись?
tools/entity_editor/delete_text = Запись исчезнет из файла мода после сохранения. Ссылки на неё в сценариях и картах останутся — проверьте их.
tools/entity_editor/undo = Отменить
tools/entity_editor/undo_tip = Отменить последнее изменение (Ctrl+Z)
tools/entity_editor/redo = Повторить
tools/entity_editor/redo_tip = Повторить отменённое (Ctrl+Y)
tools/entity_editor/no_entity = Выберите запись слева или создайте новую кнопкой «Новый».
tools/entity_editor/file = Мод {0} · файл {1}
tools/entity_editor/id = Id
tools/entity_editor/id_tip = Уникальный id: по нему запись находят сценарии, карты и другие моды. Мод, который зависит от владельца и даёт тот же id, заменяет запись
tools/entity_editor/locale_key_tip = Ключ локализации; тексты ниже пишутся в locale/*.properties мода
tools/entity_editor/text_in = текст ({0})
tools/entity_editor/picture_pick_tip = Выбрать картинку из папки мода
tools/entity_editor/picture_pick_title = Картинки группы «{0}»
tools/entity_editor/section_other = Прочие поля
tools/entity_editor/other_hint = Поля, которых нет в форме (данные других модов, лут…), как JSON. Правки применяются, как только JSON корректен.
tools/entity_editor/other_invalid = Прочие поля: неверный JSON — {0}
tools/entity_editor/other_applied = Прочие поля применены
tools/entity_editor/opened = {0} записей в моде {1}
tools/entity_editor/added = Добавлена запись {0} в {1}
tools/entity_editor/deleted = Запись {0} удалена (сохраните, чтобы записать)
tools/entity_editor/saved = Сохранено: файлов {0}, текстов {1} в моде {2}; игра перечитала данные
tools/entity_editor/broken_files = Не прочитаны файлы с ошибкой JSON: {0}
tools/entity_editor/problem_id = У записи в файле {0} пустой id
tools/entity_editor/problem_duplicate = Id {0} встречается несколько раз
+4 -1
View File
@@ -3,11 +3,14 @@
"name": "Editor Tools",
"version": "1.0.0",
"author": "ct",
"description": "Developer toolbox of ToolBox windows: scenario graph editor, scene editor (create and change the places of a novel: backgrounds, ambient sound, default scenario, the scene's scenarios), map editor with zones, scene builder with inspector, interactive and light editors, and the mod catalog export window.",
"description": "Developer toolbox of ToolBox windows: scenario graph editor, scene editor (create and change the places of a novel: backgrounds, ambient sound, default scenario, the scene's scenarios), map editor with zones, scene builder with inspector, interactive and light editors, the base of the data editors (items, characters: a searchable list and a form over JSON entities with texts in every language of the mod) and the mod catalog export window.",
"category": "tools, editor",
"sources": [
"src/**/*.cs"
],
"test_sources": [
"tests/*.cs"
],
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
"test_suites": [
{"id": "windows", "activate": ["--itest=tools_windows"]},
+252
View File
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// The entities of one kind (<c>"Kind": "item"</c>, <c>"character"</c>…) in one
/// mod, as an editor changes them, free of any UI.
/// <para/>
/// Files are kept as JSON trees, not as typed DTOs: a form changes only the
/// properties it knows, and everything else — fields of other mods' C#
/// (<c>heal</c>, <c>verb</c>, <c>Charset</c>), loot tables, the property
/// order — survives a save untouched. A file is written only when its text
/// differs from what was loaded or last saved, so opening and saving does not
/// reformat files nobody changed. New entities go to <see cref="NewFile"/>,
/// created on the first save when the mod has no such file.
/// <para/>
/// Localized texts are part of the document too (<see cref="SetText"/>): the
/// form shows a name in every language of the mod, and an undo step restores
/// texts and data together. Undo works on whole states, as in the map editor.
/// </summary>
public class EntityDocument
{
private const int HistoryLimit = 200;
private static readonly JsonSerializerOptions WriteOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
private readonly Dictionary<string, JsonObject> files = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> savedText = new(StringComparer.Ordinal);
private readonly List<EntityRecord> records = new();
private readonly List<string> undo = new();
private readonly List<string> redo = new();
private Dictionary<string, string> texts = new(StringComparer.Ordinal);
private Dictionary<string, string> savedTexts = new(StringComparer.Ordinal);
/// <summary>Entity kind of the document.</summary>
public string Kind { get; }
/// <summary>File new entities are added to.</summary>
public string NewFile { get; }
/// <summary>The entities in file order (files sorted by path).</summary>
public IReadOnlyList<EntityRecord> Records => records;
/// <summary>True when there is a step to undo.</summary>
public bool CanUndo => undo.Count > 0;
/// <summary>True when there is a step to redo.</summary>
public bool CanRedo => redo.Count > 0;
private EntityDocument(string kind, string newFile)
{
Kind = kind;
NewFile = newFile;
}
/// <summary>
/// Builds the document from the mod's JSON files (path, text). Files of
/// another kind, without a kind or with broken JSON are skipped; their
/// paths are returned in <paramref name="skipped"/> only when they declare
/// this kind but cannot be read, so the window can name them.
/// </summary>
public static EntityDocument Load(string kind, string newFile, IEnumerable<(string Path, string Text)> sources, out List<string> skipped)
{
var document = new EntityDocument(kind, newFile);
skipped = new List<string>();
foreach (var (path, text) in sources.OrderBy(s => s.Path, StringComparer.Ordinal))
{
JsonObject root;
try
{
root = JsonNode.Parse(text ?? string.Empty) as JsonObject;
}
catch (JsonException)
{
if (text != null && text.Contains($"\"{kind}\""))
skipped.Add(path);
continue;
}
if (root == null || !string.Equals(root["Kind"]?.ToString(), kind, StringComparison.OrdinalIgnoreCase))
continue;
if (root["Items"] is not JsonArray)
root["Items"] = new JsonArray();
document.files[path] = root;
document.savedText[path] = Serialize(root);
}
document.Rebuild();
return document;
}
/// <summary>Paths of the document's files.</summary>
public IEnumerable<string> Files => files.Keys;
/// <summary>Adds an entity to <see cref="NewFile"/> (the file is created when missing) and returns it.</summary>
public EntityRecord Add(JsonObject entity)
{
if (!files.TryGetValue(NewFile, out var root))
{
root = new JsonObject { ["Kind"] = Kind, ["Items"] = new JsonArray() };
files[NewFile] = root;
}
((JsonArray)root["Items"]).Add(entity);
Rebuild();
return records.First(r => r.Data == entity);
}
/// <summary>A copy of <paramref name="record"/> with id <paramref name="newId"/>, placed right after it in its file.</summary>
public EntityRecord Duplicate(EntityRecord record, string newId)
{
var copy = (JsonObject)record.Data.DeepClone();
copy["Id"] = newId;
var items = (JsonArray)files[record.File]["Items"];
items.Insert(items.IndexOf(record.Data) + 1, copy);
Rebuild();
return records.First(r => r.Data == copy);
}
/// <summary>Removes an entity from its file (the file stays, maybe with no items).</summary>
public void Remove(EntityRecord record)
{
((JsonArray)files[record.File]["Items"]).Remove(record.Data);
Rebuild();
}
/// <summary><paramref name="wanted"/> when no entity has it, else <c>wanted_2</c>, <c>wanted_3</c>…</summary>
public string FreeId(string wanted)
{
var id = wanted;
for (var number = 2; records.Any(r => string.Equals(r.Id, id, StringComparison.OrdinalIgnoreCase)); number++)
id = wanted + "_" + number;
return id;
}
/// <summary>The record with this id, or null.</summary>
public EntityRecord Find(string id) => records.FirstOrDefault(r => r.Id == id);
/// <summary>A localized text the document holds (see <see cref="SetText"/>), or null when it was never set or loaded.</summary>
public string GetText(string language, string key)
=> texts.TryGetValue(TextKey(language, key), out var value) ? value : null;
/// <summary>Remembers the text the mod's files have now (loading, not an edit).</summary>
public void LoadText(string language, string key, string value)
{
var textKey = TextKey(language, key);
texts[textKey] = value;
savedTexts[textKey] = value;
}
/// <summary>Changes a localized text; it is written on save.</summary>
public void SetText(string language, string key, string value) => texts[TextKey(language, key)] = value ?? string.Empty;
/// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary>
public void BeginChange()
{
undo.Add(Snapshot());
if (undo.Count > HistoryLimit)
undo.RemoveAt(0);
redo.Clear();
}
/// <summary>Steps back; false when there is nothing to undo.</summary>
public bool Undo() => Swap(undo, redo);
/// <summary>Steps forward again; false when there is nothing to redo.</summary>
public bool Redo() => Swap(redo, undo);
/// <summary>Files whose text changed since loading or the last <see cref="MarkSaved"/>.</summary>
public List<(string Path, string Text)> ChangedFiles()
=> files.Select(f => (f.Key, Serialize(f.Value)))
.Where(f => !savedText.TryGetValue(f.Key, out var saved) || saved != f.Item2)
.ToList();
/// <summary>Texts changed since loading or the last <see cref="MarkSaved"/>: language, key, value.</summary>
public List<(string Language, string Key, string Value)> ChangedTexts()
=> texts.Where(t => !savedTexts.TryGetValue(t.Key, out var saved) || saved != t.Value)
.Select(t => (t.Key.Substring(0, t.Key.IndexOf('\n')), t.Key.Substring(t.Key.IndexOf('\n') + 1), t.Value))
.ToList();
/// <summary>The current files and texts are what is on disk now.</summary>
public void MarkSaved()
{
foreach (var (path, text) in ChangedFiles())
savedText[path] = text;
savedTexts = new Dictionary<string, string>(texts, StringComparer.Ordinal);
}
/// <summary>Problems that would break the game's data, as locale keys with an argument; empty when it can be saved.</summary>
public List<(string Key, string Argument)> Validate()
{
var problems = new List<(string, string)>();
foreach (var record in records.Where(r => string.IsNullOrWhiteSpace(r.Id)))
problems.Add(("tools/entity_editor/problem_id", record.File));
foreach (var group in records.Where(r => !string.IsNullOrWhiteSpace(r.Id)).GroupBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
{
if (group.Count() > 1)
problems.Add(("tools/entity_editor/problem_duplicate", group.Key));
}
return problems;
}
/// <summary>A file's text as the document writes it: two-space indented, letters unescaped, LF line breaks (the writer would use CRLF on Windows), a final one.</summary>
public static string Serialize(JsonObject root) => root.ToJsonString(WriteOptions).Replace("\r\n", "\n") + "\n";
private static string TextKey(string language, string key) => language + "\n" + key;
private void Rebuild()
{
records.Clear();
foreach (var file in files.OrderBy(f => f.Key, StringComparer.Ordinal))
{
foreach (var item in (JsonArray)file.Value["Items"])
{
if (item is JsonObject entity)
records.Add(new EntityRecord(file.Key, entity));
}
}
}
private string Snapshot()
{
var state = new JsonObject
{
["files"] = new JsonObject(files.Select(f => new KeyValuePair<string, JsonNode>(f.Key, f.Value.DeepClone()))),
["texts"] = new JsonObject(texts.Select(t => new KeyValuePair<string, JsonNode>(t.Key, t.Value)))
};
return state.ToJsonString();
}
private bool Swap(List<string> from, List<string> to)
{
if (from.Count == 0)
return false;
to.Add(Snapshot());
var state = JsonNode.Parse(from[^1]).AsObject();
from.RemoveAt(from.Count - 1);
files.Clear();
foreach (var file in state["files"].AsObject())
files[file.Key] = file.Value.DeepClone().AsObject();
texts = state["texts"].AsObject().ToDictionary(t => t.Key, t => t.Value?.ToString() ?? string.Empty, StringComparer.Ordinal);
Rebuild();
return true;
}
}
}
+701
View File
@@ -0,0 +1,701 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Nodes;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Core.UI.Theming;
using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// Base of the data editors (items, characters…): every entity of one kind in
/// one writable mod, a search list on the left and a form on the right built
/// from the editor's <see cref="EntityField"/> list. Layout and behaviour are
/// shared by all such editors, so they look and work alike; a concrete editor
/// only names its kind, its fields and how a new entity starts out.
/// <para/>
/// For a beginner the form is all there is: a name is typed in each language
/// of the mod (the locale key is made from the id), a picture is picked from a
/// grid, lists are check boxes. Save writes the mod's data and locale files and
/// makes the running game re-read them (entities, then texts), so the change is
/// visible at once. For a professional nothing is hidden: the locale key stays
/// editable, and every property the form does not know is shown as JSON under
/// "other fields", edited there and written back as it is.
/// <para/>
/// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing
/// session of one entity, and one per add, duplicate or delete.
/// </summary>
public abstract partial class EntityEditorWindow : ToolWindow
{
private readonly string kind;
private readonly string folder;
private readonly List<EntityField> fields;
private readonly Dictionary<EntityField, Control> controls = new();
private readonly Dictionary<EntityField, LineEdit> keyEdits = new();
private readonly Dictionary<EntityField, Dictionary<string, LineEdit>> textEdits = new();
private readonly Dictionary<EntityField, TextureRect> previews = new();
private readonly Dictionary<string, Texture2D> icons = new();
private readonly List<string> mods = new();
private OptionButton modPicker;
private FilteredList list;
private VBoxContainer form;
private Label emptyHint;
private Label fileLabel;
private LineEdit idEdit;
private CodeEdit otherEdit;
private EntityDocument document;
private EntityRecord selected;
private List<string> languages = new();
private string modId;
private bool suppress;
private bool editing;
/// <summary>Builds the window for entities of <paramref name="entityKind"/>; new ones go to <c>&lt;folder&gt;/&lt;mod&gt;.json</c>.</summary>
protected EntityEditorWindow(string titleKey, string entityKind, string entityFolder, List<EntityField> entityFields) : base(titleKey)
{
kind = entityKind;
folder = entityFolder;
fields = entityFields;
CthangoverTheme.Apply(this);
BuildUi();
SetHint(ToolUi.Tr("tools/entity_editor/hint"));
FillMods();
}
/// <summary>A new entity's JSON: the kind's required fields with starting values.</summary>
protected abstract JsonObject CreateEntity(string id);
/// <summary>Id a new entity starts from (numbered when taken), e.g. <c>&lt;mod&gt;/new_item</c>.</summary>
protected abstract string NewIdBase(string mod);
/// <summary>The edited mod, or null.</summary>
public string ModId => modId;
/// <summary>The selected entity's id, or null.</summary>
public string SelectedId => selected?.Id;
/// <summary>The edited entities (null before a mod is opened).</summary>
public EntityDocument Document => document;
// ── layout ─────────────────────────────────────────────────
private void BuildUi()
{
var outer = CreateFillContainer();
AddChild(outer);
outer.AddChild(BuildToolbar());
var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill };
outer.AddChild(main);
var listColumn = new VBoxContainer { SizeFlagsVertical = Control.SizeFlags.ExpandFill };
listColumn.AddChild(ToolUi.Section("tools/entity_editor/section_list"));
list = new FilteredList(320f, "tools/entity_editor/search");
list.SizeFlagsVertical = Control.SizeFlags.ExpandFill;
list.Chosen += OnChosen;
listColumn.AddChild(list);
var listPanel = CreateSidebar(listColumn, 320);
main.AddChild(listPanel);
var scroll = new ScrollContainer { HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled, SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
main.AddChild(scroll);
var column = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
column.AddThemeConstantOverride("separation", UiTokens.Space2);
scroll.AddChild(column);
emptyHint = ToolUi.Hint(ToolUi.Tr("tools/entity_editor/no_entity"));
column.AddChild(emptyHint);
form = new VBoxContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
form.AddThemeConstantOverride("separation", UiTokens.Space2);
column.AddChild(form);
BuildForm();
}
private HBoxContainer BuildToolbar()
{
var toolbar = CreateToolbar();
modPicker = new OptionButton { TooltipText = ToolUi.Tr("tools/entity_editor/mod_tip") };
modPicker.ItemSelected += OnModPicked;
toolbar.AddChild(modPicker);
toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip"));
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/entity_editor/new", AddNew, ButtonKind.Secondary, "tools/entity_editor/new_tip"));
toolbar.AddChild(ToolUi.Button("tools/entity_editor/duplicate", DuplicateSelected, ButtonKind.Secondary, "tools/entity_editor/duplicate_tip"));
toolbar.AddChild(ToolUi.Button("tools/entity_editor/delete", DeleteSelected, ButtonKind.Secondary, "tools/entity_editor/delete_tip"));
toolbar.AddChild(ToolUi.Divider());
toolbar.AddChild(ToolUi.Button("tools/entity_editor/undo", Undo, ButtonKind.Secondary, "tools/entity_editor/undo_tip"));
toolbar.AddChild(ToolUi.Button("tools/entity_editor/redo", Redo, ButtonKind.Secondary, "tools/entity_editor/redo_tip"));
return toolbar;
}
private void BuildForm()
{
fileLabel = ToolUi.Hint(string.Empty);
form.AddChild(fileLabel);
idEdit = ToolUi.Field(form, "tools/entity_editor/id", ToolUi.Text("", OnTextChanged), "tools/entity_editor/id_tip");
idEdit.AddThemeFontOverride("font", ToolUi.Mono());
foreach (var field in fields)
BuildField(field);
form.AddChild(ToolUi.Section("tools/entity_editor/section_other"));
form.AddChild(ToolUi.Hint(ToolUi.Tr("tools/entity_editor/other_hint")));
otherEdit = ToolUi.Code();
otherEdit.CustomMinimumSize = new Vector2(0, 160);
otherEdit.SizeFlagsVertical = Control.SizeFlags.Fill;
otherEdit.TextChanged += OnOtherChanged;
form.AddChild(otherEdit);
}
private void BuildField(EntityField field)
{
switch (field.Kind)
{
case EntityFieldKind.Text:
controls[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Text("", OnTextChanged), field.TooltipKey);
break;
case EntityFieldKind.Integer:
controls[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Number(-999999, 999999, 1, OnNumberChanged), field.TooltipKey);
break;
case EntityFieldKind.Choice:
var choice = ToolUi.Field(form, field.LabelKey, new OptionButton(), field.TooltipKey);
choice.ItemSelected += OnChoicePicked;
controls[field] = choice;
break;
case EntityFieldKind.Flags:
case EntityFieldKind.List:
controls[field] = ToolUi.Field(form, field.LabelKey, new HFlowContainer(), field.TooltipKey);
break;
case EntityFieldKind.Picture:
BuildPicture(field);
break;
case EntityFieldKind.LocaleText:
// The texts get their rows when a mod is opened: its languages decide them.
keyEdits[field] = ToolUi.Field(form, field.LabelKey, ToolUi.Text("", OnTextChanged), "tools/entity_editor/locale_key_tip");
keyEdits[field].AddThemeFontOverride("font", ToolUi.Mono());
var texts = new VBoxContainer();
form.AddChild(texts);
controls[field] = texts;
textEdits[field] = new Dictionary<string, LineEdit>();
break;
}
}
private void BuildPicture(EntityField field)
{
var row = new HBoxContainer();
var path = ToolUi.Text(field.Group + "/…", OnPictureTyped);
path.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(path);
var pick = UiKit.IconButton("…", ToolUi.Tr("tools/entity_editor/picture_pick_tip"));
pick.Pressed += () => OpenPicker(field);
row.AddChild(pick);
ToolUi.Field(form, field.LabelKey, row, field.TooltipKey);
controls[field] = path;
var preview = new TextureRect
{
CustomMinimumSize = new Vector2(96, 96),
ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize,
StretchMode = TextureRect.StretchModeEnum.KeepAspectCentered,
TextureFilter = CanvasItem.TextureFilterEnum.Nearest,
SizeFlagsHorizontal = Control.SizeFlags.ShrinkBegin
};
var margin = new MarginContainer();
margin.AddThemeConstantOverride("margin_left", ToolUi.LabelWidth + UiTokens.Space3);
margin.AddChild(preview);
form.AddChild(margin);
previews[field] = preview;
}
// ── mods and the list ──────────────────────────────────────
private void FillMods()
{
mods.Clear();
mods.AddRange(ModFileService.ListWritableMods());
modPicker.Clear();
foreach (var mod in mods)
modPicker.AddItem(mod);
if (mods.Count == 0)
{
SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error);
return;
}
// Start in the first mod that already has such entities: usually the game's own content.
var start = mods.FindIndex(m => LoadDocument(m).Records.Count > 0);
OpenMod(mods[Math.Max(0, start)]);
}
private EntityDocument LoadDocument(string mod)
{
var sources = ModFileService.ListFiles(mod, string.Empty, ".json")
.Select(path => (path, ModFileService.ReadText(mod, path)));
var loaded = EntityDocument.Load(kind, $"{folder}/{mod}.json", sources, out var skipped);
if (skipped.Count > 0)
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/broken_files"), string.Join(", ", skipped)), ToolStatusKind.Error);
return loaded;
}
/// <summary>Opens the entities of a mod (asks first about unsaved changes).</summary>
public void OpenMod(string mod)
{
RunDiscardingChanges(() => DoOpenMod(mod), () => modPicker.Select(mods.IndexOf(modId)));
}
private void DoOpenMod(string mod)
{
modId = mod;
modPicker.Select(mods.IndexOf(mod));
document = LoadDocument(mod);
languages = LocaleFileService.Languages(mod);
foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText))
BuildTextRows(field);
MarkClean();
selected = null;
RefreshList();
Select(document.Records.FirstOrDefault());
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/opened"), document.Records.Count, mod));
}
private void BuildTextRows(EntityField field)
{
var box = (VBoxContainer)controls[field];
ClearChildren(box);
textEdits[field].Clear();
foreach (var language in languages)
{
var label = string.Format(ToolUi.Tr("tools/entity_editor/text_in"), language);
var edit = ToolUi.Text("", OnTextChanged);
var row = new HBoxContainer();
row.AddThemeConstantOverride("separation", UiTokens.Space3);
row.AddChild(new Label { Text = label, CustomMinimumSize = new Vector2(ToolUi.LabelWidth, 0), HorizontalAlignment = HorizontalAlignment.Right });
edit.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill;
row.AddChild(edit);
box.AddChild(row);
textEdits[field][language] = edit;
}
}
private void RefreshList()
{
var first = fields.FirstOrDefault(f => f.Kind == EntityFieldKind.Picture);
var name = fields.FirstOrDefault(f => f.Kind == EntityFieldKind.LocaleText);
list.SetItems(document.Records.Select(r => (r.Id, RowText(r, name), first == null ? null : Icon(first.Group, Str(r.Data, first.Key)))));
list.Select(selected?.Id);
}
private string RowText(EntityRecord record, EntityField nameField)
{
var key = nameField == null ? null : Str(record.Data, nameField.Key);
var text = key == null ? null : document.GetText(languages.FirstOrDefault() ?? "ru", key) ?? LocaleFileService.Read(modId, languages.FirstOrDefault() ?? "ru", key);
return string.IsNullOrWhiteSpace(text) ? record.Id : $"{text} · {record.Id}";
}
private Texture2D Icon(string group, string id)
{
if (string.IsNullOrEmpty(id))
return null;
var cacheKey = group + "|" + id;
if (!icons.TryGetValue(cacheKey, out var texture))
{
texture = ModResourceService.LoadGroupImage(group, id);
icons[cacheKey] = texture;
}
return texture;
}
private void OnChosen(string id) => Select(document?.Find(id));
private void OnModPicked(long index)
{
if (index >= 0 && index < mods.Count && mods[(int)index] != modId)
OpenMod(mods[(int)index]);
}
// ── form ───────────────────────────────────────────────────
/// <summary>Shows an entity in the form (null: none).</summary>
public void Select(EntityRecord record)
{
selected = record;
editing = false;
emptyHint.Visible = record == null;
form.Visible = record != null;
list.Select(record?.Id);
if (record == null)
return;
suppress = true;
fileLabel.Text = string.Format(ToolUi.Tr("tools/entity_editor/file"), modId, record.File);
idEdit.Text = record.Id;
foreach (var field in fields)
ShowField(field, record.Data);
otherEdit.Text = OtherJson(record.Data);
suppress = false;
}
private void ShowField(EntityField field, JsonObject data)
{
switch (field.Kind)
{
case EntityFieldKind.Text:
((LineEdit)controls[field]).Text = Str(data, field.Key) ?? string.Empty;
break;
case EntityFieldKind.Integer:
((SpinBox)controls[field]).Value = data[field.Key] is JsonValue v && v.TryGetValue(out int number) ? number : 0;
break;
case EntityFieldKind.Choice:
FillChoice((OptionButton)controls[field], field, Str(data, field.Key));
break;
case EntityFieldKind.Flags:
FillChecks((HFlowContainer)controls[field], field, (Str(data, field.Key) ?? string.Empty).Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries));
break;
case EntityFieldKind.List:
FillChecks((HFlowContainer)controls[field], field, (data[field.Key] as JsonArray)?.Select(n => n?.ToString()).Where(s => s != null).ToArray() ?? Array.Empty<string>());
break;
case EntityFieldKind.Picture:
var path = Str(data, field.Key) ?? string.Empty;
((LineEdit)controls[field]).Text = path;
previews[field].Texture = ModResourceService.LoadGroupImage(field.Group, path);
break;
case EntityFieldKind.LocaleText:
var key = Str(data, field.Key) ?? string.Empty;
keyEdits[field].Text = key;
foreach (var pair in textEdits[field])
pair.Value.Text = TextOf(pair.Key, key);
break;
}
}
private string TextOf(string language, string key)
{
if (string.IsNullOrEmpty(key))
return string.Empty;
var text = document.GetText(language, key);
if (text != null)
return text;
text = LocaleFileService.Read(modId, language, key) ?? string.Empty;
document.LoadText(language, key, text);
return text;
}
private static void FillChoice(OptionButton picker, EntityField field, string value)
{
var options = (field.Options?.Invoke() ?? Enumerable.Empty<string>()).ToList();
if (!string.IsNullOrEmpty(value) && !options.Contains(value))
options.Insert(0, value);
picker.Clear();
foreach (var option in options)
picker.AddItem(option);
picker.Select(options.IndexOf(value ?? string.Empty));
}
private void FillChecks(HFlowContainer box, EntityField field, string[] values)
{
ClearChildren(box);
var options = (field.Options?.Invoke() ?? Enumerable.Empty<string>()).ToList();
options.AddRange(values.Where(v => !options.Contains(v)));
foreach (var option in options)
{
var check = new CheckBox { Text = option, ButtonPressed = values.Contains(option) };
check.Toggled += OnChecked;
box.AddChild(check);
}
}
private void OnTextChanged(string text) => WriteForm();
private void OnNumberChanged(double value) => WriteForm();
private void OnChoicePicked(long index) => WriteForm();
private void OnChecked(bool on) => WriteForm();
private void OnPictureTyped(string text)
{
WriteForm();
foreach (var pair in previews)
pair.Value.Texture = ModResourceService.LoadGroupImage(pair.Key.Group, ((LineEdit)controls[pair.Key]).Text);
}
private void OpenPicker(EntityField field)
{
if (selected == null)
return;
var dialog = new PicturePickerDialog(field.Group, ((LineEdit)controls[field]).Text);
dialog.Picked += id => ((LineEdit)controls[field]).Text = id;
dialog.Picked += OnPictureTyped;
AddChild(dialog);
dialog.PopupCentered();
}
/// <summary>Copies the form into the selected entity; the first change of a session records an undo step.</summary>
private void WriteForm()
{
if (suppress || selected == null)
return;
if (!editing)
{
document.BeginChange();
editing = true;
}
var data = selected.Data;
data["Id"] = idEdit.Text.Trim();
foreach (var field in fields)
WriteField(field, data);
SetDirty();
RefreshList();
}
private void WriteField(EntityField field, JsonObject data)
{
switch (field.Kind)
{
case EntityFieldKind.Text:
case EntityFieldKind.Picture:
SetOrRemove(data, field.Key, ((LineEdit)controls[field]).Text.Trim());
break;
case EntityFieldKind.Integer:
var number = (int)((SpinBox)controls[field]).Value;
if (number != 0 || data.ContainsKey(field.Key))
data[field.Key] = number;
break;
case EntityFieldKind.Choice:
var picker = (OptionButton)controls[field];
if (picker.Selected >= 0)
data[field.Key] = picker.GetItemText(picker.Selected);
break;
case EntityFieldKind.Flags:
var flags = Checked((HFlowContainer)controls[field]);
if (flags.Count > 0 || data.ContainsKey(field.Key))
data[field.Key] = flags.Count == 0 ? "None" : string.Join(",", flags);
break;
case EntityFieldKind.List:
var values = Checked((HFlowContainer)controls[field]);
if (values.Count > 0 || data.ContainsKey(field.Key))
data[field.Key] = new JsonArray(values.Select(v => (JsonNode)v).ToArray());
break;
case EntityFieldKind.LocaleText:
var key = keyEdits[field].Text.Trim();
SetOrRemove(data, field.Key, key);
if (key.Length == 0)
break;
foreach (var pair in textEdits[field])
document.SetText(pair.Key, key, pair.Value.Text);
break;
}
}
private static List<string> Checked(HFlowContainer box)
=> box.GetChildren().OfType<CheckBox>().Where(c => c.ButtonPressed).Select(c => c.Text).ToList();
/// <summary>Takes the children out of the tree at once (a search of the tree must not find them) and frees them.</summary>
private static void ClearChildren(Node box)
{
foreach (var child in box.GetChildren())
{
box.RemoveChild(child);
child.QueueFree();
}
}
private static void SetOrRemove(JsonObject data, string key, string value)
{
if (value.Length > 0)
data[key] = value;
else
data.Remove(key);
}
/// <summary>The properties no field shows, as an indented JSON object.</summary>
private string OtherJson(JsonObject data)
{
var other = new JsonObject();
foreach (var pair in data.Where(p => !IsFormKey(p.Key)))
other[pair.Key] = pair.Value?.DeepClone();
return EntityDocument.Serialize(other).TrimEnd();
}
private bool IsFormKey(string key) => key == "Id" || fields.Any(f => f.Key == key);
private void OnOtherChanged()
{
if (suppress || selected == null)
return;
JsonObject other;
try
{
other = JsonNode.Parse(otherEdit.Text) as JsonObject;
}
catch (JsonException ex)
{
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/other_invalid"), ex.Message), ToolStatusKind.Error);
return;
}
if (other == null)
return;
if (!editing)
{
document.BeginChange();
editing = true;
}
var data = selected.Data;
foreach (var key in data.Select(p => p.Key).Where(k => !IsFormKey(k)).ToList())
data.Remove(key);
foreach (var pair in other.Where(p => !IsFormKey(p.Key)).ToList())
data[pair.Key] = pair.Value?.DeepClone();
SetDirty();
SetStatus(ToolUi.Tr("tools/entity_editor/other_applied"));
}
// ── commands ───────────────────────────────────────────────
private void AddNew()
{
if (document == null)
return;
document.BeginChange();
var id = document.FreeId(NewIdBase(modId));
var entity = CreateEntity(id);
foreach (var field in fields.Where(f => f.Kind == EntityFieldKind.LocaleText && f.KeyPattern != null))
{
var key = string.Format(field.KeyPattern, id);
entity[field.Key] = key;
foreach (var language in languages)
document.SetText(language, key, field.Key == "Name" ? id.Substring(id.LastIndexOf('/') + 1) : string.Empty);
}
var record = document.Add(entity);
SetDirty();
RefreshList();
Select(record);
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/added"), id, record.File));
}
private void DuplicateSelected()
{
if (selected == null)
return;
document.BeginChange();
var record = document.Duplicate(selected, document.FreeId(selected.Id));
SetDirty();
RefreshList();
Select(record);
}
private void DeleteSelected()
{
if (selected == null)
return;
ShowConfirm("tools/entity_editor/delete_title", "tools/entity_editor/delete_text", ConfirmDelete);
}
private void ConfirmDelete()
{
if (selected == null)
return;
document.BeginChange();
var id = selected.Id;
document.Remove(selected);
SetDirty();
Select(null);
RefreshList();
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/deleted"), id));
}
private void Undo() => StepHistory(document != null && document.Undo());
private void Redo() => StepHistory(document != null && document.Redo());
private void StepHistory(bool stepped)
{
if (!stepped)
return;
// The document holds new JSON objects now: find the selection again by id.
var id = selected?.Id;
SetDirty();
RefreshList();
Select(document.Find(id) ?? document.Records.FirstOrDefault());
}
/// <summary>Ctrl+S.</summary>
protected override void OnSaveShortcut() => Save();
private void OnSavePressed() => Save();
/// <summary>
/// Writes the changed data and locale files and makes the game re-read
/// them: the entity cache is dropped (factories rebuild from the new
/// snapshot) and the current language's texts are reloaded.
/// </summary>
public bool Save()
{
if (document == null)
return false;
var problems = document.Validate();
if (problems.Count > 0)
{
SetStatus(string.Format(ToolUi.Tr(problems[0].Key), problems[0].Argument), ToolStatusKind.Error);
return false;
}
var changedFiles = document.ChangedFiles();
var changedTexts = document.ChangedTexts();
foreach (var (path, text) in changedFiles)
{
if (!ModFileService.WriteText(modId, path, text))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return false;
}
}
foreach (var (language, key, value) in changedTexts)
LocaleFileService.Write(modId, language, key, value);
document.MarkSaved();
MarkClean();
editing = false;
ModManager.Instance.GetResolver<EntityResolver>()?.ClearCache();
ModManager.Instance.GetResolver<FileListResolver>()?.ClearCache();
icons.Clear();
if (changedTexts.Count > 0)
LocaleFileService.ReloadGame();
GameLogger.Log("TOOLS", $"entity editor: saved {changedFiles.Count} {kind} file(s), {changedTexts.Count} text(s) of '{modId}'");
SetStatus(string.Format(ToolUi.Tr("tools/entity_editor/saved"), changedFiles.Count, changedTexts.Count, modId), ToolStatusKind.Ok);
return true;
}
/// <summary>Shortcuts: Ctrl+Z / Ctrl+Y undo and redo, Ctrl+N adds, Ctrl+D duplicates.</summary>
protected override bool HandleKey(InputEventKey key)
{
if (!key.CtrlPressed)
return false;
switch (key.Keycode)
{
case Key.Z when key.ShiftPressed:
case Key.Y:
Redo();
return true;
case Key.Z:
Undo();
return true;
case Key.N:
AddNew();
return true;
case Key.D:
DuplicateSelected();
return true;
}
return false;
}
private static string Str(JsonObject data, string key) => data[key]?.ToString();
}
}
+35
View File
@@ -0,0 +1,35 @@
using System;
using System.Collections.Generic;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// One property of an entity as an <see cref="EntityEditorWindow"/> form shows
/// it. A concrete editor lists its fields; properties no field names are
/// shown and edited as raw JSON ("other fields"), so the form never hides or
/// drops data a mod added.
/// </summary>
public class EntityField
{
/// <summary>JSON property name.</summary>
public string Key { get; init; }
/// <summary>Locale key of the label.</summary>
public string LabelKey { get; init; }
/// <summary>Locale key of the tooltip, or null.</summary>
public string TooltipKey { get; init; }
/// <summary>How the value is edited.</summary>
public EntityFieldKind Kind { get; init; }
/// <summary>Picture: the file group of the pictures (<c>items</c>, <c>characters</c>…).</summary>
public string Group { get; init; }
/// <summary>Choice, flags, list: the values offered (read when the form is built).</summary>
public Func<IEnumerable<string>> Options { get; init; }
/// <summary>Locale text: how a new entity's key is made from its id (<c>{0}</c> = id).</summary>
public string KeyPattern { get; init; }
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace Cthangover.Tools.EntityEditor
{
/// <summary>How an <see cref="EntityField"/> is shown and stored.</summary>
public enum EntityFieldKind
{
/// <summary>One line of text.</summary>
Text,
/// <summary>A locale key with its text in every language of the mod.</summary>
LocaleText,
/// <summary>A whole number.</summary>
Integer,
/// <summary>A picture path in a file group, with a preview and a picker.</summary>
Picture,
/// <summary>One value of a list (values outside the list stay possible).</summary>
Choice,
/// <summary>Several names of a list written as <c>"A,B"</c> (an enum with flags).</summary>
Flags,
/// <summary>A JSON array of strings picked from a list.</summary>
List
}
}
+24
View File
@@ -0,0 +1,24 @@
using System.Text.Json.Nodes;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>One entity of an <see cref="EntityDocument"/>: its JSON object inside the <c>Items</c> array of its file.</summary>
public class EntityRecord
{
/// <summary>Mod-relative path of the file holding the entity.</summary>
public string File { get; }
/// <summary>The entity itself; unknown properties are kept as they are.</summary>
public JsonObject Data { get; }
/// <summary>The entity's <c>Id</c> (empty when missing).</summary>
public string Id => Data["Id"]?.ToString() ?? string.Empty;
/// <summary>Wraps an entity of a file.</summary>
public EntityRecord(string file, JsonObject data)
{
File = file;
Data = data;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using System;
using System.Linq;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
namespace Cthangover.Tools.EntityEditor
{
/// <summary>
/// Grid of every picture of a file group with a search, for a picture field
/// of an entity form: a double click or OK picks one. The pictures are read
/// when the dialog opens, so a file just dropped into a mod folder shows up
/// (after the game re-reads its files: the editor's Save does that).
/// </summary>
public partial class PicturePickerDialog : AcceptDialog
{
private readonly FilteredList list;
/// <summary>A picture was picked: its id in the group.</summary>
public event Action<string> Picked;
/// <summary>Builds the dialog for the pictures of <paramref name="group"/>.</summary>
public PicturePickerDialog(string group, string current)
{
Theme = ToolTheme.Get();
Title = string.Format(ToolUi.Tr("tools/entity_editor/picture_pick_title"), group);
MinSize = new Vector2I(640, 520);
list = new FilteredList(420f);
list.UseIconGrid(64);
list.SetItems(ModResourceService.GetGroupImageIds(group)
.Select(id => (id, id.Substring(id.LastIndexOf('/') + 1), ModResourceService.LoadGroupImage(group, id))));
list.Select(current);
list.Activated += OnActivated;
AddChild(list);
Confirmed += OnConfirmed;
CloseRequested += QueueFree;
Canceled += QueueFree;
}
private void OnActivated(string id)
{
if (id == null)
return;
Picked?.Invoke(id);
Hide();
QueueFree();
}
private void OnConfirmed()
{
if (list.SelectedId != null)
Picked?.Invoke(list.SelectedId);
QueueFree();
}
}
}
+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();
}
}
}
+119
View File
@@ -0,0 +1,119 @@
namespace Cthangover.Tools.EntityEditor.Tests
{
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using Cthangover.Core.Mods.Testing;
/// <summary>The data editors' document: files of one kind, add/copy/delete, texts, undo and what gets written.</summary>
public class EntityDocumentTests
{
private const string Items = "{\n \"Kind\": \"item\",\n \"Items\": [\n {\n \"Id\": \"food/bread\",\n \"Name\": \"items/food/bread/name\",\n \"Cost\": 2,\n \"heal\": 5\n }\n ]\n}\n";
private static EntityDocument Load(out List<string> skipped) => EntityDocument.Load("item", "items/my_mod.json", new[]
{
("items/food/bread.json", Items),
("characters/hero.json", "{\"Kind\": \"character\", \"Items\": [{\"Id\": \"hero\"}]}"),
("items/broken.json", "{\"Kind\": \"item\", \"Items\": [ {"),
("scenes/town.json", "{\"Name\": \"town\"}")
}, out skipped);
[ModTest("entity_document/load_takes_only_its_kind")]
static void LoadTakesOnlyItsKind(ModTestContext ctx)
{
var document = Load(out var skipped);
ModAssert.Equal(1, document.Records.Count, "only the item file");
ModAssert.Equal("food/bread", document.Records[0].Id, "its entity");
ModAssert.Equal("items/food/bread.json", document.Records[0].File, "its file");
ModAssert.Equal(1, skipped.Count, "the broken file of this kind is reported");
ModAssert.Equal("items/broken.json", skipped[0], "by path");
ModAssert.Equal(0, document.ChangedFiles().Count, "nothing to write after loading");
}
[ModTest("entity_document/unknown_fields_survive")]
static void UnknownFieldsSurvive(ModTestContext ctx)
{
var document = Load(out _);
document.Records[0].Data["Cost"] = 3;
var changed = document.ChangedFiles();
ModAssert.Equal(1, changed.Count, "the changed file");
var root = JsonNode.Parse(changed[0].Text).AsObject();
var item = root["Items"][0].AsObject();
ModAssert.Equal("3", item["Cost"].ToString(), "the edit");
ModAssert.Equal("5", item["heal"].ToString(), "another mod's field is kept");
ModAssert.Equal("item", root["Kind"].ToString(), "the kind is kept");
ModAssert.True(changed[0].Text.EndsWith("}\n"), "LF line break at the end");
ModAssert.Equal(Items.Replace("\"Cost\": 2", "\"Cost\": 3"), changed[0].Text, "the rest of the file is byte for byte the same");
}
[ModTest("entity_document/add_copy_delete")]
static void AddCopyDelete(ModTestContext ctx)
{
var document = Load(out _);
var added = document.Add(new JsonObject { ["Id"] = document.FreeId("food/bread") });
ModAssert.Equal("food/bread_2", added.Id, "a taken id gets a number");
ModAssert.Equal("items/my_mod.json", added.File, "new entries go to the new file");
var copy = document.Duplicate(document.Find("food/bread"), document.FreeId("food/bread"));
ModAssert.Equal("food/bread_3", copy.Id, "the copy gets a free id");
ModAssert.Equal("items/food/bread.json", copy.File, "and stays in the original's file");
ModAssert.Equal("5", copy.Data["heal"].ToString(), "with all fields");
ModAssert.Equal("food/bread", document.Records[0].Id, "the original first");
ModAssert.Equal("food/bread_3", document.Records[1].Id, "the copy right after it");
document.Remove(document.Find("food/bread"));
ModAssert.Null(document.Find("food/bread"), "removed");
var files = document.ChangedFiles().Select(f => f.Path).OrderBy(p => p).ToList();
ModAssert.Equal(2, files.Count, "both files are written");
ModAssert.Equal("items/food/bread.json", files[0], "the changed one");
ModAssert.Equal("items/my_mod.json", files[1], "the new one");
ModAssert.True(document.ChangedFiles().First(f => f.Path == "items/my_mod.json").Text.Contains("\"Kind\": \"item\""), "a new file declares the kind");
}
[ModTest("entity_document/undo_restores_data_and_texts")]
static void UndoRestoresDataAndTexts(ModTestContext ctx)
{
var document = Load(out _);
document.LoadText("ru", "items/food/bread/name", "Хлеб");
document.BeginChange();
document.Records[0].Data["Cost"] = 9;
document.SetText("ru", "items/food/bread/name", "Каравай");
document.BeginChange();
document.Add(new JsonObject { ["Id"] = "food/cake" });
ModAssert.True(document.Undo(), "undo the add");
ModAssert.Null(document.Find("food/cake"), "the new entry is gone");
ModAssert.True(document.Undo(), "undo the edit");
ModAssert.Equal("2", document.Find("food/bread").Data["Cost"].ToString(), "the cost is back");
ModAssert.Equal("Хлеб", document.GetText("ru", "items/food/bread/name"), "the text is back");
ModAssert.False(document.Undo(), "nothing more");
ModAssert.Equal(0, document.ChangedFiles().Count + document.ChangedTexts().Count, "back to what is on disk");
ModAssert.True(document.Redo(), "redo the edit");
var texts = document.ChangedTexts();
ModAssert.Equal(1, texts.Count, "one changed text");
ModAssert.Equal("ru", texts[0].Language, "its language");
ModAssert.Equal("items/food/bread/name", texts[0].Key, "its key");
ModAssert.Equal("Каравай", texts[0].Value, "its value");
document.MarkSaved();
ModAssert.Equal(0, document.ChangedFiles().Count + document.ChangedTexts().Count, "nothing left after saving");
}
[ModTest("entity_document/validate_reports_ids")]
static void ValidateReportsIds(ModTestContext ctx)
{
var document = Load(out _);
ModAssert.Equal(0, document.Validate().Count, "a loaded mod is valid");
document.Add(new JsonObject { ["Id"] = "FOOD/BREAD" });
document.Add(new JsonObject { ["Name"] = "x" });
var problems = document.Validate();
ModAssert.Equal(2, problems.Count, "an empty id and a duplicate");
ModAssert.Equal("tools/entity_editor/problem_id", problems[0].Key, "empty id first");
ModAssert.Equal("tools/entity_editor/problem_duplicate", problems[1].Key, "a duplicate ignores case");
}
}
}
+32
View File
@@ -0,0 +1,32 @@
namespace Cthangover.Tools.Services.Tests
{
using Cthangover.Core.Mods.Testing;
/// <summary>Changing one key of a .properties text without touching the rest.</summary>
public class PropertiesTextTests
{
private const string Text = "# items\nitems/bread/name = Хлеб\nitems/bread/desc=Свежий\n\nitems/cake/name = Торт\n";
[ModTest("properties_text/get_reads_like_the_loader")]
static void GetReadsLikeTheLoader(ModTestContext ctx)
{
ModAssert.Equal("Хлеб", PropertiesText.Get(Text, "items/bread/name"), "spaces around '=' are trimmed");
ModAssert.Equal("Свежий", PropertiesText.Get(Text, "items/bread/desc"), "no spaces work too");
ModAssert.Null(PropertiesText.Get(Text, "items/pie/name"), "a missing key");
ModAssert.Null(PropertiesText.Get(null, "x"), "no text");
ModAssert.Equal("b", PropertiesText.Get("x = a\nx = b\n", "x"), "the last line wins");
ModAssert.Equal(3, PropertiesText.Keys(Text).Count, "three keys, the comment is not one");
}
[ModTest("properties_text/set_changes_one_line")]
static void SetChangesOneLine(ModTestContext ctx)
{
ModAssert.Equal(Text.Replace("= Хлеб", "= Булка"), PropertiesText.Set(Text, "items/bread/name", "Булка"), "only that line changes");
ModAssert.Equal(Text + "items/pie/name = Пирог\n", PropertiesText.Set(Text, "items/pie/name", "Пирог"), "a new key is appended");
ModAssert.Equal("a = 1\nb = 2\n", PropertiesText.Set("a = 1", "b", "2"), "a missing final line break is added first");
ModAssert.Equal("a = 1\r\nb = 2\r\n", PropertiesText.Set("a = 1\r\n", "b", "2"), "CRLF text stays CRLF");
ModAssert.Equal("a = x\r\n", PropertiesText.Set("a = 1\r\n", "a", "x"), "a CRLF line is rewritten with its CR");
ModAssert.Equal("a = one two\n", PropertiesText.Set("", "a", "one\ntwo"), "a line break in a value becomes a space");
}
}
}