Expand galaxy generation and viewer rendering

This commit is contained in:
2026-03-12 21:48:14 -04:00
parent e57378ad2a
commit b57b04d90a
9 changed files with 1071 additions and 63 deletions

View File

@@ -34,13 +34,24 @@ public sealed record SystemSnapshot(
string Id,
string Label,
Vector3Dto Position,
string StarKind,
int StarCount,
string StarColor,
float StarSize,
IReadOnlyList<PlanetSnapshot> Planets);
public sealed record PlanetSnapshot(
string Label,
string PlanetType,
string Shape,
int MoonCount,
float OrbitRadius,
float OrbitSpeed,
float OrbitEccentricity,
float OrbitInclination,
float OrbitLongitudeOfAscendingNode,
float OrbitArgumentOfPeriapsis,
float OrbitPhaseAtEpoch,
float Size,
string Color,
bool HasRing);
@@ -49,6 +60,7 @@ public sealed record ResourceNodeSnapshot(
string Id,
string SystemId,
Vector3Dto Position,
string SourceKind,
float OreRemaining,
float MaxOre,
string ItemId);
@@ -57,6 +69,7 @@ public sealed record ResourceNodeDelta(
string Id,
string SystemId,
Vector3Dto Position,
string SourceKind,
float OreRemaining,
float MaxOre,
string ItemId);

View File

@@ -31,6 +31,8 @@ public sealed class SolarSystemDefinition
public required string Id { get; set; }
public required string Label { get; set; }
public required float[] Position { get; set; }
public string StarKind { get; set; } = "main-sequence";
public int StarCount { get; set; } = 1;
public required string StarColor { get; set; }
public required string StarGlow { get; set; }
public float StarSize { get; set; }
@@ -50,6 +52,7 @@ public sealed class AsteroidFieldDefinition
public sealed class ResourceNodeDefinition
{
public string SourceKind { get; set; } = "asteroid-belt";
public float Angle { get; set; }
public float RadiusOffset { get; set; }
public float OreAmount { get; set; }
@@ -60,8 +63,16 @@ public sealed class ResourceNodeDefinition
public sealed class PlanetDefinition
{
public required string Label { get; set; }
public string PlanetType { get; set; } = "terrestrial";
public string Shape { get; set; } = "sphere";
public int MoonCount { get; set; }
public float OrbitRadius { get; set; }
public float OrbitSpeed { get; set; }
public float OrbitEccentricity { get; set; }
public float OrbitInclination { get; set; }
public float OrbitLongitudeOfAscendingNode { get; set; }
public float OrbitArgumentOfPeriapsis { get; set; }
public float OrbitPhaseAtEpoch { get; set; }
public float Size { get; set; }
public required string Color { get; set; }
public float Tilt { get; set; }

View File

@@ -28,6 +28,7 @@ public sealed class ResourceNodeRuntime
public required string Id { get; init; }
public required string SystemId { get; init; }
public required Vector3 Position { get; init; }
public required string SourceKind { get; init; }
public required string ItemId { get; init; }
public float OreRemaining { get; set; }
public float MaxOre { get; init; }

View File

@@ -5,6 +5,71 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed class ScenarioLoader
{
private const string DefaultFactionId = "sol-dominion";
private const int TargetSystemCount = 160;
private const int WorldSeed = 1;
private const float MinimumFactionCredits = 240f;
private const float MinimumRefineryOre = 60f;
private const float MinimumRefineryStock = 40f;
private const float MinimumShipyardStock = 180f;
private const float MinimumSystemSeparation = 3200f;
private static readonly string[] GeneratedSystemNames =
[
"Aquila Verge",
"Orion Fold",
"Draco Span",
"Lyra Shoal",
"Cygnus March",
"Vela Crossing",
"Carina Wake",
"Phoenix Rest",
"Hydra Loom",
"Cassio Reach",
"Lupus Chain",
"Pavo Line",
"Serpens Rise",
"Cetus Hollow",
"Delphin Crown",
"Volans Drift",
"Ara Bastion",
"Indus Veil",
"Pyxis Trace",
"Lacerta Bloom",
"Columba Shroud",
"Dorado Expanse",
"Reticulum Run",
"Norma Edge",
"Crux Horizon",
"Sagitta Corridor",
"Monoceros Deep",
"Eridan Spur",
"Centauri Shelf",
"Antlia Reach",
"Horologium Gate",
"Telescopium Strand",
];
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("neutron-star", "#d9ebff", "#7ab4ff", 18f, 1),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 64f, 2),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 34f, 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),
];
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
@@ -18,7 +83,7 @@ public sealed class ScenarioLoader
public SimulationWorld Load()
{
var systems = Read<List<SolarSystemDefinition>>("systems.json");
var systems = ExpandSystems(InjectSpecialSystems(Read<List<SolarSystemDefinition>>("systems.json")));
var scenario = Read<ScenarioDefinition>("scenario.json");
var ships = Read<List<ShipDefinition>>("ships.json");
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
@@ -47,8 +112,9 @@ public sealed class ScenarioLoader
SystemId = system.Definition.Id,
Position = new Vector3(
system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset),
balance.YPlane,
system.Position.Y + balance.YPlane,
system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
@@ -71,9 +137,9 @@ public sealed class ScenarioLoader
SystemId = system.Definition.Id,
Definition = definition,
Position = ResolveStationPosition(system, plan, balance),
FactionId = plan.FactionId ?? "sol-dominion",
OreStored = definition.Category == "refining" ? 120f : 0f,
RefinedStock = definition.Category == "shipyard" ? 180f : 40f,
FactionId = plan.FactionId ?? DefaultFactionId,
OreStored = 0f,
RefinedStock = 0f,
});
}
@@ -104,7 +170,7 @@ public sealed class ScenarioLoader
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? "sol-dominion",
FactionId = formation.FactionId ?? DefaultFactionId,
Position = position,
TargetPosition = position,
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
@@ -114,21 +180,13 @@ public sealed class ScenarioLoader
}
}
var factions = new List<FactionRuntime>
{
new()
{
Id = "sol-dominion",
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = 240f,
},
};
var factions = CreateFactions(stations, shipsRuntime);
BootstrapFactionEconomy(factions, stations);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = 1,
Seed = WorldSeed,
Balance = balance,
Systems = systemRuntimes,
Nodes = nodes,
@@ -140,6 +198,594 @@ public sealed class ScenarioLoader
};
}
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (systems.All((system) => system.Id != "sol"))
{
systems.Add(CreateSolSystem());
}
return systems;
}
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0)
{
return systems;
}
var existingIds = systems
.Select((system) => system.Id)
.ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count);
for (var index = systems.Count; index < TargetSystemCount; index += 1)
{
var template = authoredSystems[index % authoredSystems.Count];
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, index + 1);
while (!existingIds.Add(id))
{
id = $"{id}-x";
}
systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count]));
}
return systems;
}
private static SolarSystemDefinition CreateGeneratedSystem(
SolarSystemDefinition template,
string label,
string id,
int generatedIndex,
Vector3 position)
{
var starProfile = SelectStarProfile(generatedIndex);
var planets = BuildGeneratedPlanets(template, generatedIndex);
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList();
return new SolarSystemDefinition
{
Id = id,
Label = label,
Position = [position.X, position.Y, position.Z],
StarKind = starProfile.Kind,
StarCount = starProfile.StarCount,
StarColor = starProfile.StarColor,
StarGlow = starProfile.StarGlow,
StarSize = starProfile.BaseSize + ((generatedIndex % 4) * 2f),
GravityWellRadius = template.GravityWellRadius + ((generatedIndex % 3) * 12f),
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),
},
ResourceNodes = resourceNodes,
Planets = planets,
};
}
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
{
return new SolarSystemDefinition
{
Id = definition.Id,
Label = definition.Label,
Position = definition.Position.ToArray(),
StarKind = definition.StarKind,
StarCount = definition.StarCount,
StarColor = definition.StarColor,
StarGlow = definition.StarGlow,
StarSize = definition.StarSize,
GravityWellRadius = definition.GravityWellRadius,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = definition.AsteroidField.DecorationCount,
RadiusOffset = definition.AsteroidField.RadiusOffset,
RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance,
},
ResourceNodes = definition.ResourceNodes
.Select((node) => new ResourceNodeDefinition
{
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList(),
Planets = definition.Planets
.Select((planet) => new PlanetDefinition
{
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
MoonCount = planet.MoonCount,
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
OrbitInclination = planet.OrbitInclination,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
Size = planet.Size,
Color = planet.Color,
Tilt = planet.Tilt,
HasRing = planet.HasRing,
})
.ToList(),
};
}
private static List<ResourceNodeDefinition> BuildProceduralResourceNodes(
SolarSystemDefinition template,
IReadOnlyList<PlanetDefinition> planets,
int generatedIndex)
{
var nodes = new List<ResourceNodeDefinition>();
if (template.ResourceNodes.Count > 0)
{
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}));
}
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
nodes.AddRange(BuildGasCloudNodes(generatedIndex, planets));
return nodes;
}
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
{
var allPositions = occupiedPositions.ToList();
var generated = new List<Vector3>(count);
for (var index = 0; index < count; index += 1)
{
Vector3? accepted = null;
for (var attempt = 0; attempt < 64; attempt += 1)
{
var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
accepted = candidate;
break;
}
}
accepted ??= ComputeFallbackGeneratedSystemPosition(index);
generated.Add(accepted.Value);
allPositions.Add(accepted.Value);
}
return generated;
}
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
{
const int armCount = 4;
const float baseInnerRadius = 9000f;
const float radiusStep = 540f;
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 x = MathF.Cos(angle) * radius;
var z = MathF.Sin(angle) * radius * 0.58f;
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
return new Vector3(x, y, z);
}
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
{
const int ringCount = 5;
const float fallbackRadius = 42000f;
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f;
return new Vector3(
MathF.Cos(angle) * radius,
ComputeSystemHeight(radius, generatedIndex, 99),
MathF.Sin(angle) * radius * 0.6f);
}
private static string BuildGeneratedSystemId(string label, int ordinal)
{
var slug = string.Concat(label
.ToLowerInvariant()
.Select((character) => char.IsLetterOrDigit(character) ? character : '-'))
.Trim('-');
return $"gen-{ordinal}-{slug}";
}
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 2800f + ((generatedIndex % 5) * 320f);
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f),
OreAmount = oreAmount,
ItemId = "ore",
ShardCount = 6 + (index % 4),
};
}
}
private static IEnumerable<ResourceNodeDefinition> BuildGasCloudNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var gasAnchor = planets
.Where((planet) => planet.PlanetType is "gas-giant" or "ice-giant")
.OrderByDescending((planet) => planet.OrbitRadius)
.FirstOrDefault();
if (gasAnchor is null)
{
yield break;
}
var nodeCount = 2 + (generatedIndex % 3);
var gasAmount = 2200f + ((generatedIndex % 4) * 260f);
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "gas-cloud",
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f),
OreAmount = gasAmount,
ItemId = "gas",
ShardCount = 10 + index,
};
}
}
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex)
{
var gap = planets
.Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius))
.OrderByDescending((entry) => entry.Gap)
.FirstOrDefault();
if (gap.Gap > 1f)
{
return gap.LeftOrbitRadius + (gap.Gap * 0.52f);
}
return 420f + ((generatedIndex % 5) * 60f);
}
private static List<PlanetDefinition> BuildGeneratedPlanets(
SolarSystemDefinition template,
int generatedIndex)
{
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount);
var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f);
var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null;
for (var index = 0; index < planetCount; index += 1)
{
var profile = SelectPlanetProfile(generatedIndex, index);
var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0
? sourcePlanets[index % sourcePlanets.Count]
: null;
orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin));
var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f);
var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f);
var moonVariance = (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
planets.Add(new PlanetDefinition
{
Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}",
PlanetType = profile.Type,
Shape = profile.Shape,
MoonCount = profile.BaseMoonCount + moonVariance,
OrbitRadius = orbitRadius,
OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)),
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),
Color = templatePlanet?.Color ?? profile.Color,
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
});
}
return planets;
}
private static StarProfile SelectStarProfile(int generatedIndex)
{
var value = Hash01(generatedIndex, 80);
return value switch
{
< 0.32f => StarProfiles[0],
< 0.54f => StarProfiles[1],
< 0.68f => StarProfiles[5],
< 0.8f => StarProfiles[2],
< 0.9f => StarProfiles[3],
< 0.97f => StarProfiles[6],
_ => StarProfiles[4],
};
}
private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex)
{
var value = Hash01(generatedIndex, 90 + planetIndex);
return value switch
{
< 0.14f => PlanetProfiles[7],
< 0.28f => PlanetProfiles[0],
< 0.46f => PlanetProfiles[3],
< 0.62f => PlanetProfiles[1],
< 0.74f => PlanetProfiles[2],
< 0.86f => PlanetProfiles[4],
< 0.94f => PlanetProfiles[6],
_ => PlanetProfiles[5],
};
}
private static string BuildPlanetBaseName(int generatedIndex, int planetIndex)
{
var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length]
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0];
return source[..Math.Min(source.Length, 6)];
}
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);
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
}
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
private static float Hash01(int index, int salt)
{
uint value = (uint)(index + 1);
value ^= (uint)(salt + 0x9e3779b9);
value *= 0x85ebca6b;
value ^= value >> 13;
value *= 0xc2b2ae35;
value ^= value >> 16;
return (value & 0x00ffffff) / 16777215f;
}
private sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize,
int StarCount);
private sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + 44f;
}
private static SolarSystemDefinition CreateSolSystem()
{
return new SolarSystemDefinition
{
Id = "sol",
Label = "Sol",
Position = [18200f, 24f, -11800f],
StarKind = "main-sequence",
StarCount = 1,
StarColor = "#fff1b8",
StarGlow = "#ffd35a",
StarSize = 58f,
GravityWellRadius = 240f,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = 240,
RadiusOffset = 780f,
RadiusVariance = 180f,
HeightVariance = 22f,
},
ResourceNodes =
[
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 },
],
Planets =
[
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
],
};
}
private static PlanetDefinition CreateSolPlanet(
string label,
string planetType,
string shape,
int moonCount,
float orbitRadius,
float orbitSpeed,
float orbitEccentricity,
float orbitInclination,
float ascendingNode,
float argumentOfPeriapsis,
float phaseAtEpoch,
string color,
float tilt,
bool hasRing)
{
return new PlanetDefinition
{
Label = label,
PlanetType = planetType,
Shape = shape,
MoonCount = moonCount,
OrbitRadius = orbitRadius,
OrbitSpeed = orbitSpeed,
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
OrbitArgumentOfPeriapsis = argumentOfPeriapsis,
OrbitPhaseAtEpoch = phaseAtEpoch,
Size = planetType switch
{
"gas-giant" => label == "Saturn" ? 66f : 72f,
"ice-giant" => 48f,
_ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f,
},
Color = color,
Tilt = tilt,
HasRing = hasRing,
};
}
private static List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var factionIds = stations
.Select((station) => station.FactionId)
.Concat(ships.Select((ship) => ship.FactionId))
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId))
.Distinct(StringComparer.Ordinal)
.OrderBy((factionId) => factionId, StringComparer.Ordinal)
.ToList();
if (factionIds.Count == 0)
{
factionIds.Add(DefaultFactionId);
}
return factionIds.Select(CreateFaction).ToList();
}
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static void BootstrapFactionEconomy(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations)
{
foreach (var faction in factions)
{
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
var ownedStations = stations
.Where((station) => station.FactionId == faction.Id)
.ToList();
var refineries = ownedStations
.Where((station) => station.Definition.Category == "refining")
.ToList();
if (refineries.Count > 0)
{
foreach (var refinery in refineries)
{
refinery.RefinedStock = MathF.Max(refinery.RefinedStock, MinimumRefineryStock);
}
if (refineries.All((station) => station.OreStored < MinimumRefineryOre))
{
refineries[0].OreStored = MinimumRefineryOre;
}
}
foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard"))
{
shipyard.RefinedStock = MathF.Max(shipyard.RefinedStock, MinimumShipyardStock);
}
}
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private T Read<T>(string fileName)
{
var path = Path.Combine(_dataRoot, fileName);
@@ -195,11 +841,11 @@ public sealed class ScenarioLoader
var side = plan.LagrangeSide ?? 1;
return new Vector3(
system.Position.X + planet.OrbitRadius + (side * 72f),
balance.YPlane,
system.Position.Y + balance.YPlane,
system.Position.Z + ((planetIndex + 1) * 42f * side));
}
return new Vector3(system.Position.X + 180f, balance.YPlane, system.Position.Z);
return new Vector3(system.Position.X + 180f, system.Position.Y + balance.YPlane, system.Position.Z);
}
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);

View File

@@ -55,11 +55,22 @@ public sealed class SimulationEngine
system.Definition.Id,
system.Definition.Label,
ToDto(system.Position),
system.Definition.StarKind,
system.Definition.StarCount,
system.Definition.StarColor,
system.Definition.StarSize,
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
planet.Label,
planet.PlanetType,
planet.Shape,
planet.MoonCount,
planet.OrbitRadius,
planet.OrbitSpeed,
planet.OrbitEccentricity,
planet.OrbitInclination,
planet.OrbitLongitudeOfAscendingNode,
planet.OrbitArgumentOfPeriapsis,
planet.OrbitPhaseAtEpoch,
planet.Size,
planet.Color,
planet.HasRing)).ToList())).ToList(),
@@ -67,6 +78,7 @@ public sealed class SimulationEngine
node.Id,
node.SystemId,
node.Position,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
@@ -238,6 +250,7 @@ public sealed class SimulationEngine
node.Id,
node.SystemId,
ToDto(node.Position),
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId);

View File

@@ -2,6 +2,7 @@ import * as THREE from "three";
import { fetchWorldSnapshot, openWorldStream } from "./api";
import type {
FactionSnapshot,
PlanetSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
@@ -36,6 +37,19 @@ interface ShipVisual {
blendDurationMs: number;
}
interface PlanetVisual {
planet: PlanetSnapshot;
mesh: THREE.Mesh;
icon: THREE.Sprite;
ring?: THREE.Mesh;
moons: MoonVisual[];
}
interface MoonVisual {
mesh: THREE.Mesh;
orbit: THREE.LineLoop;
}
interface WorldState {
label: string;
seed: number;
@@ -116,6 +130,7 @@ export class GameViewer {
private readonly stationMeshes = new Map<string, THREE.Mesh>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
private readonly planetVisuals: PlanetVisual[] = [];
private readonly orbitLines: THREE.Object3D[] = [];
private readonly statusEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
@@ -126,6 +141,7 @@ export class GameViewer {
private readonly marqueeEl: HTMLDivElement;
private world?: WorldState;
private worldTimeSyncMs = performance.now();
private stream?: EventSource;
private readonly networkStats: NetworkStats = {
snapshotBytes: 0,
@@ -286,6 +302,7 @@ export class GameViewer {
}
private applySnapshot(snapshot: WorldSnapshot) {
this.worldTimeSyncMs = performance.now();
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.worldSignature) {
this.worldSignature = signature;
@@ -306,6 +323,7 @@ export class GameViewer {
return;
}
this.worldTimeSyncMs = performance.now();
this.world.sequence = delta.sequence;
this.world.tickIntervalMs = delta.tickIntervalMs;
this.world.generatedAtUtc = delta.generatedAtUtc;
@@ -337,6 +355,7 @@ export class GameViewer {
this.systemGroup.clear();
this.selectableTargets.clear();
this.presentationEntries.length = 0;
this.planetVisuals.length = 0;
this.orbitLines.length = 0;
this.systemSummaryVisuals.clear();
@@ -344,58 +363,56 @@ export class GameViewer {
const root = new THREE.Group();
root.position.set(system.position.x, system.position.y, system.position.z);
const star = new THREE.Mesh(
new THREE.SphereGeometry(system.starSize, 32, 32),
new THREE.MeshBasicMaterial({ color: system.starColor }),
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(system.starSize * 1.65, 24, 24),
new THREE.MeshBasicMaterial({
color: system.starColor,
transparent: true,
opacity: 0.14,
side: THREE.BackSide,
}),
);
const starCluster = this.createStarCluster(system);
const systemIcon = this.createTacticalIcon(system.starColor, 96);
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 110, system.position.z));
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 140, system.position.z));
summaryVisual.sprite.position.set(0, system.starSize + 110, 0);
root.add(star, halo, systemIcon, summaryVisual.sprite);
this.registerPresentation(star, systemIcon, true);
this.registerPresentation(halo, systemIcon, true);
root.add(starCluster, systemIcon, summaryVisual.sprite);
this.registerPresentation(starCluster, systemIcon, true);
this.systemSummaryVisuals.set(system.id, summaryVisual);
this.selectableTargets.set(star, { kind: "system", id: system.id });
this.selectableTargets.set(halo, { kind: "system", id: system.id });
starCluster.traverse((child) => {
if (child instanceof THREE.Mesh) {
this.selectableTargets.set(child, { kind: "system", id: system.id });
}
});
this.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
Array.from({ length: 80 }, (_, index) => {
const angle = (index / 80) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * planet.orbitRadius,
0,
Math.sin(angle) * planet.orbitRadius,
);
}),
),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.45 }),
);
const orbit = this.createPlanetOrbit(planet);
const planetMesh = new THREE.Mesh(
new THREE.SphereGeometry(planet.size, 18, 18),
new THREE.MeshStandardMaterial({
color: planet.color,
roughness: 0.92,
metalness: 0.08,
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
}),
);
planetMesh.position.set(planet.orbitRadius, 0, 0);
planetMesh.position.copy(this.computePlanetLocalPosition(planet, this.currentWorldTimeSeconds()));
const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2));
planetIcon.position.copy(planetMesh.position);
const ring = planet.hasRing ? this.createPlanetRing(planet) : undefined;
if (ring) {
ring.position.copy(planetMesh.position);
}
const moons = this.createMoonVisuals(planet);
root.add(orbit, planetMesh, planetIcon);
if (ring) {
root.add(ring);
}
for (const moon of moons) {
moon.orbit.position.copy(planetMesh.position);
moon.mesh.position.copy(planetMesh.position);
root.add(moon.orbit, moon.mesh);
this.orbitLines.push(moon.orbit);
this.registerPresentation(moon.mesh, planetIcon, true, true);
}
this.orbitLines.push(orbit);
this.registerPresentation(planetMesh, planetIcon, true, true);
if (ring) {
this.registerPresentation(ring, planetIcon, true, true);
}
this.planetVisuals.push({ planet, mesh: planetMesh, icon: planetIcon, ring, moons });
this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
}
@@ -410,7 +427,7 @@ export class GameViewer {
for (const node of nodes) {
const mesh = this.createNodeMesh(node);
const icon = this.createTacticalIcon("#d2b07a", 20);
const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
icon.position.copy(mesh.position);
this.nodeMeshes.set(node.id, mesh);
this.nodeGroup.add(mesh, icon);
@@ -585,7 +602,8 @@ export class GameViewer {
this.detailTitleEl.textContent = `Node ${node.id}`;
this.detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
return;
}
@@ -599,7 +617,9 @@ export class GameViewer {
this.detailTitleEl.textContent = planet.label;
this.detailBodyEl.innerHTML = `
<p>${system.label}</p>
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Size ${planet.size.toFixed(0)}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
`;
return;
}
@@ -611,12 +631,15 @@ export class GameViewer {
this.detailTitleEl.textContent = system.label;
this.detailBodyEl.innerHTML = `
<p>${system.id}</p>
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Height ${system.position.y.toFixed(0)}</p>
`;
}
private render() {
const delta = Math.min(this.clock.getDelta(), 0.033);
this.updateCamera(delta);
this.updatePlanetPresentation();
this.updateShipPresentation();
this.updateNetworkPanel();
this.applyZoomPresentation();
@@ -770,16 +793,132 @@ export class GameViewer {
this.updateSystemSummaryPresentation();
}
private updatePlanetPresentation() {
const nowSeconds = this.currentWorldTimeSeconds();
for (const visual of this.planetVisuals) {
const position = this.computePlanetLocalPosition(visual.planet, nowSeconds);
visual.mesh.position.copy(position);
visual.icon.position.copy(position);
if (visual.ring) {
visual.ring.position.copy(position);
}
for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.position.copy(position);
moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds));
}
}
}
private createNodeMesh(node: ResourceNodeSnapshot) {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xd2b07a,
flatShading: !isGas,
transparent: isGas,
opacity: isGas ? 0.68 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05),
}),
);
mesh.position.copy(this.toThreeVector(node.position));
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
return mesh;
}
private createStarCluster(system: SystemSnapshot) {
const root = new THREE.Group();
const offsets = system.starCount > 1
? [new THREE.Vector3(-system.starSize * 0.55, 0, 0), new THREE.Vector3(system.starSize * 0.75, system.starSize * 0.08, 0)]
: [new THREE.Vector3(0, 0, 0)];
for (const [index, offset] of offsets.entries()) {
const sizeScale = index === 0 ? 1 : 0.72;
const star = new THREE.Mesh(
new THREE.SphereGeometry(system.starSize * sizeScale, 28, 28),
new THREE.MeshBasicMaterial({ color: system.starColor }),
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(system.starSize * sizeScale * 1.72, 24, 24),
new THREE.MeshBasicMaterial({
color: system.starColor,
transparent: true,
opacity: this.starHaloOpacity(system.starKind),
side: THREE.BackSide,
}),
);
star.position.copy(offset);
halo.position.copy(offset);
root.add(star, halo);
}
return root;
}
private createPlanetOrbit(planet: PlanetSnapshot) {
const points = Array.from({ length: 120 }, (_, index) => {
const phaseDegrees = (index / 120) * 360;
return this.computePlanetLocalPosition(planet, 0, phaseDegrees);
});
return new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
);
}
private createPlanetRing(planet: PlanetSnapshot) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(planet.size * 1.35, planet.size * 2.15, 48),
new THREE.MeshBasicMaterial({
color: 0xdac89a,
transparent: true,
opacity: 0.42,
side: THREE.DoubleSide,
}),
);
ring.rotation.x = Math.PI / 2;
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
return ring;
}
private createMoonVisuals(planet: PlanetSnapshot) {
const moonCount = Math.min(planet.moonCount, 12);
const moons: MoonVisual[] = [];
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const orbitRadius = this.computeMoonOrbitRadius(planet, moonIndex);
const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
Array.from({ length: 48 }, (_, index) => {
const angle = (index / 48) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
}),
),
new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }),
);
orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35);
const moonSize = this.computeMoonSize(planet, moonIndex);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(moonSize, 12, 12),
new THREE.MeshStandardMaterial({
color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55),
roughness: 0.96,
metalness: 0.02,
}),
);
moons.push({ mesh, orbit });
}
return moons;
}
private createStationMesh(station: StationSnapshot) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
@@ -893,6 +1032,7 @@ export class GameViewer {
const ships = shipCounts.get(systemId) ?? 0;
const stations = stationCounts.get(systemId) ?? 0;
const structures = structureCounts.get(systemId) ?? 0;
const gasClouds = [...this.world.nodes.values()].filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud").length;
const total = ships + stations + structures;
if (total > 0) {
context.fillStyle = "rgba(3, 8, 18, 0.72)";
@@ -902,7 +1042,7 @@ export class GameViewer {
this.drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff");
this.drawCountIcon(context, "station", 256, 98, stations, "#ffbf69");
this.drawCountIcon(context, "structure", 386, 98, structures, "#98adc4");
this.drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4");
}
visual.texture.needsUpdate = true;
@@ -1081,6 +1221,94 @@ export class GameViewer {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}
private currentWorldTimeSeconds() {
if (!this.world) {
return 0;
}
const baseUtcMs = Date.parse(this.world.generatedAtUtc);
const elapsedMs = performance.now() - this.worldTimeSyncMs;
return ((baseUtcMs + elapsedMs) / 1000) + (this.world.seed * 97);
}
private computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds: number, phaseOverrideDegrees?: number) {
const eccentricity = THREE.MathUtils.clamp(planet.orbitEccentricity, 0, 0.85);
const meanAnomaly = THREE.MathUtils.degToRad(phaseOverrideDegrees ?? planet.orbitPhaseAtEpoch) + (timeSeconds * planet.orbitSpeed);
const eccentricAnomaly = meanAnomaly
+ (eccentricity * Math.sin(meanAnomaly))
+ (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly));
const semiMajorAxis = planet.orbitRadius;
const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05));
const local = new THREE.Vector3(
semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity),
0,
semiMinorAxis * Math.sin(eccentricAnomaly),
);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitArgumentOfPeriapsis));
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(planet.orbitInclination));
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitLongitudeOfAscendingNode));
return local;
}
private computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number) {
const orbitRadius = this.computeMoonOrbitRadius(planet, moonIndex);
const speed = this.computeMoonOrbitSpeed(planet, moonIndex);
const phase = this.hashUnit(`${planet.label}:${moonIndex}:phase`) * Math.PI * 2;
const inclination = THREE.MathUtils.degToRad((this.hashUnit(`${planet.label}:${moonIndex}:inclination`) - 0.5) * 28);
const node = THREE.MathUtils.degToRad(this.hashUnit(`${planet.label}:${moonIndex}:node`) * 360);
const angle = phase + (timeSeconds * speed);
const local = new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node);
return local;
}
private computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number) {
const spacing = planet.size * 1.4;
const variance = this.hashUnit(`${planet.label}:${moonIndex}:radius`) * planet.size * 0.9;
return (planet.size * 1.8) + (moonIndex * spacing) + variance;
}
private computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number) {
const radius = this.computeMoonOrbitRadius(planet, moonIndex);
return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003);
}
private computeMoonSize(planet: PlanetSnapshot, moonIndex: number) {
const base = Math.max(2.2, planet.size * 0.11);
const variance = this.hashUnit(`${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5);
return Math.min(base + variance, planet.size * 0.42);
}
private hashUnit(value: string) {
let hash = this.world?.seed ?? 1;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index);
hash |= 0;
}
return (hash >>> 0) / 0xffffffff;
}
private starHaloOpacity(starKind: string) {
if (starKind.includes("neutron")) {
return 0.22;
}
if (starKind.includes("white-dwarf")) {
return 0.18;
}
if (starKind.includes("brown-dwarf")) {
return 0.1;
}
return 0.14;
}
private screenPointFromClient(clientX: number, clientY: number) {
const bounds = this.renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);

View File

@@ -41,6 +41,8 @@ export interface SystemSnapshot {
id: string;
label: string;
position: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
@@ -48,7 +50,16 @@ export interface SystemSnapshot {
export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
orbitArgumentOfPeriapsis: number;
orbitPhaseAtEpoch: number;
size: number;
color: string;
hasRing: boolean;
@@ -58,6 +69,7 @@ export interface ResourceNodeSnapshot {
id: string;
systemId: string;
position: Vector3Dto;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;