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>
318 lines
12 KiB
C#
318 lines
12 KiB
C#
using SpaceGame.Simulation.Api.Data;
|
|
|
|
namespace SpaceGame.Simulation.Api.Simulation;
|
|
|
|
public sealed partial class SimulationEngine
|
|
{
|
|
private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds)
|
|
{
|
|
var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f);
|
|
var meanAnomaly = DegreesToRadians(planet.OrbitPhaseAtEpoch) + (timeSeconds * planet.OrbitSpeed);
|
|
var eccentricAnomaly = meanAnomaly
|
|
+ (eccentricity * MathF.Sin(meanAnomaly))
|
|
+ (0.5f * eccentricity * eccentricity * MathF.Sin(2f * meanAnomaly));
|
|
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),
|
|
0f,
|
|
semiMinorAxis * MathF.Sin(eccentricAnomaly));
|
|
|
|
local = RotateAroundY(local, DegreesToRadians(planet.OrbitArgumentOfPeriapsis));
|
|
local = RotateAroundX(local, DegreesToRadians(planet.OrbitInclination));
|
|
local = RotateAroundY(local, DegreesToRadians(planet.OrbitLongitudeOfAscendingNode));
|
|
return local;
|
|
}
|
|
|
|
private static Vector3 ComputeMoonOffset(PlanetDefinition planet, int moonIndex, float timeSeconds)
|
|
{
|
|
var orbitRadius = ComputeMoonOrbitRadius(planet, moonIndex);
|
|
var speed = ComputeMoonOrbitSpeed(planet, moonIndex);
|
|
var phase = HashUnit($"{planet.Label}:{moonIndex}:phase") * MathF.PI * 2f;
|
|
var inclination = DegreesToRadians((HashUnit($"{planet.Label}:{moonIndex}:inclination") - 0.5f) * 28f);
|
|
var ascendingNode = DegreesToRadians(HashUnit($"{planet.Label}:{moonIndex}:node") * 360f);
|
|
var angle = phase + (timeSeconds * speed);
|
|
|
|
var local = new Vector3(
|
|
MathF.Cos(angle) * orbitRadius,
|
|
0f,
|
|
MathF.Sin(angle) * orbitRadius);
|
|
local = RotateAroundX(local, inclination);
|
|
local = RotateAroundY(local, ascendingNode);
|
|
return local;
|
|
}
|
|
|
|
private static float ComputeMoonOrbitRadius(PlanetDefinition planet, int moonIndex)
|
|
{
|
|
var spacing = planet.Size * 1.4f;
|
|
var variance = HashUnit($"{planet.Label}:{moonIndex}:radius") * planet.Size * 0.9f;
|
|
return (planet.Size * 1.8f) + (moonIndex * spacing) + variance;
|
|
}
|
|
|
|
private static float ComputeMoonOrbitSpeed(PlanetDefinition planet, int moonIndex)
|
|
{
|
|
var radius = ComputeMoonOrbitRadius(planet, moonIndex);
|
|
return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f);
|
|
}
|
|
|
|
private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
|
|
{
|
|
var baseSpeed = node.SourceKind == "gas-cloud" ? 0.16f : 0.24f;
|
|
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180000f, 0.45f));
|
|
}
|
|
|
|
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
|
|
{
|
|
var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node));
|
|
var orbit = new Vector3(
|
|
MathF.Cos(angle) * node.OrbitRadius,
|
|
0f,
|
|
MathF.Sin(angle) * node.OrbitRadius);
|
|
return RotateAroundX(orbit, node.OrbitInclination);
|
|
}
|
|
|
|
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
|
|
Vector3 planetPosition,
|
|
PlanetDefinition planet)
|
|
{
|
|
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
|
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
|
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", Scale(radial, -orbitRadiusKm));
|
|
yield return new LagrangePointPlacement(
|
|
"L4",
|
|
Add(
|
|
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
|
|
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
|
|
yield return new LagrangePointPlacement(
|
|
"L5",
|
|
Add(
|
|
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
|
|
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
|
|
}
|
|
|
|
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
|
|
{
|
|
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)
|
|
{
|
|
var length = MathF.Sqrt(value.LengthSquared());
|
|
if (length <= 0.0001f)
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
return value.Divide(length);
|
|
}
|
|
|
|
private static Vector3 RotateAroundX(Vector3 value, float angle)
|
|
{
|
|
var cos = MathF.Cos(angle);
|
|
var sin = MathF.Sin(angle);
|
|
return new Vector3(
|
|
value.X,
|
|
(value.Y * cos) - (value.Z * sin),
|
|
(value.Y * sin) + (value.Z * cos));
|
|
}
|
|
|
|
private static Vector3 RotateAroundY(Vector3 value, float angle)
|
|
{
|
|
var cos = MathF.Cos(angle);
|
|
var sin = MathF.Sin(angle);
|
|
return new Vector3(
|
|
(value.X * cos) + (value.Z * sin),
|
|
value.Y,
|
|
(-value.X * sin) + (value.Z * cos));
|
|
}
|
|
|
|
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
|
|
|
|
private static Vector3 Scale(Vector3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar);
|
|
|
|
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
|
|
|
|
private static float HashUnit(string input)
|
|
{
|
|
unchecked
|
|
{
|
|
var hash = 2166136261u;
|
|
foreach (var character in input)
|
|
{
|
|
hash ^= character;
|
|
hash *= 16777619u;
|
|
}
|
|
|
|
return (hash & 0x00FFFFFF) / (float)0x01000000;
|
|
}
|
|
}
|
|
|
|
private void UpdateOrbitalState(SimulationWorld world)
|
|
{
|
|
var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
|
|
var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
|
|
|
foreach (var system in world.Systems)
|
|
{
|
|
var starNodeId = $"node-{system.Definition.Id}-star";
|
|
if (spatialNodesById.TryGetValue(starNodeId, out var starNode))
|
|
{
|
|
starNode.Position = Vector3.Zero;
|
|
}
|
|
|
|
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
|
|
{
|
|
var planet = system.Definition.Planets[planetIndex];
|
|
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
|
|
if (!spatialNodesById.TryGetValue(planetNodeId, out var planetNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
|
|
planetNode.Position = planetPosition;
|
|
|
|
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))
|
|
{
|
|
lagrangeNode.Position = lagrange.Position;
|
|
}
|
|
}
|
|
|
|
for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1)
|
|
{
|
|
var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
|
|
if (!spatialNodesById.TryGetValue(moonId, out var moonNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet, moonIndex, worldTimeSeconds));
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var station in world.Stations)
|
|
{
|
|
if (station.AnchorNodeId is null || !spatialNodesById.TryGetValue(station.AnchorNodeId, out var anchorNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
station.Position = anchorNode.Position;
|
|
if (station.NodeId is not null && spatialNodesById.TryGetValue(station.NodeId, out var stationNode))
|
|
{
|
|
stationNode.Position = station.Position;
|
|
}
|
|
}
|
|
|
|
foreach (var node in world.Nodes)
|
|
{
|
|
if (node.AnchorNodeId is null || !spatialNodesById.TryGetValue(node.AnchorNodeId, out var anchorNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
node.Position = Add(anchorNode.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
|
|
if (spatialNodesById.TryGetValue(node.Id, out var resourceNode))
|
|
{
|
|
resourceNode.Position = node.Position;
|
|
}
|
|
}
|
|
|
|
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
|
|
{
|
|
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
|
if (station is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var dockedPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = dockedPosition;
|
|
ship.TargetPosition = dockedPosition;
|
|
}
|
|
}
|
|
|
|
private static void SyncSpatialState(SimulationWorld world)
|
|
{
|
|
foreach (var bubble in world.LocalBubbles)
|
|
{
|
|
bubble.OccupantShipIds.Clear();
|
|
}
|
|
|
|
foreach (var ship in world.Ships)
|
|
{
|
|
ship.SpatialState.CurrentSystemId = ship.SystemId;
|
|
ship.SpatialState.LocalPosition = ship.Position;
|
|
ship.SpatialState.SystemPosition = ship.Position;
|
|
if (ship.SpatialState.Transit is not null)
|
|
{
|
|
ship.SpatialState.CurrentNodeId = null;
|
|
ship.SpatialState.CurrentBubbleId = null;
|
|
continue;
|
|
}
|
|
|
|
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
|
|
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
|
|
var nearestNode = world.SpatialNodes
|
|
.Where(candidate => candidate.SystemId == ship.SystemId)
|
|
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
|
|
.FirstOrDefault();
|
|
ship.SpatialState.CurrentNodeId = nearestNode?.Id;
|
|
ship.SpatialState.CurrentBubbleId = nearestNode?.BubbleId;
|
|
|
|
if (nearestNode is not null)
|
|
{
|
|
var nearestBubble = world.LocalBubbles.FirstOrDefault(candidate => candidate.Id == nearestNode.BubbleId);
|
|
nearestBubble?.OccupantShipIds.Add(ship.Id);
|
|
}
|
|
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
|
if (station?.BubbleId is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ship.SpatialState.CurrentNodeId = station.NodeId;
|
|
ship.SpatialState.CurrentBubbleId = station.BubbleId;
|
|
var bubble = world.LocalBubbles.FirstOrDefault(candidate => candidate.Id == station.BubbleId);
|
|
bubble?.OccupantShipIds.Add(ship.Id);
|
|
}
|
|
}
|
|
|
|
private readonly record struct LagrangePointPlacement(string Designation, Vector3 Position);
|
|
}
|