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

+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();
}
}
}