diff --git a/locale/ui_en.properties b/locale/ui_en.properties
index 9d7e8f9..93128b6 100644
--- a/locale/ui_en.properties
+++ b/locale/ui_en.properties
@@ -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
diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties
index d27f4ae..eb7e9ad 100644
--- a/locale/ui_ru.properties
+++ b/locale/ui_ru.properties
@@ -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} встречается несколько раз
diff --git a/manifest.json b/manifest.json
index 955917f..cb86823 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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"]},
diff --git a/src/EntityEditor/EntityDocument.cs b/src/EntityEditor/EntityDocument.cs
new file mode 100644
index 0000000..c0b8fb8
--- /dev/null
+++ b/src/EntityEditor/EntityDocument.cs
@@ -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
+{
+ ///
+ /// The entities of one kind ("Kind": "item", "character"…) in one
+ /// mod, as an editor changes them, free of any UI.
+ ///
+ /// 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#
+ /// (heal, verb, Charset), 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 ,
+ /// created on the first save when the mod has no such file.
+ ///
+ /// Localized texts are part of the document too (): 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.
+ ///
+ public class EntityDocument
+ {
+ private const int HistoryLimit = 200;
+
+ private static readonly JsonSerializerOptions WriteOptions = new()
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ private readonly Dictionary files = new(StringComparer.Ordinal);
+ private readonly Dictionary savedText = new(StringComparer.Ordinal);
+ private readonly List records = new();
+ private readonly List undo = new();
+ private readonly List redo = new();
+ private Dictionary texts = new(StringComparer.Ordinal);
+ private Dictionary savedTexts = new(StringComparer.Ordinal);
+
+ /// Entity kind of the document.
+ public string Kind { get; }
+
+ /// File new entities are added to.
+ public string NewFile { get; }
+
+ /// The entities in file order (files sorted by path).
+ public IReadOnlyList Records => records;
+
+ /// True when there is a step to undo.
+ public bool CanUndo => undo.Count > 0;
+
+ /// True when there is a step to redo.
+ public bool CanRedo => redo.Count > 0;
+
+ private EntityDocument(string kind, string newFile)
+ {
+ Kind = kind;
+ NewFile = newFile;
+ }
+
+ ///
+ /// 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 only when they declare
+ /// this kind but cannot be read, so the window can name them.
+ ///
+ public static EntityDocument Load(string kind, string newFile, IEnumerable<(string Path, string Text)> sources, out List skipped)
+ {
+ var document = new EntityDocument(kind, newFile);
+ skipped = new List();
+ 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;
+ }
+
+ /// Paths of the document's files.
+ public IEnumerable Files => files.Keys;
+
+ /// Adds an entity to (the file is created when missing) and returns it.
+ 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);
+ }
+
+ /// A copy of with id , placed right after it in its file.
+ 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);
+ }
+
+ /// Removes an entity from its file (the file stays, maybe with no items).
+ public void Remove(EntityRecord record)
+ {
+ ((JsonArray)files[record.File]["Items"]).Remove(record.Data);
+ Rebuild();
+ }
+
+ /// when no entity has it, else wanted_2, wanted_3…
+ 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;
+ }
+
+ /// The record with this id, or null.
+ public EntityRecord Find(string id) => records.FirstOrDefault(r => r.Id == id);
+
+ /// A localized text the document holds (see ), or null when it was never set or loaded.
+ public string GetText(string language, string key)
+ => texts.TryGetValue(TextKey(language, key), out var value) ? value : null;
+
+ /// Remembers the text the mod's files have now (loading, not an edit).
+ public void LoadText(string language, string key, string value)
+ {
+ var textKey = TextKey(language, key);
+ texts[textKey] = value;
+ savedTexts[textKey] = value;
+ }
+
+ /// Changes a localized text; it is written on save.
+ public void SetText(string language, string key, string value) => texts[TextKey(language, key)] = value ?? string.Empty;
+
+ /// Remembers the current state as one undo step and forgets the redo branch.
+ public void BeginChange()
+ {
+ undo.Add(Snapshot());
+ if (undo.Count > HistoryLimit)
+ undo.RemoveAt(0);
+ redo.Clear();
+ }
+
+ /// Steps back; false when there is nothing to undo.
+ public bool Undo() => Swap(undo, redo);
+
+ /// Steps forward again; false when there is nothing to redo.
+ public bool Redo() => Swap(redo, undo);
+
+ /// Files whose text changed since loading or the last .
+ 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();
+
+ /// Texts changed since loading or the last : language, key, value.
+ 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();
+
+ /// The current files and texts are what is on disk now.
+ public void MarkSaved()
+ {
+ foreach (var (path, text) in ChangedFiles())
+ savedText[path] = text;
+ savedTexts = new Dictionary(texts, StringComparer.Ordinal);
+ }
+
+ /// Problems that would break the game's data, as locale keys with an argument; empty when it can be saved.
+ 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;
+ }
+
+ /// 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.
+ 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(f.Key, f.Value.DeepClone()))),
+ ["texts"] = new JsonObject(texts.Select(t => new KeyValuePair(t.Key, t.Value)))
+ };
+ return state.ToJsonString();
+ }
+
+ private bool Swap(List from, List 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;
+ }
+ }
+}
diff --git a/src/EntityEditor/EntityEditorWindow.cs b/src/EntityEditor/EntityEditorWindow.cs
new file mode 100644
index 0000000..d9e8129
--- /dev/null
+++ b/src/EntityEditor/EntityEditorWindow.cs
@@ -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
+{
+ ///
+ /// 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 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.
+ ///
+ /// 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.
+ ///
+ /// Every change is undoable (Ctrl+Z / Ctrl+Y): one undo step per editing
+ /// session of one entity, and one per add, duplicate or delete.
+ ///
+ public abstract partial class EntityEditorWindow : ToolWindow
+ {
+ private readonly string kind;
+ private readonly string folder;
+ private readonly List fields;
+ private readonly Dictionary controls = new();
+ private readonly Dictionary keyEdits = new();
+ private readonly Dictionary> textEdits = new();
+ private readonly Dictionary previews = new();
+ private readonly Dictionary icons = new();
+ private readonly List 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 languages = new();
+ private string modId;
+ private bool suppress;
+ private bool editing;
+
+ /// Builds the window for entities of ; new ones go to <folder>/<mod>.json.
+ protected EntityEditorWindow(string titleKey, string entityKind, string entityFolder, List entityFields) : base(titleKey)
+ {
+ kind = entityKind;
+ folder = entityFolder;
+ fields = entityFields;
+ CthangoverTheme.Apply(this);
+ BuildUi();
+ SetHint(ToolUi.Tr("tools/entity_editor/hint"));
+ FillMods();
+ }
+
+ /// A new entity's JSON: the kind's required fields with starting values.
+ protected abstract JsonObject CreateEntity(string id);
+
+ /// Id a new entity starts from (numbered when taken), e.g. <mod>/new_item.
+ protected abstract string NewIdBase(string mod);
+
+ /// The edited mod, or null.
+ public string ModId => modId;
+
+ /// The selected entity's id, or null.
+ public string SelectedId => selected?.Id;
+
+ /// The edited entities (null before a mod is opened).
+ 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();
+ 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;
+ }
+
+ /// Opens the entities of a mod (asks first about unsaved changes).
+ 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 ───────────────────────────────────────────────────
+
+ /// Shows an entity in the form (null: none).
+ 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());
+ 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()).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()).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();
+ }
+
+ /// Copies the form into the selected entity; the first change of a session records an undo step.
+ 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 Checked(HFlowContainer box)
+ => box.GetChildren().OfType().Where(c => c.ButtonPressed).Select(c => c.Text).ToList();
+
+ /// Takes the children out of the tree at once (a search of the tree must not find them) and frees them.
+ 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);
+ }
+
+ /// The properties no field shows, as an indented JSON object.
+ 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());
+ }
+
+ /// Ctrl+S.
+ protected override void OnSaveShortcut() => Save();
+
+ private void OnSavePressed() => Save();
+
+ ///
+ /// 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.
+ ///
+ 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()?.ClearCache();
+ ModManager.Instance.GetResolver()?.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;
+ }
+
+ /// Shortcuts: Ctrl+Z / Ctrl+Y undo and redo, Ctrl+N adds, Ctrl+D duplicates.
+ 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();
+ }
+}
diff --git a/src/EntityEditor/EntityField.cs b/src/EntityEditor/EntityField.cs
new file mode 100644
index 0000000..b6c74f8
--- /dev/null
+++ b/src/EntityEditor/EntityField.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+
+namespace Cthangover.Tools.EntityEditor
+{
+ ///
+ /// One property of an entity as an 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.
+ ///
+ public class EntityField
+ {
+ /// JSON property name.
+ public string Key { get; init; }
+
+ /// Locale key of the label.
+ public string LabelKey { get; init; }
+
+ /// Locale key of the tooltip, or null.
+ public string TooltipKey { get; init; }
+
+ /// How the value is edited.
+ public EntityFieldKind Kind { get; init; }
+
+ /// Picture: the file group of the pictures (items, characters…).
+ public string Group { get; init; }
+
+ /// Choice, flags, list: the values offered (read when the form is built).
+ public Func> Options { get; init; }
+
+ /// Locale text: how a new entity's key is made from its id ({0} = id).
+ public string KeyPattern { get; init; }
+ }
+}
diff --git a/src/EntityEditor/EntityFieldKind.cs b/src/EntityEditor/EntityFieldKind.cs
new file mode 100644
index 0000000..df5aaf3
--- /dev/null
+++ b/src/EntityEditor/EntityFieldKind.cs
@@ -0,0 +1,21 @@
+namespace Cthangover.Tools.EntityEditor
+{
+ /// How an is shown and stored.
+ public enum EntityFieldKind
+ {
+ /// One line of text.
+ Text,
+ /// A locale key with its text in every language of the mod.
+ LocaleText,
+ /// A whole number.
+ Integer,
+ /// A picture path in a file group, with a preview and a picker.
+ Picture,
+ /// One value of a list (values outside the list stay possible).
+ Choice,
+ /// Several names of a list written as "A,B" (an enum with flags).
+ Flags,
+ /// A JSON array of strings picked from a list.
+ List
+ }
+}
diff --git a/src/EntityEditor/EntityRecord.cs b/src/EntityEditor/EntityRecord.cs
new file mode 100644
index 0000000..053f07b
--- /dev/null
+++ b/src/EntityEditor/EntityRecord.cs
@@ -0,0 +1,24 @@
+using System.Text.Json.Nodes;
+
+namespace Cthangover.Tools.EntityEditor
+{
+ /// One entity of an : its JSON object inside the Items array of its file.
+ public class EntityRecord
+ {
+ /// Mod-relative path of the file holding the entity.
+ public string File { get; }
+
+ /// The entity itself; unknown properties are kept as they are.
+ public JsonObject Data { get; }
+
+ /// The entity's Id (empty when missing).
+ public string Id => Data["Id"]?.ToString() ?? string.Empty;
+
+ /// Wraps an entity of a file.
+ public EntityRecord(string file, JsonObject data)
+ {
+ File = file;
+ Data = data;
+ }
+ }
+}
diff --git a/src/EntityEditor/PicturePickerDialog.cs b/src/EntityEditor/PicturePickerDialog.cs
new file mode 100644
index 0000000..106c5c9
--- /dev/null
+++ b/src/EntityEditor/PicturePickerDialog.cs
@@ -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
+{
+ ///
+ /// 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).
+ ///
+ public partial class PicturePickerDialog : AcceptDialog
+ {
+ private readonly FilteredList list;
+
+ /// A picture was picked: its id in the group.
+ public event Action Picked;
+
+ /// Builds the dialog for the pictures of .
+ 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();
+ }
+ }
+}
diff --git a/src/Services/LocaleFileService.cs b/src/Services/LocaleFileService.cs
new file mode 100644
index 0000000..e650d43
--- /dev/null
+++ b/src/Services/LocaleFileService.cs
@@ -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
+{
+ ///
+ /// Texts of one mod's locale/*.properties for the editors that show a
+ /// localized name instead of its key. A language's files are
+ /// <lang>.properties and <anything>_<lang>.properties,
+ /// as LocaleLoader reads them.
+ ///
+ /// A write goes into the file of the language that already declares the key;
+ /// a new key goes into <lang>.properties, else the first non-scenario
+ /// file of the language, else a new locale/<lang>.properties.
+ /// Scenario string files (scenarios_*) never get entity texts.
+ ///
+ public static class LocaleFileService
+ {
+ private const string Dir = "locale";
+
+ /// Languages the mod has texts for, plus the game's current one; the current one first.
+ public static List Languages(string modId)
+ {
+ var current = LanguageCode.Normalize(TranslationServer.GetLocale()) ?? "ru";
+ var languages = new List { 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;
+ }
+
+ /// The mod's text of in , or null.
+ 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;
+ }
+
+ /// Sets the text of in ; false when the mod is not writable.
+ 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));
+ }
+
+ /// Reloads the game's texts of the current language, so a saved name shows at once.
+ public static void ReloadGame() => LocaleLoader.LoadLocale(TranslationServer.GetLocale());
+
+ private static List 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;
+ }
+ }
+}
diff --git a/src/Services/ModResourceService.cs b/src/Services/ModResourceService.cs
index 0e4a7d3..211f3d6 100644
--- a/src/Services/ModResourceService.cs
+++ b/src/Services/ModResourceService.cs
@@ -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;
}
+ ///
+ /// Pictures of a file group (items, characters, charsets…):
+ /// group-relative paths without extension, sorted — what an entity's picture
+ /// field holds (food/ration, village/slime).
+ ///
+ public static List GetGroupImageIds(string group)
+ {
+ var ids = new List();
+ 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;
+ }
+
+ ///
+ /// A picture of a file group by the id lists
+ /// (a group/ 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.
+ ///
+ 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;
+ }
+
///
/// Loads a background texture by ID via .
/// Returns null on any error (missing resource, load failure).
diff --git a/src/Services/PropertiesText.cs b/src/Services/PropertiesText.cs
new file mode 100644
index 0000000..f0d1ed7
--- /dev/null
+++ b/src/Services/PropertiesText.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Collections.Generic;
+
+namespace Cthangover.Tools.Services
+{
+ ///
+ /// Reading and changing one key of a .properties text the way
+ /// LocaleLoader reads it: key = value per line, # 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.
+ ///
+ public static class PropertiesText
+ {
+ /// The value of , or null when the text does not declare it (the last line wins, as in the loader).
+ 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;
+ }
+
+ /// All keys declared in the text.
+ public static HashSet Keys(string text)
+ {
+ var keys = new HashSet(StringComparer.Ordinal);
+ foreach (var line in Lines(text))
+ {
+ var key = KeyOf(line);
+ if (key != null)
+ keys.Add(key);
+ }
+ return keys;
+ }
+
+ ///
+ /// The text with set to : every
+ /// line of the key is rewritten in place, or a key = value line is
+ /// appended (after a line break the text may lack). Line endings follow the text.
+ ///
+ 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 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();
+ }
+ }
+}
diff --git a/tests/EntityDocumentTests.cs b/tests/EntityDocumentTests.cs
new file mode 100644
index 0000000..9a2debf
--- /dev/null
+++ b/tests/EntityDocumentTests.cs
@@ -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;
+
+ /// The data editors' document: files of one kind, add/copy/delete, texts, undo and what gets written.
+ 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 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");
+ }
+ }
+}
diff --git a/tests/PropertiesTextTests.cs b/tests/PropertiesTextTests.cs
new file mode 100644
index 0000000..54956e3
--- /dev/null
+++ b/tests/PropertiesTextTests.cs
@@ -0,0 +1,32 @@
+namespace Cthangover.Tools.Services.Tests
+{
+ using Cthangover.Core.Mods.Testing;
+
+ /// Changing one key of a .properties text without touching the rest.
+ 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");
+ }
+ }
+}