AI: Add .NET MAUI star map position editor
This commit is contained in:
@@ -0,0 +1,261 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace ZeroSpace.MapEditor.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One StarSystem markdown document: the editable map data plus the
|
||||||
|
/// read-only details shown in the inspector. Has no MAUI dependencies
|
||||||
|
/// so it can be tested outside the app.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StarSystemDoc
|
||||||
|
{
|
||||||
|
public string FilePath { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Kind { get; set; } = string.Empty;
|
||||||
|
public bool HigherInfluenceYield { get; set; }
|
||||||
|
public bool HigherCreditYield { get; set; }
|
||||||
|
public bool HigherEnergyYield { get; set; }
|
||||||
|
public bool HigherChanceForItems { get; set; }
|
||||||
|
public string? OccupationBuff { get; set; }
|
||||||
|
public string? SystemMastery { get; set; }
|
||||||
|
public List<string> ConnectedSystems { get; } = new();
|
||||||
|
public double X { get; set; }
|
||||||
|
public double Y { get; set; }
|
||||||
|
public bool HasPosition { get; set; }
|
||||||
|
public bool IsDirty { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class StarSystemDocStore
|
||||||
|
{
|
||||||
|
/// <summary>World coordinate space used in the markdown files.</summary>
|
||||||
|
public const double WorldSize = 1000;
|
||||||
|
|
||||||
|
public static List<StarSystemDoc> LoadFolder(string folder)
|
||||||
|
{
|
||||||
|
var docs = new List<StarSystemDoc>();
|
||||||
|
foreach (var path in Directory.GetFiles(folder, "*.md").OrderBy(static p => p, StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var doc = LoadFile(path);
|
||||||
|
if (doc is not null)
|
||||||
|
{
|
||||||
|
docs.Add(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any file missing x/y gets a deterministic spread-out placeholder.
|
||||||
|
var missing = docs.Where(static d => !d.HasPosition).ToList();
|
||||||
|
for (var i = 0; i < missing.Count; i++)
|
||||||
|
{
|
||||||
|
var (x, y) = DefaultPosition(i, missing.Count);
|
||||||
|
missing[i].X = x;
|
||||||
|
missing[i].Y = y;
|
||||||
|
missing[i].IsDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static StarSystemDoc? LoadFile(string path)
|
||||||
|
{
|
||||||
|
var lines = File.ReadAllLines(path);
|
||||||
|
if (lines.Length < 2 || lines[0].Trim() != "---")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var values = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var scalars = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
string? listKey = null;
|
||||||
|
var closed = false;
|
||||||
|
|
||||||
|
for (var i = 1; i < lines.Length; i++)
|
||||||
|
{
|
||||||
|
var line = lines[i];
|
||||||
|
if (line.Trim() == "---")
|
||||||
|
{
|
||||||
|
closed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listKey is not null && line.StartsWith(" - ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
values[listKey].Add(CleanValue(line[4..]));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
listKey = null;
|
||||||
|
var separator = line.IndexOf(':');
|
||||||
|
if (separator < 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var key = line[..separator].Trim();
|
||||||
|
var raw = line[(separator + 1)..].Trim();
|
||||||
|
if (raw.Length == 0)
|
||||||
|
{
|
||||||
|
values[key] = new List<string>();
|
||||||
|
listKey = key;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
scalars[key] = CleanValue(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!closed)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!scalars.TryGetValue("category", out var category) ||
|
||||||
|
!string.Equals(category, "StarSystem", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var doc = new StarSystemDoc
|
||||||
|
{
|
||||||
|
FilePath = path,
|
||||||
|
Name = Path.GetFileNameWithoutExtension(path),
|
||||||
|
OccupationBuff = scalars.TryGetValue("occupationBuff", out var buff) ? buff : null,
|
||||||
|
SystemMastery = scalars.TryGetValue("systemMastery", out var mastery) ? mastery : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (IsTrue(scalars, "isSideSystem")) doc.Kind = "Side";
|
||||||
|
if (IsTrue(scalars, "isHomeworldSystem")) doc.Kind = "Homeworld";
|
||||||
|
if (IsTrue(scalars, "isCoreSystem")) doc.Kind = "Core";
|
||||||
|
|
||||||
|
doc.HigherInfluenceYield = IsTrue(scalars, "higherInfluenceYield");
|
||||||
|
doc.HigherCreditYield = IsTrue(scalars, "higherCreditYield");
|
||||||
|
doc.HigherEnergyYield = IsTrue(scalars, "higherEnergyYield");
|
||||||
|
doc.HigherChanceForItems = IsTrue(scalars, "higherChanceForItems");
|
||||||
|
|
||||||
|
if (values.TryGetValue("connectedSystems", out var connected))
|
||||||
|
{
|
||||||
|
doc.ConnectedSystems.AddRange(connected.Where(static c => !string.IsNullOrWhiteSpace(c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scalars.TryGetValue("x", out var xRaw) && TryParseDouble(xRaw, out var x) &&
|
||||||
|
scalars.TryGetValue("y", out var yRaw) && TryParseDouble(yRaw, out var y))
|
||||||
|
{
|
||||||
|
doc.X = x;
|
||||||
|
doc.Y = y;
|
||||||
|
doc.HasPosition = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes x/y back into each dirty file's frontmatter, preserving
|
||||||
|
/// every other line byte-for-byte.
|
||||||
|
/// </summary>
|
||||||
|
public static void SavePositions(IEnumerable<StarSystemDoc> docs)
|
||||||
|
{
|
||||||
|
foreach (var doc in docs.Where(static d => d.IsDirty))
|
||||||
|
{
|
||||||
|
SavePosition(doc);
|
||||||
|
doc.IsDirty = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SavePosition(StarSystemDoc doc)
|
||||||
|
{
|
||||||
|
var text = File.ReadAllText(doc.FilePath);
|
||||||
|
var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n";
|
||||||
|
var lines = text.Split(new[] { "\r\n", "\n" }, StringSplitOptions.None).ToList();
|
||||||
|
if (lines.Count < 2 || lines[0].Trim() != "---")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var closeIndex = lines.FindIndex(1, static line => line.Trim() == "---");
|
||||||
|
if (closeIndex < 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xLine = $"x: {doc.X.ToString("F1", CultureInfo.InvariantCulture)}";
|
||||||
|
var yLine = $"y: {doc.Y.ToString("F1", CultureInfo.InvariantCulture)}";
|
||||||
|
var foundX = false;
|
||||||
|
var foundY = false;
|
||||||
|
|
||||||
|
for (var i = 1; i < closeIndex; i++)
|
||||||
|
{
|
||||||
|
var key = KeyOf(lines[i]);
|
||||||
|
if (string.Equals(key, "x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines[i] = xLine;
|
||||||
|
foundX = true;
|
||||||
|
}
|
||||||
|
else if (string.Equals(key, "y", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
lines[i] = yLine;
|
||||||
|
foundY = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundX)
|
||||||
|
{
|
||||||
|
lines.Insert(closeIndex, xLine);
|
||||||
|
closeIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundY)
|
||||||
|
{
|
||||||
|
lines.Insert(closeIndex, yLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(doc.FilePath, string.Join(newline, lines));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic phyllotaxis (sunflower spiral) spread across the
|
||||||
|
/// world space, so placeholder nodes never cover each other.
|
||||||
|
/// </summary>
|
||||||
|
public static (double X, double Y) DefaultPosition(int index, int count)
|
||||||
|
{
|
||||||
|
var goldenAngle = Math.PI * (3 - Math.Sqrt(5));
|
||||||
|
var radius = (WorldSize / 2 - 30) * Math.Sqrt(index / (double)Math.Max(count, 1));
|
||||||
|
var theta = index * goldenAngle;
|
||||||
|
return (WorldSize / 2 + radius * Math.Cos(theta),
|
||||||
|
WorldSize / 2 + radius * Math.Sin(theta));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? KeyOf(string line)
|
||||||
|
{
|
||||||
|
var separator = line.IndexOf(':');
|
||||||
|
return separator < 0 ? null : line[..separator].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsTrue(Dictionary<string, string> scalars, string key)
|
||||||
|
{
|
||||||
|
return scalars.TryGetValue(key, out var value) &&
|
||||||
|
string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseDouble(string raw, out double value)
|
||||||
|
{
|
||||||
|
return double.TryParse(raw.Trim('"', '\'', ' '),
|
||||||
|
NumberStyles.Float | NumberStyles.AllowThousands,
|
||||||
|
CultureInfo.InvariantCulture, out value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string CleanValue(string value)
|
||||||
|
{
|
||||||
|
value = value.Trim();
|
||||||
|
|
||||||
|
if (value.Length >= 2 && value[0] == '"' && value[^1] == '"')
|
||||||
|
{
|
||||||
|
value = value[1..^1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.StartsWith("[[", StringComparison.Ordinal) && value.EndsWith("]]", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
value = value[2..^2];
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user