feat: migrate simulation to physically-based unit system

Replace arbitrary game units with real-world measurements throughout
the simulation and viewer: planet orbits in AU, sizes in km, galaxy
positions in light-years. Add SimulationUnits helpers for conversions,
separate WarpSpeed from FtlSpeed for ships, fix FTL transit progress
to use galaxy-space distances, overhaul Lagrange point placement with
Hill sphere approximation, and update the viewer to scale and format
all distances correctly. Ships in FTL transit now render in galaxy view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 14:21:20 -04:00
parent 5ba1287f85
commit 5df5111463
34 changed files with 1558 additions and 456 deletions

View File

@@ -22,6 +22,8 @@ public sealed record ShipSnapshot(
string? CargoItemId,
float WorkerPopulation,
float EnergyStored,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
@@ -51,6 +53,8 @@ public sealed record ShipDelta(
string? CargoItemId,
float WorkerPopulation,
float EnergyStored,
float TravelSpeed,
string TravelSpeedUnit,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,

View File

@@ -125,6 +125,7 @@ public sealed class ShipDefinition
public required string Role { get; set; }
public required string ShipClass { get; set; }
public float Speed { get; set; }
public float WarpSpeed { get; set; }
public float FtlSpeed { get; set; }
public float SpoolTime { get; set; }
public float CargoCapacity { get; set; }

View File

@@ -0,0 +1,15 @@
namespace SpaceGame.Simulation.Api.Simulation;
public static class SimulationUnits
{
public const float KilometersPerAu = 149_597_870.7f;
public const float MetersPerKilometer = 1000f;
public static float AuToKilometers(float au) => au * KilometersPerAu;
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
auPerSecond * KilometersPerAu;
public static float MetersPerSecondToKilometersPerSecond(float metersPerSecond) =>
metersPerSecond / MetersPerKilometer;
}

View File

@@ -115,9 +115,9 @@ public sealed partial class ScenarioLoader
var compactPositions = new[]
{
new[] { 0f, 0f, 0f },
new[] { 2600f, 24f, -420f },
new[] { -2400f, -36f, 560f },
new[] { 520f, 42f, 2480f },
new[] { 2.6f, 0.02f, -0.42f },
new[] { -2.4f, -0.04f, 0.56f },
new[] { 0.52f, 0.04f, 2.48f },
};
for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1)
@@ -165,9 +165,9 @@ public sealed partial class ScenarioLoader
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10),
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18f),
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12f),
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4f),
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18000f),
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12000f),
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4000f),
},
ResourceNodes = resourceNodes,
Planets = planets,
@@ -287,14 +287,14 @@ public sealed partial class ScenarioLoader
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
{
const int armCount = 4;
const float baseInnerRadius = 9000f;
const float radiusStep = 540f;
const float baseInnerRadius = 9f;
const float radiusStep = 0.54f;
const float armOffset = MathF.PI * 2f / armCount;
var armIndex = (generatedIndex + attempt) % armCount;
var armDepth = generatedIndex / armCount;
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 900f);
var angle = (armIndex * armOffset) + (radius / 8200f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 0.9f);
var angle = (armIndex * armOffset) + (radius / 8.2f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
var x = MathF.Cos(angle) * radius;
var z = MathF.Sin(angle) * radius * 0.58f;
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
@@ -304,9 +304,9 @@ public sealed partial class ScenarioLoader
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
{
const int ringCount = 5;
const float fallbackRadius = 42000f;
const float fallbackRadius = 42f;
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1.8f;
return new Vector3(
MathF.Cos(angle) * radius,
ComputeSystemHeight(radius, generatedIndex, 99),
@@ -334,7 +334,7 @@ public sealed partial class ScenarioLoader
{
SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = 120f + Jitter(generatedIndex, 200 + index, 36f),
RadiusOffset = 120000f + Jitter(generatedIndex, 200 + index, 36000f),
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f),
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets),
OreAmount = oreAmount,
@@ -374,7 +374,7 @@ public sealed partial class ScenarioLoader
{
SourceKind = "gas-cloud",
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
RadiusOffset = 170f + Jitter(generatedIndex, 260 + index, 44f),
RadiusOffset = 170000f + Jitter(generatedIndex, 260 + index, 44000f),
InclinationDegrees = Jitter(generatedIndex, 320 + index, 10f),
AnchorPlanetIndex = gasAnchorIndex,
OreAmount = gasAmount,
@@ -415,7 +415,7 @@ public sealed partial class ScenarioLoader
{
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount);
var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f);
var orbitRadius = 0.24f + (Hash01(generatedIndex, 3) * 0.12f);
var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null;
for (var index = 0; index < planetCount; index += 1)
@@ -437,13 +437,13 @@ public sealed partial class ScenarioLoader
Shape = profile.Shape,
MoonCount = profile.BaseMoonCount + moonVariance,
OrbitRadius = orbitRadius,
OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)),
OrbitSpeed = 0.11f / MathF.Sqrt(MathF.Max(orbitRadius * orbitRadius * orbitRadius, 0.02f)),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f,
OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f,
OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f,
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * 10f),
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * (profile.BaseSize * 0.35f)),
Color = templatePlanet?.Color ?? profile.Color,
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
@@ -493,8 +493,8 @@ public sealed partial class ScenarioLoader
private static float ComputeSystemHeight(float radius, int generatedIndex, int salt)
{
var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8000f) / 28000f));
var band = 220f + (normalized * 760f);
var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8f) / 28f));
var band = 0.22f + (normalized * 0.76f);
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
}
@@ -528,7 +528,7 @@ public sealed partial class ScenarioLoader
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + 44f;
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}
private static SolarSystemDefinition CreateSolSystem()
@@ -546,29 +546,29 @@ public sealed partial class ScenarioLoader
{
Id = "sol",
Label = "Sol",
Position = [18200f, 24f, -11800f],
Position = [18.2f, 0.02f, -11.8f],
StarKind = "main-sequence",
StarCount = 1,
StarColor = "#fff1b8",
StarGlow = "#ffd35a",
StarSize = 58f,
StarSize = 696340f,
GravityWellRadius = 240f,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = 240,
RadiusOffset = ScaleSolOrbitRadiusFromAu(2.82f),
RadiusVariance = 180f,
HeightVariance = 22f,
RadiusOffset = 422000000f,
RadiusVariance = 180000000f,
HeightVariance = 22000000f,
},
ResourceNodes =
[
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 126f, InclinationDegrees = 4f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 148f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 138f, InclinationDegrees = 8f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 164f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 210f, InclinationDegrees = 3f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 228f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, ItemId = "gas", ShardCount = 10 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 126000f, InclinationDegrees = 4f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 148000f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 138000f, InclinationDegrees = 8f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 164000f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 210000f, InclinationDegrees = 3f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 228000f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186000f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, ItemId = "gas", ShardCount = 10 },
],
Planets =
[
@@ -605,7 +605,7 @@ public sealed partial class ScenarioLoader
PlanetType = planetType,
Shape = shape,
MoonCount = moonCount,
OrbitRadius = ScaleSolOrbitRadiusFromAu(orbitRadiusAu),
OrbitRadius = orbitRadiusAu,
OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
@@ -614,9 +614,16 @@ public sealed partial class ScenarioLoader
OrbitPhaseAtEpoch = phaseAtEpoch,
Size = planetType switch
{
"gas-giant" => label == "Saturn" ? 66f : 72f,
"ice-giant" => 48f,
_ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f,
"gas-giant" => label == "Saturn" ? 58232f : 69911f,
"ice-giant" => label == "Uranus" ? 25362f : 24622f,
_ => label switch
{
"Mercury" => 2440f,
"Venus" => 6052f,
"Earth" => 6371f,
"Mars" => 3390f,
_ => 5000f,
},
},
Color = color,
Tilt = tilt,
@@ -624,9 +631,6 @@ public sealed partial class ScenarioLoader
};
}
private static float ScaleSolOrbitRadiusFromAu(float orbitRadiusAu) =>
MathF.Round(500f * MathF.Pow(orbitRadiusAu, 0.70f));
private static float ComputeSolOrbitSpeed(float orbitRadiusAu)
{
const float earthAngularSpeed = 0.11f;

View File

@@ -35,7 +35,7 @@ public sealed partial class ScenarioLoader
parentNodeId: starNode.Id);
var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
{
var lagrangeNode = AddSpatialNode(
nodes,
@@ -113,39 +113,52 @@ public sealed partial class ScenarioLoader
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
float orbitRadius,
float planetSize,
int planetIndex)
PlanetDefinition planet)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
yield return new LagrangePointPlacement(
"L4",
Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
{
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
var sizeScale = (planetSize * 1.9f) + 10f;
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
var planetMassProxy = EstimatePlanetMassRatio(planet);
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
return MathF.Max(minimumOffset, hillLikeOffset);
}
// The simulation does not track physical masses yet, so use a size/density proxy.
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
var densityFactor = planet.PlanetType switch
{
"gas-giant" => 0.24f,
"ice-giant" => 0.18f,
"oceanic" => 0.95f,
"ice" => 0.7f,
_ => 1f,
};
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
return earthMasses / 332_946f;
}
private static StationPlacement ResolveStationPlacement(
@@ -210,7 +223,7 @@ public sealed partial class ScenarioLoader
private static Vector3 ComputeResourceNodePosition(NodeRuntime? anchorNode, ResourceNodeDefinition definition, float yPlane)
{
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.18f, 28f);
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f);
var offset = new Vector3(
MathF.Cos(definition.Angle) * definition.RadiusOffset,
verticalOffset,
@@ -227,8 +240,9 @@ public sealed partial class ScenarioLoader
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
{
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
var x = MathF.Cos(angle) * planet.OrbitRadius;
var z = MathF.Sin(angle) * planet.OrbitRadius;
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
var x = MathF.Cos(angle) * orbitRadiusKm;
var z = MathF.Sin(angle) * orbitRadiusKm;
return new Vector3(x, 0f, z);
}

View File

@@ -12,7 +12,7 @@ public sealed partial class ScenarioLoader
private const float MinimumRefineryOre = 0f;
private const float MinimumRefineryStock = 0f;
private const float MinimumShipyardStock = 0f;
private const float MinimumSystemSeparation = 3200f;
private const float MinimumSystemSeparation = 3.2f;
private const float StarBubbleRadiusPadding = 40f;
private const float PlanetBubbleRadiusPadding = 80f;
private const float MoonBubbleRadiusPadding = 40f;
@@ -55,24 +55,24 @@ public sealed partial class ScenarioLoader
];
private static readonly StarProfile[] StarProfiles =
[
new("main-sequence", "#ffd27a", "#ffb14a", 54f, 1),
new("blue-white", "#9dc6ff", "#66a0ff", 50f, 1),
new("white-dwarf", "#f1f5ff", "#b8caff", 26f, 1),
new("brown-dwarf", "#b97d56", "#8a5438", 20f, 1),
new("main-sequence", "#ffd27a", "#ffb14a", 696340f, 1),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f, 1),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f, 1),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f, 1),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f, 1),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 64f, 2),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 34f, 2),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f, 2),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f, 2),
];
private static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 18f, 38f, 0, false),
new("terrestrial", "sphere", "#58a36c", 24f, 46f, 1, false),
new("oceanic", "sphere", "#4f84c4", 26f, 44f, 2, false),
new("desert", "sphere", "#d4a373", 22f, 42f, 0, false),
new("ice", "sphere", "#c8e4ff", 24f, 40f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 52f, 86f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 44f, 72f, 5, true),
new("lava", "sphere", "#db6846", 20f, 36f, 0, false),
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
new("ice", "sphere", "#c8e4ff", 5800f, 0.32f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 45000f, 1.40f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 25000f, 1.00f, 5, true),
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
];
private readonly string _dataRoot;

View File

@@ -2,6 +2,18 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private const float WarpEngageDistanceKilometers = 250_000f;
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
?? Vector3.Zero;
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
@@ -140,7 +152,7 @@ public sealed partial class SimulationEngine
ship.ActionTimer = 0f;
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
@@ -199,7 +211,7 @@ public sealed partial class SimulationEngine
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.SpatialNodes.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 18f
? CompleteTransitArrival(ship, targetNode.SystemId, targetPosition, targetNode)
@@ -258,10 +270,11 @@ public sealed partial class SimulationEngine
return "none";
}
var totalDistance = MathF.Max(0.001f, ship.Position.DistanceTo(targetPosition));
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 24f
var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId);
var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition));
transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * deltaSeconds) / totalDistance));
return transit.Progress >= 0.999f
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetNode)
: "none";
}

View File

@@ -11,7 +11,7 @@ public sealed partial class SimulationEngine
var eccentricAnomaly = meanAnomaly
+ (eccentricity * MathF.Sin(meanAnomaly))
+ (0.5f * eccentricity * eccentricity * MathF.Sin(2f * meanAnomaly));
var semiMajorAxis = planet.OrbitRadius;
var semiMajorAxis = SimulationUnits.AuToKilometers(planet.OrbitRadius);
var semiMinorAxis = semiMajorAxis * MathF.Sqrt(MathF.Max(1f - (eccentricity * eccentricity), 0.05f));
var local = new Vector3(
semiMajorAxis * (MathF.Cos(eccentricAnomaly) - eccentricity),
@@ -58,7 +58,7 @@ public sealed partial class SimulationEngine
private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
{
var baseSpeed = node.SourceKind == "gas-cloud" ? 0.16f : 0.24f;
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180f, 0.45f));
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180000f, 0.45f));
}
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
@@ -73,39 +73,51 @@ public sealed partial class SimulationEngine
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
float orbitRadius,
float planetSize,
int planetIndex)
PlanetDefinition planet)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
yield return new LagrangePointPlacement(
"L4",
Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
{
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
var sizeScale = (planetSize * 1.9f) + 10f;
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
var planetMassProxy = EstimatePlanetMassRatio(planet);
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
return MathF.Max(minimumOffset, hillLikeOffset);
}
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
var densityFactor = planet.PlanetType switch
{
"gas-giant" => 0.24f,
"ice-giant" => 0.18f,
"oceanic" => 0.95f,
"ice" => 0.7f,
_ => 1f,
};
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
return earthMasses / 332_946f;
}
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
@@ -185,7 +197,7 @@ public sealed partial class SimulationEngine
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
planetNode.Position = planetPosition;
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet))
{
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode))

View File

@@ -260,7 +260,9 @@ public sealed partial class SimulationEngine
{
var destinationEntryNode = ResolveSystemEntryNode(world, toSystemId);
var destinationEntryPosition = destinationEntryNode?.Position ?? toPosition;
var ftlDistance = fromPosition.DistanceTo(destinationEntryPosition);
var originSystemPosition = ResolveSystemGalaxyPosition(world, fromSystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, toSystemId);
var ftlDistance = originSystemPosition.DistanceTo(destinationSystemPosition);
var ftlDuration = ftlDistance / MathF.Max(ship.Definition.FtlSpeed, 0.01f);
return EstimateTimedEnergyUse(world, ship.Definition.SpoolTime, world.Balance.Energy.IdleDrain)
+ EstimateTimedEnergyUse(world, ftlDuration, world.Balance.Energy.WarpDrain)
@@ -278,14 +280,14 @@ public sealed partial class SimulationEngine
return 0f;
}
if (distance <= 120f)
if (distance <= WarpEngageDistanceKilometers)
{
var localDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
var localDuration = distance / MathF.Max(GetLocalTravelSpeed(ship), 0.01f);
return EstimateTimedEnergyUse(world, localDuration, world.Balance.Energy.MoveDrain);
}
var warpSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
var warpDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
var warpDuration = distance / MathF.Max(GetWarpTravelSpeed(ship), 0.01f);
return EstimateTimedEnergyUse(world, warpSpoolDuration, world.Balance.Energy.IdleDrain)
+ EstimateTimedEnergyUse(world, warpDuration, world.Balance.Energy.WarpDrain);
}

View File

@@ -165,6 +165,8 @@ public sealed partial class SimulationEngine
ship.CargoItemId,
ship.WorkerPopulation,
ship.EnergyStored,
ship.TravelSpeed,
ship.TravelSpeedUnit,
ship.Inventory,
ship.FactionId,
ship.Health,
@@ -670,6 +672,8 @@ public sealed partial class SimulationEngine
ship.Definition.CargoItemId,
ship.WorkerPopulation,
ship.EnergyStored,
ToShipTravelSpeed(ship).Speed,
ToShipTravelSpeed(ship).Unit,
ToInventoryEntries(ship.Inventory),
ship.FactionId,
ship.Health,
@@ -712,6 +716,16 @@ public sealed partial class SimulationEngine
return progress;
}
private static (float Speed, string Unit) ToShipTravelSpeed(ShipRuntime ship)
{
return ship.SpatialState.MovementRegime switch
{
MovementRegimeKinds.FtlTransit => (ship.State == ShipState.Ftl ? ship.Definition.FtlSpeed : 0f, "ly/s"),
MovementRegimeKinds.Warp => (ship.State == ShipState.Warping ? ship.Definition.WarpSpeed : 0f, "AU/s"),
_ => (MathF.Sqrt(MathF.Max(0f, ship.Velocity.LengthSquared())) * SimulationUnits.MetersPerKilometer, "m/s"),
};
}
private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) =>
new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f));

View File

@@ -64,7 +64,7 @@ public sealed partial class SimulationEngine
}
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
@@ -123,7 +123,7 @@ public sealed partial class SimulationEngine
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
}
return "none";
@@ -144,7 +144,7 @@ public sealed partial class SimulationEngine
}
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
@@ -288,7 +288,7 @@ public sealed partial class SimulationEngine
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
@@ -340,7 +340,7 @@ public sealed partial class SimulationEngine
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
@@ -405,7 +405,7 @@ public sealed partial class SimulationEngine
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
@@ -476,7 +476,7 @@ public sealed partial class SimulationEngine
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}