241 lines
8.0 KiB
C#
241 lines
8.0 KiB
C#
using Microsoft.Maui.Graphics;
|
|
using ZeroSpace.MapEditor.Models;
|
|
using ZeroSpace.MapEditor.Services;
|
|
using IImage = Microsoft.Maui.Graphics.IImage;
|
|
|
|
namespace ZeroSpace.MapEditor.Views;
|
|
|
|
/// <summary>Which part of the translation gizmo was hit or is active.</summary>
|
|
public enum GizmoPart
|
|
{
|
|
None,
|
|
Center,
|
|
XAxis,
|
|
YAxis,
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws the star map: connection lines between connected systems and
|
|
/// one circle per system. Names are intentionally not drawn on the
|
|
/// canvas; details live in the inspector.
|
|
/// </summary>
|
|
public sealed class MapDrawable : IDrawable
|
|
{
|
|
public const double NodeRadius = 14;
|
|
|
|
/// <summary>Screen-space length of each gizmo axis arm, in pixels.</summary>
|
|
public const double GizmoArmLength = 64;
|
|
|
|
public IList<MapNode> Nodes { get; set; } = Array.Empty<MapNode>();
|
|
|
|
public Func<string, MapNode?> FindNode { get; set; } = _ => null;
|
|
|
|
/// <summary>Screen pixels per world unit.</summary>
|
|
public double Scale { get; set; } = 1;
|
|
|
|
public double OffsetX { get; set; }
|
|
|
|
public double OffsetY { get; set; }
|
|
|
|
public RectF? SelectionBox { get; set; }
|
|
|
|
/// <summary>In-game galaxy map screenshot, drawn under the nodes.</summary>
|
|
public IImage? BackgroundImage { get; set; }
|
|
|
|
/// <summary>Whether the galaxy map background is shown.</summary>
|
|
public bool ShowBackground { get; set; } = true;
|
|
|
|
/// <summary>Node the translation gizmo is drawn for (the active selection).</summary>
|
|
public MapNode? GizmoNode { get; set; }
|
|
|
|
/// <summary>Gizmo part currently hovered or being dragged, for highlight.</summary>
|
|
public GizmoPart ActiveGizmoPart { get; set; } = GizmoPart.None;
|
|
|
|
public void Draw(ICanvas canvas, RectF dirtyRect)
|
|
{
|
|
canvas.FillColor = Color.FromArgb("#1e2430");
|
|
canvas.FillRectangle(dirtyRect);
|
|
|
|
DrawBackground(canvas);
|
|
|
|
PointF ToScreen(double worldX, double worldY) =>
|
|
new((float)(worldX * Scale + OffsetX), (float)(worldY * Scale + OffsetY));
|
|
|
|
// Connections first, so nodes draw on top of the lines.
|
|
canvas.StrokeColor = Color.FromArgb("#5b6b82");
|
|
canvas.StrokeSize = Math.Max(1, (float)(1.5 * Scale));
|
|
foreach (var node in Nodes)
|
|
{
|
|
var from = ToScreen(node.X, node.Y);
|
|
foreach (var name in node.Doc.ConnectedSystems)
|
|
{
|
|
var target = FindNode(name);
|
|
if (target is null || ReferenceEquals(target, node))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var to = ToScreen(target.X, target.Y);
|
|
canvas.DrawLine(from, to);
|
|
}
|
|
}
|
|
|
|
foreach (var node in Nodes)
|
|
{
|
|
var center = ToScreen(node.X, node.Y);
|
|
var radius = (float)(NodeRadius * Scale);
|
|
|
|
canvas.FillColor = ColorForOwner(node.Doc.Owner);
|
|
canvas.FillCircle(center, radius);
|
|
|
|
if (node.IsHighlighted)
|
|
{
|
|
canvas.StrokeColor = Color.FromArgb("#FFD700");
|
|
canvas.StrokeSize = 3;
|
|
canvas.DrawCircle(center, radius + 4);
|
|
}
|
|
|
|
if (node.IsSelected)
|
|
{
|
|
canvas.StrokeColor = Colors.White;
|
|
canvas.StrokeSize = 3;
|
|
canvas.DrawCircle(center, node.IsHighlighted ? radius + 8 : radius + 6);
|
|
}
|
|
}
|
|
|
|
if (SelectionBox is { Width: > 0, Height: > 0 } box)
|
|
{
|
|
canvas.FillColor = Color.FromArgb("#334a90d9");
|
|
canvas.FillRectangle(box);
|
|
canvas.StrokeColor = Color.FromArgb("#4a90d9");
|
|
canvas.StrokeSize = 1.5f;
|
|
canvas.DrawRectangle(box);
|
|
}
|
|
|
|
if (GizmoNode is not null)
|
|
{
|
|
DrawGizmo(canvas, GizmoNode);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws the 2D translation gizmo at the node's screen position:
|
|
/// a red +X arm, a green +Y arm, and a center square for free XY moves.
|
|
/// Screen-space, so it stays the same size at any zoom level.
|
|
/// </summary>
|
|
private void DrawGizmo(ICanvas canvas, MapNode node)
|
|
{
|
|
var cx = (float)(node.X * Scale + OffsetX);
|
|
var cy = (float)(node.Y * Scale + OffsetY);
|
|
const float arm = (float)GizmoArmLength;
|
|
|
|
DrawGizmoArm(canvas, cx, cy, cx + arm, cy, isX: true,
|
|
active: ActiveGizmoPart == GizmoPart.XAxis);
|
|
DrawGizmoArm(canvas, cx, cy, cx, cy + arm, isX: false,
|
|
active: ActiveGizmoPart == GizmoPart.YAxis);
|
|
|
|
// Center handle: free XY move.
|
|
const float size = 12f;
|
|
var centerColor = ActiveGizmoPart == GizmoPart.Center
|
|
? Color.FromArgb("#fde047")
|
|
: Color.FromArgb("#eab308");
|
|
canvas.FillColor = Color.FromArgb("#111827");
|
|
canvas.FillRectangle(cx - size / 2 - 2, cy - size / 2 - 2, size + 4, size + 4);
|
|
canvas.FillColor = centerColor;
|
|
canvas.FillRectangle(cx - size / 2, cy - size / 2, size, size);
|
|
}
|
|
|
|
private static void DrawGizmoArm(ICanvas canvas, float x1, float y1, float x2, float y2,
|
|
bool isX, bool active)
|
|
{
|
|
var color = isX
|
|
? (active ? Color.FromArgb("#f87171") : Color.FromArgb("#ef4444"))
|
|
: (active ? Color.FromArgb("#4ade80") : Color.FromArgb("#22c55e"));
|
|
|
|
// Dark underlay so the arm reads against the bright galaxy map.
|
|
canvas.StrokeColor = Color.FromArgb("#111827");
|
|
canvas.StrokeSize = 5;
|
|
canvas.DrawLine(x1, y1, x2, y2);
|
|
canvas.StrokeColor = color;
|
|
canvas.StrokeSize = 3;
|
|
canvas.DrawLine(x1, y1, x2, y2);
|
|
|
|
// Arrowhead pointing along the axis.
|
|
const float headLen = 12f;
|
|
const float headHalf = 6f;
|
|
PointF tip, a, b;
|
|
if (isX)
|
|
{
|
|
tip = new PointF(x2, y2);
|
|
a = new PointF(x2 - headLen, y2 - headHalf);
|
|
b = new PointF(x2 - headLen, y2 + headHalf);
|
|
}
|
|
else
|
|
{
|
|
tip = new PointF(x2, y2);
|
|
a = new PointF(x2 - headHalf, y2 - headLen);
|
|
b = new PointF(x2 + headHalf, y2 - headLen);
|
|
}
|
|
|
|
var head = new PathF();
|
|
head.MoveTo(tip);
|
|
head.LineTo(a);
|
|
head.LineTo(b);
|
|
head.Close();
|
|
canvas.FillColor = color;
|
|
canvas.FillPath(head);
|
|
canvas.StrokeColor = Color.FromArgb("#111827");
|
|
canvas.StrokeSize = 1.5f;
|
|
canvas.DrawPath(head);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Draws the galaxy map screenshot fitted inside the world square,
|
|
/// preserving aspect ratio so node world coordinates line up exactly
|
|
/// with the map underneath.
|
|
/// </summary>
|
|
private void DrawBackground(ICanvas canvas)
|
|
{
|
|
if (!ShowBackground || BackgroundImage is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var imageWidth = BackgroundImage.Width;
|
|
var imageHeight = BackgroundImage.Height;
|
|
if (imageWidth <= 0 || imageHeight <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const double world = StarSystemDocStore.WorldSize;
|
|
var fit = Math.Min(world / imageWidth, world / imageHeight);
|
|
var drawWidth = imageWidth * fit;
|
|
var drawHeight = imageHeight * fit;
|
|
var originX = (world - drawWidth) / 2;
|
|
var originY = (world - drawHeight) / 2;
|
|
|
|
canvas.DrawImage(BackgroundImage,
|
|
(float)(originX * Scale + OffsetX),
|
|
(float)(originY * Scale + OffsetY),
|
|
(float)(drawWidth * Scale),
|
|
(float)(drawHeight * Scale));
|
|
}
|
|
|
|
public static Color ColorForOwner(string owner) => owner switch
|
|
{
|
|
"The Eden Accord" => Color.FromArgb("#10b981"),
|
|
"The Keeper Council" => Color.FromArgb("#38bdf8"),
|
|
"The Obsidian Order" => Color.FromArgb("#ef4444"),
|
|
_ => Color.FromArgb("#9aa5b1"),
|
|
};
|
|
|
|
public static Color ColorForCategory(string category) => category switch
|
|
{
|
|
"Homeworld System" or "Homeworld" => Color.FromArgb("#43a047"),
|
|
"Core System" or "Core" => Color.FromArgb("#fb8c00"),
|
|
"Side System" or "Side" => Color.FromArgb("#4a90d9"),
|
|
_ => Color.FromArgb("#9aa5b1"),
|
|
};
|
|
}
|