diff --git a/locale/ui_en.properties b/locale/ui_en.properties
index d9ee13d..c333178 100644
--- a/locale/ui_en.properties
+++ b/locale/ui_en.properties
@@ -1,7 +1,7 @@
mod_rpg_editor_name = RPG Map Editor
mod_rpg_editor_desc = Editor of tile maps for the RPG world: three tile layers, the collision layer and map events, with undo and a live preview in the running game.
tools/rpg_editor/title = RPG Map Editor
-tools/rpg_editor/hint = LMB — paint · RMB — erase · Alt+LMB — pick tile · events: click — select/drag, double-click — add, Del — delete · 1–5 layers · B/E/G brush · Ctrl+wheel zoom · Ctrl+Z/Y · Ctrl+S
+tools/rpg_editor/hint = LMB — paint · RMB — erase · Alt+LMB — pick tile · events: click — select/drag, double-click — add, Del — delete · 1–5 layers · B/E/G brush · wheel/Shift+wheel, MMB drag — scroll · Ctrl+wheel zoom · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = Back
tools/rpg_editor/layer_main = Main
tools/rpg_editor/layer_front = Front
@@ -77,3 +77,28 @@ tools/rpg_editor/collision_auto = Auto — from the tiles
tools/rpg_editor/problem_tileset = Map "{0}" has no tileset.
tools/rpg_editor/problem_event_id = The event at {0} has no id.
tools/rpg_editor/problem_event_duplicate = Two events share the id "{0}".
+tools/rpg_editor/tab_tiles = Tiles
+tools/rpg_editor/tab_characters = Characters
+tools/rpg_editor/character_search = Search by name or id
+tools/rpg_editor/place_npc = Place as NPC
+tools/rpg_editor/place_npc_tip = Put the character on the map as an event talking on E/F; write its lines in the scenario lines
+tools/rpg_editor/place_enemy = Place as enemy
+tools/rpg_editor/place_enemy_tip = Put the character on the map as an enemy: E/F starts a battle with it, it vanishes once beaten
+tools/rpg_editor/characters_hint = Pick a character, press a button and click a cell on the map (RMB cancels). The charset is found by the character's Charset field or by its portrait path.
+tools/rpg_editor/place_status = Click the cell to put “{0}” on (RMB cancels)
+tools/rpg_editor/place_cancelled = Placing the character cancelled
+tools/rpg_editor/placed_npc = “{0}” placed as an NPC: event {1}
+tools/rpg_editor/placed_enemy = “{0}” placed as an enemy: event {1}
+tools/rpg_editor/icon = Icon
+tools/rpg_editor/icon_tip = Picture in the items group (like an item's Sprite) when there is neither a charset nor a tile
+tools/rpg_editor/tab_items = Items
+tools/rpg_editor/item_search = Search by name or id
+tools/rpg_editor/items_hint = Pick an item, set the count, press "Place" and click a cell (RMB cancels). The player picks it up by stepping on the cell; then it is gone.
+tools/rpg_editor/item_count = Count
+tools/rpg_editor/item_count_tip = How many the player gets
+tools/rpg_editor/place_item = Place
+tools/rpg_editor/place_item_tip = Lay the item on the map: stepping on the cell picks it up
+tools/rpg_editor/item_found_line = Found: {0} ×{1}
+tools/rpg_editor/placed_item = “{0}” lies on the map: event {1}
+tools/rpg_editor/picker_no_look = No map picture found, a marker is shown
+tools/rpg_editor/problem_event_locked = Event id {0} is taken by an event of another file or mod — pick another one
diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties
index ab3799a..66d8ce1 100644
--- a/locale/ui_ru.properties
+++ b/locale/ui_ru.properties
@@ -1,7 +1,7 @@
mod_rpg_editor_name = Редактор RPG-карт
mod_rpg_editor_desc = Редактор тайловых карт RPG-мира: три слоя тайлов, слой коллизий и события на карте, с отменой и живым предпросмотром в запущенной игре.
tools/rpg_editor/title = Редактор RPG-карт
-tools/rpg_editor/hint = ЛКМ — рисовать · ПКМ — стирать · Alt+ЛКМ — взять тайл · события: клик — выбрать/тащить, двойной клик — добавить, Del — удалить · 1–5 слои · B/E/G кисть · Ctrl+колесо масштаб · Ctrl+Z/Y · Ctrl+S
+tools/rpg_editor/hint = ЛКМ — рисовать · ПКМ — стирать · Alt+ЛКМ — взять тайл · события: клик — выбрать/тащить, двойной клик — добавить, Del — удалить · 1–5 слои · B/E/G кисть · колесо/Shift+колесо, СКМ — прокрутка · Ctrl+колесо масштаб · Ctrl+Z/Y · Ctrl+S
tools/rpg_editor/layer_back = Фон
tools/rpg_editor/layer_main = Основной
tools/rpg_editor/layer_front = Передний
@@ -54,7 +54,7 @@ tools/rpg_editor/event_id = Id
tools/rpg_editor/sprite = Чарсет
tools/rpg_editor/sprite_tip = Путь к картинке в группе charsets: 3 кадра × 4 направления
tools/rpg_editor/tile = Тайл
-tools/rpg_editor/tile_tip = Номер тайла, если чарсета нет; -1 — ничего
+tools/rpg_editor/tile_tip = Номер тайла, если чарсета нет; -1 — нет тайла
tools/rpg_editor/trigger = Запуск
tools/rpg_editor/trigger_action = Клавиша действия (E/F)
tools/rpg_editor/trigger_touch = Шаг на клетку
@@ -77,3 +77,28 @@ tools/rpg_editor/collision_auto = Авто — по тайлам
tools/rpg_editor/problem_tileset = У карты «{0}» не выбран тайлсет.
tools/rpg_editor/problem_event_id = У события в клетке {0} нет id.
tools/rpg_editor/problem_event_duplicate = Два события с одинаковым id «{0}».
+tools/rpg_editor/tab_tiles = Тайлы
+tools/rpg_editor/tab_characters = Персонажи
+tools/rpg_editor/character_search = Поиск по имени или id
+tools/rpg_editor/place_npc = На карту: NPC
+tools/rpg_editor/place_npc_tip = Поставить персонажа как событие с разговором на E/F; реплики пишутся в «Строках сценария»
+tools/rpg_editor/place_enemy = На карту: враг
+tools/rpg_editor/place_enemy_tip = Поставить персонажа как врага: E/F начинает с ним бой, после победы он исчезает
+tools/rpg_editor/characters_hint = Выберите персонажа, нажмите кнопку и кликните клетку на карте (ПКМ — отмена). Чарсет ищется по полю Charset персонажа или по пути его портрета.
+tools/rpg_editor/place_status = Кликните клетку, куда поставить «{0}» (ПКМ — отмена)
+tools/rpg_editor/place_cancelled = Постановка персонажа отменена
+tools/rpg_editor/placed_npc = «{0}» поставлен как NPC: событие {1}
+tools/rpg_editor/placed_enemy = «{0}» поставлен как враг: событие {1}
+tools/rpg_editor/icon = Иконка
+tools/rpg_editor/icon_tip = Картинка из группы items (как Sprite предмета), если нет ни чарсета, ни тайла
+tools/rpg_editor/tab_items = Предметы
+tools/rpg_editor/item_search = Поиск по названию или id
+tools/rpg_editor/items_hint = Выберите предмет, задайте количество, нажмите «На карту» и кликните клетку (ПКМ — отмена). Игрок подбирает предмет, наступив на клетку; потом предмет исчезает.
+tools/rpg_editor/item_count = Количество
+tools/rpg_editor/item_count_tip = Сколько штук получит игрок
+tools/rpg_editor/place_item = На карту
+tools/rpg_editor/place_item_tip = Положить предмет на карту: шаг на клетку подбирает его
+tools/rpg_editor/item_found_line = Найдено: {0} ×{1}
+tools/rpg_editor/placed_item = «{0}» лежит на карте: событие {1}
+tools/rpg_editor/picker_no_look = Картинка для карты не найдена, будет маркер
+tools/rpg_editor/problem_event_locked = Id события {0} уже занят событием из другого файла или мода — выберите другой
diff --git a/manifest.json b/manifest.json
index 758367c..799a081 100644
--- a/manifest.json
+++ b/manifest.json
@@ -13,6 +13,8 @@
],
"depends": [
"rpg>=1",
+ "characters_base>=1",
+ "items_base>=1",
"editor_tools>=1",
"ui_base>=1"
],
diff --git a/src/RpgCharacterCatalog.cs b/src/RpgCharacterCatalog.cs
new file mode 100644
index 0000000..4446135
--- /dev/null
+++ b/src/RpgCharacterCatalog.cs
@@ -0,0 +1,65 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Core.Characters;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Mods.Rpg;
+using Godot;
+
+namespace Cthangover.Tools.RpgEditor
+{
+ ///
+ /// The characters the running game knows (Kind: character of every
+ /// loaded mod), sorted by display name, for placing them on a map.
+ ///
+ /// A character has no map look of its own, so its charset is found by name:
+ /// an explicit "Charset" field of the character (kept by the
+ /// character's extra properties; the character editor sets it) wins, otherwise the
+ /// portrait path in the characters group (characters/village/slime or
+ /// village/slime → village/slime ), as village_pack names both. Only charsets that exist are used: the texture
+ /// loader logs an error for a missing one. The row picture is the middle frame
+ /// of the "down" row, how the character stands on the map.
+ ///
+ public static class RpgCharacterCatalog
+ {
+ private const string ImageGroup = "characters";
+
+ /// Reads the character list anew.
+ public static List Load(RpgTextures textures)
+ {
+ var charsets = RpgItemCatalog.GroupFiles(RpgTextures.CharsetGroup);
+ return (Entities.Collect()?.Values ?? Enumerable.Empty())
+ .Where(info => !string.IsNullOrEmpty(info.ID))
+ .Select(info => Entry(info, CharsetOf(info, charsets), textures))
+ .OrderBy(entry => entry.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ }
+
+ private static RpgPickerEntry Entry(CharacterInfo info, string charset, RpgTextures textures) => new()
+ {
+ Id = info.ID,
+ Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
+ Look = charset,
+ Icon = FrontFrame(charset == null ? null : textures.Charset(charset))
+ };
+
+ private static string CharsetOf(CharacterInfo info, HashSet charsets)
+ {
+ object explicitCharset = null;
+ info.ExtraProperties?.TryGetValue("Charset", out explicitCharset);
+ var image = info.Image ?? string.Empty;
+ if (image.StartsWith(ImageGroup + "/", StringComparison.OrdinalIgnoreCase))
+ image = image.Substring(ImageGroup.Length + 1);
+ var candidates = new[] { explicitCharset?.ToString(), image };
+ return candidates.FirstOrDefault(path => !string.IsNullOrEmpty(path) && charsets.Contains(path));
+ }
+
+ private static Texture2D FrontFrame(Texture2D image)
+ {
+ if (image == null)
+ return null;
+ var frame = new Vector2(image.GetWidth() / 3f, image.GetHeight() / 4f);
+ return new AtlasTexture { Atlas = image, Region = new Rect2(new Vector2(frame.X, 0f), frame) };
+ }
+ }
+}
diff --git a/src/RpgEntityPicker.cs b/src/RpgEntityPicker.cs
new file mode 100644
index 0000000..b759461
--- /dev/null
+++ b/src/RpgEntityPicker.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Cthangover.Mods.Interface.Theming;
+using Cthangover.Tools.Common;
+using Godot;
+
+namespace Cthangover.Tools.RpgEditor
+{
+ ///
+ /// A tab of the editor's left column listing entities of the game (the
+ /// characters, the items) with a picture, a search over name and id and
+ /// buttons that act on the selected row. The window decides what the
+ /// entries are ( ) and what the buttons do
+ /// ( ); placing on the map is then a click on a cell.
+ ///
+ /// The list is read again on every (the window calls it
+ /// whenever the tab is opened), so entities added to the game's data while
+ /// the editor is open show up.
+ ///
+ public partial class RpgEntityPicker : VBoxContainer
+ {
+ private readonly List shown = new();
+ private readonly List actions = new();
+ private readonly LineEdit search;
+ private readonly ItemList list;
+ private readonly HBoxContainer options;
+ private readonly HBoxContainer buttons;
+ private List all = new();
+
+ /// Builds the search field, the list, the option and button rows and the hint.
+ public RpgEntityPicker(string searchKey, string hintKey)
+ {
+ search = ToolUi.Text(ToolUi.Tr(searchKey), OnSearchChanged);
+ AddChild(search);
+
+ list = new ItemList
+ {
+ AutoHeight = true,
+ FixedIconSize = new Vector2I(32, 48),
+ TextureFilter = TextureFilterEnum.Nearest,
+ SizeFlagsHorizontal = SizeFlags.ExpandFill
+ };
+ list.ItemSelected += OnItemSelected;
+ AddChild(list);
+
+ options = new HBoxContainer();
+ AddChild(options);
+ buttons = new HBoxContainer();
+ AddChild(buttons);
+ AddChild(ToolUi.Hint(ToolUi.Tr(hintKey)));
+ }
+
+ /// Reads the entries; called by .
+ public Func> Source { get; set; }
+
+ /// The selected entry, or null.
+ public RpgPickerEntry Selected
+ {
+ get
+ {
+ var picked = list.GetSelectedItems();
+ return picked.Length == 0 ? null : shown[picked[0]];
+ }
+ }
+
+ /// Adds a button acting on the selected entry; it is disabled while nothing is selected.
+ public void AddAction(string textKey, string tooltipKey, Action action)
+ {
+ var button = ToolUi.Button(textKey, null, ButtonKind.Secondary, tooltipKey);
+ button.Pressed += () => Run(action);
+ button.Disabled = true;
+ actions.Add(button);
+ buttons.AddChild(button);
+ }
+
+ /// Adds a control (a count, a mode) to the row above the buttons.
+ public void AddOption(Control control) => options.AddChild(control);
+
+ /// Reads the entries again and reapplies the search.
+ public void Reload()
+ {
+ all = Source?.Invoke() ?? new List();
+ Filter();
+ }
+
+ private void Filter()
+ {
+ var selectedId = Selected?.Id;
+ var query = search.Text.Trim();
+ shown.Clear();
+ shown.AddRange(all.Where(entry => query.Length == 0
+ || entry.Name.Contains(query, StringComparison.CurrentCultureIgnoreCase)
+ || entry.Id.Contains(query, StringComparison.OrdinalIgnoreCase)));
+
+ list.Clear();
+ foreach (var entry in shown)
+ {
+ var index = list.AddItem($"{entry.Name} · {entry.Id}", entry.Icon);
+ list.SetItemTooltip(index, entry.Look ?? ToolUi.Tr("tools/rpg_editor/picker_no_look"));
+ if (entry.Id == selectedId)
+ list.Select(index);
+ }
+ UpdateButtons();
+ }
+
+ private void Run(Action action)
+ {
+ if (Selected != null)
+ action(Selected);
+ }
+
+ private void UpdateButtons()
+ {
+ foreach (var button in actions)
+ button.Disabled = Selected == null;
+ }
+
+ private void OnSearchChanged(string text) => Filter();
+
+ private void OnItemSelected(long index) => UpdateButtons();
+ }
+}
diff --git a/src/RpgEventPropertiesPanel.cs b/src/RpgEventPropertiesPanel.cs
index 01f55f0..68e12c4 100644
--- a/src/RpgEventPropertiesPanel.cs
+++ b/src/RpgEventPropertiesPanel.cs
@@ -8,8 +8,8 @@ using Godot;
namespace Cthangover.Tools.RpgEditor
{
///
- /// Properties of the selected map event: id, cell, look (charset path or
- /// tileset index), facing, trigger, solidity, what it runs (scenario path,
+ /// Properties of the selected map event: id, cell, look (charset path, tileset index
+ /// or item picture), facing, trigger, solidity, what it runs (scenario path,
/// inline DSL lines, or a teleport) and the condition that shows it.
///
/// A teleport is on when its map or cell is filled in; clearing the teleport
@@ -29,6 +29,7 @@ namespace Cthangover.Tools.RpgEditor
private readonly SpinBox ySpin;
private readonly LineEdit spriteEdit;
private readonly SpinBox tileSpin;
+ private readonly LineEdit iconEdit;
private readonly OptionButton dirPicker;
private readonly OptionButton triggerPicker;
private readonly OptionButton solidPicker;
@@ -66,6 +67,7 @@ namespace Cthangover.Tools.RpgEditor
ySpin = ToolUi.Field(body, "tools/rpg_editor/y", ToolUi.Number(0, 255, 1, OnNumberChanged));
spriteEdit = ToolUi.Field(body, "tools/rpg_editor/sprite", ToolUi.Text("village/elder", OnTextChanged), "tools/rpg_editor/sprite_tip");
tileSpin = ToolUi.Field(body, "tools/rpg_editor/tile", ToolUi.Number(-1, 4095, 1, OnNumberChanged), "tools/rpg_editor/tile_tip");
+ iconEdit = ToolUi.Field(body, "tools/rpg_editor/icon", ToolUi.Text("rpg_demo/branch_bundle", OnTextChanged), "tools/rpg_editor/icon_tip");
dirPicker = ToolUi.Field(body, "tools/rpg_editor/dir", RpgEditorFields.Direction(OnDirectionPicked));
triggerPicker = ToolUi.Field(body, "tools/rpg_editor/trigger", RpgEditorFields.Choice(TriggerKeys, OnChoicePicked));
solidPicker = ToolUi.Field(body, "tools/rpg_editor/solid", RpgEditorFields.Choice(SolidKeys, OnChoicePicked));
@@ -106,6 +108,7 @@ namespace Cthangover.Tools.RpgEditor
ySpin.Value = value.Y;
spriteEdit.Text = value.Sprite ?? string.Empty;
tileSpin.Value = value.Tile;
+ iconEdit.Text = value.Icon ?? string.Empty;
dirPicker.Select((int)value.Dir);
triggerPicker.Select((int)value.Trigger);
solidPicker.Select(value.Solid == null ? 0 : value.Solid.Value ? 1 : 2);
@@ -161,6 +164,7 @@ namespace Cthangover.Tools.RpgEditor
mapEvent.Y = (int)ySpin.Value;
mapEvent.Sprite = NullIfEmpty(spriteEdit.Text);
mapEvent.Tile = (int)tileSpin.Value;
+ mapEvent.Icon = NullIfEmpty(iconEdit.Text);
mapEvent.Dir = (RpgDirection)dirPicker.Selected;
mapEvent.Trigger = (RpgEventTrigger)triggerPicker.Selected;
mapEvent.Solid = solidPicker.Selected switch { 1 => true, 2 => false, _ => null };
diff --git a/src/RpgItemCatalog.cs b/src/RpgItemCatalog.cs
new file mode 100644
index 0000000..05678ae
--- /dev/null
+++ b/src/RpgItemCatalog.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Cthangover.Core.Items;
+using Cthangover.Core.Mods.Resolvers;
+using Cthangover.Mods.Rpg;
+using Godot;
+
+namespace Cthangover.Tools.RpgEditor
+{
+ ///
+ /// The items the running game knows (Kind: item of every loaded mod),
+ /// sorted by display name, for laying them on a map. The look is the item's
+ /// own Sprite in the items group, the picture the inventory
+ /// shows; an item whose picture is missing gets none (the texture loader
+ /// would log an error for it).
+ ///
+ public static class RpgItemCatalog
+ {
+ /// Reads the item list anew.
+ public static List Load(RpgTextures textures)
+ {
+ var pictures = GroupFiles(RpgTextures.IconGroup);
+ return (Entities.Collect()?.Values ?? Enumerable.Empty())
+ .Where(info => !string.IsNullOrEmpty(info.ID))
+ .Select(info => Entry(info, pictures.Contains(info.Sprite ?? string.Empty) ? info.Sprite : null, textures))
+ .OrderBy(entry => entry.Name, StringComparer.CurrentCultureIgnoreCase)
+ .ToList();
+ }
+
+ /// Paths of a file group without extensions, case-insensitive: what exists to be loaded.
+ public static HashSet GroupFiles(string group)
+ {
+ var paths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var files = Files.Collect(group);
+ if (files != null)
+ {
+ foreach (var key in files.Keys)
+ paths.Add(Path.ChangeExtension(key, null));
+ }
+ return paths;
+ }
+
+ private static RpgPickerEntry Entry(ItemInfo info, string picture, RpgTextures textures) => new()
+ {
+ Id = info.ID,
+ Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
+ Look = picture,
+ Icon = picture == null ? null : textures.Icon(picture)
+ };
+ }
+}
diff --git a/src/RpgMapDocument.cs b/src/RpgMapDocument.cs
index 0b5e6f5..8de485f 100644
--- a/src/RpgMapDocument.cs
+++ b/src/RpgMapDocument.cs
@@ -10,12 +10,19 @@ namespace Cthangover.Tools.RpgEditor
{
///
/// The map being edited, free of any UI: the with its
- /// cells parsed into an , the edit operations of the
- /// window and an undo history.
+ /// cells parsed into an , its events, the edit
+ /// operations of the window and an undo history.
+ ///
+ /// Events come in two parts. are the editor's own file of
+ /// the map's mod, ; the editor changes and saves only
+ /// them, so a beginner never deals with event files.
+ /// are all the others the game shows on this map: events other mods add, and
+ /// hand-written files of the same mod (an author may split events over files).
+ /// They are drawn, never edited, and no new event may take their ids.
///
/// Undo works on whole states: before an edit the window calls
/// , which stores the grid and the map's other data
- /// (events, spawn, size…) as JSON. A paint stroke is one change, however many
+ /// (own events, spawn, size…) as JSON. A paint stroke is one change, however many
/// cells it touches. The grid is the only source of the cells while editing;
/// writes it back into the map rows.
///
@@ -26,9 +33,15 @@ namespace Cthangover.Tools.RpgEditor
private readonly List undo = new();
private readonly List redo = new();
- /// Map data (events, spawn, encounters…); its layer rows are stale until .
+ /// Map data (spawn, encounters…); its layer rows are stale until .
public RpgMap Map { get; private set; }
+ /// Events of the editor's own file ( ), in drawing order.
+ public List Events { get; private set; } = new();
+
+ /// Events of the map from other files and mods: shown, not edited ( ).
+ public IReadOnlyList LockedEvents { get; private set; } = new List();
+
/// The cells.
public RpgMapGrid Grid { get; private set; }
@@ -41,7 +54,6 @@ namespace Cthangover.Tools.RpgEditor
private RpgMapDocument(RpgMap map)
{
Map = map;
- Map.Events ??= new List();
Map.Spawn ??= new RpgTeleport();
Grid = RpgMapGrid.Parse(map);
}
@@ -64,24 +76,41 @@ namespace Cthangover.Tools.RpgEditor
}
///
- /// Reads a map file ({"Kind":"rpg_map","Items":[…]} , the first item).
+ /// Reads a map file ({"Kind":"rpg_map","Items":[…]} , the first item),
+ /// and the editor's events file ({"Events":[…]} ; null or empty for none).
/// Throws on broken JSON and
/// on a file without maps, so the
/// window can show the reason instead of opening an empty map.
///
- public static RpgMapDocument Load(string json)
+ public static RpgMapDocument Load(string json, string eventsJson = null)
{
var data = JsonSerializer.Deserialize>(json, JsonOptionsProvider.ReadOptions);
var map = data?.Items?.FirstOrDefault() ?? throw new InvalidOperationException("the file has no rpg_map item");
- return new RpgMapDocument(map);
+ var document = new RpgMapDocument(map);
+ if (!string.IsNullOrWhiteSpace(eventsJson))
+ document.Events = JsonSerializer.Deserialize(eventsJson, JsonOptionsProvider.ReadOptions)?.Events ?? new List();
+ return document;
}
- /// The map file text: cells written into rows, nulls left out, indented.
+ /// Sets the map's events from other files and mods (shown, not edited, not undone).
+ public void SetLockedEvents(IReadOnlyList events) => LockedEvents = events ?? new List();
+
+ /// Mod-relative path of the editor's events file of a map: map_events/<id>/events.json .
+ public static string EventsPath(string mapId) => $"{RpgMapEventResolver.Directory}/{mapId}/events.json";
+
+ /// The map file text: cells written into rows, nulls left out, indented, LF line breaks on every OS.
public string ToJson()
{
Grid.WriteTo(Map);
var data = new FileData { Kind = "rpg_map", Items = new List { Map } };
- return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
+ return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions).Replace("\r\n", "\n");
+ }
+
+ /// The events file text ( ): the own events, nulls left out, indented.
+ public string EventsToJson()
+ {
+ var data = new RpgMapEventFile { Events = Events };
+ return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions).Replace("\r\n", "\n");
}
/// Remembers the current state as one undo step and forgets the redo branch.
@@ -152,7 +181,7 @@ namespace Cthangover.Tools.RpgEditor
/// The events on a cell, top-most (last) first.
public List EventsAt(int x, int y)
{
- var found = Map.Events.Where(e => e.X == x && e.Y == y).ToList();
+ var found = Events.Where(e => e.X == x && e.Y == y).ToList();
found.Reverse();
return found;
}
@@ -161,21 +190,90 @@ namespace Cthangover.Tools.RpgEditor
public RpgMapEvent AddEvent(int x, int y)
{
var number = 1;
- while (Map.Events.Any(e => string.Equals(e.Id, "event_" + number, StringComparison.OrdinalIgnoreCase)))
+ while (IdTaken("event_" + number))
number++;
var mapEvent = new RpgMapEvent { Id = "event_" + number, X = x, Y = y };
- Map.Events.Add(mapEvent);
+ Events.Add(mapEvent);
return mapEvent;
}
- /// Removes an event; false when it is not on this map.
- public bool RemoveEvent(RpgMapEvent mapEvent) => Map.Events.Remove(mapEvent);
+ ///
+ /// Puts a character of the game (Kind: character ) on a cell as an
+ /// action event looking like (null: the editor's
+ /// marker). The event id is the character id, numbered (_2 , _3 …)
+ /// when taken. An enemy starts rpg.battle with that character on E/F
+ /// and vanishes once beaten: its win flag <map>_<event>_won is
+ /// also its hiding condition, so every placed enemy is beaten separately.
+ /// A non-enemy gets no lines; the author writes its dialog.
+ ///
+ public RpgMapEvent AddCharacter(int x, int y, string characterId, string charset, bool enemy)
+ {
+ var id = FreeId(characterId);
+ var mapEvent = new RpgMapEvent { Id = id, X = x, Y = y, Sprite = charset };
+ if (enemy)
+ {
+ var flag = $"{Map.ID}_{id}_won";
+ mapEvent.Commands = new List { $"action rpg.battle enemies={characterId} win_flag={flag}", "end" };
+ mapEvent.Condition = flag + " != 1";
+ }
+ Events.Add(mapEvent);
+ return mapEvent;
+ }
- /// Changes the map size; events left outside are moved to the nearest edge cell.
+ ///
+ /// Lays an item of the game (Kind: item ) on a cell to be picked up by
+ /// stepping on it, the way the demo's forest pickups work: the event shows
+ /// (the item's picture), is not solid, says
+ /// , adds of the item and sets
+ /// its own flag <map>_<event>_taken , which hides it for good.
+ /// The event id is the item id with every character a flag name cannot hold
+ /// turned into _ (rpg_demo/branches → rpg_demo_branches ),
+ /// numbered when taken.
+ ///
+ public RpgMapEvent AddItem(int x, int y, string itemId, string icon, int count, string line)
+ {
+ var id = FreeId(new string(itemId.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray()));
+ var flag = $"{Map.ID}_{id}_taken";
+ var mapEvent = new RpgMapEvent
+ {
+ Id = id,
+ X = x,
+ Y = y,
+ Icon = icon,
+ Trigger = RpgEventTrigger.Touch,
+ Solid = false,
+ Commands = new List
+ {
+ $"ptext \"{line.Replace("\"", "'")}\"",
+ $"action item.add id={itemId} count={Math.Max(1, count)}",
+ $"flag {flag} 1",
+ "end"
+ },
+ Condition = flag + " != 1"
+ };
+ Events.Add(mapEvent);
+ return mapEvent;
+ }
+
+ /// when no event has it (ignoring case), else the first free wanted_N from 2.
+ private string FreeId(string wanted)
+ {
+ var id = wanted;
+ for (var number = 2; IdTaken(id); number++)
+ id = wanted + "_" + number;
+ return id;
+ }
+
+ private bool IdTaken(string id) => Events.Concat(LockedEvents).Any(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase));
+
+ /// Removes an event; false when it is not on this map.
+ public bool RemoveEvent(RpgMapEvent mapEvent) => Events.Remove(mapEvent);
+
+ /// Changes the map size; own events left outside are moved to the nearest edge cell.
public void Resize(int width, int height)
{
Grid.Resize(width, height);
- foreach (var mapEvent in Map.Events)
+ foreach (var mapEvent in Events)
{
mapEvent.X = Math.Clamp(mapEvent.X, 0, Grid.Width - 1);
mapEvent.Y = Math.Clamp(mapEvent.Y, 0, Grid.Height - 1);
@@ -193,12 +291,15 @@ namespace Cthangover.Tools.RpgEditor
var problems = new List<(string, string)>();
if (string.IsNullOrWhiteSpace(Map.Tileset))
problems.Add(("tools/rpg_editor/problem_tileset", Map.ID));
- foreach (var mapEvent in Map.Events)
+ foreach (var mapEvent in Events)
{
if (string.IsNullOrWhiteSpace(mapEvent.Id))
problems.Add(("tools/rpg_editor/problem_event_id", $"{mapEvent.X},{mapEvent.Y}"));
+ var locked = LockedEvents.FirstOrDefault(e => string.Equals(e.Id, mapEvent.Id, StringComparison.OrdinalIgnoreCase));
+ if (locked != null)
+ problems.Add(("tools/rpg_editor/problem_event_locked", $"{mapEvent.Id} ({locked.ModId}/{locked.File})"));
}
- foreach (var group in Map.Events.Where(e => !string.IsNullOrWhiteSpace(e.Id)).GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase))
+ foreach (var group in Events.Where(e => !string.IsNullOrWhiteSpace(e.Id)).GroupBy(e => e.Id, StringComparer.OrdinalIgnoreCase))
{
if (group.Count() > 1)
problems.Add(("tools/rpg_editor/problem_event_duplicate", group.Key));
@@ -213,7 +314,7 @@ namespace Cthangover.Tools.RpgEditor
private RpgMapSnapshot Snapshot()
{
- return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map));
+ return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map), JsonSerializer.Serialize(Events));
}
private bool Swap(List from, List to)
@@ -225,6 +326,7 @@ namespace Cthangover.Tools.RpgEditor
from.RemoveAt(from.Count - 1);
Grid = state.Grid.Clone();
Map = JsonSerializer.Deserialize(state.MapJson);
+ Events = JsonSerializer.Deserialize>(state.EventsJson);
return true;
}
}
diff --git a/src/RpgMapEditorCanvas.cs b/src/RpgMapEditorCanvas.cs
index 80d58bb..8df057f 100644
--- a/src/RpgMapEditorCanvas.cs
+++ b/src/RpgMapEditorCanvas.cs
@@ -16,7 +16,12 @@ namespace Cthangover.Tools.RpgEditor
/// a drag paints a stroke, RMB erases, Alt+LMB picks the tile under the
/// pointer. On the events layer: LMB selects the top event of a cell and drags
/// it, a double click on an empty cell adds an event, RMB clears the selection.
- /// Ctrl+wheel zooms.
+ /// Locked events (other mods' and other files') are drawn faded with a dashed
+ /// frame and ignore the mouse.
+ /// While , a click only chooses a cell for the window.
+ /// Ctrl+wheel zooms. The plain wheel (Shift+wheel horizontally) is left to
+ /// the surrounding ScrollContainer, and a middle-button drag pans it, so a
+ /// map larger than the view is reachable on any layer and with any tool.
///
/// The canvas owns no data. It edits the window's
/// and reports through events; every stroke or
@@ -29,12 +34,16 @@ namespace Cthangover.Tools.RpgEditor
private static readonly Color OpenColor = new(0.2f, 0.85f, 0.35f, 0.4f);
private static readonly Color GridColor = new(1f, 1f, 1f, 0.12f);
private static readonly Color MarkerColor = new(0.35f, 0.55f, 1f, 0.55f);
+ private static readonly Color LockedModulate = new(1f, 1f, 1f, 0.55f);
private readonly RpgTextures textures = new();
private Vector2I? hover;
private bool stroking;
private RpgMapEvent dragged;
private bool dragMoved;
+ private bool panning;
+ private Vector2 panStartMouse;
+ private Vector2 panStartScroll;
/// The edited map; null shows an empty canvas.
public RpgMapDocument Document { get; set; }
@@ -78,6 +87,15 @@ namespace Cthangover.Tools.RpgEditor
/// Alt+click picked a tile.
public event Action TilePicked;
+ ///
+ /// The next LMB on a cell is not an edit but a choice reported through
+ /// (placing a character); RMB cancels. Either ends it.
+ ///
+ public bool Placing { get; set; }
+
+ /// The cell chosen while , or null when cancelled.
+ public event Action CellChosen;
+
/// Creates the canvas; input comes through the GuiInput event.
public RpgMapEditorCanvas()
{
@@ -147,30 +165,55 @@ namespace Cthangover.Tools.RpgEditor
private void DrawEvents()
{
- foreach (var mapEvent in Document.Map.Events)
- {
- var rect = CellRect(mapEvent.X, mapEvent.Y);
- var charset = string.IsNullOrEmpty(mapEvent.Sprite) ? null : textures.Charset(mapEvent.Sprite);
- if (charset != null)
- {
- var frame = new Vector2(charset.GetWidth() / 3f, charset.GetHeight() / 4f);
- var source = new Rect2(new Vector2(frame.X, frame.Y * (int)mapEvent.Dir), frame);
- var size = frame * Zoom;
- var target = new Rect2(rect.Position + new Vector2((rect.Size.X - size.X) / 2f, rect.Size.Y - size.Y), size);
- DrawTextureRectRegion(charset, target, source);
- }
- else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
- {
- DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile));
- }
- else
- {
- DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor);
- }
+ foreach (var mapEvent in Document.LockedEvents)
+ DrawEvent(mapEvent, true);
+ foreach (var mapEvent in Document.Events)
+ DrawEvent(mapEvent, false);
+ }
- if (ActiveLayer == RpgEditLayer.Events)
- DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
+ /// One event: its look, then on the events layer a frame (dashed grey for a locked one).
+ private void DrawEvent(RpgMapEvent mapEvent, bool locked)
+ {
+ var modulate = locked ? LockedModulate : Colors.White;
+ var rect = CellRect(mapEvent.X, mapEvent.Y);
+ var charset = string.IsNullOrEmpty(mapEvent.Sprite) ? null : textures.Charset(mapEvent.Sprite);
+ if (charset != null)
+ {
+ var frame = new Vector2(charset.GetWidth() / 3f, charset.GetHeight() / 4f);
+ var source = new Rect2(new Vector2(frame.X, frame.Y * (int)mapEvent.Dir), frame);
+ var size = frame * Zoom;
+ var target = new Rect2(rect.Position + new Vector2((rect.Size.X - size.X) / 2f, rect.Size.Y - size.Y), size);
+ DrawTextureRectRegion(charset, target, source, modulate);
}
+ else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
+ {
+ DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile), modulate);
+ }
+ else if (!string.IsNullOrEmpty(mapEvent.Icon) && textures.Icon(mapEvent.Icon) is { } icon)
+ {
+ // Fitted like RpgActor.UseIcon: three quarters of the cell, centred.
+ var fit = CellSize * 0.75f / Mathf.Max(1, Mathf.Max(icon.GetWidth(), icon.GetHeight()));
+ var size = icon.GetSize() * fit;
+ DrawTextureRect(icon, new Rect2(rect.GetCenter() - size / 2f, size), false, modulate);
+ }
+ else
+ {
+ DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor * modulate);
+ }
+
+ if (ActiveLayer == RpgEditLayer.Events && locked)
+ DrawDashedRect(rect.Grow(-1f));
+ else if (ActiveLayer == RpgEditLayer.Events)
+ DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
+ }
+
+ private void DrawDashedRect(Rect2 rect)
+ {
+ var color = UiTokens.Alpha(UiTokens.White, 0.6f);
+ DrawDashedLine(rect.Position, new Vector2(rect.End.X, rect.Position.Y), color, 1f, 4f);
+ DrawDashedLine(new Vector2(rect.End.X, rect.Position.Y), rect.End, color, 1f, 4f);
+ DrawDashedLine(rect.End, new Vector2(rect.Position.X, rect.End.Y), color, 1f, 4f);
+ DrawDashedLine(new Vector2(rect.Position.X, rect.End.Y), rect.Position, color, 1f, 4f);
}
private void DrawSpawn()
@@ -228,6 +271,12 @@ namespace Cthangover.Tools.RpgEditor
if (@event is InputEventMouseMotion motion)
{
+ if (panning)
+ {
+ Pan(motion);
+ AcceptEvent();
+ return;
+ }
hover = CellAt(motion.Position);
if (stroking && (motion.ButtonMask & (MouseButtonMask.Left | MouseButtonMask.Right)) != 0)
Apply(hover.Value, (motion.ButtonMask & MouseButtonMask.Right) != 0);
@@ -246,6 +295,15 @@ namespace Cthangover.Tools.RpgEditor
AcceptEvent();
return;
}
+ // Not accepted: the event bubbles up and the ScrollContainer scrolls.
+ if (button.ButtonIndex is MouseButton.WheelUp or MouseButton.WheelDown or MouseButton.WheelLeft or MouseButton.WheelRight)
+ return;
+ if (button.ButtonIndex == MouseButton.Middle)
+ {
+ StartOrEndPan(button);
+ AcceptEvent();
+ return;
+ }
var cell = CellAt(button.Position);
if (!button.Pressed)
@@ -258,6 +316,15 @@ namespace Cthangover.Tools.RpgEditor
return;
}
+ if (Placing && (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right))
+ {
+ Placing = false;
+ var chosen = button.ButtonIndex == MouseButton.Left && Document.Grid.InBounds(cell.X, cell.Y);
+ CellChosen?.Invoke(chosen ? cell : null);
+ AcceptEvent();
+ return;
+ }
+
if (ActiveLayer == RpgEditLayer.Events)
PressOnEvents(button, cell);
else if (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right)
@@ -343,6 +410,36 @@ namespace Cthangover.Tools.RpgEditor
QueueRedraw();
}
+ ///
+ /// Middle button down starts panning the parent ScrollContainer, up ends
+ /// it. The scroll offset is anchored to the press point rather than
+ /// accumulated from deltas, so hitting a scroll edge builds no drift.
+ ///
+ private void StartOrEndPan(InputEventMouseButton button)
+ {
+ var scroll = GetParentOrNull();
+ panning = button.Pressed && scroll != null;
+ MouseDefaultCursorShape = panning ? CursorShape.Drag : CursorShape.Arrow;
+ if (!panning)
+ return;
+ panStartMouse = button.GlobalPosition;
+ panStartScroll = new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical);
+ }
+
+ private void Pan(InputEventMouseMotion motion)
+ {
+ var scroll = GetParentOrNull();
+ if (scroll == null || (motion.ButtonMask & MouseButtonMask.Middle) == 0)
+ {
+ panning = false;
+ MouseDefaultCursorShape = CursorShape.Arrow;
+ return;
+ }
+ var delta = motion.GlobalPosition - panStartMouse;
+ scroll.ScrollHorizontal = Mathf.RoundToInt(panStartScroll.X - delta.X);
+ scroll.ScrollVertical = Mathf.RoundToInt(panStartScroll.Y - delta.Y);
+ }
+
private void OnMouseExited()
{
hover = null;
diff --git a/src/RpgMapEditorWindow.cs b/src/RpgMapEditorWindow.cs
index 6e145ac..06d8cf3 100644
--- a/src/RpgMapEditorWindow.cs
+++ b/src/RpgMapEditorWindow.cs
@@ -19,8 +19,11 @@ namespace Cthangover.Tools.RpgEditor
///
/// Editor of RPG tile maps (maps/*.json , kind rpg_map ).
/// Layout: toolbar (file, save, layer, brush, grid, zoom, undo/redo, play),
- /// the tile palette on the left, the map in the middle, the map's and the
- /// selected event's properties on the right.
+ /// the tile palette, the game's characters and items as tabs on the left, the
+ /// map in the middle, the map's and the selected event's properties on the right.
+ /// A character is put on the map as an NPC or an enemy, an item as a pickup:
+ /// pick it, press the button, click a cell ( ,
+ /// ).
///
/// Layers are the game's: back, main, front, the collision layer (codes
/// over the tiles' own solidity) and events. Every edit is undoable
@@ -31,6 +34,10 @@ namespace Cthangover.Tools.RpgEditor
/// ( ), so "Play" works on a new map
/// without a mod reload.
///
+ /// Saving writes the map file and the mod's events file of the map,
+ /// map_events/<id>/events.json ( explains
+ /// own and locked events).
+ ///
/// After a save the map is put into the running game's map cache, and a
/// world showing it is remounted, so the edit is visible at once; "Play"
/// saves and moves the player onto the map at its spawn.
@@ -44,7 +51,12 @@ namespace Cthangover.Tools.RpgEditor
private readonly Dictionary toolButtons = new();
private ModFileSelector fileSelector;
+ private TabContainer sideTabs;
private RpgTilePalette palette;
+ private SpinBox itemCount;
+ private Func placeAt;
+ private string placedKey;
+ private string placedName;
private RpgMapEditorCanvas canvas;
private RpgMapPropertiesPanel mapPanel;
private RpgEventPropertiesPanel eventPanel;
@@ -78,13 +90,38 @@ namespace Cthangover.Tools.RpgEditor
var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill };
outer.AddChild(main);
+ var leftColumn = new VBoxContainer();
+ sideTabs = new TabContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
+ leftColumn.AddChild(sideTabs);
+
var paletteColumn = new VBoxContainer();
paletteColumn.AddChild(ToolUi.Section("tools/rpg_editor/section_palette"));
palette = new RpgTilePalette();
palette.TileSelected += OnPaletteTile;
palette.CollisionSelected += OnPaletteCollision;
paletteColumn.AddChild(palette);
- main.AddChild(CreateSidebar(paletteColumn, 400));
+ sideTabs.AddChild(paletteColumn);
+ sideTabs.SetTabTitle(0, ToolUi.Tr("tools/rpg_editor/tab_tiles"));
+
+ var characters = new RpgEntityPicker("tools/rpg_editor/character_search", "tools/rpg_editor/characters_hint") { Name = "Characters" };
+ characters.Source = LoadCharacters;
+ characters.AddAction("tools/rpg_editor/place_npc", "tools/rpg_editor/place_npc_tip", PlaceNpc);
+ characters.AddAction("tools/rpg_editor/place_enemy", "tools/rpg_editor/place_enemy_tip", PlaceEnemy);
+ sideTabs.AddChild(characters);
+ sideTabs.SetTabTitle(1, ToolUi.Tr("tools/rpg_editor/tab_characters"));
+
+ var items = new RpgEntityPicker("tools/rpg_editor/item_search", "tools/rpg_editor/items_hint") { Name = "Items" };
+ items.Source = LoadItems;
+ itemCount = ToolUi.Number(1, 999, 1, null);
+ itemCount.Value = 1;
+ itemCount.TooltipText = ToolUi.Tr("tools/rpg_editor/item_count_tip");
+ items.AddOption(new Label { Text = ToolUi.Tr("tools/rpg_editor/item_count") });
+ items.AddOption(itemCount);
+ items.AddAction("tools/rpg_editor/place_item", "tools/rpg_editor/place_item_tip", PlaceItem);
+ sideTabs.AddChild(items);
+ sideTabs.SetTabTitle(2, ToolUi.Tr("tools/rpg_editor/tab_items"));
+ sideTabs.TabChanged += OnSideTabChanged;
+ main.AddChild(CreateSidebar(leftColumn, 400));
var center = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill };
main.AddChild(center);
@@ -94,6 +131,7 @@ namespace Cthangover.Tools.RpgEditor
canvas.Changed += OnDocumentChanged;
canvas.EventSelected += OnEventSelected;
canvas.TilePicked += OnTilePicked;
+ canvas.CellChosen += OnCellChosen;
center.AddChild(Scroll(canvas));
var properties = new VBoxContainer();
@@ -206,7 +244,7 @@ namespace Cthangover.Tools.RpgEditor
{
var text = ModFileService.ReadText(mod, file);
isNew = string.IsNullOrEmpty(text);
- document = isNew ? CreateDocument(file) : RpgMapDocument.Load(text);
+ document = isNew ? CreateDocument(file) : LoadDocument(mod, text);
SetStatus(string.Format(ToolUi.Tr(isNew ? "tools/common/new_file_status" : "tools/common/loaded"), mod + "/" + file));
}
catch (Exception ex)
@@ -221,6 +259,23 @@ namespace Cthangover.Tools.RpgEditor
BindDocument();
}
+ ///
+ /// The map with the editor's events file of the same mod; every other event
+ /// the game shows on the map is locked. An own event hides a locked one of the
+ /// same id (another mod replacing it), so the author sees and edits their own.
+ ///
+ private static RpgMapDocument LoadDocument(string mod, string text)
+ {
+ var id = RpgMapDocument.Load(text).Map.ID;
+ var eventsPath = RpgMapDocument.EventsPath(id);
+ var document = RpgMapDocument.Load(text, ModFileService.ReadText(mod, eventsPath));
+ var own = new HashSet(document.Events.Select(e => e.Id), StringComparer.OrdinalIgnoreCase);
+ document.SetLockedEvents(RpgMapEvents.Of(id)
+ .Where(e => !(e.ModId == mod && e.File == eventsPath) && !own.Contains(e.Id))
+ .ToList());
+ return document;
+ }
+
private static RpgMapDocument CreateDocument(string file)
{
var tileset = Entities.Collect().Keys.OrderBy(k => k).FirstOrDefault();
@@ -248,11 +303,15 @@ namespace Cthangover.Tools.RpgEditor
}
var json = document.ToJson();
- if (!ModFileService.WriteText(modId, path, json))
+ var eventsPath = RpgMapDocument.EventsPath(document.Map.ID);
+ var writeEvents = document.Events.Count > 0 || !string.IsNullOrEmpty(ModFileService.ReadText(modId, eventsPath));
+ if (!ModFileService.WriteText(modId, path, json)
+ || (writeEvents && !ModFileService.WriteText(modId, eventsPath, document.EventsToJson())))
{
SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error);
return false;
}
+ RpgMapEvents.Invalidate();
var sceneNote = isNew ? EnsureScene() : null;
if (sceneNote != null)
@@ -342,7 +401,7 @@ namespace Cthangover.Tools.RpgEditor
return;
// The document holds a new map object now: find the selection again by id.
var selectedId = canvas.SelectedEvent?.Id;
- canvas.SelectedEvent = document.Map.Events.FirstOrDefault(e => e.Id == selectedId);
+ canvas.SelectedEvent = document.Events.FirstOrDefault(e => e.Id == selectedId);
SetDirty();
BindDocument();
}
@@ -379,6 +438,57 @@ namespace Cthangover.Tools.RpgEditor
private void OnPaletteCollision(char code) => canvas.SelectedCollision = code;
+ private void OnSideTabChanged(long tab)
+ {
+ if (sideTabs.GetCurrentTabControl() is RpgEntityPicker picker)
+ picker.Reload();
+ }
+
+ private List LoadCharacters() => RpgCharacterCatalog.Load(textures);
+
+ private List LoadItems() => RpgItemCatalog.Load(textures);
+
+ private void PlaceNpc(RpgPickerEntry entry)
+ => BeginPlacing(entry, "tools/rpg_editor/placed_npc", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, false));
+
+ private void PlaceEnemy(RpgPickerEntry entry)
+ => BeginPlacing(entry, "tools/rpg_editor/placed_enemy", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, true));
+
+ private void PlaceItem(RpgPickerEntry entry)
+ {
+ var count = (int)itemCount.Value;
+ var line = string.Format(ToolUi.Tr("tools/rpg_editor/item_found_line"), entry.Name, count);
+ BeginPlacing(entry, "tools/rpg_editor/placed_item", cell => document.AddItem(cell.X, cell.Y, entry.Id, entry.Look, count, line));
+ }
+
+ /// Something is to be placed: switch to the events layer and wait for a cell; makes the event.
+ private void BeginPlacing(RpgPickerEntry entry, string doneKey, Func place)
+ {
+ if (document == null)
+ return;
+ placeAt = place;
+ placedKey = doneKey;
+ placedName = entry.Name;
+ SelectLayer(RpgEditLayer.Events);
+ canvas.Placing = true;
+ SetStatus(string.Format(ToolUi.Tr("tools/rpg_editor/place_status"), entry.Name));
+ }
+
+ private void OnCellChosen(Vector2I? cell)
+ {
+ if (cell == null || placeAt == null || document == null)
+ {
+ SetStatus(ToolUi.Tr("tools/rpg_editor/place_cancelled"));
+ return;
+ }
+ RecordUndo();
+ var mapEvent = placeAt(cell.Value);
+ canvas.SelectedEvent = mapEvent;
+ eventPanel.Bind(mapEvent);
+ OnDocumentChanged();
+ SetStatus(string.Format(ToolUi.Tr(placedKey), placedName, mapEvent.Id), ToolStatusKind.Ok);
+ }
+
private void OnTilePicked(int index)
{
canvas.SelectedTile = index;
@@ -388,6 +498,8 @@ namespace Cthangover.Tools.RpgEditor
private void SelectLayer(RpgEditLayer layer)
{
canvas.ActiveLayer = layer;
+ if (layer != RpgEditLayer.Events)
+ canvas.Placing = false;
foreach (var pair in layerButtons)
pair.Value.SetPressedNoSignal(pair.Key == layer);
palette.SetCollisionMode(layer == RpgEditLayer.Collision);
diff --git a/src/RpgMapSnapshot.cs b/src/RpgMapSnapshot.cs
index 1fe0214..72117d2 100644
--- a/src/RpgMapSnapshot.cs
+++ b/src/RpgMapSnapshot.cs
@@ -2,7 +2,7 @@ using Cthangover.Mods.Rpg;
namespace Cthangover.Tools.RpgEditor
{
- /// One undo step of : the cells and the rest of the map as JSON.
+ /// One undo step of : the cells, the rest of the map and the own events as JSON.
public class RpgMapSnapshot
{
/// Copy of the cells.
@@ -11,11 +11,15 @@ namespace Cthangover.Tools.RpgEditor
/// The map without its cells (rows may be stale), serialized.
public string MapJson { get; }
+ /// The document's own events, serialized.
+ public string EventsJson { get; }
+
/// Creates a step.
- public RpgMapSnapshot(RpgMapGrid grid, string mapJson)
+ public RpgMapSnapshot(RpgMapGrid grid, string mapJson, string eventsJson)
{
Grid = grid;
MapJson = mapJson;
+ EventsJson = eventsJson;
}
}
}
diff --git a/src/RpgPickerEntry.cs b/src/RpgPickerEntry.cs
new file mode 100644
index 0000000..6f030b4
--- /dev/null
+++ b/src/RpgPickerEntry.cs
@@ -0,0 +1,20 @@
+using Godot;
+
+namespace Cthangover.Tools.RpgEditor
+{
+ /// One row of an : a character or an item of the game.
+ public class RpgPickerEntry
+ {
+ /// Entity id (what rpg.battle enemies= or item.add id= takes).
+ public string Id { get; init; }
+
+ /// Display name in the current language (the id when the entity has none).
+ public string Name { get; init; }
+
+ /// How it looks on the map: a charset path for a character, an item picture path for an item; null for none.
+ public string Look { get; init; }
+
+ /// Picture of the row, or null.
+ public Texture2D Icon { get; init; }
+ }
+}
diff --git a/test_suites/edit/src/RpgEditorTestDriver.cs b/test_suites/edit/src/RpgEditorTestDriver.cs
index 8080ba7..61e7ab6 100644
--- a/test_suites/edit/src/RpgEditorTestDriver.cs
+++ b/test_suites/edit/src/RpgEditorTestDriver.cs
@@ -16,10 +16,13 @@ namespace Cthangover.Tools.RpgEditor.Tests
///
/// Drives the RPG map editor through its controls like an author: layer
/// buttons are pressed, the palette and the canvas get mouse events through
- /// their gui_input signal, Ctrl+Z goes into the window. That window is
+ /// their gui_input signal, Ctrl+Z goes into the window, a slime found in
+ /// the characters tab is placed as an enemy and branches from the items tab as
+ /// a pickup. That window is
/// freed without saving, so the demo maps never change. A second window then
/// creates a new map through the file selector's "+" and saves it: the game must
- /// know the map's scene at once (no mod reload); its files are deleted again.
+ /// know the map's scene and its event (saved apart, into map_events) at once,
+ /// with no mod reload; its files are deleted again.
/// Result line: [TEST_RESULT] name=rpg_editor_edit passed=N failed=M .
///
public partial class RpgEditorTestDriver : Node
@@ -31,6 +34,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
private static readonly Vector2I PaintCell = new(3, 5);
private static readonly Vector2I EventCell = new(2, 6);
+ private static readonly Vector2I CharacterCell = new(4, 6);
+ private static readonly Vector2I ItemCell = new(5, 6);
private readonly string shotsDir;
private readonly List failures = new();
@@ -95,6 +100,8 @@ namespace Cthangover.Tools.RpgEditor.Tests
if (canvas?.Document == null || canvas.Tileset == null)
return;
Check(canvas.Document.Map.ID.StartsWith("rpg_"), $"the editor opened a demo map ({canvas.Document.Map.ID})");
+ Check(canvas.Document.Events.Count > 0 && canvas.Document.LockedEvents.Count == 0,
+ $"its events come from the mod's map_events file ({canvas.Document.Events.Count} own, {canvas.Document.LockedEvents.Count} locked)");
Shot("editor");
Press("tools/rpg_editor/layer_main");
Check(canvas.ActiveLayer == RpgEditLayer.Main, "the layer button switches the brush to the main layer");
@@ -110,14 +117,15 @@ namespace Cthangover.Tools.RpgEditor.Tests
window.PushInput(new InputEventKey { Keycode = Key.Z, PhysicalKeycode = Key.Z, CtrlPressed = true, Pressed = true });
Check(canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y) == before, "Ctrl+Z undoes the stroke");
Press("tools/rpg_editor/layer_events");
- eventsBefore = canvas.Document.Map.Events.Count;
+ eventsBefore = canvas.Document.Events.Count;
ClickCanvas(EventCell, true);
- Check(canvas.Document.Map.Events.Count == eventsBefore + 1 && canvas.SelectedEvent != null, "a double click adds and selects an event");
+ Check(canvas.Document.Events.Count == eventsBefore + 1 && canvas.SelectedEvent != null, "a double click adds and selects an event");
step++;
return;
case 3:
// Screenshots one tick after the action, when the frame shows it.
Shot("events");
+ PlaceSlime();
Press("tools/rpg_editor/layer_collision");
step++;
return;
@@ -149,8 +157,12 @@ namespace Cthangover.Tools.RpgEditor.Tests
return;
try
{
+ canvas.Document.AddEvent(1, 1).Sprite = "village/elder";
Press("tools/common/save");
Check(File.Exists(ModFileService.GetWritablePath(newMapMod, "scenes/" + NewMap + ".json")), "the first save of a new map writes its scene");
+ Check(!ModFileService.ReadText(newMapMod, "maps/" + NewMap + ".json").Contains("\"Events\""), "the map file holds no events");
+ Check(RpgMapEvents.Of(NewMap).Any(e => e.Id == "event_1" && e.ModId == newMapMod && e.File == RpgMapDocument.EventsPath(NewMap)),
+ "the event is saved into map_events and the game reads it at once");
Check(GetTree().Root.GetNodeOrNull("SceneManager")?.HasScene(NewMap) == true, "the game knows the new map's scene without a mod reload");
}
finally
@@ -164,16 +176,74 @@ namespace Cthangover.Tools.RpgEditor.Tests
}
}
+ /// Characters tab: search "slime", pick it, "place as enemy", click a cell; items tab: two bundles of branches.
+ private void PlaceSlime()
+ {
+ var picker = Pick("Characters", 1, "slime", "rpg_slime");
+ if (picker == null)
+ return;
+ Check(picker.Selected.Look == "village/slime" && picker.Selected.Icon != null, $"the slime is picked with its charset ({picker.Selected.Look})");
+
+ var count = canvas.Document.Events.Count;
+ Press("tools/rpg_editor/place_enemy");
+ Check(canvas.Placing && canvas.ActiveLayer == RpgEditLayer.Events, "placing waits for a cell on the events layer");
+ ClickCanvas(CharacterCell, false);
+ var placed = canvas.SelectedEvent;
+ Check(canvas.Document.Events.Count == count + 1 && placed?.Sprite == "village/slime" && placed.X == CharacterCell.X && placed.Y == CharacterCell.Y,
+ "a click on the map puts the slime there");
+ Check(placed?.Commands?.FirstOrDefault()?.StartsWith("action rpg.battle enemies=rpg_slime ") == true && !canvas.Placing, "the placed enemy starts a battle with it");
+
+ picker = Pick("Items", 2, "branches", "rpg_demo/branches");
+ if (picker == null)
+ return;
+ Check(picker.Selected.Look == "rpg_demo/branch_bundle" && picker.Selected.Icon != null, $"the branches are picked with their picture ({picker.Selected.Look})");
+ var countSpin = picker.FindChildren("*", "", true, false).OfType().First();
+ countSpin.Value = 2;
+ Press("tools/rpg_editor/place_item");
+ ClickCanvas(ItemCell, false);
+ placed = canvas.SelectedEvent;
+ Check(placed?.Icon == "rpg_demo/branch_bundle" && placed.X == ItemCell.X && placed.Trigger == RpgEventTrigger.Touch && placed.Solid == false,
+ "a click on the map lays the branches there, picked up by stepping on them");
+ Check(placed?.Commands?.Contains("action item.add id=rpg_demo/branches count=2") == true, "the pickup gives the chosen count");
+ }
+
+ /// Opens the side tab, types the query, checks every row matches it and selects ; null when it is not listed.
+ private RpgEntityPicker Pick(string name, int tab, string query, string id)
+ {
+ window.FindChildren("*", "", true, false).OfType().First(t => t.GetTabCount() == 3).CurrentTab = tab;
+ var picker = window.FindChildren("*", "", true, false).OfType().First(p => p.Name == name);
+ var search = picker.FindChildren("*", "", true, false).OfType().First(e => e.GetParent() == picker);
+ search.Text = query;
+ search.EmitSignal(LineEdit.SignalName.TextChanged, query);
+ var list = picker.FindChildren("*", "", true, false).OfType().First();
+ Check(list.ItemCount >= 1 && Enumerable.Range(0, list.ItemCount).All(i => list.GetItemText(i).Contains(query, System.StringComparison.OrdinalIgnoreCase)),
+ $"the {name} search keeps only '{query}' ({list.ItemCount} rows)");
+ for (var i = 0; i < list.ItemCount; i++)
+ {
+ if (!list.GetItemText(i).EndsWith("· " + id))
+ continue;
+ list.Select(i);
+ list.EmitSignal(ItemList.SignalName.ItemSelected, i);
+ return picker;
+ }
+ Fail($"'{id}' is not in the {name} list");
+ return null;
+ }
+
private ModFileSelector Selector() => window.FindChildren("*", "", true, false).OfType().First();
private void DeleteNewMapFiles()
{
- foreach (var file in new[] { "maps/" + NewMap + ".json", "scenes/" + NewMap + ".json" })
+ foreach (var file in new[] { "maps/" + NewMap + ".json", "scenes/" + NewMap + ".json", RpgMapDocument.EventsPath(NewMap) })
{
var path = ModFileService.GetWritablePath(newMapMod, file);
if (path != null && File.Exists(path))
File.Delete(path);
}
+ var eventsDir = Path.GetDirectoryName(ModFileService.GetWritablePath(newMapMod, RpgMapDocument.EventsPath(NewMap)) ?? "");
+ if (!string.IsNullOrEmpty(eventsDir) && Directory.Exists(eventsDir) && !Directory.EnumerateFileSystemEntries(eventsDir).Any())
+ Directory.Delete(eventsDir);
+ RpgMapEvents.Invalidate();
}
private void Press(string key)
diff --git a/tests/RpgMapDocumentTests.cs b/tests/RpgMapDocumentTests.cs
index 0832baa..e57c365 100644
--- a/tests/RpgMapDocumentTests.cs
+++ b/tests/RpgMapDocumentTests.cs
@@ -58,7 +58,7 @@ namespace Cthangover.Tools.RpgEditor.Tests
document.AddEvent(3, 1).Sprite = "village/elder";
ModAssert.True(document.Undo(), "undo the event");
- ModAssert.Equal(0, document.Map.Events.Count, "event gone");
+ ModAssert.Equal(0, document.Events.Count, "event gone");
ModAssert.Equal(9, document.Grid.GetTile(RpgLayer.Front, 0, 0), "the earlier paint stays");
ModAssert.True(document.Undo(), "undo the paint");
ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Front, 0, 0), "paint gone");
@@ -66,7 +66,7 @@ namespace Cthangover.Tools.RpgEditor.Tests
ModAssert.True(document.Redo(), "redo the paint");
ModAssert.True(document.Redo(), "redo the event");
- ModAssert.Equal("village/elder", document.Map.Events[0].Sprite, "event back with its data");
+ ModAssert.Equal("village/elder", document.Events[0].Sprite, "event back with its data");
document.Undo();
document.BeginChange();
@@ -87,6 +87,95 @@ namespace Cthangover.Tools.RpgEditor.Tests
ModAssert.Equal("event_1", document.AddEvent(0, 0).Id, "a freed id is used again");
}
+ [ModTest("document/characters_become_npc_and_enemy_events")]
+ static void CharactersBecomeNpcAndEnemyEvents(ModTestContext ctx)
+ {
+ var document = NewMap();
+ var npc = document.AddCharacter(1, 2, "rpg_elder", "village/elder", false);
+ var enemy = document.AddCharacter(3, 1, "rpg_slime", "village/slime", true);
+ var second = document.AddCharacter(4, 1, "rpg_slime", null, true);
+
+ ModAssert.Equal("rpg_elder", npc.Id, "the character id is the event id");
+ ModAssert.Equal("village/elder", npc.Sprite, "the charset is the look");
+ ModAssert.Equal(1, npc.X, "x");
+ ModAssert.Equal(2, npc.Y, "y");
+ ModAssert.True(npc.Commands == null && npc.Condition == null, "an NPC gets no lines and no condition");
+ ModAssert.Equal("Action", npc.Trigger.ToString(), "talked to on E/F");
+
+ ModAssert.Equal("action rpg.battle enemies=rpg_slime win_flag=test_map_rpg_slime_won", enemy.Commands[0], "an enemy starts a battle with itself");
+ ModAssert.Equal("end", enemy.Commands[1], "and ends the dialog");
+ ModAssert.Equal("test_map_rpg_slime_won != 1", enemy.Condition, "hidden once beaten");
+
+ ModAssert.Equal("rpg_slime_2", second.Id, "a taken id gets a number");
+ ModAssert.Null(second.Sprite, "no charset: no look");
+ ModAssert.Equal("test_map_rpg_slime_2_won != 1", second.Condition, "each placed enemy is beaten separately");
+ ModAssert.Equal(0, document.Validate().Count, "the ids are valid");
+
+ var loaded = RpgMapDocument.Load(document.ToJson(), document.EventsToJson());
+ var restored = loaded.Events[1];
+ ModAssert.Equal("rpg_slime", restored.Id, "id survives the file");
+ ModAssert.Equal("village/slime", restored.Sprite, "sprite survives the file");
+ ModAssert.Equal(2, restored.Commands.Count, "commands survive the file");
+ ModAssert.Equal(enemy.Commands[0], restored.Commands[0], "battle line survives the file");
+ ModAssert.Equal(enemy.Condition, restored.Condition, "condition survives the file");
+ }
+
+ [ModTest("document/items_become_pickups")]
+ static void ItemsBecomePickups(ModTestContext ctx)
+ {
+ var document = NewMap();
+ var first = document.AddItem(2, 1, "rpg_demo/branches", "rpg_demo/branch_bundle", 3, "Found \"branches\"");
+ var second = document.AddItem(3, 1, "rpg_demo/branches", null, 0, "x");
+
+ ModAssert.Equal("rpg_demo_branches", first.Id, "the id holds no slash");
+ ModAssert.Equal("rpg_demo/branch_bundle", first.Icon, "the item picture is the look");
+ ModAssert.Equal("Touch", first.Trigger.ToString(), "picked up by stepping on it");
+ ModAssert.False(first.IsSolid, "the player can step on it");
+ ModAssert.Equal("ptext \"Found 'branches'\"", first.Commands[0], "the line, quotes made safe");
+ ModAssert.Equal("action item.add id=rpg_demo/branches count=3", first.Commands[1], "adds the stack");
+ ModAssert.Equal("flag test_map_rpg_demo_branches_taken 1", first.Commands[2], "remembers it was taken");
+ ModAssert.Equal("end", first.Commands[3], "ends the dialog");
+ ModAssert.Equal("test_map_rpg_demo_branches_taken != 1", first.Condition, "gone once taken");
+
+ ModAssert.Equal("rpg_demo_branches_2", second.Id, "a taken id gets a number");
+ ModAssert.Equal("action item.add id=rpg_demo/branches count=1", second.Commands[1], "at least one item");
+ ModAssert.Equal("test_map_rpg_demo_branches_2_taken != 1", second.Condition, "each pickup has its own flag");
+
+ var restored = RpgMapDocument.Load(document.ToJson(), document.EventsToJson()).Events[0];
+ ModAssert.Equal("rpg_demo/branch_bundle", restored.Icon, "icon survives the file");
+ ModAssert.Equal("Touch", restored.Trigger.ToString(), "trigger survives the file");
+ ModAssert.False(restored.IsSolid, "solidity survives the file");
+ ModAssert.Equal(4, restored.Commands.Count, "commands survive the file");
+ ModAssert.Equal(first.Condition, restored.Condition, "condition survives the file");
+ }
+
+ [ModTest("document/locked_events_are_shown_not_edited")]
+ static void LockedEventsAreShownNotEdited(ModTestContext ctx)
+ {
+ var document = NewMap();
+ document.SetLockedEvents(new[]
+ {
+ new RpgMapEvent { Id = "event_1", X = 1, Y = 1, ModId = "quest_mod", File = "map_events/test_map/quest.json" },
+ new RpgMapEvent { Id = "rpg_slime", X = 2, Y = 2, ModId = "quest_mod", File = "map_events/test_map/quest.json" }
+ });
+
+ ModAssert.Equal(0, document.EventsAt(1, 1).Count, "a locked event is not picked on its cell");
+ ModAssert.Equal("event_2", document.AddEvent(1, 1).Id, "a new event does not take a locked id");
+ ModAssert.Equal("rpg_slime_2", document.AddCharacter(0, 0, "rpg_slime", null, true).Id, "nor does a placed character");
+
+ document.BeginChange();
+ document.Events[0].Id = "EVENT_1";
+ var problems = document.Validate();
+ ModAssert.Equal(1, problems.Count, "renaming onto a locked id is a problem");
+ ModAssert.Equal("tools/rpg_editor/problem_event_locked", problems[0].Key, "the problem says why");
+ ModAssert.True(problems[0].Argument.Contains("quest_mod"), "and names the mod");
+
+ document.Undo();
+ ModAssert.Equal(2, document.LockedEvents.Count, "undo keeps the locked events");
+ ModAssert.Equal("event_2", document.Events[0].Id, "and restores the own ones");
+ ModAssert.False(document.EventsToJson().Contains("quest"), "locked events are not written");
+ }
+
[ModTest("document/resize_pulls_events_inside")]
static void ResizePullsEventsInside(ModTestContext ctx)
{
@@ -128,15 +217,20 @@ namespace Cthangover.Tools.RpgEditor.Tests
mapEvent.Teleport = new RpgTeleport { Map = "other", X = 4, Y = 5, Dir = RpgDirection.Left };
var json = document.ToJson();
- var loaded = RpgMapDocument.Load(json);
+ var eventsJson = document.EventsToJson();
+ var loaded = RpgMapDocument.Load(json, eventsJson);
ModAssert.True(json.Contains("\"Kind\": \"rpg_map\""), "the file declares its kind");
- ModAssert.False(json.Contains("\"Scenario\""), "empty fields are left out");
+ ModAssert.False(json.Contains("\"Events\""), "the map file holds no events");
+ ModAssert.True(eventsJson.Contains("\"Events\""), "the events file holds them");
+ ModAssert.False(eventsJson.Contains("\"Scenario\""), "empty fields are left out");
+ ModAssert.Equal("map_events/test_map/events.json", RpgMapDocument.EventsPath("test_map"), "the editor's events file of the map");
+ ModAssert.Equal(0, RpgMapDocument.Load(json).Events.Count, "a map without an events file has no events");
ModAssert.Equal("test_map", loaded.Map.ID, "id");
ModAssert.Equal(16, loaded.Grid.GetTile(RpgLayer.Main, 3, 2), "main tile");
ModAssert.Equal("o", loaded.Grid.GetCollision(0, 3).ToString(), "collision");
- ModAssert.Equal("Touch", loaded.Map.Events[0].Trigger.ToString(), "trigger");
- ModAssert.Equal("other", loaded.Map.Events[0].Teleport.Map, "teleport");
+ ModAssert.Equal("Touch", loaded.Events[0].Trigger.ToString(), "trigger");
+ ModAssert.Equal("other", loaded.Events[0].Teleport.Map, "teleport");
ModAssert.Throws(() => RpgMapDocument.Load("{\"Kind\":\"rpg_map\",\"Items\":[]}"), "a file without maps", anyException: true);
}
}