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(); } } }