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:
1 parent
6ca1fff35f
commit
e1a1d770cf
14 files changed
+1502
-1
No files matched your search
@@ -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/preview_hint = Pick a background to see it.
|
||||||
tools/scene_editor/not_in_scene = (not in the scene)
|
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/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
|
||||||
@@ -305,3 +305,38 @@ tools/scene_editor/pick_scene = Выберите сцену слева или с
|
|||||||
tools/scene_editor/preview_hint = Выберите фон, чтобы посмотреть его.
|
tools/scene_editor/preview_hint = Выберите фон, чтобы посмотреть его.
|
||||||
tools/scene_editor/not_in_scene = (не в сцене)
|
tools/scene_editor/not_in_scene = (не в сцене)
|
||||||
tools/scene_editor/new_line_text = Новая сцена. Замените эту строку своим текстом.
|
tools/scene_editor/new_line_text = Новая сцена. Замените эту строку своим текстом.
|
||||||
|
tools/entity_editor/hint = Слева — список с поиском, справа — поля выбранной записи · Ctrl+N — новая, Ctrl+D — копия · Ctrl+Z/Y — отмена/повтор · Ctrl+S — сохранить, игра сразу видит изменения
|
||||||
|
tools/entity_editor/section_list = Записи мода
|
||||||
|
tools/entity_editor/search = Поиск по названию или id
|
||||||
|
tools/entity_editor/mod_tip = Мод, записи которого правятся; новые записи попадают в его файл
|
||||||
|
tools/entity_editor/new = Новый
|
||||||
|
tools/entity_editor/new_tip = Добавить запись в выбранный мод (Ctrl+N)
|
||||||
|
tools/entity_editor/duplicate = Копия
|
||||||
|
tools/entity_editor/duplicate_tip = Скопировать выбранную запись под новым id (Ctrl+D)
|
||||||
|
tools/entity_editor/delete = Удалить
|
||||||
|
tools/entity_editor/delete_tip = Удалить выбранную запись из файла мода
|
||||||
|
tools/entity_editor/delete_title = Удалить запись?
|
||||||
|
tools/entity_editor/delete_text = Запись исчезнет из файла мода после сохранения. Ссылки на неё в сценариях и картах останутся — проверьте их.
|
||||||
|
tools/entity_editor/undo = Отменить
|
||||||
|
tools/entity_editor/undo_tip = Отменить последнее изменение (Ctrl+Z)
|
||||||
|
tools/entity_editor/redo = Повторить
|
||||||
|
tools/entity_editor/redo_tip = Повторить отменённое (Ctrl+Y)
|
||||||
|
tools/entity_editor/no_entity = Выберите запись слева или создайте новую кнопкой «Новый».
|
||||||
|
tools/entity_editor/file = Мод {0} · файл {1}
|
||||||
|
tools/entity_editor/id = Id
|
||||||
|
tools/entity_editor/id_tip = Уникальный id: по нему запись находят сценарии, карты и другие моды. Мод, который зависит от владельца и даёт тот же id, заменяет запись
|
||||||
|
tools/entity_editor/locale_key_tip = Ключ локализации; тексты ниже пишутся в locale/*.properties мода
|
||||||
|
tools/entity_editor/text_in = текст ({0})
|
||||||
|
tools/entity_editor/picture_pick_tip = Выбрать картинку из папки мода
|
||||||
|
tools/entity_editor/picture_pick_title = Картинки группы «{0}»
|
||||||
|
tools/entity_editor/section_other = Прочие поля
|
||||||
|
tools/entity_editor/other_hint = Поля, которых нет в форме (данные других модов, лут…), как JSON. Правки применяются, как только JSON корректен.
|
||||||
|
tools/entity_editor/other_invalid = Прочие поля: неверный JSON — {0}
|
||||||
|
tools/entity_editor/other_applied = Прочие поля применены
|
||||||
|
tools/entity_editor/opened = {0} записей в моде {1}
|
||||||
|
tools/entity_editor/added = Добавлена запись {0} в {1}
|
||||||
|
tools/entity_editor/deleted = Запись {0} удалена (сохраните, чтобы записать)
|
||||||
|
tools/entity_editor/saved = Сохранено: файлов {0}, текстов {1} в моде {2}; игра перечитала данные
|
||||||
|
tools/entity_editor/broken_files = Не прочитаны файлы с ошибкой JSON: {0}
|
||||||
|
tools/entity_editor/problem_id = У записи в файле {0} пустой id
|
||||||
|
tools/entity_editor/problem_duplicate = Id {0} встречается несколько раз
|
||||||
+4
-1
@@ -3,11 +3,14 @@
|
|||||||
"name": "Editor Tools",
|
"name": "Editor Tools",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"author": "ct",
|
"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",
|
"category": "tools, editor",
|
||||||
"sources": [
|
"sources": [
|
||||||
"src/**/*.cs"
|
"src/**/*.cs"
|
||||||
],
|
],
|
||||||
|
"test_sources": [
|
||||||
|
"tests/*.cs"
|
||||||
|
],
|
||||||
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
|
"depends": ["dialog_base>=1", "interactive_base>=1", "time_base>=1", "map_base>=1", "ui_base>=1"],
|
||||||
"test_suites": [
|
"test_suites": [
|
||||||
{"id": "windows", "activate": ["--itest=tools_windows"]},
|
{"id": "windows", "activate": ["--itest=tools_windows"]},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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><folder>/<mod>.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><mod>/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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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><lang>.properties</c> and <c><anything>_<lang>.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><lang>.properties</c>, else the first non-scenario
|
||||||
|
/// file of the language, else a new <c>locale/<lang>.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
using Cthangover.Core.Factories.Impls;
|
using Cthangover.Core.Factories.Impls;
|
||||||
using Cthangover.Core.Mods;
|
using Cthangover.Core.Mods;
|
||||||
using Cthangover.Core.Mods.Resolvers;
|
using Cthangover.Core.Mods.Resolvers;
|
||||||
|
using Cthangover.Core.Utils;
|
||||||
using Godot;
|
using Godot;
|
||||||
|
|
||||||
namespace Cthangover.Tools.Services
|
namespace Cthangover.Tools.Services
|
||||||
@@ -121,6 +123,41 @@ namespace Cthangover.Tools.Services
|
|||||||
return result;
|
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>
|
/// <summary>
|
||||||
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
|
/// Loads a background texture by ID via <see cref="BackgroundFactory.Instance.Get"/>.
|
||||||
/// Returns <c>null</c> on any error (missing resource, load failure).
|
/// Returns <c>null</c> on any error (missing resource, load failure).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
namespace Cthangover.Tools.EntityEditor.Tests
|
||||||
|
{
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>The data editors' document: files of one kind, add/copy/delete, texts, undo and what gets written.</summary>
|
||||||
|
public class EntityDocumentTests
|
||||||
|
{
|
||||||
|
private const string Items = "{\n \"Kind\": \"item\",\n \"Items\": [\n {\n \"Id\": \"food/bread\",\n \"Name\": \"items/food/bread/name\",\n \"Cost\": 2,\n \"heal\": 5\n }\n ]\n}\n";
|
||||||
|
|
||||||
|
private static EntityDocument Load(out List<string> skipped) => EntityDocument.Load("item", "items/my_mod.json", new[]
|
||||||
|
{
|
||||||
|
("items/food/bread.json", Items),
|
||||||
|
("characters/hero.json", "{\"Kind\": \"character\", \"Items\": [{\"Id\": \"hero\"}]}"),
|
||||||
|
("items/broken.json", "{\"Kind\": \"item\", \"Items\": [ {"),
|
||||||
|
("scenes/town.json", "{\"Name\": \"town\"}")
|
||||||
|
}, out skipped);
|
||||||
|
|
||||||
|
[ModTest("entity_document/load_takes_only_its_kind")]
|
||||||
|
static void LoadTakesOnlyItsKind(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var document = Load(out var skipped);
|
||||||
|
ModAssert.Equal(1, document.Records.Count, "only the item file");
|
||||||
|
ModAssert.Equal("food/bread", document.Records[0].Id, "its entity");
|
||||||
|
ModAssert.Equal("items/food/bread.json", document.Records[0].File, "its file");
|
||||||
|
ModAssert.Equal(1, skipped.Count, "the broken file of this kind is reported");
|
||||||
|
ModAssert.Equal("items/broken.json", skipped[0], "by path");
|
||||||
|
ModAssert.Equal(0, document.ChangedFiles().Count, "nothing to write after loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("entity_document/unknown_fields_survive")]
|
||||||
|
static void UnknownFieldsSurvive(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var document = Load(out _);
|
||||||
|
document.Records[0].Data["Cost"] = 3;
|
||||||
|
|
||||||
|
var changed = document.ChangedFiles();
|
||||||
|
ModAssert.Equal(1, changed.Count, "the changed file");
|
||||||
|
var root = JsonNode.Parse(changed[0].Text).AsObject();
|
||||||
|
var item = root["Items"][0].AsObject();
|
||||||
|
ModAssert.Equal("3", item["Cost"].ToString(), "the edit");
|
||||||
|
ModAssert.Equal("5", item["heal"].ToString(), "another mod's field is kept");
|
||||||
|
ModAssert.Equal("item", root["Kind"].ToString(), "the kind is kept");
|
||||||
|
ModAssert.True(changed[0].Text.EndsWith("}\n"), "LF line break at the end");
|
||||||
|
ModAssert.Equal(Items.Replace("\"Cost\": 2", "\"Cost\": 3"), changed[0].Text, "the rest of the file is byte for byte the same");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("entity_document/add_copy_delete")]
|
||||||
|
static void AddCopyDelete(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var document = Load(out _);
|
||||||
|
var added = document.Add(new JsonObject { ["Id"] = document.FreeId("food/bread") });
|
||||||
|
ModAssert.Equal("food/bread_2", added.Id, "a taken id gets a number");
|
||||||
|
ModAssert.Equal("items/my_mod.json", added.File, "new entries go to the new file");
|
||||||
|
|
||||||
|
var copy = document.Duplicate(document.Find("food/bread"), document.FreeId("food/bread"));
|
||||||
|
ModAssert.Equal("food/bread_3", copy.Id, "the copy gets a free id");
|
||||||
|
ModAssert.Equal("items/food/bread.json", copy.File, "and stays in the original's file");
|
||||||
|
ModAssert.Equal("5", copy.Data["heal"].ToString(), "with all fields");
|
||||||
|
ModAssert.Equal("food/bread", document.Records[0].Id, "the original first");
|
||||||
|
ModAssert.Equal("food/bread_3", document.Records[1].Id, "the copy right after it");
|
||||||
|
|
||||||
|
document.Remove(document.Find("food/bread"));
|
||||||
|
ModAssert.Null(document.Find("food/bread"), "removed");
|
||||||
|
var files = document.ChangedFiles().Select(f => f.Path).OrderBy(p => p).ToList();
|
||||||
|
ModAssert.Equal(2, files.Count, "both files are written");
|
||||||
|
ModAssert.Equal("items/food/bread.json", files[0], "the changed one");
|
||||||
|
ModAssert.Equal("items/my_mod.json", files[1], "the new one");
|
||||||
|
ModAssert.True(document.ChangedFiles().First(f => f.Path == "items/my_mod.json").Text.Contains("\"Kind\": \"item\""), "a new file declares the kind");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("entity_document/undo_restores_data_and_texts")]
|
||||||
|
static void UndoRestoresDataAndTexts(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var document = Load(out _);
|
||||||
|
document.LoadText("ru", "items/food/bread/name", "Хлеб");
|
||||||
|
|
||||||
|
document.BeginChange();
|
||||||
|
document.Records[0].Data["Cost"] = 9;
|
||||||
|
document.SetText("ru", "items/food/bread/name", "Каравай");
|
||||||
|
document.BeginChange();
|
||||||
|
document.Add(new JsonObject { ["Id"] = "food/cake" });
|
||||||
|
|
||||||
|
ModAssert.True(document.Undo(), "undo the add");
|
||||||
|
ModAssert.Null(document.Find("food/cake"), "the new entry is gone");
|
||||||
|
ModAssert.True(document.Undo(), "undo the edit");
|
||||||
|
ModAssert.Equal("2", document.Find("food/bread").Data["Cost"].ToString(), "the cost is back");
|
||||||
|
ModAssert.Equal("Хлеб", document.GetText("ru", "items/food/bread/name"), "the text is back");
|
||||||
|
ModAssert.False(document.Undo(), "nothing more");
|
||||||
|
ModAssert.Equal(0, document.ChangedFiles().Count + document.ChangedTexts().Count, "back to what is on disk");
|
||||||
|
|
||||||
|
ModAssert.True(document.Redo(), "redo the edit");
|
||||||
|
var texts = document.ChangedTexts();
|
||||||
|
ModAssert.Equal(1, texts.Count, "one changed text");
|
||||||
|
ModAssert.Equal("ru", texts[0].Language, "its language");
|
||||||
|
ModAssert.Equal("items/food/bread/name", texts[0].Key, "its key");
|
||||||
|
ModAssert.Equal("Каравай", texts[0].Value, "its value");
|
||||||
|
|
||||||
|
document.MarkSaved();
|
||||||
|
ModAssert.Equal(0, document.ChangedFiles().Count + document.ChangedTexts().Count, "nothing left after saving");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("entity_document/validate_reports_ids")]
|
||||||
|
static void ValidateReportsIds(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
var document = Load(out _);
|
||||||
|
ModAssert.Equal(0, document.Validate().Count, "a loaded mod is valid");
|
||||||
|
document.Add(new JsonObject { ["Id"] = "FOOD/BREAD" });
|
||||||
|
document.Add(new JsonObject { ["Name"] = "x" });
|
||||||
|
|
||||||
|
var problems = document.Validate();
|
||||||
|
ModAssert.Equal(2, problems.Count, "an empty id and a duplicate");
|
||||||
|
ModAssert.Equal("tools/entity_editor/problem_id", problems[0].Key, "empty id first");
|
||||||
|
ModAssert.Equal("tools/entity_editor/problem_duplicate", problems[1].Key, "a duplicate ignores case");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace Cthangover.Tools.Services.Tests
|
||||||
|
{
|
||||||
|
using Cthangover.Core.Mods.Testing;
|
||||||
|
|
||||||
|
/// <summary>Changing one key of a .properties text without touching the rest.</summary>
|
||||||
|
public class PropertiesTextTests
|
||||||
|
{
|
||||||
|
private const string Text = "# items\nitems/bread/name = Хлеб\nitems/bread/desc=Свежий\n\nitems/cake/name = Торт\n";
|
||||||
|
|
||||||
|
[ModTest("properties_text/get_reads_like_the_loader")]
|
||||||
|
static void GetReadsLikeTheLoader(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.Equal("Хлеб", PropertiesText.Get(Text, "items/bread/name"), "spaces around '=' are trimmed");
|
||||||
|
ModAssert.Equal("Свежий", PropertiesText.Get(Text, "items/bread/desc"), "no spaces work too");
|
||||||
|
ModAssert.Null(PropertiesText.Get(Text, "items/pie/name"), "a missing key");
|
||||||
|
ModAssert.Null(PropertiesText.Get(null, "x"), "no text");
|
||||||
|
ModAssert.Equal("b", PropertiesText.Get("x = a\nx = b\n", "x"), "the last line wins");
|
||||||
|
ModAssert.Equal(3, PropertiesText.Keys(Text).Count, "three keys, the comment is not one");
|
||||||
|
}
|
||||||
|
|
||||||
|
[ModTest("properties_text/set_changes_one_line")]
|
||||||
|
static void SetChangesOneLine(ModTestContext ctx)
|
||||||
|
{
|
||||||
|
ModAssert.Equal(Text.Replace("= Хлеб", "= Булка"), PropertiesText.Set(Text, "items/bread/name", "Булка"), "only that line changes");
|
||||||
|
ModAssert.Equal(Text + "items/pie/name = Пирог\n", PropertiesText.Set(Text, "items/pie/name", "Пирог"), "a new key is appended");
|
||||||
|
ModAssert.Equal("a = 1\nb = 2\n", PropertiesText.Set("a = 1", "b", "2"), "a missing final line break is added first");
|
||||||
|
ModAssert.Equal("a = 1\r\nb = 2\r\n", PropertiesText.Set("a = 1\r\n", "b", "2"), "CRLF text stays CRLF");
|
||||||
|
ModAssert.Equal("a = x\r\n", PropertiesText.Set("a = 1\r\n", "a", "x"), "a CRLF line is rewritten with its CR");
|
||||||
|
ModAssert.Equal("a = one two\n", PropertiesText.Set("", "a", "one\ntwo"), "a line break in a value becomes a space");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user