commit c6627a8205ecf17f9f0a8bc076c15103e41cc283 Author: iisaev Date: Fri Oct 23 19:09:46 2026 +0200 Character editor: characters of a mod in a searchable list, name in every language, portrait, RPG charset, stats, battle actions; GUI suite characters_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..694afa3 --- /dev/null +++ b/locale/ui_en.properties @@ -0,0 +1,22 @@ +mod_characters_editor_name = Character Editor +mod_characters_editor_desc = Editor of the game's characters — heroes, NPCs and enemies: a searchable list with portraits, the name in every language of the mod, portrait, the RPG map charset, battle stats and actions; a save shows in the game at once. +tools/characters_editor/title = Characters +tools/characters_editor/name = Name +tools/characters_editor/image = Portrait +tools/characters_editor/image_tip = Picture in the mod's characters/ folder: dialogs, battles and the character panel show it +tools/characters_editor/charset = Charset (RPG) +tools/characters_editor/charset_tip = How the character looks on an RPG map: a 3×4 frame sheet from the charsets/ folder. A novel does not need it +tools/characters_editor/level = Level +tools/characters_editor/health = Health +tools/characters_editor/attack = Attack +tools/characters_editor/defence = Defence +tools/characters_editor/point = Action points +tools/characters_editor/point_tip = Points to spend on actions in a battle +tools/characters_editor/exp = Experience +tools/characters_editor/exp_tip = The character's experience, read by the battle cores +tools/characters_editor/recruitment = Recruit chance +tools/characters_editor/recruitment_tip = Chance (percent) to take the beaten character into the party; 0 — never +tools/characters_editor/behaviour = Behaviour +tools/characters_editor/behaviour_tip = How the character acts in battle when the game controls it; base — the usual +tools/characters_editor/actions = Actions +tools/characters_editor/actions_tip = The character's battle actions from the loaded mods diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties new file mode 100644 index 0000000..c5f1658 --- /dev/null +++ b/locale/ui_ru.properties @@ -0,0 +1,22 @@ +mod_characters_editor_name = Редактор персонажей +mod_characters_editor_desc = Редактор персонажей игры — героев, NPC и врагов: список с поиском и портретами, имя на всех языках мода, портрет, чарсет для RPG-карты, боевые параметры и действия; сохранение сразу видно в игре. +tools/characters_editor/title = Персонажи +tools/characters_editor/name = Имя +tools/characters_editor/image = Портрет +tools/characters_editor/image_tip = Картинка из папки characters/ мода: её показывают диалоги, бои и панель персонажей +tools/characters_editor/charset = Чарсет (RPG) +tools/characters_editor/charset_tip = Как персонаж выглядит на RPG-карте: лист 3×4 кадра из папки charsets/. В новелле не нужен +tools/characters_editor/level = Уровень +tools/characters_editor/health = Здоровье +tools/characters_editor/attack = Атака +tools/characters_editor/defence = Защита +tools/characters_editor/point = Очки действий +tools/characters_editor/point_tip = Запас очков на действия в бою +tools/characters_editor/exp = Опыт +tools/characters_editor/exp_tip = Опыт персонажа, его читают боевые ядра +tools/characters_editor/recruitment = Шанс вербовки +tools/characters_editor/recruitment_tip = Шанс (в процентах) позвать побеждённого в отряд; 0 — нельзя +tools/characters_editor/behaviour = Поведение +tools/characters_editor/behaviour_tip = Как персонаж ведёт себя в бою под управлением игры; base — обычное +tools/characters_editor/actions = Действия +tools/characters_editor/actions_tip = Боевые действия персонажа из загруженных модов diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2739355 --- /dev/null +++ b/manifest.json @@ -0,0 +1,24 @@ +{ + "id": "characters_editor", + "name": "Character Editor", + "version": "1.0.0", + "author": "ct", + "description": "Editor of the game's characters (characters_base, \"Kind\": \"character\") in the style of the other editor windows: heroes, NPCs and enemies of a writable mod in a searchable list with their portraits, a form with the name in every language of the mod, portrait, the RPG map charset, battle stats and the battle actions picked from the loaded ones; loot and fields of other mods stay editable as JSON. New characters, 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", + "characters_base>=1", + "ui_base>=1" + ], + "test_suites": [ + { + "id": "edit", + "activate": [ + "--itest=characters_editor_edit" + ] + } + ] +} diff --git a/mod.jpg b/mod.jpg new file mode 100644 index 0000000..0cfcf87 Binary files /dev/null and b/mod.jpg differ diff --git a/src/CharacterEditorToolBoxButton.cs b/src/CharacterEditorToolBoxButton.cs new file mode 100644 index 0000000..e0b4445 --- /dev/null +++ b/src/CharacterEditorToolBoxButton.cs @@ -0,0 +1,20 @@ +using Cthangover.Core.UI.Tool; + +namespace Cthangover.Tools.CharactersEditor +{ + /// In-game toolbar button of the character editor, so a character can be fixed while playing. + public class CharacterEditorToolBoxButton : IToolBoxButton + { + /// Matches . + public string ToolId => "characters_editor"; + + /// No icon: the button shows its label. + public string IconPath => ""; + + /// Button label key. + public string LocaleKey => "tools/characters_editor/title"; + + /// Always shown. + public bool IsVisible() => true; + } +} diff --git a/src/CharacterEditorToolProvider.cs b/src/CharacterEditorToolProvider.cs new file mode 100644 index 0000000..0e777a7 --- /dev/null +++ b/src/CharacterEditorToolProvider.cs @@ -0,0 +1,26 @@ +using Cthangover.Core.UI.Tool; +using Cthangover.Mods.Interface.Tool; +using Godot; + +namespace Cthangover.Tools.CharactersEditor +{ + /// + /// Registers the character 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 CharacterEditorToolProvider : IToolProvider + { + /// Tool id. + public string Id => "characters_editor"; + + /// Display name key. + public string LocaleKey => "tools/characters_editor/title"; + + /// Creates the window. + public Window CreateWindow() => new CharacterEditorWindow(); + + /// Adds the launcher button to the main menu's tool block. + public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this); + } +} diff --git a/src/CharacterEditorWindow.cs b/src/CharacterEditorWindow.cs new file mode 100644 index 0000000..9cbd2f9 --- /dev/null +++ b/src/CharacterEditorWindow.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Nodes; +using Cthangover.Core.Characters; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Tools.EntityEditor; + +namespace Cthangover.Tools.CharactersEditor +{ + /// + /// Editor of characters ("Kind": "character", ): + /// heroes, NPCs and enemies alike. Name in every language of the mod, the + /// portrait (characters group, shown in dialogs and battles), the RPG + /// map look (Charset in the charsets group: the RPG map editor + /// places the character with it; a novel leaves it empty), battle stats and the + /// battle actions offered from the loaded action entities. Loot and + /// other mods' fields are "other fields" of the base window. New characters go + /// to characters/<mod>.json. + /// + public partial class CharacterEditorWindow : EntityEditorWindow + { + /// Builds the window and opens the first mod that has characters. + public CharacterEditorWindow() : base("tools/characters_editor/title", "character", "characters", Fields()) + { + } + + private static List Fields() => new() + { + new EntityField { Key = "Name", LabelKey = "tools/characters_editor/name", Kind = EntityFieldKind.LocaleText, KeyPattern = "characters/{0}/name" }, + new EntityField { Key = "Image", LabelKey = "tools/characters_editor/image", TooltipKey = "tools/characters_editor/image_tip", Kind = EntityFieldKind.Picture, Group = "characters" }, + new EntityField { Key = "Charset", LabelKey = "tools/characters_editor/charset", TooltipKey = "tools/characters_editor/charset_tip", Kind = EntityFieldKind.Picture, Group = "charsets" }, + new EntityField { Key = "Level", LabelKey = "tools/characters_editor/level", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Health", LabelKey = "tools/characters_editor/health", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Attack", LabelKey = "tools/characters_editor/attack", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Defence", LabelKey = "tools/characters_editor/defence", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Point", LabelKey = "tools/characters_editor/point", TooltipKey = "tools/characters_editor/point_tip", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Exp", LabelKey = "tools/characters_editor/exp", TooltipKey = "tools/characters_editor/exp_tip", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "RecruitmentChance", LabelKey = "tools/characters_editor/recruitment", TooltipKey = "tools/characters_editor/recruitment_tip", Kind = EntityFieldKind.Integer }, + new EntityField { Key = "Behaviour", LabelKey = "tools/characters_editor/behaviour", TooltipKey = "tools/characters_editor/behaviour_tip", Kind = EntityFieldKind.Text }, + new EntityField { Key = "Actions", LabelKey = "tools/characters_editor/actions", TooltipKey = "tools/characters_editor/actions_tip", Kind = EntityFieldKind.List, Options = Actions } + }; + + private static IEnumerable Actions() + => (Entities.Collect()?.Keys ?? Enumerable.Empty()).OrderBy(id => id, StringComparer.Ordinal); + + /// A level 1 fighter with a plain attack; the portrait is the author's to pick. + protected override JsonObject CreateEntity(string id) => new() + { + ["Id"] = id, + ["Behaviour"] = "base", + ["Level"] = 1, + ["Point"] = 1, + ["Health"] = 10, + ["Defence"] = 0, + ["Attack"] = 2, + ["Exp"] = 1, + ["RecruitmentChance"] = 0, + ["Actions"] = new JsonArray("physics/attack") + }; + + /// <mod>_new_character: character ids are flat. + protected override string NewIdBase(string mod) => mod + "_new_character"; + } +} diff --git a/test_suites/edit/manifest.json b/test_suites/edit/manifest.json new file mode 100644 index 0000000..5da7699 --- /dev/null +++ b/test_suites/edit/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Character Editor Test", + "description": "GUI test of the character editor in rpg_demo: opens rpg_demo_game's characters, shows the slime's localized name, adds a character with the New button, types its name, health and RPG charset into the form, saves; the game must know the character with its stats and name at once, and the RPG map editor's characters tab must offer it with its charset. The new file and the locale edits are removed again. Activated by --itest=characters_editor_edit with --game=rpg_demo --new.", + "sources": [ + "src/*.cs" + ], + "depends": [ + "rpg_demo_game>=1", + "rpg_editor>=1" + ] +} diff --git a/test_suites/edit/src/CharacterEditorTestDriver.cs b/test_suites/edit/src/CharacterEditorTestDriver.cs new file mode 100644 index 0000000..7d6f8ae --- /dev/null +++ b/test_suites/edit/src/CharacterEditorTestDriver.cs @@ -0,0 +1,208 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Characters; +using Cthangover.Core.Localization; +using Cthangover.Core.Mods; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Mods.Rpg; +using Cthangover.Tools.RpgEditor; +using Cthangover.Tools.Services; +using Godot; + +namespace Cthangover.Tools.CharactersEditor.Tests +{ + /// + /// Drives the character editor like an author: the toolbar buttons and the + /// form's fields (their change signals), Save. Then the game itself is asked: + /// the entity cache must hold the new character with the typed health and its + /// action, the translation server its typed name, and the RPG map editor's + /// character list must offer it with the typed charset. The new characters + /// 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=characters_editor_edit passed=N failed=M. + /// + public partial class CharacterEditorTestDriver : Node + { + private const float TickSeconds = 0.15f; + private const float TimeoutSeconds = 60f; + private const string Mod = "rpg_demo_game"; + private const string NewFile = "characters/rpg_demo_game.json"; + private const string NewId = "rpg_demo_game_new_character"; + private const string TypedName = "Тестовый гоблин"; + + private readonly List failures = new(); + private readonly Dictionary backups = new(); + private readonly Dictionary characterFiles = new(); + private int passed; + private int step; + private double elapsed; + private CharacterEditorWindow window; + + /// Creates the driver; is unused (no screenshots). + public CharacterEditorTestDriver(string shotsDir) + { + Name = "CharacterEditorTestDriver"; + 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, "characters", ".json")) + characterFiles[file] = ModFileService.ReadText(Mod, file); + Check(!characterFiles.ContainsKey(NewFile), "the mod has no editor characters file yet"); + window = ToolWindow.Open(); + step++; + return; + case 1: + if (window.Document == null) + return; + window.OpenMod(Mod); + var language = Language(); + var slime = window.Document.Find("rpg_slime"); + Check(slime != null, "the demo's characters are listed"); + window.Select(slime); + Check(window.Document.GetText(language, "rpg_demo/character/slime") == TranslationServer.Translate("rpg_demo/character/slime").ToString(), + $"the form shows the character's name in '{language}'"); + + Press("tools/entity_editor/new"); + Check(window.SelectedId == NewId, $"New adds and selects {NewId} (got {window.SelectedId})"); + Type(NameEdit(), TypedName); + Row("tools/characters_editor/health").OfType().First().Value = 33; + Type(Row("tools/characters_editor/charset").OfType().First(), "village/slime"); + var record = window.Document.Find(NewId); + Check(record.Data["Health"]?.ToString() == "33" && record.Data["Charset"]?.ToString() == "village/slime", "the form writes health and the charset"); + Check(record.Data["Actions"]?.AsArray().Any(a => a?.ToString() == "physics/attack") == true, "a new character starts with the plain attack"); + step++; + return; + case 2: + try + { + Press("tools/common/save"); + var characters = Entities.Collect(); + Check(characters.TryGetValue(NewId, out var saved), "the game knows the new character right after saving"); + Check(saved?.Health == 33 && saved.Actions?.Contains("physics/attack") == true, "with the typed health and its action"); + Check(TranslationServer.Translate(saved?.Name ?? "-").ToString() == TypedName, $"and the typed name ({saved?.Name})"); + var offered = RpgCharacterCatalog.Load(new RpgTextures()).FirstOrDefault(e => e.Id == NewId); + Check(offered?.Look == "village/slime" && offered.Name == TypedName, $"the RPG map editor offers it with its charset ({offered?.Look})"); + var written = ModFileService.ReadText(Mod, NewFile) ?? string.Empty; + Check(written.Contains("\"Kind\": \"character\"") && !written.Contains("\r"), "the new file declares its kind and has LF lines"); + Check(characterFiles.All(f => ModFileService.ReadText(Mod, f.Key) == f.Value), "the demo's character files are untouched"); + } + finally + { + Cleanup(); + } + Finish(); + return; + } + } + + /// The controls of the form row labelled . + private IEnumerable Row(string labelKey) + { + var text = TranslationServer.Translate(labelKey).ToString(); + return window.FindChildren("*", "", true, false).OfType