This commit is contained in:
cthangover committed 2026-10-21 15:10:32 +03:00
commit eadc7a1157
20 files changed
+2288

No files matched your search

+30
View File
@@ -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
+79
View File
@@ -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}".
+79
View File
@@ -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}».
+27
View File
@@ -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"
]
}
]
}
+12
View File
@@ -0,0 +1,12 @@
namespace Cthangover.Tools.RpgEditor
{
/// <summary>What the editor's brush works on: one of the three tile layers, the collision layer or the events.</summary>
public enum RpgEditLayer
{
Back = 0,
Main = 1,
Front = 2,
Collision = 3,
Events = 4,
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Cthangover.Tools.RpgEditor
{
/// <summary>Brush of the tile and collision layers.</summary>
public enum RpgEditTool
{
/// <summary>Puts the selected tile (or collision code) under the pointer; dragging paints a stroke.</summary>
Pencil = 0,
/// <summary>Clears cells (collision back to auto).</summary>
Eraser = 1,
/// <summary>Flood-fills the connected area of equal cells.</summary>
Fill = 2,
}
}
+30
View File
@@ -0,0 +1,30 @@
using System;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>Small helpers for the editor's property panels: enum pickers and their localized items.</summary>
public static class RpgEditorFields
{
/// <summary>A facing picker; items in <see cref="RpgDirection"/> order.</summary>
public static OptionButton Direction(Action<RpgDirection> 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;
}
/// <summary>Items of a picker built from locale keys; the item index is the value.</summary>
public static OptionButton Choice(string[] keys, Action<int> changed)
{
var picker = new OptionButton();
foreach (var key in keys)
picker.AddItem(TranslationServer.Translate(key));
picker.ItemSelected += index => changed((int)index);
return picker;
}
}
}
+187
View File
@@ -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
{
/// <summary>
/// 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.
/// <para/>
/// 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.
/// </summary>
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;
/// <summary>Raised once before the first change after a bind.</summary>
public event Action EditStarting;
/// <summary>A property changed.</summary>
public event Action Changed;
/// <summary>The author pressed "delete".</summary>
public event Action DeleteRequested;
/// <summary>Builds the fields.</summary>
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);
}
/// <summary>Shows an event (null: the hint instead of the fields).</summary>
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<string>());
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;
}
/// <summary>Re-reads the cell after the event was dragged on the canvas.</summary>
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();
}
}
+231
View File
@@ -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
{
/// <summary>
/// The map being edited, free of any UI: the <see cref="RpgMap"/> with its
/// cells parsed into an <see cref="RpgMapGrid"/>, the edit operations of the
/// window and an undo history.
/// <para/>
/// Undo works on whole states: before an edit the window calls
/// <see cref="BeginChange"/>, 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;
/// <see cref="ToJson"/> writes it back into the map rows.
/// </summary>
public class RpgMapDocument
{
private const int HistoryLimit = 200;
private readonly List<RpgMapSnapshot> undo = new();
private readonly List<RpgMapSnapshot> redo = new();
/// <summary>Map data (events, spawn, encounters…); its layer rows are stale until <see cref="ToJson"/>.</summary>
public RpgMap Map { get; private set; }
/// <summary>The cells.</summary>
public RpgMapGrid Grid { get; private set; }
/// <summary>True when there is a step to undo.</summary>
public bool CanUndo => undo.Count > 0;
/// <summary>True when there is a step to redo.</summary>
public bool CanRedo => redo.Count > 0;
private RpgMapDocument(RpgMap map)
{
Map = map;
Map.Events ??= new List<RpgMapEvent>();
Map.Spawn ??= new RpgTeleport();
Grid = RpgMapGrid.Parse(map);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// Reads a map file (<c>{"Kind":"rpg_map","Items":[…]}</c>, the first item).
/// Throws <see cref="JsonException"/> on broken JSON and
/// <see cref="InvalidOperationException"/> on a file without maps, so the
/// window can show the reason instead of opening an empty map.
/// </summary>
public static RpgMapDocument Load(string json)
{
var data = JsonSerializer.Deserialize<FileData<RpgMap>>(json, JsonOptionsProvider.ReadOptions);
var map = data?.Items?.FirstOrDefault() ?? throw new InvalidOperationException("the file has no rpg_map item");
return new RpgMapDocument(map);
}
/// <summary>The map file text: cells written into rows, nulls left out, indented.</summary>
public string ToJson()
{
Grid.WriteTo(Map);
var data = new FileData<RpgMap> { Kind = "rpg_map", Items = new List<RpgMap> { Map } };
return JsonSerializer.Serialize(data, JsonOptionsProvider.WriteIndentedIgnoreNullOptions);
}
/// <summary>Remembers the current state as one undo step and forgets the redo branch.</summary>
public void BeginChange()
{
undo.Add(Snapshot());
if (undo.Count > HistoryLimit)
undo.RemoveAt(0);
redo.Clear();
}
/// <summary>Steps back; false when there is nothing to undo.</summary>
public bool Undo() => Swap(undo, redo);
/// <summary>Steps forward again; false when there is nothing to redo.</summary>
public bool Redo() => Swap(redo, undo);
/// <summary>Writes a tile (-1 clears) or a collision code into one cell. Returns true when the cell changed.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>The events on a cell, top-most (last) first.</summary>
public List<RpgMapEvent> EventsAt(int x, int y)
{
var found = Map.Events.Where(e => e.X == x && e.Y == y).ToList();
found.Reverse();
return found;
}
/// <summary>Adds an action event with a fresh id (<c>event_N</c>) on a cell and returns it.</summary>
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;
}
/// <summary>Removes an event; false when it is not on this map.</summary>
public bool RemoveEvent(RpgMapEvent mapEvent) => Map.Events.Remove(mapEvent);
/// <summary>Changes the map size; events left outside are moved to the nearest edge cell.</summary>
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);
}
/// <summary>
/// Problems that would make the map misbehave in the game, as locale keys
/// with an argument; empty when the map can be saved.
/// </summary>
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<RpgMapSnapshot> from, List<RpgMapSnapshot> 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<RpgMap>(state.MapJson);
return true;
}
}
}
+352
View File
@@ -0,0 +1,352 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// 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.
/// <para/>
/// 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.
/// <para/>
/// The canvas owns no data. It edits the window's
/// <see cref="RpgMapDocument"/> and reports through events; every stroke or
/// drag starts with <see cref="EditStarting"/>, so the window records exactly
/// one undo step for it.
/// </summary>
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;
/// <summary>The edited map; null shows an empty canvas.</summary>
public RpgMapDocument Document { get; set; }
/// <summary>Tileset of the map (null: tiles are not drawn).</summary>
public RpgTileset Tileset { get; set; }
/// <summary>Atlas image of <see cref="Tileset"/>.</summary>
public Texture2D Atlas { get; set; }
/// <summary>Layer the brush works on.</summary>
public RpgEditLayer ActiveLayer { get; set; } = RpgEditLayer.Back;
/// <summary>Brush.</summary>
public RpgEditTool Tool { get; set; } = RpgEditTool.Pencil;
/// <summary>Atlas index the pencil and the fill put down.</summary>
public int SelectedTile { get; set; }
/// <summary>Collision code the pencil and the fill put down.</summary>
public char SelectedCollision { get; set; } = RpgCollision.Block;
/// <summary>The selected event, or null.</summary>
public RpgMapEvent SelectedEvent { get; set; }
/// <summary>Draw the cell grid.</summary>
public bool ShowGrid { get; set; } = true;
/// <summary>Screen pixels per tile pixel.</summary>
public float Zoom { get; private set; } = 1.5f;
/// <summary>An edit is about to change the document: record an undo step.</summary>
public event Action EditStarting;
/// <summary>The document changed.</summary>
public event Action Changed;
/// <summary>The selected event changed (null for none).</summary>
public event Action<RpgMapEvent> EventSelected;
/// <summary>Alt+click picked a tile.</summary>
public event Action<int> TilePicked;
/// <summary>Creates the canvas; input comes through the GuiInput event.</summary>
public RpgMapEditorCanvas()
{
ClipContents = true;
FocusMode = FocusModeEnum.Click;
TextureFilter = TextureFilterEnum.Nearest;
GuiInput += OnGuiInput;
MouseExited += OnMouseExited;
}
private float CellSize => (Tileset?.TileSize ?? 32) * Zoom;
/// <summary>Changes the zoom (0.25–4) and resizes the canvas to the map.</summary>
public void SetZoom(float zoom)
{
Zoom = Mathf.Clamp(zoom, 0.25f, 4f);
Refresh();
}
/// <summary>Resizes to the map and redraws; call after any outside change.</summary>
public void Refresh()
{
var grid = Document?.Grid;
CustomMinimumSize = grid == null ? Vector2.Zero : new Vector2(grid.Width, grid.Height) * CellSize;
QueueRedraw();
}
/// <summary>Draws the map.</summary>
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();
}
}
}
+20
View File
@@ -0,0 +1,20 @@
using Cthangover.Core.UI.Tool;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>In-game toolbar button of the RPG map editor, so a map can be fixed while walking on it.</summary>
public class RpgMapEditorToolBoxButton : IToolBoxButton
{
/// <summary>Matches <see cref="RpgMapEditorToolProvider.Id"/>.</summary>
public string ToolId => "rpg_map_editor";
/// <summary>No icon: the button shows its label.</summary>
public string IconPath => "";
/// <summary>Button label key.</summary>
public string LocaleKey => "tools/rpg_editor/title";
/// <summary>Always shown.</summary>
public bool IsVisible() => true;
}
}
+27
View File
@@ -0,0 +1,27 @@
using Cthangover.Core.UI.Tool;
using Cthangover.Mods.Interface.Tool;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// Registers the RPG map editor as a tool: the kernel's tool factory finds
/// every <see cref="IToolProvider"/>, so the editor appears in the main
/// menu's tool block, the novel editor hub and (with
/// <see cref="RpgMapEditorToolBoxButton"/>) the in-game toolbar.
/// </summary>
public class RpgMapEditorToolProvider : IToolProvider
{
/// <summary>Tool id.</summary>
public string Id => "rpg_map_editor";
/// <summary>Display name key.</summary>
public string LocaleKey => "tools/rpg_editor/title";
/// <summary>Creates the window.</summary>
public Window CreateWindow() => new RpgMapEditorWindow();
/// <summary>Adds the launcher button to the main menu's tool block.</summary>
public void AddMenuButton(Container container) => ToolMenuButton.Attach(container, this);
}
}
+458
View File
@@ -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
{
/// <summary>
/// Editor of RPG tile maps (<c>maps/*.json</c>, kind <c>rpg_map</c>).
/// 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.
/// <para/>
/// 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
/// <c>scenes/&lt;id&gt;.json</c> when the mod has no such scene, because the
/// map id is the scene name.
/// <para/>
/// 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.
/// </summary>
public partial class RpgMapEditorWindow : ToolWindow
{
private const string MapDir = "maps";
private const string SceneDir = "scenes";
private readonly RpgTextures textures = new();
private readonly Dictionary<RpgEditLayer, Button> layerButtons = new();
private readonly Dictionary<RpgEditTool, Button> 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;
/// <summary>Builds the window and opens the first map found.</summary>
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<RpgTileset>().Keys.OrderBy(k => k).FirstOrDefault();
return RpgMapDocument.Create(Path.GetFileNameWithoutExtension(file), tileset, 20, 15, 0);
}
/// <summary>Ctrl+S.</summary>
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;
}
/// <summary>Writes <c>scenes/&lt;id&gt;.json</c> for a new map when the mod has none; returns a status suffix.</summary>
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>("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}%";
/// <summary>Shortcuts: Ctrl+Z / Ctrl+Y undo and redo, 1–5 pick the layer, B/E/G the brush, Del deletes the selected event.</summary>
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;
}
}
}
+183
View File
@@ -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
{
/// <summary>
/// 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
/// <c>slime; wolf,slime</c> — groups split by semicolons.
/// <para/>
/// The first edit after <see cref="Bind"/> raises <see cref="EditStarting"/>
/// once, so typing into a field is one undo step, not one per letter.
/// </summary>
public partial class RpgMapPropertiesPanel : VBoxContainer
{
private readonly List<string> 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;
/// <summary>Raised once before the first change after a bind.</summary>
public event Action EditStarting;
/// <summary>A property changed.</summary>
public event Action Changed;
/// <summary>The tileset changed: the window reloads the atlas.</summary>
public event Action TilesetChanged;
/// <summary>The author asked for a new size.</summary>
public event Action<int, int> ResizeRequested;
/// <summary>Builds the fields.</summary>
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");
}
/// <summary>Shows a document's map (null clears).</summary>
public void Bind(RpgMapDocument value)
{
document = value;
editing = false;
suppress = true;
tilesets.Clear();
tilesets.AddRange(Entities.Collect<RpgTileset>().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<string>());
}
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();
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Cthangover.Mods.Rpg;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>One undo step of <see cref="RpgMapDocument"/>: the cells and the rest of the map as JSON.</summary>
public class RpgMapSnapshot
{
/// <summary>Copy of the cells.</summary>
public RpgMapGrid Grid { get; }
/// <summary>The map without its cells (rows may be stale), serialized.</summary>
public string MapJson { get; }
/// <summary>Creates a step.</summary>
public RpgMapSnapshot(RpgMapGrid grid, string mapJson)
{
Grid = grid;
MapJson = mapJson;
}
}
}
+165
View File
@@ -0,0 +1,165 @@
using System;
using System.Linq;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>Selected atlas index.</summary>
public int SelectedTile { get; private set; }
/// <summary>Selected collision code.</summary>
public char SelectedCollision { get; private set; } = RpgCollision.Block;
/// <summary>A tile was picked.</summary>
public event Action<int> TileSelected;
/// <summary>A collision code was picked.</summary>
public event Action<char> CollisionSelected;
/// <summary>Creates the palette.</summary>
public RpgTilePalette()
{
TextureFilter = TextureFilterEnum.Nearest;
MouseFilter = MouseFilterEnum.Stop;
GuiInput += OnGuiInput;
}
/// <summary>Shows a tileset (null: nothing to pick).</summary>
public void SetTileset(RpgTileset value, Texture2D image)
{
tileset = value;
atlas = image;
Relayout();
}
/// <summary>Switches between the tile atlas and the collision codes.</summary>
public void SetCollisionMode(bool value)
{
collisionMode = value;
Relayout();
}
/// <summary>Selects a tile from outside (the canvas' pipette).</summary>
public void Select(int index)
{
SelectedTile = index;
QueueRedraw();
}
/// <summary>Draws the atlas or the codes.</summary>
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}";
}
}
}
+10
View File
@@ -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=<dir> saves screenshots (windowed runs only).",
"sources": [
"src/*.cs"
],
"depends": [
"rpg_demo_game>=1"
]
}
+186
View File
@@ -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
{
/// <summary>
/// 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 <c>gui_input</c> signal, Ctrl+Z goes into the window. The window is
/// freed at the end without saving, so the demo maps never change.
/// Result line: <c>[TEST_RESULT] name=rpg_editor_edit passed=N failed=M</c>.
/// </summary>
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<string> failures = new();
private int passed;
private int step;
private double elapsed;
private RpgMapEditorWindow window;
private RpgMapEditorCanvas canvas;
private int eventsBefore;
/// <summary>Creates the driver; <paramref name="shotsDir"/> null disables screenshots.</summary>
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<RpgMapEditorWindow>();
step++;
return;
case 1:
canvas = window.FindChildren("*", "", true, false).OfType<RpgMapEditorCanvas>().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<Button>().FirstOrDefault(b => b.Text == text);
if (button == null)
{
Fail($"button '{text}' not found");
return;
}
button.EmitSignal(BaseButton.SignalName.Pressed);
}
private void ClickPalette(int index)
{
var palette = window.FindChildren("*", "", true, false).OfType<RpgTilePalette>().First();
var tileset = canvas.Tileset;
var cell = palette.Size.X / tileset.Columns;
var position = new Vector2(index % tileset.Columns + 0.5f, index / tileset.Columns + 0.5f) * cell;
palette.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, Position = position });
}
private void ClickCanvas(Vector2I cell, bool doubleClick)
{
var size = canvas.Tileset.TileSize * canvas.Zoom;
var position = new Vector2(cell.X + 0.5f, cell.Y + 0.5f) * size;
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = true, DoubleClick = doubleClick, Position = position });
canvas.EmitSignal(Control.SignalName.GuiInput, new InputEventMouseButton { ButtonIndex = MouseButton.Left, Pressed = false, Position = position });
}
private void Check(bool condition, string what)
{
if (condition)
{
passed++;
GameLogger.Log("TEST", $"[PASS] {what}");
}
else
{
Fail(what);
}
}
private void Fail(string what)
{
failures.Add(what);
GameLogger.Log("TEST", $"[FAIL] {what}", LogLevel.Error);
}
private void Shot(string name)
{
if (string.IsNullOrEmpty(shotsDir) || DisplayServer.GetName() == "headless")
return;
DirAccess.MakeDirRecursiveAbsolute(shotsDir);
GetViewport().GetTexture().GetImage()?.SavePng(shotsDir.TrimEnd('/') + "/rpg_editor_" + name + ".png");
}
private void Finish()
{
step = -1;
GameLogger.Log("TEST", $"[TEST_RESULT] name=rpg_editor_edit passed={passed} failed={failures.Count}");
GetTree().Quit(failures.Count == 0 ? 0 : 1);
}
}
}
@@ -0,0 +1,33 @@
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Utils;
using Godot;
namespace Cthangover.Tools.RpgEditor.Tests
{
/// <summary>Adds the editor test driver when <c>--itest=rpg_editor_edit</c> is given.</summary>
public class RpgEditorTestInitializer : IModInitializer
{
private static bool driverQueued;
/// <summary>Nothing per mod.</summary>
public void OnModLoaded(string modId)
{
}
/// <summary>Queues the driver once.</summary>
public void OnModResourcesReady()
{
var args = OS.GetCmdlineUserArgs();
if (driverQueued || !args.Any(a => a == "--itest=rpg_editor_edit"))
return;
driverQueued = true;
var shots = args.Where(a => a.StartsWith("--shots=", System.StringComparison.Ordinal))
.Select(a => a.Substring("--shots=".Length))
.FirstOrDefault();
GameLogger.Log("TEST", $"RpgEditorTestInitializer: activating rpg_editor_edit, shots={shots ?? "off"}");
(Godot.Engine.GetMainLoop() as SceneTree)?.Root?.CallDeferred("add_child", new RpgEditorTestDriver(shots));
}
}
}
+143
View File
@@ -0,0 +1,143 @@
namespace Cthangover.Tools.RpgEditor.Tests
{
using Cthangover.Core.Mods.Testing;
using Cthangover.Mods.Rpg;
/// <summary>The editor's document: brushes, events, undo and the saved file.</summary>
public class RpgMapDocumentTests
{
private static RpgMapDocument NewMap() => RpgMapDocument.Create("test_map", "village", 5, 4, 0);
[ModTest("document/create_fills_ground_and_centres_spawn")]
static void CreateFillsGroundAndCentresSpawn(ModTestContext ctx)
{
var document = NewMap();
ModAssert.Equal(0, document.Grid.GetTile(RpgLayer.Back, 4, 3), "ground everywhere");
ModAssert.Equal(-1, document.Grid.GetTile(RpgLayer.Main, 0, 0), "main empty");
ModAssert.Equal(2, document.Map.Spawn.X, "spawn x");
ModAssert.Equal(2, document.Map.Spawn.Y, "spawn y");
}
[ModTest("document/paint_reports_real_changes")]
static void PaintReportsRealChanges(ModTestContext ctx)
{
var document = NewMap();
ModAssert.True(document.Paint(RpgEditLayer.Main, 1, 1, 8, RpgCollision.Auto), "a new tile");
ModAssert.False(document.Paint(RpgEditLayer.Main, 1, 1, 8, RpgCollision.Auto), "the same tile again");
ModAssert.False(document.Paint(RpgEditLayer.Main, 9, 9, 8, RpgCollision.Auto), "outside");
ModAssert.True(document.Paint(RpgEditLayer.Collision, 2, 2, -1, RpgCollision.Block), "collision code");
ModAssert.Equal("#", document.Grid.GetCollision(2, 2).ToString(), "collision written");
ModAssert.False(document.Paint(RpgEditLayer.Events, 0, 0, 3, RpgCollision.Auto), "the events layer has no cells");
}
[ModTest("document/fill_stops_at_other_cells")]
static void FillStopsAtOtherCells(ModTestContext ctx)
{
var document = NewMap();
for (var y = 0; y < 4; y++)
document.Paint(RpgEditLayer.Back, 2, y, 5, RpgCollision.Auto);
var changed = document.Fill(RpgEditLayer.Back, 0, 0, 3, RpgCollision.Auto);
ModAssert.Equal(8, changed, "the left two columns");
ModAssert.Equal(3, document.Grid.GetTile(RpgLayer.Back, 1, 3), "filled");
ModAssert.Equal(5, document.Grid.GetTile(RpgLayer.Back, 2, 0), "the wall stays");
ModAssert.Equal(0, document.Grid.GetTile(RpgLayer.Back, 4, 0), "the other side stays");
ModAssert.Equal(0, document.Fill(RpgEditLayer.Back, 0, 0, 3, RpgCollision.Auto), "filling with the same tile changes nothing");
}
[ModTest("document/undo_and_redo_restore_cells_and_events")]
static void UndoAndRedoRestoreCellsAndEvents(ModTestContext ctx)
{
var document = NewMap();
ModAssert.False(document.CanUndo, "fresh document");
document.BeginChange();
document.Paint(RpgEditLayer.Front, 0, 0, 9, RpgCollision.Auto);
document.BeginChange();
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(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");
ModAssert.False(document.Undo(), "nothing more");
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");
document.Undo();
document.BeginChange();
ModAssert.False(document.CanRedo, "a new edit drops the redo branch");
}
[ModTest("document/events_get_unique_ids_and_stack")]
static void EventsGetUniqueIdsAndStack(ModTestContext ctx)
{
var document = NewMap();
var first = document.AddEvent(1, 1);
var second = document.AddEvent(1, 1);
ModAssert.Equal("event_1", first.Id, "first id");
ModAssert.Equal("event_2", second.Id, "second id");
ModAssert.Equal("event_2", document.EventsAt(1, 1)[0].Id, "the top event comes first");
ModAssert.True(document.RemoveEvent(first), "removed");
ModAssert.Equal("event_1", document.AddEvent(0, 0).Id, "a freed id is used again");
}
[ModTest("document/resize_pulls_events_inside")]
static void ResizePullsEventsInside(ModTestContext ctx)
{
var document = NewMap();
var mapEvent = document.AddEvent(4, 3);
document.Map.Spawn.X = 4;
document.Resize(3, 2);
ModAssert.Equal(2, mapEvent.X, "event x clamped");
ModAssert.Equal(1, mapEvent.Y, "event y clamped");
ModAssert.Equal(2, document.Map.Spawn.X, "spawn clamped");
}
[ModTest("document/validate_reports_ids")]
static void ValidateReportsIds(ModTestContext ctx)
{
var document = NewMap();
ModAssert.Equal(0, document.Validate().Count, "a fresh map is valid");
document.AddEvent(0, 0).Id = "door";
document.AddEvent(1, 0).Id = "DOOR";
document.AddEvent(2, 0).Id = " ";
var problems = document.Validate();
ModAssert.Equal(2, problems.Count, "an empty id and a duplicate");
ModAssert.Equal("tools/rpg_editor/problem_event_id", problems[0].Key, "empty id first");
ModAssert.Equal("tools/rpg_editor/problem_event_duplicate", problems[1].Key, "duplicate ignores case");
}
[ModTest("document/saved_file_loads_back")]
static void SavedFileLoadsBack(ModTestContext ctx)
{
var document = NewMap();
document.Paint(RpgEditLayer.Main, 3, 2, 16, RpgCollision.Auto);
document.Paint(RpgEditLayer.Collision, 0, 3, -1, RpgCollision.Open);
var mapEvent = document.AddEvent(1, 2);
mapEvent.Trigger = RpgEventTrigger.Touch;
mapEvent.Teleport = new RpgTeleport { Map = "other", X = 4, Y = 5, Dir = RpgDirection.Left };
var json = document.ToJson();
var loaded = RpgMapDocument.Load(json);
ModAssert.True(json.Contains("\"Kind\": \"rpg_map\""), "the file declares its kind");
ModAssert.False(json.Contains("\"Scenario\""), "empty fields are left out");
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.Throws(() => RpgMapDocument.Load("{\"Kind\":\"rpg_map\",\"Items\":[]}"), "a file without maps", anyException: true);
}
}
}