diff --git a/Assets/Resources/Settings.asset b/Assets/Resources/Settings.asset index 4536e99f1..f92142d6b 100644 --- a/Assets/Resources/Settings.asset +++ b/Assets/Resources/Settings.asset @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c67fcecfea500365c54e5c80e59b6f26d0526e877c266d9fa75a43053df225d -size 12647 +oid sha256:ab2cdc0732716dcbfa4875e90e598b0dff3f0c8d4f871cc180649a3db2e4cf0c +size 12562 diff --git a/Assets/Scripts/Gameplay/ActionGameManager.cs b/Assets/Scripts/Gameplay/ActionGameManager.cs index ecb4af6d2..503d2836f 100644 --- a/Assets/Scripts/Gameplay/ActionGameManager.cs +++ b/Assets/Scripts/Gameplay/ActionGameManager.cs @@ -723,6 +723,7 @@ public class ActionGameManager : MonoBehaviour galaxyZone.PackedContents ??= ZoneGenerator.GenerateZone( ItemManager, Settings.ZoneSettings, + Settings.PlanetSettings, CurrentGalaxy, galaxyZone, CurrentGalaxy.IsPrelude @@ -846,7 +847,7 @@ public class ActionGameManager : MonoBehaviour { staged = RunSave.Replace(CultCache, () => { - RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, CurrentGalaxy, scenario); + RunStart.GenerateArena(ItemManager, Settings.ZoneSettings, Settings.PlanetSettings, CurrentGalaxy, scenario); PopulateLevel(CurrentGalaxy.Entrance); return RunStart.Stage(ItemManager, Zone, scenario, Settings.StartingHullName, Settings.TutorialGenerationSettings, failures); }); diff --git a/Assets/Scripts/ServerShared/RunStart.cs b/Assets/Scripts/ServerShared/RunStart.cs index a8e0f6134..b330ad944 100644 --- a/Assets/Scripts/ServerShared/RunStart.cs +++ b/Assets/Scripts/ServerShared/RunStart.cs @@ -23,11 +23,11 @@ public static class RunStart return scenario.Generate(stage) ?? throw new InvalidOperationException($"Scenario {scenario.Name} generated no galaxy."); } - public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, Galaxy galaxy, Scenario scenario) + public static ZonePack GenerateArena(ItemManager itemManager, ZoneGenerationSettings zoneSettings, PlanetSettings planetSettings, Galaxy galaxy, Scenario scenario) { SeedItems(itemManager, galaxy, 1); return galaxy.Entrance.PackedContents = ZoneGenerator.GenerateZone( - itemManager, zoneSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); + itemManager, zoneSettings, planetSettings, galaxy, galaxy.Entrance, galaxy.IsPrelude, scenario.Ambient); } // Runs the scenario's Stage, then admits its player and everything it placed: each entity's stance set both ways diff --git a/Assets/Scripts/ServerShared/Settings.cs b/Assets/Scripts/ServerShared/Settings.cs index 235092f90..85d1f3d96 100644 --- a/Assets/Scripts/ServerShared/Settings.cs +++ b/Assets/Scripts/ServerShared/Settings.cs @@ -111,7 +111,6 @@ public class TutorialGenerationSettings [Serializable, MessagePackObject(keyAsPropertyName:true), JsonObject] public class ZoneGenerationSettings { - public ExponentialCurve PlanetSafetyRadius; public float MassFloor = 1; public float SunMass = 10000; diff --git a/Assets/Scripts/ServerShared/ZoneGenerator.cs b/Assets/Scripts/ServerShared/ZoneGenerator.cs index c6ec75375..149e63db4 100644 --- a/Assets/Scripts/ServerShared/ZoneGenerator.cs +++ b/Assets/Scripts/ServerShared/ZoneGenerator.cs @@ -40,6 +40,7 @@ public static class ZoneGenerator public static ZonePack GenerateZone( ItemManager itemManager, ZoneGenerationSettings zoneSettings, + PlanetSettings planetSettings, Galaxy galaxy, GalaxyZone galaxyZone, bool isTutorial = false, @@ -88,13 +89,17 @@ public static class ZoneGenerator } var totalArea = occupiedAreas.Sum(c => c.Area); - foreach (var c in occupiedAreas) + // Every area root is massive, so a root's reach is Reach x its nearest area's centre (WellTree.SubzoneReach) + var centres = occupiedAreas.Select(c => c.Center).ToList(); + for (var k = 0; k < occupiedAreas.Count; k++) { - planets.AddRange(GenerateEntities(zoneSettings, ref random, c.Area / totalArea * pack.Mass, c.Radius, c.Center)); + var c = occupiedAreas[k]; + planets.AddRange(GenerateEntities(zoneSettings, planetSettings, ref random, c.Area / totalArea * pack.Mass, c.Radius, c.Center, + WellTree.SubzoneReach(centres, k))); } } else - planets.AddRange(GenerateEntities(zoneSettings, ref random, pack.Mass, pack.Radius, float2.zero)); + planets.AddRange(GenerateEntities(zoneSettings, planetSettings, ref random, pack.Mass, pack.Radius, float2.zero, float.PositiveInfinity)); // Every orbit is a record; parents link to records, so each orbit holds its key before any child names it var orbitMap = new Dictionary(); @@ -147,7 +152,7 @@ public static class ZoneGenerator Enumerable.Range(0, (int) zoneSettings.AsteroidCount.Evaluate(beltData.Mass * orbitMap[planet].Distance)) .Select(_ => new Asteroid { - Distance = orbitMap[planet].Distance + random.NextFloat()*(random.NextFloat()-.5f)*zoneSettings.AsteroidBeltWidth.Evaluate(orbitMap[planet].Distance), + Distance = orbitMap[planet].Distance + random.NextFloat()*(random.NextFloat()-.5f)*2*planet.BeltHalfWidth, Phase = random.NextFloat(), Size = random.NextFloat(), RotationSpeed = zoneSettings.AsteroidRotationSpeed.Evaluate(random.NextFloat()) @@ -209,6 +214,20 @@ public static class ZoneGenerator return cache.Upsert(planetData); }).ToList(); + // The generator's own well tree, for clearance only (the Zone builds its own): what a rail must stay out of is the body's + // support, or its Roche margin if more. + var wellNodes = planets.Select(p => new WellNode(cache.RefOf(orbitMap[p]).Key, p.Parent == null ? default : cache.RefOf(orbitMap[p.Parent]).Key, + p.Distance, p.Phase, p.Empty || p.Belt ? 0f : p.Mass, + p.Belt ? p.Distance + p.BeltHalfWidth : p.Distance, p.FixedPosition)).ToList(); + var wells = WellTree.Build(wellNodes, planetSettings, pack.Radius); + float Extent(GeneratorPlanet p) + { + var key = cache.RefOf(orbitMap[p]).Key; + var roche = p.Empty || p.Belt ? 0f : WellTree.Curves(planetSettings, p.Mass).Softening * RocheMargin; + return Math.Max(wells.Support(key), roche); + } + bool Massive(GeneratorPlanet p) => wells.Reach(cache.RefOf(orbitMap[p]).Key) > 0f; + var nearestFaction = galaxy.Factions.MinBy(f => galaxy.HomeZones[f].Distance[galaxyZone]); var nearestFactionHomeZone = galaxy.HomeZones[nearestFaction]; var factionPresence = galaxy.InfluenceOf(nearestFaction) - nearestFactionHomeZone.Distance[galaxyZone] + 1; @@ -224,30 +243,35 @@ public static class ZoneGenerator // seed. galaxy.Entrance is reference-identity, not a name comparison. var isTutorialEntrance = isTutorial && galaxyZone == galaxy.Entrance; if (isTutorialEntrance && stationCount < 1) stationCount = 1; - var potentialLagrangePoints = planets - .Where(p => p.Parent != null && p.Parent.Children - .TrueForAll(c => !(c != p && abs(c.Distance - p.Distance) < .1f))) // Filter Rosettes + // A station's source is a well-bearing body alone on its ring whose rail clears every massive sibling's extent (the + // sibling's support, or its Roche margin if more): its Lagrange orbit then sits clear of any other body. + var candidates = planets + .Where(p => p.Parent != null && !p.Empty && !p.Belt + && p.Parent.Children.TrueForAll(c => c == p || (abs(c.Distance - p.Distance) >= WellTree.RingWindow + && (!Massive(c) || abs(c.Distance - p.Distance) > Extent(c))))) .OrderBy(p => p.Distance) .ToArray(); - // The entrance's own station count is the floor above; its orbit always comes from this same selection. - // Ordinarily that selection is potentialLagrangePoints (non-rosette planet orbits). If the entrance zone - // has none of those (a boring single-body system, or a system that is entirely one rosette), widen to - // every planet orbit ordinary placement's own candidate pool is drawn from before the rosette filter -- - // still a real, parented planet orbit with a positive Distance -- rather than inventing an unparented, - // zero-distance orbit (that gave PlaceTurret's phase = 20*multiplier/orbit.Distance a division by zero, - // and left an orbit record with no planet behind it). - var lagrangeCandidates = potentialLagrangePoints; - if (isTutorialEntrance && lagrangeCandidates.Length == 0) - lagrangeCandidates = planets.Where(p => p.Parent != null).OrderBy(p => p.Distance).ToArray(); - // Pick a selection from the middle of the distribution - var selectedStationOrbits = lagrangeCandidates - .Skip(lagrangeCandidates.Length / 2) - .Take(stationCount) - .Select(p=>orbitMap[p]) - .ToArray(); - if (isTutorialEntrance && lagrangeCandidates.Length == 0) - throw new InvalidOperationException( - "The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root."); + // (the rail, how far a body on it keeps others out, whether it is a station's own orbit rather than a base's) + var sources = candidates.Skip(candidates.Length / 2).Take(stationCount) + .Select(p => (orbit: orbitMap[p], extent: Extent(p), direct: false)).ToArray(); + if (isTutorialEntrance && candidates.Length == 0) + { + // The entrance gets a station whatever its bodies: it orbits a root node beyond its ring group (or, for a body, its + // whole family), never at a ring member's distance, and sits there, not at a Lagrange point. + var rootNode = planets.FirstOrDefault(p => p.Parent == null && p.Children.Count > 0); + if (rootNode == null) + throw new InvalidOperationException( + "The tutorial entrance zone must always get a station, but it has no candidate orbit at all -- every planet in the zone is a parentless root."); + var rootKey = cache.RefOf(orbitMap[rootNode]).Key; + var distance = rootNode.Empty + ? (1 + GravityField.RingReach) * GravityField.Beta * rootNode.Children.Where(Massive).Min(c => c.Distance) + : wells.Support(rootKey); + var direct = new OrbitData { Parent = cache.RefOf(orbitMap[rootNode]), Distance = distance, Phase = random.NextFloat() }; + pack.Orbits.Add(cache.Upsert(direct)); + sources = new[] { (orbit: direct, extent: float.NegativeInfinity, direct: true) }; + } + var selectedStationOrbits = sources.Select(x => x.orbit).ToArray(); + var extentOf = sources.ToDictionary(x => x.orbit, x => x.extent); var loadoutGenerators = new Dictionary(); @@ -261,6 +285,7 @@ public static class ZoneGenerator // L4 or L5: 60 degrees (one sixth of a turn) ahead of or behind the planet on its orbit. Phase is in turns. OrbitData CreateLagrangeOrbit(OrbitData baseOrbit) { + if (sources.First(x => x.orbit == baseOrbit).direct) return baseOrbit; var lagrangeOrbit = new OrbitData { Parent = baseOrbit.Parent, @@ -276,36 +301,48 @@ public static class ZoneGenerator // body's. Every body's reach counts, so turrets of differing reach clear each other. Phase is in turns, so the // arc length divides by the orbit's circumference. Returns where the turret sits (signed arc from the station) // and its reach, or null when there is no turret hull to generate, before any orbit is created. - (float arc, float reach)? PlaceTurret(OrbitData orbit, LoadoutGenerator loadoutGenerator, float side, float innerArc, float innerReach) + (float arc, float reach, bool placed)? PlaceTurret(OrbitData orbit, LoadoutGenerator loadoutGenerator, float side, float innerArc, float innerReach, + OrbitData baseOrbit) { var turret = loadoutGenerator.GenerateTurretLoadout(); if (turret == null) return null; var turretReach = (itemManager.GetData(turret.Hull) as HullData).Reach(itemManager.GameplaySettings.SchematicCellSize); var arc = innerArc + side * (innerReach + 2 * turretReach); + // A turret whose centre is within the base body's extent plus its own reach of the base is not placed: its side stops. + var phase = orbit.Phase + arc / (2 * PI * orbit.Distance); + if (2 * orbit.Distance * abs(sin(PI * (phase - baseOrbit.Phase))) < extentOf[baseOrbit] + turretReach) + { + itemManager.Log("A turret was not placed: its station's base body is within its reach (story-turret-tail-clearance)."); + return (arc, turretReach, false); + } var turretOrbit = new OrbitData { Parent = orbit.Parent, Distance = orbit.Distance, - Phase = orbit.Phase + arc / (2 * PI * orbit.Distance) + Phase = phase + }; pack.Orbits.Add(cache.Upsert(turretOrbit)); turret.Orbit = cache.RefOf(turretOrbit); pack.Entities.Add(turret); - return (arc, turretReach); + return (arc, turretReach, true); } // Turrets flank the station alternately, behind then ahead, each side growing outward from the station. - void PlaceTurrets(OrbitData orbit, LoadoutGenerator loadoutGenerator, int count, float stationReach) + void PlaceTurrets(OrbitData orbit, LoadoutGenerator loadoutGenerator, int count, float stationReach, OrbitData baseOrbit) { var arcs = new float[2]; + var stopped = new bool[2]; var reaches = new[] { stationReach, stationReach }; for(int t=0; t= selectedStationOrbits.Length) { itemManager.Log($"Story station for {story.Faction?.Name} has no rail clear of the bodies; its story has nowhere to live!"); continue; } var orbit = selectedStationOrbits[i]; var lagrangeOrbit = CreateLagrangeOrbit(orbit); var station = GetLoadoutGenerator(story.Faction).GenerateStationLoadout(); @@ -329,7 +367,7 @@ public static class ZoneGenerator station.Story = i; pack.Entities.Add(station); - if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets, StationReach(station)); + if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(story.Faction), story.Turrets, StationReach(station), orbit); } for (var i = storyStations.Length; i < selectedStationOrbits.Length; i++) @@ -351,7 +389,7 @@ public static class ZoneGenerator station.SecurityRadius = pack.Radius; pack.Entities.Add(station); - if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2, StationReach(station)); + if (ambient) PlaceTurrets(lagrangeOrbit, GetLoadoutGenerator(nearestFaction), 2, StationReach(station), orbit); } // A boss zone gets its faction's boss whether or not the zone is ambient: the boss is the run's goal, not @@ -413,16 +451,15 @@ public static class ZoneGenerator // 1 / lerp(settings.ResourceDensityMinimum, settings.ResourceDensityMaximum, density)); // } - public static GeneratorPlanet[] GenerateEntities(ZoneGenerationSettings settings, ref Random random, float mass, float radius, float2 fixedPosition) + public static GeneratorPlanet[] GenerateEntities(ZoneGenerationSettings settings, PlanetSettings planetSettings, ref Random random, float mass, float radius, float2 fixedPosition, + float rootReach) { var root = new GeneratorPlanet { FixedPosition = fixedPosition, Settings = settings, - Mass = mass, - ChildDistanceMaximum = radius * .75f, - ChildDistanceMinimum = settings.PlanetSafetyRadius.Evaluate(mass) - }; + Mass = mass + }; // There is some chance of generating a rosette or binary system // Probabilities which are fixed for the entire galaxy are in GlobalData, contained in the GameContext @@ -439,8 +476,6 @@ public static class ZoneGenerator count: (int)(random.NextFloat(1, 3) * random.NextFloat(1, 2)), massMulMin: .6f, massMulMax: .8f, - distMulMin: 1.25f, - distMulMax: 1.75f, jupiterJump: 1, massFraction: .1f); @@ -454,8 +489,6 @@ public static class ZoneGenerator count: (int) (random.NextFloat(1, 3 * m) + random.NextFloat(1, 3 * m)), massMulMin: 0.75f, massMulMax: 2.5f, - distMulMin: 1 + m * .25f, - distMulMax: 1.05f + m * .5f, jupiterJump: random.NextFloat() * random.NextFloat() * 10 + 1, massFraction: .5f ); @@ -469,8 +502,6 @@ public static class ZoneGenerator count: random.NextInt(5, 15), massMulMin: 0.75f, massMulMax: 2.5f, - distMulMin: 1.1f, - distMulMax: 1.25f, jupiterJump: random.NextFloat() * random.NextFloat() * 10 + 1, massFraction: .25f ); @@ -511,8 +542,6 @@ public static class ZoneGenerator count: planet.Mass < settings.GasGiantMass ? random.NextInt(1, 3) : random.NextInt(2, 6), massMulMin: .75f, massMulMax: 1.5f, - distMulMin: 1.05f, - distMulMax: 1.25f, jupiterJump: 1, massFraction: .15f); // Planetary satellites are not nearly as massive as planets themselves } @@ -535,8 +564,126 @@ public static class ZoneGenerator foreach (var planet in root.AllPlanets()) planet.Mass = (planet.Mass / totalMass) * rootMass; + Place(root, planetSettings, radius, rootReach); + return root.AllPlanets().ToArray(); } + + // Placement (tail-taming-law-r3-band-loose-cusp, c4-proceed-and-fly): a child sits at least RocheMargin of its parent's Roche + // limits out; a massive child of a body also far enough that its own pull peak beats CuspMargin times its parent's pull there; + // siblings sit SiblingSpacing of a reach apart. Every other number is a GravityField constant. + public const float RocheMargin = 1.5f; + public const float CuspMargin = 1.5f; + public const float SiblingSpacing = .5f; + + /// + /// The one placement pass: gives every generated node its distance and every belt its half width, top-down. A body's children + /// are laid inside-out at the nearest the rules allow, the lightest family is folded into the parent (its mass kept) while the + /// outermost overflows the window, and the rest are stretched geometrically so the outermost sits at the window's edge. A + /// non-root's window ends before its own fade (Gamma x Reach x its distance); a root's, in a zone of several, ends under + /// rootReach / Beta so its support never meets another root's (root-window-floor), and is 0.75 x the area radius at most. + /// A rosette's ring is one orbit; it folds into its node, which becomes a body, if it cannot keep the heavy member outside + /// its Roche margin. Ported from docs/research/gravity-well-law/replace.py. + /// + public static void Place(GeneratorPlanet root, PlanetSettings planetSettings, float radius, float rootReach) + { + var q = 1 / (1 - SiblingSpacing * GravityField.Reach); + float Roche(float mass) => WellTree.Curves(planetSettings, mass).Softening; + float Strength(float mass) => WellTree.Curves(planetSettings, mass).Strength; + float WellMass(GeneratorPlanet p) => p.Empty || p.Belt ? 0f : p.Mass; + float Family(GeneratorPlanet p) => (p.Empty ? 0f : p.Mass) + p.Children.Sum(c => Family(c)); + void Fold(GeneratorPlanet node, GeneratorPlanet into) + { + into.Mass += Family(node); + node.Parent.Children.Remove(node); + } + + void PlaceChildren(List children, Func lower, float upper, GeneratorPlanet into) + { + upper *= 1 - 1e-6f; + float AtLeast(GeneratorPlanet child, float edge) + { + var m = WellMass(child); + var mInto = WellMass(into); + return m <= 0 || mInto <= 0 ? edge : Math.Max(edge, 2 * CuspMargin * Roche(m) * Strength(mInto) / Strength(m)); + } + + var lo = 0f; + var positions = new List(); + while (children.Count > 0) + { + lo = lower(); + positions.Clear(); + for (var k = 0; k < children.Count; k++) + positions.Add(AtLeast(children[k], k == 0 ? lo : Math.Max(lo, positions[k - 1] * q))); + if (positions[positions.Count - 1] <= upper) break; + var lightest = children.OrderBy(c => Family(c)).First(); + Fold(lightest, into); + children.Remove(lightest); + } + if (children.Count == 0) return; + + var g = children.Count > 1 ? pow(upper / positions[positions.Count - 1], 1f / (children.Count - 1)) : 1f; + for (var k = 0; k < children.Count; k++) + children[k].Distance = positions[k] * pow(g, k); + for (var k = 0; k < children.Count; k++) + { + if (!children[k].Belt) continue; + var d = children[k].Distance; + var half = Math.Min(children[k].Settings.AsteroidBeltWidth.Evaluate(d) / 2, d - Math.Max(lo, k > 0 ? children[k - 1].Distance * q : 0f)); + half = Math.Min(half, upper - d); + if (k + 1 < children.Count) half = Math.Min(half, children[k + 1].Distance / q - d); + children[k].BeltHalfWidth = Math.Max(half, 0f); + } + } + + var chordCap = new Dictionary(); // a ring member's children stay within .2 of the nearest partner's chord + void Settle(GeneratorPlanet node) + { + var isRoot = node.Parent == null; + var reach = isRoot ? rootReach : GravityField.Reach * node.Distance; + var share = isRoot ? 1 / GravityField.Beta : GravityField.Gamma; + var cap = isRoot ? .75f * radius : float.PositiveInfinity; + var upper = Math.Min(cap, share * reach / Math.Max(GravityField.Beta, 1 + GravityField.Reach)); + if (chordCap.TryGetValue(node, out var chord)) upper = Math.Min(upper, chord); + if (WellMass(node) > 0) + { + PlaceChildren(node.Children.ToList(), () => RocheMargin * Roche(WellMass(node)), upper, node); + return; + } + + var ring = node.Children.Where(c => c.Ring && WellMass(c) > 0).ToList(); + if (ring.Count == 0) return; + var heavy = ring.OrderByDescending(c => c.Mass).First(); + var soleRoot = isRoot && float.IsInfinity(reach); + var ringDistance = soleRoot ? .5f * cap + : Math.Min(.5f * cap, share * reach / (GravityField.Beta * GravityField.Beta * (1 + GravityField.RingReach))); + if (!soleRoot && ringDistance < RocheMargin * Roche(heavy.Mass)) + { + // The pair cannot keep its heavy member outside its Roche margin inside its node's reach: the node is one body. + node.Empty = false; + node.Mass = 0; + foreach (var child in node.Children.ToList()) Fold(child, node); + return; + } + foreach (var member in ring) + { + member.Distance = ringDistance; + chordCap[member] = .2f * 2 * ringDistance * sin(PI / ring.Count); + } + PlaceChildren(node.Children.Where(c => !ring.Contains(c)).ToList(), + () => (1 + GravityField.RingReach) * ringDistance * GravityField.Beta, upper, heavy); + } + + var queue = new Queue(); + queue.Enqueue(root); + while (queue.Count > 0) + { + var node = queue.Dequeue(); + Settle(node); + foreach (var child in node.Children) queue.Enqueue(child); + } + } } // All orbiting bodies are referred to here as "Planets" @@ -548,10 +695,13 @@ public class GeneratorPlanet public float Distance; public float Phase; public float Mass; - public float ChildDistanceMinimum; - public float ChildDistanceMaximum; + // A belt's half width, written by ZoneGenerator.Place: its inner edge sits outside the parent's Roche margin and its outer + // edge inside the parent's window. + public float BeltHalfWidth; public bool Empty = false; public bool Belt = false; + // A member of a rosette's ring, which Place puts on one orbit round its bodyless parent. + public bool Ring = false; public List Children = new List(); // Planets orbiting this one are referred to as children public GeneratorPlanet Parent; public float2 FixedPosition; @@ -562,12 +712,10 @@ public class GeneratorPlanet return new[]{this}.Concat(Children.SelectMany(c=>c.AllPlanets())); } - // Create children that fill a single orbit, equally spaced + // Create children that fill a single orbit, equally spaced; Place sets the orbit's radius // https://en.wikipedia.org/wiki/Klemperer_rosette public void ExpandRosette(ref Random random, int vertices) { - //Debug.Log("Expanding Rosette"); - // Rosette children replace the parent, parent orbital node is left empty Empty = true; @@ -577,60 +725,37 @@ public class GeneratorPlanet // Proportion of the masses of sequential pairs is random, but only for even vertex counts var proportion = vertices % 2 == 0 ? random.NextFloat(.5f,.95f) : .5f; - // Place children at a fixed distance in the center of the range - var dist = (ChildDistanceMinimum + ChildDistanceMaximum) / 2; - - // Position of first child - var p0 = new float2(0, dist); - - // Position of second child - var p1 = OrbitData.Evaluate(1.0f / vertices) * dist; - - // Maximum child distance is half the distance to the neighbor minus the neighbor's radius - var p0ChildDist = (distance(p0, p1) * proportion - Settings.PlanetSafetyRadius.Evaluate(sharedMass * (1 - proportion))) * .75f; - var p1ChildDist = (distance(p0, p1) * (1 - proportion) - Settings.PlanetSafetyRadius.Evaluate(sharedMass * proportion)) * .75f; - for (int i = 0; i < vertices; i++) { - var child = new GeneratorPlanet + Children.Add(new GeneratorPlanet { Settings = Settings, Parent = this, Mass = sharedMass * (i % 2 == 0 ? proportion : 1 - proportion), // Masses alternate - Distance = dist, Phase = (float) i / vertices, - ChildDistanceMaximum = (i % 2 == 0 ? p0ChildDist : p1ChildDist) - }; - child.ChildDistanceMinimum = Settings.PlanetSafetyRadius.Evaluate(child.Mass) * 2; - Children.Add(child); + Ring = true + }); } - - ChildDistanceMinimum = dist + p0ChildDist; } - // Create children that mimic the distribution of planetary masses in the solar system - public void ExpandSolar(ref Random random, int count, float massMulMin, float massMulMax, float distMulMin, float distMulMax, float jupiterJump, float massFraction) + // Create children that mimic the distribution of planetary masses in the solar system, inside-out in list order; + // Place gives them their distances + public void ExpandSolar(ref Random random, int count, float massMulMin, float massMulMax, float jupiterJump, float massFraction) { - //Debug.Log("Expanding Solar"); - // Expansion is impossible when child space is full - if (count == 0 || ChildDistanceMaximum < ChildDistanceMinimum) + if (count == 0) return; var masses = new float[count]; - var distances = new float[count]; // Accumulate total mass - // Initialize first mass and distance, actual number doesn't matter since total mass will be divided proportionally - float massTotal = distances[0] = masses[0] = 1; + // Initialize first mass, actual number doesn't matter since total mass will be divided proportionally + float massTotal = masses[0] = 1; - // Masses and distances multiply from one planet to the next + // Masses multiply from one planet to the next // Mass typically increases exponentially as you go further out // jupiterJump is an additional mass multiplier applied after half of the planets for (var i = 1; i < count; i++) - { massTotal += masses[i] = masses[i - 1] * random.NextFloat(massMulMin, massMulMax) * (count/2==i ? jupiterJump : 1); - distances[i] = distances[i - 1] * random.NextFloat(distMulMin, distMulMax); - } // Add some randomness to child masses for (var i = 0; i < masses.Length; i++) @@ -640,37 +765,17 @@ public class GeneratorPlanet for (var i = 0; i < count; i++) masses[i] = masses[i] / massTotal * Mass * massFraction; - // Map child distances to range between minimum and maximum - if (count > 1) - { - var oldDistances = (float[]) distances.Clone(); - for (var i = 0; i < count; i++) - distances[i] = lerp(ChildDistanceMinimum, ChildDistanceMaximum, - (oldDistances[i] - oldDistances[0]) / (oldDistances[count - 1] - oldDistances[0])) + Settings.PlanetSafetyRadius.Evaluate(masses[i]); - } - for (var i = 0; i < count; i++) { // Only instantiate children above the mass floor if (masses[i] > Settings.MassFloor) - { - var child = new GeneratorPlanet + Children.Add(new GeneratorPlanet { Settings = Settings, Parent = this, Mass = masses[i], - Distance = distances[i], Phase = random.NextFloat() - }; - child.ChildDistanceMinimum = Settings.PlanetSafetyRadius.Evaluate(child.Mass) * 2; - // Maximum child distance of child is the smallest distance to either of its neighbors - child.ChildDistanceMaximum = min(i == 0 ? child.Distance - ChildDistanceMinimum : child.Distance - distances[i - 1], - i < count - 1 ? distances[i + 1] - child.Distance : float.PositiveInfinity); - if (float.IsNaN(child.Distance)) - throw new NotFiniteNumberException($"Planet created with NaN distance, something went very wrong!"); - if (child.ChildDistanceMaximum > child.ChildDistanceMinimum) - Children.Add(child); - } + }); } } } diff --git a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs index fd522e357..e77d6c89b 100644 --- a/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs +++ b/tests/Aetheria.Shared.Tests/MiningCut3Tests.cs @@ -820,7 +820,7 @@ public sealed partial class MiningCut3Tests : IDisposable var items = new ItemManager(cache, new ProvenanceLedger(), GameSettings(), _ => { }); foreach (var zone in galaxy.Zones.Take(4)) { - var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance); + var pack = ZoneGenerator.GenerateZone(items, RestoredHullsTests.TutorialZoneSettings(), RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, isTutorial: zone == galaxy.Entrance); foreach (var body in pack.Planets) { if (!(cache.Get(body) is AsteroidBeltData belt)) continue; diff --git a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs index 217be696a..5c8bae4ce 100644 --- a/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs +++ b/tests/Aetheria.Shared.Tests/RestoredHullsTests.cs @@ -57,9 +57,10 @@ public sealed class RestoredHullsTests // Shared tutorial-galaxy construction settings (Assets/Resources/Settings.asset's own values), factored out // so every test that needs a real tutorial Galaxy -- not just EntranceZoneAlwaysGetsADockedStationAcrossSeeds // -- builds it the same way instead of re-inlining the same ~20 fields. + internal static PlanetSettings AuthoredPlanetSettings() => AuthoredSettings.Load(RunStartTests.FindRepoRoot()).Read("PlanetSettings"); + internal static ZoneGenerationSettings TutorialZoneSettings() => new ZoneGenerationSettings { - PlanetSafetyRadius = new ExponentialCurve { Exponent = .25f, Multiplier = 2.5f, Constant = 0 }, MassFloor = 1, SunMass = 5000, GasGiantMass = 1000, PlanetMass = 100, SatellitePasses = 5, SatelliteCreationMassFloor = 100, SatelliteCreationProbability = .25f, BinaryCreationProbability = .1f, RosetteProbability = .1f, @@ -156,7 +157,7 @@ public sealed class RestoredHullsTests cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); foreach (var zone in galaxy.Zones.Where(z => !ReferenceEquals(z, galaxy.Entrance))) { - var orbits = ParentedPlanetOrbits(cache, ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial: false, ambient: false)); + var orbits = ParentedPlanetOrbits(cache, ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial: false, ambient: false)); if (orbits.Length > 0 && !HasGenuineLagrangeCandidate(orbits)) return (seed, zone.Name); } } @@ -377,7 +378,7 @@ public sealed class RestoredHullsTests var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var dockedStation = pack.Entities.OfType() .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); @@ -418,7 +419,7 @@ public sealed class RestoredHullsTests var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var planetOrbits = pack.Planets.Select(p => cache.Get(cache.Get(p).Orbit)).Where(o => o.Parent.IsSet()).ToArray(); var hasGenuineCandidate = planetOrbits.Any(o => planetOrbits.Count(c => c.Parent.Key.Equals(o.Parent.Key) && abs(c.Distance - o.Distance) < .1f) == 1); @@ -462,7 +463,7 @@ public sealed class RestoredHullsTests zoneSettings.MassFloor = 1e9f; Assert.Throws(() => - ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true)); + ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true)); } finally { @@ -497,7 +498,7 @@ public sealed class RestoredHullsTests zoneSettings.SatelliteCreationProbability = 0; zoneSettings.BinaryCreationProbability = 0; - var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var roots = pack.Planets.Select(p => cache.Get(p).Orbit) .Select(cache.Get) @@ -545,7 +546,7 @@ public sealed class RestoredHullsTests var galaxy = new Galaxy(tutorialSettings, background, names, cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, galaxy.Entrance, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var dockedStation = pack.Entities.OfType() .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); @@ -740,7 +741,7 @@ public sealed class RestoredHullsTests var galaxy = BuildGalaxyWithForcedEntrance(cache, seed, zoneName); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var station = pack.Entities.OfType() .FirstOrDefault(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station && e.DockingBays.Length > 0); @@ -790,7 +791,7 @@ public sealed class RestoredHullsTests var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); int StationCount(GalaxyZone zone, bool isTutorial) => - ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial) + ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial) .Entities.OfType() .Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); @@ -830,7 +831,7 @@ public sealed class RestoredHullsTests cache, new PlayerSettings(), new DirectoryInfo(Path.GetTempPath()), _ => { }, null, seed: 1u); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, galaxy.Entrance, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, galaxy.Entrance, isTutorial: true); var stationCount = pack.Entities.OfType().Count(e => (cache.Get(e.Hull.Data) as HullData)?.HullType == HullType.Station); // Soul's finding (this cut's fix batch 3): pack.Entities.OfType().Count() also counts the // zone's neutral wanderers (ZoneGenerator.cs's own EligibleWandererFactions spawn, keyed to @@ -914,7 +915,7 @@ public sealed class RestoredHullsTests Assert.NotSame(galaxy.Entrance, zone); var items = new ItemManager(cache, new ProvenanceLedger(), Settings(), _ => { }); - var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), galaxy, zone, isTutorial: true); + var pack = ZoneGenerator.GenerateZone(items, TutorialZoneSettings(), AuthoredPlanetSettings(), galaxy, zone, isTutorial: true); // Confirms this fixture still has no genuine (non-rosette) Lagrange candidate -- a regression here // would mean the fixture no longer probes what this test claims to probe. diff --git a/tests/Aetheria.Shared.Tests/RunGoalTests.cs b/tests/Aetheria.Shared.Tests/RunGoalTests.cs index ed96732c9..25b46fdd3 100644 --- a/tests/Aetheria.Shared.Tests/RunGoalTests.cs +++ b/tests/Aetheria.Shared.Tests/RunGoalTests.cs @@ -19,7 +19,7 @@ public sealed partial class RunStartTests private ZonePack BossZonePack(Galaxy galaxy, ItemManager items, GalaxyZone zone, bool ambient = true) { items.Random = new CultMath.Random(7); - return ZoneGenerator.GenerateZone(items, _zoneSettings, galaxy, zone, galaxy.IsPrelude, ambient); + return ZoneGenerator.GenerateZone(items, _zoneSettings, _planetSettings, galaxy, zone, galaxy.IsPrelude, ambient); } private Zone ZoneOf(Galaxy galaxy, GalaxyZone zone) => diff --git a/tests/Aetheria.Shared.Tests/RunStartTests.cs b/tests/Aetheria.Shared.Tests/RunStartTests.cs index 5ad89c639..ce853d07c 100644 --- a/tests/Aetheria.Shared.Tests/RunStartTests.cs +++ b/tests/Aetheria.Shared.Tests/RunStartTests.cs @@ -78,7 +78,7 @@ public sealed partial class RunStartTests : IDisposable // The fixture galaxy's arena, with or without its generated ships and turrets. private Zone Arena(bool ambient) => - new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(ambient, _ => { })), _galaxy.Entrance, _galaxy); + new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _planetSettings, _galaxy, new Scripted(ambient, _ => { })), _galaxy.Entrance, _galaxy); private HullData Hull(string name) => _cache.GetAll().Single(hull => hull.Name == name); @@ -673,7 +673,7 @@ public sealed partial class RunStartTests : IDisposable [Fact] public void AdmitIsTheOnlyAgentCreator() { - var pack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { })); + var pack = RunStart.GenerateArena(_items, _zoneSettings, _planetSettings, _galaxy, new Scripted(true, _ => { })); var packedPlayer = pack.Entities.OfType().First(); packedPlayer.IsPlayerShip = true; var zone = new Zone(_items, _planetSettings, pack, _galaxy.Entrance, _galaxy); diff --git a/tests/Aetheria.Shared.Tests/ScenarioTests.cs b/tests/Aetheria.Shared.Tests/ScenarioTests.cs index 11ce2ddf2..da1d08e9a 100644 --- a/tests/Aetheria.Shared.Tests/ScenarioTests.cs +++ b/tests/Aetheria.Shared.Tests/ScenarioTests.cs @@ -64,7 +64,7 @@ public sealed partial class RunStartTests private (Galaxy galaxy, Zone arena, RunStart.Staged staged, List failures) Launch(Scenario scenario, GalaxyStage inputs = null, Action beforeStaging = null) { var galaxy = RunStart.Generate(scenario, inputs ?? Inputs()); - var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, galaxy, scenario), galaxy.Entrance, galaxy); + var arena = new Zone(_items, _planetSettings, RunStart.GenerateArena(_items, _zoneSettings, _planetSettings, galaxy, scenario), galaxy.Entrance, galaxy); beforeStaging?.Invoke(); var failures = new List(); var staged = RunStart.Stage(_items, arena, scenario, _startingHull, _tutorialSettings, failures); @@ -392,8 +392,8 @@ public sealed partial class RunStartTests { Zone = _galaxy.Entrance, Name = "story", Type = LocationType.Station, Faction = _protagonist, Turrets = 2 }); - var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(true, _ => { }))); - var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _galaxy, new Scripted(false, _ => { })); + var ambient = Census(RunStart.GenerateArena(_items, _zoneSettings, _planetSettings, _galaxy, new Scripted(true, _ => { }))); + var quietPack = RunStart.GenerateArena(_items, _zoneSettings, _planetSettings, _galaxy, new Scripted(false, _ => { })); var quiet = Census(quietPack); Assert.Same(quietPack, _galaxy.Entrance.PackedContents); diff --git a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs index 52bd650f7..4603b5b1b 100644 --- a/tests/Aetheria.Shared.Tests/StationPlacementTests.cs +++ b/tests/Aetheria.Shared.Tests/StationPlacementTests.cs @@ -53,7 +53,7 @@ public sealed class StationPlacementTests : IDisposable { if (storyTurrets > 0) galaxyZone.Locations.Add(new LocationStory { Zone = galaxyZone, Name = "Test station", Faction = galaxy.Factions[0], Type = LocationType.Station, Turrets = storyTurrets }); - var pack = ZoneGenerator.GenerateZone(items, authored.Read("ZoneSettings"), galaxy, galaxyZone, + var pack = ZoneGenerator.GenerateZone(items, authored.Read("ZoneSettings"), authored.Read("PlanetSettings"), galaxy, galaxyZone, galaxyZone == galaxy.Entrance && galaxy.IsPrelude, true); var zone = new Zone(items, authored.Read("PlanetSettings"), pack, galaxyZone, galaxy); zone.Update(.02f); diff --git a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs index e6f5314ef..85cbe7ec8 100644 --- a/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs +++ b/tests/Aetheria.Shared.Tests/SunsNeverGreenTests.cs @@ -191,7 +191,7 @@ public sealed class SunsNeverGreenTests : IDisposable foreach (var zone in galaxy.Zones) { zones++; - var pack = ZoneGenerator.GenerateZone(items, zoneSettings, galaxy, zone, false, false); + var pack = ZoneGenerator.GenerateZone(items, zoneSettings, RestoredHullsTests.AuthoredPlanetSettings(), galaxy, zone, false, false); suns.AddRange(pack.Planets.Select(p => cache.Get(p)).OfType()); } } diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index a99c7526c..fd1fe941c 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -561,7 +561,6 @@ public static class Program var missing = new List(); if (zones.ZoneRadius == null || zones.ZoneRadius.Maximum <= 0) missing.Add("ZoneSettings.ZoneRadius"); if (zones.SubZoneCount == null || zones.SubZoneCount.Maximum <= 0) missing.Add("ZoneSettings.SubZoneCount"); - if (zones.PlanetSafetyRadius == null || zones.PlanetSafetyRadius.Multiplier <= 0) missing.Add("ZoneSettings.PlanetSafetyRadius"); if (gameplay.Tiers == null || gameplay.Tiers.Length != 5) missing.Add($"GameplaySettings.Tiers (expected 5, got {gameplay.Tiers?.Length ?? 0})"); else if (gameplay.Tiers.Any(t => string.IsNullOrEmpty(t.Name) || t.Quality <= 0)) missing.Add("GameplaySettings.Tiers (a tier parsed without a name or quality)"); if (gameplay.DefaultEntitySettings == null) missing.Add("GameplaySettings.DefaultEntitySettings");