Io/zerospace converter #1
@@ -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<MapNode> _nodes = new();
|
||||
private Dictionary<string, MapNode> _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<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);
|
||||
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")}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user