init mod
This commit is contained in:
commit
eadc7a1157
20 files changed
+2288
No files matched your search
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user