1195 lines
38 KiB
C#
1195 lines
38 KiB
C#
using System.Globalization;
|
|
using CommunityToolkit.Maui.Storage;
|
|
using ZeroSpace.MapEditor.Models;
|
|
using ZeroSpace.MapEditor.Services;
|
|
using ZeroSpace.Model;
|
|
|
|
namespace ZeroSpace.MapEditor.Views;
|
|
|
|
public partial class MapEditorPage : ContentPage
|
|
{
|
|
private readonly MapDrawable _drawable = new();
|
|
private List<MapNode> _nodes = new();
|
|
private Dictionary<string, MapNode> _byName = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly HashSet<MapNode> _selectedNodes = new();
|
|
private MapNode? _selected;
|
|
private MapNode? _pressNode;
|
|
private Point _pressPoint;
|
|
private Point _lastPoint;
|
|
private Point _dragStartWorldPoint;
|
|
private readonly Dictionary<MapNode, (double X, double Y)> _dragStartWorldPositions = new();
|
|
private readonly HashSet<MapNode> _preMarqueeSelected = new();
|
|
private bool _draggingNode;
|
|
private GizmoPart _gizmoPart = GizmoPart.None;
|
|
private bool _draggingGizmo;
|
|
private bool _marqueeSelecting;
|
|
private bool _panning;
|
|
private bool _panModeEnabled;
|
|
private bool _wasAlreadySelectedInGroup;
|
|
private bool _viewFitted;
|
|
private bool _updatingInspector;
|
|
private string? _folder;
|
|
private readonly SemaphoreSlim _saveLock = new(1, 1);
|
|
private CancellationTokenSource? _autoSaveCts;
|
|
|
|
public MapEditorPage()
|
|
{
|
|
InitializeComponent();
|
|
UpdateFactionModeUI();
|
|
_drawable.FindNode = name => _byName.TryGetValue(name, out var node) ? node : null;
|
|
MapView.Drawable = _drawable;
|
|
MapView.SizeChanged += OnMapViewSizeChanged;
|
|
#if WINDOWS
|
|
Loaded += OnPageLoaded;
|
|
Unloaded += OnPageUnloaded;
|
|
#endif
|
|
TryAutoLoad();
|
|
LoadGalaxyMapAsync();
|
|
}
|
|
|
|
// ----- galaxy map background -----
|
|
|
|
private async void LoadGalaxyMapAsync()
|
|
{
|
|
_drawable.BackgroundImage = await MapBackground.LoadAsync();
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
private void OnBackgroundToggled(object? sender, CheckedChangedEventArgs e)
|
|
{
|
|
_drawable.ShowBackground = e.Value;
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
// ----- view transform -----
|
|
|
|
private (double X, double Y) ToWorld(Point screen) =>
|
|
((screen.X - _drawable.OffsetX) / _drawable.Scale,
|
|
(screen.Y - _drawable.OffsetY) / _drawable.Scale);
|
|
|
|
private void FitView()
|
|
{
|
|
var width = MapView.Width;
|
|
var height = MapView.Height;
|
|
if (width <= 0 || height <= 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
const double margin = 60;
|
|
double minX = 0, minY = 0, maxX = StarSystemDocStore.WorldSize, maxY = StarSystemDocStore.WorldSize;
|
|
if (_nodes.Count > 0)
|
|
{
|
|
minX = Math.Min(0, _nodes.Min(static n => n.X));
|
|
minY = Math.Min(0, _nodes.Min(static n => n.Y));
|
|
maxX = Math.Max(StarSystemDocStore.WorldSize, _nodes.Max(static n => n.X));
|
|
maxY = Math.Max(StarSystemDocStore.WorldSize, _nodes.Max(static n => n.Y));
|
|
}
|
|
|
|
var worldW = Math.Max(maxX - minX, 100);
|
|
var worldH = Math.Max(maxY - minY, 100);
|
|
_drawable.Scale = Math.Min((width - margin * 2) / worldW, (height - margin * 2) / worldH);
|
|
_drawable.OffsetX = width / 2 - (minX + worldW / 2) * _drawable.Scale;
|
|
_drawable.OffsetY = height / 2 - (minY + worldH / 2) * _drawable.Scale;
|
|
_viewFitted = true;
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
private void OnMapViewSizeChanged(object? sender, EventArgs e)
|
|
{
|
|
if (!_viewFitted)
|
|
{
|
|
FitView();
|
|
}
|
|
}
|
|
|
|
private void Zoom(double factor)
|
|
{
|
|
var centerX = MapView.Width / 2;
|
|
var centerY = MapView.Height / 2;
|
|
var (worldX, worldY) = ToWorld(new Point(centerX, centerY));
|
|
_drawable.Scale = Math.Clamp(_drawable.Scale * factor, 0.05, 20);
|
|
_drawable.OffsetX = centerX - worldX * _drawable.Scale;
|
|
_drawable.OffsetY = centerY - worldY * _drawable.Scale;
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
private void OnZoomInClicked(object? sender, EventArgs e) => Zoom(1.25);
|
|
|
|
private void OnZoomOutClicked(object? sender, EventArgs e) => Zoom(1 / 1.25);
|
|
|
|
private void OnResetViewClicked(object? sender, EventArgs e)
|
|
{
|
|
_viewFitted = false;
|
|
FitView();
|
|
}
|
|
|
|
private void OnPanModeClicked(object? sender, EventArgs e)
|
|
{
|
|
_panModeEnabled = !_panModeEnabled;
|
|
PanModeButton.Text = _panModeEnabled ? "Pan: ON" : "Pan: Off";
|
|
StatusLabel.Text = _panModeEnabled
|
|
? "Pan mode enabled. Drag anywhere to move the view."
|
|
: "Select mode enabled. Drag empty space to multi-select.";
|
|
}
|
|
|
|
// ----- faction mode -----
|
|
|
|
private FactionMode _activeFaction = FactionMode.None;
|
|
|
|
public FactionMode ActiveFaction => _activeFaction;
|
|
|
|
public void SetActiveFactionMode(FactionMode mode)
|
|
{
|
|
_activeFaction = mode;
|
|
UpdateFactionModeUI();
|
|
if (_activeFaction != FactionMode.None)
|
|
{
|
|
StatusLabel.Text = $"Faction painting mode: {_activeFaction.ToDisplayName()} (Click system or press A/S/D).";
|
|
}
|
|
}
|
|
|
|
public void HandleFactionHotkey(FactionMode mode)
|
|
{
|
|
SetActiveFactionMode(mode);
|
|
var targets = _selectedNodes.Count > 0
|
|
? _selectedNodes.ToList()
|
|
: (_selected is not null ? new List<MapNode> { _selected } : null);
|
|
|
|
if (targets is not null && targets.Count > 0)
|
|
{
|
|
ApplyFactionToNodes(targets, mode);
|
|
}
|
|
}
|
|
|
|
public void ApplyFactionToNodes(IEnumerable<MapNode> nodes, FactionMode mode)
|
|
{
|
|
var ownerName = mode.ToOwnerName();
|
|
var anyChanged = false;
|
|
foreach (var node in nodes)
|
|
{
|
|
if (!string.Equals(node.Doc.Owner, ownerName, StringComparison.Ordinal))
|
|
{
|
|
node.Doc.Owner = ownerName;
|
|
anyChanged = true;
|
|
}
|
|
}
|
|
|
|
if (anyChanged)
|
|
{
|
|
UpdateDirtyState();
|
|
if (_selected is not null)
|
|
{
|
|
PopulateInspector(_selected);
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
ScheduleAutoSave();
|
|
}
|
|
}
|
|
|
|
private bool IsTextInputFocused()
|
|
{
|
|
if (SearchControl.IsFocused || XEntry.IsFocused || YEntry.IsFocused)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
#if WINDOWS
|
|
if (Handler?.PlatformView is Microsoft.UI.Xaml.FrameworkElement fe && fe.XamlRoot is not null)
|
|
{
|
|
var focused = Microsoft.UI.Xaml.Input.FocusManager.GetFocusedElement(fe.XamlRoot);
|
|
if (focused is Microsoft.UI.Xaml.Controls.TextBox ||
|
|
focused is Microsoft.UI.Xaml.Controls.AutoSuggestBox ||
|
|
focused is Microsoft.UI.Xaml.Controls.RichEditBox ||
|
|
focused is Microsoft.UI.Xaml.Controls.PasswordBox)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
return false;
|
|
}
|
|
|
|
#if WINDOWS
|
|
private Microsoft.UI.Xaml.UIElement? _keyElement;
|
|
private Microsoft.UI.Xaml.Input.KeyEventHandler? _keyHandler;
|
|
|
|
private void OnPageLoaded(object? sender, EventArgs e)
|
|
{
|
|
if (Handler?.PlatformView is Microsoft.UI.Xaml.FrameworkElement fe)
|
|
{
|
|
_keyHandler = new Microsoft.UI.Xaml.Input.KeyEventHandler(OnPlatformKeyDown);
|
|
_keyElement = fe.XamlRoot?.Content as Microsoft.UI.Xaml.UIElement ?? fe;
|
|
_keyElement.AddHandler(Microsoft.UI.Xaml.UIElement.KeyDownEvent, _keyHandler, handledEventsToo: true);
|
|
}
|
|
}
|
|
|
|
private void OnPageUnloaded(object? sender, EventArgs e)
|
|
{
|
|
if (_keyHandler is not null)
|
|
{
|
|
if (_keyElement is not null)
|
|
{
|
|
_keyElement.RemoveHandler(Microsoft.UI.Xaml.UIElement.KeyDownEvent, _keyHandler);
|
|
_keyElement = null;
|
|
}
|
|
|
|
_keyHandler = null;
|
|
}
|
|
}
|
|
|
|
private void OnPlatformKeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
|
|
{
|
|
if (IsTextInputFocused())
|
|
{
|
|
return;
|
|
}
|
|
|
|
var ctrlDown = Microsoft.UI.Input.InputKeyboardSource
|
|
.GetKeyStateForCurrentThread(global::Windows.System.VirtualKey.Control)
|
|
.HasFlag(global::Windows.UI.Core.CoreVirtualKeyStates.Down);
|
|
var altDown = e.KeyStatus.IsMenuKeyDown || Microsoft.UI.Input.InputKeyboardSource
|
|
.GetKeyStateForCurrentThread(global::Windows.System.VirtualKey.Menu)
|
|
.HasFlag(global::Windows.UI.Core.CoreVirtualKeyStates.Down);
|
|
|
|
if (ctrlDown || altDown)
|
|
{
|
|
return;
|
|
}
|
|
|
|
switch (e.Key)
|
|
{
|
|
case global::Windows.System.VirtualKey.A:
|
|
HandleFactionHotkey(FactionMode.EdenAccord);
|
|
e.Handled = true;
|
|
break;
|
|
case global::Windows.System.VirtualKey.S:
|
|
HandleFactionHotkey(FactionMode.KeeperCouncil);
|
|
e.Handled = true;
|
|
break;
|
|
case global::Windows.System.VirtualKey.D:
|
|
HandleFactionHotkey(FactionMode.ObsidianOrder);
|
|
e.Handled = true;
|
|
break;
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private void OnEdenFactionClicked(object? sender, EventArgs e) =>
|
|
ToggleOrSetFactionMode(FactionMode.EdenAccord);
|
|
|
|
private void OnKeeperFactionClicked(object? sender, EventArgs e) =>
|
|
ToggleOrSetFactionMode(FactionMode.KeeperCouncil);
|
|
|
|
private void OnObsidianFactionClicked(object? sender, EventArgs e) =>
|
|
ToggleOrSetFactionMode(FactionMode.ObsidianOrder);
|
|
|
|
private void OnNoneFactionClicked(object? sender, EventArgs e) =>
|
|
SetActiveFactionMode(FactionMode.None);
|
|
|
|
private void ToggleOrSetFactionMode(FactionMode mode)
|
|
{
|
|
var targetMode = _activeFaction == mode ? FactionMode.None : mode;
|
|
SetActiveFactionMode(targetMode);
|
|
if (targetMode != FactionMode.None && _selectedNodes.Count > 0)
|
|
{
|
|
ApplyFactionToNodes(_selectedNodes, targetMode);
|
|
}
|
|
}
|
|
|
|
private void UpdateFactionModeUI()
|
|
{
|
|
// Eden Accord [A]
|
|
var isEden = _activeFaction == FactionMode.EdenAccord;
|
|
EdenFactionButton.BackgroundColor = isEden ? Color.FromArgb("#10b981") : Color.FromArgb("#1e293b");
|
|
EdenFactionButton.TextColor = isEden ? Colors.White : Color.FromArgb("#10b981");
|
|
EdenFactionButton.BorderColor = isEden ? Color.FromArgb("#059669") : Color.FromArgb("#10b981");
|
|
EdenFactionButton.BorderWidth = isEden ? 2 : 1;
|
|
EdenFactionButton.FontAttributes = isEden ? FontAttributes.Bold : FontAttributes.None;
|
|
|
|
// Keeper Council [S]
|
|
var isKeeper = _activeFaction == FactionMode.KeeperCouncil;
|
|
KeeperFactionButton.BackgroundColor = isKeeper ? Color.FromArgb("#38bdf8") : Color.FromArgb("#1e293b");
|
|
KeeperFactionButton.TextColor = isKeeper ? Color.FromArgb("#0f172a") : Color.FromArgb("#38bdf8");
|
|
KeeperFactionButton.BorderColor = isKeeper ? Color.FromArgb("#0284c7") : Color.FromArgb("#38bdf8");
|
|
KeeperFactionButton.BorderWidth = isKeeper ? 2 : 1;
|
|
KeeperFactionButton.FontAttributes = isKeeper ? FontAttributes.Bold : FontAttributes.None;
|
|
|
|
// Obsidian Order [D]
|
|
var isObsidian = _activeFaction == FactionMode.ObsidianOrder;
|
|
ObsidianFactionButton.BackgroundColor = isObsidian ? Color.FromArgb("#ef4444") : Color.FromArgb("#1e293b");
|
|
ObsidianFactionButton.TextColor = isObsidian ? Colors.White : Color.FromArgb("#ef4444");
|
|
ObsidianFactionButton.BorderColor = isObsidian ? Color.FromArgb("#dc2626") : Color.FromArgb("#ef4444");
|
|
ObsidianFactionButton.BorderWidth = isObsidian ? 2 : 1;
|
|
ObsidianFactionButton.FontAttributes = isObsidian ? FontAttributes.Bold : FontAttributes.None;
|
|
|
|
// None
|
|
var isNone = _activeFaction == FactionMode.None;
|
|
NoneFactionButton.BackgroundColor = isNone ? Color.FromArgb("#475569") : Color.FromArgb("#1e293b");
|
|
NoneFactionButton.TextColor = isNone ? Colors.White : Color.FromArgb("#94a3b8");
|
|
NoneFactionButton.BorderColor = isNone ? Color.FromArgb("#64748b") : Color.FromArgb("#475569");
|
|
NoneFactionButton.BorderWidth = isNone ? 2 : 1;
|
|
NoneFactionButton.FontAttributes = isNone ? FontAttributes.Bold : FontAttributes.None;
|
|
|
|
// Badge indicator
|
|
if (_activeFaction != FactionMode.None)
|
|
{
|
|
var hex = _activeFaction.GetColorHex();
|
|
var factionColor = Color.FromArgb(hex);
|
|
ActiveFactionBadge.IsVisible = true;
|
|
ActiveFactionBadge.Stroke = factionColor;
|
|
ActiveFactionBadge.BackgroundColor = factionColor.WithAlpha(0.2f);
|
|
ActiveFactionLabel.Text = $"Active: {_activeFaction.ToDisplayName()}";
|
|
ActiveFactionLabel.TextColor = factionColor;
|
|
}
|
|
else
|
|
{
|
|
ActiveFactionBadge.IsVisible = false;
|
|
}
|
|
}
|
|
|
|
private void CenterOnNode(MapNode node)
|
|
{
|
|
var width = MapView.Width;
|
|
var height = MapView.Height;
|
|
if (width > 0 && height > 0)
|
|
{
|
|
_drawable.OffsetX = width / 2 - node.X * _drawable.Scale;
|
|
_drawable.OffsetY = height / 2 - node.Y * _drawable.Scale;
|
|
MapView.Invalidate();
|
|
}
|
|
}
|
|
|
|
// ----- search -----
|
|
|
|
private void OnSearchTextChanged(object? sender, TextChangedEventArgs e) => ApplySearch(e.NewTextValue);
|
|
|
|
private void OnSearchButtonPressed(object? sender, EventArgs e)
|
|
{
|
|
ApplySearch(SearchControl.Text);
|
|
FocusFirstMatch();
|
|
}
|
|
|
|
private void ApplySearch(string? query)
|
|
{
|
|
var trimmed = query?.Trim();
|
|
if (string.IsNullOrEmpty(trimmed))
|
|
{
|
|
foreach (var node in _nodes)
|
|
{
|
|
node.IsHighlighted = false;
|
|
}
|
|
|
|
if (_folder is not null)
|
|
{
|
|
StatusLabel.Text = $"{_nodes.Count} systems loaded from {_folder}.";
|
|
}
|
|
else
|
|
{
|
|
StatusLabel.Text = "No folder loaded.";
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
return;
|
|
}
|
|
|
|
var matchCount = 0;
|
|
foreach (var node in _nodes)
|
|
{
|
|
var isMatch = node.Doc.Name.Contains(trimmed, StringComparison.OrdinalIgnoreCase);
|
|
node.IsHighlighted = isMatch;
|
|
if (isMatch)
|
|
{
|
|
matchCount++;
|
|
}
|
|
}
|
|
|
|
if (matchCount == 0)
|
|
{
|
|
StatusLabel.Text = $"No systems matching '{trimmed}'.";
|
|
}
|
|
else
|
|
{
|
|
StatusLabel.Text = $"Found {matchCount} matching system{(matchCount == 1 ? string.Empty : "s")}.";
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
private void FocusFirstMatch()
|
|
{
|
|
var match = _nodes.FirstOrDefault(static n => n.IsHighlighted);
|
|
if (match is not null)
|
|
{
|
|
Select(match);
|
|
CenterOnNode(match);
|
|
}
|
|
}
|
|
|
|
// ----- pointer interaction -----
|
|
|
|
private MapNode? HitTest(Point screen)
|
|
{
|
|
var (worldX, worldY) = ToWorld(screen);
|
|
var radius = MapDrawable.NodeRadius + 4;
|
|
foreach (var node in _nodes)
|
|
{
|
|
var dx = node.X - worldX;
|
|
var dy = node.Y - worldY;
|
|
if (dx * dx + dy * dy <= radius * radius)
|
|
{
|
|
return node;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static double Distance(Point a, Point b)
|
|
{
|
|
var dx = a.X - b.X;
|
|
var dy = a.Y - b.Y;
|
|
return Math.Sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
private void OnPointerPressed(object? sender, PointerEventArgs e)
|
|
{
|
|
var position = e.GetPosition(MapView);
|
|
if (position is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_pressPoint = position.Value;
|
|
_lastPoint = position.Value;
|
|
_draggingNode = false;
|
|
_marqueeSelecting = false;
|
|
_panning = false;
|
|
_wasAlreadySelectedInGroup = false;
|
|
|
|
var isRightOrMiddle = false;
|
|
var isShiftOrCtrl = false;
|
|
var isAltPressed = false;
|
|
|
|
#if WINDOWS
|
|
if (e.PlatformArgs?.PointerRoutedEventArgs is { } winArgs)
|
|
{
|
|
var pt = winArgs.GetCurrentPoint(null);
|
|
isRightOrMiddle = pt.Properties.IsRightButtonPressed || pt.Properties.IsMiddleButtonPressed;
|
|
isShiftOrCtrl = winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Shift) ||
|
|
winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Control);
|
|
isAltPressed = winArgs.KeyModifiers.HasFlag(global::Windows.System.VirtualKeyModifiers.Menu);
|
|
}
|
|
#endif
|
|
|
|
if (_panModeEnabled || isRightOrMiddle || isAltPressed)
|
|
{
|
|
_panning = true;
|
|
_pressNode = null;
|
|
return;
|
|
}
|
|
|
|
// The translation gizmo wins over node hit-testing: grabbing an axis
|
|
// handle moves the active node's selection along that axis.
|
|
_gizmoPart = GizmoPart.None;
|
|
_draggingGizmo = false;
|
|
_drawable.ActiveGizmoPart = GizmoPart.None;
|
|
if (_selected is not null)
|
|
{
|
|
_gizmoPart = HitTestGizmo(position.Value, _selected);
|
|
}
|
|
|
|
if (_gizmoPart != GizmoPart.None)
|
|
{
|
|
_drawable.ActiveGizmoPart = _gizmoPart;
|
|
_pressNode = null;
|
|
_dragStartWorldPositions.Clear();
|
|
var (clickWorldX, clickWorldY) = ToWorld(position.Value);
|
|
_dragStartWorldPoint = new Point(clickWorldX, clickWorldY);
|
|
foreach (var node in _selectedNodes)
|
|
{
|
|
_dragStartWorldPositions[node] = (node.X, node.Y);
|
|
}
|
|
|
|
if (_selected is not null && !_dragStartWorldPositions.ContainsKey(_selected))
|
|
{
|
|
_dragStartWorldPositions[_selected] = (_selected.X, _selected.Y);
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
return;
|
|
}
|
|
|
|
_pressNode = HitTest(position.Value);
|
|
|
|
if (_pressNode is not null)
|
|
{
|
|
if (isShiftOrCtrl)
|
|
{
|
|
if (_selectedNodes.Contains(_pressNode))
|
|
{
|
|
_selectedNodes.Remove(_pressNode);
|
|
_pressNode.IsSelected = false;
|
|
SetActiveNode(_selectedNodes.FirstOrDefault());
|
|
if (_selected is not null)
|
|
{
|
|
PopulateInspector(_selected);
|
|
}
|
|
else
|
|
{
|
|
InspectorScroll.IsVisible = false;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_selectedNodes.Add(_pressNode);
|
|
_pressNode.IsSelected = true;
|
|
SetActiveNode(_pressNode);
|
|
InspectorScroll.IsVisible = true;
|
|
PopulateInspector(_pressNode);
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
}
|
|
else
|
|
{
|
|
if (_selectedNodes.Contains(_pressNode))
|
|
{
|
|
_wasAlreadySelectedInGroup = _selectedNodes.Count > 1;
|
|
SetActiveNode(_pressNode);
|
|
PopulateInspector(_pressNode);
|
|
}
|
|
else
|
|
{
|
|
Select(_pressNode);
|
|
}
|
|
}
|
|
|
|
_dragStartWorldPositions.Clear();
|
|
var (clickWorldX, clickWorldY) = ToWorld(position.Value);
|
|
_dragStartWorldPoint = new Point(clickWorldX, clickWorldY);
|
|
foreach (var node in _selectedNodes)
|
|
{
|
|
_dragStartWorldPositions[node] = (node.X, node.Y);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_marqueeSelecting = true;
|
|
_preMarqueeSelected.Clear();
|
|
if (isShiftOrCtrl)
|
|
{
|
|
foreach (var n in _selectedNodes)
|
|
{
|
|
_preMarqueeSelected.Add(n);
|
|
}
|
|
}
|
|
|
|
_drawable.SelectionBox = null;
|
|
}
|
|
}
|
|
|
|
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
|
{
|
|
var position = e.GetPosition(MapView);
|
|
if (position is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_panning)
|
|
{
|
|
_drawable.OffsetX += position.Value.X - _lastPoint.X;
|
|
_drawable.OffsetY += position.Value.Y - _lastPoint.Y;
|
|
_lastPoint = position.Value;
|
|
MapView.Invalidate();
|
|
return;
|
|
}
|
|
|
|
if (_gizmoPart != GizmoPart.None)
|
|
{
|
|
if (!_draggingGizmo && Distance(position.Value, _pressPoint) > 4)
|
|
{
|
|
_draggingGizmo = true;
|
|
}
|
|
|
|
if (_draggingGizmo)
|
|
{
|
|
var (currentWorldX, currentWorldY) = ToWorld(position.Value);
|
|
var deltaX = currentWorldX - _dragStartWorldPoint.X;
|
|
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
|
|
MoveSelectedNodes(deltaX, deltaY,
|
|
lockX: _gizmoPart == GizmoPart.YAxis,
|
|
lockY: _gizmoPart == GizmoPart.XAxis);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_pressNode is not null)
|
|
{
|
|
if (!_draggingNode && Distance(position.Value, _pressPoint) > 4)
|
|
{
|
|
_draggingNode = true;
|
|
}
|
|
|
|
if (_draggingNode)
|
|
{
|
|
var (currentWorldX, currentWorldY) = ToWorld(position.Value);
|
|
var deltaX = currentWorldX - _dragStartWorldPoint.X;
|
|
var deltaY = currentWorldY - _dragStartWorldPoint.Y;
|
|
|
|
MoveSelectedNodes(deltaX, deltaY, lockX: false, lockY: false);
|
|
}
|
|
}
|
|
else if (_marqueeSelecting)
|
|
{
|
|
var minX = (float)Math.Min(_pressPoint.X, position.Value.X);
|
|
var minY = (float)Math.Min(_pressPoint.Y, position.Value.Y);
|
|
var width = (float)Math.Abs(_pressPoint.X - position.Value.X);
|
|
var height = (float)Math.Abs(_pressPoint.Y - position.Value.Y);
|
|
var rect = new RectF(minX, minY, width, height);
|
|
_drawable.SelectionBox = rect;
|
|
|
|
foreach (var node in _nodes)
|
|
{
|
|
var screenX = (float)(node.X * _drawable.Scale + _drawable.OffsetX);
|
|
var screenY = (float)(node.Y * _drawable.Scale + _drawable.OffsetY);
|
|
var inBox = screenX >= minX && screenX <= minX + width &&
|
|
screenY >= minY && screenY <= minY + height;
|
|
|
|
node.IsSelected = inBox || _preMarqueeSelected.Contains(node);
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
// Hover highlight for the gizmo when idle (no drag, pan, or marquee).
|
|
if (_selected is not null && _pressNode is null && !_marqueeSelecting)
|
|
{
|
|
var hover = HitTestGizmo(position.Value, _selected);
|
|
if (hover != _drawable.ActiveGizmoPart)
|
|
{
|
|
_drawable.ActiveGizmoPart = hover;
|
|
MapView.Invalidate();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void OnPointerReleased(object? sender, PointerEventArgs e)
|
|
{
|
|
var position = e.GetPosition(MapView);
|
|
|
|
if (_gizmoPart != GizmoPart.None)
|
|
{
|
|
var wasDragging = _draggingGizmo;
|
|
_gizmoPart = GizmoPart.None;
|
|
_draggingGizmo = false;
|
|
_drawable.ActiveGizmoPart = GizmoPart.None;
|
|
MapView.Invalidate();
|
|
|
|
if (wasDragging)
|
|
{
|
|
_ = AutoSaveAsync();
|
|
}
|
|
}
|
|
else if (_panning)
|
|
{
|
|
if (_panModeEnabled && position is not null && Distance(position.Value, _pressPoint) <= 4)
|
|
{
|
|
var hit = HitTest(position.Value);
|
|
Select(hit);
|
|
if (hit is not null && _activeFaction != FactionMode.None)
|
|
{
|
|
ApplyFactionToNodes(new[] { hit }, _activeFaction);
|
|
}
|
|
}
|
|
|
|
_panning = false;
|
|
}
|
|
else if (_pressNode is not null)
|
|
{
|
|
var wasDragging = _draggingNode;
|
|
var clickedNode = _pressNode;
|
|
if (!_draggingNode && _wasAlreadySelectedInGroup)
|
|
{
|
|
Select(_pressNode);
|
|
}
|
|
|
|
_draggingNode = false;
|
|
_pressNode = null;
|
|
|
|
if (wasDragging)
|
|
{
|
|
_ = AutoSaveAsync();
|
|
}
|
|
else if (_activeFaction != FactionMode.None)
|
|
{
|
|
ApplyFactionToNodes(new[] { clickedNode }, _activeFaction);
|
|
}
|
|
}
|
|
else if (_marqueeSelecting)
|
|
{
|
|
var isDrag = position is not null && Distance(position.Value, _pressPoint) > 4;
|
|
_drawable.SelectionBox = null;
|
|
_marqueeSelecting = false;
|
|
|
|
if (isDrag)
|
|
{
|
|
var selectedInMarquee = _nodes.Where(static n => n.IsSelected).ToList();
|
|
SetSelection(selectedInMarquee);
|
|
}
|
|
else
|
|
{
|
|
Select(null);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
Select(null);
|
|
}
|
|
|
|
_drawable.SelectionBox = null;
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
// ----- translation gizmo -----
|
|
|
|
/// <summary>
|
|
/// Moves every node captured at drag start by the given world delta.
|
|
/// Used by both free node dragging and gizmo axis-constrained dragging.
|
|
/// </summary>
|
|
private void MoveSelectedNodes(double deltaX, double deltaY, bool lockX, bool lockY)
|
|
{
|
|
foreach (var node in _dragStartWorldPositions.Keys.ToList())
|
|
{
|
|
var startPos = _dragStartWorldPositions[node];
|
|
if (!lockX)
|
|
{
|
|
node.X = startPos.X + deltaX;
|
|
}
|
|
|
|
if (!lockY)
|
|
{
|
|
node.Y = startPos.Y + deltaY;
|
|
}
|
|
|
|
node.Doc.IsDirty = true;
|
|
}
|
|
|
|
UpdateDirtyState();
|
|
SyncPositionEntries();
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Hit-tests the translation gizmo arms of the given node in screen space.
|
|
/// </summary>
|
|
private GizmoPart HitTestGizmo(Point screen, MapNode node)
|
|
{
|
|
var cx = node.X * _drawable.Scale + _drawable.OffsetX;
|
|
var cy = node.Y * _drawable.Scale + _drawable.OffsetY;
|
|
const double arm = MapDrawable.GizmoArmLength;
|
|
const double tolerance = 10;
|
|
|
|
if (Math.Abs(screen.X - cx) <= tolerance && Math.Abs(screen.Y - cy) <= tolerance)
|
|
{
|
|
return GizmoPart.Center;
|
|
}
|
|
|
|
if (DistanceToSegment(screen.X, screen.Y, cx, cy, cx + arm, cy) <= tolerance)
|
|
{
|
|
return GizmoPart.XAxis;
|
|
}
|
|
|
|
if (DistanceToSegment(screen.X, screen.Y, cx, cy, cx, cy + arm) <= tolerance)
|
|
{
|
|
return GizmoPart.YAxis;
|
|
}
|
|
|
|
return GizmoPart.None;
|
|
}
|
|
|
|
private static double DistanceToSegment(double px, double py,
|
|
double x1, double y1, double x2, double y2)
|
|
{
|
|
var dx = x2 - x1;
|
|
var dy = y2 - y1;
|
|
var lenSq = dx * dx + dy * dy;
|
|
var t = lenSq > 0 ? ((px - x1) * dx + (py - y1) * dy) / lenSq : 0;
|
|
t = Math.Clamp(t, 0, 1);
|
|
var qx = x1 + t * dx;
|
|
var qy = y1 + t * dy;
|
|
return Math.Sqrt((px - qx) * (px - qx) + (py - qy) * (py - qy));
|
|
}
|
|
|
|
// ----- selection + inspector -----
|
|
|
|
/// <summary>
|
|
/// Sets the active (inspector) node and keeps the translation gizmo
|
|
/// in sync with it.
|
|
/// </summary>
|
|
private void SetActiveNode(MapNode? node)
|
|
{
|
|
_selected = node;
|
|
_drawable.GizmoNode = node;
|
|
_drawable.ActiveGizmoPart = GizmoPart.None;
|
|
}
|
|
|
|
private void Select(MapNode? node)
|
|
{
|
|
foreach (var n in _nodes)
|
|
{
|
|
n.IsSelected = false;
|
|
}
|
|
|
|
_selectedNodes.Clear();
|
|
SetActiveNode(node);
|
|
|
|
if (_selected is not null)
|
|
{
|
|
_selected.IsSelected = true;
|
|
_selectedNodes.Add(_selected);
|
|
}
|
|
|
|
InspectorScroll.IsVisible = node is not null;
|
|
if (node is not null)
|
|
{
|
|
PopulateInspector(node);
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
private void SetSelection(IEnumerable<MapNode> nodes, MapNode? primary = null)
|
|
{
|
|
foreach (var n in _nodes)
|
|
{
|
|
n.IsSelected = false;
|
|
}
|
|
|
|
_selectedNodes.Clear();
|
|
foreach (var n in nodes)
|
|
{
|
|
n.IsSelected = true;
|
|
_selectedNodes.Add(n);
|
|
}
|
|
|
|
if (_selectedNodes.Count == 0)
|
|
{
|
|
SetActiveNode(null);
|
|
InspectorScroll.IsVisible = false;
|
|
}
|
|
else
|
|
{
|
|
var primaryNode = primary ?? (_selected is not null && _selectedNodes.Contains(_selected)
|
|
? _selected
|
|
: _selectedNodes.First());
|
|
SetActiveNode(primaryNode);
|
|
InspectorScroll.IsVisible = true;
|
|
PopulateInspector(primaryNode);
|
|
if (_selectedNodes.Count > 1)
|
|
{
|
|
StatusLabel.Text = $"{_selectedNodes.Count} systems selected.";
|
|
}
|
|
}
|
|
|
|
MapView.Invalidate();
|
|
}
|
|
|
|
private void OnConnectionSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (_updatingInspector)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (e.CurrentSelection.FirstOrDefault() is string name)
|
|
{
|
|
SelectConnectedSystem(name);
|
|
}
|
|
}
|
|
|
|
private void SelectConnectedSystem(string name)
|
|
{
|
|
if (_byName.TryGetValue(name, out var targetNode))
|
|
{
|
|
Select(targetNode);
|
|
}
|
|
}
|
|
|
|
private void PopulateInspector(MapNode node)
|
|
{
|
|
_updatingInspector = true;
|
|
try
|
|
{
|
|
var doc = node.Doc;
|
|
NameLabel.Text = doc.Name;
|
|
CategoryLabel.Text = string.IsNullOrEmpty(doc.SystemCategory) ? "—" : doc.SystemCategory;
|
|
var owner = doc.Owner;
|
|
OwnerLabel.Text = string.IsNullOrEmpty(owner) ? "—" : owner;
|
|
var ownerColor = Color.FromArgb(StarSystemOwners.GetColor(owner));
|
|
OwnerLabel.TextColor = string.IsNullOrEmpty(owner) ? Color.FromArgb("#94a3b8") : ownerColor;
|
|
OwnerBadge.Stroke = string.IsNullOrEmpty(owner) ? Color.FromArgb("#334155") : ownerColor;
|
|
OwnerBadge.BackgroundColor = string.IsNullOrEmpty(owner)
|
|
? Color.FromArgb("#1e293b")
|
|
: ownerColor.WithAlpha(0.15f);
|
|
YieldsLabel.Text = YieldText(doc);
|
|
BuffLabel.Text = doc.OccupationBuff ?? "—";
|
|
MasteryLabel.Text = doc.SystemMastery ?? "—";
|
|
XEntry.Text = doc.X.ToString("F1", CultureInfo.InvariantCulture);
|
|
YEntry.Text = doc.Y.ToString("F1", CultureInfo.InvariantCulture);
|
|
ConnectionsView.SelectedItem = null;
|
|
ConnectionsView.ItemsSource = doc.ConnectedSystems.ToList();
|
|
}
|
|
finally
|
|
{
|
|
_updatingInspector = false;
|
|
}
|
|
}
|
|
|
|
private void SyncPositionEntries()
|
|
{
|
|
if (_selected is null || _selected != _pressNode)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_updatingInspector = true;
|
|
try
|
|
{
|
|
XEntry.Text = _selected.X.ToString("F1", CultureInfo.InvariantCulture);
|
|
YEntry.Text = _selected.Y.ToString("F1", CultureInfo.InvariantCulture);
|
|
}
|
|
finally
|
|
{
|
|
_updatingInspector = false;
|
|
}
|
|
}
|
|
|
|
private void OnPositionTextChanged(object? sender, TextChangedEventArgs e)
|
|
{
|
|
if (_updatingInspector || _selected is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (double.TryParse(XEntry.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var x) &&
|
|
double.TryParse(YEntry.Text, NumberStyles.Float, CultureInfo.InvariantCulture, out var y))
|
|
{
|
|
if (_selected.X != x || _selected.Y != y)
|
|
{
|
|
_selected.X = x;
|
|
_selected.Y = y;
|
|
_selected.Doc.IsDirty = true;
|
|
UpdateDirtyState();
|
|
MapView.Invalidate();
|
|
ScheduleAutoSave();
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ScheduleAutoSave(int delayMs = 400)
|
|
{
|
|
_autoSaveCts?.Cancel();
|
|
_autoSaveCts?.Dispose();
|
|
_autoSaveCts = new CancellationTokenSource();
|
|
var token = _autoSaveCts.Token;
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
await Task.Delay(delayMs, token);
|
|
if (!token.IsCancellationRequested)
|
|
{
|
|
await MainThread.InvokeOnMainThreadAsync(() => AutoSaveAsync());
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Debounce cancelled by a newer edit or immediate save
|
|
}
|
|
}, token);
|
|
}
|
|
|
|
private static string YieldText(StarSystemDoc doc)
|
|
{
|
|
var yields = new List<string>();
|
|
if (doc.HigherInfluenceYield)
|
|
{
|
|
yields.Add("Influence");
|
|
}
|
|
|
|
if (doc.HigherCreditYield)
|
|
{
|
|
yields.Add("Credits");
|
|
}
|
|
|
|
if (doc.HigherEnergyYield)
|
|
{
|
|
yields.Add("Energy");
|
|
}
|
|
|
|
if (doc.HigherChanceForItems)
|
|
{
|
|
yields.Add("Items");
|
|
}
|
|
|
|
return yields.Count == 0 ? "Standard" : "Higher " + string.Join(", ", yields);
|
|
}
|
|
|
|
// ----- folder + save -----
|
|
|
|
private void TryAutoLoad()
|
|
{
|
|
// Dev-repo layout: walk up from the app directory looking for zs.docs/StarSystem.
|
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
|
for (var i = 0; i < 12 && directory is not null; i++, directory = directory.Parent)
|
|
{
|
|
var candidate = Path.Combine(directory.FullName, "zs.docs", "StarSystem");
|
|
if (Directory.Exists(candidate))
|
|
{
|
|
LoadFolder(candidate);
|
|
return;
|
|
}
|
|
}
|
|
|
|
var remembered = Preferences.Get("MapEditorFolder", string.Empty);
|
|
if (!string.IsNullOrEmpty(remembered) && Directory.Exists(remembered))
|
|
{
|
|
LoadFolder(remembered);
|
|
}
|
|
}
|
|
|
|
private void LoadFolder(string folder)
|
|
{
|
|
var docs = StarSystemDocStore.LoadFolder(folder);
|
|
_folder = folder;
|
|
_nodes = docs.Select(static doc => new MapNode(doc)).ToList();
|
|
_byName = _nodes.ToDictionary(static node => node.Doc.Name, StringComparer.OrdinalIgnoreCase);
|
|
_drawable.Nodes = _nodes;
|
|
Select(null);
|
|
_viewFitted = false;
|
|
FitView();
|
|
Preferences.Set("MapEditorFolder", folder);
|
|
|
|
if (!string.IsNullOrWhiteSpace(SearchControl.Text))
|
|
{
|
|
ApplySearch(SearchControl.Text);
|
|
}
|
|
else
|
|
{
|
|
StatusLabel.Text = $"{_nodes.Count} systems loaded from {folder}.";
|
|
}
|
|
|
|
var dirtyCount = _nodes.Count(static node => node.Doc.IsDirty);
|
|
if (dirtyCount > 0)
|
|
{
|
|
_ = AutoSaveAsync();
|
|
}
|
|
else
|
|
{
|
|
UpdateDirtyState();
|
|
}
|
|
}
|
|
|
|
private async void OnOpenFolderClicked(object? sender, EventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var result = await FolderPicker.Default.PickAsync();
|
|
if (result?.Folder is not null && Directory.Exists(result.Folder.Path))
|
|
{
|
|
LoadFolder(result.Folder.Path);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
StatusLabel.Text = $"Could not open folder: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private void OnSaveClicked(object? sender, EventArgs e)
|
|
{
|
|
_ = AutoSaveAsync();
|
|
}
|
|
|
|
private async Task AutoSaveAsync()
|
|
{
|
|
_autoSaveCts?.Cancel();
|
|
_autoSaveCts?.Dispose();
|
|
_autoSaveCts = null;
|
|
|
|
var dirtyNodes = _nodes.Where(static n => n.Doc.IsDirty).ToList();
|
|
if (dirtyNodes.Count == 0)
|
|
{
|
|
SaveButton.Text = "Saved";
|
|
SaveButton.IsEnabled = false;
|
|
return;
|
|
}
|
|
|
|
SaveButton.Text = "Saving...";
|
|
SaveButton.IsEnabled = false;
|
|
|
|
await _saveLock.WaitAsync();
|
|
try
|
|
{
|
|
var docsToSave = _nodes.Where(static n => n.Doc.IsDirty).Select(static n => n.Doc).ToList();
|
|
if (docsToSave.Count == 0)
|
|
{
|
|
SaveButton.Text = "Saved";
|
|
SaveButton.IsEnabled = false;
|
|
return;
|
|
}
|
|
|
|
var savedCount = await Task.Run(() => StarSystemDocStore.SavePositions(docsToSave));
|
|
|
|
var remainingDirty = _nodes.Count(static n => n.Doc.IsDirty);
|
|
if (remainingDirty > 0)
|
|
{
|
|
SaveButton.Text = "Save";
|
|
SaveButton.IsEnabled = true;
|
|
StatusLabel.Text =
|
|
$"{remainingDirty} unsaved change{(remainingDirty == 1 ? string.Empty : "s")}.";
|
|
}
|
|
else
|
|
{
|
|
SaveButton.Text = "Saved";
|
|
SaveButton.IsEnabled = false;
|
|
StatusLabel.Text = savedCount == 1
|
|
? "Auto-saved 1 system."
|
|
: $"Auto-saved {savedCount} systems.";
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var remainingDirty = _nodes.Count(static n => n.Doc.IsDirty);
|
|
SaveButton.Text = "Save";
|
|
SaveButton.IsEnabled = remainingDirty > 0;
|
|
StatusLabel.Text = $"Auto-save failed: {ex.Message}";
|
|
}
|
|
finally
|
|
{
|
|
_saveLock.Release();
|
|
}
|
|
}
|
|
|
|
private void UpdateDirtyState()
|
|
{
|
|
var dirty = _nodes.Count(static node => node.Doc.IsDirty);
|
|
if (dirty > 0)
|
|
{
|
|
SaveButton.Text = "Save";
|
|
SaveButton.IsEnabled = true;
|
|
StatusLabel.Text = $"{dirty} unsaved change{(dirty == 1 ? string.Empty : "s")}.";
|
|
}
|
|
else
|
|
{
|
|
SaveButton.Text = "Saved";
|
|
SaveButton.IsEnabled = false;
|
|
}
|
|
}
|
|
} |