From 190c12537c12c40fa0ba548da49377ac4a3eb848 Mon Sep 17 00:00:00 2001 From: Jonathan McCaffrey Date: Sun, 27 Sep 2026 15:08:57 +0000 Subject: [PATCH] AI: Add .NET MAUI star map position editor --- .../Views/MapEditorPage.xaml.cs | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs diff --git a/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs b/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs new file mode 100644 index 0000000..b84613a --- /dev/null +++ b/ZeroSpace/ZeroSpace.MapEditor/Views/MapEditorPage.xaml.cs @@ -0,0 +1,366 @@ +using System.Globalization; +using Microsoft.Maui.Storage; +using ZeroSpace.MapEditor.Models; +using ZeroSpace.MapEditor.Services; + +namespace ZeroSpace.MapEditor.Views; + +public partial class MapEditorPage : ContentPage +{ + private readonly MapDrawable _drawable = new(); + private List _nodes = new(); + private Dictionary _byName = new(StringComparer.OrdinalIgnoreCase); + private MapNode? _selected; + private MapNode? _pressNode; + private Point _pressPoint; + private Point _lastPoint; + private bool _draggingNode; + private bool _panning; + private bool _viewFitted; + private bool _updatingInspector; + private string? _folder; + + public MapEditorPage() + { + InitializeComponent(); + _drawable.FindNode = name => _byName.TryGetValue(name, out var node) ? node : null; + MapView.Drawable = _drawable; + MapView.SizeChanged += OnMapViewSizeChanged; + TryAutoLoad(); + } + + // ----- 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; + const double world = StarSystemDocStore.WorldSize; + _drawable.Scale = Math.Min((width - margin * 2) / world, (height - margin * 2) / world); + _drawable.OffsetX = width / 2 - world / 2 * _drawable.Scale; + _drawable.OffsetY = height / 2 - world / 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(); + } + + // ----- 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; + _pressNode = HitTest(position.Value); + _draggingNode = false; + _panning = _pressNode is null; + } + + private void OnPointerMoved(object? sender, PointerEventArgs e) + { + var position = e.GetPosition(MapView); + if (position is null) + { + return; + } + + if (_pressNode is not null) + { + if (!_draggingNode && Distance(position.Value, _pressPoint) > 4) + { + _draggingNode = true; + } + + if (_draggingNode) + { + var (worldX, worldY) = ToWorld(position.Value); + _pressNode.X = Math.Clamp(worldX, 0, StarSystemDocStore.WorldSize); + _pressNode.Y = Math.Clamp(worldY, 0, StarSystemDocStore.WorldSize); + _pressNode.Doc.IsDirty = true; + UpdateDirtyState(); + SyncPositionEntries(); + MapView.Invalidate(); + } + } + else if (_panning) + { + _drawable.OffsetX += position.Value.X - _lastPoint.X; + _drawable.OffsetY += position.Value.Y - _lastPoint.Y; + _lastPoint = position.Value; + MapView.Invalidate(); + } + } + + private void OnPointerReleased(object? sender, PointerEventArgs e) + { + var position = e.GetPosition(MapView); + + if (_pressNode is not null && !_draggingNode) + { + Select(_pressNode); + } + else if (_pressNode is null && _panning && + position is not null && Distance(position.Value, _pressPoint) <= 4) + { + Select(null); + } + + _pressNode = null; + _draggingNode = false; + _panning = false; + } + + // ----- selection + inspector ----- + + private void Select(MapNode? node) + { + if (_selected is not null) + { + _selected.IsSelected = false; + } + + _selected = node; + + if (_selected is not null) + { + _selected.IsSelected = true; + } + + InspectorScroll.IsVisible = node is not null; + if (node is not null) + { + PopulateInspector(node); + } + + MapView.Invalidate(); + } + + private void PopulateInspector(MapNode node) + { + _updatingInspector = true; + try + { + var doc = node.Doc; + NameLabel.Text = doc.Name; + KindLabel.Text = string.IsNullOrEmpty(doc.Kind) ? "—" : doc.Kind; + 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.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)) + { + _selected.X = Math.Clamp(x, 0, StarSystemDocStore.WorldSize); + _selected.Y = Math.Clamp(y, 0, StarSystemDocStore.WorldSize); + _selected.Doc.IsDirty = true; + UpdateDirtyState(); + MapView.Invalidate(); + } + } + + private static string YieldText(StarSystemDoc doc) + { + var yields = new List(); + 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); + UpdateDirtyState(); + StatusLabel.Text = $"{_nodes.Count} systems loaded from {folder}."; + } + + 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) + { + try + { + StarSystemDocStore.SavePositions(_nodes.Select(static node => node.Doc)); + UpdateDirtyState(); + StatusLabel.Text = $"Saved positions to {_folder}."; + } + catch (Exception ex) + { + StatusLabel.Text = $"Save failed: {ex.Message}"; + } + } + + private void UpdateDirtyState() + { + var dirty = _nodes.Count(static node => node.Doc.IsDirty); + SaveButton.IsEnabled = dirty > 0; + if (dirty > 0) + { + StatusLabel.Text = $"{dirty} unsaved position change{(dirty == 1 ? string.Empty : "s")}."; + } + } +}