using System;
using Cthangover.Core.UI.Theming;
using Cthangover.Mods.Rpg;
using Godot;
namespace Cthangover.Tools.RpgEditor
{
///
/// The map as the editor shows it: back, main and front tiles, the events
/// (their charset frame or tile, else a marker), the spawn point, the
/// collision layer and a grid. Tiles of layers other than the active one are
/// dimmed, so the author sees which layer the brush is on; the collision
/// codes are shown only while the collision layer is active.
///
/// Mouse, on a tile or collision layer: LMB paints with the current tool and
/// a drag paints a stroke, RMB erases, Alt+LMB picks the tile under the
/// pointer. On the events layer: LMB selects the top event of a cell and drags
/// it, a double click on an empty cell adds an event, RMB clears the selection.
/// Locked events (other mods' and other files') are drawn faded with a dashed
/// frame and ignore the mouse.
/// While , a click only chooses a cell for the window.
/// Ctrl+wheel zooms. The plain wheel (Shift+wheel horizontally) is left to
/// the surrounding ScrollContainer, and a middle-button drag pans it, so a
/// map larger than the view is reachable on any layer and with any tool.
///
/// The canvas owns no data. It edits the window's
/// and reports through events; every stroke or
/// drag starts with , so the window records exactly
/// one undo step for it.
///
public partial class RpgMapEditorCanvas : Control
{
private static readonly Color BlockColor = new(0.9f, 0.2f, 0.2f, 0.45f);
private static readonly Color OpenColor = new(0.2f, 0.85f, 0.35f, 0.4f);
private static readonly Color GridColor = new(1f, 1f, 1f, 0.12f);
private static readonly Color MarkerColor = new(0.35f, 0.55f, 1f, 0.55f);
private static readonly Color LockedModulate = new(1f, 1f, 1f, 0.55f);
private readonly RpgTextures textures = new();
private Vector2I? hover;
private bool stroking;
private RpgMapEvent dragged;
private bool dragMoved;
private bool panning;
private Vector2 panStartMouse;
private Vector2 panStartScroll;
/// The edited map; null shows an empty canvas.
public RpgMapDocument Document { get; set; }
/// Tileset of the map (null: tiles are not drawn).
public RpgTileset Tileset { get; set; }
/// Atlas image of .
public Texture2D Atlas { get; set; }
/// Layer the brush works on.
public RpgEditLayer ActiveLayer { get; set; } = RpgEditLayer.Back;
/// Brush.
public RpgEditTool Tool { get; set; } = RpgEditTool.Pencil;
/// Atlas index the pencil and the fill put down.
public int SelectedTile { get; set; }
/// Collision code the pencil and the fill put down.
public char SelectedCollision { get; set; } = RpgCollision.Block;
/// The selected event, or null.
public RpgMapEvent SelectedEvent { get; set; }
/// Draw the cell grid.
public bool ShowGrid { get; set; } = true;
/// Screen pixels per tile pixel.
public float Zoom { get; private set; } = 1.5f;
/// An edit is about to change the document: record an undo step.
public event Action EditStarting;
/// The document changed.
public event Action Changed;
/// The selected event changed (null for none).
public event Action EventSelected;
/// Alt+click picked a tile.
public event Action TilePicked;
///
/// The next LMB on a cell is not an edit but a choice reported through
/// (placing a character); RMB cancels. Either ends it.
///
public bool Placing { get; set; }
/// The cell chosen while , or null when cancelled.
public event Action CellChosen;
/// Creates the canvas; input comes through the GuiInput event.
public RpgMapEditorCanvas()
{
ClipContents = true;
FocusMode = FocusModeEnum.Click;
TextureFilter = TextureFilterEnum.Nearest;
GuiInput += OnGuiInput;
MouseExited += OnMouseExited;
}
private float CellSize => (Tileset?.TileSize ?? 32) * Zoom;
/// Changes the zoom (0.25–4) and resizes the canvas to the map.
public void SetZoom(float zoom)
{
Zoom = Mathf.Clamp(zoom, 0.25f, 4f);
Refresh();
}
/// Resizes to the map and redraws; call after any outside change.
public void Refresh()
{
var grid = Document?.Grid;
CustomMinimumSize = grid == null ? Vector2.Zero : new Vector2(grid.Width, grid.Height) * CellSize;
QueueRedraw();
}
/// Draws the map.
public override void _Draw()
{
DrawRect(new Rect2(Vector2.Zero, Size), UiTokens.Surface0);
var grid = Document?.Grid;
if (grid == null)
return;
DrawRect(new Rect2(Vector2.Zero, new Vector2(grid.Width, grid.Height) * CellSize), Colors.Black);
DrawLayer(RpgLayer.Back);
DrawLayer(RpgLayer.Main);
DrawEvents();
DrawLayer(RpgLayer.Front);
DrawSpawn();
if (ActiveLayer == RpgEditLayer.Collision)
DrawCollision();
if (ShowGrid)
DrawGrid();
if (hover.HasValue && grid.InBounds(hover.Value.X, hover.Value.Y))
DrawRect(CellRect(hover.Value.X, hover.Value.Y), UiTokens.AccentBright, false, 2f);
}
private void DrawLayer(RpgLayer layer)
{
if (Atlas == null || Tileset == null)
return;
var dim = ActiveLayer <= RpgEditLayer.Front && (int)ActiveLayer != (int)layer;
var modulate = new Color(1f, 1f, 1f, dim ? 0.45f : 1f);
var grid = Document.Grid;
for (var y = 0; y < grid.Height; y++)
{
for (var x = 0; x < grid.Width; x++)
{
var index = grid.GetTile(layer, x, y);
if (index >= 0)
DrawTextureRectRegion(Atlas, CellRect(x, y), TileRegion(index), modulate);
}
}
}
private void DrawEvents()
{
foreach (var mapEvent in Document.LockedEvents)
DrawEvent(mapEvent, true);
foreach (var mapEvent in Document.Events)
DrawEvent(mapEvent, false);
}
/// One event: its look, then on the events layer a frame (dashed grey for a locked one).
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++)
{
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)
{
if (panning)
{
Pan(motion);
AcceptEvent();
return;
}
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;
}
// Not accepted: the event bubbles up and the ScrollContainer scrolls.
if (button.ButtonIndex is MouseButton.WheelUp or MouseButton.WheelDown or MouseButton.WheelLeft or MouseButton.WheelRight)
return;
if (button.ButtonIndex == MouseButton.Middle)
{
StartOrEndPan(button);
AcceptEvent();
return;
}
var cell = CellAt(button.Position);
if (!button.Pressed)
{
if (stroking || dragMoved)
Changed?.Invoke();
stroking = false;
dragged = null;
dragMoved = false;
return;
}
if (Placing && (button.ButtonIndex == MouseButton.Left || button.ButtonIndex == MouseButton.Right))
{
Placing = false;
var chosen = button.ButtonIndex == MouseButton.Left && Document.Grid.InBounds(cell.X, cell.Y);
CellChosen?.Invoke(chosen ? cell : null);
AcceptEvent();
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();
}
///
/// Middle button down starts panning the parent ScrollContainer, up ends
/// it. The scroll offset is anchored to the press point rather than
/// accumulated from deltas, so hitting a scroll edge builds no drift.
///
private void StartOrEndPan(InputEventMouseButton button)
{
var scroll = GetParentOrNull();
panning = button.Pressed && scroll != null;
MouseDefaultCursorShape = panning ? CursorShape.Drag : CursorShape.Arrow;
if (!panning)
return;
panStartMouse = button.GlobalPosition;
panStartScroll = new Vector2(scroll.ScrollHorizontal, scroll.ScrollVertical);
}
private void Pan(InputEventMouseMotion motion)
{
var scroll = GetParentOrNull();
if (scroll == null || (motion.ButtonMask & MouseButtonMask.Middle) == 0)
{
panning = false;
MouseDefaultCursorShape = CursorShape.Arrow;
return;
}
var delta = motion.GlobalPosition - panStartMouse;
scroll.ScrollHorizontal = Mathf.RoundToInt(panStartScroll.X - delta.X);
scroll.ScrollVertical = Mathf.RoundToInt(panStartScroll.Y - delta.Y);
}
private void OnMouseExited()
{
hover = null;
QueueRedraw();
}
}
}