Files
mod_rpg_editor/src/RpgMapEditorCanvas.cs
T

450 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
/// Locked events (other mods' and other files') are drawn faded with a dashed
/// frame and ignore the mouse.
/// While <see cref="Placing"/>, 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.
/// <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 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;
/// <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>
/// The next LMB on a cell is not an edit but a choice reported through
/// <see cref="CellChosen"/> (placing a character); RMB cancels. Either ends it.
/// </summary>
public bool Placing { get; set; }
/// <summary>The cell chosen while <see cref="Placing"/>, or null when cancelled.</summary>
public event Action<Vector2I?> CellChosen;
/// <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.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++)
{
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();
}
/// <summary>
/// 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.
/// </summary>
private void StartOrEndPan(InputEventMouseButton button)
{
var scroll = GetParentOrNull<ScrollContainer>();
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<ScrollContainer>();
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();
}
}
}