init mod
This commit is contained in:
commit
eadc7a1157
20 files changed
+2288
No files matched your search
@@ -0,0 +1,30 @@
|
||||
# Line endings: LF everywhere, on every OS, whatever core.autocrlf says.
|
||||
* text=auto eol=lf
|
||||
|
||||
# cmd.exe misreads labels and goto in LF batch files, so they stay CRLF.
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# Binaries: never converted, never diffed as text.
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.webp binary
|
||||
*.svg text
|
||||
*.ogg binary
|
||||
*.wav binary
|
||||
*.mp3 binary
|
||||
*.ttf binary
|
||||
*.otf binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.dll binary
|
||||
*.so binary
|
||||
*.dylib binary
|
||||
*.pdb binary
|
||||
*.exe binary
|
||||
*.zip binary
|
||||
*.pck binary
|
||||
*.moc3 binary
|
||||
*.res binary
|
||||
*.scn binary
|
||||
@@ -0,0 +1,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}".
|
||||
@@ -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}».
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/<id>.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/<id>.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user