feat: improved visualisation and x4 data import

This commit is contained in:
2026-03-18 20:58:17 -04:00
parent 358122a74a
commit f98c47a8a7
45 changed files with 32840 additions and 1482 deletions

View File

@@ -1,20 +1,36 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record StarSnapshot(
string Kind,
string Color,
string Glow,
float Size,
float OrbitRadius,
float OrbitSpeed,
float OrbitPhaseAtEpoch);
public sealed record MoonSnapshot(
string Label,
float Size,
string Color,
float OrbitRadius,
float OrbitSpeed,
float OrbitPhaseAtEpoch,
float OrbitInclination,
float OrbitLongitudeOfAscendingNode);
public sealed record SystemSnapshot(
string Id,
string Label,
Vector3Dto GalaxyPosition,
string StarKind,
int StarCount,
string StarColor,
float StarSize,
IReadOnlyList<StarSnapshot> Stars,
IReadOnlyList<PlanetSnapshot> Planets);
public sealed record PlanetSnapshot(
string Label,
string PlanetType,
string Shape,
int MoonCount,
IReadOnlyList<MoonSnapshot> Moons,
float OrbitRadius,
float OrbitSpeed,
float OrbitEccentricity,

View File

@@ -1,3 +1,5 @@
using System.Text.Json.Serialization;
namespace SpaceGame.Simulation.Api.Data;
public sealed class ConstructionDefinition
@@ -13,6 +15,29 @@ public sealed class ConstructionDefinition
public int Priority { get; set; }
}
public sealed class ItemPriceDefinition
{
public float Min { get; set; }
public float Max { get; set; }
public float Avg { get; set; }
}
public sealed class ItemEffectDefinition
{
public required string Type { get; set; }
public float Product { get; set; }
}
public sealed class ItemProductionDefinition
{
public float Time { get; set; }
public float Amount { get; set; }
public string Method { get; set; } = "default";
public string Name { get; set; } = "Universal";
public List<RecipeInputDefinition> Wares { get; set; } = [];
public List<ItemEffectDefinition> Effects { get; set; } = [];
}
public sealed class BalanceDefinition
{
public float YPlane { get; set; }
@@ -25,17 +50,35 @@ public sealed class BalanceDefinition
public float UndockDistance { get; set; }
}
public sealed class StarDefinition
{
public string Kind { get; set; } = "main-sequence";
public required string Color { get; set; }
public required string Glow { get; set; }
public float Size { get; set; }
public float OrbitRadius { get; set; }
public float OrbitSpeed { get; set; }
public float OrbitPhaseAtEpoch { get; set; }
}
public sealed class MoonDefinition
{
public required string Label { get; set; }
public float Size { get; set; }
public required string Color { get; set; }
public float OrbitRadius { get; set; }
public float OrbitSpeed { get; set; }
public float OrbitPhaseAtEpoch { get; set; }
public float OrbitInclination { get; set; }
public float OrbitLongitudeOfAscendingNode { get; set; }
}
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; }
public float GravityWellRadius { get; set; }
public required List<StarDefinition> Stars { get; set; }
public required AsteroidFieldDefinition AsteroidField { get; set; }
public required List<ResourceNodeDefinition> ResourceNodes { get; set; }
public required List<PlanetDefinition> Planets { get; set; }
@@ -68,9 +111,21 @@ public sealed class ItemDefinition
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Type { get; set; } = "material";
public required string CargoKind { get; set; }
public string CargoKind { get; set; } = string.Empty;
public float Volume { get; set; } = 1f;
public int Version { get; set; }
public string FactoryName { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string Group { get; set; } = string.Empty;
public ItemPriceDefinition? Price { get; set; }
public List<string> Illegal { get; set; } = [];
public List<ItemProductionDefinition> Production { get; set; } = [];
public ConstructionDefinition? Construction { get; set; }
[JsonPropertyName("transport")]
public string Transport
{
set => CargoKind = value;
}
}
public sealed class RecipeOutputDefinition
@@ -81,8 +136,13 @@ public sealed class RecipeOutputDefinition
public sealed class RecipeInputDefinition
{
public required string ItemId { get; set; }
public string ItemId { get; set; } = string.Empty;
public float Amount { get; set; }
[JsonPropertyName("ware")]
public string Ware
{
set => ItemId = value;
}
}
public sealed class ModuleConstructionDefinition
@@ -91,18 +151,73 @@ public sealed class ModuleConstructionDefinition
public float ProductionTime { get; set; }
}
public sealed class ModuleDockDefinition
{
public int Capacity { get; set; }
public required string Size { get; set; }
}
public sealed class ModuleCargoDefinition
{
public float Max { get; set; }
public required string Type { get; set; }
}
public sealed class ModuleWorkForceDefinition
{
public float Capacity { get; set; }
public float Max { get; set; }
public string Race { get; set; } = string.Empty;
}
public sealed class ModuleMountDefinition
{
public required string Group { get; set; }
public required string Size { get; set; }
public bool Hittable { get; set; }
public List<string> Types { get; set; } = [];
}
public sealed class ModuleProductionDefinition
{
public float Time { get; set; }
public float Amount { get; set; }
public string Method { get; set; } = "default";
public string Name { get; set; } = "Universal";
public List<RecipeInputDefinition> Wares { get; set; } = [];
}
public sealed class ModuleDefinition
{
public required string Id { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public required string Type { get; set; }
[JsonIgnore]
public string? Product { get; set; }
public List<string> Products { get; set; } = [];
public string ProductionMode { get; set; } = "passive";
public float Radius { get; set; } = 12f;
public float Hull { get; set; } = 100f;
public float WorkforceNeeded { get; set; }
public int Version { get; set; }
public string Macro { get; set; } = string.Empty;
public string MakerRace { get; set; } = string.Empty;
public int ExplosionDamage { get; set; }
public ItemPriceDefinition? Price { get; set; }
public List<string> Owners { get; set; } = [];
public ModuleCargoDefinition? Cargo { get; set; }
public ModuleWorkForceDefinition? WorkForce { get; set; }
public List<ModuleDockDefinition> Docks { get; set; } = [];
public List<ModuleMountDefinition> Shields { get; set; } = [];
public List<ModuleMountDefinition> Turrets { get; set; } = [];
public List<ModuleProductionDefinition> Production { get; set; } = [];
public ModuleConstructionDefinition? Construction { get; set; }
[JsonPropertyName("product")]
public List<string> ProductIds
{
set => Products = value ?? [];
}
}
public sealed class ModuleRecipeDefinition
@@ -130,7 +245,7 @@ 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 List<MoonDefinition> Moons { get; set; } = [];
public float OrbitRadius { get; set; }
public float OrbitSpeed { get; set; }
public float OrbitEccentricity { get; set; }

View File

@@ -8,19 +8,11 @@ public sealed partial class ScenarioLoader
private const string DevelopmentCompanionSystemId = "helios";
private static List<SolarSystemDefinition> InjectSpecialSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
bool includeSolSystem)
IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
return authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (includeSolSystem && systems.All((system) => system.Id != "sol"))
{
systems.Add(CreateSolSystem());
}
return systems;
}
private static List<SolarSystemDefinition> ExpandSystems(
@@ -156,12 +148,16 @@ public sealed partial class ScenarioLoader
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),
Stars =
[
new StarDefinition
{
Kind = starProfile.Kind,
Color = starProfile.StarColor,
Glow = starProfile.StarGlow,
Size = starProfile.BaseSize + ((generatedIndex % 4) * 2f),
},
],
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10),
@@ -181,12 +177,7 @@ public sealed partial class ScenarioLoader
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,
Stars = definition.Stars.Select(s => new StarDefinition { Kind = s.Kind, Color = s.Color, Glow = s.Glow, Size = s.Size, OrbitRadius = s.OrbitRadius, OrbitSpeed = s.OrbitSpeed, OrbitPhaseAtEpoch = s.OrbitPhaseAtEpoch }).ToList(),
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = definition.AsteroidField.DecorationCount,
@@ -214,7 +205,7 @@ public sealed partial class ScenarioLoader
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
MoonCount = planet.MoonCount,
Moons = planet.Moons.Select(m => new MoonDefinition { Label = m.Label, Size = m.Size, Color = m.Color, OrbitRadius = m.OrbitRadius, OrbitSpeed = m.OrbitSpeed, OrbitPhaseAtEpoch = m.OrbitPhaseAtEpoch, OrbitInclination = m.OrbitInclination, OrbitLongitudeOfAscendingNode = m.OrbitLongitudeOfAscendingNode }).ToList(),
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
@@ -387,14 +378,15 @@ public sealed partial class ScenarioLoader
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);
var moonCount = profile.BaseMoonCount + (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
var planetLabel = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}";
planets.Add(new PlanetDefinition
{
Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}",
Label = planetLabel,
PlanetType = profile.Type,
Shape = profile.Shape,
MoonCount = profile.BaseMoonCount + moonVariance,
Moons = GenerateMoons(planetLabel, profile.BaseSize, moonCount),
OrbitRadius = orbitRadius,
OrbitSpeed = 0.11f / MathF.Sqrt(MathF.Max(orbitRadius * orbitRadius * orbitRadius, 0.02f)),
OrbitEccentricity = orbitEccentricity,
@@ -471,12 +463,44 @@ public sealed partial class ScenarioLoader
return (value & 0x00ffffff) / 16777215f;
}
private static List<MoonDefinition> GenerateMoons(string planetLabel, float planetSize, int moonCount)
{
var seed = planetLabel.Aggregate(0, (acc, c) => acc * 31 + c);
var moons = new List<MoonDefinition>(moonCount);
for (var moonIndex = 0; moonIndex < moonCount; moonIndex += 1)
{
var spacing = planetSize * 1.4f;
var radiusVariance = Hash01(seed, 10 + moonIndex) * planetSize * 0.9f;
var orbitRadius = (planetSize * 1.8f) + (moonIndex * spacing) + radiusVariance;
var orbitSpeed = 0.9f / MathF.Sqrt(MathF.Max(orbitRadius, 1f)) + (moonIndex * 0.003f);
var phase = Hash01(seed, 20 + moonIndex) * 360f;
var inclination = (Hash01(seed, 30 + moonIndex) - 0.5f) * 28f;
var ascendingNode = Hash01(seed, 40 + moonIndex) * 360f;
var sizeBase = MathF.Max(2.2f, planetSize * 0.11f);
var sizeVariance = Hash01(seed, 50 + moonIndex) * MathF.Max(planetSize * 0.16f, 2.5f);
var size = MathF.Min(sizeBase + sizeVariance, planetSize * 0.42f);
moons.Add(new MoonDefinition
{
Label = $"{planetLabel}-m{moonIndex + 1}",
Size = size,
Color = "#c8c4bc",
OrbitRadius = orbitRadius,
OrbitSpeed = orbitSpeed,
OrbitPhaseAtEpoch = phase,
OrbitInclination = inclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
});
}
return moons;
}
private sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize,
int StarCount);
float BaseSize);
private sealed record PlanetProfile(
string Type,
@@ -490,106 +514,4 @@ public sealed partial class ScenarioLoader
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}
private static SolarSystemDefinition CreateSolSystem()
{
var mercuryOrbitAu = 0.3871f;
var venusOrbitAu = 0.7233f;
var earthOrbitAu = 1.000f;
var marsOrbitAu = 1.5237f;
var jupiterOrbitAu = 5.203f;
var saturnOrbitAu = 9.582f;
var uranusOrbitAu = 19.201f;
var neptuneOrbitAu = 30.047f;
return new SolarSystemDefinition
{
Id = "sol",
Label = "Sol",
Position = [18.2f, 0.02f, -11.8f],
StarKind = "main-sequence",
StarCount = 1,
StarColor = "#fff1b8",
StarGlow = "#ffd35a",
StarSize = 696340f,
GravityWellRadius = 240f,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = 240,
RadiusOffset = 422000000f,
RadiusVariance = 180000000f,
HeightVariance = 22000000f,
},
ResourceNodes =
[
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 },
],
Planets =
[
CreateSolPlanet("Mercury", "barren", "sphere", 0, mercuryOrbitAu, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
CreateSolPlanet("Venus", "desert", "sphere", 0, venusOrbitAu, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, earthOrbitAu, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
CreateSolPlanet("Mars", "desert", "sphere", 2, marsOrbitAu, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, jupiterOrbitAu, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, saturnOrbitAu, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, uranusOrbitAu, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, neptuneOrbitAu, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
],
};
}
private static PlanetDefinition CreateSolPlanet(
string label,
string planetType,
string shape,
int moonCount,
float orbitRadiusAu,
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 = orbitRadiusAu,
OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
OrbitArgumentOfPeriapsis = argumentOfPeriapsis,
OrbitPhaseAtEpoch = phaseAtEpoch,
Size = planetType switch
{
"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,
HasRing = hasRing,
};
}
private static float ComputeSolOrbitSpeed(float orbitRadiusAu)
{
const float earthAngularSpeed = 0.11f;
return earthAngularSpeed / MathF.Sqrt(orbitRadiusAu * orbitRadiusAu * orbitRadiusAu);
}
}

View File

@@ -60,14 +60,14 @@ public sealed partial class ScenarioLoader
.ToList();
var refineries = ownedStations
.Where((station) => HasInstalledModules(station, "refinery-stack", "power-core", "liquid-tank"))
.Where((station) => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"))
.ToList();
if (refineries.Count > 0)
{
foreach (var refinery in refineries)
{
refinery.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refined-metals"), MinimumRefineryStock);
refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock);
}
if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
@@ -76,9 +76,9 @@ public sealed partial class ScenarioLoader
}
}
foreach (var shipyard in ownedStations.Where((station) => HasInstalledModules(station, "ship-factory")))
foreach (var shipyard in ownedStations.Where((station) => HasInstalledModules(station, "module_gen_build_l_01")))
{
shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock);
shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock);
}
}
}
@@ -189,13 +189,13 @@ public sealed partial class ScenarioLoader
{
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
{
("refinery-stack", 1),
("container-bay", 1),
("fabricator-array", 2),
("component-factory", 1),
("ship-factory", 1),
("solar-array", 2),
("dock-bay-small", 2),
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
})
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
@@ -210,7 +210,7 @@ public sealed partial class ScenarioLoader
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "habitat-ring");
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.Population = habitatModules > 0

View File

@@ -9,13 +9,18 @@ public sealed partial class ScenarioLoader
var celestials = new List<CelestialRuntime>();
var lagrangeNodesByPlanetIndex = new Dictionary<int, Dictionary<string, CelestialRuntime>>();
AddCelestial(
celestials,
id: $"node-{system.Definition.Id}-star",
systemId: system.Definition.Id,
kind: SpatialNodeKind.Star,
position: Vector3.Zero,
localSpaceRadius: MathF.Max(system.Definition.GravityWellRadius + StarBubbleRadiusPadding, LocalSpaceRadius));
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
{
AddCelestial(
celestials,
id: $"node-{system.Definition.Id}-star-{starIndex + 1}",
systemId: system.Definition.Id,
kind: SpatialNodeKind.Star,
position: Vector3.Zero,
localSpaceRadius: LocalSpaceRadius);
}
var primaryStarNodeId = $"node-{system.Definition.Id}-star-1";
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
@@ -29,7 +34,7 @@ public sealed partial class ScenarioLoader
kind: SpatialNodeKind.Planet,
position: planetPosition,
localSpaceRadius: LocalSpaceRadius,
parentNodeId: $"node-{system.Definition.Id}-star");
parentNodeId: primaryStarNodeId);
var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
@@ -48,15 +53,10 @@ public sealed partial class ScenarioLoader
lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes;
if (planet.MoonCount <= 0)
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
{
continue;
}
var moonOrbitRadius = MathF.Max(planet.Size + 48f, 42f);
for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1)
{
var moonPosition = ComputeMoonPosition(planetPosition, moonOrbitRadius, moonIndex, planetIndex);
var moon = planet.Moons[moonIndex];
var moonPosition = ComputeMoonPosition(planetPosition, moon);
AddCelestial(
celestials,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}",
@@ -65,7 +65,6 @@ public sealed partial class ScenarioLoader
position: moonPosition,
localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id);
moonOrbitRadius += 30f;
}
}
@@ -232,10 +231,11 @@ public sealed partial class ScenarioLoader
return new Vector3(x, 0f, z);
}
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, float orbitRadius, int moonIndex, int planetIndex)
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
{
var angle = ((MathF.PI * 2f) / MathF.Max(1, moonIndex + 3)) * (moonIndex + 1) + (planetIndex * 0.37f);
return Add(planetPosition, new Vector3(MathF.Cos(angle) * orbitRadius, 0f, MathF.Sin(angle) * orbitRadius));
var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch);
var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius);
return Add(planetPosition, local);
}
private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)

View File

@@ -12,7 +12,6 @@ public sealed partial class ScenarioLoader
private const float MinimumRefineryStock = 0f;
private const float MinimumShipyardStock = 0f;
private const float MinimumSystemSeparation = 3.2f;
private const float StarBubbleRadiusPadding = 40f;
private const float LocalSpaceRadius = 10_000f;
private static readonly string[] GeneratedSystemNames =
[
@@ -51,13 +50,13 @@ public sealed partial class ScenarioLoader
];
private static readonly StarProfile[] StarProfiles =
[
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", 780000f, 2),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f, 2),
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
];
private static readonly PlanetProfile[] PlanetProfiles =
[
@@ -88,16 +87,16 @@ public sealed partial class ScenarioLoader
{
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var systems = ExpandSystems(
InjectSpecialSystems(authoredSystems, _worldGeneration.IncludeSolSystem),
InjectSpecialSystems(authoredSystems),
_worldGeneration.TargetSystemCount);
var scenario = NormalizeScenarioToAvailableSystems(
Read<ScenarioDefinition>("scenario.json"),
systems.Select((system) => system.Id).ToList());
var modules = Read<List<ModuleDefinition>>("modules.json");
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json");
var items = Read<List<ItemDefinition>>("items.json");
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
var balance = Read<BalanceDefinition>("balance.json");
var recipes = BuildRecipes(items, ships);
var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules);
var moduleDefinitions = modules.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
@@ -177,7 +176,7 @@ public sealed partial class ScenarioLoader
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["dock-bay-small", "power-core", "bulk-bay", "liquid-tank"];
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
foreach (var moduleId in startingModules)
{
AddStationModule(stations[^1], moduleDefinitions, moduleId);
@@ -187,7 +186,7 @@ public sealed partial class ScenarioLoader
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refined-metals"] = 120f;
station.Inventory["refinedmetals"] = 120f;
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
@@ -195,9 +194,9 @@ public sealed partial class ScenarioLoader
}
var refinery = stations.FirstOrDefault((station) =>
HasInstalledModules(station, "power-core", "liquid-tank") &&
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank"));
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
var patrolRoutes = scenario.PatrolRoutes
.GroupBy((route) => route.SystemId, StringComparer.Ordinal)
@@ -400,12 +399,12 @@ public sealed partial class ScenarioLoader
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> modules) =>
modules
.Where((module) => module.Construction is not null)
.Where((module) => module.Construction is not null || module.Production.Count > 0)
.Select((module) => new ModuleRecipeDefinition
{
ModuleId = module.Id,
Duration = module.Construction!.ProductionTime,
Inputs = module.Construction.Requirements
Duration = module.Construction?.ProductionTime ?? module.Production[0].Time,
Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares)
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
@@ -415,12 +414,54 @@ public sealed partial class ScenarioLoader
})
.ToList();
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships)
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
{
var recipes = new List<RecipeDefinition>();
var preferredProducerByItemId = modules
.Where((module) => module.Products.Count > 0)
.GroupBy((module) => module.Products[0], StringComparer.Ordinal)
.ToDictionary(
(group) => group.Key,
(group) => group.OrderBy((module) => module.Id, StringComparer.Ordinal).First().Id,
StringComparer.Ordinal);
foreach (var item in items)
{
if (item.Production.Count > 0)
{
foreach (var production in item.Production)
{
recipes.Add(new RecipeDefinition
{
Id = $"{item.Id}-{production.Method}-production",
Label = production.Name == "Universal"
? item.Name
: $"{item.Name} ({production.Name})",
FacilityCategory = InferFacilityCategory(item),
Duration = production.Time,
Priority = InferRecipePriority(item),
RequiredModules = InferRequiredModules(item, preferredProducerByItemId),
Inputs = production.Wares
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = production.Amount,
},
],
});
}
continue;
}
if (item.Construction is null)
{
continue;
@@ -481,6 +522,74 @@ public sealed partial class ScenarioLoader
return recipes;
}
private static string InferFacilityCategory(ItemDefinition item) =>
item.Group switch
{
"agricultural" or "food" or "pharmaceutical" or "water" => "farm",
_ => "station",
};
private static List<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> preferredProducerByItemId)
{
if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId))
{
return [moduleId];
}
return [];
}
private static int InferRecipePriority(ItemDefinition item) =>
item.Group switch
{
"energy" => 140,
"water" => 130,
"food" => 120,
"agricultural" => 110,
"refined" => 100,
"hightech" => 90,
"shiptech" => 80,
"pharmaceutical" => 70,
_ => 60,
};
private static List<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
{
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.Type))
{
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
}
}
return items;
}
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
{
foreach (var module in modules)
{
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
{
module.Products = [module.Product];
}
if (string.IsNullOrWhiteSpace(module.ProductionMode))
{
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
? "commanded"
: "passive";
}
if (module.WorkforceNeeded <= 0f)
{
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
}
}
return modules;
}
private static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);

View File

@@ -141,9 +141,9 @@ public sealed partial class SimulationEngine
string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)),
ControlledSystemCount = GetFactionControlledSystemsCount(world, factionId),
TargetSystemCount = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count)),
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("ship-factory", StringComparer.Ordinal)),
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refined-metals")),
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")),
};
}

View File

@@ -24,37 +24,18 @@ public sealed partial class SimulationEngine
return local;
}
private static Vector3 ComputeMoonOffset(PlanetDefinition planet, int moonIndex, float timeSeconds)
private static Vector3 ComputeMoonOffset(MoonDefinition moon, 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 angle = DegreesToRadians(moon.OrbitPhaseAtEpoch) + (timeSeconds * moon.OrbitSpeed);
var local = new Vector3(
MathF.Cos(angle) * orbitRadius,
MathF.Cos(angle) * moon.OrbitRadius,
0f,
MathF.Sin(angle) * orbitRadius);
local = RotateAroundX(local, inclination);
local = RotateAroundY(local, ascendingNode);
MathF.Sin(angle) * moon.OrbitRadius);
local = RotateAroundX(local, DegreesToRadians(moon.OrbitInclination));
local = RotateAroundY(local, DegreesToRadians(moon.OrbitLongitudeOfAscendingNode));
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 = 0.24f;
@@ -179,10 +160,24 @@ public sealed partial class SimulationEngine
foreach (var system in world.Systems)
{
var starNodeId = $"node-{system.Definition.Id}-star";
if (celestialsById.TryGetValue(starNodeId, out var starNode))
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
{
starNode.Position = Vector3.Zero;
var star = system.Definition.Stars[starIndex];
var starNodeId = $"node-{system.Definition.Id}-star-{starIndex + 1}";
if (!celestialsById.TryGetValue(starNodeId, out var starNode))
{
continue;
}
if (star.OrbitRadius <= 0f)
{
starNode.Position = Vector3.Zero;
}
else
{
var angle = DegreesToRadians(star.OrbitPhaseAtEpoch) + (worldTimeSeconds * star.OrbitSpeed);
starNode.Position = new Vector3(MathF.Cos(angle) * star.OrbitRadius, 0f, MathF.Sin(angle) * star.OrbitRadius);
}
}
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
@@ -206,7 +201,7 @@ public sealed partial class SimulationEngine
}
}
for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1)
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
{
var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
if (!celestialsById.TryGetValue(moonId, out var moonNode))
@@ -214,7 +209,7 @@ public sealed partial class SimulationEngine
continue;
}
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet, moonIndex, worldTimeSeconds));
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet.Moons[moonIndex], worldTimeSeconds));
}
}
}

View File

@@ -44,9 +44,9 @@ public sealed partial class SimulationEngine
_ => 0f,
};
var bulkBays = CountStationModules(station, "bulk-bay");
var liquidTanks = CountStationModules(station, "liquid-tank");
var containerBays = CountStationModules(station, "container-bay");
var bulkBays = CountStationModules(station, "module_arg_stor_solid_m_01");
var liquidTanks = CountStationModules(station, "module_arg_stor_liquid_m_01");
var containerBays = CountStationModules(station, "module_arg_stor_container_m_01");
var moduleCapacity = storageClass switch
{
@@ -118,8 +118,8 @@ public sealed partial class SimulationEngine
private static string? GetStorageRequirement(string storageClass) =>
storageClass switch
{
"solid" => "bulk-bay",
"liquid" => "liquid-tank",
"solid" => "module_arg_stor_solid_m_01",
"liquid" => "module_arg_stor_liquid_m_01",
_ => null,
};

View File

@@ -20,15 +20,27 @@ public sealed partial 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.Stars.Select(star => new StarSnapshot(
star.Kind,
star.Color,
star.Glow,
star.Size,
star.OrbitRadius,
star.OrbitSpeed,
star.OrbitPhaseAtEpoch)).ToList(),
system.Definition.Planets.Select(planet => new PlanetSnapshot(
planet.Label,
planet.PlanetType,
planet.Shape,
planet.MoonCount,
planet.Moons.Select(moon => new MoonSnapshot(
moon.Label,
moon.Size,
moon.Color,
moon.OrbitRadius,
moon.OrbitSpeed,
moon.OrbitPhaseAtEpoch,
moon.OrbitInclination,
moon.OrbitLongitudeOfAscendingNode)).ToList(),
planet.OrbitRadius,
planet.OrbitSpeed,
planet.OrbitEccentricity,

View File

@@ -110,9 +110,9 @@ public sealed partial class SimulationEngine
const float StorageExpansionThreshold = 0.85f;
var storageExpansionCandidates = new[]
{
("solid", "bulk-bay"),
("liquid", "liquid-tank"),
("container", "container-bay"),
("solid", "module_arg_stor_solid_m_01"),
("liquid", "module_arg_stor_liquid_m_01"),
("container", "module_arg_stor_container_m_01"),
};
foreach (var (storageClass, moduleId) in storageExpansionCandidates)
@@ -136,25 +136,25 @@ public sealed partial class SimulationEngine
var priorities = GetFactionExpansionPressure(world, station.FactionId) > 0f
? new (string ModuleId, int TargetCount)[]
{
("refinery-stack", 1),
("bulk-bay", 1),
("container-bay", 1),
("fabricator-array", 2),
("component-factory", 1),
("ship-factory", 1),
("dock-bay-small", 2),
("solar-array", 2),
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_arg_dock_m_01_lowtech", 2),
("module_gen_prod_energycells_01", 2),
}
: new (string ModuleId, int TargetCount)[]
{
("refinery-stack", 1),
("bulk-bay", 1),
("container-bay", 1),
("fabricator-array", 2),
("component-factory", 1),
("ship-factory", 1),
("solar-array", 2),
("dock-bay-small", 2),
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
};
foreach (var (moduleId, targetCount) in priorities)
@@ -225,7 +225,7 @@ public sealed partial class SimulationEngine
}
private static int GetDockingPadCount(StationRuntime station) =>
CountModules(station.InstalledModules, "dock-bay-small") * 2;
CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2;
private static int? ReserveDockingPad(StationRuntime station, string shipId)
{

View File

@@ -14,22 +14,22 @@ public sealed partial class SimulationEngine
var desiredOrders = new List<DesiredMarketOrder>();
var waterReserve = MathF.Max(30f, station.Population * 3f);
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
var shipPartsReserve = HasStationModules(station, "fabricator-array")
&& !HasStationModules(station, "component-factory", "ship-factory")
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01")
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
? 90f
: 0f;
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f);
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f);
AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f);
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
AddSupplyOrder(desiredOrders, station, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
ReconcileStationMarketOrders(world, station, desiredOrders);
}
@@ -133,7 +133,7 @@ public sealed partial class SimulationEngine
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
priority += recipe.Id switch
{
"ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory")
"ship-parts-integration" => HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
? -140f * MathF.Max(expansionPressure, fleetPressure)
: 280f * MathF.Max(expansionPressure, fleetPressure),
"hull-fabrication" => 180f * expansionPressure,
@@ -211,7 +211,7 @@ public sealed partial class SimulationEngine
}
private static bool HasRefineryCapability(StationRuntime station) =>
HasStationModules(station, "refinery-stack", "power-core", "bulk-bay");
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01");
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
{

View File

@@ -31,7 +31,7 @@ public sealed partial class SimulationEngine
var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds;
var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater);
var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater;
var habitatModules = CountModules(station.InstalledModules, "habitat-ring");
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
if (waterSatisfied)

View File

@@ -4,5 +4,4 @@ public sealed class WorldGenerationOptions
{
public int TargetSystemCount { get; init; } = 160;
public bool IncludeSolSystem { get; init; } = true;
}

View File

@@ -28,19 +28,12 @@ import { SystemLayer } from "./viewerSystemLayer";
import { LocalLayer } from "./viewerLocalLayer";
import type { FactionSnapshot } from "./contracts";
import type {
CelestialVisual,
CameraMode,
ClaimVisual,
ConstructionSiteVisual,
DragMode,
HistoryWindowState,
NetworkStats,
NodeVisual,
OrbitLineVisual,
PerformanceStats,
Selectable,
ShipVisual,
StructureVisual,
SystemVisual,
WorldState,
PovLevel,
@@ -51,10 +44,10 @@ export class ViewerAppController {
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
// ── Three independent rendering layers ───────────────────────────────────
private readonly universeLayer = new UniverseLayer();
private readonly galaxyLayer = new GalaxyLayer();
private readonly systemLayer = new SystemLayer();
private readonly localLayer = new LocalLayer();
readonly universeLayer = new UniverseLayer();
readonly galaxyLayer = new GalaxyLayer();
readonly systemLayer = new SystemLayer();
readonly localLayer = new LocalLayer();
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
@@ -70,16 +63,6 @@ export class ViewerAppController {
private readonly gamePanelEl: HTMLDivElement;
private readonly celestialVisuals = new Map<string, CelestialVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly claimVisuals = new Map<string, ClaimVisual>();
private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly planetVisuals: any[] = [];
private readonly orbitLines: OrbitLineVisual[] = [];
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
@@ -98,6 +81,7 @@ export class ViewerAppController {
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
private readonly hoverConnectorLineEl: SVGLineElement;
private world?: WorldState;
private worldTimeSyncMs = performance.now();
@@ -165,6 +149,7 @@ export class ViewerAppController {
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
this.hoverConnectorLineEl = hud.hoverConnectorLineEl;
({
sceneDataController: this.sceneDataController,
navigationController: this.navigationController,
@@ -231,13 +216,10 @@ export class ViewerAppController {
renderFrame({
clock: this.clock,
renderer: this.renderer,
universeScene: this.universeLayer.scene,
galaxyScene: this.galaxyLayer.scene,
galaxyCamera: this.galaxyLayer.camera,
systemScene: this.systemLayer.scene,
systemCamera: this.systemLayer.camera,
localScene: this.localLayer.scene,
localCamera: this.localLayer.camera,
universeLayer: this.universeLayer,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
getPovLevel: () => this.povLevel,
updateCamera: (delta) => this.updateCamera(delta),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
@@ -294,7 +276,7 @@ export class ViewerAppController {
// Update star dot scales in galaxy scene
updateSystemStarPresentation(
this.systemVisuals,
this.galaxyLayer.systemVisuals,
this.activeSystemId,
this.galaxyLayer.camera,
(sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
@@ -343,9 +325,9 @@ export class ViewerAppController {
private onResize = () => {
resizeViewer({
renderer: this.renderer,
galaxyCamera: this.galaxyLayer.camera,
systemCamera: this.systemLayer.camera,
localCamera: this.localLayer.camera,
galaxyLayer: this.galaxyLayer,
systemLayer: this.systemLayer,
localLayer: this.localLayer,
});
};
@@ -354,7 +336,7 @@ export class ViewerAppController {
}
private describeSelectionParent(selection: Selectable) {
return describeSelectionParent(this.world, selection, this.stationVisuals, this.nodeVisuals);
return describeSelectionParent(this.world, selection, this.systemLayer.stationVisuals, this.systemLayer.nodeVisuals);
}
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {

View File

@@ -7,6 +7,8 @@ export type {
OrbitalSimulationSnapshot,
} from "./contractsWorld";
export type {
StarSnapshot,
MoonSnapshot,
SystemSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,

View File

@@ -1,13 +1,31 @@
import type { Vector3Dto } from "./contractsCommon";
export interface StarSnapshot {
kind: string;
color: string;
glow: string;
size: number;
orbitRadius: number;
orbitSpeed: number;
orbitPhaseAtEpoch: number;
}
export interface MoonSnapshot {
label: string;
size: number;
color: string;
orbitRadius: number;
orbitSpeed: number;
orbitPhaseAtEpoch: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
}
export interface SystemSnapshot {
id: string;
label: string;
galaxyPosition: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
stars: StarSnapshot[];
planets: PlanetSnapshot[];
}
@@ -15,7 +33,7 @@ export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
moons: MoonSnapshot[];
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;

View File

@@ -65,6 +65,25 @@ canvas {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04);
}
.hover-connector-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
overflow: visible;
}
.hover-connector-line {
stroke: rgba(255, 88, 72, 0.45);
stroke-width: 1.5;
stroke-dasharray: 4 3;
}
.hover-connector-line[hidden] {
display: none;
}
.hover-label {
position: absolute;
padding: 8px 10px;

View File

@@ -215,6 +215,12 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
return computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
}
if (selection.kind === "moon") {
const system = world.systems.get(selection.systemId);
const planet = system?.planets[selection.planetIndex];
return planet ? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs)) : undefined;
}
const system = world.systems.get(selection.id);
return system ? scaleGalaxyVector(toThreeVector(system.galaxyPosition)) : undefined;
}

View File

@@ -6,6 +6,10 @@ export const NAV_DISTANCE: Record<PovLevel, number> = {
galaxy: 32000,
};
// Close-orbit distance when double-clicking a planet (display units).
// 0.005 units = ~333 km from planet center in system space.
export const NAV_DISTANCE_PLANET_ORBIT = 0.005;
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
export const GALAXY_PARALLAX_FACTOR = 0.025;
export const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000;
@@ -13,7 +17,8 @@ export const PROJECTED_GALAXY_RADIUS = 65000;
export const STAR_RENDER_SCALE = 0.18;
export const PLANET_RENDER_SCALE = 0.95;
export const MOON_RENDER_SCALE = 1.1;
export const MIN_CAMERA_DISTANCE = 2;
// 0.002 units = ~133 km — allows scrolling into low orbit around planets.
export const MIN_CAMERA_DISTANCE = 0.002;
export const MAX_CAMERA_DISTANCE = 150000;
export interface ZoomBlend {

View File

@@ -25,15 +25,14 @@ export function createViewerControllers(host: any) {
shipGroup: host.systemLayer.shipGroup,
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
systemVisuals: host.systemVisuals,
planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines,
celestialVisuals: host.celestialVisuals,
nodeVisuals: host.nodeVisuals,
stationVisuals: host.stationVisuals,
claimVisuals: host.claimVisuals,
constructionSiteVisuals: host.constructionSiteVisuals,
shipVisuals: host.shipVisuals,
systemVisuals: host.galaxyLayer.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals,
celestialVisuals: host.systemLayer.celestialVisuals,
nodeVisuals: host.systemLayer.nodeVisuals,
stationVisuals: host.systemLayer.stationVisuals,
claimVisuals: host.systemLayer.claimVisuals,
constructionSiteVisuals: host.systemLayer.constructionSiteVisuals,
shipVisuals: host.systemLayer.shipVisuals,
});
const navigationController = new ViewerNavigationController({
@@ -62,10 +61,10 @@ export function createViewerControllers(host: any) {
systemAnchor: host.systemAnchor,
galaxyCamera: host.galaxyLayer.camera,
systemCamera: host.systemLayer.camera,
shipVisuals: host.shipVisuals,
nodeVisuals: host.nodeVisuals,
planetVisuals: host.planetVisuals,
systemVisuals: host.systemVisuals,
shipVisuals: host.systemLayer.shipVisuals,
nodeVisuals: host.systemLayer.nodeVisuals,
planetVisuals: host.systemLayer.planetVisuals,
systemVisuals: host.galaxyLayer.systemVisuals,
followCameraPosition: host.followCameraPosition,
followCameraFocus: host.followCameraFocus,
followCameraDirection: host.followCameraDirection,
@@ -103,9 +102,8 @@ export function createViewerControllers(host: any) {
getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance,
planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines,
systemVisuals: host.systemVisuals,
planetVisuals: host.systemLayer.planetVisuals,
systemVisuals: host.galaxyLayer.systemVisuals,
createWorldPresentationContext: () => host.createWorldPresentationContext(),
});
@@ -198,6 +196,7 @@ export function createViewerControllers(host: any) {
galaxySelectableTargets: host.galaxyLayer.selectableTargets,
systemSelectableTargets: host.systemLayer.selectableTargets,
hoverLabelEl: host.hoverLabelEl,
hoverConnectorLineEl: host.hoverConnectorLineEl,
marqueeEl: host.marqueeEl,
keyState: host.keyState,
getWorld: () => host.world,

View File

@@ -1,5 +1,5 @@
import * as THREE from "three";
import type { Selectable } from "./viewerTypes";
import type { Selectable, SystemVisual } from "./viewerTypes";
/**
* Galaxy rendering layer — the galaxy map.
@@ -15,6 +15,7 @@ export class GalaxyLayer {
readonly systemGroup = new THREE.Group();
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
readonly systemVisuals = new Map<string, SystemVisual>();
constructor() {
this.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
@@ -34,4 +35,8 @@ export class GalaxyLayer {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
render(renderer: THREE.WebGLRenderer) {
renderer.render(this.scene, this.camera);
}
}

View File

@@ -19,6 +19,7 @@ export interface ViewerHudElements {
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
}
export function createViewerHud(documentRef: Document): ViewerHudElements {
@@ -73,6 +74,9 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
<div class="history-layer"></div>
<section class="ops-strip"></section>
<div class="marquee-box"></div>
<svg class="hover-connector-svg" aria-hidden="true">
<line class="hover-connector-line" x1="0" y1="0" x2="0" y2="0" hidden></line>
</svg>
<div class="hover-label" hidden></div>
`;
@@ -97,5 +101,6 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
hoverConnectorLineEl: root.querySelector(".hover-connector-line") as unknown as SVGLineElement,
};
}

View File

@@ -68,6 +68,7 @@ export function pickSelectableHitAtClientPosition(
export function updateHoverLabel(params: {
dragMode?: string;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
hoverPick: HoverPickResult | undefined;
activeSystemId?: string;
povLevel: PovLevel;
@@ -77,6 +78,7 @@ export function updateHoverLabel(params: {
const {
dragMode,
hoverLabelEl,
hoverConnectorLineEl,
hoverPick,
activeSystemId,
povLevel,
@@ -84,13 +86,9 @@ export function updateHoverLabel(params: {
point,
} = params;
if (dragMode) {
hoverLabelEl.hidden = true;
return;
}
if (!hoverPick) {
if (dragMode || !hoverPick) {
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
}
@@ -98,6 +96,7 @@ export function updateHoverLabel(params: {
const label = describeHoverLabel(world, selection);
if (!label) {
hoverLabelEl.hidden = true;
hoverConnectorLineEl.setAttribute("hidden", "");
return;
}
@@ -105,8 +104,16 @@ export function updateHoverLabel(params: {
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = `${label}\n${distance}`;
hoverLabelEl.style.left = `${point.x + 14}px`;
hoverLabelEl.style.top = `${point.y + 14}px`;
hoverLabelEl.style.left = `${point.x + 44}px`;
hoverLabelEl.style.top = `${point.y - 90}px`;
const rect = hoverLabelEl.getBoundingClientRect();
const svgRect = (hoverConnectorLineEl.ownerSVGElement as SVGSVGElement).getBoundingClientRect();
hoverConnectorLineEl.removeAttribute("hidden");
hoverConnectorLineEl.setAttribute("x1", String(point.x));
hoverConnectorLineEl.setAttribute("y1", String(point.y));
hoverConnectorLineEl.setAttribute("x2", String(rect.left - svgRect.left));
hoverConnectorLineEl.setAttribute("y2", String(rect.top - svgRect.top + rect.height / 2));
}
function formatHoverDistance(

View File

@@ -12,6 +12,7 @@ import {
toggleCameraMode,
navigateFromWheel,
} from "./viewerControls";
import { NAV_DISTANCE, NAV_DISTANCE_PLANET_ORBIT } from "./viewerConstants";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type {
CameraMode,
@@ -30,6 +31,7 @@ export interface ViewerInteractionContext {
galaxySelectableTargets: Map<THREE.Object3D, Selectable>;
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
hoverConnectorLineEl: SVGLineElement;
marqueeEl: HTMLDivElement;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
@@ -231,8 +233,12 @@ export class ViewerInteractionController {
return;
}
this.context.focusOnSelection(selectedItems[0]);
const selection = selectedItems[0];
this.context.focusOnSelection(selection);
this.context.syncFollowStateFromSelection();
if (selection.kind === "planet") {
this.context.setDesiredDistance(NAV_DISTANCE_PLANET_ORBIT);
}
};
readonly onWheel = (event: WheelEvent) => {
@@ -269,6 +275,7 @@ export class ViewerInteractionController {
updateHoverLabel({
dragMode: this.context.getDragMode(),
hoverLabelEl: this.context.hoverLabelEl,
hoverConnectorLineEl: this.context.hoverConnectorLineEl,
hoverPick: this.pickSelectableHitAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(),
povLevel: this.context.getPovLevel(),

View File

@@ -21,4 +21,8 @@ export class LocalLayer {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
render(renderer: THREE.WebGLRenderer) {
renderer.render(this.scene, this.camera);
}
}

View File

@@ -3,6 +3,7 @@ import { MOON_RENDER_SCALE } from "./viewerConstants";
import type {
ShipSnapshot,
PlanetSnapshot,
MoonSnapshot,
Vector3Dto,
WorldSnapshot,
} from "./contracts";
@@ -176,7 +177,7 @@ export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds:
const eccentricAnomaly = meanAnomaly
+ (eccentricity * Math.sin(meanAnomaly))
+ (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly));
const semiMajorAxis = planet.orbitRadius;
const semiMajorAxis = planet.orbitRadius * KILOMETERS_PER_AU;
const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05));
const local = new THREE.Vector3(
semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity),
@@ -190,47 +191,24 @@ export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds:
return local;
}
export function computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const spacing = planet.size * 1.4;
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:radius`) * planet.size * 0.9;
return (planet.size * 1.8) + (moonIndex * spacing) + variance;
}
export function computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const radius = computeMoonOrbitRadius(planet, moonIndex, seed);
return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003);
}
export function computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number, seed: number): THREE.Vector3 {
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
const speed = computeMoonOrbitSpeed(planet, moonIndex, seed);
const phase = hashUnit(seed, `${planet.label}:${moonIndex}:phase`) * Math.PI * 2;
const inclination = THREE.MathUtils.degToRad((hashUnit(seed, `${planet.label}:${moonIndex}:inclination`) - 0.5) * 28);
const node = THREE.MathUtils.degToRad(hashUnit(seed, `${planet.label}:${moonIndex}:node`) * 360);
const angle = phase + (timeSeconds * speed);
export function computeMoonLocalPosition(moon: MoonSnapshot, timeSeconds: number): THREE.Vector3 {
const angle = THREE.MathUtils.degToRad(moon.orbitPhaseAtEpoch) + (timeSeconds * moon.orbitSpeed);
const local = new THREE.Vector3(
Math.cos(angle) * orbitRadius,
Math.cos(angle) * moon.orbitRadius,
0,
Math.sin(angle) * orbitRadius,
Math.sin(angle) * moon.orbitRadius,
);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(moon.orbitInclination));
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(moon.orbitLongitudeOfAscendingNode));
return local;
}
export function computeMoonSize(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const base = Math.max(2.2, planet.size * 0.11);
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5);
return Math.min(base + variance, planet.size * 0.42);
}
export function celestialRenderRadius(size: number, scale: number, minRadius: number, exponent = 1): number {
return Math.max(minRadius, Math.pow(Math.max(size, 0.1), exponent) * scale);
}
export function computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), 0.00011, 0.025, 0.62);
export function computeMoonRenderRadius(moon: MoonSnapshot): number {
return celestialRenderRadius(moon.size, 0.00011, 0.025, 0.62);
}
export function starHaloOpacity(starKind: string): number {
@@ -251,7 +229,6 @@ export function resolveOrbitalAnchorPosition(
systemId: string,
anchor: OrbitalAnchor,
timeSeconds: number,
seed: number,
): THREE.Vector3 {
if (!world || anchor.kind === "star") {
return new THREE.Vector3();
@@ -268,5 +245,6 @@ export function resolveOrbitalAnchorPosition(
return planetPosition;
}
return planetPosition.add(computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds, seed));
const moon = planet.moons[anchor.moonIndex];
return planetPosition.add(computeMoonLocalPosition(moon, timeSeconds));
}

View File

@@ -351,13 +351,27 @@ export function updateDetailPanel(
detailBodyEl.innerHTML = `
<p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moons.length}</p>
<p>Orbit ${formatSystemDistance(planet.orbitRadius)}<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;
}
if (selected.kind === "moon") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
const moon = planet?.moons[selected.moonIndex];
if (moon) {
detailTitleEl.textContent = moon.label;
detailBodyEl.innerHTML = `
<p>${system?.label ?? selected.systemId} / ${planet?.label ?? `planet ${selected.planetIndex + 1}`}</p>
<p>Orbit ${formatSystemDistance(moon.orbitRadius)}<br>Inc ${moon.orbitInclination.toFixed(1)}°</p>
`;
}
return;
}
const system = world.systems.get(selected.id);
if (!system) {
return;

View File

@@ -2,6 +2,14 @@ import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds, scaleLocalVector } from "./viewerMath";
import type { PlanetVisual, ShipVisual, SystemVisual, WorldState } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
const MIN_ICON_PIXELS = 25;
const MAX_ICON_PIXELS = 50;
export function iconWorldScale(distToCamera: number, camera: THREE.PerspectiveCamera, pixels: number): number {
return pixels * distToCamera * 2 * Math.tan((camera.fov * Math.PI / 180) / 2) / window.innerHeight;
}
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs;
@@ -26,6 +34,7 @@ export function updatePlanetPresentation(
world: WorldState | undefined,
worldTimeSyncMs: number,
planetVisuals: PlanetVisual[],
systemCamera: THREE.PerspectiveCamera,
) {
const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs);
// In systemScene all positions use scaleLocalVector * ACTIVE_SYSTEM_DETAIL_SCALE.
@@ -34,23 +43,44 @@ export function updatePlanetPresentation(
const position = scaleLocalVector(computePlanetLocalPosition(visual.planet, nowSeconds))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
visual.orbit.setPosition(new THREE.Vector3(0, 0, 0));
visual.mesh.setPosition(position);
visual.icon.setPosition(position);
const iconWorldPos = visual.icon.getWorldPosition(new THREE.Vector3());
const distToIcon = systemCamera.position.distanceTo(iconWorldPos);
const t = THREE.MathUtils.clamp(distToIcon / 300, 0, 1);
const rawScale = visual.iconBaseScale * t * Math.sqrt(t);
const planetIconScale = THREE.MathUtils.clamp(rawScale, iconWorldScale(distToIcon, systemCamera, MIN_ICON_PIXELS), iconWorldScale(distToIcon, systemCamera, MAX_ICON_PIXELS));
visual.icon.setScaleScalar(planetIconScale);
if (visual.ring) {
visual.ring.setPosition(position);
}
const distToPlanet = systemCamera.position.distanceTo(position);
const moonOrbitOpacity = THREE.MathUtils.clamp(1 - distToPlanet / 500, 0, 1) * 0.18;
const clusterVisible = distToPlanet < 300;
for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.setPosition(position);
moon.orbit.setScaleScalar(ACTIVE_SYSTEM_DETAIL_SCALE);
moon.mesh.setPosition(
position.clone().add(
scaleLocalVector(computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
),
const moonPos = position.clone().add(
scaleLocalVector(computeMoonLocalPosition(visual.planet.moons[moonIndex], nowSeconds))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
);
moon.mesh.setPosition(moonPos);
moon.mesh.setVisible(clusterVisible);
moon.icon.setPosition(moonPos);
moon.icon.setVisible(clusterVisible);
if (clusterVisible) {
const iconWorldPos = moon.icon.getWorldPosition(new THREE.Vector3());
const moonDist = systemCamera.position.distanceTo(iconWorldPos);
const t = THREE.MathUtils.clamp(moonDist / 120, 0, 1);
const rawMoonScale = moon.iconBaseScale * t * Math.sqrt(t);
const moonIconScale = THREE.MathUtils.clamp(rawMoonScale, iconWorldScale(moonDist, systemCamera, MIN_ICON_PIXELS), iconWorldScale(moonDist, systemCamera, MAX_ICON_PIXELS));
moon.icon.setScaleScalar(moonIconScale);
}
moon.orbit.setPosition(position);
const orbitObj = rawObject(moon.orbit);
if (orbitObj instanceof THREE.LineLoop) {
(orbitObj.material as THREE.LineBasicMaterial).opacity = moonOrbitOpacity;
}
}
}
}

View File

@@ -9,8 +9,8 @@ import {
import { updatePlanetPresentation } from "./viewerPresentation";
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
import { updateSystemPanel } from "./viewerPanels";
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
import type { OrbitLineVisual, Selectable } from "./viewerTypes";
import { createBackdropStars, createMilkyWayBand, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
import type { Selectable } from "./viewerTypes";
export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer;
@@ -40,7 +40,6 @@ export interface ViewerPresentationContext {
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
planetVisuals: any[];
orbitLines: OrbitLineVisual[];
systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any;
}
@@ -50,30 +49,28 @@ export class ViewerPresentationController {
initializeAmbience() {
this.context.ambienceGroup.renderOrder = -10;
this.context.ambienceGroup.add(createBackdropStars());
this.context.ambienceGroup.add(createBackdropStars(document));
this.context.ambienceGroup.add(...createNebulaClouds(createNebulaTexture(document)));
this.context.ambienceGroup.add(createMilkyWayBand(document));
}
updateAmbience(delta: number) {
updateAmbience(_delta: number) {
const activeCamera = this.context.getPovLevel() === "galaxy"
? this.context.galaxyCamera
: this.context.systemCamera;
this.context.ambienceGroup.position.copy(activeCamera.position);
this.context.ambienceGroup.rotation.y += delta * 0.005;
this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
applyZoomPresentation() {
const activeSystemId = this.context.getActiveSystemId();
const povLevel = this.context.getPovLevel();
// Orbit lines: only show for active system in system/local zoom
for (const orbitLine of this.context.orbitLines) {
const alpha = this.resolveOrbitLineOpacity(orbitLine, povLevel, activeSystemId);
orbitLine.line.setOpacity(alpha);
}
this.context.galaxyScene.fog = new THREE.FogExp2(0x040912, 0.000035);
const showPlanetIcons = povLevel !== "local";
for (const visual of this.context.planetVisuals) {
visual.icon.setVisible(showPlanetIcons);
}
}
updateNetworkPanel() {
@@ -100,6 +97,7 @@ export class ViewerPresentationController {
world,
this.context.getWorldTimeSyncMs(),
this.context.planetVisuals,
this.context.systemCamera,
);
}
@@ -148,21 +146,4 @@ export class ViewerPresentationController {
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
}
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, povLevel: "local" | "system" | "galaxy", activeSystemId?: string) {
if (povLevel === "galaxy" || !activeSystemId || orbitLine.systemId !== activeSystemId) {
return 0;
}
const selected = this.context.getSelectedItems();
const selectedItem = selected.length === 1 ? selected[0] : undefined;
const baseAlpha = povLevel === "local" ? 0.55 : 0.9;
if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex
? baseAlpha
: 0;
}
return orbitLine.kind === "planet" ? baseAlpha : 0;
}
}

View File

@@ -1,17 +1,18 @@
import * as THREE from "three";
import { classifyPovLevel } from "./viewerMath";
import type { PovLevel, PerformanceStats } from "./viewerTypes";
import type { PovLevel } from "./viewerTypes";
import type { UniverseLayer } from "./viewerUniverseLayer";
import type { GalaxyLayer } from "./viewerGalaxyLayer";
import type { SystemLayer } from "./viewerSystemLayer";
import type { LocalLayer } from "./viewerLocalLayer";
export interface RenderFrameParams {
clock: THREE.Clock;
renderer: THREE.WebGLRenderer;
universeScene: THREE.Scene;
galaxyScene: THREE.Scene;
galaxyCamera: THREE.PerspectiveCamera;
systemScene: THREE.Scene;
systemCamera: THREE.PerspectiveCamera;
localScene: THREE.Scene;
localCamera: THREE.PerspectiveCamera;
universeLayer: UniverseLayer;
galaxyLayer: GalaxyLayer;
systemLayer: SystemLayer;
localLayer: LocalLayer;
getPovLevel: () => PovLevel;
updateCamera: (delta: number) => void;
updateAmbience: (delta: number) => void;
@@ -25,9 +26,9 @@ export interface RenderFrameParams {
export interface ResizeParams {
renderer: THREE.WebGLRenderer;
galaxyCamera: THREE.PerspectiveCamera;
systemCamera: THREE.PerspectiveCamera;
localCamera: THREE.PerspectiveCamera;
galaxyLayer: GalaxyLayer;
systemLayer: SystemLayer;
localLayer: LocalLayer;
}
export interface CameraStepParams {
@@ -48,22 +49,22 @@ export function renderFrame(params: RenderFrameParams) {
params.applyZoomPresentation();
const povLevel = params.getPovLevel();
const activeCamera = povLevel === "galaxy" ? params.galaxyCamera : params.systemCamera;
const activeCamera = povLevel === "galaxy" ? params.galaxyLayer.camera : params.systemLayer.camera;
params.renderer.autoClear = false;
params.renderer.clear();
// Universe backdrop — always first, rendered with the active camera so it aligns with the foreground
params.renderer.render(params.universeScene, activeCamera);
params.universeLayer.render(params.renderer, activeCamera);
params.renderer.clearDepth();
if (povLevel === "galaxy") {
// Galaxy map on top of universe backdrop
params.renderer.render(params.galaxyScene, params.galaxyCamera);
params.galaxyLayer.render(params.renderer);
} else if (povLevel === "system") {
params.renderer.render(params.systemScene, params.systemCamera);
params.systemLayer.render(params.renderer);
} else {
// local: system as mid-ground backdrop, then local on top
params.renderer.render(params.systemScene, params.systemCamera);
params.systemLayer.render(params.renderer);
params.renderer.clearDepth();
params.renderer.render(params.localScene, params.localCamera);
params.localLayer.render(params.renderer);
}
params.recordPerformanceStats(performance.now() - frameStartedAtMs);
@@ -73,10 +74,9 @@ export function renderFrame(params: RenderFrameParams) {
export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth;
const height = window.innerHeight;
for (const camera of [params.galaxyCamera, params.systemCamera, params.localCamera]) {
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
params.galaxyLayer.onResize(width / height);
params.systemLayer.onResize(width / height);
params.localLayer.onResize(width / height);
params.renderer.setSize(width, height);
}

View File

@@ -42,7 +42,7 @@ import type {
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type { OrbitLineVisual, OrbitalAnchor, Selectable } from "./viewerTypes";
import type { OrbitalAnchor, Selectable } from "./viewerTypes";
import { rawObject } from "./viewerScenePrimitives";
export interface ViewerSceneDataContext {
@@ -65,7 +65,6 @@ export interface ViewerSceneDataContext {
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<any, any>;
planetVisuals: any[];
orbitLines: OrbitLineVisual[];
celestialVisuals: Map<any, any>;
nodeVisuals: Map<any, any>;
stationVisuals: Map<any, any>;
@@ -251,7 +250,6 @@ export class ViewerSceneDataController {
systemSelectableTargets: this.context.systemSelectableTargets,
systemVisuals: this.context.systemVisuals,
planetVisuals: this.context.planetVisuals,
orbitLines: this.context.orbitLines,
celestialVisuals: this.context.celestialVisuals,
nodeVisuals: this.context.nodeVisuals,
stationVisuals: this.context.stationVisuals,

View File

@@ -1,5 +1,6 @@
import * as THREE from "three";
import {
ACTIVE_SYSTEM_DETAIL_SCALE,
MOON_RENDER_SCALE,
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
@@ -8,6 +9,7 @@ import type {
CelestialSnapshot,
ClaimSnapshot,
ConstructionSiteSnapshot,
MoonSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
@@ -17,10 +19,9 @@ import type {
import type { MoonVisual } from "./viewerTypes";
import {
celestialRenderRadius,
computeMoonOrbitRadius,
computeMoonLocalPosition,
computeMoonRenderRadius,
computePlanetLocalPosition,
scaleLocalScalar,
scaleLocalVector,
starHaloOpacity,
toThreeVector,
@@ -84,45 +85,34 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): Scen
export function createStarCluster(system: SystemSnapshot): SceneNode {
const root = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, 0.00018, 0.16, 0.62);
const offsets = system.starCount > 1
? [new THREE.Vector3(-renderedStarSize * 0.55, 0, 0), new THREE.Vector3(renderedStarSize * 0.75, renderedStarSize * 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(renderedStarSize * sizeScale, 24, 24),
new THREE.MeshBasicMaterial({ color: system.starColor }),
for (const [index, star] of system.stars.entries()) {
const renderedSize = celestialRenderRadius(star.size, 0.00018, 40, 0.62);
const offset = system.stars.length > 1
? (index === 0
? new THREE.Vector3(-renderedSize * 0.55, 0, 0)
: new THREE.Vector3(renderedSize * 0.75, renderedSize * 0.08, 0))
: new THREE.Vector3(0, 0, 0);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(renderedSize, 24, 24),
new THREE.MeshBasicMaterial({ color: star.color }),
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20),
new THREE.SphereGeometry(renderedSize * 1.45, 20, 20),
new THREE.MeshBasicMaterial({
color: system.starColor,
color: star.color,
transparent: true,
opacity: starHaloOpacity(system.starKind),
opacity: starHaloOpacity(star.kind),
side: THREE.BackSide,
}),
);
star.position.copy(offset);
mesh.position.copy(offset);
halo.position.copy(offset);
root.add(star, halo);
root.add(mesh, halo);
}
return createSceneNode(root);
}
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
const points = Array.from({ length: 120 }, (_, index) => {
const phaseDegrees = (index / 120) * 360;
return scaleLocalVector(computePlanetLocalPosition(planet, 0, phaseDegrees));
});
return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
));
}
export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
const renderedPlanetRadius = celestialRenderRadius(planet.size, 0.00012, 0.03, 0.62);
@@ -140,41 +130,74 @@ export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
return createSceneNode(ring);
}
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
const moonCount = Math.min(planet.moonCount, 12);
const moons: MoonVisual[] = [];
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const orbitRadius = scaleLocalScalar(computeMoonOrbitRadius(planet, moonIndex, seed));
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 }),
function createMoonOrbit(moon: MoonSnapshot): SceneNode {
const segments = 64;
const period = (2 * Math.PI) / Math.max(Math.abs(moon.orbitSpeed), 1e-6);
const points: THREE.Vector3[] = [];
for (let i = 0; i <= segments; i++) {
points.push(
scaleLocalVector(computeMoonLocalPosition(moon, (i / segments) * period))
.multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE),
);
orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35);
}
return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: moon.color,
transparent: true,
opacity: 0,
depthWrite: false,
depthTest: false,
}),
));
}
const moonSize = computeMoonRenderRadius(planet, moonIndex, seed);
export function createMoonVisuals(planet: PlanetSnapshot, documentRef: Document): MoonVisual[] {
return planet.moons.map((moon, moonIndex) => {
const moonSize = computeMoonRenderRadius(moon);
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),
color: moon.color,
roughness: 0.96,
metalness: 0.02,
}),
);
const baseColor = new THREE.Color(moon.color);
const hsl = { h: 0, s: 0, l: 0 };
baseColor.getHSL(hsl);
const iconColor = new THREE.Color().setHSL(hsl.h, Math.max(hsl.s, 0.4), 0.72).getStyle();
const iconBaseScale = 72;
const icon = createTacticalIcon(documentRef, iconColor, iconBaseScale);
return {
systemId: "",
planetIndex: -1,
moonIndex,
mesh: createSceneNode(mesh),
icon,
iconBaseScale,
orbit: createMoonOrbit(moon),
};
});
}
moons.push({ systemId: "", planetIndex: -1, mesh: createSceneNode(mesh), orbit: createSceneNode(orbit) });
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
const segments = 96;
const points: THREE.Vector3[] = [];
for (let i = 0; i <= segments; i++) {
const phase = (i / segments) * 360;
points.push(scaleLocalVector(computePlanetLocalPosition(planet, 0, phase)).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
}
return moons;
return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({
color: planet.color,
transparent: true,
opacity: 0.22,
depthWrite: false,
depthTest: false,
}),
));
}
export function createStationMesh(station: StationSnapshot): SceneNode {
@@ -201,32 +224,160 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number,
return createSceneNode(mesh);
}
export function createBackdropStars(): THREE.Points {
const starCount = 1800;
const radius = 36000;
function createStarGlowTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create star glow texture");
}
const gradient = context.createRadialGradient(64, 64, 0, 64, 64, 64);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.14, "rgba(255,255,255,0.95)");
gradient.addColorStop(0.35, "rgba(255,255,255,0.42)");
gradient.addColorStop(0.68, "rgba(180,205,255,0.1)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 128, 128);
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function createStarSparkleTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create star sparkle texture");
}
context.clearRect(0, 0, 128, 128);
context.translate(64, 64);
context.lineCap = "round";
const bloom = context.createRadialGradient(0, 0, 0, 0, 0, 48);
bloom.addColorStop(0, "rgba(255,255,255,0.95)");
bloom.addColorStop(0.3, "rgba(255,255,255,0.24)");
bloom.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = bloom;
context.beginPath();
context.arc(0, 0, 48, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(255,255,255,0.75)";
context.lineWidth = 4;
context.beginPath();
context.moveTo(-38, 0);
context.lineTo(38, 0);
context.moveTo(0, -38);
context.lineTo(0, 38);
context.stroke();
context.rotate(Math.PI / 4);
context.strokeStyle = "rgba(255,255,255,0.35)";
context.lineWidth = 2;
context.beginPath();
context.moveTo(-28, 0);
context.lineTo(28, 0);
context.moveTo(0, -28);
context.lineTo(0, 28);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function createMilkyWayTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 1024;
canvas.height = 256;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create milky way texture");
}
const background = context.createLinearGradient(0, 0, 1024, 0);
background.addColorStop(0, "rgba(0,0,0,0)");
background.addColorStop(0.1, "rgba(150,110,255,0.08)");
background.addColorStop(0.32, "rgba(120,210,255,0.14)");
background.addColorStop(0.5, "rgba(255,240,220,0.28)");
background.addColorStop(0.68, "rgba(255,165,210,0.16)");
background.addColorStop(0.88, "rgba(115,155,255,0.08)");
background.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = background;
context.fillRect(0, 0, 1024, 256);
for (let index = 0; index < 220; index += 1) {
const x = THREE.MathUtils.randFloat(0, 1024);
const y = 128 + THREE.MathUtils.randFloatSpread(78);
const radiusX = THREE.MathUtils.randFloat(40, 180);
const radiusY = THREE.MathUtils.randFloat(8, 28);
const alpha = THREE.MathUtils.randFloat(0.025, 0.09);
const hue = THREE.MathUtils.randFloat(0.52, 0.76);
const color = new THREE.Color().setHSL(hue, THREE.MathUtils.randFloat(0.25, 0.6), THREE.MathUtils.randFloat(0.72, 0.9));
const puff = context.createRadialGradient(x, y, 0, x, y, radiusX);
puff.addColorStop(0, `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${alpha})`);
puff.addColorStop(0.55, `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${alpha * 0.45})`);
puff.addColorStop(1, "rgba(0,0,0,0)");
context.save();
context.translate(x, y);
context.scale(1, radiusY / radiusX);
context.fillStyle = puff;
context.beginPath();
context.arc(0, 0, radiusX, 0, Math.PI * 2);
context.fill();
context.restore();
}
for (let index = 0; index < 540; index += 1) {
const x = THREE.MathUtils.randFloat(0, 1024);
const y = 128 + THREE.MathUtils.randFloatSpread(54);
const alpha = THREE.MathUtils.randFloat(0.12, 0.65);
const size = THREE.MathUtils.randFloat(0.8, 2.4);
context.fillStyle = `rgba(255,255,255,${alpha})`;
context.fillRect(x, y, size, size);
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
function sampleBackdropStarColor(): THREE.Color {
const roll = Math.random();
if (roll < 0.1) {
return new THREE.Color().setHSL(0.08, THREE.MathUtils.randFloat(0.65, 0.9), THREE.MathUtils.randFloat(0.78, 0.9));
}
if (roll < 0.28) {
return new THREE.Color().setHSL(0.58, THREE.MathUtils.randFloat(0.28, 0.55), THREE.MathUtils.randFloat(0.78, 0.9));
}
if (roll < 0.92) {
return new THREE.Color().setHSL(0.61, THREE.MathUtils.randFloat(0.08, 0.3), THREE.MathUtils.randFloat(0.84, 0.97));
}
return new THREE.Color().setHSL(0.76, THREE.MathUtils.randFloat(0.25, 0.48), THREE.MathUtils.randFloat(0.78, 0.88));
}
function createStarPointLayer(radius: number, starCount: number, size: number, opacity: number): THREE.Points {
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const color = new THREE.Color();
for (let index = 0; index < starCount; index += 1) {
const direction = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1));
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.83, 1));
const color = sampleBackdropStarColor().multiplyScalar(THREE.MathUtils.randFloat(0.55, 1.2));
positions[index * 3] = direction.x;
positions[index * 3 + 1] = direction.y;
positions[index * 3 + 2] = direction.z;
const tint = THREE.MathUtils.randFloat(0, 1);
color.setRGB(
THREE.MathUtils.lerp(0.68, 1, tint),
THREE.MathUtils.lerp(0.76, 0.94, tint),
THREE.MathUtils.lerp(0.9, 1, tint),
);
if (Math.random() < 0.08) {
color.lerp(new THREE.Color(0xffd6a0), 0.45);
}
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
@@ -239,77 +390,244 @@ export function createBackdropStars(): THREE.Points {
return new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 2.2,
size,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: 0.9,
opacity,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}),
);
}
export function createBackdropStars(documentRef: Document): THREE.Group {
const radius = 36000;
const root = new THREE.Group();
root.add(
createStarPointLayer(radius, 2800, 1.15, 0.5),
createStarPointLayer(radius, 900, 1.9, 0.85),
createStarPointLayer(radius, 240, 3.1, 0.95),
);
const glowTexture = createStarGlowTexture(documentRef);
const sparkleTexture = createStarSparkleTexture(documentRef);
for (let index = 0; index < 72; index += 1) {
const direction = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.84, 0.98));
const color = sampleBackdropStarColor().multiplyScalar(THREE.MathUtils.randFloat(0.9, 1.45));
const glow = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTexture,
color,
transparent: true,
opacity: THREE.MathUtils.randFloat(0.5, 0.95),
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}));
const sparkle = new THREE.Sprite(new THREE.SpriteMaterial({
map: sparkleTexture,
color: color.clone().lerp(new THREE.Color(0xffffff), 0.35),
transparent: true,
opacity: THREE.MathUtils.randFloat(0.2, 0.55),
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false,
}));
const glowScale = THREE.MathUtils.randFloat(120, 260);
glow.position.copy(direction);
glow.scale.set(glowScale, glowScale, 1);
sparkle.position.copy(direction);
sparkle.material.rotation = THREE.MathUtils.randFloat(0, Math.PI);
sparkle.scale.set(glowScale * THREE.MathUtils.randFloat(0.9, 1.4), glowScale * THREE.MathUtils.randFloat(0.9, 1.4), 1);
root.add(glow, sparkle);
}
return root;
}
export function createPlanetTexture(color: string, seed: number, documentRef: Document): THREE.CanvasTexture {
const W = 256, H = 128;
const canvas = documentRef.createElement("canvas");
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("Unable to create planet texture");
const imageData = ctx.createImageData(W, H);
const base = new THREE.Color(color);
function hash(x: number, y: number): number {
const n = Math.sin(x * 127.1 + y * 311.7 + seed * 74.3) * 43758.5453;
return n - Math.floor(n);
}
function smoothNoise(x: number, y: number): number {
const ix = Math.floor(x), iy = Math.floor(y);
const fx = x - ix, fy = y - iy;
const ux = fx * fx * (3 - 2 * fx), uy = fy * fy * (3 - 2 * fy);
const a = hash(ix, iy), b = hash(ix + 1, iy);
const c = hash(ix, iy + 1), d = hash(ix + 1, iy + 1);
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}
function fbm(x: number, y: number): number {
let v = 0, amp = 0.5, freq = 1;
for (let i = 0; i < 5; i++) { v += smoothNoise(x * freq, y * freq) * amp; amp *= 0.5; freq *= 2; }
return v;
}
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const nx = (x / W) * 5, ny = (y / H) * 3;
const turb = fbm(nx + 0.1, ny + 0.1) - 0.5;
const band = Math.sin((y / H * 10 + turb * 3) * Math.PI);
const light = 0.62 + band * 0.38;
const idx = (y * W + x) * 4;
imageData.data[idx] = Math.min(255, base.r * 255 * light);
imageData.data[idx + 1] = Math.min(255, base.g * 255 * light);
imageData.data[idx + 2] = Math.min(255, base.b * 255 * light);
imageData.data[idx + 3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.needsUpdate = true;
return texture;
}
export function createNebulaTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
canvas.width = 512;
canvas.height = 512;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create nebula texture");
}
const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118);
gradient.addColorStop(0, "rgba(255,255,255,0.95)");
gradient.addColorStop(0.2, "rgba(255,255,255,0.48)");
gradient.addColorStop(0.55, "rgba(140,180,255,0.14)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 256, 256);
const palettes = [
["rgba(80,220,255,0.24)", "rgba(120,110,255,0.18)", "rgba(255,255,255,0.14)"],
["rgba(255,130,205,0.24)", "rgba(110,170,255,0.16)", "rgba(255,240,255,0.12)"],
["rgba(120,255,205,0.2)", "rgba(100,160,255,0.18)", "rgba(255,255,255,0.1)"],
];
for (let index = 0; index < 10; index += 1) {
const x = THREE.MathUtils.randFloat(30, 226);
const y = THREE.MathUtils.randFloat(30, 226);
const radius = THREE.MathUtils.randFloat(24, 72);
const puff = context.createRadialGradient(x, y, 0, x, y, radius);
puff.addColorStop(0, "rgba(255,255,255,0.16)");
puff.addColorStop(0.45, "rgba(255,255,255,0.08)");
puff.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = puff;
context.clearRect(0, 0, 512, 512);
for (let layer = 0; layer < palettes.length; layer += 1) {
for (let index = 0; index < 18; index += 1) {
const x = THREE.MathUtils.randFloat(40, 472);
const y = THREE.MathUtils.randFloat(40, 472);
const radius = THREE.MathUtils.randFloat(55, 180);
const [core, mid, edge] = palettes[layer];
const puff = context.createRadialGradient(x, y, 0, x, y, radius);
puff.addColorStop(0, core);
puff.addColorStop(0.4, mid);
puff.addColorStop(0.78, edge);
puff.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = puff;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
}
for (let index = 0; index < 36; index += 1) {
const x = THREE.MathUtils.randFloat(50, 462);
const y = THREE.MathUtils.randFloat(50, 462);
const radius = THREE.MathUtils.randFloat(18, 60);
const glow = context.createRadialGradient(x, y, 0, x, y, radius);
glow.addColorStop(0, "rgba(255,255,255,0.12)");
glow.addColorStop(0.4, "rgba(255,255,255,0.05)");
glow.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = glow;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
// Feather the entire texture toward the borders so large sprites do not show a card-like cutoff.
const edgeFade = context.createRadialGradient(256, 256, 86, 256, 256, 256);
edgeFade.addColorStop(0, "rgba(255,255,255,1)");
edgeFade.addColorStop(0.58, "rgba(255,255,255,0.96)");
edgeFade.addColorStop(0.82, "rgba(255,255,255,0.42)");
edgeFade.addColorStop(1, "rgba(255,255,255,0)");
context.globalCompositeOperation = "destination-in";
context.fillStyle = edgeFade;
context.fillRect(0, 0, 512, 512);
context.globalCompositeOperation = "source-over";
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
const directions = [
new THREE.Vector3(0.74, 0.34, -0.58),
new THREE.Vector3(-0.62, 0.18, -0.77),
new THREE.Vector3(0.22, -0.44, -0.87),
new THREE.Vector3(-0.38, 0.56, 0.73),
const seeds = [
{ direction: new THREE.Vector3(0.76, 0.28, -0.58), color: "#5bd4ff", scale: 24000, opacity: 0.22, rotation: 0.18 },
{ direction: new THREE.Vector3(0.7, 0.34, -0.54), color: "#93b3ff", scale: 18000, opacity: 0.16, rotation: -0.22 },
{ direction: new THREE.Vector3(-0.58, 0.24, -0.78), color: "#ff8cc6", scale: 22000, opacity: 0.2, rotation: 0.34 },
{ direction: new THREE.Vector3(-0.48, 0.14, -0.86), color: "#8a8dff", scale: 16000, opacity: 0.14, rotation: -0.4 },
{ direction: new THREE.Vector3(0.24, -0.46, -0.85), color: "#79ffd6", scale: 20000, opacity: 0.17, rotation: 0.52 },
{ direction: new THREE.Vector3(-0.34, 0.58, 0.74), color: "#79b7ff", scale: 26000, opacity: 0.16, rotation: -0.12 },
];
return directions.map((direction, index) => {
return seeds.map((seed, index) => {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.14,
opacity: seed.opacity,
depthWrite: false,
color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff",
color: seed.color,
blending: THREE.AdditiveBlending,
fog: false,
}));
sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600));
const scale = 15000 + index * 2400;
sprite.scale.set(scale, scale * 0.62, 1);
sprite.position.copy(seed.direction.normalize().multiplyScalar(23000 + index * 1800));
sprite.material.rotation = seed.rotation;
sprite.scale.set(seed.scale, seed.scale * THREE.MathUtils.randFloat(0.52, 0.78), 1);
return sprite;
});
}
export function createMilkyWayBand(documentRef: Document): THREE.Group {
const radius = 33800;
const texture = createMilkyWayTexture(documentRef);
const root = new THREE.Group();
const planeNormal = new THREE.Vector3(0.24, 0.92, -0.3).normalize();
const tangent = new THREE.Vector3().crossVectors(planeNormal, new THREE.Vector3(0, 0, 1));
if (tangent.lengthSq() < 1e-6) {
tangent.set(1, 0, 0);
}
tangent.normalize();
const bitangent = new THREE.Vector3().crossVectors(planeNormal, tangent).normalize();
for (let index = 0; index < 8; index += 1) {
const angle = (index / 8) * Math.PI * 2;
const direction = tangent.clone().multiplyScalar(Math.cos(angle)).add(bitangent.clone().multiplyScalar(Math.sin(angle)));
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: index % 2 === 0 ? 0.22 : 0.15,
depthWrite: false,
blending: THREE.AdditiveBlending,
color: index % 3 === 0 ? "#ffd3f1" : index % 3 === 1 ? "#c8d8ff" : "#ffffff",
fog: false,
}));
sprite.position.copy(direction.multiplyScalar(radius));
sprite.scale.set(16500, 4300 + (index % 3) * 800, 1);
sprite.material.rotation = angle + Math.PI / 2;
root.add(sprite);
}
return root;
}
export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
const canvas = documentRef.createElement("canvas");
canvas.width = 64;
@@ -325,12 +643,6 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
context.beginPath();
context.arc(32, 32, 18, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(32, 8);
context.lineTo(32, 56);
context.moveTo(8, 32);
context.lineTo(56, 32);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({

View File

@@ -10,7 +10,6 @@ import type {
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
OrbitLineVisual,
PlanetVisual,
Selectable,
ShipVisual,
@@ -48,6 +47,7 @@ import {
createNodeMesh,
createPlanetOrbit,
createPlanetRing,
createPlanetTexture,
createShellReticle,
createShipMesh,
createCelestialMesh,
@@ -86,7 +86,6 @@ interface SceneSyncContext {
systemSelectableTargets: Map<THREE.Object3D, Selectable>;
systemVisuals: Map<string, SystemVisual>;
planetVisuals: PlanetVisual[];
orbitLines: OrbitLineVisual[];
celestialVisuals: Map<string, CelestialVisual>;
nodeVisuals: Map<string, NodeVisual>;
stationVisuals: Map<string, StructureVisual>;
@@ -121,7 +120,6 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
context.galaxySelectableTargets.clear();
context.systemSelectableTargets.clear();
context.planetVisuals.length = 0;
context.orbitLines.length = 0;
context.systemVisuals.clear();
for (const system of systems) {
@@ -129,7 +127,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
const galaxyRoot = createSceneNode(new THREE.Group());
galaxyRoot.setPosition(toDisplayGalaxyVector(system.galaxyPosition));
const systemIcon = createStarDot(context.documentRef, system.starColor);
const systemIcon = createStarDot(context.documentRef, system.stars[0]?.color ?? "#ffffff");
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
galaxyRoot.add(systemIcon, shellReticle);
@@ -150,50 +148,45 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
);
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet);
const renderedPlanetRadius = celestialRenderRadius(planet.size, 0.00012, 0.03, 0.62);
const planetTexture = createPlanetTexture(planet.color, planetIndex * 17 + system.id.length * 31, context.documentRef);
const planetMesh = createSceneNode(new THREE.Mesh(
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
new THREE.SphereGeometry(renderedPlanetRadius, 24, 24),
new THREE.MeshStandardMaterial({
color: planet.color,
roughness: 0.92,
metalness: 0.08,
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
map: planetTexture,
roughness: 0.88,
metalness: 0.04,
}),
));
const initialPos = toSystemPos(computePlanetLocalPosition(planet, worldTimeSeconds));
planetMesh.setPosition(initialPos);
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
const iconBaseScale = Math.max(120, renderedPlanetRadius * 10);
const planetHsl = { h: 0, s: 0, l: 0 };
new THREE.Color(planet.color).getHSL(planetHsl);
const planetIconColor = new THREE.Color().setHSL(planetHsl.h, Math.max(planetHsl.s, 0.5), 0.72).getStyle();
const planetIcon = createTacticalIcon(context.documentRef, planetIconColor, iconBaseScale);
planetIcon.setPosition(initialPos);
planetIcon.setVisible(true);
const orbit = createPlanetOrbit(planet);
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) {
ring.setPosition(initialPos);
}
const moons = createMoonVisuals(planet, context.worldSeed);
const moons = createMoonVisuals(planet, context.documentRef);
detailGroup.add(orbit, planetMesh, planetIcon);
if (ring) {
detailGroup.add(ring);
}
for (const moon of moons) {
for (const [moonIdx, moon] of moons.entries()) {
moon.systemId = system.id;
moon.planetIndex = planetIndex;
moon.orbit.setPosition(initialPos);
moon.mesh.setPosition(initialPos);
detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push({
line: moon.orbit,
systemId: system.id,
kind: "moon",
planetIndex,
});
moon.icon.setPosition(initialPos);
detailGroup.add(moon.mesh, moon.icon, moon.orbit);
registerSelectableTarget(context.systemSelectableTargets, moon.mesh, { kind: "moon", systemId: system.id, planetIndex, moonIndex: moonIdx });
registerSelectableTarget(context.systemSelectableTargets, moon.icon, { kind: "moon", systemId: system.id, planetIndex, moonIndex: moonIdx });
}
context.orbitLines.push({
line: orbit,
systemId: system.id,
kind: "planet",
planetIndex,
});
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, iconBaseScale, ring, moons });
registerSelectableTarget(context.systemSelectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
registerSelectableTarget(context.systemSelectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
}
@@ -225,7 +218,8 @@ export function syncCelestials(context: SceneSyncContext, celestials: CelestialS
}
const mesh = createCelestialMesh(celestial, context.celestialColor);
const icon = createTacticalIcon(context.documentRef, context.celestialColor(celestial.kind), 18);
const celestialIconBaseScale = 90;
const icon = createTacticalIcon(context.documentRef, context.celestialColor(celestial.kind), celestialIconBaseScale);
const orbitalAnchor = toSystemPos(toThreeVector(celestial.orbitalAnchor));
mesh.setPosition(orbitalAnchor);
icon.setPosition(orbitalAnchor);
@@ -237,6 +231,7 @@ export function syncCelestials(context: SceneSyncContext, celestials: CelestialS
systemId: celestial.systemId,
mesh,
icon,
iconBaseScale: celestialIconBaseScale,
kind: celestial.kind,
orbitalAnchor,
});
@@ -252,7 +247,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
for (const node of nodes) {
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 100);
const localPosition = toThreeVector(node.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
@@ -285,7 +280,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
for (const station of stations) {
const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 26);
const icon = createTacticalIcon(context.documentRef, station.color, 130);
const localPosition = toThreeVector(station.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);
@@ -320,7 +315,7 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[], a
const localPosition = context.resolvePointPosition(claim.systemId, claim.celestialId);
const displayPos = toSystemPos(localPosition);
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 90);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = claim.systemId === activeSystemId;
@@ -348,7 +343,7 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
const localPosition = context.resolvePointPosition(site.systemId, site.celestialId);
const displayPos = toSystemPos(localPosition);
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 90);
mesh.setPosition(displayPos);
icon.setPosition(displayPos);
const isActive = site.systemId === activeSystemId;
@@ -375,7 +370,7 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
for (const ship of ships) {
const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship));
const shipColor = context.shipPresentationColor(ship);
const icon = createTacticalIcon(context.documentRef, shipColor, 18);
const icon = createTacticalIcon(context.documentRef, shipColor, 90);
const localPosition = toThreeVector(ship.localPosition);
const displayPos = toSystemPos(localPosition);
mesh.setPosition(displayPos);

View File

@@ -33,6 +33,10 @@ export function describeSelectable(world: WorldState | undefined, item: Selectab
if (item.kind === "planet") {
return world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`;
}
if (item.kind === "moon") {
const planet = world.systems.get(item.systemId)?.planets[item.planetIndex];
return planet?.moons[item.moonIndex]?.label ?? `moon ${item.moonIndex + 1}`;
}
return world.systems.get(item.id)?.label ?? item.id;
}
@@ -54,7 +58,7 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
if (!system) {
return item.id;
}
const starLabel = system.starCount > 1 ? `${system.starCount}× ${system.starKind}` : system.starKind;
const starLabel = system.stars.length > 1 ? `${system.stars.length}× ${system.stars[0]?.kind}` : (system.stars[0]?.kind ?? "unknown");
const planetCount = system.planets.length;
const shipCount = [...world.ships.values()].filter((s) => s.systemId === item.id).length;
const stationCount = [...world.stations.values()].filter((s) => s.systemId === item.id).length;
@@ -81,6 +85,16 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
return planet ? `${system?.label ?? item.systemId} / ${planet.label}` : `${item.systemId} / planet ${item.planetIndex + 1}`;
}
if (item.kind === "moon") {
const system = world.systems.get(item.systemId);
const planet = system?.planets[item.planetIndex];
const moon = planet?.moons[item.moonIndex];
if (moon) {
return `${system?.label ?? item.systemId} / ${planet?.label ?? `planet ${item.planetIndex + 1}`} / ${moon.label}`;
}
return `${item.systemId} / planet ${item.planetIndex + 1} / moon ${item.moonIndex + 1}`;
}
if (item.kind === "node") {
const node = world.nodes.get(item.id);
if (!node) {
@@ -168,6 +182,9 @@ export function resolveSelectableSystemId(world: WorldState | undefined, selecti
if (selection.kind === "planet") {
return selection.systemId;
}
if (selection.kind === "moon") {
return selection.systemId;
}
return selection.id;
}
@@ -271,7 +288,7 @@ export function renderSystemDetails(
}
}
for (const planet of system.planets) {
moonCount += planet.moonCount;
moonCount += planet.moons.length;
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
@@ -280,7 +297,7 @@ export function renderSystemDetails(
return `
<p>${system.id}${activeContext ? " · active system" : ""}</p>
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
<p>${system.stars[0]?.kind ?? "unknown"} · ${system.stars.length} star${system.stars.length > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}</p>
<p>Celestials ${celestialCount}<br>Resource nodes ${nodeCount}</p>
<p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>

View File

@@ -1,5 +1,14 @@
import * as THREE from "three";
import type { Selectable } from "./viewerTypes";
import type {
CelestialVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
PlanetVisual,
Selectable,
ShipVisual,
StructureVisual,
} from "./viewerTypes";
/**
* System rendering layer.
@@ -9,7 +18,7 @@ import type { Selectable } from "./viewerTypes";
*/
export class SystemLayer {
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000);
readonly camera = new THREE.PerspectiveCamera(50, 1, 0.0001, 300000);
readonly celestialGroup = new THREE.Group();
readonly nodeGroup = new THREE.Group();
@@ -20,6 +29,14 @@ export class SystemLayer {
readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
readonly planetVisuals: PlanetVisual[] = [];
readonly shipVisuals = new Map<string, ShipVisual>();
readonly celestialVisuals = new Map<string, CelestialVisual>();
readonly nodeVisuals = new Map<string, NodeVisual>();
readonly stationVisuals = new Map<string, StructureVisual>();
readonly claimVisuals = new Map<string, ClaimVisual>();
readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
constructor() {
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
@@ -44,4 +61,8 @@ export class SystemLayer {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
render(renderer: THREE.WebGLRenderer) {
renderer.render(this.scene, this.camera);
}
}

View File

@@ -29,7 +29,8 @@ export type Selectable =
| { kind: "claim"; id: string }
| { kind: "construction-site"; id: string }
| { kind: "system"; id: string }
| { kind: "planet"; systemId: string; planetIndex: number };
| { kind: "planet"; systemId: string; planetIndex: number }
| { kind: "moon"; systemId: string; planetIndex: number; moonIndex: number };
export interface ShipVisual {
systemId: string;
@@ -49,6 +50,7 @@ export interface PlanetVisual {
orbit: SceneNode;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
ring?: SceneNode;
moons: MoonVisual[];
}
@@ -56,17 +58,13 @@ export interface PlanetVisual {
export interface MoonVisual {
systemId: string;
planetIndex: number;
moonIndex: number;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
orbit: SceneNode;
}
export interface OrbitLineVisual {
line: SceneNode;
systemId: string;
kind: "planet" | "moon";
planetIndex: number;
}
export type OrbitalAnchor =
| { kind: "star" }
| { kind: "planet"; planetIndex: number }
@@ -89,6 +87,7 @@ export interface CelestialVisual {
systemId: string;
mesh: SceneNode;
icon: SceneNode;
iconBaseScale: number;
kind: string;
orbitalAnchor: THREE.Vector3;
}

View File

@@ -17,9 +17,11 @@ export class UniverseLayer {
this.scene.add(this.ambienceGroup);
}
updateAmbience(activeCamera: THREE.Camera, delta: number) {
updateAmbience(activeCamera: THREE.Camera, _delta: number) {
this.ambienceGroup.position.copy(activeCamera.position);
this.ambienceGroup.rotation.y += delta * 0.005;
this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
render(renderer: THREE.WebGLRenderer, camera: THREE.Camera) {
renderer.render(this.scene, camera);
}
}

View File

@@ -4,7 +4,6 @@ import {
DISPLAY_UNITS_PER_LIGHT_YEAR,
KILOMETERS_PER_AU,
computeMoonLocalPosition,
computeMoonSize,
computePlanetLocalPosition,
currentWorldTimeSeconds,
resolveOrbitalAnchorPosition,
@@ -16,6 +15,7 @@ import {
resolveShipHeading,
updateSystemStarPresentation,
getAnimatedShipLocalPosition,
iconWorldScale,
} from "./viewerPresentation";
import { rawObject } from "./viewerScenePrimitives";
import type {
@@ -114,7 +114,15 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition));
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.setVisible(visual.systemId === context.activeSystemId);
const iconWorldPos = visual.icon.getWorldPosition(new THREE.Vector3());
const distToIcon = context.camera.position.distanceTo(iconWorldPos);
const isNearPlanetLagrange = /-l[12]$/.test(visual.id);
const inCluster = !isNearPlanetLagrange || distToIcon < 400;
visual.icon.setVisible(visual.systemId === context.activeSystemId && inCluster);
const t = THREE.MathUtils.clamp(distToIcon / 300, 0, 1);
const rawCelestialScale = visual.iconBaseScale * t * Math.sqrt(t);
const celestialIconScale = THREE.MathUtils.clamp(rawCelestialScale, iconWorldScale(distToIcon, context.camera, 15), iconWorldScale(distToIcon, context.camera, 100));
visual.icon.setScaleScalar(celestialIconScale);
}
for (const visual of context.stationVisuals.values()) {
@@ -351,13 +359,12 @@ export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: str
bestAnchor = { kind: "planet", planetIndex };
}
const moonCount = Math.min(planet.moonCount, 12);
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
for (const [moonIndex, moon] of planet.moons.entries()) {
const moonPosition = planetPosition
.clone()
.add(computeMoonLocalPosition(planet, moonIndex, nowSeconds, context.world.seed));
.add(computeMoonLocalPosition(moon, nowSeconds));
const moonDistance = localPosition.distanceTo(moonPosition);
const moonThreshold = Math.max(computeMoonSize(planet, moonIndex, context.world.seed) * 14, 80);
const moonThreshold = Math.max(moon.size * 14, 80);
if (moonDistance < moonThreshold && moonDistance < bestDistance) {
bestDistance = moonDistance;
bestAnchor = { kind: "moon", planetIndex, moonIndex };
@@ -417,9 +424,15 @@ export function computeCelestialLocalPositionById(
const parentInitialPosition = toThreeVector(parentCelestial.orbitalAnchor);
const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
const rotatedOffset = relativeOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentAngle - initialAngle);
const initialDir = parentInitialPosition.clone().normalize();
const currentDir = parentCurrentPosition.clone().normalize();
let rotatedOffset: THREE.Vector3;
if (initialDir.lengthSq() > 0.0001 && currentDir.lengthSq() > 0.0001) {
const quaternion = new THREE.Quaternion().setFromUnitVectors(initialDir, currentDir);
rotatedOffset = relativeOffset.clone().applyQuaternion(quaternion);
} else {
rotatedOffset = relativeOffset.clone();
}
return parentCurrentPosition.clone().add(rotatedOffset);
}
@@ -486,7 +499,7 @@ function computeStructureLocalPosition(
}
function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string, anchor: OrbitalAnchor, timeSeconds: number) {
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed);
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds);
}
function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) {

136
docs/SYSTEMS.md Normal file
View File

@@ -0,0 +1,136 @@
# Systems
This document describes the `shared/data/systems.json` schema and the units used throughout.
## Overview
Each entry in `systems.json` defines a solar system in the galaxy. Systems are loaded by the backend and used to build the simulation world. The galaxy position, star properties, planet orbits, asteroid fields, and resource nodes are all authored here.
---
## Top-Level Fields
| Field | Type | Unit | Description |
|---|---|---|---|
| `id` | string | — | Unique identifier, referenced by scenario, factions, routes |
| `label` | string | — | Display name |
| `position` | `[x, y, z]` | **light-years (ly)** | Position in the galaxy map. Used directly for inter-system distances and FTL transit time. Sol is at `[18.2, 0.02, -11.8]` ly, Helios at `[0, 0, 0]`. |
| `stars` | array | — | One entry per star. Single-star systems have one element; binary systems have two. See `stars` section below. |
---
## `stars`
Each star is an explicit object. Single-star systems have one entry; binary systems have two. Binary stars orbit the barycenter using the same orbital fields as planets.
| Field | Type | Unit | Description |
|---|---|---|---|
| `kind` | string | — | Classification: `"main-sequence"`, `"blue-white"`, `"white-dwarf"`, `"brown-dwarf"`, `"neutron-star"` |
| `color` | string | — | Hex color of the star body |
| `glow` | string | — | Hex color of the star's halo/corona |
| `size` | float | **km** | Radius of the star. Sol = 696,340 km |
| `orbitRadius` | float | **km** | Distance from system barycenter. `0` for single stars or the primary in a simple system. |
| `orbitSpeed` | float | **rad/s** | Angular velocity around barycenter. `0` for static stars. |
| `orbitPhaseAtEpoch` | float | **degrees** | Starting angle. |
---
## `asteroidField`
Defines the decorative asteroid belt for the system.
| Field | Type | Unit | Description |
|---|---|---|---|
| `decorationCount` | int | — | Number of decorative asteroid meshes to scatter |
| `radiusOffset` | float | **km** | Base orbit radius of the belt from the system center |
| `radiusVariance` | float | **km** | Random spread around `radiusOffset` |
| `heightVariance` | float | **km** | Vertical (Y-axis) spread of the belt |
Sol's belt (422,000,000 km base) maps to the real asteroid belt between Mars and Jupiter. Helios and Perseus use much smaller values since their orbits are scaled differently.
---
## `resourceNodes`
Each node is a mineable location in the system.
| Field | Type | Unit | Description |
|---|---|---|---|
| `sourceKind` | string | — | Type of node. e.g. `"asteroid-belt"` |
| `angle` | float | **radians** | Angular position in the orbit around the anchor |
| `radiusOffset` | float | **km** | Orbit radius around the anchor celestial |
| `inclinationDegrees` | float | **degrees** | Orbital inclination of the node's orbit |
| `anchorPlanetIndex` | int? | — | 0-based index into `planets` array. If absent, anchored to the system center |
| `oreAmount` | float | — | Starting ore amount in the node |
| `itemId` | string | — | Item ID of the mined resource |
| `shardCount` | int | — | Number of individual shards the node splits into |
---
## `planets`
Planets are defined with real Keplerian orbital elements. All angular values use the same epoch for consistency.
| Field | Type | Unit | Description |
|---|---|---|---|
| `label` | string | — | Display name |
| `planetType` | string | — | Classification: `"terrestrial"`, `"desert"`, `"barren"`, `"gas-giant"`, `"ice-giant"` |
| `shape` | string | — | Mesh hint: `"sphere"` or `"oblate"` |
| `moons` | array | — | Explicit moon definitions. See `moons` section below. |
| `orbitRadius` | float | **AU** | Semi-major axis in Astronomical Units. Converted to km at runtime using 1 AU = 149,597,870.7 km |
| `orbitSpeed` | float | **rad/s** (orbital sim seconds) | Angular velocity. Earth = 0.11 rad/s. Derived from Kepler's third law: `0.11 / sqrt(r³)` where r is in AU |
| `orbitEccentricity` | float | — | Orbital eccentricity (0 = circular) |
| `orbitInclination` | float | **degrees** | Inclination relative to the ecliptic plane. Converted to radians at runtime |
| `orbitLongitudeOfAscendingNode` | float | **degrees** | Longitude of the ascending node (Ω). Converted to radians at runtime |
| `orbitArgumentOfPeriapsis` | float | **degrees** | Argument of periapsis (ω). Converted to radians at runtime |
| `orbitPhaseAtEpoch` | float | **degrees** | Mean anomaly at epoch (initial angular position). Converted to radians at runtime |
| `size` | float | **km** | Radius of the planet. Earth = 6,371 km, Jupiter = 69,911 km |
| `color` | string | — | Hex color |
| `tilt` | float | **radians** | Axial tilt |
| `hasRing` | bool | — | Whether to render a ring system |
### `moons`
Each moon is an explicit celestial — a docking target in the simulation, just like a planet.
| Field | Type | Unit | Description |
|---|---|---|---|
| `label` | string | — | Display name |
| `size` | float | **km** | Moon radius |
| `color` | string | — | Hex color |
| `orbitRadius` | float | **km** | Orbit radius around the parent planet |
| `orbitSpeed` | float | **rad/s** | Angular velocity. Negative = retrograde (e.g. Triton). |
| `orbitPhaseAtEpoch` | float | **degrees** | Initial angle |
| `orbitInclination` | float | **degrees** | Inclination relative to the planet's equatorial plane |
| `orbitLongitudeOfAscendingNode` | float | **degrees** | Longitude of the ascending node |
For generated systems, moons are procedurally derived from the planet's size and label (seeded hash). Explicitly authored systems (Sol) define their moons directly.
---
### Orbit Speed Formula
```
orbitSpeed = 0.11 / sqrt(orbitRadius³)
```
Where `0.11` is Earth's angular speed in orbital-sim-seconds per radian, and `orbitRadius` is in AU. This gives each planet the correct relative orbital period.
Orbital sim time runs at a configurable multiplier relative to real time (`SimulatedSecondsPerRealSecond`), so the absolute speed only matters in relation to other planets, not to wall-clock time.
---
## Unit Summary
| Concept | Unit |
|---|---|
| Galaxy positions | light-years (ly) |
| Star / planet / moon sizes | km |
| Asteroid field radii | km |
| Resource node radii | km |
| Moon orbit radius | km |
| Planet orbit radius | AU (converted to km at runtime) |
| Planet / moon / star orbit speed | rad / orbital-sim-second |
| Orbital angles (inclination, ascending node, periapsis, phase) | degrees (converted to radians at runtime) |
| Resource node angle | radians |
| Resource node inclination | degrees |

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,10 +3,10 @@
{
"label": "Orbital Station",
"startingModules": [
"dock-bay-small",
"power-core",
"bulk-bay",
"liquid-tank"
"module_arg_dock_m_01_lowtech",
"module_gen_prod_energycells_01",
"module_arg_stor_solid_m_01",
"module_arg_stor_liquid_m_01"
],
"systemId": "helios",
"planetIndex": 2,

View File

@@ -21,35 +21,35 @@
"recipeId": "frigate-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 26
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "gun-turret-module",
"itemId": "turretcomponents",
"amount": 1
}
],
@@ -81,35 +81,35 @@
"recipeId": "destroyer-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 44
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "gun-turret-module",
"itemId": "turretcomponents",
"amount": 2
}
],
@@ -141,35 +141,35 @@
"recipeId": "cruiser-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 60
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "gun-turret-module",
"itemId": "turretcomponents",
"amount": 2
}
],
@@ -207,39 +207,39 @@
"recipeId": "carrier-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 120
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "carrier-bay-module",
"itemId": "dronecomponents",
"amount": 2
},
{
"itemId": "gun-turret-module",
"itemId": "turretcomponents",
"amount": 1
}
],
@@ -272,35 +272,35 @@
"recipeId": "hauler-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 34
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "container-bay-module",
"itemId": "hullparts",
"amount": 1
}
],
@@ -333,39 +333,39 @@
"recipeId": "constructor-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 42
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "fabricator-array-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "container-bay-module",
"itemId": "hullparts",
"amount": 1
}
],
@@ -399,39 +399,39 @@
"recipeId": "miner-construction",
"facilityCategory": "station",
"requiredModules": [
"ship-factory"
"module_gen_build_l_01"
],
"requirements": [
{
"itemId": "hull-sections",
"itemId": "hullparts",
"amount": 34
},
{
"itemId": "command-bridge-module",
"itemId": "advancedelectronics",
"amount": 1
},
{
"itemId": "reactor-core-module",
"itemId": "antimatterconverters",
"amount": 1
},
{
"itemId": "capacitor-bank-module",
"itemId": "shieldcomponents",
"amount": 1
},
{
"itemId": "ion-drive-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "ftl-core-module",
"itemId": "engineparts",
"amount": 1
},
{
"itemId": "mining-turret-module",
"itemId": "turretcomponents",
"amount": 1
},
{
"itemId": "bulk-bay-module",
"itemId": "hullparts",
"amount": 1
}
],

View File

@@ -1,12 +1,359 @@
[
{
"id": "sol",
"label": "Sol",
"position": [
18.2,
0.02,
-11.8
],
"stars": [
{
"kind": "main-sequence",
"color": "#fff1b8",
"glow": "#ffd35a",
"size": 696340
}
],
"asteroidField": {
"decorationCount": 240,
"radiusOffset": 422000000,
"radiusVariance": 180000000,
"heightVariance": 22000000
},
"resourceNodes": [
{
"sourceKind": "asteroid-belt",
"angle": 0.2,
"radiusOffset": 126000,
"inclinationDegrees": 4,
"anchorPlanetIndex": 3,
"oreAmount": 1000,
"itemId": "ore",
"shardCount": 9
},
{
"sourceKind": "asteroid-belt",
"angle": 1.8,
"radiusOffset": 148000,
"inclinationDegrees": -6,
"anchorPlanetIndex": 3,
"oreAmount": 1000,
"itemId": "ore",
"shardCount": 9
},
{
"sourceKind": "asteroid-belt",
"angle": 3.5,
"radiusOffset": 138000,
"inclinationDegrees": 8,
"anchorPlanetIndex": 4,
"oreAmount": 1000,
"itemId": "ore",
"shardCount": 9
},
{
"sourceKind": "asteroid-belt",
"angle": 5.1,
"radiusOffset": 164000,
"inclinationDegrees": -5,
"anchorPlanetIndex": 4,
"oreAmount": 1000,
"itemId": "ore",
"shardCount": 9
}
],
"planets": [
{
"label": "Mercury",
"planetType": "barren",
"shape": "sphere",
"moons": [],
"orbitRadius": 0.3871,
"orbitSpeed": 0.4567,
"orbitEccentricity": 0.2056,
"orbitInclination": 7.0,
"orbitLongitudeOfAscendingNode": 48,
"orbitArgumentOfPeriapsis": 29,
"orbitPhaseAtEpoch": 252,
"size": 2440,
"color": "#b7a08f",
"tilt": 0.03,
"hasRing": false
},
{
"label": "Venus",
"planetType": "desert",
"shape": "sphere",
"moons": [],
"orbitRadius": 0.7233,
"orbitSpeed": 0.1788,
"orbitEccentricity": 0.0067,
"orbitInclination": 3.4,
"orbitLongitudeOfAscendingNode": 76,
"orbitArgumentOfPeriapsis": 54,
"orbitPhaseAtEpoch": 181,
"size": 6052,
"color": "#d9b38c",
"tilt": 2.64,
"hasRing": false
},
{
"label": "Earth",
"planetType": "terrestrial",
"shape": "sphere",
"moons": [
{
"label": "Moon",
"size": 1737,
"color": "#c0bdb7",
"orbitRadius": 384400,
"orbitSpeed": 2.664,
"orbitPhaseAtEpoch": 135,
"orbitInclination": 5.1,
"orbitLongitudeOfAscendingNode": 0
}
],
"orbitRadius": 1.0,
"orbitSpeed": 0.11,
"orbitEccentricity": 0.0167,
"orbitInclination": 0.0,
"orbitLongitudeOfAscendingNode": 0,
"orbitArgumentOfPeriapsis": 114,
"orbitPhaseAtEpoch": 100,
"size": 6371,
"color": "#4f84c4",
"tilt": 0.41,
"hasRing": false
},
{
"label": "Mars",
"planetType": "desert",
"shape": "sphere",
"moons": [
{
"label": "Phobos",
"size": 11,
"color": "#b0a898",
"orbitRadius": 9376,
"orbitSpeed": 19.84,
"orbitPhaseAtEpoch": 20,
"orbitInclination": 1.1,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Deimos",
"size": 6,
"color": "#b8b0a4",
"orbitRadius": 23463,
"orbitSpeed": 7.72,
"orbitPhaseAtEpoch": 200,
"orbitInclination": 1.8,
"orbitLongitudeOfAscendingNode": 0
}
],
"orbitRadius": 1.5237,
"orbitSpeed": 0.05847,
"orbitEccentricity": 0.0934,
"orbitInclination": 1.85,
"orbitLongitudeOfAscendingNode": 49,
"orbitArgumentOfPeriapsis": 286,
"orbitPhaseAtEpoch": 54,
"size": 3390,
"color": "#c56e52",
"tilt": 0.44,
"hasRing": false
},
{
"label": "Jupiter",
"planetType": "gas-giant",
"shape": "oblate",
"moons": [
{
"label": "Io",
"size": 1821,
"color": "#e8c97a",
"orbitRadius": 421800,
"orbitSpeed": 0.8924,
"orbitPhaseAtEpoch": 0,
"orbitInclination": 0.04,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Europa",
"size": 1561,
"color": "#c8bfa8",
"orbitRadius": 671100,
"orbitSpeed": 0.5567,
"orbitPhaseAtEpoch": 90,
"orbitInclination": 0.47,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Ganymede",
"size": 2634,
"color": "#a89880",
"orbitRadius": 1070400,
"orbitSpeed": 0.3470,
"orbitPhaseAtEpoch": 180,
"orbitInclination": 0.18,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Callisto",
"size": 2410,
"color": "#8c8070",
"orbitRadius": 1882700,
"orbitSpeed": 0.1836,
"orbitPhaseAtEpoch": 270,
"orbitInclination": 0.19,
"orbitLongitudeOfAscendingNode": 0
}
],
"orbitRadius": 5.203,
"orbitSpeed": 0.009274,
"orbitEccentricity": 0.0489,
"orbitInclination": 1.3,
"orbitLongitudeOfAscendingNode": 100,
"orbitArgumentOfPeriapsis": 275,
"orbitPhaseAtEpoch": 34,
"size": 69911,
"color": "#d9b06f",
"tilt": 0.05,
"hasRing": true
},
{
"label": "Saturn",
"planetType": "gas-giant",
"shape": "oblate",
"moons": [
{
"label": "Titan",
"size": 2575,
"color": "#d4a850",
"orbitRadius": 1221900,
"orbitSpeed": 0.2238,
"orbitPhaseAtEpoch": 45,
"orbitInclination": 0.33,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Enceladus",
"size": 252,
"color": "#e8eef5",
"orbitRadius": 238000,
"orbitSpeed": 0.9328,
"orbitPhaseAtEpoch": 220,
"orbitInclination": 0.02,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Rhea",
"size": 764,
"color": "#c8c0b0",
"orbitRadius": 527100,
"orbitSpeed": 0.3894,
"orbitPhaseAtEpoch": 130,
"orbitInclination": 0.35,
"orbitLongitudeOfAscendingNode": 0
}
],
"orbitRadius": 9.582,
"orbitSpeed": 0.003708,
"orbitEccentricity": 0.0565,
"orbitInclination": 2.49,
"orbitLongitudeOfAscendingNode": 113,
"orbitArgumentOfPeriapsis": 339,
"orbitPhaseAtEpoch": 200,
"size": 58232,
"color": "#dfc27d",
"tilt": 0.47,
"hasRing": true
},
{
"label": "Uranus",
"planetType": "ice-giant",
"shape": "oblate",
"moons": [
{
"label": "Titania",
"size": 789,
"color": "#b0b8c0",
"orbitRadius": 435910,
"orbitSpeed": 0.3560,
"orbitPhaseAtEpoch": 60,
"orbitInclination": 0.08,
"orbitLongitudeOfAscendingNode": 0
},
{
"label": "Oberon",
"size": 761,
"color": "#a0a8b0",
"orbitRadius": 583520,
"orbitSpeed": 0.2706,
"orbitPhaseAtEpoch": 240,
"orbitInclination": 0.07,
"orbitLongitudeOfAscendingNode": 0
}
],
"orbitRadius": 19.201,
"orbitSpeed": 0.001307,
"orbitEccentricity": 0.046,
"orbitInclination": 0.77,
"orbitLongitudeOfAscendingNode": 74,
"orbitArgumentOfPeriapsis": 97,
"orbitPhaseAtEpoch": 130,
"size": 25362,
"color": "#9fd3df",
"tilt": 1.71,
"hasRing": true
},
{
"label": "Neptune",
"planetType": "ice-giant",
"shape": "oblate",
"moons": [
{
"label": "Triton",
"size": 1353,
"color": "#b8c8d0",
"orbitRadius": 354800,
"orbitSpeed": -0.4076,
"orbitPhaseAtEpoch": 100,
"orbitInclination": 157,
"orbitLongitudeOfAscendingNode": 0
}
],
"orbitRadius": 30.047,
"orbitSpeed": 0.000668,
"orbitEccentricity": 0.009,
"orbitInclination": 1.77,
"orbitLongitudeOfAscendingNode": 132,
"orbitArgumentOfPeriapsis": 273,
"orbitPhaseAtEpoch": 256,
"size": 24622,
"color": "#4c79c9",
"tilt": 0.49,
"hasRing": true
}
]
},
{
"id": "helios",
"label": "Helios Reach",
"position": [0, 0, 0],
"starColor": "#ffd27a",
"starGlow": "#ffb14a",
"starSize": 720000,
"gravityWellRadius": 210,
"position": [
0,
0,
0
],
"stars": [
{
"kind": "main-sequence",
"color": "#ffd27a",
"glow": "#ffb14a",
"size": 720000
}
],
"asteroidField": {
"decorationCount": 180,
"radiusOffset": 330000,
@@ -15,20 +362,57 @@
},
"resourceNodes": [],
"planets": [
{ "label": "Icarus", "orbitRadius": 0.36, "orbitSpeed": 0.5093, "size": 4200, "color": "#d4a373", "tilt": 0.2 },
{ "label": "Viridia", "orbitRadius": 0.60, "orbitSpeed": 0.2366, "size": 6200, "color": "#58a36c", "tilt": -0.4 },
{ "label": "Aster", "orbitRadius": 0.92, "orbitSpeed": 0.1246, "size": 7800, "color": "#6ea7d4", "tilt": 0.3, "hasRing": true },
{ "label": "Noctis", "orbitRadius": 1.34, "orbitSpeed": 0.0710, "size": 11200, "color": "#6958a8", "tilt": -0.15 }
{
"label": "Icarus",
"orbitRadius": 0.36,
"orbitSpeed": 0.5093,
"size": 4200,
"color": "#d4a373",
"tilt": 0.2
},
{
"label": "Viridia",
"orbitRadius": 0.60,
"orbitSpeed": 0.2366,
"size": 6200,
"color": "#58a36c",
"tilt": -0.4
},
{
"label": "Aster",
"orbitRadius": 0.92,
"orbitSpeed": 0.1246,
"size": 7800,
"color": "#6ea7d4",
"tilt": 0.3,
"hasRing": true
},
{
"label": "Noctis",
"orbitRadius": 1.34,
"orbitSpeed": 0.0710,
"size": 11200,
"color": "#6958a8",
"tilt": -0.15
}
]
},
{
"id": "perseus",
"label": "Perseus Gate",
"position": [4.4, 0, 0.62],
"starColor": "#9dc6ff",
"starGlow": "#66a0ff",
"starSize": 930000,
"gravityWellRadius": 230,
"position": [
4.4,
0,
0.62
],
"stars": [
{
"kind": "blue-white",
"color": "#9dc6ff",
"glow": "#66a0ff",
"size": 930000
}
],
"asteroidField": {
"decorationCount": 180,
"radiusOffset": 330000,
@@ -36,14 +420,54 @@
"heightVariance": 18000
},
"resourceNodes": [
{ "angle": 0.45, "radiusOffset": 180000, "oreAmount": 3000, "itemId": "ore", "shardCount": 7 },
{ "angle": 2.544395102, "radiusOffset": 180000, "oreAmount": 3000, "itemId": "ore", "shardCount": 7 },
{ "angle": 4.638790205, "radiusOffset": 180000, "oreAmount": 3000, "itemId": "ore", "shardCount": 7 }
{
"angle": 0.45,
"radiusOffset": 180000,
"oreAmount": 3000,
"itemId": "ore",
"shardCount": 7
},
{
"angle": 2.544395102,
"radiusOffset": 180000,
"oreAmount": 3000,
"itemId": "ore",
"shardCount": 7
},
{
"angle": 4.638790205,
"radiusOffset": 180000,
"oreAmount": 3000,
"itemId": "ore",
"shardCount": 7
}
],
"planets": [
{ "label": "Talos", "orbitRadius": 0.40, "orbitSpeed": 0.4348, "size": 5000, "color": "#c48f6a", "tilt": 0.18 },
{ "label": "Cygnus", "orbitRadius": 0.72, "orbitSpeed": 0.1800, "size": 6900, "color": "#4f84c4", "tilt": -0.22, "hasRing": true },
{ "label": "Rhea", "orbitRadius": 1.08, "orbitSpeed": 0.0981, "size": 9600, "color": "#8f8fb0", "tilt": 0.08 }
{
"label": "Talos",
"orbitRadius": 0.40,
"orbitSpeed": 0.4348,
"size": 5000,
"color": "#c48f6a",
"tilt": 0.18
},
{
"label": "Cygnus",
"orbitRadius": 0.72,
"orbitSpeed": 0.1800,
"size": 6900,
"color": "#4f84c4",
"tilt": -0.22,
"hasRing": true
},
{
"label": "Rhea",
"orbitRadius": 1.08,
"orbitSpeed": 0.0981,
"size": 9600,
"color": "#8f8fb0",
"tilt": 0.08
}
]
}
]