champions-of-luneraria/Assets/Luneraria/Core/Simulation/LuneMap.cs
Martin Stickelbroeck 4d6ed17dca U1: Regelkern in C#, verhaltensgleich zur Godot-Referenz
Vollständiger, engineunabhängiger Regelkern (Luneraria.Core, ohne
UnityEngine): Partie mit sechs Plätzen und zwei Teams, private Planung mit
atomarer Übernahme, Gold, Käufe, Aufwertungen, Formation, drei Lanes mit
zwei Laneturmen und zwei Basistürmen je Team, Ancients, unbegrenzte Wellen,
Helden mit Stufen, Items und Talenten, Angriffschampions, Bossgrotte mit
Anspruch, Entsendung, Kopfgeld und dreirundiger Sperre, Bots und
Siegbedingung (nur Ancient-Fall). Serverautoritative Befehlsschnittstelle.

- Deterministisch: fester Takt, geordnete Maps, fortlaufende IDs, kein
  Zufall, keine Uhr, keine Threads
- Tabellen einmal aus Godot erzeugt (tools/port/gen_content.py)
- Golden-Referenzen aus Godot (tools/godot-golden): 19 Szenarien,
  120 Snapshots, 51.221 Werte, Abweichung 0 (Toleranzen dokumentiert)
- Tests: content 75, core 91, m2 73, golden 157, determinism 6 = 402/402
- Leistung (M5): Simulationsschritt 0,259 ms statt 1,587 ms in GDScript
- Referenzfehler R1 im Bot-Gedächtnis bewusst übernommen und dokumentiert
- Geplante Helden (Lucy, Ghaliga, Siegfried, Feena) im Datenmodell, inaktiv
- Doku unter docs/port (U1-RULES, PORT-STATUS, PORT-QA, U2 bis U5)

Unity-Editor-Lauf offen: Die Unity-Lizenz ist noch nicht aktiviert; gebaut
und getestet mit dem .NET 8 SDK auf Unitys Sprachstand (C# 9, .NET
Standard 2.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:31:18 +02:00

345 lines
15 KiB
C#

using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>Static data of one building (18 per match).</summary>
public sealed class BuildingDef
{
public string Id = "";
public int Team;
/// <summary>top, mid, bot, or base for base towers and Ancients.</summary>
public string Lane = "";
/// <summary>outer (Grenzwacht), inner (Bastion), base (Kernwacht) or core (Ancient).</summary>
public string Tier = "";
/// <summary>Lane progress from the own base; -1 for base buildings.</summary>
public double Progress;
public Vec2 Position;
public double MaxHp;
public double Damage;
public double Range;
public int Bounty;
}
/// <summary>
/// Lane polyline with cumulative lengths. Points are float vectors; the cumulative table is
/// stored as float and the total length as double, as in the reference.
/// </summary>
public sealed class LanePath
{
public readonly Vec2[] Points;
public readonly float[] Cumulative;
public readonly double Length;
public LanePath(List<Vec2> source)
{
Points = source.ToArray();
Cumulative = new float[Points.Length];
Cumulative[0] = 0f;
double length = 0.0;
for (int i = 1; i < Points.Length; i++)
{
length += Points[i].DistanceTo(Points[i - 1]);
Cumulative[i] = (float)length;
}
Length = length;
}
/// <summary>Index i of the segment [i - 1, i] that contains lane coordinate d.</summary>
int Segment(double d)
{
int low = 1;
int high = Points.Length - 1;
while (low < high)
{
int middle = (low + high) >> 1;
if (d <= Cumulative[middle])
high = middle;
else
low = middle + 1;
}
return low;
}
public Vec2 PointAt(double s)
{
double d = GdMath.Clamp(s, 0.0, Length);
int i = Segment(d);
double span = GdMath.Max(0.0001, (double)Cumulative[i] - (double)Cumulative[i - 1]);
return Points[i - 1].Lerp(Points[i], GdMath.Clamp((d - Cumulative[i - 1]) / span, 0.0, 1.0));
}
public Vec2 TangentAt(double s)
{
int i = Segment(GdMath.Clamp(s, 0.0, Length));
return (Points[i] - Points[i - 1]).Normalized();
}
/// <summary>Point and unnormalised tangent (b - a) / span at s in one pass.</summary>
public void Sample(double s, out Vec2 point, out Vec2 tangent)
{
double d = GdMath.Clamp(s, 0.0, Length);
int i = Segment(d);
Vec2 a = Points[i - 1];
Vec2 b = Points[i];
double span = GdMath.Max(0.0001, (double)Cumulative[i] - (double)Cumulative[i - 1]);
point = a.Lerp(b, GdMath.Clamp((d - Cumulative[i - 1]) / span, 0.0, 1.0));
tangent = (b - a) / span;
}
/// <summary>Lane coordinate of the closest point on the polyline.</summary>
public double Project(Vec2 position)
{
double best = double.PositiveInfinity;
double bestS = 0.0;
for (int i = 1; i < Points.Length; i++)
{
Vec2 a = Points[i - 1];
Vec2 b = Points[i];
Vec2 ab = b - a;
double t = GdMath.Clamp((double)(position - a).Dot(ab) / GdMath.Max(0.0001, (double)ab.LengthSquared()), 0.0, 1.0);
double d = position.DistanceSquaredTo(a + ab * t);
if (d < best)
{
best = d;
bestS = (double)Cumulative[i - 1] + (double)ab.Length() * t;
}
}
return bestS;
}
}
/// <summary>
/// Map in ground coordinates (x, z): Blau (team 0) in the south-west, Rot (team 1) in the
/// north-east, Rot the mirror image of Blau across x = z. Every lane is built from its Blau
/// half and that half mirrored, so lanes, towers and spawns are equal by construction
/// (reference: lune_map.gd, G1 layout). Top and Bottom have exactly the same length.
/// </summary>
public sealed class LuneMap
{
public static readonly string[] Lanes = { "top", "mid", "bot" };
public static readonly Dictionary<string, string> LaneNames = new Dictionary<string, string> { { "top", "Top" }, { "mid", "Mid" }, { "bot", "Bottom" } };
/// <summary>Catmull-Rom control points of each Blau half, base to symmetry axis.</summary>
public static readonly Vec2[] TopControl = { new Vec2(-27.4, 21.0), new Vec2(-26.4, 13.0), new Vec2(-28.4, 4.0), new Vec2(-26.2, -5.0),
new Vec2(-28.6, -13.5), new Vec2(-28.4, -21.2), new Vec2(-25.0, -25.0) };
public static readonly Vec2[] MidControl = { new Vec2(-21.6, 21.6), new Vec2(-16.4, 17.2), new Vec2(-10.9, 11.5), new Vec2(-5.6, 5.9), new Vec2(0.0, 0.0) };
public static readonly Vec2 CoreBlue = new Vec2(-30.0, 30.0);
/// <summary>Two base towers per Ancient, 6 m out and 40° either side of the Mid direction.</summary>
public static readonly Vec2[] BaseTowersBlue = { new Vec2(-29.477, 24.023), new Vec2(-24.023, 29.477) };
/// <summary>Neutral boss grotto on the symmetry axis between Top and Mid.</summary>
public static readonly Vec2 Grotto = new Vec2(-13.5, -13.5);
public const double HalfExtent = 36.0;
public static readonly Vec2 Axis = new Vec2(0.70710678, 0.70710678);
public static readonly Vec2 BlueSide = new Vec2(-0.70710678, 0.70710678);
public const double SampleSpacing = 2.0;
public const string GrottoZone = "grotto";
public readonly LuneBalance Balance;
public readonly Dictionary<string, LanePath> LanePaths = new Dictionary<string, LanePath>();
public readonly List<BuildingDef> Buildings = new List<BuildingDef>();
readonly Dictionary<string, BuildingDef> _index = new Dictionary<string, BuildingDef>();
public LuneMap(LuneBalance balance = null)
{
Balance = balance ?? LuneBalance.Default();
LanePaths["top"] = new LanePath(FullLane(SampleHalf(TopControl)));
LanePaths["mid"] = new LanePath(FullLane(SampleHalf(MidControl)));
LanePaths["bot"] = new LanePath(SolveBottom(LanePaths["top"].Length));
BuildBuildings();
}
public static int LaneIndex(string lane)
{
return System.Array.IndexOf(Lanes, lane);
}
public static Vec2 Mirror(Vec2 v) { return new Vec2(v.Y, v.X); }
public static Vec2 MirrorMid(Vec2 v) { return new Vec2(-v.Y, -v.X); }
public static List<Vec2> FullLane(List<Vec2> half)
{
var result = new List<Vec2>(half);
for (int i = half.Count - 2; i >= 0; i--)
result.Add(Mirror(half[i]));
return result;
}
/// <summary>
/// Samples the Catmull-Rom curve through the control points of a Blau half. The phantom
/// point after the axis point mirrors the one before it, so the joined lane has no kink.
/// </summary>
public static List<Vec2> SampleHalf(IList<Vec2> control)
{
var ext = new List<Vec2> { control[0] * 2.0 - control[1] };
ext.AddRange(control);
ext.Add(Mirror(control[control.Count - 2]));
var result = new List<Vec2>();
for (int i = 1; i < ext.Count - 2; i++)
{
Vec2 p0 = ext[i - 1];
Vec2 p1 = ext[i];
Vec2 p2 = ext[i + 1];
Vec2 p3 = ext[i + 2];
int steps = GdMath.Max(2, GdMath.CeilI(p1.DistanceTo(p2) / SampleSpacing));
for (int k = 0; k < steps; k++)
{
double t = (double)k / steps;
double t2 = t * t;
double t3 = t2 * t;
result.Add(0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3));
}
}
result.Add(control[control.Count - 1]);
return result;
}
/// <summary>Bottom half through the south-eastern lowland; bend pulls the meander inwards.</summary>
public static Vec2[] BottomControl(double bend)
{
return new[] { new Vec2(-21.0, 27.4), new Vec2(-12.5, 27.0), new Vec2(-4.0, 27.6), new Vec2(4.0, 27.0 - bend),
new Vec2(12.0, 27.4 - 0.6 * bend), new Vec2(19.5, 27.0), new Vec2(22.2, 27.8), new Vec2(25.0, 25.0) };
}
/// <summary>Bisection on the meander so Bottom is exactly as long as Top.</summary>
public static List<Vec2> SolveBottom(double targetLength)
{
double low = 0.0;
double high = 8.0;
for (int i = 0; i < 60; i++)
{
double bend = (low + high) * 0.5;
if (new LanePath(FullLane(SampleHalf(BottomControl(bend)))).Length < targetLength)
low = bend;
else
high = bend;
}
return FullLane(SampleHalf(BottomControl((low + high) * 0.5)));
}
/// <summary>Lane coordinate s for a team's own marching progress (0 at its own base).</summary>
public double SFor(int team, string lane, double progress)
{
return team == 0 ? progress : LanePaths[lane].Length - progress;
}
public double ProgressFor(int team, string lane, double s)
{
return team == 0 ? s : LanePaths[lane].Length - s;
}
/// <summary>Blau frame point, mirrored for Rot so both sides are exact images.</summary>
public Vec2 LanePoint(int team, string lane, double progress, double lateral = 0.0)
{
Vec2 point;
Vec2 tangent;
LanePaths[lane].Sample(progress, out point, out tangent);
Vec2 blue = point + new Vec2(-tangent.Y, tangent.X) * lateral;
return team == 0 ? blue : new Vec2(blue.Y, blue.X);
}
public Vec2 LaneForward(int team, string lane, double progress)
{
LanePath path = LanePaths[lane];
Vec2 tangent = path.TangentAt(GdMath.Clamp(progress, 0.0, path.Length));
return team == 0 ? tangent : Mirror(tangent);
}
/// <summary>Grotto entrance of a team (mirrored); the boss rests slightly inside.</summary>
public Vec2 GrottoEntry(int team)
{
Vec2 blue = Grotto + new Vec2(1.2, 4.6);
return team == 0 ? blue : Mirror(blue);
}
public Vec2 GrottoBossPoint() { return Grotto + new Vec2(-1.5, -1.5); }
public Vec2 GrottoCenter() { return Grotto; }
/// <summary>Army formation area on a lane: progress from/to and lateral half width.</summary>
public static void DeploymentSpan(out double from, out double to, out double halfWidth)
{
from = 0.4;
to = 7.2;
halfWidth = 2.4;
}
public Vec2 CorePosition(int team) { return team == 0 ? CoreBlue : Mirror(CoreBlue); }
void BuildBuildings()
{
Buildings.Clear();
_index.Clear();
for (int team = 0; team < 2; team++)
{
string prefix = team == 0 ? "blau" : "rot";
foreach (string lane in Lanes)
{
LanePath path = LanePaths[lane];
foreach (string tier in new[] { "inner", "outer" })
{
double fraction = tier == "inner" ? Balance.InnerTowerFraction : Balance.OuterTowerFraction;
double progress = path.Length * fraction;
Add(new BuildingDef { Id = prefix + "_" + lane + "_" + tier, Team = team, Lane = lane, Tier = tier, Progress = progress,
Position = LanePoint(team, lane, progress, Balance.TowerSideOffset),
MaxHp = tier == "inner" ? Balance.TowerInnerHp : Balance.TowerOuterHp, Damage = Balance.TowerDamage, Range = Balance.TowerRange,
Bounty = tier == "inner" ? Balance.BountyInnerTower : Balance.BountyOuterTower });
}
}
for (int index = 0; index < 2; index++)
{
Vec2 point = BaseTowersBlue[index];
Add(new BuildingDef { Id = prefix + "_base_" + (index + 1), Team = team, Lane = "base", Tier = "base", Progress = -1.0,
Position = team == 0 ? point : Mirror(point), MaxHp = Balance.BaseTowerHp, Damage = Balance.BaseTowerDamage,
Range = Balance.TowerRange, Bounty = Balance.BountyBaseTower });
}
Add(new BuildingDef { Id = prefix + "_ancient", Team = team, Lane = "base", Tier = "core", Progress = -1.0,
Position = CorePosition(team), MaxHp = Balance.CoreHp, Damage = 0.0, Range = 0.0, Bounty = 0 });
}
}
void Add(BuildingDef definition)
{
Buildings.Add(definition);
_index[definition.Id] = definition;
}
public BuildingDef BuildingDef(string id)
{
BuildingDef definition;
return _index.TryGetValue(id, out definition) ? definition : null;
}
/// <summary>
/// Gate rule shared by every damage source: outer first, inner after its outer, base
/// towers once any lane is fully open, the Ancient after both base towers.
/// alive maps building id to alive; missing ids count as destroyed.
/// </summary>
public static bool IsAttackable(BuildingDef definition, IDictionary<string, bool> alive)
{
string prefix = definition.Team == 0 ? "blau" : "rot";
switch (definition.Tier)
{
case "outer":
return true;
case "inner":
return !Get(alive, prefix + "_" + definition.Lane + "_outer");
case "base":
foreach (string lane in Lanes)
{
if (!Get(alive, prefix + "_" + lane + "_outer") && !Get(alive, prefix + "_" + lane + "_inner"))
return true;
}
return false;
case "core":
return !Get(alive, prefix + "_base_1") && !Get(alive, prefix + "_base_2");
}
return false;
}
static bool Get(IDictionary<string, bool> alive, string id)
{
bool value;
return alive.TryGetValue(id, out value) && value;
}
}
}