Palette picks a whole object on a click; mod documentation

This commit is contained in:
cthangover committed 2026-10-29 09:26:11 +01:00
1 parent c585960c61
commit bbdfe95de3
41 files changed
+1114 -179

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Cthangover.Core.Mods;
using Cthangover.Core.Mods.Resolvers;
using Cthangover.Mods.Rpg;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>
/// All map charsets supplied by the loaded mods, including NPC artwork without
/// a character entity. Paths stay distinct even when two mods use the same file name.
/// These entries describe appearance only and must never be used as battle enemy ids.
/// </summary>
public static class RpgAppearanceCatalog
{
/// <summary>Reads charset images anew and builds standing-frame previews for the picker.</summary>
public static List<RpgPickerEntry> Load(RpgTextures textures)
{
var files = Files.Collect(RpgTextures.CharsetGroup);
return (files?.Keys.AsEnumerable() ?? Enumerable.Empty<string>())
.Where(IsImage)
.Select(file => Path.ChangeExtension(file, null))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.Select(path => new RpgPickerEntry
{
Id = path,
Name = Path.GetFileName(path),
Look = path,
Icon = RpgCharacterCatalog.FrontFrame(textures.Charset(path)),
OwnsIcon = true
})
.ToList();
}
private static bool IsImage(string path)
=> ModConfig.Instance.GetTextureExtensionSet().Contains(Path.GetExtension(path));
}
}
+3 -2
View File
@@ -40,7 +40,8 @@ namespace Cthangover.Tools.RpgEditor
Id = info.ID,
Name = string.IsNullOrEmpty(info.Name) ? info.ID : TranslationServer.Translate(info.Name).ToString(),
Look = charset,
Icon = FrontFrame(charset == null ? null : textures.Charset(charset))
Icon = FrontFrame(charset == null ? null : textures.Charset(charset)),
OwnsIcon = true
};
private static string CharsetOf(CharacterInfo info, HashSet<string> charsets)
@@ -54,7 +55,7 @@ namespace Cthangover.Tools.RpgEditor
return candidates.FirstOrDefault(path => !string.IsNullOrEmpty(path) && charsets.Contains(path));
}
private static Texture2D FrontFrame(Texture2D image)
internal static Texture2D FrontFrame(Texture2D image)
{
if (image == null)
return null;
+22 -4
View File
@@ -84,13 +84,14 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>Reads the entries again and reapplies the search.</summary>
public void Reload()
{
var selectedId = Selected?.Id;
ReleaseEntries();
all = Source?.Invoke() ?? new List<RpgPickerEntry>();
Filter();
Filter(selectedId);
}
private void Filter()
private void Filter(string selectedId)
{
var selectedId = Selected?.Id;
var query = search.Text.Trim();
shown.Clear();
shown.AddRange(all.Where(entry => query.Length == 0
@@ -108,6 +109,23 @@ namespace Cthangover.Tools.RpgEditor
UpdateButtons();
}
/// <summary>Drop the list's native references before disposing entry-owned previews; source images remain cache-owned.</summary>
public override void _ExitTree()
{
ReleaseEntries();
Source = null;
}
private void ReleaseEntries()
{
list.Clear();
shown.Clear();
var owned = new HashSet<Texture2D>(all.Where(e => e.OwnsIcon && e.Icon != null).Select(e => e.Icon));
all.Clear();
foreach (var icon in owned)
icon.Dispose();
}
private void Run(Action<RpgPickerEntry> action)
{
if (Selected != null)
@@ -120,7 +138,7 @@ namespace Cthangover.Tools.RpgEditor
button.Disabled = Selected == null;
}
private void OnSearchChanged(string text) => Filter();
private void OnSearchChanged(string text) => Filter(Selected?.Id);
private void OnItemSelected(long index) => UpdateButtons();
}
+16
View File
@@ -146,6 +146,22 @@ namespace Cthangover.Tools.RpgEditor
return true;
}
/// <summary>Places a rectangular selection, skipping padding and clipping at map edges; one history step is recorded by the caller.</summary>
public int Stamp(RpgEditLayer layer, int x, int y, RpgTileStamp stamp)
{
if (layer > RpgEditLayer.Front || stamp == null)
return 0;
var changed = 0;
for (var sy = 0; sy < stamp.Height; sy++)
for (var sx = 0; sx < stamp.Width; sx++)
{
var tile = stamp.TileAt(sx, sy);
if (tile >= 0 && Paint(layer, x + sx, y + sy, tile, RpgCollision.Auto))
changed++;
}
return changed;
}
/// <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.
+171 -140
View File
@@ -1,10 +1,12 @@
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Cthangover.Mods.TileRender;
using Cthangover.Core.Events;
using Godot;
/// <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
@@ -37,6 +39,13 @@ namespace Cthangover.Tools.RpgEditor
private static readonly Color LockedModulate = new(1f, 1f, 1f, 0.55f);
private readonly RpgTextures textures = new();
private ITileWorldPresentation preview;
private RpgRenderSceneBuilder builder;
private TileRenderScene previewScene;
private ITileProjection projection;
private RpgMapEditorOverlay overlay;
public string RendererId { get; private set; } = "topdown";
public ITileWorldPresentation Preview => preview;
private Vector2I? hover;
private bool stroking;
private RpgMapEvent dragged;
@@ -63,6 +72,9 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>Atlas index the pencil and the fill put down.</summary>
public int SelectedTile { get; set; }
/// <summary>Multi-cell brush selected in the object atlas; null uses SelectedTile.</summary>
public RpgTileStamp SelectedStamp { get; set; }
/// <summary>Collision code the pencil and the fill put down.</summary>
public char SelectedCollision { get; set; } = RpgCollision.Block;
@@ -99,11 +111,16 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>Creates the canvas; input comes through the GuiInput event.</summary>
public RpgMapEditorCanvas()
{
RendererId = Cthangover.Core.Games.ActiveGame.GetSetting("tile.renderer") ?? (TileRendererRegistry.Instance.Ids.Count == 1 ? TileRendererRegistry.Instance.Ids[0] : "topdown");
foreach (var arg in OS.GetCmdlineUserArgs())
if (arg.StartsWith("--editor-renderer=")) RendererId = arg.Substring(18);
ClipContents = true;
FocusMode = FocusModeEnum.Click;
TextureFilter = TextureFilterEnum.Nearest;
GuiInput += OnGuiInput;
MouseExited += OnMouseExited;
overlay = new RpgMapEditorOverlay(this);
AddChild(overlay);
}
private float CellSize => (Tileset?.TileSize ?? 32) * Zoom;
@@ -115,160 +132,168 @@ namespace Cthangover.Tools.RpgEditor
Refresh();
}
/// <summary>Resizes to the map and redraws; call after any outside change.</summary>
public void SetRenderer(string id)
{
TileRendererRegistry.Instance.Resolve(id);
if (RendererId != id) { ReleasePreview(); projection = null; }
RendererId = id;
Refresh();
}
public override void _EnterTree()
{
TileRendererRegistry.Instance.Removing += OnRendererRemoving;
ModEventBus.Subscribe<ModSetReloadedEvent>(OnModsReloaded);
}
public override void _ExitTree()
{
TileRendererRegistry.Instance.Removing -= OnRendererRemoving;
ModEventBus.Unsubscribe<ModSetReloadedEvent>(OnModsReloaded);
ReleasePreview(); projection = null; builder?.Dispose(); textures.Dispose();
}
private void OnRendererRemoving(string id)
{
if (RendererId != id) return;
ReleasePreview(); projection = null;
}
private void OnModsReloaded(ModSetReloadedEvent evt)
{
if (!evt.Applied || Document == null) return;
ReleasePreview(); builder?.Dispose(); textures.Dispose();
Tileset = RpgTilesetFactory.Instance.Get(Document.Map.Tileset);
Refresh();
}
private void ReleasePreview()
{
preview?.Dispose();
preview = null;
previewScene?.Release();
previewScene = null;
}
/// <summary>The same renderer as runtime, with a static frame and authoring overlays.</summary>
public void Refresh()
{
var grid = Document?.Grid;
CustomMinimumSize = grid == null ? Vector2.Zero : new Vector2(grid.Width, grid.Height) * CellSize;
if (Document == null || Tileset == null) { ReleasePreview(); CustomMinimumSize = Vector2.Zero; QueueRedraw(); return; }
var factory = TileRendererRegistry.Instance.Resolve(RendererId);
projection = factory.Projection(Tileset.TileSize);
builder ??= new RpgRenderSceneBuilder(textures);
var previous = previewScene;
previewScene = builder.Build(Document.Map, Document.Grid, Tileset);
previewScene.Zoom = Zoom;
foreach (var visual in previewScene.Visuals)
visual.Tint = new Color(1, 1, 1, ActiveLayer <= RpgEditLayer.Front && visual.Layer != (int)ActiveLayer ? 0.45f : 1f);
if (preview == null)
{
preview = factory.Create();
AddChild(preview.Root);
MoveChild(preview.Root, 0);
preview.Mount(previewScene);
}
else preview.Apply(new TileSceneChanges { BaseRevision = previous.Revision, Scene = previewScene });
previous?.Release();
CustomMinimumSize = preview.ContentBounds.Size * Zoom;
preview.Resize(CustomMinimumSize);
var frame = new TileFrame { Ambient = Colors.White, Focus = new Vector2(Document.Grid.Width, Document.Grid.Height) / 2 };
foreach (var mapEvent in Document.LockedEvents) AddPreviewEvent(frame, builder, mapEvent, true);
foreach (var mapEvent in Document.Events) AddPreviewEvent(frame, builder, mapEvent, false);
preview.Present(frame, 0);
overlay.Position = preview.Root.Position;
overlay.Scale = preview.Root.Scale;
QueueRedraw();
}
/// <summary>Draws the map.</summary>
public override void _Draw()
private void AddPreviewEvent(TileFrame frame, RpgRenderSceneBuilder builder, RpgMapEvent mapEvent, bool locked)
{
DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0);
var grid = Document?.Grid;
if (grid == null)
return;
frame.Actors.Add(new TileActorPose { Id = (locked ? "locked/" : "own/") + mapEvent.Id,
Position = new Vector2(mapEvent.X, mapEvent.Y), Visible = true, Look = builder.EventLook(mapEvent, Tileset),
Facing = (int)mapEvent.Dir, Charset = !string.IsNullOrEmpty(mapEvent.Sprite), Tint = locked ? LockedModulate : Colors.White });
}
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();
public new void QueueRedraw() { base.QueueRedraw(); overlay?.QueueRedraw(); }
public override void _Draw() => DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0);
/// <summary>Mouse and automation coordinates use the real presentation transform, including negative iso X.</summary>
public Vector2 ProjectCell(Vector2 cell) => preview?.ProjectToView(new Vector3(cell.X, cell.Y, 0)) ?? cell * CellSize;
public void DrawOverlays(CanvasItem target)
{
if (Document == null || projection == null) return;
var grid = Document.Grid;
var unit = projection.Unit;
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)
continue;
// Autotiles are assembled from their neighbours exactly like in the game.
var autotile = Tileset.AutotileOf(index);
if (autotile == null)
DrawTextureRectRegion(Atlas, CellRect(x, y), TileRegion(index), modulate);
else if (textures.Tileset(autotile.Autotile) is { } block)
RpgAutotiles.Draw(this, block, CellRect(x, y), RpgAutotiles.QuartersAt(grid, layer, x, y, index), Tileset.TileSize, modulate);
}
for (var x = 0; x <= grid.Width; x++) target.DrawLine(P(x, 0), P(x, grid.Height), GridColor);
for (var y = 0; y <= grid.Height; y++) target.DrawLine(P(0, y), P(grid.Width, y), GridColor);
}
}
private void DrawEvents()
{
foreach (var mapEvent in Document.LockedEvents)
DrawEvent(mapEvent, true);
foreach (var mapEvent in Document.Events)
DrawEvent(mapEvent, false);
}
/// <summary>One event: its look, then on the events layer a frame (dashed grey for a locked one).</summary>
private void DrawEvent(RpgMapEvent mapEvent, bool locked)
{
var modulate = locked ? LockedModulate : Colors.White;
var rect = CellRect(mapEvent.X, mapEvent.Y);
var charset = string.IsNullOrEmpty(mapEvent.Sprite) ? null : textures.Charset(mapEvent.Sprite);
if (charset != null)
{
var frame = new Vector2(charset.GetWidth() / 3f, charset.GetHeight() / 4f);
var source = new Rect2(new Vector2(frame.X, frame.Y * (int)mapEvent.Dir), frame);
var size = frame * Zoom;
var target = new Rect2(rect.Position + new Vector2((rect.Size.X - size.X) / 2f, rect.Size.Y - size.Y), size);
DrawTextureRectRegion(charset, target, source, modulate);
}
else if (mapEvent.Tile >= 0 && Atlas != null && Tileset != null)
{
DrawTextureRectRegion(Atlas, rect, TileRegion(mapEvent.Tile), modulate);
}
else if (!string.IsNullOrEmpty(mapEvent.Icon) && textures.Icon(mapEvent.Icon) is { } icon)
{
// Fitted like RpgActor.UseIcon: three quarters of the cell, centred.
var fit = CellSize * 0.75f / Mathf.Max(1, Mathf.Max(icon.GetWidth(), icon.GetHeight()));
var size = icon.GetSize() * fit;
DrawTextureRect(icon, new Rect2(rect.GetCenter() - size / 2f, size), false, modulate);
}
else
{
DrawRect(rect.Grow(-CellSize * 0.15f), MarkerColor * modulate);
}
if (ActiveLayer == RpgEditLayer.Events && locked)
DrawDashedRect(rect.Grow(-1f));
else if (ActiveLayer == RpgEditLayer.Events)
DrawRect(rect, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.5f), false, mapEvent == SelectedEvent ? 3f : 1f);
}
private void DrawDashedRect(Rect2 rect)
{
var color = UiTokens.Alpha(UiTokens.White, 0.6f);
DrawDashedLine(rect.Position, new Vector2(rect.End.X, rect.Position.Y), color, 1f, 4f);
DrawDashedLine(new Vector2(rect.End.X, rect.Position.Y), rect.End, color, 1f, 4f);
DrawDashedLine(rect.End, new Vector2(rect.Position.X, rect.End.Y), color, 1f, 4f);
DrawDashedLine(new Vector2(rect.Position.X, rect.End.Y), rect.Position, color, 1f, 4f);
}
private void DrawSpawn()
{
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++)
{
if (ActiveLayer == RpgEditLayer.Collision)
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);
if (code == RpgCollision.Block || code == RpgCollision.Open || !RpgPassability.IsWalkable(grid, Tileset, x, y))
target.DrawColoredPolygon(Polygon(x, y, 1, 1), code == RpgCollision.Open ? OpenColor : BlockColor);
}
foreach (var mapEvent in Document.LockedEvents) DrawEventMarker(target, mapEvent, true);
foreach (var mapEvent in Document.Events) DrawEventMarker(target, mapEvent, false);
if (Document.Map.Spawn is { } spawn)
{
var center = P(spawn.X + 0.5f, spawn.Y + 0.5f);
target.DrawCircle(center, unit * 0.18f, UiTokens.Ok);
target.DrawArc(center, unit * 0.18f, 0, Mathf.Tau, 24, Colors.Black);
}
if (!hover.HasValue || !grid.InBounds(hover.Value.X, hover.Value.Y)) return;
var cell = hover.Value;
var stamp = SelectedStamp ?? RpgTileStamp.Single(SelectedTile);
if (!Placing && Tool == RpgEditTool.Pencil && ActiveLayer <= RpgEditLayer.Front && Atlas != null)
for (var y = 0; y < stamp.Height; y++)
for (var x = 0; x < stamp.Width; x++)
{
var id = stamp.TileAt(x, y);
if (id < 0) continue;
var info = Tileset.TileOf(id);
var texture = Atlas;
var source = RpgRenderSceneBuilder.TileRegion(Tileset, id);
if (info?.Surface is { } patch)
{
texture = textures.Tileset(Tileset.SurfaceImage);
source = new Rect2(patch * Tileset.SurfaceSize + (cell.X + x) * Tileset.TileSize % Tileset.SurfaceSize,
(cell.Y + y) * Tileset.TileSize % Tileset.SurfaceSize, Tileset.TileSize, Tileset.TileSize);
}
else if (!string.IsNullOrEmpty(info?.Autotile))
{
texture = textures.Autotile(info.Autotile, Tileset.TileSize, cell.X + x, cell.Y + y);
source = RpgAutotiles.PreviewRect(Tileset.TileSize);
}
TileDrawing.Draw(target, new TileVisual { Texture = texture, Source = source,
Bounds = new Rect2(cell.X + x, cell.Y + y, 1, 1) }, projection, new Color(1, 1, 1, 0.5f));
}
Outline(target, Polygon(cell.X, cell.Y, ActiveLayer <= RpgEditLayer.Front ? stamp.Width : 1,
ActiveLayer <= RpgEditLayer.Front ? stamp.Height : 1), UiTokens.AccentBright, false);
}
private void DrawGrid()
private void DrawEventMarker(CanvasItem target, RpgMapEvent mapEvent, bool locked)
{
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);
var polygon = Polygon(mapEvent.X, mapEvent.Y, 1, 1);
if (string.IsNullOrEmpty(mapEvent.Sprite) && mapEvent.Tile < 0 && string.IsNullOrEmpty(mapEvent.Icon))
target.DrawColoredPolygon(polygon, MarkerColor);
if (ActiveLayer != RpgEditLayer.Events) return;
Outline(target, polygon, mapEvent == SelectedEvent ? UiTokens.AccentBright : UiTokens.Alpha(UiTokens.White, 0.6f), locked);
}
private Rect2 CellRect(int x, int y) => new(new Vector2(x, y) * CellSize, new Vector2(CellSize, CellSize));
private Rect2 TileRegion(int index)
private static void Outline(CanvasItem target, Vector2[] polygon, Color color, bool dashed)
{
var coords = RpgTileSetBuilder.AtlasCoords(Tileset, index);
return new Rect2(coords * Tileset.TileSize, new Vector2(Tileset.TileSize, Tileset.TileSize));
for (var i = 0; i < polygon.Length; i++)
if (dashed) target.DrawDashedLine(polygon[i], polygon[(i + 1) % polygon.Length], color, 1, 4);
else target.DrawLine(polygon[i], polygon[(i + 1) % polygon.Length], color, 1.5f);
}
private Vector2I CellAt(Vector2 position) => new((int)Mathf.Floor(position.X / CellSize), (int)Mathf.Floor(position.Y / CellSize));
private Vector2 P(float x, float y) => TileDrawing.Project(projection, new Vector2(x, y));
private Vector2[] Polygon(int x, int y, int width, int height)
=> TileDrawing.CellPolygon(projection, new Rect2(x, y, width, height));
private Vector2I CellAt(Vector2 position)
=> preview != null && preview.TryPickGround(position, out var cell) ? cell : new Vector2I(-1, -1);
private void OnGuiInput(InputEvent @event)
{
@@ -391,8 +416,13 @@ namespace Cthangover.Tools.RpgEditor
{
var tile = erase ? -1 : SelectedTile;
var collision = erase ? RpgCollision.Auto : SelectedCollision;
if (Document.Paint(ActiveLayer, cell.X, cell.Y, tile, collision))
QueueRedraw();
var changed = !erase && ActiveLayer <= RpgEditLayer.Front && SelectedStamp != null
? Document.Stamp(ActiveLayer, cell.X, cell.Y, SelectedStamp) > 0
: Document.Paint(ActiveLayer, cell.X, cell.Y, tile, collision);
if (changed)
{
Refresh();
}
}
private void Drag(Vector2I cell)
@@ -406,6 +436,7 @@ namespace Cthangover.Tools.RpgEditor
}
dragged.X = cell.X;
dragged.Y = cell.Y;
Refresh();
EventSelected?.Invoke(dragged);
}
+11
View File
@@ -0,0 +1,11 @@
namespace Cthangover.Tools.RpgEditor
{
using Godot;
/// <summary>Authoring marks render above the shared world, without intercepting canvas input.</summary>
public partial class RpgMapEditorOverlay : Node2D
{
private readonly RpgMapEditorCanvas canvas;
public RpgMapEditorOverlay(RpgMapEditorCanvas canvas) { this.canvas = canvas; ZIndex = 10; }
public override void _Draw() => canvas.DrawOverlays(this);
}
}
+59 -2
View File
@@ -10,6 +10,7 @@ using Cthangover.Core.Utils;
using Cthangover.Mods.Interface.Theming;
using Cthangover.Mods.Interface.Tool;
using Cthangover.Mods.Rpg;
using Cthangover.Mods.TileRender;
using Cthangover.Tools.Common;
using Cthangover.Tools.Services;
using Godot;
@@ -19,7 +20,7 @@ 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, the game's characters and items as tabs on the left, the
/// the tile palette, the game's characters, items and all map appearances as tabs on the left, the
/// map in the middle, the map's and the selected event's properties on the right.
/// A character is put on the map as an NPC or an enemy, an item as a pickup:
/// pick it, press the button, click a cell (<see cref="RpgMapDocument.AddCharacter"/>,
@@ -53,6 +54,7 @@ namespace Cthangover.Tools.RpgEditor
private ModFileSelector fileSelector;
private TabContainer sideTabs;
private RpgTilePalette palette;
private OptionButton rendererPicker;
private SpinBox itemCount;
private Func<Vector2I, RpgMapEvent> placeAt;
private string placedKey;
@@ -79,6 +81,14 @@ namespace Cthangover.Tools.RpgEditor
fileSelector.Refresh();
}
/// <summary>Child pickers release their preview regions on exit before the window releases the shared source images.</summary>
protected override void Cleanup()
{
placeAt = null;
textures.Dispose();
base.Cleanup();
}
// ── layout ─────────────────────────────────────────────────
private void BuildUi()
@@ -98,6 +108,7 @@ namespace Cthangover.Tools.RpgEditor
paletteColumn.AddChild(ToolUi.Section("tools/rpg_editor/section_palette"));
palette = new RpgTilePalette();
palette.TileSelected += OnPaletteTile;
palette.StampSelected += OnPaletteStamp;
palette.CollisionSelected += OnPaletteCollision;
paletteColumn.AddChild(palette);
sideTabs.AddChild(paletteColumn);
@@ -120,6 +131,12 @@ namespace Cthangover.Tools.RpgEditor
items.AddAction("tools/rpg_editor/place_item", "tools/rpg_editor/place_item_tip", PlaceItem);
sideTabs.AddChild(items);
sideTabs.SetTabTitle(2, ToolUi.Tr("tools/rpg_editor/tab_items"));
var appearances = new RpgEntityPicker("tools/rpg_editor/appearance_search", "tools/rpg_editor/appearances_hint") { Name = "Appearances" };
appearances.Source = LoadAppearances;
appearances.AddAction("tools/rpg_editor/place_appearance", "tools/rpg_editor/place_appearance_tip", PlaceAppearance);
sideTabs.AddChild(appearances);
sideTabs.SetTabTitle(3, ToolUi.Tr("tools/rpg_editor/tab_appearances"));
sideTabs.TabChanged += OnSideTabChanged;
main.AddChild(CreateSidebar(leftColumn, 400));
@@ -170,6 +187,24 @@ namespace Cthangover.Tools.RpgEditor
AddToolButton(toolbar, RpgEditTool.Fill, "tools/rpg_editor/tool_fill");
toolbar.AddChild(ToolUi.Divider());
rendererPicker = new OptionButton { Name = "RendererPicker" };
var wantedRenderer = Cthangover.Core.Games.ActiveGame.GetSetting("tile.renderer") ?? "topdown";
foreach (var arg in OS.GetCmdlineUserArgs()) if (arg.StartsWith("--editor-renderer=")) wantedRenderer = arg.Substring(18);
foreach (var id in TileRendererRegistry.Instance.Ids)
{
var index = rendererPicker.ItemCount;
var label = id switch
{
"topdown" => ToolUi.Tr("tools/rpg_editor/view_topdown"),
"isometric" => ToolUi.Tr("tools/rpg_editor/view_isometric"),
_ => id
};
rendererPicker.AddItem(label);
rendererPicker.SetItemMetadata(index, id);
if (id == wantedRenderer) rendererPicker.Select(index);
}
rendererPicker.ItemSelected += OnRendererSelected;
toolbar.AddChild(rendererPicker);
gridCheck = new CheckBox { Text = ToolUi.Tr("tools/rpg_editor/grid"), ButtonPressed = true };
gridCheck.Toggled += OnGridToggled;
toolbar.AddChild(gridCheck);
@@ -432,10 +467,17 @@ namespace Cthangover.Tools.RpgEditor
private void OnPaletteTile(int index)
{
canvas.SelectedTile = index;
canvas.SelectedStamp = RpgTileStamp.Single(index);
if (canvas.ActiveLayer > RpgEditLayer.Front)
SelectLayer(RpgEditLayer.Main);
}
private void OnPaletteStamp(RpgTileStamp stamp)
{
canvas.SelectedStamp = stamp;
canvas.QueueRedraw();
}
private void OnPaletteCollision(char code) => canvas.SelectedCollision = code;
private void OnSideTabChanged(long tab)
@@ -448,6 +490,14 @@ namespace Cthangover.Tools.RpgEditor
private List<RpgPickerEntry> LoadItems() => RpgItemCatalog.Load(textures);
private List<RpgPickerEntry> LoadAppearances() => RpgAppearanceCatalog.Load(textures);
private void PlaceAppearance(RpgPickerEntry entry)
{
var id = "npc_" + new string(entry.Look.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray());
BeginPlacing(entry, "tools/rpg_editor/placed_npc", cell => document.AddCharacter(cell.X, cell.Y, id, entry.Look, false));
}
private void PlaceNpc(RpgPickerEntry entry)
=> BeginPlacing(entry, "tools/rpg_editor/placed_npc", cell => document.AddCharacter(cell.X, cell.Y, entry.Id, entry.Look, false));
@@ -492,6 +542,7 @@ namespace Cthangover.Tools.RpgEditor
private void OnTilePicked(int index)
{
canvas.SelectedTile = index;
canvas.SelectedStamp = RpgTileStamp.Single(index);
palette.Select(index);
}
@@ -505,7 +556,7 @@ namespace Cthangover.Tools.RpgEditor
palette.SetCollisionMode(layer == RpgEditLayer.Collision);
foreach (var button in toolButtons.Values)
button.Disabled = layer == RpgEditLayer.Events;
canvas.QueueRedraw();
canvas.Refresh();
}
private void SelectTool(RpgEditTool tool)
@@ -515,6 +566,12 @@ namespace Cthangover.Tools.RpgEditor
pair.Value.SetPressedNoSignal(pair.Key == tool);
}
private void OnRendererSelected(long index)
{
canvas.SetRenderer(rendererPicker.GetItemMetadata((int)index).AsString());
UpdateZoomLabel();
}
private void OnGridToggled(bool on)
{
canvas.ShowGrid = on;
+5 -2
View File
@@ -2,10 +2,10 @@ using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>One row of an <see cref="RpgEntityPicker"/>: a character or an item of the game.</summary>
/// <summary>One row of an <see cref="RpgEntityPicker"/>: a character, item or map appearance.</summary>
public class RpgPickerEntry
{
/// <summary>Entity id (what <c>rpg.battle enemies=</c> or <c>item.add id=</c> takes).</summary>
/// <summary>Entity id for character/item entries; a charset path for appearance-only entries.</summary>
public string Id { get; init; }
/// <summary>Display name in the current language (the id when the entity has none).</summary>
@@ -16,5 +16,8 @@ namespace Cthangover.Tools.RpgEditor
/// <summary>Picture of the row, or null.</summary>
public Texture2D Icon { get; init; }
/// <summary>True for an entry-owned atlas preview; false for a texture borrowed from the window cache.</summary>
public bool OwnsIcon { get; init; }
}
}
+90 -7
View File
@@ -17,7 +17,8 @@ namespace Cthangover.Tools.RpgEditor
/// </summary>
public partial class RpgTilePalette : Control
{
private const float Scale = 1.5f;
// Keep wide architectural blocks fully visible in the standard sidebar.
private float Scale => tileset == null ? 1.5f : Math.Min(1.5f, 384f / Math.Max(1, tileset.Columns * tileset.TileSize));
private const float CodeHeight = 44f;
private const float AutotileGap = 8f;
@@ -28,6 +29,15 @@ namespace Cthangover.Tools.RpgEditor
private RpgTileset tileset;
private Texture2D atlas;
private bool collisionMode;
private Vector2I? selectionStart;
private Rect2I? selection;
private bool dragged;
/// <summary>The current rectangular brush in logical map ids.</summary>
public RpgTileStamp SelectedStamp { get; private set; } = RpgTileStamp.Single(0);
/// <summary>A dragged rectangle changed the whole brush.</summary>
public event Action<RpgTileStamp> StampSelected;
/// <summary>Selected atlas index.</summary>
public int SelectedTile { get; private set; }
@@ -54,15 +64,18 @@ namespace Cthangover.Tools.RpgEditor
{
tileset = value;
atlas = image;
selection = null;
selectionStart = null;
autotiles.Clear();
if (value != null)
autotiles.AddRange(value.Tiles.Where(t => !string.IsNullOrEmpty(t.Autotile)));
autotiles.AddRange(value.Tiles.Where(t => !string.IsNullOrEmpty(t.Autotile) || t.Surface != null));
Relayout();
}
/// <summary>Switches between the tile atlas and the collision codes.</summary>
public void SetCollisionMode(bool value)
{
selectionStart = null;
collisionMode = value;
Relayout();
}
@@ -71,6 +84,9 @@ namespace Cthangover.Tools.RpgEditor
public void Select(int index)
{
SelectedTile = index;
SelectedStamp = RpgTileStamp.Single(index);
selection = null;
selectionStart = null;
QueueRedraw();
}
@@ -88,18 +104,24 @@ namespace Cthangover.Tools.RpgEditor
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 && string.IsNullOrEmpty(t.Autotile)))
foreach (var tile in tileset.Tiles.Where(t => t.Solid && string.IsNullOrEmpty(t.Autotile) && t.Surface == null))
DrawSolidMark((Vector2)RpgTileSetBuilder.AtlasCoords(tileset, tile.Index) * cell, cell);
for (var i = 0; i < autotiles.Count; i++)
{
if (textures.Tileset(autotiles[i].Autotile) is { } block)
if (autotiles[i].Surface is { } patch && textures.Tileset(tileset.SurfaceImage) is { } surface)
DrawTextureRectRegion(surface, new Rect2(Slot(i), new Vector2(cell, cell)),
new Rect2(patch * tileset.SurfaceSize, 0, tileset.TileSize, tileset.TileSize));
else if (textures.Tileset(autotiles[i].Autotile) is { } block)
DrawTextureRectRegion(block, new Rect2(Slot(i), new Vector2(cell, cell)), RpgAutotiles.PreviewRect(tileset.TileSize));
if (autotiles[i].Solid)
DrawSolidMark(Slot(i), cell);
}
var slot = autotiles.FindIndex(t => t.Index == SelectedTile);
var selected = slot >= 0 ? Slot(slot) : (Vector2)RpgTileSetBuilder.AtlasCoords(tileset, SelectedTile) * cell;
DrawRect(new Rect2(selected, new Vector2(cell, cell)), UiTokens.AccentBright, false, 3f);
var highlight = selection is { } rectangle
? new Rect2((Vector2)rectangle.Position * cell, (Vector2)rectangle.Size * cell)
: new Rect2(selected, new Vector2(cell, cell));
DrawRect(highlight, UiTokens.AccentBright, false, 3f);
}
private void DrawSolidMark(Vector2 position, float cell)
@@ -135,9 +157,19 @@ namespace Cthangover.Tools.RpgEditor
{
if (@event is InputEventMouseMotion motion && !collisionMode && tileset != null)
{
if (selectionStart.HasValue && (dragged || AtlasCellAt(motion.Position) != selectionStart.Value))
{
dragged = true;
SelectRectangle(motion.Position);
}
TooltipText = Describe(IndexAt(motion.Position));
return;
}
if (@event is InputEventMouseButton { Pressed: false, ButtonIndex: MouseButton.Left })
{
selectionStart = null;
return;
}
if (@event is not InputEventMouseButton { Pressed: true, ButtonIndex: MouseButton.Left } button)
return;
@@ -153,16 +185,63 @@ namespace Cthangover.Tools.RpgEditor
else if (tileset != null && atlas != null)
{
var index = IndexAt(button.Position);
if (index >= 0 && (index < RpgTileSetBuilder.TileCount(tileset, atlas) || tileset.AutotileOf(index) != null))
if (index >= 0 && (index < RpgTileSetBuilder.TileCount(tileset, atlas) || tileset.TileOf(index) != null))
{
SelectedTile = index;
SelectedStamp = RpgTileStamp.Single(index);
selection = null;
selectionStart = button.Position.Y < atlas.GetHeight() * Scale
? AtlasCellAt(button.Position) : null;
dragged = false;
SelectGroupOf(index);
TileSelected?.Invoke(index);
StampSelected?.Invoke(SelectedStamp);
}
}
QueueRedraw();
AcceptEvent();
}
/// <summary>
/// A click on any cell of a visual group picks the whole object, so it is stamped complete: a partial
/// pattern does not match the group and the isometric view falls back to separate per-cell sprites.
/// </summary>
private void SelectGroupOf(int index)
{
var group = tileset.VisualGroups?.Find(g => g.Pattern != null && Array.IndexOf(g.Pattern, index) >= 0);
if (group == null || RpgVisualGroupValidator.Error(group) != null)
return;
SelectedStamp = new RpgTileStamp(group.Width, group.Height, group.Pattern);
SelectedTile = group.Pattern[0];
var origin = RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[0]);
var packed = true;
for (var y = 0; y < group.Height; y++)
for (var x = 0; x < group.Width; x++)
packed &= RpgTileSetBuilder.AtlasCoords(tileset, group.Pattern[y * group.Width + x]) == origin + new Vector2I(x, y);
selection = packed ? new Rect2I(origin, group.Width, group.Height) : null;
}
private Vector2I AtlasCellAt(Vector2 position)
{
var cell = tileset.TileSize * Scale;
return new Vector2I(Math.Clamp((int)(position.X / cell), 0, tileset.Columns - 1),
Math.Clamp((int)(position.Y / cell), 0, atlas.GetHeight() / tileset.TileSize - 1));
}
private void SelectRectangle(Vector2 position)
{
var end = AtlasCellAt(position);
var start = selectionStart.Value;
var left = Math.Min(start.X, end.X);
var top = Math.Min(start.Y, end.Y);
selection = new Rect2I(left, top, Math.Abs(start.X - end.X) + 1, Math.Abs(start.Y - end.Y) + 1);
SelectedStamp = RpgTileStamp.FromAtlas(tileset, selection.Value);
SelectedTile = SelectedStamp.TileAt(0, 0);
TileSelected?.Invoke(SelectedTile);
StampSelected?.Invoke(SelectedStamp);
QueueRedraw();
}
private float AutotileTop => atlas == null ? 0f : atlas.GetHeight() * Scale + (autotiles.Count > 0 ? AutotileGap : 0f);
private int AutotileRows => (autotiles.Count + Math.Max(1, tileset.Columns) - 1) / Math.Max(1, tileset.Columns);
@@ -184,7 +263,11 @@ namespace Cthangover.Tools.RpgEditor
}
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;
if (column < 0 || column >= tileset.Columns || row < 0 || position.Y >= atlas.GetHeight() * Scale)
return -1;
var physical = row * tileset.Columns + column;
return tileset.Tiles.FirstOrDefault(t => t.AtlasIndex == physical)?.Index
?? (tileset.Tiles.Any(t => t.AtlasIndex != null) ? -1 : physical);
}
private string Describe(int index)
+49
View File
@@ -0,0 +1,49 @@
using System;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
/// <summary>A rectangular palette selection in logical map ids; empty atlas slots are skipped when stamping.</summary>
public class RpgTileStamp
{
private readonly int[] tiles;
/// <summary>Selection width in cells.</summary>
public int Width { get; }
/// <summary>Selection height in cells.</summary>
public int Height { get; }
/// <summary>Copies a rectangular set of logical tile ids; negative ids denote gaps.</summary>
public RpgTileStamp(int width, int height, int[] values)
{
if (width < 1 || height < 1 || values == null || values.Length != width * height)
throw new ArgumentException("A stamp must contain exactly width times height cells.");
Width = width;
Height = height;
tiles = (int[])values.Clone();
}
/// <summary>Logical id at the given offset, or -1 outside the selection.</summary>
public int TileAt(int x, int y) => x >= 0 && y >= 0 && x < Width && y < Height ? tiles[y * Width + x] : -1;
/// <summary>Single-tile brush, also used for surface materials and the pipette.</summary>
public static RpgTileStamp Single(int index) => new(1, 1, new[] { index });
/// <summary>Converts physical object-atlas coordinates into logical ids without exposing padding cells.</summary>
public static RpgTileStamp FromAtlas(RpgTileset tileset, Rect2I rectangle)
{
var values = new int[rectangle.Size.X * rectangle.Size.Y];
var packed = tileset.Tiles.Exists(t => t.AtlasIndex != null);
for (var y = 0; y < rectangle.Size.Y; y++)
for (var x = 0; x < rectangle.Size.X; x++)
{
var physical = (rectangle.Position.Y + y) * tileset.Columns + rectangle.Position.X + x;
values[y * rectangle.Size.X + x] = packed
? tileset.Tiles.Find(t => t.AtlasIndex == physical)?.Index ?? -1 : physical;
}
return new RpgTileStamp(rectangle.Size.X, rectangle.Size.Y, values);
}
}
}