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
This commit is contained in:
commit
138b0f689d
11 files changed
+423
No files matched your search
@@ -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
|
||||
@@ -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"
|
||||
@@ -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) — в «Прочих полях»
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Cthangover.Core.UI.Tool;
|
||||
|
||||
namespace Cthangover.Tools.ItemsEditor
|
||||
{
|
||||
/// <summary>In-game toolbar button of the item editor, so an item can be fixed while playing.</summary>
|
||||
public class ItemEditorToolBoxButton : IToolBoxButton
|
||||
{
|
||||
/// <summary>Matches <see cref="ItemEditorToolProvider.Id"/>.</summary>
|
||||
public string ToolId => "items_editor";
|
||||
|
||||
/// <summary>No icon: the button shows its label.</summary>
|
||||
public string IconPath => "";
|
||||
|
||||
/// <summary>Button label key.</summary>
|
||||
public string LocaleKey => "tools/items_editor/title";
|
||||
|
||||
/// <summary>Always shown.</summary>
|
||||
public bool IsVisible() => true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Cthangover.Core.UI.Tool;
|
||||
using Cthangover.Mods.Interface.Tool;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.ItemsEditor
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the item editor as a tool: it appears in the main menu's tool
|
||||
/// block, the novel editor hub and (with <see cref="ItemEditorToolBoxButton"/>)
|
||||
/// the in-game toolbar.
|
||||
/// </summary>
|
||||
public class ItemEditorToolProvider : IToolProvider
|
||||
{
|
||||
/// <summary>Tool id.</summary>
|
||||
public string Id => "items_editor";
|
||||
|
||||
/// <summary>Display name key.</summary>
|
||||
public string LocaleKey => "tools/items_editor/title";
|
||||
|
||||
/// <summary>Creates the window.</summary>
|
||||
public Window CreateWindow() => new ItemEditorWindow();
|
||||
|
||||
/// <summary>Adds the launcher button to the main menu's tool block.</summary>
|
||||
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor of items (<c>"Kind": "item"</c>, <see cref="ItemInfo"/>): name and
|
||||
/// description in every language of the mod, price, picture in the
|
||||
/// <c>items</c> group (the inventory icon), item types and the use action
|
||||
/// offered from the actions the loaded mods register. Fields of other mods'
|
||||
/// item C# (<c>heal</c>, <c>verb</c>…) are "other fields" of the base window.
|
||||
/// New items go to <c>items/<mod>.json</c>.
|
||||
/// </summary>
|
||||
public partial class ItemEditorWindow : EntityEditorWindow
|
||||
{
|
||||
/// <summary>Builds the window and opens the first mod that has items.</summary>
|
||||
public ItemEditorWindow() : base("tools/items_editor/title", "item", "items", Fields())
|
||||
{
|
||||
}
|
||||
|
||||
private static List<EntityField> 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<string> ItemTypes() => Enum.GetNames(typeof(ItemType)).Where(n => n != nameof(ItemType.None));
|
||||
|
||||
private static IEnumerable<string> Actions() => new[] { "null" }.Concat(ItemActionFactory.Instance.Ids.OrderBy(id => id, StringComparer.Ordinal));
|
||||
|
||||
/// <summary>A resource worth one coin with no use action; the picture is the author's to pick.</summary>
|
||||
protected override JsonObject CreateEntity(string id) => new()
|
||||
{
|
||||
["Id"] = id,
|
||||
["Cost"] = 1,
|
||||
["ItemType"] = nameof(ItemType.Resource),
|
||||
["ItemAction"] = "null"
|
||||
};
|
||||
|
||||
/// <summary><c><mod>/new_item</c>, the folder-style ids items use.</summary>
|
||||
protected override string NewIdBase(string mod) => mod + "/new_item";
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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: <c>[TEST_RESULT] name=items_editor_edit passed=N failed=M</c>.
|
||||
/// </summary>
|
||||
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<string> failures = new();
|
||||
private readonly Dictionary<string, string> backups = new();
|
||||
private readonly Dictionary<string, string> itemFiles = new();
|
||||
private int passed;
|
||||
private int step;
|
||||
private double elapsed;
|
||||
private ItemEditorWindow window;
|
||||
|
||||
/// <summary>Creates the driver; <paramref name="shotsDir"/> is unused (no screenshots).</summary>
|
||||
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<ItemEditorWindow>();
|
||||
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<SpinBox>().First();
|
||||
cost.Value = 7;
|
||||
var food = window.FindChildren("*", "", true, false).OfType<CheckBox>().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<ItemInfo>();
|
||||
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";
|
||||
|
||||
/// <summary>The first text row of the current language: the name (the first locale field).</summary>
|
||||
private LineEdit NameEdit()
|
||||
{
|
||||
var label = string.Format(TranslationServer.Translate("tools/entity_editor/text_in"), Language());
|
||||
return window.FindChildren("*", "", true, false).OfType<Label>().First(l => l.Text == label && l.IsVisibleInTree())
|
||||
.GetParent().GetChildren().OfType<LineEdit>().First();
|
||||
}
|
||||
|
||||
private static void Type(LineEdit edit, string text)
|
||||
{
|
||||
edit.Text = text;
|
||||
edit.EmitSignal(LineEdit.SignalName.TextChanged, text);
|
||||
}
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
window?.QueueFree();
|
||||
var path = ModFileService.GetWritablePath(Mod, NewFile);
|
||||
if (path != null && File.Exists(path))
|
||||
File.Delete(path);
|
||||
foreach (var backup in backups)
|
||||
ModFileService.WriteText(Mod, backup.Key, backup.Value);
|
||||
ModManager.Instance.GetResolver<EntityResolver>()?.ClearCache();
|
||||
LocaleFileService.ReloadGame();
|
||||
}
|
||||
|
||||
private void Press(string key)
|
||||
{
|
||||
var text = TranslationServer.Translate(key);
|
||||
var button = window.FindChildren("*", "", true, false).OfType<Button>().FirstOrDefault(b => b.Text == text);
|
||||
if (button == null)
|
||||
{
|
||||
Fail($"button '{text}' not found");
|
||||
return;
|
||||
}
|
||||
button.EmitSignal(BaseButton.SignalName.Pressed);
|
||||
}
|
||||
|
||||
private void Check(bool condition, string what)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
passed++;
|
||||
GameLogger.Log("TEST", $"[PASS] {what}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail(what);
|
||||
}
|
||||
}
|
||||
|
||||
private void Fail(string what)
|
||||
{
|
||||
failures.Add(what);
|
||||
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
|
||||
}
|
||||
|
||||
private void Finish()
|
||||
{
|
||||
step = -1;
|
||||
GameLogger.Log("TEST", $"[TEST_RESULT] name=items_editor_edit passed={passed} failed={failures.Count}");
|
||||
GetTree().Quit(failures.Count == 0 ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Linq;
|
||||
using Cthangover.Core.Mods;
|
||||
using Cthangover.Core.Utils;
|
||||
using Godot;
|
||||
|
||||
namespace Cthangover.Tools.ItemsEditor.Tests
|
||||
{
|
||||
/// <summary>Adds the editor test driver when <c>--itest=items_editor_edit</c> is given.</summary>
|
||||
public class ItemEditorTestInitializer : IModInitializer
|
||||
{
|
||||
private static bool driverQueued;
|
||||
|
||||
/// <summary>Nothing per mod.</summary>
|
||||
public void OnModLoaded(string modId)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Queues the driver once.</summary>
|
||||
public void OnModResourcesReady()
|
||||
{
|
||||
var args = OS.GetCmdlineUserArgs();
|
||||
if (driverQueued || !args.Any(a => a == "--itest=items_editor_edit"))
|
||||
return;
|
||||
|
||||
driverQueued = true;
|
||||
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
|
||||
.Select(a => a.Substring("--shots=".Length))
|
||||
.FirstOrDefault();
|
||||
GameLogger.Log("TEST", $"ItemEditorTestInitializer: activating items_editor_edit, shots={shots ?? "off"}");
|
||||
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new ItemEditorTestDriver(shots));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user