Files
space-game/apps/backend/Universe/Scenario/SystemGenerationService.cs

569 lines
23 KiB
C#

using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class SystemGenerationService
{
private const float KnownSystemSelectionChance = 0.5f;
internal List<SolarSystemDefinition> PrepareKnownSystems(IReadOnlyList<SolarSystemDefinition> knownSystems) =>
knownSystems
.Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index))
.ToList();
internal List<SolarSystemDefinition> GenerateSystems(
IReadOnlyList<SolarSystemDefinition> knownSystems,
WorldGenerationOptions worldGenerationOptions)
{
if (worldGenerationOptions.TargetSystemCount <= 0)
{
return [];
}
if (knownSystems.Count == 0)
{
throw new InvalidOperationException("World generation requires at least one known system template.");
}
var systems = new List<SolarSystemDefinition>(worldGenerationOptions.TargetSystemCount);
var availableKnownSystems = knownSystems.Select(CloneSystemDefinition).ToList();
var templateSystems = knownSystems.Select(CloneSystemDefinition).ToList();
var existingIds = new HashSet<string>(StringComparer.Ordinal);
var occupiedPositions = new List<Vector3>();
var generatedSystemCount = 0;
for (var slotIndex = 0; slotIndex < worldGenerationOptions.TargetSystemCount; slotIndex += 1)
{
if (ShouldUseKnownSystem(worldGenerationOptions, slotIndex, availableKnownSystems.Count))
{
var knownSystemIndex = SelectKnownSystemIndex(worldGenerationOptions.Seed, slotIndex, availableKnownSystems.Count);
var knownSystem = availableKnownSystems[knownSystemIndex];
availableKnownSystems.RemoveAt(knownSystemIndex);
systems.Add(knownSystem);
existingIds.Add(knownSystem.Id);
occupiedPositions.Add(ToVector(knownSystem.Position));
continue;
}
var template = templateSystems[generatedSystemCount % templateSystems.Count];
var name = GeneratedSystemNames[generatedSystemCount % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, generatedSystemCount + 1);
while (!existingIds.Add(id))
{
id = $"{id}-x";
}
var position = BuildGeneratedSystemPosition(occupiedPositions, generatedSystemCount);
systems.Add(CreateGeneratedSystem(template, name, id, generatedSystemCount, position));
occupiedPositions.Add(position);
generatedSystemCount += 1;
}
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,
AnchorReference = node.AnchorReference,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList();
return EnsureStrategicResourceCoverage(new SolarSystemDefinition
{
Id = id,
Label = label,
Position = [position.X, position.Y, position.Z],
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),
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18000f),
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12000f),
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4000f),
},
ResourceNodes = resourceNodes,
Planets = planets,
}, generatedIndex + 1024);
}
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
{
return new SolarSystemDefinition
{
Id = definition.Id,
Label = definition.Label,
Position = definition.Position.ToArray(),
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,
RadiusOffset = definition.AsteroidField.RadiusOffset,
RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance,
},
ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
AnchorReference = node.AnchorReference,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
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,
Moons = planet.Moons.Select(moon => new MoonDefinition { Label = moon.Label, Size = moon.Size, Color = moon.Color, OrbitRadius = moon.OrbitRadius, OrbitSpeed = moon.OrbitSpeed, OrbitPhaseAtEpoch = moon.OrbitPhaseAtEpoch, OrbitInclination = moon.OrbitInclination, OrbitLongitudeOfAscendingNode = moon.OrbitLongitudeOfAscendingNode }).ToList(),
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,
AnchorReference = node.AnchorReference,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}));
}
return nodes;
}
private static SolarSystemDefinition EnsureStrategicResourceCoverage(SolarSystemDefinition system, int seed)
{
for (var index = 0; index < system.ResourceNodes.Count; index += 1)
{
system.ResourceNodes[index] = SanitizeResourceNode(system.ResourceNodes[index], system.Planets, seed, index);
}
var requiredItems = new[] { "ore", "silicon", "ice", "hydrogen", "helium", "methane" };
foreach (var itemId in requiredItems)
{
if (system.ResourceNodes.Any(node => string.Equals(node.ItemId, itemId, StringComparison.Ordinal)))
{
continue;
}
system.ResourceNodes.Add(BuildStrategicResourceNode(itemId, system.Planets, seed, system.ResourceNodes.Count));
}
return system;
}
private static Vector3 BuildGeneratedSystemPosition(IReadOnlyCollection<Vector3> occupiedPositions, int generatedIndex)
{
var allPositions = occupiedPositions.ToList();
for (var attempt = 0; attempt < 64; attempt += 1)
{
var candidate = ComputeGeneratedSystemPosition(generatedIndex, attempt);
if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
return candidate;
}
}
return ComputeFallbackGeneratedSystemPosition(generatedIndex);
}
private static bool ShouldUseKnownSystem(
WorldGenerationOptions worldGenerationOptions,
int slotIndex,
int remainingKnownSystemCount)
{
if (!worldGenerationOptions.UseKnownSystems || remainingKnownSystemCount <= 0)
{
return false;
}
return Hash01(worldGenerationOptions.Seed, 700 + slotIndex) >= KnownSystemSelectionChance;
}
private static int SelectKnownSystemIndex(int seed, int slotIndex, int remainingKnownSystemCount)
{
var selection = Hash01(seed, 900 + slotIndex);
return Math.Min((int)(selection * remainingKnownSystemCount), remainingKnownSystemCount - 1);
}
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
{
const int armCount = 4;
const float baseInnerRadius = 9f;
const float radiusStep = 0.54f;
const float armOffset = MathF.PI * 2f / armCount;
var armIndex = (generatedIndex + attempt) % armCount;
var armDepth = generatedIndex / armCount;
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 0.9f);
var angle = (armIndex * armOffset) + (radius / 8.2f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
var x = MathF.Cos(angle) * radius;
var z = MathF.Sin(angle) * radius * 0.58f;
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
return new Vector3(x, y, z);
}
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
{
const int ringCount = 5;
const float fallbackRadius = 42f;
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1.8f;
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 ResourceNodeDefinition BuildStrategicResourceNode(
string itemId,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{
var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets);
return new ResourceNodeDefinition
{
SourceKind = "local-space",
AnchorReference = ResolveStrategicAnchorReference(itemId, planets, ordinal),
Angle = (MathF.PI * 2f * ((ordinal % 7) / 7f)) + Jitter(seed, 400 + ordinal, 0.35f),
RadiusOffset = 150000f + Jitter(seed, 460 + ordinal, 42000f),
InclinationDegrees = Jitter(seed, 520 + ordinal, 10f),
AnchorPlanetIndex = anchorPlanetIndex,
OreAmount = itemId switch
{
"ore" => 12000f,
"silicon" => 10000f,
"ice" => 9000f,
_ => 8000f,
},
ItemId = itemId,
ShardCount = itemId switch
{
"ore" or "silicon" or "ice" => 8,
_ => 6,
},
};
}
private static ResourceNodeDefinition SanitizeResourceNode(
ResourceNodeDefinition node,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{
node.SourceKind = "local-space";
node.AnchorReference ??= ResolveLegacyAnchorReference(node, planets, seed, ordinal);
return node;
}
private static string ResolveLegacyAnchorReference(
ResourceNodeDefinition node,
IReadOnlyList<PlanetDefinition> planets,
int seed,
int ordinal)
{
if (node.AnchorMoonIndex is int moonIndex && node.AnchorPlanetIndex is int planetIndex && planetIndex >= 0)
{
return $"planet-{planetIndex + 1}-moon-{moonIndex + 1}";
}
if (node.AnchorPlanetIndex is int anchoredPlanetIndex && anchoredPlanetIndex >= 0)
{
return $"planet-{anchoredPlanetIndex + 1}";
}
return ResolveStrategicAnchorReference(node.ItemId, planets, ordinal + seed);
}
private static string ResolveStrategicAnchorReference(string itemId, IReadOnlyList<PlanetDefinition> planets, int ordinal)
{
if (itemId is "hydrogen" or "helium" or "methane")
{
var gasGiantIndex = planets
.Select((planet, index) => (planet, index))
.FirstOrDefault(entry => entry.planet.PlanetType is "gas-giant" or "ice-giant")
.index;
return gasGiantIndex > 0 || (planets.Count > 0 && planets[0].PlanetType is "gas-giant" or "ice-giant")
? $"planet-{gasGiantIndex + 1}"
: "star-1";
}
if (itemId == "ice")
{
var moonAnchor = planets
.Select((planet, index) => (planet, index))
.FirstOrDefault(entry => entry.planet.Moons.Count > 0 && entry.planet.PlanetType is "ice" or "ice-giant" or "oceanic");
if (moonAnchor.planet is not null && moonAnchor.planet.Moons.Count > 0)
{
return $"planet-{moonAnchor.index + 1}-moon-1";
}
}
var anchorPlanetIndex = ResolveStrategicResourceAnchorPlanetIndex(itemId, planets);
var lagrange = (ordinal % 3) switch
{
0 => "l1",
1 => "l4",
_ => "l5",
};
return $"planet-{anchorPlanetIndex + 1}-{lagrange}";
}
private static int ResolveStrategicResourceAnchorPlanetIndex(string itemId, IReadOnlyList<PlanetDefinition> planets)
{
if (planets.Count == 0)
{
return 0;
}
bool MatchesPlanetType(PlanetDefinition planet) => itemId switch
{
"hydrogen" or "helium" or "methane" => planet.PlanetType is "gas-giant" or "ice-giant",
"ice" => planet.PlanetType is "ice" or "ice-giant" or "oceanic",
_ => planet.PlanetType is not "gas-giant" and not "ice-giant",
};
for (var index = 0; index < planets.Count; index += 1)
{
if (MatchesPlanetType(planets[index]))
{
return index;
}
}
return ResolveAsteroidAnchorPlanetIndex(planets);
}
private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)
{
if (planets.Count == 0)
{
return 0;
}
var gasGiantIndex = -1;
for (var index = 0; index < planets.Count; index += 1)
{
if (planets[index].PlanetType is "gas-giant" or "ice-giant")
{
gasGiantIndex = index;
break;
}
}
if (gasGiantIndex > 0)
{
return gasGiantIndex - 1;
}
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
}
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 = 0.24f + (Hash01(generatedIndex, 3) * 0.12f);
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 moonCount = profile.BaseMoonCount + (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
var planetLabel = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}";
planets.Add(new PlanetDefinition
{
Label = planetLabel,
PlanetType = profile.Type,
Shape = profile.Shape,
Moons = GenerateMoons(planetLabel, profile.BaseSize, moonCount),
OrbitRadius = orbitRadius,
OrbitSpeed = 0.11f / MathF.Sqrt(MathF.Max(orbitRadius * orbitRadius * orbitRadius, 0.02f)),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f,
OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f,
OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f,
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * (profile.BaseSize * 0.35f)),
Color = templatePlanet?.Color ?? profile.Color,
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
});
}
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 - 8f) / 28f));
var band = 0.22f + (normalized * 0.76f);
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
}
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
// Cheap deterministic pseudo-random helper: same (index, salt) pair always maps to the same 0..1 value.
// Generation code uses it instead of a mutable RNG so each procedural choice stays stable for a given seed.
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 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;
}
}