commit eadc7a11572039d770218c1236c74e083d44c228 Author: iisaev Date: Wed Oct 21 15:10:32 2026 +0300 init mod 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..ffcdeef --- /dev/null +++ b/locale/ui_en.properties @@ -0,0 +1,79 @@ +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/layer_back = Back +tools/rpg_editor/layer_main = Main +tools/rpg_editor/layer_front = Front +tools/rpg_editor/layer_collision = Collision +tools/rpg_editor/layer_events = Events +tools/rpg_editor/tool_pencil = Pencil +tools/rpg_editor/tool_eraser = Eraser +tools/rpg_editor/tool_fill = Fill +tools/rpg_editor/grid = Grid +tools/rpg_editor/zoom_in = Zoom in +tools/rpg_editor/zoom_out = Zoom out +tools/rpg_editor/undo = Undo +tools/rpg_editor/undo_tip = Undo the last edit (Ctrl+Z) +tools/rpg_editor/redo = Redo +tools/rpg_editor/redo_tip = Redo (Ctrl+Y) +tools/rpg_editor/play = Play +tools/rpg_editor/play_tip = Save and walk the map from its spawn in the running game +tools/rpg_editor/play_no_scene = Scene "{0}" is not loaded yet: reload the mods (Ctrl+Shift+R) after creating a map. +tools/rpg_editor/scene_created = scene {0} created +tools/rpg_editor/section_palette = Palette +tools/rpg_editor/section_map = Map +tools/rpg_editor/section_spawn = Spawn +tools/rpg_editor/section_encounters = Random battles +tools/rpg_editor/section_event = Event +tools/rpg_editor/section_action = What it does +tools/rpg_editor/section_teleport = Teleport +tools/rpg_editor/map_id = Id +tools/rpg_editor/map_id_tip = The map id is the file name and the name of the scene that shows the map +tools/rpg_editor/map_name = Name key +tools/rpg_editor/map_name_tip = Locale key of the map's display name +tools/rpg_editor/tileset = Tileset +tools/rpg_editor/width = Width +tools/rpg_editor/height = Height +tools/rpg_editor/resize = Apply size +tools/rpg_editor/zoom = Zoom in game +tools/rpg_editor/zoom_tip = Screen pixels per tile pixel in the game +tools/rpg_editor/x = X +tools/rpg_editor/y = Y +tools/rpg_editor/dir = Facing +tools/rpg_editor/dir_down = Down +tools/rpg_editor/dir_left = Left +tools/rpg_editor/dir_right = Right +tools/rpg_editor/dir_up = Up +tools/rpg_editor/encounter_steps = Every ~N steps +tools/rpg_editor/encounter_steps_tip = Average number of steps between random battles; 0 turns them off +tools/rpg_editor/encounter_groups = Enemy groups +tools/rpg_editor/encounter_groups_tip = Character ids, groups split by ";" — e.g. slime; wolf,slime +tools/rpg_editor/no_event = Switch to the Events layer and click an event, or double-click an empty cell to add one. +tools/rpg_editor/event_id = Id +tools/rpg_editor/sprite = Charset +tools/rpg_editor/sprite_tip = Image path in the charsets group: 3 frames × 4 facings +tools/rpg_editor/tile = Tile +tools/rpg_editor/tile_tip = Tileset index drawn when there is no charset; -1 for none +tools/rpg_editor/trigger = Trigger +tools/rpg_editor/trigger_action = Action key (E/F) +tools/rpg_editor/trigger_touch = Stepping on it +tools/rpg_editor/solid = Solid +tools/rpg_editor/solid_auto = When drawn +tools/rpg_editor/solid_yes = Yes +tools/rpg_editor/solid_no = No +tools/rpg_editor/solid_tile = solid +tools/rpg_editor/condition = Condition +tools/rpg_editor/condition_tip = Scenario condition; the event exists only while it is true +tools/rpg_editor/scenario = Scenario +tools/rpg_editor/scenario_tip = Path in the scenarios group, extension optional +tools/rpg_editor/commands = Inline scenario lines, run after the scenario: +tools/rpg_editor/teleport_map = Map +tools/rpg_editor/teleport_map_tip = Target map id; empty with a cell set means this map +tools/rpg_editor/delete_event = Delete event +tools/rpg_editor/collision_block = Wall — always blocked +tools/rpg_editor/collision_open = Passage — always walkable +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}". diff --git a/locale/ui_ru.properties b/locale/ui_ru.properties new file mode 100644 index 0000000..9e62197 --- /dev/null +++ b/locale/ui_ru.properties @@ -0,0 +1,79 @@ +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/layer_back = Фон +tools/rpg_editor/layer_main = Основной +tools/rpg_editor/layer_front = Передний +tools/rpg_editor/layer_collision = Коллизии +tools/rpg_editor/layer_events = События +tools/rpg_editor/tool_pencil = Карандаш +tools/rpg_editor/tool_eraser = Ластик +tools/rpg_editor/tool_fill = Заливка +tools/rpg_editor/grid = Сетка +tools/rpg_editor/zoom_in = Приблизить +tools/rpg_editor/zoom_out = Отдалить +tools/rpg_editor/undo = Отменить +tools/rpg_editor/undo_tip = Отменить последнюю правку (Ctrl+Z) +tools/rpg_editor/redo = Вернуть +tools/rpg_editor/redo_tip = Вернуть отменённое (Ctrl+Y) +tools/rpg_editor/play = Играть +tools/rpg_editor/play_tip = Сохранить и пройтись по карте от точки появления в запущенной игре +tools/rpg_editor/play_no_scene = Сцена «{0}» ещё не загружена: после создания карты перезагрузите моды (Ctrl+Shift+R). +tools/rpg_editor/scene_created = создана сцена {0} +tools/rpg_editor/section_palette = Палитра +tools/rpg_editor/section_map = Карта +tools/rpg_editor/section_spawn = Точка появления +tools/rpg_editor/section_encounters = Случайные бои +tools/rpg_editor/section_event = Событие +tools/rpg_editor/section_action = Что делает +tools/rpg_editor/section_teleport = Телепорт +tools/rpg_editor/map_id = Id +tools/rpg_editor/map_id_tip = Id карты — это имя файла и имя сцены, которая её показывает +tools/rpg_editor/map_name = Ключ имени +tools/rpg_editor/map_name_tip = Ключ локализации отображаемого имени карты +tools/rpg_editor/tileset = Тайлсет +tools/rpg_editor/width = Ширина +tools/rpg_editor/height = Высота +tools/rpg_editor/resize = Применить размер +tools/rpg_editor/zoom = Масштаб в игре +tools/rpg_editor/zoom_tip = Сколько пикселей экрана на пиксель тайла в игре +tools/rpg_editor/x = X +tools/rpg_editor/y = Y +tools/rpg_editor/dir = Взгляд +tools/rpg_editor/dir_down = Вниз +tools/rpg_editor/dir_left = Влево +tools/rpg_editor/dir_right = Вправо +tools/rpg_editor/dir_up = Вверх +tools/rpg_editor/encounter_steps = Примерно раз в N шагов +tools/rpg_editor/encounter_steps_tip = Среднее число шагов между случайными боями; 0 — без боёв +tools/rpg_editor/encounter_groups = Группы врагов +tools/rpg_editor/encounter_groups_tip = Id персонажей, группы через «;» — например slime; wolf,slime +tools/rpg_editor/no_event = Включите слой «События» и щёлкните по событию или дважды щёлкните по пустой клетке, чтобы добавить. +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/trigger = Запуск +tools/rpg_editor/trigger_action = Клавиша действия (E/F) +tools/rpg_editor/trigger_touch = Шаг на клетку +tools/rpg_editor/solid = Твёрдое +tools/rpg_editor/solid_auto = Если видно +tools/rpg_editor/solid_yes = Да +tools/rpg_editor/solid_no = Нет +tools/rpg_editor/solid_tile = непроходимый +tools/rpg_editor/condition = Условие +tools/rpg_editor/condition_tip = Условие сценария; событие есть, только пока оно истинно +tools/rpg_editor/scenario = Сценарий +tools/rpg_editor/scenario_tip = Путь в группе scenarios, расширение можно не писать +tools/rpg_editor/commands = Строки сценария, выполняются после сценария: +tools/rpg_editor/teleport_map = Карта +tools/rpg_editor/teleport_map_tip = Id карты назначения; пусто при заданной клетке — эта же карта +tools/rpg_editor/delete_event = Удалить событие +tools/rpg_editor/collision_block = Стена — всегда непроходимо +tools/rpg_editor/collision_open = Проход — всегда проходимо +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}». diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..758367c --- /dev/null +++ b/manifest.json @@ -0,0 +1,27 @@ +{ + "id": "rpg_editor", + "name": "RPG Map Editor", + "version": "1.0.0", + "author": "ct", + "description": "Editor of the rpg mod's tile maps (maps/*.json): paints the back, main and front tile layers and the collision layer with a pencil, an eraser and a flood fill from the tileset palette, places and edits map events (look, trigger, scenario, inline commands, teleport, condition), sets the map size, spawn and random encounters. Undo/redo, live update of the running world after saving, play from the spawn. Opens from the main menu tools, the novel editor hub and the in-game toolbar.", + "category": "tools, editor", + "sources": [ + "src/**/*.cs" + ], + "test_sources": [ + "tests/*.cs" + ], + "depends": [ + "rpg>=1", + "editor_tools>=1", + "ui_base>=1" + ], + "test_suites": [ + { + "id": "edit", + "activate": [ + "--itest=rpg_editor_edit" + ] + } + ] +} diff --git a/src/RpgEditLayer.cs b/src/RpgEditLayer.cs new file mode 100644 index 0000000..b699816 --- /dev/null +++ b/src/RpgEditLayer.cs @@ -0,0 +1,12 @@ +namespace Cthangover.Tools.RpgEditor +{ + /// What the editor's brush works on: one of the three tile layers, the collision layer or the events. + public enum RpgEditLayer + { + Back = 0, + Main = 1, + Front = 2, + Collision = 3, + Events = 4, + } +} diff --git a/src/RpgEditTool.cs b/src/RpgEditTool.cs new file mode 100644 index 0000000..d1fd1b6 --- /dev/null +++ b/src/RpgEditTool.cs @@ -0,0 +1,15 @@ +namespace Cthangover.Tools.RpgEditor +{ + /// Brush of the tile and collision layers. + public enum RpgEditTool + { + /// Puts the selected tile (or collision code) under the pointer; dragging paints a stroke. + Pencil = 0, + + /// Clears cells (collision back to auto). + Eraser = 1, + + /// Flood-fills the connected area of equal cells. + Fill = 2, + } +} diff --git a/src/RpgEditorFields.cs b/src/RpgEditorFields.cs new file mode 100644 index 0000000..fe035e3 --- /dev/null +++ b/src/RpgEditorFields.cs @@ -0,0 +1,30 @@ +using System; +using Cthangover.Mods.Rpg; +using Godot; + +namespace Cthangover.Tools.RpgEditor +{ + /// Small helpers for the editor's property panels: enum pickers and their localized items. + public static class RpgEditorFields + { + /// A facing picker; items in order. + public static OptionButton Direction(Action changed) + { + var picker = new OptionButton(); + foreach (var key in new[] { "tools/rpg_editor/dir_down", "tools/rpg_editor/dir_left", "tools/rpg_editor/dir_right", "tools/rpg_editor/dir_up" }) + picker.AddItem(TranslationServer.Translate(key)); + picker.ItemSelected += index => changed((RpgDirection)(int)index); + return picker; + } + + /// Items of a picker built from locale keys; the item index is the value. + public static OptionButton Choice(string[] keys, Action changed) + { + var picker = new OptionButton(); + foreach (var key in keys) + picker.AddItem(TranslationServer.Translate(key)); + picker.ItemSelected += index => changed((int)index); + return picker; + } + } +} diff --git a/src/RpgEventPropertiesPanel.cs b/src/RpgEventPropertiesPanel.cs new file mode 100644 index 0000000..01f55f0 --- /dev/null +++ b/src/RpgEventPropertiesPanel.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Mods.Rpg; +using Cthangover.Tools.Common; +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, + /// 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 + /// map and both coordinates turns it off again. Solidity has a third state, + /// "auto": solid when the event is drawn. Empty text fields are stored as + /// null so the saved file stays short. + /// + public partial class RpgEventPropertiesPanel : VBoxContainer + { + private static readonly string[] TriggerKeys = { "tools/rpg_editor/trigger_action", "tools/rpg_editor/trigger_touch" }; + private static readonly string[] SolidKeys = { "tools/rpg_editor/solid_auto", "tools/rpg_editor/solid_yes", "tools/rpg_editor/solid_no" }; + + private readonly Label emptyHint; + private readonly VBoxContainer body; + private readonly LineEdit idEdit; + private readonly SpinBox xSpin; + private readonly SpinBox ySpin; + private readonly LineEdit spriteEdit; + private readonly SpinBox tileSpin; + private readonly OptionButton dirPicker; + private readonly OptionButton triggerPicker; + private readonly OptionButton solidPicker; + private readonly LineEdit scenarioEdit; + private readonly TextEdit commandsEdit; + private readonly LineEdit conditionEdit; + private readonly LineEdit teleportMap; + private readonly SpinBox teleportX; + private readonly SpinBox teleportY; + private readonly OptionButton teleportDir; + private RpgMapEvent mapEvent; + private bool suppress; + private bool editing; + + /// Raised once before the first change after a bind. + public event Action EditStarting; + + /// A property changed. + public event Action Changed; + + /// The author pressed "delete". + public event Action DeleteRequested; + + /// Builds the fields. + public RpgEventPropertiesPanel() + { + AddChild(ToolUi.Section("tools/rpg_editor/section_event")); + emptyHint = ToolUi.Hint(ToolUi.Tr("tools/rpg_editor/no_event")); + AddChild(emptyHint); + body = new VBoxContainer(); + AddChild(body); + + idEdit = ToolUi.Field(body, "tools/rpg_editor/event_id", ToolUi.Text("", OnTextChanged)); + xSpin = ToolUi.Field(body, "tools/rpg_editor/x", ToolUi.Number(0, 255, 1, OnNumberChanged)); + 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"); + 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)); + conditionEdit = ToolUi.Field(body, "tools/rpg_editor/condition", ToolUi.Text("flag_name != 1", OnTextChanged), "tools/rpg_editor/condition_tip"); + + body.AddChild(ToolUi.Section("tools/rpg_editor/section_action")); + scenarioEdit = ToolUi.Field(body, "tools/rpg_editor/scenario", ToolUi.Text("my_game/events/elder", OnTextChanged), "tools/rpg_editor/scenario_tip"); + body.AddChild(ToolUi.Hint(ToolUi.Tr("tools/rpg_editor/commands"))); + commandsEdit = ToolUi.Code(); + commandsEdit.CustomMinimumSize = new Vector2(0, 90); + commandsEdit.SizeFlagsVertical = SizeFlags.Fill; + commandsEdit.TextChanged += OnCommandsChanged; + body.AddChild(commandsEdit); + + body.AddChild(ToolUi.Section("tools/rpg_editor/section_teleport")); + teleportMap = ToolUi.Field(body, "tools/rpg_editor/teleport_map", ToolUi.Text("", OnTextChanged), "tools/rpg_editor/teleport_map_tip"); + teleportX = ToolUi.Field(body, "tools/rpg_editor/x", ToolUi.Number(-1, 255, 1, OnNumberChanged)); + teleportY = ToolUi.Field(body, "tools/rpg_editor/y", ToolUi.Number(-1, 255, 1, OnNumberChanged)); + teleportDir = ToolUi.Field(body, "tools/rpg_editor/dir", RpgEditorFields.Direction(OnDirectionPicked)); + + body.AddChild(ToolUi.Button("tools/rpg_editor/delete_event", OnDeletePressed)); + Bind(null); + } + + /// Shows an event (null: the hint instead of the fields). + public void Bind(RpgMapEvent value) + { + mapEvent = value; + editing = false; + emptyHint.Visible = value == null; + body.Visible = value != null; + if (value == null) + return; + + suppress = true; + idEdit.Text = value.Id ?? string.Empty; + xSpin.Value = value.X; + ySpin.Value = value.Y; + spriteEdit.Text = value.Sprite ?? string.Empty; + tileSpin.Value = value.Tile; + dirPicker.Select((int)value.Dir); + triggerPicker.Select((int)value.Trigger); + solidPicker.Select(value.Solid == null ? 0 : value.Solid.Value ? 1 : 2); + conditionEdit.Text = value.Condition ?? string.Empty; + scenarioEdit.Text = value.Scenario ?? string.Empty; + commandsEdit.Text = string.Join("\n", value.Commands ?? new List()); + teleportMap.Text = value.Teleport?.Map ?? string.Empty; + teleportX.Value = value.Teleport?.X ?? -1; + teleportY.Value = value.Teleport?.Y ?? -1; + teleportDir.Select((int)(value.Teleport?.Dir ?? RpgDirection.Down)); + suppress = false; + } + + /// Re-reads the cell after the event was dragged on the canvas. + public void RefreshCell() + { + if (mapEvent == null) + return; + suppress = true; + xSpin.Value = mapEvent.X; + ySpin.Value = mapEvent.Y; + suppress = false; + } + + private void OnTextChanged(string text) => Write(); + + private void OnNumberChanged(double value) => Write(); + + private void OnDirectionPicked(RpgDirection dir) => Write(); + + private void OnChoicePicked(int index) => Write(); + + private void OnCommandsChanged() => Write(); + + private void OnDeletePressed() + { + if (mapEvent != null) + DeleteRequested?.Invoke(); + } + + private void Write() + { + if (suppress || mapEvent == null) + return; + if (!editing) + { + EditStarting?.Invoke(); + editing = true; + } + + mapEvent.Id = idEdit.Text.Trim(); + mapEvent.X = (int)xSpin.Value; + mapEvent.Y = (int)ySpin.Value; + mapEvent.Sprite = NullIfEmpty(spriteEdit.Text); + mapEvent.Tile = (int)tileSpin.Value; + mapEvent.Dir = (RpgDirection)dirPicker.Selected; + mapEvent.Trigger = (RpgEventTrigger)triggerPicker.Selected; + mapEvent.Solid = solidPicker.Selected switch { 1 => true, 2 => false, _ => null }; + mapEvent.Condition = NullIfEmpty(conditionEdit.Text); + mapEvent.Scenario = NullIfEmpty(scenarioEdit.Text); + var lines = commandsEdit.Text.Split('\n').Select(l => l.TrimEnd()).Where(l => l.Length > 0).ToList(); + mapEvent.Commands = lines.Count > 0 ? lines : null; + + var hasTeleport = teleportMap.Text.Trim().Length > 0 || teleportX.Value >= 0 || teleportY.Value >= 0; + mapEvent.Teleport = hasTeleport + ? new RpgTeleport + { + Map = NullIfEmpty(teleportMap.Text), + X = Math.Max(0, (int)teleportX.Value), + Y = Math.Max(0, (int)teleportY.Value), + Dir = (RpgDirection)teleportDir.Selected, + } + : null; + Changed?.Invoke(); + } + + private static string NullIfEmpty(string text) => string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + } +} diff --git a/src/RpgMapDocument.cs b/src/RpgMapDocument.cs new file mode 100644 index 0000000..0b5e6f5 --- /dev/null +++ b/src/RpgMapDocument.cs @@ -0,0 +1,231 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using Cthangover.Core.Mods; +using Cthangover.Core.Utils; +using Cthangover.Mods.Rpg; + +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. + /// + /// 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 + /// cells it touches. The grid is the only source of the cells while editing; + /// writes it back into the map rows. + /// + public class RpgMapDocument + { + private const int HistoryLimit = 200; + + private readonly List undo = new(); + private readonly List redo = new(); + + /// Map data (events, spawn, encounters…); its layer rows are stale until . + public RpgMap Map { get; private set; } + + /// The cells. + public RpgMapGrid Grid { get; private set; } + + /// True when there is a step to undo. + public bool CanUndo => undo.Count > 0; + + /// True when there is a step to redo. + public bool CanRedo => redo.Count > 0; + + private RpgMapDocument(RpgMap map) + { + Map = map; + Map.Events ??= new List(); + Map.Spawn ??= new RpgTeleport(); + Grid = RpgMapGrid.Parse(map); + } + + /// + /// A new map filled with one ground tile, its id taken from the file name. + /// The spawn is the centre, so a fresh map is playable at once. + /// + public static RpgMapDocument Create(string id, string tileset, int width, int height, int groundTile) + { + var map = new RpgMap { ID = id, Tileset = tileset, Width = width, Height = height }; + var document = new RpgMapDocument(map); + for (var y = 0; y < document.Grid.Height; y++) + { + for (var x = 0; x < document.Grid.Width; x++) + document.Grid.SetTile(RpgLayer.Back, x, y, groundTile); + } + map.Spawn = new RpgTeleport { X = document.Grid.Width / 2, Y = document.Grid.Height / 2 }; + return document; + } + + /// + /// Reads a map file ({"Kind":"rpg_map","Items":[…]}, the first item). + /// 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) + { + 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); + } + + /// The map file text: cells written into rows, nulls left out, indented. + public string ToJson() + { + Grid.WriteTo(Map); + var data = new FileData { Kind = "rpg_map", Items = new List { Map } }; + return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions); + } + + /// Remembers the current state as one undo step and forgets the redo branch. + public void BeginChange() + { + undo.Add(Snapshot()); + if (undo.Count > HistoryLimit) + undo.RemoveAt(0); + redo.Clear(); + } + + /// Steps back; false when there is nothing to undo. + public bool Undo() => Swap(undo, redo); + + /// Steps forward again; false when there is nothing to redo. + public bool Redo() => Swap(redo, undo); + + /// Writes a tile (-1 clears) or a collision code into one cell. Returns true when the cell changed. + public bool Paint(RpgEditLayer layer, int x, int y, int tile, char collision) + { + if (!Grid.InBounds(x, y)) + return false; + if (layer == RpgEditLayer.Collision) + { + if (Grid.GetCollision(x, y) == collision) + return false; + Grid.SetCollision(x, y, collision); + return true; + } + if (layer == RpgEditLayer.Events || Grid.GetTile((RpgLayer)layer, x, y) == tile) + return false; + Grid.SetTile((RpgLayer)layer, x, y, tile); + return true; + } + + /// + /// Flood-fills (4-neighbourhood) the area of cells equal to the start + /// cell with a tile or a collision code. Returns the number of cells changed. + /// + public int Fill(RpgEditLayer layer, int x, int y, int tile, char collision) + { + if (!Grid.InBounds(x, y) || layer == RpgEditLayer.Events) + return 0; + + var target = Read(layer, x, y); + var replacement = layer == RpgEditLayer.Collision ? collision : tile; + if (target == replacement) + return 0; + + var changed = 0; + var stack = new Stack<(int X, int Y)>(); + stack.Push((x, y)); + while (stack.Count > 0) + { + var (cx, cy) = stack.Pop(); + if (!Grid.InBounds(cx, cy) || Read(layer, cx, cy) != target) + continue; + Paint(layer, cx, cy, tile, collision); + changed++; + stack.Push((cx + 1, cy)); + stack.Push((cx - 1, cy)); + stack.Push((cx, cy + 1)); + stack.Push((cx, cy - 1)); + } + return changed; + } + + /// 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(); + found.Reverse(); + return found; + } + + /// Adds an action event with a fresh id (event_N) on a cell and returns it. + public RpgMapEvent AddEvent(int x, int y) + { + var number = 1; + while (Map.Events.Any(e => string.Equals(e.Id, "event_" + number, StringComparison.OrdinalIgnoreCase))) + number++; + var mapEvent = new RpgMapEvent { Id = "event_" + number, X = x, Y = y }; + Map.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); + + /// Changes the map size; 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) + { + mapEvent.X = Math.Clamp(mapEvent.X, 0, Grid.Width - 1); + mapEvent.Y = Math.Clamp(mapEvent.Y, 0, Grid.Height - 1); + } + Map.Spawn.X = Math.Clamp(Map.Spawn.X, 0, Grid.Width - 1); + Map.Spawn.Y = Math.Clamp(Map.Spawn.Y, 0, Grid.Height - 1); + } + + /// + /// Problems that would make the map misbehave in the game, as locale keys + /// with an argument; empty when the map can be saved. + /// + public List<(string Key, string Argument)> Validate() + { + 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) + { + if (string.IsNullOrWhiteSpace(mapEvent.Id)) + problems.Add(("tools/rpg_editor/problem_event_id", $"{mapEvent.X},{mapEvent.Y}")); + } + foreach (var group in Map.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)); + } + return problems; + } + + private int Read(RpgEditLayer layer, int x, int y) + { + return layer == RpgEditLayer.Collision ? Grid.GetCollision(x, y) : Grid.GetTile((RpgLayer)layer, x, y); + } + + private RpgMapSnapshot Snapshot() + { + return new RpgMapSnapshot(Grid.Clone(), JsonSerializer.Serialize(Map)); + } + + private bool Swap(List from, List to) + { + if (from.Count == 0) + return false; + to.Add(Snapshot()); + var state = from[^1]; + from.RemoveAt(from.Count - 1); + Grid = state.Grid.Clone(); + Map = JsonSerializer.Deserialize(state.MapJson); + return true; + } + } +} diff --git a/src/RpgMapEditorCanvas.cs b/src/RpgMapEditorCanvas.cs new file mode 100644 index 0000000..80d58bb --- /dev/null +++ b/src/RpgMapEditorCanvas.cs @@ -0,0 +1,352 @@ +using System; +using Cthangover.Core.UI.Theming; +using Cthangover.Mods.Rpg; +using Godot; + +namespace Cthangover.Tools.RpgEditor +{ + /// + /// The map as the editor shows it: back, main and front tiles, the events + /// (their charset frame or tile, else a marker), the spawn point, the + /// collision layer and a grid. Tiles of layers other than the active one are + /// dimmed, so the author sees which layer the brush is on; the collision + /// codes are shown only while the collision layer is active. + /// + /// Mouse, on a tile or collision layer: LMB paints with the current tool and + /// 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. + /// + /// The canvas owns no data. It edits the window's + /// and reports through events; every stroke or + /// drag starts with , so the window records exactly + /// one undo step for it. + /// + public partial class RpgMapEditorCanvas : Control + { + private static readonly Color BlockColor = new(0.9f, 0.2f, 0.2f, 0.45f); + 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 readonly RpgTextures textures = new(); + private Vector2I? hover; + private bool stroking; + private RpgMapEvent dragged; + private bool dragMoved; + + /// The edited map; null shows an empty canvas. + public RpgMapDocument Document { get; set; } + + /// Tileset of the map (null: tiles are not drawn). + public RpgTileset Tileset { get; set; } + + /// Atlas image of . + public Texture2D Atlas { get; set; } + + /// Layer the brush works on. + public RpgEditLayer ActiveLayer { get; set; } = RpgEditLayer.Back; + + /// Brush. + public RpgEditTool Tool { get; set; } = RpgEditTool.Pencil; + + /// Atlas index the pencil and the fill put down. + public int SelectedTile { get; set; } + + /// Collision code the pencil and the fill put down. + public char SelectedCollision { get; set; } = RpgCollision.Block; + + /// The selected event, or null. + public RpgMapEvent SelectedEvent { get; set; } + + /// Draw the cell grid. + public bool ShowGrid { get; set; } = true; + + /// Screen pixels per tile pixel. + public float Zoom { get; private set; } = 1.5f; + + /// An edit is about to change the document: record an undo step. + public event Action EditStarting; + + /// The document changed. + public event Action Changed; + + /// The selected event changed (null for none). + public event Action EventSelected; + + /// Alt+click picked a tile. + public event Action TilePicked; + + /// Creates the canvas; input comes through the GuiInput event. + public RpgMapEditorCanvas() + { + ClipContents = true; + FocusMode = FocusModeEnum.Click; + TextureFilter = TextureFilterEnum.Nearest; + GuiInput += OnGuiInput; + MouseExited += OnMouseExited; + } + + private float CellSize => (Tileset?.TileSize ?? 32) * Zoom; + + /// Changes the zoom (0.25–4) and resizes the canvas to the map. + public void SetZoom(float zoom) + { + Zoom = Mathf.Clamp(zoom, 0.25f, 4f); + Refresh(); + } + + /// Resizes to the map and redraws; call after any outside change. + public void Refresh() + { + var grid = Document?.Grid; + CustomMinimumSize = grid == null ? Vector2.Zero : new Vector2(grid.Width, grid.Height) * CellSize; + QueueRedraw(); + } + + /// Draws the map. + public override void _Draw() + { + DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0); + var grid = Document?.Grid; + if (grid == null) + return; + + DrawRect(new Rect2(Vector2.Zero, new Vector2(grid.Width, grid.Height) * CellSize), Colors.Black); + DrawLayer(RpgLayer.Back); + DrawLayer(RpgLayer.Main); + DrawEvents(); + DrawLayer(RpgLayer.Front); + DrawSpawn(); + if (ActiveLayer == RpgEditLayer.Collision) + DrawCollision(); + if (ShowGrid) + DrawGrid(); + if (hover.HasValue && grid.InBounds(hover.Value.X, hover.Value.Y)) + DrawRect(CellRect(hover.Value.X, hover.Value.Y), UiTokens.AccentBright, false, 2f); + } + + private void DrawLayer(RpgLayer layer) + { + if (Atlas == null || Tileset == null) + return; + var dim = ActiveLayer <= RpgEditLayer.Front && (int)ActiveLayer != (int)layer; + var modulate = new Color(1f, 1f, 1f, dim ? 0.45f : 1f); + var grid = Document.Grid; + for (var y = 0; y < grid.Height; y++) + { + for (var x = 0; x < grid.Width; x++) + { + var index = grid.GetTile(layer, x, y); + if (index >= 0) + DrawTextureRectRegion(Atlas, CellRect(x, y), TileRegion(index), modulate); + } + } + } + + 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); + } + + if (ActiveLayer == RpgEditLayer.Events) + DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f); + } + } + + private void DrawSpawn() + { + var spawn = Document.Map.Spawn; + if (spawn == null) + return; + var rect = CellRect(spawn.X, spawn.Y); + DrawCircle(rect.GetCenter(), CellSize * 0.18f, UiTokens.Ok); + DrawArc(rect.GetCenter(), CellSize * 0.18f, 0f, Mathf.Tau, 24, Colors.Black, 1.5f); + } + + private void DrawCollision() + { + var grid = Document.Grid; + for (var y = 0; y < grid.Height; y++) + { + for (var x = 0; x < grid.Width; x++) + { + var code = grid.GetCollision(x, y); + if (code == RpgCollision.Block) + DrawRect(CellRect(x, y), BlockColor); + else if (code == RpgCollision.Open) + DrawRect(CellRect(x, y), OpenColor); + else if (!RpgPassability.IsWalkable(grid, Tileset, x, y)) + DrawRect(CellRect(x, y).Grow(-CellSize * 0.35f), BlockColor); + } + } + } + + private void DrawGrid() + { + var grid = Document.Grid; + var size = new Vector2(grid.Width, grid.Height) * CellSize; + for (var x = 0; x <= grid.Width; x++) + DrawLine(new Vector2(x * CellSize, 0f), new Vector2(x * CellSize, size.Y), GridColor); + for (var y = 0; y <= grid.Height; y++) + DrawLine(new Vector2(0f, y * CellSize), new Vector2(size.X, y * CellSize), GridColor); + } + + private Rect2 CellRect(int x, int y) => new(new Vector2(x, y) * CellSize, new Vector2(CellSize, CellSize)); + + private Rect2 TileRegion(int index) + { + var coords = RpgTileSetBuilder.AtlasCoords(Tileset, index); + return new Rect2(coords * Tileset.TileSize, new Vector2(Tileset.TileSize, Tileset.TileSize)); + } + + private Vector2I CellAt(Vector2 position) => new((int)Mathf.Floor(position.X / CellSize), (int)Mathf.Floor(position.Y / CellSize)); + + private void OnGuiInput(InputEvent @event) + { + if (Document == null) + return; + + if (@event is InputEventMouseMotion motion) + { + hover = CellAt(motion.Position); + if (stroking && (motion.ButtonMask & (MouseButtonMask.Left | MouseButtonMask.Right)) != 0) + Apply(hover.Value, (motion.ButtonMask & MouseButtonMask.Right) != 0); + else if (dragged != null && (motion.ButtonMask & MouseButtonMask.Left) != 0) + Drag(hover.Value); + QueueRedraw(); + return; + } + + if (@event is not InputEventMouseButton button) + return; + + if (button.Pressed && button.CtrlPressed && (button.ButtonIndex == MouseButton.WheelUp || button.ButtonIndex == MouseButton.WheelDown)) + { + SetZoom(Zoom * (button.ButtonIndex == MouseButton.WheelUp ? 1.25f : 0.8f)); + AcceptEvent(); + return; + } + + var cell = CellAt(button.Position); + if (!button.Pressed) + { + if (stroking || dragMoved) + Changed?.Invoke(); + stroking = false; + dragged = null; + dragMoved = false; + return; + } + + if (ActiveLayer == RpgEditLayer.Events) + PressOnEvents(button, cell); + else if (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right) + PressOnCells(button, cell); + AcceptEvent(); + } + + private void PressOnCells(InputEventMouseButton button, Vector2I cell) + { + if (button.AltPressed && button.ButtonIndex == MouseButton.Left && ActiveLayer != RpgEditLayer.Collision) + { + var index = Document.Grid.GetTile((RpgLayer)ActiveLayer, cell.X, cell.Y); + if (index >= 0) + TilePicked?.Invoke(index); + return; + } + + var erase = button.ButtonIndex == MouseButton.Right || Tool == RpgEditTool.Eraser; + EditStarting?.Invoke(); + if (Tool == RpgEditTool.Fill && !erase) + { + Document.Fill(ActiveLayer, cell.X, cell.Y, SelectedTile, SelectedCollision); + Changed?.Invoke(); + QueueRedraw(); + return; + } + stroking = true; + Apply(cell, erase); + } + + private void PressOnEvents(InputEventMouseButton button, Vector2I cell) + { + if (button.ButtonIndex == MouseButton.Right) + { + Select(null); + return; + } + if (button.ButtonIndex != MouseButton.Left || !Document.Grid.InBounds(cell.X, cell.Y)) + return; + + var found = Document.EventsAt(cell.X, cell.Y); + if (found.Count == 0 && button.DoubleClick) + { + EditStarting?.Invoke(); + Select(Document.AddEvent(cell.X, cell.Y)); + Changed?.Invoke(); + return; + } + + // Repeated clicks on a stack of events cycle through it. + var index = SelectedEvent != null ? found.IndexOf(SelectedEvent) : -1; + Select(found.Count == 0 ? null : found[(index + 1) % found.Count]); + dragged = SelectedEvent; + dragMoved = false; + } + + private void Apply(Vector2I cell, bool erase) + { + var tile = erase ? -1 : SelectedTile; + var collision = erase ? RpgCollision.Auto : SelectedCollision; + if (Document.Paint(ActiveLayer, cell.X, cell.Y, tile, collision)) + QueueRedraw(); + } + + private void Drag(Vector2I cell) + { + if (!Document.Grid.InBounds(cell.X, cell.Y) || (dragged.X == cell.X && dragged.Y == cell.Y)) + return; + if (!dragMoved) + { + EditStarting?.Invoke(); + dragMoved = true; + } + dragged.X = cell.X; + dragged.Y = cell.Y; + EventSelected?.Invoke(dragged); + } + + private void Select(RpgMapEvent mapEvent) + { + SelectedEvent = mapEvent; + EventSelected?.Invoke(mapEvent); + QueueRedraw(); + } + + private void OnMouseExited() + { + hover = null; + QueueRedraw(); + } + } +} diff --git a/src/RpgMapEditorToolBoxButton.cs b/src/RpgMapEditorToolBoxButton.cs new file mode 100644 index 0000000..11530ff --- /dev/null +++ b/src/RpgMapEditorToolBoxButton.cs @@ -0,0 +1,20 @@ +using Cthangover.Core.UI.Tool; + +namespace Cthangover.Tools.RpgEditor +{ + /// In-game toolbar button of the RPG map editor, so a map can be fixed while walking on it. + public class RpgMapEditorToolBoxButton : IToolBoxButton + { + /// Matches . + public string ToolId => "rpg_map_editor"; + + /// No icon: the button shows its label. + public string IconPath => ""; + + /// Button label key. + public string LocaleKey => "tools/rpg_editor/title"; + + /// Always shown. + public bool IsVisible() => true; + } +} diff --git a/src/RpgMapEditorToolProvider.cs b/src/RpgMapEditorToolProvider.cs new file mode 100644 index 0000000..35de08f --- /dev/null +++ b/src/RpgMapEditorToolProvider.cs @@ -0,0 +1,27 @@ +using Cthangover.Core.UI.Tool; +using Cthangover.Mods.Interface.Tool; +using Godot; + +namespace Cthangover.Tools.RpgEditor +{ + /// + /// Registers the RPG map editor as a tool: the kernel's tool factory finds + /// every , so the editor appears in the main + /// menu's tool block, the novel editor hub and (with + /// ) the in-game toolbar. + /// + public class RpgMapEditorToolProvider : IToolProvider + { + /// Tool id. + public string Id => "rpg_map_editor"; + + /// Display name key. + public string LocaleKey => "tools/rpg_editor/title"; + + /// Creates the window. + public Window CreateWindow() => new RpgMapEditorWindow(); + + /// Adds the launcher button to the main menu's tool block. + public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this); + } +} diff --git a/src/RpgMapEditorWindow.cs b/src/RpgMapEditorWindow.cs new file mode 100644 index 0000000..ff7d8c1 --- /dev/null +++ b/src/RpgMapEditorWindow.cs @@ -0,0 +1,458 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Core.Scenes; +using Cthangover.Core.UI; +using Cthangover.Core.UI.Theming; +using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Theming; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Mods.Rpg; +using Cthangover.Tools.Common; +using Cthangover.Tools.Services; +using Godot; + +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. + /// + /// Layers are the game's: back, main, front, the collision layer (codes + /// over the tiles' own solidity) and events. Every edit is undoable + /// (Ctrl+Z / Ctrl+Y); Ctrl+S saves. A new file becomes a map filled with the + /// first tile of the first tileset, and the first save also writes + /// scenes/<id>.json when the mod has no such scene, because the + /// map id is the scene name. + /// + /// 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. + /// + public partial class RpgMapEditorWindow : ToolWindow + { + private const string MapDir = "maps"; + private const string SceneDir = "scenes"; + + private readonly RpgTextures textures = new(); + private readonly Dictionary layerButtons = new(); + private readonly Dictionary toolButtons = new(); + + private ModFileSelector fileSelector; + private RpgTilePalette palette; + private RpgMapEditorCanvas canvas; + private RpgMapPropertiesPanel mapPanel; + private RpgEventPropertiesPanel eventPanel; + private CheckBox gridCheck; + private Label zoomLabel; + + private RpgMapDocument document; + private string modId; + private string path; + private bool isNew; + + /// Builds the window and opens the first map found. + public RpgMapEditorWindow() : base("tools/rpg_editor/title") + { + CthangoverTheme.Apply(this); + BuildUi(); + SetHint(ToolUi.Tr("tools/rpg_editor/hint")); + SelectLayer(RpgEditLayer.Back); + SelectTool(RpgEditTool.Pencil); + fileSelector.Refresh(); + } + + // ── layout ───────────────────────────────────────────────── + + private void BuildUi() + { + var outer = CreateFillContainer(); + AddChild(outer); + outer.AddChild(BuildToolbar()); + + var main = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + outer.AddChild(main); + + var 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)); + + var center = new HSplitContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill }; + main.AddChild(center); + + canvas = new RpgMapEditorCanvas(); + canvas.EditStarting += RecordUndo; + canvas.Changed += OnDocumentChanged; + canvas.EventSelected += OnEventSelected; + canvas.TilePicked += OnTilePicked; + center.AddChild(Scroll(canvas)); + + var properties = new VBoxContainer(); + mapPanel = new RpgMapPropertiesPanel(); + mapPanel.EditStarting += RecordUndo; + mapPanel.Changed += OnDocumentChanged; + mapPanel.TilesetChanged += LoadTileset; + mapPanel.ResizeRequested += OnResizeRequested; + properties.AddChild(mapPanel); + eventPanel = new RpgEventPropertiesPanel(); + eventPanel.EditStarting += RecordUndo; + eventPanel.Changed += OnDocumentChanged; + eventPanel.DeleteRequested += DeleteSelectedEvent; + properties.AddChild(eventPanel); + center.AddChild(CreateSidebar(properties, 340)); + } + + private HBoxContainer BuildToolbar() + { + var toolbar = CreateToolbar(); + fileSelector = new ModFileSelector(MapDir, ".json"); + fileSelector.FileRequested += OnFileRequested; + toolbar.AddChild(fileSelector); + toolbar.AddChild(ToolUi.Button("tools/common/save", OnSavePressed, ButtonKind.Primary, "tools/common/save_tip")); + toolbar.AddChild(ToolUi.Divider()); + + AddLayerButton(toolbar, RpgEditLayer.Back, "tools/rpg_editor/layer_back"); + AddLayerButton(toolbar, RpgEditLayer.Main, "tools/rpg_editor/layer_main"); + AddLayerButton(toolbar, RpgEditLayer.Front, "tools/rpg_editor/layer_front"); + AddLayerButton(toolbar, RpgEditLayer.Collision, "tools/rpg_editor/layer_collision"); + AddLayerButton(toolbar, RpgEditLayer.Events, "tools/rpg_editor/layer_events"); + toolbar.AddChild(ToolUi.Divider()); + + AddToolButton(toolbar, RpgEditTool.Pencil, "tools/rpg_editor/tool_pencil"); + AddToolButton(toolbar, RpgEditTool.Eraser, "tools/rpg_editor/tool_eraser"); + AddToolButton(toolbar, RpgEditTool.Fill, "tools/rpg_editor/tool_fill"); + toolbar.AddChild(ToolUi.Divider()); + + gridCheck = new CheckBox { Text = ToolUi.Tr("tools/rpg_editor/grid"), ButtonPressed = true }; + gridCheck.Toggled += OnGridToggled; + toolbar.AddChild(gridCheck); + var zoomOut = UiKit.IconButton("−", ToolUi.Tr("tools/rpg_editor/zoom_out")); + zoomOut.Pressed += OnZoomOut; + toolbar.AddChild(zoomOut); + zoomLabel = new Label { CustomMinimumSize = new Vector2(48, 0), HorizontalAlignment = HorizontalAlignment.Center }; + toolbar.AddChild(zoomLabel); + var zoomIn = UiKit.IconButton("+", ToolUi.Tr("tools/rpg_editor/zoom_in")); + zoomIn.Pressed += OnZoomIn; + toolbar.AddChild(zoomIn); + toolbar.AddChild(ToolUi.Divider()); + + toolbar.AddChild(ToolUi.Button("tools/rpg_editor/undo", Undo, ButtonKind.Secondary, "tools/rpg_editor/undo_tip")); + toolbar.AddChild(ToolUi.Button("tools/rpg_editor/redo", Redo, ButtonKind.Secondary, "tools/rpg_editor/redo_tip")); + toolbar.AddChild(ToolUi.Button("tools/rpg_editor/play", Play, ButtonKind.Secondary, "tools/rpg_editor/play_tip")); + return toolbar; + } + + private void AddLayerButton(HBoxContainer toolbar, RpgEditLayer layer, string key) + { + var button = ToolUi.Button(key, null); + button.ToggleMode = true; + button.Pressed += () => SelectLayer(layer); + layerButtons[layer] = button; + toolbar.AddChild(button); + } + + private void AddToolButton(HBoxContainer toolbar, RpgEditTool tool, string key) + { + var button = ToolUi.Button(key, null); + button.ToggleMode = true; + button.Pressed += () => SelectTool(tool); + toolButtons[tool] = button; + toolbar.AddChild(button); + } + + private static ScrollContainer Scroll(Control content) + { + var scroll = new ScrollContainer { SizeFlagsHorizontal = Control.SizeFlags.ExpandFill, SizeFlagsVertical = Control.SizeFlags.ExpandFill }; + content.SizeFlagsHorizontal = Control.SizeFlags.ExpandFill; + scroll.AddChild(content); + return scroll; + } + + // ── file ─────────────────────────────────────────────────── + + private void OnFileRequested(string mod, string file) + { + RunDiscardingChanges(() => Open(mod, file), () => fileSelector.Show(modId, path)); + } + + private void Open(string mod, string file) + { + modId = mod; + path = file; + document = null; + isNew = false; + MarkClean(); + + if (mod == null) + { + SetStatus(ToolUi.Tr("tools/common/no_writable_mods"), ToolStatusKind.Error); + } + else if (file == null) + { + SetStatus(string.Format(ToolUi.Tr("tools/common/no_files"), mod)); + } + else + { + try + { + var text = ModFileService.ReadText(mod, file); + isNew = string.IsNullOrEmpty(text); + document = isNew ? CreateDocument(file) : RpgMapDocument.Load(text); + SetStatus(string.Format(ToolUi.Tr(isNew ? "tools/common/new_file_status" : "tools/common/loaded"), mod + "/" + file)); + } + catch (Exception ex) + { + SetStatus($"{ToolUi.Tr("tools/common/load_error")}: {ex.Message}", ToolStatusKind.Error); + GameLogger.Log("RPG_EDITOR", $"load failed for '{mod}/{file}': {ex.Message}", LogLevel.Error); + } + } + + fileSelector.Show(modId, path); + canvas.SelectedEvent = null; + BindDocument(); + } + + private static RpgMapDocument CreateDocument(string file) + { + var tileset = Entities.Collect().Keys.OrderBy(k => k).FirstOrDefault(); + return RpgMapDocument.Create(Path.GetFileNameWithoutExtension(file), tileset, 20, 15, 0); + } + + /// Ctrl+S. + protected override void OnSaveShortcut() => Save(); + + private void OnSavePressed() => Save(); + + private bool Save() + { + if (document == null || path == null) + { + SetStatus(ToolUi.Tr("tools/common/no_file_to_save"), ToolStatusKind.Error); + return false; + } + + var problems = document.Validate(); + if (problems.Count > 0) + { + SetStatus(string.Format(ToolUi.Tr(problems[0].Key), problems[0].Argument), ToolStatusKind.Error); + return false; + } + + var json = document.ToJson(); + if (!ModFileService.WriteText(modId, path, json)) + { + SetStatus(ToolUi.Tr("tools/common/readonly"), ToolStatusKind.Error); + return false; + } + + var sceneNote = isNew ? EnsureScene() : null; + isNew = false; + MarkClean(); + PublishToGame(json); + SetStatus(string.Format(ToolUi.Tr("tools/common/saved"), modId + "/" + path) + (sceneNote ?? string.Empty), ToolStatusKind.Ok); + return true; + } + + /// Writes scenes/<id>.json for a new map when the mod has none; returns a status suffix. + private string EnsureScene() + { + var scenePath = SceneDir + "/" + document.Map.ID + ".json"; + if (!string.IsNullOrEmpty(ModFileService.ReadText(modId, scenePath))) + return null; + var json = "{\n \"name\": \"" + document.Map.ID + "\",\n \"defaultAmbient\": \"\"\n}\n"; + return ModFileService.WriteText(modId, scenePath, json) + ? " · " + string.Format(ToolUi.Tr("tools/rpg_editor/scene_created"), scenePath) + : null; + } + + private static void PublishToGame(string json) + { + var copy = RpgMapDocument.Load(json).Map; + RpgMapFactory.Instance.Publish(copy); + var host = RpgWorldHost.Current; + if (host?.View?.Map?.ID == copy.ID) + host.Mount(copy.ID); + } + + private void Play() + { + if (!Save()) + return; + var sceneManager = SceneContextNode.FindNode("SceneManager"); + if (sceneManager == null || !sceneManager.HasScene(document.Map.ID)) + { + SetStatus(string.Format(ToolUi.Tr("tools/rpg_editor/play_no_scene"), document.Map.ID), ToolStatusKind.Error); + return; + } + var spawn = document.Map.Spawn; + RpgWorldHost.Teleport(new RpgTeleport { Map = document.Map.ID, X = spawn.X, Y = spawn.Y, Dir = spawn.Dir }); + } + + // ── editing ──────────────────────────────────────────────── + + private void BindDocument() + { + canvas.Document = document; + mapPanel.Bind(document); + eventPanel.Bind(canvas.SelectedEvent); + LoadTileset(); + } + + private void LoadTileset() + { + var tileset = document == null ? null : RpgTilesetFactory.Instance.Get(document.Map.Tileset); + var atlas = tileset == null ? null : textures.Tileset(tileset.Image); + canvas.Tileset = tileset; + canvas.Atlas = atlas; + palette.SetTileset(tileset, atlas); + canvas.Refresh(); + UpdateZoomLabel(); + } + + private void RecordUndo() + { + document?.BeginChange(); + SetDirty(); + } + + private void OnDocumentChanged() + { + SetDirty(); + canvas.Refresh(); + eventPanel.RefreshCell(); + } + + private void Undo() => StepHistory(document != null && document.Undo()); + + private void Redo() => StepHistory(document != null && document.Redo()); + + private void StepHistory(bool stepped) + { + if (!stepped) + return; + // The document holds 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); + SetDirty(); + BindDocument(); + } + + private void OnResizeRequested(int width, int height) + { + if (document == null || (width == document.Grid.Width && height == document.Grid.Height)) + return; + RecordUndo(); + document.Resize(width, height); + mapPanel.Bind(document); + canvas.Refresh(); + } + + private void DeleteSelectedEvent() + { + if (document == null || canvas.SelectedEvent == null) + return; + RecordUndo(); + document.RemoveEvent(canvas.SelectedEvent); + canvas.SelectedEvent = null; + eventPanel.Bind(null); + canvas.Refresh(); + } + + private void OnEventSelected(RpgMapEvent mapEvent) => eventPanel.Bind(mapEvent); + + private void OnPaletteTile(int index) + { + canvas.SelectedTile = index; + if (canvas.ActiveLayer > RpgEditLayer.Front) + SelectLayer(RpgEditLayer.Main); + } + + private void OnPaletteCollision(char code) => canvas.SelectedCollision = code; + + private void OnTilePicked(int index) + { + canvas.SelectedTile = index; + palette.Select(index); + } + + private void SelectLayer(RpgEditLayer layer) + { + canvas.ActiveLayer = layer; + foreach (var pair in layerButtons) + pair.Value.SetPressedNoSignal(pair.Key == layer); + palette.SetCollisionMode(layer == RpgEditLayer.Collision); + foreach (var button in toolButtons.Values) + button.Disabled = layer == RpgEditLayer.Events; + canvas.QueueRedraw(); + } + + private void SelectTool(RpgEditTool tool) + { + canvas.Tool = tool; + foreach (var pair in toolButtons) + pair.Value.SetPressedNoSignal(pair.Key == tool); + } + + private void OnGridToggled(bool on) + { + canvas.ShowGrid = on; + canvas.QueueRedraw(); + } + + private void OnZoomIn() + { + canvas.SetZoom(canvas.Zoom * 1.25f); + UpdateZoomLabel(); + } + + private void OnZoomOut() + { + canvas.SetZoom(canvas.Zoom * 0.8f); + UpdateZoomLabel(); + } + + private void UpdateZoomLabel() => zoomLabel.Text = $"{canvas.Zoom * 100f:0}%"; + + /// Shortcuts: Ctrl+Z / Ctrl+Y undo and redo, 1–5 pick the layer, B/E/G the brush, Del deletes the selected event. + protected override bool HandleKey(InputEventKey key) + { + if (key.CtrlPressed && key.Keycode == Key.Z) + { + if (key.ShiftPressed) + Redo(); + else + Undo(); + return true; + } + if (key.CtrlPressed && key.Keycode == Key.Y) + { + Redo(); + return true; + } + if (key.CtrlPressed || key.AltPressed) + return false; + + switch (key.Keycode) + { + case Key.Key1: SelectLayer(RpgEditLayer.Back); return true; + case Key.Key2: SelectLayer(RpgEditLayer.Main); return true; + case Key.Key3: SelectLayer(RpgEditLayer.Front); return true; + case Key.Key4: SelectLayer(RpgEditLayer.Collision); return true; + case Key.Key5: SelectLayer(RpgEditLayer.Events); return true; + case Key.B: SelectTool(RpgEditTool.Pencil); return true; + case Key.E: SelectTool(RpgEditTool.Eraser); return true; + case Key.G: SelectTool(RpgEditTool.Fill); return true; + case Key.Delete: DeleteSelectedEvent(); return true; + } + return false; + } + } +} diff --git a/src/RpgMapPropertiesPanel.cs b/src/RpgMapPropertiesPanel.cs new file mode 100644 index 0000000..5363a6f --- /dev/null +++ b/src/RpgMapPropertiesPanel.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Mods.Resolvers; +using Cthangover.Mods.Rpg; +using Cthangover.Tools.Common; +using Godot; + +namespace Cthangover.Tools.RpgEditor +{ + /// + /// The map's own properties in the right column: id (the scene name, read + /// only — it is the file name), display name key, tileset, size, zoom, spawn + /// and random encounters. The size is applied with a button, because a + /// smaller size cuts cells off. Encounter groups are written as + /// slime; wolf,slime — groups split by semicolons. + /// + /// The first edit after raises + /// once, so typing into a field is one undo step, not one per letter. + /// + public partial class RpgMapPropertiesPanel : VBoxContainer + { + private readonly List tilesets = new(); + private readonly Label idLabel; + private readonly LineEdit nameEdit; + private readonly OptionButton tilesetPicker; + private readonly SpinBox widthSpin; + private readonly SpinBox heightSpin; + private readonly SpinBox zoomSpin; + private readonly SpinBox spawnX; + private readonly SpinBox spawnY; + private readonly OptionButton spawnDir; + private readonly SpinBox stepsSpin; + private readonly LineEdit groupsEdit; + private RpgMapDocument document; + private bool suppress; + private bool editing; + + /// Raised once before the first change after a bind. + public event Action EditStarting; + + /// A property changed. + public event Action Changed; + + /// The tileset changed: the window reloads the atlas. + public event Action TilesetChanged; + + /// The author asked for a new size. + public event Action ResizeRequested; + + /// Builds the fields. + public RpgMapPropertiesPanel() + { + AddChild(ToolUi.Section("tools/rpg_editor/section_map")); + idLabel = ToolUi.Field(this, "tools/rpg_editor/map_id", new Label(), "tools/rpg_editor/map_id_tip"); + nameEdit = ToolUi.Field(this, "tools/rpg_editor/map_name", ToolUi.Text("rpg/map/name", OnNameChanged), "tools/rpg_editor/map_name_tip"); + tilesetPicker = ToolUi.Field(this, "tools/rpg_editor/tileset", new OptionButton()); + tilesetPicker.ItemSelected += OnTilesetPicked; + + widthSpin = ToolUi.Field(this, "tools/rpg_editor/width", ToolUi.Number(1, 256, 1, null)); + heightSpin = ToolUi.Field(this, "tools/rpg_editor/height", ToolUi.Number(1, 256, 1, null)); + AddChild(ToolUi.Button("tools/rpg_editor/resize", OnResizePressed)); + zoomSpin = ToolUi.Field(this, "tools/rpg_editor/zoom", ToolUi.Number(1, 4, 1, OnZoomChanged), "tools/rpg_editor/zoom_tip"); + + AddChild(ToolUi.Section("tools/rpg_editor/section_spawn")); + spawnX = ToolUi.Field(this, "tools/rpg_editor/x", ToolUi.Number(0, 255, 1, OnSpawnChanged)); + spawnY = ToolUi.Field(this, "tools/rpg_editor/y", ToolUi.Number(0, 255, 1, OnSpawnChanged)); + spawnDir = ToolUi.Field(this, "tools/rpg_editor/dir", RpgEditorFields.Direction(OnSpawnDirPicked)); + + AddChild(ToolUi.Section("tools/rpg_editor/section_encounters")); + stepsSpin = ToolUi.Field(this, "tools/rpg_editor/encounter_steps", ToolUi.Number(0, 999, 1, OnEncountersChanged), "tools/rpg_editor/encounter_steps_tip"); + groupsEdit = ToolUi.Field(this, "tools/rpg_editor/encounter_groups", ToolUi.Text("slime; wolf,slime", OnGroupsChanged), "tools/rpg_editor/encounter_groups_tip"); + } + + /// Shows a document's map (null clears). + public void Bind(RpgMapDocument value) + { + document = value; + editing = false; + suppress = true; + tilesets.Clear(); + tilesets.AddRange(Entities.Collect().Keys.OrderBy(k => k)); + tilesetPicker.Clear(); + foreach (var id in tilesets) + tilesetPicker.AddItem(id); + + var map = value?.Map; + Visible = map != null; + if (map != null) + { + idLabel.Text = map.ID; + nameEdit.Text = map.Name ?? string.Empty; + tilesetPicker.Select(tilesets.IndexOf(map.Tileset ?? string.Empty)); + widthSpin.Value = value.Grid.Width; + heightSpin.Value = value.Grid.Height; + zoomSpin.Value = map.Zoom; + spawnX.Value = map.Spawn.X; + spawnY.Value = map.Spawn.Y; + spawnDir.Select((int)map.Spawn.Dir); + stepsSpin.Value = map.Encounters?.Steps ?? 0; + groupsEdit.Text = string.Join("; ", map.Encounters?.Groups ?? new List()); + } + suppress = false; + } + + private bool Begin() + { + if (suppress || document == null) + return false; + if (!editing) + { + EditStarting?.Invoke(); + editing = true; + } + return true; + } + + private void OnNameChanged(string text) + { + if (!Begin()) + return; + document.Map.Name = string.IsNullOrWhiteSpace(text) ? null : text.Trim(); + Changed?.Invoke(); + } + + private void OnTilesetPicked(long index) + { + if (!Begin() || index < 0 || index >= tilesets.Count) + return; + document.Map.Tileset = tilesets[(int)index]; + Changed?.Invoke(); + TilesetChanged?.Invoke(); + } + + private void OnResizePressed() + { + if (document != null) + ResizeRequested?.Invoke((int)widthSpin.Value, (int)heightSpin.Value); + } + + private void OnZoomChanged(double value) + { + if (!Begin()) + return; + document.Map.Zoom = (int)value; + Changed?.Invoke(); + } + + private void OnSpawnChanged(double value) + { + if (!Begin()) + return; + document.Map.Spawn.X = Math.Clamp((int)spawnX.Value, 0, document.Grid.Width - 1); + document.Map.Spawn.Y = Math.Clamp((int)spawnY.Value, 0, document.Grid.Height - 1); + Changed?.Invoke(); + } + + private void OnSpawnDirPicked(RpgDirection dir) + { + if (!Begin()) + return; + document.Map.Spawn.Dir = dir; + Changed?.Invoke(); + } + + private void OnEncountersChanged(double value) => WriteEncounters(); + + private void OnGroupsChanged(string text) => WriteEncounters(); + + private void WriteEncounters() + { + if (!Begin()) + return; + var groups = groupsEdit.Text.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(g => g.Replace(" ", string.Empty)) + .Where(g => g.Length > 0) + .ToList(); + var steps = (int)stepsSpin.Value; + document.Map.Encounters = steps > 0 || groups.Count > 0 ? new RpgEncounterTable { Steps = steps, Groups = groups } : null; + Changed?.Invoke(); + } + } +} diff --git a/src/RpgMapSnapshot.cs b/src/RpgMapSnapshot.cs new file mode 100644 index 0000000..1fe0214 --- /dev/null +++ b/src/RpgMapSnapshot.cs @@ -0,0 +1,21 @@ +using Cthangover.Mods.Rpg; + +namespace Cthangover.Tools.RpgEditor +{ + /// One undo step of : the cells and the rest of the map as JSON. + public class RpgMapSnapshot + { + /// Copy of the cells. + public RpgMapGrid Grid { get; } + + /// The map without its cells (rows may be stale), serialized. + public string MapJson { get; } + + /// Creates a step. + public RpgMapSnapshot(RpgMapGrid grid, string mapJson) + { + Grid = grid; + MapJson = mapJson; + } + } +} diff --git a/src/RpgTilePalette.cs b/src/RpgTilePalette.cs new file mode 100644 index 0000000..723a5f1 --- /dev/null +++ b/src/RpgTilePalette.cs @@ -0,0 +1,165 @@ +using System; +using System.Linq; +using Cthangover.Core.UI.Theming; +using Cthangover.Mods.Rpg; +using Godot; + +namespace Cthangover.Tools.RpgEditor +{ + /// + /// Brush picker of the editor's left column. For a tile layer it shows the + /// tileset atlas at one and a half times the tile size with the selected tile framed and the + /// solid tiles marked by a red corner; a click picks a tile, the tooltip + /// names it (index, tag, solid). For the collision layer it shows the three + /// collision codes instead. + /// + public partial class RpgTilePalette : Control + { + private const float Scale = 1.5f; + private const float CodeHeight = 44f; + + private static readonly char[] Codes = { RpgCollision.Block, RpgCollision.Open, RpgCollision.Auto }; + + private RpgTileset tileset; + private Texture2D atlas; + private bool collisionMode; + + /// Selected atlas index. + public int SelectedTile { get; private set; } + + /// Selected collision code. + public char SelectedCollision { get; private set; } = RpgCollision.Block; + + /// A tile was picked. + public event Action TileSelected; + + /// A collision code was picked. + public event Action CollisionSelected; + + /// Creates the palette. + public RpgTilePalette() + { + TextureFilter = TextureFilterEnum.Nearest; + MouseFilter = MouseFilterEnum.Stop; + GuiInput += OnGuiInput; + } + + /// Shows a tileset (null: nothing to pick). + public void SetTileset(RpgTileset value, Texture2D image) + { + tileset = value; + atlas = image; + Relayout(); + } + + /// Switches between the tile atlas and the collision codes. + public void SetCollisionMode(bool value) + { + collisionMode = value; + Relayout(); + } + + /// Selects a tile from outside (the canvas' pipette). + public void Select(int index) + { + SelectedTile = index; + QueueRedraw(); + } + + /// Draws the atlas or the codes. + public override void _Draw() + { + if (collisionMode) + { + for (var i = 0; i < Codes.Length; i++) + DrawCode(i); + return; + } + if (atlas == null || tileset == null) + return; + + var cell = tileset.TileSize * Scale; + DrawTextureRect(atlas, new Rect2(Vector2.Zero, atlas.GetSize() * Scale), false); + foreach (var tile in tileset.Tiles.Where(t => t.Solid)) + { + var position = (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, tile.Index) * cell; + DrawColoredPolygon(new[] { position, position + new Vector2(cell * 0.25f, 0f), position + new Vector2(0f, cell * 0.25f) }, new Color(0.9f, 0.2f, 0.2f, 0.9f)); + } + var selected = (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, SelectedTile) * cell; + DrawRect(new Rect2(selected, new Vector2(cell, cell)), UiTokens.AccentBright, false, 3f); + } + + private void DrawCode(int i) + { + var rect = new Rect2(0f, i * (CodeHeight + 6f), Size.X, CodeHeight); + var color = Codes[i] == RpgCollision.Block ? new Color(0.9f, 0.2f, 0.2f, 0.6f) + : Codes[i] == RpgCollision.Open ? new Color(0.2f, 0.85f, 0.35f, 0.5f) : UiTokens.Surface2; + DrawRect(rect, color); + if (Codes[i] == SelectedCollision) + DrawRect(rect, UiTokens.AccentBright, false, 3f); + var font = GetThemeDefaultFont(); + DrawString(font, rect.Position + new Vector2(10f, CodeHeight / 2f + 6f), CodeLabel(Codes[i]), HorizontalAlignment.Left, -1, UiTokens.FontBody, UiTokens.White); + } + + private static string CodeLabel(char code) => TranslationServer.Translate(code == RpgCollision.Block + ? "tools/rpg_editor/collision_block" + : code == RpgCollision.Open ? "tools/rpg_editor/collision_open" : "tools/rpg_editor/collision_auto"); + + private void Relayout() + { + if (collisionMode) + CustomMinimumSize = new Vector2(200f, Codes.Length * (CodeHeight + 6f)); + else + CustomMinimumSize = atlas != null ? atlas.GetSize() * Scale : Vector2.Zero; + TooltipText = string.Empty; + QueueRedraw(); + } + + private void OnGuiInput(InputEvent @event) + { + if (@event is InputEventMouseMotion motion && !collisionMode && tileset != null) + { + TooltipText = Describe(IndexAt(motion.Position)); + return; + } + if (@event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } button) + return; + + if (collisionMode) + { + var i = (int)(button.Position.Y / (CodeHeight + 6f)); + if (i >= 0 && i < Codes.Length) + { + SelectedCollision = Codes[i]; + CollisionSelected?.Invoke(SelectedCollision); + } + } + else if (tileset != null && atlas != null) + { + var index = IndexAt(button.Position); + if (index >= 0 && index < RpgTileSetBuilder.TileCount(tileset, atlas)) + { + SelectedTile = index; + TileSelected?.Invoke(index); + } + } + QueueRedraw(); + AcceptEvent(); + } + + private int IndexAt(Vector2 position) + { + var cell = tileset.TileSize * Scale; + var column = (int)(position.X / cell); + var row = (int)(position.Y / cell); + return column < 0 || column >= tileset.Columns || row < 0 ? -1 : row * tileset.Columns + column; + } + + private string Describe(int index) + { + var info = tileset.Tiles.FirstOrDefault(t => t.Index == index); + var solid = info?.Solid == true ? " · " + TranslationServer.Translate("tools/rpg_editor/solid_tile") : string.Empty; + return $"#{index} {info?.Tag}{solid}"; + } + } +} diff --git a/test_suites/edit/manifest.json b/test_suites/edit/manifest.json new file mode 100644 index 0000000..57b56cf --- /dev/null +++ b/test_suites/edit/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "RPG Map Editor Test", + "description": "GUI test of the RPG map editor on the rpg_demo maps: opens the window, picks a tile in the palette, paints it on the main layer with the mouse, undoes with Ctrl+Z, adds an event by double-clicking on the events layer, and closes without saving (the mod's files stay untouched). Activated by --itest=rpg_editor_edit with --game=rpg_demo --new; --shots= saves screenshots (windowed runs only).", + "sources": [ + "src/*.cs" + ], + "depends": [ + "rpg_demo_game>=1" + ] +} diff --git a/test_suites/edit/src/RpgEditorTestDriver.cs b/test_suites/edit/src/RpgEditorTestDriver.cs new file mode 100644 index 0000000..d947ca4 --- /dev/null +++ b/test_suites/edit/src/RpgEditorTestDriver.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using System.Linq; +using Cthangover.Core.Bootstrap; +using Cthangover.Core.Utils; +using Cthangover.Mods.Interface.Tool; +using Cthangover.Mods.Rpg; +using Godot; + +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. The window is + /// freed at the end without saving, so the demo maps never change. + /// Result line: [TEST_RESULT] name=rpg_editor_edit passed=N failed=M. + /// + public partial class RpgEditorTestDriver : Node + { + private const float TickSeconds = 0.15f; + private const float TimeoutSeconds = 60f; + private const int RockTile = 11; + + private static readonly Vector2I PaintCell = new(3, 5); + private static readonly Vector2I EventCell = new(2, 6); + + private readonly string shotsDir; + private readonly List failures = new(); + private int passed; + private int step; + private double elapsed; + private RpgMapEditorWindow window; + private RpgMapEditorCanvas canvas; + private int eventsBefore; + + /// Creates the driver; null disables screenshots. + public RpgEditorTestDriver(string shotsDir) + { + this.shotsDir = shotsDir; + Name = "RpgEditorTestDriver"; + 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 || RpgWorldHost.Current?.View == null) + return; + var box = RpgInput.FindDialogBox(); + if (box != null && box.IsRunning) + { + box.NextAction(); + return; + } + window = ToolWindow.Open(); + step++; + return; + case 1: + canvas = window.FindChildren("*", "", true, false).OfType().FirstOrDefault(); + 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})"); + Shot("editor"); + Press("tools/rpg_editor/layer_main"); + Check(canvas.ActiveLayer == RpgEditLayer.Main, "the layer button switches the brush to the main layer"); + ClickPalette(RockTile); + Check(canvas.SelectedTile == RockTile, $"a palette click picks tile {RockTile}, got {canvas.SelectedTile}"); + step++; + return; + case 2: + var before = canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y); + ClickCanvas(PaintCell, false); + Check(canvas.Document.Grid.GetTile(RpgLayer.Main, PaintCell.X, PaintCell.Y) == RockTile, "a canvas click paints the tile"); + Check(window.Title.StartsWith("* "), "the window is marked dirty"); + 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; + ClickCanvas(EventCell, true); + Check(canvas.Document.Map.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"); + Press("tools/rpg_editor/layer_collision"); + step++; + return; + case 4: + Shot("collision"); + window.QueueFree(); + Finish(); + return; + } + } + + private void Press(string key) + { + var text = TranslationServer.Translate(key); + var button = window.FindChildren("*", "", true, false).OfType