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