commit 138b0f689d19fc83e908a99c7a472174b47164ae Author: iisaev Date: Fri Oct 23 19:09:45 2026 +0200 Item editor: items of a mod in a searchable list, name/description in every language, price, icon, types, use action; GUI suite items_editor_edit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3030c1a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,30 @@ +# Line endings: LF everywhere, on every OS, whatever core.autocrlf says. +* text=auto eol=lf + +# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Binaries: never converted, never diffed as text. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.svg text +*.ogg binary +*.wav binary +*.mp3 binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.dll binary +*.so binary +*.dylib binary +*.pdb binary +*.exe binary +*.zip binary +*.pck binary +*.moc3 binary +*.res binary +*.scn binary diff --git a/locale/ui_en.properties b/locale/ui_en.properties new file mode 100644 index 0000000..5531fac --- /dev/null +++ b/locale/ui_en.properties @@ -0,0 +1,13 @@ +mod_items_editor_name = Item Editor +mod_items_editor_desc = Editor of the game's items: a searchable list with pictures, name and description in every language of the mod, price, icon, types and the use action; a save shows in the game at once. +tools/items_editor/title = Items +tools/items_editor/name = Name +tools/items_editor/description = Description +tools/items_editor/cost = Price +tools/items_editor/cost_tip = Price in coins; trading and shops use it +tools/items_editor/sprite = Icon +tools/items_editor/sprite_tip = Picture in the mod's items/ folder: the inventory, trading and an item lying on an RPG map show it +tools/items_editor/type = Types +tools/items_editor/type_tip = Quest — a quest item, Used — can be used, TargetUsed — used on a character, Food, Resource, Recipe, CantDrop — cannot be dropped +tools/items_editor/action = Action +tools/items_editor/action_tip = What using it does; null — nothing. The action's parameters (heal, for example) are in "Other fields" diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties new file mode 100644 index 0000000..9965d06 --- /dev/null +++ b/locale/ui_ru.properties @@ -0,0 +1,13 @@ +mod_items_editor_name = Редактор предметов +mod_items_editor_desc = Редактор предметов игры: список с поиском и картинками, название и описание на всех языках мода, цена, иконка, типы и действие при использовании; сохранение сразу видно в игре. +tools/items_editor/title = Предметы +tools/items_editor/name = Название +tools/items_editor/description = Описание +tools/items_editor/cost = Цена +tools/items_editor/cost_tip = Цена в монетах; ею пользуются торговля и магазины +tools/items_editor/sprite = Иконка +tools/items_editor/sprite_tip = Картинка из папки items/ мода: её показывают инвентарь, торговля и предмет на RPG-карте +tools/items_editor/type = Типы +tools/items_editor/type_tip = Quest — квестовый, Used — используется, TargetUsed — используется на персонаже, Food — еда, Resource — ресурс, Recipe — рецепт, CantDrop — нельзя выбросить +tools/items_editor/action = Действие +tools/items_editor/action_tip = Что происходит при использовании; null — ничего. Параметры действия (например heal) — в «Прочих полях» diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..0ce1741 --- /dev/null +++ b/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "items_editor", + "name": "Item Editor", + "version": "1.0.0", + "author": "ct", + "description": "Editor of the game's items (items_base, \"Kind\": \"item\") in the style of the other editor windows: the items of a writable mod in a searchable list with their pictures, a form with the name and the description in every language of the mod, price, picture picked from the items folders, item types and the use action; fields of other mods stay editable as JSON. New items, copies, deletion, undo, and a save the running game sees at once. Opens from the main menu tools, the novel editor hub and the in-game toolbar.", + "category": "tools, editor", + "sources": [ + "src/**/*.cs" + ], + "depends": [ + "editor_tools>=1", + "items_base>=1", + "ui_base>=1" + ], + "test_suites": [ + { + "id": "edit", + "activate": [ + "--itest=items_editor_edit" + ] + } + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..cf7ef25 Binary files /dev/null and b/mod.jpg differ diff --git a/src/ItemEditorToolBoxButton.cs b/src/ItemEditorToolBoxButton.cs new file mode 100644 index 0000000..ae729c0 --- /dev/null +++ b/src/ItemEditorToolBoxButton.cs @@ -0,0 +1,20 @@ +using Cthangover.Core.UI.Tool; + +namespace Cthangover.Tools.ItemsEditor +{ + /// In-game toolbar button of the item editor, so an item can be fixed while playing. + public class ItemEditorToolBoxButton : IToolBoxButton + { + /// Matches . + public string ToolId => "items_editor"; + + /// No icon: the button shows its label. + public string IconPath => ""; + + /// Button label key. + public string LocaleKey => "tools/items_editor/title"; + + /// Always shown. + public bool IsVisible() => true; + } +} diff --git a/src/ItemEditorToolProvider.cs b/src/ItemEditorToolProvider.cs new file mode 100644 index 0000000..5047b1f --- /dev/null +++ b/src/ItemEditorToolProvider.cs @@ -0,0 +1,26 @@ +using Cthangover.Core.UI.Tool; +using Cthangover.Mods.Interface.Tool; +using Godot; + +namespace Cthangover.Tools.ItemsEditor +{ + /// + /// Registers the item editor as a tool: it appears in the main menu's tool + /// block, the novel editor hub and (with ) + /// the in-game toolbar. + /// + public class ItemEditorToolProvider : IToolProvider + { + /// Tool id. + public string Id => "items_editor"; + + /// Display name key. + public string LocaleKey => "tools/items_editor/title"; + + /// Creates the window. + public Window CreateWindow() => new ItemEditorWindow(); + + /// Adds the launcher button to the main menu's tool block. + public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this); + } +} diff --git a/src/ItemEditorWindow.cs b/src/ItemEditorWindow.cs new file mode 100644 index 0000000..425c2a4 --- /dev/null +++ b/src/ItemEditorWindow.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Cthangover.Core.Factories.Impls; +using Cthangover.Core.Items; +using Cthangover.Tools.EntityEditor; + +namespace Cthangover.Tools.ItemsEditor +{ + /// + /// Editor of items ("Kind": "item", ): name and + /// description in every language of the mod, price, picture in the + /// items group (the inventory icon), item types and the use action + /// offered from the actions the loaded mods register. Fields of other mods' + /// item C# (heal, verb…) are "other fields" of the base window. + /// New items go to items/<mod>.json. + /// + public partial class ItemEditorWindow : EntityEditorWindow + { + /// Builds the window and opens the first mod that has items. + public ItemEditorWindow() : base("tools/items_editor/title", "item", "items", Fields()) + { + } + + private static List Fields() => new() + { + new EntityField { Key = "Name", LabelKey = "tools/items_editor/name", Kind = EntityFieldKind.LocaleText, KeyPattern = "items/{0}/name" }, + new EntityField { Key = "Description", LabelKey = "tools/items_editor/description", Kind = EntityFieldKind.LocaleText, KeyPattern = "items/{0}/desc" }, + new EntityField { Key = "Cost", LabelKey = "tools/items_editor/cost", TooltipKey = "tools/items_editor/cost_tip", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Sprite", LabelKey = "tools/items_editor/sprite", TooltipKey = "tools/items_editor/sprite_tip", Kind = EntityFieldKind.Picture, Group = "items" }, + new EntityField { Key = "ItemType", LabelKey = "tools/items_editor/type", TooltipKey = "tools/items_editor/type_tip", Kind = EntityFieldKind.Flags, Options = ItemTypes }, + new EntityField { Key = "ItemAction", LabelKey = "tools/items_editor/action", TooltipKey = "tools/items_editor/action_tip", Kind = EntityFieldKind.Choice, Options = Actions } + }; + + private static IEnumerable ItemTypes() => Enum.GetNames(typeof(ItemType)).Where(n => n != nameof(ItemType.None)); + + private static IEnumerable Actions() => new[] { "null" }.Concat(ItemActionFactory.Instance.Ids.OrderBy(id => id, StringComparer.Ordinal)); + + /// A resource worth one coin with no use action; the picture is the author's to pick. + protected override JsonObject CreateEntity(string id) => new() + { + ["Id"] = id, + ["Cost"] = 1, + ["ItemType"] = nameof(ItemType.Resource), + ["ItemAction"] = "null" + }; + + /// <mod>/new_item, the folder-style ids items use. + protected override string NewIdBase(string mod) => mod + "/new_item"; + } +} diff --git a/test_suites/edit/manifest.json b/test_suites/edit/manifest.json new file mode 100644 index 0000000..eed60fa --- /dev/null +++ b/test_suites/edit/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "Item Editor Test", + "description": "GUI test of the item editor in rpg_demo: opens rpg_demo_game's items, shows a demo item's localized name, adds an item with the New button, types its name, price and a type into the form, copies it and undoes the copy with Ctrl+Z, saves; the game must know the item and its name at once and the other item files stay unchanged. The new file and the locale edits are removed again. Activated by --itest=items_editor_edit with --game=rpg_demo --new.", + "sources": [ + "src/*.cs" + ], + "depends": [ + "rpg_demo_game>=1" + ] +} diff --git a/test_suites/edit/src/ItemEditorTestDriver.cs b/test_suites/edit/src/ItemEditorTestDriver.cs new file mode 100644 index 0000000..245bef2 --- /dev/null +++ b/test_suites/edit/src/ItemEditorTestDriver.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Items; +using Cthangover.Core.Localization; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.ItemsEditor.Tests +{ + /// + /// Drives the item editor like an author: the toolbar buttons, the form's + /// fields (their change signals), Ctrl+Z into the window, Save. Then the game + /// itself is asked: the entity cache must hold the new item with the typed + /// price and type, and the translation server its typed name. The new items + /// file is deleted and the mod's locale files are restored afterwards, so + /// rpg_demo_game stays as it was. + /// Result line: [TEST_RESULT] name=items_editor_edit passed=N failed=M. + /// + public partial class ItemEditorTestDriver : Node + { + private const float TickSeconds = 0.15f; + private const float TimeoutSeconds = 60f; + private const string Mod = "rpg_demo_game"; + private const string NewFile = "items/rpg_demo_game.json"; + private const string NewId = "rpg_demo_game/new_item"; + private const string TypedName = "Тестовый амулет"; + + private readonly List failures = new(); + private readonly Dictionary backups = new(); + private readonly Dictionary itemFiles = new(); + private int passed; + private int step; + private double elapsed; + private ItemEditorWindow window; + + /// Creates the driver; is unused (no screenshots). + public ItemEditorTestDriver(string shotsDir) + { + Name = "ItemEditorTestDriver"; + var timer = new Timer { WaitTime = TickSeconds, Autostart = true }; + AddChild(timer); + timer.Timeout += Tick; + } + + private void Tick() + { + if (step < 0) + return; + elapsed += TickSeconds; + if (elapsed > TimeoutSeconds) + { + Fail($"timeout at step {step}"); + Finish(); + return; + } + try + { + RunStep(); + } + catch (System.Exception ex) + { + Fail($"step {step}: {ex.GetType().Name}: {ex.Message}"); + Finish(); + } + } + + private void RunStep() + { + switch (step) + { + case 0: + if (!Boot.IsReady) + return; + foreach (var file in ModFileService.ListFiles(Mod, "locale", ".properties")) + backups[file] = ModFileService.ReadText(Mod, file); + foreach (var file in ModFileService.ListFiles(Mod, "items", ".json")) + itemFiles[file] = ModFileService.ReadText(Mod, file); + Check(!itemFiles.ContainsKey(NewFile), "the mod has no editor items file yet"); + window = ToolWindow.Open(); + step++; + return; + case 1: + if (window.Document == null) + return; + window.OpenMod(Mod); + var language = Language(); + var branches = window.Document.Find("rpg_demo/branches"); + Check(branches != null, "the demo's items are listed"); + window.Select(branches); + Check(window.Document.GetText(language, "rpg_demo/item/branches/name") == TranslationServer.Translate("rpg_demo/item/branches/name").ToString(), + $"the form shows the item's name in '{language}'"); + + Press("tools/entity_editor/new"); + Check(window.SelectedId == NewId, $"New adds and selects {NewId} (got {window.SelectedId})"); + Type(NameEdit(), TypedName); + var cost = window.FindChildren("*", "", true, false).OfType().First(); + cost.Value = 7; + var food = window.FindChildren("*", "", true, false).OfType().First(c => c.Text == nameof(ItemType.Food)); + food.ButtonPressed = true; + var record = window.Document.Find(NewId); + Check(record.Data["Cost"]?.ToString() == "7" && record.Data["ItemType"]?.ToString().Split(',').OrderBy(t => t).SequenceEqual(new[] { "Food", "Resource" }) == true, "the form writes price and types"); + + Press("tools/entity_editor/duplicate"); + Check(window.Document.Find(NewId + "_2") != null, "Copy adds a copy under a free id"); + window.PushInput(new InputEventKey { Keycode = Key.Z, PhysicalKeycode = Key.Z, CtrlPressed = true, Pressed = true }); + Check(window.Document.Find(NewId + "_2") == null && window.Document.Find(NewId) != null, "Ctrl+Z takes the copy back"); + step++; + return; + case 2: + try + { + Press("tools/common/save"); + var items = Entities.Collect(); + Check(items.TryGetValue(NewId, out var saved), "the game knows the new item right after saving"); + Check(saved?.Cost == 7 && saved.ItemType.HasFlag(ItemType.Food) && saved.ItemType.HasFlag(ItemType.Resource), "with the typed price and types"); + Check(TranslationServer.Translate(saved?.Name ?? "-").ToString() == TypedName, $"and the typed name ({saved?.Name})"); + var written = ModFileService.ReadText(Mod, NewFile) ?? string.Empty; + Check(written.Contains("\"Kind\": \"item\"") && !written.Contains("\r") && written.Contains(TypedName) == false, "the new file declares its kind, has LF lines and no texts"); + Check(itemFiles.All(f => ModFileService.ReadText(Mod, f.Key) == f.Value), "the demo's item files are untouched"); + } + finally + { + Cleanup(); + } + Finish(); + return; + } + } + + private static string Language() => LanguageCode.Normalize(TranslationServer.GetLocale()) ?? "ru"; + + /// The first text row of the current language: the name (the first locale field). + private LineEdit NameEdit() + { + var label = string.Format(TranslationServer.Translate("tools/entity_editor/text_in"), Language()); + return window.FindChildren("*", "", true, false).OfType